chore(plating): de-dash shipped code + intake-neutral customer emails

Replace em-dashes and en-dashes with hyphens across 789 shipped source
files (py/xml/js/scss) so the delivered module reads as human-written;
em-dashes had become a recognizable AI-generated tell. Internal .md dev
notes are excluded. The WO-sticker mojibake strippers keep their dash
search targets (now written — / –). No logic changes: comments
and display strings only; validated with py_compile + lxml parse.

Rewrite the 7 customer notification emails to be intake-neutral
(ship-in / drop-off / pickup) and repair-aware, and fix the Shipped
email documents line (packing slip vs bill of lading; certificate only
when issued). Subjects use a hyphen separator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-06-05 00:16:19 -04:00
parent c9eb61ee0c
commit 8c76a16366
789 changed files with 4692 additions and 4692 deletions

View File

@@ -18,13 +18,13 @@ def post_init_hook(env):
Does several things, each guarded by an "is this already done?" Does several things, each guarded by an "is this already done?"
check so re-running the hook doesn't clobber state: check so re-running the hook doesn't clobber state:
1. Auto-detect a sensible default timezone (original behavior). 1. Auto-detect a sensible default timezone (original behavior).
2. Sub 12a backfill `kind='step_input'` on existing 2. Sub 12a - backfill `kind='step_input'` on existing
fusion.plating.process.node.input rows that pre-date the fusion.plating.process.node.input rows that pre-date the
`kind` field. `kind` field.
3. Sub 12a seed fp.step.template with starter library entries 3. Sub 12a - seed fp.step.template with starter library entries
derived from ENP-ALUM-BASIC if the library is currently empty. derived from ENP-ALUM-BASIC if the library is currently empty.
4. Sub 12b seed 4 starter rack tags if the registry is empty. 4. Sub 12b - seed 4 starter rack tags if the registry is empty.
5. Phase H create a pending fp.migration.preview if any user 5. Phase H - create a pending fp.migration.preview if any user
still holds an old plating-role group + notify Owners. still holds an old plating-role group + notify Owners.
""" """
_seed_default_timezone(env) _seed_default_timezone(env)
@@ -43,7 +43,7 @@ def post_init_hook(env):
# from uninstalled modules so this is safe across configurations. # from uninstalled modules so this is safe across configurations.
# Kept visible to technicians (NOT in this list): Discuss, To-do, # Kept visible to technicians (NOT in this list): Discuss, To-do,
# Plating, AI, Maintenance, Time Off. Settings/Apps/Tests are admin- # Plating, AI, Maintenance, Time Off. Settings/Apps/Tests are admin-
# restricted upstream also not in this list. # restricted upstream - also not in this list.
# See security/fp_menu_visibility.xml for the design rationale. # See security/fp_menu_visibility.xml for the design rationale.
MENU_HIDE_FROM_TECHNICIANS = [ MENU_HIDE_FROM_TECHNICIANS = [
'calendar.mail_menu_calendar', 'calendar.mail_menu_calendar',
@@ -81,12 +81,12 @@ def _fp_apply_office_user_menu_visibility(env):
MENU_HIDE_FROM_TECHNICIANS that exists in this DB. MENU_HIDE_FROM_TECHNICIANS that exists in this DB.
Field is `group_ids` on ir.ui.menu in Odoo 19 (was `groups_id` in Field is `group_ids` on ir.ui.menu in Odoo 19 (was `groups_id` in
earlier versions Odoo 18 renamed it). Same naming-rename pattern earlier versions - Odoo 18 renamed it). Same naming-rename pattern
as res.users (CLAUDE.md Critical Rule 13c). as res.users (CLAUDE.md Critical Rule 13c).
Idempotent: if a menu already has only the office_user group, no Idempotent: if a menu already has only the office_user group, no
change is made. If it has additional groups (e.g. a previous custom change is made. If it has additional groups (e.g. a previous custom
restriction), they're REPLACED the design accepts this trade-off restriction), they're REPLACED - the design accepts this trade-off
because office_user is implied by every fp role above Technician, because office_user is implied by every fp role above Technician,
so non-fp users keep their access on entech. so non-fp users keep their access on entech.
@@ -156,7 +156,7 @@ def _seed_starter_recipes_once(env):
Before 19.0.20.5.0 the recipe XML files (ENP-STEEL-BASIC, ENP-SP, Before 19.0.20.5.0 the recipe XML files (ENP-STEEL-BASIC, ENP-SP,
ENP-ALUM-BASIC, etc.) lived in the manifest's ``data`` list. With ENP-ALUM-BASIC, etc.) lived in the manifest's ``data`` list. With
``noupdate="1"`` we expected user edits / deletions to survive ``noupdate="1"`` we expected user edits / deletions to survive
module upgrades but Odoo only treats noupdate=1 as "don't update module upgrades - but Odoo only treats noupdate=1 as "don't update
existing records". If a record's ir.model.data row is deleted via existing records". If a record's ir.model.data row is deleted via
unlink, Odoo on the next ``-u`` sees the xmlid as missing and unlink, Odoo on the next ``-u`` sees the xmlid as missing and
RE-CREATES the record from XML. Bug reported 2026-05-20: every RE-CREATES the record from XML. Bug reported 2026-05-20: every
@@ -167,7 +167,7 @@ def _seed_starter_recipes_once(env):
here via convert_file ONCE per xmlid. Each file gets a sentinel here via convert_file ONCE per xmlid. Each file gets a sentinel
check (does the root recipe's xmlid exist in ir.model.data?); if check (does the root recipe's xmlid exist in ir.model.data?); if
yes, skip. The hook is itself idempotent so it's safe to run on yes, skip. The hook is itself idempotent so it's safe to run on
every upgrade as well but the sentinel ensures recipe content every upgrade as well - but the sentinel ensures recipe content
is only seeded the very first time. is only seeded the very first time.
""" """
from odoo.tools import convert from odoo.tools import convert
@@ -197,7 +197,7 @@ def _seed_starter_recipes_once(env):
module_name, name = xmlid.split('.', 1) module_name, name = xmlid.split('.', 1)
if IMD.search_count([('module', '=', module_name), ('name', '=', name)]): if IMD.search_count([('module', '=', module_name), ('name', '=', name)]):
# Recipe already in DB (either from a previous install, or # Recipe already in DB (either from a previous install, or
# already loaded by an earlier hook run). Don't touch user # already loaded by an earlier hook run). Don't touch - user
# may have made edits. # may have made edits.
continue continue
# File not yet loaded for this DB. Run it once. # File not yet loaded for this DB. Run it once.
@@ -235,7 +235,7 @@ def _resolve_kind_id(env, code):
def _backfill_contract_review_template(env): def _backfill_contract_review_template(env):
"""Idempotent ensure the Contract Review library template exists. """Idempotent - ensure the Contract Review library template exists.
`_seed_step_library_if_empty` only fires on a fresh DB; existing DBs `_seed_step_library_if_empty` only fires on a fresh DB; existing DBs
upgraded from pre-Policy-B versions still have a populated library upgraded from pre-Policy-B versions still have a populated library
@@ -271,7 +271,7 @@ def _seed_default_timezone(env):
def _backfill_node_input_kind(env): def _backfill_node_input_kind(env):
"""Sub 12a set kind='step_input' on rows that have NULL kind.""" """Sub 12a - set kind='step_input' on rows that have NULL kind."""
cr = env.cr cr = env.cr
cr.execute( cr.execute(
"UPDATE fusion_plating_process_node_input " "UPDATE fusion_plating_process_node_input "
@@ -287,7 +287,7 @@ def _backfill_node_input_kind(env):
# Mapping of recipe-step name → default_kind. Drives sane-default # Mapping of recipe-step name → default_kind. Drives sane-default
# input seeding on the starter library entries. # input seeding on the starter library entries.
_STARTER_KIND_BY_NAME = { _STARTER_KIND_BY_NAME = {
# Policy B (2026-04-28) recipe-side Contract Review step. # Policy B (2026-04-28) - recipe-side Contract Review step.
# When an author drops this template into a recipe, fp.job.step.button_* # When an author drops this template into a recipe, fp.job.step.button_*
# hooks in fusion_plating_jobs detect the kind=='contract_review' and # hooks in fusion_plating_jobs detect the kind=='contract_review' and
# auto-open / gate the QA-005 audit form (fp.contract.review). # auto-open / gate the QA-005 audit form (fp.contract.review).
@@ -363,7 +363,7 @@ _STARTER_KIND_BY_NAME = {
'ready for post-plate inspection': 'gating', 'ready for post-plate inspection': 'gating',
'ready for final inspection': 'gating', 'ready for final inspection': 'gating',
'ready for shipping': 'gating', 'ready for shipping': 'gating',
# 2026-05-24 Recipe cleanup additions (spec # 2026-05-24 - Recipe cleanup additions (spec
# 2026-05-24-recipe-cleanup-design.md). Covers names the existing # 2026-05-24-recipe-cleanup-design.md). Covers names the existing
# resolver didn't know that turned up in the entech recipes audit. # resolver didn't know that turned up in the entech recipes audit.
# Blasting variants # Blasting variants
@@ -413,13 +413,13 @@ def fp_resolve_step_kind(name):
key = name.strip().lower() key = name.strip().lower()
if key in _STARTER_KIND_BY_NAME: if key in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[key] return _STARTER_KIND_BY_NAME[key]
# Parenthetical strip "Masking (If Required)" → "masking", # Parenthetical strip - "Masking (If Required)" → "masking",
# "Incoming Inspection (Standard)" → "incoming inspection", # "Incoming Inspection (Standard)" → "incoming inspection",
# "Trivalent Chromate Conversion (A-14 / A)" → "trivalent chromate conversion". # "Trivalent Chromate Conversion (A-14 / A)" → "trivalent chromate conversion".
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip() bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
if bare and bare != key and bare in _STARTER_KIND_BY_NAME: if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[bare] return _STARTER_KIND_BY_NAME[bare]
# Gating "Ready for / Ready For" prefix anything starting with that # Gating "Ready for / Ready For" prefix - anything starting with that
# is a gating node regardless of the destination step name. # is a gating node regardless of the destination step name.
if key.startswith('ready for ') or key.startswith('ready '): if key.startswith('ready for ') or key.startswith('ready '):
return 'gating' return 'gating'
@@ -429,7 +429,7 @@ def fp_resolve_step_kind(name):
# Translates resolver kind output to the active fp.step.kind.code values. # Translates resolver kind output to the active fp.step.kind.code values.
# The resolver still returns the OLD vocabulary (cleaning, electroclean, # The resolver still returns the OLD vocabulary (cleaning, electroclean,
# etch, rinse, strike, dry, wbf_test) which were deactivated in # etch, rinse, strike, dry, wbf_test) which were deactivated in
# 19.0.20.6.0 those roll up to the active wet_process kind. Other # 19.0.20.6.0 - those roll up to the active wet_process kind. Other
# codes pass through 1:1. Used by the auto-classify hook on # codes pass through 1:1. Used by the auto-classify hook on
# fusion.plating.process.node + the recipe-cleanup migration # fusion.plating.process.node + the recipe-cleanup migration
# (fusion_plating_jobs 19.0.10.26.0). # (fusion_plating_jobs 19.0.10.26.0).
@@ -446,7 +446,7 @@ RESOLVER_KIND_TO_ACTIVE_KIND = {
# for "Strip Process - AL", "Chemical # for "Strip Process - AL", "Chemical
# Conversion", "Trivalent Chromate # Conversion", "Trivalent Chromate
# Conversion" maps DIRECTLY to # Conversion" maps DIRECTLY to
# 'wet_process' this passthrough # 'wet_process' - this passthrough
# entry lets those land correctly. # entry lets those land correctly.
# 1:1 mappings (kind exists and is active) # 1:1 mappings (kind exists and is active)
'contract_review': 'contract_review', 'contract_review': 'contract_review',
@@ -465,10 +465,10 @@ RESOLVER_KIND_TO_ACTIVE_KIND = {
def _seed_step_library_if_empty(env): def _seed_step_library_if_empty(env):
"""Sub 12a seed fp.step.template starter library. """Sub 12a - seed fp.step.template starter library.
Source priority: Source priority:
1. ENP-ALUM-BASIC recipe's child nodes (best reuses the 1. ENP-ALUM-BASIC recipe's child nodes (best - reuses the
author-curated step set). author-curated step set).
2. Hard-coded minimal list (fallback for fresh DBs). 2. Hard-coded minimal list (fallback for fresh DBs).
""" """
@@ -600,7 +600,7 @@ def _migrate_legacy_uom_columns(env):
def _seed_rack_tags_if_empty(env): def _seed_rack_tags_if_empty(env):
"""Sub 12b seed 4 starter rack tags.""" """Sub 12b - seed 4 starter rack tags."""
Tag = env['fp.rack.tag'] Tag = env['fp.rack.tag']
if Tag.search_count([]): if Tag.search_count([]):
return return

View File

@@ -9,7 +9,7 @@
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.', 'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """ 'description': """
Fusion Plating Core Fusion Plating - Core
===================== =====================
Part of the Fusion Plating product family by Nexa Systems Inc. Part of the Fusion Plating product family by Nexa Systems Inc.
@@ -19,35 +19,35 @@ finishing shops. This core module provides the process-agnostic foundation that
every shop needs regardless of size, process mix, jurisdiction, or industry. every shop needs regardless of size, process mix, jurisdiction, or industry.
The core ships intentionally empty of region-specific or process-specific The core ships intentionally empty of region-specific or process-specific
content that comes from add-on modules: content - that comes from add-on modules:
* fusion_plating_process_en Electroless nickel plating * fusion_plating_process_en - Electroless nickel plating
* fusion_plating_process_chrome Chrome coating (hex or trivalent) * fusion_plating_process_chrome - Chrome coating (hex or trivalent)
* fusion_plating_process_anodize Aluminum anodizing (Type II, III) * fusion_plating_process_anodize - Aluminum anodizing (Type II, III)
* fusion_plating_process_black_oxide Black oxidizing * fusion_plating_process_black_oxide - Black oxidizing
* fusion_plating_quality QMS (NCR, CAPA, calibration, CoC, doc control) * fusion_plating_quality - QMS (NCR, CAPA, calibration, CoC, doc control)
* fusion_plating_compliance Generic compliance framework * fusion_plating_compliance - Generic compliance framework
* fusion_plating_compliance_on Ontario regulatory pack * fusion_plating_compliance_on - Ontario regulatory pack
* fusion_plating_compliance_tor Toronto Ch. 681 municipal pack * fusion_plating_compliance_tor - Toronto Ch. 681 municipal pack
* fusion_plating_safety SDS, WHMIS/TDG training, JHSC, exposure * fusion_plating_safety - SDS, WHMIS/TDG training, JHSC, exposure
* fusion_plating_shopfloor Tablet operator stations, QR scanning * fusion_plating_shopfloor - Tablet operator stations, QR scanning
* fusion_plating_portal Customer portal * fusion_plating_portal - Customer portal
* fusion_plating_aerospace AS9100 + Nadcap AC7108 pack * fusion_plating_aerospace - AS9100 + Nadcap AC7108 pack
* fusion_plating_nuclear CSA N299, CNSC, NQA-1 pack * fusion_plating_nuclear - CSA N299, CNSC, NQA-1 pack
* fusion_plating_cgp Controlled Goods Program pack * fusion_plating_cgp - Controlled Goods Program pack
* fusion_plating_logistics Pickup & delivery * fusion_plating_logistics - Pickup & delivery
* fusion_plating_culture Values / fundamentals framework * fusion_plating_culture - Values / fundamentals framework
Core concepts Core concepts
------------- -------------
* Facility a physical site with its own tanks, operators, compliance profile * Facility - a physical site with its own tanks, operators, compliance profile
* Process Type extensible taxonomy of finishing processes * Process Type - extensible taxonomy of finishing processes
* Work Center production line or station within a facility * Work Center - production line or station within a facility
* Tank physical vessel with QR code and state * Tank - physical vessel with QR code and state
* Bath the chemistry currently in a tank, with its own lifecycle * Bath - the chemistry currently in a tank, with its own lifecycle
* Bath Log daily chemistry readings with pass/fail vs target * Bath Log - daily chemistry readings with pass/fail vs target
* KPI configurable headline metrics per shop * KPI - configurable headline metrics per shop
* Delegation Inbox single pane of "things waiting for someone" * Delegation Inbox - single pane of "things waiting for someone"
Design principles Design principles
----------------- -----------------
@@ -82,7 +82,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'security/fp_security.xml', 'security/fp_security.xml',
'security/fp_security_v2.xml', 'security/fp_security_v2.xml',
'security/ir.model.access.csv', 'security/ir.model.access.csv',
# Menu visibility loads after fp_security_v2.xml so the role # Menu visibility - loads after fp_security_v2.xml so the role
# group xmlids exist when we add office_user to their # group xmlids exist when we add office_user to their
# implied_ids. Loads after fp_menu.xml in spirit BUT references # implied_ids. Loads after fp_menu.xml in spirit BUT references
# cross-module menus (calendar, sale, hr, etc.) which exist by # cross-module menus (calendar, sale, hr, etc.) which exist by
@@ -95,7 +95,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'data/fp_numbering_sequences.xml', 'data/fp_numbering_sequences.xml',
'data/fp_rack_load_sequence.xml', 'data/fp_rack_load_sequence.xml',
'data/fp_process_category_data.xml', 'data/fp_process_category_data.xml',
# fp_menu.xml MUST load early defines menu_fp_root, menu_fp_config, # fp_menu.xml MUST load early - defines menu_fp_root, menu_fp_config,
# menu_fp_compliance_hub, plus the 7 Phase-2 Configuration sub-folder # menu_fp_compliance_hub, plus the 7 Phase-2 Configuration sub-folder
# buckets. Every other view file (in this module and downstream) # buckets. Every other view file (in this module and downstream)
# that creates a child menu under those buckets references them # that creates a child menu under those buckets references them
@@ -108,7 +108,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_facility_views.xml', 'views/fp_facility_views.xml',
'views/fp_bath_views.xml', 'views/fp_bath_views.xml',
'views/fp_process_node_views.xml', 'views/fp_process_node_views.xml',
# Sub 14b fp.step.kind catalog. MUST load before # Sub 14b - fp.step.kind catalog. MUST load before
# fp_step_template_data.xml (templates reference kinds via # fp_step_template_data.xml (templates reference kinds via
# kind_id) AND before fp_step_template_views.xml (the form # kind_id) AND before fp_step_template_views.xml (the form
# references the kind action menu). # references the kind action menu).
@@ -123,7 +123,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_operator_certification_views.xml', 'views/fp_operator_certification_views.xml',
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',
'views/fp_landing_views.xml', 'views/fp_landing_views.xml',
# Phase F Owner-only Team page + Designated Officials on res.company. # Phase F - Owner-only Team page + Designated Officials on res.company.
# Both reference menu_fp_config (Configuration root) and Phase 1 # Both reference menu_fp_config (Configuration root) and Phase 1
# role groups, all loaded earlier (fp_menu.xml + fp_security_v2.xml). # role groups, all loaded earlier (fp_menu.xml + fp_security_v2.xml).
'views/fp_team_views.xml', 'views/fp_team_views.xml',
@@ -139,7 +139,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
# so user edits / deletions survive every -u upgrade. Putting # so user edits / deletions survive every -u upgrade. Putting
# them back here would re-create deleted nodes on every # them back here would re-create deleted nodes on every
# module upgrade (the noupdate="1" flag only blocks UPDATE, # module upgrade (the noupdate="1" flag only blocks UPDATE,
# not CREATE-when-missing Odoo treats a missing ir.model.data # not CREATE-when-missing - Odoo treats a missing ir.model.data
# record as "needs creating"). # record as "needs creating").
# 'data/fp_recipe_enp_alum_basic.xml', # 'data/fp_recipe_enp_alum_basic.xml',
# 'data/fp_recipe_enp_steel_basic.xml', # 'data/fp_recipe_enp_steel_basic.xml',
@@ -148,7 +148,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
# 'data/fp_recipe_anodize.xml', # 'data/fp_recipe_anodize.xml',
# 'data/fp_recipe_chem_conversion.xml', # 'data/fp_recipe_chem_conversion.xml',
'data/fp_step_template_data.xml', 'data/fp_step_template_data.xml',
# Phase H Owner-approval migration workflow. # Phase H - Owner-approval migration workflow.
# Views file declares the action + menu; cron declares the # Views file declares the action + menu; cron declares the
# daily 30-day expiry purge. Both reference model_fp_migration_preview # daily 30-day expiry purge. Both reference model_fp_migration_preview
# which Odoo's model autoload makes available before data load. # which Odoo's model autoload makes available before data load.
@@ -162,7 +162,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating/static/src/scss/recipe_tree_editor.scss', 'fusion_plating/static/src/scss/recipe_tree_editor.scss',
'fusion_plating/static/src/scss/fp_chatter_dark.scss', 'fusion_plating/static/src/scss/fp_chatter_dark.scss',
'fusion_plating/static/src/scss/simple_recipe_editor.scss', 'fusion_plating/static/src/scss/simple_recipe_editor.scss',
# Sub 14b visual icon picker for fp.step.kind etc. # Sub 14b - visual icon picker for fp.step.kind etc.
'fusion_plating/static/src/scss/fp_icon_picker.scss', 'fusion_plating/static/src/scss/fp_icon_picker.scss',
'fusion_plating/static/src/xml/recipe_tree_editor.xml', 'fusion_plating/static/src/xml/recipe_tree_editor.xml',
'fusion_plating/static/src/xml/simple_recipe_editor.xml', 'fusion_plating/static/src/xml/simple_recipe_editor.xml',

View File

@@ -15,7 +15,7 @@ class FpRecipeController(http.Controller):
"""JSON-RPC endpoints for the process recipe tree editor.""" """JSON-RPC endpoints for the process recipe tree editor."""
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Read full tree # Read - full tree
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/recipe/tree', type='jsonrpc', auth='user') @http.route('/fp/recipe/tree', type='jsonrpc', auth='user')
def get_tree(self, recipe_id): def get_tree(self, recipe_id):
@@ -27,7 +27,7 @@ class FpRecipeController(http.Controller):
recipe = Node.browse(int(recipe_id)) recipe = Node.browse(int(recipe_id))
if not recipe.exists(): if not recipe.exists():
return {'ok': False, 'error': f'Recipe {recipe_id} not found.'} return {'ok': False, 'error': f'Recipe {recipe_id} not found.'}
# Workflow states for the dropdown runtime-detect the model # Workflow states for the dropdown - runtime-detect the model
# so the tree editor still works on installs without # so the tree editor still works on installs without
# fusion_plating_jobs (where the model lives). # fusion_plating_jobs (where the model lives).
workflow_states = [] workflow_states = []
@@ -103,9 +103,9 @@ class FpRecipeController(http.Controller):
'estimated_duration', 'estimated_duration',
'auto_complete', 'customer_visible', 'is_manual', 'auto_complete', 'customer_visible', 'is_manual',
'requires_signoff', 'opt_in_out', 'sequence', 'version', 'requires_signoff', 'opt_in_out', 'sequence', 'version',
# Sub 13 sequential enforcement # Sub 13 - sequential enforcement
'enforce_sequential', 'parallel_start', 'enforce_sequential', 'parallel_start',
# Sub 14 workflow milestone trigger # Sub 14 - workflow milestone trigger
'triggers_workflow_state_id', 'triggers_workflow_state_id',
} }
safe_vals = {k: v for k, v in vals.items() if k in allowed} safe_vals = {k: v for k, v in vals.items() if k in allowed}
@@ -164,7 +164,7 @@ class FpRecipeController(http.Controller):
def list_recipes(self, exclude_id=None): def list_recipes(self, exclude_id=None):
"""Return all recipe-roots available for the import picker. """Return all recipe-roots available for the import picker.
exclude_id: optional skip this recipe (usually the currently- exclude_id: optional - skip this recipe (usually the currently-
open one, so the user can't import from themselves). open one, so the user can't import from themselves).
""" """
Node = request.env['fusion.plating.process.node'] Node = request.env['fusion.plating.process.node']
@@ -181,7 +181,7 @@ class FpRecipeController(http.Controller):
} }
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Move sibling up / down explicit button-driven reorder # Move sibling up / down - explicit button-driven reorder
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/recipe/node/move_sibling', type='jsonrpc', auth='user') @http.route('/fp/recipe/node/move_sibling', type='jsonrpc', auth='user')
def move_sibling(self, node_id, direction): def move_sibling(self, node_id, direction):
@@ -212,7 +212,7 @@ class FpRecipeController(http.Controller):
# Swap the two sequence values # Swap the two sequence values
a_seq, b_seq = node.sequence, other.sequence a_seq, b_seq = node.sequence, other.sequence
if a_seq == b_seq: if a_seq == b_seq:
# Sequences collided renumber everyone cleanly, then swap # Sequences collided - renumber everyone cleanly, then swap
for i, s in enumerate(siblings, 1): for i, s in enumerate(siblings, 1):
s.sequence = i * 10 s.sequence = i * 10
a_seq, b_seq = node.sequence, other.sequence a_seq, b_seq = node.sequence, other.sequence
@@ -240,7 +240,7 @@ class FpRecipeController(http.Controller):
* 0 → insert at the start. * 0 → insert at the start.
* <positive int> → insert right before that child id. * <positive int> → insert right before that child id.
Needed because "General Processing" has Shipping as the Needed because "General Processing" has Shipping as the
LAST operation importing a plating pack belongs between LAST operation - importing a plating pack belongs between
Scheduling and Shipping, not after Shipping. Scheduling and Shipping, not after Shipping.
Returns: {ok, imported_count, skipped_count} Returns: {ok, imported_count, skipped_count}
@@ -251,7 +251,7 @@ class FpRecipeController(http.Controller):
if not source.exists() or not target.exists(): if not source.exists() or not target.exists():
return {'ok': False, 'error': 'Source or target not found.'} return {'ok': False, 'error': 'Source or target not found.'}
if f'/{source.id}/' in (target.parent_path or ''): if f'/{source.id}/' in (target.parent_path or ''):
return {'ok': False, 'error': 'Target is inside source would loop.'} return {'ok': False, 'error': 'Target is inside source - would loop.'}
existing_names = set() existing_names = set()
if dedupe_by_name: if dedupe_by_name:
@@ -282,7 +282,7 @@ class FpRecipeController(http.Controller):
_copy_subtree(child, new_node, i * 10) _copy_subtree(child, new_node, i * 10)
return new_node return new_node
# Phase 1 create every copied top-level child, tracking their # Phase 1 - create every copied top-level child, tracking their
# ids so we can separate them from the original children when # ids so we can separate them from the original children when
# reordering below. # reordering below.
new_top_level_ids = [] new_top_level_ids = []
@@ -298,7 +298,7 @@ class FpRecipeController(http.Controller):
if key: if key:
existing_names.add(key) existing_names.add(key)
# Phase 2 compute the final top-level ordering and reassign # Phase 2 - compute the final top-level ordering and reassign
# sequences so imported nodes land at the requested position # sequences so imported nodes land at the requested position
# instead of always appearing after every existing child. # instead of always appearing after every existing child.
target.invalidate_recordset(['child_ids']) target.invalidate_recordset(['child_ids'])

View File

@@ -14,7 +14,7 @@ from odoo.http import request
# Field list copied from a library template into a new recipe step on # Field list copied from a library template into a new recipe step on
# drag-drop. Snapshot semantics (Q4 from the design doc editing a # drag-drop. Snapshot semantics (Q4 from the design doc - editing a
# library template later does NOT change recipes already built). # library template later does NOT change recipes already built).
_SNAPSHOT_FIELDS = [ _SNAPSHOT_FIELDS = [
'name', 'code', 'description', 'icon', 'name', 'code', 'description', 'icon',
@@ -24,9 +24,9 @@ _SNAPSHOT_FIELDS = [
'voltage_target', 'viscosity_target', 'voltage_target', 'viscosity_target',
'requires_signoff', 'requires_predecessor_done', 'requires_signoff', 'requires_predecessor_done',
'parallel_start', 'parallel_start',
'triggers_workflow_state_id', # Sub 14 workflow milestone trigger 'triggers_workflow_state_id', # Sub 14 - workflow milestone trigger
'requires_rack_assignment', 'requires_transition_form', 'requires_rack_assignment', 'requires_transition_form',
'kind_id', # Sub 14b replaces default_kind (now a related Char) 'kind_id', # Sub 14b - replaces default_kind (now a related Char)
] ]
# Fields on fp.step.template.input that copy 1:1 into # Fields on fp.step.template.input that copy 1:1 into
@@ -40,7 +40,7 @@ _INPUT_SNAPSHOT_FIELDS = [
def _copy_snapshot_fields(source, fields): def _copy_snapshot_fields(source, fields):
"""Copy ``fields`` from ``source`` record into a write-ready dict. """Copy ``fields`` from ``source`` record into a write-ready dict.
Many2one values must be unwrapped to their integer id passing a Many2one values must be unwrapped to their integer id - passing a
recordset to ``create`` triggers psycopg2 ``can't adapt type X`` recordset to ``create`` triggers psycopg2 ``can't adapt type X``
because the SQL adapter doesn't know how to serialize a recordset. because the SQL adapter doesn't know how to serialize a recordset.
Scalar fields pass through untouched. Scalar fields pass through untouched.
@@ -66,7 +66,7 @@ class SimpleRecipeController(http.Controller):
# Tree-Editor-authored recipes carry FOUR node levels: # Tree-Editor-authored recipes carry FOUR node levels:
# recipe → sub_process → operation → step # recipe → sub_process → operation → step
# The Tree Editor shows all of them. The Simple Editor used to # The Tree Editor shows all of them. The Simple Editor used to
# only show direct children of the recipe so for # only show direct children of the recipe - so for
# ENP-STEEL-BASIC (1 sub_process + 16 operations + 26 step # ENP-STEEL-BASIC (1 sub_process + 16 operations + 26 step
# nodes), authors saw 10 rows out of 43. Work-order generation # nodes), authors saw 10 rows out of 43. Work-order generation
# walked the full tree and emitted operations as fp.job.step # walked the full tree and emitted operations as fp.job.step
@@ -98,7 +98,7 @@ class SimpleRecipeController(http.Controller):
} }
def _flatten_recipe_operations(self, recipe): def _flatten_recipe_operations(self, recipe):
"""Legacy helper returns ONLY operations. """Legacy helper - returns ONLY operations.
Kept for back-compat with callers and tests that asked for the Kept for back-compat with callers and tests that asked for the
operations-only view. Most paths should now use operations-only view. Most paths should now use
@@ -144,7 +144,7 @@ class SimpleRecipeController(http.Controller):
for child in node.child_ids.sorted('sequence'): for child in node.child_ids.sorted('sequence'):
_walk(child, sub_path) _walk(child, sub_path)
# `step` nodes that are direct children of a recipe (rare, # `step` nodes that are direct children of a recipe (rare,
# legacy seed data) are silently dropped _generate_steps # legacy seed data) are silently dropped - _generate_steps
# has always skipped them. # has always skipped them.
_walk(recipe, '') _walk(recipe, '')
@@ -161,7 +161,7 @@ class SimpleRecipeController(http.Controller):
[recipe.process_type_id.id, recipe.process_type_id.name] [recipe.process_type_id.id, recipe.process_type_id.name]
if recipe.process_type_id else False if recipe.process_type_id else False
), ),
# 2026-05-20 drives the visibility of admin-only affordances # 2026-05-20 - drives the visibility of admin-only affordances
# in the Simple Editor (e.g. "+ New kind…" inline create). # in the Simple Editor (e.g. "+ New kind…" inline create).
'user_is_manager': request.env.user.has_group( 'user_is_manager': request.env.user.has_group(
'fusion_plating.group_fusion_plating_manager' 'fusion_plating.group_fusion_plating_manager'
@@ -169,7 +169,7 @@ class SimpleRecipeController(http.Controller):
} }
def _step_payload(self, step): def _step_payload(self, step):
# Sub 12d measurement prompts. Filter to step_input only (transition # Sub 12d - measurement prompts. Filter to step_input only (transition
# prompts live on the move dialog). Sort by sequence so the editor # prompts live on the move dialog). Sort by sequence so the editor
# renders them in author order. # renders them in author order.
step_inputs = step.input_ids.filtered( step_inputs = step.input_ids.filtered(
@@ -209,9 +209,9 @@ class SimpleRecipeController(http.Controller):
'work_center_id': step.work_center_id.id if step.work_center_id else False, 'work_center_id': step.work_center_id.id if step.work_center_id else False,
'source_template_id': step.source_template_id.id or False, 'source_template_id': step.source_template_id.id or False,
'collect_measurements': bool(step.collect_measurements), 'collect_measurements': bool(step.collect_measurements),
# Sub 13 per-step opt-out of the sequential gate # Sub 13 - per-step opt-out of the sequential gate
'parallel_start': bool(step.parallel_start), 'parallel_start': bool(step.parallel_start),
# Sub 14 workflow milestone trigger override # Sub 14 - workflow milestone trigger override
'triggers_workflow_state_id': ( 'triggers_workflow_state_id': (
step.triggers_workflow_state_id.id step.triggers_workflow_state_id.id
if 'triggers_workflow_state_id' in step._fields if 'triggers_workflow_state_id' in step._fields
@@ -222,7 +222,7 @@ class SimpleRecipeController(http.Controller):
'measurements_badge_class': badge_class, 'measurements_badge_class': badge_class,
# Reference images attached to the step. Operators see # Reference images attached to the step. Operators see
# these in the Record Inputs dialog and the step quick-look # these in the Record Inputs dialog and the step quick-look
# modal recipe authors upload via the inline edit panel. # modal - recipe authors upload via the inline edit panel.
'instruction_images': [ 'instruction_images': [
{ {
'id': att.id, 'id': att.id,
@@ -328,7 +328,7 @@ class SimpleRecipeController(http.Controller):
'requires_signoff': tpl.requires_signoff, 'requires_signoff': tpl.requires_signoff,
'requires_predecessor_done': tpl.requires_predecessor_done, 'requires_predecessor_done': tpl.requires_predecessor_done,
'parallel_start': tpl.parallel_start, 'parallel_start': tpl.parallel_start,
# Sub 14 workflow trigger (id + name for display) # Sub 14 - workflow trigger (id + name for display)
'triggers_workflow_state_id': ( 'triggers_workflow_state_id': (
tpl.triggers_workflow_state_id.id tpl.triggers_workflow_state_id.id
if tpl.triggers_workflow_state_id else False if tpl.triggers_workflow_state_id else False
@@ -367,9 +367,9 @@ class SimpleRecipeController(http.Controller):
refresh in one round-trip. refresh in one round-trip.
""" """
Tpl = request.env['fp.step.template'] Tpl = request.env['fp.step.template']
# Whitelist never trust client-provided write_uid / id / etc. # Whitelist - never trust client-provided write_uid / id / etc.
# Sub 14b: `default_kind` is now a related read-only Char. The # Sub 14b: `default_kind` is now a related read-only Char. The
# client may still send it as a string code for back-compat we # client may still send it as a string code for back-compat - we
# translate it to kind_id below. # translate it to kind_id below.
allowed = { allowed = {
'name', 'code', 'icon', 'kind_id', 'description', 'name', 'code', 'icon', 'kind_id', 'description',
@@ -408,7 +408,7 @@ class SimpleRecipeController(http.Controller):
@http.route('/fp/simple_recipe/library/seed_defaults', type='jsonrpc', auth='user') @http.route('/fp/simple_recipe/library/seed_defaults', type='jsonrpc', auth='user')
def library_seed_defaults(self, template_id): def library_seed_defaults(self, template_id):
"""Run action_seed_default_inputs on this template. Idempotent """Run action_seed_default_inputs on this template. Idempotent -
only adds prompts whose name doesn't already exist. only adds prompts whose name doesn't already exist.
""" """
tpl = request.env['fp.step.template'].browse(int(template_id)) tpl = request.env['fp.step.template'].browse(int(template_id))
@@ -483,12 +483,12 @@ class SimpleRecipeController(http.Controller):
@http.route('/fp/simple_recipe/kinds/list', @http.route('/fp/simple_recipe/kinds/list',
type='jsonrpc', auth='user') type='jsonrpc', auth='user')
def kinds_list(self): def kinds_list(self):
"""Sub 14b Step Kind dropdown options for the inline library """Sub 14b - Step Kind dropdown options for the inline library
form. User-extensible via /fp/simple_recipe/kinds/create. form. User-extensible via /fp/simple_recipe/kinds/create.
2026-05-24 payload now includes `area_kind` + a humanized 2026-05-24 - payload now includes `area_kind` + a humanized
`area_kind_label` so the Simple Editor picker can render `area_kind_label` so the Simple Editor picker can render
"Masking Masking column" and authors see which Shop Floor "Masking - Masking column" and authors see which Shop Floor
column they're routing the step to. column they're routing the step to.
""" """
Kind = request.env['fp.step.kind'] Kind = request.env['fp.step.kind']
@@ -517,7 +517,7 @@ class SimpleRecipeController(http.Controller):
Auto-derives a code from the name if blank. Auto-derives a code from the name if blank.
2026-05-20 lockdown: manager group only. Kinds drive gates, 2026-05-20 lockdown: manager group only. Kinds drive gates,
milestones, and operator routing a user-created kind with no milestones, and operator routing - a user-created kind with no
corresponding behaviour is a silent foot-gun. The dropdown is corresponding behaviour is a silent foot-gun. The dropdown is
the curated catalog; adding a new kind requires manager the curated catalog; adding a new kind requires manager
approval and follow-up code work to wire the new code into the approval and follow-up code work to wire the new code into the
@@ -535,7 +535,7 @@ class SimpleRecipeController(http.Controller):
'Only Plating Managers can add new Step Kinds. The ' 'Only Plating Managers can add new Step Kinds. The '
'catalog is curated because each kind drives gates, ' 'catalog is curated because each kind drives gates, '
'milestones, and operator routing. Pick "Other" if ' 'milestones, and operator routing. Pick "Other" if '
'no existing kind fits or ask a manager to add the ' 'no existing kind fits - or ask a manager to add the '
'new kind once the downstream behaviour is wired up.' 'new kind once the downstream behaviour is wired up.'
), ),
} }
@@ -561,12 +561,12 @@ class SimpleRecipeController(http.Controller):
@http.route('/fp/simple_recipe/workflow_states/list', @http.route('/fp/simple_recipe/workflow_states/list',
type='jsonrpc', auth='user') type='jsonrpc', auth='user')
def workflow_states_list(self): def workflow_states_list(self):
"""Sub 14 workflow-state picker for the inline library form. """Sub 14 - workflow-state picker for the inline library form.
Returns active states ordered by sequence so the dropdown Returns active states ordered by sequence so the dropdown
renders left-to-right matching the status bar. renders left-to-right matching the status bar.
Soft-fail when fp.job.workflow.state isn't installed (rare, Soft-fail when fp.job.workflow.state isn't installed (rare,
only when fusion_plating_jobs is missing) empty list lets the only when fusion_plating_jobs is missing) - empty list lets the
dropdown render disabled instead of throwing. dropdown render disabled instead of throwing.
""" """
WS = request.env.get('fp.job.workflow.state') WS = request.env.get('fp.job.workflow.state')
@@ -594,7 +594,7 @@ class SimpleRecipeController(http.Controller):
new_vals = { new_vals = {
'parent_id': recipe.id, 'parent_id': recipe.id,
# Must be 'operation' fp.job._generate_steps() only creates # Must be 'operation' - fp.job._generate_steps() only creates
# fp.job.step rows for operation nodes. Flat 'step' children # fp.job.step rows for operation nodes. Flat 'step' children
# of a recipe were silently skipped pre-19.0.18.8.0. # of a recipe were silently skipped pre-19.0.18.8.0.
'node_type': 'operation', 'node_type': 'operation',
@@ -666,7 +666,7 @@ class SimpleRecipeController(http.Controller):
"""Update fields on an existing recipe step (operation node). """Update fields on an existing recipe step (operation node).
Whitelisted to the fields the inline edit panel actually surfaces Whitelisted to the fields the inline edit panel actually surfaces
never trust client-provided node_type / parent_id / etc. - never trust client-provided node_type / parent_id / etc.
""" """
node = request.env['fusion.plating.process.node'].browse(int(node_id)) node = request.env['fusion.plating.process.node'].browse(int(node_id))
if not node.exists(): if not node.exists():
@@ -674,7 +674,7 @@ class SimpleRecipeController(http.Controller):
node.check_access('write') node.check_access('write')
allowed = { allowed = {
'name', 'description', 'icon', 'name', 'description', 'icon',
'kind_id', # Sub 14b replaces default_kind 'kind_id', # Sub 14b - replaces default_kind
'requires_signoff', 'requires_predecessor_done', 'requires_signoff', 'requires_predecessor_done',
'parallel_start', # Sub 13 'parallel_start', # Sub 13
'triggers_workflow_state_id', # Sub 14 'triggers_workflow_state_id', # Sub 14
@@ -705,14 +705,14 @@ class SimpleRecipeController(http.Controller):
Naive version (pre-19.0.20.5.0): renumber the entire flat list Naive version (pre-19.0.20.5.0): renumber the entire flat list
1..N regardless of parent. Broke when the flat list mixed 1..N regardless of parent. Broke when the flat list mixed
operations and substeps siblings got out-of-order numbers operations and substeps - siblings got out-of-order numbers
because the list interleaved them. because the list interleaved them.
New version: group node ids by their parent_id, then renumber New version: group node ids by their parent_id, then renumber
within each parent. Substeps stay sequenced under their within each parent. Substeps stay sequenced under their
operation; operations stay sequenced under the recipe / sub- operation; operations stay sequenced under the recipe / sub-
process. Drop-across-parent shows up as a same-position no-op process. Drop-across-parent shows up as a same-position no-op
the UI's Promote/Demote buttons are the way to change - the UI's Promote/Demote buttons are the way to change
parents. parents.
""" """
Node = request.env['fusion.plating.process.node'] Node = request.env['fusion.plating.process.node']
@@ -782,7 +782,7 @@ class SimpleRecipeController(http.Controller):
If ``target_op_id`` is provided, the node becomes a substep of If ``target_op_id`` is provided, the node becomes a substep of
that operation. Otherwise it falls under the operation that operation. Otherwise it falls under the operation
immediately preceding it in the editor list (most common case immediately preceding it in the editor list (most common case
author drops a header into the preceding section). - author drops a header into the preceding section).
""" """
Node = request.env['fusion.plating.process.node'] Node = request.env['fusion.plating.process.node']
node = Node.browse(int(node_id)) node = Node.browse(int(node_id))
@@ -792,7 +792,7 @@ class SimpleRecipeController(http.Controller):
if node.node_type != 'operation': if node.node_type != 'operation':
return {'ok': False, 'error': 'not_an_operation', return {'ok': False, 'error': 'not_an_operation',
'message': 'Only operations can be demoted to substeps.'} 'message': 'Only operations can be demoted to substeps.'}
# Substeps of operations don't recurse further bail if this # Substeps of operations don't recurse further - bail if this
# operation has its own step children (would lose them on demote). # operation has its own step children (would lose them on demote).
if node.child_ids: if node.child_ids:
return {'ok': False, 'error': 'has_children', return {'ok': False, 'error': 'has_children',
@@ -864,7 +864,7 @@ class SimpleRecipeController(http.Controller):
Node = request.env['fusion.plating.process.node'] Node = request.env['fusion.plating.process.node']
new_vals = { new_vals = {
'parent_id': target_recipe.id, 'parent_id': target_recipe.id,
# See _SNAPSHOT_FIELDS comment operation, not step. # See _SNAPSHOT_FIELDS comment - operation, not step.
'node_type': 'operation', 'node_type': 'operation',
'sequence': src_node.sequence, 'sequence': src_node.sequence,
'source_template_id': src_node.source_template_id.id or False, 'source_template_id': src_node.source_template_id.id or False,
@@ -894,12 +894,12 @@ class SimpleRecipeController(http.Controller):
}) })
# ============================================================ # ============================================================
# Sub 12d per-recipe configurability endpoints # Sub 12d - per-recipe configurability endpoints
# ============================================================ # ============================================================
@http.route('/fp/simple_recipe/step/toggle_collect', type='jsonrpc', auth='user') @http.route('/fp/simple_recipe/step/toggle_collect', type='jsonrpc', auth='user')
def step_toggle_collect(self, node_id, collect): def step_toggle_collect(self, node_id, collect):
"""Master switch toggle collect_measurements on a recipe step.""" """Master switch - toggle collect_measurements on a recipe step."""
node = request.env['fusion.plating.process.node'].browse(int(node_id)) node = request.env['fusion.plating.process.node'].browse(int(node_id))
node.check_access('write') node.check_access('write')
node.collect_measurements = bool(collect) node.collect_measurements = bool(collect)
@@ -945,7 +945,7 @@ class SimpleRecipeController(http.Controller):
@http.route('/fp/simple_recipe/step/remove_input', type='jsonrpc', auth='user') @http.route('/fp/simple_recipe/step/remove_input', type='jsonrpc', auth='user')
def step_remove_input(self, input_id): def step_remove_input(self, input_id):
"""Delete a custom prompt. Library-sourced rows are protected """Delete a custom prompt. Library-sourced rows are protected
recipe authors should toggle collect=False instead of deleting.""" - recipe authors should toggle collect=False instead of deleting."""
Input = request.env['fusion.plating.process.node.input'] Input = request.env['fusion.plating.process.node.input']
rec = Input.browse(int(input_id)) rec = Input.browse(int(input_id))
if not rec.exists(): if not rec.exists():
@@ -961,7 +961,7 @@ class SimpleRecipeController(http.Controller):
return {'ok': True} return {'ok': True}
# ============================================================ # ============================================================
# Step instruction images recipe authors attach reference photos # Step instruction images - recipe authors attach reference photos
# / screenshots / diagrams to a step from the Simple Editor's inline # / screenshots / diagrams to a step from the Simple Editor's inline
# edit panel. Operators see them on the Record Inputs dialog and # edit panel. Operators see them on the Record Inputs dialog and
# the step quick-look modal at runtime. # the step quick-look modal at runtime.

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Copyright 2026 Nexa Systems Inc. DEMO DATA (temporary) Copyright 2026 Nexa Systems Inc. - DEMO DATA (temporary)
Remove this file and its manifest entry before production release. Remove this file and its manifest entry before production release.
--> -->
<odoo noupdate="1"> <odoo noupdate="1">
@@ -35,13 +35,13 @@
<!-- ========== FACILITIES ========== --> <!-- ========== FACILITIES ========== -->
<record id="demo_facility_main" model="fusion.plating.facility"> <record id="demo_facility_main" model="fusion.plating.facility">
<field name="name">Fusion Plating Main Plant</field> <field name="name">Fusion Plating - Main Plant</field>
<field name="code">FP-MAIN</field> <field name="code">FP-MAIN</field>
<field name="sequence">10</field> <field name="sequence">10</field>
</record> </record>
<record id="demo_facility_east" model="fusion.plating.facility"> <record id="demo_facility_east" model="fusion.plating.facility">
<field name="name">Fusion Plating East Annex</field> <field name="name">Fusion Plating - East Annex</field>
<field name="code">FP-EAST</field> <field name="code">FP-EAST</field>
<field name="sequence">20</field> <field name="sequence">20</field>
</record> </record>
@@ -85,7 +85,7 @@
<!-- ========== TANKS ========== --> <!-- ========== TANKS ========== -->
<!-- EN Line --> <!-- EN Line -->
<record id="demo_tank_en1" model="fusion.plating.tank"> <record id="demo_tank_en1" model="fusion.plating.tank">
<field name="name">EN Tank 1 Mid-Phos</field> <field name="name">EN Tank 1 - Mid-Phos</field>
<field name="code">T-EN-01</field> <field name="code">T-EN-01</field>
<field name="facility_id" ref="demo_facility_main"/> <field name="facility_id" ref="demo_facility_main"/>
<field name="work_center_id" ref="demo_wc_en_line"/> <field name="work_center_id" ref="demo_wc_en_line"/>
@@ -99,7 +99,7 @@
</record> </record>
<record id="demo_tank_en2" model="fusion.plating.tank"> <record id="demo_tank_en2" model="fusion.plating.tank">
<field name="name">EN Tank 2 High-Phos</field> <field name="name">EN Tank 2 - High-Phos</field>
<field name="code">T-EN-02</field> <field name="code">T-EN-02</field>
<field name="facility_id" ref="demo_facility_main"/> <field name="facility_id" ref="demo_facility_main"/>
<field name="work_center_id" ref="demo_wc_en_line"/> <field name="work_center_id" ref="demo_wc_en_line"/>
@@ -212,7 +212,7 @@
</record> </record>
<record id="demo_tank_an_dye" model="fusion.plating.tank"> <record id="demo_tank_an_dye" model="fusion.plating.tank">
<field name="name">Dye Immersion Tank Black</field> <field name="name">Dye Immersion Tank - Black</field>
<field name="code">T-AN-DYE</field> <field name="code">T-AN-DYE</field>
<field name="facility_id" ref="demo_facility_main"/> <field name="facility_id" ref="demo_facility_main"/>
<field name="work_center_id" ref="demo_wc_anodize_line"/> <field name="work_center_id" ref="demo_wc_anodize_line"/>

View File

@@ -2,14 +2,14 @@
<!-- <!--
Copyright 2026 Nexa Systems Inc. Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0) License OPL-1 (Odoo Proprietary License v1.0)
Demo recipe: Electroless Nickel Plating Steel Line Demo recipe: Electroless Nickel Plating - Steel Line
--> -->
<odoo> <odoo>
<data noupdate="1"> <data noupdate="1">
<!-- ===== ROOT: Electroless Nickel Plating Steel Line ===== --> <!-- ===== ROOT: Electroless Nickel Plating - Steel Line ===== -->
<record id="demo_recipe_en_steel" model="fusion.plating.process.node"> <record id="demo_recipe_en_steel" model="fusion.plating.process.node">
<field name="name">Electroless Nickel Plating Steel Line</field> <field name="name">Electroless Nickel Plating - Steel Line</field>
<field name="code">EN_STEEL</field> <field name="code">EN_STEEL</field>
<field name="node_type">recipe</field> <field name="node_type">recipe</field>
<field name="icon">fa-flask</field> <field name="icon">fa-flask</field>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- noupdate="1" is REQUIRED without it, every -u fusion_plating <!-- noupdate="1" is REQUIRED - without it, every -u fusion_plating
resets number_next back to 1, which corrupts the live sequence resets number_next back to 1, which corrupts the live sequence
on every module update. Matches the convention in fp_sequence_data.xml. --> on every module update. Matches the convention in fp_sequence_data.xml. -->
<odoo noupdate="1"> <odoo noupdate="1">

View File

@@ -4,7 +4,7 @@
License OPL-1 (Odoo Proprietary License v1.0) License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family. Part of the Fusion Plating product family.
Phase 1 Plating landing-page resolver. Phase 1 - Plating landing-page resolver.
The Plating app's root menu (menu_fp_root) calls this server action The Plating app's root menu (menu_fp_root) calls this server action
on click. It resolves which window action to open in this priority on click. It resolves which window action to open in this priority
@@ -20,7 +20,7 @@
<odoo noupdate="0"> <odoo noupdate="0">
<record id="action_fp_resolve_plating_landing" model="ir.actions.server"> <record id="action_fp_resolve_plating_landing" model="ir.actions.server">
<field name="name">Plating Open Landing Page</field> <field name="name">Plating - Open Landing Page</field>
<field name="model_id" ref="base.model_res_users"/> <field name="model_id" ref="base.model_res_users"/>
<field name="state">code</field> <field name="state">code</field>
<field name="code"><![CDATA[ <field name="code"><![CDATA[

View File

@@ -5,7 +5,7 @@
Part of the Fusion Plating product family. Part of the Fusion Plating product family.
Seed process categories. Categories are the one pinch of generic Seed process categories. Categories are the one pinch of generic
taxonomy core ships with specific process types themselves are taxonomy core ships with - specific process types themselves are
loaded by process packs (fusion_plating_process_en, etc.). loaded by process packs (fusion_plating_process_en, etc.).
--> -->
<odoo noupdate="1"> <odoo noupdate="1">
@@ -42,7 +42,7 @@
<field name="name">Preparation</field> <field name="name">Preparation</field>
<field name="code">prep</field> <field name="code">prep</field>
<field name="sequence">50</field> <field name="sequence">50</field>
<field name="description">Cleaning, degreasing, etching, activation surface prep before the main finishing step.</field> <field name="description">Cleaning, degreasing, etching, activation - surface prep before the main finishing step.</field>
</record> </record>
<record id="pcat_strip" model="fusion.plating.process.category"> <record id="pcat_strip" model="fusion.plating.process.category">

View File

@@ -2,7 +2,7 @@
<!-- <!--
Copyright 2026 Nexa Systems Inc. Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0) License OPL-1 (Odoo Proprietary License v1.0)
Recipe: ANODIZE (Sulfuric Anodize Type II) Recipe: ANODIZE (Sulfuric Anodize - Type II)
Source: Client's Steelhead export (April 2026 transcription). Source: Client's Steelhead export (April 2026 transcription).
Anodize is an umbrella workflow covering pre-treatment, the wet Anodize is an umbrella workflow covering pre-treatment, the wet

View File

@@ -2,7 +2,7 @@
<!-- <!--
Copyright 2026 Nexa Systems Inc. Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0) License OPL-1 (Odoo Proprietary License v1.0)
Recipe: ENP-ALUM-BASIC (Electroless Nickel Plating Aluminium Basic) Recipe: ENP-ALUM-BASIC (Electroless Nickel Plating - Aluminium Basic)
Source: Client's Steelhead export Source: Client's Steelhead export
--> -->
<odoo> <odoo>

View File

@@ -8,7 +8,7 @@
Notes: Notes:
- Steelhead allows a node to appear at multiple positions in the - Steelhead allows a node to appear at multiple positions in the
same recipe ("occurrence #2"). Our parent_id model is strict same recipe ("occurrence #2"). Our parent_id model is strict
single-parent so we duplicate the node see "Oven baking" single-parent so we duplicate the node - see "Oven baking"
appearing first as #1 and again later in the flow. appearing first as #1 and again later in the flow.
- The Electroless Nickel Plating sub-process holds the wet line; - The Electroless Nickel Plating sub-process holds the wet line;
everything inside it is a separate plating step (cleaner → everything inside it is a separate plating step (cleaner →
@@ -16,7 +16,7 @@
Tree: Tree:
ENP-SP (recipe) ENP-SP (recipe)
├── Oven baking (op, signoff, auto) first oven cycle ├── Oven baking (op, signoff, auto) - first oven cycle
│ ├── Ready for bake │ ├── Ready for bake
│ └── Bake (customer-visible) │ └── Bake (customer-visible)
├── Adhesion Test Coupon (op, opt-out) ├── Adhesion Test Coupon (op, opt-out)
@@ -43,7 +43,7 @@
│ ├── E-Nickel Plate (Hi-Phos) (SP-8) (opt-out) │ ├── E-Nickel Plate (Hi-Phos) (SP-8) (opt-out)
│ │ └── Rinse (SP-11) │ │ └── Rinse (SP-11)
│ └── Drying │ └── Drying
├── Oven baking (op, signoff, auto) second oven cycle (#2) ├── Oven baking (op, signoff, auto) - second oven cycle (#2)
│ ├── Ready for bake │ ├── Ready for bake
│ └── Bake (customer-visible) │ └── Bake (customer-visible)
├── De-racking (op, auto) ├── De-racking (op, auto)
@@ -310,7 +310,7 @@
<field name="customer_visible">True</field> <field name="customer_visible">True</field>
</record> </record>
<!-- 7e. E-Nickel Plate (Hi-Phos) (SP-8) opt-out --> <!-- 7e. E-Nickel Plate (Hi-Phos) (SP-8) - opt-out -->
<record id="enp_sp_enp_hi_phos" model="fusion.plating.process.node"> <record id="enp_sp_enp_hi_phos" model="fusion.plating.process.node">
<field name="name">E-Nickel Plate (Hi-Phos) (SP-8)</field> <field name="name">E-Nickel Plate (Hi-Phos) (SP-8)</field>
<field name="node_type">operation</field> <field name="node_type">operation</field>
@@ -343,7 +343,7 @@
<!-- ========================= 8. Post-plate Bake (H2 Embrittlement Relief) ========================= <!-- ========================= 8. Post-plate Bake (H2 Embrittlement Relief) =========================
Drives out hydrogen absorbed during plating. Must START within Drives out hydrogen absorbed during plating. Must START within
~4 hours of plate exit (ASTM B850 / AMS 2759). Parts still on ~4 hours of plate exit (ASTM B850 / AMS 2759). Parts still on
rack de-rack happens after this bake. --> rack - de-rack happens after this bake. -->
<record id="enp_sp_oven_baking_2" model="fusion.plating.process.node"> <record id="enp_sp_oven_baking_2" model="fusion.plating.process.node">
<field name="name">Post-plate Bake (H2 Embrittlement Relief)</field> <field name="name">Post-plate Bake (H2 Embrittlement Relief)</field>
<field name="node_type">operation</field> <field name="node_type">operation</field>

View File

@@ -248,7 +248,7 @@
<field name="customer_visible">True</field> <field name="customer_visible">True</field>
</record> </record>
<!-- 5d. E-Nickel Plate (Mid Phos)(S-9) opt-out (chosen per job) --> <!-- 5d. E-Nickel Plate (Mid Phos)(S-9) - opt-out (chosen per job) -->
<record id="enp_sb_sl_enp_mid_phos" model="fusion.plating.process.node"> <record id="enp_sb_sl_enp_mid_phos" model="fusion.plating.process.node">
<field name="name">E-Nickel Plate (Mid Phos)(S-9)</field> <field name="name">E-Nickel Plate (Mid Phos)(S-9)</field>
<field name="node_type">operation</field> <field name="node_type">operation</field>
@@ -276,7 +276,7 @@
<field name="customer_visible">True</field> <field name="customer_visible">True</field>
</record> </record>
<!-- 5e. E-Nickel Plate (S-10) primary plating --> <!-- 5e. E-Nickel Plate (S-10) - primary plating -->
<record id="enp_sb_sl_enp_s10" model="fusion.plating.process.node"> <record id="enp_sb_sl_enp_s10" model="fusion.plating.process.node">
<field name="name">E-Nickel Plate (S-10)</field> <field name="name">E-Nickel Plate (S-10)</field>
<field name="node_type">operation</field> <field name="node_type">operation</field>
@@ -328,7 +328,7 @@
<!-- ========================= 6. Post-plate Bake (H2 Embrittlement Relief) ========================= <!-- ========================= 6. Post-plate Bake (H2 Embrittlement Relief) =========================
Drives out hydrogen absorbed during plating. Must START within Drives out hydrogen absorbed during plating. Must START within
~4 hours of plate exit (ASTM B850 / AMS 2759). Parts still on ~4 hours of plate exit (ASTM B850 / AMS 2759). Parts still on
rack de-rack happens after this bake. --> rack - de-rack happens after this bake. -->
<record id="enp_sb_oven_baking" model="fusion.plating.process.node"> <record id="enp_sb_oven_baking" model="fusion.plating.process.node">
<field name="name">Post-plate Bake (H2 Embrittlement Relief)</field> <field name="name">Post-plate Bake (H2 Embrittlement Relief)</field>
<field name="node_type">operation</field> <field name="node_type">operation</field>

View File

@@ -2,7 +2,7 @@
<!-- <!--
Copyright 2026 Nexa Systems Inc. Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0) License OPL-1 (Odoo Proprietary License v1.0)
Recipe: GENERAL_PROCESSING (General Processing common workflow umbrella) Recipe: GENERAL_PROCESSING (General Processing - common workflow umbrella)
Source: Client's Steelhead export (April 2026 transcription). Source: Client's Steelhead export (April 2026 transcription).
This is the "envelope" workflow that sits AROUND every job: contract This is the "envelope" workflow that sits AROUND every job: contract
@@ -11,10 +11,10 @@
ENP-SP, ENP-ALUM, etc.) handle the technical processing in between. ENP-SP, ENP-ALUM, etc.) handle the technical processing in between.
Steelhead-only features that did NOT migrate (we don't have these Steelhead-only features that did NOT migrate (we don't have these
wired up yet let me know if you want them): wired up yet - let me know if you want them):
- "Default Lead Time" on the recipe - "Default Lead Time" on the recipe
- "Product" linkage from the recipe to a product/service record - "Product" linkage from the recipe to a product/service record
- "Contract Review Users" list of users who must approve - "Contract Review Users" - list of users who must approve
contract review before the job can advance contract review before the job can advance
- Treatment Groups / "Use Price Builders" hook into pricing - Treatment Groups / "Use Price Builders" hook into pricing

View File

@@ -22,9 +22,9 @@
<field name="company_id" eval="False"/> <field name="company_id" eval="False"/>
</record> </record>
<!-- Sub 12b Move Parts / Move Rack chain-of-custody log --> <!-- Sub 12b - Move Parts / Move Rack chain-of-custody log -->
<record id="seq_fp_job_step_move" model="ir.sequence"> <record id="seq_fp_job_step_move" model="ir.sequence">
<field name="name">FP Move Log</field> <field name="name">FP - Move Log</field>
<field name="code">fp.job.step.move</field> <field name="code">fp.job.step.move</field>
<field name="prefix">FP/MOVE/%(year)s/</field> <field name="prefix">FP/MOVE/%(year)s/</field>
<field name="padding">5</field> <field name="padding">5</field>

View File

@@ -12,15 +12,15 @@
adhesion_test, salt_spray, packaging, gating) are kept adhesion_test, salt_spray, packaging, gating) are kept
in this XML for history but flipped active=False by the in this XML for history but flipped active=False by the
migration script so they no longer appear in the migration script so they no longer appear in the
dropdown and bulk-remapped onto the new `other` / dropdown - and bulk-remapped onto the new `other` /
`wet_process` kinds. `wet_process` kinds.
- New: `other` (catch-all, default) and `wet_process` - New: `other` (catch-all, default) and `wet_process`
(covers all bath-based steps). (covers all bath-based steps).
- `mask` covers Masking + De-Masking, `racking` covers - `mask` covers Masking + De-Masking, `racking` covers
Racking + De-Racking operators differentiate by the Racking + De-Racking - operators differentiate by the
step name. step name.
2026-05-24 update (19.0.21.2.0 Shop Floor live-step fix): 2026-05-24 update (19.0.21.2.0 - Shop Floor live-step fix):
- New `area_kind` field on fp.step.kind drives plant-view - New `area_kind` field on fp.step.kind drives plant-view
column routing. Every record below carries an column routing. Every record below carries an
area_kind. New `blast` kind for the Blasting column. area_kind. New `blast` kind for the Blasting column.
@@ -29,7 +29,7 @@
now since they're meant to be active going forward). --> now since they're meant to be active going forward). -->
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- ACTIVE KINDS visible in dropdown --> <!-- ACTIVE KINDS - visible in dropdown -->
<!-- ============================================================ --> <!-- ============================================================ -->
<record id="step_kind_other" model="fp.step.kind"> <record id="step_kind_other" model="fp.step.kind">
@@ -224,7 +224,7 @@
</record> </record>
<!-- ============================================================ <!-- ============================================================
Default inputs per kind 1:1 port of DEFAULT_INPUTS_BY_KIND Default inputs per kind - 1:1 port of DEFAULT_INPUTS_BY_KIND
============================================================ --> ============================================================ -->
<!-- receiving --> <!-- receiving -->
@@ -407,7 +407,7 @@
<field name="kind_id" ref="step_kind_rinse"/> <field name="kind_id" ref="step_kind_rinse"/>
<field name="name">Conductivity</field> <field name="name">Conductivity</field>
<field name="input_type">number</field> <field name="input_type">number</field>
<field name="hint">µS/cm required for DI rinses</field> <field name="hint">µS/cm - required for DI rinses</field>
<field name="sequence">20</field> <field name="sequence">20</field>
</record> </record>
<record id="step_kind_input_rinse_time" model="fp.step.kind.default.input"> <record id="step_kind_input_rinse_time" model="fp.step.kind.default.input">
@@ -499,7 +499,7 @@
<field name="kind_id" ref="step_kind_plate"/> <field name="kind_id" ref="step_kind_plate"/>
<field name="name">Current Density</field> <field name="name">Current Density</field>
<field name="input_type">number</field> <field name="input_type">number</field>
<field name="hint">ASF electroplate only</field> <field name="hint">ASF - electroplate only</field>
<field name="sequence">60</field> <field name="sequence">60</field>
</record> </record>
<record id="step_kind_input_plate_thick" model="fp.step.kind.default.input"> <record id="step_kind_input_plate_thick" model="fp.step.kind.default.input">

View File

@@ -103,7 +103,7 @@
]]></field> ]]></field>
</record> </record>
<!-- 2026-05-24 additions (19.0.21.2.0 Shop Floor live-step fix) --> <!-- 2026-05-24 additions (19.0.21.2.0 - Shop Floor live-step fix) -->
<record id="fp_step_template_hwp_a15" model="fp.step.template"> <record id="fp_step_template_hwp_a15" model="fp.step.template">
<field name="name">Hot Water Porosity Test (A-15)</field> <field name="name">Hot Water Porosity Test (A-15)</field>

View File

@@ -30,7 +30,7 @@
<field name="code">plating_op</field> <field name="code">plating_op</field>
<field name="sequence">30</field> <field name="sequence">30</field>
<field name="icon">fa-flask</field> <field name="icon">fa-flask</field>
<field name="description">Runs the plating line chemistry checks, dwell, thickness.</field> <field name="description">Runs the plating line - chemistry checks, dwell, thickness.</field>
</record> </record>
<record id="work_role_demask" model="fp.work.role"> <record id="work_role_demask" model="fp.work.role">

View File

@@ -3,12 +3,12 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
"""19.0.12.1.0 Convert every free-text UoM column to the curated """19.0.12.1.0 - Convert every free-text UoM column to the curated
selection keys defined in models/_fp_uom_selection.py. selection keys defined in models/_fp_uom_selection.py.
Runs after fusion_plating's tables have been re-described (so the Runs after fusion_plating's tables have been re-described (so the
columns are now Selection-typed at the ORM level), but before users columns are now Selection-typed at the ORM level), but before users
hit the new views. Idempotent re-running maps already-converted hit the new views. Idempotent - re-running maps already-converted
values to themselves and leaves them in place. values to themselves and leaves them in place.
""" """
@@ -32,7 +32,7 @@ def migrate(cr, version):
('fusion_plating_process_node_input', 'uom', 'process node input'), ('fusion_plating_process_node_input', 'uom', 'process node input'),
('fusion_plating_process_node_input', 'target_unit', 'process node target'), ('fusion_plating_process_node_input', 'target_unit', 'process node target'),
('fp_step_template_input', 'target_unit', 'step template input target'), ('fp_step_template_input', 'target_unit', 'step template input target'),
# compliance (only migrated when the module is installed the # compliance (only migrated when the module is installed - the
# helper is no-op when the table doesn't exist) # helper is no-op when the table doesn't exist)
('fusion_plating_discharge_limit', 'uom', 'discharge limit'), ('fusion_plating_discharge_limit', 'uom', 'discharge limit'),
('fusion_plating_discharge_sample_line', 'uom', 'discharge sample line'), ('fusion_plating_discharge_sample_line', 'uom', 'discharge sample line'),
@@ -50,7 +50,7 @@ def migrate(cr, version):
total_cleared += cleared total_cleared += cleared
_logger.info( _logger.info(
'Fusion Plating 19.0.12.1.0 UoM migration complete: ' 'Fusion Plating 19.0.12.1.0 - UoM migration complete: '
'%s rewritten, %s cleared (across %s columns).', '%s rewritten, %s cleared (across %s columns).',
total_rewritten, total_cleared, len(targets), total_rewritten, total_cleared, len(targets),
) )

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
"""19.0.12.4.0 Step-library polish + Policy B Contract Review backfill. """19.0.12.4.0 - Step-library polish + Policy B Contract Review backfill.
post_init_hook only fires on fresh install. Existing DBs upgrading post_init_hook only fires on fresh install. Existing DBs upgrading
from pre-Policy-B versions need this migration to: from pre-Policy-B versions need this migration to:
@@ -21,12 +21,12 @@ from pre-Policy-B versions need this migration to:
3. Add canonical missing entries (Soak Clean, Rinse, Etch, Acid Dip, 3. Add canonical missing entries (Soak Clean, Rinse, Etch, Acid Dip,
Drying, Inspection, Shipping, Water Break Test, Desmut, Zincate) Drying, Inspection, Shipping, Water Break Test, Desmut, Zincate)
that ENP-ALUM-BASIC's seed didn't include these are the names that ENP-ALUM-BASIC's seed didn't include - these are the names
a fresh estimator would expect to find when they open the library a fresh estimator would expect to find when they open the library
from scratch. Without them, an empty recipe has no obvious starting from scratch. Without them, an empty recipe has no obvious starting
templates for cleaning / rinsing / standard inspection. templates for cleaning / rinsing / standard inspection.
All three steps are idempotent re-running on an already-fixed DB All three steps are idempotent - re-running on an already-fixed DB
is a no-op. is a no-op.
""" """

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
"""19.0.12.5.0 Backfill default_kind on existing recipe nodes. """19.0.12.5.0 - Backfill default_kind on existing recipe nodes.
The Page-2 audit (2026-04-28) showed that pre-Sub-12a recipe nodes The Page-2 audit (2026-04-28) showed that pre-Sub-12a recipe nodes
have NULL `default_kind` because the field was added later. The have NULL `default_kind` because the field was added later. The
@@ -19,7 +19,7 @@ default_kind, resolves a sensible kind via the central
It also walks `fp.job.step` rows whose `kind` is the legacy 'other' It also walks `fp.job.step` rows whose `kind` is the legacy 'other'
placeholder and re-derives `kind` from `recipe_node_id.default_kind` placeholder and re-derives `kind` from `recipe_node_id.default_kind`
(after the node-side backfill above sets it). Non-other kinds are (after the node-side backfill above sets it). Non-other kinds are
left alone operator may have set them deliberately. left alone - operator may have set them deliberately.
Idempotent. Idempotent.
""" """
@@ -33,7 +33,7 @@ from odoo.addons.fusion_plating import fp_resolve_step_kind
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# Same mapping as in fp_job.py keep them in sync. # Same mapping as in fp_job.py - keep them in sync.
_NODE_KIND_TO_STEP_KIND = { _NODE_KIND_TO_STEP_KIND = {
'cleaning': 'wet', 'cleaning': 'wet',
'etch': 'wet', 'etch': 'wet',

View File

@@ -3,13 +3,13 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
""" """
Migration 19.0.18.12.0 Sub 13 sequential step enforcement. Migration 19.0.18.12.0 - Sub 13 sequential step enforcement.
Background: Background:
The legacy per-step `requires_predecessor_done` opt-in defaulted to The legacy per-step `requires_predecessor_done` opt-in defaulted to
False, so 98.7% of operations system-wide had no enforcement and False, so 98.7% of operations system-wide had no enforcement and
operators were able to start arbitrary steps out of order (e.g. job operators were able to start arbitrary steps out of order (e.g. job
WH/JOB/00339 Incoming Inspection ran while Contract Review was WH/JOB/00339 - Incoming Inspection ran while Contract Review was
still in progress). still in progress).
This migration: This migration:
@@ -19,7 +19,7 @@ This migration:
and continue to work for any recipe whose author opts back into and continue to work for any recipe whose author opts back into
free-flow mode (sets enforce_sequential = False). free-flow mode (sets enforce_sequential = False).
Idempotent safe to re-run. Idempotent - safe to re-run.
""" """
import logging import logging
@@ -29,14 +29,14 @@ _logger = logging.getLogger(__name__)
def migrate(cr, version): def migrate(cr, version):
if not version: if not version:
return # Brand new install defaults already correct. return # Brand new install - defaults already correct.
_logger.info( _logger.info(
'[migration 19.0.18.12.0] Promoting all existing recipes to ' '[migration 19.0.18.12.0] Promoting all existing recipes to '
'enforce_sequential=True (Sub 13 sequential step enforcement).' 'enforce_sequential=True (Sub 13 - sequential step enforcement).'
) )
# Direct SQL avoids loading the ORM at this stage and is fast. # Direct SQL - avoids loading the ORM at this stage and is fast.
# We intentionally only flip nodes that are CURRENTLY False; nodes # We intentionally only flip nodes that are CURRENTLY False; nodes
# already set True (or set by a manual install of a newer version) # already set True (or set by a manual install of a newer version)
# are skipped so the operation is clean to re-run. # are skipped so the operation is clean to re-run.

View File

@@ -2,19 +2,19 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
"""19.0.18.13.0 Backfill kind_id from legacy default_kind text values. """19.0.18.13.0 - Backfill kind_id from legacy default_kind text values.
Sub 14b `default_kind` was a Selection on fp.step.template and Sub 14b - `default_kind` was a Selection on fp.step.template and
fusion.plating.process.node. It is now a stored related Char that fusion.plating.process.node. It is now a stored related Char that
reads from kind_id.code on a new fp.step.kind catalog. reads from kind_id.code on a new fp.step.kind catalog.
When a Selection field is converted into a Many2one in code, Odoo's When a Selection field is converted into a Many2one in code, Odoo's
ORM does NOT auto-migrate the data the new column starts empty. This ORM does NOT auto-migrate the data - the new column starts empty. This
script reads the (still-present) text values out of the OLD `default_kind` script reads the (still-present) text values out of the OLD `default_kind`
column and points kind_id at the seeded fp.step.kind record whose code column and points kind_id at the seeded fp.step.kind record whose code
matches. matches.
Idempotent running it twice is a no-op. Idempotent - running it twice is a no-op.
""" """
import logging import logging
@@ -29,7 +29,7 @@ def migrate(cr, version):
code_to_id = {code: kid for kid, code in cr.fetchall()} code_to_id = {code: kid for kid, code in cr.fetchall()}
if not code_to_id: if not code_to_id:
_logger.warning( _logger.warning(
'19.0.18.13.0: fp_step_kind is empty seed data did not ' '19.0.18.13.0: fp_step_kind is empty - seed data did not '
'load before post-migrate. Skipping backfill.', 'load before post-migrate. Skipping backfill.',
) )
return return
@@ -74,7 +74,7 @@ def _backfill(cr, table, text_col, m2o_col, code_to_id):
"""Generic backfill helper. Reads `text_col` text values, updates """Generic backfill helper. Reads `text_col` text values, updates
`m2o_col` per row using the supplied code→id lookup table. `m2o_col` per row using the supplied code→id lookup table.
""" """
# Defensive if either column is missing (fresh install, never had # Defensive - if either column is missing (fresh install, never had
# default_kind data) skip silently. # default_kind data) skip silently.
cr.execute(""" cr.execute("""
SELECT column_name FROM information_schema.columns SELECT column_name FROM information_schema.columns
@@ -89,7 +89,7 @@ def _backfill(cr, table, text_col, m2o_col, code_to_id):
return return
# Pull every row with a non-null default_kind that doesn't yet have # Pull every row with a non-null default_kind that doesn't yet have
# a kind_id leaves manually-edited rows alone if migration is # a kind_id - leaves manually-edited rows alone if migration is
# rerun. # rerun.
cr.execute(f""" cr.execute(f"""
SELECT id, {text_col} FROM {table} SELECT id, {text_col} FROM {table}
@@ -120,7 +120,7 @@ def _backfill(cr, table, text_col, m2o_col, code_to_id):
updated += len(ids) updated += len(ids)
_logger.info( _logger.info(
'19.0.18.13.0: backfilled %s.%s on %s rows (%s skipped code ' '19.0.18.13.0: backfilled %s.%s on %s rows (%s skipped - code '
'not found in fp_step_kind seed data)', 'not found in fp_step_kind seed data)',
table, m2o_col, updated, skipped, table, m2o_col, updated, skipped,
) )

View File

@@ -2,10 +2,10 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
"""19.0.18.13.0 Snapshot legacy default_kind values BEFORE the field """19.0.18.13.0 - Snapshot legacy default_kind values BEFORE the field
type change wipes them. type change wipes them.
Sub 14b `default_kind` was a Selection on fp.step.template and Sub 14b - `default_kind` was a Selection on fp.step.template and
fusion.plating.process.node. The new model code defines it as a fusion.plating.process.node. The new model code defines it as a
stored related Char (`related='kind_id.code', store=True`). On first stored related Char (`related='kind_id.code', store=True`). On first
ORM access after upgrade, Odoo recomputes the stored related from ORM access after upgrade, Odoo recomputes the stored related from
@@ -13,7 +13,7 @@ kind_id (which is NULL → wipes the column). We must snapshot the
original text values now, so post-migrate can read them and resolve original text values now, so post-migrate can read them and resolve
kind_id. kind_id.
Idempotent running it twice is a no-op (snapshot column gets Idempotent - running it twice is a no-op (snapshot column gets
re-written with the same data). re-written with the same data).
""" """

View File

@@ -1,12 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
"""Post-migration for 19.0.18.7.0 Step Library audit expansion. """Post-migration for 19.0.18.7.0 - Step Library audit expansion.
1. Default `collect=True` on all existing recipe-step inputs. 1. Default `collect=True` on all existing recipe-step inputs.
2. Default `collect_measurements=True` on all existing recipe steps. 2. Default `collect_measurements=True` on all existing recipe steps.
3. Re-run action_seed_default_inputs on every existing template to 3. Re-run action_seed_default_inputs on every existing template to
pull in the newly-added prompts (idempotent skips rows whose pull in the newly-added prompts (idempotent - skips rows whose
name is already present, so user edits survive). name is already present, so user edits survive).
4. Backfill template_input_id by name-matching against the linked 4. Backfill template_input_id by name-matching against the linked
library template (best-effort). library template (best-effort).
@@ -55,7 +55,7 @@ def migrate(cr, version):
) )
_logger.info("Re-seeded defaults on %s templates", len(templates)) _logger.info("Re-seeded defaults on %s templates", len(templates))
# 4. Backfill template_input_id name-match recipe-node inputs against # 4. Backfill template_input_id - name-match recipe-node inputs against
# their parent recipe's source library template. # their parent recipe's source library template.
# Note: fusion_plating_process_node_input.name is plain varchar; # Note: fusion_plating_process_node_input.name is plain varchar;
# fp_step_template_input.name is translatable JSONB (use ->>'en_US'). # fp_step_template_input.name is translatable JSONB (use ->>'en_US').

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
"""Post-migration for 19.0.18.8.0 Simple Editor node_type fix. """Post-migration for 19.0.18.8.0 - Simple Editor node_type fix.
Background Background
---------- ----------
@@ -10,14 +10,14 @@ into a recipe with `node_type='step'` directly under the recipe root.
But fp.job._generate_steps() (in fusion_plating_jobs/models/fp_job.py) But fp.job._generate_steps() (in fusion_plating_jobs/models/fp_job.py)
only creates fp.job.step rows for nodes whose node_type is 'operation'. only creates fp.job.step rows for nodes whose node_type is 'operation'.
Top-level 'step' children of a recipe were silently skipped, meaning Top-level 'step' children of a recipe were silently skipped, meaning
Simple-Editor recipes generated zero job steps no traveller content, Simple-Editor recipes generated zero job steps - no traveller content,
no shopfloor tablet entries, no CoC moves. no shopfloor tablet entries, no CoC moves.
Fix Fix
--- ---
Promote every `step` node whose direct parent is a `recipe` to Promote every `step` node whose direct parent is a `recipe` to
`node_type='operation'`. Tree-editor authored 'step' nodes (which sit `node_type='operation'`. Tree-editor authored 'step' nodes (which sit
under `operation` parents) are left untouched the filter is on under `operation` parents) are left untouched - the filter is on
`parent.node_type='recipe'`. `parent.node_type='recipe'`.
Idempotent: a second run finds no `step` children of recipes and is Idempotent: a second run finds no `step` children of recipes and is
@@ -39,7 +39,7 @@ _AUDIT_BODY = Markup(
'<p><strong>Recipe migrated to v19.0.18.8.0 step layout.</strong></p>' '<p><strong>Recipe migrated to v19.0.18.8.0 step layout.</strong></p>'
'<p>Step nodes that were direct children of this recipe (Simple ' '<p>Step nodes that were direct children of this recipe (Simple '
'Editor authoring) have been promoted to operation nodes so they ' 'Editor authoring) have been promoted to operation nodes so they '
'generate work-order steps correctly. No data was lost only ' 'generate work-order steps correctly. No data was lost - only '
'<code>node_type</code> changed.</p>' '<code>node_type</code> changed.</p>'
'<p>If this recipe was authored via the Tree Editor with explicit ' '<p>If this recipe was authored via the Tree Editor with explicit '
'sub-process / operation hierarchy, this migration was a no-op ' 'sub-process / operation hierarchy, this migration was a no-op '
@@ -73,7 +73,7 @@ def migrate(cr, version):
) )
return return
# Flip the node_type. Filter is intentionally narrow only direct # Flip the node_type. Filter is intentionally narrow - only direct
# children of a recipe get promoted. Tree-editor sub-step rows # children of a recipe get promoted. Tree-editor sub-step rows
# (parent.node_type='operation') are untouched. # (parent.node_type='operation') are untouched.
cr.execute(""" cr.execute("""

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# #
# 2026-05-20 Step Kind curation post-migrate. # 2026-05-20 Step Kind curation - post-migrate.
# #
# Runs AFTER the schema settles. Marks the 15 retired kinds inactive so # Runs AFTER the schema settles. Marks the 15 retired kinds inactive so
# they no longer appear in the dropdown. We keep them in the DB rather # they no longer appear in the dropdown. We keep them in the DB rather
@@ -13,7 +13,7 @@
# #
# Pre-migrate has already re-mapped every template + node pointing at # Pre-migrate has already re-mapped every template + node pointing at
# these kinds, so flipping active=False has no operator-facing data # these kinds, so flipping active=False has no operator-facing data
# impact it only hides them from pickers. # impact - it only hides them from pickers.
import logging import logging

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# #
# 2026-05-20 Step Kind curation pre-migrate. # 2026-05-20 Step Kind curation - pre-migrate.
# #
# Runs BEFORE the model schema is applied so `kind_id` can become # Runs BEFORE the model schema is applied so `kind_id` can become
# required=True without choking on existing NULL rows. Three jobs: # required=True without choking on existing NULL rows. Three jobs:
@@ -37,7 +37,7 @@ import logging
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# -- Remap table retired-kind code -> new-kind code ---------------------- # -- Remap table - retired-kind code -> new-kind code ----------------------
# IMPORTANT: `plate` stays active (its own milestone trigger). Only the # IMPORTANT: `plate` stays active (its own milestone trigger). Only the
# wet-bath specialisations roll up into wet_process. # wet-bath specialisations roll up into wet_process.
_REMAP = { _REMAP = {
@@ -60,7 +60,7 @@ _REMAP = {
# -- Name-match heuristic for NULL backfill -------------------------------- # -- Name-match heuristic for NULL backfill --------------------------------
# Each rule: (substring to match in lower(name), target kind code). First # Each rule: (substring to match in lower(name), target kind code). First
# match wins. Order matters more specific patterns come first. # match wins. Order matters - more specific patterns come first.
_NAME_HEURISTIC = [ _NAME_HEURISTIC = [
# Most specific # Most specific
('qa-005', 'contract_review'), ('qa-005', 'contract_review'),
@@ -111,7 +111,7 @@ _NAME_HEURISTIC = [
('dry', 'wet_process'), ('dry', 'wet_process'),
('water break', 'wet_process'), ('water break', 'wet_process'),
('wbf', 'wet_process'), ('wbf', 'wet_process'),
# Gating / ready / wait soft sequencers, no behaviour # Gating / ready / wait - soft sequencers, no behaviour
('ready for', 'other'), ('ready for', 'other'),
('ready to', 'other'), ('ready to', 'other'),
] ]
@@ -127,19 +127,19 @@ def migrate(cr, version):
cr.execute("SELECT id, code FROM fp_step_kind") cr.execute("SELECT id, code FROM fp_step_kind")
by_code = {code: kid for kid, code in cr.fetchall()} by_code = {code: kid for kid, code in cr.fetchall()}
if 'other' not in by_code: if 'other' not in by_code:
_logger.error('pre-migrate: `other` kind missing after _ensure_kind aborting') _logger.error('pre-migrate: `other` kind missing after _ensure_kind - aborting')
return return
other_id = by_code['other'] other_id = by_code['other']
# 3. Re-map references to retired kinds. # 3. Re-map references to retired kinds.
# `default_kind` is a stored related on `kind_id.code` updating # `default_kind` is a stored related on `kind_id.code` - updating
# kind_id via SQL doesn't auto-recompute the stored copy, so we # kind_id via SQL doesn't auto-recompute the stored copy, so we
# write both columns together. # write both columns together.
for retired_code, new_code in _REMAP.items(): for retired_code, new_code in _REMAP.items():
retired_id = by_code.get(retired_code) retired_id = by_code.get(retired_code)
new_id = by_code.get(new_code) or other_id new_id = by_code.get(new_code) or other_id
if not retired_id: if not retired_id:
continue # not in this DB nothing to remap continue # not in this DB - nothing to remap
cr.execute(""" cr.execute("""
UPDATE fp_step_template UPDATE fp_step_template
SET kind_id = %s, default_kind = %s SET kind_id = %s, default_kind = %s
@@ -197,7 +197,7 @@ def migrate(cr, version):
(kid, by_id.get(kid, 'other'), ids), (kid, by_id.get(kid, 'other'), ids),
) )
_logger.info( _logger.info(
'Step Kind curation: backfilled %d %s row(s) ' 'Step Kind curation: backfilled %d %s row(s) - '
'distribution: %s', 'distribution: %s',
len(rows), table, len(rows), table,
{next(c for c, i in by_code.items() if i == k): len(v) {next(c for c, i in by_code.items() if i == k): len(v)
@@ -224,7 +224,7 @@ def _ensure_kind(cr, code, name, icon, sequence):
Also registers the ir.model.data entry so the subsequent XML data Also registers the ir.model.data entry so the subsequent XML data
load (which runs AFTER pre-migrate) sees the xmlid as already load (which runs AFTER pre-migrate) sees the xmlid as already
bound and skips creation otherwise we get duplicate records. bound and skips creation - otherwise we get duplicate records.
""" """
cr.execute("SELECT id FROM fp_step_kind WHERE code = %s", (code,)) cr.execute("SELECT id FROM fp_step_kind WHERE code = %s", (code,))
row = cr.fetchone() row = cr.fetchone()

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# #
# 19.0.21.0.0 Plant-view Shop Floor kanban redesign. # 19.0.21.0.0 - Plant-view Shop Floor kanban redesign.
# Backfill fp.work.centre.area_kind from the existing `kind` taxonomy so # Backfill fp.work.centre.area_kind from the existing `kind` taxonomy so
# every routing station has a defined Floor Column on day 1. Admins can # every routing station has a defined Floor Column on day 1. Admins can
# override afterwards via Configuration → Shop Setup → Routing Stations. # override afterwards via Configuration → Shop Setup → Routing Stations.

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Phase H: fire role-migration preview creation on `-u fusion_plating`. """Phase H: fire role-migration preview creation on `-u fusion_plating`.
Odoo 19's `post_init_hook` ONLY fires on fresh install never on Odoo 19's `post_init_hook` ONLY fires on fresh install - never on
upgrade. So on entech (and any other already-installed deployment), upgrade. So on entech (and any other already-installed deployment),
`-u fusion_plating` after this branch lands would otherwise leave the `-u fusion_plating` after this branch lands would otherwise leave the
post_init_hook's `_fp_post_init_role_migration` un-fired and the post_init_hook's `_fp_post_init_role_migration` un-fired and the
@@ -27,7 +27,7 @@ def migrate(cr, version):
'Fusion Plating: role-migration preview check ran via post-migrate.py' 'Fusion Plating: role-migration preview check ran via post-migrate.py'
) )
except Exception as e: except Exception as e:
# Migration scripts must not block module upgrade log and swallow # Migration scripts must not block module upgrade - log and swallow
_logger.exception( _logger.exception(
'Failed to run role-migration preview check (non-fatal): %s', e 'Failed to run role-migration preview check (non-fatal): %s', e
) )

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
"""19.0.21.2.0 Shop Floor live-step + kind taxonomy. """19.0.21.2.0 - Shop Floor live-step + kind taxonomy.
Seeds fp.step.kind.area_kind on existing kinds BEFORE the required Seeds fp.step.kind.area_kind on existing kinds BEFORE the required
NOT NULL constraint on the new field hits the schema. Also activates NOT NULL constraint on the new field hits the schema. Also activates
@@ -50,14 +50,14 @@ KIND_TO_AREA = {
def migrate(cr, version): def migrate(cr, version):
# Phase 1 Pre-create the column NULL-permitting so we can seed it # Phase 1 - Pre-create the column NULL-permitting so we can seed it
# BEFORE Odoo's schema sync tries to enforce NOT NULL. # BEFORE Odoo's schema sync tries to enforce NOT NULL.
cr.execute( cr.execute(
"ALTER TABLE fp_step_kind " "ALTER TABLE fp_step_kind "
"ADD COLUMN IF NOT EXISTS area_kind VARCHAR" "ADD COLUMN IF NOT EXISTS area_kind VARCHAR"
) )
# Phase 2 Seed area_kind on existing kinds. Idempotent: only fills # Phase 2 - Seed area_kind on existing kinds. Idempotent: only fills
# NULLs, so re-running -u is safe. # NULLs, so re-running -u is safe.
seeded = 0 seeded = 0
for code, area in KIND_TO_AREA.items(): for code, area in KIND_TO_AREA.items():
@@ -73,7 +73,7 @@ def migrate(cr, version):
seeded, seeded,
) )
# Phase 3 Fallback: any user-created custom kinds not in our seed # Phase 3 - Fallback: any user-created custom kinds not in our seed
# map → 'plating'. Clears the NOT NULL constraint for any leftover. # map → 'plating'. Clears the NOT NULL constraint for any leftover.
cr.execute( cr.execute(
"UPDATE fp_step_kind SET area_kind = 'plating' " "UPDATE fp_step_kind SET area_kind = 'plating' "
@@ -85,7 +85,7 @@ def migrate(cr, version):
cr.rowcount, cr.rowcount,
) )
# Phase 4 Activate kinds we need for full coverage. # Phase 4 - Activate kinds we need for full coverage.
activated = 0 activated = 0
for code in ('derack', 'demask', 'gating'): for code in ('derack', 'demask', 'gating'):
cr.execute( cr.execute(

View File

@@ -1,12 +1,12 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
"""19.0.21.4.0 Apply office-user menu visibility on -u. """19.0.21.4.0 - Apply office-user menu visibility on -u.
post_init_hook only fires on FIRST install (CLAUDE.md Rule 13d). post_init_hook only fires on FIRST install (CLAUDE.md Rule 13d).
This script runs the same helper on every -u so existing installs This script runs the same helper on every -u so existing installs
get the menu restrictions applied without needing to uninstall + get the menu restrictions applied without needing to uninstall +
reinstall. Idempotent the helper checks current state and skips reinstall. Idempotent - the helper checks current state and skips
already-restricted menus. already-restricted menus.
""" """
import logging import logging

View File

@@ -1,14 +1,14 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 # License OPL-1
"""Post-migrate for 19.0.22.0.0 Recipe-level cert suppression Booleans. """Post-migrate for 19.0.22.0.0 - Recipe-level cert suppression Booleans.
Backfills NULL -> TRUE on the five new requires_* columns on Backfills NULL -> TRUE on the five new requires_* columns on
fusion.plating.process.node (requires_coc, requires_thickness_report, fusion.plating.process.node (requires_coc, requires_thickness_report,
requires_nadcap_cert, requires_mill_test, requires_customer_specific). requires_nadcap_cert, requires_mill_test, requires_customer_specific).
Default TRUE = inherit current behaviour for every existing recipe Default TRUE = inherit current behaviour for every existing recipe
(zero migration surprises every existing recipe keeps producing (zero migration surprises - every existing recipe keeps producing
the same cert set it produces today). the same cert set it produces today).
Idempotent: safe to re-run. Idempotent: safe to re-run.

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 # License OPL-1
# #
# Phase 1 (Sub 11) relocate fp.work.role, fp.operator.proficiency, # Phase 1 (Sub 11) - relocate fp.work.role, fp.operator.proficiency,
# and the hr.employee shop-roles inherit from fusion_plating_bridge_mrp # and the hr.employee shop-roles inherit from fusion_plating_bridge_mrp
# into fusion_plating core. Re-key all related ir.model.data so the # into fusion_plating core. Re-key all related ir.model.data so the
# new module owner picks up the existing records cleanly. # new module owner picks up the existing records cleanly.
@@ -14,7 +14,7 @@ _logger = logging.getLogger(__name__)
def migrate(cr, version): def migrate(cr, version):
if not version: if not version:
return # Fresh install nothing to migrate return # Fresh install - nothing to migrate
patterns = [ patterns = [
'model_fp_work_role', 'model_fp_work_role',

View File

@@ -28,7 +28,7 @@ from . import res_company
from . import res_users from . import res_users
from . import res_config_settings from . import res_config_settings
# Phase 1 (Sub 11) relocated from fusion_plating_bridge_mrp via # Phase 1 (Sub 11) - relocated from fusion_plating_bridge_mrp via
# fusion_plating_jobs to core, so other downstream modules # fusion_plating_jobs to core, so other downstream modules
# (fusion_plating_cgp, etc.) that touch hr.employee can see the # (fusion_plating_cgp, etc.) that touch hr.employee can see the
# shop-roles fields without a transitive dep on jobs. # shop-roles fields without a transitive dep on jobs.
@@ -37,20 +37,20 @@ from . import fp_proficiency
from . import hr_employee from . import hr_employee
from . import fp_process_node_inherit from . import fp_process_node_inherit
# Sub 12a Simple Recipe Editor + Step Library # Sub 12a - Simple Recipe Editor + Step Library
from . import fp_step_kind # MUST load before fp_step_template (dependency) from . import fp_step_kind # MUST load before fp_step_template (dependency)
from . import fp_step_template from . import fp_step_template
from . import fp_step_template_input from . import fp_step_template_input
from . import fp_step_template_transition_input from . import fp_step_template_transition_input
# Sub 12b Rack-aware moves + persistent labor reconciliation # Sub 12b - Rack-aware moves + persistent labor reconciliation
from . import fp_rack_tag from . import fp_rack_tag
from . import fp_job_step_move from . import fp_job_step_move
# Phase 1 Plating landing-page resolver # Phase 1 - Plating landing-page resolver
from . import fp_landing from . import fp_landing
# Phase H dry-run + Owner-approval role migration workflow. # Phase H - dry-run + Owner-approval role migration workflow.
# fp_role_constants MUST be imported before fp_migration (the latter # fp_role_constants MUST be imported before fp_migration (the latter
# imports the predicate chain + xmlid maps from the former). # imports the predicate chain + xmlid maps from the former).
from . import fp_role_constants from . import fp_role_constants

View File

@@ -8,8 +8,8 @@ quantities, and process inputs.
Free-text unit fields invite typos ("kgs", "Kg", "kilo", "KG") that Free-text unit fields invite typos ("kgs", "Kg", "kilo", "KG") that
break filters, reports, and trend graphs. Every UoM in the plating break filters, reports, and trend graphs. Every UoM in the plating
domain chemistry, mass, volume, length, area, electrical, time, domain - chemistry, mass, volume, length, area, electrical, time,
pressure, dimensionless lives here as a curated selection so users pressure, dimensionless - lives here as a curated selection so users
pick from a known list instead of typing. pick from a known list instead of typing.
Re-use: Re-use:
@@ -23,7 +23,7 @@ Migration:
the map gets cleared (NULL) so the user is forced to pick. the map gets cleared (NULL) so the user is forced to pick.
""" """
# Single source of truth keep alphabetised within each section. # Single source of truth - keep alphabetised within each section.
FP_UOM_SELECTION = [ FP_UOM_SELECTION = [
# --- Concentration / chemistry --------------------------------------- # --- Concentration / chemistry ---------------------------------------
('g_l', 'g/L'), ('g_l', 'g/L'),
@@ -51,7 +51,7 @@ FP_UOM_SELECTION = [
('ph', 'pH'), ('ph', 'pH'),
('su', 'SU (Standard Units)'), ('su', 'SU (Standard Units)'),
('ratio', 'Ratio (e.g. 5:1)'), ('ratio', 'Ratio (e.g. 5:1)'),
('none', ' (none)'), ('none', '- (none)'),
# --- Conductivity / turbidity ---------------------------------------- # --- Conductivity / turbidity ----------------------------------------
('us_cm', 'µS/cm'), ('us_cm', 'µS/cm'),
@@ -369,7 +369,7 @@ def fp_migrate_uom_column(env, table, column, label_for_log=None):
selection keys. Unmapped values are set to NULL so the user is forced selection keys. Unmapped values are set to NULL so the user is forced
to pick a valid one. to pick a valid one.
Idempotent running on a column that's already converted is a no-op Idempotent - running on a column that's already converted is a no-op
because all values will already be selection keys (which are a subset because all values will already be selection keys (which are a subset
of FP_UOM_LEGACY_MAP via identity mappings like 'g_l''g_l'). of FP_UOM_LEGACY_MAP via identity mappings like 'g_l''g_l').
@@ -411,7 +411,7 @@ def fp_migrate_uom_column(env, table, column, label_for_log=None):
import logging import logging
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_logger.info( _logger.info(
'Fusion Plating UoM migration %s.%s%s: %s rewritten, %s cleared', 'Fusion Plating UoM migration - %s.%s%s: %s rewritten, %s cleared',
table, column, f' ({label_for_log})' if label_for_log else '', table, column, f' ({label_for_log})' if label_for_log else '',
rewritten, cleared, rewritten, cleared,
) )

View File

@@ -24,7 +24,7 @@ class FpBath(models.Model):
without touching the generic bath model. without touching the generic bath model.
""" """
_name = 'fusion.plating.bath' _name = 'fusion.plating.bath'
_description = 'Fusion Plating Bath' _description = 'Fusion Plating - Bath'
_inherit = ['mail.thread', 'mail.activity.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'state, makeup_date desc, id desc' _order = 'state, makeup_date desc, id desc'
_rec_name = 'display_name' _rec_name = 'display_name'
@@ -190,7 +190,7 @@ class FpBath(models.Model):
@api.depends('state', 'last_log_status') @api.depends('state', 'last_log_status')
def _compute_status_color(self): def _compute_status_color(self):
"""Kanban colour index neutral palette that works in light + dark. """Kanban colour index - neutral palette that works in light + dark.
Uses Odoo's built-in color index rather than hex codes, so themes Uses Odoo's built-in color index rather than hex codes, so themes
control the final rendering. control the final rendering.
@@ -237,7 +237,7 @@ class FpBath(models.Model):
class FpBathTarget(models.Model): class FpBathTarget(models.Model):
"""Per-bath target range for a chemistry parameter.""" """Per-bath target range for a chemistry parameter."""
_name = 'fusion.plating.bath.target' _name = 'fusion.plating.bath.target'
_description = 'Fusion Plating Bath Target' _description = 'Fusion Plating - Bath Target'
_order = 'bath_id, sequence, parameter_id' _order = 'bath_id, sequence, parameter_id'
bath_id = fields.Many2one( bath_id = fields.Many2one(

View File

@@ -15,12 +15,12 @@ class FpBathLog(models.Model):
Each log has one or more lines (one per parameter). Each log has one or more lines (one per parameter).
Overall log status is rolled up from the lines: Overall log status is rolled up from the lines:
* ok every line is within target * ok - every line is within target
* warning at least one line is within warning tolerance * warning - at least one line is within warning tolerance
* out_of_spec at least one line is outside target * out_of_spec - at least one line is outside target
""" """
_name = 'fusion.plating.bath.log' _name = 'fusion.plating.bath.log'
_description = 'Fusion Plating Bath Chemistry Log' _description = 'Fusion Plating - Bath Chemistry Log'
_inherit = ['mail.thread', 'mail.activity.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'log_date desc, id desc' _order = 'log_date desc, id desc'
_rec_name = 'display_name' _rec_name = 'display_name'
@@ -118,7 +118,7 @@ class FpBathLog(models.Model):
@api.constrains('line_ids') @api.constrains('line_ids')
def _check_has_readings(self): def _check_has_readings(self):
"""A bath log without readings is a useless empty record it """A bath log without readings is a useless empty record - it
pollutes daily-chemistry reports and the trend graphs assume pollutes daily-chemistry reports and the trend graphs assume
every log carries data. Block save until at least one reading. every log carries data. Block save until at least one reading.
@@ -155,7 +155,7 @@ class FpBathLog(models.Model):
parts.append(rec.bath_id.name) parts.append(rec.bath_id.name)
if rec.log_date: if rec.log_date:
parts.append(fields.Datetime.to_string(rec.log_date)) parts.append(fields.Datetime.to_string(rec.log_date))
rec.display_name = ' '.join(parts) if parts else rec.name rec.display_name = ' - '.join(parts) if parts else rec.name
@api.depends('line_ids', 'line_ids.status') @api.depends('line_ids', 'line_ids.status')
def _compute_status(self): def _compute_status(self):

View File

@@ -16,7 +16,7 @@ class FpBathLogLine(models.Model):
up to the parent log. up to the parent log.
""" """
_name = 'fusion.plating.bath.log.line' _name = 'fusion.plating.bath.log.line'
_description = 'Fusion Plating Bath Log Reading' _description = 'Fusion Plating - Bath Log Reading'
_order = 'log_id, sequence, id' _order = 'log_id, sequence, id'
log_id = fields.Many2one( log_id = fields.Many2one(
@@ -87,7 +87,7 @@ class FpBathLogLine(models.Model):
This means the operator (or backend user) hits "add reading", picks This means the operator (or backend user) hits "add reading", picks
Temperature, and the tank's `default_temperature` lands in the value Temperature, and the tank's `default_temperature` lands in the value
column automatically they confirm with one tap or nudge with column automatically - they confirm with one tap or nudge with
keyboard arrows. Avoids retyping the same number every shift. keyboard arrows. Avoids retyping the same number every shift.
Fires only when value is currently empty so the user's edits aren't Fires only when value is currently empty so the user's edits aren't
@@ -137,7 +137,7 @@ class FpBathLogLine(models.Model):
rec.status = 'ok' rec.status = 'ok'
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# T1.2 Auto-suggest replenishment on every log line # T1.2 - Auto-suggest replenishment on every log line
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@api.model_create_multi @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):

View File

@@ -18,7 +18,7 @@ class FpBathParameter(models.Model):
or on the bath recipe. or on the bath recipe.
""" """
_name = 'fusion.plating.bath.parameter' _name = 'fusion.plating.bath.parameter'
_description = 'Fusion Plating Bath Parameter' _description = 'Fusion Plating - Bath Parameter'
_order = 'sequence, name' _order = 'sequence, name'
name = fields.Char( name = fields.Char(
@@ -62,7 +62,7 @@ class FpBathParameter(models.Model):
string='Unit (display)', string='Unit (display)',
compute='_compute_uom_display', compute='_compute_uom_display',
help='Resolved display string for the chosen unit ' help='Resolved display string for the chosen unit '
'(e.g. "g/L", "°C") used by views that need plain text.', '(e.g. "g/L", "°C") - used by views that need plain text.',
) )
target_min = fields.Float( target_min = fields.Float(
string='Default Target Min', string='Default Target Min',
@@ -79,7 +79,7 @@ class FpBathParameter(models.Model):
target_value = fields.Float( target_value = fields.Float(
string='Default Setpoint / Optimum', string='Default Setpoint / Optimum',
help='The IDEAL operating value, expressed in the unit selected ' help='The IDEAL operating value, expressed in the unit selected '
'above what the heater/chiller controls toward, what ' 'above - what the heater/chiller controls toward, what '
'dashboards compare against. Sits between Target Min and ' 'dashboards compare against. Sits between Target Min and '
'Target Max. Per-sensor override via ' 'Target Max. Per-sensor override via '
'fp.tank.sensor.target_value_override.', 'fp.tank.sensor.target_value_override.',

View File

@@ -19,7 +19,7 @@ class FpBathReplenishmentRule(models.Model):
Shops wanting non-linear or piecewise rules can extend this model. Shops wanting non-linear or piecewise rules can extend this model.
""" """
_name = 'fusion.plating.bath.replenishment.rule' _name = 'fusion.plating.bath.replenishment.rule'
_description = 'Fusion Plating Replenishment Rule' _description = 'Fusion Plating - Replenishment Rule'
_order = 'process_type_id, parameter_id' _order = 'process_type_id, parameter_id'
name = fields.Char(string='Rule Name', required=True) name = fields.Char(string='Rule Name', required=True)
@@ -42,7 +42,7 @@ class FpBathReplenishmentRule(models.Model):
) )
product_name = fields.Char( product_name = fields.Char(
string='Replenisher Name', required=True, string='Replenisher Name', required=True,
help='Human-readable chemical name, e.g. "Nickel Sulfamate 30% — Grade A"', help='Human-readable chemical name, e.g. "Nickel Sulfamate 30% - Grade A"',
) )
product_id = fields.Many2one( product_id = fields.Many2one(
'product.product', string='Product (Inventory)', 'product.product', string='Product (Inventory)',
@@ -111,7 +111,7 @@ class FpBathReplenishmentSuggestion(models.Model):
"""One suggestion generated from a bath-log reading. Operators mark """One suggestion generated from a bath-log reading. Operators mark
them applied or dismissed once the dose has been added.""" them applied or dismissed once the dose has been added."""
_name = 'fusion.plating.bath.replenishment.suggestion' _name = 'fusion.plating.bath.replenishment.suggestion'
_description = 'Fusion Plating Replenishment Suggestion' _description = 'Fusion Plating - Replenishment Suggestion'
_inherit = ['mail.thread'] _inherit = ['mail.thread']
_order = 'create_date desc, id desc' _order = 'create_date desc, id desc'

View File

@@ -18,7 +18,7 @@ class FpFacility(models.Model):
model with jurisdiction-specific fields via inheritance. model with jurisdiction-specific fields via inheritance.
""" """
_name = 'fusion.plating.facility' _name = 'fusion.plating.facility'
_description = 'Fusion Plating Facility' _description = 'Fusion Plating - Facility'
_inherit = ['mail.thread', 'mail.activity.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'sequence, name' _order = 'sequence, name'

View File

@@ -2,11 +2,11 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# #
# fp.job native plating job model. # fp.job - native plating job model.
# #
# Replaces mrp.production for plating. One record per shop-floor job. # Replaces mrp.production for plating. One record per shop-floor job.
# Header data lives here; per-operation detail on fp.job.step (Task 1.5). # Header data lives here; per-operation detail on fp.job.step (Task 1.5).
# Recipe template (fusion.plating.process.node) is unchanged this # Recipe template (fusion.plating.process.node) is unchanged - this
# model just instantiates from it via fp.job.step.recipe_node_id. # model just instantiates from it via fp.job.step.recipe_node_id.
# #
# State machine: # State machine:
@@ -52,7 +52,7 @@ class FpJob(models.Model):
return dt.astimezone(tz).strftime(fmt) return dt.astimezone(tz).strftime(fmt)
_description = 'Work Order' _description = 'Work Order'
_inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin', 'fp.parent.numbered.mixin']
# Sub 12d state-aware sort. Active work bubbles to the top # Sub 12d - state-aware sort. Active work bubbles to the top
# (in_progress → confirmed/draft → on_hold → done → cancelled), # (in_progress → confirmed/draft → on_hold → done → cancelled),
# then high-priority first within each state, then nearest deadline. # then high-priority first within each state, then nearest deadline.
# state_priority is a small stored compute below. # state_priority is a small stored compute below.
@@ -96,7 +96,7 @@ class FpJob(models.Model):
tracking=True, tracking=True,
index=True, index=True,
) )
# Sub 12d drives the default sort so active jobs surface above # Sub 12d - drives the default sort so active jobs surface above
# closed jobs. Lower number = sorted earlier. Stored + indexed so # closed jobs. Lower number = sorted earlier. Stored + indexed so
# SQL ORDER BY hits an index and doesn't recompute per row. # SQL ORDER BY hits an index and doesn't recompute per row.
state_priority = fields.Integer( state_priority = fields.Integer(
@@ -154,7 +154,7 @@ class FpJob(models.Model):
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Source / recipe / invoicing core-safe (target models reachable # Source / recipe / invoicing - core-safe (target models reachable
# via current depends: sale_management → sale → account, and our # via current depends: sale_management → sale → account, and our
# own fusion.plating.process.node). # own fusion.plating.process.node).
# #
@@ -187,7 +187,7 @@ class FpJob(models.Model):
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Cost rollup actual_cost stays at 0 until Task 1.5 wires step # Cost rollup - actual_cost stays at 0 until Task 1.5 wires step
# time × work_centre.cost_per_hour. quoted_revenue is a manual entry # time × work_centre.cost_per_hour. quoted_revenue is a manual entry
# for now (will be filled by the SO → job hook in Phase 2). # for now (will be filled by the SO → job hook in Phase 2).
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -229,7 +229,7 @@ class FpJob(models.Model):
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# current_location operator-readable status string. Stub here; # current_location - operator-readable status string. Stub here;
# full "Queued: Bath 3" / "In progress: Oven A" rendering needs # full "Queued: Bath 3" / "In progress: Oven A" rendering needs
# fp.job.step + fp.work.centre, which lands in Tasks 1.5/1.6. # fp.job.step + fp.work.centre, which lands in Tasks 1.5/1.6.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -250,7 +250,7 @@ class FpJob(models.Model):
job.current_location = job.state.replace('_', ' ').title() job.current_location = job.state.replace('_', ' ').title()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Steps One2many to fp.job.step (Task 1.5) # Steps - One2many to fp.job.step (Task 1.5)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
step_ids = fields.One2many( step_ids = fields.One2many(
'fp.job.step', 'fp.job.step',
@@ -258,7 +258,7 @@ class FpJob(models.Model):
string='Steps', string='Steps',
) )
# ===== Sub 12b traveller header + active timer ======================== # ===== Sub 12b - traveller header + active timer ========================
# Header counters mirror the paper traveller's "Qty Rec." / "VIS INSP." # Header counters mirror the paper traveller's "Qty Rec." / "VIS INSP."
# / "Rework" columns (screens 16-18). Sub 12c's traveller report pulls # / "Rework" columns (screens 16-18). Sub 12c's traveller report pulls
# these into the printed header. # these into the printed header.
@@ -285,13 +285,13 @@ class FpJob(models.Model):
string='Rush Order', string='Rush Order',
tracking=True, tracking=True,
help='High-priority flag mirrored from sale.order. Operators see ' help='High-priority flag mirrored from sale.order. Operators see '
'this on the queue / tablet at-a-glance saves lifting the ' 'this on the queue / tablet at-a-glance - saves lifting the '
'job form to know it\'s rush.', 'job form to know it\'s rush.',
) )
# ---- Scheduling targets mirrored from sale.order ----------------- # ---- Scheduling targets mirrored from sale.order -----------------
# These are kept separate from the operational date_planned_start / # These are kept separate from the operational date_planned_start /
# date_deadline fields (which may be tweaked by scheduling logic) # date_deadline fields (which may be tweaked by scheduling logic) -
# this preserves the ORIGINAL customer-facing dates entered on the SO. # this preserves the ORIGINAL customer-facing dates entered on the SO.
x_fc_internal_deadline = fields.Date( x_fc_internal_deadline = fields.Date(
string='Internal Deadline', string='Internal Deadline',
@@ -342,7 +342,7 @@ class FpJob(models.Model):
'job_id', 'job_id',
string='Active Timers', string='Active Timers',
domain=[('state', 'in', ('running', 'paused'))], domain=[('state', 'in', ('running', 'paused'))],
help='Sub 12b used by tablet for live timer badges. Filtered ' help='Sub 12b - used by tablet for live timer badges. Filtered '
'on state by Task 7\'s state field.', 'on state by Task 7\'s state field.',
) )
move_ids = fields.One2many( move_ids = fields.One2many(
@@ -350,7 +350,7 @@ class FpJob(models.Model):
string='Move Log', string='Move Log',
) )
# step_count + step_done_count are stored (drive list views / stat # step_count + step_done_count are stored (drive list views / stat
# buttons in Task 1.8). step_progress_pct stays non-stored it's a # buttons in Task 1.8). step_progress_pct stays non-stored - it's a
# cheap derivative. Odoo flags as inconsistent when stored and # cheap derivative. Odoo flags as inconsistent when stored and
# non-stored fields share a compute method, so they get distinct # non-stored fields share a compute method, so they get distinct
# methods below. # methods below.
@@ -424,7 +424,7 @@ class FpJob(models.Model):
continue # caller set an explicit name (e.g. bulk SO confirm) continue # caller set an explicit name (e.g. bulk SO confirm)
if not rec._fp_assign_parent_name(): if not rec._fp_assign_parent_name():
seq = self.env['ir.sequence'].next_by_code('fp.job') or _('New') seq = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
# Raw SQL fp.job has no immutability guard yet in this # Raw SQL - fp.job has no immutability guard yet in this
# task, but Task 11 will add one. Using SQL here keeps the # task, but Task 11 will add one. Using SQL here keeps the
# fallback path consistent across all child models. # fallback path consistent across all child models.
self.env.cr.execute( self.env.cr.execute(
@@ -435,10 +435,10 @@ class FpJob(models.Model):
return records return records
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# State machine actions # State machine - actions
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# TODO(fp.job state-machine completeness): action_hold, action_resume, # TODO(fp.job state-machine completeness): action_hold, action_resume,
# action_revert_to_confirmed (rework path) to be added when shopfloor # action_revert_to_confirmed (rework path) - to be added when shopfloor
# / rework workflows are wired up. For now, draft → confirmed and the # / rework workflows are wired up. For now, draft → confirmed and the
# cancel paths are the only enforced transitions; everything else is # cancel paths are the only enforced transitions; everything else is
# an explicit `state` write by privileged code. # an explicit `state` write by privileged code.
@@ -450,7 +450,7 @@ class FpJob(models.Model):
) % (job.name, job.state)) ) % (job.name, job.state))
job.state = 'confirmed' job.state = 'confirmed'
# Step auto-promote happens in the fusion_plating_jobs override # Step auto-promote happens in the fusion_plating_jobs override
# AFTER _generate_steps_from_recipe runs at this point step_ids # AFTER _generate_steps_from_recipe runs - at this point step_ids
# is empty for any newly-confirmed job. # is empty for any newly-confirmed job.
return True return True
@@ -458,7 +458,7 @@ class FpJob(models.Model):
for job in self: for job in self:
if job.state == 'done': if job.state == 'done':
raise UserError(_( raise UserError(_(
"Job %s is done cannot cancel." "Job %s is done - cannot cancel."
) % job.name) ) % job.name)
if job.state == 'cancelled': if job.state == 'cancelled':
raise UserError(_( raise UserError(_(

View File

@@ -2,13 +2,13 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# #
# fp.job.step one operation within a plating job. # fp.job.step - one operation within a plating job.
# #
# Replaces mrp.workorder. Each step instantiates from a recipe # Replaces mrp.workorder. Each step instantiates from a recipe
# operation node (recipe_node_id). Container nodes (recipe, # operation node (recipe_node_id). Container nodes (recipe,
# sub_process) and step nodes (instructions) are NOT rows here # sub_process) and step nodes (instructions) are NOT rows here -
# they live on the recipe template and are used at view-render time # they live on the recipe template and are used at view-render time
# to display hierarchy. See spec §5.2 (Option A operations only). # to display hierarchy. See spec §5.2 (Option A - operations only).
# #
# State machine: # State machine:
# pending → ready → in_progress → done # pending → ready → in_progress → done
@@ -83,9 +83,9 @@ class FpJobStep(models.Model):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Equipment + audit (Task 1.6) # Equipment + audit (Task 1.6)
# oven_id is deferred to a bridge module fusion.plating.bake.oven # oven_id is deferred to a bridge module - fusion.plating.bake.oven
# lives in fusion_plating_shopfloor and core can't depend on it. # lives in fusion_plating_shopfloor and core can't depend on it.
# masking_material_id is deferred fusion.plating.masking.material # masking_material_id is deferred - fusion.plating.masking.material
# does not yet exist in any installed module; will be added when # does not yet exist in any installed module; will be added when
# the masking model lands (likely in fusion_plating_process_en # the masking model lands (likely in fusion_plating_process_en
# or a future fusion_plating_masking module). # or a future fusion_plating_masking module).
@@ -109,7 +109,7 @@ class FpJobStep(models.Model):
default='um', default='um',
) )
dwell_time_minutes = fields.Float() dwell_time_minutes = fields.Float()
# Label intentionally has no unit suffix the unit follows the # Label intentionally has no unit suffix - the unit follows the
# company's `x_fc_default_temp_uom` setting and is surfaced via the # company's `x_fc_default_temp_uom` setting and is surfaced via the
# adjacent `bake_setpoint_temp_uom_display` compute. Hardcoding °C # adjacent `bake_setpoint_temp_uom_display` compute. Hardcoding °C
# in the label was the most visible "Celsius leaks everywhere" # in the label was the most visible "Celsius leaks everywhere"
@@ -158,7 +158,7 @@ class FpJobStep(models.Model):
'enforce_sequential=False (free-flow recipe with one ' 'enforce_sequential=False (free-flow recipe with one '
'specific step that needs to wait).', 'specific step that needs to wait).',
) )
# Sub 13 sequential enforcement (recipe + per-step). New default # Sub 13 - sequential enforcement (recipe + per-step). New default
# behaviour is "every step waits for predecessors", with two escape # behaviour is "every step waits for predecessors", with two escape
# hatches: enforce_sequential=False on the recipe (free-flow), or # hatches: enforce_sequential=False on the recipe (free-flow), or
# parallel_start=True on this specific step (explicit parallelism). # parallel_start=True on this specific step (explicit parallelism).
@@ -175,8 +175,8 @@ class FpJobStep(models.Model):
'parent recipe has enforce_sequential=True.', 'parent recipe has enforce_sequential=True.',
) )
# ===== Sub 12b chain-of-custody + rack awareness ===================== # ===== Sub 12b - chain-of-custody + rack awareness =====================
# Note: rack_id (line 95 above) already exists reused as the "current # Note: rack_id (line 95 above) already exists - reused as the "current
# rack on this step" pointer. Sub 12b builds the runtime guards on top. # rack on this step" pointer. Sub 12b builds the runtime guards on top.
requires_rack_assignment = fields.Boolean( requires_rack_assignment = fields.Boolean(
related='recipe_node_id.requires_rack_assignment', related='recipe_node_id.requires_rack_assignment',
@@ -199,12 +199,12 @@ class FpJobStep(models.Model):
) )
is_racked = fields.Boolean( is_racked = fields.Boolean(
string='Racked', compute='_compute_is_racked', store=True, string='Racked', compute='_compute_is_racked', store=True,
help='True when rack_id is set drives the tablet rack-vs-parts ' help='True when rack_id is set - drives the tablet rack-vs-parts '
'button-state guard (Move Parts greys out).', 'button-state guard (Move Parts greys out).',
) )
qty_at_step_start = fields.Integer(string='Qty at Step Start') qty_at_step_start = fields.Integer(string='Qty at Step Start')
qty_at_step_finish = fields.Integer(string='Qty at Step Finish') qty_at_step_finish = fields.Integer(string='Qty at Step Finish')
# Live "qty currently parked at this step" drives partial-qty # Live "qty currently parked at this step" - drives partial-qty
# workflows. = (incoming moves' qty outgoing moves' qty), with a # workflows. = (incoming moves' qty outgoing moves' qty), with a
# first-step seed: the lowest-sequence step on a confirmed job # first-step seed: the lowest-sequence step on a confirmed job
# implicitly receives the full job qty when the job starts (no # implicitly receives the full job qty when the job starts (no
@@ -227,7 +227,7 @@ class FpJobStep(models.Model):
def _compute_qty_at_step(self): def _compute_qty_at_step(self):
for rec in self: for rec in self:
# Terminal states: nothing parked here anymore. Operators # Terminal states: nothing parked here anymore. Operators
# don't care if "done" steps technically have qty residue # don't care if "done" steps technically have qty residue -
# surfacing zero keeps the column readable. # surfacing zero keeps the column readable.
if rec.state in ('done', 'cancelled', 'skipped'): if rec.state in ('done', 'cancelled', 'skipped'):
rec.qty_at_step = 0 rec.qty_at_step = 0
@@ -287,7 +287,7 @@ class FpJobStep(models.Model):
step.cost_total = (step.duration_actual / 60.0) * step.cost_per_hour step.cost_total = (step.duration_actual / 60.0) * step.cost_per_hour
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# State machine actions # State machine - actions
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Implemented: button_start (ready/paused → in_progress), # Implemented: button_start (ready/paused → in_progress),
# button_finish (in_progress → done). # button_finish (in_progress → done).
@@ -308,7 +308,7 @@ class FpJobStep(models.Model):
for step in self: for step in self:
if step.state != 'in_progress': if step.state != 'in_progress':
raise UserError(_( raise UserError(_(
"Step '%s' is in state '%s' only in-progress steps can pause." "Step '%s' is in state '%s' - only in-progress steps can pause."
) % (step.name, step.state)) ) % (step.name, step.state))
now = fields.Datetime.now() now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished) open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
@@ -318,25 +318,25 @@ class FpJobStep(models.Model):
return True return True
def button_resume(self): def button_resume(self):
"""Resume a paused step thin alias over button_start so views """Resume a paused step - thin alias over button_start so views
can show distinct labels (Resume vs Start) without duplicating can show distinct labels (Resume vs Start) without duplicating
the state-machine logic.""" the state-machine logic."""
for step in self: for step in self:
if step.state != 'paused': if step.state != 'paused':
raise UserError(_( raise UserError(_(
"Step '%s' is in state '%s' only paused steps can resume." "Step '%s' is in state '%s' - only paused steps can resume."
) % (step.name, step.state)) ) % (step.name, step.state))
return self.button_start() return self.button_start()
def button_skip(self): def button_skip(self):
"""Skip an opt-in step that wasn't activated for this job. Allowed """Skip an opt-in step that wasn't activated for this job. Allowed
from pending or ready only a step that's already running shouldn't from pending or ready only - a step that's already running shouldn't
be skipped without an audit narrative (use button_cancel for that). be skipped without an audit narrative (use button_cancel for that).
""" """
for step in self: for step in self:
if step.state not in ('pending', 'ready'): if step.state not in ('pending', 'ready'):
raise UserError(_( raise UserError(_(
"Step '%s' is in state '%s' only pending/ready steps can skip." "Step '%s' is in state '%s' - only pending/ready steps can skip."
) % (step.name, step.state)) ) % (step.name, step.state))
step.state = 'skipped' step.state = 'skipped'
step.message_post(body=_('Step skipped by %s') % self.env.user.name) step.message_post(body=_('Step skipped by %s') % self.env.user.name)
@@ -351,7 +351,7 @@ class FpJobStep(models.Model):
for step in self: for step in self:
if step.state in ('done', 'cancelled'): if step.state in ('done', 'cancelled'):
raise UserError(_( raise UserError(_(
"Step '%s' is in state '%s' cannot cancel." "Step '%s' is in state '%s' - cannot cancel."
) % (step.name, step.state)) ) % (step.name, step.state))
now = fields.Datetime.now() now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished) open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
@@ -364,7 +364,7 @@ class FpJobStep(models.Model):
for step in self: for step in self:
if step.state not in ('ready', 'paused'): if step.state not in ('ready', 'paused'):
raise UserError(_( raise UserError(_(
"Step '%s' is in state '%s' only ready/paused steps can start." "Step '%s' is in state '%s' - only ready/paused steps can start."
) % (step.name, step.state)) ) % (step.name, step.state))
now = fields.Datetime.now() now = fields.Datetime.now()
step.state = 'in_progress' step.state = 'in_progress'
@@ -372,7 +372,7 @@ class FpJobStep(models.Model):
if not step.date_started: if not step.date_started:
step.date_started = now step.date_started = now
step.started_by_user_id = self.env.user step.started_by_user_id = self.env.user
# Open a fresh timelog row for this start interval uses the # Open a fresh timelog row for this start interval - uses the
# same `now` as the first-start stamp so the step and its # same `now` as the first-start stamp so the step and its
# first log share a single instant. # first log share a single instant.
self.env['fp.job.step.timelog'].create({ self.env['fp.job.step.timelog'].create({
@@ -387,17 +387,17 @@ class FpJobStep(models.Model):
for step in self: for step in self:
if step.state != 'in_progress': if step.state != 'in_progress':
raise UserError(_( raise UserError(_(
"Step '%s' is in state '%s' only in-progress steps can finish." "Step '%s' is in state '%s' - only in-progress steps can finish."
) % (step.name, step.state)) ) % (step.name, step.state))
# Quantity gate: refuses if parts still parked AND there's # Quantity gate: refuses if parts still parked AND there's
# a downstream step to move them into. Last runnable step # a downstream step to move them into. Last runnable step
# is exempt parts finishing there complete in place # is exempt - parts finishing there complete in place
# (qty_done reconciliation at job close is the catch-net). # (qty_done reconciliation at job close is the catch-net).
# #
# Seed-only exemption: the first-step seed in # Seed-only exemption: the first-step seed in
# _compute_qty_at_step gives the earliest non-terminal step # _compute_qty_at_step gives the earliest non-terminal step
# a notional qty = job.qty. That's a UI hint, not a real # a notional qty = job.qty. That's a UI hint, not a real
# parked batch no incoming move record backs it. Paperwork # parked batch - no incoming move record backs it. Paperwork
# steps (Contract Review, Inspection, etc.) sit on that seed. # steps (Contract Review, Inspection, etc.) sit on that seed.
# If the step has no REAL incoming moves, skip the gate. # If the step has no REAL incoming moves, skip the gate.
if not skip_qty_gate and step.qty_at_step > 0: if not skip_qty_gate and step.qty_at_step > 0:
@@ -413,7 +413,7 @@ class FpJobStep(models.Model):
if has_downstream and has_real_incoming: if has_downstream and has_real_incoming:
raise UserError(_( raise UserError(_(
"Step '%(name)s' still has %(n)d part(s) " "Step '%(name)s' still has %(n)d part(s) "
"parked move them to the next step before " "parked - move them to the next step before "
"finishing. Use the row's 'Complete 1 → Next' " "finishing. Use the row's 'Complete 1 → Next' "
"or 'Move…' button." "or 'Move…' button."
) % {'name': step.name, 'n': step.qty_at_step}) ) % {'name': step.name, 'n': step.qty_at_step})
@@ -453,7 +453,7 @@ class FpJobStep(models.Model):
) % step.name) ) % step.name)
prev_state = step.state prev_state = step.state
now = fields.Datetime.now() now = fields.Datetime.now()
# Close any open timelogs first labour already incurred # Close any open timelogs first - labour already incurred
# stays in the audit even when we shortcut to done. # stays in the audit even when we shortcut to done.
open_log = step.time_log_ids.filtered( open_log = step.time_log_ids.filtered(
lambda l: not l.date_finished lambda l: not l.date_finished
@@ -484,7 +484,7 @@ class FpJobStep(models.Model):
next button_finish writes fresh first-finish stamps instead next button_finish writes fresh first-finish stamps instead
of preserving stale ones. of preserving stale ones.
date_started + started_by_user_id are preserved across resets date_started + started_by_user_id are preserved across resets -
they record the first start ever (audit), and duration_actual is they record the first start ever (audit), and duration_actual is
computed from the sum of timelogs, not (finish - start), so the computed from the sum of timelogs, not (finish - start), so the
elapsed math remains correct.""" elapsed math remains correct."""
@@ -502,7 +502,7 @@ class FpJobStep(models.Model):
prev_state = step.state prev_state = step.state
vals = {'state': 'ready'} vals = {'state': 'ready'}
# Close any still-open timelog (defensive usually only # Close any still-open timelog (defensive - usually only
# in_progress/paused will have one). # in_progress/paused will have one).
open_log = step.time_log_ids.filtered( open_log = step.time_log_ids.filtered(
lambda l: not l.date_finished lambda l: not l.date_finished
@@ -512,7 +512,7 @@ class FpJobStep(models.Model):
# If the step had been completed, wipe the finish stamps so # If the step had been completed, wipe the finish stamps so
# the next Finish records fresh audit values. Skip this for # the next Finish records fresh audit values. Skip this for
# in_progress / paused / skipped / cancelled / pending they # in_progress / paused / skipped / cancelled / pending - they
# either have no finish stamp or shouldn't have one cleared. # either have no finish stamp or shouldn't have one cleared.
if step.state == 'done': if step.state == 'done':
vals['date_finished'] = False vals['date_finished'] = False
@@ -538,7 +538,7 @@ class FpJobStep(models.Model):
) % self.name) ) % self.name)
if self.qty_at_step < 1: if self.qty_at_step < 1:
raise UserError(_( raise UserError(_(
"No parts parked at step '%s' nothing to complete." "No parts parked at step '%s' - nothing to complete."
) % self.name) ) % self.name)
next_step = self.job_id.step_ids.filtered( next_step = self.job_id.step_ids.filtered(
lambda s: s.sequence > self.sequence lambda s: s.sequence > self.sequence
@@ -546,7 +546,7 @@ class FpJobStep(models.Model):
).sorted('sequence')[:1] ).sorted('sequence')[:1]
if not next_step: if not next_step:
raise UserError(_( raise UserError(_(
"Step '%s' is the last runnable step on the job " "Step '%s' is the last runnable step on the job - "
"no downstream step to move into. Finish the step " "no downstream step to move into. Finish the step "
"instead (it will close out the job)." "instead (it will close out the job)."
) % self.name) ) % self.name)

View File

@@ -10,14 +10,14 @@ from odoo.exceptions import UserError
class FpJobStepMove(models.Model): class FpJobStepMove(models.Model):
"""Chain-of-custody log one row per part-batch move. """Chain-of-custody log - one row per part-batch move.
Sub 12b: every Move Parts / Move Rack click commits one (or, for Sub 12b: every Move Parts / Move Rack click commits one (or, for
rack moves, one-per-batch atomic) row here. Sub 12c walks these in rack moves, one-per-batch atomic) row here. Sub 12c walks these in
chronological order to render the customer CoC PDF. chronological order to render the customer CoC PDF.
""" """
_name = 'fp.job.step.move' _name = 'fp.job.step.move'
_description = 'Fusion Plating Job Step Move (Chain-of-Custody)' _description = 'Fusion Plating - Job Step Move (Chain-of-Custody)'
_inherit = ['mail.thread'] _inherit = ['mail.thread']
_order = 'move_datetime desc, id desc' _order = 'move_datetime desc, id desc'
@@ -99,12 +99,12 @@ class FpJobStepMove(models.Model):
return moves return moves
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# S23 required transition-input gate # S23 - required transition-input gate
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# When the destination step has requires_transition_form=True, the # When the destination step has requires_transition_form=True, the
# recipe author wants chain-of-custody attestations captured on the # recipe author wants chain-of-custody attestations captured on the
# move (location, photo, customer WO #, etc.). Same dormant-field # move (location, photo, customer WO #, etc.). Same dormant-field
# shape as S22's signoff bug the field existed but nothing enforced # shape as S22's signoff bug - the field existed but nothing enforced
# it. Callers (tablet controllers, future backend wizards) MUST call # it. Callers (tablet controllers, future backend wizards) MUST call
# _fp_check_transition_inputs_complete() after writing values to # _fp_check_transition_inputs_complete() after writing values to
# transition_input_value_ids. # transition_input_value_ids.
@@ -117,7 +117,7 @@ class FpJobStepMove(models.Model):
def _fp_missing_required_transition_inputs(self): def _fp_missing_required_transition_inputs(self):
"""Return the recordset of required transition_input prompts on """Return the recordset of required transition_input prompts on
the to_step's recipe node that have NO captured value on this the to_step's recipe node that have NO captured value on this
move. Centralised helper used by the gate below and by future move. Centralised helper - used by the gate below and by future
diagnostics.""" diagnostics."""
self.ensure_one() self.ensure_one()
Prompt = self.env['fusion.plating.process.node.input'] Prompt = self.env['fusion.plating.process.node.input']
@@ -145,7 +145,7 @@ class FpJobStepMove(models.Model):
def _fp_check_transition_inputs_complete(self): def _fp_check_transition_inputs_complete(self):
"""Raise UserError when the destination step has """Raise UserError when the destination step has
requires_transition_form=True and required transition_input requires_transition_form=True and required transition_input
prompts haven't been recorded on this move. Audit gate same prompts haven't been recorded on this move. Audit gate - same
shape as fp.job.step._fp_check_step_inputs_complete (S21) and shape as fp.job.step._fp_check_step_inputs_complete (S21) and
._fp_check_signoff_complete (S22). ._fp_check_signoff_complete (S22).
@@ -160,7 +160,7 @@ class FpJobStepMove(models.Model):
continue continue
move.message_post(body=Markup(_( move.message_post(body=Markup(_(
'Transition-form gate bypassed by %s. ' 'Transition-form gate bypassed by %s. '
'Documented deviation required prompts not ' 'Documented deviation - required prompts not '
'recorded on this move.' 'recorded on this move.'
)) % self.env.user.name) )) % self.env.user.name)
return return
@@ -172,7 +172,7 @@ class FpJobStepMove(models.Model):
'"%s"' % (p.name or '').strip() for p in missing '"%s"' % (p.name or '').strip() for p in missing
) )
raise UserError(_( raise UserError(_(
'Move to step "%(step)s" cannot be committed ' 'Move to step "%(step)s" cannot be committed - '
'%(n)s required transition prompt(s) not recorded: ' '%(n)s required transition prompt(s) not recorded: '
'%(names)s. Fill them in the Move dialog before ' '%(names)s. Fill them in the Move dialog before '
'committing. Managers can override via context flag ' 'committing. Managers can override via context flag '
@@ -192,7 +192,7 @@ class FpJobStepMoveInputValue(models.Model):
the operator typed at move-time. Used by Sub 12c CoC report. the operator typed at move-time. Used by Sub 12c CoC report.
""" """
_name = 'fp.job.step.move.input.value' _name = 'fp.job.step.move.input.value'
_description = 'Fusion Plating Captured Transition Input Value' _description = 'Fusion Plating - Captured Transition Input Value'
_order = 'move_id, id' _order = 'move_id, id'
move_id = fields.Many2one('fp.job.step.move', string='Move', move_id = fields.Many2one('fp.job.step.move', string='Move',

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# #
# fp.job.step.timelog granular start/stop intervals for a step. # fp.job.step.timelog - granular start/stop intervals for a step.
# #
# Each step.button_start() opens a fresh timelog row. Each # Each step.button_start() opens a fresh timelog row. Each
# step.button_finish() (or button_pause once added) closes the open # step.button_finish() (or button_pause once added) closes the open
@@ -55,7 +55,7 @@ class FpJobStepTimeLog(models.Model):
rec_bits.append(mins) rec_bits.append(mins)
log.display_name = ' · '.join(rec_bits) log.display_name = ' · '.join(rec_bits)
# ===== Sub 12b persistent timer state machine ========================= # ===== Sub 12b - persistent timer state machine =========================
# Extends the existing timelog (used by S1/S2 battle tests) with a state # Extends the existing timelog (used by S1/S2 battle tests) with a state
# field + reconciliation columns. Default state='running' → existing # field + reconciliation columns. Default state='running' → existing
# battle tests are unaffected. Stop Timer dialog (Task 13) flips to # battle tests are unaffected. Stop Timer dialog (Task 13) flips to
@@ -214,7 +214,7 @@ class FpJobStepTimeLog(models.Model):
"""Manager+ only: rewind a closed or stuck timelog back to running. """Manager+ only: rewind a closed or stuck timelog back to running.
Clears date_finished, last_paused_at, total_paused_seconds so accrued Clears date_finished, last_paused_at, total_paused_seconds so accrued
starts fresh from the original date_started. Use for genuine corrections starts fresh from the original date_started. Use for genuine corrections
only the audit-trail entry names who did it.""" only - the audit-trail entry names who did it."""
if not self.env.user.has_group( if not self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'): 'fusion_plating.group_fusion_plating_manager'):
raise AccessError(_( raise AccessError(_(
@@ -234,7 +234,7 @@ class FpJobStepTimeLog(models.Model):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Auto-resync hooks keep fp.job.step.duration_actual in sync with # Auto-resync hooks - keep fp.job.step.duration_actual in sync with
# the timelog rows. Without these, editing a timelog's start/end # the timelog rows. Without these, editing a timelog's start/end
# leaves the step's "Actual Min" column showing stale data. # leaves the step's "Actual Min" column showing stale data.
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -2,28 +2,28 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
"""Phase 1 + Phase E Plating landing-page resolver. """Phase 1 + Phase E - Plating landing-page resolver.
Layers: Layers:
1. ``ir.actions.act_window.x_fc_pickable_landing`` AND 1. ``ir.actions.act_window.x_fc_pickable_landing`` AND
``ir.actions.client.x_fc_pickable_landing`` Boolean tag on BOTH ``ir.actions.client.x_fc_pickable_landing`` - Boolean tag on BOTH
action types. Mark a curated set of plating actions (Sale Orders, action types. Mark a curated set of plating actions (Sale Orders,
Quotations, Manager Desk, Plant Kanban, Quality Dashboard, etc.) so Quotations, Manager Desk, Plant Kanban, Quality Dashboard, etc.) so
the landing-page dropdown only offers sensible options, not all 200+ the landing-page dropdown only offers sensible options, not all 200+
action records in the DB. action records in the DB.
2. ``res.company.x_fc_default_landing_action_id`` admin sets the 2. ``res.company.x_fc_default_landing_action_id`` - admin sets the
fallback for users who don't pick a preference. References fallback for users who don't pick a preference. References
``ir.actions.act_window`` (only act_window actions can be selected ``ir.actions.act_window`` (only act_window actions can be selected
as the company default since they're navigable from the menu tree). as the company default since they're navigable from the menu tree).
3. ``res.users.x_fc_plating_landing_action_id`` each user's own 3. ``res.users.x_fc_plating_landing_action_id`` - each user's own
override. References ``ir.actions.act_window`` and is filtered by override. References ``ir.actions.act_window`` and is filtered by
the user's actually-accessible actions (Technician can't pick the user's actually-accessible actions (Technician can't pick
"Manager Desk" if they can't see it). "Manager Desk" if they can't see it).
4. ``ir.actions.act_window._fp_resolve_landing_for_current_user()`` 4. ``ir.actions.act_window._fp_resolve_landing_for_current_user()`` -
role-based dispatch resolver. Section 3 of the permissions design role-based dispatch resolver. Section 3 of the permissions design
spec. Returns an action dict suitable for the spec. Returns an action dict suitable for the
``action_fp_resolve_plating_landing`` server action. ``action_fp_resolve_plating_landing`` server action.
@@ -58,7 +58,7 @@ class IrActionsActions(models.Model):
) )
def _render_resolved(self): def _render_resolved(self):
"""Dispatcher render this action as a dict for the landing resolver. """Dispatcher - render this action as a dict for the landing resolver.
Routes to the correct subclass based on `type` so both act_window Routes to the correct subclass based on `type` so both act_window
and client actions resolve correctly.""" and client actions resolve correctly."""
self.ensure_one() self.ensure_one()
@@ -66,7 +66,7 @@ class IrActionsActions(models.Model):
return self.env['ir.actions.client'].browse(self.id)._render_resolved() return self.env['ir.actions.client'].browse(self.id)._render_resolved()
if self.type == 'ir.actions.act_window': if self.type == 'ir.actions.act_window':
return self.env['ir.actions.act_window'].browse(self.id)._render_resolved() return self.env['ir.actions.act_window'].browse(self.id)._render_resolved()
# URL / server / report generic dict # URL / server / report - generic dict
action = self.sudo().read()[0] action = self.sudo().read()[0]
action.pop('id', None) action.pop('id', None)
action['xml_id'] = self.get_external_id().get(self.id) or None action['xml_id'] = self.get_external_id().get(self.id) or None
@@ -77,7 +77,7 @@ class IrActionsActWindow(models.Model):
_inherit = 'ir.actions.act_window' _inherit = 'ir.actions.act_window'
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Resolver role-based dispatch (Phase E) # Resolver - role-based dispatch (Phase E)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@api.model @api.model
def _fp_resolve_landing_for_current_user(self): def _fp_resolve_landing_for_current_user(self):
@@ -108,7 +108,7 @@ class IrActionsActWindow(models.Model):
and company.x_fc_default_landing_action_id: and company.x_fc_default_landing_action_id:
return company.x_fc_default_landing_action_id._render_resolved() return company.x_fc_default_landing_action_id._render_resolved()
# 4. Hardcoded last-ditch Sale Orders # 4. Hardcoded last-ditch - Sale Orders
fallback = self.env.ref( fallback = self.env.ref(
'fusion_plating_configurator.action_fp_sale_orders', 'fusion_plating_configurator.action_fp_sale_orders',
raise_if_not_found=False, raise_if_not_found=False,
@@ -152,7 +152,7 @@ class IrActionsActWindow(models.Model):
Returns ``action_fp_plant_kanban`` (the 2026-05-23 plant view). Returns ``action_fp_plant_kanban`` (the 2026-05-23 plant view).
The legacy ``fp_shopfloor_landing`` component was retired The legacy ``fp_shopfloor_landing`` component was retired
2026-05-25 (one feature ported across the inline QR scanner). 2026-05-25 (one feature ported across - the inline QR scanner).
The ``fusion_plating_shopfloor.layout`` ir.config_parameter The ``fusion_plating_shopfloor.layout`` ir.config_parameter
survives orphaned for one release cycle so we can ship a survives orphaned for one release cycle so we can ship a
settings-UI cleanup separately; flipping it has no effect. settings-UI cleanup separately; flipping it has no effect.
@@ -179,7 +179,7 @@ class IrActionsActWindow(models.Model):
class IrActionsClient(models.Model): class IrActionsClient(models.Model):
"""Client actions also need to be tagged as pickable landings """Client actions also need to be tagged as pickable landings -
Manager Desk, Plant Kanban, Quality Dashboard are all client Manager Desk, Plant Kanban, Quality Dashboard are all client
actions, not act_window records. actions, not act_window records.
@@ -189,7 +189,7 @@ class IrActionsClient(models.Model):
""" """
_inherit = 'ir.actions.client' _inherit = 'ir.actions.client'
# x_fc_pickable_landing moved to ir.actions.actions base see IrActionsActions # x_fc_pickable_landing moved to ir.actions.actions base - see IrActionsActions
# above. This subclass keeps _render_resolved for the dispatcher to call. # above. This subclass keeps _render_resolved for the dispatcher to call.
def _render_resolved(self): def _render_resolved(self):

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Phase H dry-run + Owner-approval migration workflow.""" """Phase H - dry-run + Owner-approval migration workflow."""
import json import json
import logging import logging
from datetime import timedelta from datetime import timedelta
@@ -96,7 +96,7 @@ class FpMigrationPreview(models.Model):
def _fp_notify_owners(self): def _fp_notify_owners(self):
"""Schedule a 'Review Fusion Plating role migration' activity on """Schedule a 'Review Fusion Plating role migration' activity on
every Owner user. Idempotent won't double-schedule.""" every Owner user. Idempotent - won't double-schedule."""
self.ensure_one() self.ensure_one()
owner_grp = self.env.ref('fusion_plating.group_fp_owner', raise_if_not_found=False) owner_grp = self.env.ref('fusion_plating.group_fp_owner', raise_if_not_found=False)
if not owner_grp: if not owner_grp:
@@ -214,7 +214,7 @@ class FpMigrationPreview(models.Model):
# Clear snapshots (no more rollback possible) # Clear snapshots (no more rollback possible)
for preview in expired: for preview in expired:
preview.line_ids.write({'applied_groups_snapshot': False}) preview.line_ids.write({'applied_groups_snapshot': False})
# Unlink old plating groups (now confirmed unused every user is # Unlink old plating groups (now confirmed unused - every user is
# on the new groups; backward-compat implied_ids chains can drop) # on the new groups; backward-compat implied_ids chains can drop)
old_group_ids = [] old_group_ids = []
for xmlid in _FP_OLD_GROUP_XMLIDS: for xmlid in _FP_OLD_GROUP_XMLIDS:
@@ -222,7 +222,7 @@ class FpMigrationPreview(models.Model):
if g: if g:
old_group_ids.append(g.id) old_group_ids.append(g.id)
if old_group_ids: if old_group_ids:
# I6 safety check never unlink a group that still has active # I6 safety check - never unlink a group that still has active
# internal users on it. If anyone still references the group # internal users on it. If anyone still references the group
# we'd cascade-strip them silently from their permissions. # we'd cascade-strip them silently from their permissions.
safe_to_unlink = [] safe_to_unlink = []

View File

@@ -15,7 +15,7 @@ class FpOperatorCertification(models.Model):
for that process. for that process.
""" """
_name = 'fp.operator.certification' _name = 'fp.operator.certification'
_description = 'Fusion Plating Operator Certification' _description = 'Fusion Plating - Operator Certification'
_inherit = ['mail.thread', 'mail.activity.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'employee_id, process_type_id' _order = 'employee_id, process_type_id'
@@ -53,7 +53,7 @@ class FpOperatorCertification(models.Model):
('revoked', 'Revoked')], ('revoked', 'Revoked')],
string='Status', string='Status',
compute='_compute_state', store=True, tracking=True, compute='_compute_state', store=True, tracking=True,
# NOT readonly=False this is purely derived from revoked + expires_date # NOT readonly=False - this is purely derived from revoked + expires_date
# so the nightly recompute never fights with manual edits. # so the nightly recompute never fights with manual edits.
) )
@@ -85,7 +85,7 @@ class FpOperatorCertification(models.Model):
continue continue
if rec.expires_date and rec.expires_date < today: if rec.expires_date and rec.expires_date < today:
continue continue
# This record is active look for another active sibling # This record is active - look for another active sibling
dupes = self.search_count([ dupes = self.search_count([
('id', '!=', rec.id), ('id', '!=', rec.id),
('employee_id', '=', rec.employee_id.id), ('employee_id', '=', rec.employee_id.id),
@@ -108,7 +108,7 @@ class FpOperatorCertification(models.Model):
@api.model @api.model
def has_active_cert(self, employee_id, process_type_id): def has_active_cert(self, employee_id, process_type_id):
"""Utility True if this employee holds a current certification. """Utility - True if this employee holds a current certification.
Checks revoked + expires_date directly instead of the computed Checks revoked + expires_date directly instead of the computed
`state` column, so even a certification that expired yesterday `state` column, so even a certification that expired yesterday

View File

@@ -84,21 +84,21 @@ class FpParentNumberedMixin(models.AbstractModel):
# downstream modules (e.g. fusion_plating_receiving) inherit the # downstream modules (e.g. fusion_plating_receiving) inherit the
# mixin but don't depend on jobs, so so.x_fc_parent_number can # mixin but don't depend on jobs, so so.x_fc_parent_number can
# raise AttributeError at test time. hasattr keeps the mixin safe # raise AttributeError at test time. hasattr keeps the mixin safe
# in either install topology falls through to the legacy # in either install topology - falls through to the legacy
# sequence when the column isn't there. # sequence when the column isn't there.
if not so or 'x_fc_parent_number' not in so._fields: if not so or 'x_fc_parent_number' not in so._fields:
return False return False
if not so.x_fc_parent_number: if not so.x_fc_parent_number:
return False return False
counter_field = self._fp_parent_counter_field() counter_field = self._fp_parent_counter_field()
# Whitelist check the field name is interpolated directly into # Whitelist check - the field name is interpolated directly into
# SQL below, so we never trust an arbitrary string. All current # SQL below, so we never trust an arbitrary string. All current
# subclasses return a literal; this guard exists so a future # subclasses return a literal; this guard exists so a future
# subclass that reads the field name from context / Selection / # subclass that reads the field name from context / Selection /
# user input can't smuggle a SQL fragment in. # user input can't smuggle a SQL fragment in.
if not _FP_COUNTER_FIELD_RE.match(counter_field or ''): if not _FP_COUNTER_FIELD_RE.match(counter_field or ''):
raise UserError(_( raise UserError(_(
'Invalid parent-counter field name %r must match ' 'Invalid parent-counter field name %r - must match '
'pattern x_fc_pn_*_count.' 'pattern x_fc_pn_*_count.'
) % counter_field) ) % counter_field)
# SELECT FOR UPDATE - locks the SO row until commit, so a # SELECT FOR UPDATE - locks the SO row until commit, so a
@@ -163,12 +163,12 @@ class FpParentNumberedMixin(models.AbstractModel):
# Cancellation must go through the state machine so the audit trail # Cancellation must go through the state machine so the audit trail
# keeps the issued number tied to its cancellation reason. Hard # keeps the issued number tied to its cancellation reason. Hard
# delete would leave a phantom gap in the counter. Applies to ALL # delete would leave a phantom gap in the counter. Applies to ALL
# users including admins no group bypass. # users including admins - no group bypass.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def unlink(self): def unlink(self):
for rec in self: for rec in self:
# Records still in their initial 'New' state (no number # Records still in their initial 'New' state (no number
# ever issued) are fine to delete they're not yet in # ever issued) are fine to delete - they're not yet in
# the audit trail. Once x_fc_doc_index is non-zero OR # the audit trail. Once x_fc_doc_index is non-zero OR
# name is something other than 'New' / '/', the record # name is something other than 'New' / '/', the record
# has been issued and is permanent. # has been issued and is permanent.

View File

@@ -14,7 +14,7 @@ class FpProcessCategory(models.Model):
load specific process types. load specific process types.
""" """
_name = 'fusion.plating.process.category' _name = 'fusion.plating.process.category'
_description = 'Fusion Plating Process Category' _description = 'Fusion Plating - Process Category'
_order = 'sequence, name' _order = 'sequence, name'
name = fields.Char( name = fields.Char(

View File

@@ -19,15 +19,15 @@ class FpProcessNode(models.Model):
Node types Node types
---------- ----------
* recipe top-level root (e.g. "Electroless Nickel Steel Line") * recipe - top-level root (e.g. "Electroless Nickel - Steel Line")
* sub_process a group of operations (e.g. "Steel Line", "Cleaner") * sub_process - a group of operations (e.g. "Steel Line", "Cleaner")
* operation a single production step (e.g. "Acid Dip", "Nickel Strike") * operation - a single production step (e.g. "Acid Dip", "Nickel Strike")
* step a sub-step within an operation (e.g. "Ready for Blast", "Blast") * step - a sub-step within an operation (e.g. "Ready for Blast", "Blast")
Hierarchy uses Odoo's _parent_store for efficient tree queries. Hierarchy uses Odoo's _parent_store for efficient tree queries.
""" """
_name = 'fusion.plating.process.node' _name = 'fusion.plating.process.node'
_description = 'Fusion Plating Process Node' _description = 'Fusion Plating - Process Node'
_inherit = ['mail.thread', 'mail.activity.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin']
_parent_store = True _parent_store = True
_parent_name = 'parent_id' _parent_name = 'parent_id'
@@ -108,7 +108,7 @@ class FpProcessNode(models.Model):
string='Description', string='Description',
help='Rich text instructions for this step.', help='Rich text instructions for this step.',
) )
# Sub 12d master switch for runtime data collection. When False the # Sub 12d - master switch for runtime data collection. When False the
# operator wizard skips this step entirely (no input prompts shown). # operator wizard skips this step entirely (no input prompts shown).
collect_measurements = fields.Boolean( collect_measurements = fields.Boolean(
string='Collect Measurements at Runtime', string='Collect Measurements at Runtime',
@@ -178,7 +178,7 @@ class FpProcessNode(models.Model):
# Recipe authors attach photos and screenshots here so operators see # Recipe authors attach photos and screenshots here so operators see
# them on the shop floor when running the step. Anything from a # them on the shop floor when running the step. Anything from a
# process diagram, masking-line photo, or annotated screenshot of the # process diagram, masking-line photo, or annotated screenshot of the
# WI document. Many2many supports zero, one, or many images. # WI document. Many2many - supports zero, one, or many images.
instruction_attachment_ids = fields.Many2many( instruction_attachment_ids = fields.Many2many(
'ir.attachment', 'ir.attachment',
'fp_node_instruction_attachment_rel', 'fp_node_instruction_attachment_rel',
@@ -187,7 +187,7 @@ class FpProcessNode(models.Model):
domain=[('mimetype', 'ilike', 'image/')], domain=[('mimetype', 'ilike', 'image/')],
help='Reference photos and screenshots that operators see at ' help='Reference photos and screenshots that operators see at '
'runtime. Anything visual that helps them execute the step ' 'runtime. Anything visual that helps them execute the step '
'correctly fixture orientation, masking pattern, gauge ' 'correctly - fixture orientation, masking pattern, gauge '
'reading. Supports multiple images per step.', 'reading. Supports multiple images per step.',
) )
instruction_attachment_count = fields.Integer( instruction_attachment_count = fields.Integer(
@@ -227,7 +227,7 @@ class FpProcessNode(models.Model):
requires_signoff = fields.Boolean( requires_signoff = fields.Boolean(
string='Requires Sign-Off', string='Requires Sign-Off',
default=False, default=False,
help='Quality hold point requires operator sign-off.', help='Quality hold point - requires operator sign-off.',
) )
requires_predecessor_done = fields.Boolean( requires_predecessor_done = fields.Boolean(
string='Requires Predecessor Done (legacy)', string='Requires Predecessor Done (legacy)',
@@ -235,16 +235,16 @@ class FpProcessNode(models.Model):
help='LEGACY per-step opt-in for predecessor enforcement. As of ' help='LEGACY per-step opt-in for predecessor enforcement. As of '
'19.0.X, recipes default to enforce_sequential=True so every ' '19.0.X, recipes default to enforce_sequential=True so every '
'step naturally waits for its predecessors. This flag still ' 'step naturally waits for its predecessors. This flag still '
'works on recipes whose enforce_sequential is False turn ' 'works on recipes whose enforce_sequential is False - turn '
'it on to make a single step block in an otherwise free-flow ' 'it on to make a single step block in an otherwise free-flow '
'recipe.', 'recipe.',
) )
# ===== Sub 13 sequential step enforcement (recipe + per-step) ========== # ===== Sub 13 - sequential step enforcement (recipe + per-step) ==========
# Replaces the unused per-step requires_predecessor_done as the primary # Replaces the unused per-step requires_predecessor_done as the primary
# enforcement vector. Two layers: # enforcement vector. Two layers:
# 1. enforce_sequential (recipe root) entire recipe is sequential # 1. enforce_sequential (recipe root) - entire recipe is sequential
# by default. Author can disable for free-flow recipes. # by default. Author can disable for free-flow recipes.
# 2. parallel_start (operation step) escape hatch within a # 2. parallel_start (operation step) - escape hatch within a
# sequential recipe, for steps that legitimately run in parallel # sequential recipe, for steps that legitimately run in parallel
# (e.g. paperwork that doesn't need previous step done). # (e.g. paperwork that doesn't need previous step done).
enforce_sequential = fields.Boolean( enforce_sequential = fields.Boolean(
@@ -286,10 +286,10 @@ class FpProcessNode(models.Model):
default='disabled', default='disabled',
help='Controls whether this step can be skipped or added on a ' help='Controls whether this step can be skipped or added on a '
'per-job basis:\n' 'per-job basis:\n'
' * Required every job runs this step. Cannot be removed.\n' ' * Required - every job runs this step. Cannot be removed.\n'
' * Opt-Out included by default; an estimator can remove ' ' * Opt-Out - included by default; an estimator can remove '
'it per job when the customer doesn\'t need it.\n' 'it per job when the customer doesn\'t need it.\n'
' * Opt-In excluded by default; an estimator can add it ' ' * Opt-In - excluded by default; an estimator can add it '
'per job when the customer specifically asks for it.', 'per job when the customer specifically asks for it.',
tracking=True, tracking=True,
) )
@@ -314,7 +314,7 @@ class FpProcessNode(models.Model):
# ---- Part ownership & provenance (Sub 3) -------------------------------- # ---- Part ownership & provenance (Sub 3) --------------------------------
# Sub 3 fields (part_catalog_id, cloned_from_id, treatment_uom) are # Sub 3 fields (part_catalog_id, cloned_from_id, treatment_uom) are
# declared as an inherit in fusion_plating_configurator they need # declared as an inherit in fusion_plating_configurator - they need
# to reference fp.part.catalog, which lives in configurator (a child # to reference fp.part.catalog, which lives in configurator (a child
# module). Adding them here would create a circular dependency. # module). Adding them here would create a circular dependency.
# See fusion_plating_configurator/models/fp_process_node_inherit.py. # See fusion_plating_configurator/models/fp_process_node_inherit.py.
@@ -354,9 +354,9 @@ class FpProcessNode(models.Model):
# NB. `pricing_rule_ids` lives in fusion_plating_configurator # NB. `pricing_rule_ids` lives in fusion_plating_configurator
# (added there so this core module doesn't depend on the configurator). # (added there so this core module doesn't depend on the configurator).
# ---- Spec-derived metadata (recipe-root only Promote Customer Spec) ---- # ---- Spec-derived metadata (recipe-root only - Promote Customer Spec) ----
# These were on fp.coating.config (since retired). They describe the # These were on fp.coating.config (since retired). They describe the
# PROCESS the recipe runs, not the customer-facing specification # PROCESS the recipe runs, not the customer-facing specification -
# specs live on fusion.plating.customer.spec. # specs live on fusion.plating.customer.spec.
phosphorus_level = fields.Selection( phosphorus_level = fields.Selection(
[('low_phos', 'Low Phosphorus (2-5%)'), [('low_phos', 'Low Phosphorus (2-5%)'),
@@ -375,12 +375,12 @@ class FpProcessNode(models.Model):
[('mils', 'mils'), ('microns', 'microns'), ('inches', 'inches')], [('mils', 'mils'), ('microns', 'microns'), ('inches', 'inches')],
string='Thickness UoM', default='mils', string='Thickness UoM', default='mils',
) )
# thickness_option_ids removed fp.recipe.thickness model deleted. # thickness_option_ids removed - fp.recipe.thickness model deleted.
# Thickness on the SO line is now a free-text Char range (e.g. # 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 # "0.0005-0.0008 mils") that auto-fills from last-used per
# (part, customer) or the part's x_fc_default_thickness_range. # (part, customer) or the part's x_fc_default_thickness_range.
# ---- Bake relief AMS 2759/9 hydrogen embrittlement (recipe root) ---- # ---- Bake relief - AMS 2759/9 hydrogen embrittlement (recipe root) ----
requires_bake_relief = fields.Boolean( requires_bake_relief = fields.Boolean(
string='Requires Bake Relief', string='Requires Bake Relief',
help='Hydrogen embrittlement relief bake required (high-strength ' help='Hydrogen embrittlement relief bake required (high-strength '
@@ -454,7 +454,7 @@ class FpProcessNode(models.Model):
copy=True, copy=True,
) )
# ===== Sub 12a Simple Editor + Step Library extensions ================= # ===== Sub 12a - Simple Editor + Step Library extensions =================
# All fields are additive; tree editor + runtime are unaffected. Drag-drop # All fields are additive; tree editor + runtime are unaffected. Drag-drop
# from the library snapshot-copies these into a new node (no live ref). # from the library snapshot-copies these into a new node (no live ref).
@@ -468,7 +468,7 @@ class FpProcessNode(models.Model):
string='Source Library Template', string='Source Library Template',
ondelete='set null', ondelete='set null',
index=True, index=True,
help='Snapshot trace set when this node was created by dragging ' help='Snapshot trace - set when this node was created by dragging '
'a library step in. Editing the template later does not change ' 'a library step in. Editing the template later does not change '
'this node (snapshot semantics).', 'this node (snapshot semantics).',
) )
@@ -499,14 +499,14 @@ class FpProcessNode(models.Model):
viscosity_target = fields.Float(string='Viscosity Target') viscosity_target = fields.Float(string='Viscosity Target')
requires_rack_assignment = fields.Boolean( requires_rack_assignment = fields.Boolean(
string='Requires Rack Assignment', string='Requires Rack Assignment',
help='Sub 12b triggers Rack Parts sub-dialog at runtime.', help='Sub 12b - triggers Rack Parts sub-dialog at runtime.',
) )
requires_transition_form = fields.Boolean( requires_transition_form = fields.Boolean(
string='Requires Transition Form', string='Requires Transition Form',
help='Sub 12b opens the transition form before Mark Done.', help='Sub 12b - opens the transition form before Mark Done.',
) )
# Certificate Output recipe-level cert suppression (2026-05-27 sub # Certificate Output - recipe-level cert suppression (2026-05-27 sub
# docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md). # docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md).
# Default True for all five so existing recipes keep producing the # Default True for all five so existing recipes keep producing the
# same cert set they produce today. A recipe author flips OFF only # same cert set they produce today. A recipe author flips OFF only
@@ -532,7 +532,7 @@ class FpProcessNode(models.Model):
default=True, default=True,
help='When False, this recipe never produces a thickness report. ' help='When False, this recipe never produces a thickness report. '
'Use for passivation, chemical conversion, anodize seal-only, ' 'Use for passivation, chemical conversion, anodize seal-only, '
'etc. processes that physically have no plating thickness ' 'etc. - processes that physically have no plating thickness '
'to measure.', 'to measure.',
) )
requires_nadcap_cert = fields.Boolean( requires_nadcap_cert = fields.Boolean(
@@ -555,8 +555,8 @@ class FpProcessNode(models.Model):
'cert.', 'cert.',
) )
# Sub 14b User-extensible Step Kinds (was Selection of 24). # Sub 14b - User-extensible Step Kinds (was Selection of 24).
# 2026-05-20: required + ondelete='restrict' kind drives gates, # 2026-05-20: required + ondelete='restrict' - kind drives gates,
# workflow milestones, and operator routing. Optional was a foot-gun # workflow milestones, and operator routing. Optional was a foot-gun
# (operators silently picked Generic / nothing). Pre-migrate # (operators silently picked Generic / nothing). Pre-migrate
# 19.0.20.6.0 backfills every existing row before this NOT NULL # 19.0.20.6.0 backfills every existing row before this NOT NULL
@@ -642,7 +642,7 @@ class FpProcessNode(models.Model):
# how stable a recipe is and (later) lets a job pin to a specific # how stable a recipe is and (later) lets a job pin to a specific
# recipe revision so already-running MOs don't see mid-flight changes. # recipe revision so already-running MOs don't see mid-flight changes.
# Fields that don't represent a "meaningful" change adjusting these # Fields that don't represent a "meaningful" change - adjusting these
# alone does not bump the version. `version` itself is in the list to # alone does not bump the version. `version` itself is in the list to
# avoid an infinite write loop. # avoid an infinite write loop.
_FP_NON_VERSIONED_FIELDS = { _FP_NON_VERSIONED_FIELDS = {
@@ -656,7 +656,7 @@ class FpProcessNode(models.Model):
the current recordset.""" the current recordset."""
roots = self.mapped('recipe_root_id') roots = self.mapped('recipe_root_id')
# _compute_recipe_root_id falls back to self for nodes whose # _compute_recipe_root_id falls back to self for nodes whose
# parent_path isn't yet stored pick those up too. # parent_path isn't yet stored - pick those up too.
for rec in self: for rec in self:
if not rec.recipe_root_id and rec.node_type == 'recipe': if not rec.recipe_root_id and rec.node_type == 'recipe':
roots |= rec roots |= rec
@@ -676,7 +676,7 @@ class FpProcessNode(models.Model):
@api.model_create_multi @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
records = super().create(vals_list) records = super().create(vals_list)
# Skip non-recipe roots only count when the new node lives # Skip non-recipe roots - only count when the new node lives
# inside an existing recipe. # inside an existing recipe.
descendants = records.filtered(lambda r: r.node_type != 'recipe') descendants = records.filtered(lambda r: r.node_type != 'recipe')
if descendants: if descendants:
@@ -684,7 +684,7 @@ class FpProcessNode(models.Model):
return records return records
def write(self, vals): def write(self, vals):
# NADCAP / change-control lock block writes on locked recipes # NADCAP / change-control lock - block writes on locked recipes
# (and their descendants) for non-manager users. Manager bypass # (and their descendants) for non-manager users. Manager bypass
# so the lock can be toggled off. # so the lock can be toggled off.
if (self if (self
@@ -759,11 +759,11 @@ class FpProcessNode(models.Model):
'customer_visible': self.customer_visible, 'customer_visible': self.customer_visible,
'is_manual': self.is_manual, 'is_manual': self.is_manual,
'requires_signoff': self.requires_signoff, 'requires_signoff': self.requires_signoff,
# Sub 13 sequential enforcement # Sub 13 - sequential enforcement
'enforce_sequential': self.enforce_sequential, 'enforce_sequential': self.enforce_sequential,
'parallel_start': self.parallel_start, 'parallel_start': self.parallel_start,
'requires_predecessor_done': self.requires_predecessor_done, 'requires_predecessor_done': self.requires_predecessor_done,
# Sub 14 workflow milestone trigger (Many2one or False) # Sub 14 - workflow milestone trigger (Many2one or False)
'triggers_workflow_state_id': ( 'triggers_workflow_state_id': (
self.triggers_workflow_state_id.id self.triggers_workflow_state_id.id
if 'triggers_workflow_state_id' in self._fields if 'triggers_workflow_state_id' in self._fields
@@ -817,7 +817,7 @@ class FpProcessNode(models.Model):
return { return {
'type': 'ir.actions.client', 'type': 'ir.actions.client',
'tag': 'fp_recipe_tree_editor', 'tag': 'fp_recipe_tree_editor',
'name': f'Recipe {root.name}', 'name': f'Recipe - {root.name}',
'context': {'recipe_id': root.id}, 'context': {'recipe_id': root.id},
} }
@@ -828,7 +828,7 @@ class FpProcessNode(models.Model):
return { return {
'type': 'ir.actions.client', 'type': 'ir.actions.client',
'tag': 'fp_simple_recipe_editor', 'tag': 'fp_simple_recipe_editor',
'name': f'Recipe {root.name}', 'name': f'Recipe - {root.name}',
'context': {'recipe_id': root.id}, 'context': {'recipe_id': root.id},
} }
@@ -846,7 +846,7 @@ class FpProcessNode(models.Model):
def action_open_recipe_with_preferred_editor(self): def action_open_recipe_with_preferred_editor(self):
"""Routes to whichever editor the recipe (or company) prefers. """Routes to whichever editor the recipe (or company) prefers.
Used by menu actions / context-menu opens gives the Used by menu actions / context-menu opens - gives the
simple-loving foreman a one-click path that respects their simple-loving foreman a one-click path that respects their
preference without forcing a tree-loving engineer to pick preference without forcing a tree-loving engineer to pick
between two buttons every time. between two buttons every time.
@@ -930,10 +930,10 @@ class FpProcessNodeInput(models.Model):
"""An operator input definition attached to a process node. """An operator input definition attached to a process node.
These define what the operator needs to record when executing this These define what the operator needs to record when executing this
step temperature readings, visual inspections, timing, etc. step - temperature readings, visual inspections, timing, etc.
""" """
_name = 'fusion.plating.process.node.input' _name = 'fusion.plating.process.node.input'
_description = 'Fusion Plating Process Node Input' _description = 'Fusion Plating - Process Node Input'
_order = 'sequence, id' _order = 'sequence, id'
name = fields.Char( name = fields.Char(
@@ -954,7 +954,7 @@ class FpProcessNodeInput(models.Model):
('boolean', 'Yes / No'), ('boolean', 'Yes / No'),
('selection', 'Selection'), ('selection', 'Selection'),
('photo', 'Photo'), ('photo', 'Photo'),
# Sub 12a typed inputs the simple editor + traveller need # Sub 12a - typed inputs the simple editor + traveller need
('time_hms', 'Time (HH:MM:SS)'), ('time_hms', 'Time (HH:MM:SS)'),
('time_seconds', 'Time (seconds)'), ('time_seconds', 'Time (seconds)'),
('temperature', 'Temperature'), ('temperature', 'Temperature'),
@@ -992,11 +992,11 @@ class FpProcessNodeInput(models.Model):
uom = fields.Selection( uom = fields.Selection(
FP_UOM_SELECTION, FP_UOM_SELECTION,
string='Unit', string='Unit',
help='Unit the operator is recording in (pick from the curated list ' help='Unit the operator is recording in (pick from the curated list - '
'avoids "kg" vs "kgs" vs "kilo" inconsistencies).', 'avoids "kg" vs "kgs" vs "kilo" inconsistencies).',
) )
# ===== Sub 12a kind + target ranges + compliance tag ================== # ===== Sub 12a - kind + target ranges + compliance tag ==================
kind = fields.Selection( kind = fields.Selection(
[ [
('step_input', 'Step Measurement'), ('step_input', 'Step Measurement'),
@@ -1036,7 +1036,7 @@ class FpProcessNodeInput(models.Model):
string='Compliance Tag', default='none', string='Compliance Tag', default='none',
) )
# ===== Sub 12d per-recipe configurability ============================= # ===== Sub 12d - per-recipe configurability =============================
collect = fields.Boolean( collect = fields.Boolean(
string='Collect This Measurement', string='Collect This Measurement',
default=True, default=True,
@@ -1049,7 +1049,7 @@ class FpProcessNodeInput(models.Model):
string='Source Library Prompt', string='Source Library Prompt',
ondelete='set null', ondelete='set null',
help='Set when this row was snapshot-copied from a library template ' help='Set when this row was snapshot-copied from a library template '
'prompt. Powers "Reset to Library Defaults" rows where this ' 'prompt. Powers "Reset to Library Defaults" - rows where this '
'is False are treated as recipe-only custom prompts and survive ' 'is False are treated as recipe-only custom prompts and survive '
'the reset.', 'the reset.',
) )

View File

@@ -19,14 +19,14 @@ class FpProcessType(models.Model):
and linked here via parameter_ids. and linked here via parameter_ids.
""" """
_name = 'fusion.plating.process.type' _name = 'fusion.plating.process.type'
_description = 'Fusion Plating Process Type' _description = 'Fusion Plating - Process Type'
_order = 'sequence, name' _order = 'sequence, name'
name = fields.Char( name = fields.Char(
string='Process', string='Process',
required=True, required=True,
translate=True, translate=True,
help='Display name (e.g. "Electroless Nickel Mid Phosphorus").', help='Display name (e.g. "Electroless Nickel - Mid Phosphorus").',
) )
code = fields.Char( code = fields.Char(
string='Code', string='Code',

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
# #
# Phase 1 (Sub 11) relocated from fusion_plating_bridge_mrp. The model # Phase 1 (Sub 11) - relocated from fusion_plating_bridge_mrp. The model
# never had MRP fields; the bridge module was just its initial home. # never had MRP fields; the bridge module was just its initial home.
from markupsafe import Markup from markupsafe import Markup
@@ -12,7 +12,7 @@ from odoo import _, api, fields, models
class FpOperatorProficiency(models.Model): class FpOperatorProficiency(models.Model):
"""Operator proficiency tracker counts successful step completions """Operator proficiency tracker - counts successful step completions
per (employee, role) pair and auto-promotes the employee once the per (employee, role) pair and auto-promotes the employee once the
role's mastery threshold is crossed. role's mastery threshold is crossed.
@@ -23,7 +23,7 @@ class FpOperatorProficiency(models.Model):
in a form; their growing skill set just unlocks itself. in a form; their growing skill set just unlocks itself.
""" """
_name = 'fp.operator.proficiency' _name = 'fp.operator.proficiency'
_description = 'Fusion Plating Operator Task Proficiency' _description = 'Fusion Plating - Operator Task Proficiency'
_rec_name = 'display_name' _rec_name = 'display_name'
_order = 'employee_id, role_id' _order = 'employee_id, role_id'
@@ -55,7 +55,7 @@ class FpOperatorProficiency(models.Model):
index=True, index=True,
help='True once the role has been added to the operator\'s Shop ' help='True once the role has been added to the operator\'s Shop '
'Roles automatically. Stays True even if a manager removes ' 'Roles automatically. Stays True even if a manager removes '
'the role afterwards the count and promotion history are ' 'the role afterwards - the count and promotion history are '
'preserved as a training record.', 'preserved as a training record.',
) )
promoted_at = fields.Datetime( promoted_at = fields.Datetime(
@@ -83,7 +83,7 @@ class FpOperatorProficiency(models.Model):
def _compute_display_name(self): def _compute_display_name(self):
for rec in self: for rec in self:
rec.display_name = ( rec.display_name = (
f'{rec.employee_id.name or "?"} {rec.role_id.name or "?"}' f'{rec.employee_id.name or "?"} - {rec.role_id.name or "?"}'
) )
@api.depends('completed_count', 'role_id.mastery_required') @api.depends('completed_count', 'role_id.mastery_required')
@@ -99,7 +99,7 @@ class FpOperatorProficiency(models.Model):
def _record_completion(self, employee, role): def _record_completion(self, employee, role):
"""Increment the (employee, role) tally and promote if at threshold. """Increment the (employee, role) tally and promote if at threshold.
Idempotent for the (employee, role) pair if no record exists, Idempotent for the (employee, role) pair - if no record exists,
we create one. Always uses sudo() because the worker may not we create one. Always uses sudo() because the worker may not
have write access to their own profile. have write access to their own profile.
""" """
@@ -151,7 +151,7 @@ class FpOperatorProficiency(models.Model):
}) })
employee.message_post( employee.message_post(
body=Markup(_( body=Markup(_(
'<b>%(name)s promoted</b> qualified for ' '<b>%(name)s promoted</b> - qualified for '
'<b>%(role)s</b> after %(count)s successful ' '<b>%(role)s</b> after %(count)s successful '
'completions.' 'completions.'
)) % { )) % {

View File

@@ -15,7 +15,7 @@ class FpRack(models.Model):
on parts. on parts.
""" """
_name = 'fusion.plating.rack' _name = 'fusion.plating.rack'
_description = 'Fusion Plating Rack / Fixture' _description = 'Fusion Plating - Rack / Fixture'
_inherit = ['mail.thread', 'mail.activity.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'facility_id, rack_type, name' _order = 'facility_id, rack_type, name'
@@ -79,7 +79,7 @@ class FpRack(models.Model):
def _compute_state(self): def _compute_state(self):
for rec in self: for rec in self:
if rec.state in ('stripping', 'retired'): if rec.state in ('stripping', 'retired'):
continue # Manually set don't override continue # Manually set - don't override
if rec.strip_interval_mto and rec.mto_count >= rec.strip_interval_mto: if rec.strip_interval_mto and rec.mto_count >= rec.strip_interval_mto:
rec.state = 'needs_strip' rec.state = 'needs_strip'
elif rec.state != 'active': elif rec.state != 'active':
@@ -116,7 +116,7 @@ class FpRack(models.Model):
for rec in self: for rec in self:
rec.mto_count = (rec.mto_count or 0.0) + delta rec.mto_count = (rec.mto_count or 0.0) + delta
# ===== Sub 12b racking lifecycle (orthogonal to wear-tracking state) = # ===== Sub 12b - racking lifecycle (orthogonal to wear-tracking state) =
racking_state = fields.Selection( racking_state = fields.Selection(
[ [
('empty', 'Empty'), ('empty', 'Empty'),
@@ -136,8 +136,8 @@ class FpRack(models.Model):
string='Tags', string='Tags',
) )
capacity_count = fields.Integer( capacity_count = fields.Integer(
string='Capacity (parts) soft warn', string='Capacity (parts) - soft warn',
help='Soft warning threshold runtime informs operator when ' help='Soft warning threshold - runtime informs operator when '
'rack is loaded beyond this. Not enforced. Distinct from ' 'rack is loaded beyond this. Not enforced. Distinct from '
'`capacity` field (planning capacity).', '`capacity` field (planning capacity).',
) )

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
# #
# Multi-rack splitting + WO grouping at Racking Phase 1 core models. # Multi-rack splitting + WO grouping at Racking - Phase 1 core models.
# Spec: docs/superpowers/specs/2026-06-03-racking-multi-rack-wo-grouping-design.md # Spec: docs/superpowers/specs/2026-06-03-racking-multi-rack-wo-grouping-design.md
# Plan: docs/superpowers/plans/2026-06-03-racking-multi-rack-phase1.md # Plan: docs/superpowers/plans/2026-06-03-racking-multi-rack-phase1.md
# #
@@ -60,7 +60,7 @@ class FpRackLoad(models.Model):
return super().create(vals_list) return super().create(vals_list)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Pure division math (no DB) verifiable in isolation. # Pure division math (no DB) - verifiable in isolation.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@api.model @api.model
def _fp_equal_split(self, total, n): def _fp_equal_split(self, total, n):

View File

@@ -9,12 +9,12 @@ from odoo import fields, models
class FpRackTag(models.Model): class FpRackTag(models.Model):
"""Operator-visible labels applied to physical racks. """Operator-visible labels applied to physical racks.
"Rush" / "Hold for QC" / "Customer-Amphenol" / "Damaged" the "Rush" / "Hold for QC" / "Customer-Amphenol" / "Damaged" - the
coloured tag chips that appear in the Move Rack dialog and on the coloured tag chips that appear in the Move Rack dialog and on the
plant-overview rack rows. M2M; one rack can carry many tags. plant-overview rack rows. M2M; one rack can carry many tags.
""" """
_name = 'fp.rack.tag' _name = 'fp.rack.tag'
_description = 'Fusion Plating Rack Tag' _description = 'Fusion Plating - Rack Tag'
_order = 'sequence, name' _order = 'sequence, name'
name = fields.Char(string='Tag', required=True, translate=True) name = fields.Char(string='Tag', required=True, translate=True)

View File

@@ -39,7 +39,7 @@ _NEW_ROLE_XMLID = {
# Predicate is a callable taking a res.users record; returns bool. # Predicate is a callable taking a res.users record; returns bool.
_FP_ROLE_MAPPING_RULES = [ _FP_ROLE_MAPPING_RULES = [
# cgp_designated_official MUST be first so admin/uid_1/uid_2 users who ALSO # cgp_designated_official MUST be first so admin/uid_1/uid_2 users who ALSO
# hold the DO group still get the capability_delta marker which is what # hold the DO group still get the capability_delta marker - which is what
# triggers action_approve_and_run to set res.company.x_fc_cgp_designated_official_id. # triggers action_approve_and_run to set res.company.x_fc_cgp_designated_official_id.
# If admin matched first, the DO field would never get populated for shops # If admin matched first, the DO field would never get populated for shops
# where the admin is also the registered PSPC Designated Official. # where the admin is also the registered PSPC Designated Official.

View File

@@ -16,7 +16,7 @@ class FpStepKind(models.Model):
inputs that get seeded onto a step template when the kind is picked. inputs that get seeded onto a step template when the kind is picked.
""" """
_name = 'fp.step.kind' _name = 'fp.step.kind'
_description = 'Fusion Plating Step Kind' _description = 'Fusion Plating - Step Kind'
_order = 'sequence, name' _order = 'sequence, name'
code = fields.Char( code = fields.Char(
@@ -34,7 +34,7 @@ class FpStepKind(models.Model):
string='Icon', string='Icon',
default='fa-cog', default='fa-cog',
) )
# 2026-05-24 Shop Floor live-step fix. # 2026-05-24 - Shop Floor live-step fix.
# Each kind self-declares which plant-view column its steps land in. # Each kind self-declares which plant-view column its steps land in.
# Replaces the hardcoded _STEP_KIND_TO_AREA dict (removed from # Replaces the hardcoded _STEP_KIND_TO_AREA dict (removed from
# fusion_plating_jobs/models/fp_job_step.py). Pre-migrate # fusion_plating_jobs/models/fp_job_step.py). Pre-migrate
@@ -56,7 +56,7 @@ class FpStepKind(models.Model):
index=True, index=True,
help='Determines which column on the Shop Floor plant kanban shows ' help='Determines which column on the Shop Floor plant kanban shows '
'cards whose active step uses this kind. Step kinds drive ' 'cards whose active step uses this kind. Step kinds drive '
'routing automatically picking a kind tells the system both ' 'routing automatically - picking a kind tells the system both '
'what gates fire AND where the card lives.', 'what gates fire AND where the card lives.',
) )
company_id = fields.Many2one( company_id = fields.Many2one(
@@ -79,7 +79,7 @@ class FpStepKind(models.Model):
] ]
# Curated FontAwesome 4 icon catalog for the visual icon picker. # Curated FontAwesome 4 icon catalog for the visual icon picker.
# Bigger than the 24 historical fp.process.node list covers # Bigger than the 24 historical fp.process.node list - covers
# manufacturing, lab, quality, shipping, safety, time, status etc. # manufacturing, lab, quality, shipping, safety, time, status etc.
# FA4 ships with Odoo (no extra deps). Key = CSS class, Value = label. # FA4 ships with Odoo (no extra deps). Key = CSS class, Value = label.
_ICON_SELECTION = [ _ICON_SELECTION = [
@@ -258,7 +258,7 @@ class FpStepKind(models.Model):
self.ensure_one() self.ensure_one()
return { return {
'type': 'ir.actions.act_window', 'type': 'ir.actions.act_window',
'name': _('Step Templates %s') % self.name, 'name': _('Step Templates - %s') % self.name,
'res_model': 'fp.step.template', 'res_model': 'fp.step.template',
'view_mode': 'list,form', 'view_mode': 'list,form',
'domain': [('kind_id', '=', self.id)], 'domain': [('kind_id', '=', self.id)],
@@ -271,10 +271,10 @@ class FpStepKindDefaultInput(models.Model):
When a recipe author picks a kind on a step template and clicks When a recipe author picks a kind on a step template and clicks
'Seed Defaults', these get copied into the template's input list 'Seed Defaults', these get copied into the template's input list
(idempotent skips by name). (idempotent - skips by name).
""" """
_name = 'fp.step.kind.default.input' _name = 'fp.step.kind.default.input'
_description = 'Fusion Plating Step Kind Default Input' _description = 'Fusion Plating - Step Kind Default Input'
_order = 'sequence, name' _order = 'sequence, name'
name = fields.Char(string='Name', required=True, translate=True) name = fields.Char(string='Name', required=True, translate=True)

View File

@@ -10,13 +10,13 @@ class FpStepTemplate(models.Model):
"""Reusable step template for the Simple Recipe Editor. """Reusable step template for the Simple Recipe Editor.
A library entry the recipe author can drag into a recipe. Snapshot- A library entry the recipe author can drag into a recipe. Snapshot-
copied at drag time editing the template later does NOT change copied at drag time - editing the template later does NOT change
recipes already built. Carries the same shape fields as the runtime recipes already built. Carries the same shape fields as the runtime
`fusion.plating.process.node` so a snapshot copy is a 1:1 field `fusion.plating.process.node` so a snapshot copy is a 1:1 field
transfer. transfer.
""" """
_name = 'fp.step.template' _name = 'fp.step.template'
_description = 'Fusion Plating Step Library Template' _description = 'Fusion Plating - Step Library Template'
_inherit = ['mail.thread', 'mail.activity.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'sequence, name' _order = 'sequence, name'
@@ -69,7 +69,7 @@ class FpStepTemplate(models.Model):
requires_predecessor_done = fields.Boolean( requires_predecessor_done = fields.Boolean(
string='Require Predecessor Done (legacy)', string='Require Predecessor Done (legacy)',
help='Legacy per-step opt-in for predecessor enforcement. Recipes ' help='Legacy per-step opt-in for predecessor enforcement. Recipes '
'now default to Enforce Sequential Order use Parallel ' 'now default to Enforce Sequential Order - use Parallel '
'Start instead when you want a step to run alongside others.', 'Start instead when you want a step to run alongside others.',
) )
parallel_start = fields.Boolean( parallel_start = fields.Boolean(
@@ -79,7 +79,7 @@ class FpStepTemplate(models.Model):
'earlier-sequence steps are still in progress (e.g. ' 'earlier-sequence steps are still in progress (e.g. '
'paperwork that runs alongside production).', 'paperwork that runs alongside production).',
) )
# Sub 14 triggers_workflow_state_id is declared via _inherit in # Sub 14 - triggers_workflow_state_id is declared via _inherit in
# fusion_plating_jobs/models/fp_job.py. It can't live here because # fusion_plating_jobs/models/fp_job.py. It can't live here because
# the target model (fp.job.workflow.state) is defined in jobs, and # the target model (fp.job.workflow.state) is defined in jobs, and
# core can't depend on jobs (cyclic dependency). # core can't depend on jobs (cyclic dependency).
@@ -88,8 +88,8 @@ class FpStepTemplate(models.Model):
requires_transition_form = fields.Boolean(string='Requires Transition Form', requires_transition_form = fields.Boolean(string='Requires Transition Form',
help='Opens the transition form before Mark Done (Sub 12b).') help='Opens the transition form before Mark Done (Sub 12b).')
# Sub 14b User-extensible Step Kinds (was Selection of 24). # Sub 14b - User-extensible Step Kinds (was Selection of 24).
# 2026-05-20: required same rationale as on fusion.plating.process.node # 2026-05-20: required - same rationale as on fusion.plating.process.node
# (kind drives every downstream gate / milestone / routing decision). # (kind drives every downstream gate / milestone / routing decision).
kind_id = fields.Many2one( kind_id = fields.Many2one(
'fp.step.kind', string='Step Kind', ondelete='restrict', 'fp.step.kind', string='Step Kind', ondelete='restrict',
@@ -102,7 +102,7 @@ class FpStepTemplate(models.Model):
'milestones / routing when authors instantiate the template. ' 'milestones / routing when authors instantiate the template. '
'Pick "Other" only when the step has no special behaviour.', 'Pick "Other" only when the step has no special behaviour.',
) )
# Back-compat shim every legacy `tpl.default_kind == "cleaning"` # Back-compat shim - every legacy `tpl.default_kind == "cleaning"`
# call site keeps working without a refactor. Stored=True so existing # call site keeps working without a refactor. Stored=True so existing
# search domains [('default_kind', '=', 'cleaning')] still hit an # search domains [('default_kind', '=', 'cleaning')] still hit an
# indexed column. # indexed column.
@@ -148,7 +148,7 @@ class FpStepTemplate(models.Model):
return super().write(vals) return super().write(vals)
# ----- Sane defaults seeding --------------------------------------------- # ----- Sane defaults seeding ---------------------------------------------
# Sub 14b moved from a Python dict into seeded fp.step.kind records # Sub 14b - moved from a Python dict into seeded fp.step.kind records
# so users can add new kinds + their default inputs through the # so users can add new kinds + their default inputs through the
# standard UI. The dict below is preserved as a fallback only for # standard UI. The dict below is preserved as a fallback only for
# codes that don't have a matching kind_id record (legacy data after # codes that don't have a matching kind_id record (legacy data after
@@ -205,7 +205,7 @@ class FpStepTemplate(models.Model):
{'name': 'Rinse Type', 'input_type': 'selection', 'sequence': 10, {'name': 'Rinse Type', 'input_type': 'selection', 'sequence': 10,
'selection_options': 'cascade,spray,DI,city'}, 'selection_options': 'cascade,spray,DI,city'},
{'name': 'Conductivity', 'input_type': 'number', 'sequence': 20, {'name': 'Conductivity', 'input_type': 'number', 'sequence': 20,
'hint': 'µS/cm required for DI rinses'}, 'hint': 'µS/cm - required for DI rinses'},
{'name': 'Actual Time', 'input_type': 'time_seconds', {'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 's', 'sequence': 30}, 'target_unit': 's', 'sequence': 30},
], ],
@@ -232,7 +232,7 @@ class FpStepTemplate(models.Model):
{'name': 'Bath Concentration', 'input_type': 'number', 'sequence': 50, {'name': 'Bath Concentration', 'input_type': 'number', 'sequence': 50,
'hint': 'g/L'}, 'hint': 'g/L'},
{'name': 'Current Density', 'input_type': 'number', 'sequence': 60, {'name': 'Current Density', 'input_type': 'number', 'sequence': 60,
'hint': 'ASF electroplate only'}, 'hint': 'ASF - electroplate only'},
{'name': 'Plating Thickness', 'input_type': 'multi_point_thickness', {'name': 'Plating Thickness', 'input_type': 'multi_point_thickness',
'target_unit': 'in', 'sequence': 70}, 'target_unit': 'in', 'sequence': 70},
], ],
@@ -414,7 +414,7 @@ class FpStepTemplate(models.Model):
return True return True
# Mapping from fp.step.kind.default.input fields → fp.step.template.input # Mapping from fp.step.kind.default.input fields → fp.step.template.input
# spec dict. Keep narrow copy only the columns both models share. # spec dict. Keep narrow - copy only the columns both models share.
_KIND_DEFAULT_INPUT_FIELDS = ( _KIND_DEFAULT_INPUT_FIELDS = (
'name', 'input_type', 'target_unit', 'required', 'name', 'input_type', 'target_unit', 'required',
'hint', 'selection_options', 'sequence', 'hint', 'selection_options', 'sequence',
@@ -422,12 +422,12 @@ class FpStepTemplate(models.Model):
def action_seed_default_inputs(self): def action_seed_default_inputs(self):
"""Seed input_template_ids from kind_id.default_input_ids. """Seed input_template_ids from kind_id.default_input_ids.
Idempotent only adds inputs whose names don't already exist on Idempotent - only adds inputs whose names don't already exist on
this template. this template.
Falls back to the legacy DEFAULT_INPUTS_BY_KIND dict if the Falls back to the legacy DEFAULT_INPUTS_BY_KIND dict if the
template has no kind_id but still carries a default_kind code template has no kind_id but still carries a default_kind code
(defensive shouldn't happen post-migration). (defensive - shouldn't happen post-migration).
Public method (Odoo 19 requires non-underscore-prefixed names Public method (Odoo 19 requires non-underscore-prefixed names
for methods called from a view button). for methods called from a view button).
@@ -441,7 +441,7 @@ class FpStepTemplate(models.Model):
spec = {f: d[f] for f in self._KIND_DEFAULT_INPUT_FIELDS} spec = {f: d[f] for f in self._KIND_DEFAULT_INPUT_FIELDS}
specs.append(spec) specs.append(spec)
elif tpl.default_kind: elif tpl.default_kind:
# Legacy fallback kind_id never got linked. # Legacy fallback - kind_id never got linked.
specs = self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, []) specs = self.DEFAULT_INPUTS_BY_KIND.get(tpl.default_kind, [])
for spec in specs: for spec in specs:
if spec['name'] in existing_names: if spec['name'] in existing_names:

View File

@@ -16,7 +16,7 @@ class FpStepTemplateInput(models.Model):
step. step.
""" """
_name = 'fp.step.template.input' _name = 'fp.step.template.input'
_description = 'Fusion Plating Step Template Input' _description = 'Fusion Plating - Step Template Input'
_order = 'sequence, name' _order = 'sequence, name'
name = fields.Char(string='Name', required=True, translate=True) name = fields.Char(string='Name', required=True, translate=True)

View File

@@ -15,7 +15,7 @@ class FpStepTemplateTransitionInput(models.Model):
into a recipe. Sub 12b uses these to render the Move Parts dialog. into a recipe. Sub 12b uses these to render the Move Parts dialog.
""" """
_name = 'fp.step.template.transition.input' _name = 'fp.step.template.transition.input'
_description = 'Fusion Plating Step Template Transition Input' _description = 'Fusion Plating - Step Template Transition Input'
_order = 'sequence, name' _order = 'sequence, name'
name = fields.Char(string='Name', required=True, translate=True) name = fields.Char(string='Name', required=True, translate=True)

View File

@@ -17,7 +17,7 @@ class FpTank(models.Model):
shop-floor station. shop-floor station.
""" """
_name = 'fusion.plating.tank' _name = 'fusion.plating.tank'
_description = 'Fusion Plating Tank' _description = 'Fusion Plating - Tank'
_inherit = ['mail.thread', 'mail.activity.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'facility_id, section_id, sequence, code' _order = 'facility_id, section_id, sequence, code'
@@ -228,7 +228,7 @@ class FpTank(models.Model):
@api.onchange('current_bath_process_id') @api.onchange('current_bath_process_id')
def _onchange_seed_current_process(self): def _onchange_seed_current_process(self):
"""Pre-fill the editable Current Process from the active bath when """Pre-fill the editable Current Process from the active bath when
the operator hasn't already set one keeps the field useful out of the operator hasn't already set one - keeps the field useful out of
the box while still allowing manual override.""" the box while still allowing manual override."""
for rec in self: for rec in self:
if not rec.current_process_id and rec.current_bath_process_id: if not rec.current_process_id and rec.current_bath_process_id:

View File

@@ -19,7 +19,7 @@ class FpTankComposition(models.Model):
percentages. Changes to ingredients are also chatter-tracked. percentages. Changes to ingredients are also chatter-tracked.
""" """
_name = 'fusion.plating.tank.composition' _name = 'fusion.plating.tank.composition'
_description = 'Fusion Plating Tank Composition' _description = 'Fusion Plating - Tank Composition'
_inherit = ['mail.thread', 'mail.activity.mixin'] _inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'tank_id, sequence, name' _order = 'tank_id, sequence, name'
@@ -31,7 +31,7 @@ class FpTankComposition(models.Model):
code = fields.Char( code = fields.Char(
string='Code', string='Code',
tracking=True, tracking=True,
help='Short identifier "A", "B", "C".', help='Short identifier - "A", "B", "C".',
) )
sequence = fields.Integer( sequence = fields.Integer(
string='Sequence', string='Sequence',
@@ -114,7 +114,7 @@ class FpTankCompositionIngredient(models.Model):
composition; total % roll-up lives on the parent composition. composition; total % roll-up lives on the parent composition.
""" """
_name = 'fusion.plating.tank.composition.ingredient' _name = 'fusion.plating.tank.composition.ingredient'
_description = 'Fusion Plating Tank Composition Ingredient' _description = 'Fusion Plating - Tank Composition Ingredient'
_order = 'composition_id, sequence, id' _order = 'composition_id, sequence, id'
composition_id = fields.Many2one( composition_id = fields.Many2one(
@@ -166,7 +166,7 @@ class FpTankCompositionIngredient(models.Model):
records = super().create(vals_list) records = super().create(vals_list)
for rec in records: for rec in records:
rec.composition_id.message_post(body=_( rec.composition_id.message_post(body=_(
'Ingredient added: %(name)s %(pct)s %(uom)s' 'Ingredient added: %(name)s - %(pct)s %(uom)s'
) % { ) % {
'name': rec.name, 'name': rec.name,
'pct': rec.percentage, 'pct': rec.percentage,
@@ -198,7 +198,7 @@ class FpTankCompositionIngredient(models.Model):
changed.append(_('unit: %s%s') % (before.get('uom'), rec.uom)) changed.append(_('unit: %s%s') % (before.get('uom'), rec.uom))
if changed: if changed:
rec.composition_id.message_post(body=_( rec.composition_id.message_post(body=_(
'Ingredient %(name)s updated %(changes)s' 'Ingredient %(name)s updated - %(changes)s'
) % { ) % {
'name': rec.name, 'name': rec.name,
'changes': '; '.join(changed), 'changes': '; '.join(changed),
@@ -208,7 +208,7 @@ class FpTankCompositionIngredient(models.Model):
def unlink(self): def unlink(self):
for rec in self: for rec in self:
rec.composition_id.message_post(body=_( rec.composition_id.message_post(body=_(
'Ingredient removed: %(name)s %(pct)s' 'Ingredient removed: %(name)s - %(pct)s'
) % { ) % {
'name': rec.name, 'name': rec.name,
'pct': rec.percentage, 'pct': rec.percentage,

View File

@@ -11,12 +11,12 @@ class FpTankSection(models.Model):
"Specialty Line"). "Specialty Line").
Sections give the shop a familiar way to slice the tank list: every shop Sections give the shop a familiar way to slice the tank list: every shop
organises its tanks differently by metal, by chemistry family, by organises its tanks differently - by metal, by chemistry family, by
physical aisle, or by customer programme and a fixed taxonomy never physical aisle, or by customer programme - and a fixed taxonomy never
fits. Sections are free-form, renameable, and per-facility. fits. Sections are free-form, renameable, and per-facility.
""" """
_name = 'fusion.plating.tank.section' _name = 'fusion.plating.tank.section'
_description = 'Fusion Plating Tank Section' _description = 'Fusion Plating - Tank Section'
_order = 'sequence, name' _order = 'sequence, name'
name = fields.Char( name = fields.Char(

View File

@@ -5,7 +5,7 @@
"""Timezone helpers for Fusion Plating. """Timezone helpers for Fusion Plating.
The Postgres database stores all datetimes naive-UTC. Anything that is The Postgres database stores all datetimes naive-UTC. Anything that is
shown to a user dashboards, PDFs, emails, OWL frontends must be shown to a user - dashboards, PDFs, emails, OWL frontends - must be
converted to a human's local timezone first. converted to a human's local timezone first.
Resolution order for "what timezone does this user see": Resolution order for "what timezone does this user see":
@@ -140,7 +140,7 @@ def detect_default_tz(env=None):
if partner_tz: if partner_tz:
return partner_tz return partner_tz
# Server-side detection works on most Linux hosts. # Server-side detection - works on most Linux hosts.
try: try:
from datetime import datetime from datetime import datetime
local = datetime.now().astimezone() local = datetime.now().astimezone()

View File

@@ -9,14 +9,14 @@ from odoo import fields, models
class FpWorkCenter(models.Model): class FpWorkCenter(models.Model):
"""A physical production line inside a facility. """A physical production line inside a facility.
Examples: "Line 1 EN", "Anodize Line", "Prep Bay", "Bake Station", Examples: "Line 1 - EN", "Anodize Line", "Prep Bay", "Bake Station",
"Inspection Booth", "Shipping Dock". Production lines group tanks "Inspection Booth", "Shipping Dock". Production lines group tanks
and provide daily-capacity scheduling. This is the SHOP-LAYOUT and provide daily-capacity scheduling. This is the SHOP-LAYOUT
entity distinct from `fp.work.centre` which is the per-job-step entity - distinct from `fp.work.centre` which is the per-job-step
routing station with cost-per-hour rollup. routing station with cost-per-hour rollup.
""" """
_name = 'fusion.plating.work.center' _name = 'fusion.plating.work.center'
_description = 'Fusion Plating Production Line' _description = 'Fusion Plating - Production Line'
_order = 'facility_id, sequence, name' _order = 'facility_id, sequence, name'
name = fields.Char( name = fields.Char(
@@ -58,7 +58,7 @@ class FpWorkCenter(models.Model):
) )
capacity_per_day = fields.Float( capacity_per_day = fields.Float(
string='Capacity / Day', string='Capacity / Day',
help='Theoretical throughput (parts, jobs, or square metres per day) unit depends on shop.', help='Theoretical throughput (parts, jobs, or square metres per day) - unit depends on shop.',
) )
_sql_constraints = [ _sql_constraints = [

View File

@@ -2,10 +2,10 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# #
# fp.work.centre native plating work-centre model. # fp.work.centre - native plating work-centre model.
# #
# Replaces mrp.workcenter for the plating flow. Plating work centres # Replaces mrp.workcenter for the plating flow. Plating work centres
# are domain-specific (a tank line, a bake oven, a rack station not # are domain-specific (a tank line, a bake oven, a rack station - not
# assembly cells). Each centre has a 'kind' that drives release-ready # assembly cells). Each centre has a 'kind' that drives release-ready
# validation on fp.job.step (e.g. wet_line -> bath+tank required). # validation on fp.job.step (e.g. wet_line -> bath+tank required).
@@ -13,7 +13,7 @@ from odoo import fields, models
class FpWorkCentre(models.Model): class FpWorkCentre(models.Model):
"""Routing station for a job step replaces mrp.workcenter for """Routing station for a job step - replaces mrp.workcenter for
plating after the Sub 11 MRP cutout. plating after the Sub 11 MRP cutout.
Each routing station has a `kind` (wet_line / bake / mask / rack / Each routing station has a `kind` (wet_line / bake / mask / rack /
@@ -62,7 +62,7 @@ class FpWorkCentre(models.Model):
], ],
string='Floor Column', string='Floor Column',
help='Which Shop Floor column this work centre belongs to. ' help='Which Shop Floor column this work centre belongs to. '
'Drives the plant-view kanban grouping any job whose ' 'Drives the plant-view kanban grouping - any job whose '
'active step uses this work centre routes into this column. ' 'active step uses this work centre routes into this column. '
'See docs/superpowers/specs/2026-05-23-shopfloor-plant-view-' 'See docs/superpowers/specs/2026-05-23-shopfloor-plant-view-'
'design.md §4.2 for the mapping rules.', 'design.md §4.2 for the mapping rules.',
@@ -78,21 +78,21 @@ class FpWorkCentre(models.Model):
) )
default_bath_id = fields.Many2one('fusion.plating.bath') default_bath_id = fields.Many2one('fusion.plating.bath')
default_tank_id = fields.Many2one('fusion.plating.tank') default_tank_id = fields.Many2one('fusion.plating.tank')
# NOTE: `default_oven_id` from the spec/plan is omitted here the # NOTE: `default_oven_id` from the spec/plan is omitted here - the
# `fusion.plating.bake.oven` model lives in fusion_plating_shopfloor, # `fusion.plating.bake.oven` model lives in fusion_plating_shopfloor,
# which the core module cannot depend on. The bridge module that # which the core module cannot depend on. The bridge module that
# introduces fp.job/fp.job.step (Task 1.x) can re-introduce this # introduces fp.job/fp.job.step (Task 1.x) can re-introduce this
# field via _inherit if/when the bake-oven coupling is needed. # field via _inherit if/when the bake-oven coupling is needed.
active = fields.Boolean(default=True) active = fields.Boolean(default=True)
# Phase 4 tablet redesign Manager At-Risk heatmap inputs. # Phase 4 tablet redesign - Manager At-Risk heatmap inputs.
# Non-stored (recomputed on every read by /fp/manager/at_risk; the # Non-stored (recomputed on every read by /fp/manager/at_risk; the
# endpoint caches the payload for 60s anyway so the cost is bounded). # endpoint caches the payload for 60s anyway so the cost is bounded).
bottleneck_score = fields.Float( bottleneck_score = fields.Float(
compute='_compute_bottleneck', compute='_compute_bottleneck',
string='Bottleneck Score', string='Bottleneck Score',
help='active_step_count * avg_wait_minutes (rolling 7-day). ' help='active_step_count * avg_wait_minutes (rolling 7-day). '
'Drives the Manager At-Risk heatmap work centres with ' 'Drives the Manager At-Risk heatmap - work centres with '
'high score have queue + wait pressure.', 'high score have queue + wait pressure.',
) )
avg_wait_minutes = fields.Float( avg_wait_minutes = fields.Float(

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
# #
# Phase 1 (Sub 11) relocated from fusion_plating_bridge_mrp. The model # Phase 1 (Sub 11) - relocated from fusion_plating_bridge_mrp. The model
# never had MRP fields; the bridge module was just its initial home. # never had MRP fields; the bridge module was just its initial home.
from odoo import api, fields, models from odoo import api, fields, models
@@ -19,11 +19,11 @@ class FpWorkRole(models.Model):
role each. role each.
- Cross-trained workers: multiple roles per worker. - Cross-trained workers: multiple roles per worker.
The model is intentionally flat no hierarchy, no workflow. Roles The model is intentionally flat - no hierarchy, no workflow. Roles
are just tags that the step auto-assignment compares. are just tags that the step auto-assignment compares.
""" """
_name = 'fp.work.role' _name = 'fp.work.role'
_description = 'Fusion Plating Shop Work Role' _description = 'Fusion Plating - Shop Work Role'
_order = 'sequence, code' _order = 'sequence, code'
name = fields.Char(string='Role Name', required=True, translate=True) name = fields.Char(string='Role Name', required=True, translate=True)

View File

@@ -14,7 +14,7 @@ class HrEmployee(models.Model):
of them. A small shop where the owner wears every hat just tags of them. A small shop where the owner wears every hat just tags
themselves with every role. themselves with every role.
Lead hands are a separate per-role list they don't have to be Lead hands are a separate per-role list - they don't have to be
primary owners of those roles, but they're authorised to step in primary owners of those roles, but they're authorised to step in
when the regular owner is absent or behind. The Manager Desk when the regular owner is absent or behind. The Manager Desk
promotes lead hands above other workers in its dropdown for any promotes lead hands above other workers in its dropdown for any
@@ -53,9 +53,9 @@ class HrEmployee(models.Model):
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Attendance helpers used by the Manager Desk to show who is # Attendance helpers - used by the Manager Desk to show who is
# currently clocked in. Works with vanilla hr_attendance or the # currently clocked in. Works with vanilla hr_attendance or the
# full fusion_clock module both store an open record (no # full fusion_clock module - both store an open record (no
# check_out) for as long as the employee is on shift. # check_out) for as long as the employee is on shift.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
x_fc_is_clocked_in = fields.Boolean( x_fc_is_clocked_in = fields.Boolean(
@@ -70,7 +70,7 @@ class HrEmployee(models.Model):
"""Compute attendance status from hr.attendance. """Compute attendance status from hr.attendance.
Batched so the manager dashboard doesn't issue one query per Batched so the manager dashboard doesn't issue one query per
employee important when the shop has dozens of operators. employee - important when the shop has dozens of operators.
""" """
if not self: if not self:
return return
@@ -96,7 +96,7 @@ class HrEmployee(models.Model):
1. Odoo 19 normalises ``('=', True)`` into 1. Odoo 19 normalises ``('=', True)`` into
``('in', OrderedSet([True]))`` before invoking the search ``('in', OrderedSet([True]))`` before invoking the search
method. The previous code only handled ``=`` / ``!=`` and method. The previous code only handled ``=`` / ``!=`` and
fell through to ``return []`` for ``in`` / ``not in`` fell through to ``return []`` for ``in`` / ``not in`` -
which Odoo treats as "no constraint" and matches every which Odoo treats as "no constraint" and matches every
row. row.
@@ -110,7 +110,7 @@ class HrEmployee(models.Model):
on the cached open-attendance employee ids. Variable signature on the cached open-attendance employee ids. Variable signature
future-proofs against Odoo's compute-field API shifting again. future-proofs against Odoo's compute-field API shifting again.
""" """
# Variable signature Odoo 19 may pass (records, op, val). # Variable signature - Odoo 19 may pass (records, op, val).
if len(args) == 3: if len(args) == 3:
_records, operator, value = args _records, operator, value = args
elif len(args) == 2: elif len(args) == 2:

View File

@@ -163,7 +163,7 @@ class ResCompany(models.Model):
) )
# ===================================================================== # =====================================================================
# Sub 12a Default recipe editor # Sub 12a - Default recipe editor
# ===================================================================== # =====================================================================
x_fc_default_recipe_editor = fields.Selection( x_fc_default_recipe_editor = fields.Selection(
[('tree', 'Tree Editor'), ('simple', 'Simple Editor')], [('tree', 'Tree Editor'), ('simple', 'Simple Editor')],
@@ -175,7 +175,7 @@ class ResCompany(models.Model):
) )
# ===================================================================== # =====================================================================
# Sub 12c+ Default Certification Statement # Sub 12c+ - Default Certification Statement
# ===================================================================== # =====================================================================
x_fc_default_cert_statement = fields.Text( x_fc_default_cert_statement = fields.Text(
string='Default Cert Statement', string='Default Cert Statement',
@@ -187,7 +187,7 @@ class ResCompany(models.Model):
) )
# ===================================================================== # =====================================================================
# Phase F Plating Designated Officials # Phase F - Plating Designated Officials
# ===================================================================== # =====================================================================
# These are SPECIFIC NAMED PEOPLE registered with regulatory bodies. # These are SPECIFIC NAMED PEOPLE registered with regulatory bodies.
# Stored as Many2one to res.users so the link survives renames. # Stored as Many2one to res.users so the link survives renames.

View File

@@ -56,14 +56,14 @@ class ResConfigSettings(models.TransientModel):
readonly=False, string='Area Unit', readonly=False, string='Area Unit',
) )
# ----- Sub 12a recipe editor default ------------------------------ # ----- Sub 12a - recipe editor default ------------------------------
x_fc_default_recipe_editor = fields.Selection( x_fc_default_recipe_editor = fields.Selection(
related='company_id.x_fc_default_recipe_editor', related='company_id.x_fc_default_recipe_editor',
readonly=False, readonly=False,
string='Default Recipe Editor', string='Default Recipe Editor',
) )
# ----- Phase 1 Plating landing page default ----------------------- # ----- Phase 1 - Plating landing page default -----------------------
# Comodel MUST match res.company.x_fc_default_landing_action_id, which # Comodel MUST match res.company.x_fc_default_landing_action_id, which
# was widened to ir.actions.actions in the post-deploy fixes so the # was widened to ir.actions.actions in the post-deploy fixes so the
# picker accepts both window AND client actions (Manager Desk, Plant # picker accepts both window AND client actions (Manager Desk, Plant

View File

@@ -23,7 +23,7 @@ _FP_PLATING_ROLE_TO_GROUP_XMLID = {
'owner': 'fusion_plating.group_fp_owner', 'owner': 'fusion_plating.group_fp_owner',
} }
# Highest precedence first first match wins # Highest precedence first - first match wins
_FP_ROLE_PRECEDENCE = ( _FP_ROLE_PRECEDENCE = (
'owner', 'quality_manager', 'manager', 'sales_manager', 'owner', 'quality_manager', 'manager', 'sales_manager',
'shop_manager', 'sales_rep', 'technician', 'shop_manager', 'sales_rep', 'technician',
@@ -35,7 +35,7 @@ class ResUsers(models.Model):
# Allow non-admin users to write their OWN plating-related fields # Allow non-admin users to write their OWN plating-related fields
# from the standard User Preferences dialog. SELF_WRITEABLE_FIELDS is # from the standard User Preferences dialog. SELF_WRITEABLE_FIELDS is
# a @property in Odoo 19 (not a class attribute) must override via # a @property in Odoo 19 (not a class attribute) - must override via
# @property + super(). See CLAUDE.md rule 13k. # @property + super(). See CLAUDE.md rule 13k.
@property @property
def SELF_WRITEABLE_FIELDS(self): def SELF_WRITEABLE_FIELDS(self):
@@ -99,7 +99,7 @@ class ResUsers(models.Model):
role_to_group[role] = grp role_to_group[role] = grp
all_role_ids.append(grp.id) all_role_ids.append(grp.id)
# I4 fix capture old roles BEFORE the cache mutates by reading # I4 fix - capture old roles BEFORE the cache mutates by reading
# the stored x_fc_plating_role column directly from PostgreSQL. # the stored x_fc_plating_role column directly from PostgreSQL.
# `user._origin.x_fc_plating_role` returns the IN-CACHE new value # `user._origin.x_fc_plating_role` returns the IN-CACHE new value
# (the assignment that triggered the inverse), not the prior DB # (the assignment that triggered the inverse), not the prior DB
@@ -115,7 +115,7 @@ class ResUsers(models.Model):
old_role = old_role_by_id.get(user.id) or 'unset' old_role = old_role_by_id.get(user.id) or 'unset'
new_role = user.x_fc_plating_role new_role = user.x_fc_plating_role
if old_role == new_role: if old_role == new_role:
# No actual change skip both the writes and the audit so # No actual change - skip both the writes and the audit so
# we don't spam chatter with "X -> X" rows. # we don't spam chatter with "X -> X" rows.
continue continue

View File

@@ -5,10 +5,10 @@ then create an SO for ABC Manufacturing and confirm it so the
operator-facing job is ready to run. operator-facing job is ready to run.
After running, the user navigates to: After running, the user navigates to:
- Recipe form (Process Recipes menu) verify instructions present - Recipe form (Process Recipes menu) - verify instructions present
- Simple Recipe Editor verify per-step Instructions + Measurements - Simple Recipe Editor - verify per-step Instructions + Measurements
- Sale Orders verify the new SO with line referencing the recipe - Sale Orders - verify the new SO with line referencing the recipe
- Plating Jobs verify job created with all steps - Plating Jobs - verify job created with all steps
- Click Mark Done on any step → verify operator wizard shows - Click Mark Done on any step → verify operator wizard shows
instructions + measurement prompts instructions + measurement prompts
""" """
@@ -25,7 +25,7 @@ print('\n========== Build Hard Anodize Type III Recipe ==========\n')
# Clean up any prior run of this script (prior recipes + their variants + jobs + SOs) # Clean up any prior run of this script (prior recipes + their variants + jobs + SOs)
prior_recipes = Node.search([ prior_recipes = Node.search([
'|', ('name', '=', 'Hard Anodize Type III + Dye + Seal'), '|', ('name', '=', 'Hard Anodize Type III + Dye + Seal'),
('name', 'ilike', 'Hard Anodize Type III + Dye + Seal '), ('name', 'ilike', 'Hard Anodize Type III + Dye + Seal - '),
]) ])
if prior_recipes: if prior_recipes:
print('Cleaning up %d prior recipe(s)...' % len(prior_recipes)) print('Cleaning up %d prior recipe(s)...' % len(prior_recipes))
@@ -53,7 +53,7 @@ recipe = Node.create({
'is_template': True, 'is_template': True,
'description': '''<p><strong>Hard Anodize Type III per MIL-A-8625F</strong></p> 'description': '''<p><strong>Hard Anodize Type III per MIL-A-8625F</strong></p>
<p>Aluminum substrate. Black sulfuric dye. Hot nickel acetate seal.</p> <p>Aluminum substrate. Black sulfuric dye. Hot nickel acetate seal.</p>
<p>Tolerances: 0.0015"0.0020" coating thickness, 60kV breakdown <p>Tolerances: 0.0015"-0.0020" coating thickness, 60kV breakdown
voltage. Hardness ≥350HV300.</p>''', voltage. Hardness ≥350HV300.</p>''',
}) })
print('Created recipe:', recipe.id, recipe.name) print('Created recipe:', recipe.id, recipe.name)
@@ -100,14 +100,14 @@ STEPS = [
{ {
'name': '5. Alkaline Clean (Tank A-1)', 'name': '5. Alkaline Clean (Tank A-1)',
'kind': 'cleaning', 'kind': 'cleaning',
'description': '''<p><strong>Soak clean Aluminum Etch Cleaner.</strong></p> 'description': '''<p><strong>Soak clean - Aluminum Etch Cleaner.</strong></p>
<ul><li>Tank: A-1, Bath: ALKCLEAN-1</li> <ul><li>Tank: A-1, Bath: ALKCLEAN-1</li>
<li>Time: 46 minutes</li> <li>Time: 4-6 minutes</li>
<li>Temperature: 140160°F</li> <li>Temperature: 140-160°F</li>
<li>Confirm titration done within last 24 hours</li></ul>''', <li>Confirm titration done within last 24 hours</li></ul>''',
}, },
{ {
'name': '6. Rinse Cascade DI (Tank A-2)', 'name': '6. Rinse - Cascade DI (Tank A-2)',
'kind': 'rinse', 'kind': 'rinse',
'description': '''<p><strong>Triple cascade DI rinse.</strong></p> 'description': '''<p><strong>Triple cascade DI rinse.</strong></p>
<ul><li>Tank A-2 (DI rinse, conductivity &lt; 50 µS/cm)</li> <ul><li>Tank A-2 (DI rinse, conductivity &lt; 50 µS/cm)</li>
@@ -117,47 +117,47 @@ STEPS = [
{ {
'name': '7. Etch (Tank A-3)', 'name': '7. Etch (Tank A-3)',
'kind': 'etch', 'kind': 'etch',
'description': '''<p><strong>Caustic etch sodium hydroxide bath.</strong></p> 'description': '''<p><strong>Caustic etch - sodium hydroxide bath.</strong></p>
<ul><li>Tank: A-3, Bath: ETCH-1</li> <ul><li>Tank: A-3, Bath: ETCH-1</li>
<li>Time: 3090 seconds (per drawing heavy etch removes 0.0005"/side)</li> <li>Time: 30-90 seconds (per drawing - heavy etch removes 0.0005"/side)</li>
<li>Temperature: 130150°F</li> <li>Temperature: 130-150°F</li>
<li>Concentration: 46 oz/gal NaOH</li> <li>Concentration: 4-6 oz/gal NaOH</li>
<li>HE-risk parts (high-strength) require post-bake flag accordingly</li></ul>''', <li>HE-risk parts (high-strength) require post-bake - flag accordingly</li></ul>''',
}, },
{ {
'name': '8. Rinse Cascade DI (Tank A-4)', 'name': '8. Rinse - Cascade DI (Tank A-4)',
'kind': 'rinse', 'kind': 'rinse',
'description': '<p>Triple cascade DI rinse Tank A-4. 30 sec agitate.</p>', 'description': '<p>Triple cascade DI rinse - Tank A-4. 30 sec agitate.</p>',
}, },
{ {
'name': '9. Desmut / Deoxidize (Tank A-5)', 'name': '9. Desmut / Deoxidize (Tank A-5)',
'kind': 'etch', 'kind': 'etch',
'description': '''<p><strong>Acid desmut to remove black smut from etch.</strong></p> 'description': '''<p><strong>Acid desmut to remove black smut from etch.</strong></p>
<ul><li>Tank: A-5, Bath: DEOX-1 (HNO3-based)</li> <ul><li>Tank: A-5, Bath: DEOX-1 (HNO3-based)</li>
<li>Time: 3060 seconds</li> <li>Time: 30-60 seconds</li>
<li>Temperature: ambient</li> <li>Temperature: ambient</li>
<li>Surface should be water-break-free after this step</li></ul>''', <li>Surface should be water-break-free after this step</li></ul>''',
}, },
{ {
'name': '10. Rinse Cascade DI (Tank A-6)', 'name': '10. Rinse - Cascade DI (Tank A-6)',
'kind': 'rinse', 'kind': 'rinse',
'description': '<p>Final pre-anodize rinse Tank A-6. Conductivity must be &lt; 50 µS/cm.</p>', 'description': '<p>Final pre-anodize rinse - Tank A-6. Conductivity must be &lt; 50 µS/cm.</p>',
}, },
{ {
'name': '11. Hard Anodize Type III (Tank A-9)', 'name': '11. Hard Anodize Type III (Tank A-9)',
'kind': 'plate', 'kind': 'plate',
'description': '''<p><strong>HARD ANODIZE the primary process step.</strong></p> 'description': '''<p><strong>HARD ANODIZE - the primary process step.</strong></p>
<ul><li>Tank: A-9, Bath: HARDANO-1 (15% sulfuric acid)</li> <ul><li>Tank: A-9, Bath: HARDANO-1 (15% sulfuric acid)</li>
<li>Temperature: 2832°F (chilled bath confirm chiller is running)</li> <li>Temperature: 28-32°F (chilled bath - confirm chiller is running)</li>
<li>Current density: 2436 ASF</li> <li>Current density: 24-36 ASF</li>
<li>Voltage ramp: 080V over first 5 minutes</li> <li>Voltage ramp: 0-80V over first 5 minutes</li>
<li>Time at voltage: 60 minutes (gives ~0.002" coating)</li> <li>Time at voltage: 60 minutes (gives ~0.002" coating)</li>
<li>Record amperage every 15 minutes</li> <li>Record amperage every 15 minutes</li>
<li>Check thickness midway with Fischerscope on witness coupon</li> <li>Check thickness midway with Fischerscope on witness coupon</li>
<li>If color reading off, halt and call supervisor</li></ul>''', <li>If color reading off, halt and call supervisor</li></ul>''',
}, },
{ {
'name': '12. Rinse Cold (Tank A-12)', 'name': '12. Rinse - Cold (Tank A-12)',
'kind': 'rinse', 'kind': 'rinse',
'description': '<p>Cold cascade rinse to remove sulfuric residue. Tank A-12.</p>', 'description': '<p>Cold cascade rinse to remove sulfuric residue. Tank A-12.</p>',
}, },
@@ -166,24 +166,24 @@ STEPS = [
'kind': 'plate', 'kind': 'plate',
'description': '''<p><strong>Sulfo Black BL dye absorption.</strong></p> 'description': '''<p><strong>Sulfo Black BL dye absorption.</strong></p>
<ul><li>Tank: A-14, Bath: DYE-BL-1</li> <ul><li>Tank: A-14, Bath: DYE-BL-1</li>
<li>Temperature: 130150°F</li> <li>Temperature: 130-150°F</li>
<li>Time: 1218 minutes</li> <li>Time: 12-18 minutes</li>
<li>Maintain pH 5.06.0</li> <li>Maintain pH 5.0-6.0</li>
<li>Visually verify uniform black with no streaks before sealing</li></ul>''', <li>Visually verify uniform black with no streaks before sealing</li></ul>''',
}, },
{ {
'name': '14. Rinse Warm (Tank A-15)', 'name': '14. Rinse - Warm (Tank A-15)',
'kind': 'rinse', 'kind': 'rinse',
'description': '<p>Warm rinse before sealing. Tank A-15. ~110°F.</p>', 'description': '<p>Warm rinse before sealing. Tank A-15. ~110°F.</p>',
}, },
{ {
'name': '15. Hot Nickel Acetate Seal (Tank A-16)', 'name': '15. Hot Nickel Acetate Seal (Tank A-16)',
'kind': 'bake', 'kind': 'bake',
'description': '''<p><strong>Nickel acetate seal locks in dye, improves corrosion resistance.</strong></p> 'description': '''<p><strong>Nickel acetate seal - locks in dye, improves corrosion resistance.</strong></p>
<ul><li>Tank: A-16, Bath: SEAL-NA-1</li> <ul><li>Tank: A-16, Bath: SEAL-NA-1</li>
<li>Temperature: 195205°F</li> <li>Temperature: 195-205°F</li>
<li>Time: 1822 minutes</li> <li>Time: 18-22 minutes</li>
<li>pH: 5.56.0</li> <li>pH: 5.5-6.0</li>
<li>Attach AMS-2759 chart-recorder file as photo before unloading</li> <li>Attach AMS-2759 chart-recorder file as photo before unloading</li>
<li>Quality of seal verified post-process by dye absorption test</li></ul>''', <li>Quality of seal verified post-process by dye absorption test</li></ul>''',
}, },
@@ -195,10 +195,10 @@ STEPS = [
{ {
'name': '17. Drying (Hot Air Knife)', 'name': '17. Drying (Hot Air Knife)',
'kind': 'dry', 'kind': 'dry',
'description': '''<p><strong>Hot-air knife dry leave parts on rack.</strong></p> 'description': '''<p><strong>Hot-air knife dry - leave parts on rack.</strong></p>
<ul><li>Hot air knife @ 180°F</li> <ul><li>Hot air knife @ 180°F</li>
<li>Time: 5 minutes minimum</li> <li>Time: 5 minutes minimum</li>
<li>Verify parts fully dry before unracking water spotting is a defect</li></ul>''', <li>Verify parts fully dry before unracking - water spotting is a defect</li></ul>''',
}, },
{ {
'name': '18. De-Racking', 'name': '18. De-Racking',
@@ -248,7 +248,7 @@ STEPS = [
{ {
'name': '23. Shipping', 'name': '23. Shipping',
'kind': 'ship', 'kind': 'ship',
'description': '''<p><strong>Outbound confirm carrier and BoL.</strong></p> 'description': '''<p><strong>Outbound - confirm carrier and BoL.</strong></p>
<ul><li>Carrier per SO (UPS / FedEx / customer pickup)</li> <ul><li>Carrier per SO (UPS / FedEx / customer pickup)</li>
<li>Print BoL, attach to package</li> <li>Print BoL, attach to package</li>
<li>Photograph sealed shipment for proof-of-shipment</li> <li>Photograph sealed shipment for proof-of-shipment</li>
@@ -315,7 +315,7 @@ sol = env['sale.order.line'].create({
}) })
print('Created SO:', so.name, 'line', sol.id) print('Created SO:', so.name, 'line', sol.id)
# Confirm triggers fp.job creation # Confirm - triggers fp.job creation
so.action_confirm() so.action_confirm()
print('Confirmed SO. State =', so.state) print('Confirmed SO. State =', so.state)
env.cr.commit() env.cr.commit()
@@ -337,7 +337,7 @@ for js in job_steps:
prompts = len(rn.input_ids.filtered(lambda i: i.collect)) prompts = len(rn.input_ids.filtered(lambda i: i.collect))
instructions_visible += int(ins) instructions_visible += int(ins)
prompts_visible += prompts prompts_visible += prompts
print(' [%2d] %s instructions=%s prompts=%d kind=%s' % ( print(' [%2d] %s - instructions=%s prompts=%d kind=%s' % (
js.sequence, js.name, '' if ins else '', prompts, rn.default_kind or '-' js.sequence, js.name, '' if ins else '', prompts, rn.default_kind or '-'
)) ))

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""End-to-end battle test full Anodize job for ABC Manufacturing. """End-to-end battle test - full Anodize job for ABC Manufacturing.
Phases: Phases:
A. Auto-infer default_kind on existing Anodize recipe steps + seed prompts A. Auto-infer default_kind on existing Anodize recipe steps + seed prompts
@@ -25,7 +25,7 @@ NodeInput = env['fusion.plating.process.node.input']
Template = env['fp.step.template'] Template = env['fp.step.template']
# ============================================================ # ============================================================
# Phase A auto-seed prompts on the Anodize recipe # Phase A - auto-seed prompts on the Anodize recipe
# ============================================================ # ============================================================
print('\n========== PHASE A: seed prompts on Anodize recipe ==========\n') print('\n========== PHASE A: seed prompts on Anodize recipe ==========\n')
@@ -122,7 +122,7 @@ for s in all_steps[:5]:
)) ))
# ============================================================ # ============================================================
# Phase B create SO line + confirm # Phase B - create SO line + confirm
# ============================================================ # ============================================================
print('\n========== PHASE B: create SO + confirm ==========\n') print('\n========== PHASE B: create SO + confirm ==========\n')
@@ -179,7 +179,7 @@ for js in job_steps[:5]:
)) ))
# ============================================================ # ============================================================
# Phase C record measurements covering every input type # Phase C - record measurements covering every input type
# ============================================================ # ============================================================
print('\n========== PHASE C: record measurements ==========\n') print('\n========== PHASE C: record measurements ==========\n')
@@ -296,7 +296,7 @@ else:
find('PASS', 'No value-creation errors') find('PASS', 'No value-creation errors')
# ============================================================ # ============================================================
# Phase D verify CoC chronological can render # Phase D - verify CoC chronological can render
# ============================================================ # ============================================================
print('\n========== PHASE D: render CoC report ==========\n') print('\n========== PHASE D: render CoC report ==========\n')

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""End-to-end battle test v2 operates on existing job 1234. """End-to-end battle test v2 - operates on existing job 1234.
1. Seed prompts onto variant subtree (recipe id=1775) 1. Seed prompts onto variant subtree (recipe id=1775)
2. Re-link recipe nodes to job steps (recipe_node_id should point at variant nodes) 2. Re-link recipe nodes to job steps (recipe_node_id should point at variant nodes)
@@ -21,7 +21,7 @@ NodeInput = env['fusion.plating.process.node.input']
Template = env['fp.step.template'] Template = env['fp.step.template']
# ============================================================ # ============================================================
# Phase A seed prompts on the per-part variant (id=1775) # Phase A - seed prompts on the per-part variant (id=1775)
# ============================================================ # ============================================================
print('\n========== PHASE A: seed prompts on variant 1775 ==========\n') print('\n========== PHASE A: seed prompts on variant 1775 ==========\n')
@@ -49,7 +49,7 @@ KIND_KEYWORDS = [
('adhesion_test', ['adhesion']), ('adhesion_test', ['adhesion']),
('salt_spray', ['salt spray', 'corrosion test']), ('salt_spray', ['salt spray', 'corrosion test']),
('replenishment', ['replenish', 'top-up']), ('replenishment', ['replenish', 'top-up']),
# Surface prep fall back to cleaning since shops record similar fields # Surface prep - fall back to cleaning since shops record similar fields
('cleaning', ['blast']), ('cleaning', ['blast']),
] ]
@@ -111,7 +111,7 @@ find('INFO', 'Seeded %d prompts onto variant subtree' % seeded)
env.cr.commit() env.cr.commit()
# ============================================================ # ============================================================
# Phase B verify job 1234 sees the prompts # Phase B - verify job 1234 sees the prompts
# ============================================================ # ============================================================
print('\n========== PHASE B: job sees prompts ==========\n') print('\n========== PHASE B: job sees prompts ==========\n')
@@ -126,12 +126,12 @@ find('INFO', 'Total prompts visible to job %d: %d across %d steps' % (
job.id, prompt_total, len(job_steps) job.id, prompt_total, len(job_steps)
)) ))
if prompt_total == 0: if prompt_total == 0:
find('FAIL', 'No prompts visible variant cloning still broken') find('FAIL', 'No prompts visible - variant cloning still broken')
else: else:
find('PASS', 'Prompts now visible at runtime') find('PASS', 'Prompts now visible at runtime')
# ============================================================ # ============================================================
# Phase C record measurements covering every input type # Phase C - record measurements covering every input type
# ============================================================ # ============================================================
print('\n========== PHASE C: record measurements ==========\n') print('\n========== PHASE C: record measurements ==========\n')
@@ -255,7 +255,7 @@ if some_step:
types_exercised.add('bath_chemistry_panel') types_exercised.add('bath_chemistry_panel')
# ============================================================ # ============================================================
# Phase D render CoC chronological body via QWeb # Phase D - render CoC chronological body via QWeb
# ============================================================ # ============================================================
print('\n========== PHASE D: render CoC body ==========\n') print('\n========== PHASE D: render CoC body ==========\n')

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""End-to-end battle test Phase 1: reconnaissance.""" """End-to-end battle test - Phase 1: reconnaissance."""
# Find ABC Manufacturing # Find ABC Manufacturing
abc = env['res.partner'].search([('name', 'ilike', 'ABC Manufactor')], limit=1) abc = env['res.partner'].search([('name', 'ilike', 'ABC Manufactor')], limit=1)

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Phase 2 recon examine Anodize recipe tree + SO→job flow.""" """Phase 2 recon - examine Anodize recipe tree + SO→job flow."""
Node = env['fusion.plating.process.node'] Node = env['fusion.plating.process.node']
@@ -31,14 +31,14 @@ def walk(n, depth=0):
walk(anodize) walk(anodize)
# Sample part for ABC make sure we have one # Sample part for ABC - make sure we have one
abc = env['res.partner'].browse(943) abc = env['res.partner'].browse(943)
parts = env['fp.part.catalog'].search([('partner_id', '=', abc.id)], limit=3) parts = env['fp.part.catalog'].search([('partner_id', '=', abc.id)], limit=3)
print('\n=== ABC parts ===') print('\n=== ABC parts ===')
for p in parts: for p in parts:
print(' id=%d num=%s rev=%s' % (p.id, p.part_number, p.revision or '')) print(' id=%d num=%s rev=%s' % (p.id, p.part_number, p.revision or ''))
if not parts: if not parts:
print(' (none will need to create one)') print(' (none - will need to create one)')
# How does an SO line reference a recipe? Look at sale.order.line fields # How does an SO line reference a recipe? Look at sale.order.line fields
sol = env['sale.order.line'] sol = env['sale.order.line']

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Phase 3 recon SO→job→step trigger pipeline.""" """Phase 3 recon - SO→job→step trigger pipeline."""
# Find recent confirmed SO with x_fc_process_variant_id set # Find recent confirmed SO with x_fc_process_variant_id set
sol = env['sale.order.line'].search([ sol = env['sale.order.line'].search([

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Phase 4 recon diagnose recipe variant cloning.""" """Phase 4 recon - diagnose recipe variant cloning."""
Node = env['fusion.plating.process.node'] Node = env['fusion.plating.process.node']
n = Node.browse(1777) n = Node.browse(1777)

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Battle test Step Library audit expansion (Sub 12d). """Battle test - Step Library audit expansion (Sub 12d).
Run via odoo-shell on entech: Run via odoo-shell on entech:
@@ -132,7 +132,7 @@ check(12, 'wizard filter excludes collect=False',
ni_off not in visible and ni_on in visible, ni_off not in visible and ni_on in visible,
'%d/%d visible' % (len(visible), len(node.input_ids))) '%d/%d visible' % (len(visible), len(node.input_ids)))
# 13. Master switch path when False, filter returns empty # 13. Master switch path - when False, filter returns empty
node.collect_measurements = False node.collect_measurements = False
empty_path = (not node.collect_measurements) empty_path = (not node.collect_measurements)
check(13, 'master collect_measurements=False short-circuits', check(13, 'master collect_measurements=False short-circuits',

View File

@@ -4,10 +4,10 @@
License OPL-1 (Odoo Proprietary License v1.0) License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family. Part of the Fusion Plating product family.
2026-05-24 Hide non-essential app menus from Technicians. 2026-05-24 - Hide non-essential app menus from Technicians.
Per user request: technicians should see ONLY the apps they actually Per user request: technicians should see ONLY the apps they actually
need on the tablet Discuss, To-do, Plating, AI, Maintenance, Time need on the tablet - Discuss, To-do, Plating, AI, Maintenance, Time
Off. Every other top-level app menu is restricted to a new "office Off. Every other top-level app menu is restricted to a new "office
user" group implied by every fp role ABOVE technician. user" group implied by every fp role ABOVE technician.
@@ -18,7 +18,7 @@
<menuitem id="other_module.X" groups="..."/> overrides require the <menuitem id="other_module.X" groups="..."/> overrides require the
other module in `depends`, which would lock us into hard other module in `depends`, which would lock us into hard
dependencies on calendar/sale/hr/etc. The hook uses dependencies on calendar/sale/hr/etc. The hook uses
env.ref(..., raise_if_not_found=False) modules that aren't env.ref(..., raise_if_not_found=False) - modules that aren't
installed are silently skipped. installed are silently skipped.
Why a separate office_user group instead of !technician? Why a separate office_user group instead of !technician?
@@ -32,7 +32,7 @@
<data> <data>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- New marker group: "Office User" implied by every non- --> <!-- New marker group: "Office User" - implied by every non- -->
<!-- technician fp role. --> <!-- technician fp role. -->
<!-- ============================================================ --> <!-- ============================================================ -->
<record id="group_fp_office_user" model="res.groups"> <record id="group_fp_office_user" model="res.groups">

View File

@@ -73,10 +73,10 @@
</record> </record>
<!-- ================================================================== --> <!-- ================================================================== -->
<!-- RECORD RULE Multi-company isolation on facilities --> <!-- RECORD RULE - Multi-company isolation on facilities -->
<!-- ================================================================== --> <!-- ================================================================== -->
<record id="fp_facility_company_rule" model="ir.rule"> <record id="fp_facility_company_rule" model="ir.rule">
<field name="name">Fusion Plating: Facility multi-company</field> <field name="name">Fusion Plating: Facility - multi-company</field>
<field name="model_id" ref="model_fusion_plating_facility"/> <field name="model_id" ref="model_fusion_plating_facility"/>
<field name="global" eval="True"/> <field name="global" eval="True"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field> <field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>

View File

@@ -1,5 +1,5 @@
/** @odoo-module **/ /** @odoo-module **/
// Sub 14b visual FontAwesome icon picker for fp.step.kind.icon and any // Sub 14b - visual FontAwesome icon picker for fp.step.kind.icon and any
// other Selection field whose values are FA classes (e.g. 'fa-flask'). // other Selection field whose values are FA classes (e.g. 'fa-flask').
// //
// Always-visible compact grid with a Search box. Glyph-only tiles // Always-visible compact grid with a Search box. Glyph-only tiles

View File

@@ -1,6 +1,6 @@
/** @odoo-module **/ /** @odoo-module **/
// ============================================================================= // =============================================================================
// Fusion Plating Recipe Tree Editor (OWL backend client action) // Fusion Plating - Recipe Tree Editor (OWL backend client action)
// Copyright 2026 Nexa Systems Inc. // Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0) // License OPL-1 (Odoo Proprietary License v1.0)
// //
@@ -102,7 +102,7 @@ export class RecipeTreeEditor extends Component {
this.state = useState({ this.state = useState({
recipe: null, recipe: null,
tree: null, tree: null,
workflowStates: [], // Sub 14 populated by loadTree workflowStates: [], // Sub 14 - populated by loadTree
loading: false, loading: false,
saving: false, saving: false,
selectedNodeId: null, selectedNodeId: null,
@@ -158,13 +158,13 @@ export class RecipeTreeEditor extends Component {
if (result && result.ok) { if (result && result.ok) {
this.state.recipe = result.recipe; this.state.recipe = result.recipe;
this.state.tree = result.tree; this.state.tree = result.tree;
// Sub 14 workflow states for the per-step trigger // Sub 14 - workflow states for the per-step trigger
// dropdown in the properties panel. // dropdown in the properties panel.
this.state.workflowStates = result.workflow_states || []; this.state.workflowStates = result.workflow_states || [];
// Auto-expand every node on first load AND auto-expand // Auto-expand every node on first load AND auto-expand
// any node we haven't seen before (e.g. freshly imported // any node we haven't seen before (e.g. freshly imported
// nodes after a "Import from recipe" run). Nodes the // nodes after a "Import from recipe" run). Nodes the
// user has explicitly collapsed stay collapsed we only // user has explicitly collapsed stay collapsed - we only
// touch nodes that are missing from expandedNodes. // touch nodes that are missing from expandedNodes.
if (result.tree) { if (result.tree) {
const applyDefault = (n) => { const applyDefault = (n) => {
@@ -225,7 +225,7 @@ export class RecipeTreeEditor extends Component {
} }
collapseAll() { collapseAll() {
// Collapse everything EXCEPT the recipe root itself otherwise // Collapse everything EXCEPT the recipe root itself - otherwise
// the canvas goes blank and the user has to click the root open // the canvas goes blank and the user has to click the root open
// again to see anything. // again to see anything.
const walk = (n, isRoot) => { const walk = (n, isRoot) => {
@@ -272,10 +272,10 @@ export class RecipeTreeEditor extends Component {
customer_visible: node.customer_visible, customer_visible: node.customer_visible,
is_manual: node.is_manual, is_manual: node.is_manual,
requires_signoff: node.requires_signoff, requires_signoff: node.requires_signoff,
// Sub 13 sequential enforcement // Sub 13 - sequential enforcement
enforce_sequential: !!node.enforce_sequential, enforce_sequential: !!node.enforce_sequential,
parallel_start: !!node.parallel_start, parallel_start: !!node.parallel_start,
// Sub 14 workflow milestone trigger // Sub 14 - workflow milestone trigger
triggers_workflow_state_id: node.triggers_workflow_state_id || false, triggers_workflow_state_id: node.triggers_workflow_state_id || false,
}; };
const result = await rpc("/fp/recipe/node/write", { const result = await rpc("/fp/recipe/node/write", {
@@ -425,7 +425,7 @@ export class RecipeTreeEditor extends Component {
const targetParentId = parentNode ? parentNode.id : null; const targetParentId = parentNode ? parentNode.id : null;
if (dragged.parentId === targetParentId) { if (dragged.parentId === targetParentId) {
// Reorder within same parent swap positions // Reorder within same parent - swap positions
const siblings = parentNode const siblings = parentNode
? (parentNode.children || []) ? (parentNode.children || [])
: [this.state.tree]; : [this.state.tree];
@@ -556,7 +556,7 @@ export class RecipeTreeEditor extends Component {
onBackToList() { onBackToList() {
// Pop this editor off the action stack and restore the // Pop this editor off the action stack and restore the
// previous controller which is whatever opened the editor: // previous controller - which is whatever opened the editor:
// * Recipes list → recipe form → editor ⇒ back to recipe form // * Recipes list → recipe form → editor ⇒ back to recipe form
// * Part form → composer → editor ⇒ back to composer // * Part form → composer → editor ⇒ back to composer
// * Part form → editor (direct link) ⇒ back to part form // * Part form → editor (direct link) ⇒ back to part form
@@ -568,7 +568,7 @@ export class RecipeTreeEditor extends Component {
this.action.restore(); this.action.restore();
return; return;
} catch (e) { } catch (e) {
// No prior controller fall through to a sensible default. // No prior controller - fall through to a sensible default.
} }
// Fallback: when opened directly via URL with no prior crumb, // Fallback: when opened directly via URL with no prior crumb,
// pick the most contextual landing page we have. // pick the most contextual landing page we have.

View File

@@ -1,6 +1,6 @@
/** @odoo-module */ /** @odoo-module */
/* /*
* Sub 12a Simple Recipe Editor (OWL client action). * Sub 12a - Simple Recipe Editor (OWL client action).
* *
* Flat drag-drop alternative to the tree editor. Library on the right, * Flat drag-drop alternative to the tree editor. Library on the right,
* Selected (ordered steps) on the left. Drag from library → snapshot- * Selected (ordered steps) on the left. Drag from library → snapshot-
@@ -38,25 +38,25 @@ export class FpSimpleRecipeEditor extends Component {
dragOverIndex: null, // 0..N (insertion index) dragOverIndex: null, // 0..N (insertion index)
dragPreviewLabel: "", // shown next to the indicator line dragPreviewLabel: "", // shown next to the indicator line
dragPreviewIcon: "fa-cog", dragPreviewIcon: "fa-cog",
// Inline edit panel id of the step currently being edited // Inline edit panel - id of the step currently being edited
// (null = no panel open). Mirrors live values so the textarea // (null = no panel open). Mirrors live values so the textarea
// stays controlled without RPC roundtrip on every keystroke. // stays controlled without RPC roundtrip on every keystroke.
editingStepId: null, editingStepId: null,
editName: "", editName: "",
editInstructions: "", editInstructions: "",
// Sub 14 + Sub 13 additional per-step settings that the // Sub 14 + Sub 13 - additional per-step settings that the
// user can change inline without delete + re-add. // user can change inline without delete + re-add.
editDefaultKind: "", editDefaultKind: "",
editTriggersWorkflowStateId: false, editTriggersWorkflowStateId: false,
editParallelStart: false, editParallelStart: false,
editRequiresSignoff: false, editRequiresSignoff: false,
// Inline library form open when authoring or editing a // Inline library form - open when authoring or editing a
// library template directly from the right pane. null = // library template directly from the right pane. null =
// closed; otherwise carries the template payload. // closed; otherwise carries the template payload.
libraryEditor: null, libraryEditor: null,
libraryEditorBusy: false, libraryEditorBusy: false,
tankSearchResults: [], tankSearchResults: [],
// Sub 14 workflow-state catalog cache for the inline // Sub 14 - workflow-state catalog cache for the inline
// library form's "Triggers Workflow State" dropdown. Lazy- // library form's "Triggers Workflow State" dropdown. Lazy-
// loaded the first time the user opens the library editor. // loaded the first time the user opens the library editor.
workflowStates: [], workflowStates: [],
@@ -87,7 +87,7 @@ export class FpSimpleRecipeEditor extends Component {
async loadAll() { async loadAll() {
// Preserve scroll position across the re-render. .o_fp_simple_editor // Preserve scroll position across the re-render. .o_fp_simple_editor
// is the overflow:auto scroll container when `state.steps` is // is the overflow:auto scroll container - when `state.steps` is
// replaced with a fresh array, OWL tears down the t-foreach and // replaced with a fresh array, OWL tears down the t-foreach and
// rebuilds every row, which snaps scrollTop back to 0. Operators // rebuilds every row, which snaps scrollTop back to 0. Operators
// hate this: they save a step half-way down the recipe and the // hate this: they save a step half-way down the recipe and the
@@ -153,7 +153,7 @@ export class FpSimpleRecipeEditor extends Component {
} }
// dragOverIndex is the insertion point in the ORIGINAL list. Once // dragOverIndex is the insertion point in the ORIGINAL list. Once
// we splice the dragged item out, every position to the right of // we splice the dragged item out, every position to the right of
// oldIndex shifts left by one so an insertion at newIndex when // oldIndex shifts left by one - so an insertion at newIndex when
// newIndex > oldIndex must be decremented. Without this, dropping // newIndex > oldIndex must be decremented. Without this, dropping
// right after itself moves the row one slot down instead of // right after itself moves the row one slot down instead of
// staying put. // staying put.
@@ -285,13 +285,13 @@ export class FpSimpleRecipeEditor extends Component {
* opened from the part-scoped Process Composer, return to that part * opened from the part-scoped Process Composer, return to that part
* form; otherwise drop the user back on the Recipes list. * form; otherwise drop the user back on the Recipes list.
* *
* `clearBreadcrumbs: true` is critical without it, every part → * `clearBreadcrumbs: true` is critical - without it, every part →
* composer → editor → back leaves intermediate pages on the * composer → editor → back leaves intermediate pages on the
* breadcrumb stack so a second visit shows nonsense. * breadcrumb stack so a second visit shows nonsense.
*/ */
onBackToList() { onBackToList() {
// Pop this editor off the action stack and restore the // Pop this editor off the action stack and restore the
// previous controller preserves the full breadcrumb trail // previous controller - preserves the full breadcrumb trail
// (Recipes > LGPS1104 > Editor → back keeps "Recipes" // (Recipes > LGPS1104 > Editor → back keeps "Recipes"
// visible; Part > Composer > Editor → back returns to the // visible; Part > Composer > Editor → back returns to the
// Composer with crumbs intact). // Composer with crumbs intact).
@@ -299,7 +299,7 @@ export class FpSimpleRecipeEditor extends Component {
this.action.restore(); this.action.restore();
return; return;
} catch (e) { } catch (e) {
// No prior controller fall through to a sensible default. // No prior controller - fall through to a sensible default.
} }
if (this._partId) { if (this._partId) {
this.action.doAction( this.action.doAction(
@@ -337,8 +337,8 @@ export class FpSimpleRecipeEditor extends Component {
description: "", description: "",
requires_signoff: false, requires_signoff: false,
requires_predecessor_done: false, requires_predecessor_done: false,
parallel_start: false, // Sub 13 per-step opt-out parallel_start: false, // Sub 13 - per-step opt-out
triggers_workflow_state_id: false, // Sub 14 workflow trigger triggers_workflow_state_id: false, // Sub 14 - workflow trigger
triggers_workflow_state_name: "", triggers_workflow_state_name: "",
requires_rack_assignment: false, requires_rack_assignment: false,
requires_transition_form: false, requires_transition_form: false,
@@ -349,7 +349,7 @@ export class FpSimpleRecipeEditor extends Component {
} }
/** /**
* Sub 14 fetch the workflow-state catalog once per editor session, * Sub 14 - fetch the workflow-state catalog once per editor session,
* cache on this.state.workflowStates. Used by both create + edit * cache on this.state.workflowStates. Used by both create + edit
* flows to populate the "Triggers Workflow State" dropdown. * flows to populate the "Triggers Workflow State" dropdown.
*/ */
@@ -368,7 +368,7 @@ export class FpSimpleRecipeEditor extends Component {
} }
/** /**
* Sub 14b fetch the user-extensible Step Kind catalog once per * Sub 14b - fetch the user-extensible Step Kind catalog once per
* editor session, cache on this.state.kindOptions. Used by both * editor session, cache on this.state.kindOptions. Used by both
* create + edit flows to populate the "Step Kind" dropdown so * create + edit flows to populate the "Step Kind" dropdown so
* user-added kinds appear without a page reload. * user-added kinds appear without a page reload.
@@ -386,7 +386,7 @@ export class FpSimpleRecipeEditor extends Component {
} }
/** /**
* Sub 14b handler for Step Kind dropdown change. Special-cases * Sub 14b - handler for Step Kind dropdown change. Special-cases
* the "+ Add a new kind…" sentinel: prompt the user for a name, * the "+ Add a new kind…" sentinel: prompt the user for a name,
* round-trip to /kinds/create, refresh the cached options, then * round-trip to /kinds/create, refresh the cached options, then
* select the newly-created kind. * select the newly-created kind.
@@ -412,7 +412,7 @@ export class FpSimpleRecipeEditor extends Component {
name: name.trim(), name: name.trim(),
}); });
if (!data.ok) { if (!data.ok) {
// 2026-05-20 backend forbids non-managers from // 2026-05-20 - backend forbids non-managers from
// creating kinds. Surface the explanatory message // creating kinds. Surface the explanatory message
// instead of a generic error code. // instead of a generic error code.
alert(data.message || data.error || "Could not create Step Kind."); alert(data.message || data.error || "Could not create Step Kind.");
@@ -435,7 +435,7 @@ export class FpSimpleRecipeEditor extends Component {
template_id: templateId, template_id: templateId,
}); });
if (data.ok) { if (data.ok) {
// Defensive copy OWL useState wraps top-level fields, but // Defensive copy - OWL useState wraps top-level fields, but
// we want to be able to mutate this.state.libraryEditor.* in // we want to be able to mutate this.state.libraryEditor.* in
// place without triggering library list re-renders. // place without triggering library list re-renders.
this.state.libraryEditor = JSON.parse(JSON.stringify(data.template)); this.state.libraryEditor = JSON.parse(JSON.stringify(data.template));
@@ -448,7 +448,7 @@ export class FpSimpleRecipeEditor extends Component {
); );
} else { } else {
this.notification.add( this.notification.add(
_t("Could not load library template it may have been deleted."), _t("Could not load library template - it may have been deleted."),
{ type: "warning" } { type: "warning" }
); );
} }
@@ -477,7 +477,7 @@ export class FpSimpleRecipeEditor extends Component {
requires_signoff: !!ed.requires_signoff, requires_signoff: !!ed.requires_signoff,
requires_predecessor_done: !!ed.requires_predecessor_done, requires_predecessor_done: !!ed.requires_predecessor_done,
parallel_start: !!ed.parallel_start, parallel_start: !!ed.parallel_start,
// Sub 14 workflow trigger (Many2one int or false) // Sub 14 - workflow trigger (Many2one int or false)
triggers_workflow_state_id: ed.triggers_workflow_state_id || false, triggers_workflow_state_id: ed.triggers_workflow_state_id || false,
requires_rack_assignment: !!ed.requires_rack_assignment, requires_rack_assignment: !!ed.requires_rack_assignment,
requires_transition_form: !!ed.requires_transition_form, requires_transition_form: !!ed.requires_transition_form,
@@ -645,7 +645,7 @@ export class FpSimpleRecipeEditor extends Component {
this.state.dragOverIndex = before ? rowIndex : rowIndex + 1; this.state.dragOverIndex = before ? rowIndex : rowIndex + 1;
} }
/** Trailing dropzone always inserts at the end. */ /** Trailing dropzone - always inserts at the end. */
onTailDragOver(ev) { onTailDragOver(ev) {
ev.preventDefault(); ev.preventDefault();
ev.dataTransfer.dropEffect = ev.dataTransfer.dropEffect =
@@ -657,7 +657,7 @@ export class FpSimpleRecipeEditor extends Component {
/** /**
* Panel-level dragover. Required so HTML5 `drop` actually fires * Panel-level dragover. Required so HTML5 `drop` actually fires
* across the whole panel surface including the gap between rows * across the whole panel surface - including the gap between rows
* (.25rem margin) and the panel padding (1rem). Without this, drops * (.25rem margin) and the panel padding (1rem). Without this, drops
* on those areas are silently rejected by the browser. Row-level * on those areas are silently rejected by the browser. Row-level
* dragover handlers still run first and set the precise index; * dragover handlers still run first and set the precise index;
@@ -695,7 +695,7 @@ export class FpSimpleRecipeEditor extends Component {
onDragLeave(ev) { onDragLeave(ev) {
// Only clear when leaving the panel entirely. Browser fires // Only clear when leaving the panel entirely. Browser fires
// dragleave when crossing into a child element too guard against // dragleave when crossing into a child element too - guard against
// that by checking relatedTarget. // that by checking relatedTarget.
if (!ev.currentTarget.contains(ev.relatedTarget)) { if (!ev.currentTarget.contains(ev.relatedTarget)) {
this.state.dragOverIndex = null; this.state.dragOverIndex = null;
@@ -716,7 +716,7 @@ export class FpSimpleRecipeEditor extends Component {
/** /**
* Toggle the inline edit panel for a step. Closing without explicit * Toggle the inline edit panel for a step. Closing without explicit
* Save discards changes operator-style "I clicked the wrong row" * Save discards changes - operator-style "I clicked the wrong row"
* shouldn't write garbage to the recipe. * shouldn't write garbage to the recipe.
*/ */
async onToggleEdit(stepId) { async onToggleEdit(stepId) {
@@ -726,10 +726,10 @@ export class FpSimpleRecipeEditor extends Component {
} }
const step = this.state.steps.find((s) => s.id === stepId); const step = this.state.steps.find((s) => s.id === stepId);
if (!step) return; if (!step) return;
// Sub 14 make sure the workflow-state catalog is cached so // Sub 14 - make sure the workflow-state catalog is cached so
// the dropdown in the inline form has options to render. // the dropdown in the inline form has options to render.
await this._fpEnsureWorkflowStatesLoaded(); await this._fpEnsureWorkflowStatesLoaded();
// 2026-05-20 Step Type dropdown is now driven by the // 2026-05-20 - Step Type dropdown is now driven by the
// fp.step.kind catalog (curated to 12 active kinds). Cache the // fp.step.kind catalog (curated to 12 active kinds). Cache the
// list before opening the panel so the select renders with // list before opening the panel so the select renders with
// options instead of being empty. // options instead of being empty.
@@ -738,7 +738,7 @@ export class FpSimpleRecipeEditor extends Component {
this.state.editName = step.name || ""; this.state.editName = step.name || "";
this.state.editInstructions = this._htmlToText(step.description || ""); this.state.editInstructions = this._htmlToText(step.description || "");
// Settings the user can now change WITHOUT delete + re-add. // Settings the user can now change WITHOUT delete + re-add.
// Default to 'other' when no kind is set kind_id is required // Default to 'other' when no kind is set - kind_id is required
// on the model so we never want a blank value to round-trip. // on the model so we never want a blank value to round-trip.
this.state.editDefaultKind = step.default_kind || "other"; this.state.editDefaultKind = step.default_kind || "other";
this.state.editTriggersWorkflowStateId = this.state.editTriggersWorkflowStateId =
@@ -763,7 +763,7 @@ export class FpSimpleRecipeEditor extends Component {
const vals = { const vals = {
name: this.state.editName || _t("Untitled Step"), name: this.state.editName || _t("Untitled Step"),
description: this._textToHtml(this.state.editInstructions), description: this._textToHtml(this.state.editInstructions),
// New per-step settings user can flip these without // New per-step settings - user can flip these without
// deleting and re-adding the step. // deleting and re-adding the step.
default_kind: this.state.editDefaultKind || false, default_kind: this.state.editDefaultKind || false,
triggers_workflow_state_id: triggers_workflow_state_id:
@@ -798,7 +798,7 @@ export class FpSimpleRecipeEditor extends Component {
for (const file of files) { for (const file of files) {
if (!file.type.startsWith("image/")) { if (!file.type.startsWith("image/")) {
this.notification.add( this.notification.add(
_t("%s isn't an image skipped.").replace("%s", file.name), _t("%s isn't an image - skipped.").replace("%s", file.name),
{ type: "warning" }, { type: "warning" },
); );
continue; continue;
@@ -864,7 +864,7 @@ export class FpSimpleRecipeEditor extends Component {
} }
} }
// -------------------- Sub 12d measurements config -------------------- // -------------------- Sub 12d - measurements config --------------------
async onToggleStepCollect(stepId, collect) { async onToggleStepCollect(stepId, collect) {
await rpc("/fp/simple_recipe/step/toggle_collect", { await rpc("/fp/simple_recipe/step/toggle_collect", {
@@ -915,7 +915,7 @@ export class FpSimpleRecipeEditor extends Component {
}); });
if (result.error === "library_sourced") { if (result.error === "library_sourced") {
this.notification.add( this.notification.add(
_t("Library prompts can't be deleted toggle Collect off instead."), _t("Library prompts can't be deleted - toggle Collect off instead."),
{ type: "warning" } { type: "warning" }
); );
return; return;
@@ -936,7 +936,7 @@ export class FpSimpleRecipeEditor extends Component {
} }
await this.loadAll(); await this.loadAll();
this.notification.add( this.notification.add(
_t("Reset to library defaults custom prompts preserved"), _t("Reset to library defaults - custom prompts preserved"),
{ type: "success" } { type: "success" }
); );
} }
@@ -944,7 +944,7 @@ export class FpSimpleRecipeEditor extends Component {
/** /**
* Render stored HTML as plain text for the textarea. Strips tags, * Render stored HTML as plain text for the textarea. Strips tags,
* collapses block elements to newlines. Good enough for the simple * collapses block elements to newlines. Good enough for the simple
* editor the tree editor handles full rich text. * editor - the tree editor handles full rich text.
*/ */
_htmlToText(html) { _htmlToText(html) {
if (!html) return ""; if (!html) return "";

View File

@@ -1,5 +1,5 @@
// ===================================================================== // =====================================================================
// Fusion Plating Chatter dark-mode patch // Fusion Plating - Chatter dark-mode patch
// //
// In dark mode the floating message-action toolbar (reaction / reply / // In dark mode the floating message-action toolbar (reaction / reply /
// star / link icons) renders white-on-white because Odoo sets the // star / link icons) renders white-on-white because Odoo sets the

View File

@@ -1,4 +1,4 @@
// Sub 14b Visual icon picker for fp.step.kind.icon and similar // Sub 14b - Visual icon picker for fp.step.kind.icon and similar
// Selection fields whose values are FontAwesome class names. // Selection fields whose values are FontAwesome class names.
// //
// Compact 12-column grid with Search filter. Glyph-only tiles // Compact 12-column grid with Search filter. Glyph-only tiles

View File

@@ -1,5 +1,5 @@
// ============================================================================= // =============================================================================
// Fusion Plating backend styles // Fusion Plating - backend styles
// Copyright 2026 Nexa Systems Inc. // Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0) // License OPL-1 (Odoo Proprietary License v1.0)
// //
@@ -18,10 +18,10 @@
// //
// Semantic status colours (green / amber / red) use `color-mix()` against the // Semantic status colours (green / amber / red) use `color-mix()` against the
// Bootstrap theme token so a green badge is darker on light mode and brighter // Bootstrap theme token so a green badge is darker on light mode and brighter
// on dark mode automatically one rule, two looks. // on dark mode automatically - one rule, two looks.
// //
// We never target `.o_dark`, `html.dark`, or `@media (prefers-color-scheme)` // We never target `.o_dark`, `html.dark`, or `@media (prefers-color-scheme)`
// to override colours. If you find yourself needing that, it's a smell use // to override colours. If you find yourself needing that, it's a smell - use
// a variable instead. // a variable instead.
// ============================================================================= // =============================================================================
@@ -72,12 +72,12 @@
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Tank kanban state badge theming // Tank kanban - state badge theming
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
.o_fp_tank_kanban { .o_fp_tank_kanban {
.o_fp_tank_card { .o_fp_tank_card {
// Let the left-border carry the state subtle, theme-aware. // Let the left-border carry the state - subtle, theme-aware.
border-left-width: 4px; border-left-width: 4px;
&[data-state="empty"], &[data-state="empty"],
@@ -123,7 +123,7 @@
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Bath kanban chemistry health dot // Bath kanban - chemistry health dot
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
.o_fp_bath_kanban { .o_fp_bath_kanban {
@@ -162,7 +162,7 @@
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Facility kanban stat strip spacing // Facility kanban - stat strip spacing
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
.o_fp_facility_kanban { .o_fp_facility_kanban {

View File

@@ -1,5 +1,5 @@
// ============================================================================= // =============================================================================
// Fusion Plating Recipe Tree Editor (horizontal bracket-tree, v2, 2026-04) // Fusion Plating - Recipe Tree Editor (horizontal bracket-tree, v2, 2026-04)
// Copyright 2026 Nexa Systems Inc. · License OPL-1 // Copyright 2026 Nexa Systems Inc. · License OPL-1
// //
// Same Steelhead-style bracket layout as the read-only Process Tree, but // Same Steelhead-style bracket layout as the read-only Process Tree, but
@@ -235,7 +235,7 @@ $re-line-w : 2px;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Card dark Steelhead-style // Card - dark Steelhead-style
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
.o_fp_re_card { .o_fp_re_card {
display: inline-flex; display: inline-flex;
@@ -296,14 +296,14 @@ $re-line-w : 2px;
// ---- MO-execution state palette (Sub 3) -------------------------- // ---- MO-execution state palette (Sub 3) --------------------------
// Applied when rendering the tree in an MO context where each // Applied when rendering the tree in an MO context where each
// node can be mapped to a linked WO. Red is reserved for true // node can be mapped to a linked WO. Red is reserved for true
// error / blocked states previously the "active" highlight // error / blocked states - previously the "active" highlight
// used red which confused operators into seeing it as an error. // used red which confused operators into seeing it as an error.
// completed -> green (WO done) // completed -> green (WO done)
// active -> blue (WO in progress) // active -> blue (WO in progress)
// failed / // failed /
// blocked -> red (WO cancel OR quality hold active) // blocked -> red (WO cancel OR quality hold active)
// Pending nodes (no WO, or WO pending/ready) keep the default // Pending nodes (no WO, or WO pending/ready) keep the default
// card styling no modifier class applied. // card styling - no modifier class applied.
&.o_fp_re_card_completed { &.o_fp_re_card_completed {
background-color: color-mix(in srgb, var(--bs-success, #28a745) 10%, #{$re-card}); background-color: color-mix(in srgb, var(--bs-success, #28a745) 10%, #{$re-card});
border: 2px solid var(--bs-success, #28a745); border: 2px solid var(--bs-success, #28a745);
@@ -371,7 +371,7 @@ $re-line-w : 2px;
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Capability flags small icon row inside the card // Capability flags - small icon row inside the card
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
.o_fp_re_flags { .o_fp_re_flags {
display: inline-flex; display: inline-flex;
@@ -467,7 +467,7 @@ $re-line-w : 2px;
position: relative; position: relative;
padding-left: $re-stub; padding-left: $re-stub;
// horizontal stub bus column → child card // horizontal stub - bus column → child card
&::before { &::before {
content: ""; content: "";
position: absolute; position: absolute;

View File

@@ -1,8 +1,8 @@
// Sub 12a Simple Recipe Editor styling. // Sub 12a - Simple Recipe Editor styling.
// //
// Tokens follow the existing fp_shopfloor pattern (CSS custom props // Tokens follow the existing fp_shopfloor pattern (CSS custom props
// with hex fallbacks; dark-mode aware via $o-webclient-color-scheme // with hex fallbacks; dark-mode aware via $o-webclient-color-scheme
// SCSS @if branch see fusion_plating CLAUDE.md for the rule). // SCSS @if branch - see fusion_plating CLAUDE.md for the rule).
$o-webclient-color-scheme: bright !default; $o-webclient-color-scheme: bright !default;
@@ -118,7 +118,7 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
gap: 1rem; gap: 1rem;
// align-items: start so the library panel can be shorter than // align-items: start so the library panel can be shorter than
// the recipe-step column without stretching to match its height // the recipe-step column without stretching to match its height
// required for sticky positioning to behave. // - required for sticky positioning to behave.
align-items: start; align-items: start;
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -141,7 +141,7 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
} }
} }
// Step Library pin to the top of the scroll container so authors // Step Library - pin to the top of the scroll container so authors
// can drag from it into the recipe without scrolling back up. // can drag from it into the recipe without scrolling back up.
// Recipes can be 40+ steps long; before this, the library scrolled // Recipes can be 40+ steps long; before this, the library scrolled
// off with the page and you had to scroll to the top, grab a step, // off with the page and you had to scroll to the top, grab a step,
@@ -149,7 +149,7 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
// //
// Sticky inside the editor's overflow:auto container. max-height + // Sticky inside the editor's overflow:auto container. max-height +
// internal overflow-y so the library's OWN content (could be 30+ // internal overflow-y so the library's OWN content (could be 30+
// entries) doesn't blow past the viewport it grows a scrollbar // entries) doesn't blow past the viewport - it grows a scrollbar
// instead. // instead.
.o_fp_library_panel { .o_fp_library_panel {
position: sticky; position: sticky;
@@ -161,7 +161,7 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
@media (max-width: 900px) { @media (max-width: 900px) {
// Stacked layout no sticky, behaves like a normal block. // Stacked layout - no sticky, behaves like a normal block.
position: static; position: static;
max-height: none; max-height: none;
box-shadow: none; box-shadow: none;
@@ -271,7 +271,7 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
} }
// Step nodes inside an operation are rendered as indented sub-rows // Step nodes inside an operation are rendered as indented sub-rows
// same node model as operations, but they're sub-instructions // - same node model as operations, but they're sub-instructions
// (the WO generator folds them into the operation's instruction // (the WO generator folds them into the operation's instruction
// text). Visual treatment: smaller, indented, no drag handle, no // text). Visual treatment: smaller, indented, no drag handle, no
// numeric position so the eye can tell them apart from operations. // numeric position so the eye can tell them apart from operations.
@@ -615,7 +615,7 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
// ============================================================================= // =============================================================================
// Instruction images gallery recipe-author upload + thumbnail strip in // Instruction images gallery - recipe-author upload + thumbnail strip in
// the Simple Editor's inline step edit panel. Mirrors what the Record // the Simple Editor's inline step edit panel. Mirrors what the Record
// Inputs dialog renders at runtime so authors can preview the same way // Inputs dialog renders at runtime so authors can preview the same way
// the operator will see it. // the operator will see it.

View File

@@ -4,7 +4,7 @@
License OPL-1 (Odoo Proprietary License v1.0) License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family. Part of the Fusion Plating product family.
Recipe Tree Editor horizontal hierarchical layout (Steelhead-style). Recipe Tree Editor - horizontal hierarchical layout (Steelhead-style).
Recursive template renders recipe → sub-process → operation → step Recursive template renders recipe → sub-process → operation → step
cards left→right with bracket connectors. Each card carries hover- cards left→right with bracket connectors. Each card carries hover-
revealed Add / Delete buttons; a side panel slides in for editing revealed Add / Delete buttons; a side panel slides in for editing
@@ -212,7 +212,7 @@
<label class="o_fp_re_import_label">Import children from:</label> <label class="o_fp_re_import_label">Import children from:</label>
<select class="o_fp_re_import_select" <select class="o_fp_re_import_select"
t-model="state.importRecipeId"> t-model="state.importRecipeId">
<option value=""> Pick a recipe </option> <option value="">- Pick a recipe -</option>
<t t-foreach="state.importRecipeOptions" t-as="r" t-key="r.id"> <t t-foreach="state.importRecipeOptions" t-as="r" t-key="r.id">
<option t-att-value="r.id" t-esc="r.name"/> <option t-att-value="r.id" t-esc="r.name"/>
</t> </t>
@@ -350,7 +350,7 @@
t-on-change="(ev) => { state.selectedNode.customer_visible = ev.target.checked; }"/> t-on-change="(ev) => { state.selectedNode.customer_visible = ev.target.checked; }"/>
<label class="form-check-label" for="fp_re_chk_visible">Customer visible</label> <label class="form-check-label" for="fp_re_chk_visible">Customer visible</label>
</div> </div>
<!-- Sub 13 sequential enforcement (recipe root) --> <!-- Sub 13 - sequential enforcement (recipe root) -->
<div class="form-check" <div class="form-check"
t-if="state.selectedNode.node_type === 'recipe'"> t-if="state.selectedNode.node_type === 'recipe'">
<input type="checkbox" class="form-check-input" id="fp_re_chk_seq" <input type="checkbox" class="form-check-input" id="fp_re_chk_seq"
@@ -361,7 +361,7 @@
Enforce Sequential Order Enforce Sequential Order
</label> </label>
</div> </div>
<!-- Sub 13 per-step opt-out (operation/step nodes) --> <!-- Sub 13 - per-step opt-out (operation/step nodes) -->
<div class="form-check" <div class="form-check"
t-if="state.selectedNode.node_type === 'operation' or state.selectedNode.node_type === 'step'"> t-if="state.selectedNode.node_type === 'operation' or state.selectedNode.node_type === 'step'">
<input type="checkbox" class="form-check-input" id="fp_re_chk_par" <input type="checkbox" class="form-check-input" id="fp_re_chk_par"
@@ -374,7 +374,7 @@
</div> </div>
</div> </div>
<!-- Sub 14 workflow milestone trigger (operation / step nodes) --> <!-- Sub 14 - workflow milestone trigger (operation / step nodes) -->
<div class="o_fp_re_field" <div class="o_fp_re_field"
t-if="(state.selectedNode.node_type === 'operation' or state.selectedNode.node_type === 'step') and state.workflowStates.length"> t-if="(state.selectedNode.node_type === 'operation' or state.selectedNode.node_type === 'step') and state.workflowStates.length">
<label for="fp_re_workflow_state">Triggers Workflow State</label> <label for="fp_re_workflow_state">Triggers Workflow State</label>
@@ -383,7 +383,7 @@
t-on-change="(ev) => { state.selectedNode.triggers_workflow_state_id = ev.target.value ? (+ev.target.value) : false; }"> t-on-change="(ev) => { state.selectedNode.triggers_workflow_state_id = ev.target.value ? (+ev.target.value) : false; }">
<option value="" <option value=""
t-att-selected="!state.selectedNode.triggers_workflow_state_id"> t-att-selected="!state.selectedNode.triggers_workflow_state_id">
None (use default-kind matching) - None (use default-kind matching) -
</option> </option>
<t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id"> <t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id">
<option t-att-value="ws.id" <option t-att-value="ws.id"
@@ -411,9 +411,9 @@
t-att-selected="state.selectedNode.opt_in_out === 'opt_in'">Opt-In (excluded by default)</option> t-att-selected="state.selectedNode.opt_in_out === 'opt_in'">Opt-In (excluded by default)</option>
</select> </select>
<small class="text-muted d-block mt-1"> <small class="text-muted d-block mt-1">
<strong>Required</strong> every job runs this step. <strong>Required</strong> - every job runs this step.
<strong>Opt-Out</strong> ships included, estimator can remove per job. <strong>Opt-Out</strong> - ships included, estimator can remove per job.
<strong>Opt-In</strong> ships excluded, estimator can add per job. <strong>Opt-In</strong> - ships excluded, estimator can add per job.
</small> </small>
</div> </div>

View File

@@ -30,7 +30,7 @@
<select id="fp_import_template_select" <select id="fp_import_template_select"
class="form-select o_fp_import_select" class="form-select o_fp_import_select"
t-model="state.selectedTemplate"> t-model="state.selectedTemplate">
<option value=""> Select template </option> <option value="">- Select template -</option>
<t t-foreach="state.templateOptions" t-as="tpl" t-key="tpl.id"> <t t-foreach="state.templateOptions" t-as="tpl" t-key="tpl.id">
<option t-att-value="tpl.id"> <option t-att-value="tpl.id">
<t t-esc="tpl.name"/> (<t t-esc="tpl.step_count"/> steps) <t t-esc="tpl.name"/> (<t t-esc="tpl.step_count"/> steps)
@@ -56,7 +56,7 @@
<div class="o_fp_steps_list"> <div class="o_fp_steps_list">
<!-- Top drop indicator (insertion at index 0). Visible <!-- Top drop indicator (insertion at index 0). Visible
only when dragOverIndex === 0 i.e. cursor is only when dragOverIndex === 0 - i.e. cursor is
hovering above the first row's midpoint. --> hovering above the first row's midpoint. -->
<div class="o_fp_drop_indicator" <div class="o_fp_drop_indicator"
t-att-class="state.dragOverIndex === 0 ? 'o_fp_drop_indicator_active' : ''"> t-att-class="state.dragOverIndex === 0 ? 'o_fp_drop_indicator_active' : ''">
@@ -145,13 +145,13 @@
<textarea class="form-control" <textarea class="form-control"
rows="5" rows="5"
t-model="state.editInstructions" t-model="state.editInstructions"
placeholder="What the operator/employee sees on the shop floor when running this step. Plain text line breaks are preserved."/> placeholder="What the operator/employee sees on the shop floor when running this step. Plain text - line breaks are preserved."/>
<p class="o_fp_edit_hint"> <p class="o_fp_edit_hint">
Shown to operators when running this step at the tank. Use line breaks for separate points. Shown to operators when running this step at the tank. Use line breaks for separate points.
</p> </p>
</div> </div>
<!-- Sub 14 + Sub 13 settings the user can change <!-- Sub 14 + Sub 13 - settings the user can change
without delete + re-add. Step Type drives the without delete + re-add. Step Type drives the
default-kind workflow trigger; the dropdown default-kind workflow trigger; the dropdown
below it lets them override per-step. --> below it lets them override per-step. -->
@@ -200,7 +200,7 @@
<label>Triggers Workflow State</label> <label>Triggers Workflow State</label>
<select class="form-select" <select class="form-select"
t-on-change="(ev) => { state.editTriggersWorkflowStateId = ev.target.value ? (+ev.target.value) : false; }"> t-on-change="(ev) => { state.editTriggersWorkflowStateId = ev.target.value ? (+ev.target.value) : false; }">
<option value="" t-att-selected="!state.editTriggersWorkflowStateId"> None (use Step Type) </option> <option value="" t-att-selected="!state.editTriggersWorkflowStateId">- None (use Step Type) -</option>
<t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id"> <t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id">
<option t-att-value="ws.id" <option t-att-value="ws.id"
t-att-selected="state.editTriggersWorkflowStateId === ws.id" t-att-selected="state.editTriggersWorkflowStateId === ws.id"
@@ -234,7 +234,7 @@
</label> </label>
</div> </div>
<!-- Instruction images recipe author drops <!-- Instruction images - recipe author drops
photos / screenshots / diagrams here. photos / screenshots / diagrams here.
Operators see the gallery at runtime in Operators see the gallery at runtime in
the Record Inputs dialog and the step the Record Inputs dialog and the step
@@ -283,7 +283,7 @@
</label> </label>
</div> </div>
<!-- Sub 12d Measurements config --> <!-- Sub 12d - Measurements config -->
<div class="o_fp_edit_field o_fp_measurements_config"> <div class="o_fp_edit_field o_fp_measurements_config">
<label> <label>
<input type="checkbox" <input type="checkbox"
@@ -324,7 +324,7 @@
t-on-blur="(ev) => this.onInputBlur(inp.id, 'name', ev)"/> t-on-blur="(ev) => this.onInputBlur(inp.id, 'name', ev)"/>
<small t-if="inp.from_library" <small t-if="inp.from_library"
class="text-muted" class="text-muted"
title="From library template toggle Collect off instead of deleting"> title="From library template - toggle Collect off instead of deleting">
from library from library
</small> </small>
</td> </td>
@@ -509,7 +509,7 @@
<t t-foreach="state.kindOptions || []" t-as="k" t-key="k.id"> <t t-foreach="state.kindOptions || []" t-as="k" t-key="k.id">
<option t-att-value="k.code" t-att-selected="k.code === state.libraryEditor.default_kind"> <option t-att-value="k.code" t-att-selected="k.code === state.libraryEditor.default_kind">
<t t-esc="k.name"/> <t t-esc="k.name"/>
<t t-if="k.area_kind_label"> <t t-esc="k.area_kind_label"/> column</t> <t t-if="k.area_kind_label"> - <t t-esc="k.area_kind_label"/> column</t>
</option> </option>
</t> </t>
<!-- Manager-only inline create. The <!-- Manager-only inline create. The
@@ -592,7 +592,7 @@
</label> </label>
</div> </div>
<!-- Sub 14 workflow milestone trigger dropdown. <!-- Sub 14 - workflow milestone trigger dropdown.
Hidden when no states exist (e.g. catalog Hidden when no states exist (e.g. catalog
not seeded yet). --> not seeded yet). -->
<div class="o_fp_le_field" <div class="o_fp_le_field"
@@ -602,7 +602,7 @@
t-on-change="(ev) => { state.libraryEditor.triggers_workflow_state_id = ev.target.value ? (+ev.target.value) : false; }"> t-on-change="(ev) => { state.libraryEditor.triggers_workflow_state_id = ev.target.value ? (+ev.target.value) : false; }">
<option value="" <option value=""
t-att-selected="!state.libraryEditor.triggers_workflow_state_id"> t-att-selected="!state.libraryEditor.triggers_workflow_state_id">
None (use default-kind matching) - None (use default-kind matching) -
</option> </option>
<t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id"> <t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id">
<option t-att-value="ws.id" <option t-att-value="ws.id"
@@ -702,7 +702,7 @@
Save the step first, then add prompts. Save the step first, then add prompts.
</t> </t>
<t t-else=""> <t t-else="">
No prompts yet operators will not be asked for measurements at runtime. No prompts yet - operators will not be asked for measurements at runtime.
</t> </t>
</p> </p>
<div class="o_fp_le_prompt_actions" <div class="o_fp_le_prompt_actions"
@@ -714,7 +714,7 @@
<button class="btn btn-link btn-sm" <button class="btn btn-link btn-sm"
t-if="state.libraryEditor.default_kind" t-if="state.libraryEditor.default_kind"
t-on-click="onSeedLibraryDefaults" t-on-click="onSeedLibraryDefaults"
title="Append the canonical prompts for this Step Kind. Idempotent won't duplicate existing prompts."> title="Append the canonical prompts for this Step Kind. Idempotent - won't duplicate existing prompts.">
<i class="fa fa-magic"/> Seed defaults from kind <i class="fa fa-magic"/> Seed defaults from kind
</button> </button>
</div> </div>

View File

@@ -46,11 +46,11 @@ class TestFpJobStateMachine(TransactionCase):
job.action_confirm() job.action_confirm()
def test_cannot_cancel_done(self): def test_cannot_cancel_done(self):
# Done jobs cannot be cancelled covers the UserError branch in # Done jobs cannot be cancelled - covers the UserError branch in
# action_cancel. # action_cancel.
job = self._make_job() job = self._make_job()
job.action_confirm() job.action_confirm()
# Force the state to 'done' for the test (no public action yet # Force the state to 'done' for the test (no public action yet -
# done is set by step-completion logic landing in Task 1.5+). # done is set by step-completion logic landing in Task 1.5+).
job.state = 'done' job.state = 'done'
with self.assertRaises(UserError): with self.assertRaises(UserError):
@@ -71,7 +71,7 @@ class TestFpJobStateMachine(TransactionCase):
job = self._make_job() job = self._make_job()
# Force state to 'done' (no public action yet) # Force state to 'done' (no public action yet)
job.state = 'done' job.state = 'done'
# Recompute Odoo's compute is auto on read # Recompute - Odoo's compute is auto on read
self.assertEqual(job.current_location, 'Done') self.assertEqual(job.current_location, 'Done')
def test_margin_zero_when_no_revenue(self): def test_margin_zero_when_no_revenue(self):

View File

@@ -33,7 +33,7 @@ class TestFpJobStepStateMachine(TransactionCase):
def test_button_start_requires_ready_or_paused(self): def test_button_start_requires_ready_or_paused(self):
step = self._make_step() step = self._make_step()
# state is 'pending' should raise # state is 'pending' - should raise
with self.assertRaises(UserError): with self.assertRaises(UserError):
step.button_start() step.button_start()

View File

@@ -15,7 +15,7 @@ class TestFpWorkCentre(TransactionCase):
def test_facility_optional_at_create(self): def test_facility_optional_at_create(self):
# Facility is soft-required (warning at confirm, not constraint # Facility is soft-required (warning at confirm, not constraint
# at create) verify a centre without facility still creates. # at create) - verify a centre without facility still creates.
wc = self.env['fp.work.centre'].create({ wc = self.env['fp.work.centre'].create({
'name': 'Test', 'name': 'Test',
'code': 'T', 'code': 'T',

View File

@@ -2,7 +2,7 @@
# Copyright 2026 Nexa Systems Inc. # Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family. # Part of the Fusion Plating product family.
"""Phase E (Plating permissions overhaul) role-based landing dispatch. """Phase E (Plating permissions overhaul) - role-based landing dispatch.
Section 3 of the design spec covers per-role landing pages: Section 3 of the design spec covers per-role landing pages:
@@ -57,7 +57,7 @@ class TestLandingResolver(TransactionCase):
The resolver lives on `ir.actions.act_window` (helper method, not a The resolver lives on `ir.actions.act_window` (helper method, not a
column). It can return an action dict for either an act_window or a column). It can return an action dict for either an act_window or a
client action both carry an `xml_id` key once we go through client action - both carry an `xml_id` key once we go through
`_render_resolved`. `_render_resolved`.
""" """
Window = self.env['ir.actions.act_window'] Window = self.env['ir.actions.act_window']
@@ -123,7 +123,7 @@ class TestLandingResolver(TransactionCase):
"""The legacy 'fp_shopfloor_landing' component was retired """The legacy 'fp_shopfloor_landing' component was retired
2026-05-25. The ``fusion_plating_shopfloor.layout`` flag is now 2026-05-25. The ``fusion_plating_shopfloor.layout`` flag is now
orphaned (kept in res.config.settings for one release cycle) and orphaned (kept in res.config.settings for one release cycle) and
flipping it must NOT change the landing every technician lands flipping it must NOT change the landing - every technician lands
on the plant kanban.""" on the plant kanban."""
self.env['ir.config_parameter'].sudo().set_param( self.env['ir.config_parameter'].sudo().set_param(
'fusion_plating_shopfloor.layout', 'legacy') 'fusion_plating_shopfloor.layout', 'legacy')

View File

@@ -14,7 +14,7 @@ class TestMenuVisibility(TransactionCase):
'email': f'menu_{name}@example.com', 'email': f'menu_{name}@example.com',
'group_ids': [(6, 0, [self.env.ref(xmlid).id])] if xmlid else [(6, 0, [])], 'group_ids': [(6, 0, [self.env.ref(xmlid).id])] if xmlid else [(6, 0, [])],
}) })
# "No" user has only base.group_user no plating group # "No" user has only base.group_user - no plating group
no_user = Users.create({ no_user = Users.create({
'login': 'menu_no', 'name': 'Menu Test no', 'login': 'menu_no', 'name': 'Menu Test no',
'email': 'menu_no@example.com', 'email': 'menu_no@example.com',

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Phase 1 rack-load core model tests. # Phase 1 - rack-load core model tests.
from odoo.tests import TransactionCase, tagged from odoo.tests import TransactionCase, tagged

View File

@@ -11,7 +11,7 @@ class TestRoleGroupsStructure(TransactionCase):
def test_all_seven_groups_exist(self): def test_all_seven_groups_exist(self):
"""The 7 new res.groups records must all be defined. (The 8th role 'No' """The 7 new res.groups records must all be defined. (The 8th role 'No'
is implicit absence of any plating group.)""" is implicit - absence of any plating group.)"""
xmlids = { xmlids = {
'group_fp_technician', 'group_fp_sales_rep', 'group_fp_technician', 'group_fp_sales_rep',
'group_fp_shop_manager_v2', 'group_fp_sales_manager', 'group_fp_shop_manager_v2', 'group_fp_sales_manager',
@@ -33,7 +33,7 @@ class TestRoleGroupsStructure(TransactionCase):
'Owner must transitively imply base.group_system') 'Owner must transitively imply base.group_system')
def test_manager_implies_both_branches(self): def test_manager_implies_both_branches(self):
"""Manager is the diamond apex must imply both Shop Manager and Sales Manager.""" """Manager is the diamond apex - must imply both Shop Manager and Sales Manager."""
mgr = self.env.ref('fusion_plating.group_fp_manager') mgr = self.env.ref('fusion_plating.group_fp_manager')
sm = self.env.ref('fusion_plating.group_fp_shop_manager_v2') sm = self.env.ref('fusion_plating.group_fp_shop_manager_v2')
sales_mgr = self.env.ref('fusion_plating.group_fp_sales_manager') sales_mgr = self.env.ref('fusion_plating.group_fp_sales_manager')

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