21 Commits

Author SHA1 Message Date
gsinghpal
191a9c82be changes 2026-05-16 13:07:50 -04:00
gsinghpal
00981a502a feat(acr-wedge+kiosk): SSE bridge for ACR122U / PC-SC readers
macOS keystroke injection from a CLI-launched Python hits multiple
TCC permission walls (Accessibility AND Automation, both attaching
to identities macOS often can't resolve cleanly). After bouncing
through Quartz, AppleScript, and pyautogui fallbacks, none of them
worked reliably in our test environment.

Switch to a proper IPC channel instead of pretending to be a
keyboard.

Daemon (wedge.py):
  - Adds a ThreadingHTTPServer on 127.0.0.1:8765 exposing /events
  - SSE stream pushes each detected UID as one event
  - 30s keep-alive comments to keep idle connections open
  - CORS: Access-Control-Allow-Origin: * (kiosk page may be on any
    client-domain HTTPS origin; SSE source is always localhost)
  - Keystroke injection kept as best-effort fallback for non-SSE
    clients

Kiosk JS (fusion_clock_nfc_kiosk.js):
  - Adds startWedgeSseListener() that opens EventSource to
    http://localhost:8765/events on setup
  - On message: same handleTap()/_onEnrollTap() flow as Web NFC + HID
  - EventSource auto-reconnects; first error is logged then silenced
  - http://localhost is a "potentially trustworthy origin" so this
    works from https:// pages without mixed-content blocking

Result: ACR122U + wedge.py daemon now drives the kiosk with zero
macOS permission prompts and no focused-window dependency. Same
input plumbing as Web NFC and HID — penalty/photo/activity log
fire identically.

Bump fusion_clock to 19.0.3.3.0.
2026-05-15 20:10:40 -04:00
gsinghpal
d75198be9f fix(acr-wedge): use AppleScript on macOS for keystroke injection
pyautogui's Quartz-based keystroke path often fails on newer macOS
because the Python CLI binary doesn't auto-surface in System Settings
> Accessibility. User reported the daemon detected taps fine but
keystrokes never landed in any window.

Switch to AppleScript / System Events on macOS. Permission attaches
to whatever terminal/app launched the Python process (Terminal.app,
iTerm, etc.) — a familiar named app the user can grant Accessibility
to in one click. Combined keystroke + Return in a single osascript
call to keep latency ~100ms per tap.

Fall back to pyautogui if osascript fails (handles edge cases) and
on non-macOS platforms.
2026-05-15 19:56:49 -04:00
gsinghpal
d009a1ef50 feat(acr-wedge): ACR122U PC/SC -> keyboard wedge daemon
ACR122U is a 13.56 MHz PC/SC (CCID) reader, not HID. Browsers can't
talk to PC/SC devices directly, so the kiosk JS can't see ACR122U
taps the way it sees a USB-HID reader.

This daemon bridges the gap:
  - Polls the ACR122U via pyscard
  - Reads UID via the standard ACS GET_UID APDU (FF CA 00 00 00)
  - Types UID + Enter into the focused window using pyautogui
  - Debounces re-reads of the same card (2s window)

Output format matches FusionClockNfcKiosk._normalize_uid() expectations:
colon-separated uppercase hex (04:10:5B:CA:FD:22:90 + Enter).

The kiosk JS already has a keyboard-wedge listener (v19.0.3.2.0+),
so no server-side or kiosk-side changes needed — wedge.py's
keystrokes route through the same handleTap() path as a USB-HID
reader, preserving photo verification + penalty + activity log.

Setup docs include macOS, Windows, Linux instructions plus
launchd/Task Scheduler/systemd snippets for running as a service.

Strategic value: with this, ACR122U deployments support UA-Pockets
(13.56 MHz DESFire EV3) for single-card door+clock setups in the
premium tier of the standard product kit. The 125 kHz EM4100 USB-C
HID reader remains the default tier.
2026-05-15 19:45:53 -04:00
gsinghpal
9001b6fc51 feat(fusion_clock): USB HID reader support + desktop-tolerant kiosk setup
The NFC kiosk previously required Web NFC, which is Android-Chrome-only.
This blocked desktop testing and locked us to a single hardware path.

Add a keyboard-wedge listener that captures keystrokes from USB HID NFC
readers (the standard Sycreader/Yanzeo class). The listener buffers hex
chars + separators, flushes on Enter (or 600ms idle as fallback for
readers without a terminator), and routes the UID through the same
handleTap()/_onEnrollTap() codepath as Web NFC. Photo verification,
penalty calc, and activity logging all fire identically.

Make the setup button tolerant: try Web NFC, but treat its absence as
non-fatal. USB HID always activates. Only hard-fail when photoRequired
is True AND the camera is unavailable.

Result: same kiosk page now works on Android Chrome (Web NFC), desktop
Chrome with a USB reader, or both at once.

Bump manifest to 19.0.3.2.0.
2026-05-15 19:30:51 -04:00
gsinghpal
a24ef15a02 fix(fusion_clock): add ir.model.access for NFC enrollment wizard
Wizard was deployed without an entry in security/ir.model.access.csv,
so ANY user (including managers) got a permission error when opening
the menu. The model is registered but has no group access rules,
so Odoo's ORM blocks read/create on it.

Grant full CRUD on fusion.clock.nfc.enrollment.wizard to
group_fusion_clock_manager (the same group the menu is gated to).

Bump manifest to 19.0.3.1.1.
2026-05-15 19:15:56 -04:00
gsinghpal
7fdab094fc fix(fusion_clock): load wizard XML before clock_menus.xml
The Enroll NFC Card menu item references action_fusion_clock_nfc_enrollment_wizard,
which is defined in wizard/clock_nfc_enrollment_views.xml. With the wizard file
listed AFTER clock_menus.xml in the manifest, the menu load failed with
"External ID not found in the system" on first upgrade.

Move the wizard view above clock_menus.xml so the action XMLID exists by the
time the menu references it.

Verified on odoo-entech: fusion_clock upgraded cleanly to 19.0.3.1.0, all
wizard XMLIDs registered.
2026-05-15 19:09:26 -04:00
gsinghpal
c2646f59c4 feat(fusion_clock): NFC card enrollment wizard + employee form field
Adds a tap-driven enrollment workflow so managers can pair NFC/RFID
cards to employees using a USB HID reader at their desk:

- New wizard model fusion.clock.nfc.enrollment.wizard with auto-focused
  Card UID field, employee picker, and reassignment warning if the
  card is already held by someone else.
- Two actions: 'Enroll Card' (single) and 'Enroll & Next' (bulk).
- Menu entry under Fusion Clock root, manager-gated.
- Exposes x_fclk_nfc_card_uid on the Employee form Clock Settings
  section (next to Kiosk PIN) so it can be inspected/edited directly.
- Bumps manifest to 19.0.3.1.0 for asset cache bust.

Wizard reuses FusionClockNfcKiosk._normalize_uid so stored format
matches what the kiosk /tap endpoint looks up later. Reassignment
clears the UID from the previous holder and logs both events to the
activity log under 'card_enrollment'.
2026-05-15 18:55:42 -04:00
gsinghpal
152ed86c3a feat(thickness): single Char range field — drop fp.recipe.thickness picker
Per client direction: every order is a thickness RANGE (e.g.
"0.0005-0.0008 mils" or "5-10 mils"), never a single value. The
old picker model (fp.recipe.thickness with a single 'value' Float)
was modelling the wrong concept and overcrowding the order entry
UI. Replaced with one free-text Char field that auto-fills from
last-used or part default.

DELETED entirely:
- fp.recipe.thickness model (file + view + ACL + manifest entry)
- recipe.thickness_option_ids One2many (the picker source)
- "Thickness Options" inline list on the recipe form
- sale.order.line.x_fc_thickness_id (M2O picker)
- account.move.line.x_fc_thickness_id
- fp.delivery.x_fc_thickness_id
- fp.direct.order.line.thickness_id

ADDED:
- sale.order.line.x_fc_thickness_range (Char) — operator types range
- account.move.line.x_fc_thickness_range — for invoice rendering
- fp.delivery.x_fc_thickness_range — for packing slip
- fp.direct.order.line.thickness_range — for the wizard
- fp.part.catalog.x_fc_default_thickness_range — part default

AUTO-FILL CHAIN (sale.order.line + wizard line):
1. Operator already typed → keep
2. Most recent SO line for (this part, this customer) with a
   non-empty thickness_range → copy that
3. part.x_fc_default_thickness_range → copy
4. Blank — operator types

Implemented as both an @api.onchange (interactive) AND a
create() override (programmatic — wizard, sale_mrp bridge,
imports). Same logic in both paths.

WIZARD push-to-defaults: when "Save as Default" toggle is ticked
on a wizard line, persist the line's thickness_range to
part.x_fc_default_thickness_range so future first-customer orders
get a sensible starting point.

REPORTS: customer_line_header.xml + report_fp_wo_sticker.xml now
print the Char range as-typed (no display_name lookup needed).

KEPT (admin documentation only — doesn't affect order entry):
- recipe.thickness_min, thickness_max, thickness_uom on the recipe
  root: documents the recipe's CAPABILITY range. No UI gate; just
  for spec authors to record what the chemistry can produce.

JOB GROUPING: fp.job auto-create groups SO lines by (recipe, part,
spec, thickness, serial). Updated to key on the thickness_range
Char (stripped) instead of the deleted thickness_id integer.

DB cleanup: --update=base ran on the upgrade, dropping the
fp_recipe_thickness table + the four x_fc_thickness_id columns.
Existing data was already nulled in earlier dev work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:54:40 -04:00
gsinghpal
21754c1660 fix(specs): @api.depends on _compute_display_name — fixes 'Unnamed' dropdown
The _compute_display_name method on fusion.plating.customer.spec was
missing its @api.depends decorator. Without it, Odoo doesn't know
when to fire the compute, so display_name stayed NULL on:
- All seeded specs (created via XML data import)
- Any spec created later (the field was never recomputed)

Symptom: Specification dropdown on the SO line showed "Unnamed" for
every option, making spec selection useless.

Fix:
- @api.depends('code', 'revision', 'name') on _compute_display_name
- Imported `api` (was only `fields, models`)

Companion entech-side action: forced recompute on the 15 existing
specs via `env.add_to_compute(specs._fields['display_name'], specs)`
so the stored column was backfilled. New specs created via UI will
trigger the compute automatically going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:36:00 -04:00
gsinghpal
145b424760 fix(seeds): noupdate=1 on remaining 3 user-editable seed files
Audit of all 86 data XML files in the fusion_plating module set
turned up 3 more files that lacked noupdate=1 protection — every
module upgrade would re-import them and silently overwrite user
customisations. Following the ENP-ALUM-BASIC recovery (a68bf2e),
locked these too:

1. fusion_tasks/data/ir_cron_data.xml — 4 ir.cron records
   (technician travel times, push notifications, late-arrival
   checks, location cleanup). Users may disable / re-schedule.

2. fusion_plating_shopfloor/data/fp_cron_data.xml — 1 ir.cron
   (Bake Window state updater). Same reasoning.

3. fusion_plating_bridge_maintenance/data/fp_maintenance_stage_data.xml
   — 3 maintenance.stage records (kanban columns: New / Active /
   Completed). Admin may rename, reorder, or add new stages.

Companion entech-side action (executed via SQL during the fix
session): 11 ir.model.data rows for these records were updated to
noupdate=true so the next module upgrade respects the new flag.

Files left explicitly noupdate=0 — verified safe:
- fusion_plating/data/fp_landing_data.xml — 1 ir.actions.server
  (system action, code-defined; re-import is harmless)
- fusion_plating_reports/data/fp_hide_default_reports.xml —
  re-asserts deletion of default Odoo report bindings; intentional
  to re-run on every upgrade

Final audit confirmed 0 user-editable noupdate=false records remain.
ir.model.inherit + report.paperformat rows still noupdate=false but
those are system metadata (Odoo manages) and Odoo's standard
paperformat pattern, both safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:32:30 -04:00
gsinghpal
a68bf2eae7 fix(recipes): noupdate=1 on 5 seeded recipes — STOP wiping user edits
CRITICAL BUG: 5 of 6 seeded recipe files had <data noupdate="0">
which caused EVERY module upgrade to re-import the recipe and
overwrite any user customisations to the base recipe (renamed
steps, added child nodes, custom prompts on seeded steps).

Files fixed (now noupdate="1"):
- fp_recipe_enp_alum_basic.xml
- fp_recipe_enp_steel_basic.xml
- fp_recipe_enp_sp.xml
- fp_recipe_anodize.xml
- fp_recipe_chem_conversion.xml

(fp_recipe_general_processing.xml was already correctly noupdate=1.)

Companion entech-side action (not in this commit, executed via SQL
during the fix session): 200 ir.model.data rows for the affected
process_node + process_node_input records were updated to
noupdate=true so the next module upgrade will skip them entirely
and respect the user's current state.

Recovery for users whose base recipe edits were already lost:
the variants (part-cloned recipes that share the recipe name)
were untouched because they have no XML xmlid match. The
customisations are preserved in the variants and can be lifted
back to the base recipe via the simple/tree editor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:20:04 -04:00
gsinghpal
bc7c771f20 chore(menu): promote Specifications + clarify misleading menu names
Specifications menu (urgent — workflow blocker for estimators):
- Moved from Configuration → Quality & Documents (manager-only) up
  to Plating → Quality (sequence 70). Now visible to estimator,
  supervisor, and manager.
- Renamed "Customer Specs" → "Specifications" — the seeded library
  includes industry standards (AMS, MIL, ASTM, BAC) not just
  customer-private specs.
- Action display name updated: "Customer Specifications" → "Specifications".
- Added action.help HTML so the empty-state placeholder explains
  the Specifications library purpose to first-time users.
- Old xmlid (menu_fp_config_customer_spec) preserved so existing
  links / breadcrumbs / search references continue to resolve.

Other clarifying renames:
- Safety: "JHSC" / "JHSC Meetings" → "H&S Committee (JHSC)" /
  "H&S Committee Meetings" — acronym was opaque to non-Canadian
  H&S folks.
- Operations: "Move Log" → "Parts & Rack Move Log" — generic name
  could be confused with chatter messages or stock moves.
- Configuration → Recipes & Steps: "Workflow States" →
  "Job Workflow Stages" — generic name; clarifies these are job
  state milestones (passed-stage tracking), not generic workflow.
- Compliance → General: child folder "Configuration" → "Reference
  Data" — three levels of "Configuration" nesting (Plating>Config
  vs Plating>Compliance>General>Config) was confusing.

No model / data changes. Pure menu metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:05:19 -04:00
gsinghpal
1ed414c6fb chore(menu): retire Configurator top-level — fold survivors into Configuration hub
After Phase E removed Coating Config + Treatments + Customer Price List
+ Coating Thickness from the Configurator submenu, only 3 admin items
remained — not enough to justify a top-level menu just for an
estimator.

Re-homed:
- Pricing Rules                → Configuration → Pricing & Billing
                                  (sequence 40, joins Invoice Strategy
                                   Defaults + Account Holds)
- Materials                    → Configuration → Materials & Tanks
                                  (sequence 40, joins Bath Parameters,
                                   Replenishment Rules, Chemicals,
                                   Rack Tags, Calibration Equipment)
- Line Description Templates   → Configuration → Quality & Documents
                                  (sequence 90, joins Notification
                                   Templates — same "templates" pattern)

All three keep estimator visibility (group_fp_estimator) plus manager
access. Top-level menu count under "Plating" drops from 9 visible to 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:52:53 -04:00
gsinghpal
7d27db69c6 fix(promote-customer-spec): leftover has_cost_data ref in _compute_margin
Phase E removed the coating-rollup loop but left a stale `has_cost_data`
reference in the percent computation. NameError on every SO list /
form load.

Margin is "not available" until recipe-level cost data exists
(backlog item). Set all three margin fields to 0 / False explicitly
so no stale references remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:11:41 -04:00
gsinghpal
d891002c84 feat(promote-customer-spec): Phase E — final removal of coating + treatment
DELETED entirely (model + view + ACL + data file + menu):
- fp.coating.config (configurator)
- fp.treatment (configurator + seeded data)
- fp.coating.thickness (configurator) — replaced by fp.recipe.thickness in Phase A
- fp.customer.price.list (configurator) — coating-keyed, no replacement

Field deletions:
- sale.order.x_fc_coating_config_id
- sale.order.line.x_fc_coating_config_id + x_fc_treatment_ids
- account.move.line.x_fc_coating_config_id
- fp.part.catalog.x_fc_default_coating_config_id + x_fc_default_treatment_ids
- fp.job.coating_config_id
- fp.pricing.rule.coating_config_id
- fp.quality.point.coating_config_ids
- fp.direct.order.line.coating_config_id + treatment_ids
- fp.sale.description.template.coating_config_id

Refactored:
- fp.quote.configurator.coating_config_id → recipe_id (now points at
  fusion.plating.process.node, the actual recipe). All compute, onchange,
  and matcher logic updated to use recipe directly. Quality inherit
  extends matcher with spec-tier scoring.
- fp.job._fp_create_certificates now reads spec from job.customer_spec_id
  and formats spec_reference as "code Rev rev". Same for thickness
  source — bake fields read from recipe_root (Phase A).
- fp.job.step.button_finish bake-window auto-spawn reads bake settings
  from recipe_root instead of coating.
- fp.certificate auto-fill spec_min_mils/max_mils from recipe (Phase A
  thickness fields) instead of coating.
- jobs/sale_order.py: job creation reads x_fc_customer_spec_id from
  line, drops coating refs and the legacy header-coating fallback.
- Wizards drop coating + treatment fields and refs.
- Configurator views drop x_fc_coating_config_id + x_fc_treatment_ids
  fields entirely. Quality inherits re-anchor on stable fields
  (x_fc_part_catalog_id, x_fc_internal_description, default_process_id,
  process_variant_id, substrate_material) so they keep working.
- Reports drop coating fallback elifs; print recipe / spec.
- Tablet payload drops coating_config_id from job.read fields.

Skipped (deferred to backlog):
- fusion_plating_bridge_mrp — module is uninstalled per Sub 11; source
  files retain coating refs but no runtime impact.
- fusion_plating_portal — circular dep (portal → quality → certs →
  portal). Customer-facing portal coating picker stays for now;
  promote-spec polish is a separate sub-project.

Verification: grep for "coating_config_id|fp.coating.config|
fp.treatment|fp.coating.thickness" in live (non-bridge_mrp,
non-portal, non-script, non-test) Python/XML/CSV returns 3 hits,
all in module / class docstrings explaining Phase E history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:00:41 -04:00
gsinghpal
e0eacc2530 feat(promote-customer-spec): Phase D — reports + tablet payload include spec
Reports updated to print Specification (with revision via display_name):
- report_fp_sale.xml — header sections show "SPECIFICATION" instead
  of "COATING CONFIG", reads doc.x_fc_customer_spec_id (added on
  sale.order via quality inherit, computed from line.customer_spec_id)
- report_fp_wo_sticker.xml — propagates _spec alongside _coating
- fusion_plating_reports/report_fp_job_traveller.xml — header row
  now shows Specification (falls back to coating)
- fusion_plating_jobs/report_fp_job_traveller.xml — same fall-back
- fusion_plating_jobs/report_fp_job_sticker.xml — _spec added

sale.order.x_fc_customer_spec_id added as a stored compute on
sale.order (in quality) so reports can render order-level spec.
Mirrors the line's first spec; updates on line edit.

Tablet payload (shopfloor_controller.py):
- spec_label added to the job payload dict
- defensive 'customer_spec_id' in job._fields check (shopfloor doesn't
  depend on quality — circular if added)

Portal: deferred (same circular-dep issue, more substantial UI rewrite
needed; Phase E backlog item).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:30:05 -04:00
gsinghpal
c637f82ae2 feat(promote-customer-spec): Phase C — pricing, quality, job, cert re-keyed
Pricing:
- Quality inherit on fp.pricing.rule adds customer_spec_id + recipe_id
- Quality inherit on fp.quote.configurator adds customer_spec_id field
  + extends _find_matching_rule with priority chain:
    spec (+8) > recipe (+6) > coating (+4) > material (+2) > cert (+1)
- View inherit surfaces both new pickers on the rule form

Quality points:
- fp.quality.point now has customer_spec_ids + recipe_ids M2M filters
- Matcher (_matches + _find_matching) accepts new args
- Hook overrides on SO confirm + job confirm/done + step finish
  pass spec/recipe context through to the matcher
- View surfaces both new M2M widgets

Job:
- jobs/sale_order.py wires x_fc_customer_spec_id from SO line to
  fp.job.customer_spec_id on action_confirm

Cert:
- Quality inherit on fp.certificate adds customer_spec_id field +
  create() override auto-fills spec_reference from spec.code+revision
  Resolution priority: explicit spec_reference > cert.customer_spec_id
  > SO line spec (with print_on_cert) > legacy coating fallback

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:23:06 -04:00
gsinghpal
7cafab1b9f feat(promote-customer-spec): Phase B — two-picker SO line UX
Spec-side picker (x_fc_customer_spec_id / customer_spec_id) added on:
- sale.order.line (via quality inherit — onchange autofill, create()
  fallback to part default, _prepare_invoice_line carry)
- account.move.line (via quality inherit — invoice rendering)
- fp.part.catalog (via quality inherit — x_fc_default_customer_spec_id)
- fp.direct.order.line (via quality inherit — wizard picker + autofill)
- fp.direct.order.wizard (action_create_order post-creates spec on SO line)

Thickness picker switched to fp.recipe.thickness (replaces coating-scoped):
- sale.order.line.x_fc_thickness_id comodel + domain rewired to recipe
- account.move.line + fp.delivery same
- fp.direct.order.line.thickness_id same

View inherits in quality add Specification picker next to legacy
Primary Treatment column on:
- SO form line tree
- part catalog Default Treatments block
- direct-order wizard line tree + drawer

Wizard files (fp.contract.review.client.email.wizard) pulled from
entech into the repo — they were ahead of the repo. Quality __init__
now imports wizards/.

Legacy x_fc_coating_config_id + treatment_ids remain visible during
transition; Phase E removes them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:16:25 -04:00
gsinghpal
c96f27b96c feat(promote-customer-spec): NADCAP recipe lock (Phase A+)
Per client review: NADCAP-qualified recipes need manager-only edit
permission. Word-doc external approval workflow stays outside ERP;
this is the in-app enforcement.

- New field fp.process.node.is_locked (recipe root)
- write() override blocks non-manager edits when recipe root is_locked
  Lock checks via recipe_root_id so child ops/steps are also protected
  Manager bypass via group + env.su (sudo) bypass for system jobs
- Amber "LOCKED — Manager Edit Only" ribbon at top of recipe form
- Toggle on Specification & Bake page under "Change Control (NADCAP)"
- Spec doc updated with Decision 6.5 + backlog from client review:
  approvals list, doc control auto-sync, oven recorder sync, SOP
  word-doc workflow, final-inspection signoff on cert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:55:07 -04:00
gsinghpal
406cac1362 feat(promote-customer-spec): Phase A — recipe + spec foundation
- Add fp.recipe.thickness model (replaces fp.coating.thickness, scoped to recipe root)
- Add spec metadata + bake-relief fields to fusion.plating.process.node (recipe root):
  phosphorus_level, thickness_min/max/uom, thickness_option_ids,
  requires_bake_relief + bake_window_hours/temperature/duration
- Add recipe_ids M2M + print_on_cert to fusion.plating.customer.spec
- Add applicable_spec_ids reverse M2M as inherit in fusion_plating_quality
  (avoids circular dep — core can't reference customer.spec which lives in quality)
- Surface new fields on recipe form ("Specification & Bake" notebook page)
- Surface recipe linkage on customer spec form

Pure additive. Foundation for Phases B-E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:50:17 -04:00
1398 changed files with 222421 additions and 1347 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -4,3 +4,4 @@
from . import models
from . import controllers
from . import wizard

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
'version': '19.0.3.0.0',
'version': '19.0.3.3.0',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """
@@ -70,6 +70,8 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/clock_correction_views.xml',
'views/clock_dashboard_views.xml',
'views/hr_employee_views.xml',
# Wizards (must load before clock_menus.xml since menu references wizard action)
'wizard/clock_nfc_enrollment_views.xml',
'views/clock_menus.xml',
# Views - Portal
'views/portal_clock_templates.xml',

View File

@@ -22,3 +22,4 @@ access_fusion_clock_correction_portal,fusion.clock.correction.portal,model_fusio
access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0
access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
22 access_hr_attendance_portal hr.attendance.portal hr_attendance.model_hr_attendance base.group_portal 1 0 0 0
23 access_hr_employee_portal_clock hr.employee.portal.clock hr.model_hr_employee base.group_portal 1 0 0 0
24 access_fusion_clock_shift_portal fusion.clock.shift.portal model_fusion_clock_shift base.group_portal 1 0 0 0
25 access_fusion_clock_nfc_enrollment_wizard_manager fusion.clock.nfc.enrollment.wizard.manager model_fusion_clock_nfc_enrollment_wizard group_fusion_clock_manager 1 1 1 1

View File

@@ -414,6 +414,127 @@
debugLog("startNfcReader: listeners attached, nfcReady=true");
}
// ──────────────────────────────────────────────────────────────
// USB HID keyboard-wedge listener (works alongside Web NFC).
// Most USB NFC readers in HID mode type the UID as keystrokes and
// end with Enter. We buffer chars until Enter arrives (or 500ms
// pause), then route the UID through the same flow Web NFC uses.
//
// Critical: this listener fires the same handleTap()/_onEnrollTap()
// codepath as Web NFC, so penalty + photo + activity log all work
// identically regardless of which reader produced the UID.
// ──────────────────────────────────────────────────────────────
let _hidBuffer = "";
let _hidLastKeyAt = 0;
let _hidFlushTimer = null;
const HID_RESET_MS = 500; // pause longer than this resets the buffer
const HID_FLUSH_MS = 600; // if no Enter arrives, flush this long after last char
const HID_MIN_LEN = 4; // shortest plausible UID
const HID_CHAR_RE = /^[0-9A-Fa-f:\-]$/; // hex digits + common separators
function _flushHidBuffer() {
const uid = _hidBuffer.trim().toUpperCase();
_hidBuffer = "";
if (_hidFlushTimer) { clearTimeout(_hidFlushTimer); _hidFlushTimer = null; }
if (uid.length < HID_MIN_LEN) {
debugLog("HID flush: too short, ignored (" + JSON.stringify(uid) + ")");
return;
}
debugLog("HID flush: uid=" + uid + " state=" + currentState);
if (currentState === STATE.ENROLL) {
window.__nfcKiosk && window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
} else if (currentState === STATE.IDLE) {
handleTap(uid);
} else {
debugLog(" → IGNORED: state=" + currentState);
}
}
// ──────────────────────────────────────────────────────────────
// Local wedge daemon SSE listener.
//
// If a `wedge.py` daemon is running on this machine (used for
// ACR122U / PC/SC readers that can't emit keystrokes themselves),
// it exposes a Server-Sent Events stream at
// http://localhost:8765/events that pushes each detected UID.
//
// Chrome treats http://localhost as a secure origin, so an HTTPS
// kiosk page can connect to it without mixed-content blocking.
// No keystroke injection, no Accessibility permission needed,
// no focused-window dependency.
//
// Routes the UID through the same handleTap()/_onEnrollTap() flow
// as Web NFC and USB HID — so photo, penalty, activity log all
// fire identically.
// ──────────────────────────────────────────────────────────────
const WEDGE_SSE_URL = "http://localhost:8765/events";
let _wedgeEs = null;
function startWedgeSseListener() {
try {
_wedgeEs = new EventSource(WEDGE_SSE_URL);
_wedgeEs.addEventListener("message", (ev) => {
const uid = (ev.data || "").trim().toUpperCase();
if (!uid) return;
debugLog("wedge SSE: " + uid + " state=" + currentState);
if (currentState === STATE.ENROLL) {
window.__nfcKiosk && window.__nfcKiosk._onEnrollTap &&
window.__nfcKiosk._onEnrollTap(uid);
} else if (currentState === STATE.IDLE) {
handleTap(uid);
} else {
debugLog(" → IGNORED: state=" + currentState);
}
});
_wedgeEs.addEventListener("open", () => {
debugLog("wedge SSE: connected to " + WEDGE_SSE_URL);
});
_wedgeEs.addEventListener("error", () => {
// EventSource auto-reconnects; this fires on every
// dropped connection. Log first occurrence only.
if (!_wedgeEs._loggedError) {
debugLog("wedge SSE: connection error (daemon may not be running) — will auto-retry");
_wedgeEs._loggedError = true;
}
});
debugLog("startWedgeSseListener: subscribed to " + WEDGE_SSE_URL);
} catch (e) {
debugLog("startWedgeSseListener: failed to start — " + e.message);
}
}
function startUsbHidListener() {
document.addEventListener("keydown", (e) => {
// Don't capture keystrokes inside form inputs — preserves
// typing in enroll-mode search box, etc.
const t = e.target;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) {
return;
}
// Don't fight the existing Ctrl+Shift+T mock-tap shortcut.
if (e.ctrlKey || e.metaKey || e.altKey) return;
const now = Date.now();
if (now - _hidLastKeyAt > HID_RESET_MS) {
_hidBuffer = "";
}
_hidLastKeyAt = now;
if (e.key === "Enter") {
e.preventDefault();
_flushHidBuffer();
return;
}
if (HID_CHAR_RE.test(e.key)) {
_hidBuffer += e.key;
// Fallback flush if the reader doesn't emit Enter
if (_hidFlushTimer) clearTimeout(_hidFlushTimer);
_hidFlushTimer = setTimeout(_flushHidBuffer, HID_FLUSH_MS);
}
});
debugLog("startUsbHidListener: listening for HID keystrokes ✓");
}
function onNfcReading(event) {
// event.serialNumber is the card UID — works for raw MIFARE access cards
const rawSerial = event.serialNumber || "";
@@ -547,27 +668,44 @@
if (setupBtn) {
setupBtn.addEventListener("click", async () => {
debugLog("setup button clicked");
// Try Web NFC, but don't fail if absent — USB HID reader is a
// first-class alternative (works on desktops/iOS too).
let webNfcOk = false;
try {
await startNfcReader();
debugLog("setup: NFC ready, starting camera...");
webNfcOk = true;
debugLog("setup: Web NFC ready ✓");
} catch (webNfcErr) {
debugLog("setup: Web NFC unavailable, continuing with USB HID — " + webNfcErr.message);
}
// USB HID listener: no permission needed, works on any platform.
startUsbHidListener();
// Local wedge daemon SSE listener (for ACR122U / PC/SC readers).
startWedgeSseListener();
// Camera: best-effort unless photoRequired forces it.
try {
await startCamera();
debugLog("setup: camera ready ✓");
} catch (camErr) {
debugLog("setup: camera failed: " + camErr.message);
if (photoRequired) throw camErr;
if (photoRequired) {
// Only THIS path is a hard fail. Use the existing error
// render to keep DOM patterns consistent with the rest
// of this file.
stateContainer.innerHTML = `
<div class="nfc-kiosk__setup">
<h2 style="color:#d9374e">Setup failed</h2>
<p>${escapeHtml(camErr.message)}</p>
<p style="opacity:.7;font-size:.9em">Camera is required but unavailable. Either plug in a webcam, or disable "Photo Required" in Fusion Clock settings.</p>
</div>
`;
return;
}
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
}
await acquireWakeLock();
setState(STATE.IDLE);
} catch (e) {
stateContainer.innerHTML = `
<div class="nfc-kiosk__setup">
<h2 style="color:#d9374e">Setup failed</h2>
<p>${escapeHtml(e.message)}</p>
</div>
`;
}
debugLog("setup: IDLE — Web NFC: " + (webNfcOk ? "✓" : "✗") + " · USB HID: ✓");
});
}

View File

@@ -97,6 +97,14 @@
sequence="50"
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
<!-- NFC Card Enrollment Wizard -->
<menuitem id="menu_fusion_clock_nfc_enrollment"
name="Enroll NFC Card"
parent="menu_fusion_clock_root"
action="action_fusion_clock_nfc_enrollment_wizard"
sequence="55"
groups="group_fusion_clock_manager"/>
<!-- Configuration Sub-Menu -->
<menuitem id="menu_fusion_clock_config"
name="Configuration"

View File

@@ -20,6 +20,9 @@
<field name="x_fclk_break_minutes"/>
<field name="x_fclk_kiosk_pin" password="True"
groups="fusion_clock.group_fusion_clock_manager"/>
<field name="x_fclk_nfc_card_uid"
placeholder="Tap card on USB reader, or paste UID"
groups="fusion_clock.group_fusion_clock_manager"/>
</group>
<group string="Status">
<field name="x_fclk_ontime_streak"/>

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import clock_nfc_enrollment_wizard

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Enrollment Wizard Form -->
<record id="view_fusion_clock_nfc_enrollment_wizard_form" model="ir.ui.view">
<field name="name">fusion.clock.nfc.enrollment.wizard.form</field>
<field name="model">fusion.clock.nfc.enrollment.wizard</field>
<field name="arch" type="xml">
<form string="Enroll NFC Card">
<sheet>
<div class="alert alert-info" role="alert">
<strong>How to enroll:</strong> Tap an NFC card on the USB reader connected
to this computer. The reader will type the UID into the field below.
Then select the employee and click <b>Enroll Card</b> (or
<b>Enroll &amp; Next</b> to keep enrolling).
</div>
<group>
<field name="card_uid"
placeholder="Tap card on reader, or paste UID manually"/>
<field name="normalized_uid"
invisible="not normalized_uid"
readonly="1"/>
<field name="warning_message"
invisible="not warning_message"
readonly="1"
nolabel="1"
colspan="2"/>
<field name="existing_employee_id" invisible="1"/>
<field name="employee_id"
options="{'no_create': True, 'no_create_edit': True}"/>
</group>
</sheet>
<footer>
<button name="action_enroll"
string="Enroll Card"
type="object"
class="btn-primary"
invisible="not normalized_uid or not employee_id"/>
<button name="action_enroll_and_next"
string="Enroll &amp; Next"
type="object"
class="btn-secondary"
invisible="not normalized_uid or not employee_id"/>
<button special="cancel"
string="Cancel"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
<!-- Action to open the wizard -->
<record id="action_fusion_clock_nfc_enrollment_wizard" model="ir.actions.act_window">
<field name="name">Enroll NFC Card</field>
<field name="res_model">fusion.clock.nfc.enrollment.wizard</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_fusion_clock_nfc_enrollment_wizard_form"/>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from ..controllers.clock_nfc_kiosk import FusionClockNfcKiosk
class FusionClockNfcEnrollmentWizard(models.TransientModel):
"""Tap-driven NFC card enrollment for the fusion_clock kiosk.
Flow:
1. Manager opens this wizard.
2. Card UID field is auto-focused.
3. Manager taps an NFC card on the USB HID reader; the reader types the
UID into the focused field and hits Enter, which advances focus to
the Employee picker.
4. Manager selects the employee.
5. Manager clicks "Enroll Card" (closes wizard) or "Enroll & Next"
(resets wizard for the next card).
The wizard reuses ``FusionClockNfcKiosk._normalize_uid`` so the stored
format matches whatever the kiosk's ``/tap`` endpoint will look up later.
"""
_name = 'fusion.clock.nfc.enrollment.wizard'
_description = 'NFC Card Enrollment Wizard'
card_uid = fields.Char(
string='Card UID',
required=True,
help='Tap an NFC card on the USB reader. Most HID readers type the '
'UID followed by Enter, which advances focus to the Employee '
'field below. You can also paste a UID manually.',
)
normalized_uid = fields.Char(
string='Normalized UID',
compute='_compute_normalized_uid',
store=False,
help='UID after format normalization (uppercase, colon-separated hex). '
'This is what gets stored on the employee record.',
)
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
domain=[('x_fclk_enable_clock', '=', True)],
)
existing_employee_id = fields.Many2one(
'hr.employee',
string='Currently Assigned To',
compute='_compute_existing_employee',
store=False,
)
warning_message = fields.Char(
compute='_compute_existing_employee',
store=False,
)
@api.depends('card_uid')
def _compute_normalized_uid(self):
for wiz in self:
wiz.normalized_uid = FusionClockNfcKiosk._normalize_uid(wiz.card_uid) or ''
@api.depends('normalized_uid', 'employee_id')
def _compute_existing_employee(self):
for wiz in self:
if not wiz.normalized_uid:
wiz.existing_employee_id = False
wiz.warning_message = ''
continue
existing = self.env['hr.employee'].sudo().search([
('x_fclk_nfc_card_uid', '=', wiz.normalized_uid),
], limit=1)
if existing and existing != wiz.employee_id:
wiz.existing_employee_id = existing
wiz.warning_message = _(
"⚠ This card is currently assigned to %(name)s. "
"Enrolling will reassign it.",
name=existing.name,
)
else:
wiz.existing_employee_id = False
wiz.warning_message = ''
def action_enroll(self):
"""Enroll the card to the selected employee and close the wizard."""
self.ensure_one()
self._do_enroll()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Card Enrolled'),
'message': _("%(uid)s assigned to %(name)s.",
uid=self.normalized_uid, name=self.employee_id.name),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}
def action_enroll_and_next(self):
"""Enroll the card, then reopen the wizard cleared for the next card."""
self.ensure_one()
self._do_enroll()
return {
'type': 'ir.actions.act_window',
'name': _('Enroll NFC Card'),
'res_model': self._name,
'view_mode': 'form',
'target': 'new',
'context': {}, # explicitly empty so no defaults persist
}
def _do_enroll(self):
"""Validate, then write the normalized UID to the employee record.
Reassigns the card from any existing holder. Logs the event in the
activity log for audit.
"""
if not self.normalized_uid:
raise ValidationError(_(
"Card UID is empty or not valid hex. Tap the card again on "
"the reader."
))
if not self.employee_id:
raise ValidationError(_("Please select an employee."))
# Reassignment: clear the UID from whoever currently holds it
if self.existing_employee_id and self.existing_employee_id != self.employee_id:
self.existing_employee_id.sudo().x_fclk_nfc_card_uid = False
self.env['fusion.clock.activity.log'].sudo().create({
'employee_id': self.existing_employee_id.id,
'log_type': 'card_enrollment',
'description': _(
"NFC card %(uid)s unassigned by %(user)s (reassigning to %(new)s)",
uid=self.normalized_uid,
user=self.env.user.name,
new=self.employee_id.name,
),
'source': 'nfc_kiosk',
})
self.employee_id.sudo().x_fclk_nfc_card_uid = self.normalized_uid
self.env['fusion.clock.activity.log'].sudo().create({
'employee_id': self.employee_id.id,
'log_type': 'card_enrollment',
'description': _(
"NFC card %(uid)s enrolled by %(user)s",
uid=self.normalized_uid, user=self.env.user.name,
),
'source': 'nfc_kiosk',
})

View File

@@ -136,7 +136,20 @@ Rejected alternatives:
- "Customer Spec" — fine but slightly off when the spec is a public industry standard
- "Treatment" / "Coating Configuration" — what we're explicitly removing
### Decision 6 — Recipe ↔ Specification relationship
### Decision 6.5NADCAP recipe lock (added 2026-05-15 from client review)
After client validation of the design, ENPlating raised: "For NADCAP recipes, once it's in the system and we check it, only a manager profile should be able to change." Added to scope.
Implementation:
- New field `fusion.plating.process.node.is_locked` Boolean (recipe root, but checked on all descendants via `recipe_root_id`)
- `write()` override blocks modifications by non-manager users when the recipe root has `is_locked=True`
- Manager bypass via `env.user.has_group('fusion_plating.group_fusion_plating_manager')` so the lock can be toggled off + edits made
- `env.su` (sudo) also bypasses (for migrations / system jobs)
- View: amber "LOCKED — Manager Edit Only" ribbon at top of recipe form when locked; `is_locked` toggle on the Specification & Bake page under "Change Control (NADCAP)" group
The Word-doc external approval workflow (REV 0, REV 1 in filenames on Engineering Drive) lives outside the ERP. The lock is the ERP-side enforcement point that prevents accidental in-app edits between approval cycles.
### Decision 7 — Recipe ↔ Specification relationship
Many-to-many. One spec applies to multiple recipes; one recipe can satisfy multiple specs.
@@ -434,6 +447,20 @@ These are nice-to-haves; the design proceeds without their answers but the answe
---
## Backlog from client review (2026-05-15) — separate sub-projects
These surfaced from the client's scenario walkthrough but are NOT part of this refactor. Tracked here so they aren't forgotten.
1. **Customer Approvals List** (Compliance → Aerospace → Approvals List menu) — small new model `fp.customer.approval` tracking which customer specs the shop is source-approved for, with approval letter PDF, effective date, expiry date. Filterable by prime (Boeing/Lockheed/etc.). Driven by client S4 answer: "Can we maintain a list of approvals under Compliance > Aerospace (AS9100/NADCAP) > APPROVALS LIST?"
2. **Document Control auto-sync** — every customer-facing artifact (PO, packing slip, invoice, certificate, photos) auto-saves to a doc control folder (Engineering Drive / SharePoint / OneDrive). Major Documents-integration project. Driven by client S6: "I need the ERP to download all the files... to our DOC control folder."
3. **Oven recorder data sync** — pull chart-recorder data from the bake oven into the ERP and attach to the relevant job. IoT / hardware-integration project, lives in `fusion_iot` family. Driven by client S6: "How can we sync the oven recorders with the ERP?"
4. **Recipe SOP Word-doc workflow polish** — recipes already accept attachments via `mail.thread`. Add a prominent "Current Approved SOP" attachment slot on the recipe form, with revision history visible. Driven by client S3 + S6: "submit the steps in Word format to the customer for approval... First submission will be REV 0. If we make changes the file will be saved REV 1."
5. **Final inspection signoff captured on certificate** — already partially exists (signoff workflow on jobs); ensure the "who did final inspection" name lands on the cert PDF body. Driven by client S7.
## Out of scope (explicitly NOT doing)
- Data migration of existing coating config records (per user direction: dev-stage, no historical data to preserve)

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.18.15.16',
'version': '19.0.20.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

View File

@@ -51,7 +51,7 @@
De-Masking (operation, customer-visible)
-->
<odoo>
<data noupdate="0">
<data noupdate="1">
<!-- ========================= ROOT ========================= -->
<record id="recipe_anodize" model="fusion.plating.process.node">

View File

@@ -38,7 +38,7 @@
Post Stripping Inspection (customer-visible)
-->
<odoo>
<data noupdate="0">
<data noupdate="1">
<!-- ========================= ROOT ========================= -->
<record id="recipe_chem_conversion" model="fusion.plating.process.node">

View File

@@ -6,7 +6,7 @@
Source: Client's Steelhead export
-->
<odoo>
<data noupdate="0">
<data noupdate="1">
<!-- ===== ROOT ===== -->
<record id="recipe_enp_alum_basic" model="fusion.plating.process.node">

View File

@@ -70,7 +70,7 @@
└── Lab Testing Results
-->
<odoo>
<data noupdate="0">
<data noupdate="1">
<!-- ========================= ROOT ========================= -->
<record id="recipe_enp_sp" model="fusion.plating.process.node">

View File

@@ -52,7 +52,7 @@
└── Post Plate Inspection (customer-visible)
-->
<odoo>
<data noupdate="0">
<data noupdate="1">
<!-- ========================= ROOT ========================= -->
<record id="recipe_enp_steel_basic" model="fusion.plating.process.node">

View File

@@ -4,7 +4,7 @@
# Part of the Fusion Plating product family.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.exceptions import UserError, ValidationError
from .fp_tz import fp_isoformat_utc
from ._fp_uom_selection import FP_UOM_SELECTION
@@ -336,6 +336,79 @@ class FpProcessNode(models.Model):
# NB. `pricing_rule_ids` lives in fusion_plating_configurator
# (added there so this core module doesn't depend on the configurator).
# ---- Spec-derived metadata (recipe-root only — Promote Customer Spec) ----
# These were on fp.coating.config (since retired). They describe the
# PROCESS the recipe runs, not the customer-facing specification —
# specs live on fusion.plating.customer.spec.
phosphorus_level = fields.Selection(
[('low_phos', 'Low Phosphorus (2-5%)'),
('mid_phos', 'Mid Phosphorus (6-9%)'),
('high_phos', 'High Phosphorus (10-13%)'),
('na', 'N/A')],
string='Phosphorus Level',
default='na',
help='EN-specific. Set to N/A for non-EN processes (chrome, '
'anodize, black oxide). Drives certificate annotation and '
'hydrogen-embrittlement risk assessment for bake-relief.',
)
thickness_min = fields.Float(string='Min Thickness', digits=(10, 4))
thickness_max = fields.Float(string='Max Thickness', digits=(10, 4))
thickness_uom = fields.Selection(
[('mils', 'mils'), ('microns', 'microns'), ('inches', 'inches')],
string='Thickness UoM', default='mils',
)
# thickness_option_ids removed — fp.recipe.thickness model deleted.
# Thickness on the SO line is now a free-text Char range (e.g.
# "0.0005-0.0008 mils") that auto-fills from last-used per
# (part, customer) or the part's x_fc_default_thickness_range.
# ---- Bake relief — AMS 2759/9 hydrogen embrittlement (recipe root) ----
requires_bake_relief = fields.Boolean(
string='Requires Bake Relief',
help='Hydrogen embrittlement relief bake required (high-strength '
'steel ≥ HRC 31 in conjunction with this chemistry). When '
'set, finishing the job auto-creates a bake-window record '
'and blocks shipment until bake is complete.',
)
bake_window_hours = fields.Float(
string='Bake Window (hours)', default=4.0,
help='Maximum time between plate exit and bake start. Typical 4h '
'per AMS 2759/9.',
)
bake_temperature = fields.Float(
string='Bake Temperature', default=375.0,
help='Relief bake temperature. Default 375 (°F per AMS 2759/9 for '
'steel ≥ HRC 40).',
)
bake_temperature_uom = fields.Selection(
[('F', '°F'), ('C', '°C')],
string='Bake Temp Unit',
default='F',
)
bake_duration_hours = fields.Float(
string='Bake Duration (hours)', default=23.0,
help='Minimum bake hold time at temperature. Typical 23h.',
)
# ---- NADCAP / change-control lock (recipe root) ----
# Per client direction: NADCAP-qualified recipes need manager-only
# edit permission once they're checked into the system. The Word-doc
# change-control workflow lives outside the ERP; this flag is the
# ERP-side enforcement point.
is_locked = fields.Boolean(
string='Locked (Manager-Edit Only)',
help='When True, only users in the Manager group can modify '
'this recipe (or any of its child operations / steps). '
'Use for NADCAP-qualified processes that need '
'change-control sign-off before any edit. The flag itself '
'can only be toggled by a manager.',
)
# NB. `applicable_spec_ids` (reverse of customer.spec.recipe_ids) is
# defined as an inherit in fusion_plating_quality (the module that
# owns fusion.plating.customer.spec). Core can't reference it
# directly without a dependency inversion.
# ---- Computed fields -----------------------------------------------------
display_name = fields.Char(
@@ -529,6 +602,22 @@ class FpProcessNode(models.Model):
return records
def write(self, vals):
# NADCAP / change-control lock — block writes on locked recipes
# (and their descendants) for non-manager users. Manager bypass
# so the lock can be toggled off.
if (self
and not self.env.su
and not self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager')):
for rec in self:
root = (rec if (rec.node_type == 'recipe' and not rec.parent_id)
else rec.recipe_root_id)
if root and root.is_locked:
raise UserError(_(
"Recipe '%s' is locked (NADCAP / change-control). "
"Only managers can edit it. Ask a manager to "
"unlock the recipe first."
) % (root.display_name or root.name or '?'))
meaningful = bool(set(vals.keys()) - self._FP_NON_VERSIONED_FIELDS)
res = super().write(vals)
if meaningful and self:

View File

@@ -119,7 +119,7 @@
Phase 3 — supervisor+ only. Operators see their own moves on
the tablet; this is an audit view of every move. -->
<menuitem id="menu_fp_job_step_move"
name="Move Log"
name="Parts &amp; Rack Move Log"
parent="menu_fp_operations"
action="action_fp_job_step_move"
sequence="90"

View File

@@ -45,6 +45,9 @@
icon="fa-list-ol"
invisible="node_type != 'recipe'"/>
</header>
<widget name="web_ribbon" title="LOCKED — Manager Edit Only"
bg_color="text-bg-warning"
invisible="not is_locked"/>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_open_tree_editor" type="object"
@@ -226,6 +229,44 @@
<page string="Notes" name="notes">
<field name="notes" placeholder="Internal notes..."/>
</page>
<page string="Specification &amp; Bake"
name="spec_metadata"
invisible="node_type != 'recipe' or parent_id">
<group>
<group string="Spec Metadata">
<field name="phosphorus_level"/>
<field name="thickness_min"/>
<field name="thickness_max"/>
<field name="thickness_uom"/>
</group>
<group string="Bake Relief (AMS 2759/9)">
<field name="requires_bake_relief"/>
<field name="bake_window_hours"
invisible="not requires_bake_relief"/>
<field name="bake_temperature"
invisible="not requires_bake_relief"/>
<field name="bake_temperature_uom"
invisible="not requires_bake_relief"/>
<field name="bake_duration_hours"
invisible="not requires_bake_relief"/>
</group>
</group>
<group string="Change Control (NADCAP)">
<field name="is_locked" widget="boolean_toggle"
help="When ON, only managers can edit this recipe and its child operations / steps. Use for NADCAP-qualified processes."/>
</group>
<!-- Thickness Options group removed. The
fp.recipe.thickness picker model was
retired in favour of a single free-text
thickness range field on the SO line.
Recipe still carries thickness_min /
thickness_max above as documentation
of the recipe's capability range. -->
<!-- Applicable Specifications group is added
by fusion_plating_quality via an inherit
view (the field lives there too). -->
</page>
</notebook>
</sheet>
<chatter/>

View File

@@ -4,7 +4,7 @@
{
'name': 'Fusion Plating — Maintenance Bridge',
'version': '19.0.1.1.0',
'version': '19.0.1.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge standard Odoo Maintenance with Fusion Plating equipment, '
'plans, checklists, and sensor integration.',

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<data noupdate="1">
<!-- Override standard stages to match Steelhead lifecycle -->
<record id="maintenance.stage_0" model="maintenance.stage">

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.5.6.0',
'version': '19.0.6.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -286,14 +286,27 @@ class FpCertificate(models.Model):
def create(self, vals_list):
SaleOrder = self.env['sale.order']
for vals in vals_list:
# Spec-limit auto-fill (existing behaviour, preserved).
# Spec-limit auto-fill — sources thickness range from the
# recipe (Phase A moved the thickness fields onto the
# recipe root). Falls back gracefully when the SO has no
# recipe-bearing line.
already_set = vals.get('spec_min_mils') or vals.get('spec_max_mils')
if not already_set and vals.get('sale_order_id'):
so = SaleOrder.browse(vals['sale_order_id'])
cfg = getattr(so, 'x_fc_coating_config_id', False)
if cfg and cfg.thickness_uom == 'mils':
vals.setdefault('spec_min_mils', cfg.thickness_min or 0.0)
vals.setdefault('spec_max_mils', cfg.thickness_max or 0.0)
# Look across order_line for the first recipe with a
# populated thickness range.
first_line = so.order_line[:1] if so.order_line else False
recipe = (
first_line.x_fc_process_variant_id
if (first_line
and 'x_fc_process_variant_id' in first_line._fields)
else False
)
if (recipe
and 'thickness_uom' in recipe._fields
and recipe.thickness_uom == 'mils'):
vals.setdefault('spec_min_mils', recipe.thickness_min or 0.0)
vals.setdefault('spec_max_mils', recipe.thickness_max or 0.0)
# Defer naming: let the record exist so the mixin can write
# name via raw SQL, then fall back to the legacy sequence if
# no parent SO is reachable.

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating - Compliance (Framework)',
'version': '19.0.1.2.0',
'version': '19.0.1.3.0',
'category': 'Manufacturing/Plating',
'summary': 'Jurisdiction-agnostic compliance framework: permits, discharge monitoring, waste manifests, pollutant inventory, compliance calendar, spill register.',
'description': 'Generic compliance framework. Region packs load jurisdiction-specific data.',

View File

@@ -11,7 +11,7 @@
<menuitem id="menu_fp_compliance_calendar" name="Compliance Calendar" parent="menu_fp_compliance_root" action="action_fp_compliance_event" sequence="40"/>
<menuitem id="menu_fp_compliance_spill_register" name="Spill Register" parent="menu_fp_compliance_root" action="action_fp_spill_register" sequence="50"/>
<menuitem id="menu_fp_compliance_config" name="Configuration" parent="menu_fp_compliance_root" sequence="100"/>
<menuitem id="menu_fp_compliance_config" name="Reference Data" parent="menu_fp_compliance_root" sequence="100"/>
<menuitem id="menu_fp_compliance_jurisdiction" name="Jurisdictions" parent="menu_fp_compliance_config" action="action_fp_jurisdiction" sequence="10"/>
<menuitem id="menu_fp_compliance_regulator" name="Regulators" parent="menu_fp_compliance_config" action="action_fp_regulator" sequence="20"/>
<menuitem id="menu_fp_compliance_discharge_limit" name="Discharge Limits" parent="menu_fp_compliance_config" action="action_fp_discharge_limit" sequence="30"/>

View File

@@ -21,8 +21,6 @@ def _backfill_currency(env):
return
for model_name in (
'fp.pricing.rule',
'fp.treatment',
'fp.customer.price.list',
'fp.quote.configurator',
):
Model = env.get(model_name)

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.18.10.4',
'version': '19.0.21.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -39,16 +39,11 @@ Provides:
'security/ir.model.access.csv',
'data/fp_configurator_sequence_data.xml',
'data/fp_sub5_sequence_data.xml',
'data/fp_treatment_data.xml',
'data/fp_part_material_data.xml',
'views/fp_treatment_views.xml',
'views/fp_part_material_views.xml',
'views/fp_coating_thickness_views.xml',
'views/fp_part_catalog_views.xml',
'views/fp_process_node_part_scoped_views.xml',
'views/fp_coating_config_views.xml',
'views/fp_pricing_rule_views.xml',
'views/fp_customer_price_list_views.xml',
'views/fp_quote_configurator_views.xml',
'views/sale_order_views.xml',
'views/res_partner_views.xml',

View File

@@ -1,61 +0,0 @@
<?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 noupdate="1">
<!-- Pre-treatments -->
<record id="treatment_alkaline_clean" model="fp.treatment">
<field name="name">Alkaline Clean</field>
<field name="treatment_type">pre</field>
<field name="sequence">10</field>
<field name="default_duration_minutes">15</field>
</record>
<record id="treatment_acid_etch" model="fp.treatment">
<field name="name">Acid Etch</field>
<field name="treatment_type">pre</field>
<field name="sequence">20</field>
<field name="default_duration_minutes">10</field>
</record>
<record id="treatment_zincate" model="fp.treatment">
<field name="name">Zincate (Aluminium)</field>
<field name="treatment_type">pre</field>
<field name="sequence">30</field>
<field name="default_duration_minutes">5</field>
</record>
<record id="treatment_bead_blast" model="fp.treatment">
<field name="name">Bead Blast</field>
<field name="treatment_type">pre</field>
<field name="sequence">40</field>
<field name="default_duration_minutes">20</field>
</record>
<record id="treatment_degrease" model="fp.treatment">
<field name="name">Solvent Degrease</field>
<field name="treatment_type">pre</field>
<field name="sequence">50</field>
<field name="default_duration_minutes">10</field>
</record>
<!-- Post-treatments -->
<record id="treatment_bake" model="fp.treatment">
<field name="name">Hydrogen Embrittlement Bake</field>
<field name="treatment_type">post</field>
<field name="sequence">10</field>
<field name="default_duration_minutes">240</field>
</record>
<record id="treatment_passivate" model="fp.treatment">
<field name="name">Passivate</field>
<field name="treatment_type">post</field>
<field name="sequence">20</field>
<field name="default_duration_minutes">30</field>
</record>
<record id="treatment_chromate_seal" model="fp.treatment">
<field name="name">Chromate Seal</field>
<field name="treatment_type">post</field>
<field name="sequence">30</field>
<field name="default_duration_minutes">15</field>
</record>
</odoo>

View File

@@ -3,14 +3,10 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import fp_treatment
from . import fp_part_material
from . import fp_part_catalog
from . import fp_coating_thickness
from . import fp_coating_config
from . import fp_pricing_complexity_surcharge
from . import fp_pricing_rule
from . import fp_customer_price_list
from . import fp_sale_description_template
from . import fp_quote_configurator
from . import fp_serial

View File

@@ -65,11 +65,11 @@ class AccountMoveLine(models.Model):
string='Job #', index=True,
help='Copied from sale.order.line.',
)
x_fc_thickness_id = fields.Many2one(
'fp.coating.thickness',
x_fc_thickness_range = fields.Char(
string='Thickness',
help='Copied from sale.order.line for customer-facing invoice PDFs.',
help='Carried from the SO line — prints on the invoice PDF.',
)
# x_fc_customer_spec_id added by fusion_plating_quality.
x_fc_revision_snapshot = fields.Char(
string='Revision (snapshot)',
help='Revision letter from the source SO line.',

View File

@@ -1,91 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpCoatingConfig(models.Model):
"""Coating configuration template.
Defines a specific coating setup: process type, phosphorus level,
thickness range, spec reference, and required pre/post treatments.
Used by the configurator to drive pricing and recipe selection.
"""
_name = 'fp.coating.config'
_description = 'Fusion Plating — Coating Configuration'
_order = 'sequence, name'
name = fields.Char(string='Configuration', required=True, help='e.g. "EN Mid-Phos AMS 2404"')
process_type_id = fields.Many2one(
'fusion.plating.process.type', string='Process Type', required=True, ondelete='restrict',
)
recipe_id = fields.Many2one(
'fusion.plating.process.node', string='Default Recipe',
domain="[('node_type', '=', 'recipe')]",
help='Default recipe template for this coating configuration.',
)
phosphorus_level = fields.Selection(
[('low_phos', 'Low Phosphorus (2-5%)'), ('mid_phos', 'Mid Phosphorus (6-9%)'),
('high_phos', 'High Phosphorus (10-13%)'), ('na', 'N/A')],
string='Phosphorus Level', default='na', help='EN-specific. Set to N/A for non-EN processes.',
)
thickness_min = fields.Float(string='Min Thickness', digits=(10, 4))
thickness_max = fields.Float(string='Max Thickness', digits=(10, 4))
thickness_uom = fields.Selection(
[('mils', 'mils'), ('microns', 'microns'), ('inches', 'inches')],
string='Thickness UoM', default='mils',
)
thickness_option_ids = fields.One2many(
'fp.coating.thickness',
'coating_config_id',
string='Thickness Options',
help='Discrete thickness values the estimator can pick from when '
'this coating appears on a sale order line. Each value is '
'driven by the spec the coating is built against. Sub 5.',
)
spec_reference = fields.Char(string='Spec Reference', help='e.g. "AMS 2404", "E499-303-00-005"')
certification_level = fields.Selection(
[('commercial', 'Commercial'), ('mil_spec', 'Mil-Spec'),
('nadcap', 'Nadcap'), ('nuclear', 'Nuclear (CSA N299)')],
string='Certification Level', default='commercial',
)
pre_treatment_ids = fields.Many2many(
'fp.treatment', 'fp_coating_config_pre_treatment_rel', 'config_id', 'treatment_id',
string='Pre-Treatments', domain="[('treatment_type', '=', 'pre')]",
)
post_treatment_ids = fields.Many2many(
'fp.treatment', 'fp_coating_config_post_treatment_rel', 'config_id', 'treatment_id',
string='Post-Treatments', domain="[('treatment_type', '=', 'post')]",
)
# ---- Hydrogen embrittlement relief (AMS 2759/9) ----
requires_bake_relief = fields.Boolean(
string='Requires Bake Relief',
help='Hydrogen embrittlement relief bake required (high-strength steel, '
'Rockwell C ≥ 31). When set, finishing the plating WO auto-creates '
'a bake window record and blocks shipment until bake is complete.',
)
bake_window_hours = fields.Float(
string='Bake Window (hours)', default=4.0,
help='Maximum time between plate exit and bake start. Typically 4h per AMS 2759/9.',
)
bake_temperature = fields.Float(
string='Bake Temperature', default=375.0,
help='Relief bake temperature. Default 375 (°F per AMS 2759/9 for '
'steel ≥ HRC 40). Unit follows bake_temperature_uom.',
)
bake_temperature_uom = fields.Selection(
[('F', '°F'), ('C', '°C')],
string='Temp Unit',
default=lambda self: self.env.company.x_fc_default_temp_uom or 'F',
)
bake_duration_hours = fields.Float(
string='Bake Duration (hours)', default=23.0,
help='Minimum bake hold time at temperature. Typical: 23h.',
)
sequence = fields.Integer(string='Sequence', default=10)
description = fields.Text(string='Description')
active = fields.Boolean(string='Active', default=True)

View File

@@ -1,90 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import _, api, fields, models
class FpCoatingThickness(models.Model):
"""Allowed thickness option for a coating configuration.
Each plating process (ENP Class 4, hard chrome 0.001", Type III
anodize, etc.) has its own set of valid thicknesses driven by the
spec it's built from. This child of `fp.coating.config` holds the
discrete options so the SO-line thickness dropdown can filter to
only what's actually achievable for the line's coating.
"""
_name = 'fp.coating.thickness'
_description = 'Coating Thickness Option'
_order = 'coating_config_id, sequence, value'
coating_config_id = fields.Many2one(
'fp.coating.config',
required=True,
ondelete='cascade',
)
value = fields.Float(
string='Nominal',
digits=(10, 4),
required=True,
help='Target / nominal thickness value (the number printed on the cert). '
'Magnitude only — UoM lives in the next field.',
)
# Hitting an exact thickness on plated parts is impossible — the spec
# is always "X mils ± tolerance" or a min/max range. These fields
# capture the acceptance band so QC can mark a reading pass/fail
# against real customer specs (e.g. AMS-2404 Class 4 = 0.001"0.0015").
# Both optional: leave blank for legacy single-value entries.
value_min = fields.Float(
string='Min',
digits=(10, 4),
help='Lower acceptance bound. Readings below this fail QC.',
)
value_max = fields.Float(
string='Max',
digits=(10, 4),
help='Upper acceptance bound. Readings above this fail QC.',
)
uom = fields.Selection(
[('mils', 'mils (0.001 in)'),
('microns', 'microns (µm)'),
('inches', 'inches'),
('mm', 'mm')],
required=True,
default='mils',
)
sequence = fields.Integer(default=10)
active = fields.Boolean(default=True)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
@api.depends('value', 'value_min', 'value_max', 'uom')
def _compute_display_name(self):
uom_labels = dict(self._fields['uom'].selection)
for rec in self:
label = uom_labels.get(rec.uom, rec.uom or '')
# Strip the bracketed clarification for a tighter dropdown row.
if ' (' in label:
label = label.split(' (')[0]
# Range overrides single value when both bounds are set —
# operators see the real spec, not a phantom-precise nominal.
if rec.value_min and rec.value_max:
rec.display_name = (
f'{rec.value_min:g}{rec.value_max:g} {label}'.strip()
)
elif rec.value:
rec.display_name = f'{rec.value:g} {label}'.strip()
else:
rec.display_name = label
@api.constrains('value_min', 'value_max')
def _check_range(self):
for rec in self:
if rec.value_min and rec.value_max and rec.value_min > rec.value_max:
from odoo.exceptions import ValidationError
raise ValidationError(_(
'Thickness Min (%(mn)s) cannot exceed Max (%(mx)s).'
) % {'mn': rec.value_min, 'mx': rec.value_max})

View File

@@ -1,97 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpCustomerPriceList(models.Model):
"""Standing price per (customer, coating config).
Repeat customers accept a negotiated price per coating — the configurator
and Direct Order wizard auto-fill `unit_price` from here before falling
back to the formula-based pricing engine.
Optional effective_from / effective_to support annual contracts.
"""
_name = 'fp.customer.price.list'
_description = 'Fusion Plating — Customer Price List'
_inherit = ['mail.thread']
_order = 'partner_id, coating_config_id, effective_from desc'
name = fields.Char(
string='Reference', compute='_compute_name', store=True,
)
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True, ondelete='cascade',
tracking=True, domain="[('customer_rank', '>', 0)]",
)
coating_config_id = fields.Many2one(
'fp.coating.config', string='Coating', required=True, ondelete='restrict',
tracking=True,
)
unit_price = fields.Monetary(
string='Unit Price', required=True, currency_field='currency_id',
tracking=True,
)
price_uom = fields.Selection(
[('per_part', 'per Part'),
('per_sqin', 'per sq in'),
('per_sqft', 'per sq ft'),
('per_lb', 'per lb')],
string='Price Basis', default='per_part', required=True,
)
currency_id = fields.Many2one(
'res.currency', string='Currency',
required=True, default=lambda self: self.env.company.currency_id,
)
effective_from = fields.Date(
string='Effective From', default=fields.Date.today, required=True, tracking=True,
)
effective_to = fields.Date(
string='Effective To',
help='Blank = no expiry. Set for annual contract pricing.',
tracking=True,
)
min_quantity = fields.Integer(
string='Minimum Qty', default=1,
help='Volume break — this price applies for orders of this size or larger.',
)
notes = fields.Html(string='Notes')
active = fields.Boolean(default=True)
_sql_constraints = [
('fp_price_list_unique',
'unique(partner_id, coating_config_id, effective_from, min_quantity)',
'A price entry already exists for this customer + coating + '
'effective date + quantity tier.'),
]
@api.depends('partner_id', 'coating_config_id', 'min_quantity', 'effective_from')
def _compute_name(self):
for rec in self:
parts = []
if rec.partner_id:
parts.append(rec.partner_id.name)
if rec.coating_config_id:
parts.append(rec.coating_config_id.name)
if rec.min_quantity > 1:
parts.append(f'{rec.min_quantity}')
rec.name = ' / '.join(parts) if parts else ''
@api.model
def _find_price(self, partner_id, coating_config_id, quantity=1, on_date=None):
"""Return the best-matching active price list entry for this request."""
if not (partner_id and coating_config_id):
return False
on_date = on_date or fields.Date.today()
candidates = self.search([
('partner_id', '=', partner_id),
('coating_config_id', '=', coating_config_id),
('active', '=', True),
('effective_from', '<=', on_date),
'|', ('effective_to', '=', False), ('effective_to', '>=', on_date),
('min_quantity', '<=', quantity),
], order='min_quantity desc, effective_from desc')
return candidates[:1]

View File

@@ -277,18 +277,15 @@ class FpPartCatalog(models.Model):
rec.process_variant_count = len(variants)
# ---- Direct-order defaults (Phase C — C4) ----
x_fc_default_coating_config_id = fields.Many2one(
'fp.coating.config',
string='Default Treatment',
help='Default coating applied when this part is dropped onto a '
'direct order line. Updated when "Save as Default" is ticked.',
)
x_fc_default_treatment_ids = fields.Many2many(
'fp.treatment',
relation='fp_part_catalog_default_treatment_rel',
string='Default Additional Treatments',
help='Default additional treatments. Seeded when "Save as Default" '
'is ticked on a direct order line.',
# x_fc_default_customer_spec_id added by fusion_plating_quality.
# Legacy default_coating_config_id + default_treatment_ids removed.
x_fc_default_thickness_range = fields.Char(
string='Default Thickness',
help='Default thickness range as free text (e.g. "0.0005-0.0008 mils" '
'or "5-10 mils"). Pre-fills the thickness on new sale order '
'lines for this part — falls back when no recent order for '
'the same (part, customer) pair exists. Updated when the '
'wizard\'s "Save as Default" toggle is ticked.',
)
# Substrate density mapping (g/cm³) for material weight calculation

View File

@@ -18,8 +18,9 @@ class FpPricingRule(models.Model):
_order = 'sequence, id'
name = fields.Char(string='Rule Name', required=True)
coating_config_id = fields.Many2one('fp.coating.config', string='Coating Config',
help='Leave blank for a global rule.')
# coating_config_id removed. Spec + recipe match keys live on
# fusion_plating_quality.fp_pricing_rule_inherit. Material +
# cert_level (below) remain as generic filters.
substrate_material = fields.Selection(
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],

View File

@@ -243,8 +243,15 @@ class FpQuoteConfigurator(models.Model):
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
upload_po_filename = fields.Char(string='PO Filename')
coating_config_id = fields.Many2one(
'fp.coating.config', string='Coating Configuration', required=True,
# Renamed from coating_config_id (Phase E — Promote Customer Spec).
# Now points at the recipe directly. The quote's specification
# (customer-facing audit ref) is added by quality inherit as
# customer_spec_id.
recipe_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe',
required=True,
domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
)
quantity = fields.Integer(string='Quantity', default=1, required=True)
batch_size = fields.Integer(string='Batch Size', help='Parts per rack or barrel load.')
@@ -345,10 +352,10 @@ class FpQuoteConfigurator(models.Model):
# Copy masking area too (for effective-area calculation)
self.masking_area_sqin = cat.masking_area_sqin
@api.onchange('coating_config_id')
def _onchange_coating_config_id(self):
if self.coating_config_id:
self.thickness_requested = self.coating_config_id.thickness_min
@api.onchange('recipe_id')
def _onchange_recipe_id(self):
if self.recipe_id and self.recipe_id.thickness_min:
self.thickness_requested = self.recipe_id.thickness_min
# -------------------------------------------------------------------------
# Price calculation
@@ -358,11 +365,11 @@ class FpQuoteConfigurator(models.Model):
'masking_zones', 'complexity', 'substrate_material',
'quantity', 'batch_size', 'rush_order',
'shipping_fee', 'delivery_fee',
'coating_config_id', 'coating_config_id.certification_level',
'recipe_id',
)
def _compute_price(self):
for rec in self:
if not rec.coating_config_id or not rec.surface_area:
if not rec.recipe_id or not rec.surface_area:
rec.calculated_price = 0
rec.price_breakdown_html = ''
continue
@@ -476,19 +483,17 @@ class FpQuoteConfigurator(models.Model):
def _find_matching_rule(self):
"""Find the best pricing rule matching this configurator's filters.
Scores rules by specificity -- most specific match wins.
Scores rules by specificity most specific match wins.
If no rule matches filters, returns None.
When the chosen coating config points at a recipe and that recipe
has `pricing_rule_ids` configured, the search is constrained to
those rules ("Use Price Builders" semantics). Otherwise the
whole active rule set is considered as before.
When the chosen recipe has `pricing_rule_ids` configured, the
search is constrained to those rules ("Use Price Builders"
semantics). Otherwise the whole active rule set is considered.
Spec-tier scoring is added by an inherit in
fusion_plating_quality (where customer.spec lives).
"""
recipe = (
self.coating_config_id.recipe_id
if self.coating_config_id and self.coating_config_id.recipe_id
else False
)
recipe = self.recipe_id or False
builder_rules = (
recipe.pricing_rule_ids if recipe else self.env['fp.pricing.rule']
)
@@ -500,27 +505,15 @@ class FpQuoteConfigurator(models.Model):
rules = self.env['fp.pricing.rule'].search(
[('active', '=', True)], order='sequence, id'
)
cert_level = (
self.coating_config_id.certification_level
if self.coating_config_id else False
)
best = None
best_score = -1
for rule in rules:
score = 0
if rule.coating_config_id:
if rule.coating_config_id != self.coating_config_id:
continue
score += 4
if rule.substrate_material:
if rule.substrate_material != self.substrate_material:
continue
score += 2
if rule.certification_level:
if rule.certification_level != cert_level:
continue
score += 1
if score > best_score:
best_score = score
best = rule
@@ -569,9 +562,9 @@ class FpQuoteConfigurator(models.Model):
raise UserError(_(
'Pick a part catalog entry before promoting this quote.'
))
if not self.coating_config_id:
if not self.recipe_id:
raise UserError(_(
'Pick a coating configuration before promoting this quote.'
'Pick a recipe before promoting this quote.'
))
existing_line = self.env['fp.direct.order.line'].search([
('quote_id', '=', self.id),
@@ -618,14 +611,13 @@ class FpQuoteConfigurator(models.Model):
'purchase_ok': False,
})
coating_name = self.coating_config_id.name if self.coating_config_id else ''
recipe_name = self.recipe_id.name if self.recipe_id else ''
part_name = self.part_catalog_id.name if self.part_catalog_id else 'Custom Part'
so_vals = {
'partner_id': self.partner_id.id,
'x_fc_configurator_id': self.id,
'x_fc_part_catalog_id': self.part_catalog_id.id if self.part_catalog_id else False,
'x_fc_coating_config_id': self.coating_config_id.id,
'x_fc_rush_order': self.rush_order,
'x_fc_delivery_method': self.delivery_method,
# Transfer RFQ / PO documents from configurator (if any)
@@ -641,17 +633,19 @@ class FpQuoteConfigurator(models.Model):
'origin': self.name,
'order_line': [(0, 0, {
'product_id': product.id,
'name': '%s%s (x%d)' % (coating_name, part_name, self.quantity),
'name': '%s%s (x%d)' % (recipe_name, part_name, self.quantity),
'product_uom_qty': self.quantity,
'price_unit': price / self.quantity if self.quantity else price,
# Sub 11 fix — propagate part + coating to the LINE too.
# Propagate part + recipe to the LINE.
# fusion_plating_jobs._fp_auto_create_job filters lines
# by x_fc_part_catalog_id; without it, no fp.job spawns.
# Spec carry-over to SO line is handled by the quality
# inherit (sale_order_line_inherit.create override).
'x_fc_part_catalog_id': (
self.part_catalog_id.id if self.part_catalog_id else False
),
'x_fc_coating_config_id': (
self.coating_config_id.id if self.coating_config_id else False
'x_fc_process_variant_id': (
self.recipe_id.id if self.recipe_id else False
),
})],
}

View File

@@ -52,19 +52,14 @@ class FpSaleDescriptionTemplate(models.Model):
'part — it only appears in the picker when this part is on '
'the order. Leave blank for generic fallback templates.',
)
# Related fields — surface the part's partner/coating for search &
# grouping without writing them twice.
# Related fields — surface the part's partner for search & grouping
# without writing it twice.
partner_id = fields.Many2one(
'res.partner', string='Customer',
related='part_catalog_id.partner_id', store=True, readonly=True,
)
# Keep the explicit coating slot for global templates that aren't
# part-specific but are still coating-specific.
coating_config_id = fields.Many2one(
'fp.coating.config', string='Associated Coating',
ondelete='set null',
help='For generic (no-part) templates, restrict to one coating.',
)
# coating_config_id removed; templates can be customer- or part-
# scoped. Spec-scoped templates are a future enhancement.
tag = fields.Selection(
[('standard', 'Standard'),
('masking', 'Masking / Selective'),

View File

@@ -1,52 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpTreatment(models.Model):
"""Pre- or post-treatment step (bead blast, zincate, bake, passivate, etc.).
Used by coating configurations to specify which preparation and
finishing steps are required for a given process.
"""
_name = 'fp.treatment'
_description = 'Fusion Plating — Treatment'
_order = 'treatment_type, sequence, name'
name = fields.Char(
string='Treatment',
required=True,
help='e.g. "Bead Blast", "Zincate", "Hydrogen Embrittlement Bake"',
)
treatment_type = fields.Selection(
[('pre', 'Pre-Treatment'), ('post', 'Post-Treatment')],
string='Type',
required=True,
default='pre',
)
sequence = fields.Integer(string='Sequence', default=10)
default_duration_minutes = fields.Float(
string='Default Duration (min)',
help='Estimated duration per application in minutes.',
)
currency_id = fields.Many2one(
'res.currency',
string='Currency',
required=True,
default=lambda self: self.env.company.currency_id,
)
default_cost = fields.Monetary(
string='Default Cost',
currency_field='currency_id',
help='Default cost per application. Can be overridden on pricing rules.',
)
description = fields.Text(string='Description')
active = fields.Boolean(string='Active', default=True)
_sql_constraints = [
('fp_treatment_name_type_uniq', 'unique(name, treatment_type)',
'Treatment name must be unique per type.'),
]

View File

@@ -11,7 +11,8 @@ class SaleOrder(models.Model):
x_fc_configurator_id = fields.Many2one('fp.quote.configurator', string='Configurator', copy=False)
x_fc_part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
x_fc_coating_config_id = fields.Many2one('fp.coating.config', string='Coating Configuration')
# x_fc_coating_config_id removed; specs live on customer.spec via
# the line-level x_fc_customer_spec_id (added by quality inherit).
x_fc_po_number = fields.Char(string='Customer PO #', tracking=True)
x_fc_po_attachment_id = fields.Many2one(
'ir.attachment', string='PO Document', tracking=True,
@@ -209,7 +210,7 @@ class SaleOrder(models.Model):
for so in self:
variants = []
for line in so.order_line:
if not (line.x_fc_part_catalog_id or line.x_fc_coating_config_id):
if not line.x_fc_part_catalog_id:
continue # non-plating line
variant = (line.x_fc_process_variant_id
or line.x_fc_part_catalog_id.default_process_id)
@@ -553,35 +554,17 @@ class SaleOrder(models.Model):
@api.depends('order_line.price_subtotal', 'amount_untaxed')
def _compute_margin(self):
"""Margin = untaxed total rolled-up cost from coating configs.
"""Margin computation — stub.
x_fc_margin_percent is stored as a fraction (0.0 - 1.0) so the
widget='percentage' formats 100% as 100%, not 10000%.
x_fc_margin_available is False when NO line has a costed coating
(i.e. fp.coating.config.unit_cost isn't populated anywhere). The
UI should render margin fields as "n/a" in that case rather than
showing a misleading 100%.
Pre-promote-customer-spec, this rolled up cost from
fp.coating.config.unit_cost. Coating Config is retired; cost
data on the recipe is a future enhancement (backlog). Until
then, margin is "not available" and the UI hides the fields.
"""
for rec in self:
has_cost_data = False
cost = 0.0
for line in rec.order_line:
cc = line.x_fc_coating_config_id
if not cc:
continue
if 'unit_cost' not in cc._fields:
continue
if cc.unit_cost:
has_cost_data = True
cost_per_unit = cc.unit_cost or 0.0
cost += cost_per_unit * (line.product_uom_qty or 0)
rec.x_fc_margin_available = has_cost_data
rec.x_fc_margin_amount = (rec.amount_untaxed or 0) - cost
rec.x_fc_margin_percent = (
(rec.x_fc_margin_amount / rec.amount_untaxed)
if (rec.amount_untaxed and has_cost_data) else 0.0
)
rec.x_fc_margin_available = False
rec.x_fc_margin_amount = 0.0
rec.x_fc_margin_percent = 0.0
@api.onchange('upload_rfq_file')
def _onchange_upload_rfq_file(self):

View File

@@ -59,12 +59,9 @@ class SaleOrderLine(models.Model):
string='Description Template',
help='Which template row populated this line. Informational.',
)
x_fc_coating_config_id = fields.Many2one(
'fp.coating.config', string='Primary Treatment',
)
x_fc_treatment_ids = fields.Many2many(
'fp.treatment', string='Additional Treatments',
)
# Specification picker (x_fc_customer_spec_id) is added by
# fusion_plating_quality. Legacy x_fc_coating_config_id +
# x_fc_treatment_ids removed.
x_fc_part_deadline = fields.Date(
string='Part Deadline Override',
help='Absolute-date manual override. When set, beats the days-offset '
@@ -307,13 +304,13 @@ class SaleOrderLine(models.Model):
help='Shop-floor reference for this line. Auto-sequenced on sale '
'order confirmation; editable. Blank is allowed.',
)
x_fc_thickness_id = fields.Many2one(
'fp.coating.thickness',
x_fc_thickness_range = fields.Char(
string='Thickness',
ondelete='set null',
domain="[('coating_config_id', '=', x_fc_coating_config_id)]",
help="Target coating thickness. Options come from the line's "
'coating configuration.',
help='Target thickness range as the operator types it, e.g. '
'"0.0005-0.0008 mils" or "5-10 mils". Free-form text — '
'auto-fills from the last order for this (part, customer) '
'pair, falling back to the part\'s default range. Prints '
'verbatim on the cert, packing slip, and invoice.',
)
x_fc_revision_snapshot = fields.Char(
string='Revision (snapshot)',
@@ -403,6 +400,32 @@ class SaleOrderLine(models.Model):
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
if part and part.revision:
vals['x_fc_revision_snapshot'] = part.revision
# Auto-fill thickness range — same logic as the onchange but
# for programmatic creators (wizard, sale_mrp, imports).
# Resolution: explicit > last-used (part, partner) > part default.
if (not vals.get('x_fc_thickness_range')
and vals.get('x_fc_part_catalog_id')):
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
if part:
# Need partner_id from the parent order
partner_id = False
if vals.get('order_id'):
order = self.env['sale.order'].browse(vals['order_id']).exists()
if order:
partner_id = order.partner_id.id
if partner_id:
recent = self.search([
('x_fc_part_catalog_id', '=', part.id),
('order_id.partner_id', '=', partner_id),
('x_fc_thickness_range', '!=', False),
('x_fc_thickness_range', '!=', ''),
], order='create_date desc', limit=1)
if recent:
vals['x_fc_thickness_range'] = recent.x_fc_thickness_range
if (not vals.get('x_fc_thickness_range')
and getattr(part, 'x_fc_default_thickness_range', None)):
vals['x_fc_thickness_range'] = part.x_fc_default_thickness_range
lines = super().create(vals_list)
lines._fp_apply_recipe_polish()
return lines
@@ -477,10 +500,12 @@ class SaleOrderLine(models.Model):
vals['x_fc_serial_id'] = self.x_fc_serial_id.id
if self.x_fc_job_number:
vals['x_fc_job_number'] = self.x_fc_job_number
if self.x_fc_thickness_id:
vals['x_fc_thickness_id'] = self.x_fc_thickness_id.id
if self.x_fc_thickness_range:
vals['x_fc_thickness_range'] = self.x_fc_thickness_range
if self.x_fc_revision_snapshot:
vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot
# x_fc_customer_spec_id carry-over is handled by an
# extension in fusion_plating_quality (the field lives there).
return vals
@api.onchange('x_fc_part_catalog_id')
@@ -498,6 +523,9 @@ class SaleOrderLine(models.Model):
if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id:
line.x_fc_process_variant_id = line.x_fc_part_catalog_id.default_process_id
# Spec auto-fill onchange lives in fusion_plating_quality
# (the customer.spec model lives there, so the inherit must too).
def _fp_clone_recipe_to_part(self):
"""Deep-copy the picked recipe onto this line's part if it isn't
already scoped there. Returns the cloned (or unchanged) variant.
@@ -575,18 +603,41 @@ class SaleOrderLine(models.Model):
'target': 'current',
}
@api.onchange('x_fc_coating_config_id')
def _onchange_coating_clears_thickness(self):
"""Clear the thickness picker when coating config changes.
@api.onchange('x_fc_part_catalog_id')
def _onchange_part_default_thickness(self):
"""Auto-fill thickness range from last-used or part default.
The thickness options are scoped to the coating config; a value
carried over from a previous coating would fail its domain.
Resolution order (first match wins):
1. Operator already typed a value → keep
2. Most recent SO line for (this part, this customer) with a
non-empty thickness_range → copy that
3. Part's x_fc_default_thickness_range → copy
4. Blank — operator types
"""
for line in self:
if (line.x_fc_thickness_id
and line.x_fc_thickness_id.coating_config_id
!= line.x_fc_coating_config_id):
line.x_fc_thickness_id = False
if line.x_fc_thickness_range:
continue
if not line.x_fc_part_catalog_id:
continue
partner = line.order_id.partner_id
# 2. Last-used for (part, customer)
if partner:
recent = self.env['sale.order.line'].search([
('x_fc_part_catalog_id', '=', line.x_fc_part_catalog_id.id),
('order_id.partner_id', '=', partner.id),
('x_fc_thickness_range', '!=', False),
('x_fc_thickness_range', '!=', ''),
('id', '!=', line.id or 0),
], order='create_date desc', limit=1)
if recent:
line.x_fc_thickness_range = recent.x_fc_thickness_range
continue
# 3. Part default
part_default = getattr(
line.x_fc_part_catalog_id, 'x_fc_default_thickness_range', None,
)
if part_default:
line.x_fc_thickness_range = part_default
def action_generate_serial(self):
"""Generate one new auto-sequenced serial and append it to the M2M.

View File

@@ -1,13 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_treatment_operator,fp.treatment.operator,model_fp_treatment,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_treatment_supervisor,fp.treatment.supervisor,model_fp_treatment,fusion_plating.group_fusion_plating_supervisor,1,1,0,0
access_fp_treatment_manager,fp.treatment.manager,model_fp_treatment,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_catalog_operator,fp.part.catalog.operator,model_fp_part_catalog,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_part_catalog_estimator,fp.part.catalog.estimator,model_fp_part_catalog,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_part_catalog_manager,fp.part.catalog.manager,model_fp_part_catalog,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_coating_config_operator,fp.coating.config.operator,model_fp_coating_config,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_coating_config_estimator,fp.coating.config.estimator,model_fp_coating_config,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_coating_config_manager,fp.coating.config.manager,model_fp_coating_config,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_pricing_rule_operator,fp.pricing.rule.operator,model_fp_pricing_rule,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_pricing_rule_estimator,fp.pricing.rule.estimator,model_fp_pricing_rule,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_pricing_rule_manager,fp.pricing.rule.manager,model_fp_pricing_rule,fusion_plating.group_fusion_plating_manager,1,1,1,1
@@ -35,9 +29,6 @@ access_fp_sale_assembly_line_estimator,fp.sale.assembly.line.estimator,model_fp_
access_fp_sale_assembly_line_manager,fp.sale.assembly.line.manager,model_fp_sale_assembly_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_import_wizard_estimator,fp.part.catalog.import.wizard.estimator,model_fp_part_catalog_import_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_part_import_wizard_manager,fp.part.catalog.import.wizard.manager,model_fp_part_catalog_import_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_customer_price_list_operator,fp.customer.price.list.operator,model_fp_customer_price_list,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_customer_price_list_estimator,fp.customer.price.list.estimator,model_fp_customer_price_list,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_customer_price_list_manager,fp.customer.price.list.manager,model_fp_customer_price_list,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_sale_desc_template_user,fp.sale.description.template.user,model_fp_sale_description_template,base.group_user,1,0,0,0
access_fp_sale_desc_template_estimator,fp.sale.description.template.estimator,model_fp_sale_description_template,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_fp_sale_description_template,fusion_plating.group_fusion_plating_manager,1,1,1,1
@@ -48,9 +39,6 @@ access_fp_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_revision_bump_estimator,fp.part.revision.bump.estimator,model_fp_part_revision_bump_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part_revision_bump_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
access_fp_treatment_operator fp.treatment.operator model_fp_treatment fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_treatment_supervisor fp.treatment.supervisor model_fp_treatment fusion_plating.group_fusion_plating_supervisor 1 1 0 0
access_fp_treatment_manager fp.treatment.manager model_fp_treatment fusion_plating.group_fusion_plating_manager 1 1 1 1
2 access_fp_part_catalog_operator fp.part.catalog.operator model_fp_part_catalog fusion_plating.group_fusion_plating_operator 1 0 0 0
3 access_fp_part_catalog_estimator fp.part.catalog.estimator model_fp_part_catalog fusion_plating_configurator.group_fp_estimator 1 1 1 0
4 access_fp_part_catalog_manager fp.part.catalog.manager model_fp_part_catalog fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_coating_config_operator fp.coating.config.operator model_fp_coating_config fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_coating_config_estimator fp.coating.config.estimator model_fp_coating_config fusion_plating_configurator.group_fp_estimator 1 1 1 0
access_fp_coating_config_manager fp.coating.config.manager model_fp_coating_config fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_pricing_rule_operator fp.pricing.rule.operator model_fp_pricing_rule fusion_plating.group_fusion_plating_operator 1 0 0 0
6 access_fp_pricing_rule_estimator fp.pricing.rule.estimator model_fp_pricing_rule fusion_plating_configurator.group_fp_estimator 1 1 1 0
7 access_fp_pricing_rule_manager fp.pricing.rule.manager model_fp_pricing_rule fusion_plating.group_fusion_plating_manager 1 1 1 1
29 access_fp_sale_assembly_line_manager fp.sale.assembly.line.manager model_fp_sale_assembly_line fusion_plating.group_fusion_plating_manager 1 1 1 1
30 access_fp_part_import_wizard_estimator fp.part.catalog.import.wizard.estimator model_fp_part_catalog_import_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
31 access_fp_part_import_wizard_manager fp.part.catalog.import.wizard.manager model_fp_part_catalog_import_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_customer_price_list_operator fp.customer.price.list.operator model_fp_customer_price_list fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_customer_price_list_estimator fp.customer.price.list.estimator model_fp_customer_price_list fusion_plating_configurator.group_fp_estimator 1 1 1 0
access_fp_customer_price_list_manager fp.customer.price.list.manager model_fp_customer_price_list fusion_plating.group_fusion_plating_manager 1 1 1 1
32 access_fp_sale_desc_template_user fp.sale.description.template.user model_fp_sale_description_template base.group_user 1 0 0 0
33 access_fp_sale_desc_template_estimator fp.sale.description.template.estimator model_fp_sale_description_template fusion_plating_configurator.group_fp_estimator 1 1 1 0
34 access_fp_sale_desc_template_manager fp.sale.description.template.manager model_fp_sale_description_template fusion_plating.group_fusion_plating_manager 1 1 1 1
39 access_fp_serial_bulk_add_manager fp.serial.bulk.add.manager model_fp_serial_bulk_add_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
40 access_fp_part_revision_bump_estimator fp.part.revision.bump.estimator model_fp_part_revision_bump_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
41 access_fp_part_revision_bump_manager fp.part.revision.bump.manager model_fp_part_revision_bump_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_coating_thickness_user fp.coating.thickness.user model_fp_coating_thickness base.group_user 1 0 0 0
access_fp_coating_thickness_estimator fp.coating.thickness.estimator model_fp_coating_thickness fusion_plating_configurator.group_fp_estimator 1 1 1 0
access_fp_coating_thickness_manager fp.coating.thickness.manager model_fp_coating_thickness fusion_plating.group_fusion_plating_manager 1 1 1 1
42 access_fp_part_material_user fp.part.material.user model_fp_part_material base.group_user 1 0 0 0
43 access_fp_part_material_estimator fp.part.material.estimator model_fp_part_material fusion_plating_configurator.group_fp_estimator 1 1 1 0
44 access_fp_part_material_manager fp.part.material.manager model_fp_part_material fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -1,143 +0,0 @@
<?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>
<!-- ===== Coating Configuration List View ===== -->
<record id="view_fp_coating_config_list" model="ir.ui.view">
<field name="name">fp.coating.config.list</field>
<field name="model">fp.coating.config</field>
<field name="arch" type="xml">
<list string="Coating Configurations" decoration-muted="not active">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="process_type_id"/>
<field name="phosphorus_level"/>
<field name="thickness_min"/>
<field name="thickness_max"/>
<field name="spec_reference"/>
<field name="certification_level"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Coating Configuration Form View ===== -->
<record id="view_fp_coating_config_form" model="ir.ui.view">
<field name="name">fp.coating.config.form</field>
<field name="model">fp.coating.config</field>
<field name="arch" type="xml">
<form string="Coating Configuration">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. EN Mid-Phos AMS 2404"/></h1>
</div>
<group>
<group>
<field name="process_type_id"/>
<field name="recipe_id"/>
<field name="phosphorus_level"/>
<field name="certification_level"/>
<field name="sequence"/>
</group>
<group>
<field name="thickness_min"/>
<field name="thickness_max"/>
<field name="thickness_uom"/>
<field name="spec_reference"/>
</group>
</group>
<notebook>
<page string="Treatments" name="treatments">
<group>
<group string="Pre-Treatments">
<field name="pre_treatment_ids" widget="many2many_tags" nolabel="1"/>
</group>
<group string="Post-Treatments">
<field name="post_treatment_ids" widget="many2many_tags" nolabel="1"/>
</group>
</group>
</page>
<page string="Description" name="description">
<field name="description" placeholder="Detailed description of this coating configuration..."/>
</page>
<page string="Thickness Options" name="thickness_options">
<p class="text-muted">
Discrete thickness values the estimator can pick when
this coating appears on a sale order line. Each value
is driven by the spec this coating is built against
(e.g. AMS-2404 Class 4 → 0.0005″ / 0.001″ / 0.0015″).
Leave empty if no dropdown is needed for this coating.
</p>
<field name="thickness_option_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="value" string="Nominal"/>
<field name="value_min" string="Min"/>
<field name="value_max" string="Max"/>
<field name="uom"/>
<field name="display_name" string="Display" readonly="1"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</page>
</notebook>
<group>
<field name="active" widget="boolean_toggle"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Coating Configuration Search View ===== -->
<record id="view_fp_coating_config_search" model="ir.ui.view">
<field name="name">fp.coating.config.search</field>
<field name="model">fp.coating.config</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="process_type_id"/>
<field name="spec_reference"/>
<separator/>
<filter string="Commercial" name="commercial" domain="[('certification_level','=','commercial')]"/>
<filter string="Mil-Spec" name="mil_spec" domain="[('certification_level','=','mil_spec')]"/>
<filter string="Nadcap" name="nadcap" domain="[('certification_level','=','nadcap')]"/>
<filter string="Nuclear" name="nuclear" domain="[('certification_level','=','nuclear')]"/>
<separator/>
<filter string="Low Phosphorus" name="low_phos" domain="[('phosphorus_level','=','low_phos')]"/>
<filter string="Mid Phosphorus" name="mid_phos" domain="[('phosphorus_level','=','mid_phos')]"/>
<filter string="High Phosphorus" name="high_phos" domain="[('phosphorus_level','=','high_phos')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Process Type" name="group_process_type" context="{'group_by':'process_type_id'}"/>
<filter string="Certification Level" name="group_cert_level" context="{'group_by':'certification_level'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_coating_config" model="ir.actions.act_window">
<field name="name">Coating Configurations</field>
<field name="res_model">fp.coating.config</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_coating_config_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No coating configurations defined yet
</p>
<p>
Define coating setups with process type, phosphorus level,
thickness range, spec reference, and required treatments.
</p>
</field>
</record>
</odoo>

View File

@@ -1,94 +0,0 @@
<?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.
Standalone views for fp.coating.thickness so SO-line m2o pickers
can offer "Create and edit..." — the inline-on-coating-config
editor was the only way to add thicknesses pre-Sub-12d.
-->
<odoo>
<record id="view_fp_coating_thickness_list" model="ir.ui.view">
<field name="name">fp.coating.thickness.list</field>
<field name="model">fp.coating.thickness</field>
<field name="arch" type="xml">
<list string="Coating Thicknesses" decoration-muted="not active">
<field name="sequence" widget="handle"/>
<field name="coating_config_id"/>
<field name="value" string="Nominal"/>
<field name="value_min" string="Min" optional="show"/>
<field name="value_max" string="Max" optional="show"/>
<field name="uom"/>
<field name="display_name" string="Label"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fp_coating_thickness_form" model="ir.ui.view">
<field name="name">fp.coating.thickness.form</field>
<field name="model">fp.coating.thickness</field>
<field name="arch" type="xml">
<form string="Coating Thickness">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="display_name" string="Thickness"/>
<h2><field name="display_name" readonly="1" placeholder="Auto-generated from value + UoM"/></h2>
</div>
<group>
<group string="Spec">
<field name="coating_config_id"
options="{'no_create_edit': True}"/>
<field name="value" string="Nominal"/>
<field name="uom"/>
</group>
<group string="Acceptance Band (optional)">
<field name="value_min" string="Min"/>
<field name="value_max" string="Max"/>
<div colspan="2" class="text-muted">
Set Min/Max when the customer spec is a
range (e.g. AMS-2404 Class 4 = 0.001"0.0015").
QC readings outside the band fail.
</div>
</group>
<group>
<field name="sequence"/>
<field name="active" widget="boolean_toggle"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_fp_coating_thickness_search" model="ir.ui.view">
<field name="name">fp.coating.thickness.search</field>
<field name="model">fp.coating.thickness</field>
<field name="arch" type="xml">
<search>
<field name="coating_config_id"/>
<field name="display_name"/>
<field name="uom"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Coating" name="group_coating"
context="{'group_by':'coating_config_id'}"/>
<filter string="UoM" name="group_uom"
context="{'group_by':'uom'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_coating_thickness" model="ir.actions.act_window">
<field name="name">Coating Thicknesses</field>
<field name="res_model">fp.coating.thickness</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_coating_thickness_search"/>
</record>
</odoo>

View File

@@ -80,41 +80,27 @@
action="action_fp_part_catalog_import_wizard"
sequence="45"/>
<!-- ===== CONFIGURATOR submenu (admin-only: coating/pricing/treatments) ===== -->
<menuitem id="menu_fp_configurator"
name="Configurator"
parent="fusion_plating.menu_fp_root"
sequence="8"
groups="group_fp_estimator"/>
<menuitem id="menu_fp_coating_configs"
name="Coating Configurations"
parent="menu_fp_configurator"
action="action_fp_coating_config"
sequence="20"/>
<!-- The Configurator top-level menu was retired in Phase F (2026-05-15)
after the Promote Customer Spec refactor left only 3 admin items
under it. They've been re-homed into the Configuration hub's
themed folders, where managers expect to find admin records:
Pricing Rules → Configuration → Pricing & Billing
Materials → Configuration → Materials & Tanks
Line Desc Tpl → Configuration → Quality & Documents (in
fp_sale_description_template_views.xml)
-->
<menuitem id="menu_fp_pricing_rules"
name="Pricing Rules"
parent="menu_fp_configurator"
parent="fusion_plating.menu_fp_config_pricing_billing"
action="action_fp_pricing_rule"
sequence="30"/>
<menuitem id="menu_fp_customer_price_lists"
name="Customer Price Lists"
parent="menu_fp_configurator"
action="action_fp_customer_price_list"
sequence="35"/>
<menuitem id="menu_fp_treatments"
name="Treatments"
parent="menu_fp_configurator"
action="action_fp_treatment"
sequence="40"/>
sequence="40"
groups="group_fp_estimator,fusion_plating.group_fusion_plating_manager"/>
<menuitem id="menu_fp_part_materials"
name="Materials"
parent="menu_fp_configurator"
parent="fusion_plating.menu_fp_config_materials_tanks"
action="action_fp_part_material"
sequence="50"/>
sequence="40"
groups="group_fp_estimator,fusion_plating.group_fusion_plating_manager"/>
</odoo>

View File

@@ -1,86 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_customer_price_list_list" model="ir.ui.view">
<field name="name">fp.customer.price.list.list</field>
<field name="model">fp.customer.price.list</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="partner_id"/>
<field name="coating_config_id"/>
<field name="currency_id" column_invisible="True"/>
<field name="unit_price" widget="monetary"
options="{'currency_field': 'currency_id'}" sum="Total"/>
<field name="price_uom"/>
<field name="min_quantity"/>
<field name="effective_from"/>
<field name="effective_to"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_fp_customer_price_list_form" model="ir.ui.view">
<field name="name">fp.customer.price.list.form</field>
<field name="model">fp.customer.price.list</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_title">
<h2><field name="name" readonly="1"/></h2>
</div>
<group>
<group>
<field name="partner_id"/>
<field name="coating_config_id"/>
<field name="currency_id"/>
<field name="unit_price" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="price_uom"/>
</group>
<group>
<field name="effective_from"/>
<field name="effective_to"/>
<field name="min_quantity"/>
<field name="active" widget="boolean_toggle"/>
</group>
</group>
<separator string="Notes"/>
<field name="notes" colspan="2"/>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_customer_price_list_search" model="ir.ui.view">
<field name="name">fp.customer.price.list.search</field>
<field name="model">fp.customer.price.list</field>
<field name="arch" type="xml">
<search>
<field name="partner_id"/>
<field name="coating_config_id"/>
<filter name="active" string="Active"
domain="[('active', '=', True)]"/>
<filter name="expired" string="Expired"
domain="[('effective_to', '&lt;', context_today().strftime('%Y-%m-%d'))]"/>
<separator/>
<group>
<filter name="group_customer" string="Customer"
context="{'group_by': 'partner_id'}"/>
<filter name="group_coating" string="Coating"
context="{'group_by': 'coating_config_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_customer_price_list" model="ir.actions.act_window">
<field name="name">Customer Price Lists</field>
<field name="res_model">fp.customer.price.list</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_customer_price_list_search"/>
<field name="context">{'search_default_active': 1}</field>
</record>
</odoo>

View File

@@ -201,20 +201,18 @@
class="btn-link"/>
</list>
</field>
<separator string="Default Treatments" class="mt-4"/>
<!-- Default Specification picker added by
fusion_plating_quality view inherit. -->
<separator string="Default Thickness" class="mt-4"/>
<group>
<field name="x_fc_default_coating_config_id"
string="Default Treatment"
options="{'no_create_edit': True}"/>
<field name="x_fc_default_treatment_ids"
string="Default Additional Treatments"
widget="many2many_tags"
options="{'no_create_edit': True}"/>
<field name="x_fc_default_thickness_range"
placeholder="e.g. 0.0005-0.0008 mils"/>
</group>
<p class="text-muted">
Seeds the treatment fields on new direct-order
lines for this part. Updated whenever "Save as
Default" is ticked while placing an order.
Defaults pre-fill new direct-order lines
for this part. Thickness also auto-fills
from the most recent order for the same
(part, customer) pair when one exists.
</p>
</page>
<page string="Dimensions &amp; Complexity" name="dimensions">

View File

@@ -14,7 +14,6 @@
<list string="Pricing Rules" decoration-muted="not active">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="coating_config_id"/>
<field name="substrate_material"/>
<field name="certification_level"/>
<field name="pricing_method"/>
@@ -42,7 +41,6 @@
</div>
<group string="Filters">
<group>
<field name="coating_config_id"/>
<field name="substrate_material"/>
<field name="certification_level"/>
</group>
@@ -104,7 +102,6 @@
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="coating_config_id"/>
<separator/>
<filter string="Per Square Inch" name="per_sqin" domain="[('pricing_method','=','per_sqin')]"/>
<filter string="Per Square Foot" name="per_sqft" domain="[('pricing_method','=','per_sqft')]"/>
@@ -113,7 +110,6 @@
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Coating Config" name="group_coating_config" context="{'group_by':'coating_config_id'}"/>
<filter string="Pricing Method" name="group_pricing_method" context="{'group_by':'pricing_method'}"/>
</group>
</search>

View File

@@ -129,7 +129,7 @@
<group string="Customer &amp; Part">
<field name="partner_id"/>
<field name="part_catalog_id"/>
<field name="coating_config_id"/>
<field name="recipe_id"/>
<!-- 3D File: upload before, filename + clear button after -->
<field name="upload_3d_file" filename="upload_3d_filename"
invisible="state != 'draft' or model_attachment_id"
@@ -325,7 +325,7 @@
<field name="create_date" string="Date"/>
<field name="name"/>
<field name="partner_id"/>
<field name="coating_config_id"/>
<field name="recipe_id"/>
<field name="surface_area"/>
<field name="quantity"/>
<field name="currency_id" column_invisible="1"/>
@@ -350,14 +350,14 @@
<search>
<field name="name"/>
<field name="partner_id"/>
<field name="coating_config_id"/>
<field name="recipe_id"/>
<separator/>
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
<group>
<filter string="Customer" name="group_customer" context="{'group_by': 'partner_id'}"/>
<filter string="Coating Config" name="group_coating" context="{'group_by': 'coating_config_id'}"/>
<filter string="Recipe" name="group_recipe" context="{'group_by': 'recipe_id'}"/>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
</group>
</search>

View File

@@ -22,7 +22,6 @@
decoration-danger="tag == 'rework'"
decoration-success="tag in ('aerospace','nuclear')"/>
<field name="partner_id" optional="show"/>
<field name="coating_config_id" optional="hide"/>
<field name="usage_count" string="Used"/>
<field name="active" widget="boolean_toggle"/>
</list>
@@ -46,9 +45,6 @@
<field name="tag"/>
</group>
<group>
<field name="coating_config_id"
help="Only used for generic (no-part) templates."
invisible="part_catalog_id"/>
<field name="sequence"/>
<field name="usage_count" readonly="1"/>
<field name="active" widget="boolean_toggle"/>
@@ -75,7 +71,6 @@
<field name="internal_description"/>
<field name="customer_facing_description"/>
<field name="part_catalog_id"/>
<field name="coating_config_id"/>
<field name="partner_id"/>
<field name="tag"/>
<filter name="active" string="Active" domain="[('active','=',True)]"/>
@@ -114,9 +109,10 @@
</record>
<menuitem id="menu_fp_sale_description_templates"
name="Line Descriptions"
parent="menu_fp_configurator"
name="Line Description Templates"
parent="fusion_plating.menu_fp_config_quality_docs"
action="action_fp_sale_description_template"
sequence="45"/>
sequence="90"
groups="fusion_plating_configurator.group_fp_estimator,fusion_plating.group_fusion_plating_manager"/>
</odoo>

View File

@@ -1,100 +0,0 @@
<?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>
<!-- ===== Treatment List View ===== -->
<record id="view_fp_treatment_list" model="ir.ui.view">
<field name="name">fp.treatment.list</field>
<field name="model">fp.treatment</field>
<field name="arch" type="xml">
<list string="Treatments">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="treatment_type"/>
<field name="default_duration_minutes" string="Duration (min)"/>
<field name="currency_id" column_invisible="1"/>
<field name="default_cost" widget="monetary"
options="{'currency_field': 'currency_id'}" sum="Total"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Treatment Form View ===== -->
<record id="view_fp_treatment_form" model="ir.ui.view">
<field name="name">fp.treatment.form</field>
<field name="model">fp.treatment</field>
<field name="arch" type="xml">
<form string="Treatment">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Bead Blast"/></h1>
</div>
<group>
<group>
<field name="treatment_type"/>
<field name="sequence"/>
</group>
<group>
<label for="default_duration_minutes"/>
<div class="o_row">
<field name="default_duration_minutes" nolabel="1" class="oe_inline"/>
<span class="ms-1">min</span>
</div>
<field name="currency_id"/>
<field name="default_cost" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
</group>
</group>
<field name="description" placeholder="Description of this treatment step..."/>
<group>
<field name="active" widget="boolean_toggle"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Treatment Search View ===== -->
<record id="view_fp_treatment_search" model="ir.ui.view">
<field name="name">fp.treatment.search</field>
<field name="model">fp.treatment</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<separator/>
<filter string="Pre-Treatment" name="pre" domain="[('treatment_type','=','pre')]"/>
<filter string="Post-Treatment" name="post" domain="[('treatment_type','=','post')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Type" name="group_type" context="{'group_by':'treatment_type'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_treatment" model="ir.actions.act_window">
<field name="name">Treatments</field>
<field name="res_model">fp.treatment</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_treatment_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No treatments defined yet
</p>
<p>
Add pre-treatment steps (bead blast, zincate, acid etch) and
post-treatment steps (bake, passivate, chromate seal).
</p>
</field>
</record>
</odoo>

View File

@@ -106,7 +106,7 @@
so you can confirm an order has the right parts/coatings
without scrolling pricing columns. The pre-Sub-12 SO-
header singletons (x_fc_part_catalog_id /
x_fc_coating_config_id) only ever populated when the
x_fc_customer_spec_id) only ever populated when the
order was built via the quote configurator — they're
silent on direct orders, which is why they appeared
empty after confirm. They still exist on the model
@@ -118,8 +118,7 @@
readonly="1">
<list create="false" delete="false" edit="false">
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="x_fc_thickness_id" optional="show"/>
<field name="x_fc_thickness_range" optional="show"/>
<field name="x_fc_process_variant_id" optional="show"
string="Process"/>
<field name="product_uom_qty" string="Qty"/>
@@ -251,7 +250,6 @@
<field name="x_fc_internal_description"
placeholder="Shop-floor workflow instructions (prints on WO / traveler)"
optional="hide"/>
<field name="x_fc_coating_config_id" optional="show"/>
<field name="x_fc_process_variant_id"
string="Process / Recipe"
options="{'no_quick_create': True}"
@@ -262,11 +260,8 @@
widget="boolean_toggle"
invisible="not x_fc_process_variant_id"
optional="hide"/>
<field name="x_fc_thickness_id"
options="{'no_quick_create': True}"
context="{'default_coating_config_id': x_fc_coating_config_id}"
domain="[('coating_config_id', '=', x_fc_coating_config_id)]"
invisible="not x_fc_coating_config_id"
<field name="x_fc_thickness_range"
placeholder="e.g. 0.0005-0.0008 mils"
optional="show"/>
<field name="x_fc_serial_ids"
widget="many2many_tags"
@@ -290,7 +285,6 @@
<field name="x_fc_revision_snapshot"
readonly="1"
optional="hide"/>
<field name="x_fc_treatment_ids" widget="many2many_tags" optional="hide"/>
<field name="x_fc_part_deadline" string="Part Deadline Override" optional="hide"/>
<field name="x_fc_part_deadline_offset_days" string="Days Offset" optional="hide"/>
<field name="x_fc_effective_part_deadline" string="Effective Deadline"
@@ -335,7 +329,6 @@
<field name="x_fc_wo_completion" optional="show"/>
<field name="x_fc_planned_start_date" optional="hide"/>
<field name="x_fc_part_catalog_id" optional="hide"/>
<field name="x_fc_coating_config_id" optional="hide"/>
<field name="amount_total" sum="Total"/>
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
widget="monetary"
@@ -363,7 +356,6 @@
<field name="arch" type="xml">
<kanban default_group_by="x_fc_part_catalog_id" records_draggable="0">
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="product_uom_qty"/>
<field name="qty_delivered"/>
<field name="x_fc_wo_group_tag"/>
@@ -373,7 +365,7 @@
<t t-name="card">
<div class="o_kanban_card_content">
<div class="o_kanban_record_title">
<strong><field name="x_fc_coating_config_id"/></strong>
<strong><field name="x_fc_part_catalog_id"/></strong>
</div>
<div class="text-muted">
Qty: <field name="product_uom_qty"/>
@@ -399,7 +391,6 @@
<kanban default_group_by="x_fc_wo_group_tag" records_draggable="0">
<field name="x_fc_wo_group_tag"/>
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="product_uom_qty"/>
<templates>
<t t-name="card">
@@ -407,9 +398,6 @@
<div>
<strong><field name="x_fc_part_catalog_id"/></strong>
</div>
<div class="text-muted">
<field name="x_fc_coating_config_id"/>
</div>
<div>
Qty: <field name="product_uom_qty"/>
</div>

View File

@@ -43,14 +43,14 @@ class FpAddFromQuoteWizard(models.TransientModel):
wizard = self.direct_order_wizard_id
copied = 0
for q in self.quote_ids:
if not q.part_catalog_id or not q.coating_config_id:
if not q.part_catalog_id or not q.recipe_id:
continue
Line._create_from_quote(q, wizard)
copied += 1
if not copied:
raise UserError(_(
'The selected quotes do not have both part and coating set, '
'The selected quotes do not have both part and recipe set, '
'so nothing could be copied.'
))

View File

@@ -22,7 +22,7 @@
<list>
<field name="name"/>
<field name="part_catalog_id"/>
<field name="coating_config_id"/>
<field name="recipe_id"/>
<field name="quantity"/>
<field name="calculated_price" widget="monetary"/>
<field name="estimator_override_price" widget="monetary"/>

View File

@@ -53,14 +53,12 @@ class FpAddFromSoWizard(models.TransientModel):
wizard = self.direct_order_wizard_id
copied = 0
for src in self.source_line_ids:
if not src.x_fc_part_catalog_id or not src.x_fc_coating_config_id:
# Skip SO lines that predate the plating fields
if not src.x_fc_part_catalog_id:
# Skip non-plating SO lines
continue
Line.create({
'wizard_id': wizard.id,
'part_catalog_id': src.x_fc_part_catalog_id.id,
'coating_config_id': src.x_fc_coating_config_id.id,
'treatment_ids': [(6, 0, src.x_fc_treatment_ids.ids)],
'quantity': int(src.product_uom_qty) or 1,
'unit_price': src.price_unit or 0.0,
'part_deadline': src.x_fc_part_deadline,

View File

@@ -27,7 +27,7 @@
<list>
<field name="name"/>
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="x_fc_part_deadline" optional="hide"/>
<field name="product_uom_qty"/>
<field name="price_unit"/>
<field name="x_fc_part_deadline"/>

View File

@@ -51,20 +51,9 @@ class FpDirectOrderLine(models.Model):
new_drawing_filename = fields.Char(string='Filename')
revision_note = fields.Char(string='Revision Note')
# ---- Treatments ----
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Primary Treatment',
help='Optional. Some orders are non-coating work (re-inspection, '
'rework, masking-only, etc.) and the operator picks the '
'workflow downstream — leaving this blank lets that path '
'through.',
)
treatment_ids = fields.Many2many(
'fp.treatment',
string='Additional Treatments',
help='Extra pre/post treatments applied to this line.',
)
# Specification picker (customer_spec_id) added by
# fusion_plating_quality. Legacy coating_config_id +
# treatment_ids removed.
# Sub 9 (polished 2026-04-28) — process variant per line. The picker
# now lets the estimator pick ANY root recipe in the system: the
# part's own variants, another customer's variants, or a template
@@ -105,8 +94,7 @@ class FpDirectOrderLine(models.Model):
)
@api.depends('process_variant_id',
'part_catalog_id.default_process_id',
'coating_config_id.recipe_id')
'part_catalog_id.default_process_id')
def _compute_effective_process(self):
for rec in self:
if rec.process_variant_id:
@@ -120,12 +108,6 @@ class FpDirectOrderLine(models.Model):
rec.effective_process_id = part_proc
rec.effective_process_source = 'Part default'
continue
cc_proc = (rec.coating_config_id.recipe_id
if rec.coating_config_id else False)
if cc_proc:
rec.effective_process_id = cc_proc
rec.effective_process_source = 'Coating default'
continue
rec.effective_process_id = False
rec.effective_process_source = False
@@ -166,33 +148,26 @@ class FpDirectOrderLine(models.Model):
if not rec.part_catalog_id:
continue
part = rec.part_catalog_id
has_default_coating = bool(getattr(
part, 'x_fc_default_coating_config_id', False))
has_default_treatments = bool(getattr(
part, 'x_fc_default_treatment_ids', False))
# Pre-fill default coating if the line is empty.
if not rec.coating_config_id and has_default_coating:
rec.coating_config_id = part.x_fc_default_coating_config_id
# Pre-fill default treatments if any are configured.
if not rec.treatment_ids and has_default_treatments:
rec.treatment_ids = [(6, 0, part.x_fc_default_treatment_ids.ids)]
# New-part auto-suggest: if neither default exists, this is
# Default-spec auto-fill is implemented by an inherit in
# fusion_plating_quality (where customer_spec_id field lives).
has_default_spec = bool(getattr(
part, 'x_fc_default_customer_spec_id', False))
# New-part auto-suggest: if no default spec exists, this is
# likely a first-time use of the part. Auto-tick the
# push_to_defaults toggle so whatever Sarah picks becomes
# the saved default — surface a warning popup so she knows.
# `is_one_off` always wins (operator opted out of catalog
# persistence), so don't auto-tick in that case.
if (not has_default_coating
and not has_default_treatments
if (not has_default_spec
and not rec.is_one_off
and not rec.push_to_defaults):
rec.push_to_defaults = True
warning = {
'title': _('First-Time Part — Defaults Will Be Saved'),
'message': _(
'%(part)s has no saved coating / treatments. '
'The coating + treatments you pick on this line '
'will be saved as the part\'s defaults so the '
'%(part)s has no saved specification. '
'The specification you pick on this line will '
'be saved as the part\'s default so the '
'next order auto-fills them. Untick "Save as '
'Default" on the line if you don\'t want this.'
) % {'part': part.display_name or part.part_number or '(part)'},
@@ -265,11 +240,11 @@ class FpDirectOrderLine(models.Model):
start_at_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Start at Node',
domain="[('id', 'child_of', coating_config_id and coating_config_id.recipe_id.id or 0)]",
domain="[('id', 'child_of', process_variant_id and process_variant_id.id or 0)]",
help='For re-work jobs: pick the recipe step where this job should '
'begin. Pick a coating first — nodes are scoped to its '
'recipe tree. Skips earlier steps in the generated WO but '
'keeps later siblings and sub-processes.',
'begin. Pick a recipe first — nodes are scoped to it. Skips '
'earlier steps in the generated WO but keeps later siblings '
'and sub-processes.',
)
is_one_off = fields.Boolean(
string='One-off Part',
@@ -419,11 +394,11 @@ class FpDirectOrderLine(models.Model):
if rec.serial_id and rec.serial_id not in rec.serial_ids:
rec.serial_ids = [(4, rec.serial_id.id)]
job_number = fields.Char(string='Job #')
thickness_id = fields.Many2one(
'fp.coating.thickness',
thickness_range = fields.Char(
string='Thickness',
domain="[('coating_config_id', '=', coating_config_id)]",
ondelete='set null',
help='Free-form range, e.g. "0.0005-0.0008 mils" or "5-10 mils". '
'Auto-fills from last order for this (part, customer) pair, '
'or from the part\'s default range.',
)
# ---- Computes ----
@@ -432,22 +407,45 @@ class FpDirectOrderLine(models.Model):
for rec in self:
rec.line_subtotal = (rec.quantity or 0) * (rec.unit_price or 0.0)
@api.depends('part_catalog_id', 'coating_config_id', 'unit_price', 'quantity')
@api.depends('part_catalog_id', 'unit_price', 'quantity')
def _compute_is_missing_info(self):
for rec in self:
rec.is_missing_info = not (
rec.part_catalog_id
and rec.coating_config_id
and rec.unit_price
and rec.quantity
)
@api.onchange('coating_config_id')
def _onchange_coating_clears_thickness(self):
@api.onchange('part_catalog_id')
def _onchange_part_default_thickness(self):
"""Auto-fill thickness range — same chain as the SO line.
1. Operator already typed → keep
2. Most recent SO line for (part, customer) with a thickness → copy
3. Part's x_fc_default_thickness_range → copy
4. Blank
"""
for rec in self:
if (rec.thickness_id
and rec.thickness_id.coating_config_id != rec.coating_config_id):
rec.thickness_id = False
if rec.thickness_range:
continue
if not rec.part_catalog_id:
continue
partner = rec.wizard_id.partner_id
if partner:
recent = self.env['sale.order.line'].search([
('x_fc_part_catalog_id', '=', rec.part_catalog_id.id),
('order_id.partner_id', '=', partner.id),
('x_fc_thickness_range', '!=', False),
('x_fc_thickness_range', '!=', ''),
], order='create_date desc', limit=1)
if recent:
rec.thickness_range = recent.x_fc_thickness_range
continue
part_default = getattr(
rec.part_catalog_id, 'x_fc_default_thickness_range', None,
)
if part_default:
rec.thickness_range = part_default
def action_generate_serial(self):
"""Generate one auto-sequenced fp.serial and append to the M2M.
@@ -495,14 +493,16 @@ class FpDirectOrderLine(models.Model):
# ---- Onchange ----
@api.onchange('quote_id')
def _onchange_quote_id(self):
"""Auto-fill part, coating, and unit price from the linked quote."""
"""Auto-fill part and unit price from the linked quote.
Spec carry-over from quote → wizard line is handled by an
inherit in fusion_plating_quality.
"""
if not self.quote_id:
return
q = self.quote_id
if q.part_catalog_id and not self.part_catalog_id:
self.part_catalog_id = q.part_catalog_id
if q.coating_config_id and not self.coating_config_id:
self.coating_config_id = q.coating_config_id
if not self.unit_price:
final = q.estimator_override_price or q.calculated_price
if final and q.quantity:
@@ -510,13 +510,13 @@ class FpDirectOrderLine(models.Model):
@api.onchange('part_catalog_id')
def _onchange_part_defaults(self):
"""When a part is picked, seed coating + treatments from its catalog defaults."""
"""Seed defaults when a part is picked.
Spec auto-fill is handled by an inherit in fusion_plating_quality
(the customer_spec_id field lives there).
"""
if not self.part_catalog_id:
return
if not self.coating_config_id and self.part_catalog_id.x_fc_default_coating_config_id:
self.coating_config_id = self.part_catalog_id.x_fc_default_coating_config_id
if not self.treatment_ids and self.part_catalog_id.x_fc_default_treatment_ids:
self.treatment_ids = self.part_catalog_id.x_fc_default_treatment_ids
# Seed default taxes from the FP-SERVICE product, fiscal-position
# mapped from the customer. Only fills when the user hasn't set
# taxes manually.
@@ -539,21 +539,10 @@ class FpDirectOrderLine(models.Model):
if taxes:
self.tax_ids = [(6, 0, taxes.ids)]
@api.onchange('coating_config_id', 'quantity', 'part_catalog_id')
def _onchange_lookup_price(self):
"""Auto-fill unit_price from customer price list when available."""
if self.unit_price:
return
partner = self.wizard_id.partner_id
if not (partner and self.coating_config_id):
return
price = self.env['fp.customer.price.list']._find_price(
partner.id,
self.coating_config_id.id,
quantity=self.quantity or 1,
)
if price:
self.unit_price = price.unit_price
# Auto-fill unit_price from a customer price list — extended in
# fusion_plating_quality (the spec field lives there). The base
# configurator wizard no longer triggers price lookup since
# coating_config_id is gone.
@api.onchange('description_template_id')
def _onchange_description_template(self):
@@ -571,15 +560,14 @@ class FpDirectOrderLine(models.Model):
if tpl.internal_description:
self.internal_description = tpl.internal_description
@api.onchange('part_catalog_id', 'coating_config_id')
@api.onchange('part_catalog_id')
def _onchange_suggest_template(self):
"""Offer a sensible default template — part-specific wins.
Priority (first non-empty result wins):
1. This part's lowest-sequence active template
2. This customer's templates (no part)
3. This coating's templates (no part)
4. Don't auto-pick — user has to choose
3. Don't auto-pick — user has to choose
"""
if self.description_template_id or self.line_description:
return
@@ -612,16 +600,6 @@ class FpDirectOrderLine(models.Model):
_apply(match)
return
if self.coating_config_id:
match = Template.search([
('active', '=', True),
('part_catalog_id', '=', False),
('partner_id', '=', False),
('coating_config_id', '=', self.coating_config_id.id),
], order='sequence', limit=1)
if match:
_apply(match)
# ---- Helpers ----
@api.model
def _create_from_quote(self, quote, wizard):
@@ -631,16 +609,17 @@ class FpDirectOrderLine(models.Model):
the bulk "Add From Quotes" sub-wizard — keeps the field mapping
in one place so the two flows can never drift.
"""
if not quote.part_catalog_id or not quote.coating_config_id:
if not quote.part_catalog_id:
raise UserError(_(
'Quote %s has no part or coating set; cannot seed a line.'
'Quote %s has no part set; cannot seed a line.'
) % (quote.name or quote.id))
final = quote.estimator_override_price or quote.calculated_price
unit = (final / quote.quantity) if (final and quote.quantity) else 0.0
# Spec carry-over from quote → wizard line is handled by an
# inherit in fusion_plating_quality (customer_spec_id field).
return self.create({
'wizard_id': wizard.id,
'part_catalog_id': quote.part_catalog_id.id,
'coating_config_id': quote.coating_config_id.id,
'quantity': int(quote.quantity) or 1,
'unit_price': unit,
'quote_id': quote.id,

View File

@@ -550,12 +550,13 @@ class FpDirectOrderWizard(models.Model):
for line in self.line_ids:
part = line._get_or_bump_revision()
resolved_parts[line.id] = part
# Build the line header. Primary treatment is optional now;
# when missing, drop it from the header rather than printing
# Build the line header. Specification is optional; when
# missing, drop it from the header rather than printing
# "False - PartName Rev A".
treatment_label = line.coating_config_id.name or _('No coating')
spec = getattr(line, 'customer_spec_id', False)
spec_label = (spec.display_name if spec else '') or _('No spec')
header = '%s - %s Rev %s (x%d)' % (
treatment_label,
spec_label,
part.name,
part.revision,
line.quantity,
@@ -573,8 +574,9 @@ class FpDirectOrderWizard(models.Model):
'x_fc_part_catalog_id': part.id,
'x_fc_description_template_id': line.description_template_id.id or False,
'x_fc_internal_description': line.internal_description or False,
'x_fc_coating_config_id': line.coating_config_id.id,
'x_fc_treatment_ids': [(6, 0, line.treatment_ids.ids)],
# x_fc_customer_spec_id is set on the resulting SO line
# by an extension in fusion_plating_quality (post-create
# patch — see fp_direct_order_line_inherit.py).
'x_fc_part_deadline': line.part_deadline,
'x_fc_part_deadline_offset_days': line.part_deadline_offset_days,
'x_fc_rush_order': line.rush_order,
@@ -593,7 +595,7 @@ class FpDirectOrderWizard(models.Model):
if line.serial_ids else False),
'x_fc_serial_id': line.serial_id.id or False,
'x_fc_job_number': line.job_number or False,
'x_fc_thickness_id': line.thickness_id.id or False,
'x_fc_thickness_range': line.thickness_range or False,
# Sub 9 — explicit tax override from the wizard line.
# When blank, Odoo will compute taxes from the product
# defaults at SO-line save time (the standard behaviour).
@@ -628,19 +630,18 @@ class FpDirectOrderWizard(models.Model):
'Quote won — promoted onto Direct Order %(doo)s, SO %(so)s.'
) % {'doo': self.name, 'so': so.name})
# 6. Push-to-defaults (C4) — uses the resolved part cached
# during the build loop so rev-bumped lines write defaults to
# the NEW revision, not the pre-bump one.
# 6. Push-to-defaults — Specification carry-over to the part's
# x_fc_default_customer_spec_id is handled by an inherit in
# fusion_plating_quality (the field lives there).
# Thickness range: lives in configurator, push here.
for line in self.line_ids:
if not line.push_to_defaults or line.is_one_off:
continue
part = resolved_parts.get(line.id) or line.part_catalog_id
if not part:
continue
part.write({
'x_fc_default_coating_config_id': line.coating_config_id.id or False,
'x_fc_default_treatment_ids': [(6, 0, line.treatment_ids.ids)],
})
if line.thickness_range and not part.x_fc_default_thickness_range:
part.x_fc_default_thickness_range = line.thickness_range
so.message_post(body=_(
'Quotation created from PO %s with %d line(s). '
'Review and confirm manually when ready.'

View File

@@ -154,8 +154,6 @@
optional="hide"/>
<field name="internal_description"
optional="hide"/>
<field name="coating_config_id"
optional="show"/>
<field name="process_variant_id"
string="Process / Recipe"
options="{'no_quick_create': True}"
@@ -174,11 +172,8 @@
string="Process Source"
readonly="1"
optional="hide"/>
<field name="thickness_id"
options="{'no_quick_create': True}"
context="{'default_coating_config_id': coating_config_id}"
domain="[('coating_config_id', '=', coating_config_id)]"
invisible="not coating_config_id"
<field name="thickness_range"
placeholder="e.g. 0.0005-0.0008 mils"
optional="show"/>
<field name="serial_ids"
widget="many2many_tags"
@@ -194,9 +189,6 @@
class="btn-link"
invisible="not part_catalog_id or serial_count &gt; 0"/>
<field name="job_number" optional="hide"/>
<field name="treatment_ids"
widget="many2many_tags"
optional="hide"/>
<field name="quantity"
optional="show"/>
<field name="unit_price"
@@ -239,9 +231,6 @@
invisible="not part_catalog_id"/>
<field name="part_revision"
invisible="not part_catalog_id"/>
<field name="coating_config_id"/>
<field name="treatment_ids"
widget="many2many_tags"/>
<field name="process_variant_id"
string="Process / Recipe"
options="{'no_quick_create': True}"

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.8.27.0',
'version': '19.0.10.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
@@ -39,7 +39,7 @@ full design rationale and §6.2 of the implementation plan for task list.
'fusion_plating', # fp.job, fp.job.step, fp.work.centre
'fusion_plating_batch', # fusion.plating.batch (Phase 3)
'fusion_plating_certificates', # fp.certificate, fp.thickness.reading
'fusion_plating_configurator', # fp.part.catalog, fp.coating.config
'fusion_plating_configurator', # fp.part.catalog
'fusion_plating_kpi', # fusion.plating.kpi.value (Phase 4)
'fusion_plating_logistics', # fusion.plating.delivery
'fusion_plating_notifications', # fp.notification.template (Phase 4)

View File

@@ -48,15 +48,12 @@ class FpJob(models.Model):
string='Part',
ondelete='restrict',
)
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Coating Configuration',
ondelete='restrict',
)
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Customer Spec',
string='Specification',
ondelete='set null',
help='Customer / industry spec the job ships under. Auto-filled '
'from the SO line at job creation.',
)
portal_job_id = fields.Many2one(
'fusion.plating.portal.job',
@@ -996,29 +993,28 @@ class FpJob(models.Model):
if node.estimated_duration:
vals['dwell_time_minutes'] = node.estimated_duration
# Pull thickness target from the coating config when
# this is a plating step (matched by node name keyword).
coating = job.coating_config_id
# Pull thickness target from the recipe root when this
# is a plating step (matched by node name keyword).
# Recipe-root carries thickness fields post-promote-spec.
recipe_root = job.recipe_id
name_l = (node.name or '').lower()
is_plating_node = (
'plat' in name_l or 'nickel' in name_l
or 'chrome' in name_l or 'anodiz' in name_l
)
if coating and is_plating_node:
if recipe_root and is_plating_node:
if (
'thickness_max' in coating._fields
and coating.thickness_max
'thickness_max' in recipe_root._fields
and recipe_root.thickness_max
):
vals['thickness_target'] = coating.thickness_max
vals['thickness_target'] = recipe_root.thickness_max
if (
'thickness_uom' in coating._fields
and coating.thickness_uom
'thickness_uom' in recipe_root._fields
and recipe_root.thickness_uom
):
# fp.coating.config uses long-form uom names
# (mils / microns / inches); fp.job.step uses
# short codes (mil / um / inch). Map between
# them. Unknown values fall through to the
# step's default ('um').
# Recipe uses long-form uom names (mils /
# microns / inches); fp.job.step uses short
# codes (mil / um / inch). Map between them.
_UOM_MAP = {
'mils': 'mil',
'mil': 'mil',
@@ -1029,7 +1025,7 @@ class FpJob(models.Model):
'inch': 'inch',
'in': 'inch',
}
mapped = _UOM_MAP.get(coating.thickness_uom)
mapped = _UOM_MAP.get(recipe_root.thickness_uom)
if mapped:
vals['thickness_uom'] = mapped
@@ -1546,7 +1542,9 @@ class FpJob(models.Model):
if not required:
return
has_job_link = 'x_fc_job_id' in Cert._fields
coating = self.coating_config_id
# 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
for cert_type in sorted(required):
# Idempotency per type.
existing_dom = [('certificate_type', '=', cert_type)]
@@ -1574,9 +1572,16 @@ class FpJob(models.Model):
if 'sale_order_id' in Cert._fields and self.sale_order_id:
vals['sale_order_id'] = self.sale_order_id.id
# spec_reference is what action_issue blocks on.
if coating and 'spec_reference' in Cert._fields \
and getattr(coating, 'spec_reference', False):
vals['spec_reference'] = coating.spec_reference
# Format spec.code + revision for the cert text.
if spec and 'spec_reference' in Cert._fields:
ref = spec.code or ''
if spec.revision:
ref = (f'{ref} Rev {spec.revision}'
if ref else f'Rev {spec.revision}')
if ref:
vals['spec_reference'] = ref
if 'customer_spec_id' in Cert._fields:
vals['customer_spec_id'] = spec.id
if 'part_number' in Cert._fields and self.part_catalog_id:
vals['part_number'] = (
self.part_catalog_id.part_number or ''

View File

@@ -474,8 +474,9 @@ class FpJobStep(models.Model):
def button_finish(self):
"""Override to:
1) Auto-spawn a bake.window when a wet plating step finishes
on a coating that requires hydrogen-embrittlement relief
(AS9100 / Nadcap compliance);
on a recipe that requires hydrogen-embrittlement relief
(AS9100 / Nadcap compliance). Bake fields live on the
recipe root post-promote-customer-spec.
2) Post a chatter warning when duration_actual exceeds 1.5×
duration_expected — silent overruns are a red flag for
scheduling and costing.
@@ -499,12 +500,11 @@ class FpJobStep(models.Model):
'estimate too tight.'
)) % (step.name, ratio, step.duration_expected,
step.duration_actual))
coating = step.job_id.coating_config_id \
if 'coating_config_id' in step.job_id._fields else False
if not coating:
recipe_root = step.job_id.recipe_id
if not recipe_root:
continue
requires = getattr(coating, 'requires_bake_relief', False)
window_hrs = getattr(coating, 'bake_window_hours', 0.0)
requires = getattr(recipe_root, 'requires_bake_relief', False)
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
if not requires or not window_hrs:
continue
# Trigger only on the actual plating-out step. We want

View File

@@ -339,11 +339,8 @@ class SaleOrder(models.Model):
1. line.x_fc_process_variant_id — Sarah explicitly picked a
part-scoped variant on this order line. Always wins.
2. part.default_process_id — part's flagged default
variant. Customer-and-part-tuned recipe; must beat any
generic coating template.
3. coating.recipe_id — coating-config recipe
(generic template fallback).
4. part.recipe_id — legacy fallback.
variant. Customer-and-part-tuned recipe.
3. part.recipe_id — legacy fallback.
Returns the recipe record or an empty recordset.
"""
Node = self.env['fusion.plating.process.node']
@@ -352,11 +349,6 @@ class SaleOrder(models.Model):
) or False
if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False
coating = (
'x_fc_coating_config_id' in line._fields and line.x_fc_coating_config_id
) or False
if not coating and 'x_fc_coating_config_id' in self._fields:
coating = self.x_fc_coating_config_id or False
picked = (
'x_fc_process_variant_id' in line._fields
and line.x_fc_process_variant_id
@@ -365,8 +357,6 @@ class SaleOrder(models.Model):
return picked
if part and 'default_process_id' in part._fields and part.default_process_id:
return part.default_process_id
if coating and 'recipe_id' in coating._fields and coating.recipe_id:
return coating.recipe_id
if part and 'recipe_id' in part._fields and part.recipe_id:
return part.recipe_id
return Node
@@ -389,22 +379,22 @@ class SaleOrder(models.Model):
if existing:
return
# Find plating lines (those with a part_catalog_id or coating_config_id)
# Find plating lines (those with a part_catalog_id or
# customer_spec_id).
plating_lines = self.order_line.filtered(
lambda l: (
('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)
or ('x_fc_coating_config_id' in l._fields and l.x_fc_coating_config_id)
or ('x_fc_customer_spec_id' in l._fields and l.x_fc_customer_spec_id)
)
)
# Fallback: legacy/configurator SOs that carry part+coating on the
# header but not on the line. Treat the entire order as one
# plating line so the planner gets an fp.job to work against.
# Fallback: SOs that carry part on the header but not on the
# line. Treat the entire order as one plating job so the planner
# gets an fp.job to work against.
if not plating_lines and self.order_line and (
('x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id)
or ('x_fc_coating_config_id' in self._fields and self.x_fc_coating_config_id)
'x_fc_part_catalog_id' in self._fields and self.x_fc_part_catalog_id
):
_logger.info(
'SO %s: no line-level part/coating but header carries one — '
'SO %s: no line-level part but header carries one — '
'treating all lines as a single plating job.', self.name,
)
plating_lines = self.order_line
@@ -412,13 +402,12 @@ class SaleOrder(models.Model):
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return
# Group by (recipe, part, coating, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Same compliance reasoning
# as part_id + coating_id: bundling lines with different thicknesses
# or different serials under one WO would carry the first line's
# values onto the cert + sticker — silent mis-attestation. Sub 5
# added thickness_id + serial_id; this extends the grouping logic
# to honour them. No-recipe lines still get their own group each.
# Group by (recipe, part, spec, thickness, serial). Lines that
# share ALL FIVE collapse into one WO. Bundling lines with
# different specs / thicknesses / serials under one WO would
# carry the first line's values onto the cert + sticker —
# silent mis-attestation. No-recipe lines still get their own
# group each.
groups = {}
unrecipe_idx = 0
for line in plating_lines:
@@ -427,20 +416,20 @@ class SaleOrder(models.Model):
'x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id.id
) or False
coating_id = (
'x_fc_coating_config_id' in line._fields
and line.x_fc_coating_config_id.id
spec_id = (
'x_fc_customer_spec_id' in line._fields
and line.x_fc_customer_spec_id.id
) or False
thickness_id = (
'x_fc_thickness_id' in line._fields
and line.x_fc_thickness_id.id
thickness_key = (
'x_fc_thickness_range' in line._fields
and (line.x_fc_thickness_range or '').strip()
) or False
serial_id = (
'x_fc_serial_id' in line._fields
and line.x_fc_serial_id.id
) or False
if recipe:
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
key = (recipe.id, part_id, spec_id, thickness_key, serial_id)
else:
unrecipe_idx += 1
key = ('no_recipe', unrecipe_idx)
@@ -465,15 +454,13 @@ class SaleOrder(models.Model):
and first_line.x_fc_part_catalog_id
or False
)
coating = (
'x_fc_coating_config_id' in first_line._fields
and first_line.x_fc_coating_config_id
customer_spec = (
'x_fc_customer_spec_id' in first_line._fields
and first_line.x_fc_customer_spec_id
or False
)
if not part and 'x_fc_part_catalog_id' in self._fields:
part = self.x_fc_part_catalog_id or False
if not coating and 'x_fc_coating_config_id' in self._fields:
coating = self.x_fc_coating_config_id or False
recipe = self._fp_resolve_recipe_for_line(first_line)
vals = {
@@ -487,8 +474,8 @@ class SaleOrder(models.Model):
}
if part:
vals['part_catalog_id'] = part.id
if coating:
vals['coating_config_id'] = coating.id
if customer_spec:
vals['customer_spec_id'] = customer_spec.id
if recipe:
vals['recipe_id'] = recipe.id

View File

@@ -56,7 +56,7 @@
<t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
<t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/>
<t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/>
<t t-set="_qty" t-value="job.qty"/>
@@ -98,7 +98,7 @@
<t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
<t t-set="_spec" t-value="('customer_spec_id' in job._fields and job.customer_spec_id) or False"/>
<t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/>
<t t-set="_qty" t-value="job.qty"/>

View File

@@ -200,8 +200,8 @@
<t t-else=""></t>
</td>
<td>
<t t-if="'coating_config_id' in job._fields and job.coating_config_id">
<span t-esc="job.coating_config_id.name"/>
<t t-if="'customer_spec_id' in job._fields and job.customer_spec_id">
<span t-esc="job.customer_spec_id.display_name"/>
</t>
</td>
</tr>

View File

@@ -95,7 +95,7 @@
</xpath>
<xpath expr="//field[@name='product_id']" position="after">
<field name="part_catalog_id" string="Part"/>
<field name="coating_config_id" string="Coating"/>
<field name="customer_spec_id" string="Specification"/>
<field name="recipe_id" string="Process Recipe"/>
</xpath>
<!-- Show qty completed alongside total so the partial-qty

View File

@@ -133,7 +133,7 @@
<!-- Workflow milestones live under "Recipes & Steps" because each
state is triggered by a recipe-step kind / per-step override. -->
<menuitem id="menu_fp_workflow_state"
name="Workflow States"
name="Job Workflow Stages"
parent="fusion_plating.menu_fp_config_recipes_steps"
action="action_fp_workflow_state"
sequence="50"/>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Logistics',
'version': '19.0.3.6.0',
'version': '19.0.3.8.0',
'category': 'Manufacturing/Plating',
'summary': (
'Pickup & delivery for plating shops: vehicle master, driver '

View File

@@ -67,9 +67,9 @@ class FpDelivery(models.Model):
string='Job #', index=True,
help='Shop-floor job number from the MO. Prints on packing slip.',
)
x_fc_thickness_id = fields.Many2one(
'fp.coating.thickness', string='Thickness',
ondelete='set null',
x_fc_thickness_range = fields.Char(
string='Thickness',
help='Carried from the SO line — prints on packing slip / BoL.',
)
x_fc_revision_snapshot = fields.Char(
string='Revision (snapshot)',

View File

@@ -5,3 +5,4 @@
from . import models
from . import controllers
from . import wizards

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Quality (QMS)',
'version': '19.0.4.14.0',
'version': '19.0.6.2.0',
'category': 'Manufacturing/Plating',
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
'internal audits, customer specs, document control. CE + EE compatible.',
@@ -90,6 +90,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_calibration_views.xml',
'views/fp_avl_views.xml',
'views/fp_customer_spec_views.xml',
'views/fp_process_node_inherit_views.xml',
'views/sale_order_views_inherit.xml',
'views/fp_part_catalog_views_inherit.xml',
'views/fp_direct_order_wizard_views_inherit.xml',
'views/fp_pricing_rule_views_inherit.xml',
'views/fp_audit_views.xml',
'views/fp_fair_views.xml',
'views/fp_doc_control_views.xml',

View File

@@ -9,6 +9,13 @@ from . import fp_calibration
from . import fp_calibration_event
from . import fp_avl
from . import fp_customer_spec
from . import fp_process_node_inherit
from . import sale_order_line_inherit
from . import account_move_line_inherit
from . import fp_direct_order_line_inherit
from . import fp_pricing_rule_inherit
from . import fp_quote_configurator_inherit
from . import fp_certificate_inherit
from . import fp_audit
from . import fp_fair
from . import fp_doc_control

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class AccountMoveLine(models.Model):
"""Add the Specification reference to the invoice line.
Lives here (not in configurator) because fusion.plating.customer.spec
lives in fusion_plating_quality and configurator can't reference it
without a circular dep.
"""
_inherit = 'account.move.line'
x_fc_customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Specification',
help='Carried from the SO line so the invoice PDF can render the '
'spec reference next to the part number.',
)

View File

@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpCertificate(models.Model):
"""Add Specification linkage + auto-fill spec_reference from it.
Lives in fusion_plating_quality because customer.spec lives here.
Quality already depends on certificates, so the inverse direction
works.
"""
_inherit = 'fp.certificate'
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Specification',
help='Snapshot of the specification the cert was issued against. '
'Drives the spec_reference printed on the CoC.',
)
@api.model_create_multi
def create(self, vals_list):
"""Auto-fill spec_reference from the SO line's customer_spec_id.
Resolution order (first match wins):
1. Explicit spec_reference passed in vals.
2. customer_spec_id (this field) → format "code Rev rev".
3. SO line x_fc_customer_spec_id (with print_on_cert=True).
4. Existing legacy fall-back lives in the parent module
(reads x_fc_coating_config_id.spec_reference). Untouched.
"""
SaleOrder = self.env['sale.order']
for vals in vals_list:
if vals.get('spec_reference'):
continue
spec = False
# 2. Explicit spec on the cert.
if vals.get('customer_spec_id'):
spec = self.env['fusion.plating.customer.spec'].browse(
vals['customer_spec_id'],
).exists()
# 3. SO line's spec.
if not spec and vals.get('sale_order_id'):
so = SaleOrder.browse(vals['sale_order_id'])
if 'x_fc_customer_spec_id' in so.order_line._fields:
spec = so.order_line.mapped(
'x_fc_customer_spec_id',
).filtered('print_on_cert')[:1]
if spec and not vals.get('customer_spec_id'):
vals['customer_spec_id'] = spec.id
if spec:
ref = spec.code or ''
if spec.revision:
ref = f'{ref} Rev {spec.revision}' if ref else f'Rev {spec.revision}'
if ref:
vals['spec_reference'] = ref
return super().create(vals_list)

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
from odoo import api, fields, models
class FpCustomerSpec(models.Model):
@@ -74,6 +74,22 @@ class FpCustomerSpec(models.Model):
notes = fields.Html(
string='Notes',
)
recipe_ids = fields.Many2many(
'fusion.plating.process.node',
'fp_customer_spec_recipe_rel',
'spec_id', 'recipe_id',
domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
string='Applicable Recipes',
help='Recipes that can produce work to this specification. '
'Many-to-many — one spec can cover multiple processes; '
'one recipe can satisfy multiple specs.',
)
print_on_cert = fields.Boolean(
string='Print on Certificate',
default=True,
help="When enabled, this spec's code+revision appear on the CoC "
'when the spec is selected on the SO line.',
)
company_id = fields.Many2one(
'res.company',
string='Company',
@@ -89,6 +105,7 @@ class FpCustomerSpec(models.Model):
),
]
@api.depends('code', 'revision', 'name')
def _compute_display_name(self):
for rec in self:
parts = [rec.code or '']

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpDirectOrderLine(models.Model):
"""Add the Specification picker to the direct-order wizard line.
Lives in fusion_plating_quality because fusion.plating.customer.spec
lives here.
"""
_inherit = 'fp.direct.order.line'
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Specification',
help='Customer / industry specification the work ships to. '
'Carried onto the SO line at order creation.',
)
@api.onchange('part_catalog_id')
def _onchange_part_default_spec(self):
"""Pre-fill the line's specification from the part's default."""
for rec in self:
if (rec.part_catalog_id
and rec.part_catalog_id.x_fc_default_customer_spec_id
and not rec.customer_spec_id):
rec.customer_spec_id = (
rec.part_catalog_id.x_fc_default_customer_spec_id
)
class FpDirectOrderWizard(models.Model):
_inherit = 'fp.direct.order.wizard'
def action_create_order(self):
"""Carry customer_spec_id from each wizard line to its SO line.
The base method (in configurator) builds the SO with all the
coating/treatment/process fields. We can't insert spec into the
vals dict from here without a circular dep, so post-create we
pair wizard lines to SO lines by sequence and patch.
"""
result = super().action_create_order()
if self.sale_order_id:
wiz_lines = self.line_ids.sorted(
key=lambda r: (r.sequence, r.id)
)
so_lines = self.sale_order_id.order_line.sorted(
key=lambda r: (r.sequence, r.id)
)
for wiz_line, so_line in zip(wiz_lines, so_lines):
if wiz_line.customer_spec_id and not so_line.x_fc_customer_spec_id:
so_line.x_fc_customer_spec_id = wiz_line.customer_spec_id.id
return result

View File

@@ -14,6 +14,12 @@ _logger = logging.getLogger(__name__)
class FpPartCatalog(models.Model):
_inherit = 'fp.part.catalog'
x_fc_default_customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Default Specification',
help='Default specification applied when this part is dropped on '
'a direct order line. Operator can override per order.',
)
x_fc_contract_review_id = fields.Many2one(
'fp.contract.review',
string='Contract Review',

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpPricingRule(models.Model):
"""Add Specification + Recipe match keys to the pricing rule.
Lives in fusion_plating_quality because fusion.plating.customer.spec
lives here. Rules can now match on:
- customer_spec_id (most specific — e.g. "AMS 2404 surcharge")
- recipe_id (recipe-tier — e.g. "EN Mid-Phos $X/sqft")
- both blank (fallback — material/cert-level matching)
The configurator's matcher is extended in fp_quote_configurator_inherit.
"""
_inherit = 'fp.pricing.rule'
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Specification',
help='Match rule against the SO line specification. Combine with '
'recipe_id for spec+recipe specific pricing, or leave recipe '
'blank for spec-tier pricing.',
)
recipe_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe',
domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
help='Match rule against the SO line recipe. Combine with '
'customer_spec_id for spec+recipe specific pricing, or '
'leave spec blank for recipe-tier pricing.',
)

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FusionPlatingProcessNode(models.Model):
"""Add the reverse M2M from recipe → applicable specifications.
The forward M2M lives on fusion.plating.customer.spec.recipe_ids.
Defined here (in the quality module) because customer.spec is owned
by quality and core can't reference it without a circular dep.
"""
_inherit = 'fusion.plating.process.node'
applicable_spec_ids = fields.Many2many(
'fusion.plating.customer.spec',
'fp_customer_spec_recipe_rel',
'recipe_id', 'spec_id',
string='Applicable Specifications',
help='Customer / industry specifications this recipe is qualified '
'to satisfy. Set on the spec record; mirrored here for '
'navigation.',
)

View File

@@ -63,9 +63,22 @@ class FpQualityPoint(models.Model):
'fp.part.catalog', 'fp_quality_point_part_rel',
'point_id', 'part_id', string='Parts',
)
coating_config_ids = fields.Many2many(
'fp.coating.config', 'fp_quality_point_coating_rel',
'point_id', 'coating_id', string='Coatings',
customer_spec_ids = fields.Many2many(
'fusion.plating.customer.spec',
'fp_quality_point_spec_rel',
'point_id', 'spec_id',
string='Specifications',
help='If set, this trigger only fires for SOs / jobs whose '
'specification is in this list. Leave blank to ignore spec.',
)
recipe_ids = fields.Many2many(
'fusion.plating.process.node',
'fp_quality_point_recipe_rel',
'point_id', 'recipe_id',
domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
string='Recipes',
help='If set, this trigger only fires for jobs running one of '
'these recipes. Leave blank to ignore recipe.',
)
step_kind = fields.Selection(STEP_KINDS, string='Step Kind')
@@ -102,7 +115,8 @@ class FpQualityPoint(models.Model):
# ------------------------------------------------------------------
# Matching + spawning
# ------------------------------------------------------------------
def _matches(self, partner=None, part=None, coating=None, step=None):
def _matches(self, partner=None, part=None, step=None,
customer_spec=None, recipe=None):
"""Return True if this point's filters all pass against the supplied
context. Empty filter == match anything.
"""
@@ -112,8 +126,12 @@ class FpQualityPoint(models.Model):
if self.part_catalog_ids and (
not part or part not in self.part_catalog_ids):
return False
if self.coating_config_ids and (
not coating or coating not in self.coating_config_ids):
if self.customer_spec_ids and (
not customer_spec
or customer_spec not in self.customer_spec_ids):
return False
if self.recipe_ids and (
not recipe or recipe not in self.recipe_ids):
return False
if self.step_kind and step and getattr(step, 'kind', None) \
and step.kind != self.step_kind:
@@ -121,15 +139,16 @@ class FpQualityPoint(models.Model):
return True
@api.model
def _find_matching(self, trigger, partner=None, part=None, coating=None,
step=None):
def _find_matching(self, trigger, partner=None, part=None,
step=None, customer_spec=None, recipe=None):
"""Return active points whose trigger + filters match the context."""
candidates = self.search([
('active', '=', True),
('trigger_type', '=', trigger),
])
return candidates.filtered(lambda p: p._matches(
partner=partner, part=part, coating=coating, step=step,
partner=partner, part=part, step=step,
customer_spec=customer_spec, recipe=recipe,
))
def _spawn_check_for(self, source, partner=None, job=None, step=None):

View File

@@ -49,21 +49,21 @@ class SaleOrderPointHook(models.Model):
Point = self.env['fp.quality.point']
for so in self:
partner = so.partner_id
# Walk lines for part / coating context.
# Walk lines for part / coating / spec context.
parts = so.order_line.mapped('x_fc_part_catalog_id') \
if 'x_fc_part_catalog_id' in so.order_line._fields else False
coatings = so.order_line.mapped('x_fc_coating_config_id') \
if 'x_fc_coating_config_id' in so.order_line._fields else False
specs = so.order_line.mapped('x_fc_customer_spec_id') \
if 'x_fc_customer_spec_id' in so.order_line._fields else False
points = Point._find_matching(
trigger='so_confirmed', partner=partner,
)
for point in points:
# Filter by part / coating intersection if the point cares.
# Filter by part / spec intersection if the point cares.
if point.part_catalog_ids and parts and \
not (point.part_catalog_ids & parts):
continue
if point.coating_config_ids and coatings and \
not (point.coating_config_ids & coatings):
if point.customer_spec_ids and specs and \
not (point.customer_spec_ids & specs):
continue
point._spawn_check_for(source=so, partner=partner)
return result
@@ -79,10 +79,13 @@ class FpJobPointHook(models.Model):
for job in self:
partner = job.partner_id
part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
customer_spec = getattr(job, 'customer_spec_id', False) or False
recipe = getattr(job, 'recipe_id', False) or False
points = Point._find_matching(
trigger='job_confirmed', partner=partner,
part=part or None, coating=coating or None,
part=part or None,
customer_spec=customer_spec or None,
recipe=recipe or None,
)
for point in points:
point._spawn_check_for(
@@ -98,10 +101,13 @@ class FpJobPointHook(models.Model):
continue
partner = job.partner_id
part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
customer_spec = getattr(job, 'customer_spec_id', False) or False
recipe = getattr(job, 'recipe_id', False) or False
points = Point._find_matching(
trigger='job_done', partner=partner,
part=part or None, coating=coating or None,
part=part or None,
customer_spec=customer_spec or None,
recipe=recipe or None,
)
for point in points:
point._spawn_check_for(
@@ -123,10 +129,13 @@ class FpJobStepPointHook(models.Model):
job = step.job_id
partner = job.partner_id if job else False
part = getattr(job, 'part_catalog_id', False) or False
coating = getattr(job, 'coating_config_id', False) or False
customer_spec = getattr(job, 'customer_spec_id', False) or False
recipe = getattr(job, 'recipe_id', False) or False
points = Point._find_matching(
trigger='job_step_done', partner=partner,
part=part or None, coating=coating or None, step=step,
part=part or None, step=step,
customer_spec=customer_spec or None,
recipe=recipe or None,
)
for point in points:
point._spawn_check_for(

View File

@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpQuoteConfigurator(models.Model):
"""Add Specification field + extend the pricing rule matcher.
Lives in fusion_plating_quality because customer.spec lives here.
"""
_inherit = 'fp.quote.configurator'
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Specification',
help='Customer / industry spec the quote is built against. '
'Drives pricing rule lookup and certificate auto-fill.',
)
def _find_matching_rule(self):
"""Extend the configurator's matcher to consider Spec + Recipe.
Spec match adds +8 (highest priority — explicit customer spec
wins over chemistry filters). Recipe adds +6. Material is +2.
"""
recipe = self.recipe_id or False
builder_rules = (
recipe.pricing_rule_ids
if recipe else self.env['fp.pricing.rule']
)
if builder_rules:
rules = builder_rules.filtered('active').sorted(
lambda r: (r.sequence, r.id)
)
else:
rules = self.env['fp.pricing.rule'].search(
[('active', '=', True)], order='sequence, id'
)
best = None
best_score = -1
for rule in rules:
score = 0
# Spec wins biggest
if rule.customer_spec_id:
if rule.customer_spec_id != self.customer_spec_id:
continue
score += 8
# Recipe is next
if rule.recipe_id:
if rule.recipe_id != recipe:
continue
score += 6
if rule.substrate_material:
if rule.substrate_material != self.substrate_material:
continue
score += 2
if score > best_score:
best_score = score
best = rule
return best

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class SaleOrder(models.Model):
"""Add an order-level Specification mirror so reports can print it
in the header summary section. Computed from the lines (first
spec wins; falls back to blank when lines have no spec).
"""
_inherit = 'sale.order'
x_fc_customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Specification',
compute='_compute_x_fc_customer_spec_id',
store=True,
help='First specification cited on this order (or blank). '
'Drives the order-level header in customer-facing PDFs.',
)
@api.depends('order_line.x_fc_customer_spec_id')
def _compute_x_fc_customer_spec_id(self):
for so in self:
specs = so.order_line.mapped('x_fc_customer_spec_id')
so.x_fc_customer_spec_id = specs[:1] if specs else False
class SaleOrderLine(models.Model):
"""Add the Specification picker to the SO line.
Lives in fusion_plating_quality because fusion.plating.customer.spec
lives here. Configurator can't reference it directly without a
circular dep (quality depends on configurator).
"""
_inherit = 'sale.order.line'
x_fc_customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Specification',
help='Customer / industry specification the work is being shipped '
'to (e.g. AMS 2404 Rev D, BAC 5680 Rev E). Drives certificate '
'auto-fill and FAI / Nadcap routing.',
)
@api.onchange('x_fc_part_catalog_id')
def _onchange_part_default_spec(self):
"""Pre-fill the line's specification from the part's default."""
for line in self:
if (line.x_fc_part_catalog_id
and line.x_fc_part_catalog_id.x_fc_default_customer_spec_id
and not line.x_fc_customer_spec_id):
line.x_fc_customer_spec_id = (
line.x_fc_part_catalog_id.x_fc_default_customer_spec_id
)
@api.model_create_multi
def create(self, vals_list):
"""Fall back to the part's default spec when none is supplied.
Catches programmatic creation paths (wizard, import, sale_mrp
bridge) where the onchange doesn't fire. Explicit spec wins;
only fills when blank AND the part has a default.
"""
Part = self.env['fp.part.catalog']
for vals in vals_list:
if (not vals.get('x_fc_customer_spec_id')
and vals.get('x_fc_part_catalog_id')):
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
if part and part.x_fc_default_customer_spec_id:
vals['x_fc_customer_spec_id'] = (
part.x_fc_default_customer_spec_id.id
)
return super().create(vals_list)
def _prepare_invoice_line(self, **optional_values):
"""Carry x_fc_customer_spec_id to the invoice line."""
vals = super()._prepare_invoice_line(**optional_values)
if self.x_fc_customer_spec_id:
vals['x_fc_customer_spec_id'] = self.x_fc_customer_spec_id.id
return vals

View File

@@ -50,6 +50,13 @@
<group string="Applicable Processes" name="applicable_processes">
<field name="process_type_ids" widget="many2many_tags" nolabel="1"/>
</group>
<group string="Applicable Recipes" name="applicable_recipes">
<field name="recipe_ids" widget="many2many_tags" nolabel="1"
options="{'no_create_edit': True}"/>
</group>
<group>
<field name="print_on_cert"/>
</group>
<notebook>
<page string="Notes">
<field name="notes"/>
@@ -84,10 +91,35 @@
</record>
<record id="action_fp_customer_spec" model="ir.actions.act_window">
<field name="name">Customer Specifications</field>
<field name="name">Specifications</field>
<field name="res_model">fusion.plating.customer.spec</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_customer_spec_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Add your first specification
</p>
<p>
The Specifications library holds every standard your
customers cite on POs — industry standards (AMS 2404,
ASTM B733, MIL-C-26074), prime-specific codes (Boeing
BAC 5680, Lockheed LMS-3045), and your own internal
references.
</p>
<p>
When an estimator picks a Specification on a sale order
line, the certificate auto-fills with the spec's code
and revision (e.g. "AMS 2404 Rev D"). Aerospace flags
(Nadcap required, FAI required, AS9100 clauses) drive
workflow gates downstream.
</p>
<p>
Add a new Specification the moment a new code shows up
on a customer PO — there's no need to wait for a
manager. Set the document URL so the controlled copy
is one click away during audits.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,37 @@
<?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.
Adds the Specification picker to the direct-order wizard line.
-->
<odoo>
<record id="view_fp_direct_order_wizard_form_spec_inherit"
model="ir.ui.view">
<field name="name">fp.direct.order.wizard.form.spec.inherit</field>
<field name="model">fp.direct.order.wizard</field>
<field name="inherit_id"
ref="fusion_plating_configurator.view_fp_direct_order_wizard_form"/>
<field name="arch" type="xml">
<!-- Wizard line list (main editable rows). Anchor on
internal_description (stable, configurator-defined). -->
<xpath expr="//field[@name='line_ids']/list/field[@name='internal_description']"
position="after">
<field name="customer_spec_id"
string="Specification"
options="{'no_quick_create': True}"
optional="show"/>
</xpath>
<!-- Wizard line drawer / form view -->
<xpath expr="//field[@name='line_ids']/form//field[@name='process_variant_id']"
position="before">
<field name="customer_spec_id"
string="Specification"
options="{'no_quick_create': True}"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -68,11 +68,18 @@
action="action_fp_cal_event"
sequence="60"/>
<!-- Promote-Customer-Spec (Phase F) — Specifications is now central
to order entry (estimators add new BAC / AMS / customer codes
when they hit them). Moved out of Configuration (manager-only)
into Quality where the workflow lives. Single menu, accessible
to estimator/supervisor/manager via the Quality top-level.
Old xmlid kept so links / breadcrumbs / search references
continue to resolve. -->
<menuitem id="menu_fp_config_customer_spec"
name="Customer Specs"
parent="fusion_plating.menu_fp_config_quality_docs"
name="Specifications"
parent="menu_fp_quality"
action="action_fp_customer_spec"
sequence="10"/>
sequence="70"/>
<menuitem id="menu_fp_config_avl"
name="Approved Vendor List"

View File

@@ -0,0 +1,30 @@
<?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.
Adds the "Default Specification" picker to the part catalog form
next to "Default Treatment". Phase E removes the legacy field
entirely.
-->
<odoo>
<record id="view_fp_part_catalog_form_spec_inherit" model="ir.ui.view">
<field name="name">fp.part.catalog.form.spec.inherit</field>
<field name="model">fp.part.catalog</field>
<field name="inherit_id"
ref="fusion_plating_configurator.view_fp_part_catalog_form"/>
<field name="arch" type="xml">
<!-- Anchor on default_process_id (stable, in core).
Default Treatment block was removed in Phase E. -->
<xpath expr="//field[@name='default_process_id']"
position="after">
<field name="x_fc_default_customer_spec_id"
string="Default Specification"
options="{'no_create_edit': True}"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
<?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.
Adds Specification + Recipe pickers to the pricing rule form.
Both fields live on this module's inherit (the customer.spec model
lives here).
-->
<odoo>
<record id="view_fp_pricing_rule_form_quality_inherit" model="ir.ui.view">
<field name="name">fp.pricing.rule.form.quality.spec.inherit</field>
<field name="model">fp.pricing.rule</field>
<field name="inherit_id"
ref="fusion_plating_configurator.view_fp_pricing_rule_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='substrate_material']"
position="before">
<field name="customer_spec_id"
options="{'no_quick_create': True}"/>
<field name="recipe_id"
options="{'no_quick_create': True}"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,29 @@
<?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.
Adds the "Applicable Specifications" group to the recipe form
(defined in core under the "Specification & Bake" notebook page).
Lives here because the field applicable_spec_ids is added by an
inherit in this module.
-->
<odoo>
<record id="view_fp_process_node_form_quality_inherit" model="ir.ui.view">
<field name="name">fusion.plating.process.node.form.quality.inherit</field>
<field name="model">fusion.plating.process.node</field>
<field name="inherit_id" ref="fusion_plating.view_fp_process_node_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='spec_metadata']" position="inside">
<group string="Applicable Specifications">
<field name="applicable_spec_ids" nolabel="1"
widget="many2many_tags"
options="{'no_create': True}"/>
</group>
</xpath>
</field>
</record>
</odoo>

View File

@@ -65,8 +65,10 @@
placeholder="All parts if empty"/>
</group>
<group>
<field name="coating_config_ids" widget="many2many_tags"
placeholder="All coatings if empty"/>
<field name="customer_spec_ids" widget="many2many_tags"
placeholder="All specs if empty"/>
<field name="recipe_ids" widget="many2many_tags"
placeholder="All recipes if empty"/>
<field name="step_kind"
invisible="trigger_type != 'job_step_done'"
placeholder="Any step kind if empty"/>

View File

@@ -0,0 +1,46 @@
<?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.
Adds the Specification picker to the SO line tree (the configurator's
main editable line list inside the SO form). The Spec field lives on
sale.order.line as an _inherit added in this module, so the view
that surfaces it must also live here.
During Phases B-D the Spec picker sits ALONGSIDE the legacy
Primary Treatment picker (both visible). Phase E removes the legacy
field entirely.
-->
<odoo>
<!-- Configurator's view_sale_order_form_fp inherits sale.view_order_form
and adds Plating fields to the order_line tree. We inherit THAT
view to add Specification next to Part Catalog. -->
<record id="view_sale_order_form_quality_inherit" model="ir.ui.view">
<field name="name">sale.order.form.quality.spec.inherit</field>
<field name="model">sale.order</field>
<field name="inherit_id"
ref="fusion_plating_configurator.view_sale_order_form_fp"/>
<field name="arch" type="xml">
<!-- Editable order_line tree (estimator's main grid).
Anchor on x_fc_internal_description because it's
unique to the editable list (not in the read-only
summary list at the form bottom). -->
<xpath expr="//field[@name='x_fc_internal_description']"
position="after">
<field name="x_fc_customer_spec_id"
string="Specification"
options="{'no_quick_create': True}"
optional="show"/>
</xpath>
</field>
</record>
<!-- The SO list's coating column is on sale.order itself (header
field). Adding a parallel spec column on the order header is
a Phase B+ enhancement — for now, the line tree (above) is
sufficient for the operator. -->
</odoo>

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import fp_contract_review_client_email_wizard

View File

@@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpContractReviewClientEmailWizard(models.TransientModel):
"""Email-composer wizard for the Contract Review "Awaiting Client Info"
workflow. Pre-fills subject + body from the QA failure reason so the
QA Signer (Brett, or any other configured signer) can ping the
customer in a single click.
Sending the wizard:
1. Posts a chatter message of message_type='email' on the review
(the smart-button counter on the review form picks this up).
2. Sends the actual email via mail.mail to the customer's email.
3. Stamps `info_requested_date` on the review the first time, so
the form clearly shows when the request went out.
"""
_name = 'fp.contract.review.client.email.wizard'
_description = 'Contract Review — Email Client (Request Info)'
review_id = fields.Many2one(
'fp.contract.review',
string='Contract Review',
required=True,
ondelete='cascade',
)
customer_id = fields.Many2one(
'res.partner',
related='review_id.customer_id',
readonly=True,
)
recipient_email = fields.Char(
string='To',
required=True,
help='Customer contact email. Edit if the request needs to go to a '
'specific buyer / engineer.',
)
recipient_name = fields.Char(
string='Recipient Name',
)
subject = fields.Char(
string='Subject',
required=True,
)
body = fields.Html(
string='Message',
required=True,
sanitize=True,
)
@api.model
def default_get(self, fields_list):
vals = super().default_get(fields_list)
review_id = self.env.context.get('default_review_id')
if review_id:
review = self.env['fp.contract.review'].browse(review_id)
company = review.company_id or self.env.company
part_label = (review.part_id and review.part_id.display_name) or '-'
po_label = review.contract_po_number or review.quote_or_job_number or '-'
failure_html = review.qa_failure_reason or _(
'<p>(Reason not yet captured — type details here.)</p>'
)
if 'subject' in fields_list and not vals.get('subject'):
vals['subject'] = _(
'%(company)s — Information request for Contract Review '
'%(name)s (PO %(po)s)'
) % {
'company': company.name or '',
'name': review.name or '',
'po': po_label,
}
if 'body' in fields_list and not vals.get('body'):
vals['body'] = _(
'<p>Hello %(recipient)s,</p>'
'<p>We are reviewing your contract for <b>%(part)s</b> '
'(PO %(po)s) and need additional information to '
'finalise our QA-005 review.</p>'
'<p><b>Items requiring clarification:</b></p>'
'%(failure)s'
'<p>Please reply with the requested information at '
'your earliest convenience so we can complete the '
'review and proceed with production.</p>'
'<p>Thank you,<br/>%(company)s — Quality Team</p>'
) % {
'recipient': (review.customer_id.name or _('there')),
'part': part_label,
'po': po_label,
'failure': failure_html,
'company': company.name or '',
}
return vals
def action_send(self):
"""Send the email + post chatter + stamp request date."""
self.ensure_one()
if not self.recipient_email:
raise UserError(_(
'A recipient email is required. Set the customer\'s email '
'on their contact card or override here.'
))
review = self.review_id
# Post into the review's chatter as message_type='email' so the
# smart-button counter picks it up. message_post handles the
# actual mail.mail send when partner_ids / email_to is set.
review.message_post(
body=self.body,
subject=self.subject,
message_type='email',
subtype_xmlid='mail.mt_comment',
partner_ids=review.customer_id.ids if review.customer_id else [],
email_layout_xmlid='mail.mail_notification_light',
email_add_signature=True,
)
# Belt-and-braces direct send to the recipient_email when it
# differs from the partner's primary email (e.g. a buyer-specific
# address typed into the wizard).
partner_email = review.customer_id.email if review.customer_id else ''
if self.recipient_email and self.recipient_email != partner_email:
self.env['mail.mail'].sudo().create({
'subject': self.subject,
'body_html': self.body,
'email_from': self.env.user.email_formatted or
(review.company_id and review.company_id.email) or '',
'email_to': self.recipient_email,
'auto_delete': True,
'model': 'fp.contract.review',
'res_id': review.id,
}).send()
if not review.info_requested_date:
review.write({'info_requested_date': fields.Datetime.now()})
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<odoo>
<record id="view_fp_contract_review_client_email_wizard_form" model="ir.ui.view">
<field name="name">fp.contract.review.client.email.wizard.form</field>
<field name="model">fp.contract.review.client.email.wizard</field>
<field name="arch" type="xml">
<form string="Email Client — Request Info">
<sheet>
<group>
<field name="review_id" readonly="1"/>
<field name="customer_id" readonly="1"/>
<field name="recipient_email"/>
<field name="recipient_name"/>
<field name="subject"/>
</group>
<separator string="Message"/>
<field name="body" placeholder="Compose the message to the client. The body has been pre-filled with the QA failure reason — edit as needed."/>
</sheet>
<footer>
<button name="action_send"
type="object"
string="Send Email"
class="btn-primary"
icon="fa-paper-plane"/>
<button special="cancel"
string="Cancel"
class="btn-secondary"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.10.16.0',
'version': '19.0.11.1.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -94,9 +94,9 @@
<br/>
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
</t>
<t t-if="'x_fc_thickness_id' in line._fields and line.x_fc_thickness_id">
<t t-if="'x_fc_thickness_range' in line._fields and line.x_fc_thickness_range">
<br/>
<small>Thickness: <span t-esc="line.x_fc_thickness_id.display_name"/></small>
<small>Thickness: <span t-esc="line.x_fc_thickness_range"/></small>
</t>
</t>
<t t-else="">

View File

@@ -110,10 +110,10 @@
</td>
</tr>
<tr>
<th class="info-header">Coating Config</th>
<th class="info-header">Specification</th>
<td>
<t t-if="so and so.x_fc_coating_config_id">
<span t-field="so.x_fc_coating_config_id"/>
<t t-if="so and so.x_fc_customer_spec_id">
<span t-field="so.x_fc_customer_spec_id"/>
</t>
<t t-else=""></t>
</td>

View File

@@ -75,19 +75,19 @@
</table>
<!-- Plating info -->
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_coating_config_id or doc.x_fc_delivery_method">
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_customer_spec_id or doc.x_fc_delivery_method">
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 34%;">PART</th>
<th class="info-header" style="width: 33%;">COATING CONFIG</th>
<th class="info-header" style="width: 33%;">SPECIFICATION</th>
<th class="info-header" style="width: 33%;">DELIVERY METHOD</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td>
<td class="text-center"><span t-field="doc.x_fc_coating_config_id"/></td>
<td class="text-center"><span t-field="doc.x_fc_customer_spec_id"/></td>
<td class="text-center">
<t t-set="dm" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '-')"/>
<span t-esc="dm"/>
@@ -340,12 +340,12 @@
</table>
<!-- Plating details -->
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_coating_config_id">
<t t-if="doc.x_fc_part_catalog_id or doc.x_fc_customer_spec_id">
<table class="bordered info-table">
<thead>
<tr>
<th>PART CATALOG</th>
<th>COATING CONFIGURATION</th>
<th>SPECIFICATION</th>
<th>INVOICE STRATEGY</th>
<th>DEPOSIT %</th>
</tr>
@@ -353,7 +353,7 @@
<tbody>
<tr>
<td class="text-center"><span t-field="doc.x_fc_part_catalog_id"/></td>
<td class="text-center"><span t-field="doc.x_fc_coating_config_id"/></td>
<td class="text-center"><span t-field="doc.x_fc_customer_spec_id"/></td>
<td class="text-center">
<t t-set="inv_strat" t-value="dict(doc._fields['x_fc_invoice_strategy'].selection).get(doc.x_fc_invoice_strategy, '-')"/>
<span t-esc="inv_strat"/>

View File

@@ -19,7 +19,7 @@
* _mo — the mrp.production record (or False)
* _so, _line — the originating sale order / line
* _part — fp.part.catalog
* _coating — fp.coating.config
* _spec — fusion.plating.customer.spec (audit-tracked spec)
* _process — the resolved fusion.plating.process.node tree
* _due — datetime/date for "Due Date" row
* _qty — float for "Qty" row
@@ -47,10 +47,9 @@
or (_so and _so.order_line[:1])
or False"/>
<t t-set="_part" t-value="_part or (_line and _line.x_fc_part_catalog_id) or False"/>
<t t-set="_coating" t-value="_coating or (_line and _line.x_fc_coating_config_id) or False"/>
<t t-set="_spec" t-value="_spec or (_line and _line.x_fc_customer_spec_id) or False"/>
<t t-set="_process" t-value="_process
or (_part and _part.default_process_id)
or (_coating and _coating.recipe_id)
or False"/>
<t t-set="_due" t-value="_due
or (_mo and (_mo.date_deadline or _mo.date_finished))
@@ -88,14 +87,11 @@
<!-- Serial number — Sub 5 added x_fc_serial_id (M2O fp.serial) on
the SO line. The serial record's `name` is the printable label. -->
<t t-set="_serial_number" t-value="(_line and 'x_fc_serial_id' in _line._fields and _line.x_fc_serial_id and _line.x_fc_serial_id.name) or '-'"/>
<!-- Thickness — Sub 5 added x_fc_thickness_id (M2O fp.coating.thickness)
on the SO line. `display_name` is the human-readable range, e.g.
"0.30.5 mils". The en-dash (U+2013) in display_name mojibakes
to "â€"" through wkhtmltopdf's font path on entech, so we
swap en-dash + em-dash for a plain hyphen-minus before
rendering. ASCII-only printable for any QR-label printer. -->
<t t-set="_thickness_dn" t-value="_line and 'x_fc_thickness_id' in _line._fields and _line.x_fc_thickness_id and _line.x_fc_thickness_id.display_name"/>
<t t-set="_thickness" t-value="(_thickness_dn and _thickness_dn.replace(u'', '-').replace(u'—', '-')) or '-'"/>
<!-- Thickness — operator-typed Char range, e.g. "0.0005-0.0008 mils".
Stored as-typed; ASCII-safe by convention. Strip en/em-dash
defensively for the wkhtmltopdf font path on entech. -->
<t t-set="_thickness_raw" t-value="_line and 'x_fc_thickness_range' in _line._fields and _line.x_fc_thickness_range"/>
<t t-set="_thickness" t-value="(_thickness_raw and _thickness_raw.replace(u'', '-').replace(u'—', '-')) or '-'"/>
<!-- Notes content — outer can pre-set this (e.g. the Internal
variant passes line.x_fc_internal_description). Otherwise
falls back to line.name (customer-facing description per
@@ -468,7 +464,7 @@
<t t-set="_so" t-value="so"/>
<t t-set="_line" t-value="line"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
@@ -498,7 +494,7 @@
<t t-set="_so" t-value="so"/>
<t t-set="_line" t-value="line"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
<t t-set="_spec" t-value="line.x_fc_customer_spec_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_qty_total" t-value="line.product_uom_qty"/>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Safety (EHS)',
'version': '19.0.1.2.0',
'version': '19.0.1.3.0',
'category': 'Manufacturing/Plating',
'summary': 'Occupational health and safety for plating shops: SDS library, '
'WHMIS/TDG training, exposure monitoring, JHSC, incidents, PPE, '

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