Compare commits
150 Commits
d3c5c25865
...
phase4-man
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cdb2e3d0b | ||
|
|
f00dda2abd | ||
|
|
3b7b2477cf | ||
|
|
e762ee4b68 | ||
|
|
5d086c7f27 | ||
|
|
3eba80bb31 | ||
|
|
2a0d1862df | ||
|
|
7f70785b79 | ||
|
|
9dcd00d9b2 | ||
|
|
5a28c7e90f | ||
|
|
3c2efae951 | ||
|
|
c06d3d442a | ||
|
|
c76eb94724 | ||
|
|
06dc6a62b9 | ||
|
|
5463efcfc2 | ||
|
|
3fdbeed813 | ||
|
|
a18ef6c405 | ||
|
|
eae6a471e8 | ||
|
|
a61bd05a5c | ||
|
|
8109b3ec76 | ||
|
|
9d78bc4317 | ||
|
|
5c3c979f77 | ||
|
|
b52fe01d07 | ||
|
|
81da9bf71c | ||
|
|
1d04ac8cb7 | ||
|
|
27465cfeac | ||
|
|
fb5da1e3cd | ||
|
|
f661724c72 | ||
|
|
d127e19b45 | ||
|
|
d022e529d9 | ||
|
|
894eea7ce2 | ||
|
|
b395600a1c | ||
|
|
612394c987 | ||
|
|
d6d6249857 | ||
|
|
3440e4b7c6 | ||
|
|
5295aefd8f | ||
|
|
4025789ba0 | ||
|
|
5b6e53c863 | ||
|
|
b70fff01e1 | ||
|
|
07f9bcf79b | ||
|
|
1420a5c445 | ||
|
|
2bfb1015ea | ||
|
|
ace82de88c | ||
|
|
1b1e9fdb9e | ||
|
|
95e0e2d9bd | ||
|
|
cdc9f864b2 | ||
|
|
a00c891277 | ||
|
|
f45883233c | ||
|
|
d5e79cdc10 | ||
|
|
1a8a96d94e | ||
|
|
53fd6114e7 | ||
|
|
1314f4581d | ||
|
|
b2f483d67c | ||
|
|
48dd7718e2 | ||
|
|
ecca8e357f | ||
|
|
f41426c5b9 | ||
|
|
ebbadb3002 | ||
|
|
4f1b7c2df6 | ||
|
|
b4b59cc3c9 | ||
|
|
638b223d3b | ||
|
|
f463600585 | ||
|
|
bf4464ba37 | ||
|
|
65c4d8801c | ||
|
|
ef0c096e48 | ||
|
|
c506b53dec | ||
|
|
d93b500901 | ||
|
|
5c8768c556 | ||
|
|
3a15164605 | ||
|
|
194850e3cf | ||
|
|
f1cea2fb35 | ||
|
|
d15d9e4303 | ||
|
|
7f8a80fecb | ||
|
|
38a79a4b04 | ||
|
|
5a5e310a83 | ||
|
|
cb56a38680 | ||
|
|
750c7068e2 | ||
|
|
44e5b391f9 | ||
|
|
8ef57a4bb1 | ||
|
|
c86f1bbbe5 | ||
|
|
afe19f2105 | ||
|
|
73ee48e7c9 | ||
|
|
7727745b73 | ||
|
|
ad553b1082 | ||
|
|
429084e0bf | ||
|
|
79fbfec61f | ||
|
|
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 | ||
|
|
25f568f225 | ||
|
|
4e54ecc32f | ||
|
|
ab7ff3eea5 | ||
|
|
f8fc6be370 | ||
|
|
b27f68b8d5 | ||
|
|
d9bdbd8e18 | ||
|
|
281941c7ee | ||
|
|
7eb9dd02a7 | ||
|
|
3a520564a7 | ||
|
|
6f2bea9773 | ||
|
|
e50631c46a | ||
|
|
76c68e0311 | ||
|
|
04862e8a28 | ||
|
|
cdc47554ed | ||
|
|
77b84ac11b | ||
|
|
b92a396934 | ||
|
|
8225061dfa | ||
|
|
fe4cceeffa | ||
|
|
a99f9aa5ee | ||
|
|
ca60500c07 | ||
|
|
d17cadabf0 | ||
|
|
df74d702af | ||
|
|
ada22a583f | ||
|
|
009562913c | ||
|
|
0593b70354 | ||
|
|
26fe41e7d4 | ||
|
|
2802fcf738 | ||
|
|
153b980e2b | ||
|
|
6cad69cb86 | ||
|
|
27badff570 | ||
|
|
a63fbe1558 | ||
|
|
49013c64fb | ||
|
|
ba6f39375a | ||
|
|
cbed74e5eb | ||
|
|
2730c455f5 | ||
|
|
669ba0fd8a | ||
|
|
8e172132e7 |
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.
|
||||
1351
docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
Normal file
1351
docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
Normal file
File diff suppressed because it is too large
Load Diff
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.2.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;
|
||||
}
|
||||
282
fusion_claims/static/src/scss/fc_dashboard.scss
Normal file
282
fusion_claims/static/src/scss/fc_dashboard.scss
Normal file
@@ -0,0 +1,282 @@
|
||||
// =============================================================================
|
||||
// Fusion Claims Dashboard — Layout & Section Styles
|
||||
// Consumes tokens from _fc_dashboard_tokens.scss (must load FIRST in bundle).
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// Force full-width sheet on the dashboard. The sheet defaults to ~1100-1300px
|
||||
// max-width via `flex: 1 1 <fixed>` plus a CSS max-width. We override both
|
||||
// at every possible nesting level + use !important to beat media-query rules.
|
||||
// =============================================================================
|
||||
|
||||
// 1. The sheet itself
|
||||
.o_fc_dashboard_sheet,
|
||||
.o_form_sheet.o_fc_dashboard_sheet,
|
||||
.o_form_view .o_fc_dashboard_sheet,
|
||||
.o_form_renderer .o_fc_dashboard_sheet {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
flex: 1 1 100% !important;
|
||||
flex-basis: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
// 2. The sheet-bg wrapper around the sheet
|
||||
.o_form_view:has(.o_fc_dashboard_sheet) .o_form_sheet_bg,
|
||||
.o_form_renderer:has(.o_fc_dashboard_sheet) .o_form_sheet_bg,
|
||||
.o_form_sheet_bg:has(> .o_fc_dashboard_sheet) {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
flex: 1 1 100% !important;
|
||||
}
|
||||
|
||||
// 3. The form view itself
|
||||
.o_form_view.o_fc_dashboard,
|
||||
.o_form_view:has(.o_fc_dashboard_sheet) {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
// 4. Legacy fallback (older Odoo selector pattern)
|
||||
.o_fc_dashboard .o_form_sheet {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
flex: 1 1 100% !important;
|
||||
}
|
||||
|
||||
.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,536 @@
|
||||
<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">
|
||||
<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>
|
||||
<form string="Dashboard" create="0" delete="0" edit="0"
|
||||
class="o_fc_dashboard">
|
||||
<sheet class="o_fc_dashboard_sheet">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- RESPONSIVE GRID — 5/7 on lg, 3/5/4 on xl (≥1200px) -->
|
||||
<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"/>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<field name="panel3_html" class="w-100" nolabel="1"/>
|
||||
</div>
|
||||
|
||||
<!-- COLUMN 1: Personal / actionable (Activities + Bottlenecks) -->
|
||||
<div class="col-12 col-lg-5 col-xl-3">
|
||||
|
||||
<!-- 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>
|
||||
</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"/>
|
||||
|
||||
<!-- COLUMN 2: Workflow center (ADP + MOD) -->
|
||||
<div class="col-12 col-lg-7 col-xl-5">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- COLUMN 3: Analytics (Aging + Other Funders + Recent Exports)
|
||||
Full-width below cols 1+2 on lg, dedicated right-column on xl -->
|
||||
<div class="col-12 col-xl-4">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<!-- 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 +547,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:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Faxes',
|
||||
'version': '19.0.2.0.0',
|
||||
'version': '19.0.2.1.1',
|
||||
'category': 'Productivity',
|
||||
'summary': 'Send and receive faxes via RingCentral API from Sale Orders, Invoices, and Contacts.',
|
||||
'description': """
|
||||
|
||||
@@ -32,5 +32,13 @@
|
||||
<field name="value"></field>
|
||||
</record>
|
||||
|
||||
<!-- UI toggle — when False, hides the "Send Fax" header button
|
||||
on sale orders and invoices. Smart "Faxes" button (count
|
||||
badge) is unaffected. -->
|
||||
<record id="config_show_send_fax_button" model="ir.config_parameter">
|
||||
<field name="key">fusion_faxes.show_send_fax_button</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -17,12 +17,26 @@ class AccountMove(models.Model):
|
||||
string='Fax Count',
|
||||
compute='_compute_fax_count',
|
||||
)
|
||||
x_ff_show_send_fax_button = fields.Boolean(
|
||||
string='Show Send Fax Button',
|
||||
compute='_compute_show_send_fax_button',
|
||||
help='Driven by the Settings toggle '
|
||||
'(fusion_faxes.show_send_fax_button).',
|
||||
)
|
||||
|
||||
@api.depends('x_ff_fax_ids')
|
||||
def _compute_fax_count(self):
|
||||
for move in self:
|
||||
move.x_ff_fax_count = len(move.x_ff_fax_ids)
|
||||
|
||||
def _compute_show_send_fax_button(self):
|
||||
param = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_faxes.show_send_fax_button', 'True',
|
||||
)
|
||||
show = str(param).lower() not in ('false', '0', '')
|
||||
for move in self:
|
||||
move.x_ff_show_send_fax_button = show
|
||||
|
||||
def action_send_fax(self):
|
||||
"""Open the Send Fax wizard pre-filled with this invoice."""
|
||||
self.ensure_one()
|
||||
|
||||
@@ -15,6 +15,15 @@ class ResConfigSettings(models.TransientModel):
|
||||
string='Enable RingCentral Faxing',
|
||||
config_parameter='fusion_faxes.ringcentral_enabled',
|
||||
)
|
||||
ff_show_send_fax_button = fields.Boolean(
|
||||
string='Show "Send Fax" Button on Sale Orders & Invoices',
|
||||
config_parameter='fusion_faxes.show_send_fax_button',
|
||||
default=True,
|
||||
help='When enabled, the "Send Fax" header button appears on '
|
||||
'sale order and invoice forms (for users in the Fax User '
|
||||
'group). Turn off to hide the button without removing '
|
||||
'fax-user access.',
|
||||
)
|
||||
ff_ringcentral_server_url = fields.Char(
|
||||
string='RingCentral Server URL',
|
||||
config_parameter='fusion_faxes.ringcentral_server_url',
|
||||
@@ -103,7 +112,15 @@ class ResConfigSettings(models.TransientModel):
|
||||
}
|
||||
|
||||
def set_values(self):
|
||||
"""Protect credential fields from being blanked accidentally."""
|
||||
"""Protect credential fields from being blanked accidentally
|
||||
and force-persist the Send Fax Boolean.
|
||||
|
||||
Odoo's stock ``set_param`` removes the row when a Boolean
|
||||
config_parameter is False, which makes the ``get_param``
|
||||
fallback default kick in — toggling OFF then would silently
|
||||
re-show the button. We bypass that by writing 'True' / 'False'
|
||||
as a string after super() runs so the row always exists.
|
||||
"""
|
||||
protected_keys = [
|
||||
'fusion_faxes.ringcentral_client_id',
|
||||
'fusion_faxes.ringcentral_client_secret',
|
||||
@@ -122,4 +139,9 @@ class ResConfigSettings(models.TransientModel):
|
||||
existing = ICP.get_param(key, '')
|
||||
if existing:
|
||||
ICP.set_param(key, existing)
|
||||
return super().set_values()
|
||||
res = super().set_values()
|
||||
ICP.set_param(
|
||||
'fusion_faxes.show_send_fax_button',
|
||||
'True' if self.ff_show_send_fax_button else 'False',
|
||||
)
|
||||
return res
|
||||
|
||||
@@ -17,12 +17,28 @@ class SaleOrder(models.Model):
|
||||
string='Fax Count',
|
||||
compute='_compute_fax_count',
|
||||
)
|
||||
x_ff_show_send_fax_button = fields.Boolean(
|
||||
string='Show Send Fax Button',
|
||||
compute='_compute_show_send_fax_button',
|
||||
help='Driven by the Settings toggle '
|
||||
'(fusion_faxes.show_send_fax_button). Default True for '
|
||||
'back-compat — the button stays visible until a manager '
|
||||
'turns it off.',
|
||||
)
|
||||
|
||||
@api.depends('x_ff_fax_ids')
|
||||
def _compute_fax_count(self):
|
||||
for order in self:
|
||||
order.x_ff_fax_count = len(order.x_ff_fax_ids)
|
||||
|
||||
def _compute_show_send_fax_button(self):
|
||||
param = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_faxes.show_send_fax_button', 'True',
|
||||
)
|
||||
show = str(param).lower() not in ('false', '0', '')
|
||||
for order in self:
|
||||
order.x_ff_show_send_fax_button = show
|
||||
|
||||
def action_send_fax(self):
|
||||
"""Open the Send Fax wizard pre-filled with this sale order."""
|
||||
self.ensure_one()
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
|
||||
<!-- Send Fax header button (fax users only) -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_ff_show_send_fax_button" invisible="1"/>
|
||||
<button name="action_send_fax" string="Send Fax"
|
||||
type="object" class="btn-secondary"
|
||||
icon="fa-fax"
|
||||
invisible="not x_ff_show_send_fax_button"
|
||||
groups="fusion_faxes.group_fax_user"/>
|
||||
</xpath>
|
||||
|
||||
|
||||
@@ -26,6 +26,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show "Send Fax" button toggle -->
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="ff_show_send_fax_button"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="ff_show_send_fax_button"/>
|
||||
<div class="text-muted">
|
||||
Show the "Send Fax" header button on sale orders and invoices.
|
||||
Turn off to hide the button without removing fax-user access.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server URL -->
|
||||
<div class="col-12 col-lg-6 o_setting_box"
|
||||
invisible="not ff_ringcentral_enabled">
|
||||
|
||||
@@ -20,9 +20,11 @@
|
||||
|
||||
<!-- Send Fax header button (fax users only) -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="x_ff_show_send_fax_button" invisible="1"/>
|
||||
<button name="action_send_fax" string="Send Fax"
|
||||
type="object" class="btn-secondary"
|
||||
icon="fa-fax"
|
||||
invisible="not x_ff_show_send_fax_button"
|
||||
groups="fusion_faxes.group_fax_user"/>
|
||||
</xpath>
|
||||
|
||||
|
||||
@@ -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 || "",
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -3,9 +3,13 @@
|
||||
## Project
|
||||
Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and metal finishing shops. Built by Nexa Systems for EN Technologies (the client). Replaces Steelhead Software.
|
||||
|
||||
## Recent Session Handoff — 2026-05-17
|
||||
## Recent Session Handoff — 2026-05-17 (Portal Redesign + Sub-A IA approved)
|
||||
|
||||
> **For the next Claude session.** All changes below are LIVE on entech (db `admin` on LXC 111 / pve-worker5). Local repo has uncommitted changes against base commit `9ebf89b`; run `git status` to see them. All durable conventions added during this session are folded into the Critical Rules below (rules 6a, 14, 14a, 14b) — read those FIRST before changing reports / stickers / signatures / SO line tax fields.
|
||||
> **For the next Claude session.** Portal dashboard + jobs + detail + configurator are LIVE on entech at `fusion_plating_portal 19.0.3.7.0`. Sub-A (Portal IA + Sidebar) is brainstormed, spec'd, planned, NOT yet executed — pick up there. Full handoff (live state, decisions, gotchas, how-to-deploy, what's deferred): **[`docs/superpowers/handoffs/2026-05-17-portal-redesign-handoff.md`](docs/superpowers/handoffs/2026-05-17-portal-redesign-handoff.md)**. Approved plan ready to execute: **[`docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md`](docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md)** (11 tasks, 4 phases). Don't re-brainstorm; just execute.
|
||||
|
||||
### Previous handoff (pre-portal redesign — superseded but kept for context)
|
||||
|
||||
> All changes below are LIVE on entech (db `admin` on LXC 111 / pve-worker5). Local repo has uncommitted changes against base commit `9ebf89b`; run `git status` to see them. All durable conventions added during this session are folded into the Critical Rules below (rules 6a, 14, 14a, 14b) — read those FIRST before changing reports / stickers / signatures / SO line tax fields.
|
||||
|
||||
### Shipped this session
|
||||
|
||||
@@ -23,6 +27,27 @@ 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 (the CoC pattern — proven to work):** use a TINY `margin_top` (≤8mm) + `header_spacing=0` + `header_line=False`, then put a generous `padding-top: 20mm` on the body wrapper class (`.fp-coc`, `.fp-sale`, etc.). The header HTML is allowed to overflow the reserved zone INTO the body area; the body's wrapper padding is what actually clears it. This is more robust than trying to size `margin_top` to the rendered header height — any future header change (extra phone line, taller logo) immediately causes overlap with the "sized exactly" approach, whereas the overflow+padding approach has slack built in. Reference setup: `paperformat_fp_coc` and `paperformat_fp_a4_portrait` both use `margin_top=8`, `header_spacing=0`, `header_line=False`; CoC's `.fp-coc` and SO's `.fp-sale` both use `padding-top: 20mm` on the wrapper. **For landscape custom-header reports, reuse `paperformat_fp_a4_landscape_compact`** (same shape, just rotated) — invoice landscape uses this; don't create yet-another-landscape-compact. 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); SO portrait uses "Fusion Plating A4 Portrait (Compact)". Update the right one and don't bleed changes across reports. **Corollary — don't use negative `margin-top` to "tighten" the gap** (e.g. `.my-page { margin-top: -10px; }` to pull the H1 up under the header). The body wrapper sits at the bottom edge of the reserved margin_top zone; any negative margin pushes content INTO the header band, where wkhtmltopdf clips the top of glyphs (looks like the title is half-eaten). If the gap really feels too big, shrink the title font instead, or reduce `paperformat.margin_top` so the entire header zone is shorter. **For customer-facing portrait reports** (SO confirmation, quote, invoice, packing slip, BoL) the canonical compact paperformat is `fusion_plating_reports.paperformat_fp_a4_portrait` (margin_top=22mm, header_spacing=3mm, keeps the standard header band). Bind it via `<field name="paperformat_id" ref="fusion_plating_reports.paperformat_fp_a4_portrait"/>` rather than creating yet-another-one. **Two compounding-padding traps to be aware of:** (1) Odoo's `.page` class has `padding: 1cm` baked in (Bootstrap-derived). If you wrap your body in `<div class="page">` AND add a body `padding-top: 15mm`, you get the paperformat margin_top + 10mm Odoo + 15mm yours = ~65mm of dead space above the title. To remove the .page contribution without losing its left/right padding, override only the top: `.fp-report.fp-sale .page { padding-top: 0 !important; }`. CoC sidesteps this by NOT using an inner `.page` div — it wraps directly in `<div class="fp-coc">` and puts padding on that. (2) The base `.fp-report table.bordered th, .fp-report table.bordered td` rule applies borders explicitly, BUT a separate cascade still bleeds borders onto NESTED `<table>` elements even when the inner table has no `.bordered` class — `border: 0 !important` on the cells does NOT reliably override it (some wkhtmltopdf rendering paths still draw the lines). **Don't use a `<table>` for non-bordered layouts** like a title/barcode strip; use `<div>` + `float: right` / flexbox instead. Saves an hour of CSS specificity arguments with wkhtmltopdf. (3) **CSS comments inside QWeb `<style>` blocks are XML-parsed** — writing `/* don't use a <table> here */` makes lxml see a literal `<table>` opening tag and the file fails to load with `XMLSyntaxError: Opening and ending tag mismatch`. Strip the angle brackets from any HTML-like literals in CSS comments: write `/* don't use a table here */` or quote it as `"<table>"`. (4) **XML comments cannot contain `--` (double-hyphen)** per the XML spec — `<!-- needs wkhtmltopdf --footer-html -->` fails with `XMLSyntaxError: Comment must not contain '--' (double-hyphen)`. Rewrite without the double-hyphen: `<!-- needs a wkhtmltopdf footer-html arg -->`. Bites when documenting CLI flags or option names in QWeb comments. | `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 |
|
||||
| **stock.picking.move_ids_without_package removed** | Odoo 19 dropped `move_ids_without_package` on `stock.picking` — `<t t-foreach="doc.move_ids_without_package">` raises `AttributeError: 'stock.picking' object has no attribute 'move_ids_without_package'` at QWeb render. Use `move_ids` instead (all stock moves on the picking). The filtered "without_package" variant no longer exists; if you really need to exclude packaged moves, filter `move_ids` in QWeb (`move_ids.filtered(lambda m: not m.package_level_id)`) or in a python helper. Same gotcha applies to any old report/template ported from Odoo 16/17. | any report/view iterating over picking moves |
|
||||
| **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 |
|
||||
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition` → `Adresse d'expédition`, `N° de pièce` → `N° de pièce`, `—` → `â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). | any custom-header PDF report on entech wkhtmltopdf |
|
||||
| **QWeb `t-field` requires a dotted path — bare variables fail at compile** | Odoo 19 enforces `assert "." in el.get('t-field')` in `_compile_directive_field`. Writing `<div t-field="partner" t-options="{'widget': 'contact', ...}"/>` (where `partner` came from a `<t t-set="partner" t-value="..."/>` in the calling template) **fails at template-compile time** with `AssertionError: t-field must have at least a dot like 'record.field_name'`. The error message points at the line, but the broader trap is that **you can't write a generic "render-a-partner-as-contact" sub-template that takes a record via t-set** — the contact-widget pattern only works on real field traversals like `doc.partner_id` baked into the template at author time. **Workarounds:** (a) Inline the partner rendering at each call site so the `t-field` has a dotted path (`<div t-field="doc.partner_invoice_id" t-options=...`). (b) Render the address parts manually in the sub-template using `t-esc` on explicit fields (`partner.street`, `partner.city`, etc.) — verbose but works with bare variables. Pattern (b) is what `fp_packing_slip_addr_block` uses now after this trap was hit. Same applies to `t-out` with `widget` options. | any QWeb sub-template trying to render a record via `t-field` |
|
||||
| **Assigning a `Date` to a `Datetime` field shifts the day in negative-UTC timezones** | When a transient/wizard `fields.Date` value is written into a target `fields.Datetime` field (e.g. wizard `customer_deadline` → SO `commitment_date`), Odoo stores midnight UTC of the picked date. Rendered back in any negative-UTC timezone (Eastern UTC-4/-5, all of CA/US), midnight UTC = 8pm the previous day — so the user picks "May 25" in the wizard and sees "May 24" on the SO header / PDF report. **Fix:** combine the date with noon before writing: `datetime.combine(self.my_date, time(12, 0))` — noon UTC stays on the same calendar date in every reasonable timezone (±12hr). Caught here on `fp.direct.order.wizard._prepare_order_vals` writing `commitment_date`. Watch for the same pattern any time a wizard/configurator with a Date field hands off to a Datetime target. The reverse (`Datetime` field read into a Date-display) is fine if `t-options="{'widget':'date'}"` is used — Odoo handles the tz-aware date extraction. | any wizard writing a Date value into a Datetime field |
|
||||
| **Customer-facing reports use bilingual EN/FR labels** | Every customer-facing report label (column titles, section banners, totals, document title) renders English first and French second. **Default to inline slash format** ("English / French" on one line) — easier to scan and saves vertical space. **Use the stacked variant only for cells too narrow** for the French word to fit on the same line (QTY, UOM, narrow column headers in dense tables). CSS classes live in the `fp_sale_bilingual_styles` template in `report_fp_sale.xml`. **Inline (default):** `.fp-bl-en { font-weight:bold; }` + `.fp-bl-sep { color:#999; margin:0 3px; }` + `.fp-bl-fr { font-weight:normal; font-style:italic; color:#555; }`. Pattern: `<span class="fp-bl-en">English</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">French</span>`. **Stacked (narrow cells):** `.fp-bl-en-stk` + `.fp-bl-fr-stk` (each `display:block`). **Always render both spans even when EN and FR are the same word** (e.g. "Description / Description", "Taxes / Taxes") — visual consistency across the row matters more than the redundancy; dropping the FR span on identical-word labels leaves an obvious gap when scanning down a column of headers. When a report has a barcode block, encode `doc.name` via `ir.actions.report.barcode_data_uri('Code128', doc.name, 600, 100)` (the helper inlines a data URI — don't `/report/barcode/...` over HTTP, wkhtmltopdf network fetches fail on entech). Apply to ALL outward-facing reports (SO confirmation, quote, invoice, CoC, packing slip, BoL); internal-only reports (job traveller, WO sticker) can stay English. | `fusion_plating_reports/report/report_fp_sale.xml` (canonical), every customer-facing report |
|
||||
|
||||
### Pending — IN PROGRESS when this session ended
|
||||
|
||||
@@ -170,6 +195,12 @@ These modules have **source code in this repo** but are **intentionally NOT inst
|
||||
14. **Sticker template — leave the CSS units alone**: `report_fp_wo_sticker_inner` is calibrated for **px units at paperformat dpi=300** on entech's wkhtmltopdf. Do NOT "modernise" it by converting px→mm or by bumping paperformat dpi — both have been tried (2026-05-16) and both collapsed the layout (tiny logo, tiny QR, body grid shorter than the body band, font sizes visually smaller despite using pt). The math suggests the conversions should be equivalent, but wkhtmltopdf's px↔mm↔dpi mapping doesn't follow the obvious model on this image. Trust the working geometry, change only what you came to change. **Barcode size cap**: Odoo core raises `ValueError("Barcode too large")` when `width * height > 1_200_000` OR `max(width, height) > 10000` (see `base/models/ir_actions_report.py::barcode`). Largest safe square is ~1095×1095 — we use 1000×1000 to stay clear of the ceiling. **Em-dash mojibake**: wkhtmltopdf's default font on entech mojibakes em-dash (—), en-dash (–), smart quotes, and ellipsis into `â€"` etc. — strip them defensively for any free-text field that bleeds into the sticker (thickness, notes, line.name). The strip pattern is `.replace(u'—', '-').replace(u'–', '-')...` in `report_fp_wo_sticker_inner`.
|
||||
15. **Recipe editor parity**: Step-level UX features (image attachments, prompt editing, settings toggles, preview affordances, etc.) MUST be implemented in BOTH the **Simple Editor** (`fusion_plating/static/src/{js,xml,scss}/simple_recipe_editor.*` + `controllers/simple_recipe_controller.py`) AND the **Tree Editor** (`fusion_plating/static/src/{js,xml,scss}/recipe_tree_editor.*` + `controllers/recipe_controller.py`). Authors choose between editors per-recipe via `preferred_editor`; if a feature only lands in one, half the userbase silently misses it. Default assumption: most clients use the Simple Editor — when in doubt, ship Simple first, then port to Tree in the same change. Backend model + view changes (e.g. new fields on `fusion.plating.process.node`, new tabs on the node form) automatically reach both editors via the related model — only the editor-specific JS/XML/SCSS needs duplicating.
|
||||
16. **HTTP controller route override = method name must match parent**: To override a route on an inherited controller (e.g. `portal.CustomerPortal.home()` at `/my/home`), the override method MUST share the parent's method name. Declaring a new method name with the same `@http.route()` URL does NOT override — Odoo registers BOTH handlers as siblings and the parent typically wins, silently. Pattern: `class FpCustomerPortal(CustomerPortal): @http.route() def home(self, **kw): ...`. Bit us 2026-05-17 in `fusion_plating_portal/controllers/portal.py` — `portal_my_home_dashboard()` failed to override stock `home()`; symptom was the rich FP dashboard never rendering at `/my/home` even though the template was active in DB.
|
||||
17. **Test scaffolding — creating account.move in tests**: Two custom gates block direct invoice creation in tests:
|
||||
- `fusion_plating_jobs` blocks all `out_invoice`/`out_refund`/`out_receipt` creates unless `context.get('fp_from_so_invoice')` is set or `invoice_origin` matches an SO name. Bypass: `self.env['account.move'].with_context(fp_from_so_invoice=True).create(...)`.
|
||||
- `fusion_plating_invoicing` blocks `action_post()` when `invoice_payment_term_id` is unset. Bypass: pass `'invoice_payment_term_id': self.env.ref('account.account_payment_term_immediate').id` in the create vals.
|
||||
Both are test-data scaffolding; neither weakens assertions and neither must appear in production code paths.
|
||||
18. **Portal list pages — no pagination, 500-record cap**: All FP portal list routes (quote requests, jobs, certifications, deliveries) load up to 500 records and rely on client-side JS filtering. Do NOT re-add `portal_pager` to these routes. The `fp_portal_list_controls` macro + `fp_portal_list_search.js` handle filtering, counting, and the sort dropdown. Hidden `<td class="d-none">` cells inside each row carry extra searchable text (part number, customer PO, contact) that isn't displayed but is matched by the JS.
|
||||
19. **QWeb `t-value` is Python, not Jinja**: `t-value="orders|length"` does NOT call a filter — Python parses `|` as bitwise/recordset OR, so on a non-empty recordset it tries `recordset | length_var` and raises `TypeError: unsupported operand types in: sale.order(…) | None` (when `length` is undefined) or returns a merged recordset (when `length` happens to be another recordset). Use `len(orders)` or `bool(orders)` or `(orders and orders[0]) or False` — explicit Python. Same trap applies to `|default`, `|first`, `|join`, etc. — none of these Jinja filters exist in QWeb. Bit us 2026-05-18 on `fp_sale_order_portal.xml` injecting `result_total` into the list-controls macro.
|
||||
|
||||
## Naming
|
||||
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# Portal Redesign — Session Handoff (2026-05-17)
|
||||
|
||||
> **Read this first.** This session ran long; the next session picks up here. Everything below is intentionally short. Authoritative details live in the linked spec / plan files.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Customer-portal redesign across two long sessions. Dashboard + jobs + detail page + configurator are LIVE on entech. The next step (sidebar nav + page audit + Account Summary view) has an APPROVED PLAN ready to execute — do not re-brainstorm, just execute.
|
||||
|
||||
**Immediately actionable:** execute [`docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md`](../plans/2026-05-17-portal-ia-sidebar-plan.md) via `superpowers:subagent-driven-development` or `superpowers:executing-plans`. User was offered both at handoff time and chose subagent-driven (preferred). 11 tasks across 4 phases.
|
||||
|
||||
## Live state on entech (2026-05-17)
|
||||
|
||||
| Module | Version live | Notes |
|
||||
|---|---|---|
|
||||
| `fusion_plating_portal` | `19.0.3.7.0` | Dashboard, job cards, configurator, detail page, doc downloads, repeat order, animations — all shipped |
|
||||
| `fusion_plating_jobs` | `19.0.10.8.0` + write-hook + create-init | fp.job → fp.portal.job state-sync hook on write, initial state derive on create |
|
||||
| `fusion_plating_reports` | `19.0.11.15.0` | Customer Acceptance / Authorized Representative signature blocks removed from `report_fp_sale_portrait/landscape` |
|
||||
| All 5 portal unit tests green | | `--test-tags=fp_portal` |
|
||||
|
||||
Branch: `main`. Local repo is many commits ahead of `origin/main`; user has not been asked to push (per system-prompt safety default). Run `git log --oneline origin/main..HEAD` at session start to see what's outstanding.
|
||||
|
||||
## What shipped this session (high-level)
|
||||
|
||||
1. **Dashboard rebuild** — `/my/home` → jobs-forward layout (KPI tiles → Active Work Orders hero → 5 secondary panels). Welcome line summarises status in plain words. EN Plating teal brand palette with gradient CTAs.
|
||||
2. **Job card upgrade** — shared `fp_portal_job_card` macro (used by `/my/home` + `/my/jobs`). Wrap div + inner anchor + sibling actions footer (4 doc download chips + Repeat Order POST form). Part info + ship-to address pulled inline. Pulse animation on the active step circle + matching detail-page timeline dot.
|
||||
3. **Detail page** — V2 stepper + V3 timestamps + 5-group document panel (From You / Specifications / Work Order / Quality / Shipping). Sales Order Confirmation, Work Order Detail, CoC, Packing Slip all sudo-render from the FP custom reports. Hero shows part + ship-to.
|
||||
4. **Configurator fixes** — `/my/configurator/coating` 500 fixed (`fp.coating.config` → `fusion.plating.process.type`). Manual measurements hidden in step 1. Split single-file upload into Drawing (PDF) + 3D Model.
|
||||
5. **Sale report cleanup** — Customer Acceptance / Authorized Representative signature block removed.
|
||||
6. **Misc** — `/my` route added, button sizing normalised, hover-underline suppressed globally, sidebar of legacy stuff redirected, dashboard expanded to 5 panels (Quote Requests + Purchase Orders added).
|
||||
|
||||
24+ commits this session, all on `main`. Browse `git log --oneline -30` for the full sweep.
|
||||
|
||||
## What's queued for execution
|
||||
|
||||
**Sub-A (Portal IA + Sidebar):** plan ready, not yet executed. Brainstorm decisions baked in:
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Sidebar shape | **B** — Dashboard top, then grouped Activity / Documents / Account sections |
|
||||
| Account Summary tabs | 3 (Invoices / Credit Memos / Statements) + Open Balance pill in header |
|
||||
| Statements V1 | Placeholder card ("Coming soon") — real statement generation deferred |
|
||||
| Legacy URL redirects | `/my/fp_invoices` → `/my/account_summary`; `/my/purchase_orders` → `/my/orders` (Odoo default); `/my/quote_requests/new` GET → `/my/configurator/new` |
|
||||
| Future Users / Search slots | Omit from V1 (no "coming soon" placeholders); add when sub-B/sub-C ship |
|
||||
|
||||
Spec: [`docs/superpowers/specs/2026-05-17-portal-ia-sidebar-design.md`](../specs/2026-05-17-portal-ia-sidebar-design.md)
|
||||
|
||||
## What's deferred (do NOT re-litigate in next session)
|
||||
|
||||
These were explicitly scoped OUT during brainstorming. Open new brainstorm sessions for each when their turn comes:
|
||||
|
||||
- **Sub-B Multi-user account management** — invite teammates, role per user, per-action ACLs. Will add a Users item under the Account section of the sidebar.
|
||||
- **Sub-C Portal search** — global search across jobs / quotes / invoices / certs. Search input slot above Dashboard in the sidebar.
|
||||
- **Saved drafts (RFQ)** — user mentioned wanting drafts during configurator. Three scoping options proposed (minimal/medium/big); awaiting user direction. Not part of sub-A.
|
||||
- **Real Statements generation** — account.followup integration OR cron-precomputed monthly PDFs. Decide during sub-A Phase 3 implementation or defer to its own follow-up.
|
||||
- **Top Recurring Parts / Favorites / SerialNumber Lookup** — competitor-style features; deferred until customer demand confirmed.
|
||||
- **RMA customer portal** — sub-12 RMA backend exists; portal exposure is its own sub-project.
|
||||
|
||||
## Gotchas that bit us this session
|
||||
|
||||
Future Claude will hit these too unless documented. Most are already inline in CLAUDE.md or MEMORY.md. Worth a re-skim before touching the portal:
|
||||
|
||||
1. **`fp.coating.config` is retired** (Sub-11 cleanup). Use `fusion.plating.process.type` as the customer-facing coating taxonomy. Multiple `*.py` files still reference the dead model in COMMENTS — don't pattern-match from those.
|
||||
2. **Portal users can't read `fp.job` directly.** Controllers that return `fp.portal.job` records to a template MUST `sudo()` the search if the template traverses `job.x_fc_job_id`. Same pattern is already used for `sale.order`, `account.move`, `stock.picking`. Domain still filters to commercial partner tree.
|
||||
3. **`sale_pdf_quote_builder` gates on `report_name == 'sale.report_saleorder'`** (already in MEMORY.md). For customer-facing SO PDFs on the portal, render the FP custom `fusion_plating_reports.report_fp_sale_portrait` instead, and use a dedicated portal route that sudo-renders so the QWeb template can walk into `fp.part.catalog` etc.
|
||||
4. **Forms inside anchors is invalid HTML.** When making a whole card clickable AND embedding a Repeat-Order form inside, use a wrap div + inner anchor (main click target) + sibling actions footer (form lives here). Don't nest `<form>` inside `<a>`.
|
||||
5. **Groups list indexing drift.** `_fp_group_documents` builds the docs panel by appending to `groups[N]`. If you reorder the initial list or insert a new group mid-helper, every `groups[N]` reference shifts. The code has an inline warning comment now; respect it.
|
||||
6. **Per-stage timestamps are NULL on records created before the write hook deployed.** `_fp_get_stage_timeline` has a Date-fallback chain (received_date → received_at; actual_ship_date → shipped_at) plus linear interpolation for middle stages. Records created post-hook get real datetimes from the `fp.job.write()` mirror.
|
||||
7. **Stepper SCSS — `.o_fp_step_line` MUST stay nested inside `.o_fp_stepper`** (inline comment in the SCSS warns about this). When `flex:1` isn't applied because the rule slipped outside the parent, circles cluster on the left of the row.
|
||||
8. **Stepper labels align via absolute positioning per-unit** (not as a separate flex container). Wider labels like "Inspected" overflow equally to both sides of their circle's centre. Don't revert to the dual-container approach.
|
||||
9. **`fp.portal.job` state-sync map** uses `_FP_JOB_STATE_TO_PORTAL_STATE` in `fusion_plating_jobs/models/fp_job.py`. `on_hold` and `cancelled` deliberately NOT mirrored to the customer-facing state. Manager decision what to surface.
|
||||
|
||||
## How to deploy (entech LXC 111 on pve-worker5)
|
||||
|
||||
Same recipe used 20+ times this session. Per file:
|
||||
|
||||
```bash
|
||||
cat K:/Github/Odoo-Modules/fusion_plating/<module>/<path> | \
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/<module>/<path>'"
|
||||
```
|
||||
|
||||
Then upgrade module + run tests:
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && \
|
||||
su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin \
|
||||
-u fusion_plating_portal --test-tags=fp_portal --stop-after-init 2>&1 | tail -25\" && \
|
||||
systemctl start odoo'"
|
||||
```
|
||||
|
||||
Bust asset cache for SCSS/JS changes:
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- su - postgres -c \
|
||||
\"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
|
||||
```
|
||||
|
||||
Service status / version check:
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl is-active odoo'"
|
||||
ssh pve-worker5 "pct exec 111 -- su - postgres -c \
|
||||
\"psql -d admin -t -c \\\"SELECT latest_version FROM ir_module_module \
|
||||
WHERE name='fusion_plating_portal';\\\"\""
|
||||
```
|
||||
|
||||
## How to start the next session
|
||||
|
||||
1. Open Claude Code in `K:\Github\Odoo-Modules\fusion-plating` (or `K:\Github\Odoo-Modules\fusion_plating` — both work, the dash dir has only 3 modules but it's the active working dir for the user's terminal).
|
||||
2. First message: "Resume the portal sub-A IA work — execute the approved plan from this session."
|
||||
3. New session should:
|
||||
- Read `CLAUDE.md` (auto-loaded) — the "Recent Session Handoff" section at the top points back to this file
|
||||
- Read this handoff doc
|
||||
- Read the plan: `docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md`
|
||||
- Invoke `superpowers:subagent-driven-development` (or `executing-plans` for inline mode)
|
||||
- Execute the 11 tasks across 4 phases
|
||||
4. Optional but useful: re-run the existing test suite first to confirm starting from green: `--test-tags=fp_portal --stop-after-init`.
|
||||
|
||||
## Brainstorm artifacts
|
||||
|
||||
Visual companion mockups for this session live in `.superpowers/brainstorm/*/content/` (gitignored). Useful for visual comparison if needed:
|
||||
- `design-direction.html` — Modern SaaS / Corporate / Industrial picker
|
||||
- `saas-refinements.html` — V1/V2/V3 card variants
|
||||
- `dashboard-layout.html` — 6-card grid vs jobs-forward
|
||||
- `job-detail.html`, `branded-job-detail.html` — detail page mockups
|
||||
- `branded-dashboard.html` — final brand-applied dashboard
|
||||
- `sidebar-structure.html` — flat vs grouped vs hybrid (chose grouped)
|
||||
|
||||
Brainstorm server idles out after 30 min. Restart command:
|
||||
|
||||
```bash
|
||||
"C:/Users/gur_p/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/skills/brainstorming/scripts/start-server.sh" \
|
||||
--project-dir "K:/Github/Odoo-Modules/fusion_plating"
|
||||
```
|
||||
|
||||
(Run in background; URL appears in `.superpowers/brainstorm/*/state/server-info`.)
|
||||
|
||||
## Critical files modified this session
|
||||
|
||||
If the next session needs to read context fast:
|
||||
- `fusion_plating_portal/controllers/portal.py` — most changes here
|
||||
- `fusion_plating_portal/controllers/portal_configurator.py` — coating model swap + dual upload
|
||||
- `fusion_plating_portal/views/fp_portal_dashboard.xml` — jobs-forward layout
|
||||
- `fusion_plating_portal/views/fp_portal_templates.xml` — jobs list + detail rewrites
|
||||
- `fusion_plating_portal/views/fp_portal_macros.xml` — `fp_portal_job_card`, `fp_portal_stepper`, `fp_portal_status_badge`, `fp_portal_doc_chip`, `fp_portal_doc_group`
|
||||
- `fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss` — brand tokens
|
||||
- `fusion_plating_portal/static/src/scss/fp_portal_*.scss` — 7 partials (buttons, badges, cards, stepper, timeline, dashboard, legacy catch-all)
|
||||
- `fusion_plating_portal/models/fp_portal_job.py` — per-stage Datetime fields + write/create snapshot hooks
|
||||
- `fusion_plating_jobs/models/fp_job.py` — fp.job → fp.portal.job state-sync hook
|
||||
- `fusion_plating_portal/tests/test_portal_dashboard.py` — 5 tests, all green
|
||||
|
||||
## What user feedback is still outstanding
|
||||
|
||||
Nothing concrete waiting on user. Last thing the user did was approve the plan and say "create a handsoff script so i start a new session" — i.e., they want to pause here. Next session resumes execution.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
# Customer Portal — Information Architecture + Sidebar Nav
|
||||
|
||||
**Module**: `fusion_plating_portal` (touches `portal.portal_layout` inherit)
|
||||
**Date**: 2026-05-17
|
||||
**Status**: Design locked, awaiting implementation plan
|
||||
**Surface**: every `/my/*` page on `https://enplating.com`
|
||||
**Sub-project**: A (of A/B/C); B = multi-user, C = portal search — deferred to separate brainstorms.
|
||||
|
||||
## Problem
|
||||
|
||||
The post-2026-05-17 portal redesign gave us a credible dashboard + jobs-detail page, but the navigation between pages is still "scroll the standard Odoo portal cards and hope you find the right entry point." Eight distinct customer surfaces (`/my/home`, `/my/jobs`, `/my/quote_requests`, `/my/configurator`, `/my/purchase_orders`, `/my/fp_invoices`, `/my/deliveries`, `/my/certifications`) and there's no persistent way to move between them. The customer's competitor screenshot (Mobility Specialties Inc / Drive Medical) shows the right pattern: a sticky left sidebar that lists every section, current page highlighted, secondary "Company Account" group at the bottom.
|
||||
|
||||
This spec restructures the portal around that sidebar pattern, audits the existing pages (replace thin custom pages with Odoo defaults where the default is better), and adds one missing page — a consolidated **Account Summary** with tabbed Invoices / Credit Memos / Statements + an Open Balance pill — that the existing thin `/my/fp_invoices` page doesn't deliver.
|
||||
|
||||
## User stories
|
||||
|
||||
1. **As a returning customer**, I want a persistent sidebar showing every section so I can jump between Quote Requests and Work Orders without going through the dashboard.
|
||||
2. **As an accounting clerk**, when I open the portal I want a single Account Summary page with Open Balance + filterable invoices + credit memos + downloadable monthly statements — without hunting through three separate menu items.
|
||||
3. **As any customer**, I want the active page visually marked so I always know where I am.
|
||||
4. **As a mobile user**, the sidebar should collapse to a hamburger so the page content gets the screen.
|
||||
|
||||
## Locked design decisions (from brainstorming 2026-05-17)
|
||||
|
||||
| Decision | Choice | Why |
|
||||
|---|---|---|
|
||||
| Decomposition | A first (IA), B (multi-user) + C (search) deferred to separate brainstorms | Sidebar + pages are the foundation; building search before pages exist or a Users tab before the sidebar shape is locked would be rework. |
|
||||
| Sidebar shape | Option B — Dashboard at top, then 3 grouped sections (Activity / Documents / Account) | 10 items needs grouping to scan; matches how the redesigned dashboard already groups (KPI tiles → jobs hero → secondary panels). |
|
||||
| Account Summary tabs | 3 tabs: Invoices · Credit Memos · Statements, plus an "Open Balance: $X" pill in the page header | Mirrors competitor; one summary number front-of-mind, three drilldowns. |
|
||||
| Future placeholders | NEITHER "Users (soon)" nor a search input shown in the sidebar today | Empty placeholders add visual noise; ship them when sub-B / sub-C land. |
|
||||
| Sidebar persistence | Sticky on scroll; visible on every `/my/*` page (including Odoo defaults via `portal.portal_layout` inherit); sub-pages keep their parent highlighted | Industry standard. Consistency means the customer never loses their place. |
|
||||
| Mobile collapse | Below 768px the sidebar collapses to a hamburger button in the page header; opens as a slide-in drawer | Standard portal pattern, no content rearrangement needed. |
|
||||
| Single quote-creation path | `/my/quote_requests/new` redirects to `/my/configurator/new` | Two paths to the same outcome confuses customers; the configurator is the more complete flow. |
|
||||
| Sign Out placement | Bottom of sidebar, separated by a hairline border | Matches competitor; gets sign-out off the page chrome. |
|
||||
|
||||
## Scope
|
||||
|
||||
**IN SCOPE — pages restructured / new:**
|
||||
|
||||
- `/my/home` — keep dashboard, gets sidebar
|
||||
- `/my/jobs` — keep list, gets sidebar
|
||||
- `/my/jobs/<id>` — keep detail, gets sidebar (highlight parent)
|
||||
- `/my/quote_requests` — keep list, gets sidebar
|
||||
- `/my/quote_requests/<id>` — keep detail, gets sidebar
|
||||
- `/my/quote_requests/new` — **REDIRECT** to `/my/configurator/new`
|
||||
- `/my/configurator` — keep landing, gets sidebar
|
||||
- `/my/configurator/new`, `.../coating`, `.../estimate` — keep wizard, gets sidebar
|
||||
- `/my/purchase_orders` — **REDIRECT** to Odoo default `/my/orders`; controller + template deleted
|
||||
- `/my/fp_invoices` — **REDIRECT** to new `/my/account_summary`; controller + template deleted
|
||||
- `/my/account_summary` — **NEW** tabbed page (this spec)
|
||||
- `/my/deliveries` — keep, gets sidebar
|
||||
- `/my/certifications` — keep, gets sidebar
|
||||
- `/my/account` — Odoo default, gets sidebar
|
||||
- `/my/orders/<id>` — Odoo default, gets sidebar
|
||||
|
||||
**IN SCOPE — chrome:**
|
||||
|
||||
- New `fp_portal_shell` template that inherits `portal.portal_layout` and wraps every `o_portal` page body with a sticky 240px sidebar on the left.
|
||||
- Sidebar SCSS partial (`fp_portal_sidebar.scss`) — brand-teal active state, mint gradient highlight, hairline section dividers.
|
||||
- Mobile breakpoint: hamburger toggle + slide-in drawer below 768px.
|
||||
- All Odoo default portal pages (`/my/account`, `/my/orders`, `/my/orders/<id>`, `/my/invoices/<id>`, etc.) get the sidebar via the `portal.portal_layout` inherit — zero per-page edits.
|
||||
|
||||
**OUT OF SCOPE — deferred to other sub-projects:**
|
||||
|
||||
- Multi-user account management (sub-project B): Users tab in sidebar, invitation flow, per-action ACLs.
|
||||
- Portal search (sub-project C): global search input above Dashboard, search-result page.
|
||||
- Saved drafts (separate brainstorm — needs its own scoping).
|
||||
- Top Recurring Parts / Favorites / SerialNumber Lookup (defer until customer demand confirmed).
|
||||
- RMA customer portal (sub-project after RMA backend ships).
|
||||
|
||||
**OUT OF SCOPE — explicit non-goals:**
|
||||
|
||||
- Top-bar navigation, breadcrumbs redesign, footer changes — none of these are part of A.
|
||||
- Restyling Odoo default `/my/account` or `/my/orders/<id>` page BODIES. We give them the sidebar via the layout inherit, but their content stays Odoo-standard.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Sidebar shell
|
||||
|
||||
```
|
||||
fusion_plating_portal/views/fp_portal_shell.xml
|
||||
└── inherits portal.portal_layout
|
||||
└── injects .o_fp_portal_shell wrapper that contains:
|
||||
├── <aside class="o_fp_portal_sidebar"> (sticky, 240px)
|
||||
│ └── partner header + 4 sections + sign-out
|
||||
└── <main class="o_fp_portal_main"> (existing portal body)
|
||||
```
|
||||
|
||||
Per Odoo's `portal.portal_layout` extension pattern, we inherit and use `<xpath expr="//div[@id='wrap']" position="replace">` (or `position="inside"` on the right anchor — TBD during implementation) to wrap the existing layout. The sidebar is a single shared template (`fp_portal_sidebar`) rendered above the existing portal page body.
|
||||
|
||||
Active-state marker: each sidebar `<a>` reads the current `page_name` from the template context (already set by every FP route — `fp_dashboard`, `fp_jobs`, etc.) and applies `o_fp_sidebar_active` when matched. Falls back to URL prefix match for Odoo default pages (`/my/orders` → Purchase Orders highlighted, `/my/account` → Profile highlighted).
|
||||
|
||||
### Sidebar items (final list)
|
||||
|
||||
```
|
||||
ACME AEROSPACE <-- partner.commercial_partner_id.name
|
||||
─────────────────────────────────────────
|
||||
🏠 Dashboard /my/home
|
||||
ACTIVITY
|
||||
📄 Quote Requests /my/quote_requests
|
||||
+ Get a Quote /my/configurator
|
||||
🛒 Purchase Orders /my/orders (Odoo)
|
||||
⚙️ Work Orders /my/jobs
|
||||
DOCUMENTS
|
||||
📑 Certifications /my/certifications
|
||||
📦 Packing Slips /my/deliveries
|
||||
💰 Account Summary /my/account_summary (NEW)
|
||||
ACCOUNT
|
||||
👤 Profile /my/account (Odoo)
|
||||
─────────────────────────────────────────
|
||||
↪ Sign Out /web/session/logout
|
||||
```
|
||||
|
||||
Section headers (`ACTIVITY` / `DOCUMENTS` / `ACCOUNT`) are display-only, not links. The whole list is rendered from a single Python data structure in the template context (passed by a small helper on `FpCustomerPortal`), so adding the future Users / Drafts / Search items is a one-line addition.
|
||||
|
||||
### Account Summary page
|
||||
|
||||
**URL**: `/my/account_summary`
|
||||
**Controller method**: `portal_account_summary(self, **kw)` on `FpCustomerPortal`
|
||||
**Template**: `portal_my_account_summary` in `fp_portal_account_summary.xml` (new file)
|
||||
|
||||
**Page structure:**
|
||||
|
||||
```
|
||||
[ Account Summary ] Open Balance: $4,820.00
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
[ Invoices ] [ Credit Memos ] [ Statements ]
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
Showing: Open · Closed · All [Search PO or #__________ ] [Sort ▾]
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
# | Status | Posted On | PO # | Due Date | Balance | View PDF
|
||||
─────────────────────────────────────────────────────────────────────────────
|
||||
0035180274 | ● Open | May 13, 2026 | 53469 | Jun 12, 2026 | C$305.73 | View PDF
|
||||
...
|
||||
◀ Prev 1 2 3 4 5 Next ▶
|
||||
```
|
||||
|
||||
**Data sources (per tab):**
|
||||
|
||||
| Tab | Model + domain | Notes |
|
||||
|---|---|---|
|
||||
| Invoices | `account.move` where `move_type='out_invoice'`, `partner_id child_of commercial`, `state='posted'` | Today's `/my/fp_invoices` already does this; relocated here. |
|
||||
| Credit Memos | `account.move` where `move_type='out_refund'`, `partner_id child_of commercial`, `state='posted'` | Surfaces RMA credits when sub-12 RMA flow runs. Tab shows empty state with "No credits yet" when partner has none. |
|
||||
| Statements | Generated PDF per month via `account.followup` or a custom QWeb cron — **decided during implementation; preferred = use account.followup report directly per-customer with date filter** | Tab UI: month picker + Download button. |
|
||||
|
||||
**Open Balance pill** = sum of `amount_residual` across all open `out_invoice` records (regardless of tab). Computed in the controller, shown in the page header.
|
||||
|
||||
**Search box** = case-insensitive substring match on `name` (invoice number) OR `ref` (customer PO). Server-side filter, not JS.
|
||||
|
||||
**Sort options:** Newest → Oldest (default), Oldest → Newest, Largest balance, Smallest balance.
|
||||
|
||||
**Filter pills:** `Open` (residual > 0) / `Closed` (residual = 0) / `All`.
|
||||
|
||||
**Pagination:** 10 per page, server-side via `portal_pager`.
|
||||
|
||||
Invoice detail = existing Odoo `/my/invoices/<id>` page (no rewrite); the table's "View PDF" link goes to `/my/invoices/<id>?report_type=pdf&download=true` per Odoo's standard portal pattern.
|
||||
|
||||
### Mobile behavior
|
||||
|
||||
```scss
|
||||
@media (max-width: 768px) {
|
||||
.o_fp_portal_sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
position: fixed; top: 0; left: 0; bottom: 0;
|
||||
z-index: 1040;
|
||||
}
|
||||
.o_fp_portal_sidebar.o_fp_open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.o_fp_portal_hamburger { display: inline-flex; }
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.o_fp_portal_hamburger { display: none; }
|
||||
}
|
||||
```
|
||||
|
||||
Hamburger button lives in the page header (above the main content). Click toggles `o_fp_open` on the sidebar via 5-line vanilla JS (no framework). Backdrop click closes the drawer.
|
||||
|
||||
## Files
|
||||
|
||||
**NEW:**
|
||||
|
||||
- `fusion_plating_portal/views/fp_portal_shell.xml` — `portal.portal_layout` inherit + sidebar markup
|
||||
- `fusion_plating_portal/views/fp_portal_account_summary.xml` — `portal_my_account_summary` template
|
||||
- `fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss` — sidebar styling (sticky, active state, sections, mobile drawer)
|
||||
- `fusion_plating_portal/static/src/js/fp_portal_sidebar.js` — hamburger toggle (vanilla JS, no OWL)
|
||||
|
||||
**MODIFY:**
|
||||
|
||||
- `fusion_plating_portal/controllers/portal.py`
|
||||
- NEW route `portal_account_summary` at `/my/account_summary`
|
||||
- DELETE route `portal_my_fp_invoices` (the thin invoice list at `/my/fp_invoices`)
|
||||
- REPLACE route `portal_my_purchase_orders` body with `return request.redirect('/my/orders')`
|
||||
- REPLACE the GET handler for `portal_my_quote_request_new` with `return request.redirect('/my/configurator/new')` (or delete entirely if the configurator already exposes the equivalent form)
|
||||
- NEW helper `_fp_sidebar_items(self)` returning the sidebar data structure (consumed by `fp_portal_sidebar` template via inherited `_prepare_portal_layout_values`)
|
||||
- Extend `_prepare_portal_layout_values()` to inject `fp_sidebar_items` + `fp_partner_display_name` into every portal page's context so the sidebar renders correctly on Odoo default pages too.
|
||||
- `fusion_plating_portal/views/fp_portal_templates.xml` — delete `portal_my_fp_invoices` template body (route is gone). Remaining templates (jobs list, jobs detail, deliveries, certifications) get the sidebar **for free** via the `portal.portal_layout` inherit; no per-template edits.
|
||||
- `fusion_plating_portal/views/fp_portal_dashboard.xml` — dashboard template gets the sidebar via the layout inherit; no edits needed.
|
||||
- `fusion_plating_portal/__manifest__.py` — version bump + register the new XML/SCSS/JS files. Add `fp_portal_shell.xml` near the TOP of the `data` list (loaded before any template that uses sidebar variables).
|
||||
|
||||
**DELETE (or stub):**
|
||||
|
||||
- The `portal_my_fp_invoices` template body and the `portal_my_purchase_orders` template body. Routes redirected, templates unused. Keep route stubs so existing bookmarks 302 cleanly instead of 404.
|
||||
|
||||
## Migration / backward compatibility
|
||||
|
||||
| Old URL | New behavior |
|
||||
|---|---|
|
||||
| `/my/fp_invoices` | 302 → `/my/account_summary` |
|
||||
| `/my/purchase_orders` | 302 → `/my/orders` |
|
||||
| `/my/quote_requests/new` | 302 → `/my/configurator/new` |
|
||||
|
||||
No DB migration. No template namespace changes that break inherits. The page audit removes routes from the controller and templates from the data list; Odoo's module-upgrade cycle handles the ORM-side cleanup.
|
||||
|
||||
## Open items to verify during implementation
|
||||
|
||||
1. **`portal.portal_layout` extension pattern** — confirm the cleanest xpath for injecting the sidebar wrapper without breaking Odoo's existing portal CSS (`#wrap`, `.o_portal`). Likely `position="before"` on the main content slot. If unclear, fall back to inheriting at the `website.layout` level and writing a wholly new shell template.
|
||||
2. **Statements tab data source** — decide between (a) inline render of `account.followup` report per requested month, vs (b) precomputed monthly statement PDFs stored as attachments. Latter is simpler for V1; cron generates last-month statement on the 1st.
|
||||
3. **Mobile hamburger placement** — header anchor: a small button at the top-left of the main content area (above the page title) on mobile only. Confirm during Phase 4 visual pass.
|
||||
4. **Page-name → active-item mapping** — most FP routes set a clean `page_name` (e.g., `fp_jobs`, `fp_dashboard`). Odoo defaults don't; we'll match by URL prefix (`/my/orders` → `purchase_orders` item). One-helper `_fp_resolve_active_sidebar_item(url, page_name)` keeps the mapping in one place.
|
||||
5. **Account Summary Statements scope** — confirm whether monthly statements are something EN Plating currently generates, or if this is a new artifact we need to define a template for. If the latter, that's a separate small spec.
|
||||
|
||||
## What ships in a "done" state
|
||||
|
||||
- Every `/my/*` page (FP + Odoo default) shows the new sidebar.
|
||||
- Active page is visually marked.
|
||||
- Sidebar collapses to hamburger drawer below 768px.
|
||||
- `/my/account_summary` exists with 3 tabs, Open Balance pill, search + filter pills + sort + pagination.
|
||||
- 3 legacy URLs (`/my/fp_invoices`, `/my/purchase_orders`, `/my/quote_requests/new`) 302-redirect to their new homes.
|
||||
- Unit tests cover the new account_summary controller (3 tabs return the right counts, filter/search produce the right subset, Open Balance sums residuals correctly).
|
||||
- Module version bumped, deployed to entech, all 5 existing portal tests still green plus 3+ new tests for Account Summary.
|
||||
|
||||
---
|
||||
|
||||
*Sub-projects B (multi-user) and C (portal search) are tracked separately — they'll consume the sidebar slot conventions (insertion under ACCOUNT for Users, above DASHBOARD for the search input) defined here.*
|
||||
@@ -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.
|
||||
@@ -0,0 +1,487 @@
|
||||
# Shop Floor Tablet Redesign — Design Spec
|
||||
|
||||
**Date:** 2026-05-22
|
||||
**Status:** Brainstorm complete, awaiting user review
|
||||
**Authors:** Garry Singh + Claude
|
||||
**Module owners:** `fusion_plating_shopfloor`, `fusion_plating_jobs`
|
||||
**Target client:** EN Technologies (Fusion Plating)
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
The current Shop Floor tablet view (client action `fp_shopfloor_tablet`, OWL component `ShopfloorTablet`) was built during the initial Fusion Plating implementation. Since then the underlying models — `fp.job`, `fp.job.step`, `fp.job.workflow.state`, `fp.certificate`, `fp.thickness.reading`, `fp.job.consumption`, `fp.job.node.override`, `fp.racking.inspection` and friends — have grown substantially. Many of those new fields, actions, and workflows are not surfaced on the tablet.
|
||||
|
||||
Symptoms observed on a live development instance:
|
||||
- Step name shows "Active: Blasting" with no WO/customer context
|
||||
- Qty rendered as "Qty 17/1" — ambiguous direction
|
||||
- Step position shown as "step 1.1/11" (sequence divided by 10)
|
||||
- Active timer reading **411:52:16** — a stale start that never finished and was never auto-paused
|
||||
- "SIGN-OFF REQUIRED" chip is informational only; no actual sign-off control
|
||||
- Customer spec, drawings, recipe overrides, milestone progress, holds, and most lifecycle actions are invisible
|
||||
|
||||
Three roles operate the system: **Owner**, **Manager**, **Technician**. Technicians wear multiple hats (receiving, plating, QC, shipping) — the client explicitly wants minimal gating between roles.
|
||||
|
||||
## 2. Goals
|
||||
|
||||
- A technician can manage a WO end-to-end from a single full-screen workspace, without typing into search bars or jumping to the back-office.
|
||||
- A manager can see at a glance: where every WO is in the workflow, what needs their decision right now, what's trending late, and where the bottlenecks are.
|
||||
- All recent additions to `fp.job` / `fp.job.step` (workflow milestones, blocker reasons, recipe overrides, customer spec, etc.) are surfaced on the tablet and manager dashboard.
|
||||
- Terminology matches how techs talk on the shop floor — "WO # 00001" not "WH/JOB/00001".
|
||||
- The system never displays a 411-hour ghost timer.
|
||||
|
||||
## 3. Non-goals (v1)
|
||||
|
||||
- Multi-tablet pairing per technician
|
||||
- Offline-first / PWA mode
|
||||
- Voice input
|
||||
- Performance optimization beyond ~500 active jobs (current scale: ~50)
|
||||
- Webhooks to external dashboards
|
||||
- Cost roll-up per job, cycle time per recipe, per-tech throughput (P2 — deferred to v2)
|
||||
- System-wide rename of `fp.job` sequence (`WH/JOB/...` → `WO ...`) — display-only on tablet for now; back-office/reports/emails keep current sequence until a separate decision is made
|
||||
|
||||
## 4. Terminology decisions
|
||||
|
||||
All approved in brainstorm:
|
||||
|
||||
| Element | Was | Is now |
|
||||
|---|---|---|
|
||||
| Page title | "Tablet Station" | **Shop Floor** (with station chip e.g. "@ EN Plating Tank") |
|
||||
| Document number | `WH/JOB/00001` | **`WO # 00001`** (display only) |
|
||||
| Active step header | "Active: Blasting" | **"WO # 00001 — Blasting"** (Step 1 of 11) |
|
||||
| Qty | "Qty 17/1" | **"1 / 17 done"** + scrap subtext + mini progress bar |
|
||||
| Step position | "step 1.1/11" | **"Step 1 of 11"** |
|
||||
| Sign-off chip | "SIGN-OFF REQUIRED" | **"Finish & Sign Off"** action button (replaces plain Finish) |
|
||||
| Queue heading | "My Queue" | **"Up Next"** (at this station) |
|
||||
| Bath state | "OPERATIONAL / LOG: OUT_OF_SPEC" | **"Operating"** / **"Last log out of spec"** |
|
||||
| Bake panel | "Bake Windows" | **"Embrittlement Bakes"** |
|
||||
| Gate panel | "First-Piece Gates" | **"First-Piece Inspections"** |
|
||||
| Tile set | 6 mixed tiles | **4 tech-relevant tiles**: Ready · Running · Bakes Due · Holds (others move to manager dashboard) |
|
||||
| Stale timer | "411:52:16" | Auto-pause at 8h (configurable) with chatter audit; display switches to "Started Nd ago" past 24h |
|
||||
|
||||
## 5. Architecture — option B (specialized components + shared services)
|
||||
|
||||
Three OWL client actions, five shared OWL services, a small set of backend additions. Each client action is independently deployable.
|
||||
|
||||
```
|
||||
┌────────────────────────────────┐ ┌────────────────────────────────┐ ┌────────────────────────────────┐
|
||||
│ fp_shopfloor_landing │ │ fp_job_workspace │ │ fp_manager_dashboard │
|
||||
│ (replaces fp_shopfloor_tablet │ │ NEW — full-screen WO surface │ │ refactored — 4 tabs │
|
||||
│ + folds in fp_plant_overview)│ │ │ │ │
|
||||
│ • station-scoped kanban │ │ • sticky header + WO chips │ │ • Workflow Funnel (default) │
|
||||
│ • All-Plant toggle │ │ • workflow milestone bar │ │ • Approval Inbox │
|
||||
│ • QR scan, station picker │ │ • step list + side panel │ │ • Plant Board (existing) │
|
||||
│ • tap card → JobWorkspace │ │ • sticky action rail │ │ • At-Risk │
|
||||
└────────────────────────────────┘ └────────────────────────────────┘ └────────────────────────────────┘
|
||||
│ │ │
|
||||
└──────────────────────────────────┴──────────────────────────────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ Shared OWL services │
|
||||
│ WorkflowChip · GateViz │
|
||||
│ SignaturePad · KanbanCard │
|
||||
│ HoldComposer │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.1 Shared OWL services
|
||||
|
||||
| Service | Used by | Props | Depends on |
|
||||
|---|---|---|---|
|
||||
| **WorkflowChip** | Landing card · Workspace header · Manager funnel | `{ state: {id, name, color}, nextActionLabel? }` | `fp.job.workflow.state` records (already shipped). Reads `color` field. |
|
||||
| **GateViz** | Workspace step rows · Manager "Needs Worker" cards | `{ canStart, blockerKind, blockerReason, jumpTarget? }` | New `fp.job.step.blocker_kind` + `blocker_reason` computes |
|
||||
| **SignaturePad** | Workspace (Finish & Sign Off) · Cert issue | `{ title, contextLabel, onSubmit(dataUri), onCancel }` | Odoo `dialog` service; HTML canvas + pointer events |
|
||||
| **HoldComposer** | Workspace (Hold button) · Manager Approval Inbox | `{ jobId, stepId?, defaultQty, partRef, onCreated(hold) }` | New endpoint `/fp/workspace/hold` (with photo attachment) |
|
||||
| **KanbanCard** | Landing (station + all-plant) · Manager (Plant Board + Workflow Funnel) | `{ data, density: 'compact'\|'normal', showWorkflowChip, showWorkcenter, showAssignedTo, onTap }` | Embeds `WorkflowChip` + `GateViz` badge |
|
||||
|
||||
Each service is its own file under `fusion_plating_shopfloor/static/src/js/components/`. Roughly 80–200 lines OWL + 30–80 lines SCSS per service.
|
||||
|
||||
### 5.2 Landing component (`fp_shopfloor_landing`)
|
||||
|
||||
Replaces today's `fp_shopfloor_tablet`, folds in `fp_plant_overview`. Single entry surface for technicians.
|
||||
|
||||
**Layout regions** (top-to-bottom):
|
||||
|
||||
1. **Header strip** — "Shop Floor" title, station chip, station picker, mode toggle (`Station` ⟷ `All Plant`), QR scan controls (Code + Camera), refresh indicator.
|
||||
2. **KPI tile row (4 tiles)** — Ready · Running · Bakes Due · Holds. Holds turns red when > 0.
|
||||
3. **Kanban board** — columns = work centres; cards = `KanbanCard` (one per WO at that work centre); urgency-sorted within column (existing logic in `plant_overview.py` carries over). Drag-and-drop between columns keeps current behaviour.
|
||||
4. **Optional left filter rail** (collapsed by default) — search box, priority, customer, due-by, blocker filter. Promote the existing plant_overview search bar.
|
||||
5. **Footer** — auto-refresh indicator + "Last sync HH:MM:SS".
|
||||
|
||||
**Mode behaviour:**
|
||||
|
||||
| Mode | Columns shown | Default when |
|
||||
|---|---|---|
|
||||
| **Station** | Paired work centre + Unassigned + next 1–2 work centres in the recipe flow | A station is paired (via QR scan or picker) |
|
||||
| **All Plant** | Every active work centre, recipe-flow order | No station paired, OR user toggles |
|
||||
|
||||
Toggle persists in `localStorage` per tablet (same pattern as `fp_tablet_station_id`).
|
||||
|
||||
**Card tap behaviour:**
|
||||
|
||||
```js
|
||||
action.doAction({
|
||||
type: 'ir.actions.client',
|
||||
tag: 'fp_job_workspace',
|
||||
params: { job_id: card.job_id, focus_step_id: card.current_step_id }
|
||||
});
|
||||
```
|
||||
|
||||
Browser back returns to Landing with kanban scroll/mode preserved.
|
||||
|
||||
**QR scan dispatch** (existing `/fp/shopfloor/scan` endpoint, unchanged):
|
||||
|
||||
| Scanned | Behaviour |
|
||||
|---|---|
|
||||
| `FP-STATION:<code>` | Pair tablet, switch to Station mode |
|
||||
| `FP-JOB:<name>` | Open JobWorkspace for that WO |
|
||||
| `FP-STEP:<id>` | Open JobWorkspace, focus that step |
|
||||
| `FP-TANK:<code>` / `FP-BATH:<name>` | Chemistry quick-log dialog (existing endpoint) |
|
||||
| `FP-OVEN:<code>` | Jump to next bake awaiting that oven |
|
||||
|
||||
**Auto-refresh** — every 15s.
|
||||
|
||||
**Files:**
|
||||
|
||||
```
|
||||
fusion_plating_shopfloor/
|
||||
controllers/landing_controller.py ← NEW (~250 lines)
|
||||
static/src/js/shopfloor_landing.js ← OWL (~600 lines)
|
||||
static/src/xml/shopfloor_landing.xml
|
||||
static/src/scss/shopfloor_landing.scss
|
||||
```
|
||||
|
||||
### 5.3 Job Workspace component (`fp_job_workspace`)
|
||||
|
||||
The heart of the redesign. Full-screen surface a tech opens by tapping a kanban card.
|
||||
|
||||
**Layout regions** (sticky top → scrollable middle → sticky bottom):
|
||||
|
||||
| Region | Sticky | Data | Behaviour |
|
||||
|---|---|---|---|
|
||||
| **Back** | top | — | `doAction` back to Landing, preserves kanban scroll/mode |
|
||||
| **WO header** | top | `display_wo_name`, `partner_id`, `part_catalog_id` + rev, qty/qty_done/qty_scrapped, `date_deadline`, `workflow_state_id`, `quality_hold_count`, `customer_spec_id` | `+1 Done` / `−1 Done` / `+1 Scrap` quick bumps inline. Holds count → opens Holds drawer. |
|
||||
| **Workflow milestone bar** | top | All `fp.job.workflow.state` records ordered by sequence; current = `workflow_state_id`; `next_milestone_action` + `next_milestone_label` | Dots: passed (●), current (filled), pending (○). "Next" button on right fires `/fp/workspace/advance_milestone`. Disabled until preconditions met. |
|
||||
| **Step list** (left/center, scrolls) | scrolls | `fp.job.step_ids` sorted by `sequence` | Each row uses step-row template (see below). Active step auto-scrolled and auto-expanded if `focus_step_id` param set. |
|
||||
| **Side panel** (collapsible right) | scrolls | `customer_spec_id` (PDF), attachments, chatter | Three sub-cards: **Spec** (inline via `fusion_pdf_preview`), **Drawings**, **Notes** (chatter — read + quick-add). Collapses to icon strip on narrow screens. |
|
||||
| **Action rail** | bottom | — | Always: Create Hold (`HoldComposer`), Add Note, Photo. Conditional: Issue Cert (when `_fp_has_draft_required_certs()`), Mark Done / Schedule Delivery / Mark Shipped (per `next_milestone_action`). |
|
||||
|
||||
**Step row anatomy:**
|
||||
|
||||
- **Collapsed** (default for done/pending/paused): one line — icon + Step N · Name + assigned tech + duration + state badge
|
||||
- **Expanded active** (auto for `state == 'in_progress'`): recipe chips + instructions + primary + secondary actions
|
||||
- **Expanded by tap** (any step): same shape, action buttons gated by `can_start`
|
||||
|
||||
**Per-step actions:**
|
||||
|
||||
| Button | Visible when | Calls |
|
||||
|---|---|---|
|
||||
| Start | `state in ('ready','paused')` AND `can_start` | `/fp/shopfloor/start_wo` |
|
||||
| Finish (or Finish & Sign Off) | `state == 'in_progress'` | `/fp/shopfloor/stop_wo` (finish=true) OR `/fp/workspace/sign_off` if `requires_signoff` |
|
||||
| Pause | `state == 'in_progress'` | `step.button_pause()` |
|
||||
| Skip | `state in ('ready','paused')` AND user is supervisor+ | `step.button_skip()` |
|
||||
| Move Parts | always | `FpMovePartsDialog` (existing) |
|
||||
| Move Rack | when `kind == 'rack'` | `FpMoveRackDialog` (existing) |
|
||||
| Quick QC | when `quick_look_prompt_ids` non-empty | `step.action_open_quick_look()` |
|
||||
| Operator Inputs | when step has unrecorded inputs | `step.action_open_input_wizard()` (existing wizard) |
|
||||
| Photo | always | inline camera → attach to step |
|
||||
| Stop Timer (correction) | when duration looks wrong | `FpStopTimerDialog` (existing) |
|
||||
| Open in backend | always (small icon) | `doAction` to fp.job.step form (escape hatch) |
|
||||
|
||||
When step is **blocked** (`can_start == False`), action button row is replaced by `GateViz` block.
|
||||
|
||||
When step is **opted out** (`override_ids` says excluded), row shows ✕ icon + "Skipped per recipe override" + supervisor-only "Re-include" button.
|
||||
|
||||
**Auto-pause integration** — if `_cron_autopause_stale_steps` flips a step to paused, the row's chatter reflects "Auto-paused after Nh idle". Tech can tap Resume.
|
||||
|
||||
**Auto-refresh** — every 15s.
|
||||
|
||||
**Files:**
|
||||
|
||||
```
|
||||
fusion_plating_shopfloor/
|
||||
controllers/workspace_controller.py ← NEW (~400 lines)
|
||||
static/src/js/job_workspace.js ← OWL (~800 lines)
|
||||
static/src/xml/job_workspace.xml
|
||||
static/src/scss/job_workspace.scss
|
||||
static/src/js/components/{workflow_chip,gate_viz,signature_pad,hold_composer,kanban_card}.js
|
||||
```
|
||||
|
||||
### 5.4 Manager Dashboard refactor (`fp_manager_dashboard`)
|
||||
|
||||
Same client action, **four sibling tabs** under a shared header + KPI strip.
|
||||
|
||||
**KPI strip (extended):** keep existing 4 always-on (Unassigned Steps · In Progress · Ready to Ship · Awaiting Assignment) + existing conditional reds (Missed Bakes · Open Holds · Stale Steps · Predecessor Locked) + **2 new: Pending Cert · At-Risk**.
|
||||
|
||||
**Tabs:**
|
||||
|
||||
| Tab | Default? | Content |
|
||||
|---|---|---|
|
||||
| **Workflow Funnel** | yes | Vertical stack of `fp.job.workflow.state` records. Each row shows stage chip + count badge + first ~5 `KanbanCard`s + "+ N more" drawer. Bar chart bar behind the row scaled to count. Tap card → JobWorkspace. |
|
||||
| **Approval Inbox** | no | 4 grouped strips: **Holds to Release** (`state in ('on_hold','under_review')`), **Certs to Issue** (`all_steps_terminal` + draft required cert), **Scrap to Review** (recent `qty_scrapped` bumps with operator reason), **Override Requests** (deferred — placeholder). Per-row inline action buttons + bulk-action ("Release all"). |
|
||||
| **Plant Board** | no | Today's existing 3-column "Needs Worker / In Progress / Team" view — unchanged behaviour. Becomes one tab among four. |
|
||||
| **At-Risk** | no | 3 sub-panels: **Trending Late** (sorted by `late_risk_ratio` desc, top 20), **Hold Reasons** (open holds grouped by `hold_reason`), **Bottleneck Heatmap** (work centres ranked by `bottleneck_score`). |
|
||||
|
||||
**Cross-tab features:** live 8s refresh (existing cadence), QR scan in header, "Take Over Tablet" supervisor handover.
|
||||
|
||||
**Permissions:** dashboard already gated to `group_fusion_plating_supervisor`+. That stays. Owner + Manager hit this; Technicians don't.
|
||||
|
||||
**Files:**
|
||||
|
||||
```
|
||||
fusion_plating_shopfloor/
|
||||
controllers/manager_controller.py ← add 3 endpoints (funnel, approval_inbox, at_risk)
|
||||
static/src/js/manager_dashboard.js ← refactor: extract Plant Board, add 3 sibling tabs
|
||||
static/src/xml/manager_dashboard.xml
|
||||
static/src/scss/manager_dashboard.scss
|
||||
```
|
||||
|
||||
## 6. Backend support
|
||||
|
||||
### 6.1 HTTP endpoints
|
||||
|
||||
All `type='jsonrpc'`, `auth='user'`.
|
||||
|
||||
**NEW** — added by this work:
|
||||
|
||||
| Endpoint | Lives in | Purpose |
|
||||
|---|---|---|
|
||||
| `POST /fp/landing/kanban` | `landing_controller.py` | Station OR all-plant kanban data |
|
||||
| `POST /fp/workspace/load` | `workspace_controller.py` | Full Job Workspace payload |
|
||||
| `POST /fp/workspace/hold` | `workspace_controller.py` | HoldComposer create (with photo) |
|
||||
| `POST /fp/workspace/sign_off` | `workspace_controller.py` | Signature + finish step atomically |
|
||||
| `POST /fp/workspace/advance_milestone` | `workspace_controller.py` | Fire `next_milestone_action` |
|
||||
| `POST /fp/manager/funnel` | `manager_controller.py` (add) | Workflow funnel data |
|
||||
| `POST /fp/manager/approval_inbox` | `manager_controller.py` (add) | Holds + draft certs + scrap to review |
|
||||
| `POST /fp/manager/at_risk` | `manager_controller.py` (add) | Late-risk + hold reasons + bottlenecks |
|
||||
|
||||
**KEPT** — unchanged, used by new components via wrappers: `/fp/shopfloor/scan`, `start_wo`, `stop_wo`, `start_bake`, `end_bake`, `log_chemistry`, `log_thickness_reading`, `bump_qty_done`, `bump_qty_scrapped`, `mark_gate`, `pair_station`.
|
||||
|
||||
**DEPRECATED** — kept as stubs for 1 release, then removed:
|
||||
- `/fp/shopfloor/tablet_overview` → calls `/fp/landing/kanban` internally
|
||||
- `/fp/shopfloor/plant_overview` → calls `/fp/landing/kanban?mode=all_plant`
|
||||
- `/fp/shopfloor/queue` → removed (no replacement)
|
||||
|
||||
### 6.2 Model fields / computes
|
||||
|
||||
On `fp.job` (`fusion_plating_jobs/models/fp_job.py`):
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `display_wo_name` | computed Char | "WO # 00001" formatter from `name` |
|
||||
| `late_risk_ratio` | computed Float, stored | `remaining_planned_minutes / minutes_to_deadline` |
|
||||
| `active_step_id` | computed Many2one→fp.job.step | Current `in_progress` step (Workspace landing focus) |
|
||||
|
||||
On `fp.job.step` (`fusion_plating_jobs/models/fp_job_step.py`):
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `blocker_kind` | computed Selection | `predecessor` · `contract_review` · `parts_not_received` · `racking_required` · `manager_input` · `none` |
|
||||
| `blocker_reason` | computed Char | Human reason (e.g. "Waiting on Step 3: Activation") |
|
||||
| `blocker_jump_target_model` | computed Char | Optional tap-to-jump target model |
|
||||
| `blocker_jump_target_id` | computed Integer | Optional tap-to-jump target id |
|
||||
|
||||
On `fp.work.centre` (`fusion_plating/models/fp_work_centre.py`):
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `bottleneck_score` | computed Float, non-stored | `active_step_count × avg_wait_minutes` |
|
||||
| `avg_wait_minutes` | computed Float, non-stored | Rolling 7-day avg ready→start wait |
|
||||
|
||||
On `fusion.plating.process.node` (recipe node):
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `long_running` | Boolean | Opt out of auto-pause (24h bakes etc.) |
|
||||
|
||||
### 6.3 Auto-pause cron
|
||||
|
||||
```xml
|
||||
<!-- fusion_plating_jobs/data/fp_cron_data.xml -->
|
||||
<record id="ir_cron_autopause_stale_steps" model="ir.cron">
|
||||
<field name="name">FP Jobs: auto-pause stale in-progress steps</field>
|
||||
<field name="model_id" ref="model_fp_job_step"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_autopause_stale_steps()</field>
|
||||
<field name="interval_number">30</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
```
|
||||
|
||||
Method (`fp_job_step.py`):
|
||||
|
||||
```python
|
||||
@api.model
|
||||
def _cron_autopause_stale_steps(self):
|
||||
threshold = float(self.env['ir.config_parameter'].sudo()
|
||||
.get_param('fp.shopfloor.autopause_threshold_hours', 8))
|
||||
deadline = fields.Datetime.now() - timedelta(hours=threshold)
|
||||
stale = self.search([
|
||||
('state', '=', 'in_progress'),
|
||||
('date_started', '<', deadline),
|
||||
('recipe_node_id.long_running', '=', False),
|
||||
])
|
||||
for step in stale:
|
||||
step.button_pause()
|
||||
step.message_post(body=Markup(
|
||||
"<b>Auto-paused</b> after %.1fh idle. "
|
||||
"Resume from the tablet when work continues."
|
||||
) % threshold)
|
||||
_logger.info("Auto-paused step %s after %.1fh idle", step.id, threshold)
|
||||
```
|
||||
|
||||
`ir.config_parameter` key: `fp.shopfloor.autopause_threshold_hours` (default `8`).
|
||||
|
||||
### 6.4 ACL changes (operator group)
|
||||
|
||||
Per "techs wear multiple hats" rule — minimal new gates.
|
||||
|
||||
| Model | Read | Write | Create | Unlink | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `fp.certificate` | ✓ existing | **NEW ✓** | — | — | Flip draft → issued from tablet "Issue Cert" |
|
||||
| `fp.thickness.reading` | **NEW ✓** | **NEW ✓** | **NEW ✓** | — | Capture Fischerscope readings from tablet |
|
||||
| `fp.job.node.override` | **NEW ✓** | — | — | — | Read-only — tech sees opt-out badge |
|
||||
|
||||
Supervisor-only operations enforced in `workspace_controller.py` (not via ACL):
|
||||
- Step Skip (`button_skip`)
|
||||
- Hold Release (state transition `on_hold` → `released`)
|
||||
- Override Re-include
|
||||
|
||||
### 6.5 Terminology — `display_wo_name`
|
||||
|
||||
`fp.job.display_wo_name` is a computed Char that formats `name` as `WO # 00001`. All new tablet/dashboard payloads use this field. The underlying `fp.job.name` (`WH/JOB/00001`) stays unchanged — reports, emails, back-office forms continue using `name`.
|
||||
|
||||
System-wide sequence rename is **out of scope** for this work. If pursued separately, it requires: (a) updating `ir.sequence` prefix to `WO `, (b) backfill script for existing records, (c) coordination with any external integrations that grep on the old prefix.
|
||||
|
||||
## 7. Build & deploy sequence
|
||||
|
||||
Each phase is independently deployable. Rollback is per-phase, not all-or-nothing.
|
||||
|
||||
| Phase | Ships | Independently deployable? |
|
||||
|---|---|---|
|
||||
| **1** | Shared OWL services + JobWorkspace + workspace_controller + fp.job.step blocker_* computes + display_wo_name. Opens from existing `fp.job` form smart button. | Yes — works before Landing refactor |
|
||||
| **2** | Auto-pause cron + ACL lift + `late_risk_ratio` + `active_step_id` computes | Yes — silent infra |
|
||||
| **3** | landing_controller + `fp_shopfloor_landing` component. Old `fp_shopfloor_tablet` menu redirected. PlantOverview menu hidden. | Yes — Workspace already works via smart button |
|
||||
| **4** | 3 new manager endpoints + manager dashboard refactor (4 tabs) + `bottleneck_score` compute | Yes |
|
||||
| **5** | Cleanup: remove deprecated endpoint stubs, retire fp_plant_overview module dir | Last |
|
||||
|
||||
## 8. Testing strategy
|
||||
|
||||
### 8.1 Python tests (`fusion_plating_shopfloor/tests/`)
|
||||
|
||||
| Test | Verifies |
|
||||
|---|---|
|
||||
| `test_display_wo_name` | Formatter handles various `name` shapes |
|
||||
| `test_late_risk_ratio` | Correct ratio with deadline / no deadline / overdue / not started |
|
||||
| `test_active_step_id` | Sole in_progress step; empty when none; first-by-sequence when multiple |
|
||||
| `test_blocker_kind_and_reason` | Each kind returns correct enum + human string + jump target |
|
||||
| `test_autopause_cron` | Stale flips; chatter posted; respects `long_running`; idempotent |
|
||||
| `test_workspace_load_payload` | Full payload shape — keys, types, opted-out marked |
|
||||
| `test_workspace_sign_off` | Signature captured, step finished, empty-sig rejected |
|
||||
| `test_workspace_advance_milestone` | Fires only when preconditions met; friendly error otherwise |
|
||||
| `test_hold_composer_create` | Hold + photo + qty split; rollback on validation error |
|
||||
| `test_acl_operator_permissions` | Operator can issue cert, cannot skip step (controller gate) |
|
||||
| `test_funnel_and_inbox` | Funnel grouping correct; inbox returns all 4 buckets |
|
||||
|
||||
### 8.2 OWL tests (light)
|
||||
|
||||
- `WorkflowChip` renders correct color per state
|
||||
- `GateViz` renders correct copy per blocker_kind
|
||||
- `SignaturePad` returns non-empty data URI after stroke
|
||||
- `HoldComposer` validates qty ≤ remaining before submit
|
||||
- `KanbanCard` collapses chips at compact density
|
||||
|
||||
### 8.3 Manual QA checklist
|
||||
|
||||
Lives at `docs/qa/shopfloor-redesign-qa.md`. 10-step walkthrough covering: pairing → Landing modes → tap card → Workspace → Finish & Sign Off → Create Hold → Manager Funnel → Approval Inbox release → auto-pause test → dark mode.
|
||||
|
||||
## 9. Observability
|
||||
|
||||
- `_logger.INFO` on milestone advance, hold create from tablet, sign-off, auto-pause
|
||||
- `_logger.WARNING` on workspace_load with bad job_id, sign_off with empty data URI
|
||||
- `_logger.EXCEPTION` on controller failures (existing pattern)
|
||||
- Chatter audit on auto-pause, hold create, milestone advance, sign-off
|
||||
|
||||
Future metrics (flagged, no infra now): tablet refresh frequency, time-to-Start from Workspace open, auto-pause rate, hold creation rate.
|
||||
|
||||
## 10. Edge cases
|
||||
|
||||
| Case | Handling |
|
||||
|---|---|
|
||||
| Job has zero steps | "Recipe not generated" placeholder + back-office link |
|
||||
| Job has 50+ steps | Standard scroll, no virtualization in v1 |
|
||||
| All steps in_progress (defensive) | `active_step_id` picks first by sequence; logs warning |
|
||||
| No workflow states defined | Bar hides; Next button disabled |
|
||||
| Step state changed during 15s gap | Refresh corrects; no error toast |
|
||||
| Two techs tap Start simultaneously | `button_start` idempotent; second call returns state |
|
||||
| Wrong station scanned | Header "Unpair" link; localStorage cleared |
|
||||
| Network drop mid-action | Toast "Saving failed — tap to retry"; UI state preserved |
|
||||
| 24h-bake step | `recipe_node.long_running=True` skips auto-pause |
|
||||
| Customer spec PDF missing | Side panel: "No customer spec attached" |
|
||||
| Funnel with 200+ jobs in stage | Top 5 cards + "View all (N)" drawer |
|
||||
| Operator with no facility | Landing prompts pick-or-scan station |
|
||||
| HoldComposer fails after photo upload | Photo cleaned in `except` block — no orphans |
|
||||
| Manual step (no recipe_node_id) | Chips/instructions empty — OK |
|
||||
| Sign-off step finished from back-office | Workspace re-renders without re-prompting |
|
||||
| Tech opens Workspace for unassigned job | Allowed — read+write per "many hats" rule |
|
||||
|
||||
## 11. Performance
|
||||
|
||||
| Surface | Load shape | Mitigation |
|
||||
|---|---|---|
|
||||
| Landing kanban (All Plant, ~400 cards) | Existing `plant_overview.py` batch prefetch carries over | None new |
|
||||
| Workspace load (1 job × ~11 steps) | Trivial | None |
|
||||
| Manager funnel (~50 jobs × 9 stages) | Trivial | None |
|
||||
| Manager At-Risk (7-day step state scan) | Potentially heavy on large plants | Cache 60s per facility |
|
||||
| Auto-pause cron | Filtered query, no N+1 | None |
|
||||
| `late_risk_ratio` (stored) | Recomputed on step state change | `@api.depends` triggers |
|
||||
|
||||
## 12. Rollback strategy
|
||||
|
||||
| Phase | Rollback |
|
||||
|---|---|
|
||||
| 1 (Workspace) | Hide smart-button entry in fp.job form. Workspace becomes orphan, harmless. |
|
||||
| 2 (Cron + ACL) | Disable cron via UI. ACL changes are CSV-line edits. |
|
||||
| 3 (Landing) | Re-enable old `fp_shopfloor_tablet` menu. Old endpoint stub still active. |
|
||||
| 4 (Manager) | Revert manager_dashboard.xml to single Plant Board tab. |
|
||||
| 5 (Cleanup) | Defer if issues — leave stubs longer. |
|
||||
|
||||
Only stored field added is `late_risk_ratio` Float — additive `ALTER TABLE`, safe to drop.
|
||||
|
||||
## 13. Backwards compatibility
|
||||
|
||||
- All existing QR codes keep working — `/fp/shopfloor/scan` unchanged
|
||||
- Existing `fp.job` form smart buttons → Workspace opens via doAction
|
||||
- Existing `fp.job.step` form view stays as "Open in backend" escape hatch
|
||||
- Old reports/emails keep showing `WH/JOB/00001` (sequence rename deferred)
|
||||
|
||||
## 14. Decisions log
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| Hybrid mental model (queue → workspace), not pure queue or pure job-first | Queue is the natural entry; full workspace solves "manage the whole job" goal |
|
||||
| One tablet, no role-segmentation per persona | Client said techs wear multiple hats; minimal gating |
|
||||
| Architecture B (specialized components + shared services) over mega-component | Each surface has its own lifecycle; shared services enforce consistency |
|
||||
| Station-scoped kanban as default landing, All Plant as toggle | Matches physical reality (tablet at station) + provides escape hatch |
|
||||
| Approval Inbox + Workflow Funnel as new manager tabs, Plant Board stays | Tactical (assignment) and strategic (where is everything) views coexist |
|
||||
| Auto-pause stale timers at 8h default | Solves the 411-hour ghost timer permanently; protects cost/cycle-time math |
|
||||
| WO # display only; sequence rename deferred | Lower risk; user can choose system-wide rename separately |
|
||||
| ACL lift for operator group on cert / thickness reading / override read | Per "techs wear many hats" rule; supervisor-only ops enforced in controller, not ACL |
|
||||
|
||||
## 15. Out of scope for v1
|
||||
|
||||
- Multi-tablet pairing per tech
|
||||
- Offline-first / PWA mode
|
||||
- Voice input
|
||||
- Performance optimisation beyond ~500 active jobs
|
||||
- Webhooks to external dashboards
|
||||
- Cost roll-up per job (P2)
|
||||
- Cycle time per recipe (P2)
|
||||
- Notification audit feed (P2)
|
||||
- Per-tech throughput (P2)
|
||||
- System-wide `fp.job` sequence rename to `WO ` prefix
|
||||
|
||||
---
|
||||
|
||||
**Next step:** user reviews this spec; once approved, transition to `superpowers:writing-plans` skill to produce the phased implementation plan.
|
||||
@@ -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.8.0',
|
||||
'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
|
||||
|
||||
@@ -263,6 +263,16 @@ class FpProcessNode(models.Model):
|
||||
'progress (e.g. paperwork or QA review that runs alongside '
|
||||
'production).',
|
||||
)
|
||||
long_running = fields.Boolean(
|
||||
string='Long-running step',
|
||||
default=False,
|
||||
help='When True, steps generated from this recipe node are exempt '
|
||||
'from the shop-floor auto-pause cron. Use for 24h bakes, '
|
||||
'multi-shift soaks, and similar legitimately-long operations '
|
||||
'that would otherwise be auto-paused after the idle threshold '
|
||||
'(ir.config_parameter fp.shopfloor.autopause_threshold_hours, '
|
||||
'default 8h). See plan 2026-05-22-shopfloor-tablet-redesign.',
|
||||
)
|
||||
opt_in_out = fields.Selection(
|
||||
[
|
||||
('disabled', 'Required'),
|
||||
@@ -493,9 +503,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
|
||||
|
||||
@@ -65,6 +65,51 @@ class FpWorkCentre(models.Model):
|
||||
# field via _inherit if/when the bake-oven coupling is needed.
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# Phase 4 tablet redesign — Manager At-Risk heatmap inputs.
|
||||
# Non-stored (recomputed on every read by /fp/manager/at_risk; the
|
||||
# endpoint caches the payload for 60s anyway so the cost is bounded).
|
||||
bottleneck_score = fields.Float(
|
||||
compute='_compute_bottleneck',
|
||||
string='Bottleneck Score',
|
||||
help='active_step_count * avg_wait_minutes (rolling 7-day). '
|
||||
'Drives the Manager At-Risk heatmap — work centres with '
|
||||
'high score have queue + wait pressure.',
|
||||
)
|
||||
avg_wait_minutes = fields.Float(
|
||||
compute='_compute_bottleneck',
|
||||
string='Avg Wait (min)',
|
||||
help='Average minutes that steps at this work centre waited in '
|
||||
'ready state before starting, over the last 7 days.',
|
||||
)
|
||||
|
||||
def _compute_bottleneck(self):
|
||||
from datetime import timedelta
|
||||
Step = self.env['fp.job.step']
|
||||
now = fields.Datetime.now()
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
for wc in self:
|
||||
active_n = Step.search_count([
|
||||
('work_centre_id', '=', wc.id),
|
||||
('state', 'in', ('ready', 'in_progress')),
|
||||
])
|
||||
# Avg wait: recent steps where date_started is set; approximate
|
||||
# "ready since" as create_date when no explicit ready timestamp
|
||||
# is recorded. Bounded set (last 7 days) keeps the search cheap.
|
||||
recent = Step.search([
|
||||
('work_centre_id', '=', wc.id),
|
||||
('date_started', '>=', seven_days_ago),
|
||||
('date_started', '!=', False),
|
||||
])
|
||||
waits = []
|
||||
for s in recent:
|
||||
if s.create_date and s.date_started:
|
||||
waits.append(
|
||||
(s.date_started - s.create_date).total_seconds() / 60.0
|
||||
)
|
||||
avg = (sum(waits) / len(waits)) if waits else 0.0
|
||||
wc.avg_wait_minutes = avg
|
||||
wc.bottleneck_score = active_n * avg
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_code', 'UNIQUE(code)', 'Work centre code must be unique.'),
|
||||
]
|
||||
|
||||
@@ -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')
|
||||
@@ -96,6 +96,11 @@
|
||||
<field name="parallel_start"
|
||||
invisible="node_type not in ('operation', 'step')"
|
||||
help="When the parent recipe is Sequential, ticking this lets the step start while earlier-sequence steps are still in progress."/>
|
||||
<!-- Phase 2 tablet redesign — opt out of the
|
||||
auto-pause cron for legitimately-long steps
|
||||
(24h bakes, multi-shift soaks). -->
|
||||
<field name="long_running"
|
||||
invisible="node_type not in ('operation', 'step')"/>
|
||||
<field name="requires_predecessor_done"
|
||||
invisible="node_type not in ('operation', 'step')"
|
||||
groups="fusion_plating.group_fusion_plating_supervisor"
|
||||
|
||||
@@ -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.9.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.',
|
||||
)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,0,0,0
|
||||
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,1,0,0
|
||||
access_fp_certificate_supervisor,fp.certificate.supervisor,model_fp_certificate,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
|
||||
access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
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_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,1,1,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.7.2',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
@@ -56,10 +56,12 @@ Provides:
|
||||
'wizard/fp_part_catalog_import_wizard_views.xml',
|
||||
'wizard/fp_serial_bulk_add_wizard_views.xml',
|
||||
'views/fp_configurator_menu.xml',
|
||||
'views/fp_so_job_sort_views.xml',
|
||||
'data/fp_sale_description_template_data.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss',
|
||||
'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
|
||||
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
|
||||
@@ -72,6 +74,13 @@ Provides:
|
||||
'fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml',
|
||||
'fusion_plating_configurator/static/src/js/fp_part_process_composer.js',
|
||||
],
|
||||
# Register the Job Status pill SCSS in both bundles so the
|
||||
# `@if $o-webclient-color-scheme == dark` branch compiles for
|
||||
# the dark variant (see CLAUDE.md "Dark Mode" — Odoo 19 has no
|
||||
# runtime DOM toggle, two pre-built bundles).
|
||||
'web.assets_web_dark': [
|
||||
'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -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'
|
||||
""")
|
||||
@@ -8,6 +8,7 @@ from . import fp_part_catalog
|
||||
from . import fp_pricing_complexity_surcharge
|
||||
from . import fp_pricing_rule
|
||||
from . import fp_sale_description_template
|
||||
from . import fp_so_job_sort
|
||||
from . import fp_quote_configurator
|
||||
from . import fp_serial
|
||||
from . import sale_order
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpSoJobSort(models.Model):
|
||||
"""A user-defined grouping bucket for sale orders ("Job Sorting").
|
||||
|
||||
Same pattern as `fusion.plating.tank.section` — every shop slices its
|
||||
SO backlog differently (by customer programme, by priority, by
|
||||
fabricator group, by week, etc.). Sections are free-form, renameable,
|
||||
quick-creatable from the M2O dropdown, and let users group the SO
|
||||
list with fold/expand sections.
|
||||
"""
|
||||
_name = 'fp.so.job.sort'
|
||||
_description = 'Fusion Plating — Sale Order Job Sort'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Job Sorting',
|
||||
required=True,
|
||||
translate=True,
|
||||
)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
color = fields.Integer(string='Color', default=0)
|
||||
fold = fields.Boolean(
|
||||
string='Folded by Default',
|
||||
help='When set, this section appears collapsed in the grouped '
|
||||
'SO list so the body rows are hidden until expanded.',
|
||||
)
|
||||
description = fields.Text(string='Description', translate=True)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
sale_order_ids = fields.One2many(
|
||||
'sale.order', 'x_fc_job_sort_id', string='Sale Orders',
|
||||
)
|
||||
sale_order_count = fields.Integer(
|
||||
compute='_compute_sale_order_count',
|
||||
)
|
||||
|
||||
@api.depends('sale_order_ids')
|
||||
def _compute_sale_order_count(self):
|
||||
for rec in self:
|
||||
rec.sale_order_count = len(rec.sale_order_ids)
|
||||
|
||||
def action_view_sale_orders(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('x_fc_job_sort_id', '=', self.id)],
|
||||
'context': {'default_x_fc_job_sort_id': self.id},
|
||||
}
|
||||
@@ -67,6 +67,28 @@ class SaleOrder(models.Model):
|
||||
'Net Terms strategies.',
|
||||
)
|
||||
x_fc_rush_order = fields.Boolean(string='Rush Order', tracking=True)
|
||||
|
||||
# Lead Time (Phase D11) — promised production window in business
|
||||
# days. Operators enter a min/max range (e.g. 3-5 days or 7-10 days)
|
||||
# so we render a proper expectation on the SO confirmation instead
|
||||
# of the binary Standard/Rush we had before. Both fields default to
|
||||
# 0 — `x_fc_lead_time_display` computes the right human-readable
|
||||
# string (range / single value / Rush / Standard) for the PDF.
|
||||
x_fc_lead_time_min_days = fields.Integer(
|
||||
string='Lead Time Min (days)', tracking=True,
|
||||
help='Lower bound of the promised production lead time, in '
|
||||
'business days. Leave 0 if not committed.',
|
||||
)
|
||||
x_fc_lead_time_max_days = fields.Integer(
|
||||
string='Lead Time Max (days)', tracking=True,
|
||||
help='Upper bound of the promised production lead time, in '
|
||||
'business days. Leave 0 if not committed.',
|
||||
)
|
||||
x_fc_lead_time_display = fields.Char(
|
||||
string='Lead Time',
|
||||
compute='_compute_lead_time_display',
|
||||
help='Human-readable lead time string for the SO confirmation PDF.',
|
||||
)
|
||||
x_fc_delivery_method = fields.Selection(
|
||||
[('local_delivery', 'Local Delivery'), ('shipping_partner', 'Shipping Partner'),
|
||||
('customer_pickup', 'Customer Pickup')],
|
||||
@@ -74,8 +96,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) ----
|
||||
@@ -84,6 +110,16 @@ class SaleOrder(models.Model):
|
||||
help="Customer's internal job number for cross-referencing.",
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_job_sort_id = fields.Many2one(
|
||||
'fp.so.job.sort',
|
||||
string='Job Sorting',
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
help='Free-form bucket that groups this SO in the "Sale Orders '
|
||||
'by Sorting" list view. Quick-create from the dropdown — '
|
||||
'each shop slices its backlog differently (customer programme, '
|
||||
'priority, week, etc.).',
|
||||
)
|
||||
x_fc_planned_start_date = fields.Date(
|
||||
string='Planned Start Date', tracking=True,
|
||||
)
|
||||
@@ -125,6 +161,16 @@ class SaleOrder(models.Model):
|
||||
string='Deadline',
|
||||
compute='_compute_deadline_countdown',
|
||||
)
|
||||
# Drives the colour of the Deadline column. Computed in the same pass
|
||||
# as x_fc_deadline_countdown so the buckets always agree with the
|
||||
# human-readable countdown string.
|
||||
x_fc_deadline_urgency = fields.Selection(
|
||||
[('overdue', 'Overdue'),
|
||||
('urgent', 'Due within 2 days'),
|
||||
('safe', 'More than 2 days')],
|
||||
string='Deadline Urgency',
|
||||
compute='_compute_deadline_countdown',
|
||||
)
|
||||
x_fc_order_completion_date = fields.Date(
|
||||
string='Order Completion Date',
|
||||
compute='_compute_order_completion_date',
|
||||
@@ -237,6 +283,157 @@ class SaleOrder(models.Model):
|
||||
compute='_compute_invoiced_amount',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
# Single "Job Status" pill rendered in the SO list. Pipeline order:
|
||||
# Draft → Awaiting Parts → Parts Partial → Ready to Start →
|
||||
# <Step Name> → Ready to Ship → Ship Booked → In Transit →
|
||||
# Delivered → Invoiced → Paid → Cancelled.
|
||||
# Rendered as an Html field so each kind can carry its own tint via
|
||||
# an .fp-kind-* class — Bootstrap's 5 decoration-* slots aren't
|
||||
# enough to give every phase a distinct colour. SCSS bundle at
|
||||
# static/src/scss/fp_job_status_pill.scss owns the colour map.
|
||||
x_fc_fp_job_status = fields.Html(
|
||||
string='Job Status',
|
||||
compute='_compute_fp_job_status',
|
||||
sanitize=False,
|
||||
help='Single at-a-glance pill that advances through the order '
|
||||
'lifecycle: receiving → WO progress → shipping → invoicing.',
|
||||
)
|
||||
x_fc_fp_job_status_kind = fields.Selection(
|
||||
[('muted', 'Draft (grey)'),
|
||||
('warning', 'Awaiting / Partial (amber)'),
|
||||
('primary', 'Ready / Milestone (purple)'),
|
||||
('info', 'Active Work (blue)'),
|
||||
('shipping', 'Shipping (cyan)'),
|
||||
('delivered', 'Delivered (teal)'),
|
||||
('invoiced', 'Invoiced (lime)'),
|
||||
('paid', 'Paid (green bold)'),
|
||||
('danger', 'Cancelled (red)')],
|
||||
string='Job Status Kind',
|
||||
compute='_compute_fp_job_status',
|
||||
help='Colour category that backs the Job Status pill — also '
|
||||
'usable for filtering / grouping in the list search panel.',
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'state',
|
||||
'x_fc_receiving_status',
|
||||
'x_fc_wo_completion',
|
||||
'invoice_ids.state',
|
||||
'invoice_ids.payment_state',
|
||||
'invoice_ids.move_type',
|
||||
)
|
||||
def _compute_fp_job_status(self):
|
||||
from markupsafe import Markup as _Markup
|
||||
from markupsafe import escape as _escape
|
||||
for so in self:
|
||||
label, kind = self._fp_resolve_job_status(so)
|
||||
so.x_fc_fp_job_status_kind = kind
|
||||
so.x_fc_fp_job_status = _Markup(
|
||||
'<span class="fp-job-status fp-kind-%s">%s</span>'
|
||||
) % (_Markup(kind), _escape(label))
|
||||
|
||||
@staticmethod
|
||||
def _fp_resolve_job_status(so):
|
||||
# Terminal SO states first.
|
||||
if so.state == 'cancel':
|
||||
return ('Cancelled', 'danger')
|
||||
if so.state in ('draft', 'sent'):
|
||||
return ('Draft', 'muted')
|
||||
|
||||
# Invoice phase (terminal positive states).
|
||||
posted = so.invoice_ids.filtered(
|
||||
lambda m: m.state == 'posted'
|
||||
and m.move_type in ('out_invoice', 'out_refund')
|
||||
)
|
||||
if posted and all(
|
||||
m.payment_state in ('paid', 'in_payment') for m in posted
|
||||
):
|
||||
return ('Paid', 'paid')
|
||||
|
||||
# Shipping phase signals — read once.
|
||||
ship_status = None
|
||||
if 'x_fc_receiving_ids' in so._fields:
|
||||
for r in so.x_fc_receiving_ids:
|
||||
ship = (
|
||||
r.x_fc_outbound_shipment_id
|
||||
if 'x_fc_outbound_shipment_id' in r._fields else False
|
||||
)
|
||||
if not ship:
|
||||
continue
|
||||
# Latch the most-advanced status across all receivings.
|
||||
rank = {None: 0, 'booked': 1, 'in_transit': 2, 'delivered': 3}
|
||||
cur = (
|
||||
'delivered' if ship.status == 'delivered'
|
||||
else 'in_transit' if ship.status == 'shipped'
|
||||
else 'booked' if ship.status in ('confirmed', 'draft')
|
||||
else None
|
||||
)
|
||||
if rank[cur] > rank[ship_status]:
|
||||
ship_status = cur
|
||||
|
||||
if posted and ship_status == 'delivered':
|
||||
return ('Invoiced', 'invoiced')
|
||||
if ship_status == 'delivered':
|
||||
return ('Delivered', 'delivered')
|
||||
if ship_status == 'in_transit':
|
||||
return ('In Transit', 'shipping')
|
||||
|
||||
# WO phase — figure out total steps and the current step name.
|
||||
tot = 0
|
||||
current_step_name = None
|
||||
Job = so.env.get('fp.job')
|
||||
if Job is not None and so.name:
|
||||
jobs = Job.sudo().search([('origin', '=', so.name)])
|
||||
if jobs:
|
||||
steps = jobs.mapped('step_ids').sorted(
|
||||
lambda s: (s.job_id.id, s.sequence)
|
||||
)
|
||||
tot = len(steps)
|
||||
# Priority: in_progress → paused → next ready/pending.
|
||||
current = (
|
||||
steps.filtered(lambda s: s.state == 'in_progress')[:1]
|
||||
or steps.filtered(lambda s: s.state == 'paused')[:1]
|
||||
or steps.filtered(lambda s: s.state in ('ready', 'pending'))[:1]
|
||||
)
|
||||
current_step_name = current.name if current else None
|
||||
|
||||
all_steps_done = tot > 0 and current_step_name is None
|
||||
|
||||
if all_steps_done:
|
||||
if ship_status == 'booked':
|
||||
return ('Ship Booked', 'shipping')
|
||||
return ('Ready to Ship', 'primary')
|
||||
if current_step_name:
|
||||
return (current_step_name, 'info')
|
||||
|
||||
# Receiving phase (no WO yet).
|
||||
recv = so.x_fc_receiving_status or 'not_received'
|
||||
if recv == 'received':
|
||||
return ('Ready to Start', 'primary')
|
||||
if recv == 'partial':
|
||||
return ('Parts Partial', 'warning')
|
||||
return ('Awaiting Parts', 'warning')
|
||||
|
||||
@api.depends('x_fc_lead_time_min_days', 'x_fc_lead_time_max_days', 'x_fc_rush_order')
|
||||
def _compute_lead_time_display(self):
|
||||
"""Render the lead time as a human-readable string for reports.
|
||||
|
||||
Priority order:
|
||||
- Real min/max range set → "X-Y days" or "X days"
|
||||
- Range not set, rush_order on → "Rush"
|
||||
- Otherwise → "Standard"
|
||||
"""
|
||||
for so in self:
|
||||
mn = so.x_fc_lead_time_min_days or 0
|
||||
mx = so.x_fc_lead_time_max_days or 0
|
||||
if mn and mx and mn != mx:
|
||||
so.x_fc_lead_time_display = '%d-%d days' % (min(mn, mx), max(mn, mx))
|
||||
elif mx or mn:
|
||||
so.x_fc_lead_time_display = '%d days' % (mx or mn)
|
||||
elif so.x_fc_rush_order:
|
||||
so.x_fc_lead_time_display = 'Rush'
|
||||
else:
|
||||
so.x_fc_lead_time_display = 'Standard'
|
||||
|
||||
@api.depends('name')
|
||||
def _compute_wo_completion(self):
|
||||
@@ -489,9 +686,11 @@ class SaleOrder(models.Model):
|
||||
def _compute_deadline_countdown(self):
|
||||
from datetime import datetime
|
||||
now = fields.Datetime.now()
|
||||
TWO_DAYS = 2 * 86400 # seconds threshold for "urgent"
|
||||
for rec in self:
|
||||
if not rec.commitment_date:
|
||||
rec.x_fc_deadline_countdown = False
|
||||
rec.x_fc_deadline_urgency = False
|
||||
continue
|
||||
target = rec.commitment_date
|
||||
if isinstance(target, datetime):
|
||||
@@ -502,12 +701,13 @@ class SaleOrder(models.Model):
|
||||
secs = int(delta.total_seconds())
|
||||
if secs == 0:
|
||||
rec.x_fc_deadline_countdown = 'due now'
|
||||
rec.x_fc_deadline_urgency = 'overdue'
|
||||
continue
|
||||
past = secs < 0
|
||||
secs = abs(secs)
|
||||
days = secs // 86400
|
||||
hours = (secs % 86400) // 3600
|
||||
mins = (secs % 3600) // 60
|
||||
abs_secs = abs(secs)
|
||||
days = abs_secs // 86400
|
||||
hours = (abs_secs % 86400) // 3600
|
||||
mins = (abs_secs % 3600) // 60
|
||||
bits = []
|
||||
if days:
|
||||
bits.append('%dd' % days)
|
||||
@@ -519,6 +719,12 @@ class SaleOrder(models.Model):
|
||||
rec.x_fc_deadline_countdown = (
|
||||
'overdue %s' % phrase if past else 'in %s' % phrase
|
||||
)
|
||||
if past:
|
||||
rec.x_fc_deadline_urgency = 'overdue'
|
||||
elif secs <= TWO_DAYS:
|
||||
rec.x_fc_deadline_urgency = 'urgent'
|
||||
else:
|
||||
rec.x_fc_deadline_urgency = 'safe'
|
||||
|
||||
@api.depends(
|
||||
'order_line.x_fc_effective_part_deadline',
|
||||
|
||||
@@ -42,3 +42,5 @@ access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part
|
||||
access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
|
||||
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_so_job_sort_user,fp.so.job.sort.user,model_fp_so_job_sort,base.group_user,1,1,1,0
|
||||
access_fp_so_job_sort_manager,fp.so.job.sort.manager,model_fp_so_job_sort,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,68 @@
|
||||
// =============================================================================
|
||||
// Fusion Plating — Job Status pill on the SO list
|
||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||
//
|
||||
// One pill per row, one colour per phase, vibrant + saturated so phases
|
||||
// pop at a glance against both the light and dark Odoo bundles. Same
|
||||
// hue map for both modes — saturated 500-level Tailwind hues with white
|
||||
// text give consistent contrast against either page background.
|
||||
// =============================================================================
|
||||
|
||||
// ----- Vibrant tints (light + dark) -----
|
||||
$_fp-muted-bg : #6b7280; // slate
|
||||
$_fp-warning-bg : #f59e0b; // amber
|
||||
$_fp-primary-bg : #8b5cf6; // violet
|
||||
$_fp-info-bg : #3b82f6; // blue
|
||||
$_fp-shipping-bg : #06b6d4; // cyan
|
||||
$_fp-delivered-bg : #14b8a6; // teal
|
||||
$_fp-invoiced-bg : #84cc16; // lime
|
||||
$_fp-paid-bg : #16a34a; // green
|
||||
$_fp-danger-bg : #ef4444; // red
|
||||
|
||||
// Matching glow shadows — darker tone of the same hue for a subtle
|
||||
// drop-shadow that gives the pill a "lifted" feel without being noisy.
|
||||
$_fp-muted-glow : rgba(31, 41, 55, 0.35);
|
||||
$_fp-warning-glow : rgba(180, 83, 9, 0.45);
|
||||
$_fp-primary-glow : rgba(91, 33, 182, 0.45);
|
||||
$_fp-info-glow : rgba(29, 78, 216, 0.45);
|
||||
$_fp-shipping-glow : rgba(14, 116, 144, 0.45);
|
||||
$_fp-delivered-glow : rgba(15, 118, 110, 0.45);
|
||||
$_fp-invoiced-glow : rgba(101, 163, 13, 0.45);
|
||||
$_fp-paid-glow : rgba(21, 128, 61, 0.5);
|
||||
$_fp-danger-glow : rgba(185, 28, 28, 0.45);
|
||||
|
||||
// =============================================================================
|
||||
// Pill base
|
||||
// =============================================================================
|
||||
.fp-job-status {
|
||||
display: inline-block;
|
||||
padding: 0.4em 0.95em;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
font-size: 0.82em;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.015em;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
min-width: 72px;
|
||||
color: #ffffff !important;
|
||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Per-kind tints — same map applies to light + dark bundles. White text
|
||||
// gives consistent contrast against any saturated mid-tone hue.
|
||||
// =============================================================================
|
||||
.fp-kind-muted { background-color: $_fp-muted-bg; box-shadow: 0 1px 3px $_fp-muted-glow; }
|
||||
.fp-kind-warning { background-color: $_fp-warning-bg; box-shadow: 0 1px 3px $_fp-warning-glow; }
|
||||
.fp-kind-primary { background-color: $_fp-primary-bg; box-shadow: 0 1px 3px $_fp-primary-glow; }
|
||||
.fp-kind-info { background-color: $_fp-info-bg; box-shadow: 0 1px 3px $_fp-info-glow; }
|
||||
.fp-kind-shipping { background-color: $_fp-shipping-bg; box-shadow: 0 1px 3px $_fp-shipping-glow; }
|
||||
.fp-kind-delivered { background-color: $_fp-delivered-bg; box-shadow: 0 1px 3px $_fp-delivered-glow; }
|
||||
.fp-kind-invoiced { background-color: $_fp-invoiced-bg; box-shadow: 0 1px 3px $_fp-invoiced-glow; }
|
||||
.fp-kind-paid {
|
||||
background-color: $_fp-paid-bg;
|
||||
box-shadow: 0 1px 4px $_fp-paid-glow;
|
||||
font-weight: 700;
|
||||
}
|
||||
.fp-kind-danger { background-color: $_fp-danger-bg; box-shadow: 0 1px 3px $_fp-danger-glow; }
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
Job Sorting:
|
||||
- Section model views (list/form) under Configuration → Sales.
|
||||
- Alternate SO list ("Sale Orders by Sorting") grouped by job sort
|
||||
with foldable sections and create-from-here support.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<!-- ===== Section management (Configuration) ===== -->
|
||||
<record id="view_fp_so_job_sort_list" model="ir.ui.view">
|
||||
<field name="name">fp.so.job.sort.list</field>
|
||||
<field name="model">fp.so.job.sort</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Job Sorting" editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="color" widget="color_picker"/>
|
||||
<field name="fold" widget="boolean_toggle"/>
|
||||
<field name="sale_order_count"/>
|
||||
<field name="active" widget="boolean_toggle" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_so_job_sort_form" model="ir.ui.view">
|
||||
<field name="name">fp.so.job.sort.form</field>
|
||||
<field name="model">fp.so.job.sort</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Job Sorting">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_sale_orders" type="object"
|
||||
class="oe_stat_button" icon="fa-shopping-cart">
|
||||
<field name="sale_order_count" widget="statinfo"
|
||||
string="Sale Orders"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="e.g. Rush Orders"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
<field name="color" widget="color_picker"/>
|
||||
<field name="fold"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="description"
|
||||
placeholder="What kinds of orders belong in this section?"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_so_job_sort" model="ir.actions.act_window">
|
||||
<field name="name">Job Sorting</field>
|
||||
<field name="res_model">fp.so.job.sort</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_so_job_sort"
|
||||
name="Job Sorting"
|
||||
parent="fusion_plating.menu_fp_config_pricing_billing"
|
||||
action="action_fp_so_job_sort"
|
||||
sequence="25"/>
|
||||
|
||||
<!-- ===== Kanban grouped by Job Sorting =====
|
||||
Groups SOs into foldable columns by x_fc_job_sort_id.
|
||||
Drag-drop between columns rewrites the bucket; quick-create on
|
||||
the column header creates a new fp.so.job.sort row. Wired into
|
||||
the existing Sale Orders action below so it shows up in the
|
||||
view-switcher next to the flat list. -->
|
||||
<record id="view_sale_order_kanban_fp_by_sorting" model="ir.ui.view">
|
||||
<field name="name">sale.order.kanban.fp.by_sorting</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="x_fc_job_sort_id"
|
||||
group_create="true"
|
||||
group_edit="true"
|
||||
group_delete="true"
|
||||
quick_create="false"
|
||||
sample="1">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="amount_total"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="x_fc_part_numbers_summary"/>
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
<field name="x_fc_deadline_countdown"/>
|
||||
<field name="x_fc_deadline_urgency"/>
|
||||
<field name="x_fc_fp_job_status"/>
|
||||
<field name="state"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="o_kanban_card_content p-2">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<strong><field name="name"/></strong>
|
||||
<span t-att-class="'badge ' + (
|
||||
record.x_fc_deadline_urgency.raw_value == 'overdue' and 'text-bg-danger' or
|
||||
record.x_fc_deadline_urgency.raw_value == 'urgent' and 'text-bg-warning' or
|
||||
record.x_fc_deadline_urgency.raw_value == 'safe' and 'text-bg-success' or
|
||||
'text-bg-light')"
|
||||
t-if="record.x_fc_deadline_countdown.raw_value">
|
||||
<field name="x_fc_deadline_countdown"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-muted small mb-1">
|
||||
<field name="partner_id"/>
|
||||
</div>
|
||||
<div class="small mb-1" t-if="record.x_fc_part_numbers_summary.raw_value">
|
||||
<i class="fa fa-cube me-1"/>
|
||||
<field name="x_fc_part_numbers_summary"/>
|
||||
</div>
|
||||
<div class="small mb-2" t-if="record.x_fc_customer_job_number.raw_value">
|
||||
<i class="fa fa-hashtag me-1"/>
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<field name="x_fc_fp_job_status" widget="html"/>
|
||||
<strong>
|
||||
<field name="amount_total" widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Sale Orders by Sorting (alternate SO list) ===== -->
|
||||
<!-- Duplicate of view_sale_order_list_fp but renamed and intended
|
||||
to be opened with group_by=x_fc_job_sort_id by default so the
|
||||
user sees foldable sections per Job Sorting bucket. -->
|
||||
<record id="view_sale_order_list_fp_by_sorting" model="ir.ui.view">
|
||||
<field name="name">sale.order.list.fp.by_sorting</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="priority">99</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Sale Orders by Sorting" 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 Order"
|
||||
class="btn-primary"
|
||||
display="always"/>
|
||||
</header>
|
||||
<field name="name" optional="show"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
<field name="x_fc_po_number" optional="show"/>
|
||||
<field name="x_fc_customer_job_number" optional="show"/>
|
||||
<field name="x_fc_job_sort_id" optional="show"
|
||||
options="{'no_create_edit': False, 'no_open': True}"/>
|
||||
<field name="x_fc_internal_deadline" optional="show"/>
|
||||
<field name="commitment_date" string="Customer Deadline"
|
||||
optional="show"/>
|
||||
<field name="x_fc_order_completion_date" string="Completion"
|
||||
optional="show"/>
|
||||
<field name="x_fc_is_late_forecast" optional="hide"
|
||||
widget="boolean_toggle"/>
|
||||
<field name="x_fc_deadline_urgency" column_invisible="1"/>
|
||||
<field name="x_fc_deadline_countdown" optional="show"
|
||||
decoration-danger="x_fc_deadline_urgency == 'overdue'"
|
||||
decoration-warning="x_fc_deadline_urgency == 'urgent'"
|
||||
decoration-success="x_fc_deadline_urgency == 'safe'"/>
|
||||
<field name="x_fc_wo_completion" optional="show"/>
|
||||
<field name="x_fc_planned_start_date" optional="hide"/>
|
||||
<field name="x_fc_part_numbers_summary" string="Part"
|
||||
optional="show"/>
|
||||
<field name="amount_total" sum="Total" optional="show"/>
|
||||
<field name="x_fc_invoiced_amount" sum="Invoiced"
|
||||
optional="hide"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="x_fc_fp_job_status" widget="html"
|
||||
string="Job Status" optional="show" readonly="1"/>
|
||||
<field name="x_fc_receiving_status" widget="badge"
|
||||
optional="hide"
|
||||
decoration-warning="x_fc_receiving_status == 'not_received'"
|
||||
decoration-info="x_fc_receiving_status == 'partial'"
|
||||
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" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search view for the alternate list: surface "Group by Job
|
||||
Sorting" as a search-default filter. -->
|
||||
<record id="view_sale_order_search_fp_by_sorting" model="ir.ui.view">
|
||||
<field name="name">sale.order.search.fp.by_sorting</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Sale Orders by Sorting">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="x_fc_part_numbers_summary" string="Part"/>
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
<field name="x_fc_po_number"/>
|
||||
<field name="x_fc_job_sort_id"/>
|
||||
<filter name="late_forecast" string="Late Forecast"
|
||||
domain="[('x_fc_is_late_forecast','=',True)]"/>
|
||||
<filter name="cancelled" string="Cancelled"
|
||||
domain="[('state','=','cancel')]"/>
|
||||
<separator/>
|
||||
<group>
|
||||
<filter name="group_by_job_sort"
|
||||
string="Job Sorting"
|
||||
context="{'group_by': 'x_fc_job_sort_id'}"/>
|
||||
<filter name="group_by_customer"
|
||||
string="Customer"
|
||||
context="{'group_by': 'partner_id'}"/>
|
||||
<filter name="group_by_state"
|
||||
string="Status"
|
||||
context="{'group_by': 'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Append the kanban view to the existing Sale Orders action so
|
||||
users can switch from the flat list to the grouped-by-sorting
|
||||
kanban (foldable columns, drag-drop bucket reassignment) via
|
||||
the view-switcher icon in the top-right of the SO list. -->
|
||||
<record id="action_fp_sale_orders" model="ir.actions.act_window">
|
||||
<field name="view_mode">list,kanban,form</field>
|
||||
<field name="view_ids" eval="[(5, 0, 0),
|
||||
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')}),
|
||||
(0, 0, {'view_mode': 'kanban', 'view_id': ref('view_sale_order_kanban_fp_by_sorting')})]"/>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_sale_orders_by_sorting" model="ir.actions.act_window">
|
||||
<field name="name">Sale Orders (by Sorting)</field>
|
||||
<field name="res_model">sale.order</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="view_id" ref="view_sale_order_list_fp_by_sorting"/>
|
||||
<field name="search_view_id" ref="view_sale_order_search_fp_by_sorting"/>
|
||||
<field name="domain">[('state', 'not in', ('draft', 'sent'))]</field>
|
||||
<field name="context">{'search_default_group_by_job_sort': 1}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fp_sale_orders_by_sorting"
|
||||
name="Sale Orders (by Sorting)"
|
||||
parent="fusion_plating_configurator.menu_fp_sales"
|
||||
action="action_fp_sale_orders_by_sorting"
|
||||
sequence="12"/>
|
||||
|
||||
</odoo>
|
||||
@@ -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. -->
|
||||
@@ -98,6 +111,27 @@
|
||||
string="Job Groups"/>
|
||||
</button>
|
||||
</xpath>
|
||||
<!-- Surface Delivery Date (commitment_date) right after Order
|
||||
Date in the header info group. The standard view only
|
||||
shows it buried under the Delivery section in the Other
|
||||
Info tab — having it at the top keeps it visible without
|
||||
scrolling, since most operators are setting it on every
|
||||
order. The same field is also surfaced lower in our
|
||||
Plating tab Scheduling group as "Customer Deadline"; both
|
||||
reference the same field so edits sync. -->
|
||||
<xpath expr="//group[@name='order_details']/field[@name='payment_term_id']" position="before">
|
||||
<field name="commitment_date" string="Delivery Date"
|
||||
readonly="state in ('cancel',)"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Job Sorting sits right under Payment Terms — a free-form
|
||||
bucket that groups the SO in the "Sale Orders by Sorting"
|
||||
list. Quick-create from the dropdown. -->
|
||||
<xpath expr="//group[@name='order_details']/field[@name='payment_term_id']" position="after">
|
||||
<field name="x_fc_job_sort_id"
|
||||
options="{'no_create_edit': False, 'no_open': True}"
|
||||
placeholder="Type to create a new bucket..."/>
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Plating" name="plating_tab">
|
||||
<!-- Multi-part summary: read-only list of every order line
|
||||
@@ -131,11 +165,9 @@
|
||||
string="Job #"/>
|
||||
</list>
|
||||
</field>
|
||||
<!-- Row 1: RFQ/PO (left) + Scheduling (right) — pairs the two
|
||||
tallest groups so neither column dangles empty. -->
|
||||
<group>
|
||||
<group string="Configurator (legacy)" invisible="not x_fc_configurator_id">
|
||||
<field name="x_fc_configurator_id" readonly="1"/>
|
||||
<field name="x_fc_process_summary" readonly="1"/>
|
||||
</group>
|
||||
<group string="RFQ / PO">
|
||||
<field name="x_fc_po_number"/>
|
||||
<field name="upload_rfq_file"
|
||||
@@ -161,29 +193,6 @@
|
||||
<field name="x_fc_po_override_reason"
|
||||
invisible="not x_fc_po_override"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Invoicing">
|
||||
<field name="x_fc_invoice_strategy"/>
|
||||
<field name="x_fc_deposit_percent"
|
||||
invisible="x_fc_invoice_strategy != 'deposit'"/>
|
||||
<field name="x_fc_progress_initial_percent"
|
||||
invisible="x_fc_invoice_strategy != 'progress'"/>
|
||||
<field name="x_fc_final_invoice_id" readonly="1"
|
||||
invisible="not x_fc_final_invoice_id"/>
|
||||
</group>
|
||||
<group string="Delivery">
|
||||
<field name="x_fc_rush_order"/>
|
||||
<field name="x_fc_delivery_method"/>
|
||||
<field name="x_fc_receiving_status"/><!-- Will become computed when fusion_plating_receiving is installed -->
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Customer Reference">
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
<field name="x_fc_contact_phone"/>
|
||||
<field name="x_fc_ship_via"/>
|
||||
</group>
|
||||
<group string="Scheduling">
|
||||
<field name="x_fc_planned_start_date"/>
|
||||
<field name="x_fc_internal_deadline"/>
|
||||
@@ -201,9 +210,45 @@
|
||||
</div>
|
||||
<field name="x_fc_is_blanket_order"/>
|
||||
<field name="x_fc_block_partial_shipments"/>
|
||||
<!-- Lead Time range. Both 0 = "Standard" on
|
||||
the PDF; otherwise renders "X-Y days"
|
||||
(or "X days" if min==max or one is 0). -->
|
||||
<label for="x_fc_lead_time_min_days" string="Lead Time (days)"/>
|
||||
<div class="o_row">
|
||||
<field name="x_fc_lead_time_min_days" class="oe_inline" style="width: 4em;"/>
|
||||
<span> to </span>
|
||||
<field name="x_fc_lead_time_max_days" class="oe_inline" style="width: 4em;"/>
|
||||
</div>
|
||||
<field name="x_fc_lead_time_display" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Row 2: Invoicing + Delivery (unchanged pairing). -->
|
||||
<group>
|
||||
<group string="Invoicing">
|
||||
<field name="x_fc_invoice_strategy"/>
|
||||
<field name="x_fc_deposit_percent"
|
||||
invisible="x_fc_invoice_strategy != 'deposit'"/>
|
||||
<field name="x_fc_progress_initial_percent"
|
||||
invisible="x_fc_invoice_strategy != 'progress'"/>
|
||||
<field name="x_fc_final_invoice_id" readonly="1"
|
||||
invisible="not x_fc_final_invoice_id"/>
|
||||
</group>
|
||||
<group string="Delivery">
|
||||
<field name="x_fc_rush_order"/>
|
||||
<field name="x_fc_delivery_method"/>
|
||||
<field name="x_fc_receiving_status"/><!-- Will become computed when fusion_plating_receiving is installed -->
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Row 3: Customer Reference + Margin — both short groups, so
|
||||
pairing them keeps the right column from going blank. -->
|
||||
<group>
|
||||
<group string="Customer Reference">
|
||||
<field name="x_fc_customer_job_number"/>
|
||||
<field name="x_fc_contact_phone"/>
|
||||
<field name="x_fc_ship_via"/>
|
||||
</group>
|
||||
<group string="Margin">
|
||||
<div colspan="2"
|
||||
invisible="x_fc_margin_available"
|
||||
@@ -222,14 +267,29 @@
|
||||
<field name="x_fc_margin_available" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Row 4: Notes — two side-by-side textareas instead of the
|
||||
previous broken separator-in-group layout. -->
|
||||
<group>
|
||||
<group string="Internal Notes">
|
||||
<field name="x_fc_internal_note" nolabel="1"
|
||||
placeholder="Internal notes for estimator / planner / shop floor..."/>
|
||||
</group>
|
||||
<separator string="External Notes (customer-visible)"/>
|
||||
<field name="x_fc_external_note"
|
||||
<group string="External Notes (customer-visible)">
|
||||
<field name="x_fc_external_note" nolabel="1"
|
||||
placeholder="Notes that appear on the acknowledgement and portal..."/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Legacy configurator block — invisible on new SOs (only
|
||||
the handful that came through the old quote configurator
|
||||
flow have x_fc_configurator_id set). Kept at the bottom
|
||||
so it doesn't waste vertical space on the common case. -->
|
||||
<group invisible="not x_fc_configurator_id">
|
||||
<group string="Configurator (legacy)">
|
||||
<field name="x_fc_configurator_id" readonly="1"/>
|
||||
<field name="x_fc_process_summary" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
@@ -307,29 +367,39 @@
|
||||
<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>
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="x_fc_po_number"/>
|
||||
<field name="name" optional="show"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
<field name="x_fc_po_number" optional="show"/>
|
||||
<field name="x_fc_customer_job_number" optional="show"/>
|
||||
<field name="x_fc_internal_deadline" optional="show"/>
|
||||
<field name="commitment_date" string="Customer Deadline" optional="show"/>
|
||||
<field name="x_fc_order_completion_date" string="Completion" optional="show"/>
|
||||
<field name="x_fc_is_late_forecast" optional="hide" widget="boolean_toggle"/>
|
||||
<field name="x_fc_deadline_countdown" optional="show"/>
|
||||
<field name="x_fc_deadline_urgency" column_invisible="1"/>
|
||||
<field name="x_fc_deadline_countdown" optional="show"
|
||||
decoration-danger="x_fc_deadline_urgency == 'overdue'"
|
||||
decoration-warning="x_fc_deadline_urgency == 'urgent'"
|
||||
decoration-success="x_fc_deadline_urgency == 'safe'"/>
|
||||
<field name="x_fc_wo_completion" optional="show"/>
|
||||
<field name="x_fc_planned_start_date" optional="hide"/>
|
||||
<field name="x_fc_part_catalog_id" optional="hide"/>
|
||||
<field name="amount_total" sum="Total"/>
|
||||
<!-- "Part" column — walks order_line.x_fc_part_catalog_id
|
||||
and shows a compact summary (e.g. "M1234, M5678
|
||||
(+3 more)"). The header x_fc_part_catalog_id field
|
||||
is rarely populated in the configurator flow; the
|
||||
line carries the authoritative part link. -->
|
||||
<field name="x_fc_part_numbers_summary" string="Part"
|
||||
optional="show"/>
|
||||
<field name="amount_total" sum="Total" optional="show"/>
|
||||
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
@@ -339,12 +409,21 @@
|
||||
<field name="x_fc_margin_percent" optional="hide"
|
||||
widget="percentage"/>
|
||||
<field name="x_fc_is_blanket_order" optional="hide"/>
|
||||
<!-- Single Job Status pill. Renders as HTML with a
|
||||
per-kind class (.fp-kind-*) so every phase carries
|
||||
its own distinct tint — see
|
||||
static/src/scss/fp_job_status_pill.scss. -->
|
||||
<field name="x_fc_fp_job_status" widget="html"
|
||||
string="Job Status" optional="show"
|
||||
readonly="1"/>
|
||||
<field name="x_fc_receiving_status" widget="badge"
|
||||
optional="hide"
|
||||
decoration-warning="x_fc_receiving_status == 'not_received'"
|
||||
decoration-success="x_fc_receiving_status in ('received','inspected')"/>
|
||||
decoration-info="x_fc_receiving_status == 'partial'"
|
||||
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"/>
|
||||
<field name="state" widget="badge" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
@@ -414,8 +493,8 @@
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Quotations" decoration-muted="state == 'cancel'">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="name" optional="show"/>
|
||||
<field name="partner_id" optional="show"/>
|
||||
<field name="x_fc_part_numbers_summary" optional="show"/>
|
||||
<field name="x_fc_po_number" optional="hide"/>
|
||||
<field name="x_fc_customer_job_number" optional="hide"/>
|
||||
@@ -423,15 +502,16 @@
|
||||
<field name="validity_date" string="Expires" optional="show"/>
|
||||
<field name="x_fc_follow_up_date" optional="show"/>
|
||||
<field name="x_fc_follow_up_user_id" optional="show"/>
|
||||
<field name="amount_total" sum="Total"/>
|
||||
<field name="amount_total" sum="Total" optional="show"/>
|
||||
<field name="x_fc_is_signed" widget="boolean_toggle"
|
||||
string="Signed" optional="show"/>
|
||||
<field name="x_fc_email_status" widget="badge"
|
||||
optional="show"
|
||||
decoration-info="x_fc_email_status == 'sent'"
|
||||
decoration-warning="x_fc_email_status == 'opened'"
|
||||
decoration-success="x_fc_email_status == 'won'"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
<field name="state" widget="badge"/>
|
||||
<field name="state" widget="badge" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
@@ -531,7 +611,10 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== Window Action — Confirmed Sale Orders ===== -->
|
||||
<!-- ===== Window Action — Confirmed Sale Orders =====
|
||||
The kanban view_mode + kanban view_id are appended in
|
||||
fp_so_job_sort_views.xml after the kanban view is defined, so
|
||||
we don't hit a missing-ref at module load. -->
|
||||
<record id="action_fp_sale_orders" model="ir.actions.act_window">
|
||||
<field name="name">Sale Orders</field>
|
||||
<field name="res_model">sale.order</field>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
@@ -79,6 +79,13 @@ class FpDirectOrderWizard(models.Model):
|
||||
help="Customer's internal job number for cross-referencing. "
|
||||
"Appears on work orders and invoices.",
|
||||
)
|
||||
job_sort_id = fields.Many2one(
|
||||
'fp.so.job.sort',
|
||||
string='Job Sorting',
|
||||
help='Free-form bucket that groups the new SO in the '
|
||||
'"Sale Orders by Sorting" list. Type a new label in the '
|
||||
'dropdown to create a section on the fly.',
|
||||
)
|
||||
|
||||
# ---- Scheduling ----
|
||||
planned_start_date = fields.Date(
|
||||
@@ -86,6 +93,11 @@ class FpDirectOrderWizard(models.Model):
|
||||
)
|
||||
internal_deadline = fields.Date(string='Internal Deadline')
|
||||
customer_deadline = fields.Date(string='Customer Deadline', tracking=True)
|
||||
# Lead Time — promised production window. Mirrors directly to
|
||||
# x_fc_lead_time_min_days / x_fc_lead_time_max_days on the SO via
|
||||
# _prepare_order_vals. Leaving both 0 = Standard (no commitment).
|
||||
lead_time_min_days = fields.Integer(string='Lead Time Min (days)')
|
||||
lead_time_max_days = fields.Integer(string='Lead Time Max (days)')
|
||||
|
||||
# ---- Order flags (Phase B) ----
|
||||
is_blanket_order = fields.Boolean(
|
||||
@@ -528,9 +540,20 @@ class FpDirectOrderWizard(models.Model):
|
||||
'x_fc_po_pending': self.po_pending,
|
||||
'x_fc_po_expected_date': self.po_expected_date or False,
|
||||
'x_fc_customer_job_number': self.customer_job_number or False,
|
||||
'x_fc_job_sort_id': self.job_sort_id.id or False,
|
||||
'x_fc_planned_start_date': self.planned_start_date,
|
||||
'x_fc_internal_deadline': self.internal_deadline,
|
||||
'commitment_date': self.customer_deadline,
|
||||
'x_fc_lead_time_min_days': self.lead_time_min_days or 0,
|
||||
'x_fc_lead_time_max_days': self.lead_time_max_days or 0,
|
||||
# commitment_date is a Datetime; customer_deadline is a Date.
|
||||
# Assigning a bare Date stores midnight UTC, which renders as
|
||||
# the PREVIOUS day in any negative-UTC timezone (Eastern shifts
|
||||
# May 25 00:00 UTC → May 24 8pm). Combining with noon keeps
|
||||
# the date stable across all reasonable user timezones.
|
||||
'commitment_date': (
|
||||
datetime.combine(self.customer_deadline, time(12, 0))
|
||||
if self.customer_deadline else False
|
||||
),
|
||||
'x_fc_invoice_strategy': self.invoice_strategy,
|
||||
'x_fc_deposit_percent': self.deposit_percent,
|
||||
'x_fc_progress_initial_percent': self.progress_initial_percent,
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
options="{'no_create_edit': True}"
|
||||
invisible="not partner_id"/>
|
||||
<field name="customer_job_number"/>
|
||||
<field name="job_sort_id"
|
||||
options="{'no_create_edit': False, 'no_open': True}"
|
||||
placeholder="Type to create a new bucket..."/>
|
||||
</group>
|
||||
<group string="Purchase Order">
|
||||
<field name="po_number"
|
||||
@@ -95,7 +98,21 @@
|
||||
<group string="Scheduling">
|
||||
<field name="planned_start_date"/>
|
||||
<field name="internal_deadline"/>
|
||||
<field name="customer_deadline"/>
|
||||
<!-- Labelled "Delivery Date" here to match
|
||||
the SO header field of the same name —
|
||||
same field, same value, just consistent
|
||||
wording end-to-end. Backing field is
|
||||
still `customer_deadline` (wizard) →
|
||||
`commitment_date` (SO). -->
|
||||
<field name="customer_deadline" string="Delivery Date"/>
|
||||
<!-- Lead time range (min/max business days).
|
||||
Both 0 = "Standard" on the SO confirm PDF. -->
|
||||
<label for="lead_time_min_days" string="Lead Time (days)"/>
|
||||
<div class="o_row">
|
||||
<field name="lead_time_min_days" class="oe_inline" style="width: 4em;"/>
|
||||
<span> to </span>
|
||||
<field name="lead_time_max_days" class="oe_inline" style="width: 4em;"/>
|
||||
</div>
|
||||
<field name="is_blanket_order"/>
|
||||
<field name="block_partial_shipments"/>
|
||||
</group>
|
||||
|
||||
@@ -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.20.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
@@ -57,19 +57,21 @@ full design rationale and §6.2 of the implementation plan for task list.
|
||||
# so the statusbar's m2o has its targets available at view-render time).
|
||||
'data/fp_workflow_state_data.xml',
|
||||
'views/fp_workflow_state_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/fp_job_step_quick_look_views.xml',
|
||||
'views/fp_job_form_inherit.xml',
|
||||
'views/fp_job_quality_buttons.xml',
|
||||
'views/sale_order_views.xml',
|
||||
'views/fp_receiving_views.xml',
|
||||
'views/fp_certificate_views.xml',
|
||||
'views/fp_job_consumption_views.xml',
|
||||
'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',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -31,4 +31,21 @@
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Phase 2 tablet redesign — actual auto-pause (not just nudge).
|
||||
Flips in_progress steps idle > N hours to paused with chatter
|
||||
audit. Threshold configurable via ir.config_parameter
|
||||
`fp.shopfloor.autopause_threshold_hours` (default 8.0). Recipe
|
||||
nodes can opt out via long_running=True (e.g. 24h bakes). -->
|
||||
<record id="ir_cron_autopause_stale_steps" model="ir.cron">
|
||||
<field name="name">Fusion Plating: Auto-pause stale in-progress steps</field>
|
||||
<field name="model_id" ref="fusion_plating.model_fp_job_step"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_autopause_stale_steps()</field>
|
||||
<field name="interval_number">30</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user