47 Commits

Author SHA1 Message Date
gsinghpal
4025789ba0 feat(fusion_claims): expand dashboard with this-month, pipeline, aging, recent exports + full-width
Adds 4 new sections:
- This Month rollup: submitted/approved/delivered/billed counts MTD
- Pipeline $ by stage: pre-submit / submitted / approved / ready-to-bill amounts
- Aging buckets: 30-59d, 60-89d, 90+ days
- Recent ADP Exports: last 5 with totals

Also overrides Odoo's form-sheet max-width on .o_fc_dashboard so the
dashboard uses the full browser width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:26:25 -04:00
gsinghpal
5b6e53c863 fix(fusion_claims): add Dashboard menu item under ADP Claims root
The dashboard action existed but no menuitem ever pointed to it (latent
bug in the original module). Adding menu_fusion_claims_dashboard as the
first child of menu_adp_claims_root so the dashboard becomes the default
landing for the Fusion Claims app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:04:20 -04:00
gsinghpal
b70fff01e1 feat(fusion_claims): bump version to 19.0.9.0.0 for dashboard rewrite
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:53:25 -04:00
gsinghpal
07f9bcf79b feat(fusion_claims): add OWL countdown widget for posting deadline
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:53:18 -04:00
gsinghpal
1420a5c445 feat(fusion_claims): add dashboard SCSS with dual-bundle theming
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:52:57 -04:00
gsinghpal
2bfb1015ea feat(fusion_claims): rewrite dashboard form view with action-oriented layout
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:51:59 -04:00
gsinghpal
ace82de88c feat(fusion_claims): add dashboard create-SO hotlinks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:50:58 -04:00
gsinghpal
1b1e9fdb9e feat(fusion_claims): add dashboard open-list action methods
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:50:32 -04:00
gsinghpal
95e0e2d9bd feat(fusion_claims): add dashboard ADP + MOD workflow tile counts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:49:48 -04:00
gsinghpal
cdc9f864b2 feat(fusion_claims): add dashboard other-funder counts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:49:10 -04:00
gsinghpal
a00c891277 feat(fusion_claims): add dashboard activities and bottlenecks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:48:41 -04:00
gsinghpal
f45883233c feat(fusion_claims): add dashboard KPI tiles (ready/claimed/AR)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:48:08 -04:00
gsinghpal
d5e79cdc10 feat(fusion_claims): add dashboard banner fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:47:24 -04:00
gsinghpal
1a8a96d94e feat(fusion_claims): scaffold dashboard model with role filter
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:46:17 -04:00
gsinghpal
53fd6114e7 changes 2026-05-21 03:42:46 -04:00
gsinghpal
1314f4581d changes 2026-05-21 03:37:25 -04:00
gsinghpal
b2f483d67c docs(fusion_claims): add dashboard redesign spec
Action-oriented dashboard replacing the existing 4-panel HTML overview:
posting-week banner with live countdown, 3 KPI tiles, 8 funder hotlinks,
ADP + MOD workflow flag tiles, role-aware filtering, dark-mode aware SCSS.

Spec captures all design decisions from the brainstorm session; ready to
hand off to writing-plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:29:23 -04:00
gsinghpal
f1cea2fb35 fix(fusion_schedule): stop archiving valid events on @removed=changed
Microsoft Graph's delta API returns @removed={reason:'changed'} when an
event drifts outside the original delta-query window — the event still
exists upstream. The old code treated any truthy @removed the same as a
real delete and archived the local calendar.event. Combined with
_find_existing_event filtering by active=True, every subsequent sync
recreated a duplicate (then archived it on the next pass), accumulating
5x duplicates and emptying the user's calendar.

- _process_microsoft_event: only archive on isCancelled or
  @removed.reason='deleted'; skip on @removed.reason='changed'
- _process_microsoft_event link path: reactivate when MS Graph confirms
  a previously-archived event still exists
- _process_microsoft_event iCalUId path: same reactivation
- _find_existing_event: include archived records so wrongly-archived
  duplicates are reused instead of piling up
- callers reactivate the matched archived record

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 23:21:15 -04:00
gsinghpal
8ef57a4bb1 fix(task_sync): defend against silent sync_id integrity violations
The cross-instance sync silently drops tasks when x_fc_tech_sync_id is
missing on the technician, and silently collapses duplicates via dict
comprehension. Both make sync break in ways that are invisible until
someone notices a missing task on the other instance.

- _get_remote_tech_map / _get_local_syncid_to_uid: warn on duplicates
- _push_tasks_to_remote: info-log when a task is skipped because the
  tech has no sync_id or no remote counterpart
- res.users onchange: warn in the form when entering a sync_id that
  is already used by another active field staff

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:29:48 -04:00
gsinghpal
d4fb1eebbf changes 2026-05-20 21:01:58 -04:00
gsinghpal
2e4d957a47 fix(certs): auto-edit first row in Issue Certs wizard so upload is visible
Previous attempt (e5928b96) used CSS to force the binary widget's
"Upload your file" button visible in display mode. Problem: it
rendered a non-clickable stub in every row, then DUPLICATED when
the operator clicked into edit mode (two upload links stacked).

Drop the SCSS hack entirely. Replace with a custom form-view
controller that auto-edits the first incomplete row on mount.
When the wizard opens, the JS:

  1. Scopes itself via the form's o_fp_cert_issue_wizard_form class
     (no-ops on every other form view in the system).
  2. Finds rows where the is_ready toggle is False.
  3. Clicks the fischer_file cell of the first such row.
  4. The row enters edit mode → Odoo's native binary widget renders
     its upload button → operator drops the file → onchange fires
     → readings parse.

Wired via js_class="fp_cert_issue_wizard_form" on the form root.
Banner copy updated to "Click a row, then click Upload your file in
the Fischerscope column" so even if the auto-edit fails for some
DOM reason, the operator knows the click path.

Module: fusion_plating_jobs 19.0.10.16.1 → 19.0.10.16.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:15:27 -04:00
gsinghpal
e5928b965f fix(certs): always-visible upload button in Issue Certs wizard list
Reported 2026-05-20: the Fischerscope file column shows "↑ Upload
your file" only when the operator clicks the cell. Until then, the
cell looks empty and operators don't know they can upload there.

Root cause: Odoo's default `widget="binary"` only renders the
upload button in EDIT mode. In editable lists, non-selected rows
stay in display mode, which hides the button. Stock theme CSS
hides .o_select_file_button on inactive rows.

Fix: scoped SCSS that overrides the default theme rule for the
Issue Certs wizard ONLY. `.o_select_file_button` becomes
`display: inline-flex !important` so it shows on every row from
the moment the wizard opens. Added a fa-upload icon glyph + dotted
underline so the button reads as clickable-action, not text.

Scoped to `.o_field_one2many[name="line_ids"]` inside the form view
so binary fields elsewhere in the system are unaffected. Registered
in both web.assets_backend and web.assets_web_dark per CLAUDE.md
two-bundle rule.

Module: fusion_plating_jobs 19.0.10.16.0 → 19.0.10.16.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:51:55 -04:00
gsinghpal
0600b87a29 fix(certs): surface Fischerscope upload inline in Issue Certs wizard
Reported 2026-05-20: clicking "Issue Cert" on a job opened the
wizard with a banner saying "Fischerscope file or readings needed
— fill it in below before confirming", but the list view only
showed status toggles (Needs Thickness / Is Ready). No upload
affordance was visible. Operators had to know they could click a
list row to expand into a hidden detail form where the upload
field lived.

The wizard model already had the file field, the .docx parser
(_fp_parse_fischerscope_docx), and the @onchange that prefills
readings — only the view was hiding it.

Fix: promote the file upload into the list as its own editable
binary column, alongside the existing Needs Thickness toggle.
Operator now sees:

  Reference │ Type │ Customer │ Needs Thickness │
  Fischerscope File (PDF or .docx) │ Parsed │ Ready

Drop the file → onchange fires → readings + parsed summary
populate in-row. Click "Confirm & Issue" to commit.

The per-line expanded form is preserved (still accessible via
row click) as a "details" panel for editing individual readings
after upload — but the primary upload action is now in the list
row where the operator's eyes are.

Module: fusion_plating_jobs 19.0.10.15.0 → 19.0.10.16.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:46:06 -04:00
gsinghpal
3d1b6e7ec5 fix(receiving): drop dead staged state — Option B (draft→counted→closed)
Reported 2026-05-20: the receiving state machine had four states
(draft → counted → staged → closed) where the middle pair was pure
ceremony. Real-usage data on entech:

  state distribution: 14 draft, 4 closed (zero `staged` records)
  median dwell counted → staged: 11 seconds
  median dwell staged  → closed: 4 minutes

`staged` captured no fields, fired no gates, mapped to the same SO
`x_fc_receiving_status='partial'` as `counted`. Pure click-through.

Cleanup:
- State Selection retains `staged` as `Staged (legacy)` so historical
  records remain readable; new transitions never write it.
- statusbar_visible drops it from the chevron header.
- action_mark_staged becomes a thin shim that advances counted →
  closed directly (any old button binding still works).
- action_close now accepts `counted` as a valid source state (was
  previously only `staged` / legacy `accepted` / `resolved`).
- View: "Stage for Racking" button removed. "Close" button renamed
  to "Close — Racking Confirmed" so the racking-crew confirmation
  meaning stays obvious.
- _update_so_receiving_status mapping unchanged for legacy `staged`
  (still maps to partial) — only the comment block updated to
  describe the new canonical flow.

Migration 19.0.3.20.0 advances any `staged` records to `closed`
and syncs the linked SO's x_fc_receiving_status to `received` so
downstream gates (job step start, mark_done qty check, cert
creation) don't see a stale "partial" status.

Module: fusion_plating_receiving 19.0.3.19.0 → 19.0.3.20.0.

Tests: TestQtyReceivedPropagation updated — 5 tests dropped the
action_mark_staged() call, walk draft → counted → closed directly.
All 11 tests green (carrier 6 + propagation 5).

Verified on entech: existing 14 draft + 4 closed records untouched.
Direct draft → counted → closed transition works end-to-end on
RCV-30041 (was the test target).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:40:43 -04:00
gsinghpal
d7bee9e854 fix(configurator): widen Template dropdown in Add Variant strip
Reported 2026-05-20: the Template dropdown in the Part > Process
Composer's 'Add Variant from Template' row truncated long recipe
names to 4 characters ("Cher" instead of "Chemical Conversion …").
The hard-coded max-width: 280px was set before the curated template
catalog grew names like "Chemical Conversion — Iridite Type II Cl 3"
and "ENP-STEEL-BASIC — Standard Heavy Phos".

Fix: replace the rigid max-width with a flex sizing that gives the
dropdown room to grow:
  - min-width: 360px (full common recipe name fits)
  - flex: 1 1 360px  (grows to fill available space)
  - max-width: 560px (cap so it doesn't push the buttons off-screen)

Same flex pattern applied to the Variant label input (slightly
narrower min/max).

Also: pulled the entech-side version of fp_part_process_composer.xml
back into the local repo — local was stale (one 'Add Variant' button;
entech had the dual 'Add — Tree' / 'Add — Simple' buttons that
landed in an out-of-band edit).

Module: fusion_plating_configurator 19.0.21.5.0 → 19.0.21.5.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:27:04 -04:00
gsinghpal
6343386488 fix(simple-editor): sticky Step Library panel for long recipes
Reported 2026-05-20: on a 40+ step recipe (e.g. ENP-STEEL-BASIC),
scrolling down into the Selected steps pane scrolled the Step
Library off the top of the screen. Authors had to scroll back up
to grab a step, then scroll down to drop it.

Fix: position: sticky on .o_fp_library_panel, pinned to top: 1rem
(matches the editor's padding) inside the .o_fp_simple_editor
overflow container. align-items: start on the grid so the library
column doesn't stretch to match the recipe column's height
(prerequisite for sticky to behave).

The library itself can have 30+ entries (curated step kinds +
shop-defined library templates). max-height: calc(100vh - 8rem)
+ overflow-y: auto keeps it from blowing past the viewport — it
grows its own internal scrollbar instead.

Mobile (≤900px) reverts to static positioning so the stacked
layout stays sensible.

Module: fusion_plating 19.0.20.6.1 → 19.0.20.6.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:20:59 -04:00
gsinghpal
afe0fd1206 fix(simple-editor): preserve scroll position across loadAll() re-renders
Regression of an earlier fix. Operators reported the editor jumping
to the top of the page on every step save / insert / remove / promote.

Root cause: .o_fp_simple_editor is the overflow:auto scroll
container. loadAll() replaces state.steps with a fresh JSONRPC
payload — OWL tears down the t-foreach and rebuilds every row, which
snaps scrollTop back to 0. Every author action (Save Step, Add
Step, Remove, Promote, Demote, Reorder, Import Template) routes
through loadAll, so the symptom hit everywhere.

Fix: capture scrollTop before the RPC, restore in a double-rAF
after the response settles. rAF (microtask runs before paint in
OWL 2; we need the rebuilt DOM to exist). One choke point fix —
every caller benefits without per-handler changes.

Cheap: a single DOM lookup + an integer save/restore. No XML or
state-shape changes.

Module: fusion_plating 19.0.20.6.0 → 19.0.20.6.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:13:55 -04:00
gsinghpal
ac1db177e1 feat(step-kinds): curate to 11 + mandatory + admin-only creation
Operator-reported foot-gun: Step Kind dropdown had 24 options, most
of which were visual-only (cleaning, electroclean, etch, rinse,
strike, dry, wbf_test, hardness_test, adhesion_test, salt_spray,
packaging, etc.) and didn't drive any gate or milestone. Picking the
wrong one meant nothing happened; picking Generic (left default)
meant nothing happened. Authors couldn't tell which choice mattered.

Curation: 24 → 11 active kinds. Each remaining kind has a concrete
downstream behaviour (gate, portal milestone, hardware tie-in, or
"explicitly no behaviour" for Other):

  other            Other (catch-all, default — no special behaviour)
  receiving        Received portal milestone
  contract_review  QA-005 form gate + button_finish lock
  racking          Rack-assignment dialog + button_finish lock
  mask             Visual mask kind (covers Masking + De-Masking)
  wet_process      Visual wet kind (NEW, covers cleaning, rinse,
                   etch, strike, dry, electroclean, wbf_test)
  plate            Plated portal milestone (last plate step closes)
  bake             Bake-window state machine + Baked milestone
  inspect          Intermediate inspection milestone
  final_inspect    Inspected (terminal) portal milestone
  ship             Shipped milestone (back-compat; delivery-state
                   driven is preferred)

Retired kinds (active=False, hidden from dropdown): cleaning,
electroclean, etch, rinse, strike, dry, wbf_test, demask, derack,
replenishment, hardness_test, adhesion_test, salt_spray, packaging,
gating. Kept in DB for audit / history but not selectable.

Mandatory enforcement:
- fp.step.kind_id on fusion.plating.process.node and fp.step.template
  is now required=True with ondelete='restrict' and a default that
  resolves to the 'other' kind. Existing NULL rows are backfilled by
  the pre-migrate before the NOT NULL constraint hits the schema.
- Dropdown no longer offers a blank / "Generic" option. New steps
  land on 'other' instead of NULL.

Admin-only catalog:
- /fp/simple_recipe/kinds/create endpoint now refuses requests from
  non-managers (group_fusion_plating_manager). Returns a clear
  message explaining why ("each kind drives gates / milestones /
  routing — pick Other if none fits, or ask a manager to wire up a
  new kind").
- "+ Add a new kind…" sentinel option in the library form is hidden
  unless state.recipe.user_is_manager. Backend gate is the authority;
  the UI hide is just to stop showing a button that will error.
- The Step Type dropdown in the inline step-edit panel switched from
  a 24-line hard-coded XML option list to a t-foreach over
  state.kindOptions (the same kinds/list endpoint payload). One
  source of truth — retire / add a kind in the catalog and every
  picker reflects the change.

Migration impact (entech): 5 templates + 579 nodes backfilled via
name-match heuristic. 15 kinds flipped to active=False. Distribution
of the 579 backfilled nodes:
  racking 105, other 97, bake 91, wet_process 90, mask 74,
  inspect 44, plate 32, final_inspect 25, receiving 10,
  contract_review 9, ship 2.

Drive-by:
- Migration uses _ensure_kind() that also registers ir.model.data
  for the new xmlids so the subsequent data XML load doesn't create
  duplicate kind records.
- Stored related default_kind on fusion.plating.process.node /
  fp.step.template is written alongside kind_id in every SQL UPDATE
  so legacy `node.default_kind == 'foo'` comparisons stay accurate
  (the ORM doesn't recompute stored related fields after direct
  SQL writes).

Module: fusion_plating 19.0.20.5.0 → 19.0.20.6.0.
15 existing tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:08:31 -04:00
gsinghpal
7c31269691 fix(simple-editor): stop seed resurrection + add promote/demote + drag substeps
Three bugs reported on 2026-05-20:

1. RESURRECTION. User deletes a substep in the Simple Editor (e.g.
   Soak Clean (S-3) under Cleaner), then on the next -u fusion_plating
   the substep comes back. Root cause: the recipe XML lived in the
   manifest's `data` list with `noupdate="1"`. Odoo's noupdate=1 only
   blocks UPDATE of existing records — when a record's ir.model.data
   row is missing, the loader treats it as "not yet created" and
   re-creates from XML. Every upgrade resurrected every user-deleted
   seed node.

   Fix: pull the recipe XML files out of `data` and load them once
   via post_init_hook → _seed_starter_recipes_once. Sentinel checks
   ir.model.data for each recipe's root xmlid; if present, skip
   loading entirely. Result: deletions are permanent across all
   future upgrades. Existing entech recipes untouched.

   Files affected: fp_recipe_enp_alum_basic, fp_recipe_enp_steel_basic,
   fp_recipe_enp_sp, fp_recipe_general_processing, fp_recipe_anodize,
   fp_recipe_chem_conversion.

2. PROMOTE / DEMOTE. Simple Editor had no way to turn a substep into
   a top-level operation, or to tuck an operation under another as a
   substep. Authors had to delete + re-create. New endpoints:

   * /fp/simple_recipe/step/promote → flips node_type 'step' →
     'operation', re-parents to the recipe (or sub-process) root,
     places right after the old parent operation.
   * /fp/simple_recipe/step/demote → flips 'operation' → 'step',
     re-parents under the preceding operation (or a caller-supplied
     target_op_id). Blocks demoting an operation that has its own
     children, with a helpful message.

   UI: each row in the editor now carries an up-arrow (promote, only
   shown on substeps) and a down-arrow (demote, only shown on
   operations). Confirmation dialog explains what's about to happen.

3. DRAG SUBSTEPS. Last commit (2142a66b) disabled drag on substep
   rows. Operators couldn't reorder substeps within an operation.
   Re-enabled drag on substeps. The step_reorder endpoint now groups
   incoming node_ids by parent_id and renumbers within each parent
   (10, 20, 30…). Cross-parent drag still no-ops on parent change —
   Promote/Demote buttons are the way to move between parents.

Drive-by:
- Added `from odoo import _` to the controller (missing import the
  new endpoints surfaced).
- Edit-panel field wiring audited: all fields visible in the screen
  (Step name, Default instructions, Step Type, Triggers Workflow,
  Parallel Start, QA Sign-off, Collect measurements, Instruction
  Images, custom prompts) persist correctly through step_write or
  dedicated endpoints. No broken wires.

Tests: 15 total in TestSimpleRecipeFlatten (was 10). 5 new cover
promote happy-path, promote reject (non-substep), demote happy-path,
demote block on has_children, and reorder parent-scoping.

Module: fusion_plating 19.0.20.4.0 → 19.0.20.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:53:09 -04:00
gsinghpal
2142a66bc0 fix(simple-editor): also surface step children of operations
Follow-up to 821e768b. The previous fix flattened sub_process nodes
so all 16 operations of ENP-STEEL-BASIC became visible — but the
Tree Editor also shows the 26 `step` nodes that live under each
operation ("Ready For Blast / Blast", "Soak Clean / Electroclean /
Primary Rinse", etc.). The Simple Editor still hid those, so author
+ Tree Editor still disagreed by 26 rows.

New `_flatten_recipe_nodes(recipe)` helper walks DFS and surfaces
BOTH operations and their step children. Each operation is followed
immediately by its step children in sequence order so the editor
renders them as a contiguous block:

  10. Ready For Steel Line
  11. Cleaner                            [Steel Line]
     ↳ Soak Clean (S-3)                  [Steel Line › Cleaner]
     ↳ Electroclean (S-3)                [Steel Line › Cleaner]
     ↳ Primary Rinse (S-4)               [Steel Line › Cleaner]
  15. Acid Dip (S-5)                     [Steel Line]
     ↳ Primary Rinse (S-6)               [Steel Line › Acid Dip (S-5)]
     ...

Payload additions on each step:
- `node_type`: 'operation' | 'step'
- `is_substep`: True for steps (renders indented)
- `nested_under`: chained path (sub-process › operation for substeps,
  sub-process for nested operations, '' for top-level operations)

UI: substep rows are indented 2.5rem, smaller font, no drag handle,
no numeric position. The "↳" indent glyph and a "[parent operation]"
chip make the parent-child relationship obvious. Substeps are not
draggable to keep the existing reorder semantics simple — Tree Editor
remains the home for structural changes.

Legacy `_flatten_recipe_operations` helper retained for back-compat
(it now delegates by filtering `node.node_type == 'operation'` from
the full walk).

ENP-STEEL-BASIC on entech: Simple Editor now shows 42 rows (was 10
before 821e768b, was 16 after 821e768b) — matches what the Tree
Editor displays exactly.

Tests: 10 total (was 7), 3 new cover the substep surfacing, path
chaining, and is_substep / node_type flags on the payload.

Module: fusion_plating 19.0.20.3.0 → 19.0.20.4.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:30:00 -04:00
gsinghpal
821e768b7e fix(simple-editor): surface operations nested inside sub_process nodes
Bug on ENP-STEEL-BASIC (2026-05-20): authoring used the Tree Editor
to build a recipe with a "Steel Line" sub_process holding 7 nested
operations (Cleaner, Acid Dip, Nickel Strike, E-Nickel Plate, etc.).
The Simple Editor's /fp/simple_recipe/load endpoint only walked
`recipe.child_ids`, so it returned 10 steps. The work order generator
(fp.job._generate_steps) walked the same tree depth-first and emitted
16 steps. Author and operator disagreed about what was in the recipe.

Fix: new `_flatten_recipe_operations(recipe)` helper walks the tree
depth-first, recurses into `recipe` and `sub_process`, emits each
`operation` exactly once, skips `step` children (they're sub-
instructions of operations). Mirrors the WO walker.

Step payload now carries a `nested_under` string — the chained sub-
process name(s) the operation lives inside (empty for top-level).
The Simple Editor XML renders that as a small "↳ Steel Line" badge
next to the step name so the author can see where each row came from
in the tree. Deep nesting chains with ' › ' (e.g. "Outer › Inner").

`step` children of `recipe` itself remain invisible — they were
silently skipped by the WO generator pre-19.0.18.8.0 anyway (only
operation nodes spawn fp.job.step rows). Restoring them here would
contradict that long-standing contract.

Edit/insert/reorder/remove endpoints unchanged: editing a nested
operation's name / description / tanks works (no parent change).
Drag-reorder within sub-process siblings still works. Drag across
sub-process boundaries isn't supported — opens the door for a Tree
Editor follow-up if needed, but the immediate "I can't see my
steps" complaint is resolved.

ENP-STEEL-BASIC on entech now shows all 16 operations in the Simple
Editor (was 10), with the 7 inside Steel Line tagged accordingly.

Tests: 7 new (TestSimpleRecipeFlatten) — flat recipes still work,
nested operations surface with correct path label, sub_process
nodes never appear as editor rows, step children of operations
stay hidden, deep-nested sub_processes chain path labels.

Module: fusion_plating 19.0.20.2.0 → 19.0.20.3.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:22:54 -04:00
gsinghpal
2645db40a2 fix(receiving): propagate qty_received to fp.job + drop duplicate carrier field
Bug surfaced on WO-30043 (2026-05-20): operator walked every step
including a fully closed receiving record, then hit
"Quantity Received is blank — close the receiving record for
SO SO-30043 before completing this job." Receiving WAS closed.

Root cause: the 2026-05-18 cert-creation gate
(fp.job.button_mark_done) blocks on job.qty_received but nothing
populated it. fp.receiving carried the qty on its line records,
fp.job stayed at 0 indefinitely. Two disconnected records on the
same SO.

Fix: when fp.receiving._update_so_receiving_status runs (i.e. on
every state transition — counted / staged / closed / accepted /
resolved), also mirror each line's received_qty onto the matching
fp.job by (sale_order_id + part_catalog_id). Single-part SOs map
1-to-1; multi-part SOs spawn one job per line so the same join
still works.

Two defensive guards in the hook:
- Skip silently when fusion_plating_jobs not installed
  (Job = env.get('fp.job') returns None).
- Skip silently when fp.job doesn't yet carry part_catalog_id /
  qty_received (test scope, unusual install topology).

Drive-by during cleanup:
- fp_parent_numbered_mixin._fp_assign_parent_name: guard
  so.x_fc_parent_number access with field-existence check. The
  column lives in fusion_plating_jobs; downstream modules that
  inherit the mixin (receiving) but don't depend on jobs were
  hitting AttributeError on every fp.receiving.create at test
  time. Falls through to the legacy sequence when the column
  isn't there.

- fp_receiving_views.xml: legacy carrier_name Char field rendered
  as a second carrier row labeled "Legacy Carrier" alongside the
  proper x_fc_carrier_id M2O — operators saw two carrier fields
  and got confused. Hide the legacy display (data stays in DB for
  audit; migration 19.0.3.10.0 already matched it to a real
  delivery.carrier).

Migration 19.0.3.19.0/post-migrate.py backfills qty_received from
closed receiving lines for any job stuck at 0 — fixes WO-30043
and two sibling jobs on entech.

Modules: fusion_plating 19.0.20.2.0, fusion_plating_receiving
19.0.3.19.0, fusion_plating_jobs 19.0.10.15.0.

All 19 tests green (TestCarrierFields 6, TestQtyReceivedPropagation 5
new, TestReceivingGate 8). Direct verification on entech: WO-30043
qty_received = 1, mark_done succeeds, delivery + cert auto-created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:15:46 -04:00
gsinghpal
60eb2adef3 fix(claims): intake_mode title above radio, not on the left
Switched the section title from group string= (which Odoo was rendering
as a left-side column label) to a real <separator/>, so the heading
sits above the radio and the options use the full form width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:48:37 -04:00
gsinghpal
e3bec557b6 fix(claims): restore long intake_mode labels, give group full width
Reverts the label shortening and instead sets col=1 on the radio group
so the group's inner layout is a single column. With the full wizard
width available, the full labels fit on one line each.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:41:51 -04:00
gsinghpal
6a1640ff6d fix(claims): shorten intake_mode labels — single line in radio
The group title already says "How were pages 11 & 12 provided?", so the
radio labels don't need to repeat "Pages 11 & 12". Shortened to:
"Inside the original application" / "Separate file" / "Sign remotely".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:37:31 -04:00
gsinghpal
10f5d44965 chore(claims): bump version to 19.0.8.0.7
Bumps fusion_claims version to bust the asset bundle cache after the
Application Received wizard refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:01:32 -04:00
gsinghpal
a4d615d74e feat(claims): wizard view — intake-mode radio + conditional groups
Three-mode radio at the top of the Application Received wizard. The
Signed Pages 11 & 12 group is only shown in Separate mode; the remote
sign banner/button is only shown in Remote mode. Adds a read-only
'Detected pages' indicator next to the uploaded original PDF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:01:08 -04:00
gsinghpal
f5ac8d07d7 feat(claims): three-mode Application Received wizard
Adds intake_mode (bundled / separate / remote) so staff can mark
applications received with a single bundled PDF, the existing
separate-pages-file flow, or a pending remote signature. Folds in
content-based PDF validation, a friendlier status-gate message,
and a page-count helper for the original application.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:00:41 -04:00
gsinghpal
50539741ce feat(claims): case-close audit accepts bundled pages flag
The signed-pages verification step on case close now treats the bundled
flag as 'pages present', matching the ready-for-submission gate and the
audit trail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:20:56 -04:00
gsinghpal
7a891c5aaa feat(claims): ready-for-submission gate accepts bundled pages flag
Both the has_documents indicator and the action_confirm missing-items
gate now read x_fc_has_signed_pages_11_12, so orders with pages 11 & 12
bundled inside the original PDF can move to Ready for Submission without
a separate signed-pages file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:20:35 -04:00
gsinghpal
3bef640979 feat(claims): audit trail honours bundled pages flag
x_fc_trail_has_signed_pages now reads x_fc_has_signed_pages_11_12, so
the trail correctly shows complete when pages 11 & 12 are bundled inside
the original application.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:15:52 -04:00
gsinghpal
1f20eb3d2a feat(claims): add x_fc_pages_11_12_in_original + computed gate
New boolean on sale.order tracks whether pages 11 & 12 are bundled
inside the original application PDF. Computed helper
x_fc_has_signed_pages_11_12 ORs bundled flag with separate-file and
remote-signing presence so downstream gates can read one field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:15:50 -04:00
gsinghpal
df53ab956f docs(plan): ADP application received — bundled pages 11 & 12
Seven-task TDD implementation plan for the design at
2026-05-19-adp-application-received-bundled-pages-design.md. Adds the
bundled-flag + computed gate to sale.order, updates downstream gates
(ready-for-submission, case-close, audit trail), rewrites the
Application Received wizard with a three-mode radio, and bumps the
module version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:26:10 -04:00
gsinghpal
5ff271a7b1 docs(spec): ADP application received — bundled pages 11 & 12 design
Design for refining the Application Received wizard so staff can mark
applications received with a single PDF when pages 11 & 12 are inside
the original application — without losing the existing separate-file
and remote-signing paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:19:32 -04:00
gsinghpal
8831176ec4 feat(certificates): Fischerscope thickness-report upload wizard
Operators now drop a .docx or .pdf Fischerscope XDAL 600 export
on the cert form's Thickness Report tab. The wizard parses the
readings, calibration std, operator + date metadata, and the
embedded microscope image, then shows them for review before
recording on fp.certificate.

  Operator         Wizard               Certificate
  ─────────────────────────────────────────────────────────────
  Click "Upload    Parse .docx /        - thickness_reading_ids
   Thickness         .pdf →               written (3 rows)
   Report"         Show 3 readings      - x_fc_local_thickness
  Pick file        + metadata             _pdf attached (original
  Click Parse      Click Save             file)
                                        - microscope image as
                                          ir.attachment on cert
                                        - chatter post
  ─────────────────────────────────────────────────────────────

When parse can't find readings (unrecognised format), wizard falls
through to manual state — operator can still save, file lands on
the cert as-is for the existing CoC page-2 merge logic.

Closes the gap in the S19 enforcement: x_fc_send_thickness_report
customers blocked at action_issue until the file is on file. Now
they have a parseable upload UX, not just a bare Binary field.

Architecture
- fischerscope_parser.py: pure-Python lib, branches on extension,
  python-docx + PyPDF2 already on entech (no new deps). Regex
  extraction returns {readings, metadata, image, errors}.
- fp.thickness.upload.wizard: TransientModel with upload/review/
  manual states. Lazy-imports parser at action_parse time to dodge
  Python 3.11 partial-init relative-import error.
- 27 tests (TestFischerscopeParser 9 + TestThicknessUploadWizard 8
  + the rehoused TestActionIssueGates 10) — all green on entech.

Same metadata copies onto every reading row, microscope image
attaches once at cert level (decisions 2026-05-19).

Drive-by fixes uncovered while running tests on entech:
- fp.certificate.action_issue: guard rec.company_id access with
  field-existence check. Lazy-fill-signer branch crashed when
  certified_by_id was unset on certs that don't carry a company_id
  field. Pre-existing bug that never fired in production because
  jobs auto-fill certified_by_id before reaching this branch.
- test_action_issue_gates: set x_fc_send_thickness_report=False on
  the test partner. Field defaults to True so every cert in this
  class hit the thickness gate; tests were never able to verify
  the other gates in isolation.
- Tests directory missing test_action_issue_gates.py on entech.
  Synced; turns out the 2026-05-18 "changes" commit added the file
  locally but the deploy script never copied tests/.

Module: fusion_plating_certificates 19.0.6.4.0 → 19.0.7.0.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:05:16 -04:00
gsinghpal
d77cc252bb fix(tests): TestReceivingGate — drop nonexistent step_kind_id, use step name
The helper set step_kind_id on fp.job.step when fp.step.kind model
exists, but step_kind_id field doesn't actually exist on fp.job.step
in deployed shape — both test_start_skips_contract_review and
test_finish_skips_contract_review erred with
  ValueError: Invalid field 'step_kind_id' in 'fp.job.step'

Per CLAUDE.md rule 18, _fp_is_contract_review_step() matches step
name case-insensitive against 'contract review' or 'qa-005'. The
test only needs to trigger that detection — set name='Contract
Review' on the CR branch and let the receiving gate's existing
exemption fire.

All 8 TestReceivingGate tests now pass on entech.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:21:53 -04:00
gsinghpal
091f98e1f9 changes 2026-05-18 22:33:23 -04:00
155 changed files with 20290 additions and 758 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

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

View File

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

View File

View 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
View File

@@ -0,0 +1 @@
.superpowers/

3106
fusion_claims/CLAUDE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.8.0.6',
'version': '19.0.9.1.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -175,6 +175,18 @@
'fusion_claims/static/src/js/attachment_image_compress.js',
'fusion_claims/static/src/js/debug_required_fields.js',
'fusion_claims/static/src/xml/document_preview.xml',
# Dashboard: tokens MUST load before dashboard layout
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
# Dashboard OWL countdown widget
'fusion_claims/static/src/js/fc_posting_countdown.js',
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
],
'web.assets_web_dark': [
# Dark bundle recompiles the same SCSS with the dark
# $o-webclient-color-scheme default so tokens branch correctly.
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
],
},
'images': ['static/description/icon.png'],

File diff suppressed because it is too large Load Diff

View File

@@ -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)
- `13 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.

View File

@@ -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',
}

View File

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

View 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,
});

View 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;
}

View File

@@ -0,0 +1,249 @@
// =============================================================================
// Fusion Claims Dashboard — Layout & Section Styles
// Consumes tokens from _fc_dashboard_tokens.scss (must load FIRST in bundle).
// =============================================================================
// Override Odoo's form-sheet max-width so the dashboard uses the full
// browser width. The selector matches the form (which carries the class)
// and targets the inner sheet element.
.o_fc_dashboard .o_form_sheet,
.o_form_view.o_fc_dashboard .o_form_sheet {
max-width: none;
width: 100%;
}
.o_fc_dashboard {
// Re-export tokens as CSS custom properties for devtools inspection
--fc-page-bg: #{$_fc-page-bg};
--fc-card-bg: #{$_fc-card-bg};
--fc-card-border: #{$_fc-card-border};
--fc-text: #{$_fc-text};
--fc-text-muted: #{$_fc-text-muted};
--fc-banner-from: #{$_fc-banner-from};
--fc-banner-to: #{$_fc-banner-to};
--fc-banner-border: #{$_fc-banner-border};
--fc-banner-text: #{$_fc-banner-text};
--fc-deadline-text: #{$_fc-deadline-text};
--fc-kpi-bg: #{$_fc-kpi-bg};
--fc-kpi-border: #{$_fc-kpi-border};
--fc-kpi-num: #{$_fc-kpi-num};
--fc-action-bg: #{$_fc-action-bg};
--fc-action-border: #{$_fc-action-border};
--fc-action-text: #{$_fc-action-text};
--fc-tile-bg: #{$_fc-tile-bg};
--fc-tile-border: #{$_fc-tile-border};
--fc-tile-num: #{$_fc-tile-num};
--fc-urgent-bg: #{$_fc-urgent-bg};
--fc-urgent-border: #{$_fc-urgent-border};
--fc-urgent-num: #{$_fc-urgent-num};
--fc-urgent-text: #{$_fc-urgent-text};
--fc-activity-bg: #{$_fc-activity-bg};
--fc-activity-border: #{$_fc-activity-border};
--fc-bottleneck-bg: #{$_fc-bottleneck-bg};
--fc-bottleneck-border: #{$_fc-bottleneck-border};
background: var(--fc-page-bg);
color: $_fc-text;
.o_fc_banner {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(90deg, var(--fc-banner-from), var(--fc-banner-to));
border: 1px solid var(--fc-banner-border);
border-radius: 8px;
padding: 10px 14px;
font-weight: 600;
color: var(--fc-banner-text);
}
.o_fc_banner__deadline { font-weight: 700; }
.o_fc_kpi {
background: var(--fc-kpi-bg);
border: 1px solid var(--fc-kpi-border);
border-radius: 8px;
padding: 14px 10px;
text-align: center;
transition: transform 0.15s ease;
&:hover { transform: translateY(-2px); }
}
.o_fc_kpi__num {
display: block;
font-size: 1.6rem;
font-weight: 700;
color: var(--fc-kpi-num);
}
.o_fc_kpi__lbl {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--fc-text-muted);
margin-top: 2px;
}
// Secondary KPI variant — smaller, denser. Used for "This Month" and
// "Pipeline by stage" tile strips.
.o_fc_kpi--secondary {
padding: 10px 6px;
.o_fc_kpi__num { font-size: 1.15rem; }
.o_fc_kpi__lbl { font-size: 0.68rem; }
}
.o_fc_actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.o_fc_pill {
background: var(--fc-action-bg);
border: 1px solid var(--fc-action-border);
color: var(--fc-action-text);
border-radius: 16px;
padding: 5px 12px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
&:hover { background: var(--fc-action-border); }
}
.o_fc_section {
background: var(--fc-card-bg);
border: 1px solid var(--fc-card-border);
border-radius: 8px;
padding: 10px 12px;
}
.o_fc_h6 {
display: flex;
align-items: center;
font-size: 0.9rem;
font-weight: 700;
margin-bottom: 8px;
color: var(--fc-text);
}
.o_fc_tag {
display: inline-block;
font-size: 0.65rem;
padding: 2px 7px;
border-radius: 4px;
background: var(--fc-banner-border);
color: var(--fc-banner-text);
margin-left: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.o_fc_tile {
background: var(--fc-tile-bg);
border: 1px solid var(--fc-tile-border);
border-radius: 6px;
padding: 8px 6px;
text-align: center;
font-size: 0.75rem;
line-height: 1.3;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
}
.o_fc_tile__num {
display: block;
font-size: 1.3rem;
font-weight: 700;
color: var(--fc-tile-num);
margin-bottom: 2px;
}
.o_fc_tile--urgent {
background: var(--fc-urgent-bg);
border-color: var(--fc-urgent-border);
color: var(--fc-urgent-text);
.o_fc_tile__num { color: var(--fc-urgent-num); }
}
.o_fc_activities {
background: var(--fc-activity-bg);
border: 1px solid var(--fc-activity-border);
border-radius: 8px;
padding: 10px 12px;
}
.o_fc_activity_row {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px dashed var(--fc-card-border);
font-size: 0.85rem;
&:last-child { border-bottom: none; }
}
.o_fc_activity_overdue {
color: var(--fc-urgent-text);
font-weight: 600;
}
.o_fc_activity_deadline { color: var(--fc-text-muted); }
.o_fc_empty {
color: var(--fc-text-muted);
font-style: italic;
text-align: center;
padding: 12px;
margin: 0;
}
.o_fc_bottleneck {
background: var(--fc-bottleneck-bg);
border: 1px solid var(--fc-bottleneck-border);
border-radius: 8px;
padding: 10px 12px;
}
.o_fc_bottleneck_row {
display: block;
width: 100%;
text-align: left;
padding: 4px 0;
color: var(--fc-text);
text-decoration: none;
&:hover { color: var(--fc-urgent-num); text-decoration: underline; }
}
// Recent ADP Exports list rows
.o_fc_export_row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px dashed var(--fc-card-border);
font-size: 0.85rem;
&:last-child { border-bottom: none; }
}
.o_fc_export_label small {
color: var(--fc-text-muted);
font-size: 0.72rem;
}
.o_fc_export_amount {
font-weight: 700;
color: var(--fc-kpi-num);
font-variant-numeric: tabular-nums;
}
// Countdown widget colour levels (driven by OWL state)
.o_fc_countdown {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-weight: 700;
font-size: 0.85rem;
}
.o_fc_countdown--info { color: var(--fc-banner-text); }
.o_fc_countdown--warning { color: #d97706; } // amber (intentional fixed hex)
.o_fc_countdown--danger { color: var(--fc-urgent-num); }
.o_fc_countdown--muted { color: var(--fc-text-muted); font-style: italic; }
}

View 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>

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import test_signed_pages_gate
from . import test_application_received_wizard
from . import test_dashboard

View 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}",
)

View 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")

View 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)

View File

@@ -4,151 +4,530 @@
<field name="name">fusion.claims.dashboard.form</field>
<field name="model">fusion.claims.dashboard</field>
<field name="arch" type="xml">
<form string="Dashboard" create="0" delete="0">
<form string="Dashboard" create="0" delete="0" edit="0"
class="o_fc_dashboard">
<sheet>
<!-- ===== FUNDING CARDS (one line, bigger) ===== -->
<div class="d-flex flex-nowrap gap-2 mb-4 overflow-auto">
<div invisible="adp_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_adp" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="adp_count"/></div>
<div style="font-size: 0.85rem;">ADP</div>
</div>
</button>
<!-- Hidden invariants used by buttons + widgets -->
<field name="currency_id" invisible="1"/>
<field name="posting_period_start" invisible="1"/>
<field name="is_manager" invisible="1"/>
<field name="is_pre_first_posting" invisible="1"/>
<!-- BANNER -->
<div class="o_fc_banner mb-3">
<div class="o_fc_banner__label">
<i class="fa fa-calendar me-2"/>
<span>Posting Period: </span>
<field name="posting_period_label" nolabel="1"
class="fw-bold"/>
</div>
<div invisible="odsp_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_odsp" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="odsp_count"/></div>
<div style="font-size: 0.85rem;">ODSP</div>
</div>
</button>
</div>
<div invisible="march_of_dimes_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_march" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="march_of_dimes_count"/></div>
<div style="font-size: 0.85rem;">March of Dimes</div>
</div>
</button>
</div>
<div invisible="hardship_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_hardship" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="hardship_count"/></div>
<div style="font-size: 0.85rem;">Hardship</div>
</div>
</button>
</div>
<div invisible="acsd_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_acsd" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="acsd_count"/></div>
<div style="font-size: 0.85rem;">ACSD</div>
</div>
</button>
</div>
<div invisible="muscular_dystrophy_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_muscular" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="muscular_dystrophy_count"/></div>
<div style="font-size: 0.85rem;">Muscular Dystrophy</div>
</div>
</button>
</div>
<div invisible="insurance_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_insurance" type="object" class="btn p-0 w-100 border-0">
<div class="text-dark text-center py-3 px-2" style="background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="insurance_count"/></div>
<div style="font-size: 0.85rem;">Insurance</div>
</div>
</button>
</div>
<div invisible="wsib_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_wsib" type="object" class="btn p-0 w-100 border-0">
<div class="text-dark text-center py-3 px-2" style="background: linear-gradient(135deg, #ff9a9e 0%, #fad0c4 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="wsib_count"/></div>
<div style="font-size: 0.85rem;">WSIB</div>
</div>
</button>
</div>
<div invisible="total_profiles == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_profiles" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #30cfd0 0%, #330867 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="total_profiles"/></div>
<div style="font-size: 0.85rem;">Profiles</div>
</div>
</button>
<div class="o_fc_banner__deadline">
<field name="submission_deadline_dt"
widget="fc_posting_countdown"
nolabel="1" readonly="1"/>
</div>
</div>
<!-- ===== PANEL SELECTORS (4 dropdowns) ===== -->
<!-- "Showing your cases" hint when role-filtered -->
<div class="alert alert-info py-2 mb-2"
invisible="is_manager">
Showing your assigned cases only.
</div>
<!-- KPI TILES (3-up) -->
<div class="row g-2 mb-3">
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 1</div>
<field name="panel1_type" nolabel="1"/>
<div class="col-12 col-md-4">
<button name="action_open_kpi_ready" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi">
<span class="o_fc_kpi__num">
<field name="kpi_ready_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Ready to Claim
(<field name="kpi_ready_count" nolabel="1"/>)
</span>
</div>
</button>
</div>
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 2</div>
<field name="panel2_type" nolabel="1"/>
<div class="col-12 col-md-4">
<button name="action_open_kpi_claimed" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi">
<span class="o_fc_kpi__num">
<field name="kpi_claimed_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Claimed This Period
(<field name="kpi_claimed_count" nolabel="1"/>)
</span>
</div>
</button>
</div>
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 3</div>
<field name="panel3_type" nolabel="1"/>
</div>
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 4</div>
<field name="panel4_type" nolabel="1"/>
<div class="col-12 col-md-4">
<button name="action_open_kpi_ar" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi">
<span class="o_fc_kpi__num">
<field name="kpi_ar_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Total AR
(<field name="kpi_ar_count" nolabel="1"/>)
</span>
</div>
</button>
</div>
</div>
<!-- ===== TOP PANELS ROW 1 ===== -->
<div class="row g-3 mb-3">
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<field name="panel1_title" nolabel="1"/>
<!-- THIS MONTH ROLLUP (4 count tiles) -->
<div class="row g-2 mb-3">
<div class="col-6 col-md-3">
<button name="action_open_month_submitted" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num"><field name="count_month_submitted" nolabel="1"/></span>
<span class="o_fc_kpi__lbl">Submitted MTD</span>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel1_html" class="w-100" nolabel="1"/>
</div>
</div>
</button>
</div>
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<field name="panel2_title" nolabel="1"/>
<div class="col-6 col-md-3">
<button name="action_open_month_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num"><field name="count_month_approved" nolabel="1"/></span>
<span class="o_fc_kpi__lbl">Approved MTD</span>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel2_html" class="w-100" nolabel="1"/>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_month_delivered" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num"><field name="count_month_delivered" nolabel="1"/></span>
<span class="o_fc_kpi__lbl">Delivered MTD</span>
</div>
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_month_billed" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num"><field name="count_month_billed" nolabel="1"/></span>
<span class="o_fc_kpi__lbl">Billed MTD</span>
</div>
</button>
</div>
</div>
<!-- ===== TOP PANELS ROW 2 ===== -->
<!-- PIPELINE $ BY STAGE (4 amount tiles) -->
<div class="row g-2 mb-3">
<div class="col-6 col-md-3">
<button name="action_open_pipeline_pre" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num">
<field name="pipeline_pre_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Pipeline · Pre-Submit</span>
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_pipeline_submitted" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num">
<field name="pipeline_submitted_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Pipeline · Submitted</span>
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num">
<field name="pipeline_approved_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Pipeline · Approved</span>
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_bill" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num">
<field name="pipeline_ready_bill_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Pipeline · Ready to Bill</span>
</div>
</button>
</div>
</div>
<!-- QUICK ACTION PILLS -->
<div class="o_fc_actions mb-3">
<button name="action_create_adp_so" type="object"
class="o_fc_pill">+ ADP</button>
<button name="action_create_mod_so" type="object"
class="o_fc_pill">+ MOD</button>
<button name="action_create_odsp_so" type="object"
class="o_fc_pill">+ ODSP</button>
<button name="action_create_wsib_so" type="object"
class="o_fc_pill">+ WSIB</button>
<button name="action_create_insurance_so" type="object"
class="o_fc_pill">+ Insurance</button>
<button name="action_create_mdc_so" type="object"
class="o_fc_pill">+ MDC</button>
<button name="action_create_hardship_so" type="object"
class="o_fc_pill">+ Hardship</button>
<button name="action_create_private_so" type="object"
class="o_fc_pill">+ Private</button>
</div>
<!-- 2-COLUMN GRID -->
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<field name="panel3_title" nolabel="1"/>
<!-- LEFT COLUMN -->
<div class="col-12 col-lg-5">
<!-- Your Activities -->
<div class="o_fc_activities mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-thumb-tack me-2"/>
Your Activities
<span class="o_fc_tag">
<field name="my_activities_count" nolabel="1"/>
</span>
<button name="action_open_my_activities" type="object"
class="btn btn-link btn-sm ms-auto p-0">
View all
</button>
</h6>
<field name="my_activities_html" nolabel="1"/>
</div>
<!-- Bottlenecks -->
<div class="o_fc_bottleneck mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-exclamation-triangle me-2"/>
Bottlenecks
</h6>
<button name="action_open_bottleneck_no_pod" type="object"
class="o_fc_bottleneck_row btn btn-link p-0">
Approved without POD:
<span class="fw-bold ms-1">
<field name="bottleneck_no_pod_count" nolabel="1"/>
</span>
</button>
<button name="action_open_bottleneck_no_response" type="object"
class="o_fc_bottleneck_row btn btn-link p-0">
Submitted &gt; 14d, no response:
<span class="fw-bold ms-1">
<field name="bottleneck_no_response_count" nolabel="1"/>
</span>
</button>
</div>
<!-- Aging buckets -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-clock-o me-2"/>
Aging
</h6>
<div class="row g-2">
<div class="col-4">
<button name="action_open_aging_30" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="aging_30_count" nolabel="1"/>
</span>30 59d
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_aging_60" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="aging_60_count" nolabel="1"/>
</span>60 89d
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_aging_90" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="aging_90_count" nolabel="1"/>
</span>90+ d
</div>
</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel3_html" class="w-100" nolabel="1"/>
</div>
<!-- Other Funders -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">Other Funders</h6>
<div class="row g-2">
<div class="col-4 col-xl-2">
<button name="action_open_odsp_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_odsp" nolabel="1"/>
</span>ODSP
</div>
</button>
</div>
<div class="col-4 col-xl-2">
<button name="action_open_wsib_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_wsib" nolabel="1"/>
</span>WSIB
</div>
</button>
</div>
<div class="col-4 col-xl-2">
<button name="action_open_insurance_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_insurance" nolabel="1"/>
</span>Insurance
</div>
</button>
</div>
<div class="col-4 col-xl-2">
<button name="action_open_mdc_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_mdc" nolabel="1"/>
</span>MDC
</div>
</button>
</div>
<div class="col-4 col-xl-2">
<button name="action_open_hardship_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_hardship" nolabel="1"/>
</span>Hardship
</div>
</button>
</div>
<div class="col-4 col-xl-2">
<button name="action_open_acsd_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_acsd" nolabel="1"/>
</span>ACSD
</div>
</button>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">
<field name="panel4_title" nolabel="1"/>
<!-- RIGHT COLUMN -->
<div class="col-12 col-lg-7">
<!-- ADP Pre-Approval -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">ADP
<span class="o_fc_tag">Pre-Approval</span>
</h6>
<div class="row g-2">
<div class="col-6 col-md-3">
<button name="action_open_adp_waiting_app" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="adp_waiting_app_count" nolabel="1"/>
</span>Waiting App
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_app_received" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_app_received_count" nolabel="1"/>
</span>App Received
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_submit" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_ready_submit_count" nolabel="1"/>
</span>Ready Submit
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_needs_correction" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="adp_needs_correction_count" nolabel="1"/>
</span>Needs Correction
</div>
</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel4_html" class="w-100" nolabel="1"/>
</div>
<!-- ADP Post-Approval -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">ADP
<span class="o_fc_tag">Post-Approval</span>
</h6>
<div class="row g-2">
<div class="col-6 col-md-3">
<button name="action_open_adp_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_approved_count" nolabel="1"/>
</span>Approved
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_delivery" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_ready_delivery_count" nolabel="1"/>
</span>Ready Delivery
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_bill" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_ready_bill_count" nolabel="1"/>
</span>Ready Bill
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_on_hold" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="adp_on_hold_count" nolabel="1"/>
</span>On Hold
</div>
</button>
</div>
</div>
</div>
<!-- MOD -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">MOD</h6>
<div class="row g-2">
<div class="col-6 col-md-2">
<button name="action_open_mod_awaiting_funding" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_awaiting_funding_count" nolabel="1"/>
</span>Awaiting
</div>
</button>
</div>
<div class="col-6 col-md-2">
<button name="action_open_mod_funding_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_funding_approved_count" nolabel="1"/>
</span>Approved
</div>
</button>
</div>
<div class="col-6 col-md-2">
<button name="action_open_mod_pca_received" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_pca_received_count" nolabel="1"/>
</span>PCA
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_mod_project_complete" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_project_complete_count" nolabel="1"/>
</span>Proj. Done
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_mod_pod_submitted" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_pod_submitted_count" nolabel="1"/>
</span>POD Submitted
</div>
</button>
</div>
</div>
</div>
<!-- Recent ADP Exports (last 5) -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-file-text-o me-2"/>
Recent ADP Exports
<span class="o_fc_tag">
<field name="recent_exports_count" nolabel="1"/>
</span>
<button name="action_open_recent_exports" type="object"
class="btn btn-link btn-sm ms-auto p-0">
View all
</button>
</h6>
<field name="recent_exports_html" nolabel="1"/>
</div>
</div>
</div>
</sheet>
</form>
</field>
@@ -162,4 +541,13 @@
<field name="view_id" ref="view_fusion_claims_dashboard_form"/>
<field name="target">current</field>
</record>
<!-- Dashboard Menu — top of the Fusion Claims app, sequence=1 so it
renders before "All Orders" (sequence=2) and becomes the default
landing when clicking the app icon. -->
<menuitem id="menu_fusion_claims_dashboard"
name="Dashboard"
parent="menu_adp_claims_root"
action="action_fusion_claims_dashboard"
sequence="1"/>
</odoo>

View File

@@ -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',
)

View File

@@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>

View File

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

View File

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

View File

@@ -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",
],

View File

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

View 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',
}

View 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 || "",
});
}
);

View File

@@ -27,6 +27,22 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
| **Signature unification** | All FP reports (WO Detail, CoC, CoC Chronological) now read signatures from a single source: `signer_user.x_fc_signature_image` (Plating Signature). Retired: HR Employee signature lookup AND `res.company.x_fc_coc_signature_override` (UI removed; column kept, no migration). See rule 14b. | `fusion_plating_certificates`, `fusion_plating_reports`, `fusion_plating_jobs` |
| **Report palette overhaul** | Green `res.company.primary_color` → hardcoded neutral palette: `#c1c1c1` header backgrounds, `#1d1f1e` th text, `#2e2e2e` h2/h4 titles (bumped to 20pt portrait / 22pt landscape). Grand Total row also `#c1c1c1`. Work Order Detail blue `#1a4d80` retired in favour of the same palette. Title format now "Type # Number" (Quotation # …, Sales Order # …, Invoice # …, Packing Slip # …, Work Order Traveller # …). See rule 14a. | `fusion_plating_reports` 19.0.11.14.0, `fusion_plating_jobs` 19.0.10.8.0 |
| **Report border rendering** | After two failed attempts (px→mm conversion + dpi bump; then `border-collapse: separate` single-side-per-cell), settled on **`border-collapse: collapse` + longhand borders + `background-clip: padding-box`**. Verticals are a hair softer than horizontals on entech wkhtmltopdf — accepted as the lesser evil vs misaligned tables. See rule 14a, last paragraph. **Don't retry the single-side pattern.** | `fusion_plating_reports` |
| **Page-break-inside: avoid placement** | When a long QWeb report dumps content into multi-page PDFs via wkhtmltopdf, the company header (rendered as `--header-html`) can overlap body content if a page break lands mid-row in a table. **Apply `page-break-inside: avoid` to `<tr>` elements** (and to wrapper `<div>`s that wrap whole logical sections like signature blocks), not to `<table>`. On entech wkhtmltopdf, `<table>`-level `page-break-inside` is unreliable when the table is long enough to definitely break; per-row is honoured. Pattern: keep individual readings/rows together so the wkhtmltopdf header zone never overlaps mid-row content. Wrap the larger logical block (cert thickness section, signature + certification statement) in `<div style="page-break-inside: avoid;">` to keep it together when it fits and naturally wrap to a fresh page when it doesn't. | `fusion_plating_reports/report/report_coc.xml` |
| **`opacity` + `italic` muted text renders jagged on entech wkhtmltopdf** | The obvious pattern for a subtle footnote — `font-style: italic; opacity: 0.7;` (used by `.fp-coc .small-label`) — produces washed-out, jagged characters that look "broken" or "messed up" on the printed PDF. Visually it reads as garbled text even though the source is clean. **Use solid grey (`color: #555`) at normal weight instead** for muted secondary text. Same workaround applies to any `opacity`-driven greyed-out element bound for wkhtmltopdf. The existing `.small-label` class still exists for legacy callers but new code should prefer an explicit `color:` style. | `fusion_plating_reports` |
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix:** size `paperformat.margin_top` to the actual rendered header height, then drop body `padding-top` to a tiny visual gap (~5mm). Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12). Update the right one and don't bleed changes across reports. | `fusion_plating_reports`, `report.paperformat` |
| **CoC + thickness = ONE cert (page 2 merge OR inline body)** | When a customer has both `x_fc_send_coc` and `x_fc_send_thickness_report` on (or part has `certificate_requirement='coc_thickness'`), `_resolve_required_cert_types` returns **`{'coc'}` only**. Standalone `thickness_report` certs are only created when CoC is OFF and thickness is ON (rare). The earlier "two certs" behavior was a bug — don't restore it. **Two rendering paths exist for the thickness data in the CoC PDF:** (1) **Page-2 PDF merge** via `_fp_merge_thickness_into_pdf` — used when there's a real PDF source (operator uploaded a Fischerscope PDF, or QC has `thickness_report_pdf_id`). (2) **Inline readings table in the CoC body** — used when `thickness_reading_ids` is populated but there's no PDF source (e.g. RTF upload parsed to readings, manually typed readings). Lives in `report_coc.xml` between the parts table and the signature block, gated on `doc.thickness_reading_ids`. Both can coexist on a cert — PDF merges as page 2, readings render inline; usually only one path has data per cert. | `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports` |
| **Smart-button "create or view" pattern** | For a smart button that toggles between "create" and "view" states, use **one** idempotent button with `widget="statinfo"`, not two sibling buttons gated by mutually-exclusive `invisible` expressions. Custom `<div class="o_stat_info">` without `<span class="o_stat_value">` renders awkwardly in Odoo 19 (numbers + label expected); `statinfo` handles the standard structure automatically. The action method itself should branch on whether the linked record exists (create-then-open or just open). | any module with smart buttons |
| **stock.move.name removed** | Odoo 19 dropped the `name` field on `stock.move`. Passing `name` in a create dict raises `ValueError: Invalid field 'name' on model 'stock.move'`. Use `description_picking` instead (the operator-facing line label on the picking). The DB column is gone too — `name` doesn't exist as a stored field. | any code that builds stock.move records |
| **Recordsets use `__slots__` — no transient attrs** | Odoo 19's `BaseModel` declares `__slots__ = ['env', '_ids', '_prefetch_ids']`, so `picking._my_stash = data` raises `AttributeError: 'stock.picking' object has no attribute '_my_stash'`. The error reads like a missing field but it's actually Python rejecting the assignment. Don't stash transient state on a recordset between method calls — pass it as a method arg, store on the caller's `self`, or use `env.context` for cross-frame plumbing. Caught here because `fp_receiving._fp_build_shipping_picking` tried to attach `_fp_outbound_packages` to the picking before handing off to `_fp_apply_shipping_result`; the catch-all `except Exception` swallowed it and surfaced the misleading "Carrier API call failed" wizard. | any code that wants to attach data to a recordset between calls |
| **labelary.com dependency for ZPL→PDF** | `fusion_plating_receiving` POSTs ZPL labels to `https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/` to get a PDF rasterization, so one FedEx ship call can populate both the PDF and ZPL smart buttons on the receiving form. **Privacy:** every outbound label's shipping address + tracking number leaves the network and hits labelary's servers (no payment data, but real customer info). **Operational:** anonymous tier is ~5 req/s; add an API key in the labelary helper if you ever ship more than that. PDF→ZPL is intentionally not attempted — that direction is impractical and FedEx's `/ship` endpoint only returns one format per shipment, so the carrier MUST be configured for ZPLII (not PDF) for the dual-format flow to work. Switching the carrier back to PDF will silently drop the ZPL button. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
| **FedEx ZPL ships with `^POI` — strip it** | FedEx's REST `/ship` endpoint returns ZPL with `^POI` (Print Orientation = Invert) baked in, which flips the label 180° on the printer. On a desktop direct-thermal like the Zebra ZD450 that prints upside-down for the operator, and labelary mirrors the inversion in the PDF preview. `_fp_apply_shipping_result` creates a `*-fixed.zpl` copy of the FedEx attachment with `^POI` removed and points the shipment + smart buttons at the cleaned copy; the original FedEx ZPL stays on the picking for audit. **Don't restore `^POI`** — both the PDF preview and the Zebra output need it stripped. If a future printer needs inverted orientation, configure the printer driver instead of putting `^POI` back. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
| **Per-shipment service override via `fp_service_type_override` context key** | Operator picks a FedEx service tier on `fp.receiving.x_fc_outbound_service_type` (Priority Overnight, 2Day, Ground, etc.). `action_generate_outbound_label` passes the chosen code through to `carrier.send_shipping` via `with_context(fp_service_type_override=…)`. `fusion_shipping.fusion_fedex_rest_send_shipping` reads the context key and overrides `srm.service_type` for that call only — carrier default is untouched. Empty/blank override falls back to `carrier.fedex_rest_service_type`. Only FedEx is wired up right now; mirroring this for Canada Post / UPS is a separate task. | `fusion_plating_receiving/models/fp_receiving.py``fusion_shipping/models/delivery_carrier.py` |
| **`mail.template.body_html` is `Markup` + jsonb** | Two gotchas: (1) `tpl.body_html` returns a `markupsafe.Markup` object. `Markup.replace(old, new)` *escapes both args* — quotes in `old` become `&#39;` so the literal pre-escape string never matches. **Cast to `str(tpl.body_html)` before calling `.replace`**. (2) The DB column is `jsonb` (translatable). Direct `UPDATE ... SET body_html = '...'` SQL fails with `invalid input syntax for type json`; either use ORM `tpl.write({'body_html': ...})` or wrap raw SQL with `jsonb_build_object('en_US', ...)`. (3) Mail-template XML data files typically use `<odoo noupdate="1">` so `-u <module>` does NOT reload them — users can edit templates in the UI and the module won't overwrite. To sync XML edits to existing records: temporarily flip the wrapper to `<odoo noupdate="0">`, redeploy and `-u`, then revert (and `UPDATE ir_model_data SET noupdate=true ...` to restore protection). Alternatively, post-migration script or odoo shell write. (4) **`mail.template.report_name` was removed in Odoo 19** — the dynamic PDF-filename field now lives on `ir.actions.report.print_report_name` instead. Old `<field name="report_name">` entries in mail-template data files silently survive while protected by noupdate=1, but the moment you force-reload they error with `Invalid field 'report_name' in 'mail.template'`. Strip them or move the expression to the report action. | any code scripting `mail.template.body_html` |
| **`message_post(body=...)` HTML-escapes by default** | A plain `str` body with `<b>` tags renders as literal `<b>foo</b>` text in chatter — operators see angle brackets, not bold. Wrap the template in `Markup(_('... <b>%s</b> ...'))` and use `%`/`format_map` for substitutions; markupsafe escapes the substituted values automatically so user input still can't inject HTML. Pattern: `self.message_post(body=Markup(_('Tracking: <b>%s</b>')) % tracking)`. | any model posting HTML-formatted chatter |
| **OWL `t-out` escapes plain JS strings — wrap with `markup()`** | The JS-side analogue of the `message_post` markup gotcha. `t-out="state.html"` only renders unescaped HTML when the value is a `markup()`-tagged string from `@odoo/owl`; a plain string (e.g. straight off an RPC response) gets HTML-escaped and the user sees literal `<p>foo</p>` text. Caught here because `fp_record_inputs_dialog.js` was assigning `this.state.instructionsHtml = data.instructions_html` raw — recipe author's `<p>...</p>` rendered as visible tags in the operator dialog. **Fix:** `import { markup } from "@odoo/owl"` and wrap RPC-returned HTML: `this.state.html = markup(data.html || "")`. Same rule for any OWL component that ingests HTML from the server and pushes it through `t-out`. | any OWL component rendering server-returned HTML via `t-out` |
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
### Pending — IN PROGRESS when this session ended

View File

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

View File

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

View File

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

View File

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

View File

@@ -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):

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.20.1.0',
'version': '19.0.20.6.2',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -120,12 +120,19 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_jobs_menu.xml',
'data/fp_work_role_data.xml',
'views/fp_work_role_views.xml',
'data/fp_recipe_enp_alum_basic.xml',
'data/fp_recipe_enp_steel_basic.xml',
'data/fp_recipe_enp_sp.xml',
'data/fp_recipe_general_processing.xml',
'data/fp_recipe_anodize.xml',
'data/fp_recipe_chem_conversion.xml',
# Starter recipes are NOT in 'data' on purpose. They get
# loaded once via post_init_hook → _seed_starter_recipes_once
# so user edits / deletions survive every -u upgrade. Putting
# them back here would re-create deleted nodes on every
# module upgrade (the noupdate="1" flag only blocks UPDATE,
# not CREATE-when-missing — Odoo treats a missing ir.model.data
# record as "needs creating").
# 'data/fp_recipe_enp_alum_basic.xml',
# 'data/fp_recipe_enp_steel_basic.xml',
# 'data/fp_recipe_enp_sp.xml',
# 'data/fp_recipe_general_processing.xml',
# 'data/fp_recipe_anodize.xml',
# 'data/fp_recipe_chem_conversion.xml',
'data/fp_step_template_data.xml',
],
'post_init_hook': 'post_init_hook',

View File

@@ -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):

View File

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

View File

@@ -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,
)

View File

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

View File

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

View File

@@ -493,9 +493,23 @@ class FpProcessNode(models.Model):
help='Sub 12b — opens the transition form before Mark Done.',
)
# Sub 14b — User-extensible Step Kinds (was Selection of 24).
# 2026-05-20: required + ondelete='restrict' — kind drives gates,
# workflow milestones, and operator routing. Optional was a foot-gun
# (operators silently picked Generic / nothing). Pre-migrate
# 19.0.20.6.0 backfills every existing row before this NOT NULL
# constraint hits the schema.
kind_id = fields.Many2one(
'fp.step.kind', string='Step Kind', ondelete='set null', index=True,
help='Pick from the catalog or create a new kind.',
'fp.step.kind', string='Step Kind',
ondelete='restrict', index=True,
required=True,
default=lambda self: self.env['fp.step.kind'].search(
[('code', '=', 'other')], limit=1,
).id or False,
help='Drives operator routing (auto-open Contract Review form / '
'Rack assignment dialog / Bake window), customer-portal '
'milestones (Received / Plated / Inspected / Shipped), and '
'tablet UI (icon, station filter). Pick "Other" only when '
'the step has no special behaviour.',
)
# Back-compat: code-string accessor that all legacy
# `node.default_kind == "cleaning"` comparisons keep using.

View File

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

View File

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

View File

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

View File

@@ -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 &amp; 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">

View File

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

View File

@@ -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')

View File

@@ -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': """

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.6.1.0',
'version': '19.0.7.8.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """
@@ -37,6 +37,8 @@ Includes Fischerscope thickness measurement data capture.
'views/fp_certificate_views.xml',
'views/res_partner_views.xml',
'views/fp_certificates_menu.xml',
'wizards/fp_cert_void_wizard_views.xml',
'wizards/fp_thickness_upload_wizard_views.xml',
],
'installable': True,
'application': False,

View File

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

View File

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

View File

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

View File

@@ -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.',
)

View File

@@ -5,3 +5,9 @@ access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_cert_void_wiz_mgr,fp.cert.void.wiz.manager,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_thickness_upload_wiz_sup,fp.thickness.upload.wiz.supervisor,model_fp_thickness_upload_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_thickness_upload_wiz_mgr,fp.thickness.upload.wiz.manager,model_fp_thickness_upload_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_thickness_upload_wiz_line_sup,fp.thickness.upload.wiz.line.supervisor,model_fp_thickness_upload_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_thickness_upload_wiz_line_mgr,fp.thickness.upload.wiz.line.manager,model_fp_thickness_upload_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_fp_thickness_reading_operator fp.thickness.reading.operator model_fp_thickness_reading fusion_plating.group_fusion_plating_operator 1 0 0 0
6 access_fp_thickness_reading_supervisor fp.thickness.reading.supervisor model_fp_thickness_reading fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_thickness_reading_manager fp.thickness.reading.manager model_fp_thickness_reading fusion_plating.group_fusion_plating_manager 1 1 1 1
8 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
9 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
10 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
11 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
12 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
13 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

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import test_action_issue_gates
from . import test_fischerscope_parser
from . import test_thickness_upload_wizard

View File

@@ -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')

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import fp_cert_void_wizard
from . import fp_thickness_upload_wizard

View File

@@ -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'}

View File

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

View File

@@ -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.',
)

View File

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

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.21.4.0',
'version': '19.0.21.5.1',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -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'
""")

View File

@@ -74,8 +74,12 @@ class SaleOrder(models.Model):
)
x_fc_receiving_status = fields.Selection(
[('not_received', 'Not Received'), ('partial', 'Partial'),
('received', 'Received'), ('inspected', 'Inspected')],
('received', 'Received')],
string='Receiving Status', default='not_received', tracking=True,
help='State of the linked fp.receiving record(s). Inspection is '
"no longer a receiving state — Sub 8 moved part inspection "
'into the recipe (racking step), so receiving stops at '
'"received" (boxes counted, staged, closed).',
)
# ---- Direct Order rewrite (Phase A) ----

View File

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

View File

@@ -12,6 +12,19 @@
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- Header buttons: make draft Confirm the primary CTA, demote/rename
Send to "Send Email" (red), and reorder so Confirm sits first. -->
<xpath expr="//header/button[@name='action_confirm' and not(@id)]" position="attributes">
<attribute name="class">btn-primary</attribute>
</xpath>
<xpath expr="//header/button[@id='quotation_send_primary']" position="attributes">
<attribute name="string">Send Email</attribute>
<attribute name="class">btn-danger</attribute>
</xpath>
<xpath expr="//header/button[@id='quotation_send_primary']" position="before">
<xpath expr="//header/button[@name='action_confirm' and not(@id)]" position="move"/>
</xpath>
<!-- Hide standard Delivery button: our Transfers button (below) shows
all stock.picking records - inbound receipts AND outbound deliveries -
which matches the plating workflow better than outbound-only. -->
@@ -307,13 +320,13 @@
<field name="name">sale.order.list.fp</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<list string="Sale Orders" decoration-info="state == 'draft'"
<list string="Sale Orders" create="0" decoration-info="state == 'draft'"
decoration-muted="state == 'cancel'"
decoration-danger="x_fc_is_late_forecast">
<header>
<button name="%(action_fp_direct_order_wizard)d"
type="action"
string="+ New Direct Order"
string="New Order"
class="btn-primary"
display="always"/>
</header>
@@ -341,7 +354,7 @@
<field name="x_fc_is_blanket_order" optional="hide"/>
<field name="x_fc_receiving_status" widget="badge"
decoration-warning="x_fc_receiving_status == 'not_received'"
decoration-success="x_fc_receiving_status in ('received','inspected')"/>
decoration-success="x_fc_receiving_status == 'received'"/>
<field name="x_fc_delivery_method" optional="hide"/>
<field name="currency_id" column_invisible="1"/>
<field name="state" widget="badge"/>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.10.8.0',
'version': '19.0.10.16.9',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
@@ -67,9 +67,11 @@ full design rationale and §6.2 of the implementation plan for task list.
'views/fp_step_priority_views.xml',
'views/jobs_in_shopfloor_menu.xml',
'views/legacy_menu_hide.xml',
'views/fp_job_cert_backfill.xml',
'views/res_users_views.xml',
'wizards/fp_job_step_move_wizard_views.xml',
'wizards/fp_job_step_input_wizard_views.xml',
'wizards/fp_cert_issue_wizard_views.xml',
'report/report_fp_job_sticker.xml',
'report/report_fp_job_traveller.xml',
'report/report_fp_job_wo_detail.xml',
@@ -86,6 +88,7 @@ full design rationale and §6.2 of the implementation plan for task list.
'fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss',
'fusion_plating_jobs/static/src/scss/fp_finish_btn.scss',
'fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js',
'fusion_plating_jobs/static/src/js/fp_cert_issue_wizard_autoedit.js',
'fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml',
],
'web.assets_web_dark': [
@@ -93,6 +96,7 @@ full design rationale and §6.2 of the implementation plan for task list.
'fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss',
'fusion_plating_jobs/static/src/scss/fp_finish_btn.scss',
'fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js',
'fusion_plating_jobs/static/src/js/fp_cert_issue_wizard_autoedit.js',
'fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml',
],
},

View File

@@ -56,7 +56,8 @@ class FpCertificate(models.Model):
'merged = already in the issued CoC PDF',
)
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id')
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id',
'x_fc_local_thickness_pdf')
def _compute_fischer_visibility(self):
QC = self.env.get('fusion.plating.quality.check')
empty_qc = self.env['fusion.plating.quality.check'] if QC is not None else None
@@ -65,7 +66,14 @@ class FpCertificate(models.Model):
qc = empty_qc
pdf = empty_att
status = 'none'
if QC is not None and rec.x_fc_job_id:
# Cert-local upload wins over QC-side PDF (matches the
# merge resolution order in fp_certificate.py).
if rec.x_fc_local_thickness_pdf:
if rec.state == 'issued' and rec.attachment_id:
status = 'merged'
else:
status = 'pending'
elif QC is not None and rec.x_fc_job_id:
# Same lookup the merge method uses — passed-first,
# then any QC with a PDF.
qc = QC.sudo().search([

View File

@@ -189,6 +189,15 @@ class FpJob(models.Model):
back to partner-level send_coc / send_thickness_report flags.
'none' returns empty (commercial customer, no paperwork).
Unknown requirement codes default to {'coc'} as a safety net.
Bundling rule (2026-05-18 — Entech workflow): when a CoC is
wanted AND thickness is wanted, the thickness data is delivered
as page 2 of the CoC PDF (see _fp_merge_thickness_into_pdf),
so we return ONE cert ({'coc'}) instead of two. A standalone
thickness_report cert is only produced when thickness is wanted
WITHOUT a CoC — a rare edge case kept for completeness.
Action_issue's thickness-data gate enforces actual readings or
a Fischerscope PDF on the merged CoC.
"""
self.ensure_one()
req = (
@@ -196,16 +205,17 @@ class FpJob(models.Model):
and self.part_catalog_id.certificate_requirement
) or 'inherit'
if req == 'inherit':
types = set()
if self.partner_id.x_fc_send_coc:
types.add('coc')
if self.partner_id.x_fc_send_thickness_report:
types.add('thickness_report')
return types
want_coc = bool(self.partner_id.x_fc_send_coc)
want_thickness = bool(self.partner_id.x_fc_send_thickness_report)
if want_coc:
return {'coc'} # thickness gets merged in
if want_thickness:
return {'thickness_report'}
return set()
return {
'none': set(),
'coc': {'coc'},
'coc_thickness': {'coc', 'thickness_report'},
'coc_thickness': {'coc'}, # bundled — thickness on page 2
}.get(req, {'coc'})
next_milestone_action = fields.Selection(
@@ -308,9 +318,29 @@ class FpJob(models.Model):
return fn()
def _action_open_draft_certs(self):
"""Open the cert list filtered to draft certs for this job.
Manager reviews each in turn and clicks Issue per-cert."""
"""Open the Issue Certs wizard for this job's draft certs.
The wizard prompts for a Fischerscope upload + readings per cert
that needs thickness data (bundled CoC or standalone thickness
report). Pure CoC certs (no thickness needed) appear in the
wizard too and just need a Confirm click. Cleaner than the old
"list view → open each cert → click Issue" flow.
Falls back to the cert list view if the wizard model isn't
installed (defensive — should always exist when this module is).
"""
self.ensure_one()
Wizard = self.env.get('fp.cert.issue.wizard')
if Wizard is not None:
try:
return Wizard.open_for_job(self)
except UserError:
raise
except Exception as e:
_logger.warning(
"Job %s: cert issue wizard failed (%s) — "
"falling back to cert list.", self.name, e,
)
return {
'type': 'ir.actions.act_window',
'name': _('Draft Certificates — %s') % self.name,
@@ -1510,6 +1540,23 @@ class FpJob(models.Model):
# qty tracking truly doesn't apply).
skip_qty_gate = self.env.context.get('fp_skip_qty_reconcile')
if not skip_qty_gate and job.qty:
# Smooth the typical "clean close" case so the operator
# doesn't have to manually type qty_done = ordered_qty
# every time. Conditions for safe auto-fill:
# - operator has NOT recorded any scrap or done qty
# (so we're not overriding their explicit entry)
# - the receiving closed with matching qty (parts
# physically came in as expected)
# - no visual-inspection rejects recorded
# When any of those fail, fall through to the gate so
# the operator reconciles by hand. Mirrors the receiving
# `_update_job_qty_received` pattern: server fills the
# obvious default, operator owns the edge cases.
if (not job.qty_done and not job.qty_scrapped
and not (job.qty_visual_inspection_rejects or 0)
and job.qty_received
and abs(job.qty_received - job.qty) < 0.0001):
job.qty_done = job.qty
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
if abs(accounted - job.qty) > 0.0001:
raise UserError(_(
@@ -1521,6 +1568,37 @@ class FpJob(models.Model):
job.name, job.qty, job.qty_done or 0,
job.qty_scrapped or 0, accounted, job.qty,
))
# Receiving reconciliation: parts must be physically
# received before the job can close, and the count must
# match what came out (done + scrapped + visual rejects).
# Without this guard a job ships with the wrong cert qty,
# or worse, with no closed receiving for the auditor to
# trace back to. Same bypass flag covers both checks.
if not job.qty_received:
raise UserError(_(
"Job %s cannot be marked Done — Quantity Received "
"is blank. Close the receiving record for SO %s "
"before completing this job."
) % (
job.name,
job.sale_order_id.name if job.sale_order_id else '?',
))
rejects = job.qty_visual_inspection_rejects or 0
accounted_out = (
(job.qty_done or 0)
+ (job.qty_scrapped or 0)
+ rejects
)
if abs(job.qty_received - accounted_out) > 0.0001:
raise UserError(_(
"Job %s qty mismatch — received %g, but qty_done "
"(%g) + qty_scrapped (%g) + visual rejects (%g) "
"= %g. Reconcile before closing."
) % (
job.name, job.qty_received,
job.qty_done or 0, job.qty_scrapped or 0,
rejects, accounted_out,
))
# QC gate: customers flagged x_fc_requires_qc must have a
# passed QC before the job closes. AS9100 / Nadcap compliance.
if QC and not skip_qc_gate \
@@ -1596,22 +1674,30 @@ class FpJob(models.Model):
refund auto-link, and the legacy notification dispatch all
look up by job_ref. Setting both ends keeps every consumer
happy.
Auto-populates everything we can resolve from upstream
records so the shipping crew doesn't have to re-type
addresses / contacts / dates that already exist on the SO:
- delivery_address_id, contact_name, contact_phone — SO's
partner_shipping_id (falls back to partner_id)
- scheduled_date — SO.commitment_date
- source_facility_id — job.facility_id
- x_fc_carrier_id, x_fc_outbound_shipment_id — from the
SO's first receiving record (set at receive time)
- coc_attachment_id — issued cert.attachment_id for this
job (if a CoC is already issued before delivery exists;
otherwise the cert's action_issue back-fills it later)
Everything skips silently when the source field doesn't
exist or the source value is blank, so older install
topologies and partially-configured jobs still get a
delivery — just less pre-filled.
"""
self.ensure_one()
if self.delivery_id:
return
Delivery = self.env['fusion.plating.delivery'].sudo()
vals = {'partner_id': self.partner_id.id}
if 'x_fc_job_id' in Delivery._fields:
vals['x_fc_job_id'] = self.id
if 'job_ref' in Delivery._fields:
vals['job_ref'] = self.name
if 'x_fc_job_id' not in Delivery._fields \
and 'job_ref' not in Delivery._fields:
_logger.warning(
"Job %s: fusion.plating.delivery has no job link field; "
"delivery created without job back-reference.", self.name,
)
vals = self._fp_resolve_delivery_defaults(Delivery)
try:
delivery = Delivery.create(vals)
self.delivery_id = delivery.id
@@ -1620,19 +1706,88 @@ class FpJob(models.Model):
"Job %s: failed to auto-create delivery: %s", self.name, e,
)
def _fp_resolve_delivery_defaults(self, Delivery):
"""Build the create-vals for a fresh delivery, OR the
write-vals for refreshing an existing one. Centralised so
the create path, the per-cert post-issue sync, and any
future 'Refresh from Source' button all stay consistent.
"""
self.ensure_one()
vals = {'partner_id': self.partner_id.id}
if 'x_fc_job_id' in Delivery._fields:
vals['x_fc_job_id'] = self.id
if 'job_ref' in Delivery._fields:
vals['job_ref'] = self.name
# Delivery address + contact details from the SO. shipping
# partner is preferred (that's where parts physically go);
# fall back to the SO's main partner when no separate ship-to.
so = self.sale_order_id
ship_to = (so.partner_shipping_id or so.partner_id) if so else False
if ship_to:
if 'delivery_address_id' in Delivery._fields:
vals['delivery_address_id'] = ship_to.id
if 'contact_name' in Delivery._fields and ship_to.name:
vals['contact_name'] = ship_to.name
if 'contact_phone' in Delivery._fields:
vals['contact_phone'] = ship_to.phone or ship_to.mobile or ''
# Scheduled date — operator can adjust; this just primes it
# so they're not staring at a blank field.
if so and so.commitment_date and 'scheduled_date' in Delivery._fields:
vals['scheduled_date'] = so.commitment_date
# Source facility comes from the job (where it was plated).
if self.facility_id and 'source_facility_id' in Delivery._fields:
vals['source_facility_id'] = self.facility_id.id
# Outbound carrier + shipment mirrored from the SO's first
# receiving record (the crew chose these at receipt time).
if (so and 'x_fc_receiving_ids' in so._fields
and so.x_fc_receiving_ids):
recv = so.x_fc_receiving_ids[:1]
if 'x_fc_carrier_id' in Delivery._fields \
and 'x_fc_carrier_id' in recv._fields \
and recv.x_fc_carrier_id:
vals['x_fc_carrier_id'] = recv.x_fc_carrier_id.id
if 'x_fc_outbound_shipment_id' in Delivery._fields \
and 'x_fc_outbound_shipment_id' in recv._fields \
and recv.x_fc_outbound_shipment_id:
vals['x_fc_outbound_shipment_id'] = (
recv.x_fc_outbound_shipment_id.id
)
# CoC PDF — if a cert for this job is already issued and
# the delivery field accepts an attachment, link it. The
# cert's action_issue also calls _fp_sync_to_delivery for
# the case where the cert issues AFTER the delivery exists.
Cert = self.env.get('fp.certificate')
if Cert is not None and 'coc_attachment_id' in Delivery._fields:
issued_cert = Cert.sudo().search([
('x_fc_job_id', '=', self.id),
('certificate_type', '=', 'coc'),
('state', '=', 'issued'),
('attachment_id', '!=', False),
], order='issue_date desc, id desc', limit=1)
if issued_cert and issued_cert.attachment_id:
vals['coc_attachment_id'] = issued_cert.attachment_id.id
return vals
def _fp_create_certificates(self):
"""Auto-create one draft fp.certificate per type returned by
_resolve_required_cert_types. Idempotent per type — re-running
on a job that already has a CoC won't create another one.
Each cert is pre-populated with everything action_issue needs
(partner, spec_reference, part_number, quantity_shipped, po,
(partner, spec_reference, process_description, certified_by,
contact_partner, part_number, quantity_shipped, NC qty, PO,
SO link, job link) so the manager just reviews and clicks Issue.
Replaces the single-CoC implementation: now honours
part.certificate_requirement (coc / coc_thickness / none /
inherit) and partner-level send_coc / send_thickness_report
flags. Closes spec gap C-G1.
Resolution sources for the new prefill fields:
- process_description ← recipe.name (the job's process root)
- certified_by_id ← customer_spec.signer_user_id, falling
back to company.x_fc_owner_user_id
- contact_partner_id ← partner.x_fc_default_coc_contact_id
- nc_quantity ← qty_scrapped + qty_visual_insp_rejects
Honours part.certificate_requirement (coc / coc_thickness /
none / inherit) and partner-level send_coc /
send_thickness_report flags. Closes spec gap C-G1.
"""
self.ensure_one()
if 'fp.certificate' not in self.env:
@@ -1645,6 +1800,25 @@ class FpJob(models.Model):
# Spec drives the cert spec_reference. The customer.spec was
# auto-filled onto the job at confirm time (sale_order.py).
spec = self.customer_spec_id
# Recipe drives the process description on the cert. Was previously
# sourced from sale_order.x_fc_coating_config_id (since retired);
# recipe.name is the human-readable replacement.
recipe = self.recipe_id
# Signer resolution: per-spec override wins, company default fills.
signer = False
if spec and 'signer_user_id' in spec._fields:
signer = spec.signer_user_id
if not signer and 'x_fc_owner_user_id' in self.company_id._fields:
signer = self.company_id.x_fc_owner_user_id
# Contact: per-customer default; blank means manager picks at issue.
contact = False
if 'x_fc_default_coc_contact_id' in self.partner_id._fields:
contact = self.partner_id.x_fc_default_coc_contact_id
# NC qty: scrapped + visual rejects. Both NULL-safe.
nc_qty = int(
(self.qty_scrapped or 0)
+ (self.qty_visual_inspection_rejects or 0)
)
for cert_type in sorted(required):
# Idempotency per type.
existing_dom = [('certificate_type', '=', cert_type)]
@@ -1691,6 +1865,8 @@ class FpJob(models.Model):
(self.qty_done or self.qty or 0)
- (self.qty_scrapped or 0)
)
if 'nc_quantity' in Cert._fields:
vals['nc_quantity'] = nc_qty
if 'po_number' in Cert._fields and self.sale_order_id \
and 'x_fc_po_number' in self.sale_order_id._fields:
vals['po_number'] = (
@@ -1703,8 +1879,12 @@ class FpJob(models.Model):
vals['customer_job_no'] = (
self.sale_order_id.x_fc_customer_job_number or ''
)
if 'process_description' in Cert._fields and coating:
vals['process_description'] = coating.name or ''
if 'process_description' in Cert._fields and recipe:
vals['process_description'] = recipe.name or ''
if 'certified_by_id' in Cert._fields and signer:
vals['certified_by_id'] = signer.id
if 'contact_partner_id' in Cert._fields and contact:
vals['contact_partner_id'] = contact.id
if 'entech_wo_number' in Cert._fields:
vals['entech_wo_number'] = self.name or ''
cert = Cert.create(vals)
@@ -1728,6 +1908,107 @@ class FpJob(models.Model):
) % {'t': cert_type, 'e': e})
# ------------------------------------------------------------------
# Backfill — closed jobs missing certs, plus cleanup of legacy
# duplicate thickness_report certs created before the bundling rule.
# ------------------------------------------------------------------
# One-shot management action for jobs that closed BEFORE the
# _fp_create_certificates bug fix (e.g. WO-30040). Two passes:
# 1. CREATE any missing draft cert per the (updated) resolver
# 2. VOID legacy duplicate thickness_report certs that have a
# paired CoC on the same job — the bundling rule says the
# CoC carries the thickness data on page 2
# Both passes are idempotent — safe to re-run.
@api.model
def action_backfill_missing_certs(self):
Cert = self.env.get('fp.certificate')
if Cert is None:
raise UserError(_(
'fp.certificate model is not installed. Install '
'fusion_plating_certificates before running this action.'
))
candidate_jobs = self.search([('state', '=', 'done')])
scanned = 0
backfilled_jobs = self.env['fp.job']
created_count = 0
voided_count = 0
has_job_link = 'x_fc_job_id' in Cert._fields
for job in candidate_jobs:
required = job._resolve_required_cert_types()
if not required:
continue
scanned += 1
existing_certs = (
Cert.sudo().search([('x_fc_job_id', '=', job.id)])
if has_job_link else
(Cert.sudo().search([
('sale_order_id', '=', job.sale_order_id.id),
]) if job.sale_order_id else Cert.browse())
)
existing_types = set(existing_certs.mapped('certificate_type'))
# ---- Pass 1: create missing certs --------------------------
missing = required - existing_types
if missing:
before = len(existing_certs)
job._fp_create_certificates()
# Re-read to get the freshly-created ones for pass 2.
existing_certs = (
Cert.sudo().search([('x_fc_job_id', '=', job.id)])
if has_job_link else existing_certs
)
delta = max(len(existing_certs) - before, 0)
if delta:
backfilled_jobs |= job
created_count += delta
# ---- Pass 2: void duplicate thickness_report certs ---------
# Bundling rule (CLAUDE.md): when CoC + thickness are both
# wanted, the CoC absorbs the thickness data. A leftover
# draft thickness_report cert on the same job is now noise
# and should not be issued. Void it with a clear reason so
# the audit trail tells the story.
if 'coc' in required and 'coc' in existing_types:
dup_thickness = existing_certs.filtered(
lambda c: (c.certificate_type == 'thickness_report'
and c.state == 'draft')
)
for cert in dup_thickness:
cert.sudo().write({
'state': 'voided',
'void_reason': (
'Auto-voided: bundling rule — thickness '
'data is delivered as page 2 of the paired '
'CoC, not as a separate cert.'
),
})
cert.message_post(body=_(
'Auto-voided by cleanup: bundling rule routes '
'thickness data to the CoC.'
))
voided_count += 1
backfilled_jobs |= job
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Cert backfill + cleanup complete'),
'message': _(
'Scanned %(s)d closed jobs. Created %(c)d draft '
'cert(s); voided %(v)d duplicate thickness_report '
'cert(s) across %(j)d job(s).'
) % {
's': scanned,
'c': created_count,
'v': voided_count,
'j': len(backfilled_jobs),
},
'sticky': True,
'type': 'success' if (created_count or voided_count) else 'warning',
},
}
class FpJobStep(models.Model):
"""Phase 7 — adds the migration idempotency key on fp.job.step.

View File

@@ -823,16 +823,67 @@ class FpJobStep(models.Model):
'state': state_label,
})
def _fp_check_receiving_gate(self):
"""Block step transitions until parts are physically received.
Applied to every step EXCEPT Contract Review (paperwork — doesn't
need parts on the floor). Fires from both button_start and
button_finish so an operator can't begin OR complete physical
work before the receiving record is closed.
Manager bypass: ``fp_skip_receiving_gate=True`` in context. Same
pattern as the qty / QC / bake gates. Audit trail is preserved
via the state-transition tracking on chatter.
Threshold: SO ``x_fc_receiving_status == 'received'``. Post-Sub-8
that's the terminal state (inspection moved into the recipe's
racking step; ``'inspected'`` was dropped in the 2026-05-18
cleanup).
"""
if self.env.context.get('fp_skip_receiving_gate'):
return
for step in self:
if step._fp_is_contract_review_step():
continue
so = step.job_id.sale_order_id
if not so:
# Internal rework / no SO — gate doesn't apply.
continue
if 'x_fc_receiving_status' not in so._fields:
# Defensive: configurator module not installed.
continue
if so.x_fc_receiving_status != 'received':
label = dict(
so._fields['x_fc_receiving_status'].selection
).get(
so.x_fc_receiving_status,
so.x_fc_receiving_status or 'unknown',
)
raise UserError(_(
'Step "%(step)s" cannot proceed — parts not received '
'yet (SO %(so)s receiving status: %(status)s).\n\n'
'Close the receiving record (Sales > %(so)s > '
'Receiving) before starting or finishing work on '
'this step. A manager can bypass this gate for '
'documented exceptions.'
) % {
'step': step.name,
'so': so.name or '?',
'status': label,
})
def button_start(self):
"""Single source of truth for step start:
1. Sub 13 predecessor gate (raise UserError if blocking)
2. Policy B Contract Review auto-open (route to QA-005)
3. Sub 8 Racking auto-open (route to racking inspection)
4. super().button_start() + receiving soft check + serial
promotion for the standard path
2. Receiving gate (raise UserError if parts not received)
3. Policy B Contract Review auto-open (route to QA-005)
4. Sub 8 Racking auto-open (route to racking inspection)
5. super().button_start() + serial promotion for the standard
path
Manager bypasses available via context:
fp_skip_predecessor_check=True skips the Sub 13 gate
fp_skip_receiving_gate=True skips the receiving gate
"""
# ---- 1. Sub 13 predecessor gate ----------------------------------
skip_pred = self.env.context.get('fp_skip_predecessor_check')
@@ -863,7 +914,13 @@ class FpJobStep(models.Model):
),
))
# ---- 2. Policy B Contract Review auto-open -----------------------
# ---- 2. Receiving gate -------------------------------------------
# Hard block (replaces the prior soft chatter warning). The
# helper exempts Contract Review steps internally, so contract
# review can still auto-open below regardless of receiving state.
self._fp_check_receiving_gate()
# ---- 3. Policy B Contract Review auto-open -----------------------
for step in self:
if step._fp_is_contract_review_step():
action = step._fp_open_contract_review()
@@ -873,7 +930,7 @@ class FpJobStep(models.Model):
step._fp_promote_serials_on_start()
return action
# ---- 3. Sub 8 Racking auto-open ----------------------------------
# ---- 4. Sub 8 Racking auto-open ----------------------------------
for step in self:
if step._fp_is_racking_step():
action = step._fp_open_racking_inspection()
@@ -883,33 +940,18 @@ class FpJobStep(models.Model):
step._fp_promote_serials_on_start()
return action
# ---- 4. Standard path: start + receiving check + serial promote --
# ---- 5. Standard path: start + serial promote --------------------
result = super().button_start()
for step in self:
if step.state == 'in_progress':
step._fp_promote_serials_on_start()
so = step.job_id.sale_order_id
if not so:
continue
recv = so.x_fc_receiving_status if (
'x_fc_receiving_status' in so._fields
) else None
if recv in (False, None, 'not_received'):
step.job_id.message_post(body=_(
'Step "%(step)s" started before parts were received '
'(SO %(so)s — receiving status: %(status)s). '
'Confirm the parts are physically on the floor before '
'continuing.'
) % {
'step': step.name,
'so': so.name or '',
'status': recv or 'unknown',
})
return result
def button_finish(self):
# Policy B — block until QA-005 complete (when customer requires it).
self._fp_check_contract_review_complete()
# Receiving gate — same helper as button_start, exempts CR steps.
self._fp_check_receiving_gate()
# NOTE: racking inspection gate removed — racking is now a recipe
# step, not a separate inspection workflow. _fp_check_racking_
# inspection_complete() is kept as a helper for diagnostics but

View File

@@ -175,10 +175,13 @@ class SaleOrder(models.Model):
if recv_status == 'not_received':
so.x_fc_workflow_stage = 'awaiting_parts'
continue
if recv_status in ('partial', 'received'):
so.x_fc_workflow_stage = 'inspecting'
if recv_status == 'partial':
so.x_fc_workflow_stage = 'awaiting_parts'
continue
if recv_status == 'inspected':
if recv_status == 'received':
# Sub 8: 'received' is the terminal receiving state (no
# more separate 'inspected'). Parts are on the floor;
# inspection happens inside the recipe's racking step.
if not so.x_fc_assigned_manager_id and not jobs:
so.x_fc_workflow_stage = 'assign_work'
continue
@@ -562,16 +565,27 @@ class SaleOrder(models.Model):
return True
def action_fp_accept_parts(self):
"""Mark receiving accepted; flip SO receiving status to inspected."""
"""Mark receiving complete; flip SO receiving status to received.
Sub 8 (2026-04-22) moved inspection out of receiving and into the
recipe's racking step. Receiving's terminal state is now 'closed'
(or legacy 'accepted'), which maps to SO status 'received'. The
old 'inspected' SO status no longer exists.
"""
self.ensure_one()
Recv = self.env.get('fp.receiving')
if Recv is None:
return False
for rec in Recv.search([('sale_order_id', '=', self.id)]):
if rec.state in ('draft', 'inspecting'):
# Push receiving to its terminal state — 'closed' is the
# post-Sub-8 terminal; 'accepted' kept as a legacy fallback
# only for old records still in pre-Sub-8 states.
if rec.state in ('draft', 'counted', 'staged'):
rec.state = 'closed'
elif rec.state in ('inspecting',):
rec.state = 'accepted'
if 'x_fc_receiving_status' in self._fields:
self.x_fc_receiving_status = 'inspected'
self.x_fc_receiving_status = 'received'
self.message_post(body=_('Parts accepted — ready to assign manager.'))
return True

View File

@@ -20,3 +20,9 @@ access_fp_job_step_input_wiz_l_mgr,fp.job.step.input.wiz.l.manager,model_fp_job_
access_fp_workflow_state_op,fp.workflow.state.operator,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_workflow_state_sup,fp.workflow.state.supervisor,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
access_fp_workflow_state_mgr,fp.workflow.state.manager,model_fp_job_workflow_state,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_cert_issue_wiz_sup,fp.cert.issue.wiz.supervisor,model_fp_cert_issue_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_cert_issue_wiz_mgr,fp.cert.issue.wiz.manager,model_fp_cert_issue_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_cert_issue_wiz_l_sup,fp.cert.issue.wiz.l.supervisor,model_fp_cert_issue_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_cert_issue_wiz_l_mgr,fp.cert.issue.wiz.l.manager,model_fp_cert_issue_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_cert_issue_wiz_r_sup,fp.cert.issue.wiz.r.supervisor,model_fp_cert_issue_wizard_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_cert_issue_wiz_r_mgr,fp.cert.issue.wiz.r.manager,model_fp_cert_issue_wizard_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
20 access_fp_workflow_state_op fp.workflow.state.operator model_fp_job_workflow_state fusion_plating.group_fusion_plating_operator 1 0 0 0
21 access_fp_workflow_state_sup fp.workflow.state.supervisor model_fp_job_workflow_state fusion_plating.group_fusion_plating_supervisor 1 0 0 0
22 access_fp_workflow_state_mgr fp.workflow.state.manager model_fp_job_workflow_state fusion_plating.group_fusion_plating_manager 1 1 1 1
23 access_fp_cert_issue_wiz_sup fp.cert.issue.wiz.supervisor model_fp_cert_issue_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 1
24 access_fp_cert_issue_wiz_mgr fp.cert.issue.wiz.manager model_fp_cert_issue_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
25 access_fp_cert_issue_wiz_l_sup fp.cert.issue.wiz.l.supervisor model_fp_cert_issue_wizard_line fusion_plating.group_fusion_plating_supervisor 1 1 1 1
26 access_fp_cert_issue_wiz_l_mgr fp.cert.issue.wiz.l.manager model_fp_cert_issue_wizard_line fusion_plating.group_fusion_plating_manager 1 1 1 1
27 access_fp_cert_issue_wiz_r_sup fp.cert.issue.wiz.r.supervisor model_fp_cert_issue_wizard_reading fusion_plating.group_fusion_plating_supervisor 1 1 1 1
28 access_fp_cert_issue_wiz_r_mgr fp.cert.issue.wiz.r.manager model_fp_cert_issue_wizard_reading fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,86 @@
/** @odoo-module **/
/**
* Fusion Plating — Issue Certs wizard: auto-edit the first incomplete row
* on wizard mount.
*
* Background: Odoo's editable o2m list keeps non-selected rows in display
* mode, which hides the binary widget's "↑ Upload your file" link until
* the operator clicks the row. Operators reported the wizard as broken
* because the file field appeared empty.
*
* Fix without fighting CSS: when the wizard's list renders, simulate a
* click on the first row that still needs thickness data. The native
* binary widget then renders in edit mode and the upload link is
* immediately visible — no theme override needed.
*
* Scoped to `.o_fp_cert_issue_wizard_form` (the wizard form's
* css_class) so this DOM-poke doesn't fire on other editable o2m lists.
*/
import { registry } from "@web/core/registry";
import { formView } from "@web/views/form/form_view";
import { FormController } from "@web/views/form/form_controller";
import { onMounted } from "@odoo/owl";
export class FpCertIssueWizardFormController extends FormController {
setup() {
super.setup(...arguments);
onMounted(() => {
// Defer one tick so the o2m list has finished its first paint.
requestAnimationFrame(() => this._fpAutoEditFirstRow());
});
}
_fpAutoEditFirstRow() {
// Only fire on the cert-issue wizard. Other form views that use
// the same FormController class get the default behaviour.
const root = this.rootRef && this.rootRef.el;
if (!root || !root.classList.contains("o_fp_cert_issue_wizard_form")) {
return;
}
// First row that backs a line where is_ready is False (the data
// toggle column renders as `false`). Fallback: the very first
// data row.
const dataRows = root.querySelectorAll(
".o_field_one2many[name='line_ids'] .o_list_renderer .o_data_row"
);
if (!dataRows.length) {
return;
}
let target = null;
for (const row of dataRows) {
// Look for an unchecked is_ready toggle inside the row. If
// we find one, that row needs attention.
const readyToggle = row.querySelector(
"[name='is_ready'] input[type='checkbox']"
);
if (readyToggle && !readyToggle.checked) {
target = row;
break;
}
}
if (!target) {
target = dataRows[0];
}
// Find the fischer_file cell specifically — clicking THAT cell
// (not just any cell) puts the row in edit mode AND focuses the
// upload widget, so the native "Upload your file" link is the
// very first thing the operator sees.
const fischerCell = target.querySelector("[name='fischer_file']");
if (fischerCell) {
fischerCell.click();
} else {
// Fallback: click the row anywhere, then the upload column
// shows up in the now-active row.
target.click();
}
}
}
registry.category("views").add("fp_cert_issue_wizard_form", {
...formView,
Controller: FpCertIssueWizardFormController,
});

View File

@@ -17,7 +17,7 @@
* onSave → /fp/record_inputs/commit → advance step (optional)
*/
import { Component, onWillStart, useState } from "@odoo/owl";
import { Component, markup, onWillStart, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
@@ -106,7 +106,10 @@ export class FpRecordInputsDialog extends Component {
this.state.jobName = data.job.name;
this.state.recipeRootId = data.recipe_root_id || false;
this.state.userInitials = data.user_initials || "";
this.state.instructionsHtml = data.instructions_html || "";
// `t-out` only renders unescaped HTML when the value is a
// `markup()`-tagged string — otherwise it shows literal tags
// (e.g. `<p>foo</p>`). See CLAUDE.md "OWL `t-out` escapes".
this.state.instructionsHtml = markup(data.instructions_html || "");
this.state.instructionImages = data.instruction_images || [];
const nowDt = this._fpNowForDatetimeLocal();
this.state.rows = data.prompts.map((p) => {

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import test_fp_job_extensions
from . import test_fp_job_milestone_cascade
from . import test_qty_received_propagation

View File

@@ -589,3 +589,363 @@ class TestQtyGate(TransactionCase):
with self.assertRaises(UserError) as exc:
wiz.action_commit()
self.assertIn('at least 1', str(exc.exception))
class TestCertCreationAndGates(TransactionCase):
"""2026-05-18 — cert creation bug fix + gate hardening.
Covers the fixes for the WO-30040 incident where
_fp_create_certificates raised NameError on `coating` and the cert
was never created. Also covers the new qty_received gate on
button_mark_done and the auto-fill of certified_by_id /
contact_partner_id / nc_quantity / process_description.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.signer = cls.env['res.users'].create({
'name': 'Quality Manager',
'login': 'qa_mgr_certtest',
'email': 'qa@example.com',
})
cls.contact = cls.env['res.partner'].create({
'name': 'Bob Receiver',
'email': 'bob@cust.example',
})
cls.partner = cls.env['res.partner'].create({
'name': 'CertCust',
'is_company': True,
'x_fc_send_coc': True,
'x_fc_default_coc_contact_id': cls.contact.id,
})
cls.contact.parent_id = cls.partner.id
cls.product = cls.env['product.product'].create({
'name': 'CertWidget',
})
cls.part = cls.env['fp.part.catalog'].create({
'name': 'CertPart',
'part_number': 'CP-001',
'partner_id': cls.partner.id,
'certificate_requirement': 'coc',
})
def _make_job(self, **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'part_catalog_id': self.part.id,
'qty': 1.0,
'qty_done': 1.0,
'qty_received': 1.0,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
# ---------------- bug fix regression -------------------------------
def test_create_cert_handles_job_with_no_recipe(self):
"""Regression for the `coating` NameError: cert must create
even when the job has no recipe and no coating config."""
job = self._make_job()
self.assertFalse(job.recipe_id)
job._fp_create_certificates()
certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(len(certs), 1)
self.assertFalse(certs.process_description)
# ---------------- prefill -----------------------------------------
def test_create_cert_prefills_signer_from_company(self):
self.env.company.x_fc_owner_user_id = self.signer.id
job = self._make_job()
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(cert.certified_by_id, self.signer)
def test_create_cert_prefills_contact_from_partner(self):
job = self._make_job()
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(cert.contact_partner_id, self.contact)
def test_create_cert_computes_nc_quantity(self):
job = self._make_job(
qty=4, qty_done=3, qty_scrapped=1, qty_received=4,
qty_visual_inspection_rejects=0,
)
job._fp_create_certificates()
cert = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(cert.nc_quantity, 1)
# ---------------- mark_done qty_received gate ----------------------
def test_mark_done_blocks_on_blank_qty_received(self):
from odoo.exceptions import UserError
job = self._make_job(qty=1, qty_done=1, qty_received=0)
step = self.env['fp.job.step'].create({
'job_id': job.id, 'name': 'Plate', 'state': 'done',
})
job.invalidate_recordset(['all_steps_terminal'])
with self.assertRaises(UserError) as exc:
job.button_mark_done()
self.assertIn('Quantity Received', str(exc.exception))
def test_mark_done_blocks_on_qty_received_mismatch(self):
from odoo.exceptions import UserError
# received 5, accounted = 3 done + 1 scrap + 0 rejects = 4
job = self._make_job(qty=5, qty_done=3, qty_scrapped=1,
qty_received=5, qty_visual_inspection_rejects=0)
self.env['fp.job.step'].create({
'job_id': job.id, 'name': 'Plate', 'state': 'done',
})
job.invalidate_recordset(['all_steps_terminal'])
# base qty reconcile passes: 3+1=4 != 5 → first gate raises first
# rebalance so it passes the first check and fails the new one:
job.qty = 4
with self.assertRaises(UserError) as exc:
job.button_mark_done()
self.assertIn('qty mismatch', str(exc.exception).lower())
def test_mark_done_passes_with_clean_reconcile(self):
job = self._make_job(qty=4, qty_done=3, qty_scrapped=1,
qty_received=4, qty_visual_inspection_rejects=0)
self.env['fp.job.step'].create({
'job_id': job.id, 'name': 'Plate', 'state': 'done',
})
job.invalidate_recordset(['all_steps_terminal'])
job.with_context(fp_skip_qc_gate=True).button_mark_done()
self.assertEqual(job.state, 'done')
def test_mark_done_bypass_skips_qty_received_check(self):
job = self._make_job(qty=1, qty_done=1, qty_received=0)
self.env['fp.job.step'].create({
'job_id': job.id, 'name': 'Plate', 'state': 'done',
})
job.invalidate_recordset(['all_steps_terminal'])
job.with_context(
fp_skip_qty_reconcile=True,
fp_skip_qc_gate=True,
).button_mark_done()
self.assertEqual(job.state, 'done')
# ---------------- backfill action ---------------------------------
def test_backfill_creates_missing_certs(self):
"""A closed job with no cert gets one when the backfill runs."""
job = self._make_job()
job.state = 'done'
# Sanity: no cert exists
self.assertFalse(self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
]))
self.env['fp.job'].action_backfill_missing_certs()
self.assertEqual(self.env['fp.certificate'].search_count([
('x_fc_job_id', '=', job.id),
]), 1)
def test_backfill_idempotent(self):
job = self._make_job()
job.state = 'done'
job._fp_create_certificates()
before = self.env['fp.certificate'].search_count([
('x_fc_job_id', '=', job.id),
])
self.env['fp.job'].action_backfill_missing_certs()
after = self.env['fp.certificate'].search_count([
('x_fc_job_id', '=', job.id),
])
self.assertEqual(before, after)
class TestReceivingGate(TransactionCase):
"""2026-05-18 — Hard gate on button_start / button_finish blocking
step transitions until SO receiving status = 'received'. Contract
Review steps are exempt; manager bypass via context flag
`fp_skip_receiving_gate=True`. See
docs/superpowers/specs/2026-05-18-receiving-gate-on-step-transitions-design.md
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'RecvCust'})
cls.product = cls.env['product.product'].create({'name': 'Widget'})
def _make_so(self, recv_status='not_received'):
so = self.env['sale.order'].create({'partner_id': self.partner.id})
if 'x_fc_receiving_status' in so._fields:
so.x_fc_receiving_status = recv_status
return so
def _make_job_with_step(self, recv_status='not_received',
step_state='ready', is_cr=False):
"""Build a job tied to an SO with the given receiving status,
plus a single step in the given state. Returns (job, step)."""
so = self._make_so(recv_status=recv_status)
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
'sale_order_id': so.id,
})
# _fp_is_contract_review_step() matches case-insensitive name
# against "contract review" / "qa-005" OR recipe_node_id default_kind.
# Setting name='Contract Review' is the simplest reliable trigger
# and matches how operators tag the step in production.
step_vals = {
'job_id': job.id,
'name': 'Contract Review' if is_cr else 'Plate',
'state': step_state,
}
step = self.env['fp.job.step'].create(step_vals)
return job, step
# ---- button_start gate ------------------------------------------------
def test_start_blocks_when_not_received(self):
from odoo.exceptions import UserError
job, step = self._make_job_with_step(recv_status='not_received')
with self.assertRaises(UserError) as exc:
step.button_start()
self.assertIn('parts not received', str(exc.exception).lower())
def test_start_allows_when_received(self):
job, step = self._make_job_with_step(recv_status='received')
# Should not raise; step transitions to in_progress via super().
step.button_start()
self.assertIn(step.state, ('in_progress', 'ready'))
def test_start_skips_contract_review(self):
# CR step exempt regardless of receiving status.
job, step = self._make_job_with_step(
recv_status='not_received', is_cr=True,
)
# button_start may return an action (CR auto-open) — must not raise.
try:
step.button_start()
except Exception as e:
from odoo.exceptions import UserError
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
self.fail('CR step should be exempt from receiving gate')
# Other failures (e.g. CR auto-open quirks in test env) are
# not the gate — accept them.
def test_start_bypass_via_context(self):
job, step = self._make_job_with_step(recv_status='not_received')
step.with_context(fp_skip_receiving_gate=True).button_start()
self.assertIn(step.state, ('in_progress', 'ready'))
# ---- button_finish gate -----------------------------------------------
def test_finish_blocks_when_not_received(self):
from odoo.exceptions import UserError
job, step = self._make_job_with_step(
recv_status='not_received', step_state='in_progress',
)
with self.assertRaises(UserError) as exc:
step.button_finish()
self.assertIn('parts not received', str(exc.exception).lower())
def test_finish_allows_when_received(self):
job, step = self._make_job_with_step(
recv_status='received', step_state='in_progress',
)
step.button_finish()
self.assertIn(step.state, ('done', 'in_progress'))
def test_finish_skips_contract_review(self):
job, step = self._make_job_with_step(
recv_status='not_received', step_state='in_progress',
is_cr=True,
)
try:
step.button_finish()
except Exception as e:
from odoo.exceptions import UserError
if isinstance(e, UserError) and 'parts not received' in str(e).lower():
self.fail('CR step should be exempt from receiving gate')
def test_finish_bypass_via_context(self):
job, step = self._make_job_with_step(
recv_status='not_received', step_state='in_progress',
)
step.with_context(fp_skip_receiving_gate=True).button_finish()
self.assertIn(step.state, ('done', 'in_progress'))
class TestCreateDeliveryShippingMirror(TransactionCase):
"""Phase A — _fp_create_delivery mirrors shipping fields from the
linked receiving onto the auto-created fp.delivery."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'MirrorCust'})
cls.product = cls.env['product.product'].create({'name': 'Widget'})
cls.carrier_ups = cls.env.ref(
'fusion_plating_receiving.delivery_carrier_ups',
)
def _make_so_with_receiving(self, carrier=None, shipment=None):
so = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': 1,
})],
})
recv = self.env['fp.receiving'].create({
'sale_order_id': so.id,
'x_fc_carrier_id': carrier.id if carrier else False,
'x_fc_outbound_shipment_id': shipment.id if shipment else False,
})
return so, recv
def _make_job(self, so):
return self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
'sale_order_id': so.id,
})
def test_create_delivery_mirrors_carrier_from_receiving(self):
so, recv = self._make_so_with_receiving(carrier=self.carrier_ups)
job = self._make_job(so)
job._fp_create_delivery()
self.assertTrue(job.delivery_id)
self.assertEqual(job.delivery_id.x_fc_carrier_id, self.carrier_ups)
def test_create_delivery_mirrors_outbound_shipment(self):
shipment = self.env['fusion.shipment'].create({
'sale_order_id': False,
'carrier_id': self.carrier_ups.id,
'status': 'draft',
})
so, recv = self._make_so_with_receiving(
carrier=self.carrier_ups, shipment=shipment,
)
job = self._make_job(so)
job._fp_create_delivery()
self.assertEqual(
job.delivery_id.x_fc_outbound_shipment_id, shipment,
)
def test_create_delivery_no_receiving_no_mirror(self):
so = self.env['sale.order'].create({
'partner_id': self.partner.id,
})
job = self._make_job(so)
job._fp_create_delivery()
self.assertTrue(job.delivery_id)
self.assertFalse(job.delivery_id.x_fc_carrier_id)
self.assertFalse(job.delivery_id.x_fc_outbound_shipment_id)

View File

@@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Closes the bug surfaced by WO-30043 on 2026-05-20: closing a receiving
# did not propagate received_qty to fp.job.qty_received, so the
# button_mark_done gate stayed red after the operator had completed
# every step of the workflow.
from odoo.tests.common import TransactionCase
class TestQtyReceivedPropagation(TransactionCase):
"""fp.receiving close → fp.job.qty_received mirrored per part."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'QtyCust'})
cls.product = cls.env['product.product'].create({'name': 'TestPart'})
cls.part_catalog = cls.env['fp.part.catalog'].create({
'name': 'Test Part Catalog',
'part_number': 'TPC-001',
'partner_id': cls.partner.id,
})
def _make_so_with_job(self):
so = self.env['sale.order'].create({'partner_id': self.partner.id})
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'part_catalog_id': self.part_catalog.id,
'qty': 5.0,
'sale_order_id': so.id,
})
return so, job
def _make_receiving(self, so, received_qty=5):
recv = self.env['fp.receiving'].create({
'sale_order_id': so.id,
'partner_id': self.partner.id,
'expected_qty': received_qty,
'received_qty': received_qty,
# box_count_in is required by action_mark_counted's gate.
'box_count_in': 1,
})
self.env['fp.receiving.line'].create({
'receiving_id': recv.id,
'part_catalog_id': self.part_catalog.id,
'expected_qty': received_qty,
'received_qty': received_qty,
})
return recv
# ---- propagation on state transitions -----------------------------
def test_close_propagates_received_qty_to_job(self):
"""The bug: WO-30043 had qty_received=0 after receiving closed."""
so, job = self._make_so_with_job()
recv = self._make_receiving(so, received_qty=5)
# Walk the state machine to closed (draft → counted → closed
# after the 2026-05-20 `staged` retirement).
recv.action_mark_counted()
recv.action_close()
# Reload — the hook fires inside _update_so_receiving_status.
job.invalidate_recordset(['qty_received'])
self.assertEqual(job.qty_received, 5)
def test_counted_propagates_partial_qty(self):
"""Even a not-yet-closed receiving should mirror what's counted."""
so, job = self._make_so_with_job()
recv = self._make_receiving(so, received_qty=3)
recv.action_mark_counted()
job.invalidate_recordset(['qty_received'])
self.assertEqual(job.qty_received, 3)
def test_no_job_match_is_silent(self):
"""If the receiving line's part doesn't match any job, skip
without raising — common for receivings without spawned jobs."""
# Build a receiving with a part that no job uses.
other_part = self.env['fp.part.catalog'].create({
'name': 'Orphan',
'part_number': 'ORP-001',
'partner_id': self.partner.id,
})
so = self.env['sale.order'].create({'partner_id': self.partner.id})
recv = self.env['fp.receiving'].create({
'sale_order_id': so.id,
'partner_id': self.partner.id,
'expected_qty': 1,
'received_qty': 1,
'box_count_in': 1,
})
self.env['fp.receiving.line'].create({
'receiving_id': recv.id,
'part_catalog_id': other_part.id,
'expected_qty': 1,
'received_qty': 1,
})
# Should NOT raise.
recv.action_mark_counted()
recv.action_close()
def test_multi_part_so_matches_per_part(self):
"""Two jobs on the same SO, each for a different part. Closing
a receiving with two lines must mirror to BOTH jobs by part."""
so = self.env['sale.order'].create({'partner_id': self.partner.id})
part_a = self.env['fp.part.catalog'].create({
'name': 'A', 'part_number': 'A-1', 'partner_id': self.partner.id,
})
part_b = self.env['fp.part.catalog'].create({
'name': 'B', 'part_number': 'B-1', 'partner_id': self.partner.id,
})
job_a = self.env['fp.job'].create({
'partner_id': self.partner.id, 'product_id': self.product.id,
'part_catalog_id': part_a.id, 'qty': 3.0,
'sale_order_id': so.id,
})
job_b = self.env['fp.job'].create({
'partner_id': self.partner.id, 'product_id': self.product.id,
'part_catalog_id': part_b.id, 'qty': 7.0,
'sale_order_id': so.id,
})
recv = self.env['fp.receiving'].create({
'sale_order_id': so.id,
'partner_id': self.partner.id,
'expected_qty': 10,
'received_qty': 10,
'box_count_in': 2,
})
self.env['fp.receiving.line'].create({
'receiving_id': recv.id, 'part_catalog_id': part_a.id,
'expected_qty': 3, 'received_qty': 3,
})
self.env['fp.receiving.line'].create({
'receiving_id': recv.id, 'part_catalog_id': part_b.id,
'expected_qty': 7, 'received_qty': 7,
})
recv.action_mark_counted()
recv.action_close()
job_a.invalidate_recordset(['qty_received'])
job_b.invalidate_recordset(['qty_received'])
self.assertEqual(job_a.qty_received, 3)
self.assertEqual(job_b.qty_received, 7)
def test_idempotent_under_repeated_writes(self):
"""Hook is safe to call multiple times — value just settles."""
so, job = self._make_so_with_job()
recv = self._make_receiving(so, received_qty=5)
recv.action_mark_counted()
# Manually nudge the same state transition again (legitimate
# in real life: manager re-opens then re-closes).
recv._update_so_receiving_status()
recv._update_so_receiving_status()
job.invalidate_recordset(['qty_received'])
self.assertEqual(job.qty_received, 5)

View File

@@ -64,24 +64,33 @@
as page&#160;2 — open the Certificate&#160;PDF tab to verify.
</div>
<div class="alert alert-warning" role="alert"
invisible="not x_fc_job_id or state != 'draft' or x_fc_thickness_status != 'none' or not partner_id"
invisible="state != 'draft' or x_fc_thickness_status != 'none' or not partner_id"
style="margin-top:0;">
<i class="fa fa-exclamation-triangle" title="Warning"
aria-label="Warning"/>
<strong> No Fischerscope PDF on the linked QC.</strong>
If this customer expects an XRF report with the CoC,
have the operator upload the Fischerscope PDF on the
QC check before issuing.
<strong> No Fischerscope PDF available.</strong>
Drop the PDF into the <em>Thickness Report
(Fischerscope)</em> tab below, or upload it on the
linked QC check, before issuing. Thickness Report
certs cannot issue without thickness data.
</div>
</xpath>
<!-- 3. Add a Thickness Report tab right next to the -->
<!-- Certificate PDF tab so operator can preview the -->
<!-- Fischerscope file before merging into the cert. -->
<!-- 3. Thickness Report tab — single place to see/edit
every Fischerscope-related field on the cert.
Reorganized 2026-05-21:
* Status + linked QC at the top (read-only context)
* XDAL 600 metadata (operator/product/etc.) editable
so manager can correct OCR mistakes
* Microscope image preview (auto-extracted from RTF
or manually uploaded — either way editable here)
* Source files (PDF / non-PDF evidence / source name)
* Upload wizard button + help text -->
<xpath expr="//notebook/page[@name='pdf']" position="after">
<page string="Thickness Report (Fischerscope)"
name="thickness_pdf"
invisible="not x_fc_job_id">
name="thickness_pdf">
<!-- Status + QC link (read-only context) -->
<group>
<field name="x_fc_thickness_status" widget="badge"
readonly="1"
@@ -90,40 +99,99 @@
decoration-success="x_fc_thickness_status == 'merged'"/>
<field name="x_fc_thickness_qc_id" readonly="1"
invisible="not x_fc_thickness_qc_id"/>
<field name="x_fc_thickness_pdf_id" readonly="1"
widget="many2one_binary"
invisible="not x_fc_thickness_pdf_id"/>
</group>
<!-- Hints rotate by state -->
<div class="text-muted"
invisible="x_fc_thickness_status != 'none'">
<p>
No Fischerscope thickness PDF has been
uploaded on the linked QC yet. The CoC will
be issued without an appended thickness
report. To attach one:
No Fischerscope thickness data has been
uploaded yet. Click <strong>Upload Thickness
Report</strong> below to drop a `.doc` / `.docx`
/ `.rtf` / `.pdf` file straight from the
XDAL&#160;600. The wizard parses readings +
metadata and fills out the fields on this tab.
</p>
<ol>
<li>Open the linked Plating Job (smart
button above)</li>
<li>Click into the auto-spawned Quality
Check</li>
<li>Go to the <em>Thickness Report</em> tab
and upload the PDF from the Fischerscope
/ XDAL 600 export</li>
<li>Pass the QC, then come back here and
click Issue</li>
</ol>
</div>
<div class="text-muted"
invisible="x_fc_thickness_status != 'pending'">
<p>
<i class="fa fa-arrow-up" title="Action"
aria-label="Action"/>
Click <strong>Issue</strong> in the header
and the Fischerscope PDF above will be
merged into page&#160;2 of the CoC.
<i class="fa fa-arrow-up"/>
Click <strong>Issue</strong> in the header to
merge the Fischerscope PDF as page&#160;2 of
the CoC. Readings will render inline in the
body of the cert either way.
</p>
</div>
<!-- Upload wizard CTA -->
<div style="margin: 8px 0;">
<button name="%(fusion_plating_certificates.action_fp_thickness_upload_wizard)d"
type="action"
class="btn-primary"
string="Upload Thickness Report"
context="{'default_certificate_id': id}"
invisible="state != 'draft'"/>
</div>
<separator string="XDAL 600 Measurement Context"/>
<p class="text-muted small">
These values are pulled from the uploaded file
and printed on the CoC's thickness section. Edit
any field here to override what the parser saw.
</p>
<group>
<group>
<field name="x_fc_thickness_equipment"
placeholder="Fischerscope XDAL 600"/>
<field name="x_fc_thickness_operator"
placeholder="Operator initials / name"/>
<field name="x_fc_thickness_datetime"/>
<field name="x_fc_thickness_measuring_time_sec"/>
</group>
<group>
<field name="x_fc_thickness_product"
placeholder="e.g. 2805031 / NiP/Al-alloys 2805030"/>
<field name="x_fc_thickness_application"
placeholder="e.g. 16 / NiP/Al-alloys"/>
<field name="x_fc_thickness_directory"
placeholder="XDAL save directory"/>
<field name="x_fc_thickness_source_filename"
readonly="1"/>
</group>
</group>
<separator string="Microscope Image"/>
<p class="text-muted small">
Auto-extracted from RTF uploads (via libwmf) or
manually uploaded via the wizard. Drop a new
PNG/JPEG here to override.
</p>
<group>
<field name="x_fc_thickness_image_id"
options="{'no_create': True}"/>
</group>
<separator string="Source Files"/>
<group>
<group string="Fischerscope PDF"
invisible="not x_fc_local_thickness_pdf">
<field name="x_fc_local_thickness_pdf"
filename="x_fc_local_thickness_pdf_filename"/>
<field name="x_fc_local_thickness_pdf_filename"
invisible="1"/>
</group>
<group string="Non-PDF Evidence (RTF/DOCX)"
invisible="not x_fc_local_thickness_evidence_id">
<field name="x_fc_local_thickness_evidence_id"
options="{'no_create': True}"/>
</group>
<group string="QC-side Fischerscope PDF"
invisible="not x_fc_thickness_pdf_id">
<field name="x_fc_thickness_pdf_id" readonly="1"
widget="many2one_binary"/>
</group>
</group>
</page>
</xpath>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
One-shot backfill for closed jobs that never produced a CoC because
of the `coating` NameError regression (fixed 2026-05-18). Surfaced
as a Settings > Technical menu item so the user can click once after
deploying the fix.
-->
<odoo>
<record id="action_fp_job_backfill_missing_certs" model="ir.actions.server">
<field name="name">Generate Missing Certs for Closed Jobs</field>
<field name="model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_view_types">list</field>
<field name="group_ids" eval="[(4, ref('base.group_system'))]"/>
<field name="state">code</field>
<field name="code">action = env['fp.job'].action_backfill_missing_certs()</field>
</record>
</odoo>

View File

@@ -4,3 +4,4 @@
from . import fp_job_step_move_wizard
from . import fp_job_step_input_wizard
from . import fp_cert_issue_wizard

View File

@@ -0,0 +1,740 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Issue Certs Wizard.
Opened from a job's "Issue Certs" milestone button. Walks each draft
cert on the job, lets the manager upload the Fischerscope/XDAL output
(PDF or .docx) per cert that needs thickness data, and tries to parse
the .docx to pre-populate the readings table. Manager can edit/add
readings before confirming. On confirm:
- PDF uploads land on cert.x_fc_local_thickness_pdf (merged as page 2
of the issued CoC).
- .docx uploads are attached as ir.attachment on the cert (evidence)
and the parsed readings are written as fp.thickness.reading rows.
- cert.action_issue() is called for each cert.
The wizard is a convenience layer — it does NOT replace the per-cert
Issue button on the cert form, which stays as the fallback path.
"""
import base64
import io
import logging
import os
import re
import shutil
import subprocess
import tempfile
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Minimum pixel-area for an extracted RTF image to be treated as the
# "microscope photo" candidate. Filters out narrow header banners
# (~790x203 = 160k pixels) while keeping standard XDAL exports
# (~1024x768 = 786k). See CLAUDE.md "entech apt is broken" for the
# libwmf install path that makes this possible.
_FP_RTF_IMAGE_MIN_AREA = 200_000
# Fischerscope XDAL 600 reading line, e.g.
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
_FISCHER_READING_RE = re.compile(
r'n\s*=\s*(\d+)'
r'\s+NiP\s+\d+\s*=\s*([\d.]+)\s*mils'
r'\s+Ni\s+\d+\s*=\s*([\d.]+)\s*%'
r'\s+P\s+\d+\s*=\s*([\d.]+)\s*%',
re.IGNORECASE,
)
# Capture every {\pict ... \wmetafile8 ...hex...} group in an RTF, in
# document order. The hex blob can be interspersed with whitespace
# (RTF wraps to 80 cols) — the consumer strips it.
_RTF_PICT_WMF_RE = re.compile(
r'\{\\pict'
r'(?:\\[a-zA-Z]+-?\d*\s?)*?'
r'\\wmetafile8'
r'(?:\\[a-zA-Z]+-?\d*\s?)*'
r'\s*([0-9a-fA-F\s]+?)'
r'\}',
re.DOTALL,
)
def _fp_extract_rtf_images(raw_bytes):
"""Pull all WMF picture blocks out of an RTF, unpack to PNG via
libwmf, and return the list of PNG bytes in document order.
XDAL 600 RTF exports embed each picture as a WMF metafile wrapping
the actual raster. ImageMagick on Debian Bookworm doesn't carry a
WMF delegate, so we shell out to `wmf2svg` (from libwmf-bin) — it
writes a thin SVG and a side-file `*-N.png` per raster block. We
keep the PNGs, drop the SVG/WMF temp files.
Returns [] (not raise) on any tooling/parse failure; the cert
issue keeps working even when image extraction can't run.
"""
if not raw_bytes:
return []
try:
text = raw_bytes.decode('latin-1', errors='replace')
except Exception:
return []
blobs = []
for m in _RTF_PICT_WMF_RE.finditer(text):
hex_blob = re.sub(r'\s+', '', m.group(1))
try:
blobs.append(bytes.fromhex(hex_blob))
except ValueError:
continue
if not blobs:
return []
tmpdir = tempfile.mkdtemp(prefix='fp_rtf_wmf_')
pngs = []
try:
for i, wmf in enumerate(blobs):
wmf_path = os.path.join(tmpdir, 'pict%d.wmf' % i)
svg_path = os.path.join(tmpdir, 'pict%d.svg' % i)
with open(wmf_path, 'wb') as fh:
fh.write(wmf)
try:
subprocess.run(
['wmf2svg', '-o', svg_path, wmf_path],
capture_output=True, timeout=20, check=False,
)
except (FileNotFoundError, subprocess.TimeoutExpired) as e:
_logger.warning(
'wmf2svg unavailable or timed out (%s) — skipping '
'RTF image extraction.', e,
)
return []
# wmf2svg writes <basename>-N.png next to the SVG.
for fn in sorted(os.listdir(tmpdir)):
if fn.startswith('pict%d-' % i) and fn.endswith('.png'):
full = os.path.join(tmpdir, fn)
with open(full, 'rb') as fh:
pngs.append(fh.read())
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
return pngs
def _fp_pick_microscope_image(png_bytes_list):
"""Pick the largest-area PNG (by pixel count, not file size) from
the list — that's almost always the microscope photo. Header
banners are wide-but-thin so their pixel area falls below the
threshold. Returns (png_bytes, width, height) or (None, 0, 0)
when no PNG meets the threshold.
"""
try:
from PIL import Image
except ImportError:
# Pillow ships with Odoo; this is defensive.
return (png_bytes_list[0] if png_bytes_list else None, 0, 0)
best = None
best_area = 0
for png in png_bytes_list:
try:
with Image.open(io.BytesIO(png)) as im:
area = im.width * im.height
if area > best_area and area >= _FP_RTF_IMAGE_MIN_AREA:
best = (png, im.width, im.height)
best_area = area
except Exception:
continue
return best or (None, 0, 0)
_FISCHER_CALIB_RE = re.compile(r'Calibr\.\s*Std\.\s*Set\s+(.+?)(?:\s{2,}|$)', re.IGNORECASE)
_FISCHER_OPERATOR_RE = re.compile(r'Operator:\s*(\S+)', re.IGNORECASE)
_FISCHER_DATE_RE = re.compile(r'Date:\s*([\d/]+)', re.IGNORECASE)
_FISCHER_TIME_RE = re.compile(r'Time:\s*([\d:]+\s*[APMapm]*)')
# XDAL 600 header lines — only present on full RTF reports (not on
# the .docx body the upstream parser already handled).
_FISCHER_PRODUCT_RE = re.compile(r'Product:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
_FISCHER_DIRECTORY_RE = re.compile(r'Directory:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
_FISCHER_APPLICATION_RE = re.compile(r'Application:\s*([^\r\n]+?)(?:\s{2,}|$)', re.IGNORECASE)
_FISCHER_MTIME_RE = re.compile(r'Measuring\s+time\s+(\d+)\s*sec', re.IGNORECASE)
_FISCHER_EQUIPMENT_RE = re.compile(r'(Fischerscope[^\r\n]*XDAL\s*\d+)', re.IGNORECASE)
def _fp_strip_rtf(raw_bytes):
"""Best-effort RTF → plain text. RTF is text-based with control
words prefixed by `\\` and groups wrapped in `{}`. We need to strip
all of those plus the hex-encoded image data so the Fischerscope
reading regex hits clean text.
Not a full parser — meant for the narrow case of XRF/XDAL reports
that have a simple body wrapped around an embedded WMF image.
"""
if not raw_bytes:
return ''
# RTF is ASCII-safe; latin-1 round-trips every byte.
text = raw_bytes.decode('latin-1', errors='replace')
# Drop destination groups entirely — these are the image data,
# font tables, color tables, etc. The pattern `{\* ...}` and other
# nested destinations carry binary-ish hex strings we never want.
text = re.sub(r'\{\\\*[^{}]*\}', ' ', text)
text = re.sub(r'\{\\fonttbl[^{}]*\}', ' ', text)
text = re.sub(r'\{\\colortbl[^{}]*\}', ' ', text)
# Pictures: {\pict ...} contains hex image data. The body is the
# part between `\pict...goal\d+` and the closing brace of the group.
# Easier: nuke anything matching the picture marker through the
# next closing brace at the same depth (single-level approximation
# — works for FedEx/XRF docs that have one image per pict block).
text = re.sub(r'\{\\pict[^{}]*\}', ' ', text)
# Remove control words like \rtf1, \ansicpg1252, \par, \tab,
# \tx2840, etc. (`\` + letters + optional digits + optional space)
text = re.sub(r'\\[A-Za-z]+-?\d*\s?', ' ', text)
# Hex escapes (e.g. \'ae for special chars)
text = re.sub(r"\\'[0-9a-fA-F]{2}", ' ', text)
# Other backslash escapes (`\\`, `\{`, `\}`)
text = re.sub(r'\\[^A-Za-z\s]', ' ', text)
# Strip remaining braces
text = text.replace('{', ' ').replace('}', ' ')
# Collapse runs of whitespace so the Fischerscope regex doesn't
# have to deal with weird spacing artefacts from the strip pass.
text = re.sub(r'[ \t]+', ' ', text)
return text
def _fp_parse_fischerscope_rtf(raw_bytes):
"""Fischerscope XDAL 600 RTF export → same dict shape as the
.docx parser. RTF detection is by magic bytes (`{\\rtf`) — the
XRF software names the file `.doc` for legacy reasons, but the
contents are RTF.
"""
empty = {
'readings': [], 'calibration': '', 'operator': '',
'date_str': '', 'time_str': '',
'product': '', 'directory': '', 'application': '',
'measuring_time_sec': 0, 'equipment': '',
'raw_text': '',
}
if not raw_bytes:
return empty
text = _fp_strip_rtf(raw_bytes)
readings = []
for m in _FISCHER_READING_RE.finditer(text):
try:
readings.append((
float(m.group(2)),
float(m.group(3)),
float(m.group(4)),
))
except ValueError:
continue
def _grab(rx):
m = rx.search(text)
return m.group(1).strip() if m else ''
mtime = 0
m = _FISCHER_MTIME_RE.search(text)
if m:
try:
mtime = int(m.group(1))
except ValueError:
mtime = 0
return {
'readings': readings,
'calibration': _grab(_FISCHER_CALIB_RE),
'operator': _grab(_FISCHER_OPERATOR_RE),
'date_str': _grab(_FISCHER_DATE_RE),
'time_str': _grab(_FISCHER_TIME_RE),
'product': _grab(_FISCHER_PRODUCT_RE),
'directory': _grab(_FISCHER_DIRECTORY_RE),
'application': _grab(_FISCHER_APPLICATION_RE),
'measuring_time_sec': mtime,
'equipment': _grab(_FISCHER_EQUIPMENT_RE),
'raw_text': text,
}
def _fp_parse_fischerscope_docx(raw_bytes):
"""Best-effort parse of a Fischerscope XDAL 600 .docx report.
Returns dict:
{
'readings': [(nip_mils, ni_pct, p_pct), ...],
'calibration': str or '',
'operator': str or '',
'date_str': str or '',
'time_str': str or '',
'raw_text': str (the extracted document body, for chatter),
}
Soft-fails to an empty dict-like result when python-docx isn't
installed or the bytes don't parse — the wizard still works, the
operator just has to type readings manually.
"""
empty = {
'readings': [], 'calibration': '', 'operator': '',
'date_str': '', 'time_str': '', 'raw_text': '',
}
if not raw_bytes:
return empty
try:
import docx # python-docx
except ImportError:
_logger.info(
'python-docx not installed — Fischerscope auto-parse '
'skipped. Operator will enter readings manually.'
)
return empty
try:
doc = docx.Document(io.BytesIO(raw_bytes))
except Exception as e:
_logger.warning('Fischerscope .docx parse failed: %s', e)
return empty
# Pull text from paragraphs AND tables (Fischerscope reports
# sometimes lay the readings inside a table cell).
parts = [p.text for p in doc.paragraphs]
for tbl in doc.tables:
for row in tbl.rows:
for cell in row.cells:
parts.append(cell.text)
text = '\n'.join(parts)
readings = []
for m in _FISCHER_READING_RE.finditer(text):
try:
readings.append((
float(m.group(2)), # nip mils
float(m.group(3)), # Ni %
float(m.group(4)), # P %
))
except ValueError:
continue
calib = ''
m = _FISCHER_CALIB_RE.search(text)
if m:
calib = m.group(1).strip()
operator = ''
m = _FISCHER_OPERATOR_RE.search(text)
if m:
operator = m.group(1).strip()
date_str = ''
m = _FISCHER_DATE_RE.search(text)
if m:
date_str = m.group(1).strip()
time_str = ''
m = _FISCHER_TIME_RE.search(text)
if m:
time_str = m.group(1).strip()
return {
'readings': readings,
'calibration': calib,
'operator': operator,
'date_str': date_str,
'time_str': time_str,
'raw_text': text,
}
class FpCertIssueWizard(models.TransientModel):
_name = 'fp.cert.issue.wizard'
_description = 'Fusion Plating — Issue Certs Wizard'
job_id = fields.Many2one(
'fp.job', string='Job', required=True, readonly=True,
)
line_ids = fields.One2many(
'fp.cert.issue.wizard.line', 'wizard_id', string='Certs to Issue',
)
has_blocking_lines = fields.Boolean(
compute='_compute_has_blocking_lines',
help='True when at least one line is missing data the gate '
'requires (no readings, no file, etc.). Used to disable '
'the Confirm button.',
)
@api.depends('line_ids', 'line_ids.is_ready')
def _compute_has_blocking_lines(self):
for w in self:
w.has_blocking_lines = any(not ln.is_ready for ln in w.line_ids)
@api.model
def open_for_job(self, job):
"""Factory — create a wizard pre-populated with one line per
draft cert on the job. Returns an action dict that opens the
wizard form."""
Cert = self.env['fp.certificate'].sudo()
certs = Cert.search([
('x_fc_job_id', '=', job.id),
('state', '=', 'draft'),
])
if not certs:
raise UserError(_(
'No draft certificates on %s to issue.'
) % job.name)
wiz = self.create({
'job_id': job.id,
'line_ids': [(0, 0, {'cert_id': c.id}) for c in certs],
})
return {
'type': 'ir.actions.act_window',
'name': _('Issue Certs — %s') % job.name,
'res_model': self._name,
'res_id': wiz.id,
'view_mode': 'form',
'target': 'new',
}
def action_confirm(self):
"""Apply every line's file + readings, then issue each cert.
Order matters: write the file/readings BEFORE calling action_issue
so the gate sees the populated data. If a single cert raises on
issue, the whole wizard rolls back (transactional).
"""
self.ensure_one()
issued = []
for ln in self.line_ids:
ln._apply_to_cert()
cert = ln.cert_id
if cert.state == 'draft':
cert.action_issue()
issued.append(cert.name)
if not issued:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Certs Issued'),
'message': _('%d cert(s) issued: %s') % (
len(issued), ', '.join(issued),
),
'sticky': False,
'type': 'success',
'next': {'type': 'ir.actions.act_window_close'},
},
}
class FpCertIssueWizardLine(models.TransientModel):
_name = 'fp.cert.issue.wizard.line'
_description = 'Fusion Plating — Issue Certs Wizard Line'
wizard_id = fields.Many2one(
'fp.cert.issue.wizard', required=True, ondelete='cascade',
)
cert_id = fields.Many2one(
'fp.certificate', string='Certificate', required=True, readonly=True,
)
cert_name = fields.Char(related='cert_id.name', readonly=True)
cert_type = fields.Selection(
related='cert_id.certificate_type', readonly=True,
)
partner_id = fields.Many2one(
related='cert_id.partner_id', readonly=True,
)
needs_thickness = fields.Boolean(
compute='_compute_needs_thickness', store=False,
)
fischer_file = fields.Binary(string='Fischerscope File (PDF or .docx)')
fischer_filename = fields.Char(string='Filename')
# Optional: microscope/coupon image exported separately from the
# XDAL 600. The RTF carries an embedded WMF that the entech host
# can't rasterize (no imagemagick/libwmf — see CLAUDE.md "entech
# apt is in a broken-deps state"), so the operator exports a PNG
# from the XDAL software and uploads it here. Rendered inline on
# the CoC's thickness section when present.
fischer_image_file = fields.Binary(string='Measurement Image (PNG/JPEG)')
fischer_image_filename = fields.Char(string='Image Filename')
parsed_summary = fields.Text(
string='Parsed Summary', readonly=True,
help='Output of the .docx parser. Populated when you attach a '
'Fischerscope .docx; the readings table below is auto-'
'filled from the same parse. Empty for PDF uploads.',
)
reading_line_ids = fields.One2many(
'fp.cert.issue.wizard.reading', 'line_id', string='Readings',
)
is_ready = fields.Boolean(
compute='_compute_is_ready',
help='True when this cert has enough data to issue: thickness '
'data present if needed.',
)
@api.depends('cert_id.certificate_type',
'cert_id.partner_id.x_fc_send_thickness_report',
'cert_id.partner_id.x_fc_strict_thickness_required')
def _compute_needs_thickness(self):
for ln in self:
cert = ln.cert_id
partner = cert.partner_id
ln.needs_thickness = (
cert.certificate_type == 'thickness_report'
or (cert.certificate_type == 'coc' and partner and (
partner.x_fc_strict_thickness_required
or partner.x_fc_send_thickness_report
))
)
@api.depends('needs_thickness', 'fischer_file', 'reading_line_ids',
'cert_id.thickness_reading_ids',
'cert_id.x_fc_local_thickness_pdf')
def _compute_is_ready(self):
for ln in self:
if not ln.needs_thickness:
ln.is_ready = True
continue
ln.is_ready = bool(
ln.fischer_file
or ln.reading_line_ids
or ln.cert_id.thickness_reading_ids
or ln.cert_id.x_fc_local_thickness_pdf
)
@api.onchange('fischer_file', 'fischer_filename')
def _onchange_fischer_file(self):
"""Parse .docx OR RTF on upload (XDAL 600 names RTF files
`.doc` — detected by magic bytes; see CLAUDE.md "Fischerscope
XDAL 600 `.doc` files are actually RTF"). Prefill the readings
+ summary so the operator can verify before issuing."""
if not self.fischer_file:
return
try:
raw = base64.b64decode(self.fischer_file)
except Exception:
self.parsed_summary = _('Could not decode the uploaded file.')
return
name = (self.fischer_filename or '').lower()
is_rtf = raw[:5] == b'{\\rtf' or name.endswith('.rtf')
if is_rtf:
parsed = _fp_parse_fischerscope_rtf(raw)
elif name.endswith('.docx'):
parsed = _fp_parse_fischerscope_docx(raw)
else:
self.parsed_summary = _(
'Non-parseable upload (%s) — file will be attached as '
'evidence. Type readings manually below if needed.'
) % (self.fischer_filename or 'unnamed')
return
readings = parsed.get('readings') or []
if readings:
self.reading_line_ids = [(5, 0, 0)] + [
(0, 0, {
'sequence': i + 1,
'nip_mils': nip,
'ni_percent': ni,
'p_percent': p,
})
for i, (nip, ni, p) in enumerate(readings)
]
self.parsed_summary = _(
'Parsed %(n)d reading(s) · Calibration: %(c)s · '
'Operator: %(o)s · Date: %(d)s %(t)s'
) % {
'n': len(readings),
'c': parsed.get('calibration') or '',
'o': parsed.get('operator') or '',
'd': parsed.get('date_str') or '',
't': parsed.get('time_str') or '',
}
def _write_thickness_metadata_to_cert(self, cert, parsed):
"""Persist the Fischerscope header block (operator, product,
application, equipment, measuring time, date/time, source
filename) onto the cert so the CoC report can render a full
report block instead of a bare readings table.
"""
vals = {}
field_map = (
('x_fc_thickness_operator', parsed.get('operator')),
('x_fc_thickness_product', parsed.get('product')),
('x_fc_thickness_directory', parsed.get('directory')),
('x_fc_thickness_application', parsed.get('application')),
('x_fc_thickness_measuring_time_sec',
parsed.get('measuring_time_sec') or 0),
('x_fc_thickness_equipment',
parsed.get('equipment') or 'Fischerscope XDAL 600'),
('x_fc_thickness_source_filename',
self.fischer_filename or ''),
)
for fname, fval in field_map:
if fname in cert._fields and fval:
vals[fname] = fval
# Combine the gauge's date+time and parse to Datetime — try a
# few formats since XDAL exports vary (12h vs 24h, with/without
# seconds). Best-effort: leave the field blank if no format
# matches rather than crashing the cert issue.
date_str = (parsed.get('date_str') or '').strip()
time_str = (parsed.get('time_str') or '').strip()
if date_str and 'x_fc_thickness_datetime' in cert._fields:
from datetime import datetime
combined = ('%s %s' % (date_str, time_str)).strip()
for fmt in (
'%m/%d/%Y %I:%M:%S %p', '%m/%d/%Y %I:%M %p',
'%m/%d/%Y %H:%M:%S', '%m/%d/%Y %H:%M',
'%m/%d/%Y',
):
try:
vals['x_fc_thickness_datetime'] = datetime.strptime(
combined, fmt,
)
break
except ValueError:
continue
if vals:
cert.write(vals)
def _apply_to_cert(self):
"""Write this line's data into the cert.
Order matters: operator-uploaded PNG must run LAST so it wins
over any image the RTF auto-extraction picked. Reverse order
(PNG first, then RTF) lets the WMF blow away the explicit
operator choice — exactly the bug we just hit.
"""
self.ensure_one()
cert = self.cert_id.sudo()
if not self.fischer_file:
# Just push manual readings, if any.
self._push_readings_to_cert()
# PNG-only path: still attach the operator's image upload.
self._apply_image_to_cert(cert)
return
name = (self.fischer_filename or 'fischerscope').lower()
calibration = '' # backfilled below if the parser hits
if name.endswith('.pdf'):
# Drop the PDF into the cert-local field — merges into page 2.
cert.write({
'x_fc_local_thickness_pdf': self.fischer_file,
'x_fc_local_thickness_pdf_filename': self.fischer_filename,
})
else:
# .doc / .docx / anything else — attach as evidence AND
# link the attachment to the cert's evidence slot so the
# thickness-required gate recognises it. Without the link,
# the gate would still raise (it checks specific fields,
# not stray attachments) and rolling back the transaction
# would orphan the upload.
att = self.env['ir.attachment'].sudo().create({
'name': self.fischer_filename or 'fischerscope-report',
'type': 'binary',
'datas': self.fischer_file,
'res_model': 'fp.certificate',
'res_id': cert.id,
})
if 'x_fc_local_thickness_evidence_id' in cert._fields:
cert.write({'x_fc_local_thickness_evidence_id': att.id})
# Re-parse the file at apply time so the report-header
# metadata (operator, product, application, etc.) makes it
# onto the cert. Onchange populates reading_line_ids but
# not the cert-level fields. Best-effort: any parse hiccup
# is logged and we still complete the attachment + readings.
try:
raw = base64.b64decode(self.fischer_file)
is_rtf = raw[:5] == b'{\\rtf'
if is_rtf:
parsed = _fp_parse_fischerscope_rtf(raw)
elif name.endswith('.docx'):
parsed = _fp_parse_fischerscope_docx(raw)
else:
parsed = None
if parsed:
self._write_thickness_metadata_to_cert(cert, parsed)
calibration = parsed.get('calibration') or ''
# WMF image extraction is RTF-only (the .docx path
# uses python-docx which already gives PIL-readable
# bitmaps; that flow can be added later if needed).
if is_rtf and 'x_fc_thickness_image_id' in cert._fields:
pngs = _fp_extract_rtf_images(raw)
img_bytes, img_w, img_h = _fp_pick_microscope_image(pngs)
if img_bytes:
img_att = self.env['ir.attachment'].sudo().create({
'name': '%s-microscope.png' % (
(self.fischer_filename or 'fischerscope')
.rsplit('.', 1)[0]
),
'type': 'binary',
'datas': base64.b64encode(img_bytes),
'mimetype': 'image/png',
'res_model': 'fp.certificate',
'res_id': cert.id,
})
cert.write({
'x_fc_thickness_image_id': img_att.id,
})
_logger.info(
'Cert %s: attached microscope image '
'(%dx%d, %d bytes)',
cert.name, img_w, img_h, len(img_bytes),
)
except Exception as exc:
_logger.warning(
'Cert %s: Fischerscope metadata extraction failed: %s',
cert.name, exc,
)
cert.message_post(body=Markup(_(
'Fischerscope file <b>%s</b> attached via Issue wizard.'
)) % (self.fischer_filename or 'unnamed'))
self._push_readings_to_cert(calibration=calibration)
# Operator's PNG upload wins over auto-extracted WMF — runs
# last so it overwrites x_fc_thickness_image_id if both paths
# supplied an image.
self._apply_image_to_cert(cert)
def _apply_image_to_cert(self, cert):
"""Attach the operator-uploaded PNG/JPEG and link it to the
cert's image slot so the CoC report can render it inline.
No-op when nothing was uploaded. Mirrors the evidence-file
pattern: file is attached as a regular ir.attachment AND
linked to the dedicated field so the report template can
find it predictably.
"""
self.ensure_one()
if not self.fischer_image_file or \
'x_fc_thickness_image_id' not in cert._fields:
return
att = self.env['ir.attachment'].sudo().create({
'name': self.fischer_image_filename or 'thickness-image.png',
'type': 'binary',
'datas': self.fischer_image_file,
'res_model': 'fp.certificate',
'res_id': cert.id,
})
cert.write({'x_fc_thickness_image_id': att.id})
def _push_readings_to_cert(self, calibration=''):
"""Create fp.thickness.reading rows on the cert from wizard rows.
Skips when no rows. Does not deduplicate against existing
readings — the manager has just told us this is the new data.
Per-reading calibration_std_ref is stamped from the optional
`calibration` arg so the printed CoC's calibration line stays
accurate even when readings are re-pushed from a fresh upload.
"""
self.ensure_one()
Reading = self.env.get('fp.thickness.reading')
if Reading is None or not self.reading_line_ids:
return
for r in self.reading_line_ids:
vals = {
'certificate_id': self.cert_id.id,
'nip_mils': r.nip_mils,
'ni_percent': r.ni_percent,
'p_percent': r.p_percent,
}
if 'reading_number' in Reading._fields:
vals['reading_number'] = r.sequence
if calibration and 'calibration_std_ref' in Reading._fields:
vals['calibration_std_ref'] = calibration
Reading.sudo().create(vals)
class FpCertIssueWizardReading(models.TransientModel):
_name = 'fp.cert.issue.wizard.reading'
_description = 'Fusion Plating — Issue Certs Wizard Reading Row'
_order = 'sequence, id'
line_id = fields.Many2one(
'fp.cert.issue.wizard.line', required=True, ondelete='cascade',
)
sequence = fields.Integer(default=1)
nip_mils = fields.Float(string='NiP (mils)', digits=(10, 4))
ni_percent = fields.Float(string='Ni %', digits=(6, 3))
p_percent = fields.Float(string='P %', digits=(6, 3))

View File

@@ -0,0 +1,157 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<record id="view_fp_cert_issue_wizard_form" model="ir.ui.view">
<field name="name">fp.cert.issue.wizard.form</field>
<field name="model">fp.cert.issue.wizard</field>
<field name="arch" type="xml">
<form string="Issue Certs"
js_class="fp_cert_issue_wizard_form"
class="o_fp_cert_issue_wizard_form">
<sheet>
<div class="oe_title">
<h2>
Issue Certs —
<field name="job_id" readonly="1" nolabel="1"/>
</h2>
</div>
<div class="alert alert-info" role="alert"
invisible="not has_blocking_lines">
<i class="fa fa-info-circle me-1"/>
At least one cert still needs thickness data
(Fischerscope file or readings).
<strong>Click a row, then click
<em>Upload your file</em> in the Fischerscope
column.</strong>
</div>
<!-- 2026-05-20: surface the file upload INLINE in the
list instead of behind a row-click into a sub-form.
Operators kept missing the upload affordance — the
list looked like a status display, not an action
surface. Adding the binary field as a column lets
them drop the Fischerscope file right where they
see "Needs Thickness" turned on. The form behind
the row click stays as a "details" expansion for
per-reading editing after upload. -->
<field name="line_ids" nolabel="1">
<list editable="bottom" create="false" delete="false">
<field name="cert_name" readonly="1"
string="Reference"/>
<field name="cert_type" readonly="1"
string="Type"/>
<field name="partner_id" readonly="1"
string="Customer"/>
<field name="needs_thickness" readonly="1"
widget="boolean_toggle"
string="Needs Thickness"/>
<!-- Upload column. Visible/required only when
the cert needs thickness data. Triggers
the @onchange-driven .docx parser. -->
<field name="fischer_filename" column_invisible="1"/>
<field name="fischer_file"
filename="fischer_filename"
widget="binary"
string="Fischerscope File (PDF or .docx)"
invisible="not needs_thickness"
readonly="not needs_thickness"/>
<field name="parsed_summary" readonly="1"
string="Parsed"
optional="show"
invisible="not needs_thickness or not parsed_summary"/>
<field name="is_ready" widget="boolean_toggle"
readonly="1"
string="Ready"
decoration-success="is_ready"
decoration-danger="not is_ready"/>
</list>
<form>
<header>
<field name="is_ready" widget="statusbar"
statusbar_visible="True,False"/>
</header>
<sheet>
<group>
<group>
<field name="cert_name" readonly="1"/>
<field name="cert_type" readonly="1"/>
</group>
<group>
<field name="partner_id" readonly="1"/>
<field name="needs_thickness"
readonly="1"
widget="boolean_toggle"/>
</group>
</group>
<group string="Fischerscope File"
invisible="not needs_thickness">
<field name="fischer_file"
filename="fischer_filename"/>
<field name="fischer_filename"
invisible="1"/>
</group>
<group string="Measurement Image (Optional)"
invisible="not needs_thickness">
<field name="fischer_image_file"
filename="fischer_image_filename"
widget="image"
options="{'size': [200, 200]}"/>
<field name="fischer_image_filename"
invisible="1"/>
<div colspan="2" class="text-muted small">
Drop a PNG/JPEG of the coupon
under the XRF probe (export
from the XDAL 600 software's
Image menu). Rendered inline on
the printed CoC so the customer
sees the actual measurement.
</div>
</group>
<div class="alert alert-info"
role="alert"
invisible="not needs_thickness or not parsed_summary">
<i class="fa fa-check-circle me-1"/>
<field name="parsed_summary"
readonly="1" nolabel="1"/>
</div>
<separator string="Thickness Readings"
invisible="not needs_thickness"/>
<p class="text-muted small"
invisible="not needs_thickness">
Auto-filled from the .docx upload above.
Edit/add rows manually as needed.
</p>
<field name="reading_line_ids"
invisible="not needs_thickness">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="nip_mils"/>
<field name="ni_percent"/>
<field name="p_percent"/>
</list>
</field>
</sheet>
</form>
</field>
</sheet>
<footer>
<button name="action_confirm" type="object"
string="Confirm &amp; Issue"
class="btn-primary"
invisible="has_blocking_lines"/>
<button name="action_confirm" type="object"
string="Confirm &amp; Issue"
class="btn-secondary"
invisible="not has_blocking_lines"
disabled="1"
help="One or more certs still need thickness data."/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Logistics',
'version': '19.0.3.8.0',
'version': '19.0.3.10.0',
'category': 'Manufacturing/Plating',
'summary': (
'Pickup & delivery for plating shops: vehicle master, driver '
@@ -43,6 +43,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating',
'fusion_plating_configurator',
'fusion_plating_receiving', # Shared "Shipping & Receiving" menu root
'fusion_shipping',
'hr',
'mail',
],

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
@@ -123,6 +125,86 @@ class FpDelivery(models.Model):
'ir.attachment',
string='Packing List',
)
# ---- Phase A — outbound carrier + shipment link ----------------------
# Mirrors the fields on fp.receiving. Populated by
# fp.job._fp_create_delivery from the linked receiving when this
# delivery is auto-created on job-done; shipping crew can override
# at ship time.
x_fc_carrier_id = fields.Many2one(
'delivery.carrier', string='Outbound Carrier', tracking=True,
ondelete='set null',
help='Carrier picked at receiving time; can be overridden by '
'the shipping crew before issuing the label.',
)
x_fc_outbound_shipment_id = fields.Many2one(
'fusion.shipment', string='Outbound Shipment', tracking=True,
ondelete='set null',
copy=False,
help='The shipment record carrying weight, dimensions, label '
'PDF, and tracking. Usually the same shipment that was '
'created at receiving time.',
)
x_fc_outbound_shipment_count = fields.Integer(
compute='_compute_x_fc_outbound_shipment_count',
)
@api.depends('x_fc_outbound_shipment_id')
def _compute_x_fc_outbound_shipment_count(self):
for rec in self:
rec.x_fc_outbound_shipment_count = (
1 if rec.x_fc_outbound_shipment_id else 0
)
@api.onchange('x_fc_carrier_id')
def _onchange_x_fc_carrier_id(self):
for rec in self:
ship = rec.x_fc_outbound_shipment_id
if ship and ship.status == 'draft' and rec.x_fc_carrier_id:
ship.carrier_id = rec.x_fc_carrier_id.id
def action_create_outbound_shipment(self):
self.ensure_one()
if self.x_fc_outbound_shipment_id:
return self.action_view_outbound_shipment()
if 'fusion.shipment' not in self.env:
raise UserError(_(
'fusion_shipping module is not installed. '
'Cannot create an outbound shipment.'
))
SO = self.env['sale.order'].sudo()
so = False
if self.job_ref:
Job = self.env.get('fp.job')
if Job is not None:
job = Job.sudo().search(
[('name', '=', self.job_ref)], limit=1,
)
so = job.sale_order_id if job else False
vals = {
'sale_order_id': so.id if so else False,
'carrier_id': self.x_fc_carrier_id.id if self.x_fc_carrier_id else False,
'status': 'draft',
}
shipment = self.env['fusion.shipment'].sudo().create(vals)
self.x_fc_outbound_shipment_id = shipment.id
self.message_post(body=Markup(_(
'Outbound shipment <b>%s</b> created (draft).'
)) % shipment.name)
return self.action_view_outbound_shipment()
def action_view_outbound_shipment(self):
self.ensure_one()
if not self.x_fc_outbound_shipment_id:
return False
return {
'type': 'ir.actions.act_window',
'name': self.x_fc_outbound_shipment_id.name,
'res_model': 'fusion.shipment',
'res_id': self.x_fc_outbound_shipment_id.id,
'view_mode': 'form',
'target': 'current',
}
state = fields.Selection(
[
('draft', 'Draft'),
@@ -178,6 +260,48 @@ class FpDelivery(models.Model):
def _fp_parent_counter_field(self):
return 'x_fc_pn_delivery_count'
def action_refresh_from_source(self):
"""Re-pull delivery address / contact / scheduled date / source
facility / carrier / CoC from the linked job → SO → receiving →
cert chain. Only fills BLANK fields — never overwrites operator
edits. Use when an upstream value changed after the delivery
was auto-created, or to backfill an old delivery that was
created before the auto-populate hook existed.
"""
for rec in self:
job = (rec.x_fc_job_id
if 'x_fc_job_id' in rec._fields else False)
if not job:
# Fall back via job_ref Char if M2O is empty (older data)
if rec.job_ref and 'fp.job' in self.env:
job = self.env['fp.job'].sudo().search(
[('name', '=', rec.job_ref)], limit=1,
)
if not job:
raise UserError(_(
'Delivery %s has no linked job — nothing to '
'refresh from.'
) % rec.name)
Delivery = rec.env['fusion.plating.delivery']
defaults = job._fp_resolve_delivery_defaults(Delivery)
# Drop fields the operator already filled — never clobber
# manual edits. Includes the partner/job links since those
# are non-overridable.
fill = {
k: v for k, v in defaults.items()
if v and not rec[k]
}
if not fill:
rec.message_post(body=_(
'Refresh from source: nothing to update — every '
'field already populated.'
))
continue
rec.sudo().write(fill)
rec.message_post(body=_(
'Refresh from source filled: %s'
) % ', '.join(sorted(fill.keys())))
@api.model_create_multi
def create(self, vals_list):
"""Parent-derived name (DLV-<parent>[-NN]) with legacy-sequence

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_delivery_shipping_fields

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Phase A — mirror carrier + outbound shipment fields on fp.delivery."""
from odoo.tests.common import TransactionCase
class TestDeliveryShippingFields(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'ShipCust'})
def test_carrier_id_field_exists_on_delivery(self):
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
})
self.assertIn('x_fc_carrier_id', delivery._fields)
def test_outbound_shipment_id_field_exists_on_delivery(self):
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
})
self.assertIn('x_fc_outbound_shipment_id', delivery._fields)

View File

@@ -55,10 +55,31 @@
invisible="state in ('delivered','cancelled')"/>
<button name="action_reset_to_draft" string="Reset to Draft" type="object"
invisible="state != 'cancelled'"/>
<!-- Pulls delivery address / contact / scheduled
date / source facility / carrier / CoC from
the job → SO → receiving → cert chain. Only
fills BLANK fields, never overwrites operator
edits. Useful when upstream data changed or
to backfill an old delivery. -->
<button name="action_refresh_from_source"
string="Refresh from Source"
type="object" class="btn-secondary"
icon="fa-refresh"
invisible="state in ('delivered','cancelled')"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,scheduled,en_route,delivered"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_create_outbound_shipment"
type="object"
class="oe_stat_button"
icon="fa-truck">
<field name="x_fc_outbound_shipment_count"
widget="statinfo"
string="Outbound Shipment"/>
</button>
</div>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" readonly="1"/></h1>
@@ -84,7 +105,9 @@
<field name="vehicle_id"/>
<field name="tdg_required" widget="boolean_toggle"/>
</group>
<group string="Documents">
<group string="Outbound Shipping">
<field name="x_fc_carrier_id"
options="{'no_create': True}"/>
<field name="coc_attachment_id"/>
<field name="packing_list_attachment_id"/>
<field name="pod_id" readonly="1"/>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Notifications',
'version': '19.0.6.4.0',
'version': '19.0.6.6.0',
'category': 'Manufacturing/Plating',
'summary': 'Auto-email notifications at workflow milestones with configurable templates, PDF attachments, and audit log.',
'author': 'Nexa Systems Inc.',
@@ -22,6 +22,7 @@
'fusion_plating_invoicing',
'fusion_plating_logistics',
'fusion_plating_reports',
'fusion_shipping',
'sale_management',
'account',
'mail',

View File

@@ -35,6 +35,13 @@
<field name="active" eval="True"/>
</record>
<record id="fp_notif_shipment_labeled" model="fp.notification.template">
<field name="name">Shipping Label Generated</field>
<field name="trigger_event">shipment_labeled</field>
<field name="mail_template_id" ref="fp_mail_template_shipment_labeled"/>
<field name="active" eval="True"/>
</record>
<record id="fp_notif_shipped" model="fp.notification.template">
<field name="name">Shipped / Delivered</field>
<field name="trigger_event">shipped</field>

View File

@@ -16,7 +16,7 @@
<record id="fp_mail_template_quote_sent" model="mail.template">
<field name="name">FP: Quotation Sent</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="subject">Quotation {{ object.name }} — EN Technologies</field>
<field name="subject">Quotation {{ object.name }} — Electroless Nickel Technologies Inc. (ENTECH)</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="auto_delete" eval="True"/>
@@ -24,7 +24,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Quotation Ready</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -54,16 +54,15 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>
<field name="report_template_ids"
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
<field name="report_name">Quotation_{{ (object.name or '').replace('/','_') }}</field>
</record>
<!-- ============================================================= -->
@@ -80,7 +79,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Order Confirmed</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -114,16 +113,15 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>
<field name="report_template_ids"
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_sale_portrait')])]"/>
<field name="report_name">SalesOrder_{{ (object.name or '').replace('/','_') }}</field>
</record>
<!-- ============================================================= -->
@@ -140,7 +138,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Parts Received</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -170,10 +168,10 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>
@@ -184,6 +182,70 @@
fp.notification.template's `job_complete` trigger, defined
in fp_notification_template_data.xml. -->
<!-- ============================================================= -->
<!-- 4b. Shipping Label Generated (Info, #2B6CB0) -->
<!-- Fires when fusion.shipment.tracking_number first lands. -->
<!-- Customer gets the tracking link BEFORE the package goes -->
<!-- out the door, so they can monitor from pickup. -->
<!-- ============================================================= -->
<record id="fp_mail_template_shipment_labeled" model="mail.template">
<field name="name">FP: Shipping Label Generated</field>
<field name="model_id" ref="fusion_shipping.model_fusion_shipment"/>
<field name="subject">Tracking #{{ object.tracking_number }} — your order is being prepared for shipment</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ (object.sale_order_id and object.sale_order_id.partner_id.email) or '' }}</field>
<field name="auto_delete" eval="True"/>
<field name="body_html" type="html">
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Order Is Being Prepared for Shipment</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
Hi <t t-out="object.sale_order_id.partner_id.name or ''"/>, the shipping label has been generated for your order. Tracking starts as soon as our shipping crew hands the package to the carrier.
</p>
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
<tr style="border-bottom: 2px solid rgba(128,128,128,0.35);">
<th style="text-align: left; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Shipment</th>
<th style="text-align: right; padding: 8px 4px; font-size: 12px; text-transform: uppercase; opacity: 0.55; font-weight: 600;">Detail</th>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Sale Order</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace;"><t t-out="object.sale_order_id.name or '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25); background: rgba(128,128,128,0.06);">
<td style="padding: 8px 4px;">Carrier</td>
<td style="padding: 8px 4px; text-align: right;"><t t-out="object.carrier_id.name or '—'"/></td>
</tr>
<tr style="border-bottom: 1px solid rgba(128,128,128,0.25);">
<td style="padding: 8px 4px;">Tracking Number</td>
<td style="padding: 8px 4px; text-align: right; font-family: monospace; font-weight: bold;"><t t-out="object.tracking_number or '—'"/></td>
</tr>
</table>
<div t-if="object.x_fc_tracking_url" style="margin: 24px 0; text-align: center;">
<a t-att-href="object.x_fc_tracking_url"
target="_blank"
rel="noopener noreferrer"
style="display: inline-block; padding: 12px 28px; background-color: #2B6CB0; color: #ffffff; text-decoration: none; font-weight: 600; border-radius: 4px;">
Track Shipment
</a>
</div>
<div style="border-left: 3px solid #2B6CB0; padding: 12px 16px; margin: 20px 0; font-size: 14px;">
<strong>What's next:</strong> Once the carrier collects the package, you'll receive a Shipped confirmation with the Certificate of Conformance attached.
</div>
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>
</record>
<!-- ============================================================= -->
<!-- 5. Shipped / Delivered (Success, #38a169) -->
<!-- ============================================================= -->
@@ -198,7 +260,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Your Parts Have Shipped</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -232,10 +294,10 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>
@@ -247,7 +309,7 @@
<record id="fp_mail_template_invoice_posted" model="mail.template">
<field name="name">FP: Invoice Notification</field>
<field name="model_id" ref="account.model_account_move"/>
<field name="subject">Invoice {{ object.name }} — EN Technologies</field>
<field name="subject">Invoice {{ object.name }} — Electroless Nickel Technologies Inc. (ENTECH)</field>
<field name="email_from">{{ (object.company_id.email or user.email) }}</field>
<field name="email_to">{{ object.partner_id.email }}</field>
<field name="auto_delete" eval="True"/>
@@ -255,7 +317,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #2B6CB0; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #2B6CB0; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Invoice Ready</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -293,16 +355,15 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>
<field name="report_template_ids"
eval="[(6, 0, [ref('fusion_plating_reports.action_report_fp_invoice_portrait')])]"/>
<field name="report_name">Invoice_{{ (object.name or '').replace('/','_') }}</field>
</record>
<!-- ============================================================= -->
@@ -319,7 +380,7 @@
<div style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; max-width: 600px; margin: 0 auto; padding: 32px 24px;">
<div style="height: 4px; background-color: #38a169; margin-bottom: 28px;"></div>
<div style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #38a169; font-weight: 600; margin-bottom: 8px;">
EN Technologies
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<h2 style="margin: 0 0 8px 0; font-size: 22px; font-weight: bold;">Payment Received — Thank You</h2>
<p style="margin: 0 0 20px 0; font-size: 15px; opacity: 0.65;">
@@ -353,10 +414,10 @@
<div style="margin-top: 32px; font-size: 14px;">
Best regards,<br/>
<strong><t t-out="user.name or ''"/></strong><br/>
EN Technologies Inc.
Electroless Nickel Technologies Inc. (ENTECH)
</div>
<div style="margin-top: 40px; padding-top: 16px; border-top: 1px solid rgba(128,128,128,0.25); font-size: 11px; opacity: 0.5; text-align: center;">
This is an automated notification from EN Technologies production system.
This is an automated notification from Electroless Nickel Technologies Inc. (ENTECH) production system.
</div>
</div>
</field>

View File

@@ -15,3 +15,4 @@ from . import account_payment
# fires from fp.job.button_mark_done -> _fp_fire_notification('job_complete').
# from . import mrp_production
from . import fp_delivery
from . import fusion_shipment

Some files were not shown because too many files have changed in this diff Show More