Compare commits
47 Commits
25f568f225
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4025789ba0 | ||
|
|
5b6e53c863 | ||
|
|
b70fff01e1 | ||
|
|
07f9bcf79b | ||
|
|
1420a5c445 | ||
|
|
2bfb1015ea | ||
|
|
ace82de88c | ||
|
|
1b1e9fdb9e | ||
|
|
95e0e2d9bd | ||
|
|
cdc9f864b2 | ||
|
|
a00c891277 | ||
|
|
f45883233c | ||
|
|
d5e79cdc10 | ||
|
|
1a8a96d94e | ||
|
|
53fd6114e7 | ||
|
|
1314f4581d | ||
|
|
b2f483d67c | ||
|
|
f1cea2fb35 | ||
|
|
8ef57a4bb1 | ||
|
|
d4fb1eebbf | ||
|
|
2e4d957a47 | ||
|
|
e5928b965f | ||
|
|
0600b87a29 | ||
|
|
3d1b6e7ec5 | ||
|
|
d7bee9e854 | ||
|
|
6343386488 | ||
|
|
afe0fd1206 | ||
|
|
ac1db177e1 | ||
|
|
7c31269691 | ||
|
|
2142a66bc0 | ||
|
|
821e768b7e | ||
|
|
2645db40a2 | ||
|
|
60eb2adef3 | ||
|
|
e3bec557b6 | ||
|
|
6a1640ff6d | ||
|
|
10f5d44965 | ||
|
|
a4d615d74e | ||
|
|
f5ac8d07d7 | ||
|
|
50539741ce | ||
|
|
7a891c5aaa | ||
|
|
3bef640979 | ||
|
|
1f20eb3d2a | ||
|
|
df53ab956f | ||
|
|
5ff271a7b1 | ||
|
|
8831176ec4 | ||
|
|
d77cc252bb | ||
|
|
091f98e1f9 |
18
CLAUDE.md
18
CLAUDE.md
@@ -83,6 +83,24 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
- Local URL: http://localhost:8069
|
||||
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
||||
|
||||
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab
|
||||
When a Python action opens an attachment, route it through `fusion_pdf_preview` instead of returning `ir.actions.act_url` with `download=true` or `target=new`. The preview dialog gives operators preview + print + download in one place and writes an audit log; non-PDF attachments fall back to the legacy download path automatically.
|
||||
|
||||
The drop-in replacement is the new helper on `ir.attachment`:
|
||||
```python
|
||||
return att.action_fusion_preview(title='My Doc')
|
||||
# vs. the old pattern:
|
||||
# return {'type': 'ir.actions.act_url',
|
||||
# 'url': '/web/content/%s?download=true' % att.id,
|
||||
# 'target': 'new'}
|
||||
```
|
||||
|
||||
The helper auto-detects mimetype: PDFs go to the dialog, everything else (ZPL, CSV, XML, images) stays on download. So a callsite that today serves CSV today and a PDF tomorrow doesn't need a code change — same call, different routing.
|
||||
|
||||
If you need to invoke the client action directly (rare — only when you don't have a recordset handy), the tag is `fusion_pdf_preview.open_attachment` and the params are `{attachment_id, title, model_name, record_ids, report_name}`. See `fusion_pdf_preview/static/src/js/open_attachment_action.js`.
|
||||
|
||||
Existing reports (`ir.actions.report` of type `qweb-pdf`) are intercepted automatically by `fusion_pdf_preview/static/src/js/pdf_preview.js`; the helper above is for the *other* pattern — attachments opened by custom buttons.
|
||||
|
||||
## Supabase Knowledge Base
|
||||
Before starting unfamiliar work, check Supabase for context:
|
||||
```bash
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
0
fusion-plating/%{http_code}
Normal file
0
fusion-plating/%{http_code}
Normal file
@@ -252,10 +252,23 @@ class FusionCpShipment(models.Model):
|
||||
}
|
||||
|
||||
def _action_open_attachment(self, attachment):
|
||||
"""Open an attachment PDF in the browser viewer (new tab)."""
|
||||
"""Open an attachment for the operator.
|
||||
|
||||
Delegates to ir.attachment.action_fusion_preview when
|
||||
fusion_pdf_preview is installed — PDFs render in the preview
|
||||
dialog, anything else downloads. Falls back to the legacy
|
||||
new-tab URL when the helper isn't available. See CLAUDE.md
|
||||
"PDF Preview" for the contract.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not attachment:
|
||||
return False
|
||||
if hasattr(attachment, 'action_fusion_preview'):
|
||||
return attachment.action_fusion_preview(
|
||||
title=attachment.name or 'Shipping Label',
|
||||
model_name=self._name,
|
||||
record_ids=self.id,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=false' % attachment.id,
|
||||
|
||||
1
fusion_claims/.gitignore
vendored
Normal file
1
fusion_claims/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.superpowers/
|
||||
3106
fusion_claims/CLAUDE.md
Normal file
3106
fusion_claims/CLAUDE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Claims',
|
||||
'version': '19.0.8.0.6',
|
||||
'version': '19.0.9.1.0',
|
||||
'category': 'Sales',
|
||||
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
|
||||
'description': """
|
||||
@@ -175,6 +175,18 @@
|
||||
'fusion_claims/static/src/js/attachment_image_compress.js',
|
||||
'fusion_claims/static/src/js/debug_required_fields.js',
|
||||
'fusion_claims/static/src/xml/document_preview.xml',
|
||||
# Dashboard: tokens MUST load before dashboard layout
|
||||
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
|
||||
'fusion_claims/static/src/scss/fc_dashboard.scss',
|
||||
# Dashboard OWL countdown widget
|
||||
'fusion_claims/static/src/js/fc_posting_countdown.js',
|
||||
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
# Dark bundle recompiles the same SCSS with the dark
|
||||
# $o-webclient-color-scheme default so tokens branch correctly.
|
||||
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
|
||||
'fusion_claims/static/src/scss/fc_dashboard.scss',
|
||||
],
|
||||
},
|
||||
'images': ['static/description/icon.png'],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,432 @@
|
||||
# Fusion Claims Dashboard — Design Spec
|
||||
|
||||
**Date:** 2026-05-21
|
||||
**Module:** `fusion_claims`
|
||||
**Status:** Design approved, ready for implementation plan
|
||||
**Replaces:** the existing 4-panel HTML-field dashboard at `models/dashboard.py` + `views/dashboard_views.xml`
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Surface workflow flags, posting-week context, and per-funder hotlinks on a single dashboard so claims processors, sales reps, and managers can see at a glance what needs action today and how much money is in motion for the current ADP posting cycle.
|
||||
|
||||
The existing dashboard is a case-count overview. The new dashboard is action-oriented: "what's stuck, what's due this week, what should I be doing."
|
||||
|
||||
## 2. Audience and role behaviour
|
||||
|
||||
Single dashboard used by three personas, with auto-applied role filter:
|
||||
|
||||
- **Managers** (in `fusion_claims.group_fusion_claims_manager` or `sales_team.group_sale_manager`) — see all cases.
|
||||
- **Office staff** — same as managers (they are typically in the manager group already, per the module's security model).
|
||||
- **Sales reps** (only in `group_fusion_claims_user`) — see only SOs where `user_id = self.env.uid`.
|
||||
|
||||
A small "Showing your cases" hint appears above the workflow tiles when the role filter is active (driven by computed `is_manager`).
|
||||
|
||||
## 3. Scope
|
||||
|
||||
**In scope:**
|
||||
- Posting-period banner with live countdown to submission cutoff
|
||||
- 3 KPI tiles: Ready to Claim, Claimed This Period, Total AR (ADP-portion)
|
||||
- 8 quick-action hotlinks: + ADP, + MOD, + ODSP, + WSIB, + Insurance, + MDC, + Hardship, + Private
|
||||
- "Your Activities" list (top 10 of current user's `mail.activity`)
|
||||
- Two bottleneck callouts: Approved without POD, Submitted with no ADP response > 14 days
|
||||
- ADP Pre-Approval workflow tiles (4): Waiting App, App Received, Ready Submission, Needs Correction
|
||||
- ADP Post-Approval workflow tiles (4): Approved, Ready Delivery, Ready Billing, On Hold
|
||||
- MOD workflow tiles (5): Awaiting Funding, Funding Approved, PCA Received, Project Complete, POD Submitted
|
||||
- Other-funder count cards (6): ODSP, WSIB, Insurance, MDC, Hardship, ACSD
|
||||
- Light + dark theme support via compile-time SCSS branching
|
||||
|
||||
**Out of scope:**
|
||||
- Charts / time-series graphs
|
||||
- The existing 4 configurable HTML panels (removed)
|
||||
- A "Recent Cases" power-user view (deferred — separate spec if needed)
|
||||
- Auto-refresh on window focus (manual reload only)
|
||||
- Per-user personalisation beyond the role filter (no saved layouts/filters)
|
||||
- Push notifications, email digests (out of scope, handled elsewhere)
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
### 4.1 Implementation pattern
|
||||
|
||||
**Hybrid: form-view shell + computed fields + small OWL widget for the live countdown.**
|
||||
|
||||
Server-rendered Bootstrap-grid form view sits on top of a TransientModel with ~36 computed fields. One OWL field-widget handles the live deadline countdown (ticks every 60 seconds, swaps colour as deadline approaches).
|
||||
|
||||
The TransientModel name `fusion.claims.dashboard` is **preserved** — existing menu/action records continue to resolve. The model's internals are rewritten; old fields are dropped.
|
||||
|
||||
### 4.2 Files
|
||||
|
||||
| File | Action | Purpose |
|
||||
|---|---|---|
|
||||
| `models/dashboard.py` | **Rewrite** | TransientModel with ~36 computed fields + role-filter helper + ~24 action methods |
|
||||
| `views/dashboard_views.xml` | **Rewrite** | Form view: banner → KPIs → quick-actions → 2-column grid |
|
||||
| `static/src/scss/_fc_dashboard_tokens.scss` | **New** | Colour palette tokens, compile-time `@if $o-webclient-color-scheme == dark` branch |
|
||||
| `static/src/scss/fc_dashboard.scss` | **New** | Layout + section styles, references tokens |
|
||||
| `static/src/js/fc_posting_countdown.js` | **New** | OWL field widget for live countdown (~60 lines) |
|
||||
| `static/src/xml/fc_posting_countdown.xml` | **New** | OWL template (~10 lines) |
|
||||
| `__manifest__.py` | **Edit** | Bump version (asset cache-bust), add SCSS to **both** `web.assets_backend` AND `web.assets_web_dark`, add JS+XML to backend |
|
||||
|
||||
### 4.3 Layout
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ BANNER: Posting Period: Mar 5 – 19 · [OWL: 3d to cutoff] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ KPI TILES (3-up): Ready | Claimed | Total AR │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ QUICK ACTIONS: + ADP + MOD + ODSP + WSIB + Ins + ... │
|
||||
├────────────────────────┬─────────────────────────────────────┤
|
||||
│ LEFT COLUMN │ RIGHT COLUMN │
|
||||
│ Your Activities │ ADP Pre-Approval (4 tiles) │
|
||||
│ Bottlenecks │ ADP Post-Approval (4 tiles) │
|
||||
│ Other Funders (6) │ MOD (5 tiles) │
|
||||
└────────────────────────┴─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.4 Data flow
|
||||
|
||||
1. User clicks Dashboard menu.
|
||||
2. Existing `action_fusion_claims_dashboard` creates a fresh TransientModel record.
|
||||
3. Compute methods run (5 clusters — see §6).
|
||||
4. Form renders.
|
||||
5. OWL countdown widget tickets every 60 s, reading `submission_deadline_dt` from the rendered field, formatting it client-side.
|
||||
6. User clicks a tile → returns `ir.actions.act_window` opening a filtered `sale.order` list.
|
||||
7. User clicks a quick-action pill → returns `ir.actions.act_window` opening a fresh `sale.order` form with `default_x_fc_sale_type` in context.
|
||||
8. User clicks Refresh (form header button) → reloads the action.
|
||||
|
||||
## 5. Role filter
|
||||
|
||||
Central helper on `fusion.claims.dashboard`:
|
||||
|
||||
```python
|
||||
def _role_filter_domain(self):
|
||||
user = self.env.user
|
||||
if (user.has_group('fusion_claims.group_fusion_claims_manager')
|
||||
or user.has_group('sales_team.group_sale_manager')):
|
||||
return []
|
||||
return [('user_id', '=', user.id)]
|
||||
```
|
||||
|
||||
Every count/sum compute method prepends `_role_filter_domain()` to its domain. For `account.move` based counts (KPIs), the filter is applied through `x_fc_source_sale_order_id.user_id` (the linked SO's salesperson) because invoices don't have their own `user_id` to filter on in this module.
|
||||
|
||||
`is_manager` (Boolean computed) exposed for the view to optionally show a "Showing your cases" hint.
|
||||
|
||||
## 6. Field inventory (≈36 fields)
|
||||
|
||||
### 6.1 Header / banner
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `posting_period_label` | Char | e.g. `"Mar 5 – Mar 19"` |
|
||||
| `posting_period_start` | Date | Start of current posting cycle |
|
||||
| `posting_period_end` | Date | Start of next cycle (exclusive) |
|
||||
| `submission_deadline_dt` | Datetime | Wed 18:00 of posting week, Toronto TZ |
|
||||
| `is_manager` | Boolean | Drives role-hint visibility |
|
||||
| `is_pre_first_posting` | Boolean | True if today < `adp_posting_base_date` |
|
||||
|
||||
Derived from helpers already on `adp.posting.schedule.mixin`. Dashboard `_inherit = ['adp.posting.schedule.mixin']`.
|
||||
|
||||
### 6.2 KPI tiles
|
||||
|
||||
| Field | Type | Source |
|
||||
|---|---|---|
|
||||
| `kpi_ready_amount` | Monetary | Sum of `account.move.amount_total` where `x_fc_adp_billing_status='waiting'` AND `adp_exported=False`, role-filtered via linked SO |
|
||||
| `kpi_ready_count` | Integer | Same filter, count |
|
||||
| `kpi_claimed_amount` | Monetary | Sum where `x_fc_adp_billing_status in ('submitted','resubmitted')` AND `adp_export_date >= posting_period_start` |
|
||||
| `kpi_claimed_count` | Integer | Same filter, count |
|
||||
| `kpi_ar_amount` | Monetary | Sum where `move_type='out_invoice'`, `state='posted'`, `payment_state in ('not_paid','partial')`, `x_fc_invoice_type='adp'` |
|
||||
| `kpi_ar_count` | Integer | Same filter, count |
|
||||
| `currency_id` | Many2one | Defaults to `company_id.currency_id` |
|
||||
|
||||
### 6.3 Activities (left column)
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `my_activities_count` | Integer | `mail.activity` where `user_id=current_user` AND `res_model in ('sale.order','account.move','fusion.technician.task')` |
|
||||
| `my_activities_html` | Html | Top 10 ordered by `date_deadline asc`, links via `/odoo/<model>/<id>`, overdue rows tinted |
|
||||
|
||||
### 6.4 Bottlenecks (left column)
|
||||
|
||||
| Field | Type | Domain |
|
||||
|---|---|---|
|
||||
| `bottleneck_no_pod_count` | Integer | ADP cases `x_fc_adp_application_status in ('approved','approved_deduction')` AND `x_fc_proof_of_delivery=False` |
|
||||
| `bottleneck_no_response_count` | Integer | ADP cases `x_fc_adp_application_status in ('submitted','resubmitted')` AND `x_fc_claim_submission_date < today - 14 days` |
|
||||
|
||||
### 6.5 Other funders (left column)
|
||||
|
||||
Each is an Integer count of active (non-terminal) cases:
|
||||
|
||||
| Field | Domain |
|
||||
|---|---|
|
||||
| `count_odsp` | `x_fc_sale_type in ('odsp','adp_odsp')` excluding division-specific terminal states |
|
||||
| `count_wsib` | `x_fc_sale_type='wsib'` excluding `case_closed`, `cancelled`, `denied` |
|
||||
| `count_insurance` | `x_fc_sale_type='insurance'` excluding terminal states |
|
||||
| `count_mdc` | `x_fc_sale_type='muscular_dystrophy'` excluding terminal states |
|
||||
| `count_hardship` | `x_fc_sale_type='hardship'` excluding terminal states |
|
||||
| `count_acsd` | `x_fc_client_type='ACS'` excluding terminal states |
|
||||
|
||||
### 6.6 ADP Pre-Approval (right column, 4 tiles)
|
||||
|
||||
| Field | Status filter |
|
||||
|---|---|
|
||||
| `adp_waiting_app_count` | `x_fc_adp_application_status in ('waiting_for_application','assessment_completed')` |
|
||||
| `adp_app_received_count` | `x_fc_adp_application_status='application_received'` |
|
||||
| `adp_ready_submit_count` | `x_fc_adp_application_status='ready_submission'` |
|
||||
| `adp_needs_correction_count` | `x_fc_adp_application_status='needs_correction'` (rendered as urgent tile) |
|
||||
|
||||
`adp_waiting_app_count` and `adp_needs_correction_count` are styled `--urgent` (red tint).
|
||||
|
||||
### 6.7 ADP Post-Approval (right column, 4 tiles)
|
||||
|
||||
| Field | Status filter |
|
||||
|---|---|
|
||||
| `adp_approved_count` | `x_fc_adp_application_status in ('approved','approved_deduction')` |
|
||||
| `adp_ready_delivery_count` | `x_fc_adp_application_status='ready_delivery'` |
|
||||
| `adp_ready_bill_count` | `x_fc_adp_application_status='ready_bill'` |
|
||||
| `adp_on_hold_count` | `x_fc_adp_application_status='on_hold'` (rendered as urgent tile) |
|
||||
|
||||
### 6.8 MOD (right column, 5 tiles)
|
||||
|
||||
| Field | Status filter |
|
||||
|---|---|
|
||||
| `mod_awaiting_funding_count` | `x_fc_mod_status='awaiting_funding'` |
|
||||
| `mod_funding_approved_count` | `x_fc_mod_status='funding_approved'` |
|
||||
| `mod_pca_received_count` | `x_fc_mod_status='contract_received'` |
|
||||
| `mod_project_complete_count` | `x_fc_mod_status='project_complete'` |
|
||||
| `mod_pod_submitted_count` | `x_fc_mod_status='pod_submitted'` |
|
||||
|
||||
## 7. Compute method clustering
|
||||
|
||||
Five compute methods, each owning a logical section so an expensive query in one cluster doesn't recompute the rest:
|
||||
|
||||
| Method | Fields populated |
|
||||
|---|---|
|
||||
| `_compute_banner` | 6 banner fields |
|
||||
| `_compute_kpis` | 6 KPI fields + `currency_id` |
|
||||
| `_compute_activities` | 2 activity fields |
|
||||
| `_compute_workflow_counts` | 13 stage-tile fields (ADP + MOD) |
|
||||
| `_compute_secondary_counts` | 8 fields (bottlenecks + other funders) |
|
||||
|
||||
All compute methods are bound to non-stored `compute='_compute_*'` fields (no `@api.depends` since TransientModel records are throwaway — every dashboard open is a fresh record). Counts use `search_count()` not `search()` to avoid loading recordsets.
|
||||
|
||||
## 8. Action methods (~24)
|
||||
|
||||
### 8.1 `action_open_<bucket>` (~16)
|
||||
|
||||
Thin wrappers returning `ir.actions.act_window`. Where the module already has per-stage actions (e.g. `adp_claims_views.xml` defines `act_window_adp_ready_for_billing`), reuse them via `self.env.ref(...).read()[0]`. Otherwise build the action inline.
|
||||
|
||||
Examples:
|
||||
- `action_open_adp_waiting_app` — opens SO list filtered to `('x_fc_adp_application_status', 'in', ['waiting_for_application', 'assessment_completed'])`
|
||||
- `action_open_bottleneck_no_pod` — opens SO list filtered to approved-without-POD
|
||||
- `action_open_my_activities` — opens activity list filtered to current user
|
||||
|
||||
### 8.2 `action_create_<funder>_so` (8)
|
||||
|
||||
One per funder hotlink. Each opens a fresh `sale.order` form with `default_x_fc_sale_type` in context:
|
||||
|
||||
| Method | Context |
|
||||
|---|---|
|
||||
| `action_create_adp_so` | `{'default_x_fc_sale_type': 'adp'}` |
|
||||
| `action_create_mod_so` | `{'default_x_fc_sale_type': 'march_of_dimes'}` |
|
||||
| `action_create_odsp_so` | `{'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'}` |
|
||||
| `action_create_wsib_so` | `{'default_x_fc_sale_type': 'wsib'}` |
|
||||
| `action_create_insurance_so` | `{'default_x_fc_sale_type': 'insurance'}` |
|
||||
| `action_create_mdc_so` | `{'default_x_fc_sale_type': 'muscular_dystrophy'}` |
|
||||
| `action_create_hardship_so` | `{'default_x_fc_sale_type': 'hardship'}` |
|
||||
| `action_create_private_so` | `{'default_x_fc_sale_type': 'direct_private'}` |
|
||||
|
||||
User picks ODSP division on the SO form (we default to `standard`, they can change to `sa_mobility` or `ontario_works`).
|
||||
|
||||
## 9. Theming (SCSS structure)
|
||||
|
||||
### 9.1 File order
|
||||
|
||||
Tokens load **first** in each bundle. SCSS variables defined in `_fc_dashboard_tokens.scss` must be in scope when `fc_dashboard.scss` is compiled. Odoo concatenates SCSS within a bundle in registration order, so the manifest registration sequence is load-bearing — see §11.
|
||||
|
||||
### 9.2 `_fc_dashboard_tokens.scss`
|
||||
|
||||
Single source of truth. Define light values at top level, override with `!global` inside `@if $o-webclient-color-scheme == dark`. Token names use the `$_fc-*` convention (underscore prefix for "private" partials).
|
||||
|
||||
Light palette (22 tokens):
|
||||
|
||||
```
|
||||
page-bg: #f7f7f8 card-bg: #ffffff card-border: #d8dadd
|
||||
text: #2b2b2b text-muted: #6c7480
|
||||
|
||||
banner: linear-gradient(#eef2ff → #fce7f3) border: #c7d2fe text: #3730a3
|
||||
deadline-text: #b91c1c
|
||||
|
||||
kpi-bg: #f0f4ff kpi-border: #c7d2fe kpi-num: #1e3a8a
|
||||
|
||||
action-bg: #ecfdf5 action-border: #6ee7b7 action-text: #047857
|
||||
|
||||
tile-bg: #f3f4f6 tile-border: #e5e7eb tile-num: #111827
|
||||
|
||||
urgent-bg: #fee2e2 urgent-border: #fca5a5 urgent-num: #991b1b urgent-text: #7f1d1d
|
||||
|
||||
activity-bg: #fefce8 activity-border: #fde047
|
||||
bottleneck-bg: #fef2f2 bottleneck-border: #fecaca
|
||||
```
|
||||
|
||||
Dark palette overrides (cool blue monochrome banner per Round 3 selection):
|
||||
|
||||
```
|
||||
page-bg: #1a1d21 card-bg: #22262d card-border: #3a3f47
|
||||
text: #e5e7eb text-muted: #9ca3af
|
||||
|
||||
banner: linear-gradient(#1e293b → #1e3a5f) border: #3b82f6 text: #93c5fd
|
||||
deadline-text: #fca5a5
|
||||
|
||||
kpi-bg: #1e293b kpi-border: #334155 kpi-num: #93c5fd
|
||||
|
||||
action-bg: #064e3b action-border: #047857 action-text: #6ee7b7
|
||||
|
||||
tile-bg: #2d3138 tile-border: #3a3f47 tile-num: #f3f4f6
|
||||
|
||||
urgent-bg: #4a1414 urgent-border: #7f1d1d urgent-num: #fca5a5 urgent-text: #fecaca
|
||||
|
||||
activity-bg: #3a2e0a activity-border: #854d0e
|
||||
bottleneck-bg: #3a1414 bottleneck-border: #7f1d1d
|
||||
```
|
||||
|
||||
### 9.3 `fc_dashboard.scss`
|
||||
|
||||
Layout file. Re-exports each token as a CSS custom property scoped under `.o_fc_dashboard` so dev-tools can inspect/tweak live, then uses both the SCSS variable (for compile-time work like `darken()`) and the CSS variable (for runtime). Section classes:
|
||||
|
||||
- `.o_fc_banner` — gradient + border, flex-row with deadline countdown on the right
|
||||
- `.o_fc_kpi` (with `.o_fc_kpi__num`) — 3-up KPI tiles
|
||||
- `.o_fc_pill` — quick-action button pills
|
||||
- `.o_fc_activities`, `.o_fc_bottleneck` — left-column section backgrounds
|
||||
- `.o_fc_tile`, `.o_fc_tile--urgent` (with `.o_fc_tile__num`) — workflow stage tiles
|
||||
- `.o_fc_countdown--info` / `.o_fc_countdown--warning` / `.o_fc_countdown--danger` / `.o_fc_countdown--muted` — countdown widget colour levels (driven by OWL state)
|
||||
|
||||
### 9.4 Verification
|
||||
|
||||
After deploy, in `odoo-shell`:
|
||||
|
||||
```python
|
||||
env['ir.qweb']._get_asset_bundle('web.assets_backend').css() # light bundle URL
|
||||
env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() # dark bundle URL
|
||||
```
|
||||
|
||||
The two URLs must differ. If they're identical, the dark bundle didn't recompile — fix by deleting `ir.attachment` rows under `/web/assets/%` and restarting Odoo.
|
||||
|
||||
## 10. OWL countdown widget
|
||||
|
||||
### 10.1 Why a widget
|
||||
|
||||
The rest of the dashboard is fine being recomputed on page open — case counts move slowly. The countdown ("3 days 4 hours to cutoff") needs to tick without a page refresh, and its colour needs to shift as the deadline approaches (info → warning → danger).
|
||||
|
||||
### 10.2 Behaviour
|
||||
|
||||
- Registered as a field widget under the name `fc_posting_countdown`.
|
||||
- Reads `submission_deadline_dt` from `props.record.data`.
|
||||
- Ticks every 60 seconds via `setInterval`. Cleared on `onWillDestroy`.
|
||||
- Four levels with auto-shift:
|
||||
- `> 3 days remaining` → **info** (banner text colour)
|
||||
- `1–3 days` → **warning** (amber)
|
||||
- `< 24 hours` → **danger** (urgent-num colour)
|
||||
- `past deadline` → **muted** (text-muted colour), text reads "Cutoff passed"
|
||||
- Uses Luxon for date math (already loaded by Odoo).
|
||||
|
||||
### 10.3 Template
|
||||
|
||||
```xml
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_claims.PostingCountdown" owl="1">
|
||||
<span t-att-class="'o_fc_countdown o_fc_countdown--' + state.level"
|
||||
t-esc="state.text"/>
|
||||
</t>
|
||||
</templates>
|
||||
```
|
||||
|
||||
### 10.4 Use in form view
|
||||
|
||||
```xml
|
||||
<field name="submission_deadline_dt"
|
||||
widget="fc_posting_countdown"
|
||||
nolabel="1"
|
||||
readonly="1"/>
|
||||
```
|
||||
|
||||
## 11. Manifest changes
|
||||
|
||||
```python
|
||||
'version': '<bump minor>', # e.g. 19.0.8.0.7 → 19.0.9.0.0 for asset cache-bust per CLAUDE.md §Asset Cache Busting
|
||||
|
||||
'data': [
|
||||
# ...existing entries (data files load order unchanged)...
|
||||
'views/dashboard_views.xml', # rewritten
|
||||
],
|
||||
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
# ...existing entries...
|
||||
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss', # tokens FIRST
|
||||
'fusion_claims/static/src/scss/fc_dashboard.scss',
|
||||
'fusion_claims/static/src/js/fc_posting_countdown.js',
|
||||
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
|
||||
'fusion_claims/static/src/scss/fc_dashboard.scss',
|
||||
# No JS in dark bundle — Odoo loads JS once from backend.
|
||||
],
|
||||
},
|
||||
```
|
||||
|
||||
Token file is registered **before** layout file in **both** bundles. JS+XML only in backend.
|
||||
|
||||
## 12. Edge cases
|
||||
|
||||
### 12.1 Pre-first-posting
|
||||
|
||||
If today < `fusion_claims.adp_posting_base_date` (default 2026-01-23), `_get_current_posting_date()` returns the base date itself. Treatment:
|
||||
|
||||
- `posting_period_label` reads `"Posting starts Jan 23"`.
|
||||
- `submission_deadline_dt` set to first Wednesday at 18:00.
|
||||
- KPI tiles all show `$0 / 0` (no posting period to bill against yet).
|
||||
- `is_pre_first_posting=True` is exposed; view shows a one-line info note above the KPIs.
|
||||
|
||||
### 12.2 No invoices / empty system
|
||||
|
||||
All counts compute to 0. KPI tiles render `$0.00`. Activities section renders an empty-state message ("No activities assigned"). Bottleneck section hides itself when both counts are zero.
|
||||
|
||||
### 12.3 Sales rep with no assigned SOs
|
||||
|
||||
`_role_filter_domain()` returns `[('user_id', '=', user.id)]`. All counts → 0. The form still renders; "Showing your cases" hint plus an empty-state message ("You have no assigned cases").
|
||||
|
||||
### 12.4 Portal user accidentally clicks dashboard menu
|
||||
|
||||
The dashboard menu is already gated by `groups_id` on the existing menu item to `fusion_claims.group_fusion_claims_user` (internal users only). Confirm this is preserved in the rewritten `dashboard_views.xml`.
|
||||
|
||||
### 12.5 Currency mix
|
||||
|
||||
KPI sums assume a single company currency. `currency_id` defaults to `company_id.currency_id`. If invoices in another currency exist, they are summed in their own currency by Odoo's standard behaviour — out of scope to handle multi-currency for this dashboard. Document this limitation in the design note.
|
||||
|
||||
## 13. Decisions explicitly excluded
|
||||
|
||||
- **Auto-refresh on window focus** — considered, dropped to keep scope tight. Manual refresh via form header button is sufficient.
|
||||
- **The 4 configurable HTML panels from the existing dashboard** — removed entirely. If a "Recent Cases" view is needed later, that's a separate spec.
|
||||
- **Per-funder workflow tiles for ODSP / WSIB / Insurance / MDC / Hardship** — those funders get a count card only, not a row of stage tiles. Decision: keep the dashboard focused on the two highest-volume funders (ADP, MOD).
|
||||
- **Toggle between "My Cases" and "All Cases"** — group-based auto-filter only. Sales reps see their cases, managers see everything, no switch.
|
||||
|
||||
## 14. Acceptance criteria
|
||||
|
||||
1. Dashboard menu opens to a single page; old 4-panel UI gone.
|
||||
2. Banner shows current posting period and a live (ticking) countdown to Wed 6 PM cutoff.
|
||||
3. 3 KPI tiles render with correct dollar amounts for Ready / Claimed This Period / Total AR.
|
||||
4. 8 quick-action pills open a fresh SO form with the correct `x_fc_sale_type` pre-applied.
|
||||
5. All 17 workflow tiles show non-stale counts (verified by clicking a tile → resulting SO list count matches the tile number).
|
||||
6. Both bottleneck callouts compute and render; clicking opens the matching filtered SO list.
|
||||
7. Sales reps see only their own cases; managers see all.
|
||||
8. Light and dark themes render the dashboard without any invisible / low-contrast elements. Verified by:
|
||||
- Opening in light mode → no `display:none`-like artifacts, all text readable.
|
||||
- Switching to dark mode (user profile → Color Scheme → Dark → reload) → all colours shift to the dark palette, banner gradient is the cool blue monochrome.
|
||||
9. Asset bundles compile to distinct URLs in both themes (verified with the §9.4 snippet).
|
||||
10. No regression on existing dashboard menu item / action references — module loads cleanly, no XML resolution errors.
|
||||
|
||||
## 15. Open questions / non-decisions
|
||||
|
||||
None. All design choices are locked in. Implementation plan can proceed.
|
||||
@@ -4,159 +4,763 @@
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
CASE_TYPE_SELECTION = [
|
||||
('adp', 'ADP'),
|
||||
('odsp', 'ODSP'),
|
||||
('march_of_dimes', 'March of Dimes'),
|
||||
('hardship', 'Hardship Funding'),
|
||||
('acsd', 'ACSD'),
|
||||
('muscular_dystrophy', 'Muscular Dystrophy'),
|
||||
('insurance', 'Insurance'),
|
||||
('wsib', 'WSIB'),
|
||||
]
|
||||
|
||||
TYPE_DOMAINS = {
|
||||
'adp': [('x_fc_sale_type', 'in', ['adp', 'adp_odsp'])],
|
||||
'odsp': [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp'])],
|
||||
'march_of_dimes': [('x_fc_sale_type', '=', 'march_of_dimes')],
|
||||
'hardship': [('x_fc_sale_type', '=', 'hardship')],
|
||||
'acsd': [('x_fc_client_type', '=', 'ACS')],
|
||||
'muscular_dystrophy': [('x_fc_sale_type', '=', 'muscular_dystrophy')],
|
||||
'insurance': [('x_fc_sale_type', '=', 'insurance')],
|
||||
'wsib': [('x_fc_sale_type', '=', 'wsib')],
|
||||
}
|
||||
|
||||
TYPE_LABELS = dict(CASE_TYPE_SELECTION)
|
||||
|
||||
|
||||
class FusionClaimsDashboard(models.TransientModel):
|
||||
_name = 'fusion.claims.dashboard'
|
||||
_inherit = 'fusion_claims.adp.posting.schedule.mixin'
|
||||
_description = 'Fusion Claims Dashboard'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(default='Dashboard', readonly=True)
|
||||
|
||||
# Case counts by funding type
|
||||
adp_count = fields.Integer(compute='_compute_stats')
|
||||
odsp_count = fields.Integer(compute='_compute_stats')
|
||||
march_of_dimes_count = fields.Integer(compute='_compute_stats')
|
||||
hardship_count = fields.Integer(compute='_compute_stats')
|
||||
acsd_count = fields.Integer(compute='_compute_stats')
|
||||
muscular_dystrophy_count = fields.Integer(compute='_compute_stats')
|
||||
insurance_count = fields.Integer(compute='_compute_stats')
|
||||
wsib_count = fields.Integer(compute='_compute_stats')
|
||||
total_profiles = fields.Integer(compute='_compute_stats')
|
||||
# =========================================================================
|
||||
# Role-aware filter
|
||||
# =========================================================================
|
||||
is_manager = fields.Boolean(compute='_compute_is_manager')
|
||||
|
||||
# Panel selectors (4 panels)
|
||||
panel1_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 1', default='adp')
|
||||
panel2_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 2', default='odsp')
|
||||
panel3_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 3', default='march_of_dimes')
|
||||
panel4_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 4', default='hardship')
|
||||
|
||||
# Panel HTML
|
||||
panel1_html = fields.Html(compute='_compute_panels', sanitize=False)
|
||||
panel2_html = fields.Html(compute='_compute_panels', sanitize=False)
|
||||
panel3_html = fields.Html(compute='_compute_panels', sanitize=False)
|
||||
panel4_html = fields.Html(compute='_compute_panels', sanitize=False)
|
||||
panel1_title = fields.Char(compute='_compute_panels')
|
||||
panel2_title = fields.Char(compute='_compute_panels')
|
||||
panel3_title = fields.Char(compute='_compute_panels')
|
||||
panel4_title = fields.Char(compute='_compute_panels')
|
||||
|
||||
def _compute_stats(self):
|
||||
SO = self.env['sale.order'].sudo()
|
||||
Profile = self.env['fusion.client.profile'].sudo()
|
||||
def _compute_is_manager(self):
|
||||
manager_group = self.env.ref('fusion_claims.group_fusion_claims_manager',
|
||||
raise_if_not_found=False)
|
||||
sale_mgr_group = self.env.ref('sales_team.group_sale_manager',
|
||||
raise_if_not_found=False)
|
||||
for rec in self:
|
||||
rec.adp_count = SO.search_count(TYPE_DOMAINS['adp'])
|
||||
rec.odsp_count = SO.search_count(TYPE_DOMAINS['odsp'])
|
||||
rec.march_of_dimes_count = SO.search_count(TYPE_DOMAINS['march_of_dimes'])
|
||||
rec.hardship_count = SO.search_count(TYPE_DOMAINS['hardship'])
|
||||
rec.acsd_count = SO.search_count(TYPE_DOMAINS['acsd'])
|
||||
rec.muscular_dystrophy_count = SO.search_count(TYPE_DOMAINS['muscular_dystrophy'])
|
||||
rec.insurance_count = SO.search_count(TYPE_DOMAINS['insurance'])
|
||||
rec.wsib_count = SO.search_count(TYPE_DOMAINS['wsib'])
|
||||
rec.total_profiles = Profile.search_count([])
|
||||
|
||||
@api.depends('panel1_type', 'panel2_type', 'panel3_type', 'panel4_type')
|
||||
def _compute_panels(self):
|
||||
SO = self.env['sale.order'].sudo()
|
||||
for rec in self:
|
||||
for i in range(1, 5):
|
||||
ptype = getattr(rec, f'panel{i}_type') or 'adp'
|
||||
domain = TYPE_DOMAINS.get(ptype, [])
|
||||
orders = SO.search(domain, order='create_date desc', limit=50)
|
||||
count = SO.search_count(domain)
|
||||
title = f'Window {i} - {TYPE_LABELS.get(ptype, ptype)} ({count} cases)'
|
||||
html = rec._build_top_list(orders)
|
||||
setattr(rec, f'panel{i}_title', title)
|
||||
setattr(rec, f'panel{i}_html', html)
|
||||
|
||||
def _build_top_list(self, orders):
|
||||
if not orders:
|
||||
return '<p class="text-muted text-center py-4">No cases found</p>'
|
||||
rows = []
|
||||
for o in orders:
|
||||
status = o.x_fc_adp_application_status or ''
|
||||
status_label = dict(o._fields['x_fc_adp_application_status'].selection).get(status, status)
|
||||
rows.append(
|
||||
f'<tr>'
|
||||
f'<td><a href="/odoo/sales/{o.id}">{o.name}</a></td>'
|
||||
f'<td>{o.partner_id.name or ""}</td>'
|
||||
f'<td>{status_label}</td>'
|
||||
f'<td class="text-end">${o.amount_total:,.2f}</td>'
|
||||
f'</tr>'
|
||||
user = rec.env.user
|
||||
rec.is_manager = bool(
|
||||
(manager_group and user.has_group('fusion_claims.group_fusion_claims_manager'))
|
||||
or (sale_mgr_group and user.has_group('sales_team.group_sale_manager'))
|
||||
)
|
||||
return (
|
||||
'<table class="table table-sm table-hover mb-0">'
|
||||
'<thead><tr><th>Order</th><th>Client</th><th>Status</th><th class="text-end">Total</th></tr></thead>'
|
||||
'<tbody>' + ''.join(rows) + '</tbody></table>'
|
||||
)
|
||||
|
||||
def action_open_order(self, order_id):
|
||||
"""Open a specific sale order with breadcrumbs."""
|
||||
def _role_filter_domain(self):
|
||||
"""Common domain prefix for SO-based counts.
|
||||
|
||||
Managers (fusion_claims.group_fusion_claims_manager or
|
||||
sales_team.group_sale_manager) see everything.
|
||||
Other users see only SOs where they are the salesperson.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.is_manager:
|
||||
return []
|
||||
return [('user_id', '=', self.env.user.id)]
|
||||
|
||||
def _month_start(self):
|
||||
from datetime import date
|
||||
return date.today().replace(day=1)
|
||||
|
||||
# =========================================================================
|
||||
# Header banner
|
||||
# =========================================================================
|
||||
posting_period_label = fields.Char(compute='_compute_banner')
|
||||
posting_period_start = fields.Date(compute='_compute_banner')
|
||||
posting_period_end = fields.Date(compute='_compute_banner')
|
||||
submission_deadline_dt = fields.Datetime(compute='_compute_banner')
|
||||
is_pre_first_posting = fields.Boolean(compute='_compute_banner')
|
||||
|
||||
def _compute_banner(self):
|
||||
from datetime import date, datetime, time, timedelta
|
||||
import pytz
|
||||
|
||||
today = date.today()
|
||||
for rec in self:
|
||||
base_date = rec._get_adp_posting_base_date()
|
||||
rec.is_pre_first_posting = today < base_date
|
||||
|
||||
current = rec._get_current_posting_date(today)
|
||||
nxt = rec._get_next_posting_date(today)
|
||||
# If we're sitting on a posting date, current == next; treat
|
||||
# the period as the one starting today.
|
||||
if current == nxt:
|
||||
period_start = current
|
||||
period_end = current + timedelta(days=rec._get_adp_posting_frequency())
|
||||
else:
|
||||
period_start = current
|
||||
period_end = nxt
|
||||
|
||||
rec.posting_period_start = period_start
|
||||
rec.posting_period_end = period_end
|
||||
|
||||
if rec.is_pre_first_posting:
|
||||
rec.posting_period_label = f"Posting starts {base_date.strftime('%b %d')}"
|
||||
else:
|
||||
rec.posting_period_label = (
|
||||
f"{period_start.strftime('%b %d')} – "
|
||||
f"{period_end.strftime('%b %d')}"
|
||||
)
|
||||
|
||||
wednesday = rec._get_posting_week_wednesday(nxt)
|
||||
naive_deadline = datetime.combine(wednesday, time(18, 0, 0))
|
||||
# Store as UTC; users see it in their TZ; OWL widget computes in local TZ.
|
||||
tz = pytz.timezone(rec.env.user.tz or 'America/Toronto')
|
||||
local_deadline = tz.localize(naive_deadline)
|
||||
rec.submission_deadline_dt = local_deadline.astimezone(pytz.UTC).replace(tzinfo=None)
|
||||
|
||||
# =========================================================================
|
||||
# KPI tiles (3-up)
|
||||
# =========================================================================
|
||||
currency_id = fields.Many2one('res.currency', compute='_compute_kpis')
|
||||
kpi_ready_amount = fields.Monetary(compute='_compute_kpis',
|
||||
currency_field='currency_id')
|
||||
kpi_ready_count = fields.Integer(compute='_compute_kpis')
|
||||
kpi_claimed_amount = fields.Monetary(compute='_compute_kpis',
|
||||
currency_field='currency_id')
|
||||
kpi_claimed_count = fields.Integer(compute='_compute_kpis')
|
||||
kpi_ar_amount = fields.Monetary(compute='_compute_kpis',
|
||||
currency_field='currency_id')
|
||||
kpi_ar_count = fields.Integer(compute='_compute_kpis')
|
||||
|
||||
def _invoice_role_filter(self):
|
||||
"""Role filter for invoices — applied through linked SO's user_id."""
|
||||
self.ensure_one()
|
||||
if self.is_manager:
|
||||
return []
|
||||
return [('x_fc_source_sale_order_id.user_id', '=', self.env.user.id)]
|
||||
|
||||
def _compute_kpis(self):
|
||||
Move = self.env['account.move'].sudo()
|
||||
for rec in self:
|
||||
rec.currency_id = rec.env.company.currency_id
|
||||
|
||||
inv_filter = rec._invoice_role_filter()
|
||||
|
||||
# KPI 1: Ready to Claim
|
||||
ready_domain = inv_filter + [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('x_fc_adp_billing_status', '=', 'waiting'),
|
||||
('adp_exported', '=', False),
|
||||
]
|
||||
ready_invoices = Move.search(ready_domain)
|
||||
rec.kpi_ready_count = len(ready_invoices)
|
||||
rec.kpi_ready_amount = sum(ready_invoices.mapped('amount_total'))
|
||||
|
||||
# KPI 2: Claimed This Period
|
||||
claimed_domain = inv_filter + [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('x_fc_adp_billing_status', 'in', ['submitted', 'resubmitted']),
|
||||
('adp_export_date', '>=', rec.posting_period_start),
|
||||
]
|
||||
claimed_invoices = Move.search(claimed_domain)
|
||||
rec.kpi_claimed_count = len(claimed_invoices)
|
||||
rec.kpi_claimed_amount = sum(claimed_invoices.mapped('amount_total'))
|
||||
|
||||
# KPI 3: Total AR (ADP-portion invoices, unpaid)
|
||||
ar_domain = inv_filter + [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('x_fc_invoice_type', '=', 'adp'),
|
||||
('payment_state', 'in', ['not_paid', 'partial']),
|
||||
]
|
||||
ar_invoices = Move.search(ar_domain)
|
||||
rec.kpi_ar_count = len(ar_invoices)
|
||||
rec.kpi_ar_amount = sum(ar_invoices.mapped('amount_total'))
|
||||
|
||||
# =========================================================================
|
||||
# Activities (left column)
|
||||
# =========================================================================
|
||||
my_activities_count = fields.Integer(compute='_compute_activities')
|
||||
my_activities_html = fields.Html(compute='_compute_activities', sanitize=False)
|
||||
|
||||
def _compute_activities(self):
|
||||
Activity = self.env['mail.activity'].sudo()
|
||||
domain = [
|
||||
('user_id', '=', self.env.user.id),
|
||||
('res_model', 'in', ['sale.order', 'account.move', 'fusion.technician.task']),
|
||||
]
|
||||
for rec in self:
|
||||
activities = Activity.search(domain, order='date_deadline asc', limit=10)
|
||||
rec.my_activities_count = Activity.search_count(domain)
|
||||
if not activities:
|
||||
rec.my_activities_html = (
|
||||
'<p class="o_fc_empty">No activities assigned.</p>'
|
||||
)
|
||||
continue
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
rows = []
|
||||
for act in activities:
|
||||
overdue = act.date_deadline and act.date_deadline < today
|
||||
row_class = 'o_fc_activity_row o_fc_activity_overdue' if overdue else 'o_fc_activity_row'
|
||||
deadline_text = act.date_deadline.strftime('%b %d') if act.date_deadline else '—'
|
||||
url = f'/odoo/{act.res_model.replace(".", "_")}/{act.res_id}'
|
||||
rows.append(
|
||||
f'<div class="{row_class}">'
|
||||
f'<a href="{url}"><b>{act.summary or act.activity_type_id.name or "Activity"}</b></a>'
|
||||
f'<span class="o_fc_activity_deadline">{deadline_text}</span>'
|
||||
f'</div>'
|
||||
)
|
||||
rec.my_activities_html = '\n'.join(rows)
|
||||
|
||||
# =========================================================================
|
||||
# Bottlenecks (left column) + Other funder counts
|
||||
# =========================================================================
|
||||
bottleneck_no_pod_count = fields.Integer(compute='_compute_secondary_counts')
|
||||
bottleneck_no_response_count = fields.Integer(compute='_compute_secondary_counts')
|
||||
|
||||
count_odsp = fields.Integer(compute='_compute_secondary_counts')
|
||||
count_wsib = fields.Integer(compute='_compute_secondary_counts')
|
||||
count_insurance = fields.Integer(compute='_compute_secondary_counts')
|
||||
count_mdc = fields.Integer(compute='_compute_secondary_counts')
|
||||
count_hardship = fields.Integer(compute='_compute_secondary_counts')
|
||||
count_acsd = fields.Integer(compute='_compute_secondary_counts')
|
||||
|
||||
def _compute_secondary_counts(self):
|
||||
from datetime import date, timedelta
|
||||
SO = self.env['sale.order'].sudo()
|
||||
cutoff_14d_ago = date.today() - timedelta(days=14)
|
||||
for rec in self:
|
||||
base = rec._role_filter_domain()
|
||||
active = base + [('state', '!=', 'cancel')]
|
||||
|
||||
rec.bottleneck_no_pod_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
|
||||
('x_fc_proof_of_delivery', '=', False),
|
||||
])
|
||||
rec.bottleneck_no_response_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
|
||||
('x_fc_claim_submission_date', '<', cutoff_14d_ago),
|
||||
])
|
||||
|
||||
rec.count_odsp = SO.search_count(active + [
|
||||
('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']),
|
||||
])
|
||||
rec.count_wsib = SO.search_count(active + [('x_fc_sale_type', '=', 'wsib')])
|
||||
rec.count_insurance = SO.search_count(active + [('x_fc_sale_type', '=', 'insurance')])
|
||||
rec.count_mdc = SO.search_count(active + [('x_fc_sale_type', '=', 'muscular_dystrophy')])
|
||||
rec.count_hardship = SO.search_count(active + [('x_fc_sale_type', '=', 'hardship')])
|
||||
rec.count_acsd = SO.search_count(active + [('x_fc_client_type', '=', 'ACS')])
|
||||
|
||||
# =========================================================================
|
||||
# ADP Pre-Approval (right column, 4 tiles)
|
||||
# =========================================================================
|
||||
adp_waiting_app_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
adp_app_received_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
adp_ready_submit_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
adp_needs_correction_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
|
||||
# =========================================================================
|
||||
# ADP Post-Approval (right column, 4 tiles)
|
||||
# =========================================================================
|
||||
adp_approved_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
adp_ready_delivery_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
adp_ready_bill_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
adp_on_hold_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
|
||||
# =========================================================================
|
||||
# MOD (right column, 5 tiles)
|
||||
# =========================================================================
|
||||
mod_awaiting_funding_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
mod_funding_approved_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
mod_pca_received_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
mod_project_complete_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
mod_pod_submitted_count = fields.Integer(compute='_compute_workflow_counts')
|
||||
|
||||
def _compute_workflow_counts(self):
|
||||
SO = self.env['sale.order'].sudo()
|
||||
for rec in self:
|
||||
base = rec._role_filter_domain()
|
||||
|
||||
# ADP Pre-Approval
|
||||
rec.adp_waiting_app_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', 'in',
|
||||
['waiting_for_application', 'assessment_completed']),
|
||||
])
|
||||
rec.adp_app_received_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', '=', 'application_received'),
|
||||
])
|
||||
rec.adp_ready_submit_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', '=', 'ready_submission'),
|
||||
])
|
||||
rec.adp_needs_correction_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', '=', 'needs_correction'),
|
||||
])
|
||||
|
||||
# ADP Post-Approval
|
||||
rec.adp_approved_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
|
||||
])
|
||||
rec.adp_ready_delivery_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', '=', 'ready_delivery'),
|
||||
])
|
||||
rec.adp_ready_bill_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', '=', 'ready_bill'),
|
||||
])
|
||||
rec.adp_on_hold_count = SO.search_count(base + [
|
||||
('x_fc_adp_application_status', '=', 'on_hold'),
|
||||
])
|
||||
|
||||
# MOD
|
||||
rec.mod_awaiting_funding_count = SO.search_count(base + [
|
||||
('x_fc_mod_status', '=', 'awaiting_funding'),
|
||||
])
|
||||
rec.mod_funding_approved_count = SO.search_count(base + [
|
||||
('x_fc_mod_status', '=', 'funding_approved'),
|
||||
])
|
||||
rec.mod_pca_received_count = SO.search_count(base + [
|
||||
('x_fc_mod_status', '=', 'contract_received'),
|
||||
])
|
||||
rec.mod_project_complete_count = SO.search_count(base + [
|
||||
('x_fc_mod_status', '=', 'project_complete'),
|
||||
])
|
||||
rec.mod_pod_submitted_count = SO.search_count(base + [
|
||||
('x_fc_mod_status', '=', 'pod_submitted'),
|
||||
])
|
||||
|
||||
# =========================================================================
|
||||
# This Month rollup (4-up secondary KPI strip)
|
||||
# =========================================================================
|
||||
count_month_submitted = fields.Integer(compute='_compute_this_month')
|
||||
count_month_approved = fields.Integer(compute='_compute_this_month')
|
||||
count_month_delivered = fields.Integer(compute='_compute_this_month')
|
||||
count_month_billed = fields.Integer(compute='_compute_this_month')
|
||||
|
||||
def _compute_this_month(self):
|
||||
SO = self.env['sale.order'].sudo()
|
||||
for rec in self:
|
||||
base = rec._role_filter_domain()
|
||||
ms = rec._month_start()
|
||||
rec.count_month_submitted = SO.search_count(base + [
|
||||
('x_fc_claim_submission_date', '>=', ms),
|
||||
])
|
||||
rec.count_month_approved = SO.search_count(base + [
|
||||
('x_fc_claim_approval_date', '>=', ms),
|
||||
])
|
||||
rec.count_month_delivered = SO.search_count(base + [
|
||||
('x_fc_adp_delivery_date', '>=', ms),
|
||||
])
|
||||
rec.count_month_billed = SO.search_count(base + [
|
||||
('x_fc_billing_date', '>=', ms),
|
||||
])
|
||||
|
||||
# =========================================================================
|
||||
# Pipeline $ by stage (4-up money-in-motion strip)
|
||||
# =========================================================================
|
||||
pipeline_pre_amount = fields.Monetary(compute='_compute_pipeline',
|
||||
currency_field='currency_id')
|
||||
pipeline_submitted_amount = fields.Monetary(compute='_compute_pipeline',
|
||||
currency_field='currency_id')
|
||||
pipeline_approved_amount = fields.Monetary(compute='_compute_pipeline',
|
||||
currency_field='currency_id')
|
||||
pipeline_ready_bill_amount = fields.Monetary(compute='_compute_pipeline',
|
||||
currency_field='currency_id')
|
||||
|
||||
def _compute_pipeline(self):
|
||||
SO = self.env['sale.order'].sudo()
|
||||
for rec in self:
|
||||
base = rec._role_filter_domain()
|
||||
pre = SO.search(base + [
|
||||
('x_fc_adp_application_status', 'in',
|
||||
['waiting_for_application', 'assessment_completed',
|
||||
'application_received', 'ready_submission']),
|
||||
])
|
||||
sub = SO.search(base + [
|
||||
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
|
||||
])
|
||||
app = SO.search(base + [
|
||||
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
|
||||
])
|
||||
bill = SO.search(base + [
|
||||
('x_fc_adp_application_status', '=', 'ready_bill'),
|
||||
])
|
||||
rec.pipeline_pre_amount = sum(pre.mapped('amount_total'))
|
||||
rec.pipeline_submitted_amount = sum(sub.mapped('amount_total'))
|
||||
rec.pipeline_approved_amount = sum(app.mapped('amount_total'))
|
||||
rec.pipeline_ready_bill_amount = sum(bill.mapped('amount_total'))
|
||||
|
||||
# =========================================================================
|
||||
# Aging buckets (disjoint: 30-59d, 60-89d, 90+d)
|
||||
# =========================================================================
|
||||
aging_30_count = fields.Integer(compute='_compute_aging')
|
||||
aging_60_count = fields.Integer(compute='_compute_aging')
|
||||
aging_90_count = fields.Integer(compute='_compute_aging')
|
||||
|
||||
def _compute_aging(self):
|
||||
from datetime import date, timedelta
|
||||
SO = self.env['sale.order'].sudo()
|
||||
today = date.today()
|
||||
cut_30 = today - timedelta(days=30)
|
||||
cut_60 = today - timedelta(days=60)
|
||||
cut_90 = today - timedelta(days=90)
|
||||
# "Active" = SO not cancelled at order level, AND if it has an ADP
|
||||
# status, it's not in a terminal ADP state.
|
||||
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
|
||||
for rec in self:
|
||||
base = rec._role_filter_domain() + [
|
||||
('state', '!=', 'cancel'),
|
||||
'|',
|
||||
('x_fc_adp_application_status', '=', False),
|
||||
('x_fc_adp_application_status', 'not in', terminal_adp),
|
||||
]
|
||||
rec.aging_30_count = SO.search_count(base + [
|
||||
('create_date', '<', cut_30),
|
||||
('create_date', '>=', cut_60),
|
||||
])
|
||||
rec.aging_60_count = SO.search_count(base + [
|
||||
('create_date', '<', cut_60),
|
||||
('create_date', '>=', cut_90),
|
||||
])
|
||||
rec.aging_90_count = SO.search_count(base + [
|
||||
('create_date', '<', cut_90),
|
||||
])
|
||||
|
||||
# =========================================================================
|
||||
# Recent ADP Exports (last 5)
|
||||
# =========================================================================
|
||||
recent_exports_html = fields.Html(compute='_compute_recent_exports',
|
||||
sanitize=False)
|
||||
recent_exports_count = fields.Integer(compute='_compute_recent_exports')
|
||||
|
||||
def _compute_recent_exports(self):
|
||||
Exp = self.env['fusion_claims.adp.export.record'].sudo()
|
||||
for rec in self:
|
||||
records = Exp.search([], order='export_date desc', limit=5)
|
||||
rec.recent_exports_count = Exp.search_count([])
|
||||
if not records:
|
||||
rec.recent_exports_html = (
|
||||
'<p class="o_fc_empty">No exports yet.</p>'
|
||||
)
|
||||
continue
|
||||
rows = []
|
||||
for r in records:
|
||||
total = sum(r.invoice_ids.mapped('amount_total'))
|
||||
date_str = (r.export_date.strftime('%b %d, %Y')
|
||||
if r.export_date else '—')
|
||||
label = r.posting_period_label or r.name or 'Export'
|
||||
inv_count = r.invoice_count or 0
|
||||
rows.append(
|
||||
f'<div class="o_fc_export_row" '
|
||||
f'data-export-id="{r.id}">'
|
||||
f'<div class="o_fc_export_label">'
|
||||
f'<b>{label}</b>'
|
||||
f'<br/><small>{date_str} · {inv_count} inv</small>'
|
||||
f'</div>'
|
||||
f'<div class="o_fc_export_amount">${total:,.0f}</div>'
|
||||
f'</div>'
|
||||
)
|
||||
rec.recent_exports_html = '\n'.join(rows)
|
||||
|
||||
# =========================================================================
|
||||
# Open-list action methods
|
||||
# =========================================================================
|
||||
def _so_list_action(self, name, domain):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Sale Order',
|
||||
'name': name,
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'form',
|
||||
'res_id': order_id,
|
||||
'view_mode': 'list,form',
|
||||
'domain': self._role_filter_domain() + domain,
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_open_adp(self):
|
||||
return self._open_type_action('adp')
|
||||
# ----- ADP Pre-Approval -----
|
||||
def action_open_adp_waiting_app(self):
|
||||
return self._so_list_action('ADP — Waiting for Application', [
|
||||
('x_fc_adp_application_status', 'in',
|
||||
['waiting_for_application', 'assessment_completed']),
|
||||
])
|
||||
|
||||
def action_open_odsp(self):
|
||||
return self._open_type_action('odsp')
|
||||
def action_open_adp_app_received(self):
|
||||
return self._so_list_action('ADP — Application Received', [
|
||||
('x_fc_adp_application_status', '=', 'application_received'),
|
||||
])
|
||||
|
||||
def action_open_march(self):
|
||||
return self._open_type_action('march_of_dimes')
|
||||
def action_open_adp_ready_submit(self):
|
||||
return self._so_list_action('ADP — Ready for Submission', [
|
||||
('x_fc_adp_application_status', '=', 'ready_submission'),
|
||||
])
|
||||
|
||||
def action_open_hardship(self):
|
||||
return self._open_type_action('hardship')
|
||||
def action_open_adp_needs_correction(self):
|
||||
return self._so_list_action('ADP — Needs Correction', [
|
||||
('x_fc_adp_application_status', '=', 'needs_correction'),
|
||||
])
|
||||
|
||||
def action_open_acsd(self):
|
||||
return self._open_type_action('acsd')
|
||||
# ----- ADP Post-Approval -----
|
||||
def action_open_adp_approved(self):
|
||||
return self._so_list_action('ADP — Approved', [
|
||||
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
|
||||
])
|
||||
|
||||
def action_open_muscular(self):
|
||||
return self._open_type_action('muscular_dystrophy')
|
||||
def action_open_adp_ready_delivery(self):
|
||||
return self._so_list_action('ADP — Ready for Delivery', [
|
||||
('x_fc_adp_application_status', '=', 'ready_delivery'),
|
||||
])
|
||||
|
||||
def action_open_insurance(self):
|
||||
return self._open_type_action('insurance')
|
||||
def action_open_adp_ready_bill(self):
|
||||
return self._so_list_action('ADP — Ready to Bill', [
|
||||
('x_fc_adp_application_status', '=', 'ready_bill'),
|
||||
])
|
||||
|
||||
def action_open_wsib(self):
|
||||
return self._open_type_action('wsib')
|
||||
def action_open_adp_on_hold(self):
|
||||
return self._so_list_action('ADP — On Hold', [
|
||||
('x_fc_adp_application_status', '=', 'on_hold'),
|
||||
])
|
||||
|
||||
def action_open_profiles(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window', 'name': 'Client Profiles',
|
||||
'res_model': 'fusion.client.profile', 'view_mode': 'list,form',
|
||||
}
|
||||
# ----- MOD -----
|
||||
def action_open_mod_awaiting_funding(self):
|
||||
return self._so_list_action('MOD — Awaiting Funding', [
|
||||
('x_fc_mod_status', '=', 'awaiting_funding'),
|
||||
])
|
||||
|
||||
def _open_type_action(self, type_key):
|
||||
def action_open_mod_funding_approved(self):
|
||||
return self._so_list_action('MOD — Funding Approved', [
|
||||
('x_fc_mod_status', '=', 'funding_approved'),
|
||||
])
|
||||
|
||||
def action_open_mod_pca_received(self):
|
||||
return self._so_list_action('MOD — PCA Received', [
|
||||
('x_fc_mod_status', '=', 'contract_received'),
|
||||
])
|
||||
|
||||
def action_open_mod_project_complete(self):
|
||||
return self._so_list_action('MOD — Project Complete', [
|
||||
('x_fc_mod_status', '=', 'project_complete'),
|
||||
])
|
||||
|
||||
def action_open_mod_pod_submitted(self):
|
||||
return self._so_list_action('MOD — POD Submitted', [
|
||||
('x_fc_mod_status', '=', 'pod_submitted'),
|
||||
])
|
||||
|
||||
# ----- Other funders -----
|
||||
def action_open_odsp_cases(self):
|
||||
return self._so_list_action('ODSP Cases', [
|
||||
('state', '!=', 'cancel'),
|
||||
('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']),
|
||||
])
|
||||
|
||||
def action_open_wsib_cases(self):
|
||||
return self._so_list_action('WSIB Cases', [
|
||||
('state', '!=', 'cancel'),
|
||||
('x_fc_sale_type', '=', 'wsib'),
|
||||
])
|
||||
|
||||
def action_open_insurance_cases(self):
|
||||
return self._so_list_action('Insurance Cases', [
|
||||
('state', '!=', 'cancel'),
|
||||
('x_fc_sale_type', '=', 'insurance'),
|
||||
])
|
||||
|
||||
def action_open_mdc_cases(self):
|
||||
return self._so_list_action('Muscular Dystrophy Cases', [
|
||||
('state', '!=', 'cancel'),
|
||||
('x_fc_sale_type', '=', 'muscular_dystrophy'),
|
||||
])
|
||||
|
||||
def action_open_hardship_cases(self):
|
||||
return self._so_list_action('Hardship Cases', [
|
||||
('state', '!=', 'cancel'),
|
||||
('x_fc_sale_type', '=', 'hardship'),
|
||||
])
|
||||
|
||||
def action_open_acsd_cases(self):
|
||||
return self._so_list_action('ACSD Cases', [
|
||||
('state', '!=', 'cancel'),
|
||||
('x_fc_client_type', '=', 'ACS'),
|
||||
])
|
||||
|
||||
# ----- Bottlenecks -----
|
||||
def action_open_bottleneck_no_pod(self):
|
||||
return self._so_list_action('Bottleneck — Approved without POD', [
|
||||
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
|
||||
('x_fc_proof_of_delivery', '=', False),
|
||||
])
|
||||
|
||||
def action_open_bottleneck_no_response(self):
|
||||
from datetime import date, timedelta
|
||||
cutoff = date.today() - timedelta(days=14)
|
||||
return self._so_list_action('Bottleneck — Submitted, no response', [
|
||||
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
|
||||
('x_fc_claim_submission_date', '<', cutoff),
|
||||
])
|
||||
|
||||
# ----- Activities -----
|
||||
def action_open_my_activities(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': f'{TYPE_LABELS.get(type_key, type_key)} Cases',
|
||||
'res_model': 'sale.order', 'view_mode': 'list,form',
|
||||
'domain': TYPE_DOMAINS.get(type_key, []),
|
||||
'name': 'My Activities',
|
||||
'res_model': 'mail.activity',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [
|
||||
('user_id', '=', self.env.user.id),
|
||||
('res_model', 'in', ['sale.order', 'account.move',
|
||||
'fusion.technician.task']),
|
||||
],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# ----- KPI drill-downs -----
|
||||
def action_open_kpi_ready(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Ready to Claim (ADP)',
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': self._invoice_role_filter() + [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('x_fc_adp_billing_status', '=', 'waiting'),
|
||||
('adp_exported', '=', False),
|
||||
],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_open_kpi_claimed(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Claimed This Period',
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': self._invoice_role_filter() + [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('x_fc_adp_billing_status', 'in', ['submitted', 'resubmitted']),
|
||||
('adp_export_date', '>=', self.posting_period_start),
|
||||
],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_open_kpi_ar(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Total AR (ADP)',
|
||||
'res_model': 'account.move',
|
||||
'view_mode': 'list,form',
|
||||
'domain': self._invoice_role_filter() + [
|
||||
('move_type', '=', 'out_invoice'),
|
||||
('state', '=', 'posted'),
|
||||
('x_fc_invoice_type', '=', 'adp'),
|
||||
('payment_state', 'in', ['not_paid', 'partial']),
|
||||
],
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# Create-SO hotlinks
|
||||
# =========================================================================
|
||||
def _create_so_action(self, name, ctx_extra):
|
||||
context = dict(self.env.context)
|
||||
context.update(ctx_extra)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': name,
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'form',
|
||||
'view_id': False,
|
||||
'context': context,
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_create_adp_so(self):
|
||||
return self._create_so_action('New ADP Order',
|
||||
{'default_x_fc_sale_type': 'adp'})
|
||||
|
||||
def action_create_mod_so(self):
|
||||
return self._create_so_action('New MOD Order',
|
||||
{'default_x_fc_sale_type': 'march_of_dimes'})
|
||||
|
||||
def action_create_odsp_so(self):
|
||||
return self._create_so_action('New ODSP Order', {
|
||||
'default_x_fc_sale_type': 'odsp',
|
||||
'default_x_fc_odsp_division': 'standard',
|
||||
})
|
||||
|
||||
def action_create_wsib_so(self):
|
||||
return self._create_so_action('New WSIB Order',
|
||||
{'default_x_fc_sale_type': 'wsib'})
|
||||
|
||||
def action_create_insurance_so(self):
|
||||
return self._create_so_action('New Insurance Order',
|
||||
{'default_x_fc_sale_type': 'insurance'})
|
||||
|
||||
def action_create_mdc_so(self):
|
||||
return self._create_so_action('New MDC Order',
|
||||
{'default_x_fc_sale_type': 'muscular_dystrophy'})
|
||||
|
||||
def action_create_hardship_so(self):
|
||||
return self._create_so_action('New Hardship Order',
|
||||
{'default_x_fc_sale_type': 'hardship'})
|
||||
|
||||
def action_create_private_so(self):
|
||||
return self._create_so_action('New Private Order',
|
||||
{'default_x_fc_sale_type': 'direct_private'})
|
||||
|
||||
# =========================================================================
|
||||
# Additional drill-downs (This Month, Pipeline, Aging, Exports)
|
||||
# =========================================================================
|
||||
def action_open_month_submitted(self):
|
||||
return self._so_list_action('Submitted This Month', [
|
||||
('x_fc_claim_submission_date', '>=', self._month_start()),
|
||||
])
|
||||
|
||||
def action_open_month_approved(self):
|
||||
return self._so_list_action('Approved This Month', [
|
||||
('x_fc_claim_approval_date', '>=', self._month_start()),
|
||||
])
|
||||
|
||||
def action_open_month_delivered(self):
|
||||
return self._so_list_action('Delivered This Month', [
|
||||
('x_fc_adp_delivery_date', '>=', self._month_start()),
|
||||
])
|
||||
|
||||
def action_open_month_billed(self):
|
||||
return self._so_list_action('Billed This Month', [
|
||||
('x_fc_billing_date', '>=', self._month_start()),
|
||||
])
|
||||
|
||||
def action_open_pipeline_pre(self):
|
||||
return self._so_list_action('Pipeline — Pre-Submission', [
|
||||
('x_fc_adp_application_status', 'in',
|
||||
['waiting_for_application', 'assessment_completed',
|
||||
'application_received', 'ready_submission']),
|
||||
])
|
||||
|
||||
def action_open_pipeline_submitted(self):
|
||||
return self._so_list_action('Pipeline — Submitted to ADP', [
|
||||
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
|
||||
])
|
||||
|
||||
def action_open_aging_30(self):
|
||||
from datetime import date, timedelta
|
||||
today = date.today()
|
||||
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
|
||||
return self._so_list_action('Aging — 30 to 59 Days', [
|
||||
('state', '!=', 'cancel'),
|
||||
'|',
|
||||
('x_fc_adp_application_status', '=', False),
|
||||
('x_fc_adp_application_status', 'not in', terminal_adp),
|
||||
('create_date', '<', today - timedelta(days=30)),
|
||||
('create_date', '>=', today - timedelta(days=60)),
|
||||
])
|
||||
|
||||
def action_open_aging_60(self):
|
||||
from datetime import date, timedelta
|
||||
today = date.today()
|
||||
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
|
||||
return self._so_list_action('Aging — 60 to 89 Days', [
|
||||
('state', '!=', 'cancel'),
|
||||
'|',
|
||||
('x_fc_adp_application_status', '=', False),
|
||||
('x_fc_adp_application_status', 'not in', terminal_adp),
|
||||
('create_date', '<', today - timedelta(days=60)),
|
||||
('create_date', '>=', today - timedelta(days=90)),
|
||||
])
|
||||
|
||||
def action_open_aging_90(self):
|
||||
from datetime import date, timedelta
|
||||
today = date.today()
|
||||
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
|
||||
return self._so_list_action('Aging — 90+ Days', [
|
||||
('state', '!=', 'cancel'),
|
||||
'|',
|
||||
('x_fc_adp_application_status', '=', False),
|
||||
('x_fc_adp_application_status', 'not in', terminal_adp),
|
||||
('create_date', '<', today - timedelta(days=90)),
|
||||
])
|
||||
|
||||
def action_open_recent_exports(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'ADP Export History',
|
||||
'res_model': 'fusion_claims.adp.export.record',
|
||||
'view_mode': 'list,form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
@@ -2909,7 +2909,38 @@ class SaleOrder(models.Model):
|
||||
x_fc_signed_pages_filename = fields.Char(
|
||||
string='Signed Pages Filename',
|
||||
)
|
||||
|
||||
|
||||
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')
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# PAGE 11 SIGNATURE TRACKING (Client/Agent Signature)
|
||||
# Page 11 must be signed by: Client, Spouse, Parent, Legal Guardian, POA, or Public Trustee
|
||||
@@ -3234,7 +3265,7 @@ class SaleOrder(models.Model):
|
||||
@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_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'
|
||||
)
|
||||
@@ -3245,7 +3276,7 @@ class SaleOrder(models.Model):
|
||||
)
|
||||
order.x_fc_trail_has_authorization = bool(order.x_fc_claim_authorization_date)
|
||||
order.x_fc_trail_has_original_app = bool(order.x_fc_original_application)
|
||||
order.x_fc_trail_has_signed_pages = bool(order.x_fc_signed_pages_11_12)
|
||||
order.x_fc_trail_has_signed_pages = order.x_fc_has_signed_pages_11_12
|
||||
order.x_fc_trail_has_final_app = bool(order.x_fc_final_submitted_application)
|
||||
order.x_fc_trail_has_xml = bool(order.x_fc_xml_file)
|
||||
order.x_fc_trail_has_approval_letter = bool(order.x_fc_approval_letter)
|
||||
|
||||
63
fusion_claims/static/src/js/fc_posting_countdown.js
Normal file
63
fusion_claims/static/src/js/fc_posting_countdown.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/** @odoo-module **/
|
||||
// Fusion Claims — Posting Period Countdown
|
||||
// Reads the submission_deadline_dt field, computes "Nd Xh to cutoff" client-side,
|
||||
// re-renders every 60 seconds, swaps colour class as the deadline approaches.
|
||||
// Copyright 2026 Nexa Systems Inc.
|
||||
// License OPL-1
|
||||
|
||||
import { Component, useState, onWillDestroy } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "@web/views/fields/standard_field_props";
|
||||
|
||||
class FcPostingCountdown extends Component {
|
||||
static template = "fusion_claims.PostingCountdown";
|
||||
static props = { ...standardFieldProps };
|
||||
|
||||
setup() {
|
||||
this.state = useState({ text: "", level: "info" });
|
||||
this._render();
|
||||
this._timer = setInterval(() => this._render(), 60_000);
|
||||
onWillDestroy(() => {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_render() {
|
||||
const deadline = this.props.record.data[this.props.name];
|
||||
if (!deadline) {
|
||||
this.state.text = "";
|
||||
this.state.level = "muted";
|
||||
return;
|
||||
}
|
||||
// Odoo provides a luxon DateTime for Datetime fields
|
||||
const now = luxon.DateTime.now();
|
||||
const diff = deadline.diff(now, ["days", "hours", "minutes"]).toObject();
|
||||
|
||||
if (diff.days < 0 || (diff.days === 0 && diff.hours < 0)) {
|
||||
this.state.text = "Cutoff passed";
|
||||
this.state.level = "muted";
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(diff.days);
|
||||
const hours = Math.floor(diff.hours);
|
||||
|
||||
if (days < 1) {
|
||||
this.state.text = `${hours}h to cutoff`;
|
||||
this.state.level = "danger";
|
||||
} else if (days < 3) {
|
||||
this.state.text = `${days}d ${hours}h to cutoff`;
|
||||
this.state.level = "warning";
|
||||
} else {
|
||||
this.state.text = `${days} days to cutoff`;
|
||||
this.state.level = "info";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("fields").add("fc_posting_countdown", {
|
||||
component: FcPostingCountdown,
|
||||
});
|
||||
81
fusion_claims/static/src/scss/_fc_dashboard_tokens.scss
Normal file
81
fusion_claims/static/src/scss/_fc_dashboard_tokens.scss
Normal file
@@ -0,0 +1,81 @@
|
||||
// =============================================================================
|
||||
// Fusion Claims Dashboard — Palette Tokens
|
||||
// Compile-time branch on $o-webclient-color-scheme so the same SCSS file
|
||||
// produces different palettes in web.assets_backend (light) and
|
||||
// web.assets_web_dark (dark). Tokens load FIRST in each bundle.
|
||||
// =============================================================================
|
||||
|
||||
$o-webclient-color-scheme: bright !default;
|
||||
|
||||
// ---------- LIGHT (defaults) ----------
|
||||
$_fc-page-bg: #f7f7f8 !default;
|
||||
$_fc-card-bg: #ffffff !default;
|
||||
$_fc-card-border: #d8dadd !default;
|
||||
$_fc-text: #2b2b2b !default;
|
||||
$_fc-text-muted: #6c7480 !default;
|
||||
|
||||
$_fc-banner-from: #eef2ff !default;
|
||||
$_fc-banner-to: #fce7f3 !default;
|
||||
$_fc-banner-border: #c7d2fe !default;
|
||||
$_fc-banner-text: #3730a3 !default;
|
||||
$_fc-deadline-text: #b91c1c !default;
|
||||
|
||||
$_fc-kpi-bg: #f0f4ff !default;
|
||||
$_fc-kpi-border: #c7d2fe !default;
|
||||
$_fc-kpi-num: #1e3a8a !default;
|
||||
|
||||
$_fc-action-bg: #ecfdf5 !default;
|
||||
$_fc-action-border: #6ee7b7 !default;
|
||||
$_fc-action-text: #047857 !default;
|
||||
|
||||
$_fc-tile-bg: #f3f4f6 !default;
|
||||
$_fc-tile-border: #e5e7eb !default;
|
||||
$_fc-tile-num: #111827 !default;
|
||||
|
||||
$_fc-urgent-bg: #fee2e2 !default;
|
||||
$_fc-urgent-border: #fca5a5 !default;
|
||||
$_fc-urgent-num: #991b1b !default;
|
||||
$_fc-urgent-text: #7f1d1d !default;
|
||||
|
||||
$_fc-activity-bg: #fefce8 !default;
|
||||
$_fc-activity-border: #fde047 !default;
|
||||
$_fc-bottleneck-bg: #fef2f2 !default;
|
||||
$_fc-bottleneck-border: #fecaca !default;
|
||||
|
||||
// ---------- DARK overrides ----------
|
||||
@if $o-webclient-color-scheme == dark {
|
||||
$_fc-page-bg: #1a1d21 !global;
|
||||
$_fc-card-bg: #22262d !global;
|
||||
$_fc-card-border: #3a3f47 !global;
|
||||
$_fc-text: #e5e7eb !global;
|
||||
$_fc-text-muted: #9ca3af !global;
|
||||
|
||||
// Cool blue monochrome banner (selected option A from brainstorm)
|
||||
$_fc-banner-from: #1e293b !global;
|
||||
$_fc-banner-to: #1e3a5f !global;
|
||||
$_fc-banner-border: #3b82f6 !global;
|
||||
$_fc-banner-text: #93c5fd !global;
|
||||
$_fc-deadline-text: #fca5a5 !global;
|
||||
|
||||
$_fc-kpi-bg: #1e293b !global;
|
||||
$_fc-kpi-border: #334155 !global;
|
||||
$_fc-kpi-num: #93c5fd !global;
|
||||
|
||||
$_fc-action-bg: #064e3b !global;
|
||||
$_fc-action-border: #047857 !global;
|
||||
$_fc-action-text: #6ee7b7 !global;
|
||||
|
||||
$_fc-tile-bg: #2d3138 !global;
|
||||
$_fc-tile-border: #3a3f47 !global;
|
||||
$_fc-tile-num: #f3f4f6 !global;
|
||||
|
||||
$_fc-urgent-bg: #4a1414 !global;
|
||||
$_fc-urgent-border: #7f1d1d !global;
|
||||
$_fc-urgent-num: #fca5a5 !global;
|
||||
$_fc-urgent-text: #fecaca !global;
|
||||
|
||||
$_fc-activity-bg: #3a2e0a !global;
|
||||
$_fc-activity-border: #854d0e !global;
|
||||
$_fc-bottleneck-bg: #3a1414 !global;
|
||||
$_fc-bottleneck-border: #7f1d1d !global;
|
||||
}
|
||||
249
fusion_claims/static/src/scss/fc_dashboard.scss
Normal file
249
fusion_claims/static/src/scss/fc_dashboard.scss
Normal file
@@ -0,0 +1,249 @@
|
||||
// =============================================================================
|
||||
// Fusion Claims Dashboard — Layout & Section Styles
|
||||
// Consumes tokens from _fc_dashboard_tokens.scss (must load FIRST in bundle).
|
||||
// =============================================================================
|
||||
|
||||
// Override Odoo's form-sheet max-width so the dashboard uses the full
|
||||
// browser width. The selector matches the form (which carries the class)
|
||||
// and targets the inner sheet element.
|
||||
.o_fc_dashboard .o_form_sheet,
|
||||
.o_form_view.o_fc_dashboard .o_form_sheet {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.o_fc_dashboard {
|
||||
// Re-export tokens as CSS custom properties for devtools inspection
|
||||
--fc-page-bg: #{$_fc-page-bg};
|
||||
--fc-card-bg: #{$_fc-card-bg};
|
||||
--fc-card-border: #{$_fc-card-border};
|
||||
--fc-text: #{$_fc-text};
|
||||
--fc-text-muted: #{$_fc-text-muted};
|
||||
--fc-banner-from: #{$_fc-banner-from};
|
||||
--fc-banner-to: #{$_fc-banner-to};
|
||||
--fc-banner-border: #{$_fc-banner-border};
|
||||
--fc-banner-text: #{$_fc-banner-text};
|
||||
--fc-deadline-text: #{$_fc-deadline-text};
|
||||
--fc-kpi-bg: #{$_fc-kpi-bg};
|
||||
--fc-kpi-border: #{$_fc-kpi-border};
|
||||
--fc-kpi-num: #{$_fc-kpi-num};
|
||||
--fc-action-bg: #{$_fc-action-bg};
|
||||
--fc-action-border: #{$_fc-action-border};
|
||||
--fc-action-text: #{$_fc-action-text};
|
||||
--fc-tile-bg: #{$_fc-tile-bg};
|
||||
--fc-tile-border: #{$_fc-tile-border};
|
||||
--fc-tile-num: #{$_fc-tile-num};
|
||||
--fc-urgent-bg: #{$_fc-urgent-bg};
|
||||
--fc-urgent-border: #{$_fc-urgent-border};
|
||||
--fc-urgent-num: #{$_fc-urgent-num};
|
||||
--fc-urgent-text: #{$_fc-urgent-text};
|
||||
--fc-activity-bg: #{$_fc-activity-bg};
|
||||
--fc-activity-border: #{$_fc-activity-border};
|
||||
--fc-bottleneck-bg: #{$_fc-bottleneck-bg};
|
||||
--fc-bottleneck-border: #{$_fc-bottleneck-border};
|
||||
|
||||
background: var(--fc-page-bg);
|
||||
color: $_fc-text;
|
||||
|
||||
.o_fc_banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: linear-gradient(90deg, var(--fc-banner-from), var(--fc-banner-to));
|
||||
border: 1px solid var(--fc-banner-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
color: var(--fc-banner-text);
|
||||
}
|
||||
.o_fc_banner__deadline { font-weight: 700; }
|
||||
|
||||
.o_fc_kpi {
|
||||
background: var(--fc-kpi-bg);
|
||||
border: 1px solid var(--fc-kpi-border);
|
||||
border-radius: 8px;
|
||||
padding: 14px 10px;
|
||||
text-align: center;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&:hover { transform: translateY(-2px); }
|
||||
}
|
||||
.o_fc_kpi__num {
|
||||
display: block;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--fc-kpi-num);
|
||||
}
|
||||
.o_fc_kpi__lbl {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--fc-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
// Secondary KPI variant — smaller, denser. Used for "This Month" and
|
||||
// "Pipeline by stage" tile strips.
|
||||
.o_fc_kpi--secondary {
|
||||
padding: 10px 6px;
|
||||
.o_fc_kpi__num { font-size: 1.15rem; }
|
||||
.o_fc_kpi__lbl { font-size: 0.68rem; }
|
||||
}
|
||||
|
||||
.o_fc_actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.o_fc_pill {
|
||||
background: var(--fc-action-bg);
|
||||
border: 1px solid var(--fc-action-border);
|
||||
color: var(--fc-action-text);
|
||||
border-radius: 16px;
|
||||
padding: 5px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover { background: var(--fc-action-border); }
|
||||
}
|
||||
|
||||
.o_fc_section {
|
||||
background: var(--fc-card-bg);
|
||||
border: 1px solid var(--fc-card-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.o_fc_h6 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
color: var(--fc-text);
|
||||
}
|
||||
.o_fc_tag {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--fc-banner-border);
|
||||
color: var(--fc-banner-text);
|
||||
margin-left: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.o_fc_tile {
|
||||
background: var(--fc-tile-bg);
|
||||
border: 1px solid var(--fc-tile-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 6px;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
}
|
||||
.o_fc_tile__num {
|
||||
display: block;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--fc-tile-num);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.o_fc_tile--urgent {
|
||||
background: var(--fc-urgent-bg);
|
||||
border-color: var(--fc-urgent-border);
|
||||
color: var(--fc-urgent-text);
|
||||
|
||||
.o_fc_tile__num { color: var(--fc-urgent-num); }
|
||||
}
|
||||
|
||||
.o_fc_activities {
|
||||
background: var(--fc-activity-bg);
|
||||
border: 1px solid var(--fc-activity-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.o_fc_activity_row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px dashed var(--fc-card-border);
|
||||
font-size: 0.85rem;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.o_fc_activity_overdue {
|
||||
color: var(--fc-urgent-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.o_fc_activity_deadline { color: var(--fc-text-muted); }
|
||||
.o_fc_empty {
|
||||
color: var(--fc-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.o_fc_bottleneck {
|
||||
background: var(--fc-bottleneck-bg);
|
||||
border: 1px solid var(--fc-bottleneck-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.o_fc_bottleneck_row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 4px 0;
|
||||
color: var(--fc-text);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { color: var(--fc-urgent-num); text-decoration: underline; }
|
||||
}
|
||||
|
||||
// Recent ADP Exports list rows
|
||||
.o_fc_export_row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dashed var(--fc-card-border);
|
||||
font-size: 0.85rem;
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
.o_fc_export_label small {
|
||||
color: var(--fc-text-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.o_fc_export_amount {
|
||||
font-weight: 700;
|
||||
color: var(--fc-kpi-num);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// Countdown widget colour levels (driven by OWL state)
|
||||
.o_fc_countdown {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.o_fc_countdown--info { color: var(--fc-banner-text); }
|
||||
.o_fc_countdown--warning { color: #d97706; } // amber (intentional fixed hex)
|
||||
.o_fc_countdown--danger { color: var(--fc-urgent-num); }
|
||||
.o_fc_countdown--muted { color: var(--fc-text-muted); font-style: italic; }
|
||||
}
|
||||
7
fusion_claims/static/src/xml/fc_posting_countdown.xml
Normal file
7
fusion_claims/static/src/xml/fc_posting_countdown.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="fusion_claims.PostingCountdown">
|
||||
<span t-attf-class="o_fc_countdown o_fc_countdown--{{state.level}}"
|
||||
t-esc="state.text"/>
|
||||
</t>
|
||||
</templates>
|
||||
5
fusion_claims/tests/__init__.py
Normal file
5
fusion_claims/tests/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import test_signed_pages_gate
|
||||
from . import test_application_received_wizard
|
||||
from . import test_dashboard
|
||||
191
fusion_claims/tests/test_application_received_wizard.py
Normal file
191
fusion_claims/tests/test_application_received_wizard.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# -*- 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}",
|
||||
)
|
||||
367
fusion_claims/tests/test_dashboard.py
Normal file
367
fusion_claims/tests/test_dashboard.py
Normal file
@@ -0,0 +1,367 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_claims')
|
||||
class TestFusionClaimsDashboard(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Dashboard = cls.env['fusion.claims.dashboard']
|
||||
cls.User = cls.env['res.users']
|
||||
cls.Partner = cls.env['res.partner']
|
||||
|
||||
# Manager user (sees everything)
|
||||
cls.manager = cls.User.create({
|
||||
'name': 'Test Dashboard Manager',
|
||||
'login': 'test_dash_mgr',
|
||||
'group_ids': [
|
||||
(4, cls.env.ref('fusion_claims.group_fusion_claims_manager').id),
|
||||
(4, cls.env.ref('sales_team.group_sale_salesman').id),
|
||||
],
|
||||
})
|
||||
|
||||
# Sales rep (sees only own cases)
|
||||
cls.salesrep = cls.User.create({
|
||||
'name': 'Test Dashboard Salesrep',
|
||||
'login': 'test_dash_rep',
|
||||
'group_ids': [
|
||||
(4, cls.env.ref('fusion_claims.group_fusion_claims_user').id),
|
||||
(4, cls.env.ref('sales_team.group_sale_salesman').id),
|
||||
],
|
||||
})
|
||||
|
||||
cls.partner = cls.Partner.create({'name': 'Test Client'})
|
||||
|
||||
@classmethod
|
||||
def _make_invoice(cls, user, billing_status, amount=1000.0,
|
||||
exported=False, export_date=None,
|
||||
invoice_type='adp', payment_state='not_paid'):
|
||||
"""Helper: create a posted ADP invoice linked to an SO owned by `user`."""
|
||||
so = cls.env['sale.order'].with_context(skip_status_validation=True).create({
|
||||
'partner_id': cls.partner.id,
|
||||
'user_id': user.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'approved',
|
||||
})
|
||||
invoice = cls.env['account.move'].with_context(skip_sync=True).create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': cls.partner.id,
|
||||
'x_fc_source_sale_order_id': so.id,
|
||||
'x_fc_invoice_type': invoice_type,
|
||||
'x_fc_adp_billing_status': billing_status,
|
||||
'adp_exported': exported,
|
||||
'adp_export_date': export_date,
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'name': 'Test line',
|
||||
'quantity': 1.0,
|
||||
'price_unit': amount,
|
||||
'tax_ids': [(5, 0)], # clear taxes so amount_total == price_unit
|
||||
})],
|
||||
})
|
||||
invoice.action_post()
|
||||
invoice.with_context(skip_sync=True).write({'payment_state': payment_state})
|
||||
return invoice
|
||||
|
||||
def test_dashboard_record_creates(self):
|
||||
dashboard = self.Dashboard.create({})
|
||||
self.assertTrue(dashboard.id, "Dashboard record should be creatable")
|
||||
self.assertEqual(dashboard.name, 'Dashboard')
|
||||
|
||||
def test_role_filter_empty_for_manager(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard._role_filter_domain(), [],
|
||||
"Manager should see all cases (empty domain)")
|
||||
|
||||
def test_role_filter_restricts_for_salesrep(self):
|
||||
dashboard = self.Dashboard.with_user(self.salesrep).create({})
|
||||
domain = dashboard._role_filter_domain()
|
||||
self.assertEqual(domain, [('user_id', '=', self.salesrep.id)],
|
||||
"Sales rep should see only their own SOs")
|
||||
|
||||
def test_is_manager_true_for_manager(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertTrue(dashboard.is_manager)
|
||||
|
||||
def test_is_manager_false_for_salesrep(self):
|
||||
dashboard = self.Dashboard.with_user(self.salesrep).create({})
|
||||
self.assertFalse(dashboard.is_manager)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 2 — Banner
|
||||
# -------------------------------------------------------------------------
|
||||
def test_banner_posting_period_label_format(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
label = dashboard.posting_period_label
|
||||
self.assertTrue(any(month in label
|
||||
for month in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']),
|
||||
"Label should contain a month abbreviation")
|
||||
|
||||
def test_banner_posting_period_start_and_end_are_dates(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertTrue(dashboard.posting_period_start)
|
||||
self.assertTrue(dashboard.posting_period_end)
|
||||
delta = (dashboard.posting_period_end - dashboard.posting_period_start).days
|
||||
self.assertEqual(delta, 14)
|
||||
|
||||
def test_banner_submission_deadline_is_wednesday_6pm(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
deadline = dashboard.submission_deadline_dt
|
||||
self.assertTrue(deadline, "Deadline should be set")
|
||||
# Stored in UTC; convert to user's TZ to assert the wall-clock weekday/hour
|
||||
import pytz
|
||||
tz = pytz.timezone(self.manager.tz or 'America/Toronto')
|
||||
local = pytz.UTC.localize(deadline).astimezone(tz)
|
||||
self.assertEqual(local.weekday(), 2, "Deadline should be Wednesday")
|
||||
self.assertEqual(local.hour, 18, "Deadline should be 18:00 (6 PM)")
|
||||
|
||||
def test_is_pre_first_posting_false_when_today_is_past_base_date(self):
|
||||
# Test runs after 2026-01-23 by default.
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertFalse(dashboard.is_pre_first_posting)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 3 — KPI tiles
|
||||
# -------------------------------------------------------------------------
|
||||
def test_kpi_ready_counts_waiting_invoices_not_exported(self):
|
||||
self._make_invoice(self.manager, 'waiting', amount=500.0, exported=False)
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.kpi_ready_count, 1)
|
||||
self.assertAlmostEqual(dashboard.kpi_ready_amount, 500.0, places=2)
|
||||
|
||||
def test_kpi_ready_excludes_already_exported(self):
|
||||
from datetime import date
|
||||
self._make_invoice(self.manager, 'waiting', amount=500.0,
|
||||
exported=True, export_date=date.today())
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.kpi_ready_count, 0)
|
||||
self.assertAlmostEqual(dashboard.kpi_ready_amount, 0.0, places=2)
|
||||
|
||||
def test_kpi_claimed_counts_exported_in_current_period(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
in_period_date = dashboard.posting_period_start
|
||||
self._make_invoice(self.manager, 'submitted', amount=700.0,
|
||||
exported=True, export_date=in_period_date)
|
||||
dashboard2 = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard2.kpi_claimed_count, 1)
|
||||
self.assertAlmostEqual(dashboard2.kpi_claimed_amount, 700.0, places=2)
|
||||
|
||||
def test_kpi_ar_counts_posted_unpaid_adp_invoices(self):
|
||||
self._make_invoice(self.manager, 'submitted', amount=2000.0,
|
||||
exported=True, payment_state='not_paid')
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.kpi_ar_count, 1)
|
||||
self.assertAlmostEqual(dashboard.kpi_ar_amount, 2000.0, places=2)
|
||||
|
||||
def test_kpi_ready_respects_role_filter(self):
|
||||
self._make_invoice(self.manager, 'waiting', amount=500.0)
|
||||
dashboard_rep = self.Dashboard.with_user(self.salesrep).create({})
|
||||
self.assertEqual(dashboard_rep.kpi_ready_count, 0,
|
||||
"Salesrep must not see manager's invoice")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 4 — Activities + bottlenecks
|
||||
# -------------------------------------------------------------------------
|
||||
def test_my_activities_count_zero_when_none(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.my_activities_count, 0)
|
||||
|
||||
def test_my_activities_count_picks_up_user_activity(self):
|
||||
so = self.env['sale.order'].with_context(skip_status_validation=True).create({
|
||||
'partner_id': self.partner.id,
|
||||
'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
})
|
||||
self.env['mail.activity'].create({
|
||||
'res_model_id': self.env['ir.model']._get('sale.order').id,
|
||||
'res_id': so.id,
|
||||
'res_model': 'sale.order',
|
||||
'user_id': self.manager.id,
|
||||
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
||||
'summary': 'Test activity',
|
||||
})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.my_activities_count, 1)
|
||||
self.assertIn('Test activity', dashboard.my_activities_html or '')
|
||||
|
||||
def test_bottleneck_no_pod_count(self):
|
||||
self.env['sale.order'].with_context(skip_status_validation=True).create({
|
||||
'partner_id': self.partner.id,
|
||||
'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'approved',
|
||||
})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.bottleneck_no_pod_count, 1)
|
||||
|
||||
def test_bottleneck_no_response_count(self):
|
||||
from datetime import date, timedelta
|
||||
old_date = date.today() - timedelta(days=20)
|
||||
self.env['sale.order'].with_context(skip_status_validation=True).create({
|
||||
'partner_id': self.partner.id,
|
||||
'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'submitted',
|
||||
'x_fc_claim_submission_date': old_date,
|
||||
})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.bottleneck_no_response_count, 1)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 5 — Other funder counts
|
||||
# -------------------------------------------------------------------------
|
||||
def test_other_funder_counts_segregate_by_sale_type(self):
|
||||
SO = self.env['sale.order'].with_context(skip_status_validation=True)
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'odsp'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'wsib'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'insurance'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'muscular_dystrophy'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'hardship'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp', 'x_fc_client_type': 'ACS'})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.count_odsp, 1)
|
||||
self.assertEqual(dashboard.count_wsib, 1)
|
||||
self.assertEqual(dashboard.count_insurance, 1)
|
||||
self.assertEqual(dashboard.count_mdc, 1)
|
||||
self.assertEqual(dashboard.count_hardship, 1)
|
||||
self.assertEqual(dashboard.count_acsd, 1)
|
||||
|
||||
def test_other_funder_counts_exclude_cancelled(self):
|
||||
so = self.env['sale.order'].with_context(skip_status_validation=True).create({
|
||||
'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'wsib',
|
||||
})
|
||||
so.with_context(skip_status_validation=True).write({'state': 'cancel'})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.count_wsib, 0)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 6 — ADP + MOD workflow counts
|
||||
# -------------------------------------------------------------------------
|
||||
def test_adp_pre_approval_tile_counts(self):
|
||||
SO = self.env['sale.order'].with_context(skip_status_validation=True)
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'waiting_for_application'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'application_received'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'ready_submission'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'needs_correction'})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.adp_waiting_app_count, 1)
|
||||
self.assertEqual(dashboard.adp_app_received_count, 1)
|
||||
self.assertEqual(dashboard.adp_ready_submit_count, 1)
|
||||
self.assertEqual(dashboard.adp_needs_correction_count, 1)
|
||||
|
||||
def test_adp_post_approval_tile_counts(self):
|
||||
SO = self.env['sale.order'].with_context(skip_status_validation=True)
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'approved'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'ready_delivery'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'ready_bill'})
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'adp',
|
||||
'x_fc_adp_application_status': 'on_hold'})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.adp_approved_count, 1)
|
||||
self.assertEqual(dashboard.adp_ready_delivery_count, 1)
|
||||
self.assertEqual(dashboard.adp_ready_bill_count, 1)
|
||||
self.assertEqual(dashboard.adp_on_hold_count, 1)
|
||||
|
||||
def test_mod_tile_counts(self):
|
||||
SO = self.env['sale.order'].with_context(skip_status_validation=True)
|
||||
for status in ('awaiting_funding', 'funding_approved', 'contract_received',
|
||||
'project_complete', 'pod_submitted'):
|
||||
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
|
||||
'x_fc_sale_type': 'march_of_dimes',
|
||||
'x_fc_mod_status': status})
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
self.assertEqual(dashboard.mod_awaiting_funding_count, 1)
|
||||
self.assertEqual(dashboard.mod_funding_approved_count, 1)
|
||||
self.assertEqual(dashboard.mod_pca_received_count, 1)
|
||||
self.assertEqual(dashboard.mod_project_complete_count, 1)
|
||||
self.assertEqual(dashboard.mod_pod_submitted_count, 1)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 7 — Open-list action methods
|
||||
# -------------------------------------------------------------------------
|
||||
def test_action_open_adp_waiting_app_returns_correct_domain(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_open_adp_waiting_app()
|
||||
self.assertEqual(action['res_model'], 'sale.order')
|
||||
self.assertIn(('x_fc_adp_application_status', 'in',
|
||||
['waiting_for_application', 'assessment_completed']),
|
||||
action['domain'])
|
||||
|
||||
def test_action_open_bottleneck_no_pod_returns_correct_domain(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_open_bottleneck_no_pod()
|
||||
self.assertEqual(action['res_model'], 'sale.order')
|
||||
self.assertIn(('x_fc_proof_of_delivery', '=', False), action['domain'])
|
||||
|
||||
def test_action_open_mod_awaiting_funding_returns_correct_domain(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_open_mod_awaiting_funding()
|
||||
self.assertEqual(action['res_model'], 'sale.order')
|
||||
self.assertIn(('x_fc_mod_status', '=', 'awaiting_funding'), action['domain'])
|
||||
|
||||
def test_action_open_my_activities_returns_activity_model(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_open_my_activities()
|
||||
self.assertEqual(action['res_model'], 'mail.activity')
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task 8 — Create-SO hotlinks
|
||||
# -------------------------------------------------------------------------
|
||||
def test_action_create_adp_so_has_default_sale_type(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_create_adp_so()
|
||||
self.assertEqual(action['res_model'], 'sale.order')
|
||||
self.assertEqual(action['view_mode'], 'form')
|
||||
self.assertEqual(action['context']['default_x_fc_sale_type'], 'adp')
|
||||
|
||||
def test_action_create_mod_so_has_default_sale_type(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_create_mod_so()
|
||||
self.assertEqual(action['context']['default_x_fc_sale_type'], 'march_of_dimes')
|
||||
|
||||
def test_action_create_odsp_so_has_division_default(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
action = dashboard.action_create_odsp_so()
|
||||
self.assertEqual(action['context']['default_x_fc_sale_type'], 'odsp')
|
||||
self.assertEqual(action['context']['default_x_fc_odsp_division'], 'standard')
|
||||
|
||||
def test_all_create_so_actions_exist(self):
|
||||
dashboard = self.Dashboard.with_user(self.manager).create({})
|
||||
for method_name, expected_type in [
|
||||
('action_create_adp_so', 'adp'),
|
||||
('action_create_mod_so', 'march_of_dimes'),
|
||||
('action_create_odsp_so', 'odsp'),
|
||||
('action_create_wsib_so', 'wsib'),
|
||||
('action_create_insurance_so', 'insurance'),
|
||||
('action_create_mdc_so', 'muscular_dystrophy'),
|
||||
('action_create_hardship_so', 'hardship'),
|
||||
('action_create_private_so', 'direct_private'),
|
||||
]:
|
||||
action = getattr(dashboard, method_name)()
|
||||
self.assertEqual(action['res_model'], 'sale.order')
|
||||
self.assertEqual(action['context']['default_x_fc_sale_type'], expected_type,
|
||||
f"{method_name} returned wrong default sale type")
|
||||
108
fusion_claims/tests/test_signed_pages_gate.py
Normal file
108
fusion_claims/tests/test_signed_pages_gate.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# -*- 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)
|
||||
|
||||
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)
|
||||
|
||||
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."""
|
||||
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',
|
||||
})
|
||||
wizard.action_confirm()
|
||||
self.assertEqual(self.order.x_fc_adp_application_status, 'ready_submission')
|
||||
|
||||
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,
|
||||
})
|
||||
self.assertTrue(wizard.has_signed_pages)
|
||||
@@ -4,151 +4,530 @@
|
||||
<field name="name">fusion.claims.dashboard.form</field>
|
||||
<field name="model">fusion.claims.dashboard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Dashboard" create="0" delete="0">
|
||||
<form string="Dashboard" create="0" delete="0" edit="0"
|
||||
class="o_fc_dashboard">
|
||||
<sheet>
|
||||
<!-- ===== FUNDING CARDS (one line, bigger) ===== -->
|
||||
<div class="d-flex flex-nowrap gap-2 mb-4 overflow-auto">
|
||||
<div invisible="adp_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_adp" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="adp_count"/></div>
|
||||
<div style="font-size: 0.85rem;">ADP</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Hidden invariants used by buttons + widgets -->
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="posting_period_start" invisible="1"/>
|
||||
<field name="is_manager" invisible="1"/>
|
||||
<field name="is_pre_first_posting" invisible="1"/>
|
||||
|
||||
<!-- BANNER -->
|
||||
<div class="o_fc_banner mb-3">
|
||||
<div class="o_fc_banner__label">
|
||||
<i class="fa fa-calendar me-2"/>
|
||||
<span>Posting Period: </span>
|
||||
<field name="posting_period_label" nolabel="1"
|
||||
class="fw-bold"/>
|
||||
</div>
|
||||
<div invisible="odsp_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_odsp" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="odsp_count"/></div>
|
||||
<div style="font-size: 0.85rem;">ODSP</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="march_of_dimes_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_march" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="march_of_dimes_count"/></div>
|
||||
<div style="font-size: 0.85rem;">March of Dimes</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="hardship_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_hardship" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="hardship_count"/></div>
|
||||
<div style="font-size: 0.85rem;">Hardship</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="acsd_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_acsd" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="acsd_count"/></div>
|
||||
<div style="font-size: 0.85rem;">ACSD</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="muscular_dystrophy_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_muscular" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="muscular_dystrophy_count"/></div>
|
||||
<div style="font-size: 0.85rem;">Muscular Dystrophy</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="insurance_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_insurance" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-dark text-center py-3 px-2" style="background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="insurance_count"/></div>
|
||||
<div style="font-size: 0.85rem;">Insurance</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="wsib_count == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_wsib" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-dark text-center py-3 px-2" style="background: linear-gradient(135deg, #ff9a9e 0%, #fad0c4 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="wsib_count"/></div>
|
||||
<div style="font-size: 0.85rem;">WSIB</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div invisible="total_profiles == 0" style="flex: 1 1 0; min-width: 120px;">
|
||||
<button name="action_open_profiles" type="object" class="btn p-0 w-100 border-0">
|
||||
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #30cfd0 0%, #330867 100%); border-radius: 14px;">
|
||||
<div class="fw-bold" style="font-size: 1.8rem;"><field name="total_profiles"/></div>
|
||||
<div style="font-size: 0.85rem;">Profiles</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="o_fc_banner__deadline">
|
||||
<field name="submission_deadline_dt"
|
||||
widget="fc_posting_countdown"
|
||||
nolabel="1" readonly="1"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== PANEL SELECTORS (4 dropdowns) ===== -->
|
||||
<!-- "Showing your cases" hint when role-filtered -->
|
||||
<div class="alert alert-info py-2 mb-2"
|
||||
invisible="is_manager">
|
||||
Showing your assigned cases only.
|
||||
</div>
|
||||
|
||||
<!-- KPI TILES (3-up) -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-3">
|
||||
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 1</div>
|
||||
<field name="panel1_type" nolabel="1"/>
|
||||
<div class="col-12 col-md-4">
|
||||
<button name="action_open_kpi_ready" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="kpi_ready_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Ready to Claim
|
||||
(<field name="kpi_ready_count" nolabel="1"/>)
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 2</div>
|
||||
<field name="panel2_type" nolabel="1"/>
|
||||
<div class="col-12 col-md-4">
|
||||
<button name="action_open_kpi_claimed" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="kpi_claimed_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Claimed This Period
|
||||
(<field name="kpi_claimed_count" nolabel="1"/>)
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 3</div>
|
||||
<field name="panel3_type" nolabel="1"/>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 4</div>
|
||||
<field name="panel4_type" nolabel="1"/>
|
||||
<div class="col-12 col-md-4">
|
||||
<button name="action_open_kpi_ar" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="kpi_ar_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Total AR
|
||||
(<field name="kpi_ar_count" nolabel="1"/>)
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== TOP PANELS ROW 1 ===== -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card" style="border-radius: 14px; overflow: hidden;">
|
||||
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
|
||||
<field name="panel1_title" nolabel="1"/>
|
||||
<!-- THIS MONTH ROLLUP (4 count tiles) -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_month_submitted" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num"><field name="count_month_submitted" nolabel="1"/></span>
|
||||
<span class="o_fc_kpi__lbl">Submitted MTD</span>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<field name="panel1_html" class="w-100" nolabel="1"/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card" style="border-radius: 14px; overflow: hidden;">
|
||||
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
|
||||
<field name="panel2_title" nolabel="1"/>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_month_approved" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num"><field name="count_month_approved" nolabel="1"/></span>
|
||||
<span class="o_fc_kpi__lbl">Approved MTD</span>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<field name="panel2_html" class="w-100" nolabel="1"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_month_delivered" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num"><field name="count_month_delivered" nolabel="1"/></span>
|
||||
<span class="o_fc_kpi__lbl">Delivered MTD</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_month_billed" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num"><field name="count_month_billed" nolabel="1"/></span>
|
||||
<span class="o_fc_kpi__lbl">Billed MTD</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== TOP PANELS ROW 2 ===== -->
|
||||
<!-- PIPELINE $ BY STAGE (4 amount tiles) -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_pipeline_pre" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="pipeline_pre_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Pipeline · Pre-Submit</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_pipeline_submitted" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="pipeline_submitted_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Pipeline · Submitted</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_approved" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="pipeline_approved_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Pipeline · Approved</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_ready_bill" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_kpi o_fc_kpi--secondary">
|
||||
<span class="o_fc_kpi__num">
|
||||
<field name="pipeline_ready_bill_amount"
|
||||
widget="monetary" nolabel="1"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</span>
|
||||
<span class="o_fc_kpi__lbl">Pipeline · Ready to Bill</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QUICK ACTION PILLS -->
|
||||
<div class="o_fc_actions mb-3">
|
||||
<button name="action_create_adp_so" type="object"
|
||||
class="o_fc_pill">+ ADP</button>
|
||||
<button name="action_create_mod_so" type="object"
|
||||
class="o_fc_pill">+ MOD</button>
|
||||
<button name="action_create_odsp_so" type="object"
|
||||
class="o_fc_pill">+ ODSP</button>
|
||||
<button name="action_create_wsib_so" type="object"
|
||||
class="o_fc_pill">+ WSIB</button>
|
||||
<button name="action_create_insurance_so" type="object"
|
||||
class="o_fc_pill">+ Insurance</button>
|
||||
<button name="action_create_mdc_so" type="object"
|
||||
class="o_fc_pill">+ MDC</button>
|
||||
<button name="action_create_hardship_so" type="object"
|
||||
class="o_fc_pill">+ Hardship</button>
|
||||
<button name="action_create_private_so" type="object"
|
||||
class="o_fc_pill">+ Private</button>
|
||||
</div>
|
||||
|
||||
<!-- 2-COLUMN GRID -->
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card" style="border-radius: 14px; overflow: hidden;">
|
||||
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
|
||||
<field name="panel3_title" nolabel="1"/>
|
||||
|
||||
<!-- LEFT COLUMN -->
|
||||
<div class="col-12 col-lg-5">
|
||||
|
||||
<!-- Your Activities -->
|
||||
<div class="o_fc_activities mb-3">
|
||||
<h6 class="o_fc_h6">
|
||||
<i class="fa fa-thumb-tack me-2"/>
|
||||
Your Activities
|
||||
<span class="o_fc_tag">
|
||||
<field name="my_activities_count" nolabel="1"/>
|
||||
</span>
|
||||
<button name="action_open_my_activities" type="object"
|
||||
class="btn btn-link btn-sm ms-auto p-0">
|
||||
View all
|
||||
</button>
|
||||
</h6>
|
||||
<field name="my_activities_html" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<!-- Bottlenecks -->
|
||||
<div class="o_fc_bottleneck mb-3">
|
||||
<h6 class="o_fc_h6">
|
||||
<i class="fa fa-exclamation-triangle me-2"/>
|
||||
Bottlenecks
|
||||
</h6>
|
||||
<button name="action_open_bottleneck_no_pod" type="object"
|
||||
class="o_fc_bottleneck_row btn btn-link p-0">
|
||||
Approved without POD:
|
||||
<span class="fw-bold ms-1">
|
||||
<field name="bottleneck_no_pod_count" nolabel="1"/>
|
||||
</span>
|
||||
</button>
|
||||
<button name="action_open_bottleneck_no_response" type="object"
|
||||
class="o_fc_bottleneck_row btn btn-link p-0">
|
||||
Submitted > 14d, no response:
|
||||
<span class="fw-bold ms-1">
|
||||
<field name="bottleneck_no_response_count" nolabel="1"/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Aging buckets -->
|
||||
<div class="o_fc_section mb-3">
|
||||
<h6 class="o_fc_h6">
|
||||
<i class="fa fa-clock-o me-2"/>
|
||||
Aging
|
||||
</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-4">
|
||||
<button name="action_open_aging_30" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="aging_30_count" nolabel="1"/>
|
||||
</span>30 – 59d
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button name="action_open_aging_60" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile o_fc_tile--urgent">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="aging_60_count" nolabel="1"/>
|
||||
</span>60 – 89d
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button name="action_open_aging_90" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile o_fc_tile--urgent">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="aging_90_count" nolabel="1"/>
|
||||
</span>90+ d
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<field name="panel3_html" class="w-100" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<!-- Other Funders -->
|
||||
<div class="o_fc_section mb-3">
|
||||
<h6 class="o_fc_h6">Other Funders</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-4 col-xl-2">
|
||||
<button name="action_open_odsp_cases" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="count_odsp" nolabel="1"/>
|
||||
</span>ODSP
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4 col-xl-2">
|
||||
<button name="action_open_wsib_cases" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="count_wsib" nolabel="1"/>
|
||||
</span>WSIB
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4 col-xl-2">
|
||||
<button name="action_open_insurance_cases" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="count_insurance" nolabel="1"/>
|
||||
</span>Insurance
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4 col-xl-2">
|
||||
<button name="action_open_mdc_cases" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="count_mdc" nolabel="1"/>
|
||||
</span>MDC
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4 col-xl-2">
|
||||
<button name="action_open_hardship_cases" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="count_hardship" nolabel="1"/>
|
||||
</span>Hardship
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4 col-xl-2">
|
||||
<button name="action_open_acsd_cases" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="count_acsd" nolabel="1"/>
|
||||
</span>ACSD
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card" style="border-radius: 14px; overflow: hidden;">
|
||||
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">
|
||||
<field name="panel4_title" nolabel="1"/>
|
||||
|
||||
<!-- RIGHT COLUMN -->
|
||||
<div class="col-12 col-lg-7">
|
||||
|
||||
<!-- ADP Pre-Approval -->
|
||||
<div class="o_fc_section mb-3">
|
||||
<h6 class="o_fc_h6">ADP
|
||||
<span class="o_fc_tag">Pre-Approval</span>
|
||||
</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_waiting_app" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile o_fc_tile--urgent">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_waiting_app_count" nolabel="1"/>
|
||||
</span>Waiting App
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_app_received" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_app_received_count" nolabel="1"/>
|
||||
</span>App Received
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_ready_submit" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_ready_submit_count" nolabel="1"/>
|
||||
</span>Ready Submit
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_needs_correction" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile o_fc_tile--urgent">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_needs_correction_count" nolabel="1"/>
|
||||
</span>Needs Correction
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<field name="panel4_html" class="w-100" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<!-- ADP Post-Approval -->
|
||||
<div class="o_fc_section mb-3">
|
||||
<h6 class="o_fc_h6">ADP
|
||||
<span class="o_fc_tag">Post-Approval</span>
|
||||
</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_approved" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_approved_count" nolabel="1"/>
|
||||
</span>Approved
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_ready_delivery" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_ready_delivery_count" nolabel="1"/>
|
||||
</span>Ready Delivery
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_ready_bill" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_ready_bill_count" nolabel="1"/>
|
||||
</span>Ready Bill
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_adp_on_hold" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile o_fc_tile--urgent">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="adp_on_hold_count" nolabel="1"/>
|
||||
</span>On Hold
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MOD -->
|
||||
<div class="o_fc_section mb-3">
|
||||
<h6 class="o_fc_h6">MOD</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-md-2">
|
||||
<button name="action_open_mod_awaiting_funding" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="mod_awaiting_funding_count" nolabel="1"/>
|
||||
</span>Awaiting
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<button name="action_open_mod_funding_approved" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="mod_funding_approved_count" nolabel="1"/>
|
||||
</span>Approved
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-2">
|
||||
<button name="action_open_mod_pca_received" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="mod_pca_received_count" nolabel="1"/>
|
||||
</span>PCA
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_mod_project_complete" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="mod_project_complete_count" nolabel="1"/>
|
||||
</span>Proj. Done
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<button name="action_open_mod_pod_submitted" type="object"
|
||||
class="btn p-0 w-100 border-0">
|
||||
<div class="o_fc_tile">
|
||||
<span class="o_fc_tile__num">
|
||||
<field name="mod_pod_submitted_count" nolabel="1"/>
|
||||
</span>POD Submitted
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent ADP Exports (last 5) -->
|
||||
<div class="o_fc_section mb-3">
|
||||
<h6 class="o_fc_h6">
|
||||
<i class="fa fa-file-text-o me-2"/>
|
||||
Recent ADP Exports
|
||||
<span class="o_fc_tag">
|
||||
<field name="recent_exports_count" nolabel="1"/>
|
||||
</span>
|
||||
<button name="action_open_recent_exports" type="object"
|
||||
class="btn btn-link btn-sm ms-auto p-0">
|
||||
View all
|
||||
</button>
|
||||
</h6>
|
||||
<field name="recent_exports_html" nolabel="1"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
@@ -162,4 +541,13 @@
|
||||
<field name="view_id" ref="view_fusion_claims_dashboard_form"/>
|
||||
<field name="target">current</field>
|
||||
</record>
|
||||
|
||||
<!-- Dashboard Menu — top of the Fusion Claims app, sequence=1 so it
|
||||
renders before "All Orders" (sequence=2) and becomes the default
|
||||
landing when clicking the app icon. -->
|
||||
<menuitem id="menu_fusion_claims_dashboard"
|
||||
name="Dashboard"
|
||||
parent="menu_adp_claims_root"
|
||||
action="action_fusion_claims_dashboard"
|
||||
sequence="1"/>
|
||||
</odoo>
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2025 Nexa Systems Inc.
|
||||
# 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
|
||||
from markupsafe import Markup
|
||||
import logging
|
||||
|
||||
_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."""
|
||||
@@ -21,25 +30,43 @@ class ApplicationReceivedWizard(models.TransientModel):
|
||||
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_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. '
|
||||
'Not required if a remote signing request has been sent.',
|
||||
)
|
||||
signed_pages_filename = fields.Char(
|
||||
string='Pages Filename',
|
||||
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',
|
||||
@@ -47,12 +74,15 @@ class ApplicationReceivedWizard(models.TransientModel):
|
||||
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:
|
||||
@@ -70,103 +100,136 @@ class ApplicationReceivedWizard(models.TransientModel):
|
||||
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:
|
||||
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 active_id:
|
||||
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
|
||||
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:
|
||||
if wizard.original_application_filename:
|
||||
if not wizard.original_application_filename.lower().endswith('.pdf'):
|
||||
raise UserError(
|
||||
"Original Application must be a PDF file.\n"
|
||||
f"Uploaded file: '{wizard.original_application_filename}'"
|
||||
)
|
||||
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:
|
||||
if wizard.signed_pages_filename:
|
||||
if not wizard.signed_pages_filename.lower().endswith('.pdf'):
|
||||
raise UserError(
|
||||
"Signed Pages 11 & 12 must be a PDF file.\n"
|
||||
f"Uploaded file: '{wizard.signed_pages_filename}'"
|
||||
)
|
||||
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 receive application from 'Waiting for Application' status.")
|
||||
|
||||
|
||||
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.")
|
||||
|
||||
page11_covered = bool(
|
||||
self.signed_pages_11_12
|
||||
or order.x_fc_signed_pages_11_12
|
||||
or order.page11_sign_request_ids.filtered(
|
||||
lambda r: r.state in ('sent', 'signed')
|
||||
)
|
||||
)
|
||||
if not page11_covered:
|
||||
raise UserError(
|
||||
"Signed Pages 11 & 12 are required.\n\n"
|
||||
"You can either upload the file here, or use the "
|
||||
"'Request Page 11 Signature' button on the sale order "
|
||||
"to send it for remote signing before confirming."
|
||||
)
|
||||
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.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
|
||||
|
||||
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)
|
||||
|
||||
# Post to chatter
|
||||
from datetime import date
|
||||
notes_html = f'<p style="margin: 4px 0 0 0;"><strong>Notes:</strong> {self.notes}</p>' if self.notes else ''
|
||||
|
||||
order.message_post(
|
||||
body=Markup(
|
||||
'<div style="background: #e8f4fd; border-left: 4px solid #17a2b8; padding: 12px; margin: 8px 0; border-radius: 4px;">'
|
||||
'<h4 style="color: #17a2b8; margin: 0 0 8px 0;"><i class="fa fa-file-text-o"/> Application Received</h4>'
|
||||
f'<p style="margin: 0;"><strong>Date:</strong> {date.today().strftime("%B %d, %Y")}</p>'
|
||||
'<p style="margin: 8px 0 4px 0;"><strong>Documents Uploaded:</strong></p>'
|
||||
'<ul style="margin: 0; padding-left: 20px;">'
|
||||
f'<li><i class="fa fa-check text-success"/> Original ADP Application: {self.original_application_filename}</li>'
|
||||
f'<li><i class="fa fa-check text-success"/> Signed Pages 11 & 12: {self.signed_pages_filename}</li>'
|
||||
'</ul>'
|
||||
f'{notes_html}'
|
||||
'</div>'
|
||||
),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
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 the Application Received wizard."""
|
||||
"""Open the Page 11 remote signing wizard from within this wizard."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
@@ -176,3 +239,66 @@ class ApplicationReceivedWizard(models.TransientModel):
|
||||
'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'<p style="margin: 4px 0 0 0;"><strong>Notes:</strong> {self.notes}</p>'
|
||||
if self.notes else ''
|
||||
)
|
||||
body = Markup(
|
||||
'<div style="background:#e8f4fd;border-left:4px solid #17a2b8;'
|
||||
'padding:12px;margin:8px 0;border-radius:4px;">'
|
||||
'<h4 style="color:#17a2b8;margin:0 0 8px 0;">'
|
||||
'<i class="fa fa-file-text-o"/> {headline}</h4>'
|
||||
'<p style="margin:0;"><strong>Date:</strong> {today}</p>'
|
||||
'<p style="margin:8px 0 4px 0;">{detail}</p>'
|
||||
'<p style="margin:0;color:#666;">'
|
||||
'Original: {orig_name}</p>'
|
||||
'{notes}'
|
||||
'</div>'
|
||||
).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',
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Application Received Wizard Form View -->
|
||||
<record id="view_application_received_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion_claims.application.received.wizard.form</field>
|
||||
<field name="model">fusion_claims.application.received.wizard</field>
|
||||
@@ -8,64 +7,86 @@
|
||||
<form string="Application Received">
|
||||
<div class="alert alert-info mb-3" role="alert">
|
||||
<strong><i class="fa fa-info-circle"/> Upload Required Documents</strong>
|
||||
<p class="mb-0">Please upload the ADP application documents received from the client.</p>
|
||||
<p class="mb-0">
|
||||
Please upload the ADP application documents received from the client,
|
||||
then tell the system how pages 11 & 12 were provided.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<field name="sale_order_id" invisible="1"/>
|
||||
<field name="has_pending_page11_request" invisible="1"/>
|
||||
<field name="has_signed_page11" invisible="1"/>
|
||||
|
||||
<separator string="How were pages 11 & 12 provided?"/>
|
||||
<group col="1">
|
||||
<field name="intake_mode" widget="radio" nolabel="1"/>
|
||||
</group>
|
||||
|
||||
<group>
|
||||
<group string="Original ADP Application">
|
||||
<field name="original_application" filename="original_application_filename"
|
||||
<field name="original_application"
|
||||
filename="original_application_filename"
|
||||
widget="binary" class="oe_inline"/>
|
||||
<field name="original_application_filename" invisible="1"/>
|
||||
<field name="original_page_count" readonly="1"
|
||||
string="Detected pages"
|
||||
invisible="not original_application"/>
|
||||
</group>
|
||||
|
||||
<group string="Signed Pages 11 & 12">
|
||||
<field name="signed_pages_11_12" filename="signed_pages_filename"
|
||||
widget="binary" class="oe_inline"/>
|
||||
<field name="signed_pages_filename" invisible="1"/>
|
||||
|
||||
<div invisible="has_signed_page11" class="mt-2">
|
||||
<span class="text-muted small">Don't have signed pages? </span>
|
||||
<group string="Signed Pages 11 & 12"
|
||||
invisible="intake_mode != 'separate'">
|
||||
<field name="signed_pages_11_12"
|
||||
filename="signed_pages_filename"
|
||||
widget="binary" class="oe_inline"
|
||||
required="intake_mode == 'separate'"/>
|
||||
<field name="signed_pages_filename" invisible="1"/>
|
||||
</group>
|
||||
|
||||
<group string="Remote Signature"
|
||||
invisible="intake_mode != 'remote'">
|
||||
<div invisible="has_pending_page11_request or has_signed_page11"
|
||||
class="mt-2">
|
||||
<span class="text-muted small">
|
||||
Don't have signed pages? Send a remote signing link to a family
|
||||
member or agent.
|
||||
</span>
|
||||
<button name="action_request_page11_signature" type="object"
|
||||
string="Request Remote Signature"
|
||||
class="btn btn-sm btn-outline-warning"
|
||||
icon="fa-pencil-square-o"
|
||||
help="Send Page 11 to a family member or agent for digital signing"/>
|
||||
icon="fa-pencil-square-o"/>
|
||||
</div>
|
||||
|
||||
<div invisible="not has_pending_page11_request" class="mt-2">
|
||||
<div class="alert alert-warning mb-0 py-2 px-3">
|
||||
<i class="fa fa-clock-o"/> A remote signing request has been sent.
|
||||
You can proceed without uploading signed pages -- they will be auto-filled when signed.
|
||||
<i class="fa fa-clock-o"/>
|
||||
A remote signing request has been sent. You can confirm now -
|
||||
the signed PDF will be auto-attached when received.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div invisible="not has_signed_page11 or signed_pages_11_12" class="mt-2">
|
||||
<div invisible="not has_signed_page11" class="mt-2">
|
||||
<div class="alert alert-success mb-0 py-2 px-3">
|
||||
<i class="fa fa-check-circle"/> Page 11 has been signed remotely.
|
||||
<i class="fa fa-check-circle"/>
|
||||
Page 11 has been signed remotely.
|
||||
</div>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
|
||||
<group>
|
||||
<field name="notes" placeholder="Any notes about the received application..."/>
|
||||
</group>
|
||||
|
||||
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm Application Received" class="btn-primary"
|
||||
icon="fa-check"/>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm Application Received"
|
||||
class="btn-primary" icon="fa-check"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action for the wizard -->
|
||||
<record id="action_application_received_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Application Received</field>
|
||||
<field name="res_model">fusion_claims.application.received.wizard</field>
|
||||
|
||||
@@ -78,7 +78,7 @@ class CaseCloseVerificationWizard(models.TransientModel):
|
||||
def _compute_document_status(self):
|
||||
for wizard in self:
|
||||
order = wizard.sale_order_id
|
||||
wizard.has_signed_pages = bool(order.x_fc_signed_pages_11_12)
|
||||
wizard.has_signed_pages = order.x_fc_has_signed_pages_11_12
|
||||
wizard.has_final_application = bool(order.x_fc_final_submitted_application)
|
||||
wizard.has_proof_of_delivery = bool(order.x_fc_proof_of_delivery)
|
||||
wizard.has_vendor_bills = len(order.x_fc_vendor_bill_ids) > 0
|
||||
|
||||
@@ -92,7 +92,7 @@ class ReadyForSubmissionWizard(models.TransientModel):
|
||||
wizard.has_authorization_date = bool(order.x_fc_claim_authorization_date)
|
||||
wizard.has_client_refs = bool(order.x_fc_client_ref_1 and order.x_fc_client_ref_2)
|
||||
wizard.has_reason = bool(order.x_fc_reason_for_application)
|
||||
wizard.has_documents = bool(order.x_fc_original_application and order.x_fc_signed_pages_11_12)
|
||||
wizard.has_documents = bool(order.x_fc_original_application and order.x_fc_has_signed_pages_11_12)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
@@ -145,7 +145,7 @@ class ReadyForSubmissionWizard(models.TransientModel):
|
||||
# Check documents
|
||||
if not order.x_fc_original_application:
|
||||
missing.append('Original ADP Application (upload in Application Received step)')
|
||||
if not order.x_fc_signed_pages_11_12:
|
||||
if not order.x_fc_has_signed_pages_11_12:
|
||||
missing.append('Page 11 & 12 Signed (upload in Application Received step)')
|
||||
|
||||
if missing:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{
|
||||
"name": "Fusion PDF Preview",
|
||||
"version": "19.0.2.0.0",
|
||||
"version": "19.0.2.1.0",
|
||||
"depends": ["web"],
|
||||
"author": "Nexa Systems Inc",
|
||||
"category": "web",
|
||||
@@ -41,6 +41,7 @@ Key Features:
|
||||
"assets": {
|
||||
"web.assets_backend": [
|
||||
"fusion_pdf_preview/static/src/js/pdf_preview.js",
|
||||
"fusion_pdf_preview/static/src/js/open_attachment_action.js",
|
||||
"fusion_pdf_preview/static/src/js/user_menu.js",
|
||||
"fusion_pdf_preview/static/src/xml/pdf_viewer_dialog.xml",
|
||||
],
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
from . import res_users
|
||||
from . import ir_http
|
||||
from . import ir_actions_report
|
||||
from . import ir_attachment
|
||||
from . import res_config_settings
|
||||
from . import preview_log
|
||||
|
||||
48
fusion_pdf_preview/models/ir_attachment.py
Normal file
48
fusion_pdf_preview/models/ir_attachment.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class IrAttachment(models.Model):
|
||||
_inherit = "ir.attachment"
|
||||
|
||||
def action_fusion_preview(self, title=None, model_name=None, record_ids=None):
|
||||
"""Return the right action to "view" this attachment.
|
||||
|
||||
- PDF attachments → fusion_pdf_preview's client dialog (preview
|
||||
+ print + download all in one place, with audit log).
|
||||
- Anything else (ZPL, CSV, XML, images, etc.) → legacy
|
||||
new-tab/download URL since the PDF viewer can't render them.
|
||||
|
||||
Drop-in replacement for the common pattern:
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=true' % att.id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
Use as: return att.action_fusion_preview(title='My Doc')
|
||||
|
||||
See CLAUDE.md "PDF Preview" for the full contract.
|
||||
"""
|
||||
self.ensure_one()
|
||||
is_pdf = (
|
||||
(self.mimetype or '').lower() == 'application/pdf'
|
||||
or (self.name or '').lower().endswith('.pdf')
|
||||
)
|
||||
if is_pdf:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_pdf_preview.open_attachment',
|
||||
'params': {
|
||||
'attachment_id': self.id,
|
||||
'title': title or self.name or 'Document',
|
||||
'model_name': model_name or '',
|
||||
'record_ids': str(record_ids) if record_ids else '',
|
||||
},
|
||||
}
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=true' % self.id,
|
||||
'target': 'new',
|
||||
}
|
||||
42
fusion_pdf_preview/static/src/js/open_attachment_action.js
Normal file
42
fusion_pdf_preview/static/src/js/open_attachment_action.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { openPDFViewer } from "./pdf_preview";
|
||||
|
||||
/**
|
||||
* Client action: open an ir.attachment in the PDF preview dialog.
|
||||
*
|
||||
* Python callers return:
|
||||
* {
|
||||
* 'type': 'ir.actions.client',
|
||||
* 'tag': 'fusion_pdf_preview.open_attachment',
|
||||
* 'params': {
|
||||
* 'attachment_id': <int>,
|
||||
* 'title': <str>, // optional, defaults to "Document"
|
||||
* 'model_name': <str>, // optional, for audit log
|
||||
* 'record_ids': <str>, // optional, comma-sep for audit log
|
||||
* 'report_name': <str>, // optional, for audit log
|
||||
* },
|
||||
* }
|
||||
*
|
||||
* Non-PDF attachments fall back to opening in a new browser tab — the
|
||||
* preview dialog only renders PDF. ZPL labels (text/plain) should NOT
|
||||
* use this action; route those through the direct download act_url.
|
||||
*/
|
||||
registry.category("actions").add(
|
||||
"fusion_pdf_preview.open_attachment",
|
||||
async (env, action) => {
|
||||
const params = action.params || {};
|
||||
const attachmentId = params.attachment_id;
|
||||
if (!attachmentId) {
|
||||
return;
|
||||
}
|
||||
const title = params.title || "Document";
|
||||
const url = `/web/content/${attachmentId}`;
|
||||
openPDFViewer(env, url, title, {
|
||||
modelName: params.model_name || "",
|
||||
recordIds: params.record_ids || "",
|
||||
reportName: params.report_name || "",
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -27,6 +27,22 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
|
||||
| **Signature unification** | All FP reports (WO Detail, CoC, CoC Chronological) now read signatures from a single source: `signer_user.x_fc_signature_image` (Plating Signature). Retired: HR Employee signature lookup AND `res.company.x_fc_coc_signature_override` (UI removed; column kept, no migration). See rule 14b. | `fusion_plating_certificates`, `fusion_plating_reports`, `fusion_plating_jobs` |
|
||||
| **Report palette overhaul** | Green `res.company.primary_color` → hardcoded neutral palette: `#c1c1c1` header backgrounds, `#1d1f1e` th text, `#2e2e2e` h2/h4 titles (bumped to 20pt portrait / 22pt landscape). Grand Total row also `#c1c1c1`. Work Order Detail blue `#1a4d80` retired in favour of the same palette. Title format now "Type # Number" (Quotation # …, Sales Order # …, Invoice # …, Packing Slip # …, Work Order Traveller # …). See rule 14a. | `fusion_plating_reports` 19.0.11.14.0, `fusion_plating_jobs` 19.0.10.8.0 |
|
||||
| **Report border rendering** | After two failed attempts (px→mm conversion + dpi bump; then `border-collapse: separate` single-side-per-cell), settled on **`border-collapse: collapse` + longhand borders + `background-clip: padding-box`**. Verticals are a hair softer than horizontals on entech wkhtmltopdf — accepted as the lesser evil vs misaligned tables. See rule 14a, last paragraph. **Don't retry the single-side pattern.** | `fusion_plating_reports` |
|
||||
| **Page-break-inside: avoid placement** | When a long QWeb report dumps content into multi-page PDFs via wkhtmltopdf, the company header (rendered as `--header-html`) can overlap body content if a page break lands mid-row in a table. **Apply `page-break-inside: avoid` to `<tr>` elements** (and to wrapper `<div>`s that wrap whole logical sections like signature blocks), not to `<table>`. On entech wkhtmltopdf, `<table>`-level `page-break-inside` is unreliable when the table is long enough to definitely break; per-row is honoured. Pattern: keep individual readings/rows together so the wkhtmltopdf header zone never overlaps mid-row content. Wrap the larger logical block (cert thickness section, signature + certification statement) in `<div style="page-break-inside: avoid;">` to keep it together when it fits and naturally wrap to a fresh page when it doesn't. | `fusion_plating_reports/report/report_coc.xml` |
|
||||
| **`opacity` + `italic` muted text renders jagged on entech wkhtmltopdf** | The obvious pattern for a subtle footnote — `font-style: italic; opacity: 0.7;` (used by `.fp-coc .small-label`) — produces washed-out, jagged characters that look "broken" or "messed up" on the printed PDF. Visually it reads as garbled text even though the source is clean. **Use solid grey (`color: #555`) at normal weight instead** for muted secondary text. Same workaround applies to any `opacity`-driven greyed-out element bound for wkhtmltopdf. The existing `.small-label` class still exists for legacy callers but new code should prefer an explicit `color:` style. | `fusion_plating_reports` |
|
||||
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix:** size `paperformat.margin_top` to the actual rendered header height, then drop body `padding-top` to a tiny visual gap (~5mm). Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12). Update the right one and don't bleed changes across reports. | `fusion_plating_reports`, `report.paperformat` |
|
||||
| **CoC + thickness = ONE cert (page 2 merge OR inline body)** | When a customer has both `x_fc_send_coc` and `x_fc_send_thickness_report` on (or part has `certificate_requirement='coc_thickness'`), `_resolve_required_cert_types` returns **`{'coc'}` only**. Standalone `thickness_report` certs are only created when CoC is OFF and thickness is ON (rare). The earlier "two certs" behavior was a bug — don't restore it. **Two rendering paths exist for the thickness data in the CoC PDF:** (1) **Page-2 PDF merge** via `_fp_merge_thickness_into_pdf` — used when there's a real PDF source (operator uploaded a Fischerscope PDF, or QC has `thickness_report_pdf_id`). (2) **Inline readings table in the CoC body** — used when `thickness_reading_ids` is populated but there's no PDF source (e.g. RTF upload parsed to readings, manually typed readings). Lives in `report_coc.xml` between the parts table and the signature block, gated on `doc.thickness_reading_ids`. Both can coexist on a cert — PDF merges as page 2, readings render inline; usually only one path has data per cert. | `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports` |
|
||||
| **Smart-button "create or view" pattern** | For a smart button that toggles between "create" and "view" states, use **one** idempotent button with `widget="statinfo"`, not two sibling buttons gated by mutually-exclusive `invisible` expressions. Custom `<div class="o_stat_info">` without `<span class="o_stat_value">` renders awkwardly in Odoo 19 (numbers + label expected); `statinfo` handles the standard structure automatically. The action method itself should branch on whether the linked record exists (create-then-open or just open). | any module with smart buttons |
|
||||
| **stock.move.name removed** | Odoo 19 dropped the `name` field on `stock.move`. Passing `name` in a create dict raises `ValueError: Invalid field 'name' on model 'stock.move'`. Use `description_picking` instead (the operator-facing line label on the picking). The DB column is gone too — `name` doesn't exist as a stored field. | any code that builds stock.move records |
|
||||
| **Recordsets use `__slots__` — no transient attrs** | Odoo 19's `BaseModel` declares `__slots__ = ['env', '_ids', '_prefetch_ids']`, so `picking._my_stash = data` raises `AttributeError: 'stock.picking' object has no attribute '_my_stash'`. The error reads like a missing field but it's actually Python rejecting the assignment. Don't stash transient state on a recordset between method calls — pass it as a method arg, store on the caller's `self`, or use `env.context` for cross-frame plumbing. Caught here because `fp_receiving._fp_build_shipping_picking` tried to attach `_fp_outbound_packages` to the picking before handing off to `_fp_apply_shipping_result`; the catch-all `except Exception` swallowed it and surfaced the misleading "Carrier API call failed" wizard. | any code that wants to attach data to a recordset between calls |
|
||||
| **labelary.com dependency for ZPL→PDF** | `fusion_plating_receiving` POSTs ZPL labels to `https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/` to get a PDF rasterization, so one FedEx ship call can populate both the PDF and ZPL smart buttons on the receiving form. **Privacy:** every outbound label's shipping address + tracking number leaves the network and hits labelary's servers (no payment data, but real customer info). **Operational:** anonymous tier is ~5 req/s; add an API key in the labelary helper if you ever ship more than that. PDF→ZPL is intentionally not attempted — that direction is impractical and FedEx's `/ship` endpoint only returns one format per shipment, so the carrier MUST be configured for ZPLII (not PDF) for the dual-format flow to work. Switching the carrier back to PDF will silently drop the ZPL button. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
|
||||
| **FedEx ZPL ships with `^POI` — strip it** | FedEx's REST `/ship` endpoint returns ZPL with `^POI` (Print Orientation = Invert) baked in, which flips the label 180° on the printer. On a desktop direct-thermal like the Zebra ZD450 that prints upside-down for the operator, and labelary mirrors the inversion in the PDF preview. `_fp_apply_shipping_result` creates a `*-fixed.zpl` copy of the FedEx attachment with `^POI` removed and points the shipment + smart buttons at the cleaned copy; the original FedEx ZPL stays on the picking for audit. **Don't restore `^POI`** — both the PDF preview and the Zebra output need it stripped. If a future printer needs inverted orientation, configure the printer driver instead of putting `^POI` back. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
|
||||
| **Per-shipment service override via `fp_service_type_override` context key** | Operator picks a FedEx service tier on `fp.receiving.x_fc_outbound_service_type` (Priority Overnight, 2Day, Ground, etc.). `action_generate_outbound_label` passes the chosen code through to `carrier.send_shipping` via `with_context(fp_service_type_override=…)`. `fusion_shipping.fusion_fedex_rest_send_shipping` reads the context key and overrides `srm.service_type` for that call only — carrier default is untouched. Empty/blank override falls back to `carrier.fedex_rest_service_type`. Only FedEx is wired up right now; mirroring this for Canada Post / UPS is a separate task. | `fusion_plating_receiving/models/fp_receiving.py` → `fusion_shipping/models/delivery_carrier.py` |
|
||||
| **`mail.template.body_html` is `Markup` + jsonb** | Two gotchas: (1) `tpl.body_html` returns a `markupsafe.Markup` object. `Markup.replace(old, new)` *escapes both args* — quotes in `old` become `'` so the literal pre-escape string never matches. **Cast to `str(tpl.body_html)` before calling `.replace`**. (2) The DB column is `jsonb` (translatable). Direct `UPDATE ... SET body_html = '...'` SQL fails with `invalid input syntax for type json`; either use ORM `tpl.write({'body_html': ...})` or wrap raw SQL with `jsonb_build_object('en_US', ...)`. (3) Mail-template XML data files typically use `<odoo noupdate="1">` so `-u <module>` does NOT reload them — users can edit templates in the UI and the module won't overwrite. To sync XML edits to existing records: temporarily flip the wrapper to `<odoo noupdate="0">`, redeploy and `-u`, then revert (and `UPDATE ir_model_data SET noupdate=true ...` to restore protection). Alternatively, post-migration script or odoo shell write. (4) **`mail.template.report_name` was removed in Odoo 19** — the dynamic PDF-filename field now lives on `ir.actions.report.print_report_name` instead. Old `<field name="report_name">` entries in mail-template data files silently survive while protected by noupdate=1, but the moment you force-reload they error with `Invalid field 'report_name' in 'mail.template'`. Strip them or move the expression to the report action. | any code scripting `mail.template.body_html` |
|
||||
| **`message_post(body=...)` HTML-escapes by default** | A plain `str` body with `<b>` tags renders as literal `<b>foo</b>` text in chatter — operators see angle brackets, not bold. Wrap the template in `Markup(_('... <b>%s</b> ...'))` and use `%`/`format_map` for substitutions; markupsafe escapes the substituted values automatically so user input still can't inject HTML. Pattern: `self.message_post(body=Markup(_('Tracking: <b>%s</b>')) % tracking)`. | any model posting HTML-formatted chatter |
|
||||
| **OWL `t-out` escapes plain JS strings — wrap with `markup()`** | The JS-side analogue of the `message_post` markup gotcha. `t-out="state.html"` only renders unescaped HTML when the value is a `markup()`-tagged string from `@odoo/owl`; a plain string (e.g. straight off an RPC response) gets HTML-escaped and the user sees literal `<p>foo</p>` text. Caught here because `fp_record_inputs_dialog.js` was assigning `this.state.instructionsHtml = data.instructions_html` raw — recipe author's `<p>...</p>` rendered as visible tags in the operator dialog. **Fix:** `import { markup } from "@odoo/owl"` and wrap RPC-returned HTML: `this.state.html = markup(data.html || "")`. Same rule for any OWL component that ingests HTML from the server and pushes it through `t-out`. | any OWL component rendering server-returned HTML via `t-out` |
|
||||
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
|
||||
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
|
||||
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
|
||||
|
||||
### Pending — IN PROGRESS when this session ended
|
||||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# Certificate Creation Timing + Data Completeness Gates
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Triggering incident:** WO-30040 marked done with no CoC produced — chatter showed `Cert auto-create (coc) failed: name 'coating' is not defined` (regression in `fusion_plating_jobs/models/fp_job.py:1706` where `coating` was referenced but never bound).
|
||||
|
||||
## Goal
|
||||
|
||||
Two things, decided as one unit of work:
|
||||
|
||||
1. **Fix the broken cert-creation path** so jobs marked done always produce the expected draft certs.
|
||||
2. **Harden the data-completeness gates** so a CoC cannot be issued with missing critical information.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Redesigning the cert lifecycle timing (kept at `button_mark_done()`).
|
||||
- Wizard-based "Issue CoC" flow (Approach C, rejected).
|
||||
- SO-confirm cert-stub flow (Approach B, rejected).
|
||||
- Email delivery refactor — issuance still triggers existing `fp.notification.template` dispatch.
|
||||
|
||||
## Decisions reached
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Cert creation stays at `fp.job.button_mark_done()` | All upstream data should be settled by then; existing architecture is sound — only the bug masks that. |
|
||||
| D2 | Receiving must close before job-done | qty_received blank or unreconciled blocks `button_mark_done`. Guarantees the cert always points to a closed receiving. |
|
||||
| D3 | Strict qty accounting | `qty_received ≡ qty_done + qty_scrapped + qty_visual_inspection_rejects`. NC qty on cert = `qty_scrapped + qty_visual_inspection_rejects`. |
|
||||
| D4 | Per-company default signer | New `res.company.x_fc_default_coc_signer_id`. Customer-spec signer_user_id wins if set. |
|
||||
| D5 | Per-partner default CoC contact | New `res.partner.x_fc_default_coc_contact_id`. Sales sets it once per customer. |
|
||||
| D6 | Mandatory fields at `action_issue()` | spec_reference (existing), process_description, certified_by_id, contact_partner_id with valid email, qty reconciliation. |
|
||||
| D7 | Backfill action for closed jobs missing certs | One-shot server action — walks `state='done'` jobs whose `_resolve_required_cert_types()` is non-empty and have no matching cert; calls `_fp_create_certificates()`. |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─ JOB EXECUTION ─────────────────────────────────────────────────┐
|
||||
│ Steps run → Bake → QC → Receiving closed │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ button_mark_done() [HARDENED GATE] │
|
||||
│ existing checks PLUS: │
|
||||
│ qty_received present AND │
|
||||
│ qty_received ≡ qty_done + qty_scrapped + qty_rejects │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ _fp_create_certificates() (bug fixed + richer prefill) │
|
||||
│ Resolved sources: │
|
||||
│ process_description ← job.recipe_id.name │
|
||||
│ certified_by_id ← customer_spec.signer_user_id │
|
||||
│ OR company.x_fc_default_coc_signer_id│
|
||||
│ contact_partner_id ← partner.x_fc_default_coc_contact_id │
|
||||
│ nc_quantity ← qty_scrapped + qty_visual_rejects │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Draft cert(s) — milestone advances to "Issue Certs" │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ ISSUANCE ──────────────────────────────────────────────────────┐
|
||||
│ Manager opens cert → action_issue() [HARDENED GATE] │
|
||||
│ existing checks PLUS: │
|
||||
│ process_description present │
|
||||
│ certified_by_id present │
|
||||
│ contact_partner_id present, with email │
|
||||
│ qty reconciliation (belt-and-suspenders vs Gate 1) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ state → issued, PDF generated, attached │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Schema changes (additive)
|
||||
|
||||
| Model | New field | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `res.company` | `x_fc_default_coc_signer_id` | M2O `res.users` | Default signing authority. Set once per facility. |
|
||||
| `res.partner` | `x_fc_default_coc_contact_id` | M2O `res.partner` (children of self) | Sales sets per customer. |
|
||||
|
||||
Both are additive — no data migration needed.
|
||||
|
||||
## Module changes
|
||||
|
||||
| Module | Version bump | Files |
|
||||
|---|---|---|
|
||||
| `fusion_plating` | 19.0.20.1.0 → 19.0.20.2.0 | `models/res_company.py`, `views/res_company_views.xml` (or settings view) |
|
||||
| `fusion_plating_certificates` | 19.0.6.1.0 → 19.0.6.2.0 | `models/res_partner.py`, `models/fp_certificate.py`, `views/res_partner_views.xml` |
|
||||
| `fusion_plating_jobs` | 19.0.10.8.0 → 19.0.10.9.0 | `models/fp_job.py` (mark_done gate + cert prefill bug fix + backfill action) |
|
||||
|
||||
## Gate logic — `button_mark_done()`
|
||||
|
||||
Inside the existing `if not skip_qty_gate and job.qty:` block, add:
|
||||
|
||||
```python
|
||||
if not job.qty_received:
|
||||
raise UserError(_(
|
||||
"Job %s cannot be marked Done — Quantity Received is blank. "
|
||||
"Close the receiving record for SO %s before completing this job."
|
||||
) % (job.name, job.sale_order_id.name if job.sale_order_id else '?'))
|
||||
|
||||
accounted_out = (job.qty_done or 0) + (job.qty_scrapped or 0) \
|
||||
+ (job.qty_visual_inspection_rejects or 0)
|
||||
if abs(job.qty_received - accounted_out) > 0.0001:
|
||||
raise UserError(_(
|
||||
"Job %s qty mismatch — received %g, but qty_done (%g) + "
|
||||
"qty_scrapped (%g) + visual rejects (%g) = %g. "
|
||||
"Reconcile before closing."
|
||||
) % (job.name, job.qty_received, job.qty_done or 0,
|
||||
job.qty_scrapped or 0, job.qty_visual_inspection_rejects or 0,
|
||||
accounted_out))
|
||||
```
|
||||
|
||||
Manager bypass: existing `fp_skip_qty_reconcile=True` context covers both.
|
||||
|
||||
## Cert prefill table (`_fp_create_certificates`)
|
||||
|
||||
| Cert field | Source |
|
||||
|---|---|
|
||||
| partner_id | `job.partner_id` (existing) |
|
||||
| sale_order_id | `job.sale_order_id` (existing) |
|
||||
| x_fc_job_id | `job.id` (existing) |
|
||||
| certificate_type | `_resolve_required_cert_types()` (existing) |
|
||||
| part_number | `job.part_catalog_id.part_number` (existing) |
|
||||
| entech_wo_number | `job.name` (existing) |
|
||||
| po_number | `job.sale_order_id.x_fc_po_number` (existing) |
|
||||
| customer_job_no | `job.sale_order_id.x_fc_customer_job_number` (existing) |
|
||||
| spec_reference | from `customer_spec.code [+ " Rev " + revision]` (existing) |
|
||||
| customer_spec_id | `job.customer_spec_id` (existing) |
|
||||
| quantity_shipped | `qty_done - qty_scrapped` (existing) |
|
||||
| **nc_quantity** | **`qty_scrapped + qty_visual_inspection_rejects`** (NEW) |
|
||||
| **process_description** | **`job.recipe_id.name`** (NEW; was broken — `coating` was undefined) |
|
||||
| **certified_by_id** | **`customer_spec.signer_user_id` OR `company.x_fc_default_coc_signer_id`** (NEW) |
|
||||
| **contact_partner_id** | **`partner.x_fc_default_coc_contact_id`** (NEW) |
|
||||
|
||||
## Gate logic — `action_issue()` (added in sequence before `state = 'issued'`)
|
||||
|
||||
1. **process_description present** — raise with hint to set coating-config / fill manually.
|
||||
2. **certified_by_id present** — raise with hint to set company default.
|
||||
3. **contact_partner_id present AND `email` non-empty** — raise with specific hint.
|
||||
4. **qty reconciliation** — defensive; reads `x_fc_job_id` if linked.
|
||||
|
||||
Order: cheapest checks first; first failure wins.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Job has no recipe_id | `process_description = False` → action_issue blocks → manager fills manually. |
|
||||
| Company has no default signer | `certified_by_id` blank → action_issue blocks. |
|
||||
| Partner has no default contact | `contact_partner_id` blank → action_issue blocks. |
|
||||
| Contact has no email | Action_issue blocks specifically on email. |
|
||||
| Customer-spec overrides company signer | `customer_spec.signer_user_id` wins (already used by signature unification). |
|
||||
| Multi-line SO with different recipes | First line with a recipe wins for process_description; manager can override. |
|
||||
| Re-running `_fp_create_certificates` | Idempotent by (job_id, certificate_type); NEW fields only set on initial create. |
|
||||
| Older jobs with NULL `qty_visual_inspection_rejects` | Coerce to 0; no migration needed. |
|
||||
| Receiving never existed (internal rework) | Mark_done blocks; manager bypass via `fp_skip_qty_reconcile=True`. |
|
||||
|
||||
## Backwards compatibility
|
||||
|
||||
- WO-30040 itself (already `done`, no cert) is not auto-fixed by this change.
|
||||
- New server action **"Generate missing certs for closed jobs"** walks `fp.job` records where `state='done'` AND `_resolve_required_cert_types()` is non-empty AND no matching cert exists. Surfaced in the Jobs menu so the user can run once after deploy.
|
||||
|
||||
## Test plan
|
||||
|
||||
**Unit tests** (in `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` and new `fusion_plating_certificates/tests/test_action_issue_gates.py`):
|
||||
|
||||
- `test_mark_done_blocks_on_blank_qty_received`
|
||||
- `test_mark_done_blocks_on_qty_received_mismatch`
|
||||
- `test_mark_done_passes_with_clean_qty_reconcile`
|
||||
- `test_mark_done_bypass_skips_qty_received_check`
|
||||
- `test_create_cert_resolves_recipe_name` (replaces "coating" wording)
|
||||
- `test_create_cert_handles_job_with_no_recipe`
|
||||
- `test_create_cert_prefills_signer_from_company`
|
||||
- `test_create_cert_prefills_signer_from_customer_spec`
|
||||
- `test_create_cert_prefills_contact_from_partner`
|
||||
- `test_create_cert_computes_nc_quantity`
|
||||
- `test_create_cert_handles_null_visual_rejects`
|
||||
- `test_action_issue_blocks_on_missing_process_description`
|
||||
- `test_action_issue_blocks_on_missing_certified_by`
|
||||
- `test_action_issue_blocks_on_missing_contact`
|
||||
- `test_action_issue_blocks_on_contact_without_email`
|
||||
- `test_action_issue_blocks_on_qty_mismatch`
|
||||
- `test_action_issue_passes_when_all_data_present`
|
||||
- `test_create_cert_idempotency`
|
||||
|
||||
**Manual verification on entech (post-deploy):**
|
||||
|
||||
1. Run "Generate missing certs for closed jobs" → confirm WO-30040 gets 2 draft certs.
|
||||
2. Try `action_issue` → expect blockers for unset defaults.
|
||||
3. Configure defaults; retry → cert issues, PDF renders, attaches.
|
||||
|
||||
## Deployment
|
||||
|
||||
- Push to `K:/Github/Odoo-Modules/fusion_plating/` (git path).
|
||||
- Mirror to docker mount as needed.
|
||||
- Update on entech LXC 111 via the deploy commands in `project_entech_session_handoff.md`.
|
||||
- Module install order: `fusion_plating` → `fusion_plating_certificates` → `fusion_plating_jobs`.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Phase A — Shipping Carrier Foundation
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Project:** Full shipping integration (Phases A–F). This spec covers Phase A only — the field-level foundation linking plating records to `fusion_shipping`'s existing shipment infrastructure.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the free-text `carrier_name` on `fp.receiving` with a proper M2O to `delivery.carrier`, and link both `fp.receiving` and `fp.delivery` to the `fusion.shipment` model that already exists in `fusion_shipping`. After Phase A, the receiver can pick a carrier from a 15-option dropdown and create a draft outbound shipment record — wiring is in place for Phase B (manual label entry) and Phase E (auto-label generation at receiving time).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Weight, dimensions, label PDF, tracking number on `fp.receiving` / `fp.delivery` themselves — these live on the linked `fusion.shipment` record (already implemented by `fusion_shipping`).
|
||||
- Bridge module (Phase C), Purolator integration (Phase D), at-receiving auto-label (Phase E), printer hookup (Phase F).
|
||||
- Modifying `fusion_shipping`'s existing models — Phase A is additive on the plating side only.
|
||||
|
||||
## Decisions reached
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Carrier field: M2O to `delivery.carrier` (not Selection) | Matches `fusion_shipping`'s framework; allows API integration on the same record without conversion. |
|
||||
| D2 | Architecture: mirror fields on both `fp.receiving` and `fp.delivery`, auto-sync at delivery creation | Self-contained records; loose coupling; shipping crew can override per-stage. |
|
||||
| D3 | Source of truth for weight / dims / label / tracking: `fusion.shipment`, NOT mirrored on plating records | Shipment model already has every field; avoid duplicating + the sync logic. |
|
||||
| D4 | 15 carriers seeded as `delivery.carrier` data records (XML), all `delivery_type='fixed'` initially | Phase D will flip Purolator (and any others added) to their integration types. Manual carriers (Customer Pickup etc.) stay `fixed` permanently. |
|
||||
| D5 | Existing `carrier_name` (Char) and `carrier_tracking` (Char) kept as legacy | Migration populates `x_fc_carrier_id` by name match; unmatched text stays for operator review. |
|
||||
| D6 | `fusion_plating_receiving` + `fusion_plating_logistics` gain hard `fusion_shipping` dependency | The M2O to `fusion.shipment` requires the model to exist; no conditional compilation. |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─ fp.receiving ─────────────────────────────────────────────────────┐
|
||||
│ NEW: x_fc_carrier_id M2O delivery.carrier │
|
||||
│ NEW: x_fc_outbound_shipment_id M2O fusion.shipment │
|
||||
│ NEW: x_fc_outbound_shipment_count Integer (smart-button counter) │
|
||||
│ Existing: carrier_name (Char) — legacy, populated by migration │
|
||||
│ Existing: carrier_tracking (Char) — legacy │
|
||||
│ │
|
||||
│ ACTION: action_create_outbound_shipment() │
|
||||
│ → creates fusion.shipment with sale_order_id + carrier_id │
|
||||
│ → idempotent: returns existing if already linked │
|
||||
│ ACTION: action_view_outbound_shipment() │
|
||||
│ → opens linked fusion.shipment in form view │
|
||||
│ ONCHANGE: x_fc_carrier_id propagates to linked shipment │
|
||||
│ → only if shipment.status == 'draft' │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
copy at delivery creation (fp.job._fp_create_delivery)
|
||||
│
|
||||
▼
|
||||
|
||||
┌─ fp.delivery ──────────────────────────────────────────────────────┐
|
||||
│ NEW: x_fc_carrier_id M2O delivery.carrier │
|
||||
│ NEW: x_fc_outbound_shipment_id M2O fusion.shipment │
|
||||
│ NEW: x_fc_outbound_shipment_count Integer │
|
||||
│ Same ACTIONs and propagation as fp.receiving │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ delivery.carrier (seed data) ─────────────────────────────────────┐
|
||||
│ Already on entech: Standard delivery, Canada Post, Customer Pickup│
|
||||
│ Phase A adds (delivery_type='fixed', product_id=delivery. │
|
||||
│ product_product_delivery): │
|
||||
│ UPS, FedEx, USPS, DHL, Purolator, CCT, Canpar Express, │
|
||||
│ GLS Canada, Loomis Express, Day & Ross, Dicom Transportation, │
|
||||
│ Customer Drop-off, Local Delivery │
|
||||
│ Idempotent: XML uses noupdate=1 + record ids check existing names │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Field details
|
||||
|
||||
**On `fp.receiving`:**
|
||||
```python
|
||||
x_fc_carrier_id = fields.Many2one(
|
||||
'delivery.carrier', string='Outbound Carrier', tracking=True,
|
||||
ondelete='set null',
|
||||
help='Who picks up the parts when work is done. Used to generate '
|
||||
'the return shipping label on the linked Outbound Shipment.',
|
||||
)
|
||||
x_fc_outbound_shipment_id = fields.Many2one(
|
||||
'fusion.shipment', string='Outbound Shipment', tracking=True,
|
||||
ondelete='set null',
|
||||
help='The shipment record carrying weight, dimensions, label PDF, '
|
||||
'and tracking. Created via the "Create Outbound Shipment" '
|
||||
'button.',
|
||||
)
|
||||
x_fc_outbound_shipment_count = fields.Integer(
|
||||
compute='_compute_x_fc_outbound_shipment_count',
|
||||
)
|
||||
```
|
||||
|
||||
Identical pair on `fp.delivery`.
|
||||
|
||||
## Module changes
|
||||
|
||||
| Module | Bump | Files |
|
||||
|---|---|---|
|
||||
| `fusion_plating_receiving` | 19.0.3.9.0 → 19.0.3.10.0 | manifest (+depends), `models/fp_receiving.py`, `views/fp_receiving_views.xml`, `data/delivery_carrier_seed_data.xml` (NEW), `migrations/19.0.3.10.0/post-migrate.py` (NEW), `tests/test_carrier_fields.py` (NEW) |
|
||||
| `fusion_plating_logistics` | bump | manifest (+depends), `models/fp_delivery.py`, `views/fp_delivery_views.xml`, `tests/test_delivery_shipping_fields.py` (NEW) |
|
||||
| `fusion_plating_jobs` | bump | `models/fp_job.py` (mirror at `_fp_create_delivery`), extend existing milestone-cascade test class |
|
||||
|
||||
## Migration logic (post-migrate)
|
||||
|
||||
```python
|
||||
def migrate(cr, version):
|
||||
# Name-match existing carrier_name text → delivery.carrier.name
|
||||
cr.execute("""
|
||||
UPDATE fp_receiving r
|
||||
SET x_fc_carrier_id = dc.id
|
||||
FROM delivery_carrier dc
|
||||
WHERE r.carrier_name IS NOT NULL
|
||||
AND r.carrier_name <> ''
|
||||
AND r.x_fc_carrier_id IS NULL
|
||||
AND LOWER(TRIM(r.carrier_name)) =
|
||||
LOWER(TRIM((dc.name->>'en_US')))
|
||||
""")
|
||||
```
|
||||
|
||||
`delivery.carrier.name` is jsonb in Odoo 19 (translatable). The migration strips to `en_US` for the match.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Receiving has no SO link | Shipment creation works without `sale_order_id` (set_null on shipment side). |
|
||||
| Carrier picked but no shipment yet | Smart button reads "Create Outbound Shipment" → one click creates + opens. |
|
||||
| User changes carrier on receiving after shipment exists | Onchange propagates only when `shipment.status == 'draft'`. Confirmed/shipped shipments are left alone. |
|
||||
| Two receivings on same SO (split deliveries) | Each has its own `x_fc_outbound_shipment_id`. Mirror picks first one; user can change. |
|
||||
| Migration finds ambiguous name | Case-insensitive exact match only. Unmatched stays in `carrier_name` text. |
|
||||
| Shipment is deleted | `ondelete='set null'` — receiving keeps carrier but smart button reverts to "Create". |
|
||||
| `fusion_shipping` not installed | Manifest dependency fails fast on module load — correct failure mode. |
|
||||
|
||||
## Test plan
|
||||
|
||||
**Unit tests** in `fusion_plating_receiving/tests/test_carrier_fields.py`:
|
||||
- `test_carrier_id_field_exists_on_receiving`
|
||||
- `test_outbound_shipment_id_field_exists_on_receiving`
|
||||
- `test_action_create_outbound_shipment_creates_draft`
|
||||
- `test_action_create_outbound_shipment_idempotent`
|
||||
- `test_carrier_id_change_propagates_to_draft_shipment`
|
||||
- `test_carrier_id_change_does_not_propagate_to_confirmed_shipment`
|
||||
|
||||
**Unit tests** in `fusion_plating_logistics/tests/test_delivery_shipping_fields.py`:
|
||||
- `test_carrier_id_field_exists_on_delivery`
|
||||
- `test_outbound_shipment_id_field_exists_on_delivery`
|
||||
|
||||
**Unit tests** extending `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`:
|
||||
- `test_create_delivery_mirrors_carrier_from_receiving`
|
||||
- `test_create_delivery_mirrors_outbound_shipment`
|
||||
- `test_create_delivery_no_receiving_no_mirror`
|
||||
|
||||
**Manual verification post-deploy:**
|
||||
1. Open RCV-30041 → carrier dropdown shows 15 options.
|
||||
2. Pick FedEx → click "Create Outbound Shipment" → fusion.shipment opens in draft.
|
||||
3. Confirm `x_fc_outbound_shipment_id` is populated on RCV-30041.
|
||||
4. Confirm same fields/buttons on a fresh fp.delivery record auto-created via mark-done.
|
||||
|
||||
## Deployment
|
||||
|
||||
- 3 module upgrades: `fusion_plating_receiving`, `fusion_plating_logistics`, `fusion_plating_jobs`.
|
||||
- `fusion_shipping` is already installed — no action needed.
|
||||
- Migration runs automatically; spot-check by querying `fp_receiving.x_fc_carrier_id` post-deploy.
|
||||
@@ -0,0 +1,224 @@
|
||||
# Phase C — Generate Label End-to-End
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Project:** Shipping integration phase 3 of 5 (after Phase A foundation; Phase B was merged in as a fallback path).
|
||||
|
||||
## Goal
|
||||
|
||||
Complete the at-receiving outbound-label workflow: receiver enters weight + dimensions + picks carrier, clicks one button, system generates the carrier's shipping label PDF + tracking number (API when available, manual fallback when not). Operator prints the label, ships the box, customer gets the tracking link by email and on the portal.
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
[Receiver] enters weight + dims + picks carrier on RECEIVING FORM
|
||||
↓
|
||||
Click "Generate Outbound Label"
|
||||
↓
|
||||
Carrier has API integration?
|
||||
├─ YES → carrier.send_shipping([picking]) → label PDF + tracking
|
||||
│ saved to fusion.shipment
|
||||
│
|
||||
└─ NO/API FAILS → open manual entry wizard
|
||||
operator pastes PDF + types tracking
|
||||
saved to fusion.shipment
|
||||
↓
|
||||
[Shipping] "Print Label" button → opens PDF in browser print dialog
|
||||
↓
|
||||
[Notification] fp.notification.template fires (event: shipment_labeled)
|
||||
with tracking_number + tracking_url placeholders
|
||||
↓
|
||||
[Portal] Job page renders tracking_number as clickable link to
|
||||
carrier.tracking_url template
|
||||
```
|
||||
|
||||
## Decisions reached
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Weight + dimensions live on fp.receiving as `related=` fields → fusion.shipment | Receiver enters them on the receiving form (their workflow); shipment stays as source of truth. |
|
||||
| D2 | One button: "Generate Outbound Label". API path is primary; manual is fallback | One UX, two branches inside. No separate "Manual Label Entry" flow surfaced to operator. |
|
||||
| D3 | Manual fallback opens automatically on API failure OR when carrier has no API integration | Operator never has to think about which path to take. |
|
||||
| D4 | Adapter approach: synthesize a stock.picking just for the API call (locked Phase C question) | Max reuse of existing fusion_shipping methods; picking is hidden from operator UIs. |
|
||||
| D5 | Notification trigger fires whenever tracking_number gets set (API OR manual), not at label generation | Same downstream behavior regardless of how the label was obtained. |
|
||||
| D6 | Portal renders tracking as `<a href="...">` using delivery.carrier.tracking_url template | Standard Odoo carrier tracking URL pattern. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Purolator integration (Phase D — independent).
|
||||
- Auto-print to a network printer (Phase F).
|
||||
- Multi-package shipments (single package per shipment in Phase C).
|
||||
- Rate quote / carrier shopping (just label generation).
|
||||
- Job sticker auto-print at same moment (Phase F).
|
||||
- Return labels (different API call; can come later).
|
||||
|
||||
## Files changing
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `fusion_plating_receiving/models/fp_receiving.py` | NEW related fields: `x_fc_weight`, `x_fc_weight_uom`, `x_fc_length`, `x_fc_width`, `x_fc_height`, `x_fc_dim_uom` (related to fusion.shipment / fusion.order.package). NEW `x_fc_shipping_picking_id` (M2O stock.picking, back-link). NEW `action_generate_outbound_label()`. NEW `action_print_label()`. NEW helper `_fp_build_shipping_picking()`. |
|
||||
| `fusion_plating_receiving/wizards/__init__.py` (NEW) | Wizard module init. |
|
||||
| `fusion_plating_receiving/wizards/fp_label_manual_wizard.py` (NEW) | Transient model: `receiving_id`, `label_pdf` (Binary), `label_filename` (Char), `tracking_number` (Char), `note` (Char — context why manual fallback). `action_confirm()` writes to fusion.shipment + closes wizard. |
|
||||
| `fusion_plating_receiving/wizards/fp_label_manual_wizard_views.xml` (NEW) | Wizard form view. |
|
||||
| `fusion_plating_receiving/views/fp_receiving_views.xml` | Add weight + dimensions group (Reception group). Add header buttons "Generate Outbound Label" + "Print Label". |
|
||||
| `fusion_plating_receiving/__manifest__.py` | Bump 19.0.3.10.0 → 19.0.3.11.0. Register new wizard files. Add `stock`, `delivery` to depends. |
|
||||
| `fusion_plating_receiving/security/ir.model.access.csv` | ACLs for the new wizard models. |
|
||||
| `fusion_plating_notifications/data/notification_templates.xml` (EXISTING — extend) | Add `shipment_labeled` trigger entry with default template. |
|
||||
| `fusion_plating_portal/views/fp_portal_templates.xml` (EXISTING — extend) | Render tracking_number as `<a>` link on job page. |
|
||||
| Tests | Three new files + extensions. |
|
||||
|
||||
## Implementation details
|
||||
|
||||
### Related fields on fp.receiving
|
||||
|
||||
```python
|
||||
x_fc_weight = fields.Float(
|
||||
related='x_fc_outbound_shipment_id.weight',
|
||||
readonly=False, store=False,
|
||||
)
|
||||
# Similar for length/width/height — these come from fusion.order.package, not fusion.shipment directly.
|
||||
# Decision: write to the shipment's first package (auto-create if absent).
|
||||
```
|
||||
|
||||
Wait — `fusion.shipment.weight` exists, but length/width/height live on `fusion.order.package`. The shipment has a one2many relationship via `sale_order_id.package_ids`. For Phase C, the simplest path: store dimensions on the shipment by adding them as fields, OR auto-create a package per shipment.
|
||||
|
||||
**Resolved:** Phase C reads/writes weight + dimensions on the shipment record directly. If `fusion.shipment` doesn't have dimension fields, we add them via inheritance from this side (this is in fusion_shipping's model — would require touching it). Alternative: store on a synthetic fusion.order.package.
|
||||
|
||||
**Decision for spec:** add length/width/height + dim_uom as new fields directly on `fusion.shipment` via inheritance from `fusion_plating_receiving` (or move to fusion_shipping if appropriate during implementation). Cleaner than the package indirection for a single-package flow.
|
||||
|
||||
### action_generate_outbound_label
|
||||
|
||||
```python
|
||||
def action_generate_outbound_label(self):
|
||||
self.ensure_one()
|
||||
self._fp_validate_label_inputs() # carrier, weight, recipient addr, shipment exists
|
||||
carrier = self.x_fc_carrier_id
|
||||
if carrier.delivery_type == 'fixed':
|
||||
return self._fp_open_manual_label_wizard(
|
||||
note=_('Carrier "%s" has no API integration. Enter the '
|
||||
'label PDF and tracking number manually.') % carrier.name,
|
||||
)
|
||||
try:
|
||||
picking = self._fp_build_shipping_picking()
|
||||
shipping_data = carrier.send_shipping([picking]) # standard Odoo call
|
||||
self._fp_apply_shipping_result(picking, shipping_data)
|
||||
except Exception as e:
|
||||
_logger.warning("Label gen failed for %s: %s", self.name, e)
|
||||
return self._fp_open_manual_label_wizard(
|
||||
note=_('API call failed: %s\n\nEnter the label manually below.') % e,
|
||||
)
|
||||
return self._fp_open_outbound_shipment_action() # smart-button target
|
||||
```
|
||||
|
||||
### Manual fallback wizard
|
||||
|
||||
Small transient model `fp.label.manual.wizard` with:
|
||||
- `receiving_id` (M2O fp.receiving, required)
|
||||
- `label_pdf` (Binary, required at confirm time)
|
||||
- `label_filename` (Char)
|
||||
- `tracking_number` (Char, required at confirm time)
|
||||
- `note` (Char, readonly — explanatory message)
|
||||
|
||||
`action_confirm()`:
|
||||
- Validate label + tracking present.
|
||||
- Write to the receiving's linked fusion.shipment: `label_attachment_id` (create ir.attachment) + `tracking_number` + `status='confirmed'`.
|
||||
- Close wizard, post chatter to receiving.
|
||||
|
||||
### Synthetic stock.picking
|
||||
|
||||
```python
|
||||
def _fp_build_shipping_picking(self):
|
||||
self.ensure_one()
|
||||
Picking = self.env['stock.picking']
|
||||
warehouse = self.env['stock.warehouse'].search([
|
||||
('company_id', '=', self.env.company.id)
|
||||
], limit=1)
|
||||
picking_type = warehouse.out_type_id
|
||||
so = self.sale_order_id
|
||||
return Picking.create({
|
||||
'partner_id': so.partner_shipping_id.id,
|
||||
'picking_type_id': picking_type.id,
|
||||
'origin': so.name,
|
||||
'sale_id': so.id,
|
||||
'carrier_id': self.x_fc_carrier_id.id,
|
||||
# Synthetic single move from a generic shipping product:
|
||||
'move_ids': [(0, 0, {
|
||||
'name': 'Outbound Shipment %s' % self.name,
|
||||
'product_id': self.env.ref('product.product_product_4').id, # default service-type
|
||||
'product_uom_qty': 1,
|
||||
'product_uom': self.env.ref('uom.product_uom_unit').id,
|
||||
'location_id': picking_type.default_location_src_id.id,
|
||||
'location_dest_id': picking_type.default_location_dest_id.id,
|
||||
})],
|
||||
'x_fc_fp_receiving_id': self.id, # back-link, defined on stock.picking
|
||||
})
|
||||
```
|
||||
|
||||
Then immediately after `send_shipping` succeeds:
|
||||
- `picking.action_confirm()` + `picking.action_assign()` + `picking.button_validate()` to take the picking to 'done' state (so it doesn't sit as draft in operator views).
|
||||
|
||||
### Notification trigger
|
||||
|
||||
Add event `shipment_labeled` to fp.notification.template selection. Default email template:
|
||||
```
|
||||
Subject: Your order is ready to ship — Tracking #{{ tracking_number }}
|
||||
Body: Hi {{ partner_name }},
|
||||
Your order for SO {{ sale_order_name }} has shipped.
|
||||
Tracking number: {{ tracking_number }}
|
||||
Track here: {{ tracking_url }}
|
||||
```
|
||||
|
||||
Fired by an `on_write` hook on `fusion.shipment` when `tracking_number` transitions from empty to non-empty.
|
||||
|
||||
### Portal display
|
||||
|
||||
In `fusion_plating_portal/views/fp_portal_templates.xml`, locate the job-card / job-detail rendering. Wherever tracking_ref is shown, replace with:
|
||||
```xml
|
||||
<t t-if="job.delivery_id and job.delivery_id.x_fc_outbound_shipment_id">
|
||||
<a t-att-href="job.delivery_id.x_fc_outbound_shipment_id.tracking_url"
|
||||
target="_blank">
|
||||
<t t-esc="job.delivery_id.x_fc_outbound_shipment_id.tracking_number"/>
|
||||
</a>
|
||||
</t>
|
||||
```
|
||||
`tracking_url` is a computed field on `fusion.shipment` that resolves the `delivery.carrier.tracking_url` template (already exists in Odoo).
|
||||
|
||||
## Test plan
|
||||
|
||||
| Test | Verifies |
|
||||
|---|---|
|
||||
| `test_generate_label_blocks_when_no_carrier` | UserError raised |
|
||||
| `test_generate_label_blocks_when_no_shipment` | UserError raised |
|
||||
| `test_generate_label_blocks_when_no_weight` | UserError raised |
|
||||
| `test_generate_label_routes_manual_for_fixed_carrier` | Wizard opens, no API call made |
|
||||
| `test_generate_label_calls_api_for_integrated_carrier` | carrier.send_shipping called once (mocked) |
|
||||
| `test_generate_label_writes_result_to_shipment_on_success` | tracking_number + label_attachment populated |
|
||||
| `test_generate_label_falls_back_to_wizard_on_api_failure` | Mock raises → wizard opens with note |
|
||||
| `test_manual_wizard_confirm_writes_shipment` | label + tracking saved; status confirmed |
|
||||
| `test_print_label_returns_attachment_action` | Action dict points to the label PDF |
|
||||
| `test_notification_fires_when_tracking_set` | fp.notification.template._dispatch called with shipment_labeled event |
|
||||
| `test_portal_renders_tracking_link` | Render contains `<a href="...">` with tracking URL |
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| No warehouse configured | UserError: "No warehouse for the company — configure one in Settings > Warehouse." |
|
||||
| sale_order.partner_shipping_id missing | Falls back to `sale_order.partner_id`. |
|
||||
| Multi-package SO (rare) | Phase C single-package only. Multi-package raises with a "Phase E" note. |
|
||||
| Carrier API timeout | Caught as `Exception` in the try block; manual wizard opens with error in note. |
|
||||
| Operator generates label twice | Second call sees existing tracking, refuses and prompts to void/regenerate. |
|
||||
| Customer changes weight after label generated | Block weight edit when shipment.status == 'confirmed'. Manager can void shipment to re-generate. |
|
||||
|
||||
## Deployment
|
||||
|
||||
3 modules upgraded: `fusion_plating_receiving` (main), `fusion_plating_notifications` (trigger), `fusion_plating_portal` (link).
|
||||
|
||||
Manual verification on entech:
|
||||
1. Open RCV-30041. Set weight (e.g. 5), dimensions, carrier = FedEx.
|
||||
2. Click Generate Outbound Label. Expected: UserError because the seeded FedEx carrier has `delivery_type='fixed'` — manual wizard opens.
|
||||
3. Paste a sample PDF + tracking number in wizard. Confirm.
|
||||
4. Verify fusion.shipment has the label and tracking saved.
|
||||
5. Verify Print Label button works (opens PDF).
|
||||
6. (If admin configures FedEx REST credentials and changes delivery_type) — re-test API path.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Receiving Gate on Step Start / Finish
|
||||
|
||||
**Date:** 2026-05-18
|
||||
**Status:** Approved for implementation
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Triggering observation:** WO-30040 closed with `qty_received` blank and chatter warnings on Post-plate Inspection / Final Inspection ("Step started before parts were received"). The existing soft chatter warning is not strong enough — operators ignore it and the job still completes.
|
||||
|
||||
## Goal
|
||||
|
||||
Block step transitions (start AND finish) on any non-Contract-Review step until the SO's receiving record is closed. Future-proof for custom steps added later. Allow manager bypass via the existing `fp_skip_*` context-flag pattern.
|
||||
|
||||
## Decisions reached
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Scope: all step kinds EXCEPT Contract Review | CR is paperwork — doesn't need parts on the floor. Every other step (including future custom steps) involves physical work. |
|
||||
| D2 | Timing: both `button_start` AND `button_finish` | Strongest. Operator can't begin OR complete physical work without receiving closed. Catches both "started too early" and "started before parts arrived, completed before they did". |
|
||||
| D3 | Threshold: `sale_order.x_fc_receiving_status == 'received'` | Post-Sub-8 (and the 2026-05-18 cleanup), `received` is the terminal receiving state. `not_received` and `partial` block. |
|
||||
| D4 | Manager bypass: `fp_skip_receiving_gate=True` context flag | Matches existing `fp_skip_*` pattern (qty_reconcile, qc_gate, step_gate, bake_gate). Auditor trail via chatter on the state transition. |
|
||||
| D5 | Implementation: single helper called from both buttons | Mirrors existing `_fp_check_contract_review_complete` pattern. DRY — same code tested once. |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Receiving model's state machine (already correct post-Sub-8).
|
||||
- The `_update_so_receiving_status` mapping (already maps `closed → received`).
|
||||
- Other gates (qty_reconcile, qc_gate, bake_gate) — untouched.
|
||||
- Schema changes — pure behavior change.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
fp.job.step.button_start fp.job.step.button_finish
|
||||
1. Sequential-order gate (existing) 1. _fp_check_contract_review_complete (existing)
|
||||
2. _fp_check_receiving_gate() ← NEW 2. _fp_check_receiving_gate() ← NEW
|
||||
3. Contract Review auto-open (existing) 3. super().button_finish() + downstream (existing)
|
||||
4. Racking auto-open (existing)
|
||||
5. Standard path + serial promote (existing)
|
||||
[old soft chatter warning removed]
|
||||
```
|
||||
|
||||
## Helper method
|
||||
|
||||
```python
|
||||
def _fp_check_receiving_gate(self):
|
||||
"""Block step transitions until parts are physically received.
|
||||
|
||||
Applied to every step EXCEPT Contract Review. Fires from both
|
||||
button_start and button_finish. Manager bypass via context flag
|
||||
`fp_skip_receiving_gate=True`.
|
||||
"""
|
||||
if self.env.context.get('fp_skip_receiving_gate'):
|
||||
return
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
continue
|
||||
so = step.job_id.sale_order_id
|
||||
if not so:
|
||||
continue # internal rework — gate doesn't apply
|
||||
if 'x_fc_receiving_status' not in so._fields:
|
||||
continue # defensive: configurator not installed
|
||||
if so.x_fc_receiving_status != 'received':
|
||||
label = dict(
|
||||
so._fields['x_fc_receiving_status'].selection
|
||||
).get(so.x_fc_receiving_status, so.x_fc_receiving_status or 'unknown')
|
||||
raise UserError(_(
|
||||
'Step "%(step)s" cannot proceed — parts not received yet '
|
||||
'(SO %(so)s receiving status: %(status)s).\n\n'
|
||||
'Close the receiving record (Sales > %(so)s > Receiving) '
|
||||
'before starting or finishing work on this step. A '
|
||||
'manager can bypass this gate for documented exceptions.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'so': so.name or '?',
|
||||
'status': label,
|
||||
})
|
||||
```
|
||||
|
||||
## Module changes
|
||||
|
||||
| Module | Bump | Files |
|
||||
|---|---|---|
|
||||
| `fusion_plating_jobs` | 19.0.10.12.0 → 19.0.10.13.0 | `models/fp_job_step.py` (helper + 2 callers + remove soft warning); `tests/test_fp_job_milestone_cascade.py` (new TestReceivingGate class) |
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Step on job with no SO link (internal rework) | Gate doesn't fire — `continue`. |
|
||||
| Configurator module not installed (`x_fc_receiving_status` field absent) | Gate doesn't fire — `continue`. |
|
||||
| Contract Review step on `not_received` SO | Gate exempt; step proceeds (paperwork). |
|
||||
| Step on `partial` SO | Blocks — `partial` is not `received`. Operator waits for all boxes to land. |
|
||||
| Manager bypass via context | All gates skipped uniformly. Audit trail preserved via state-transition tracking. |
|
||||
|
||||
## Test plan
|
||||
|
||||
8 unit tests in new `TestReceivingGate` class in `test_fp_job_milestone_cascade.py`:
|
||||
|
||||
- `test_start_blocks_when_not_received`
|
||||
- `test_start_allows_when_received`
|
||||
- `test_start_skips_contract_review`
|
||||
- `test_start_bypass_via_context`
|
||||
- `test_finish_blocks_when_not_received`
|
||||
- `test_finish_allows_when_received`
|
||||
- `test_finish_skips_contract_review`
|
||||
- `test_finish_bypass_via_context`
|
||||
|
||||
**Manual verification on entech post-deploy:**
|
||||
1. Open SO-30041 (currently `not_received`) → fp.job → try `button_start` on first non-CR step → UserError raised.
|
||||
2. Close the receiving record (counted → staged → closed) → SO flips to `received`.
|
||||
3. Re-try `button_start` → succeeds.
|
||||
4. Repeat the start/finish flow with `fp_skip_receiving_gate=True` from a shell to verify bypass.
|
||||
|
||||
## Backwards compatibility
|
||||
|
||||
- The old soft chatter warning at fp_job_step.py:894-907 is removed. The information is no longer useful — it was a soft warning for a behavior we're now hard-blocking. The job's chatter still tracks the state transition via Odoo's tracking.
|
||||
- Jobs already in `in_progress` on `not_received` SOs at deploy time: any future button_finish will block. Manager must either close receiving OR use bypass.
|
||||
- No DB migration needed.
|
||||
|
||||
## Deployment
|
||||
|
||||
- Single-module deploy to entech LXC 111 (`fusion_plating_jobs`).
|
||||
- No restart of dependent modules required.
|
||||
- Verify with manual flow above.
|
||||
@@ -30,6 +30,79 @@ def post_init_hook(env):
|
||||
_backfill_contract_review_template(env)
|
||||
_seed_rack_tags_if_empty(env)
|
||||
_migrate_legacy_uom_columns(env)
|
||||
_seed_starter_recipes_once(env)
|
||||
|
||||
|
||||
def _seed_starter_recipes_once(env):
|
||||
"""Load starter recipe XML files on FIRST install only.
|
||||
|
||||
Before 19.0.20.5.0 the recipe XML files (ENP-STEEL-BASIC, ENP-SP,
|
||||
ENP-ALUM-BASIC, etc.) lived in the manifest's ``data`` list. With
|
||||
``noupdate="1"`` we expected user edits / deletions to survive
|
||||
module upgrades — but Odoo only treats noupdate=1 as "don't update
|
||||
existing records". If a record's ir.model.data row is deleted via
|
||||
unlink, Odoo on the next ``-u`` sees the xmlid as missing and
|
||||
RE-CREATES the record from XML. Bug reported 2026-05-20: every
|
||||
time the user deleted a substep from a starter recipe, the next
|
||||
upgrade brought it back.
|
||||
|
||||
Fix: pull those files out of the manifest's data list, load them
|
||||
here via convert_file ONCE per xmlid. Each file gets a sentinel
|
||||
check (does the root recipe's xmlid exist in ir.model.data?); if
|
||||
yes, skip. The hook is itself idempotent so it's safe to run on
|
||||
every upgrade as well — but the sentinel ensures recipe content
|
||||
is only seeded the very first time.
|
||||
"""
|
||||
from odoo.tools import convert
|
||||
Module = env['ir.module.module']
|
||||
mod = Module.search([('name', '=', 'fusion_plating')], limit=1)
|
||||
if not mod:
|
||||
return
|
||||
|
||||
# (xmlid_to_check, data_file_path) pairs.
|
||||
# If the xmlid already exists in ir.model.data, the file is skipped.
|
||||
sentinels = [
|
||||
('fusion_plating.recipe_enp_alum_basic',
|
||||
'data/fp_recipe_enp_alum_basic.xml'),
|
||||
('fusion_plating.recipe_enp_steel_basic',
|
||||
'data/fp_recipe_enp_steel_basic.xml'),
|
||||
('fusion_plating.recipe_enp_sp',
|
||||
'data/fp_recipe_enp_sp.xml'),
|
||||
('fusion_plating.recipe_general_processing',
|
||||
'data/fp_recipe_general_processing.xml'),
|
||||
('fusion_plating.recipe_anodize',
|
||||
'data/fp_recipe_anodize.xml'),
|
||||
('fusion_plating.recipe_chem_conversion',
|
||||
'data/fp_recipe_chem_conversion.xml'),
|
||||
]
|
||||
IMD = env['ir.model.data']
|
||||
for xmlid, filepath in sentinels:
|
||||
module_name, name = xmlid.split('.', 1)
|
||||
if IMD.search_count([('module', '=', module_name), ('name', '=', name)]):
|
||||
# Recipe already in DB (either from a previous install, or
|
||||
# already loaded by an earlier hook run). Don't touch — user
|
||||
# may have made edits.
|
||||
continue
|
||||
# File not yet loaded for this DB. Run it once.
|
||||
try:
|
||||
with open_module_data_file(filepath) as fh:
|
||||
convert.convert_file(
|
||||
env, module_name, filepath, idref={}, mode='init',
|
||||
noupdate=True,
|
||||
)
|
||||
_logger.info('Seeded starter recipe %s', xmlid)
|
||||
except FileNotFoundError:
|
||||
_logger.warning('Starter recipe file %s not found, skipping',
|
||||
filepath)
|
||||
except Exception as exc:
|
||||
_logger.warning('Could not seed %s: %s', xmlid, exc)
|
||||
|
||||
|
||||
def open_module_data_file(relpath):
|
||||
"""Open a file relative to the fusion_plating module root."""
|
||||
import os
|
||||
here = os.path.dirname(__file__)
|
||||
return open(os.path.join(here, relpath), 'rb')
|
||||
|
||||
|
||||
def _resolve_kind_id(env, code):
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.20.1.0',
|
||||
'version': '19.0.20.6.2',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
@@ -120,12 +120,19 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'views/fp_jobs_menu.xml',
|
||||
'data/fp_work_role_data.xml',
|
||||
'views/fp_work_role_views.xml',
|
||||
'data/fp_recipe_enp_alum_basic.xml',
|
||||
'data/fp_recipe_enp_steel_basic.xml',
|
||||
'data/fp_recipe_enp_sp.xml',
|
||||
'data/fp_recipe_general_processing.xml',
|
||||
'data/fp_recipe_anodize.xml',
|
||||
'data/fp_recipe_chem_conversion.xml',
|
||||
# Starter recipes are NOT in 'data' on purpose. They get
|
||||
# loaded once via post_init_hook → _seed_starter_recipes_once
|
||||
# so user edits / deletions survive every -u upgrade. Putting
|
||||
# them back here would re-create deleted nodes on every
|
||||
# module upgrade (the noupdate="1" flag only blocks UPDATE,
|
||||
# not CREATE-when-missing — Odoo treats a missing ir.model.data
|
||||
# record as "needs creating").
|
||||
# 'data/fp_recipe_enp_alum_basic.xml',
|
||||
# 'data/fp_recipe_enp_steel_basic.xml',
|
||||
# 'data/fp_recipe_enp_sp.xml',
|
||||
# 'data/fp_recipe_general_processing.xml',
|
||||
# 'data/fp_recipe_anodize.xml',
|
||||
# 'data/fp_recipe_chem_conversion.xml',
|
||||
'data/fp_step_template_data.xml',
|
||||
],
|
||||
'post_init_hook': 'post_init_hook',
|
||||
|
||||
@@ -9,7 +9,7 @@ enforced by the underlying ACL on fp.step.template + process.node:
|
||||
operators get read; supervisors+ get write.
|
||||
"""
|
||||
|
||||
from odoo import http
|
||||
from odoo import _, http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
@@ -63,12 +63,93 @@ class SimpleRecipeController(http.Controller):
|
||||
def load(self, recipe_id):
|
||||
recipe = request.env['fusion.plating.process.node'].browse(recipe_id)
|
||||
recipe.check_access('read')
|
||||
steps = recipe.child_ids.sorted('sequence')
|
||||
# Tree-Editor-authored recipes carry FOUR node levels:
|
||||
# recipe → sub_process → operation → step
|
||||
# The Tree Editor shows all of them. The Simple Editor used to
|
||||
# only show direct children of the recipe — so for
|
||||
# ENP-STEEL-BASIC (1 sub_process + 16 operations + 26 step
|
||||
# nodes), authors saw 10 rows out of 43. Work-order generation
|
||||
# walked the full tree and emitted operations as fp.job.step
|
||||
# rows with step-nodes folded in as instruction text.
|
||||
#
|
||||
# We now walk the full tree depth-first and surface EVERY
|
||||
# operation and step node, in traversal order, each tagged
|
||||
# with:
|
||||
# - `nested_under`: chained sub-process path ("Steel Line",
|
||||
# "Steel Line › Cleaner", etc.)
|
||||
# - `node_type`: 'operation' or 'step'
|
||||
# - `is_substep`: True for `step` nodes (renders indented)
|
||||
#
|
||||
# The Simple Editor's drag/insert/reorder semantics still
|
||||
# treat operations as headline rows; substeps are read-only
|
||||
# by default in the UI but their fields can be edited via the
|
||||
# existing step_write endpoint (which doesn't care about
|
||||
# node_type).
|
||||
flat_nodes = self._flatten_recipe_nodes(recipe)
|
||||
return {
|
||||
'recipe': self._recipe_payload(recipe),
|
||||
'steps': [self._step_payload(s) for s in steps],
|
||||
'steps': [
|
||||
dict(self._step_payload(node),
|
||||
nested_under=path,
|
||||
node_type=node.node_type,
|
||||
is_substep=(node.node_type == 'step'))
|
||||
for node, path in flat_nodes
|
||||
],
|
||||
}
|
||||
|
||||
def _flatten_recipe_operations(self, recipe):
|
||||
"""Legacy helper — returns ONLY operations.
|
||||
|
||||
Kept for back-compat with callers and tests that asked for the
|
||||
operations-only view. Most paths should now use
|
||||
``_flatten_recipe_nodes`` which also surfaces step children.
|
||||
"""
|
||||
return [
|
||||
(n, p) for n, p in self._flatten_recipe_nodes(recipe)
|
||||
if n.node_type == 'operation'
|
||||
]
|
||||
|
||||
def _flatten_recipe_nodes(self, recipe):
|
||||
"""Walk the recipe DFS, return [(node, path_label)].
|
||||
|
||||
Surfaces both `operation` and `step` nodes. The traversal order
|
||||
matches what the Tree Editor displays:
|
||||
recipe → recurse → operation (emit) → its step children (emit)
|
||||
recipe → recurse → sub_process → recurse → operation → steps
|
||||
|
||||
Step children are emitted IMMEDIATELY after their parent
|
||||
operation so the editor can render them as a contiguous block.
|
||||
"""
|
||||
out = []
|
||||
|
||||
def _walk(node, path):
|
||||
if node.node_type == 'operation':
|
||||
out.append((node, path))
|
||||
# Emit step children right after the operation so the
|
||||
# editor sees: [Op, step, step, NextOp, step, ...].
|
||||
# The path label for a substep names its parent
|
||||
# operation, chained from the sub-process if present.
|
||||
sub_path = (
|
||||
f"{path} › {node.name}" if path else node.name
|
||||
)
|
||||
for child in node.child_ids.sorted('sequence'):
|
||||
if child.node_type == 'step':
|
||||
out.append((child, sub_path))
|
||||
return
|
||||
if node.node_type in ('recipe', 'sub_process'):
|
||||
sub_path = (
|
||||
path if node.node_type == 'recipe'
|
||||
else (f"{path} › {node.name}" if path else node.name)
|
||||
)
|
||||
for child in node.child_ids.sorted('sequence'):
|
||||
_walk(child, sub_path)
|
||||
# `step` nodes that are direct children of a recipe (rare,
|
||||
# legacy seed data) are silently dropped — _generate_steps
|
||||
# has always skipped them.
|
||||
|
||||
_walk(recipe, '')
|
||||
return out
|
||||
|
||||
def _recipe_payload(self, recipe):
|
||||
return {
|
||||
'id': recipe.id,
|
||||
@@ -80,6 +161,11 @@ class SimpleRecipeController(http.Controller):
|
||||
[recipe.process_type_id.id, recipe.process_type_id.name]
|
||||
if recipe.process_type_id else False
|
||||
),
|
||||
# 2026-05-20 — drives the visibility of admin-only affordances
|
||||
# in the Simple Editor (e.g. "+ New kind…" inline create).
|
||||
'user_is_manager': request.env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager'
|
||||
),
|
||||
}
|
||||
|
||||
def _step_payload(self, step):
|
||||
@@ -418,12 +504,32 @@ class SimpleRecipeController(http.Controller):
|
||||
@http.route('/fp/simple_recipe/kinds/create',
|
||||
type='jsonrpc', auth='user')
|
||||
def kinds_create(self, name, code=''):
|
||||
"""Sub 14b — Inline create for "+ New kind…" in the library
|
||||
form. Auto-derives a code from the name if blank."""
|
||||
"""Inline create for "+ New kind…" in the library form.
|
||||
Auto-derives a code from the name if blank.
|
||||
|
||||
2026-05-20 lockdown: manager group only. Kinds drive gates,
|
||||
milestones, and operator routing — a user-created kind with no
|
||||
corresponding behaviour is a silent foot-gun. The dropdown is
|
||||
the curated catalog; adding a new kind requires manager
|
||||
approval and follow-up code work to wire the new code into the
|
||||
downstream behaviour map.
|
||||
"""
|
||||
Kind = request.env['fp.step.kind']
|
||||
if not name or not name.strip():
|
||||
return {'ok': False, 'error': 'name_required'}
|
||||
# check_access via create attempt — supervisors+ allowed (ACL).
|
||||
if not request.env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager'
|
||||
):
|
||||
return {
|
||||
'ok': False, 'error': 'forbidden',
|
||||
'message': (
|
||||
'Only Plating Managers can add new Step Kinds. The '
|
||||
'catalog is curated because each kind drives gates, '
|
||||
'milestones, and operator routing. Pick "Other" if '
|
||||
'no existing kind fits — or ask a manager to add the '
|
||||
'new kind once the downstream behaviour is wired up.'
|
||||
),
|
||||
}
|
||||
if not code:
|
||||
code = name.strip().lower().replace(' ', '_').replace('/', '_')
|
||||
existing = Kind.search([('code', '=', code)], limit=1)
|
||||
@@ -586,11 +692,137 @@ class SimpleRecipeController(http.Controller):
|
||||
|
||||
@http.route('/fp/simple_recipe/step/reorder', type='jsonrpc', auth='user')
|
||||
def step_reorder(self, node_ids):
|
||||
"""Renumber sequence within each parent group.
|
||||
|
||||
Naive version (pre-19.0.20.5.0): renumber the entire flat list
|
||||
1..N regardless of parent. Broke when the flat list mixed
|
||||
operations and substeps — siblings got out-of-order numbers
|
||||
because the list interleaved them.
|
||||
|
||||
New version: group node ids by their parent_id, then renumber
|
||||
within each parent. Substeps stay sequenced under their
|
||||
operation; operations stay sequenced under the recipe / sub-
|
||||
process. Drop-across-parent shows up as a same-position no-op
|
||||
— the UI's Promote/Demote buttons are the way to change
|
||||
parents.
|
||||
"""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
for i, nid in enumerate(node_ids, start=1):
|
||||
Node.browse(nid).write({'sequence': i * 10})
|
||||
nodes = Node.browse([int(n) for n in node_ids])
|
||||
# Group by parent_id (preserve client-provided order within each).
|
||||
from collections import OrderedDict
|
||||
by_parent = OrderedDict()
|
||||
for n in nodes:
|
||||
by_parent.setdefault(n.parent_id.id, []).append(n)
|
||||
for parent_id, siblings in by_parent.items():
|
||||
for i, n in enumerate(siblings, start=1):
|
||||
target = i * 10
|
||||
if n.sequence != target:
|
||||
n.sequence = target
|
||||
return {'ok': True}
|
||||
|
||||
@http.route('/fp/simple_recipe/step/promote', type='jsonrpc', auth='user')
|
||||
def step_promote(self, node_id):
|
||||
"""Promote a substep (`step` node) to an operation under the
|
||||
recipe root.
|
||||
|
||||
Use case: author added a sub-step under an operation in the
|
||||
Tree Editor, but actually wants it as a standalone operation
|
||||
that the operator clocks separately. This call:
|
||||
1. Flips node_type 'step' → 'operation'
|
||||
2. Re-parents to the recipe root (or sub-process root if
|
||||
the parent operation lives inside a sub_process)
|
||||
3. Places the new operation immediately after its old
|
||||
parent (so it shows up in a sensible position in the
|
||||
editor list)
|
||||
"""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
node = Node.browse(int(node_id))
|
||||
if not node.exists():
|
||||
return {'ok': False, 'error': 'not_found'}
|
||||
node.check_access('write')
|
||||
if node.node_type != 'step':
|
||||
return {'ok': False, 'error': 'not_a_substep',
|
||||
'message': 'Only substeps can be promoted.'}
|
||||
parent_op = node.parent_id
|
||||
if not parent_op or parent_op.node_type != 'operation':
|
||||
return {'ok': False, 'error': 'no_parent_op',
|
||||
'message': 'Substep has no operation parent to promote out of.'}
|
||||
new_parent = parent_op.parent_id
|
||||
if not new_parent or new_parent.node_type not in ('recipe', 'sub_process'):
|
||||
return {'ok': False, 'error': 'no_grandparent',
|
||||
'message': 'Cannot find a recipe / sub-process to promote into.'}
|
||||
# Place the new operation right after parent_op.
|
||||
new_seq = parent_op.sequence + 1
|
||||
# Bump later siblings to make room (so we don't collide).
|
||||
for sibling in new_parent.child_ids.filtered(
|
||||
lambda s: s.sequence > parent_op.sequence and s.id != node.id
|
||||
):
|
||||
sibling.sequence = sibling.sequence + 10
|
||||
node.write({
|
||||
'node_type': 'operation',
|
||||
'parent_id': new_parent.id,
|
||||
'sequence': new_seq,
|
||||
})
|
||||
return {'ok': True, 'new_parent_id': new_parent.id,
|
||||
'new_sequence': new_seq}
|
||||
|
||||
@http.route('/fp/simple_recipe/step/demote', type='jsonrpc', auth='user')
|
||||
def step_demote(self, node_id, target_op_id=False):
|
||||
"""Demote an operation to a substep under another operation.
|
||||
|
||||
If ``target_op_id`` is provided, the node becomes a substep of
|
||||
that operation. Otherwise it falls under the operation
|
||||
immediately preceding it in the editor list (most common case
|
||||
— author drops a header into the preceding section).
|
||||
"""
|
||||
Node = request.env['fusion.plating.process.node']
|
||||
node = Node.browse(int(node_id))
|
||||
if not node.exists():
|
||||
return {'ok': False, 'error': 'not_found'}
|
||||
node.check_access('write')
|
||||
if node.node_type != 'operation':
|
||||
return {'ok': False, 'error': 'not_an_operation',
|
||||
'message': 'Only operations can be demoted to substeps.'}
|
||||
# Substeps of operations don't recurse further — bail if this
|
||||
# operation has its own step children (would lose them on demote).
|
||||
if node.child_ids:
|
||||
return {'ok': False, 'error': 'has_children',
|
||||
'message': (
|
||||
'Operation "%s" has %d child step(s). Remove '
|
||||
'or promote them first before demoting this '
|
||||
'operation.'
|
||||
) % (node.name, len(node.child_ids))}
|
||||
# Resolve target operation.
|
||||
if target_op_id:
|
||||
target = Node.browse(int(target_op_id))
|
||||
if not target.exists() or target.node_type != 'operation':
|
||||
return {'ok': False, 'error': 'invalid_target',
|
||||
'message': 'Target must be an operation.'}
|
||||
else:
|
||||
# Find the preceding operation in the same parent.
|
||||
parent = node.parent_id
|
||||
if not parent:
|
||||
return {'ok': False, 'error': 'no_parent'}
|
||||
siblings = parent.child_ids.sorted('sequence')
|
||||
before = [s for s in siblings if s.sequence < node.sequence
|
||||
and s.node_type == 'operation']
|
||||
if not before:
|
||||
return {'ok': False, 'error': 'no_preceding_op',
|
||||
'message': (
|
||||
'There is no preceding operation to demote '
|
||||
'into. Add one above this step first, or '
|
||||
'pick an operation manually.'
|
||||
)}
|
||||
target = before[-1]
|
||||
# Place the substep at the end of the target operation's children.
|
||||
last_seq = max(target.child_ids.mapped('sequence') or [0])
|
||||
node.write({
|
||||
'node_type': 'step',
|
||||
'parent_id': target.id,
|
||||
'sequence': last_seq + 10,
|
||||
})
|
||||
return {'ok': True, 'new_parent_id': target.id}
|
||||
|
||||
# -------------------------------------------------------------- template
|
||||
@http.route('/fp/simple_recipe/template/list', type='jsonrpc', auth='user')
|
||||
def template_list(self):
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- 24 seeded Step Kinds — XML IDs use the original Selection
|
||||
keys so post-migrate can map old default_kind = 'cleaning'
|
||||
to env.ref('fusion_plating.step_kind_cleaning').
|
||||
<!-- Step Kind catalog.
|
||||
|
||||
noupdate=1 so user edits to defaults survive `-u`. -->
|
||||
noupdate=1 so user edits to defaults survive `-u`.
|
||||
|
||||
2026-05-20 curation (19.0.20.6.0):
|
||||
- Cut from 24 → 12 active kinds. The dropped ones
|
||||
(cleaning, electroclean, etch, rinse, strike, dry,
|
||||
wbf_test, demask, derack, replenishment, hardness_test,
|
||||
adhesion_test, salt_spray, packaging, gating) are kept
|
||||
in this XML for history but flipped active=False by the
|
||||
migration script so they no longer appear in the
|
||||
dropdown — and bulk-remapped onto the new `other` /
|
||||
`wet_process` kinds.
|
||||
- New: `other` (catch-all, default) and `wet_process`
|
||||
(covers all bath-based steps).
|
||||
- `mask` covers Masking + De-Masking, `racking` covers
|
||||
Racking + De-Racking — operators differentiate by the
|
||||
step name. -->
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ACTIVE KINDS — visible in dropdown -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="step_kind_other" model="fp.step.kind">
|
||||
<field name="code">other</field>
|
||||
<field name="name">Other</field>
|
||||
<field name="sequence">5</field>
|
||||
<field name="icon">fa-circle-o</field>
|
||||
</record>
|
||||
|
||||
<record id="step_kind_wet_process" model="fp.step.kind">
|
||||
<field name="code">wet_process</field>
|
||||
<field name="name">Wet Process (Clean / Rinse / Etch / Dry / etc.)</field>
|
||||
<field name="sequence">55</field>
|
||||
<field name="icon">fa-tint</field>
|
||||
</record>
|
||||
|
||||
<record id="step_kind_receiving" model="fp.step.kind">
|
||||
<field name="code">receiving</field>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# 2026-05-20 Step Kind curation — post-migrate.
|
||||
#
|
||||
# Runs AFTER the schema settles. Marks the 15 retired kinds inactive so
|
||||
# they no longer appear in the dropdown. We keep them in the DB rather
|
||||
# than deleting because:
|
||||
# - ir.model.data rows would dangle and break a future re-import
|
||||
# - audit trail / reports may still reference them by code
|
||||
# - users who undo the curation get one switch back to active=True
|
||||
#
|
||||
# Pre-migrate has already re-mapped every template + node pointing at
|
||||
# these kinds, so flipping active=False has no operator-facing data
|
||||
# impact — it only hides them from pickers.
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_RETIRED_CODES = [
|
||||
'cleaning', 'electroclean', 'etch', 'rinse', 'strike', 'dry',
|
||||
'wbf_test', 'demask', 'derack', 'replenishment', 'hardness_test',
|
||||
'adhesion_test', 'salt_spray', 'packaging', 'gating',
|
||||
]
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
cr.execute("""
|
||||
UPDATE fp_step_kind
|
||||
SET active = false
|
||||
WHERE code = ANY(%s)
|
||||
AND active = true
|
||||
""", (_RETIRED_CODES,))
|
||||
n = cr.rowcount
|
||||
if n:
|
||||
_logger.info(
|
||||
'Step Kind curation: retired %d kinds (active=False): %s',
|
||||
n, _RETIRED_CODES,
|
||||
)
|
||||
@@ -0,0 +1,259 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# 2026-05-20 Step Kind curation — pre-migrate.
|
||||
#
|
||||
# Runs BEFORE the model schema is applied so `kind_id` can become
|
||||
# required=True without choking on existing NULL rows. Three jobs:
|
||||
#
|
||||
# 1. Ensure the new `other` and `wet_process` kinds exist in the DB.
|
||||
# The data XML hasn't loaded yet at pre-migrate time, so we SQL
|
||||
# them in directly. The XML on the install path will see them and
|
||||
# skip via noupdate.
|
||||
#
|
||||
# 2. Re-map every template + recipe-node pointing at a RETIRED kind
|
||||
# to its new home:
|
||||
# cleaning, electroclean, etch, rinse, strike, dry, wbf_test
|
||||
# → wet_process
|
||||
# plate
|
||||
# → wet_process (but we KEEP `plate` separately for the
|
||||
# Plated milestone trigger; only auto-remap when the
|
||||
# caller explicitly wants to retire it. Plate stays
|
||||
# active.)
|
||||
# demask → mask
|
||||
# derack → racking
|
||||
# replenishment, hardness_test, adhesion_test, salt_spray,
|
||||
# packaging, gating
|
||||
# → other
|
||||
#
|
||||
# 3. Backfill every NULL kind_id via name-matching heuristic. Anything
|
||||
# that doesn't match → 'other'.
|
||||
#
|
||||
# After this script the schema can safely add NOT NULL to kind_id.
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# -- Remap table — retired-kind code -> new-kind code ----------------------
|
||||
# IMPORTANT: `plate` stays active (its own milestone trigger). Only the
|
||||
# wet-bath specialisations roll up into wet_process.
|
||||
_REMAP = {
|
||||
'cleaning': 'wet_process',
|
||||
'electroclean': 'wet_process',
|
||||
'etch': 'wet_process',
|
||||
'rinse': 'wet_process',
|
||||
'strike': 'wet_process',
|
||||
'dry': 'wet_process',
|
||||
'wbf_test': 'wet_process',
|
||||
'demask': 'mask',
|
||||
'derack': 'racking',
|
||||
'replenishment': 'other',
|
||||
'hardness_test': 'other',
|
||||
'adhesion_test': 'other',
|
||||
'salt_spray': 'other',
|
||||
'packaging': 'other',
|
||||
'gating': 'other',
|
||||
}
|
||||
|
||||
# -- Name-match heuristic for NULL backfill --------------------------------
|
||||
# Each rule: (substring to match in lower(name), target kind code). First
|
||||
# match wins. Order matters — more specific patterns come first.
|
||||
_NAME_HEURISTIC = [
|
||||
# Most specific
|
||||
('qa-005', 'contract_review'),
|
||||
('contract review', 'contract_review'),
|
||||
('final inspect', 'final_inspect'),
|
||||
('final inspection', 'final_inspect'),
|
||||
('post plate inspect', 'final_inspect'),
|
||||
# Bake / oven
|
||||
('bake', 'bake'),
|
||||
('oven', 'bake'),
|
||||
('he relief', 'bake'),
|
||||
('embrittlement', 'bake'),
|
||||
('stress relief', 'bake'),
|
||||
# Receiving / shipping
|
||||
('receiv', 'receiving'),
|
||||
('incoming inspect', 'receiving'),
|
||||
('ship', 'ship'),
|
||||
('pack', 'ship'),
|
||||
# Racking
|
||||
('de-rack', 'racking'),
|
||||
('deracking', 'racking'),
|
||||
('derack', 'racking'),
|
||||
('rack', 'racking'),
|
||||
# Masking
|
||||
('de-mask', 'mask'),
|
||||
('demask', 'mask'),
|
||||
('unmask', 'mask'),
|
||||
('mask', 'mask'),
|
||||
# Inspection
|
||||
('inspect', 'inspect'),
|
||||
# Plating
|
||||
('plate', 'plate'),
|
||||
('plating', 'plate'),
|
||||
('nickel', 'plate'),
|
||||
('chrome', 'plate'),
|
||||
('anodi', 'plate'),
|
||||
# Wet processes (broad)
|
||||
('soak clean', 'wet_process'),
|
||||
('electroclean', 'wet_process'),
|
||||
('clean', 'wet_process'),
|
||||
('rinse', 'wet_process'),
|
||||
('etch', 'wet_process'),
|
||||
('activ', 'wet_process'),
|
||||
('strike', 'wet_process'),
|
||||
('desmut', 'wet_process'),
|
||||
('zincate', 'wet_process'),
|
||||
('acid', 'wet_process'),
|
||||
('dry', 'wet_process'),
|
||||
('water break', 'wet_process'),
|
||||
('wbf', 'wet_process'),
|
||||
# Gating / ready / wait — soft sequencers, no behaviour
|
||||
('ready for', 'other'),
|
||||
('ready to', 'other'),
|
||||
]
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
# 1. Ensure `other` and `wet_process` exist. Use SQL directly so
|
||||
# we don't depend on the XML having loaded yet.
|
||||
_ensure_kind(cr, 'other', 'Other', 'fa-circle-o', 5)
|
||||
_ensure_kind(cr, 'wet_process', 'Wet Process (Clean / Rinse / Etch / Dry / etc.)', 'fa-tint', 55)
|
||||
|
||||
# 2. Build a code → id map for ALL kinds present in DB.
|
||||
cr.execute("SELECT id, code FROM fp_step_kind")
|
||||
by_code = {code: kid for kid, code in cr.fetchall()}
|
||||
if 'other' not in by_code:
|
||||
_logger.error('pre-migrate: `other` kind missing after _ensure_kind — aborting')
|
||||
return
|
||||
other_id = by_code['other']
|
||||
|
||||
# 3. Re-map references to retired kinds.
|
||||
# `default_kind` is a stored related on `kind_id.code` — updating
|
||||
# kind_id via SQL doesn't auto-recompute the stored copy, so we
|
||||
# write both columns together.
|
||||
for retired_code, new_code in _REMAP.items():
|
||||
retired_id = by_code.get(retired_code)
|
||||
new_id = by_code.get(new_code) or other_id
|
||||
if not retired_id:
|
||||
continue # not in this DB — nothing to remap
|
||||
cr.execute("""
|
||||
UPDATE fp_step_template
|
||||
SET kind_id = %s, default_kind = %s
|
||||
WHERE kind_id = %s
|
||||
""", (new_id, new_code, retired_id))
|
||||
tpl_n = cr.rowcount
|
||||
cr.execute("""
|
||||
UPDATE fusion_plating_process_node
|
||||
SET kind_id = %s, default_kind = %s
|
||||
WHERE kind_id = %s
|
||||
""", (new_id, new_code, retired_id))
|
||||
node_n = cr.rowcount
|
||||
if tpl_n or node_n:
|
||||
_logger.info(
|
||||
'Step Kind curation: remapped %d template(s) + %d node(s) '
|
||||
'from %s → %s', tpl_n, node_n, retired_code, new_code,
|
||||
)
|
||||
|
||||
# 4. Backfill NULL kind_id on both tables via name heuristic.
|
||||
# `name` is jsonb on fp_step_template (translatable in Odoo 19) but
|
||||
# plain varchar on fusion_plating_process_node. Sniff the column
|
||||
# type so the right expression is used.
|
||||
for table in ('fp_step_template', 'fusion_plating_process_node'):
|
||||
cr.execute("""
|
||||
SELECT data_type FROM information_schema.columns
|
||||
WHERE table_name = %s AND column_name = 'name'
|
||||
""", (table,))
|
||||
row = cr.fetchone()
|
||||
col_type = (row[0] if row else '') or ''
|
||||
if 'json' in col_type.lower():
|
||||
name_expr = "COALESCE(name->>'en_US', name::text)"
|
||||
else:
|
||||
name_expr = 'name'
|
||||
cr.execute(f"""
|
||||
SELECT id, {name_expr} AS name_str
|
||||
FROM {table}
|
||||
WHERE kind_id IS NULL
|
||||
""")
|
||||
rows = cr.fetchall()
|
||||
if not rows:
|
||||
continue
|
||||
# In-process classification to avoid pummelling the DB with
|
||||
# one UPDATE per row.
|
||||
per_kind = {} # kind_id → list of row ids
|
||||
for rid, raw_name in rows:
|
||||
target_code = _classify_by_name(raw_name)
|
||||
target_id = by_code.get(target_code) or other_id
|
||||
per_kind.setdefault(target_id, []).append(rid)
|
||||
# Build a kid → code lookup so we can write default_kind together.
|
||||
by_id = {kid: code for code, kid in by_code.items()}
|
||||
for kid, ids in per_kind.items():
|
||||
cr.execute(
|
||||
f"UPDATE {table} SET kind_id = %s, default_kind = %s "
|
||||
f"WHERE id = ANY(%s)",
|
||||
(kid, by_id.get(kid, 'other'), ids),
|
||||
)
|
||||
_logger.info(
|
||||
'Step Kind curation: backfilled %d %s row(s) — '
|
||||
'distribution: %s',
|
||||
len(rows), table,
|
||||
{next(c for c, i in by_code.items() if i == k): len(v)
|
||||
for k, v in per_kind.items()},
|
||||
)
|
||||
|
||||
|
||||
def _classify_by_name(name):
|
||||
"""Return a step-kind code based on a name match. Falls back to 'other'."""
|
||||
if not name:
|
||||
return 'other'
|
||||
lower = name.lower()
|
||||
for needle, code in _NAME_HEURISTIC:
|
||||
if needle in lower:
|
||||
return code
|
||||
return 'other'
|
||||
|
||||
|
||||
def _ensure_kind(cr, code, name, icon, sequence):
|
||||
"""Create the kind via SQL if it doesn't exist yet. Idempotent.
|
||||
|
||||
fp_step_kind.name is a jsonb (translatable) column in Odoo 19, so
|
||||
we wrap the string in jsonb_build_object('en_US', ...).
|
||||
|
||||
Also registers the ir.model.data entry so the subsequent XML data
|
||||
load (which runs AFTER pre-migrate) sees the xmlid as already
|
||||
bound and skips creation — otherwise we get duplicate records.
|
||||
"""
|
||||
cr.execute("SELECT id FROM fp_step_kind WHERE code = %s", (code,))
|
||||
row = cr.fetchone()
|
||||
if row:
|
||||
kid = row[0]
|
||||
else:
|
||||
cr.execute("""
|
||||
INSERT INTO fp_step_kind (code, name, sequence, icon, active,
|
||||
create_uid, create_date, write_uid, write_date)
|
||||
VALUES (%s, jsonb_build_object('en_US', %s::text), %s, %s, true,
|
||||
1, NOW() AT TIME ZONE 'UTC', 1, NOW() AT TIME ZONE 'UTC')
|
||||
RETURNING id
|
||||
""", (code, name, sequence, icon))
|
||||
kid = cr.fetchone()[0]
|
||||
_logger.info('Step Kind curation: created kind %s (id=%s)', code, kid)
|
||||
|
||||
# Bind the xmlid so XML noupdate=1 finds the record on next load.
|
||||
xmlid_name = 'step_kind_%s' % code
|
||||
cr.execute("""
|
||||
SELECT id FROM ir_model_data
|
||||
WHERE module = 'fusion_plating' AND name = %s
|
||||
""", (xmlid_name,))
|
||||
if cr.fetchone():
|
||||
return
|
||||
cr.execute("""
|
||||
INSERT INTO ir_model_data (module, name, model, res_id, noupdate,
|
||||
create_uid, create_date, write_uid, write_date)
|
||||
VALUES ('fusion_plating', %s, 'fp.step.kind', %s, true,
|
||||
1, NOW() AT TIME ZONE 'UTC', 1, NOW() AT TIME ZONE 'UTC')
|
||||
""", (xmlid_name, kid))
|
||||
_logger.info('Step Kind curation: bound xmlid fusion_plating.%s -> id %s',
|
||||
xmlid_name, kid)
|
||||
@@ -80,7 +80,15 @@ class FpParentNumberedMixin(models.AbstractModel):
|
||||
"""
|
||||
self.ensure_one()
|
||||
so = self._fp_parent_sale_order()
|
||||
if not so or not so.x_fc_parent_number:
|
||||
# Defensive: the parent-number column lives in fusion_plating_jobs;
|
||||
# downstream modules (e.g. fusion_plating_receiving) inherit the
|
||||
# mixin but don't depend on jobs, so so.x_fc_parent_number can
|
||||
# raise AttributeError at test time. hasattr keeps the mixin safe
|
||||
# in either install topology — falls through to the legacy
|
||||
# sequence when the column isn't there.
|
||||
if not so or 'x_fc_parent_number' not in so._fields:
|
||||
return False
|
||||
if not so.x_fc_parent_number:
|
||||
return False
|
||||
counter_field = self._fp_parent_counter_field()
|
||||
# Whitelist check — the field name is interpolated directly into
|
||||
|
||||
@@ -493,9 +493,23 @@ class FpProcessNode(models.Model):
|
||||
help='Sub 12b — opens the transition form before Mark Done.',
|
||||
)
|
||||
# Sub 14b — User-extensible Step Kinds (was Selection of 24).
|
||||
# 2026-05-20: required + ondelete='restrict' — kind drives gates,
|
||||
# workflow milestones, and operator routing. Optional was a foot-gun
|
||||
# (operators silently picked Generic / nothing). Pre-migrate
|
||||
# 19.0.20.6.0 backfills every existing row before this NOT NULL
|
||||
# constraint hits the schema.
|
||||
kind_id = fields.Many2one(
|
||||
'fp.step.kind', string='Step Kind', ondelete='set null', index=True,
|
||||
help='Pick from the catalog or create a new kind.',
|
||||
'fp.step.kind', string='Step Kind',
|
||||
ondelete='restrict', index=True,
|
||||
required=True,
|
||||
default=lambda self: self.env['fp.step.kind'].search(
|
||||
[('code', '=', 'other')], limit=1,
|
||||
).id or False,
|
||||
help='Drives operator routing (auto-open Contract Review form / '
|
||||
'Rack assignment dialog / Bake window), customer-portal '
|
||||
'milestones (Received / Plated / Inspected / Shipped), and '
|
||||
'tablet UI (icon, station filter). Pick "Other" only when '
|
||||
'the step has no special behaviour.',
|
||||
)
|
||||
# Back-compat: code-string accessor that all legacy
|
||||
# `node.default_kind == "cleaning"` comparisons keep using.
|
||||
|
||||
@@ -89,11 +89,18 @@ class FpStepTemplate(models.Model):
|
||||
help='Opens the transition form before Mark Done (Sub 12b).')
|
||||
|
||||
# Sub 14b — User-extensible Step Kinds (was Selection of 24).
|
||||
# 2026-05-20: required — same rationale as on fusion.plating.process.node
|
||||
# (kind drives every downstream gate / milestone / routing decision).
|
||||
kind_id = fields.Many2one(
|
||||
'fp.step.kind', string='Step Kind', ondelete='restrict',
|
||||
index=True, tracking=True,
|
||||
help='Pick from the catalog or create a new kind. Drives sane-'
|
||||
'default input seeding.',
|
||||
required=True,
|
||||
default=lambda self: self.env['fp.step.kind'].search(
|
||||
[('code', '=', 'other')], limit=1,
|
||||
).id or False,
|
||||
help='Drives sane-default input seeding plus downstream gates / '
|
||||
'milestones / routing when authors instantiate the template. '
|
||||
'Pick "Other" only when the step has no special behaviour.',
|
||||
)
|
||||
# Back-compat shim — every legacy `tpl.default_kind == "cleaning"`
|
||||
# call site keeps working without a refactor. Stored=True so existing
|
||||
|
||||
@@ -86,6 +86,20 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
}
|
||||
|
||||
async loadAll() {
|
||||
// Preserve scroll position across the re-render. .o_fp_simple_editor
|
||||
// is the overflow:auto scroll container — when `state.steps` is
|
||||
// replaced with a fresh array, OWL tears down the t-foreach and
|
||||
// rebuilds every row, which snaps scrollTop back to 0. Operators
|
||||
// hate this: they save a step half-way down the recipe and the
|
||||
// page jumps to the top. Capture the position before the RPC,
|
||||
// restore it after the next paint.
|
||||
//
|
||||
// Regression note (2026-05-20): every save/insert/remove/promote
|
||||
// handler calls loadAll, so this single choke point fixes scroll
|
||||
// reset for the whole editor.
|
||||
const scrollRoot = document.querySelector(".o_fp_simple_editor");
|
||||
const savedScrollTop = scrollRoot ? scrollRoot.scrollTop : 0;
|
||||
|
||||
this.state.loading = true;
|
||||
const [recipeData, libraryData, templateData] = await Promise.all([
|
||||
rpc("/fp/simple_recipe/load", { recipe_id: this._recipeId }),
|
||||
@@ -97,6 +111,21 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
this.state.library = libraryData.templates;
|
||||
this.state.templateOptions = templateData.templates;
|
||||
this.state.loading = false;
|
||||
|
||||
// Restore AFTER OWL repaints. Microtask runs before paint in OWL 2;
|
||||
// we need rAF (or two of them, defensively) so the rebuilt DOM
|
||||
// exists when we set scrollTop. Without this the assignment fires
|
||||
// against the pre-render DOM and gets discarded.
|
||||
if (savedScrollTop > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(".o_fp_simple_editor");
|
||||
if (el) {
|
||||
el.scrollTop = savedScrollTop;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onSearchLibrary(ev) {
|
||||
@@ -152,6 +181,61 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
await this.loadAll();
|
||||
}
|
||||
|
||||
// ---- Promote / demote -------------------------------------------------
|
||||
//
|
||||
// Substep → operation: turn a child step into a top-level operation
|
||||
// under the recipe root (or sub-process root if applicable).
|
||||
// Operation → substep: tuck a top-level operation under the
|
||||
// preceding operation as one of its substeps. Handy when the author
|
||||
// realises a "header" should actually live as part of another
|
||||
// operation's workflow.
|
||||
|
||||
async onPromoteStep(stepId) {
|
||||
const proceed = await this._confirm(
|
||||
_t(
|
||||
"Promote this substep to a top-level operation? It will be " +
|
||||
"moved out of its parent operation and placed directly under " +
|
||||
"the recipe."
|
||||
)
|
||||
);
|
||||
if (!proceed) return;
|
||||
const res = await rpc("/fp/simple_recipe/step/promote", {
|
||||
node_id: stepId,
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.notification.add(
|
||||
res.message || _t("Could not promote step."),
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.loadAll();
|
||||
this.notification.add(_t("Step promoted to operation."), { type: "success" });
|
||||
}
|
||||
|
||||
async onDemoteStep(stepId) {
|
||||
const proceed = await this._confirm(
|
||||
_t(
|
||||
"Demote this operation to a substep under the previous " +
|
||||
"operation? It will be tucked underneath the operation " +
|
||||
"immediately above it in the list."
|
||||
)
|
||||
);
|
||||
if (!proceed) return;
|
||||
const res = await rpc("/fp/simple_recipe/step/demote", {
|
||||
node_id: stepId,
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.notification.add(
|
||||
res.message || _t("Could not demote step."),
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.loadAll();
|
||||
this.notification.add(_t("Operation demoted to substep."), { type: "success" });
|
||||
}
|
||||
|
||||
async onAddInlineStep() {
|
||||
await rpc("/fp/simple_recipe/step/insert", {
|
||||
recipe_id: this._recipeId,
|
||||
@@ -328,7 +412,10 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
name: name.trim(),
|
||||
});
|
||||
if (!data.ok) {
|
||||
alert(data.error || "Could not create Step Kind.");
|
||||
// 2026-05-20 — backend forbids non-managers from
|
||||
// creating kinds. Surface the explanatory message
|
||||
// instead of a generic error code.
|
||||
alert(data.message || data.error || "Could not create Step Kind.");
|
||||
return;
|
||||
}
|
||||
// Drop the cached list so the next ensure() refetches it.
|
||||
@@ -642,11 +729,18 @@ export class FpSimpleRecipeEditor extends Component {
|
||||
// Sub 14 — make sure the workflow-state catalog is cached so
|
||||
// the dropdown in the inline form has options to render.
|
||||
await this._fpEnsureWorkflowStatesLoaded();
|
||||
// 2026-05-20 — Step Type dropdown is now driven by the
|
||||
// fp.step.kind catalog (curated to 12 active kinds). Cache the
|
||||
// list before opening the panel so the select renders with
|
||||
// options instead of being empty.
|
||||
await this._fpEnsureKindOptionsLoaded();
|
||||
this.state.editingStepId = stepId;
|
||||
this.state.editName = step.name || "";
|
||||
this.state.editInstructions = this._htmlToText(step.description || "");
|
||||
// Settings the user can now change WITHOUT delete + re-add.
|
||||
this.state.editDefaultKind = step.default_kind || "";
|
||||
// Default to 'other' when no kind is set — kind_id is required
|
||||
// on the model so we never want a blank value to round-trip.
|
||||
this.state.editDefaultKind = step.default_kind || "other";
|
||||
this.state.editTriggersWorkflowStateId =
|
||||
step.triggers_workflow_state_id || false;
|
||||
this.state.editParallelStart = !!step.parallel_start;
|
||||
|
||||
@@ -116,6 +116,10 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 1rem;
|
||||
// align-items: start so the library panel can be shorter than
|
||||
// the recipe-step column without stretching to match its height
|
||||
// — required for sticky positioning to behave.
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 900px) {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -137,6 +141,33 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
||||
}
|
||||
}
|
||||
|
||||
// Step Library — pin to the top of the scroll container so authors
|
||||
// can drag from it into the recipe without scrolling back up.
|
||||
// Recipes can be 40+ steps long; before this, the library scrolled
|
||||
// off with the page and you had to scroll to the top, grab a step,
|
||||
// scroll back down, drop. Bug reported 2026-05-20.
|
||||
//
|
||||
// Sticky inside the editor's overflow:auto container. max-height +
|
||||
// internal overflow-y so the library's OWN content (could be 30+
|
||||
// entries) doesn't blow past the viewport — it grows a scrollbar
|
||||
// instead.
|
||||
.o_fp_library_panel {
|
||||
position: sticky;
|
||||
top: 1rem; // matches the editor's padding
|
||||
max-height: calc(100vh - 8rem); // leave room for headers + footer
|
||||
overflow-y: auto;
|
||||
// Keep a faint shadow on the sticky edge so it reads as a
|
||||
// floating sidebar, not glued onto the recipe column.
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
@media (max-width: 900px) {
|
||||
// Stacked layout — no sticky, behaves like a normal block.
|
||||
position: static;
|
||||
max-height: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================== Drop simulator
|
||||
//
|
||||
// Thin reservation line between rows that activates only when the
|
||||
@@ -224,6 +255,52 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
|
||||
padding: .125rem .5rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
// Tree-editor-authored recipes can have operations nested inside a
|
||||
// sub_process; the Simple Editor flattens those into the same list
|
||||
// but tags them with a small "inside <sub-process>" badge so the
|
||||
// author isn't confused about where they came from.
|
||||
.o_fp_nested_under,
|
||||
.o_fp_substep_parent {
|
||||
font-size: .7rem;
|
||||
font-weight: 500;
|
||||
padding: .15rem .45rem;
|
||||
border-radius: 999px;
|
||||
background: $fp-se-page;
|
||||
color: $fp-se-muted;
|
||||
i { opacity: .7; }
|
||||
}
|
||||
|
||||
// Step nodes inside an operation are rendered as indented sub-rows
|
||||
// — same node model as operations, but they're sub-instructions
|
||||
// (the WO generator folds them into the operation's instruction
|
||||
// text). Visual treatment: smaller, indented, no drag handle, no
|
||||
// numeric position so the eye can tell them apart from operations.
|
||||
&.o_fp_substep_row {
|
||||
padding-left: 2.5rem;
|
||||
background: transparent;
|
||||
font-size: .92em;
|
||||
opacity: .85;
|
||||
.o_fp_step_name { font-weight: 400; }
|
||||
.o_fp_substep_indent {
|
||||
color: $fp-se-muted;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
.o_fp_step_promote,
|
||||
.o_fp_step_demote {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $fp-se-muted;
|
||||
padding: .2rem .4rem;
|
||||
cursor: pointer;
|
||||
font-size: .85rem;
|
||||
border-radius: 4px;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
&:hover {
|
||||
background: $fp-se-page;
|
||||
color: $fp-se-accent;
|
||||
}
|
||||
}
|
||||
.o_fp_step_edit,
|
||||
.o_fp_step_remove {
|
||||
background: none;
|
||||
|
||||
@@ -68,14 +68,29 @@
|
||||
|
||||
<t t-foreach="state.steps" t-as="step" t-key="step.id">
|
||||
<div class="o_fp_step_row"
|
||||
t-att-class="state.editingStepId === step.id ? 'o_fp_step_row_editing' : ''"
|
||||
t-att-class="(state.editingStepId === step.id ? 'o_fp_step_row_editing ' : '') + (step.is_substep ? 'o_fp_substep_row' : '')"
|
||||
draggable="true"
|
||||
t-on-dragstart="(ev) => this.onSelectedDragStart(step.id, ev)"
|
||||
t-on-dragover="(ev) => this.onRowDragOver(step_index, ev)">
|
||||
<span class="o_fp_drag_handle">⠿</span>
|
||||
<span class="o_fp_step_position"><t t-esc="step_index + 1"/>.</span>
|
||||
<i t-att-class="'fa ' + (step.icon || 'fa-cog')"/>
|
||||
<span class="o_fp_drag_handle" t-if="!step.is_substep">⠿</span>
|
||||
<span class="o_fp_drag_handle o_fp_substep_indent" t-if="step.is_substep" title="Drag to reorder among substeps of the same operation">⠿</span>
|
||||
<span class="o_fp_step_position" t-if="!step.is_substep">
|
||||
<t t-esc="step_index + 1"/>.
|
||||
</span>
|
||||
<i t-att-class="'fa ' + (step.icon || (step.is_substep ? 'fa-circle-o' : 'fa-cog'))"/>
|
||||
<span class="o_fp_step_name" t-esc="step.name"/>
|
||||
<span class="o_fp_nested_under badge bg-light text-muted ms-1"
|
||||
t-if="step.nested_under and !step.is_substep"
|
||||
t-att-title="'Inside sub-process: ' + step.nested_under">
|
||||
<i class="fa fa-sitemap me-1"/>
|
||||
<t t-esc="step.nested_under"/>
|
||||
</span>
|
||||
<span class="o_fp_substep_parent badge bg-light text-muted ms-1"
|
||||
t-if="step.is_substep and step.nested_under"
|
||||
title="Sub-step of the operation above">
|
||||
<i class="fa fa-level-up fa-rotate-90 me-1"/>
|
||||
<t t-esc="step.nested_under"/>
|
||||
</span>
|
||||
<span class="o_fp_step_has_instructions"
|
||||
t-if="step.description"
|
||||
title="Has operator instructions">
|
||||
@@ -92,6 +107,18 @@
|
||||
<i class="fa fa-clipboard"/>
|
||||
<t t-esc="step.measurements_badge_text"/>
|
||||
</span>
|
||||
<button class="o_fp_step_promote"
|
||||
t-if="step.is_substep"
|
||||
title="Promote: turn this substep into a top-level operation"
|
||||
t-on-click="() => this.onPromoteStep(step.id)">
|
||||
<i class="fa fa-arrow-up"/>
|
||||
</button>
|
||||
<button class="o_fp_step_demote"
|
||||
t-if="!step.is_substep"
|
||||
title="Demote: tuck this operation under the previous one as a substep"
|
||||
t-on-click="() => this.onDemoteStep(step.id)">
|
||||
<i class="fa fa-arrow-down"/>
|
||||
</button>
|
||||
<button class="o_fp_step_edit"
|
||||
title="Edit name & instructions"
|
||||
t-on-click="() => this.onToggleEdit(step.id)">
|
||||
@@ -130,34 +157,40 @@
|
||||
below it lets them override per-step. -->
|
||||
<div class="o_fp_edit_row" style="display: flex; gap: 16px; flex-wrap: wrap;">
|
||||
<div class="o_fp_edit_field" style="flex: 1; min-width: 240px;">
|
||||
<label>Step Type (Default Kind)</label>
|
||||
<label>Step Type (Default Kind) *</label>
|
||||
<!-- 2026-05-20: hard-coded option list
|
||||
retired. The dropdown now drives
|
||||
off `state.kindOptions` (fp.step.kind
|
||||
records with active=True), which is
|
||||
the curated catalog (Other,
|
||||
Receiving, Contract Review, Racking,
|
||||
Masking, Wet Process, Plating, Bake,
|
||||
Inspection, Final Inspection,
|
||||
Shipping). New kinds need a manager
|
||||
+ code work to wire downstream gates;
|
||||
see kinds_create lockdown. -->
|
||||
<select class="form-select"
|
||||
t-on-change="(ev) => { state.editDefaultKind = ev.target.value; }">
|
||||
<option value="" t-att-selected="!state.editDefaultKind">— Generic —</option>
|
||||
<option value="receiving" t-att-selected="state.editDefaultKind === 'receiving'">Receiving / Incoming Inspection</option>
|
||||
<option value="contract_review" t-att-selected="state.editDefaultKind === 'contract_review'">Contract Review (QA-005)</option>
|
||||
<option value="racking" t-att-selected="state.editDefaultKind === 'racking'">Racking</option>
|
||||
<option value="mask" t-att-selected="state.editDefaultKind === 'mask'">Masking</option>
|
||||
<option value="cleaning" t-att-selected="state.editDefaultKind === 'cleaning'">Cleaning</option>
|
||||
<option value="electroclean" t-att-selected="state.editDefaultKind === 'electroclean'">Electroclean</option>
|
||||
<option value="etch" t-att-selected="state.editDefaultKind === 'etch'">Etch / Activation</option>
|
||||
<option value="rinse" t-att-selected="state.editDefaultKind === 'rinse'">Rinse</option>
|
||||
<option value="strike" t-att-selected="state.editDefaultKind === 'strike'">Strike</option>
|
||||
<option value="plate" t-att-selected="state.editDefaultKind === 'plate'">Plating</option>
|
||||
<option value="replenishment" t-att-selected="state.editDefaultKind === 'replenishment'">Tank Replenishment</option>
|
||||
<option value="wbf_test" t-att-selected="state.editDefaultKind === 'wbf_test'">Water Break Free Test</option>
|
||||
<option value="dry" t-att-selected="state.editDefaultKind === 'dry'">Drying</option>
|
||||
<option value="bake" t-att-selected="state.editDefaultKind === 'bake'">Bake</option>
|
||||
<option value="demask" t-att-selected="state.editDefaultKind === 'demask'">De-Masking</option>
|
||||
<option value="derack" t-att-selected="state.editDefaultKind === 'derack'">De-Racking</option>
|
||||
<option value="inspect" t-att-selected="state.editDefaultKind === 'inspect'">Inspection</option>
|
||||
<option value="final_inspect" t-att-selected="state.editDefaultKind === 'final_inspect'">Final Inspection</option>
|
||||
<option value="ship" t-att-selected="state.editDefaultKind === 'ship'">Shipping</option>
|
||||
<t t-foreach="state.kindOptions || []" t-as="k" t-key="k.id">
|
||||
<option t-att-value="k.code"
|
||||
t-att-selected="k.code === state.editDefaultKind">
|
||||
<t t-esc="k.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<p class="o_fp_edit_hint">
|
||||
Drives workflow milestone triggers (e.g. <code>final_inspect</code> fires
|
||||
the Inspected status) and routing (e.g. <code>contract_review</code> opens
|
||||
QA-005 instead of the input wizard).
|
||||
Required. Drives operator routing
|
||||
(<code>contract_review</code> opens
|
||||
QA-005, <code>racking</code> opens
|
||||
rack picker, <code>bake</code> ties
|
||||
to bake-window state machine),
|
||||
customer-portal milestones
|
||||
(<code>receiving</code> / <code>plate</code>
|
||||
/ <code>final_inspect</code> /
|
||||
<code>ship</code>), and tablet UI
|
||||
(icon, station-type filter). Pick
|
||||
<strong>Other</strong> only when the
|
||||
step has no special behaviour.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -473,13 +506,19 @@
|
||||
<select class="form-select"
|
||||
t-on-change="(ev) => this.onKindChange(ev)"
|
||||
t-att-value="state.libraryEditor.default_kind">
|
||||
<option value="">Generic — no automatic behaviour</option>
|
||||
<t t-foreach="state.kindOptions || []" t-as="k" t-key="k.id">
|
||||
<option t-att-value="k.code" t-att-selected="k.code === state.libraryEditor.default_kind">
|
||||
<t t-esc="k.name"/>
|
||||
</option>
|
||||
</t>
|
||||
<option value="__new__">+ Add a new kind…</option>
|
||||
<!-- Manager-only inline create. The
|
||||
backend kinds_create endpoint
|
||||
also gates on this group, so
|
||||
hiding here is just to avoid
|
||||
showing a button that
|
||||
immediately errors. -->
|
||||
<option value="__new__"
|
||||
t-if="state.recipe and state.recipe.user_is_manager">+ Add a new kind…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="o_fp_le_field">
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
from . import test_fp_work_centre
|
||||
from . import test_fp_job_state_machine
|
||||
from . import test_fp_job_step_state_machine
|
||||
from . import test_simple_recipe_flatten
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Bug surfaced 2026-05-20 on ENP-STEEL-BASIC: a recipe authored in the
|
||||
# Tree Editor has `sub_process` nodes holding more operations
|
||||
# underneath. The Simple Editor used to walk `recipe.child_ids` only,
|
||||
# silently hiding any operation nested inside a sub_process. The work
|
||||
# order generator on the same recipe DID see them, so author + operator
|
||||
# disagreed about what was in the recipe. This test pins the new
|
||||
# depth-first flattening behaviour.
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestSimpleRecipeFlatten(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
Node = cls.env['fusion.plating.process.node']
|
||||
# Tree shape:
|
||||
# Recipe
|
||||
# ├── Op A (top-level)
|
||||
# ├── Sub-process X
|
||||
# │ ├── Op B (nested under X)
|
||||
# │ └── Op C (nested under X)
|
||||
# └── Op D (top-level, after sub-process)
|
||||
cls.recipe = Node.create({
|
||||
'name': 'Test Tree Recipe',
|
||||
'node_type': 'recipe',
|
||||
'sequence': 10,
|
||||
})
|
||||
cls.op_a = Node.create({
|
||||
'name': 'Op A', 'node_type': 'operation',
|
||||
'parent_id': cls.recipe.id, 'sequence': 10,
|
||||
})
|
||||
cls.sub_x = Node.create({
|
||||
'name': 'Sub-X', 'node_type': 'sub_process',
|
||||
'parent_id': cls.recipe.id, 'sequence': 20,
|
||||
})
|
||||
cls.op_b = Node.create({
|
||||
'name': 'Op B', 'node_type': 'operation',
|
||||
'parent_id': cls.sub_x.id, 'sequence': 10,
|
||||
})
|
||||
cls.op_c = Node.create({
|
||||
'name': 'Op C', 'node_type': 'operation',
|
||||
'parent_id': cls.sub_x.id, 'sequence': 20,
|
||||
})
|
||||
cls.op_d = Node.create({
|
||||
'name': 'Op D', 'node_type': 'operation',
|
||||
'parent_id': cls.recipe.id, 'sequence': 30,
|
||||
})
|
||||
|
||||
def _flatten(self):
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
ctrl = SimpleRecipeController()
|
||||
return ctrl._flatten_recipe_operations(self.recipe)
|
||||
|
||||
def test_flat_recipe_returns_top_level_only(self):
|
||||
# Sanity: a flat recipe (no sub-processes) returns its direct
|
||||
# operation children with empty path labels.
|
||||
flat = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Flat', 'node_type': 'recipe', 'sequence': 1,
|
||||
})
|
||||
for name in ('A', 'B', 'C'):
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': name, 'node_type': 'operation',
|
||||
'parent_id': flat.id, 'sequence': 10,
|
||||
})
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
ops = SimpleRecipeController()._flatten_recipe_operations(flat)
|
||||
self.assertEqual([n.name for n, _ in ops], ['A', 'B', 'C'])
|
||||
self.assertEqual([p for _, p in ops], ['', '', ''])
|
||||
|
||||
def test_nested_operations_surface_with_path(self):
|
||||
ops = self._flatten()
|
||||
names = [n.name for n, _ in ops]
|
||||
# Op B / Op C live INSIDE Sub-X — the old load returned 3 ops
|
||||
# (Op A, Op D, plus Sub-X itself); the new one returns 4
|
||||
# operations and skips the sub_process node.
|
||||
self.assertEqual(names, ['Op A', 'Op B', 'Op C', 'Op D'])
|
||||
|
||||
def test_nested_under_label_carries_sub_process_name(self):
|
||||
ops = self._flatten()
|
||||
paths = {n.name: p for n, p in ops}
|
||||
self.assertEqual(paths['Op A'], '')
|
||||
self.assertEqual(paths['Op B'], 'Sub-X')
|
||||
self.assertEqual(paths['Op C'], 'Sub-X')
|
||||
self.assertEqual(paths['Op D'], '')
|
||||
|
||||
def test_sub_process_itself_is_not_surfaced(self):
|
||||
ops = self._flatten()
|
||||
node_types = {n.node_type for n, _ in ops}
|
||||
self.assertEqual(node_types, {'operation'})
|
||||
# Recipe + sub_process never appear as Simple Editor rows.
|
||||
|
||||
def test_operations_only_helper_skips_step_children(self):
|
||||
# Back-compat: the legacy _flatten_recipe_operations helper
|
||||
# still returns ONLY operations. New callers should use
|
||||
# _flatten_recipe_nodes for the full list (operations + steps).
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Substep 1', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 10,
|
||||
})
|
||||
ops = self._flatten()
|
||||
names = [n.name for n, _ in ops]
|
||||
self.assertNotIn('Substep 1', names)
|
||||
self.assertEqual(names, ['Op A', 'Op B', 'Op C', 'Op D'])
|
||||
|
||||
def test_full_nodes_helper_surfaces_step_children(self):
|
||||
# The Simple Editor's load endpoint uses _flatten_recipe_nodes,
|
||||
# which DOES surface step children. They're emitted right after
|
||||
# their parent operation so the editor renders them as a
|
||||
# contiguous block.
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Substep 1', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 10,
|
||||
})
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Substep 2', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 20,
|
||||
})
|
||||
nodes = SimpleRecipeController()._flatten_recipe_nodes(self.recipe)
|
||||
names = [n.name for n, _ in nodes]
|
||||
# Substeps appear immediately after Op A, before Op B.
|
||||
self.assertEqual(
|
||||
names,
|
||||
['Op A', 'Substep 1', 'Substep 2',
|
||||
'Op B', 'Op C', 'Op D'],
|
||||
)
|
||||
|
||||
def test_substeps_carry_parent_operation_in_path(self):
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': 'My Substep', 'node_type': 'step',
|
||||
'parent_id': self.op_b.id, 'sequence': 10,
|
||||
})
|
||||
nodes = SimpleRecipeController()._flatten_recipe_nodes(self.recipe)
|
||||
paths = {n.name: p for n, p in nodes}
|
||||
# Op B lives in Sub-X; its substep's path chains both.
|
||||
self.assertEqual(paths['My Substep'], 'Sub-X › Op B')
|
||||
|
||||
def test_load_payload_marks_substeps_with_is_substep(self):
|
||||
# End-to-end check on the load endpoint payload: substeps get
|
||||
# `is_substep=True` and `node_type='step'` so the UI can render
|
||||
# them as indented sub-rows.
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': 'A1', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 10,
|
||||
})
|
||||
# Mock the request — load() reads request.env.
|
||||
from unittest.mock import patch
|
||||
ctrl = SimpleRecipeController()
|
||||
class FakeReq:
|
||||
env = self.env
|
||||
path_to_request = (
|
||||
'odoo.addons.fusion_plating.controllers.'
|
||||
'simple_recipe_controller.request'
|
||||
)
|
||||
with patch(path_to_request, FakeReq()):
|
||||
payload = ctrl.load(self.recipe.id)
|
||||
by_name = {s['name']: s for s in payload['steps']}
|
||||
self.assertEqual(by_name['Op A']['node_type'], 'operation')
|
||||
self.assertFalse(by_name['Op A']['is_substep'])
|
||||
self.assertEqual(by_name['A1']['node_type'], 'step')
|
||||
self.assertTrue(by_name['A1']['is_substep'])
|
||||
self.assertEqual(by_name['A1']['nested_under'], 'Op A')
|
||||
|
||||
def test_load_endpoint_includes_nested_under_in_payload(self):
|
||||
# Direct call to the controller's load (mirroring the JSONRPC).
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
# The endpoint uses request.env; mock by patching the controller's
|
||||
# internal helper to use self.env instead. The flat helper is the
|
||||
# piece worth pinning here; integration with HTTP layer is
|
||||
# exercised live on entech.
|
||||
ctrl = SimpleRecipeController()
|
||||
flat = ctrl._flatten_recipe_operations(self.recipe)
|
||||
names_with_path = [(n.name, p) for n, p in flat]
|
||||
self.assertIn(('Op B', 'Sub-X'), names_with_path)
|
||||
self.assertIn(('Op A', ''), names_with_path)
|
||||
|
||||
def test_promote_turns_substep_into_operation(self):
|
||||
# Add a substep under op_a, promote it, verify it moved.
|
||||
sub = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Sub1', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 10,
|
||||
})
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
from unittest.mock import patch
|
||||
class FakeReq:
|
||||
env = self.env
|
||||
path = ('odoo.addons.fusion_plating.controllers.'
|
||||
'simple_recipe_controller.request')
|
||||
with patch(path, FakeReq()):
|
||||
res = SimpleRecipeController().step_promote(sub.id)
|
||||
self.assertTrue(res['ok'])
|
||||
sub.invalidate_recordset()
|
||||
self.assertEqual(sub.node_type, 'operation')
|
||||
self.assertEqual(sub.parent_id.id, self.recipe.id)
|
||||
|
||||
def test_promote_rejects_non_substep(self):
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
from unittest.mock import patch
|
||||
class FakeReq:
|
||||
env = self.env
|
||||
path = ('odoo.addons.fusion_plating.controllers.'
|
||||
'simple_recipe_controller.request')
|
||||
with patch(path, FakeReq()):
|
||||
res = SimpleRecipeController().step_promote(self.op_a.id)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertEqual(res['error'], 'not_a_substep')
|
||||
|
||||
def test_demote_turns_operation_into_substep_under_previous(self):
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
from unittest.mock import patch
|
||||
class FakeReq:
|
||||
env = self.env
|
||||
path = ('odoo.addons.fusion_plating.controllers.'
|
||||
'simple_recipe_controller.request')
|
||||
# Demote Op D into Sub-X (its preceding operation is op_a at
|
||||
# the recipe root, but Sub-X is between them — the preceding
|
||||
# OPERATION sibling at the recipe root is op_a).
|
||||
with patch(path, FakeReq()):
|
||||
res = SimpleRecipeController().step_demote(self.op_d.id)
|
||||
self.assertTrue(res['ok'])
|
||||
self.op_d.invalidate_recordset()
|
||||
self.assertEqual(self.op_d.node_type, 'step')
|
||||
# The preceding operation at the recipe root is op_a (Sub-X is
|
||||
# not an operation, gets filtered out).
|
||||
self.assertEqual(self.op_d.parent_id.id, self.op_a.id)
|
||||
|
||||
def test_demote_blocks_when_operation_has_children(self):
|
||||
# op_a gets a substep — now demoting op_a should fail because
|
||||
# it has children.
|
||||
self.env['fusion.plating.process.node'].create({
|
||||
'name': 'A-child', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 10,
|
||||
})
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
from unittest.mock import patch
|
||||
class FakeReq:
|
||||
env = self.env
|
||||
path = ('odoo.addons.fusion_plating.controllers.'
|
||||
'simple_recipe_controller.request')
|
||||
with patch(path, FakeReq()):
|
||||
res = SimpleRecipeController().step_demote(self.op_a.id)
|
||||
self.assertFalse(res['ok'])
|
||||
self.assertEqual(res['error'], 'has_children')
|
||||
|
||||
def test_reorder_renumbers_per_parent(self):
|
||||
# Add two substeps under op_a so reorder has something to swap.
|
||||
s1 = self.env['fusion.plating.process.node'].create({
|
||||
'name': 's1', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 10,
|
||||
})
|
||||
s2 = self.env['fusion.plating.process.node'].create({
|
||||
'name': 's2', 'node_type': 'step',
|
||||
'parent_id': self.op_a.id, 'sequence': 20,
|
||||
})
|
||||
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
|
||||
import SimpleRecipeController
|
||||
from unittest.mock import patch
|
||||
class FakeReq:
|
||||
env = self.env
|
||||
path = ('odoo.addons.fusion_plating.controllers.'
|
||||
'simple_recipe_controller.request')
|
||||
# Send reversed order — s2 should come out at seq=10, s1 at 20.
|
||||
with patch(path, FakeReq()):
|
||||
SimpleRecipeController().step_reorder([s2.id, s1.id])
|
||||
s1.invalidate_recordset()
|
||||
s2.invalidate_recordset()
|
||||
self.assertEqual(s2.sequence, 10)
|
||||
self.assertEqual(s1.sequence, 20)
|
||||
|
||||
def test_deeply_nested_sub_processes_chain_path_labels(self):
|
||||
# Three levels: recipe → Sub-Outer → Sub-Inner → Op-Deep
|
||||
outer = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Sub-Outer', 'node_type': 'sub_process',
|
||||
'parent_id': self.recipe.id, 'sequence': 40,
|
||||
})
|
||||
inner = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Sub-Inner', 'node_type': 'sub_process',
|
||||
'parent_id': outer.id, 'sequence': 10,
|
||||
})
|
||||
op_deep = self.env['fusion.plating.process.node'].create({
|
||||
'name': 'Op-Deep', 'node_type': 'operation',
|
||||
'parent_id': inner.id, 'sequence': 10,
|
||||
})
|
||||
ops = self._flatten()
|
||||
deep_paths = {n.name: p for n, p in ops if n.name == 'Op-Deep'}
|
||||
# Path chains the parent labels with ' › '
|
||||
self.assertEqual(deep_paths['Op-Deep'], 'Sub-Outer › Sub-Inner')
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
"name": "Fusion Plating — MRP Bridge",
|
||||
'version': '19.0.13.0.2',
|
||||
'version': '19.0.13.0.3',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
|
||||
'description': """
|
||||
|
||||
@@ -420,14 +420,16 @@ class SaleOrder(models.Model):
|
||||
if recv_status == 'not_received':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'partial' or recv_status == 'received':
|
||||
so.x_fc_workflow_stage = 'inspecting'
|
||||
if recv_status == 'partial':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'inspected':
|
||||
if recv_status == 'received':
|
||||
# Sub 8: 'received' is the terminal receiving state.
|
||||
# Inspection happens in the recipe's racking step, not
|
||||
# in receiving.
|
||||
if not so.x_fc_assigned_manager_id:
|
||||
so.x_fc_workflow_stage = 'assign_work'
|
||||
continue
|
||||
# Manager assigned, MOs exist → in production
|
||||
so.x_fc_workflow_stage = 'in_production'
|
||||
continue
|
||||
|
||||
@@ -450,17 +452,23 @@ class SaleOrder(models.Model):
|
||||
return True
|
||||
|
||||
def action_fp_accept_parts(self):
|
||||
"""Mark receiving as accepted; this unlocks manager assignment."""
|
||||
"""Mark receiving as accepted; this unlocks manager assignment.
|
||||
|
||||
Sub 8: receiving's terminal state is 'closed' (post-Sub-8) or
|
||||
'accepted' (legacy). Either maps to SO status 'received'. The
|
||||
old 'inspected' SO status no longer exists.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Recv = self.env.get('fp.receiving')
|
||||
if Recv is None:
|
||||
return False
|
||||
for rec in Recv.search([('sale_order_id', '=', self.id)]):
|
||||
if rec.state in ('draft', 'inspecting'):
|
||||
if rec.state in ('draft', 'counted', 'staged'):
|
||||
rec.state = 'closed'
|
||||
elif rec.state == 'inspecting':
|
||||
rec.state = 'accepted'
|
||||
# flip SO receiving status to 'inspected' if possible
|
||||
if 'x_fc_receiving_status' in self._fields:
|
||||
self.x_fc_receiving_status = 'inspected'
|
||||
self.x_fc_receiving_status = 'received'
|
||||
self.message_post(body=_('Parts accepted — ready to assign manager.'))
|
||||
return True
|
||||
|
||||
|
||||
@@ -95,10 +95,20 @@ class FpFair(models.Model):
|
||||
}
|
||||
|
||||
def action_view_signed_document(self):
|
||||
"""Open the signed PDF attachment in a new browser tab."""
|
||||
"""Open the signed PDF in the fusion_pdf_preview dialog.
|
||||
|
||||
Falls back to a new-tab URL when the helper isn't installed.
|
||||
See CLAUDE.md "PDF Preview" for the contract.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_signed_pdf_id:
|
||||
return False
|
||||
if hasattr(self.x_fc_signed_pdf_id, 'action_fusion_preview'):
|
||||
return self.x_fc_signed_pdf_id.action_fusion_preview(
|
||||
title=self.x_fc_signed_pdf_id.name or 'Signed FAIR',
|
||||
model_name=self._name,
|
||||
record_ids=self.id,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_url',
|
||||
'url': '/web/content/%s?download=true' % self.x_fc_signed_pdf_id.id,
|
||||
|
||||
@@ -3,4 +3,10 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
# Note: `lib/` is NOT eagerly imported here — Python's relative-import
|
||||
# machinery would otherwise re-enter this package mid-init when the
|
||||
# wizard module does `from ..lib.fischerscope_parser import …`, raising
|
||||
# "cannot import name X from partially initialized module" on Python
|
||||
# 3.11+. lib is imported lazily where it's used (action_parse).
|
||||
from . import models
|
||||
from . import wizards
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Certificates',
|
||||
'version': '19.0.6.1.0',
|
||||
'version': '19.0.7.8.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
|
||||
'description': """
|
||||
@@ -37,6 +37,8 @@ Includes Fischerscope thickness measurement data capture.
|
||||
'views/fp_certificate_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/fp_certificates_menu.xml',
|
||||
'wizards/fp_cert_void_wizard_views.xml',
|
||||
'wizards/fp_thickness_upload_wizard_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Parser libraries for fusion_plating_certificates.
|
||||
# Pure-Python modules, no Odoo imports — safe to unit-test in isolation.
|
||||
from . import fischerscope_parser # noqa: F401
|
||||
@@ -0,0 +1,337 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Fischerscope XDAL 600 thickness-report parser.
|
||||
#
|
||||
# Input: bytes of a .docx or .pdf file exported by the gauge.
|
||||
# Output: dict with `readings` (list of per-reading dicts), `metadata`
|
||||
# (single dict with equipment/calibration/operator info), and `image`
|
||||
# (raw bytes of the embedded microscope image, when extractable).
|
||||
#
|
||||
# Pure-Python, no Odoo imports. Suitable for direct unit testing.
|
||||
|
||||
import io
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regexes — derived from the real Fischerscope XDAL 600 export layout.
|
||||
# Sample line:
|
||||
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
|
||||
# Spaces vary; allow flexible whitespace + optional channel digit after NiP/Ni/P.
|
||||
# ---------------------------------------------------------------------------
|
||||
_READING_RE = re.compile(
|
||||
r"""n\s*=\s*(?P<n>\d+) # reading number
|
||||
\s+NiP\s*\d*\s*=\s* # NiP label (channel number optional)
|
||||
(?P<nip>[\d.]+)\s*mils # NiP thickness in mils
|
||||
\s+Ni\s*\d*\s*=\s* # Ni label
|
||||
(?P<ni>[\d.]+)\s*% # Ni percentage
|
||||
\s+P\s*\d*\s*=\s* # P label
|
||||
(?P<p>[\d.]+)\s*% # P percentage
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
# Equipment model — first non-blank line that contains "Fischerscope" or
|
||||
# similar gauge identifier. Captures everything up to end of line.
|
||||
_EQUIPMENT_RE = re.compile(
|
||||
r'(Fischerscope[^\n\r]*)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Product ref: "Product: 2805031 / NiP/Al-alloys 2805030"
|
||||
_PRODUCT_RE = re.compile(
|
||||
r'Product\s*:\s*([^\n\r]+?)(?:\s*$|\s*\n)',
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# Calibration set: "Calibr. Std. Set NiP/Al STD SET SN 100174568"
|
||||
_CALIBR_RE = re.compile(
|
||||
r'Calibr\.?\s*Std\.?\s*Set\s*([^\n\r]+?)(?:\s*$|\s*\n)',
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# Measuring time: "Measuring time 120 sec"
|
||||
_MEAS_TIME_RE = re.compile(
|
||||
r'Measuring\s*time\s*:?\s*(\d+)\s*sec',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Operator: "Operator: BK" (initials or short name)
|
||||
# Stop the capture at: 2+ whitespace, a newline, end-of-string, 2+ digits,
|
||||
# or end-of-line in multiline mode. The bare "Operator: BK\nDate: ..."
|
||||
# case (operator name immediately followed by newline + next field) was
|
||||
# the bug that fell through every other branch.
|
||||
_OPERATOR_RE = re.compile(
|
||||
r'Operator\s*:?\s*([A-Za-z][A-Za-z0-9 .\-]{0,40}?)(?=\s{2,}|\n|$|\s*\d{2,})',
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
# Date + Time: "Date: 5/15/2026 Time: 12:24:46 PM"
|
||||
_DATETIME_RE = re.compile(
|
||||
r'Date\s*:?\s*(\d{1,2}/\d{1,2}/\d{2,4})'
|
||||
r'\s*Time\s*:?\s*(\d{1,2}:\d{2}(?::\d{2})?\s*(?:AM|PM)?)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parse_fischerscope_file(filename, content_bytes):
|
||||
"""Parse a Fischerscope thickness report.
|
||||
|
||||
Branches on file extension:
|
||||
.docx → python-docx (paragraphs + inline_shapes for the image)
|
||||
.pdf → PyPDF2 (text per page; image extraction best-effort)
|
||||
|
||||
Returns:
|
||||
{
|
||||
'success': bool, # True if at least one reading was parsed
|
||||
'readings': [ # list of per-reading dicts
|
||||
{'reading_number': int, 'nip_mils': float,
|
||||
'ni_percent': float, 'p_percent': float},
|
||||
...
|
||||
],
|
||||
'metadata': { # may have None values for missing keys
|
||||
'equipment_model': str | None,
|
||||
'product_ref': str | None,
|
||||
'calibration_std_ref': str | None,
|
||||
'measuring_time_seconds': int | None,
|
||||
'operator_name': str | None,
|
||||
'reading_datetime': datetime | None,
|
||||
},
|
||||
'image': bytes | None, # microscope image, if extractable
|
||||
'image_mime': str | None, # image/jpeg, image/png, etc.
|
||||
'raw_text': str, # extracted text (for debug / fallback)
|
||||
'errors': [str], # non-fatal warnings encountered
|
||||
}
|
||||
|
||||
Never raises on parse failure — returns success=False with readings=[].
|
||||
Raises only on unrecoverable I/O (e.g. corrupted file bytes).
|
||||
"""
|
||||
name = (filename or '').lower()
|
||||
if name.endswith('.docx'):
|
||||
return _parse_docx(content_bytes)
|
||||
if name.endswith('.pdf'):
|
||||
return _parse_pdf(content_bytes)
|
||||
if name.endswith('.doc'):
|
||||
return _failed_result(
|
||||
raw_text='',
|
||||
error=(
|
||||
'Legacy .doc format not supported — re-export from the '
|
||||
'gauge as .docx or .pdf. (python-docx reads .docx only; '
|
||||
'old binary .doc needs LibreOffice conversion which '
|
||||
"isn't installed.)"
|
||||
),
|
||||
)
|
||||
return _failed_result(
|
||||
raw_text='',
|
||||
error='Unsupported file extension: %r. Expected .docx or .pdf.' % filename,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_docx(content_bytes):
|
||||
"""Parse a .docx Fischerscope report."""
|
||||
errors = []
|
||||
try:
|
||||
import docx # python-docx
|
||||
except ImportError:
|
||||
return _failed_result(
|
||||
raw_text='',
|
||||
error='python-docx not installed — cannot parse .docx files.',
|
||||
)
|
||||
try:
|
||||
doc = docx.Document(io.BytesIO(content_bytes))
|
||||
except Exception as e:
|
||||
return _failed_result(raw_text='', error='Could not open .docx: %s' % e)
|
||||
|
||||
# Build the raw text by walking paragraphs AND tables. Fischerscope
|
||||
# exports vary — sometimes the readings are in a table, sometimes
|
||||
# in justified paragraphs. Joining everything gives the regex a
|
||||
# stable target.
|
||||
parts = []
|
||||
for para in doc.paragraphs:
|
||||
text = para.text
|
||||
if text:
|
||||
parts.append(text)
|
||||
for tbl in doc.tables:
|
||||
for row in tbl.rows:
|
||||
row_text = ' '.join(cell.text for cell in row.cells)
|
||||
if row_text.strip():
|
||||
parts.append(row_text)
|
||||
raw_text = '\n'.join(parts)
|
||||
|
||||
# Image: walk inline_shapes + image-parts; pick the first one. The
|
||||
# Fischerscope export embeds exactly one microscope image per report.
|
||||
image_bytes = None
|
||||
image_mime = None
|
||||
try:
|
||||
for rel in doc.part.rels.values():
|
||||
if 'image' in (rel.reltype or '').lower():
|
||||
img_part = rel.target_part
|
||||
image_bytes = img_part.blob
|
||||
image_mime = img_part.content_type
|
||||
break
|
||||
except Exception as e:
|
||||
errors.append('image extraction failed: %s' % e)
|
||||
|
||||
return _build_result(raw_text, errors, image_bytes, image_mime)
|
||||
|
||||
|
||||
def _parse_pdf(content_bytes):
|
||||
"""Parse a .pdf Fischerscope report. Text-based PDFs only."""
|
||||
errors = []
|
||||
try:
|
||||
from PyPDF2 import PdfReader
|
||||
except ImportError:
|
||||
return _failed_result(
|
||||
raw_text='',
|
||||
error='PyPDF2 not installed — cannot parse .pdf files.',
|
||||
)
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(content_bytes))
|
||||
except Exception as e:
|
||||
return _failed_result(raw_text='', error='Could not open PDF: %s' % e)
|
||||
|
||||
raw_text_parts = []
|
||||
for i, page in enumerate(reader.pages):
|
||||
try:
|
||||
raw_text_parts.append(page.extract_text() or '')
|
||||
except Exception as e:
|
||||
errors.append('page %d extract_text failed: %s' % (i + 1, e))
|
||||
raw_text = '\n'.join(raw_text_parts)
|
||||
|
||||
# PDF image extraction is unreliable across PDF producers. Best-
|
||||
# effort: walk page resources looking for /XObject /Image entries.
|
||||
# If anything fails, drop image silently — the operator still has
|
||||
# the original file attached.
|
||||
image_bytes = None
|
||||
image_mime = None
|
||||
try:
|
||||
for page in reader.pages:
|
||||
resources = page.get('/Resources')
|
||||
if not resources:
|
||||
continue
|
||||
xobjects = resources.get('/XObject')
|
||||
if not xobjects:
|
||||
continue
|
||||
x_resolved = xobjects.get_object() if hasattr(xobjects, 'get_object') else xobjects
|
||||
for obj_name in x_resolved:
|
||||
obj = x_resolved[obj_name]
|
||||
obj = obj.get_object() if hasattr(obj, 'get_object') else obj
|
||||
if obj.get('/Subtype') == '/Image':
|
||||
image_bytes = obj.get_data()
|
||||
f = obj.get('/Filter')
|
||||
if f == '/DCTDecode':
|
||||
image_mime = 'image/jpeg'
|
||||
elif f == '/FlateDecode':
|
||||
image_mime = 'image/png'
|
||||
else:
|
||||
image_mime = 'application/octet-stream'
|
||||
break
|
||||
if image_bytes:
|
||||
break
|
||||
except Exception as e:
|
||||
errors.append('PDF image extraction failed: %s' % e)
|
||||
image_bytes = None
|
||||
|
||||
return _build_result(raw_text, errors, image_bytes, image_mime)
|
||||
|
||||
|
||||
def _build_result(raw_text, errors, image_bytes, image_mime):
|
||||
"""Run the regex extractor over raw_text and assemble the result dict."""
|
||||
readings = []
|
||||
for m in _READING_RE.finditer(raw_text):
|
||||
try:
|
||||
readings.append({
|
||||
'reading_number': int(m.group('n')),
|
||||
'nip_mils': float(m.group('nip')),
|
||||
'ni_percent': float(m.group('ni')),
|
||||
'p_percent': float(m.group('p')),
|
||||
})
|
||||
except (ValueError, TypeError) as e:
|
||||
errors.append('reading parse error at offset %d: %s' % (m.start(), e))
|
||||
|
||||
metadata = {
|
||||
'equipment_model': _capture(_EQUIPMENT_RE, raw_text),
|
||||
'product_ref': _capture(_PRODUCT_RE, raw_text),
|
||||
'calibration_std_ref': _capture(_CALIBR_RE, raw_text),
|
||||
'measuring_time_seconds': _capture_int(_MEAS_TIME_RE, raw_text),
|
||||
'operator_name': _capture(_OPERATOR_RE, raw_text),
|
||||
'reading_datetime': _capture_datetime(raw_text),
|
||||
}
|
||||
|
||||
return {
|
||||
'success': bool(readings),
|
||||
'readings': readings,
|
||||
'metadata': metadata,
|
||||
'image': image_bytes,
|
||||
'image_mime': image_mime,
|
||||
'raw_text': raw_text,
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
|
||||
def _failed_result(raw_text, error):
|
||||
return {
|
||||
'success': False,
|
||||
'readings': [],
|
||||
'metadata': {
|
||||
'equipment_model': None,
|
||||
'product_ref': None,
|
||||
'calibration_std_ref': None,
|
||||
'measuring_time_seconds': None,
|
||||
'operator_name': None,
|
||||
'reading_datetime': None,
|
||||
},
|
||||
'image': None,
|
||||
'image_mime': None,
|
||||
'raw_text': raw_text,
|
||||
'errors': [error] if error else [],
|
||||
}
|
||||
|
||||
|
||||
def _capture(rx, text):
|
||||
m = rx.search(text or '')
|
||||
if not m:
|
||||
return None
|
||||
val = m.group(1).strip()
|
||||
return val or None
|
||||
|
||||
|
||||
def _capture_int(rx, text):
|
||||
m = rx.search(text or '')
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return int(m.group(1))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _capture_datetime(text):
|
||||
m = _DATETIME_RE.search(text or '')
|
||||
if not m:
|
||||
return None
|
||||
date_str, time_str = m.group(1).strip(), m.group(2).strip()
|
||||
# Try a few likely formats; the gauge can emit either MM/DD/YYYY or
|
||||
# M/D/YY plus 12h or 24h.
|
||||
for date_fmt in ('%m/%d/%Y', '%m/%d/%y', '%d/%m/%Y', '%d/%m/%y'):
|
||||
for time_fmt in ('%I:%M:%S %p', '%I:%M %p', '%H:%M:%S', '%H:%M'):
|
||||
try:
|
||||
return datetime.strptime('%s %s' % (date_str, time_str),
|
||||
'%s %s' % (date_fmt, time_fmt))
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
@@ -88,6 +88,99 @@ class FpCertificate(models.Model):
|
||||
'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
|
||||
)
|
||||
|
||||
# ----- Inline Fischerscope PDF upload (cert-local) ----------------------
|
||||
# The merge pipeline normally pulls the Fischerscope/XDAL PDF from the
|
||||
# linked QC check. That works when the operator uploaded it via the
|
||||
# tablet, but managers issuing certs after the fact don't want to
|
||||
# navigate to the QC. This pair of fields gives them a direct upload
|
||||
# path on the cert form. When set, _fp_merge_thickness_into_pdf uses
|
||||
# this in preference to the QC-side upload.
|
||||
x_fc_local_thickness_pdf = fields.Binary(
|
||||
string='Fischerscope PDF (Upload Here)',
|
||||
attachment=True,
|
||||
help='Drop the Fischerscope / XDAL 600 XRF export PDF here. '
|
||||
'When the cert is issued it will be appended as page 2 of '
|
||||
'the CoC. Overrides any PDF on the linked QC check.',
|
||||
)
|
||||
x_fc_local_thickness_pdf_filename = fields.Char(
|
||||
string='Fischerscope PDF filename',
|
||||
)
|
||||
|
||||
# Non-PDF Fischerscope uploads (.doc / .docx / .xlsx / images) — the
|
||||
# Issue Certs wizard stashes them here so the thickness-required gate
|
||||
# can still pass. Unlike `x_fc_local_thickness_pdf`, this attachment
|
||||
# is NOT merged into the CoC PDF as page 2 (we can't rasterize .doc
|
||||
# server-side without LibreOffice). It rides along as a separate
|
||||
# evidence attachment on the cert and on any email/portal delivery.
|
||||
x_fc_local_thickness_evidence_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Fischerscope Evidence (non-PDF)',
|
||||
copy=False,
|
||||
help='Original Fischerscope/XRF upload when not a PDF. Counts '
|
||||
'as valid thickness evidence for the cert-issue gate but '
|
||||
'is delivered as a separate attachment, not merged into '
|
||||
'the CoC PDF.',
|
||||
)
|
||||
|
||||
# Report-level Fischerscope metadata — populated by the Issue Certs
|
||||
# wizard when parsing an RTF/.docx upload. Rendered on the CoC so
|
||||
# the printed cert shows the same context an auditor would see on
|
||||
# the original XDAL 600 export (equipment, operator, calibration,
|
||||
# product/application, measuring time, date/time). Per-reading
|
||||
# values (mils, Ni%, P%) live on fp.thickness.reading.
|
||||
x_fc_thickness_equipment = fields.Char(
|
||||
string='Thickness Equipment',
|
||||
help='XRF/thickness gauge model (e.g. "Fischerscope XDAL 600").',
|
||||
)
|
||||
x_fc_thickness_operator = fields.Char(
|
||||
string='Thickness Operator',
|
||||
help='Operator initials/name as recorded by the gauge.',
|
||||
)
|
||||
x_fc_thickness_datetime = fields.Datetime(
|
||||
string='Thickness Reading Date/Time',
|
||||
help='When the readings were taken on the gauge.',
|
||||
)
|
||||
x_fc_thickness_product = fields.Char(
|
||||
string='Thickness Product Profile',
|
||||
help='XDAL 600 product line + part-family reference '
|
||||
'(e.g. "2805031 / NiP/Al-alloys 2805030").',
|
||||
)
|
||||
x_fc_thickness_application = fields.Char(
|
||||
string='Thickness Application',
|
||||
help='XDAL 600 application profile '
|
||||
'(e.g. "16 / NiP/Al-alloys").',
|
||||
)
|
||||
x_fc_thickness_directory = fields.Char(
|
||||
string='Thickness Directory',
|
||||
help='XDAL 600 directory the measurements were saved into.',
|
||||
)
|
||||
x_fc_thickness_measuring_time_sec = fields.Integer(
|
||||
string='Thickness Measuring Time (sec)',
|
||||
help='Per-reading measuring time configured on the gauge.',
|
||||
)
|
||||
x_fc_thickness_source_filename = fields.Char(
|
||||
string='Thickness Source File',
|
||||
help='Filename of the Fischerscope upload the readings were '
|
||||
'parsed from.',
|
||||
)
|
||||
# Two paths populate this field, with operator upload winning:
|
||||
# 1. RTF auto-extraction — Issue Certs wizard runs libwmf
|
||||
# (wmf2svg) on the embedded WMF blocks and picks the
|
||||
# largest raster (header banners filtered by area threshold).
|
||||
# 2. Manual PNG/JPEG upload via the wizard's "Measurement
|
||||
# Image" field — operator override path when the
|
||||
# auto-extracted image is wrong, missing, or low-quality.
|
||||
# See _apply_to_cert and _apply_image_to_cert in the wizard.
|
||||
x_fc_thickness_image_id = fields.Many2one(
|
||||
'ir.attachment',
|
||||
string='Thickness Microscope Image',
|
||||
copy=False,
|
||||
help='Microscope photo of the measurement site. Auto-extracted '
|
||||
'from the Fischerscope RTF export when libwmf can parse '
|
||||
'the embedded WMF; operator can also upload a PNG/JPEG '
|
||||
'directly via the Issue Certs wizard to override.',
|
||||
)
|
||||
|
||||
# ---- Material traceability (T2.3) ----
|
||||
batch_ids = fields.Many2many(
|
||||
'fusion.plating.batch', compute='_compute_batch_ids',
|
||||
@@ -330,6 +423,29 @@ class FpCertificate(models.Model):
|
||||
for rec in self:
|
||||
if rec.state != 'draft':
|
||||
raise UserError(_('Only draft certificates can be issued.'))
|
||||
# Lazy-fill from partner defaults BEFORE running the gates.
|
||||
# Without this, a cert created before partner.x_fc_default_*
|
||||
# was configured would still trip the gate even after sales
|
||||
# set the default. Robust-by-construction: the defaults take
|
||||
# effect retroactively at issue time.
|
||||
if (not rec.contact_partner_id
|
||||
and rec.partner_id
|
||||
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_default_coc_contact_id):
|
||||
rec.contact_partner_id = (
|
||||
rec.partner_id.x_fc_default_coc_contact_id
|
||||
)
|
||||
# Guard with field-existence check — fp.certificate doesn't
|
||||
# declare company_id directly; production picks it up from
|
||||
# auto-creation context but tests can build a cert without
|
||||
# one. Without the guard, AttributeError on the .company_id
|
||||
# access bubbles up as a test error.
|
||||
if (not rec.certified_by_id
|
||||
and 'company_id' in rec._fields
|
||||
and rec.company_id
|
||||
and 'x_fc_owner_user_id' in rec.company_id._fields
|
||||
and rec.company_id.x_fc_owner_user_id):
|
||||
rec.certified_by_id = rec.company_id.x_fc_owner_user_id
|
||||
# Spec reference is what the cert ATTESTS — without it the
|
||||
# cert is just a piece of paper. AS9100 / Nadcap require
|
||||
# naming the spec the work was performed to.
|
||||
@@ -340,24 +456,131 @@ class FpCertificate(models.Model):
|
||||
'(e.g. "AMS 2404", "MIL-C-26074") so the cert '
|
||||
'states which standard the work meets.'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Aerospace / Nadcap customers: actual thickness readings
|
||||
# must be on file BEFORE the cert is issued. The flag lives
|
||||
# on the partner so commercial customers aren't blocked.
|
||||
if (rec.partner_id
|
||||
and 'x_fc_strict_thickness_required' in rec.partner_id._fields
|
||||
and rec.partner_id.x_fc_strict_thickness_required
|
||||
and rec.certificate_type == 'coc'):
|
||||
if not rec.thickness_reading_ids:
|
||||
# Process description (what was done to the parts). Without
|
||||
# it the cert PDF just shows blank process text — customer
|
||||
# has no idea what they paid for. Auto-filled from the
|
||||
# recipe at create time; manager can override before issuing.
|
||||
if not rec.process_description:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Process '
|
||||
'Description is blank.\n\nFill it manually (e.g. '
|
||||
'"ELECTROLESS NICKEL PLATING PER AMS 2404") or '
|
||||
'assign a recipe to the job so it auto-fills.'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Signing authority — the human who attests the work. Auto-
|
||||
# filled from per-spec signer_user_id, falling back to
|
||||
# company.x_fc_owner_user_id. If neither is configured, the
|
||||
# manager must pick before issuing.
|
||||
if not rec.certified_by_id:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Certified By '
|
||||
'is not set.\n\nPick the signing authority, or have '
|
||||
'an admin configure the company\'s Certificate Owner '
|
||||
'(Settings > Fusion Plating).'
|
||||
) % {'name': rec.name or rec.display_name})
|
||||
# Customer contact — the named recipient printed on the
|
||||
# cert and emailed when it ships. Auto-filled from
|
||||
# partner.x_fc_default_coc_contact_id when set.
|
||||
if not rec.contact_partner_id:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — Customer '
|
||||
'Contact is not set.\n\nPick the recipient contact, '
|
||||
'or configure a Default CoC Contact on customer '
|
||||
'"%(cust)s".'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': rec.partner_id.name if rec.partner_id else '?',
|
||||
})
|
||||
if not (rec.contact_partner_id.email or '').strip():
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — contact '
|
||||
'"%(c)s" has no email address.\n\nAdd an email '
|
||||
'to the contact before issuing (the cert is sent '
|
||||
'by email post-issue).'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'c': rec.contact_partner_id.name,
|
||||
})
|
||||
# Thickness data requirement — unified gate covering both
|
||||
# cert types. A customer needs thickness data on the cert
|
||||
# when ANY of these is true:
|
||||
# 1. cert type is thickness_report (the cert IS the data)
|
||||
# 2. partner.x_fc_strict_thickness_required (aerospace /
|
||||
# Nadcap — always strict)
|
||||
# 3. partner.x_fc_send_thickness_report (the bundling
|
||||
# rule — CoC carries thickness as page 2 by default
|
||||
# for these customers; see CLAUDE.md "CoC + thickness
|
||||
# = ONE cert (page 2 merge)")
|
||||
# Acceptable data: logged readings on the cert OR a
|
||||
# Fischerscope PDF on the linked QC OR a cert-local
|
||||
# Fischerscope upload. Any one is enough.
|
||||
partner = rec.partner_id
|
||||
needs_thickness = (
|
||||
rec.certificate_type == 'thickness_report'
|
||||
or (rec.certificate_type == 'coc' and partner and (
|
||||
('x_fc_strict_thickness_required' in partner._fields
|
||||
and partner.x_fc_strict_thickness_required)
|
||||
or ('x_fc_send_thickness_report' in partner._fields
|
||||
and partner.x_fc_send_thickness_report)
|
||||
))
|
||||
)
|
||||
if needs_thickness:
|
||||
has_readings = bool(rec.thickness_reading_ids)
|
||||
has_qc_fischer_pdf = bool(
|
||||
rec.x_fc_thickness_pdf_id
|
||||
if 'x_fc_thickness_pdf_id' in rec._fields else False
|
||||
)
|
||||
has_local_pdf = bool(rec.x_fc_local_thickness_pdf)
|
||||
has_local_evidence = bool(
|
||||
rec.x_fc_local_thickness_evidence_id
|
||||
)
|
||||
if not (has_readings or has_qc_fischer_pdf
|
||||
or has_local_pdf or has_local_evidence):
|
||||
type_label = (
|
||||
_('Thickness Report')
|
||||
if rec.certificate_type == 'thickness_report'
|
||||
else _('CoC')
|
||||
)
|
||||
raise UserError(_(
|
||||
'Cannot issue CoC "%(name)s" — customer "%(cust)s" '
|
||||
'requires actual thickness readings on every CoC '
|
||||
'(Nadcap / aerospace).\n\nLog Fischerscope readings '
|
||||
'against the job for SO %(so)s via the Tablet Station '
|
||||
'before issuing.'
|
||||
'Cannot issue %(type)s "%(name)s" — customer '
|
||||
'"%(cust)s" requires thickness data on every '
|
||||
'%(type)s. No readings, no Fischerscope PDF on '
|
||||
'the linked QC, and no local Fischerscope upload '
|
||||
'on this cert.\n\nUse the Issue Certs wizard '
|
||||
'from the work order to upload the Fischerscope '
|
||||
'report, or log readings against the job for '
|
||||
'SO %(so)s via the Tablet Station.'
|
||||
) % {
|
||||
'type': type_label,
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': partner.name if partner else '?',
|
||||
'so': rec.sale_order_id.name if rec.sale_order_id else '?',
|
||||
})
|
||||
# Defensive qty reconciliation — should already be guaranteed
|
||||
# by fp.job.button_mark_done's gate, but re-checked here so
|
||||
# certs created outside the job flow (manual, scripts) still
|
||||
# can't issue with a mismatched job. No bypass — qty integrity
|
||||
# is non-negotiable at issue.
|
||||
job = (rec.x_fc_job_id
|
||||
if 'x_fc_job_id' in rec._fields else False)
|
||||
if job and job.qty_received:
|
||||
rejects = job.qty_visual_inspection_rejects or 0
|
||||
accounted = (
|
||||
(job.qty_done or 0)
|
||||
+ (job.qty_scrapped or 0)
|
||||
+ rejects
|
||||
)
|
||||
if abs(job.qty_received - accounted) > 0.0001:
|
||||
raise UserError(_(
|
||||
'Cannot issue certificate "%(name)s" — job '
|
||||
'%(job)s qty mismatch (received %(r)g vs '
|
||||
'accounted-out %(a)g). Reconcile job '
|
||||
'quantities before issuing.'
|
||||
) % {
|
||||
'name': rec.name or rec.display_name,
|
||||
'cust': rec.partner_id.name,
|
||||
'so': rec.sale_order_id.name if rec.sale_order_id else '?',
|
||||
'job': job.name,
|
||||
'r': job.qty_received,
|
||||
'a': accounted,
|
||||
})
|
||||
rec.state = 'issued'
|
||||
# Generate the CoC PDF and attach it so action_send_to_customer
|
||||
@@ -371,8 +594,41 @@ class FpCertificate(models.Model):
|
||||
_logger.warning(
|
||||
'Cert %s: PDF render failed: %s', rec.name, e,
|
||||
)
|
||||
# Back-fill the CoC attachment onto the linked delivery
|
||||
# if one exists already. Job._fp_create_delivery handles
|
||||
# the create-time case (cert issued before delivery
|
||||
# spawned); this handles the inverse (delivery spawned
|
||||
# first, cert issued later). Best-effort.
|
||||
try:
|
||||
rec._fp_sync_coc_to_delivery()
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
'Cert %s: CoC->delivery sync failed: %s',
|
||||
rec.name, e,
|
||||
)
|
||||
rec.message_post(body=_('Certificate issued.'))
|
||||
|
||||
def _fp_sync_coc_to_delivery(self):
|
||||
"""Push this CoC's attachment onto its job's delivery so the
|
||||
shipping crew sees the CoC ready to print without hunting for
|
||||
the cert. Only acts on `coc` certs with an attachment_id;
|
||||
delivery field must exist and be empty (don't overwrite an
|
||||
operator's manual choice).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.certificate_type != 'coc' or not self.attachment_id:
|
||||
return
|
||||
job = self.x_fc_job_id if 'x_fc_job_id' in self._fields else False
|
||||
if not job or not job.delivery_id:
|
||||
return
|
||||
delivery = job.delivery_id.sudo()
|
||||
if 'coc_attachment_id' not in delivery._fields:
|
||||
return
|
||||
if delivery.coc_attachment_id:
|
||||
# Operator already picked one; don't overwrite.
|
||||
return
|
||||
delivery.coc_attachment_id = self.attachment_id.id
|
||||
|
||||
def _fp_render_and_attach_pdf(self):
|
||||
"""Render the CoC PDF via the bound report action, OPTIONALLY
|
||||
merge the Fischerscope thickness report PDF (uploaded by the
|
||||
@@ -445,35 +701,48 @@ class FpCertificate(models.Model):
|
||||
self.ensure_one()
|
||||
if self.certificate_type != 'coc':
|
||||
return None
|
||||
# Find the linked job. fp.certificate has either x_fc_job_id
|
||||
# (preferred — added by fusion_plating_jobs) or job_id (older).
|
||||
job = False
|
||||
if 'x_fc_job_id' in self._fields:
|
||||
job = self.x_fc_job_id
|
||||
if not job and 'job_id' in self._fields:
|
||||
job = self.job_id
|
||||
if not job:
|
||||
return None
|
||||
# Find a passed QC on this job with an uploaded Fischerscope PDF.
|
||||
# Prefer state=passed; fall through to any with a PDF.
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
if QC is None:
|
||||
return None
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', job.id),
|
||||
('state', '=', 'passed'),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='completed_at desc', limit=1)
|
||||
if not qc:
|
||||
# Resolution order for the source of the Fischerscope bytes:
|
||||
# 1. Cert-local upload (x_fc_local_thickness_pdf) — manager
|
||||
# dropped it directly on the cert form
|
||||
# 2. Linked QC's thickness_report_pdf_id — operator uploaded
|
||||
# via the tablet during inspection
|
||||
# Either path yields the same merged-PDF outcome.
|
||||
fischer_bytes = b''
|
||||
qc = False
|
||||
if self.x_fc_local_thickness_pdf:
|
||||
try:
|
||||
fischer_bytes = _b64.b64decode(
|
||||
self.x_fc_local_thickness_pdf or b''
|
||||
)
|
||||
except Exception:
|
||||
fischer_bytes = b''
|
||||
if not fischer_bytes:
|
||||
# Fall through to the QC-side PDF.
|
||||
job = False
|
||||
if 'x_fc_job_id' in self._fields:
|
||||
job = self.x_fc_job_id
|
||||
if not job and 'job_id' in self._fields:
|
||||
job = self.job_id
|
||||
if not job:
|
||||
return None
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
if QC is None:
|
||||
return None
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', job.id),
|
||||
('state', '=', 'passed'),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='create_date desc', limit=1)
|
||||
if not qc or not qc.thickness_report_pdf_id:
|
||||
return None
|
||||
fischer_bytes = _b64.b64decode(
|
||||
qc.thickness_report_pdf_id.datas or b''
|
||||
)
|
||||
], order='completed_at desc', limit=1)
|
||||
if not qc:
|
||||
qc = QC.sudo().search([
|
||||
('job_id', '=', job.id),
|
||||
('thickness_report_pdf_id', '!=', False),
|
||||
], order='create_date desc', limit=1)
|
||||
if not qc or not qc.thickness_report_pdf_id:
|
||||
return None
|
||||
fischer_bytes = _b64.b64decode(
|
||||
qc.thickness_report_pdf_id.datas or b''
|
||||
)
|
||||
if not fischer_bytes:
|
||||
return None
|
||||
# Merge — pypdf is the modern name; PyPDF2 still works on older
|
||||
@@ -519,11 +788,41 @@ class FpCertificate(models.Model):
|
||||
'CoC-only.', self.name,
|
||||
)
|
||||
return None
|
||||
source = (
|
||||
_('cert upload') if self.x_fc_local_thickness_pdf
|
||||
else _('QC %s') % (qc.name if qc else '?')
|
||||
)
|
||||
self.message_post(body=_(
|
||||
'Fischerscope thickness report from QC %s appended to CoC PDF.'
|
||||
) % qc.name)
|
||||
'Fischerscope thickness report (%s) appended to CoC PDF.'
|
||||
) % source)
|
||||
return merged
|
||||
|
||||
def action_reset_to_draft(self):
|
||||
"""Move an issued/voided cert back to draft so the manager can
|
||||
correct typos in the thickness metadata, swap the microscope
|
||||
image, re-pick the void reason, etc. — then re-Issue.
|
||||
|
||||
Wipes the existing `attachment_id` so the next render picks up
|
||||
whatever was changed. The original PDF stays around as a
|
||||
regular ir.attachment on the cert (for audit) since we only
|
||||
clear the FK, not the attachment record itself. Re-issue
|
||||
creates a fresh PDF.
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.state == 'draft':
|
||||
raise UserError(_(
|
||||
'Certificate %s is already a draft.'
|
||||
) % rec.name)
|
||||
rec.state = 'draft'
|
||||
old_att = rec.attachment_id
|
||||
if old_att:
|
||||
rec.attachment_id = False
|
||||
rec.message_post(body=_(
|
||||
'Reset to draft for edits. The previously-issued PDF '
|
||||
'%s remains attached for audit; a fresh PDF will be '
|
||||
'generated on re-issue.'
|
||||
) % (old_att.name if old_att else '(none)'))
|
||||
|
||||
def action_void(self):
|
||||
for rec in self:
|
||||
if rec.state != 'issued':
|
||||
@@ -533,6 +832,33 @@ class FpCertificate(models.Model):
|
||||
rec.state = 'voided'
|
||||
rec.message_post(body=_('Certificate voided. Reason: %s') % rec.void_reason)
|
||||
|
||||
def action_open_void_wizard(self):
|
||||
"""Open the void-reason wizard. Bound to the Void header button
|
||||
instead of action_void directly so the manager always supplies a
|
||||
written reason (the underlying action_void still blocks on a
|
||||
blank reason as a defensive last-line check)."""
|
||||
self.ensure_one()
|
||||
if self.state != 'issued':
|
||||
raise UserError(_(
|
||||
'Only issued certificates can be voided '
|
||||
'(current state: %s).'
|
||||
) % self.state)
|
||||
Wizard = self.env.get('fp.cert.void.wizard')
|
||||
if Wizard is None:
|
||||
raise UserError(_(
|
||||
'Void wizard not available. Reinstall '
|
||||
'fusion_plating_certificates.'
|
||||
))
|
||||
wiz = Wizard.create({'cert_id': self.id})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Void %s') % self.name,
|
||||
'res_model': Wizard._name,
|
||||
'res_id': wiz.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_view_traceability(self):
|
||||
"""Show the batches (and their chemistry logs) that produced
|
||||
these parts — auditor's dream, customer's RMA friend."""
|
||||
|
||||
@@ -98,3 +98,18 @@ class ResPartner(models.Model):
|
||||
'AS9100/ISO 9001 boilerplate. Useful for aerospace customers '
|
||||
'who require specific NIST or DFARS language.',
|
||||
)
|
||||
|
||||
# ---- Default CoC contact (cert addressee + email recipient) ----------
|
||||
# The single named contact printed on the CoC and used as the email
|
||||
# default when the cert ships. Sales sets it once per customer.
|
||||
# Falls back to manual selection at action_issue time if blank.
|
||||
x_fc_default_coc_contact_id = fields.Many2one(
|
||||
'res.partner',
|
||||
string='Default CoC Contact',
|
||||
domain="[('parent_id', '=', id), ('is_company', '=', False)]",
|
||||
tracking=True,
|
||||
help='Default contact the Certificate of Conformance is addressed '
|
||||
'to and emailed to. Pre-fills cert.contact_partner_id when a '
|
||||
'job ships. Leave blank to force the manager to pick at '
|
||||
'issue time. Must be a child contact of this company.',
|
||||
)
|
||||
|
||||
@@ -5,3 +5,9 @@ access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion
|
||||
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_void_wiz_mgr,fp.cert.void.wiz.manager,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_sup,fp.thickness.upload.wiz.supervisor,model_fp_thickness_upload_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_mgr,fp.thickness.upload.wiz.manager,model_fp_thickness_upload_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_line_sup,fp.thickness.upload.wiz.line.supervisor,model_fp_thickness_upload_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_thickness_upload_wiz_line_mgr,fp.thickness.upload.wiz.line.manager,model_fp_thickness_upload_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_action_issue_gates
|
||||
from . import test_fischerscope_parser
|
||||
from . import test_thickness_upload_wizard
|
||||
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Issuance-gate tests for fp.certificate.action_issue.
|
||||
|
||||
Covers the 2026-05-18 hardening that adds blocking checks for
|
||||
process_description, certified_by_id, contact_partner_id (with email),
|
||||
and qty reconciliation. See
|
||||
docs/superpowers/specs/2026-05-18-cert-creation-and-data-gates-design.md.
|
||||
"""
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestActionIssueGates(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.signer = cls.env['res.users'].create({
|
||||
'name': 'Signer',
|
||||
'login': 'signer_certissue',
|
||||
'email': 'signer@example.com',
|
||||
})
|
||||
cls.contact_with_email = cls.env['res.partner'].create({
|
||||
'name': 'Anne Recipient',
|
||||
'email': 'anne@cust.example',
|
||||
})
|
||||
cls.contact_no_email = cls.env['res.partner'].create({
|
||||
'name': 'Carl NoEmail',
|
||||
})
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'IssueCust',
|
||||
'is_company': True,
|
||||
# Default for x_fc_send_thickness_report is True, which would
|
||||
# add a thickness-data gate to every issue test in this class.
|
||||
# These tests are scoped to the OTHER gates (spec_ref,
|
||||
# process_description, certified_by, contact). Turn off the
|
||||
# thickness flag so we're testing one gate at a time.
|
||||
'x_fc_send_thickness_report': False,
|
||||
})
|
||||
cls.contact_with_email.parent_id = cls.partner.id
|
||||
cls.contact_no_email.parent_id = cls.partner.id
|
||||
|
||||
def _make_cert(self, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
'spec_reference': 'AMS 2404',
|
||||
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
|
||||
'certified_by_id': self.signer.id,
|
||||
'contact_partner_id': self.contact_with_email.id,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.certificate'].create(vals)
|
||||
|
||||
# ---- the existing gate still works (spec_reference) ----
|
||||
|
||||
def test_blocks_on_missing_spec_reference(self):
|
||||
cert = self._make_cert(spec_reference=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Spec Reference', str(exc.exception))
|
||||
|
||||
# ---- new gate: process_description ----
|
||||
|
||||
def test_blocks_on_missing_process_description(self):
|
||||
cert = self._make_cert(process_description=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Process Description', str(exc.exception))
|
||||
|
||||
# ---- new gate: certified_by_id ----
|
||||
|
||||
def test_blocks_on_missing_certified_by(self):
|
||||
cert = self._make_cert(certified_by_id=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Certified By', str(exc.exception))
|
||||
|
||||
# ---- new gate: contact_partner_id ----
|
||||
|
||||
def test_blocks_on_missing_contact(self):
|
||||
cert = self._make_cert(contact_partner_id=False)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Customer Contact', str(exc.exception))
|
||||
|
||||
def test_blocks_on_contact_without_email(self):
|
||||
cert = self._make_cert(contact_partner_id=self.contact_no_email.id)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('no email', str(exc.exception))
|
||||
|
||||
# ---- happy path ----
|
||||
|
||||
def test_passes_when_all_data_present(self):
|
||||
cert = self._make_cert()
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
|
||||
# ---- order: spec_reference still wins (cheapest first) ----
|
||||
|
||||
def test_gate_order_spec_reference_first(self):
|
||||
# Multiple missing → spec_reference message surfaces first.
|
||||
cert = self._make_cert(
|
||||
spec_reference=False,
|
||||
process_description=False,
|
||||
certified_by_id=False,
|
||||
contact_partner_id=False,
|
||||
)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('Spec Reference', str(exc.exception))
|
||||
# And NOT the process_description message (gate hit first).
|
||||
self.assertNotIn('Process Description', str(exc.exception))
|
||||
|
||||
# ---- new gate: thickness_report cert needs thickness data ----
|
||||
|
||||
def test_blocks_thickness_report_with_no_data(self):
|
||||
"""A thickness_report cert with zero readings and no Fischerscope
|
||||
PDF is empty paper — must block at issue."""
|
||||
cert = self._make_cert(certificate_type='thickness_report')
|
||||
with self.assertRaises(UserError) as exc:
|
||||
cert.action_issue()
|
||||
self.assertIn('thickness data', str(exc.exception).lower())
|
||||
|
||||
def test_thickness_report_passes_with_readings(self):
|
||||
cert = self._make_cert(certificate_type='thickness_report')
|
||||
self.env['fp.thickness.reading'].create({
|
||||
'certificate_id': cert.id,
|
||||
'nip_mils': 0.4,
|
||||
})
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
|
||||
def test_coc_does_not_require_thickness_data_by_default(self):
|
||||
"""Commercial CoC (no strict_thickness flag) should still pass
|
||||
even without readings — only thickness_report type is gated."""
|
||||
cert = self._make_cert(certificate_type='coc')
|
||||
cert.action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
@@ -0,0 +1,186 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Unit tests for the Fischerscope thickness-report parser.
|
||||
# Pure-Python tests — no Odoo DB needed. Builds synthetic .docx files
|
||||
# matching the real XDAL 600 export layout and verifies extraction.
|
||||
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
# Lazy import inside methods to avoid the circular-import trap that
|
||||
# fires during test-module discovery (the package __init__ pulls in
|
||||
# `lib`; if tests/__init__ also resolves `..lib` at top-level, Python
|
||||
# sees a partially-initialised parent package).
|
||||
|
||||
|
||||
class TestFischerscopeParser(TransactionCase):
|
||||
"""Round-trip tests against the parser. We build a known-shape .docx
|
||||
in memory, parse it back, and assert the structure matches what the
|
||||
real Fischerscope XDAL 600 produces (see screenshot 2026-05-19)."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
try:
|
||||
import docx # python-docx — required for tests
|
||||
cls.docx = docx
|
||||
except ImportError:
|
||||
cls.docx = None
|
||||
# Resolve the parser by absolute path at first use — relative
|
||||
# `from ..lib import` at module top trips the test loader's
|
||||
# partially-initialised-package check.
|
||||
from odoo.addons.fusion_plating_certificates.lib import (
|
||||
fischerscope_parser as _fp,
|
||||
)
|
||||
cls.fischerscope_parser = _fp
|
||||
|
||||
def _make_sample_docx(self, with_image=False):
|
||||
"""Build a .docx that matches the screenshot layout."""
|
||||
if not self.docx:
|
||||
self.skipTest('python-docx not available')
|
||||
doc = self.docx.Document()
|
||||
doc.add_paragraph('Fischerscope® XDAL 600')
|
||||
doc.add_paragraph('Product: 2805031 / NiP/Al-alloys 2805030')
|
||||
doc.add_paragraph('Directory: NiP products for flat samples')
|
||||
doc.add_paragraph('Application: 16 / NiP/Al-alloys')
|
||||
doc.add_paragraph('')
|
||||
doc.add_paragraph('Calibr. Std. Set NiP/Al STD SET SN 100174568')
|
||||
doc.add_paragraph('n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %')
|
||||
doc.add_paragraph('n= 2 NiP 1= 0.5049 mils Ni 1 = 93.179 % P 1 = 6.8209 %')
|
||||
doc.add_paragraph('n= 3 NiP 1= 0.5134 mils Ni 1 = 92.273 % P 1 = 7.7266 %')
|
||||
doc.add_paragraph('')
|
||||
doc.add_paragraph(' NiP 1 mils Ni 1 % P 1 %')
|
||||
doc.add_paragraph('Mean 0.5689 92.258 7.7415')
|
||||
doc.add_paragraph('Standard Deviation 0.1037 0.9282 0.9282')
|
||||
doc.add_paragraph('CoV (%) 18.22 1.01 11.99')
|
||||
doc.add_paragraph('Range 0.1836 1.8562 1.8562')
|
||||
doc.add_paragraph('Number of readings 3 3 3')
|
||||
doc.add_paragraph('Measuring time 120 sec')
|
||||
doc.add_paragraph('Operator: BK 4755 1')
|
||||
doc.add_paragraph('Date: 5/15/2026 Time: 12:24:46 PM')
|
||||
|
||||
if with_image:
|
||||
# Embed a tiny valid 1x1 PNG so the image-extraction path
|
||||
# is exercised. Bytes from
|
||||
# https://github.com/mathiasbynens/small/blob/master/png-transparent.png
|
||||
png = bytes.fromhex(
|
||||
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4'
|
||||
'890000000a49444154789c63000100000500010d0a2db40000000049454e44ae'
|
||||
'426082'
|
||||
)
|
||||
img_buf = io.BytesIO(png)
|
||||
doc.add_picture(img_buf)
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
# ---- happy path: full Fischerscope export ----------------------------
|
||||
def test_parse_extracts_three_readings(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.docx', self._make_sample_docx(),
|
||||
)
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(len(result['readings']), 3)
|
||||
self.assertEqual(result['readings'][0], {
|
||||
'reading_number': 1,
|
||||
'nip_mils': 0.6885,
|
||||
'ni_percent': 91.323,
|
||||
'p_percent': 8.6771,
|
||||
})
|
||||
self.assertEqual(result['readings'][2]['reading_number'], 3)
|
||||
|
||||
def test_parse_extracts_metadata(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.docx', self._make_sample_docx(),
|
||||
)
|
||||
meta = result['metadata']
|
||||
self.assertIn('Fischerscope', (meta.get('equipment_model') or ''))
|
||||
self.assertIn('XDAL 600', (meta.get('equipment_model') or ''))
|
||||
self.assertEqual(meta.get('product_ref'),
|
||||
'2805031 / NiP/Al-alloys 2805030')
|
||||
self.assertEqual(meta.get('calibration_std_ref'),
|
||||
'NiP/Al STD SET SN 100174568')
|
||||
self.assertEqual(meta.get('measuring_time_seconds'), 120)
|
||||
self.assertEqual(meta.get('operator_name'), 'BK')
|
||||
self.assertEqual(meta.get('reading_datetime'),
|
||||
datetime(2026, 5, 15, 12, 24, 46))
|
||||
|
||||
def test_parse_extracts_image_when_present(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.docx', self._make_sample_docx(with_image=True),
|
||||
)
|
||||
self.assertIsNotNone(result['image'])
|
||||
self.assertGreater(len(result['image']), 50)
|
||||
# python-docx writes the relationship type to image; mime is content_type.
|
||||
self.assertTrue((result.get('image_mime') or '').startswith('image/'))
|
||||
|
||||
def test_parse_handles_no_image(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.docx', self._make_sample_docx(with_image=False),
|
||||
)
|
||||
self.assertIsNone(result['image'])
|
||||
|
||||
# ---- fallback / error paths -----------------------------------------
|
||||
def test_parse_unknown_extension(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.csv', b'irrelevant',
|
||||
)
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual(result['readings'], [])
|
||||
self.assertTrue(result['errors'])
|
||||
self.assertIn('Unsupported', result['errors'][0])
|
||||
|
||||
def test_parse_legacy_doc_extension(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.doc', b'%PDF',
|
||||
)
|
||||
self.assertFalse(result['success'])
|
||||
self.assertIn('.doc', result['errors'][0])
|
||||
|
||||
def test_parse_corrupt_docx(self):
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'sample.docx', b'not a real docx file',
|
||||
)
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual(result['readings'], [])
|
||||
self.assertTrue(result['errors'])
|
||||
|
||||
def test_parse_empty_docx_no_readings(self):
|
||||
if not self.docx:
|
||||
self.skipTest('python-docx not available')
|
||||
doc = self.docx.Document()
|
||||
doc.add_paragraph('Just a blank report')
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'blank.docx', buf.getvalue(),
|
||||
)
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual(result['readings'], [])
|
||||
# raw_text should still be populated for debug
|
||||
self.assertIn('blank report', result['raw_text'])
|
||||
|
||||
# ---- robustness: variation in spacing / channel digits --------------
|
||||
def test_parse_tolerates_whitespace_variation(self):
|
||||
if not self.docx:
|
||||
self.skipTest('python-docx not available')
|
||||
doc = self.docx.Document()
|
||||
doc.add_paragraph('Calibr. Std. Set TESTSTD SN 999')
|
||||
# Tighter spacing, no channel digit (some exports omit "1")
|
||||
doc.add_paragraph('n=1 NiP= 0.50 mils Ni = 92.0 % P = 8.0 %')
|
||||
# Looser spacing, channel digit "1"
|
||||
doc.add_paragraph('n = 2 NiP 1 = 0.55 mils Ni 1 = 91.5 % P 1 = 8.5 %')
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
result = self.fischerscope_parser.parse_fischerscope_file(
|
||||
'variant.docx', buf.getvalue(),
|
||||
)
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual(len(result['readings']), 2)
|
||||
self.assertAlmostEqual(result['readings'][0]['nip_mils'], 0.50)
|
||||
self.assertAlmostEqual(result['readings'][1]['nip_mils'], 0.55)
|
||||
@@ -0,0 +1,150 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# End-to-end tests for the thickness-upload wizard.
|
||||
|
||||
import base64
|
||||
import io
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestThicknessUploadWizard(TransactionCase):
|
||||
"""Walk the wizard from upload → parse → save and verify the side
|
||||
effects on the certificate."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
try:
|
||||
import docx
|
||||
cls.docx = docx
|
||||
except ImportError:
|
||||
cls.docx = None
|
||||
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'WizardCust',
|
||||
'email': 'wizardcust@example.com',
|
||||
})
|
||||
cls.cert = cls.env['fp.certificate'].create({
|
||||
'partner_id': cls.partner.id,
|
||||
'certificate_type': 'coc',
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
def _sample_docx_bytes(self):
|
||||
if not self.docx:
|
||||
self.skipTest('python-docx not available')
|
||||
doc = self.docx.Document()
|
||||
doc.add_paragraph('Fischerscope® XDAL 600')
|
||||
doc.add_paragraph('Product: 2805031 / NiP/Al-alloys 2805030')
|
||||
doc.add_paragraph('Calibr. Std. Set NiP/Al STD SET SN 100174568')
|
||||
doc.add_paragraph('n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %')
|
||||
doc.add_paragraph('n= 2 NiP 1= 0.5049 mils Ni 1 = 93.179 % P 1 = 6.8209 %')
|
||||
doc.add_paragraph('n= 3 NiP 1= 0.5134 mils Ni 1 = 92.273 % P 1 = 7.7266 %')
|
||||
doc.add_paragraph('Measuring time 120 sec')
|
||||
doc.add_paragraph('Operator: BK')
|
||||
doc.add_paragraph('Date: 5/15/2026 Time: 12:24:46 PM')
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
def _make_wizard(self, file_bytes, filename='fischer.docx'):
|
||||
return self.env['fp.thickness.upload.wizard'].create({
|
||||
'certificate_id': self.cert.id,
|
||||
'file_data': base64.b64encode(file_bytes),
|
||||
'file_name': filename,
|
||||
})
|
||||
|
||||
# ---- parse step ------------------------------------------------------
|
||||
def test_action_parse_populates_review_state(self):
|
||||
wiz = self._make_wizard(self._sample_docx_bytes())
|
||||
wiz.action_parse()
|
||||
self.assertEqual(wiz.state, 'review')
|
||||
self.assertEqual(wiz.reading_count, 3)
|
||||
self.assertEqual(len(wiz.reading_line_ids), 3)
|
||||
# Spot-check the second row carries the values we expect.
|
||||
line_2 = wiz.reading_line_ids.filtered(lambda l: l.reading_number == 2)
|
||||
self.assertEqual(len(line_2), 1)
|
||||
self.assertAlmostEqual(line_2.nip_mils, 0.5049, places=4)
|
||||
|
||||
def test_action_parse_unparseable_goes_to_manual_state(self):
|
||||
wiz = self._make_wizard(b'not a docx', filename='garbage.docx')
|
||||
wiz.action_parse()
|
||||
self.assertEqual(wiz.state, 'manual')
|
||||
self.assertEqual(wiz.reading_count, 0)
|
||||
self.assertFalse(wiz.reading_line_ids)
|
||||
|
||||
def test_action_parse_extracts_metadata(self):
|
||||
wiz = self._make_wizard(self._sample_docx_bytes())
|
||||
wiz.action_parse()
|
||||
self.assertIn('Fischerscope', wiz.parsed_equipment_model or '')
|
||||
self.assertEqual(wiz.parsed_calibration_std_ref,
|
||||
'NiP/Al STD SET SN 100174568')
|
||||
self.assertEqual(wiz.parsed_measuring_time_seconds, 120)
|
||||
self.assertEqual(wiz.parsed_operator_name, 'BK')
|
||||
|
||||
# ---- save step -------------------------------------------------------
|
||||
def test_action_save_creates_thickness_readings(self):
|
||||
wiz = self._make_wizard(self._sample_docx_bytes())
|
||||
wiz.action_parse()
|
||||
wiz.action_save()
|
||||
readings = self.env['fp.thickness.reading'].search([
|
||||
('certificate_id', '=', self.cert.id),
|
||||
])
|
||||
self.assertEqual(len(readings), 3)
|
||||
# Same metadata on every row (decision 2026-05-19).
|
||||
for r in readings:
|
||||
self.assertEqual(r.calibration_std_ref,
|
||||
'NiP/Al STD SET SN 100174568')
|
||||
self.assertIn('Fischerscope', r.equipment_model or '')
|
||||
self.assertEqual(r.measuring_time_seconds, 120)
|
||||
|
||||
def test_action_save_attaches_original_file(self):
|
||||
wiz = self._make_wizard(
|
||||
self._sample_docx_bytes(), filename='fischer-WO-30040.docx',
|
||||
)
|
||||
wiz.action_parse()
|
||||
wiz.action_save()
|
||||
self.cert.invalidate_recordset(
|
||||
['x_fc_local_thickness_pdf', 'x_fc_local_thickness_pdf_filename'],
|
||||
)
|
||||
self.assertTrue(self.cert.x_fc_local_thickness_pdf)
|
||||
self.assertEqual(
|
||||
self.cert.x_fc_local_thickness_pdf_filename, 'fischer-WO-30040.docx',
|
||||
)
|
||||
|
||||
def test_action_save_posts_chatter(self):
|
||||
wiz = self._make_wizard(self._sample_docx_bytes())
|
||||
wiz.action_parse()
|
||||
before = len(self.cert.message_ids)
|
||||
wiz.action_save()
|
||||
after = len(self.cert.message_ids)
|
||||
self.assertGreater(after, before)
|
||||
last = self.cert.message_ids[0]
|
||||
self.assertIn('thickness', (last.body or '').lower())
|
||||
|
||||
def test_action_save_blocks_on_non_draft_cert(self):
|
||||
# Force the cert into 'voided' so action_save's gate fires.
|
||||
self.cert.state = 'voided'
|
||||
wiz = self._make_wizard(self._sample_docx_bytes())
|
||||
wiz.action_parse()
|
||||
with self.assertRaises(UserError):
|
||||
wiz.action_save()
|
||||
|
||||
def test_action_save_manual_fallback_still_attaches_file(self):
|
||||
"""When parse fails (state=manual), Save must still attach the
|
||||
original file so the merge path / audit trail are populated."""
|
||||
wiz = self._make_wizard(b'unparseable')
|
||||
wiz.action_parse()
|
||||
self.assertEqual(wiz.state, 'manual')
|
||||
wiz.action_save()
|
||||
self.cert.invalidate_recordset(['x_fc_local_thickness_pdf'])
|
||||
self.assertTrue(self.cert.x_fc_local_thickness_pdf)
|
||||
# No readings should have been created.
|
||||
n = self.env['fp.thickness.reading'].search_count([
|
||||
('certificate_id', '=', self.cert.id),
|
||||
])
|
||||
self.assertEqual(n, 0)
|
||||
@@ -42,12 +42,27 @@
|
||||
<button name="action_issue" string="Issue"
|
||||
type="object" class="btn-primary"
|
||||
invisible="state != 'draft'"/>
|
||||
<button name="action_void" string="Void"
|
||||
<!-- Print = the same EN report action the gear-menu
|
||||
Print > Certificate of Conformance (English)
|
||||
calls. Routes through fusion_pdf_preview's
|
||||
report interceptor automatically. For the
|
||||
French variant or any other language report,
|
||||
use the gear menu. -->
|
||||
<button name="%(fusion_plating_reports.action_report_coc_en)d"
|
||||
string="Print"
|
||||
type="action" class="btn-secondary"
|
||||
icon="fa-print"/>
|
||||
<button name="action_open_void_wizard" string="Void"
|
||||
type="object" class="btn-danger"
|
||||
invisible="state != 'issued'"/>
|
||||
<button name="action_send_to_customer" string="Send to Customer"
|
||||
type="object"
|
||||
invisible="state != 'issued'"/>
|
||||
<button name="action_reset_to_draft" string="Reset to Draft"
|
||||
type="object" class="btn-secondary"
|
||||
icon="fa-undo"
|
||||
confirm="Reset this certificate to draft? You'll be able to edit and re-issue. The previously-issued PDF stays attached for audit."
|
||||
invisible="state == 'draft'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,issued"/>
|
||||
</header>
|
||||
@@ -67,48 +82,52 @@
|
||||
<field name="name" readonly="1"/>
|
||||
</h1>
|
||||
</div>
|
||||
<!-- Main info — collapsed from 3 separate groups
|
||||
into 1 to eliminate the dead rows that
|
||||
appeared when one sub-group ran shorter than
|
||||
the other. Left column is identity / signer /
|
||||
dates; right column is part / process / qty /
|
||||
derived stats. Reorganized 2026-05-21. -->
|
||||
<group>
|
||||
<group>
|
||||
<field name="certificate_type"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="portal_job_id"/>
|
||||
<field name="issue_date"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="part_number"/>
|
||||
<field name="po_number"/>
|
||||
<field name="entech_wo_number"/>
|
||||
<field name="customer_job_no"/>
|
||||
<field name="process_description"/>
|
||||
<field name="spec_reference"/>
|
||||
<field name="quantity_shipped"/>
|
||||
<field name="nc_quantity"/>
|
||||
<field name="contact_partner_id"
|
||||
options="{'no_create': True}"
|
||||
invisible="not partner_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group>
|
||||
<field name="sale_order_id"/>
|
||||
<field name="entech_wo_number"/>
|
||||
<field name="portal_job_id"/>
|
||||
<field name="issue_date"/>
|
||||
<field name="issued_by_id"/>
|
||||
<field name="certified_by_id"/>
|
||||
<field name="body_style"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="part_number"/>
|
||||
<field name="process_description"/>
|
||||
<field name="spec_reference"/>
|
||||
<field name="po_number"/>
|
||||
<field name="customer_job_no"/>
|
||||
<field name="quantity_shipped"/>
|
||||
<field name="nc_quantity"/>
|
||||
<field name="reading_count" readonly="1"/>
|
||||
<field name="mean_nip_mils" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<!-- SPC rebalanced — spec/min/max on the left,
|
||||
derived stats on the right; trend_explanation
|
||||
spans both columns so the long message doesn't
|
||||
get cropped. -->
|
||||
<group string="SPC — Statistical Process Control">
|
||||
<group>
|
||||
<field name="spec_min_mils"/>
|
||||
<field name="spec_max_mils"/>
|
||||
<field name="min_reading_mils" readonly="1"/>
|
||||
<field name="max_reading_mils" readonly="1"/>
|
||||
<field name="std_dev_mils" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="std_dev_mils" readonly="1"/>
|
||||
<field name="cpk" readonly="1"/>
|
||||
<field name="cpk_status" readonly="1" widget="badge"
|
||||
decoration-success="cpk_status in ('capable','excellent')"
|
||||
@@ -119,9 +138,9 @@
|
||||
decoration-success="trend_alert == 'ok'"
|
||||
decoration-warning="trend_alert == 'warning'"
|
||||
decoration-danger="trend_alert == 'alert'"/>
|
||||
<field name="trend_explanation" readonly="1"
|
||||
invisible="trend_alert == 'ok'"/>
|
||||
</group>
|
||||
<field name="trend_explanation" readonly="1" colspan="2"
|
||||
invisible="trend_alert == 'ok'"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Thickness Readings" name="readings">
|
||||
|
||||
@@ -32,6 +32,17 @@
|
||||
<field name="x_fc_send_bol" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Default CoC Contact"/>
|
||||
<p class="text-muted">
|
||||
The named contact this customer's CoC is addressed
|
||||
to and emailed to. Pre-fills cert records when a
|
||||
job ships. Leave blank to force the manager to pick
|
||||
at issue time.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_default_coc_contact_id"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
<separator string="Cert Statement Override (Sub 12c+)"/>
|
||||
<p class="text-muted">
|
||||
Boilerplate text printed in the "Certification Statement"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import fp_cert_void_wizard
|
||||
from . import fp_thickness_upload_wizard
|
||||
@@ -0,0 +1,50 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Void Certificate Wizard.
|
||||
|
||||
Opened from an issued cert's "Void" button. Prompts the manager for a
|
||||
written reason, then calls action_void on the cert with the reason
|
||||
populated. The cert's chatter records the void event with the reason
|
||||
inline via the existing _logger / message_post in action_void.
|
||||
"""
|
||||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpCertVoidWizard(models.TransientModel):
|
||||
_name = 'fp.cert.void.wizard'
|
||||
_description = 'Fusion Plating — Void Certificate Wizard'
|
||||
|
||||
cert_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', required=True, readonly=True,
|
||||
)
|
||||
cert_name = fields.Char(related='cert_id.name', readonly=True)
|
||||
partner_id = fields.Many2one(
|
||||
related='cert_id.partner_id', readonly=True,
|
||||
)
|
||||
void_reason = fields.Text(
|
||||
string='Void Reason',
|
||||
help='Why this certificate is being voided. Printed on the '
|
||||
'cert chatter and visible in audit trails. Required for '
|
||||
'AS9100 / Nadcap document control. Validation happens at '
|
||||
'confirm time so the wizard can open empty.',
|
||||
)
|
||||
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
if not (self.void_reason or '').strip():
|
||||
raise UserError(_(
|
||||
'Please enter a void reason before voiding. The reason '
|
||||
'is logged to the cert chatter and printed on the audit '
|
||||
'trail (AS9100 / Nadcap requirement).'
|
||||
))
|
||||
if self.cert_id.state != 'issued':
|
||||
raise UserError(_(
|
||||
'Only issued certificates can be voided '
|
||||
'(current state: %s).'
|
||||
) % self.cert_id.state)
|
||||
# Write the reason FIRST so the cert's action_void gate passes.
|
||||
self.cert_id.void_reason = self.void_reason
|
||||
self.cert_id.action_void()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_cert_void_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.cert.void.wizard.form</field>
|
||||
<field name="model">fp.cert.void.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Void Certificate">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>
|
||||
Void Certificate <field name="cert_name"
|
||||
readonly="1"
|
||||
nolabel="1"
|
||||
class="oe_inline"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="fa fa-exclamation-triangle"/>
|
||||
Voiding marks this certificate as no longer
|
||||
valid. The audit trail keeps the record visible
|
||||
but flagged. Required for AS9100 / Nadcap
|
||||
document control.
|
||||
</div>
|
||||
<group>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="void_reason"
|
||||
placeholder="e.g. Customer rejected lot — re-plating required. Replaced by CoC-30041."
|
||||
nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Void Certificate"
|
||||
class="btn-danger"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,244 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Thickness-report upload wizard. Operator picks a Fischerscope export
|
||||
# (.docx or .pdf); the wizard parses readings + metadata via the
|
||||
# fischerscope_parser library, shows the result for review, and on Save
|
||||
# writes per-reading rows into fp.thickness.reading + stores the
|
||||
# original file in fp.certificate.x_fc_local_thickness_pdf.
|
||||
#
|
||||
# When the parser extracts ≥1 reading, the wizard enters "review" state
|
||||
# and the editable reading table is shown. When 0 readings are found,
|
||||
# the wizard enters "manual" state — the operator can still save the
|
||||
# file as-is (attach-only fallback). Either way the file ends up in
|
||||
# place to satisfy the action_issue thickness gate.
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
# Lazy parser import — `from ..lib.fischerscope_parser import …` at
|
||||
# module top fails on Python 3.11+ because the parent package
|
||||
# `fusion_plating_certificates` is still mid-init when wizards/__init__
|
||||
# imports this file (relative traversal into a partially-loaded parent
|
||||
# raises "cannot import name from partially initialized module"). The
|
||||
# parser is referenced once inside action_parse so deferring is fine.
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FpThicknessUploadWizard(models.TransientModel):
|
||||
"""Upload + parse a Fischerscope thickness report onto a certificate."""
|
||||
_name = 'fp.thickness.upload.wizard'
|
||||
_description = 'Thickness Report Upload Wizard'
|
||||
|
||||
certificate_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', required=True, ondelete='cascade',
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='certificate_id.partner_id', string='Customer', readonly=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
[('upload', 'Upload file'),
|
||||
('review', 'Review parsed readings'),
|
||||
('manual', 'Parse failed — attach only')],
|
||||
default='upload', required=True,
|
||||
)
|
||||
|
||||
# File ----------------------------------------------------------------
|
||||
file_data = fields.Binary(string='Fischerscope Report', required=True)
|
||||
file_name = fields.Char(string='File Name')
|
||||
|
||||
# Parsed metadata (readonly after parse) ------------------------------
|
||||
parsed_equipment_model = fields.Char(string='Equipment', readonly=True)
|
||||
parsed_product_ref = fields.Char(string='Product Ref', readonly=True)
|
||||
parsed_calibration_std_ref = fields.Char(string='Calibration Std', readonly=True)
|
||||
parsed_measuring_time_seconds = fields.Integer(
|
||||
string='Measuring Time (sec)', readonly=True,
|
||||
)
|
||||
parsed_operator_name = fields.Char(string='Operator', readonly=True)
|
||||
parsed_reading_datetime = fields.Datetime(
|
||||
string='Reading Date/Time', readonly=True,
|
||||
)
|
||||
|
||||
# Image preview -------------------------------------------------------
|
||||
parsed_image = fields.Binary(string='Microscope Image', readonly=True)
|
||||
parsed_image_mime = fields.Char(readonly=True)
|
||||
|
||||
# Editable reading rows -----------------------------------------------
|
||||
reading_line_ids = fields.One2many(
|
||||
'fp.thickness.upload.wizard.line', 'wizard_id', string='Readings',
|
||||
)
|
||||
|
||||
# Parse status --------------------------------------------------------
|
||||
parse_messages = fields.Text(string='Parser notes', readonly=True)
|
||||
reading_count = fields.Integer(string='Parsed Readings', readonly=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions
|
||||
# ------------------------------------------------------------------
|
||||
def action_parse(self):
|
||||
"""Run the parser; populate metadata + reading_line_ids."""
|
||||
self.ensure_one()
|
||||
if not self.file_data:
|
||||
raise UserError(_('Pick a file before parsing.'))
|
||||
try:
|
||||
content = base64.b64decode(self.file_data)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise UserError(_('File data is corrupt: %s') % e) from e
|
||||
|
||||
from ..lib.fischerscope_parser import parse_fischerscope_file
|
||||
result = parse_fischerscope_file(self.file_name or '', content)
|
||||
|
||||
# Wipe previous attempt so a retry doesn't pile up rows.
|
||||
self.reading_line_ids.unlink()
|
||||
|
||||
self.parsed_equipment_model = result['metadata'].get('equipment_model')
|
||||
self.parsed_product_ref = result['metadata'].get('product_ref')
|
||||
self.parsed_calibration_std_ref = result['metadata'].get('calibration_std_ref')
|
||||
self.parsed_measuring_time_seconds = (
|
||||
result['metadata'].get('measuring_time_seconds') or 0
|
||||
)
|
||||
self.parsed_operator_name = result['metadata'].get('operator_name')
|
||||
self.parsed_reading_datetime = result['metadata'].get('reading_datetime')
|
||||
|
||||
if result.get('image'):
|
||||
self.parsed_image = base64.b64encode(result['image'])
|
||||
self.parsed_image_mime = result.get('image_mime')
|
||||
|
||||
# Build editable rows for review/edit.
|
||||
Line = self.env['fp.thickness.upload.wizard.line']
|
||||
for r in result['readings']:
|
||||
Line.create({
|
||||
'wizard_id': self.id,
|
||||
'reading_number': r['reading_number'],
|
||||
'nip_mils': r['nip_mils'],
|
||||
'ni_percent': r['ni_percent'],
|
||||
'p_percent': r['p_percent'],
|
||||
})
|
||||
|
||||
self.reading_count = len(result['readings'])
|
||||
self.parse_messages = '\n'.join(result.get('errors') or []) or False
|
||||
self.state = 'review' if result['success'] else 'manual'
|
||||
return self._reopen()
|
||||
|
||||
def action_save(self):
|
||||
"""Commit parsed readings + file to the certificate."""
|
||||
self.ensure_one()
|
||||
cert = self.certificate_id
|
||||
if not cert:
|
||||
raise UserError(_('Wizard has no certificate to write to.'))
|
||||
if cert.state != 'draft':
|
||||
raise UserError(_(
|
||||
'Cannot attach thickness data — certificate %s is in '
|
||||
'state %s. Only draft certificates can be edited.'
|
||||
) % (cert.display_name, cert.state))
|
||||
|
||||
# Attach the original file so the merge logic + audit trail still
|
||||
# have it (also covers the "parse failed" manual fallback case).
|
||||
if self.file_data:
|
||||
cert.write({
|
||||
'x_fc_local_thickness_pdf': self.file_data,
|
||||
'x_fc_local_thickness_pdf_filename': self.file_name or False,
|
||||
})
|
||||
|
||||
# Persist the microscope image as a cert-level attachment (decision
|
||||
# confirmed 2026-05-19). One image per report, not per-reading.
|
||||
if self.parsed_image:
|
||||
ext = self._guess_image_ext(self.parsed_image_mime)
|
||||
self.env['ir.attachment'].create({
|
||||
'name': 'microscope-%s%s' % (cert.name or 'cert', ext),
|
||||
'datas': self.parsed_image,
|
||||
'res_model': cert._name,
|
||||
'res_id': cert.id,
|
||||
'mimetype': self.parsed_image_mime or 'image/jpeg',
|
||||
})
|
||||
|
||||
# Write reading rows — same metadata copied onto every row
|
||||
# (decision confirmed 2026-05-19, so each row is fully self-
|
||||
# describing for downstream queries / reports).
|
||||
if self.reading_line_ids:
|
||||
Reading = self.env['fp.thickness.reading']
|
||||
for line in self.reading_line_ids:
|
||||
Reading.create({
|
||||
'certificate_id': cert.id,
|
||||
'reading_number': line.reading_number,
|
||||
'nip_mils': line.nip_mils,
|
||||
'ni_percent': line.ni_percent,
|
||||
'p_percent': line.p_percent,
|
||||
'position_label': line.position_label or False,
|
||||
'equipment_model': self.parsed_equipment_model
|
||||
or 'Fischerscope XDAL 600',
|
||||
'product_ref': self.parsed_product_ref or False,
|
||||
'calibration_std_ref': (
|
||||
self.parsed_calibration_std_ref
|
||||
or 'NiP/Al STD SET SN 100174568'
|
||||
),
|
||||
'reading_datetime': (
|
||||
self.parsed_reading_datetime
|
||||
or fields.Datetime.now()
|
||||
),
|
||||
'measuring_time_seconds': (
|
||||
self.parsed_measuring_time_seconds or 120
|
||||
),
|
||||
})
|
||||
|
||||
# Chatter audit
|
||||
n = len(self.reading_line_ids)
|
||||
body = (
|
||||
_('Fischerscope thickness report uploaded — %d reading(s) '
|
||||
'parsed from %s.') % (n, self.file_name or 'file')
|
||||
if n else
|
||||
_('Fischerscope thickness file attached (parse returned no '
|
||||
'readings). File: %s') % (self.file_name or 'unnamed')
|
||||
)
|
||||
cert.message_post(body=body)
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': cert._name,
|
||||
'res_id': cert.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def _reopen(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _guess_image_ext(mime):
|
||||
return {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/tiff': '.tiff',
|
||||
}.get((mime or '').lower(), '.bin')
|
||||
|
||||
|
||||
class FpThicknessUploadWizardLine(models.TransientModel):
|
||||
"""Editable reading row in the upload wizard."""
|
||||
_name = 'fp.thickness.upload.wizard.line'
|
||||
_description = 'Thickness Upload Wizard — Reading'
|
||||
_order = 'reading_number'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'fp.thickness.upload.wizard', required=True, ondelete='cascade',
|
||||
)
|
||||
reading_number = fields.Integer(string='#', required=True)
|
||||
nip_mils = fields.Float(string='NiP (mils)', digits=(10, 4))
|
||||
ni_percent = fields.Float(string='Ni %', digits=(6, 3))
|
||||
p_percent = fields.Float(string='P %', digits=(6, 4))
|
||||
position_label = fields.Char(
|
||||
string='Position',
|
||||
help='Optional — where on the part this reading was taken.',
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Thickness-report upload wizard view.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Wizard form -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="fp_thickness_upload_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.thickness.upload.wizard.form</field>
|
||||
<field name="model">fp.thickness.upload.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Upload Thickness Report">
|
||||
<field name="state" invisible="1"/>
|
||||
|
||||
<!-- Upload step -->
|
||||
<div invisible="state != 'upload'">
|
||||
<p>
|
||||
Drop the Fischerscope XDAL 600 export below
|
||||
(<code>.docx</code> or <code>.pdf</code>). I'll read the
|
||||
readings, gauge calibration, and operator info, then
|
||||
let you review the values before they land on
|
||||
certificate <field name="certificate_id" readonly="1" nolabel="1"
|
||||
class="oe_inline" options="{'no_open': True, 'no_create': True}"/>.
|
||||
</p>
|
||||
<group>
|
||||
<field name="file_data" filename="file_name"/>
|
||||
<field name="file_name"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_parse" string="Parse File"
|
||||
type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Review step -->
|
||||
<div invisible="state != 'review'">
|
||||
<div class="alert alert-success" role="alert">
|
||||
Parsed <field name="reading_count" readonly="1"
|
||||
nolabel="1" class="oe_inline"/> reading(s)
|
||||
from <field name="file_name" readonly="1" nolabel="1"
|
||||
class="oe_inline"/>. Review/edit below,
|
||||
then click Save to record on the certificate.
|
||||
</div>
|
||||
|
||||
<group string="Equipment + Calibration">
|
||||
<field name="parsed_equipment_model"/>
|
||||
<field name="parsed_product_ref"/>
|
||||
<field name="parsed_calibration_std_ref"/>
|
||||
<field name="parsed_measuring_time_seconds"/>
|
||||
<field name="parsed_operator_name"/>
|
||||
<field name="parsed_reading_datetime"/>
|
||||
</group>
|
||||
|
||||
<group string="Microscope Image"
|
||||
invisible="not parsed_image">
|
||||
<field name="parsed_image" widget="image"
|
||||
options="{'preview_image': 'parsed_image'}"
|
||||
nolabel="1"/>
|
||||
</group>
|
||||
|
||||
<field name="reading_line_ids" nolabel="1">
|
||||
<list editable="bottom">
|
||||
<field name="reading_number"/>
|
||||
<field name="nip_mils"/>
|
||||
<field name="ni_percent"/>
|
||||
<field name="p_percent"/>
|
||||
<field name="position_label"/>
|
||||
</list>
|
||||
</field>
|
||||
|
||||
<group invisible="not parse_messages">
|
||||
<field name="parse_messages" readonly="1"
|
||||
widget="text" nolabel="1"/>
|
||||
</group>
|
||||
|
||||
<footer>
|
||||
<button name="action_save" string="Save"
|
||||
type="object" class="btn-primary"/>
|
||||
<button string="Re-upload" class="btn-secondary"
|
||||
name="action_parse" type="object"
|
||||
invisible="not file_data"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Manual fallback step -->
|
||||
<div invisible="state != 'manual'">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Couldn't parse readings.</strong>
|
||||
The file format didn't match what we recognise
|
||||
(Fischerscope XDAL 600 export). You can still save it
|
||||
as-is — the file will attach to the certificate and
|
||||
flow into the CoC PDF as page 2, but the readings
|
||||
won't appear as queryable rows.
|
||||
</div>
|
||||
<group>
|
||||
<field name="file_name" readonly="1"/>
|
||||
</group>
|
||||
<group invisible="not parse_messages">
|
||||
<field name="parse_messages" readonly="1"
|
||||
widget="text" nolabel="1"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_save"
|
||||
string="Attach file anyway"
|
||||
type="object" class="btn-primary"/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================== -->
|
||||
<!-- Window action — opened from the cert form button -->
|
||||
<!-- ================================================================== -->
|
||||
<record id="action_fp_thickness_upload_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Upload Thickness Report</field>
|
||||
<field name="res_model">fp.thickness.upload.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="model_fp_thickness_upload_wizard"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.21.4.0',
|
||||
'version': '19.0.21.5.1',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
"""Drop the 'inspected' value from sale_order.x_fc_receiving_status.
|
||||
|
||||
Sub 8 (2026-04-22) moved part inspection out of receiving and into the
|
||||
recipe's racking step. The SO-level receiving status no longer needs
|
||||
'inspected' as a terminal value — 'received' (boxes counted/staged/
|
||||
closed) is now the final state.
|
||||
|
||||
This migration flips any existing rows with the obsolete value to the
|
||||
new terminal value. On a freshly-installed instance there are zero rows;
|
||||
the migration is defensive for instances that had pre-Sub-8 records.
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
cr.execute("""
|
||||
UPDATE sale_order
|
||||
SET x_fc_receiving_status = 'received'
|
||||
WHERE x_fc_receiving_status = 'inspected'
|
||||
""")
|
||||
@@ -74,8 +74,12 @@ class SaleOrder(models.Model):
|
||||
)
|
||||
x_fc_receiving_status = fields.Selection(
|
||||
[('not_received', 'Not Received'), ('partial', 'Partial'),
|
||||
('received', 'Received'), ('inspected', 'Inspected')],
|
||||
('received', 'Received')],
|
||||
string='Receiving Status', default='not_received', tracking=True,
|
||||
help='State of the linked fp.receiving record(s). Inspection is '
|
||||
"no longer a receiving state — Sub 8 moved part inspection "
|
||||
'into the recipe (racking step), so receiving stops at '
|
||||
'"received" (boxes counted, staged, closed).',
|
||||
)
|
||||
|
||||
# ---- Direct Order rewrite (Phase A) ----
|
||||
|
||||
@@ -120,7 +120,12 @@
|
||||
<h4>Add Variant from Template</h4>
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<label class="me-2">Template:</label>
|
||||
<select class="form-select" style="max-width: 280px;"
|
||||
<!-- Bumped min-width 280px → 360px and let it
|
||||
flex-grow so long template names (e.g.
|
||||
"Chemical Conversion — Iridite Type II Cl 3")
|
||||
don't truncate to "Chem…". Reported 2026-05-20. -->
|
||||
<select class="form-select"
|
||||
style="min-width: 360px; flex: 1 1 360px; max-width: 560px;"
|
||||
t-on-change="onSelectTemplate">
|
||||
<t t-foreach="state.templates" t-as="tpl" t-key="tpl.id">
|
||||
<option t-att-value="tpl.id"
|
||||
@@ -129,14 +134,22 @@
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<input class="form-control" style="max-width: 240px;"
|
||||
<input class="form-control"
|
||||
style="min-width: 220px; flex: 1 1 220px; max-width: 320px;"
|
||||
placeholder="Variant label (e.g. Standard ENP)"
|
||||
t-att-value="state.newVariantLabel"
|
||||
t-on-input="onNewLabelInput"/>
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="onAddVariantFromTemplate"
|
||||
t-att-disabled="state.busy or !state.selectedTemplateId">
|
||||
<i class="fa fa-plus"/> Add Variant
|
||||
t-on-click="() => this.onAddVariantFromTemplate('tree')"
|
||||
t-att-disabled="state.busy or !state.selectedTemplateId"
|
||||
title="Add the variant and open it in the Tree Editor">
|
||||
<i class="fa fa-sitemap me-1"/> Add — Tree
|
||||
</button>
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="() => this.onAddVariantFromTemplate('simple')"
|
||||
t-att-disabled="state.busy or !state.selectedTemplateId"
|
||||
title="Add the variant and open it in the Simple Editor">
|
||||
<i class="fa fa-list me-1"/> Add — Simple
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-muted small mt-1">
|
||||
|
||||
@@ -12,6 +12,19 @@
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Header buttons: make draft Confirm the primary CTA, demote/rename
|
||||
Send to "Send Email" (red), and reorder so Confirm sits first. -->
|
||||
<xpath expr="//header/button[@name='action_confirm' and not(@id)]" position="attributes">
|
||||
<attribute name="class">btn-primary</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//header/button[@id='quotation_send_primary']" position="attributes">
|
||||
<attribute name="string">Send Email</attribute>
|
||||
<attribute name="class">btn-danger</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//header/button[@id='quotation_send_primary']" position="before">
|
||||
<xpath expr="//header/button[@name='action_confirm' and not(@id)]" position="move"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Hide standard Delivery button: our Transfers button (below) shows
|
||||
all stock.picking records - inbound receipts AND outbound deliveries -
|
||||
which matches the plating workflow better than outbound-only. -->
|
||||
@@ -307,13 +320,13 @@
|
||||
<field name="name">sale.order.list.fp</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Sale Orders" decoration-info="state == 'draft'"
|
||||
<list string="Sale Orders" create="0" decoration-info="state == 'draft'"
|
||||
decoration-muted="state == 'cancel'"
|
||||
decoration-danger="x_fc_is_late_forecast">
|
||||
<header>
|
||||
<button name="%(action_fp_direct_order_wizard)d"
|
||||
type="action"
|
||||
string="+ New Direct Order"
|
||||
string="New Order"
|
||||
class="btn-primary"
|
||||
display="always"/>
|
||||
</header>
|
||||
@@ -341,7 +354,7 @@
|
||||
<field name="x_fc_is_blanket_order" optional="hide"/>
|
||||
<field name="x_fc_receiving_status" widget="badge"
|
||||
decoration-warning="x_fc_receiving_status == 'not_received'"
|
||||
decoration-success="x_fc_receiving_status in ('received','inspected')"/>
|
||||
decoration-success="x_fc_receiving_status == 'received'"/>
|
||||
<field name="x_fc_delivery_method" optional="hide"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="state" widget="badge"/>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.10.8.0',
|
||||
'version': '19.0.10.16.9',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -67,9 +67,11 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
'views/fp_step_priority_views.xml',
|
||||
'views/jobs_in_shopfloor_menu.xml',
|
||||
'views/legacy_menu_hide.xml',
|
||||
'views/fp_job_cert_backfill.xml',
|
||||
'views/res_users_views.xml',
|
||||
'wizards/fp_job_step_move_wizard_views.xml',
|
||||
'wizards/fp_job_step_input_wizard_views.xml',
|
||||
'wizards/fp_cert_issue_wizard_views.xml',
|
||||
'report/report_fp_job_sticker.xml',
|
||||
'report/report_fp_job_traveller.xml',
|
||||
'report/report_fp_job_wo_detail.xml',
|
||||
@@ -86,6 +88,7 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
'fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss',
|
||||
'fusion_plating_jobs/static/src/scss/fp_finish_btn.scss',
|
||||
'fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js',
|
||||
'fusion_plating_jobs/static/src/js/fp_cert_issue_wizard_autoedit.js',
|
||||
'fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
@@ -93,6 +96,7 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
'fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss',
|
||||
'fusion_plating_jobs/static/src/scss/fp_finish_btn.scss',
|
||||
'fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js',
|
||||
'fusion_plating_jobs/static/src/js/fp_cert_issue_wizard_autoedit.js',
|
||||
'fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -56,7 +56,8 @@ class FpCertificate(models.Model):
|
||||
'merged = already in the issued CoC PDF',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id')
|
||||
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id',
|
||||
'x_fc_local_thickness_pdf')
|
||||
def _compute_fischer_visibility(self):
|
||||
QC = self.env.get('fusion.plating.quality.check')
|
||||
empty_qc = self.env['fusion.plating.quality.check'] if QC is not None else None
|
||||
@@ -65,7 +66,14 @@ class FpCertificate(models.Model):
|
||||
qc = empty_qc
|
||||
pdf = empty_att
|
||||
status = 'none'
|
||||
if QC is not None and rec.x_fc_job_id:
|
||||
# Cert-local upload wins over QC-side PDF (matches the
|
||||
# merge resolution order in fp_certificate.py).
|
||||
if rec.x_fc_local_thickness_pdf:
|
||||
if rec.state == 'issued' and rec.attachment_id:
|
||||
status = 'merged'
|
||||
else:
|
||||
status = 'pending'
|
||||
elif QC is not None and rec.x_fc_job_id:
|
||||
# Same lookup the merge method uses — passed-first,
|
||||
# then any QC with a PDF.
|
||||
qc = QC.sudo().search([
|
||||
|
||||
@@ -189,6 +189,15 @@ class FpJob(models.Model):
|
||||
back to partner-level send_coc / send_thickness_report flags.
|
||||
'none' returns empty (commercial customer, no paperwork).
|
||||
Unknown requirement codes default to {'coc'} as a safety net.
|
||||
|
||||
Bundling rule (2026-05-18 — Entech workflow): when a CoC is
|
||||
wanted AND thickness is wanted, the thickness data is delivered
|
||||
as page 2 of the CoC PDF (see _fp_merge_thickness_into_pdf),
|
||||
so we return ONE cert ({'coc'}) instead of two. A standalone
|
||||
thickness_report cert is only produced when thickness is wanted
|
||||
WITHOUT a CoC — a rare edge case kept for completeness.
|
||||
Action_issue's thickness-data gate enforces actual readings or
|
||||
a Fischerscope PDF on the merged CoC.
|
||||
"""
|
||||
self.ensure_one()
|
||||
req = (
|
||||
@@ -196,16 +205,17 @@ class FpJob(models.Model):
|
||||
and self.part_catalog_id.certificate_requirement
|
||||
) or 'inherit'
|
||||
if req == 'inherit':
|
||||
types = set()
|
||||
if self.partner_id.x_fc_send_coc:
|
||||
types.add('coc')
|
||||
if self.partner_id.x_fc_send_thickness_report:
|
||||
types.add('thickness_report')
|
||||
return types
|
||||
want_coc = bool(self.partner_id.x_fc_send_coc)
|
||||
want_thickness = bool(self.partner_id.x_fc_send_thickness_report)
|
||||
if want_coc:
|
||||
return {'coc'} # thickness gets merged in
|
||||
if want_thickness:
|
||||
return {'thickness_report'}
|
||||
return set()
|
||||
return {
|
||||
'none': set(),
|
||||
'coc': {'coc'},
|
||||
'coc_thickness': {'coc', 'thickness_report'},
|
||||
'coc_thickness': {'coc'}, # bundled — thickness on page 2
|
||||
}.get(req, {'coc'})
|
||||
|
||||
next_milestone_action = fields.Selection(
|
||||
@@ -308,9 +318,29 @@ class FpJob(models.Model):
|
||||
return fn()
|
||||
|
||||
def _action_open_draft_certs(self):
|
||||
"""Open the cert list filtered to draft certs for this job.
|
||||
Manager reviews each in turn and clicks Issue per-cert."""
|
||||
"""Open the Issue Certs wizard for this job's draft certs.
|
||||
|
||||
The wizard prompts for a Fischerscope upload + readings per cert
|
||||
that needs thickness data (bundled CoC or standalone thickness
|
||||
report). Pure CoC certs (no thickness needed) appear in the
|
||||
wizard too and just need a Confirm click. Cleaner than the old
|
||||
"list view → open each cert → click Issue" flow.
|
||||
|
||||
Falls back to the cert list view if the wizard model isn't
|
||||
installed (defensive — should always exist when this module is).
|
||||
"""
|
||||
self.ensure_one()
|
||||
Wizard = self.env.get('fp.cert.issue.wizard')
|
||||
if Wizard is not None:
|
||||
try:
|
||||
return Wizard.open_for_job(self)
|
||||
except UserError:
|
||||
raise
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Job %s: cert issue wizard failed (%s) — "
|
||||
"falling back to cert list.", self.name, e,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Draft Certificates — %s') % self.name,
|
||||
@@ -1510,6 +1540,23 @@ class FpJob(models.Model):
|
||||
# qty tracking truly doesn't apply).
|
||||
skip_qty_gate = self.env.context.get('fp_skip_qty_reconcile')
|
||||
if not skip_qty_gate and job.qty:
|
||||
# Smooth the typical "clean close" case so the operator
|
||||
# doesn't have to manually type qty_done = ordered_qty
|
||||
# every time. Conditions for safe auto-fill:
|
||||
# - operator has NOT recorded any scrap or done qty
|
||||
# (so we're not overriding their explicit entry)
|
||||
# - the receiving closed with matching qty (parts
|
||||
# physically came in as expected)
|
||||
# - no visual-inspection rejects recorded
|
||||
# When any of those fail, fall through to the gate so
|
||||
# the operator reconciles by hand. Mirrors the receiving
|
||||
# `_update_job_qty_received` pattern: server fills the
|
||||
# obvious default, operator owns the edge cases.
|
||||
if (not job.qty_done and not job.qty_scrapped
|
||||
and not (job.qty_visual_inspection_rejects or 0)
|
||||
and job.qty_received
|
||||
and abs(job.qty_received - job.qty) < 0.0001):
|
||||
job.qty_done = job.qty
|
||||
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
|
||||
if abs(accounted - job.qty) > 0.0001:
|
||||
raise UserError(_(
|
||||
@@ -1521,6 +1568,37 @@ class FpJob(models.Model):
|
||||
job.name, job.qty, job.qty_done or 0,
|
||||
job.qty_scrapped or 0, accounted, job.qty,
|
||||
))
|
||||
# Receiving reconciliation: parts must be physically
|
||||
# received before the job can close, and the count must
|
||||
# match what came out (done + scrapped + visual rejects).
|
||||
# Without this guard a job ships with the wrong cert qty,
|
||||
# or worse, with no closed receiving for the auditor to
|
||||
# trace back to. Same bypass flag covers both checks.
|
||||
if not job.qty_received:
|
||||
raise UserError(_(
|
||||
"Job %s cannot be marked Done — Quantity Received "
|
||||
"is blank. Close the receiving record for SO %s "
|
||||
"before completing this job."
|
||||
) % (
|
||||
job.name,
|
||||
job.sale_order_id.name if job.sale_order_id else '?',
|
||||
))
|
||||
rejects = job.qty_visual_inspection_rejects or 0
|
||||
accounted_out = (
|
||||
(job.qty_done or 0)
|
||||
+ (job.qty_scrapped or 0)
|
||||
+ rejects
|
||||
)
|
||||
if abs(job.qty_received - accounted_out) > 0.0001:
|
||||
raise UserError(_(
|
||||
"Job %s qty mismatch — received %g, but qty_done "
|
||||
"(%g) + qty_scrapped (%g) + visual rejects (%g) "
|
||||
"= %g. Reconcile before closing."
|
||||
) % (
|
||||
job.name, job.qty_received,
|
||||
job.qty_done or 0, job.qty_scrapped or 0,
|
||||
rejects, accounted_out,
|
||||
))
|
||||
# QC gate: customers flagged x_fc_requires_qc must have a
|
||||
# passed QC before the job closes. AS9100 / Nadcap compliance.
|
||||
if QC and not skip_qc_gate \
|
||||
@@ -1596,22 +1674,30 @@ class FpJob(models.Model):
|
||||
refund auto-link, and the legacy notification dispatch all
|
||||
look up by job_ref. Setting both ends keeps every consumer
|
||||
happy.
|
||||
|
||||
Auto-populates everything we can resolve from upstream
|
||||
records so the shipping crew doesn't have to re-type
|
||||
addresses / contacts / dates that already exist on the SO:
|
||||
- delivery_address_id, contact_name, contact_phone — SO's
|
||||
partner_shipping_id (falls back to partner_id)
|
||||
- scheduled_date — SO.commitment_date
|
||||
- source_facility_id — job.facility_id
|
||||
- x_fc_carrier_id, x_fc_outbound_shipment_id — from the
|
||||
SO's first receiving record (set at receive time)
|
||||
- coc_attachment_id — issued cert.attachment_id for this
|
||||
job (if a CoC is already issued before delivery exists;
|
||||
otherwise the cert's action_issue back-fills it later)
|
||||
|
||||
Everything skips silently when the source field doesn't
|
||||
exist or the source value is blank, so older install
|
||||
topologies and partially-configured jobs still get a
|
||||
delivery — just less pre-filled.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.delivery_id:
|
||||
return
|
||||
Delivery = self.env['fusion.plating.delivery'].sudo()
|
||||
vals = {'partner_id': self.partner_id.id}
|
||||
if 'x_fc_job_id' in Delivery._fields:
|
||||
vals['x_fc_job_id'] = self.id
|
||||
if 'job_ref' in Delivery._fields:
|
||||
vals['job_ref'] = self.name
|
||||
if 'x_fc_job_id' not in Delivery._fields \
|
||||
and 'job_ref' not in Delivery._fields:
|
||||
_logger.warning(
|
||||
"Job %s: fusion.plating.delivery has no job link field; "
|
||||
"delivery created without job back-reference.", self.name,
|
||||
)
|
||||
vals = self._fp_resolve_delivery_defaults(Delivery)
|
||||
try:
|
||||
delivery = Delivery.create(vals)
|
||||
self.delivery_id = delivery.id
|
||||
@@ -1620,19 +1706,88 @@ class FpJob(models.Model):
|
||||
"Job %s: failed to auto-create delivery: %s", self.name, e,
|
||||
)
|
||||
|
||||
def _fp_resolve_delivery_defaults(self, Delivery):
|
||||
"""Build the create-vals for a fresh delivery, OR the
|
||||
write-vals for refreshing an existing one. Centralised so
|
||||
the create path, the per-cert post-issue sync, and any
|
||||
future 'Refresh from Source' button all stay consistent.
|
||||
"""
|
||||
self.ensure_one()
|
||||
vals = {'partner_id': self.partner_id.id}
|
||||
if 'x_fc_job_id' in Delivery._fields:
|
||||
vals['x_fc_job_id'] = self.id
|
||||
if 'job_ref' in Delivery._fields:
|
||||
vals['job_ref'] = self.name
|
||||
# Delivery address + contact details from the SO. shipping
|
||||
# partner is preferred (that's where parts physically go);
|
||||
# fall back to the SO's main partner when no separate ship-to.
|
||||
so = self.sale_order_id
|
||||
ship_to = (so.partner_shipping_id or so.partner_id) if so else False
|
||||
if ship_to:
|
||||
if 'delivery_address_id' in Delivery._fields:
|
||||
vals['delivery_address_id'] = ship_to.id
|
||||
if 'contact_name' in Delivery._fields and ship_to.name:
|
||||
vals['contact_name'] = ship_to.name
|
||||
if 'contact_phone' in Delivery._fields:
|
||||
vals['contact_phone'] = ship_to.phone or ship_to.mobile or ''
|
||||
# Scheduled date — operator can adjust; this just primes it
|
||||
# so they're not staring at a blank field.
|
||||
if so and so.commitment_date and 'scheduled_date' in Delivery._fields:
|
||||
vals['scheduled_date'] = so.commitment_date
|
||||
# Source facility comes from the job (where it was plated).
|
||||
if self.facility_id and 'source_facility_id' in Delivery._fields:
|
||||
vals['source_facility_id'] = self.facility_id.id
|
||||
# Outbound carrier + shipment mirrored from the SO's first
|
||||
# receiving record (the crew chose these at receipt time).
|
||||
if (so and 'x_fc_receiving_ids' in so._fields
|
||||
and so.x_fc_receiving_ids):
|
||||
recv = so.x_fc_receiving_ids[:1]
|
||||
if 'x_fc_carrier_id' in Delivery._fields \
|
||||
and 'x_fc_carrier_id' in recv._fields \
|
||||
and recv.x_fc_carrier_id:
|
||||
vals['x_fc_carrier_id'] = recv.x_fc_carrier_id.id
|
||||
if 'x_fc_outbound_shipment_id' in Delivery._fields \
|
||||
and 'x_fc_outbound_shipment_id' in recv._fields \
|
||||
and recv.x_fc_outbound_shipment_id:
|
||||
vals['x_fc_outbound_shipment_id'] = (
|
||||
recv.x_fc_outbound_shipment_id.id
|
||||
)
|
||||
# CoC PDF — if a cert for this job is already issued and
|
||||
# the delivery field accepts an attachment, link it. The
|
||||
# cert's action_issue also calls _fp_sync_to_delivery for
|
||||
# the case where the cert issues AFTER the delivery exists.
|
||||
Cert = self.env.get('fp.certificate')
|
||||
if Cert is not None and 'coc_attachment_id' in Delivery._fields:
|
||||
issued_cert = Cert.sudo().search([
|
||||
('x_fc_job_id', '=', self.id),
|
||||
('certificate_type', '=', 'coc'),
|
||||
('state', '=', 'issued'),
|
||||
('attachment_id', '!=', False),
|
||||
], order='issue_date desc, id desc', limit=1)
|
||||
if issued_cert and issued_cert.attachment_id:
|
||||
vals['coc_attachment_id'] = issued_cert.attachment_id.id
|
||||
return vals
|
||||
|
||||
def _fp_create_certificates(self):
|
||||
"""Auto-create one draft fp.certificate per type returned by
|
||||
_resolve_required_cert_types. Idempotent per type — re-running
|
||||
on a job that already has a CoC won't create another one.
|
||||
|
||||
Each cert is pre-populated with everything action_issue needs
|
||||
(partner, spec_reference, part_number, quantity_shipped, po,
|
||||
(partner, spec_reference, process_description, certified_by,
|
||||
contact_partner, part_number, quantity_shipped, NC qty, PO,
|
||||
SO link, job link) so the manager just reviews and clicks Issue.
|
||||
|
||||
Replaces the single-CoC implementation: now honours
|
||||
part.certificate_requirement (coc / coc_thickness / none /
|
||||
inherit) and partner-level send_coc / send_thickness_report
|
||||
flags. Closes spec gap C-G1.
|
||||
Resolution sources for the new prefill fields:
|
||||
- process_description ← recipe.name (the job's process root)
|
||||
- certified_by_id ← customer_spec.signer_user_id, falling
|
||||
back to company.x_fc_owner_user_id
|
||||
- contact_partner_id ← partner.x_fc_default_coc_contact_id
|
||||
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
|
||||
|
||||
Honours part.certificate_requirement (coc / coc_thickness /
|
||||
none / inherit) and partner-level send_coc /
|
||||
send_thickness_report flags. Closes spec gap C-G1.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if 'fp.certificate' not in self.env:
|
||||
@@ -1645,6 +1800,25 @@ class FpJob(models.Model):
|
||||
# Spec drives the cert spec_reference. The customer.spec was
|
||||
# auto-filled onto the job at confirm time (sale_order.py).
|
||||
spec = self.customer_spec_id
|
||||
# Recipe drives the process description on the cert. Was previously
|
||||
# sourced from sale_order.x_fc_coating_config_id (since retired);
|
||||
# recipe.name is the human-readable replacement.
|
||||
recipe = self.recipe_id
|
||||
# Signer resolution: per-spec override wins, company default fills.
|
||||
signer = False
|
||||
if spec and 'signer_user_id' in spec._fields:
|
||||
signer = spec.signer_user_id
|
||||
if not signer and 'x_fc_owner_user_id' in self.company_id._fields:
|
||||
signer = self.company_id.x_fc_owner_user_id
|
||||
# Contact: per-customer default; blank means manager picks at issue.
|
||||
contact = False
|
||||
if 'x_fc_default_coc_contact_id' in self.partner_id._fields:
|
||||
contact = self.partner_id.x_fc_default_coc_contact_id
|
||||
# NC qty: scrapped + visual rejects. Both NULL-safe.
|
||||
nc_qty = int(
|
||||
(self.qty_scrapped or 0)
|
||||
+ (self.qty_visual_inspection_rejects or 0)
|
||||
)
|
||||
for cert_type in sorted(required):
|
||||
# Idempotency per type.
|
||||
existing_dom = [('certificate_type', '=', cert_type)]
|
||||
@@ -1691,6 +1865,8 @@ class FpJob(models.Model):
|
||||
(self.qty_done or self.qty or 0)
|
||||
- (self.qty_scrapped or 0)
|
||||
)
|
||||
if 'nc_quantity' in Cert._fields:
|
||||
vals['nc_quantity'] = nc_qty
|
||||
if 'po_number' in Cert._fields and self.sale_order_id \
|
||||
and 'x_fc_po_number' in self.sale_order_id._fields:
|
||||
vals['po_number'] = (
|
||||
@@ -1703,8 +1879,12 @@ class FpJob(models.Model):
|
||||
vals['customer_job_no'] = (
|
||||
self.sale_order_id.x_fc_customer_job_number or ''
|
||||
)
|
||||
if 'process_description' in Cert._fields and coating:
|
||||
vals['process_description'] = coating.name or ''
|
||||
if 'process_description' in Cert._fields and recipe:
|
||||
vals['process_description'] = recipe.name or ''
|
||||
if 'certified_by_id' in Cert._fields and signer:
|
||||
vals['certified_by_id'] = signer.id
|
||||
if 'contact_partner_id' in Cert._fields and contact:
|
||||
vals['contact_partner_id'] = contact.id
|
||||
if 'entech_wo_number' in Cert._fields:
|
||||
vals['entech_wo_number'] = self.name or ''
|
||||
cert = Cert.create(vals)
|
||||
@@ -1728,6 +1908,107 @@ class FpJob(models.Model):
|
||||
) % {'t': cert_type, 'e': e})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Backfill — closed jobs missing certs, plus cleanup of legacy
|
||||
# duplicate thickness_report certs created before the bundling rule.
|
||||
# ------------------------------------------------------------------
|
||||
# One-shot management action for jobs that closed BEFORE the
|
||||
# _fp_create_certificates bug fix (e.g. WO-30040). Two passes:
|
||||
# 1. CREATE any missing draft cert per the (updated) resolver
|
||||
# 2. VOID legacy duplicate thickness_report certs that have a
|
||||
# paired CoC on the same job — the bundling rule says the
|
||||
# CoC carries the thickness data on page 2
|
||||
# Both passes are idempotent — safe to re-run.
|
||||
@api.model
|
||||
def action_backfill_missing_certs(self):
|
||||
Cert = self.env.get('fp.certificate')
|
||||
if Cert is None:
|
||||
raise UserError(_(
|
||||
'fp.certificate model is not installed. Install '
|
||||
'fusion_plating_certificates before running this action.'
|
||||
))
|
||||
candidate_jobs = self.search([('state', '=', 'done')])
|
||||
scanned = 0
|
||||
backfilled_jobs = self.env['fp.job']
|
||||
created_count = 0
|
||||
voided_count = 0
|
||||
has_job_link = 'x_fc_job_id' in Cert._fields
|
||||
for job in candidate_jobs:
|
||||
required = job._resolve_required_cert_types()
|
||||
if not required:
|
||||
continue
|
||||
scanned += 1
|
||||
existing_certs = (
|
||||
Cert.sudo().search([('x_fc_job_id', '=', job.id)])
|
||||
if has_job_link else
|
||||
(Cert.sudo().search([
|
||||
('sale_order_id', '=', job.sale_order_id.id),
|
||||
]) if job.sale_order_id else Cert.browse())
|
||||
)
|
||||
existing_types = set(existing_certs.mapped('certificate_type'))
|
||||
|
||||
# ---- Pass 1: create missing certs --------------------------
|
||||
missing = required - existing_types
|
||||
if missing:
|
||||
before = len(existing_certs)
|
||||
job._fp_create_certificates()
|
||||
# Re-read to get the freshly-created ones for pass 2.
|
||||
existing_certs = (
|
||||
Cert.sudo().search([('x_fc_job_id', '=', job.id)])
|
||||
if has_job_link else existing_certs
|
||||
)
|
||||
delta = max(len(existing_certs) - before, 0)
|
||||
if delta:
|
||||
backfilled_jobs |= job
|
||||
created_count += delta
|
||||
|
||||
# ---- Pass 2: void duplicate thickness_report certs ---------
|
||||
# Bundling rule (CLAUDE.md): when CoC + thickness are both
|
||||
# wanted, the CoC absorbs the thickness data. A leftover
|
||||
# draft thickness_report cert on the same job is now noise
|
||||
# and should not be issued. Void it with a clear reason so
|
||||
# the audit trail tells the story.
|
||||
if 'coc' in required and 'coc' in existing_types:
|
||||
dup_thickness = existing_certs.filtered(
|
||||
lambda c: (c.certificate_type == 'thickness_report'
|
||||
and c.state == 'draft')
|
||||
)
|
||||
for cert in dup_thickness:
|
||||
cert.sudo().write({
|
||||
'state': 'voided',
|
||||
'void_reason': (
|
||||
'Auto-voided: bundling rule — thickness '
|
||||
'data is delivered as page 2 of the paired '
|
||||
'CoC, not as a separate cert.'
|
||||
),
|
||||
})
|
||||
cert.message_post(body=_(
|
||||
'Auto-voided by cleanup: bundling rule routes '
|
||||
'thickness data to the CoC.'
|
||||
))
|
||||
voided_count += 1
|
||||
backfilled_jobs |= job
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Cert backfill + cleanup complete'),
|
||||
'message': _(
|
||||
'Scanned %(s)d closed jobs. Created %(c)d draft '
|
||||
'cert(s); voided %(v)d duplicate thickness_report '
|
||||
'cert(s) across %(j)d job(s).'
|
||||
) % {
|
||||
's': scanned,
|
||||
'c': created_count,
|
||||
'v': voided_count,
|
||||
'j': len(backfilled_jobs),
|
||||
},
|
||||
'sticky': True,
|
||||
'type': 'success' if (created_count or voided_count) else 'warning',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
"""Phase 7 — adds the migration idempotency key on fp.job.step.
|
||||
|
||||
|
||||
@@ -823,16 +823,67 @@ class FpJobStep(models.Model):
|
||||
'state': state_label,
|
||||
})
|
||||
|
||||
def _fp_check_receiving_gate(self):
|
||||
"""Block step transitions until parts are physically received.
|
||||
|
||||
Applied to every step EXCEPT Contract Review (paperwork — doesn't
|
||||
need parts on the floor). Fires from both button_start and
|
||||
button_finish so an operator can't begin OR complete physical
|
||||
work before the receiving record is closed.
|
||||
|
||||
Manager bypass: ``fp_skip_receiving_gate=True`` in context. Same
|
||||
pattern as the qty / QC / bake gates. Audit trail is preserved
|
||||
via the state-transition tracking on chatter.
|
||||
|
||||
Threshold: SO ``x_fc_receiving_status == 'received'``. Post-Sub-8
|
||||
that's the terminal state (inspection moved into the recipe's
|
||||
racking step; ``'inspected'`` was dropped in the 2026-05-18
|
||||
cleanup).
|
||||
"""
|
||||
if self.env.context.get('fp_skip_receiving_gate'):
|
||||
return
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
continue
|
||||
so = step.job_id.sale_order_id
|
||||
if not so:
|
||||
# Internal rework / no SO — gate doesn't apply.
|
||||
continue
|
||||
if 'x_fc_receiving_status' not in so._fields:
|
||||
# Defensive: configurator module not installed.
|
||||
continue
|
||||
if so.x_fc_receiving_status != 'received':
|
||||
label = dict(
|
||||
so._fields['x_fc_receiving_status'].selection
|
||||
).get(
|
||||
so.x_fc_receiving_status,
|
||||
so.x_fc_receiving_status or 'unknown',
|
||||
)
|
||||
raise UserError(_(
|
||||
'Step "%(step)s" cannot proceed — parts not received '
|
||||
'yet (SO %(so)s receiving status: %(status)s).\n\n'
|
||||
'Close the receiving record (Sales > %(so)s > '
|
||||
'Receiving) before starting or finishing work on '
|
||||
'this step. A manager can bypass this gate for '
|
||||
'documented exceptions.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'so': so.name or '?',
|
||||
'status': label,
|
||||
})
|
||||
|
||||
def button_start(self):
|
||||
"""Single source of truth for step start:
|
||||
1. Sub 13 predecessor gate (raise UserError if blocking)
|
||||
2. Policy B Contract Review auto-open (route to QA-005)
|
||||
3. Sub 8 Racking auto-open (route to racking inspection)
|
||||
4. super().button_start() + receiving soft check + serial
|
||||
promotion for the standard path
|
||||
2. Receiving gate (raise UserError if parts not received)
|
||||
3. Policy B Contract Review auto-open (route to QA-005)
|
||||
4. Sub 8 Racking auto-open (route to racking inspection)
|
||||
5. super().button_start() + serial promotion for the standard
|
||||
path
|
||||
|
||||
Manager bypasses available via context:
|
||||
fp_skip_predecessor_check=True skips the Sub 13 gate
|
||||
fp_skip_receiving_gate=True skips the receiving gate
|
||||
"""
|
||||
# ---- 1. Sub 13 predecessor gate ----------------------------------
|
||||
skip_pred = self.env.context.get('fp_skip_predecessor_check')
|
||||
@@ -863,7 +914,13 @@ class FpJobStep(models.Model):
|
||||
),
|
||||
))
|
||||
|
||||
# ---- 2. Policy B Contract Review auto-open -----------------------
|
||||
# ---- 2. Receiving gate -------------------------------------------
|
||||
# Hard block (replaces the prior soft chatter warning). The
|
||||
# helper exempts Contract Review steps internally, so contract
|
||||
# review can still auto-open below regardless of receiving state.
|
||||
self._fp_check_receiving_gate()
|
||||
|
||||
# ---- 3. Policy B Contract Review auto-open -----------------------
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
action = step._fp_open_contract_review()
|
||||
@@ -873,7 +930,7 @@ class FpJobStep(models.Model):
|
||||
step._fp_promote_serials_on_start()
|
||||
return action
|
||||
|
||||
# ---- 3. Sub 8 Racking auto-open ----------------------------------
|
||||
# ---- 4. Sub 8 Racking auto-open ----------------------------------
|
||||
for step in self:
|
||||
if step._fp_is_racking_step():
|
||||
action = step._fp_open_racking_inspection()
|
||||
@@ -883,33 +940,18 @@ class FpJobStep(models.Model):
|
||||
step._fp_promote_serials_on_start()
|
||||
return action
|
||||
|
||||
# ---- 4. Standard path: start + receiving check + serial promote --
|
||||
# ---- 5. Standard path: start + serial promote --------------------
|
||||
result = super().button_start()
|
||||
for step in self:
|
||||
if step.state == 'in_progress':
|
||||
step._fp_promote_serials_on_start()
|
||||
so = step.job_id.sale_order_id
|
||||
if not so:
|
||||
continue
|
||||
recv = so.x_fc_receiving_status if (
|
||||
'x_fc_receiving_status' in so._fields
|
||||
) else None
|
||||
if recv in (False, None, 'not_received'):
|
||||
step.job_id.message_post(body=_(
|
||||
'Step "%(step)s" started before parts were received '
|
||||
'(SO %(so)s — receiving status: %(status)s). '
|
||||
'Confirm the parts are physically on the floor before '
|
||||
'continuing.'
|
||||
) % {
|
||||
'step': step.name,
|
||||
'so': so.name or '',
|
||||
'status': recv or 'unknown',
|
||||
})
|
||||
return result
|
||||
|
||||
def button_finish(self):
|
||||
# Policy B — block until QA-005 complete (when customer requires it).
|
||||
self._fp_check_contract_review_complete()
|
||||
# Receiving gate — same helper as button_start, exempts CR steps.
|
||||
self._fp_check_receiving_gate()
|
||||
# NOTE: racking inspection gate removed — racking is now a recipe
|
||||
# step, not a separate inspection workflow. _fp_check_racking_
|
||||
# inspection_complete() is kept as a helper for diagnostics but
|
||||
|
||||
@@ -175,10 +175,13 @@ class SaleOrder(models.Model):
|
||||
if recv_status == 'not_received':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status in ('partial', 'received'):
|
||||
so.x_fc_workflow_stage = 'inspecting'
|
||||
if recv_status == 'partial':
|
||||
so.x_fc_workflow_stage = 'awaiting_parts'
|
||||
continue
|
||||
if recv_status == 'inspected':
|
||||
if recv_status == 'received':
|
||||
# Sub 8: 'received' is the terminal receiving state (no
|
||||
# more separate 'inspected'). Parts are on the floor;
|
||||
# inspection happens inside the recipe's racking step.
|
||||
if not so.x_fc_assigned_manager_id and not jobs:
|
||||
so.x_fc_workflow_stage = 'assign_work'
|
||||
continue
|
||||
@@ -562,16 +565,27 @@ class SaleOrder(models.Model):
|
||||
return True
|
||||
|
||||
def action_fp_accept_parts(self):
|
||||
"""Mark receiving accepted; flip SO receiving status to inspected."""
|
||||
"""Mark receiving complete; flip SO receiving status to received.
|
||||
|
||||
Sub 8 (2026-04-22) moved inspection out of receiving and into the
|
||||
recipe's racking step. Receiving's terminal state is now 'closed'
|
||||
(or legacy 'accepted'), which maps to SO status 'received'. The
|
||||
old 'inspected' SO status no longer exists.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Recv = self.env.get('fp.receiving')
|
||||
if Recv is None:
|
||||
return False
|
||||
for rec in Recv.search([('sale_order_id', '=', self.id)]):
|
||||
if rec.state in ('draft', 'inspecting'):
|
||||
# Push receiving to its terminal state — 'closed' is the
|
||||
# post-Sub-8 terminal; 'accepted' kept as a legacy fallback
|
||||
# only for old records still in pre-Sub-8 states.
|
||||
if rec.state in ('draft', 'counted', 'staged'):
|
||||
rec.state = 'closed'
|
||||
elif rec.state in ('inspecting',):
|
||||
rec.state = 'accepted'
|
||||
if 'x_fc_receiving_status' in self._fields:
|
||||
self.x_fc_receiving_status = 'inspected'
|
||||
self.x_fc_receiving_status = 'received'
|
||||
self.message_post(body=_('Parts accepted — ready to assign manager.'))
|
||||
return True
|
||||
|
||||
|
||||
@@ -20,3 +20,9 @@ access_fp_job_step_input_wiz_l_mgr,fp.job.step.input.wiz.l.manager,model_fp_job_
|
||||
access_fp_workflow_state_op,fp.workflow.state.operator,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_workflow_state_sup,fp.workflow.state.supervisor,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
|
||||
access_fp_workflow_state_mgr,fp.workflow.state.manager,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_sup,fp.cert.issue.wiz.supervisor,model_fp_cert_issue_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_issue_wiz_mgr,fp.cert.issue.wiz.manager,model_fp_cert_issue_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_l_sup,fp.cert.issue.wiz.l.supervisor,model_fp_cert_issue_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_issue_wiz_l_mgr,fp.cert.issue.wiz.l.manager,model_fp_cert_issue_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_cert_issue_wiz_r_sup,fp.cert.issue.wiz.r.supervisor,model_fp_cert_issue_wizard_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
|
||||
access_fp_cert_issue_wiz_r_mgr,fp.cert.issue.wiz.r.manager,model_fp_cert_issue_wizard_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,86 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* Fusion Plating — Issue Certs wizard: auto-edit the first incomplete row
|
||||
* on wizard mount.
|
||||
*
|
||||
* Background: Odoo's editable o2m list keeps non-selected rows in display
|
||||
* mode, which hides the binary widget's "↑ Upload your file" link until
|
||||
* the operator clicks the row. Operators reported the wizard as broken
|
||||
* because the file field appeared empty.
|
||||
*
|
||||
* Fix without fighting CSS: when the wizard's list renders, simulate a
|
||||
* click on the first row that still needs thickness data. The native
|
||||
* binary widget then renders in edit mode and the upload link is
|
||||
* immediately visible — no theme override needed.
|
||||
*
|
||||
* Scoped to `.o_fp_cert_issue_wizard_form` (the wizard form's
|
||||
* css_class) so this DOM-poke doesn't fire on other editable o2m lists.
|
||||
*/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { formView } from "@web/views/form/form_view";
|
||||
import { FormController } from "@web/views/form/form_controller";
|
||||
import { onMounted } from "@odoo/owl";
|
||||
|
||||
|
||||
export class FpCertIssueWizardFormController extends FormController {
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
onMounted(() => {
|
||||
// Defer one tick so the o2m list has finished its first paint.
|
||||
requestAnimationFrame(() => this._fpAutoEditFirstRow());
|
||||
});
|
||||
}
|
||||
|
||||
_fpAutoEditFirstRow() {
|
||||
// Only fire on the cert-issue wizard. Other form views that use
|
||||
// the same FormController class get the default behaviour.
|
||||
const root = this.rootRef && this.rootRef.el;
|
||||
if (!root || !root.classList.contains("o_fp_cert_issue_wizard_form")) {
|
||||
return;
|
||||
}
|
||||
// First row that backs a line where is_ready is False (the data
|
||||
// toggle column renders as `false`). Fallback: the very first
|
||||
// data row.
|
||||
const dataRows = root.querySelectorAll(
|
||||
".o_field_one2many[name='line_ids'] .o_list_renderer .o_data_row"
|
||||
);
|
||||
if (!dataRows.length) {
|
||||
return;
|
||||
}
|
||||
let target = null;
|
||||
for (const row of dataRows) {
|
||||
// Look for an unchecked is_ready toggle inside the row. If
|
||||
// we find one, that row needs attention.
|
||||
const readyToggle = row.querySelector(
|
||||
"[name='is_ready'] input[type='checkbox']"
|
||||
);
|
||||
if (readyToggle && !readyToggle.checked) {
|
||||
target = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!target) {
|
||||
target = dataRows[0];
|
||||
}
|
||||
// Find the fischer_file cell specifically — clicking THAT cell
|
||||
// (not just any cell) puts the row in edit mode AND focuses the
|
||||
// upload widget, so the native "Upload your file" link is the
|
||||
// very first thing the operator sees.
|
||||
const fischerCell = target.querySelector("[name='fischer_file']");
|
||||
if (fischerCell) {
|
||||
fischerCell.click();
|
||||
} else {
|
||||
// Fallback: click the row anywhere, then the upload column
|
||||
// shows up in the now-active row.
|
||||
target.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
registry.category("views").add("fp_cert_issue_wizard_form", {
|
||||
...formView,
|
||||
Controller: FpCertIssueWizardFormController,
|
||||
});
|
||||
@@ -17,7 +17,7 @@
|
||||
* onSave → /fp/record_inputs/commit → advance step (optional)
|
||||
*/
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { Component, markup, onWillStart, useState } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
@@ -106,7 +106,10 @@ export class FpRecordInputsDialog extends Component {
|
||||
this.state.jobName = data.job.name;
|
||||
this.state.recipeRootId = data.recipe_root_id || false;
|
||||
this.state.userInitials = data.user_initials || "";
|
||||
this.state.instructionsHtml = data.instructions_html || "";
|
||||
// `t-out` only renders unescaped HTML when the value is a
|
||||
// `markup()`-tagged string — otherwise it shows literal tags
|
||||
// (e.g. `<p>foo</p>`). See CLAUDE.md "OWL `t-out` escapes".
|
||||
this.state.instructionsHtml = markup(data.instructions_html || "");
|
||||
this.state.instructionImages = data.instruction_images || [];
|
||||
const nowDt = this._fpNowForDatetimeLocal();
|
||||
this.state.rows = data.prompts.map((p) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_fp_job_extensions
|
||||
from . import test_fp_job_milestone_cascade
|
||||
from . import test_qty_received_propagation
|
||||
|
||||
@@ -589,3 +589,363 @@ class TestQtyGate(TransactionCase):
|
||||
with self.assertRaises(UserError) as exc:
|
||||
wiz.action_commit()
|
||||
self.assertIn('at least 1', str(exc.exception))
|
||||
|
||||
|
||||
class TestCertCreationAndGates(TransactionCase):
|
||||
"""2026-05-18 — cert creation bug fix + gate hardening.
|
||||
|
||||
Covers the fixes for the WO-30040 incident where
|
||||
_fp_create_certificates raised NameError on `coating` and the cert
|
||||
was never created. Also covers the new qty_received gate on
|
||||
button_mark_done and the auto-fill of certified_by_id /
|
||||
contact_partner_id / nc_quantity / process_description.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.signer = cls.env['res.users'].create({
|
||||
'name': 'Quality Manager',
|
||||
'login': 'qa_mgr_certtest',
|
||||
'email': 'qa@example.com',
|
||||
})
|
||||
cls.contact = cls.env['res.partner'].create({
|
||||
'name': 'Bob Receiver',
|
||||
'email': 'bob@cust.example',
|
||||
})
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'CertCust',
|
||||
'is_company': True,
|
||||
'x_fc_send_coc': True,
|
||||
'x_fc_default_coc_contact_id': cls.contact.id,
|
||||
})
|
||||
cls.contact.parent_id = cls.partner.id
|
||||
cls.product = cls.env['product.product'].create({
|
||||
'name': 'CertWidget',
|
||||
})
|
||||
cls.part = cls.env['fp.part.catalog'].create({
|
||||
'name': 'CertPart',
|
||||
'part_number': 'CP-001',
|
||||
'partner_id': cls.partner.id,
|
||||
'certificate_requirement': 'coc',
|
||||
})
|
||||
|
||||
def _make_job(self, **kw):
|
||||
vals = {
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'part_catalog_id': self.part.id,
|
||||
'qty': 1.0,
|
||||
'qty_done': 1.0,
|
||||
'qty_received': 1.0,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fp.job'].create(vals)
|
||||
|
||||
# ---------------- bug fix regression -------------------------------
|
||||
|
||||
def test_create_cert_handles_job_with_no_recipe(self):
|
||||
"""Regression for the `coating` NameError: cert must create
|
||||
even when the job has no recipe and no coating config."""
|
||||
job = self._make_job()
|
||||
self.assertFalse(job.recipe_id)
|
||||
job._fp_create_certificates()
|
||||
certs = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(len(certs), 1)
|
||||
self.assertFalse(certs.process_description)
|
||||
|
||||
# ---------------- prefill -----------------------------------------
|
||||
|
||||
def test_create_cert_prefills_signer_from_company(self):
|
||||
self.env.company.x_fc_owner_user_id = self.signer.id
|
||||
job = self._make_job()
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.certified_by_id, self.signer)
|
||||
|
||||
def test_create_cert_prefills_contact_from_partner(self):
|
||||
job = self._make_job()
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.contact_partner_id, self.contact)
|
||||
|
||||
def test_create_cert_computes_nc_quantity(self):
|
||||
job = self._make_job(
|
||||
qty=4, qty_done=3, qty_scrapped=1, qty_received=4,
|
||||
qty_visual_inspection_rejects=0,
|
||||
)
|
||||
job._fp_create_certificates()
|
||||
cert = self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(cert.nc_quantity, 1)
|
||||
|
||||
# ---------------- mark_done qty_received gate ----------------------
|
||||
|
||||
def test_mark_done_blocks_on_blank_qty_received(self):
|
||||
from odoo.exceptions import UserError
|
||||
job = self._make_job(qty=1, qty_done=1, qty_received=0)
|
||||
step = self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
with self.assertRaises(UserError) as exc:
|
||||
job.button_mark_done()
|
||||
self.assertIn('Quantity Received', str(exc.exception))
|
||||
|
||||
def test_mark_done_blocks_on_qty_received_mismatch(self):
|
||||
from odoo.exceptions import UserError
|
||||
# received 5, accounted = 3 done + 1 scrap + 0 rejects = 4
|
||||
job = self._make_job(qty=5, qty_done=3, qty_scrapped=1,
|
||||
qty_received=5, qty_visual_inspection_rejects=0)
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
# base qty reconcile passes: 3+1=4 != 5 → first gate raises first
|
||||
# rebalance so it passes the first check and fails the new one:
|
||||
job.qty = 4
|
||||
with self.assertRaises(UserError) as exc:
|
||||
job.button_mark_done()
|
||||
self.assertIn('qty mismatch', str(exc.exception).lower())
|
||||
|
||||
def test_mark_done_passes_with_clean_reconcile(self):
|
||||
job = self._make_job(qty=4, qty_done=3, qty_scrapped=1,
|
||||
qty_received=4, qty_visual_inspection_rejects=0)
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
job.with_context(fp_skip_qc_gate=True).button_mark_done()
|
||||
self.assertEqual(job.state, 'done')
|
||||
|
||||
def test_mark_done_bypass_skips_qty_received_check(self):
|
||||
job = self._make_job(qty=1, qty_done=1, qty_received=0)
|
||||
self.env['fp.job.step'].create({
|
||||
'job_id': job.id, 'name': 'Plate', 'state': 'done',
|
||||
})
|
||||
job.invalidate_recordset(['all_steps_terminal'])
|
||||
job.with_context(
|
||||
fp_skip_qty_reconcile=True,
|
||||
fp_skip_qc_gate=True,
|
||||
).button_mark_done()
|
||||
self.assertEqual(job.state, 'done')
|
||||
|
||||
# ---------------- backfill action ---------------------------------
|
||||
|
||||
def test_backfill_creates_missing_certs(self):
|
||||
"""A closed job with no cert gets one when the backfill runs."""
|
||||
job = self._make_job()
|
||||
job.state = 'done'
|
||||
# Sanity: no cert exists
|
||||
self.assertFalse(self.env['fp.certificate'].search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
]))
|
||||
self.env['fp.job'].action_backfill_missing_certs()
|
||||
self.assertEqual(self.env['fp.certificate'].search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
]), 1)
|
||||
|
||||
def test_backfill_idempotent(self):
|
||||
job = self._make_job()
|
||||
job.state = 'done'
|
||||
job._fp_create_certificates()
|
||||
before = self.env['fp.certificate'].search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.env['fp.job'].action_backfill_missing_certs()
|
||||
after = self.env['fp.certificate'].search_count([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
])
|
||||
self.assertEqual(before, after)
|
||||
|
||||
|
||||
class TestReceivingGate(TransactionCase):
|
||||
"""2026-05-18 — Hard gate on button_start / button_finish blocking
|
||||
step transitions until SO receiving status = 'received'. Contract
|
||||
Review steps are exempt; manager bypass via context flag
|
||||
`fp_skip_receiving_gate=True`. See
|
||||
docs/superpowers/specs/2026-05-18-receiving-gate-on-step-transitions-design.md
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'RecvCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'Widget'})
|
||||
|
||||
def _make_so(self, recv_status='not_received'):
|
||||
so = self.env['sale.order'].create({'partner_id': self.partner.id})
|
||||
if 'x_fc_receiving_status' in so._fields:
|
||||
so.x_fc_receiving_status = recv_status
|
||||
return so
|
||||
|
||||
def _make_job_with_step(self, recv_status='not_received',
|
||||
step_state='ready', is_cr=False):
|
||||
"""Build a job tied to an SO with the given receiving status,
|
||||
plus a single step in the given state. Returns (job, step)."""
|
||||
so = self._make_so(recv_status=recv_status)
|
||||
job = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
# _fp_is_contract_review_step() matches case-insensitive name
|
||||
# against "contract review" / "qa-005" OR recipe_node_id default_kind.
|
||||
# Setting name='Contract Review' is the simplest reliable trigger
|
||||
# and matches how operators tag the step in production.
|
||||
step_vals = {
|
||||
'job_id': job.id,
|
||||
'name': 'Contract Review' if is_cr else 'Plate',
|
||||
'state': step_state,
|
||||
}
|
||||
step = self.env['fp.job.step'].create(step_vals)
|
||||
return job, step
|
||||
|
||||
# ---- button_start gate ------------------------------------------------
|
||||
|
||||
def test_start_blocks_when_not_received(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step = self._make_job_with_step(recv_status='not_received')
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step.button_start()
|
||||
self.assertIn('parts not received', str(exc.exception).lower())
|
||||
|
||||
def test_start_allows_when_received(self):
|
||||
job, step = self._make_job_with_step(recv_status='received')
|
||||
# Should not raise; step transitions to in_progress via super().
|
||||
step.button_start()
|
||||
self.assertIn(step.state, ('in_progress', 'ready'))
|
||||
|
||||
def test_start_skips_contract_review(self):
|
||||
# CR step exempt regardless of receiving status.
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', is_cr=True,
|
||||
)
|
||||
# button_start may return an action (CR auto-open) — must not raise.
|
||||
try:
|
||||
step.button_start()
|
||||
except Exception as e:
|
||||
from odoo.exceptions import UserError
|
||||
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
|
||||
self.fail('CR step should be exempt from receiving gate')
|
||||
# Other failures (e.g. CR auto-open quirks in test env) are
|
||||
# not the gate — accept them.
|
||||
|
||||
def test_start_bypass_via_context(self):
|
||||
job, step = self._make_job_with_step(recv_status='not_received')
|
||||
step.with_context(fp_skip_receiving_gate=True).button_start()
|
||||
self.assertIn(step.state, ('in_progress', 'ready'))
|
||||
|
||||
# ---- button_finish gate -----------------------------------------------
|
||||
|
||||
def test_finish_blocks_when_not_received(self):
|
||||
from odoo.exceptions import UserError
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', step_state='in_progress',
|
||||
)
|
||||
with self.assertRaises(UserError) as exc:
|
||||
step.button_finish()
|
||||
self.assertIn('parts not received', str(exc.exception).lower())
|
||||
|
||||
def test_finish_allows_when_received(self):
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='received', step_state='in_progress',
|
||||
)
|
||||
step.button_finish()
|
||||
self.assertIn(step.state, ('done', 'in_progress'))
|
||||
|
||||
def test_finish_skips_contract_review(self):
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', step_state='in_progress',
|
||||
is_cr=True,
|
||||
)
|
||||
try:
|
||||
step.button_finish()
|
||||
except Exception as e:
|
||||
from odoo.exceptions import UserError
|
||||
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
|
||||
self.fail('CR step should be exempt from receiving gate')
|
||||
|
||||
def test_finish_bypass_via_context(self):
|
||||
job, step = self._make_job_with_step(
|
||||
recv_status='not_received', step_state='in_progress',
|
||||
)
|
||||
step.with_context(fp_skip_receiving_gate=True).button_finish()
|
||||
self.assertIn(step.state, ('done', 'in_progress'))
|
||||
|
||||
|
||||
class TestCreateDeliveryShippingMirror(TransactionCase):
|
||||
"""Phase A — _fp_create_delivery mirrors shipping fields from the
|
||||
linked receiving onto the auto-created fp.delivery."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'MirrorCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'Widget'})
|
||||
cls.carrier_ups = cls.env.ref(
|
||||
'fusion_plating_receiving.delivery_carrier_ups',
|
||||
)
|
||||
|
||||
def _make_so_with_receiving(self, carrier=None, shipment=None):
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 1,
|
||||
})],
|
||||
})
|
||||
recv = self.env['fp.receiving'].create({
|
||||
'sale_order_id': so.id,
|
||||
'x_fc_carrier_id': carrier.id if carrier else False,
|
||||
'x_fc_outbound_shipment_id': shipment.id if shipment else False,
|
||||
})
|
||||
return so, recv
|
||||
|
||||
def _make_job(self, so):
|
||||
return self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'qty': 1.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
|
||||
def test_create_delivery_mirrors_carrier_from_receiving(self):
|
||||
so, recv = self._make_so_with_receiving(carrier=self.carrier_ups)
|
||||
job = self._make_job(so)
|
||||
job._fp_create_delivery()
|
||||
self.assertTrue(job.delivery_id)
|
||||
self.assertEqual(job.delivery_id.x_fc_carrier_id, self.carrier_ups)
|
||||
|
||||
def test_create_delivery_mirrors_outbound_shipment(self):
|
||||
shipment = self.env['fusion.shipment'].create({
|
||||
'sale_order_id': False,
|
||||
'carrier_id': self.carrier_ups.id,
|
||||
'status': 'draft',
|
||||
})
|
||||
so, recv = self._make_so_with_receiving(
|
||||
carrier=self.carrier_ups, shipment=shipment,
|
||||
)
|
||||
job = self._make_job(so)
|
||||
job._fp_create_delivery()
|
||||
self.assertEqual(
|
||||
job.delivery_id.x_fc_outbound_shipment_id, shipment,
|
||||
)
|
||||
|
||||
def test_create_delivery_no_receiving_no_mirror(self):
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
job = self._make_job(so)
|
||||
job._fp_create_delivery()
|
||||
self.assertTrue(job.delivery_id)
|
||||
self.assertFalse(job.delivery_id.x_fc_carrier_id)
|
||||
self.assertFalse(job.delivery_id.x_fc_outbound_shipment_id)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
#
|
||||
# Closes the bug surfaced by WO-30043 on 2026-05-20: closing a receiving
|
||||
# did not propagate received_qty to fp.job.qty_received, so the
|
||||
# button_mark_done gate stayed red after the operator had completed
|
||||
# every step of the workflow.
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestQtyReceivedPropagation(TransactionCase):
|
||||
"""fp.receiving close → fp.job.qty_received mirrored per part."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'QtyCust'})
|
||||
cls.product = cls.env['product.product'].create({'name': 'TestPart'})
|
||||
cls.part_catalog = cls.env['fp.part.catalog'].create({
|
||||
'name': 'Test Part Catalog',
|
||||
'part_number': 'TPC-001',
|
||||
'partner_id': cls.partner.id,
|
||||
})
|
||||
|
||||
def _make_so_with_job(self):
|
||||
so = self.env['sale.order'].create({'partner_id': self.partner.id})
|
||||
job = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.product.id,
|
||||
'part_catalog_id': self.part_catalog.id,
|
||||
'qty': 5.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
return so, job
|
||||
|
||||
def _make_receiving(self, so, received_qty=5):
|
||||
recv = self.env['fp.receiving'].create({
|
||||
'sale_order_id': so.id,
|
||||
'partner_id': self.partner.id,
|
||||
'expected_qty': received_qty,
|
||||
'received_qty': received_qty,
|
||||
# box_count_in is required by action_mark_counted's gate.
|
||||
'box_count_in': 1,
|
||||
})
|
||||
self.env['fp.receiving.line'].create({
|
||||
'receiving_id': recv.id,
|
||||
'part_catalog_id': self.part_catalog.id,
|
||||
'expected_qty': received_qty,
|
||||
'received_qty': received_qty,
|
||||
})
|
||||
return recv
|
||||
|
||||
# ---- propagation on state transitions -----------------------------
|
||||
def test_close_propagates_received_qty_to_job(self):
|
||||
"""The bug: WO-30043 had qty_received=0 after receiving closed."""
|
||||
so, job = self._make_so_with_job()
|
||||
recv = self._make_receiving(so, received_qty=5)
|
||||
# Walk the state machine to closed (draft → counted → closed
|
||||
# after the 2026-05-20 `staged` retirement).
|
||||
recv.action_mark_counted()
|
||||
recv.action_close()
|
||||
# Reload — the hook fires inside _update_so_receiving_status.
|
||||
job.invalidate_recordset(['qty_received'])
|
||||
self.assertEqual(job.qty_received, 5)
|
||||
|
||||
def test_counted_propagates_partial_qty(self):
|
||||
"""Even a not-yet-closed receiving should mirror what's counted."""
|
||||
so, job = self._make_so_with_job()
|
||||
recv = self._make_receiving(so, received_qty=3)
|
||||
recv.action_mark_counted()
|
||||
job.invalidate_recordset(['qty_received'])
|
||||
self.assertEqual(job.qty_received, 3)
|
||||
|
||||
def test_no_job_match_is_silent(self):
|
||||
"""If the receiving line's part doesn't match any job, skip
|
||||
without raising — common for receivings without spawned jobs."""
|
||||
# Build a receiving with a part that no job uses.
|
||||
other_part = self.env['fp.part.catalog'].create({
|
||||
'name': 'Orphan',
|
||||
'part_number': 'ORP-001',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
so = self.env['sale.order'].create({'partner_id': self.partner.id})
|
||||
recv = self.env['fp.receiving'].create({
|
||||
'sale_order_id': so.id,
|
||||
'partner_id': self.partner.id,
|
||||
'expected_qty': 1,
|
||||
'received_qty': 1,
|
||||
'box_count_in': 1,
|
||||
})
|
||||
self.env['fp.receiving.line'].create({
|
||||
'receiving_id': recv.id,
|
||||
'part_catalog_id': other_part.id,
|
||||
'expected_qty': 1,
|
||||
'received_qty': 1,
|
||||
})
|
||||
# Should NOT raise.
|
||||
recv.action_mark_counted()
|
||||
recv.action_close()
|
||||
|
||||
def test_multi_part_so_matches_per_part(self):
|
||||
"""Two jobs on the same SO, each for a different part. Closing
|
||||
a receiving with two lines must mirror to BOTH jobs by part."""
|
||||
so = self.env['sale.order'].create({'partner_id': self.partner.id})
|
||||
part_a = self.env['fp.part.catalog'].create({
|
||||
'name': 'A', 'part_number': 'A-1', 'partner_id': self.partner.id,
|
||||
})
|
||||
part_b = self.env['fp.part.catalog'].create({
|
||||
'name': 'B', 'part_number': 'B-1', 'partner_id': self.partner.id,
|
||||
})
|
||||
job_a = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id, 'product_id': self.product.id,
|
||||
'part_catalog_id': part_a.id, 'qty': 3.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
job_b = self.env['fp.job'].create({
|
||||
'partner_id': self.partner.id, 'product_id': self.product.id,
|
||||
'part_catalog_id': part_b.id, 'qty': 7.0,
|
||||
'sale_order_id': so.id,
|
||||
})
|
||||
recv = self.env['fp.receiving'].create({
|
||||
'sale_order_id': so.id,
|
||||
'partner_id': self.partner.id,
|
||||
'expected_qty': 10,
|
||||
'received_qty': 10,
|
||||
'box_count_in': 2,
|
||||
})
|
||||
self.env['fp.receiving.line'].create({
|
||||
'receiving_id': recv.id, 'part_catalog_id': part_a.id,
|
||||
'expected_qty': 3, 'received_qty': 3,
|
||||
})
|
||||
self.env['fp.receiving.line'].create({
|
||||
'receiving_id': recv.id, 'part_catalog_id': part_b.id,
|
||||
'expected_qty': 7, 'received_qty': 7,
|
||||
})
|
||||
recv.action_mark_counted()
|
||||
recv.action_close()
|
||||
job_a.invalidate_recordset(['qty_received'])
|
||||
job_b.invalidate_recordset(['qty_received'])
|
||||
self.assertEqual(job_a.qty_received, 3)
|
||||
self.assertEqual(job_b.qty_received, 7)
|
||||
|
||||
def test_idempotent_under_repeated_writes(self):
|
||||
"""Hook is safe to call multiple times — value just settles."""
|
||||
so, job = self._make_so_with_job()
|
||||
recv = self._make_receiving(so, received_qty=5)
|
||||
recv.action_mark_counted()
|
||||
# Manually nudge the same state transition again (legitimate
|
||||
# in real life: manager re-opens then re-closes).
|
||||
recv._update_so_receiving_status()
|
||||
recv._update_so_receiving_status()
|
||||
job.invalidate_recordset(['qty_received'])
|
||||
self.assertEqual(job.qty_received, 5)
|
||||
@@ -64,24 +64,33 @@
|
||||
as page 2 — open the Certificate PDF tab to verify.
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert"
|
||||
invisible="not x_fc_job_id or state != 'draft' or x_fc_thickness_status != 'none' or not partner_id"
|
||||
invisible="state != 'draft' or x_fc_thickness_status != 'none' or not partner_id"
|
||||
style="margin-top:0;">
|
||||
<i class="fa fa-exclamation-triangle" title="Warning"
|
||||
aria-label="Warning"/>
|
||||
<strong> No Fischerscope PDF on the linked QC.</strong>
|
||||
If this customer expects an XRF report with the CoC,
|
||||
have the operator upload the Fischerscope PDF on the
|
||||
QC check before issuing.
|
||||
<strong> No Fischerscope PDF available.</strong>
|
||||
Drop the PDF into the <em>Thickness Report
|
||||
(Fischerscope)</em> tab below, or upload it on the
|
||||
linked QC check, before issuing. Thickness Report
|
||||
certs cannot issue without thickness data.
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
<!-- 3. Add a Thickness Report tab right next to the -->
|
||||
<!-- Certificate PDF tab so operator can preview the -->
|
||||
<!-- Fischerscope file before merging into the cert. -->
|
||||
<!-- 3. Thickness Report tab — single place to see/edit
|
||||
every Fischerscope-related field on the cert.
|
||||
Reorganized 2026-05-21:
|
||||
* Status + linked QC at the top (read-only context)
|
||||
* XDAL 600 metadata (operator/product/etc.) editable
|
||||
so manager can correct OCR mistakes
|
||||
* Microscope image preview (auto-extracted from RTF
|
||||
or manually uploaded — either way editable here)
|
||||
* Source files (PDF / non-PDF evidence / source name)
|
||||
* Upload wizard button + help text -->
|
||||
<xpath expr="//notebook/page[@name='pdf']" position="after">
|
||||
<page string="Thickness Report (Fischerscope)"
|
||||
name="thickness_pdf"
|
||||
invisible="not x_fc_job_id">
|
||||
name="thickness_pdf">
|
||||
|
||||
<!-- Status + QC link (read-only context) -->
|
||||
<group>
|
||||
<field name="x_fc_thickness_status" widget="badge"
|
||||
readonly="1"
|
||||
@@ -90,40 +99,99 @@
|
||||
decoration-success="x_fc_thickness_status == 'merged'"/>
|
||||
<field name="x_fc_thickness_qc_id" readonly="1"
|
||||
invisible="not x_fc_thickness_qc_id"/>
|
||||
<field name="x_fc_thickness_pdf_id" readonly="1"
|
||||
widget="many2one_binary"
|
||||
invisible="not x_fc_thickness_pdf_id"/>
|
||||
</group>
|
||||
|
||||
<!-- Hints rotate by state -->
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'none'">
|
||||
<p>
|
||||
No Fischerscope thickness PDF has been
|
||||
uploaded on the linked QC yet. The CoC will
|
||||
be issued without an appended thickness
|
||||
report. To attach one:
|
||||
No Fischerscope thickness data has been
|
||||
uploaded yet. Click <strong>Upload Thickness
|
||||
Report</strong> below to drop a `.doc` / `.docx`
|
||||
/ `.rtf` / `.pdf` file straight from the
|
||||
XDAL 600. The wizard parses readings +
|
||||
metadata and fills out the fields on this tab.
|
||||
</p>
|
||||
<ol>
|
||||
<li>Open the linked Plating Job (smart
|
||||
button above)</li>
|
||||
<li>Click into the auto-spawned Quality
|
||||
Check</li>
|
||||
<li>Go to the <em>Thickness Report</em> tab
|
||||
and upload the PDF from the Fischerscope
|
||||
/ XDAL 600 export</li>
|
||||
<li>Pass the QC, then come back here and
|
||||
click Issue</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="text-muted"
|
||||
invisible="x_fc_thickness_status != 'pending'">
|
||||
<p>
|
||||
<i class="fa fa-arrow-up" title="Action"
|
||||
aria-label="Action"/>
|
||||
Click <strong>Issue</strong> in the header
|
||||
and the Fischerscope PDF above will be
|
||||
merged into page 2 of the CoC.
|
||||
<i class="fa fa-arrow-up"/>
|
||||
Click <strong>Issue</strong> in the header to
|
||||
merge the Fischerscope PDF as page 2 of
|
||||
the CoC. Readings will render inline in the
|
||||
body of the cert either way.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload wizard CTA -->
|
||||
<div style="margin: 8px 0;">
|
||||
<button name="%(fusion_plating_certificates.action_fp_thickness_upload_wizard)d"
|
||||
type="action"
|
||||
class="btn-primary"
|
||||
string="Upload Thickness Report"
|
||||
context="{'default_certificate_id': id}"
|
||||
invisible="state != 'draft'"/>
|
||||
</div>
|
||||
|
||||
<separator string="XDAL 600 Measurement Context"/>
|
||||
<p class="text-muted small">
|
||||
These values are pulled from the uploaded file
|
||||
and printed on the CoC's thickness section. Edit
|
||||
any field here to override what the parser saw.
|
||||
</p>
|
||||
<group>
|
||||
<group>
|
||||
<field name="x_fc_thickness_equipment"
|
||||
placeholder="Fischerscope XDAL 600"/>
|
||||
<field name="x_fc_thickness_operator"
|
||||
placeholder="Operator initials / name"/>
|
||||
<field name="x_fc_thickness_datetime"/>
|
||||
<field name="x_fc_thickness_measuring_time_sec"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_thickness_product"
|
||||
placeholder="e.g. 2805031 / NiP/Al-alloys 2805030"/>
|
||||
<field name="x_fc_thickness_application"
|
||||
placeholder="e.g. 16 / NiP/Al-alloys"/>
|
||||
<field name="x_fc_thickness_directory"
|
||||
placeholder="XDAL save directory"/>
|
||||
<field name="x_fc_thickness_source_filename"
|
||||
readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<separator string="Microscope Image"/>
|
||||
<p class="text-muted small">
|
||||
Auto-extracted from RTF uploads (via libwmf) or
|
||||
manually uploaded via the wizard. Drop a new
|
||||
PNG/JPEG here to override.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_thickness_image_id"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
|
||||
<separator string="Source Files"/>
|
||||
<group>
|
||||
<group string="Fischerscope PDF"
|
||||
invisible="not x_fc_local_thickness_pdf">
|
||||
<field name="x_fc_local_thickness_pdf"
|
||||
filename="x_fc_local_thickness_pdf_filename"/>
|
||||
<field name="x_fc_local_thickness_pdf_filename"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
<group string="Non-PDF Evidence (RTF/DOCX)"
|
||||
invisible="not x_fc_local_thickness_evidence_id">
|
||||
<field name="x_fc_local_thickness_evidence_id"
|
||||
options="{'no_create': True}"/>
|
||||
</group>
|
||||
<group string="QC-side Fischerscope PDF"
|
||||
invisible="not x_fc_thickness_pdf_id">
|
||||
<field name="x_fc_thickness_pdf_id" readonly="1"
|
||||
widget="many2one_binary"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
One-shot backfill for closed jobs that never produced a CoC because
|
||||
of the `coating` NameError regression (fixed 2026-05-18). Surfaced
|
||||
as a Settings > Technical menu item so the user can click once after
|
||||
deploying the fix.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="action_fp_job_backfill_missing_certs" model="ir.actions.server">
|
||||
<field name="name">Generate Missing Certs for Closed Jobs</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="group_ids" eval="[(4, ref('base.group_system'))]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">action = env['fp.job'].action_backfill_missing_certs()</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -4,3 +4,4 @@
|
||||
|
||||
from . import fp_job_step_move_wizard
|
||||
from . import fp_job_step_input_wizard
|
||||
from . import fp_cert_issue_wizard
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Issue Certs Wizard.
|
||||
|
||||
Opened from a job's "Issue Certs" milestone button. Walks each draft
|
||||
cert on the job, lets the manager upload the Fischerscope/XDAL output
|
||||
(PDF or .docx) per cert that needs thickness data, and tries to parse
|
||||
the .docx to pre-populate the readings table. Manager can edit/add
|
||||
readings before confirming. On confirm:
|
||||
|
||||
- PDF uploads land on cert.x_fc_local_thickness_pdf (merged as page 2
|
||||
of the issued CoC).
|
||||
- .docx uploads are attached as ir.attachment on the cert (evidence)
|
||||
and the parsed readings are written as fp.thickness.reading rows.
|
||||
- cert.action_issue() is called for each cert.
|
||||
|
||||
The wizard is a convenience layer — it does NOT replace the per-cert
|
||||
Issue button on the cert form, which stays as the fallback path.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Minimum pixel-area for an extracted RTF image to be treated as the
|
||||
# "microscope photo" candidate. Filters out narrow header banners
|
||||
# (~790x203 = 160k pixels) while keeping standard XDAL exports
|
||||
# (~1024x768 = 786k). See CLAUDE.md "entech apt is broken" for the
|
||||
# libwmf install path that makes this possible.
|
||||
_FP_RTF_IMAGE_MIN_AREA = 200_000
|
||||
|
||||
|
||||
# Fischerscope XDAL 600 reading line, e.g.
|
||||
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
|
||||
_FISCHER_READING_RE = re.compile(
|
||||
r'n\s*=\s*(\d+)'
|
||||
r'\s+NiP\s+\d+\s*=\s*([\d.]+)\s*mils'
|
||||
r'\s+Ni\s+\d+\s*=\s*([\d.]+)\s*%'
|
||||
r'\s+P\s+\d+\s*=\s*([\d.]+)\s*%',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Capture every {\pict ... \wmetafile8 ...hex...} group in an RTF, in
|
||||
# document order. The hex blob can be interspersed with whitespace
|
||||
# (RTF wraps to 80 cols) — the consumer strips it.
|
||||
_RTF_PICT_WMF_RE = re.compile(
|
||||
r'\{\\pict'
|
||||
r'(?:\\[a-zA-Z]+-?\d*\s?)*?'
|
||||
r'\\wmetafile8'
|
||||
r'(?:\\[a-zA-Z]+-?\d*\s?)*'
|
||||
r'\s*([0-9a-fA-F\s]+?)'
|
||||
r'\}',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _fp_extract_rtf_images(raw_bytes):
|
||||
"""Pull all WMF picture blocks out of an RTF, unpack to PNG via
|
||||
libwmf, and return the list of PNG bytes in document order.
|
||||
|
||||
XDAL 600 RTF exports embed each picture as a WMF metafile wrapping
|
||||
the actual raster. ImageMagick on Debian Bookworm doesn't carry a
|
||||
WMF delegate, so we shell out to `wmf2svg` (from libwmf-bin) — it
|
||||
writes a thin SVG and a side-file `*-N.png` per raster block. We
|
||||
keep the PNGs, drop the SVG/WMF temp files.
|
||||
|
||||
Returns [] (not raise) on any tooling/parse failure; the cert
|
||||
issue keeps working even when image extraction can't run.
|
||||
"""
|
||||
if not raw_bytes:
|
||||
return []
|
||||
try:
|
||||
text = raw_bytes.decode('latin-1', errors='replace')
|
||||
except Exception:
|
||||
return []
|
||||
blobs = []
|
||||
for m in _RTF_PICT_WMF_RE.finditer(text):
|
||||
hex_blob = re.sub(r'\s+', '', m.group(1))
|
||||
try:
|
||||
blobs.append(bytes.fromhex(hex_blob))
|
||||
except ValueError:
|
||||
continue
|
||||
if not blobs:
|
||||
return []
|
||||
tmpdir = tempfile.mkdtemp(prefix='fp_rtf_wmf_')
|
||||
pngs = []
|
||||
try:
|
||||
for i, wmf in enumerate(blobs):
|
||||
wmf_path = os.path.join(tmpdir, 'pict%d.wmf' % i)
|
||||
svg_path = os.path.join(tmpdir, 'pict%d.svg' % i)
|
||||
with open(wmf_path, 'wb') as fh:
|
||||
fh.write(wmf)
|
||||
try:
|
||||
subprocess.run(
|
||||
['wmf2svg', '-o', svg_path, wmf_path],
|
||||
capture_output=True, timeout=20, check=False,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
|
||||
_logger.warning(
|
||||
'wmf2svg unavailable or timed out (%s) — skipping '
|
||||
'RTF image extraction.', e,
|
||||
)
|
||||
return []
|
||||
# wmf2svg writes <basename>-N.png next to the SVG.
|
||||
for fn in sorted(os.listdir(tmpdir)):
|
||||
if fn.startswith('pict%d-' % i) and fn.endswith('.png'):
|
||||
full = os.path.join(tmpdir, fn)
|
||||
with open(full, 'rb') as fh:
|
||||
pngs.append(fh.read())
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
return pngs
|
||||
|
||||
|
||||
def _fp_pick_microscope_image(png_bytes_list):
|
||||
"""Pick the largest-area PNG (by pixel count, not file size) from
|
||||
the list — that's almost always the microscope photo. Header
|
||||
banners are wide-but-thin so their pixel area falls below the
|
||||
threshold. Returns (png_bytes, width, height) or (None, 0, 0)
|
||||
when no PNG meets the threshold.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
# Pillow ships with Odoo; this is defensive.
|
||||
return (png_bytes_list[0] if png_bytes_list else None, 0, 0)
|
||||
best = None
|
||||
best_area = 0
|
||||
for png in png_bytes_list:
|
||||
try:
|
||||
with Image.open(io.BytesIO(png)) as im:
|
||||
area = im.width * im.height
|
||||
if area > best_area and area >= _FP_RTF_IMAGE_MIN_AREA:
|
||||
best = (png, im.width, im.height)
|
||||
best_area = area
|
||||
except Exception:
|
||||
continue
|
||||
return best or (None, 0, 0)
|
||||
|
||||
|
||||
_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+?)(?:\s{2,}|$)', re.IGNORECASE)
|
||||
_FISCHER_OPERATOR_RE = re.compile(r'Operator:\s*(\S+)', re.IGNORECASE)
|
||||
_FISCHER_DATE_RE = re.compile(r'Date:\s*([\d/]+)', re.IGNORECASE)
|
||||
_FISCHER_TIME_RE = re.compile(r'Time:\s*([\d:]+\s*[APMapm]*)')
|
||||
# XDAL 600 header lines — only present on full RTF reports (not on
|
||||
# the .docx body the upstream parser already handled).
|
||||
_FISCHER_PRODUCT_RE = re.compile(r'Product:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
|
||||
_FISCHER_DIRECTORY_RE = re.compile(r'Directory:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
|
||||
_FISCHER_APPLICATION_RE = re.compile(r'Application:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
|
||||
_FISCHER_MTIME_RE = re.compile(r'Measuring\s+time\s+(\d+)\s*sec', re.IGNORECASE)
|
||||
_FISCHER_EQUIPMENT_RE = re.compile(r'(Fischerscope[^\r\n]*XDAL\s*\d+)', re.IGNORECASE)
|
||||
|
||||
|
||||
def _fp_strip_rtf(raw_bytes):
|
||||
"""Best-effort RTF → plain text. RTF is text-based with control
|
||||
words prefixed by `\\` and groups wrapped in `{}`. We need to strip
|
||||
all of those plus the hex-encoded image data so the Fischerscope
|
||||
reading regex hits clean text.
|
||||
|
||||
Not a full parser — meant for the narrow case of XRF/XDAL reports
|
||||
that have a simple body wrapped around an embedded WMF image.
|
||||
"""
|
||||
if not raw_bytes:
|
||||
return ''
|
||||
# RTF is ASCII-safe; latin-1 round-trips every byte.
|
||||
text = raw_bytes.decode('latin-1', errors='replace')
|
||||
# Drop destination groups entirely — these are the image data,
|
||||
# font tables, color tables, etc. The pattern `{\* ...}` and other
|
||||
# nested destinations carry binary-ish hex strings we never want.
|
||||
text = re.sub(r'\{\\\*[^{}]*\}', ' ', text)
|
||||
text = re.sub(r'\{\\fonttbl[^{}]*\}', ' ', text)
|
||||
text = re.sub(r'\{\\colortbl[^{}]*\}', ' ', text)
|
||||
# Pictures: {\pict ...} contains hex image data. The body is the
|
||||
# part between `\pict...goal\d+` and the closing brace of the group.
|
||||
# Easier: nuke anything matching the picture marker through the
|
||||
# next closing brace at the same depth (single-level approximation
|
||||
# — works for FedEx/XRF docs that have one image per pict block).
|
||||
text = re.sub(r'\{\\pict[^{}]*\}', ' ', text)
|
||||
# Remove control words like \rtf1, \ansicpg1252, \par, \tab,
|
||||
# \tx2840, etc. (`\` + letters + optional digits + optional space)
|
||||
text = re.sub(r'\\[A-Za-z]+-?\d*\s?', ' ', text)
|
||||
# Hex escapes (e.g. \'ae for special chars)
|
||||
text = re.sub(r"\\'[0-9a-fA-F]{2}", ' ', text)
|
||||
# Other backslash escapes (`\\`, `\{`, `\}`)
|
||||
text = re.sub(r'\\[^A-Za-z\s]', ' ', text)
|
||||
# Strip remaining braces
|
||||
text = text.replace('{', ' ').replace('}', ' ')
|
||||
# Collapse runs of whitespace so the Fischerscope regex doesn't
|
||||
# have to deal with weird spacing artefacts from the strip pass.
|
||||
text = re.sub(r'[ \t]+', ' ', text)
|
||||
return text
|
||||
|
||||
|
||||
def _fp_parse_fischerscope_rtf(raw_bytes):
|
||||
"""Fischerscope XDAL 600 RTF export → same dict shape as the
|
||||
.docx parser. RTF detection is by magic bytes (`{\\rtf`) — the
|
||||
XRF software names the file `.doc` for legacy reasons, but the
|
||||
contents are RTF.
|
||||
"""
|
||||
empty = {
|
||||
'readings': [], 'calibration': '', 'operator': '',
|
||||
'date_str': '', 'time_str': '',
|
||||
'product': '', 'directory': '', 'application': '',
|
||||
'measuring_time_sec': 0, 'equipment': '',
|
||||
'raw_text': '',
|
||||
}
|
||||
if not raw_bytes:
|
||||
return empty
|
||||
text = _fp_strip_rtf(raw_bytes)
|
||||
readings = []
|
||||
for m in _FISCHER_READING_RE.finditer(text):
|
||||
try:
|
||||
readings.append((
|
||||
float(m.group(2)),
|
||||
float(m.group(3)),
|
||||
float(m.group(4)),
|
||||
))
|
||||
except ValueError:
|
||||
continue
|
||||
def _grab(rx):
|
||||
m = rx.search(text)
|
||||
return m.group(1).strip() if m else ''
|
||||
mtime = 0
|
||||
m = _FISCHER_MTIME_RE.search(text)
|
||||
if m:
|
||||
try:
|
||||
mtime = int(m.group(1))
|
||||
except ValueError:
|
||||
mtime = 0
|
||||
return {
|
||||
'readings': readings,
|
||||
'calibration': _grab(_FISCHER_CALIB_RE),
|
||||
'operator': _grab(_FISCHER_OPERATOR_RE),
|
||||
'date_str': _grab(_FISCHER_DATE_RE),
|
||||
'time_str': _grab(_FISCHER_TIME_RE),
|
||||
'product': _grab(_FISCHER_PRODUCT_RE),
|
||||
'directory': _grab(_FISCHER_DIRECTORY_RE),
|
||||
'application': _grab(_FISCHER_APPLICATION_RE),
|
||||
'measuring_time_sec': mtime,
|
||||
'equipment': _grab(_FISCHER_EQUIPMENT_RE),
|
||||
'raw_text': text,
|
||||
}
|
||||
|
||||
|
||||
def _fp_parse_fischerscope_docx(raw_bytes):
|
||||
"""Best-effort parse of a Fischerscope XDAL 600 .docx report.
|
||||
|
||||
Returns dict:
|
||||
{
|
||||
'readings': [(nip_mils, ni_pct, p_pct), ...],
|
||||
'calibration': str or '',
|
||||
'operator': str or '',
|
||||
'date_str': str or '',
|
||||
'time_str': str or '',
|
||||
'raw_text': str (the extracted document body, for chatter),
|
||||
}
|
||||
|
||||
Soft-fails to an empty dict-like result when python-docx isn't
|
||||
installed or the bytes don't parse — the wizard still works, the
|
||||
operator just has to type readings manually.
|
||||
"""
|
||||
empty = {
|
||||
'readings': [], 'calibration': '', 'operator': '',
|
||||
'date_str': '', 'time_str': '', 'raw_text': '',
|
||||
}
|
||||
if not raw_bytes:
|
||||
return empty
|
||||
try:
|
||||
import docx # python-docx
|
||||
except ImportError:
|
||||
_logger.info(
|
||||
'python-docx not installed — Fischerscope auto-parse '
|
||||
'skipped. Operator will enter readings manually.'
|
||||
)
|
||||
return empty
|
||||
try:
|
||||
doc = docx.Document(io.BytesIO(raw_bytes))
|
||||
except Exception as e:
|
||||
_logger.warning('Fischerscope .docx parse failed: %s', e)
|
||||
return empty
|
||||
# Pull text from paragraphs AND tables (Fischerscope reports
|
||||
# sometimes lay the readings inside a table cell).
|
||||
parts = [p.text for p in doc.paragraphs]
|
||||
for tbl in doc.tables:
|
||||
for row in tbl.rows:
|
||||
for cell in row.cells:
|
||||
parts.append(cell.text)
|
||||
text = '\n'.join(parts)
|
||||
readings = []
|
||||
for m in _FISCHER_READING_RE.finditer(text):
|
||||
try:
|
||||
readings.append((
|
||||
float(m.group(2)), # nip mils
|
||||
float(m.group(3)), # Ni %
|
||||
float(m.group(4)), # P %
|
||||
))
|
||||
except ValueError:
|
||||
continue
|
||||
calib = ''
|
||||
m = _FISCHER_CALIB_RE.search(text)
|
||||
if m:
|
||||
calib = m.group(1).strip()
|
||||
operator = ''
|
||||
m = _FISCHER_OPERATOR_RE.search(text)
|
||||
if m:
|
||||
operator = m.group(1).strip()
|
||||
date_str = ''
|
||||
m = _FISCHER_DATE_RE.search(text)
|
||||
if m:
|
||||
date_str = m.group(1).strip()
|
||||
time_str = ''
|
||||
m = _FISCHER_TIME_RE.search(text)
|
||||
if m:
|
||||
time_str = m.group(1).strip()
|
||||
return {
|
||||
'readings': readings,
|
||||
'calibration': calib,
|
||||
'operator': operator,
|
||||
'date_str': date_str,
|
||||
'time_str': time_str,
|
||||
'raw_text': text,
|
||||
}
|
||||
|
||||
|
||||
class FpCertIssueWizard(models.TransientModel):
|
||||
_name = 'fp.cert.issue.wizard'
|
||||
_description = 'Fusion Plating — Issue Certs Wizard'
|
||||
|
||||
job_id = fields.Many2one(
|
||||
'fp.job', string='Job', required=True, readonly=True,
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fp.cert.issue.wizard.line', 'wizard_id', string='Certs to Issue',
|
||||
)
|
||||
has_blocking_lines = fields.Boolean(
|
||||
compute='_compute_has_blocking_lines',
|
||||
help='True when at least one line is missing data the gate '
|
||||
'requires (no readings, no file, etc.). Used to disable '
|
||||
'the Confirm button.',
|
||||
)
|
||||
|
||||
@api.depends('line_ids', 'line_ids.is_ready')
|
||||
def _compute_has_blocking_lines(self):
|
||||
for w in self:
|
||||
w.has_blocking_lines = any(not ln.is_ready for ln in w.line_ids)
|
||||
|
||||
@api.model
|
||||
def open_for_job(self, job):
|
||||
"""Factory — create a wizard pre-populated with one line per
|
||||
draft cert on the job. Returns an action dict that opens the
|
||||
wizard form."""
|
||||
Cert = self.env['fp.certificate'].sudo()
|
||||
certs = Cert.search([
|
||||
('x_fc_job_id', '=', job.id),
|
||||
('state', '=', 'draft'),
|
||||
])
|
||||
if not certs:
|
||||
raise UserError(_(
|
||||
'No draft certificates on %s to issue.'
|
||||
) % job.name)
|
||||
wiz = self.create({
|
||||
'job_id': job.id,
|
||||
'line_ids': [(0, 0, {'cert_id': c.id}) for c in certs],
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Issue Certs — %s') % job.name,
|
||||
'res_model': self._name,
|
||||
'res_id': wiz.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_confirm(self):
|
||||
"""Apply every line's file + readings, then issue each cert.
|
||||
|
||||
Order matters: write the file/readings BEFORE calling action_issue
|
||||
so the gate sees the populated data. If a single cert raises on
|
||||
issue, the whole wizard rolls back (transactional).
|
||||
"""
|
||||
self.ensure_one()
|
||||
issued = []
|
||||
for ln in self.line_ids:
|
||||
ln._apply_to_cert()
|
||||
cert = ln.cert_id
|
||||
if cert.state == 'draft':
|
||||
cert.action_issue()
|
||||
issued.append(cert.name)
|
||||
if not issued:
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Certs Issued'),
|
||||
'message': _('%d cert(s) issued: %s') % (
|
||||
len(issued), ', '.join(issued),
|
||||
),
|
||||
'sticky': False,
|
||||
'type': 'success',
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FpCertIssueWizardLine(models.TransientModel):
|
||||
_name = 'fp.cert.issue.wizard.line'
|
||||
_description = 'Fusion Plating — Issue Certs Wizard Line'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'fp.cert.issue.wizard', required=True, ondelete='cascade',
|
||||
)
|
||||
cert_id = fields.Many2one(
|
||||
'fp.certificate', string='Certificate', required=True, readonly=True,
|
||||
)
|
||||
cert_name = fields.Char(related='cert_id.name', readonly=True)
|
||||
cert_type = fields.Selection(
|
||||
related='cert_id.certificate_type', readonly=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='cert_id.partner_id', readonly=True,
|
||||
)
|
||||
needs_thickness = fields.Boolean(
|
||||
compute='_compute_needs_thickness', store=False,
|
||||
)
|
||||
fischer_file = fields.Binary(string='Fischerscope File (PDF or .docx)')
|
||||
fischer_filename = fields.Char(string='Filename')
|
||||
# Optional: microscope/coupon image exported separately from the
|
||||
# XDAL 600. The RTF carries an embedded WMF that the entech host
|
||||
# can't rasterize (no imagemagick/libwmf — see CLAUDE.md "entech
|
||||
# apt is in a broken-deps state"), so the operator exports a PNG
|
||||
# from the XDAL software and uploads it here. Rendered inline on
|
||||
# the CoC's thickness section when present.
|
||||
fischer_image_file = fields.Binary(string='Measurement Image (PNG/JPEG)')
|
||||
fischer_image_filename = fields.Char(string='Image Filename')
|
||||
parsed_summary = fields.Text(
|
||||
string='Parsed Summary', readonly=True,
|
||||
help='Output of the .docx parser. Populated when you attach a '
|
||||
'Fischerscope .docx; the readings table below is auto-'
|
||||
'filled from the same parse. Empty for PDF uploads.',
|
||||
)
|
||||
reading_line_ids = fields.One2many(
|
||||
'fp.cert.issue.wizard.reading', 'line_id', string='Readings',
|
||||
)
|
||||
is_ready = fields.Boolean(
|
||||
compute='_compute_is_ready',
|
||||
help='True when this cert has enough data to issue: thickness '
|
||||
'data present if needed.',
|
||||
)
|
||||
|
||||
@api.depends('cert_id.certificate_type',
|
||||
'cert_id.partner_id.x_fc_send_thickness_report',
|
||||
'cert_id.partner_id.x_fc_strict_thickness_required')
|
||||
def _compute_needs_thickness(self):
|
||||
for ln in self:
|
||||
cert = ln.cert_id
|
||||
partner = cert.partner_id
|
||||
ln.needs_thickness = (
|
||||
cert.certificate_type == 'thickness_report'
|
||||
or (cert.certificate_type == 'coc' and partner and (
|
||||
partner.x_fc_strict_thickness_required
|
||||
or partner.x_fc_send_thickness_report
|
||||
))
|
||||
)
|
||||
|
||||
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
|
||||
'cert_id.thickness_reading_ids',
|
||||
'cert_id.x_fc_local_thickness_pdf')
|
||||
def _compute_is_ready(self):
|
||||
for ln in self:
|
||||
if not ln.needs_thickness:
|
||||
ln.is_ready = True
|
||||
continue
|
||||
ln.is_ready = bool(
|
||||
ln.fischer_file
|
||||
or ln.reading_line_ids
|
||||
or ln.cert_id.thickness_reading_ids
|
||||
or ln.cert_id.x_fc_local_thickness_pdf
|
||||
)
|
||||
|
||||
@api.onchange('fischer_file', 'fischer_filename')
|
||||
def _onchange_fischer_file(self):
|
||||
"""Parse .docx OR RTF on upload (XDAL 600 names RTF files
|
||||
`.doc` — detected by magic bytes; see CLAUDE.md "Fischerscope
|
||||
XDAL 600 `.doc` files are actually RTF"). Prefill the readings
|
||||
+ summary so the operator can verify before issuing."""
|
||||
if not self.fischer_file:
|
||||
return
|
||||
try:
|
||||
raw = base64.b64decode(self.fischer_file)
|
||||
except Exception:
|
||||
self.parsed_summary = _('Could not decode the uploaded file.')
|
||||
return
|
||||
name = (self.fischer_filename or '').lower()
|
||||
is_rtf = raw[:5] == b'{\\rtf' or name.endswith('.rtf')
|
||||
if is_rtf:
|
||||
parsed = _fp_parse_fischerscope_rtf(raw)
|
||||
elif name.endswith('.docx'):
|
||||
parsed = _fp_parse_fischerscope_docx(raw)
|
||||
else:
|
||||
self.parsed_summary = _(
|
||||
'Non-parseable upload (%s) — file will be attached as '
|
||||
'evidence. Type readings manually below if needed.'
|
||||
) % (self.fischer_filename or 'unnamed')
|
||||
return
|
||||
readings = parsed.get('readings') or []
|
||||
if readings:
|
||||
self.reading_line_ids = [(5, 0, 0)] + [
|
||||
(0, 0, {
|
||||
'sequence': i + 1,
|
||||
'nip_mils': nip,
|
||||
'ni_percent': ni,
|
||||
'p_percent': p,
|
||||
})
|
||||
for i, (nip, ni, p) in enumerate(readings)
|
||||
]
|
||||
self.parsed_summary = _(
|
||||
'Parsed %(n)d reading(s) · Calibration: %(c)s · '
|
||||
'Operator: %(o)s · Date: %(d)s %(t)s'
|
||||
) % {
|
||||
'n': len(readings),
|
||||
'c': parsed.get('calibration') or '—',
|
||||
'o': parsed.get('operator') or '—',
|
||||
'd': parsed.get('date_str') or '—',
|
||||
't': parsed.get('time_str') or '',
|
||||
}
|
||||
|
||||
def _write_thickness_metadata_to_cert(self, cert, parsed):
|
||||
"""Persist the Fischerscope header block (operator, product,
|
||||
application, equipment, measuring time, date/time, source
|
||||
filename) onto the cert so the CoC report can render a full
|
||||
report block instead of a bare readings table.
|
||||
"""
|
||||
vals = {}
|
||||
field_map = (
|
||||
('x_fc_thickness_operator', parsed.get('operator')),
|
||||
('x_fc_thickness_product', parsed.get('product')),
|
||||
('x_fc_thickness_directory', parsed.get('directory')),
|
||||
('x_fc_thickness_application', parsed.get('application')),
|
||||
('x_fc_thickness_measuring_time_sec',
|
||||
parsed.get('measuring_time_sec') or 0),
|
||||
('x_fc_thickness_equipment',
|
||||
parsed.get('equipment') or 'Fischerscope XDAL 600'),
|
||||
('x_fc_thickness_source_filename',
|
||||
self.fischer_filename or ''),
|
||||
)
|
||||
for fname, fval in field_map:
|
||||
if fname in cert._fields and fval:
|
||||
vals[fname] = fval
|
||||
# Combine the gauge's date+time and parse to Datetime — try a
|
||||
# few formats since XDAL exports vary (12h vs 24h, with/without
|
||||
# seconds). Best-effort: leave the field blank if no format
|
||||
# matches rather than crashing the cert issue.
|
||||
date_str = (parsed.get('date_str') or '').strip()
|
||||
time_str = (parsed.get('time_str') or '').strip()
|
||||
if date_str and 'x_fc_thickness_datetime' in cert._fields:
|
||||
from datetime import datetime
|
||||
combined = ('%s %s' % (date_str, time_str)).strip()
|
||||
for fmt in (
|
||||
'%m/%d/%Y %I:%M:%S %p', '%m/%d/%Y %I:%M %p',
|
||||
'%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M',
|
||||
'%m/%d/%Y',
|
||||
):
|
||||
try:
|
||||
vals['x_fc_thickness_datetime'] = datetime.strptime(
|
||||
combined, fmt,
|
||||
)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if vals:
|
||||
cert.write(vals)
|
||||
|
||||
def _apply_to_cert(self):
|
||||
"""Write this line's data into the cert.
|
||||
|
||||
Order matters: operator-uploaded PNG must run LAST so it wins
|
||||
over any image the RTF auto-extraction picked. Reverse order
|
||||
(PNG first, then RTF) lets the WMF blow away the explicit
|
||||
operator choice — exactly the bug we just hit.
|
||||
"""
|
||||
self.ensure_one()
|
||||
cert = self.cert_id.sudo()
|
||||
if not self.fischer_file:
|
||||
# Just push manual readings, if any.
|
||||
self._push_readings_to_cert()
|
||||
# PNG-only path: still attach the operator's image upload.
|
||||
self._apply_image_to_cert(cert)
|
||||
return
|
||||
name = (self.fischer_filename or 'fischerscope').lower()
|
||||
calibration = '' # backfilled below if the parser hits
|
||||
if name.endswith('.pdf'):
|
||||
# Drop the PDF into the cert-local field — merges into page 2.
|
||||
cert.write({
|
||||
'x_fc_local_thickness_pdf': self.fischer_file,
|
||||
'x_fc_local_thickness_pdf_filename': self.fischer_filename,
|
||||
})
|
||||
else:
|
||||
# .doc / .docx / anything else — attach as evidence AND
|
||||
# link the attachment to the cert's evidence slot so the
|
||||
# thickness-required gate recognises it. Without the link,
|
||||
# the gate would still raise (it checks specific fields,
|
||||
# not stray attachments) and rolling back the transaction
|
||||
# would orphan the upload.
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': self.fischer_filename or 'fischerscope-report',
|
||||
'type': 'binary',
|
||||
'datas': self.fischer_file,
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': cert.id,
|
||||
})
|
||||
if 'x_fc_local_thickness_evidence_id' in cert._fields:
|
||||
cert.write({'x_fc_local_thickness_evidence_id': att.id})
|
||||
# Re-parse the file at apply time so the report-header
|
||||
# metadata (operator, product, application, etc.) makes it
|
||||
# onto the cert. Onchange populates reading_line_ids but
|
||||
# not the cert-level fields. Best-effort: any parse hiccup
|
||||
# is logged and we still complete the attachment + readings.
|
||||
try:
|
||||
raw = base64.b64decode(self.fischer_file)
|
||||
is_rtf = raw[:5] == b'{\\rtf'
|
||||
if is_rtf:
|
||||
parsed = _fp_parse_fischerscope_rtf(raw)
|
||||
elif name.endswith('.docx'):
|
||||
parsed = _fp_parse_fischerscope_docx(raw)
|
||||
else:
|
||||
parsed = None
|
||||
if parsed:
|
||||
self._write_thickness_metadata_to_cert(cert, parsed)
|
||||
calibration = parsed.get('calibration') or ''
|
||||
# WMF image extraction is RTF-only (the .docx path
|
||||
# uses python-docx which already gives PIL-readable
|
||||
# bitmaps; that flow can be added later if needed).
|
||||
if is_rtf and 'x_fc_thickness_image_id' in cert._fields:
|
||||
pngs = _fp_extract_rtf_images(raw)
|
||||
img_bytes, img_w, img_h = _fp_pick_microscope_image(pngs)
|
||||
if img_bytes:
|
||||
img_att = self.env['ir.attachment'].sudo().create({
|
||||
'name': '%s-microscope.png' % (
|
||||
(self.fischer_filename or 'fischerscope')
|
||||
.rsplit('.', 1)[0]
|
||||
),
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(img_bytes),
|
||||
'mimetype': 'image/png',
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': cert.id,
|
||||
})
|
||||
cert.write({
|
||||
'x_fc_thickness_image_id': img_att.id,
|
||||
})
|
||||
_logger.info(
|
||||
'Cert %s: attached microscope image '
|
||||
'(%dx%d, %d bytes)',
|
||||
cert.name, img_w, img_h, len(img_bytes),
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.warning(
|
||||
'Cert %s: Fischerscope metadata extraction failed: %s',
|
||||
cert.name, exc,
|
||||
)
|
||||
cert.message_post(body=Markup(_(
|
||||
'Fischerscope file <b>%s</b> attached via Issue wizard.'
|
||||
)) % (self.fischer_filename or 'unnamed'))
|
||||
self._push_readings_to_cert(calibration=calibration)
|
||||
# Operator's PNG upload wins over auto-extracted WMF — runs
|
||||
# last so it overwrites x_fc_thickness_image_id if both paths
|
||||
# supplied an image.
|
||||
self._apply_image_to_cert(cert)
|
||||
|
||||
def _apply_image_to_cert(self, cert):
|
||||
"""Attach the operator-uploaded PNG/JPEG and link it to the
|
||||
cert's image slot so the CoC report can render it inline.
|
||||
No-op when nothing was uploaded. Mirrors the evidence-file
|
||||
pattern: file is attached as a regular ir.attachment AND
|
||||
linked to the dedicated field so the report template can
|
||||
find it predictably.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.fischer_image_file or \
|
||||
'x_fc_thickness_image_id' not in cert._fields:
|
||||
return
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': self.fischer_image_filename or 'thickness-image.png',
|
||||
'type': 'binary',
|
||||
'datas': self.fischer_image_file,
|
||||
'res_model': 'fp.certificate',
|
||||
'res_id': cert.id,
|
||||
})
|
||||
cert.write({'x_fc_thickness_image_id': att.id})
|
||||
|
||||
def _push_readings_to_cert(self, calibration=''):
|
||||
"""Create fp.thickness.reading rows on the cert from wizard rows.
|
||||
Skips when no rows. Does not deduplicate against existing
|
||||
readings — the manager has just told us this is the new data.
|
||||
Per-reading calibration_std_ref is stamped from the optional
|
||||
`calibration` arg so the printed CoC's calibration line stays
|
||||
accurate even when readings are re-pushed from a fresh upload.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Reading = self.env.get('fp.thickness.reading')
|
||||
if Reading is None or not self.reading_line_ids:
|
||||
return
|
||||
for r in self.reading_line_ids:
|
||||
vals = {
|
||||
'certificate_id': self.cert_id.id,
|
||||
'nip_mils': r.nip_mils,
|
||||
'ni_percent': r.ni_percent,
|
||||
'p_percent': r.p_percent,
|
||||
}
|
||||
if 'reading_number' in Reading._fields:
|
||||
vals['reading_number'] = r.sequence
|
||||
if calibration and 'calibration_std_ref' in Reading._fields:
|
||||
vals['calibration_std_ref'] = calibration
|
||||
Reading.sudo().create(vals)
|
||||
|
||||
|
||||
class FpCertIssueWizardReading(models.TransientModel):
|
||||
_name = 'fp.cert.issue.wizard.reading'
|
||||
_description = 'Fusion Plating — Issue Certs Wizard Reading Row'
|
||||
_order = 'sequence, id'
|
||||
|
||||
line_id = fields.Many2one(
|
||||
'fp.cert.issue.wizard.line', required=True, ondelete='cascade',
|
||||
)
|
||||
sequence = fields.Integer(default=1)
|
||||
nip_mils = fields.Float(string='NiP (mils)', digits=(10, 4))
|
||||
ni_percent = fields.Float(string='Ni %', digits=(6, 3))
|
||||
p_percent = fields.Float(string='P %', digits=(6, 3))
|
||||
@@ -0,0 +1,157 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
<record id="view_fp_cert_issue_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fp.cert.issue.wizard.form</field>
|
||||
<field name="model">fp.cert.issue.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Issue Certs"
|
||||
js_class="fp_cert_issue_wizard_form"
|
||||
class="o_fp_cert_issue_wizard_form">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h2>
|
||||
Issue Certs —
|
||||
<field name="job_id" readonly="1" nolabel="1"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="alert alert-info" role="alert"
|
||||
invisible="not has_blocking_lines">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
At least one cert still needs thickness data
|
||||
(Fischerscope file or readings).
|
||||
<strong>Click a row, then click
|
||||
<em>Upload your file</em> in the Fischerscope
|
||||
column.</strong>
|
||||
</div>
|
||||
<!-- 2026-05-20: surface the file upload INLINE in the
|
||||
list instead of behind a row-click into a sub-form.
|
||||
Operators kept missing the upload affordance — the
|
||||
list looked like a status display, not an action
|
||||
surface. Adding the binary field as a column lets
|
||||
them drop the Fischerscope file right where they
|
||||
see "Needs Thickness" turned on. The form behind
|
||||
the row click stays as a "details" expansion for
|
||||
per-reading editing after upload. -->
|
||||
<field name="line_ids" nolabel="1">
|
||||
<list editable="bottom" create="false" delete="false">
|
||||
<field name="cert_name" readonly="1"
|
||||
string="Reference"/>
|
||||
<field name="cert_type" readonly="1"
|
||||
string="Type"/>
|
||||
<field name="partner_id" readonly="1"
|
||||
string="Customer"/>
|
||||
<field name="needs_thickness" readonly="1"
|
||||
widget="boolean_toggle"
|
||||
string="Needs Thickness"/>
|
||||
<!-- Upload column. Visible/required only when
|
||||
the cert needs thickness data. Triggers
|
||||
the @onchange-driven .docx parser. -->
|
||||
<field name="fischer_filename" column_invisible="1"/>
|
||||
<field name="fischer_file"
|
||||
filename="fischer_filename"
|
||||
widget="binary"
|
||||
string="Fischerscope File (PDF or .docx)"
|
||||
invisible="not needs_thickness"
|
||||
readonly="not needs_thickness"/>
|
||||
<field name="parsed_summary" readonly="1"
|
||||
string="Parsed"
|
||||
optional="show"
|
||||
invisible="not needs_thickness or not parsed_summary"/>
|
||||
<field name="is_ready" widget="boolean_toggle"
|
||||
readonly="1"
|
||||
string="Ready"
|
||||
decoration-success="is_ready"
|
||||
decoration-danger="not is_ready"/>
|
||||
</list>
|
||||
<form>
|
||||
<header>
|
||||
<field name="is_ready" widget="statusbar"
|
||||
statusbar_visible="True,False"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="cert_name" readonly="1"/>
|
||||
<field name="cert_type" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="partner_id" readonly="1"/>
|
||||
<field name="needs_thickness"
|
||||
readonly="1"
|
||||
widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Fischerscope File"
|
||||
invisible="not needs_thickness">
|
||||
<field name="fischer_file"
|
||||
filename="fischer_filename"/>
|
||||
<field name="fischer_filename"
|
||||
invisible="1"/>
|
||||
</group>
|
||||
<group string="Measurement Image (Optional)"
|
||||
invisible="not needs_thickness">
|
||||
<field name="fischer_image_file"
|
||||
filename="fischer_image_filename"
|
||||
widget="image"
|
||||
options="{'size': [200, 200]}"/>
|
||||
<field name="fischer_image_filename"
|
||||
invisible="1"/>
|
||||
<div colspan="2" class="text-muted small">
|
||||
Drop a PNG/JPEG of the coupon
|
||||
under the XRF probe (export
|
||||
from the XDAL 600 software's
|
||||
Image menu). Rendered inline on
|
||||
the printed CoC so the customer
|
||||
sees the actual measurement.
|
||||
</div>
|
||||
</group>
|
||||
<div class="alert alert-info"
|
||||
role="alert"
|
||||
invisible="not needs_thickness or not parsed_summary">
|
||||
<i class="fa fa-check-circle me-1"/>
|
||||
<field name="parsed_summary"
|
||||
readonly="1" nolabel="1"/>
|
||||
</div>
|
||||
<separator string="Thickness Readings"
|
||||
invisible="not needs_thickness"/>
|
||||
<p class="text-muted small"
|
||||
invisible="not needs_thickness">
|
||||
Auto-filled from the .docx upload above.
|
||||
Edit/add rows manually as needed.
|
||||
</p>
|
||||
<field name="reading_line_ids"
|
||||
invisible="not needs_thickness">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="nip_mils"/>
|
||||
<field name="ni_percent"/>
|
||||
<field name="p_percent"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm & Issue"
|
||||
class="btn-primary"
|
||||
invisible="has_blocking_lines"/>
|
||||
<button name="action_confirm" type="object"
|
||||
string="Confirm & Issue"
|
||||
class="btn-secondary"
|
||||
invisible="not has_blocking_lines"
|
||||
disabled="1"
|
||||
help="One or more certs still need thickness data."/>
|
||||
<button string="Cancel" class="btn-secondary"
|
||||
special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Logistics',
|
||||
'version': '19.0.3.8.0',
|
||||
'version': '19.0.3.10.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': (
|
||||
'Pickup & delivery for plating shops: vehicle master, driver '
|
||||
@@ -43,6 +43,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating',
|
||||
'fusion_plating_configurator',
|
||||
'fusion_plating_receiving', # Shared "Shipping & Receiving" menu root
|
||||
'fusion_shipping',
|
||||
'hr',
|
||||
'mail',
|
||||
],
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
@@ -123,6 +125,86 @@ class FpDelivery(models.Model):
|
||||
'ir.attachment',
|
||||
string='Packing List',
|
||||
)
|
||||
|
||||
# ---- Phase A — outbound carrier + shipment link ----------------------
|
||||
# Mirrors the fields on fp.receiving. Populated by
|
||||
# fp.job._fp_create_delivery from the linked receiving when this
|
||||
# delivery is auto-created on job-done; shipping crew can override
|
||||
# at ship time.
|
||||
x_fc_carrier_id = fields.Many2one(
|
||||
'delivery.carrier', string='Outbound Carrier', tracking=True,
|
||||
ondelete='set null',
|
||||
help='Carrier picked at receiving time; can be overridden by '
|
||||
'the shipping crew before issuing the label.',
|
||||
)
|
||||
x_fc_outbound_shipment_id = fields.Many2one(
|
||||
'fusion.shipment', string='Outbound Shipment', tracking=True,
|
||||
ondelete='set null',
|
||||
copy=False,
|
||||
help='The shipment record carrying weight, dimensions, label '
|
||||
'PDF, and tracking. Usually the same shipment that was '
|
||||
'created at receiving time.',
|
||||
)
|
||||
x_fc_outbound_shipment_count = fields.Integer(
|
||||
compute='_compute_x_fc_outbound_shipment_count',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_outbound_shipment_id')
|
||||
def _compute_x_fc_outbound_shipment_count(self):
|
||||
for rec in self:
|
||||
rec.x_fc_outbound_shipment_count = (
|
||||
1 if rec.x_fc_outbound_shipment_id else 0
|
||||
)
|
||||
|
||||
@api.onchange('x_fc_carrier_id')
|
||||
def _onchange_x_fc_carrier_id(self):
|
||||
for rec in self:
|
||||
ship = rec.x_fc_outbound_shipment_id
|
||||
if ship and ship.status == 'draft' and rec.x_fc_carrier_id:
|
||||
ship.carrier_id = rec.x_fc_carrier_id.id
|
||||
|
||||
def action_create_outbound_shipment(self):
|
||||
self.ensure_one()
|
||||
if self.x_fc_outbound_shipment_id:
|
||||
return self.action_view_outbound_shipment()
|
||||
if 'fusion.shipment' not in self.env:
|
||||
raise UserError(_(
|
||||
'fusion_shipping module is not installed. '
|
||||
'Cannot create an outbound shipment.'
|
||||
))
|
||||
SO = self.env['sale.order'].sudo()
|
||||
so = False
|
||||
if self.job_ref:
|
||||
Job = self.env.get('fp.job')
|
||||
if Job is not None:
|
||||
job = Job.sudo().search(
|
||||
[('name', '=', self.job_ref)], limit=1,
|
||||
)
|
||||
so = job.sale_order_id if job else False
|
||||
vals = {
|
||||
'sale_order_id': so.id if so else False,
|
||||
'carrier_id': self.x_fc_carrier_id.id if self.x_fc_carrier_id else False,
|
||||
'status': 'draft',
|
||||
}
|
||||
shipment = self.env['fusion.shipment'].sudo().create(vals)
|
||||
self.x_fc_outbound_shipment_id = shipment.id
|
||||
self.message_post(body=Markup(_(
|
||||
'Outbound shipment <b>%s</b> created (draft).'
|
||||
)) % shipment.name)
|
||||
return self.action_view_outbound_shipment()
|
||||
|
||||
def action_view_outbound_shipment(self):
|
||||
self.ensure_one()
|
||||
if not self.x_fc_outbound_shipment_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': self.x_fc_outbound_shipment_id.name,
|
||||
'res_model': 'fusion.shipment',
|
||||
'res_id': self.x_fc_outbound_shipment_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
state = fields.Selection(
|
||||
[
|
||||
('draft', 'Draft'),
|
||||
@@ -178,6 +260,48 @@ class FpDelivery(models.Model):
|
||||
def _fp_parent_counter_field(self):
|
||||
return 'x_fc_pn_delivery_count'
|
||||
|
||||
def action_refresh_from_source(self):
|
||||
"""Re-pull delivery address / contact / scheduled date / source
|
||||
facility / carrier / CoC from the linked job → SO → receiving →
|
||||
cert chain. Only fills BLANK fields — never overwrites operator
|
||||
edits. Use when an upstream value changed after the delivery
|
||||
was auto-created, or to backfill an old delivery that was
|
||||
created before the auto-populate hook existed.
|
||||
"""
|
||||
for rec in self:
|
||||
job = (rec.x_fc_job_id
|
||||
if 'x_fc_job_id' in rec._fields else False)
|
||||
if not job:
|
||||
# Fall back via job_ref Char if M2O is empty (older data)
|
||||
if rec.job_ref and 'fp.job' in self.env:
|
||||
job = self.env['fp.job'].sudo().search(
|
||||
[('name', '=', rec.job_ref)], limit=1,
|
||||
)
|
||||
if not job:
|
||||
raise UserError(_(
|
||||
'Delivery %s has no linked job — nothing to '
|
||||
'refresh from.'
|
||||
) % rec.name)
|
||||
Delivery = rec.env['fusion.plating.delivery']
|
||||
defaults = job._fp_resolve_delivery_defaults(Delivery)
|
||||
# Drop fields the operator already filled — never clobber
|
||||
# manual edits. Includes the partner/job links since those
|
||||
# are non-overridable.
|
||||
fill = {
|
||||
k: v for k, v in defaults.items()
|
||||
if v and not rec[k]
|
||||
}
|
||||
if not fill:
|
||||
rec.message_post(body=_(
|
||||
'Refresh from source: nothing to update — every '
|
||||
'field already populated.'
|
||||
))
|
||||
continue
|
||||
rec.sudo().write(fill)
|
||||
rec.message_post(body=_(
|
||||
'Refresh from source filled: %s'
|
||||
) % ', '.join(sorted(fill.keys())))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Parent-derived name (DLV-<parent>[-NN]) with legacy-sequence
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_delivery_shipping_fields
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Phase A — mirror carrier + outbound shipment fields on fp.delivery."""
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
class TestDeliveryShippingFields(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'ShipCust'})
|
||||
|
||||
def test_carrier_id_field_exists_on_delivery(self):
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
self.assertIn('x_fc_carrier_id', delivery._fields)
|
||||
|
||||
def test_outbound_shipment_id_field_exists_on_delivery(self):
|
||||
delivery = self.env['fusion.plating.delivery'].create({
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
self.assertIn('x_fc_outbound_shipment_id', delivery._fields)
|
||||
@@ -55,10 +55,31 @@
|
||||
invisible="state in ('delivered','cancelled')"/>
|
||||
<button name="action_reset_to_draft" string="Reset to Draft" type="object"
|
||||
invisible="state != 'cancelled'"/>
|
||||
<!-- Pulls delivery address / contact / scheduled
|
||||
date / source facility / carrier / CoC from
|
||||
the job → SO → receiving → cert chain. Only
|
||||
fills BLANK fields, never overwrites operator
|
||||
edits. Useful when upstream data changed or
|
||||
to backfill an old delivery. -->
|
||||
<button name="action_refresh_from_source"
|
||||
string="Refresh from Source"
|
||||
type="object" class="btn-secondary"
|
||||
icon="fa-refresh"
|
||||
invisible="state in ('delivered','cancelled')"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,scheduled,en_route,delivered"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_create_outbound_shipment"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-truck">
|
||||
<field name="x_fc_outbound_shipment_count"
|
||||
widget="statinfo"
|
||||
string="Outbound Shipment"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
@@ -84,7 +105,9 @@
|
||||
<field name="vehicle_id"/>
|
||||
<field name="tdg_required" widget="boolean_toggle"/>
|
||||
</group>
|
||||
<group string="Documents">
|
||||
<group string="Outbound Shipping">
|
||||
<field name="x_fc_carrier_id"
|
||||
options="{'no_create': True}"/>
|
||||
<field name="coc_attachment_id"/>
|
||||
<field name="packing_list_attachment_id"/>
|
||||
<field name="pod_id" readonly="1"/>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Notifications',
|
||||
'version': '19.0.6.4.0',
|
||||
'version': '19.0.6.6.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -22,6 +22,7 @@
|
||||
'fusion_plating_invoicing',
|
||||
'fusion_plating_logistics',
|
||||
'fusion_plating_reports',
|
||||
'fusion_shipping',
|
||||
'sale_management',
|
||||
'account',
|
||||
'mail',
|
||||
|
||||
@@ -35,6 +35,13 @@
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="fp_notif_shipment_labeled" model="fp.notification.template">
|
||||
<field name="name">Shipping Label Generated</field>
|
||||
<field name="trigger_event">shipment_labeled</field>
|
||||
<field name="mail_template_id" ref="fp_mail_template_shipment_labeled"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="fp_notif_shipped" model="fp.notification.template">
|
||||
<field name="name">Shipped / Delivered</field>
|
||||
<field name="trigger_event">shipped</field>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<record id="fp_mail_template_quote_sent" model="mail.template">
|
||||
<field name="name">FP: Quotation Sent</field>
|
||||
<field name="model_id" ref="sale.model_sale_order"/>
|
||||
<field name="subject">Quotation {{ object.name }} — EN Technologies</field>
|
||||
<field name="subject">Quotation {{ object.name }} — Electroless Nickel Technologies Inc. (ENTECH)</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="email_to">{{ object.partner_id.email }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
@@ -24,7 +24,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Quotation Ready</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -54,16 +54,15 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
<field name="report_template_ids"
|
||||
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
|
||||
<field name="report_name">Quotation_{{ (object.name or '').replace('/','_') }}</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
@@ -80,7 +79,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Order Confirmed</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -114,16 +113,15 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
<field name="report_template_ids"
|
||||
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
|
||||
<field name="report_name">SalesOrder_{{ (object.name or '').replace('/','_') }}</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
@@ -140,7 +138,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Parts Received</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -170,10 +168,10 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
@@ -184,6 +182,70 @@
|
||||
fp.notification.template's `job_complete` trigger, defined
|
||||
in fp_notification_template_data.xml. -->
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 4b. Shipping Label Generated (Info, #2B6CB0) -->
|
||||
<!-- Fires when fusion.shipment.tracking_number first lands. -->
|
||||
<!-- Customer gets the tracking link BEFORE the package goes -->
|
||||
<!-- out the door, so they can monitor from pickup. -->
|
||||
<!-- ============================================================= -->
|
||||
<record id="fp_mail_template_shipment_labeled" model="mail.template">
|
||||
<field name="name">FP: Shipping Label Generated</field>
|
||||
<field name="model_id" ref="fusion_shipping.model_fusion_shipment"/>
|
||||
<field name="subject">Tracking #{{ object.tracking_number }} — your order is being prepared for shipment</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="email_to">{{ (object.sale_order_id and object.sale_order_id.partner_id.email) or '' }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
<field name="body_html" type="html">
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Order Is Being Prepared for Shipment</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
Hi <t t-out="object.sale_order_id.partner_id.name or ''"/>, the shipping label has been generated for your order. Tracking starts as soon as our shipping crew hands the package to the carrier.
|
||||
</p>
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
|
||||
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Shipment</th>
|
||||
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Sale Order</td>
|
||||
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.sale_order_id.name or '—'"/></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
|
||||
<td style="padding: 8px 4px;">Carrier</td>
|
||||
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.carrier_id.name or '—'"/></td>
|
||||
</tr>
|
||||
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
|
||||
<td style="padding: 8px 4px;">Tracking Number</td>
|
||||
<td style="padding: 8px 4px; text-align: right; font-family: monospace; font-weight: bold;"><t t-out="object.tracking_number or '—'"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
<div t-if="object.x_fc_tracking_url" style="margin: 24px 0; text-align: center;">
|
||||
<a t-att-href="object.x_fc_tracking_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style="display: inline-block; padding: 12px 28px; background-color: #2B6CB0; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 4px;">
|
||||
Track Shipment
|
||||
</a>
|
||||
</div>
|
||||
<div style="border-left: 3px solid #2B6CB0; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
|
||||
<strong>What's next:</strong> Once the carrier collects the package, you'll receive a Shipped confirmation with the Certificate of Conformance attached.
|
||||
</div>
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<!-- 5. Shipped / Delivered (Success, #38a169) -->
|
||||
<!-- ============================================================= -->
|
||||
@@ -198,7 +260,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Parts Have Shipped</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -232,10 +294,10 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
@@ -247,7 +309,7 @@
|
||||
<record id="fp_mail_template_invoice_posted" model="mail.template">
|
||||
<field name="name">FP: Invoice Notification</field>
|
||||
<field name="model_id" ref="account.model_account_move"/>
|
||||
<field name="subject">Invoice {{ object.name }} — EN Technologies</field>
|
||||
<field name="subject">Invoice {{ object.name }} — Electroless Nickel Technologies Inc. (ENTECH)</field>
|
||||
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
|
||||
<field name="email_to">{{ object.partner_id.email }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
@@ -255,7 +317,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Invoice Ready</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -293,16 +355,15 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
<field name="report_template_ids"
|
||||
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_invoice_portrait')])]"/>
|
||||
<field name="report_name">Invoice_{{ (object.name or '').replace('/','_') }}</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
@@ -319,7 +380,7 @@
|
||||
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
|
||||
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
|
||||
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
|
||||
EN Technologies
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Payment Received — Thank You</h2>
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
|
||||
@@ -353,10 +414,10 @@
|
||||
<div style="margin-top: 32px; font-size: 14px;">
|
||||
Best regards,<br/>
|
||||
<strong><t t-out="user.name or ''"/></strong><br/>
|
||||
EN Technologies Inc.
|
||||
Electroless Nickel Technologies Inc. (ENTECH)
|
||||
</div>
|
||||
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
|
||||
This is an automated notification from EN Technologies production system.
|
||||
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
|
||||
@@ -15,3 +15,4 @@ from . import account_payment
|
||||
# fires from fp.job.button_mark_done -> _fp_fire_notification('job_complete').
|
||||
# from . import mrp_production
|
||||
from . import fp_delivery
|
||||
from . import fusion_shipment
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user