This commit is contained in:
gsinghpal
2026-04-28 19:39:37 -04:00
parent 2d42b33d68
commit 13e300d90e
103 changed files with 4959 additions and 331 deletions

View File

@@ -62,7 +62,7 @@ class FpTankReading(models.Model):
'per-company without re-migrating history).', 'per-company without re-migrating history).',
) )
unit = fields.Char( unit = fields.Char(
string='Unit (raw)', related='parameter_id.uom', store=True, string='Unit (raw)', related='parameter_id.uom_display', store=True,
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -93,7 +93,7 @@ class FpTankReading(models.Model):
r.display_unit = '°F' r.display_unit = '°F'
else: else:
r.display_value = r.value r.display_value = r.value
r.display_unit = r.parameter_id.uom or '' r.display_unit = r.parameter_id.uom_display or ''
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Deviation from setpoint — signed Δ from the sensor's effective target # Deviation from setpoint — signed Δ from the sensor's effective target

View File

@@ -239,7 +239,7 @@ class FpTankSensor(models.Model):
rec.effective_target_unit = '°F' rec.effective_target_unit = '°F'
else: else:
rec.effective_target = raw rec.effective_target = raw
rec.effective_target_unit = rec.parameter_id.uom or '' rec.effective_target_unit = rec.parameter_id.uom_display or ''
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Cached latest-reading fields (for quick display in list views) # Cached latest-reading fields (for quick display in list views)
@@ -276,7 +276,7 @@ class FpTankSensor(models.Model):
rec.last_reading_display_unit = '°F' rec.last_reading_display_unit = '°F'
else: else:
rec.last_reading_display = rec.last_reading_value rec.last_reading_display = rec.last_reading_value
rec.last_reading_display_unit = rec.parameter_id.uom or '' rec.last_reading_display_unit = rec.parameter_id.uom_display or ''
reading_ids = fields.One2many( reading_ids = fields.One2many(
'fp.tank.reading', 'sensor_id', string='Reading History', 'fp.tank.reading', 'sensor_id', string='Reading History',

View File

@@ -813,6 +813,120 @@ UNION ALL SELECT 'check', count(*) FROM fusion_plating_quality_check;
--- ---
## Contract Review — Policy B (shipped 2026-04-28)
The `fp.contract.review` model (QA-005) was originally shipped as
"always optional, never blocks anything" (Sub 4). Audit 2026-04-28
revealed three integration holes:
1. The **Simple Recipe Editor library** had no Contract Review step
template, so authors couldn't drop QA-005 into a recipe at all.
2. Adding a node literally named "Contract Review" to a recipe did
**nothing** — no auto-create, no operator routing, no gate.
3. The pre-Sub-11 `contract_review_user_ids` approver list on
`fp.process.node` was dead — `mrp.workorder.button_finish` used to
gate on it, but `fp.job.step` never picked up the gate.
**Policy B (chosen 2026-04-28)** — Contract Review is REQUIRED on a
per-customer basis (`partner.x_fc_contract_review_required`), soft
elsewhere. Recipe-side enforcement closes the post-Sub-11 hole.
### What's wired
| Trigger | Behaviour |
|---|---|
| `fp.step.template.default_kind = 'contract_review'` | New kind in the Simple Editor library. Auto-seeds 3 inputs: Reviewer Initials / Date Reviewed / QA-005 Approved (pass_fail). |
| Library seeders (`_STARTER_KIND_BY_NAME`, `_seed_minimal_library`) | "Contract Review" is the FIRST entry in the minimal library. Authors drag-drop it into recipes from the Simple Editor sidebar. |
| `fp.job.step.button_start` on a Contract Review step | Auto-creates `fp.contract.review` for the linked part if missing, returns an act_window pointing at the QA-005 form. Operator gets routed straight to the form without hunting for the smart button on the part. |
| `fp.job.step.button_finish` on a Contract Review step | Blocks unless `fp.contract.review.state == 'complete'` AND current user is on `recipe.contract_review_user_ids` (when configured). Manager bypass: `fp_skip_contract_review_gate=True` in context. |
| Step detection | `_fp_is_contract_review_step()` matches case-insensitive name == "contract review" / "qa-005" OR `recipe_node_id.source_template_id.default_kind == 'contract_review'` (simple-editor library entry). |
### What stays optional (NOT enforced)
- Customers without `x_fc_contract_review_required=True` get the soft
banner only — no step-level block. The customer-flag gate is the
ONLY enforcement trigger.
- Adding a Contract Review node to a recipe for a customer that
doesn't require it is purely documentary; nothing fires.
### Why the part-side banner stays
The part-form banner ("New part created. Please complete the Contract
Review (QA-005) if applicable.") is independent of the recipe step.
It nudges QA before any job is started — an early-detection mechanism
distinct from the in-flight step gate. Both can fire on the same part
(banner first, then step gate later); one resolution clears both.
### Manager bypass examples
```python
# Skip the step-level gate from a privileged caller (script / shell)
step.with_context(fp_skip_contract_review_gate=True).button_finish()
```
### Files touched
- `fusion_plating/models/fp_step_template.py` — added `contract_review`
kind + 3 default inputs.
- `fusion_plating/models/fp_process_node.py` — **also added
`contract_review` to `default_kind` Selection here.** Easy to miss:
the node and the template have separate Selection fields and they
must stay in lockstep.
- `fusion_plating/__init__.py` — added "Contract Review" / "QA-005" to
`_STARTER_KIND_BY_NAME` + first entry in `_seed_minimal_library`,
exposed `fp_resolve_step_kind()` helper.
- `fusion_plating_jobs/models/fp_job_step.py` — added
`_fp_is_contract_review_step`, `_fp_resolve_contract_review_part`,
`_fp_open_contract_review`, `_fp_check_contract_review_complete`;
hooked into `button_start` (auto-open form) + `button_finish`
(gate). Sub 11's `contract_review_user_ids` field on
`fp.process.node` is now wired again.
### Bugs caught during the persona walkthrough (2026-04-28, fixed 12.4.1)
A scripted "brand-new estimator builds a recipe from scratch" walk
(`/tmp/fp_recipe_walkthrough.py` on entech) surfaced 7 real gaps; all
fixed in 19.0.12.4.1. The walk is preserved as a smoke test —
re-runnable on any DB to verify the library is healthy.
| # | Bug | Fix |
|---|---|---|
| 1 | `_seed_step_library_if_empty` skips when the library is non-empty, so existing DBs got NO Contract Review template after Policy B shipped. | Migration `19.0.12.4.1/post-migrate.py` — backfills the template if missing. |
| 2 | `fp.process.node.default_kind` Selection didn't include `contract_review`, so dropping the template into a recipe blew up with `ValueError`. The kind is on TWO models (template + node) and they drifted. | Added `contract_review` to the node's Selection too. |
| 3 | The library had only `racking` populated as a kind (1/16). 12 of 14 templates landed with `default_kind = NULL` because the original seeder used a brittle case-sensitive lookup. | Migration backfills `default_kind` via the new `fp_resolve_step_kind()` helper. |
| 4 | `_STARTER_KIND_BY_NAME` lookup was hyphen / -ing / case sensitive — "E-Nickel Plating" didn't match `'e-nickel plate'`, "DeRacking" didn't match `'de-racking'`, "Ready For Masking" didn't map to `gating`. | Expanded the lookup with 30+ alias entries + a "Ready for X → gating" prefix rule in `fp_resolve_step_kind()`. |
| 5 | The library was missing the canonical names a fresh estimator would type from scratch (Soak Clean, Rinse, Etch, Acid Dip, Desmut, Zincate, Drying, Inspection, Shipping, Water Break Test). The ENP-ALUM-BASIC seed included only the names from that one recipe. | Migration adds 13 canonical missing entries (Soak Clean, Electroclean, Rinse, Etch, Desmut, Zincate, Acid Dip, HCl Activation, Water Break Test, Drying, Inspection, Final Inspection, Shipping, Contract Review). |
| 6 | `_seed_minimal_library` (the fresh-DB fallback path) had only 15 entries, didn't include Contract Review, and used English names that don't match the 30+ aliases. | Added "Contract Review" as the first entry. Library is now bigger, but `fp_resolve_step_kind()` is the canonical way authors will get coverage. |
| 7 | `DEFAULT_INPUTS_BY_KIND` in `fp_step_template.py` still had free-text `target_unit` values (`'HH:MM'`, `'°F'`, `'sec'`, `'in'`, `'each'`) left over from before the 19.0.12.1.0 UoM cleanup. `action_seed_default_inputs()` blew up with `Wrong value for target_unit: 'HH:MM'` when called against the new Selection-typed column. | Translated to selection keys: `'sec' → 's'`, `'°F' → 'f'`, `'in' → 'in'`, `'each' → 'each'`, `'min' → 'min'`. Format-only strings (`'HH:MM'`) dropped — they're not units. |
The walkthrough script is checked into context at
`/tmp/fp_recipe_walkthrough.py` (rerun via odoo shell) and is the
recommended smoke test before any future library / step-template
changes ship.
---
## Record Inputs Wizard — ad-hoc rows (shipped 2026-04-28)
The backend `Record Inputs` button on the job-form Steps tab opened
an empty wizard when the recipe step had no `step_input` prompts
authored — operator had no way to log anything. Fixed by:
- Making `node_input_id` optional on
`fp.job.step.input.wizard.line`. Authored prompts still show
pre-filled + readonly; ad-hoc rows are fully editable (operator types
the prompt label + value).
- View now shows a helpful empty-state hint and an `Add a line` button.
- Commit step requires every ad-hoc row to have a Prompt label, then
serialises it into `value_text` of the resulting
`fp.job.step.move.input.value` (format `Prompt: value [unit]`) so
the chronological CoC report still renders the captured data.
Files: `fusion_plating_jobs/wizards/fp_job_step_input_wizard.py` +
`fp_job_step_input_wizard_views.xml`.
---
## Battle Tests — Real-World Operator Scenario Coverage ## Battle Tests — Real-World Operator Scenario Coverage
Persona-driven shop-floor scenarios that surfaced bugs / workflow holes. Every scenario has: Persona-driven shop-floor scenarios that surfaced bugs / workflow holes. Every scenario has:

View File

@@ -27,7 +27,32 @@ def post_init_hook(env):
_seed_default_timezone(env) _seed_default_timezone(env)
_backfill_node_input_kind(env) _backfill_node_input_kind(env)
_seed_step_library_if_empty(env) _seed_step_library_if_empty(env)
_backfill_contract_review_template(env)
_seed_rack_tags_if_empty(env) _seed_rack_tags_if_empty(env)
_migrate_legacy_uom_columns(env)
def _backfill_contract_review_template(env):
"""Idempotent — ensure the Contract Review library template exists.
`_seed_step_library_if_empty` only fires on a fresh DB; existing DBs
upgraded from pre-Policy-B versions still have a populated library
minus the Contract Review entry. This function fills that hole.
Re-running it is a no-op once the template exists.
"""
Tpl = env['fp.step.template']
if Tpl.search([('default_kind', '=', 'contract_review')], limit=1):
return # already there
tpl = Tpl.create({
'name': 'Contract Review',
'default_kind': 'contract_review',
})
tpl.action_seed_default_inputs()
_logger.info(
"Fusion Plating: backfilled Contract Review library template "
"(id=%s, %s default inputs).",
tpl.id, len(tpl.input_template_ids),
)
def _seed_default_timezone(env): def _seed_default_timezone(env):
@@ -60,6 +85,12 @@ 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.
# When an author drops this template into a recipe, fp.job.step.button_*
# hooks in fusion_plating_jobs detect the kind=='contract_review' and
# auto-open / gate the QA-005 audit form (fp.contract.review).
'contract review': 'contract_review',
'qa-005': 'contract_review',
'soak clean': 'cleaning', 'soak clean': 'cleaning',
'electroclean': 'cleaning', 'electroclean': 'cleaning',
'solvent clean': 'cleaning', 'solvent clean': 'cleaning',
@@ -73,23 +104,85 @@ _STARTER_KIND_BY_NAME = {
'zincate': 'etch', 'zincate': 'etch',
'strip zincate': 'etch', 'strip zincate': 'etch',
'acid dip': 'etch', 'acid dip': 'etch',
'hcl activation': 'etch',
'water break test': 'wbf_test', 'water break test': 'wbf_test',
'water break free test': 'wbf_test',
'issue panels': 'mask', 'issue panels': 'mask',
'masking': 'mask',
'mask': 'mask',
'racking': 'racking', 'racking': 'racking',
'rack': 'racking', 'rack': 'racking',
'e-nickel plate': 'plate', 'e-nickel plate': 'plate',
'e-nickel plating': 'plate',
'electroless nickel plate': 'plate', 'electroless nickel plate': 'plate',
'electroless nickel plating': 'plate', 'electroless nickel plating': 'plate',
'enp': 'plate',
'plate': 'plate',
'plating': 'plate',
'drying': 'dry', 'drying': 'dry',
'dry': 'dry', 'dry': 'dry',
'bake': 'bake',
'oven baking': 'bake',
'oven bake': 'bake',
'baking': 'bake',
'hydrogen embrittlement bake': 'bake',
'he bake': 'bake',
'de-rack': 'derack', 'de-rack': 'derack',
'de-racking': 'derack', 'de-racking': 'derack',
'deracking': 'derack',
'derack': 'derack',
'demask': 'demask',
'de-mask': 'demask',
'de-masking': 'demask',
'demasking': 'demask',
'inspection': 'inspect', 'inspection': 'inspect',
'incoming inspection': 'inspect',
'post-plate inspection': 'inspect',
'post plate inspection': 'inspect',
'visual inspection': 'inspect',
'porosity test': 'inspect',
'adhesion test': 'inspect',
'final inspection': 'final_inspect', 'final inspection': 'final_inspect',
'final inspection / packaging': 'final_inspect',
'shipping': 'ship', 'shipping': 'ship',
'pack': 'ship',
'packaging': 'ship',
# Gating steps (Steelhead-style "Ready for X" intermediate states).
'ready for incoming inspection': 'gating',
'ready for plating': 'gating',
'ready for racking': 'gating',
'ready for de-masking': 'gating',
'ready for demasking': 'gating',
'ready for masking': 'gating',
'ready for bake': 'gating',
'ready for deracking': 'gating',
'ready for de-racking': 'gating',
'ready for post plate inspection': 'gating',
'ready for post-plate inspection': 'gating',
'ready for final inspection': 'gating',
'ready for shipping': 'gating',
} }
def fp_resolve_step_kind(name):
"""Resolve a step name to a default_kind, tolerant of whitespace and
case. Used by both the seeder and the migration backfill so we don't
have two slightly-different lookup paths.
Returns the kind str or None when no match.
"""
if not name:
return None
key = name.strip().lower()
if key in _STARTER_KIND_BY_NAME:
return _STARTER_KIND_BY_NAME[key]
# Gating "Ready for / Ready For" prefix — anything starting with that
# is a gating node regardless of the destination step name.
if key.startswith('ready for ') or key.startswith('ready '):
return 'gating'
return None
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.
@@ -135,7 +228,7 @@ def _create_template_from_node(env, node, seen):
return return
seen.add(node.name.lower()) seen.add(node.name.lower())
kind = _STARTER_KIND_BY_NAME.get(node.name.lower()) kind = fp_resolve_step_kind(node.name)
vals = { vals = {
'name': node.name, 'name': node.name,
'description': node.description or False, 'description': node.description or False,
@@ -164,6 +257,7 @@ def _seed_minimal_library(env):
"""Hard-coded minimal seed when ENP-ALUM-BASIC isn't on the target DB.""" """Hard-coded minimal seed when ENP-ALUM-BASIC isn't on the target DB."""
Tpl = env['fp.step.template'] Tpl = env['fp.step.template']
minimal = [ minimal = [
('Contract Review', 'contract_review'),
('Soak Clean', 'cleaning'), ('Soak Clean', 'cleaning'),
('Electroclean', 'cleaning'), ('Electroclean', 'cleaning'),
('Rinse', 'rinse'), ('Rinse', 'rinse'),
@@ -189,6 +283,38 @@ def _seed_minimal_library(env):
) )
def _migrate_legacy_uom_columns(env):
"""Translate every free-text UoM column in the plating suite into the
new curated Selection keys.
Runs unconditionally on every fusion_plating upgrade so the day a
downstream module's migration converts a Char to Selection, the data
follows. Each call is a no-op when:
* the column already holds selection keys (identity mapping)
* the table doesn't exist (module not installed on this DB)
"""
from .models._fp_uom_selection import fp_migrate_uom_column
targets = [
# core
('fusion_plating_bath_parameter', 'uom', 'bath parameter'),
('fusion_plating_process_node_input', 'uom', 'process node input'),
('fusion_plating_process_node_input', 'target_unit', 'process node target'),
('fp_step_template_input', 'target_unit', 'step template input target'),
# compliance
('fusion_plating_discharge_limit', 'uom', 'discharge limit'),
('fusion_plating_discharge_sample_line', 'uom', 'discharge sample line'),
('fusion_plating_waste_manifest', 'uom', 'waste manifest'),
('fusion_plating_waste_stream', 'generation_uom', 'waste stream'),
('fusion_plating_spill_register', 'uom', 'spill register'),
# safety
('fusion_plating_chemical', 'container_uom', 'chemical container'),
('fusion_plating_exposure_monitoring', 'uom', 'exposure monitoring'),
]
for table, column, label in targets:
fp_migrate_uom_column(env, table, column, label)
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']

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating', 'name': 'Fusion Plating',
'version': '19.0.11.3.0', 'version': '19.0.12.5.0',
'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': """

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""19.0.12.1.0 — Convert every free-text UoM column to the curated
selection keys defined in models/_fp_uom_selection.py.
Runs after fusion_plating's tables have been re-described (so the
columns are now Selection-typed at the ORM level), but before users
hit the new views. Idempotent — re-running maps already-converted
values to themselves and leaves them in place.
"""
import logging
from odoo.api import Environment
from odoo.addons.fusion_plating.models._fp_uom_selection import (
fp_migrate_uom_column,
)
_logger = logging.getLogger(__name__)
def migrate(cr, version):
env = Environment(cr, 1, {}) # SUPERUSER
targets = [
# core
('fusion_plating_bath_parameter', 'uom', 'bath parameter'),
('fusion_plating_process_node_input', 'uom', 'process node input'),
('fusion_plating_process_node_input', 'target_unit', 'process node target'),
('fp_step_template_input', 'target_unit', 'step template input target'),
# compliance (only migrated when the module is installed — the
# helper is no-op when the table doesn't exist)
('fusion_plating_discharge_limit', 'uom', 'discharge limit'),
('fusion_plating_discharge_sample_line', 'uom', 'discharge sample line'),
('fusion_plating_waste_manifest', 'uom', 'waste manifest'),
('fusion_plating_waste_stream', 'generation_uom', 'waste stream'),
('fusion_plating_spill_register', 'uom', 'spill register'),
# safety
('fusion_plating_chemical', 'container_uom', 'chemical container'),
('fusion_plating_exposure_monitoring', 'uom', 'exposure monitoring'),
]
total_rewritten = total_cleared = 0
for table, column, label in targets:
rewritten, cleared = fp_migrate_uom_column(env, table, column, label)
total_rewritten += rewritten
total_cleared += cleared
_logger.info(
'Fusion Plating 19.0.12.1.0 — UoM migration complete: '
'%s rewritten, %s cleared (across %s columns).',
total_rewritten, total_cleared, len(targets),
)

View File

@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""19.0.12.4.0 — Step-library polish + Policy B Contract Review backfill.
post_init_hook only fires on fresh install. Existing DBs upgrading
from pre-Policy-B versions need this migration to:
1. Add the missing 'Contract Review' library template (the
_seed_step_library_if_empty seeder skipped it because their
library was already populated when 19.0.12.3.0 landed).
2. Backfill default_kind on existing library entries that landed
without a kind because the original seeder used a brittle
case-sensitive lookup that missed common name variations
("E-Nickel Plating" vs "E-Nickel Plate", "DeRacking" vs
"De-Racking", "Ready for X" gating prefixes, etc.). The new
`fp_resolve_step_kind` helper is hyphen / case / -ing tolerant.
3. Add canonical missing entries (Soak Clean, Rinse, Etch, Acid Dip,
Drying, Inspection, Shipping, Water Break Test, Desmut, Zincate)
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
from scratch. Without them, an empty recipe has no obvious starting
templates for cleaning / rinsing / standard inspection.
All three steps are idempotent — re-running on an already-fixed DB
is a no-op.
"""
import logging
from odoo.api import Environment
from odoo.addons.fusion_plating import fp_resolve_step_kind
_logger = logging.getLogger(__name__)
CANONICAL_MISSING = [
('Soak Clean', 'cleaning'),
('Electroclean', 'cleaning'),
('Rinse', 'rinse'),
('Etch', 'etch'),
('Desmut', 'etch'),
('Zincate', 'etch'),
('Acid Dip', 'etch'),
('HCl Activation', 'etch'),
('Water Break Test', 'wbf_test'),
('Drying', 'dry'),
('Inspection', 'inspect'),
('Final Inspection', 'final_inspect'),
('Shipping', 'ship'),
('Contract Review', 'contract_review'),
]
def migrate(cr, version):
env = Environment(cr, 1, {}) # SUPERUSER
Tpl = env['fp.step.template']
# ---- 1. Backfill default_kind on existing library entries -----------
blank_kind = Tpl.search([('default_kind', '=', False)])
fixed = 0
for tpl in blank_kind:
kind = fp_resolve_step_kind(tpl.name)
if kind:
tpl.default_kind = kind
tpl.action_seed_default_inputs()
fixed += 1
_logger.info(
'Fusion Plating 19.0.12.4.0: backfilled default_kind on %s/%s '
'library entries via fp_resolve_step_kind.',
fixed, len(blank_kind),
)
# ---- 2. Add canonical missing entries -------------------------------
existing_names_lower = {
(n.strip().lower()) for n in Tpl.search([]).mapped('name') if n
}
added = 0
for name, kind in CANONICAL_MISSING:
if name.lower() in existing_names_lower:
continue
tpl = Tpl.create({
'name': name,
'default_kind': kind,
})
tpl.action_seed_default_inputs()
added += 1
_logger.info(
'Fusion Plating 19.0.12.4.0: added %s canonical missing library '
'entries (Soak Clean, Rinse, Etch, etc.).', added,
)

View File

@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""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
have NULL `default_kind` because the field was added later. The
recipe-side soft-gates (Sub 8 racking, Policy B contract review) fall
back to name-matching when the kind is missing, which means a
renamed step ("Hang on Bar" instead of "Racking") silently bypasses
the gate.
This migration walks `fusion.plating.process.node` rows with NULL
default_kind, resolves a sensible kind via the central
`fp_resolve_step_kind()` helper, and sets it.
It also walks `fp.job.step` rows whose `kind` is the legacy 'other'
placeholder and re-derives `kind` from `recipe_node_id.default_kind`
(after the node-side backfill above sets it). Non-other kinds are
left alone — operator may have set them deliberately.
Idempotent.
"""
import logging
from odoo.api import Environment
from odoo.addons.fusion_plating import fp_resolve_step_kind
_logger = logging.getLogger(__name__)
# Same mapping as in fp_job.py — keep them in sync.
_NODE_KIND_TO_STEP_KIND = {
'cleaning': 'wet',
'etch': 'wet',
'rinse': 'wet',
'plate': 'wet',
'dry': 'wet',
'wbf_test': 'wet',
'bake': 'bake',
'mask': 'mask',
'demask': 'mask',
'racking': 'rack',
'derack': 'rack',
'inspect': 'inspect',
'final_inspect': 'inspect',
'contract_review': 'other',
'gating': 'other',
'ship': 'other',
}
def migrate(cr, version):
env = Environment(cr, 1, {})
# ---- 1. Backfill default_kind on recipe nodes -----------------------
Node = env['fusion.plating.process.node']
blank = Node.search([
('default_kind', '=', False),
('node_type', 'in', ('operation', 'step')),
])
fixed = 0
for n in blank:
kind = fp_resolve_step_kind(n.name)
if kind:
n.default_kind = kind
fixed += 1
_logger.info(
'19.0.12.5.0: backfilled default_kind on %s/%s recipe nodes via '
'fp_resolve_step_kind.', fixed, len(blank),
)
# ---- 2. Re-derive fp.job.step.kind from recipe node default_kind ----
Step = env['fp.job.step']
other_steps = Step.search([
('kind', '=', 'other'),
('recipe_node_id', '!=', False),
('state', 'not in', ('done', 'cancelled')),
])
rederived = 0
for s in other_steps:
node_kind = (
s.recipe_node_id.default_kind
if 'default_kind' in s.recipe_node_id._fields else None
)
new_kind = _NODE_KIND_TO_STEP_KIND.get(node_kind) if node_kind else None
if new_kind and new_kind != 'other':
s.kind = new_kind
rederived += 1
_logger.info(
'19.0.12.5.0: re-derived kind on %s/%s in-flight job steps from '
'recipe node default_kind.', rederived, len(other_steps),
)

View File

@@ -8,7 +8,9 @@ from . import fp_process_type
from . import fp_facility from . import fp_facility
from . import fp_work_center from . import fp_work_center
from . import fp_work_centre from . import fp_work_centre
from . import fp_tank_section
from . import fp_tank from . import fp_tank
from . import fp_tank_composition
from . import fp_bath from . import fp_bath
from . import fp_bath_log from . import fp_bath_log
from . import fp_bath_log_line from . import fp_bath_log_line

View File

@@ -0,0 +1,418 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Shared Unit-of-Measure selection list for plating chemistry, physical
quantities, and process inputs.
Free-text unit fields invite typos ("kgs", "Kg", "kilo", "KG") that
break filters, reports, and trend graphs. Every UoM in the plating
domain — chemistry, mass, volume, length, area, electrical, time,
pressure, dimensionless — lives here as a curated selection so users
pick from a known list instead of typing.
Re-use:
from .._fp_uom_selection import FP_UOM_SELECTION, FP_UOM_LEGACY_MAP
uom = fields.Selection(FP_UOM_SELECTION, string='Unit')
Migration:
Use FP_UOM_LEGACY_MAP to translate pre-existing free-text values
into selection keys during post_init / migration. Anything not in
the map gets cleared (NULL) so the user is forced to pick.
"""
# Single source of truth — keep alphabetised within each section.
FP_UOM_SELECTION = [
# --- Concentration / chemistry ---------------------------------------
('g_l', 'g/L'),
('mg_l', 'mg/L'),
('ug_l', 'µg/L'),
('kg_l', 'kg/L'),
('oz_gal', 'oz/gal (US)'),
('oz_gal_imp', 'oz/Imp gal'),
('ml_l', 'mL/L'),
('mol_l', 'mol/L'),
('n', 'N (Normality)'),
('ppm', 'ppm'),
('ppb', 'ppb'),
('pct', '%'),
('pct_w', '% (w/w)'),
('pct_v', '% (v/v)'),
('pct_vw', '% (v/w)'),
# --- Temperature -----------------------------------------------------
('c', '°C'),
('f', '°F'),
('k', 'K'),
# --- Dimensionless / pH / specific units -----------------------------
('ph', 'pH'),
('su', 'SU (Standard Units)'),
('ratio', 'Ratio (e.g. 5:1)'),
('none', '— (none)'),
# --- Conductivity / turbidity ----------------------------------------
('us_cm', 'µS/cm'),
('ms_cm', 'mS/cm'),
('ntu', 'NTU'),
# --- Time ------------------------------------------------------------
('s', 's (seconds)'),
('min', 'min'),
('h', 'h'),
('day', 'day'),
# --- Mass ------------------------------------------------------------
('mg', 'mg'),
('g', 'g'),
('kg', 'kg'),
('t', 't (tonne)'),
('oz', 'oz'),
('lb', 'lb'),
# --- Volume ----------------------------------------------------------
('ml', 'mL'),
('l', 'L'),
('m3', ''),
('gal_us', 'US gal'),
('gal_imp', 'Imp gal'),
('ft3', 'ft³'),
# --- Length / thickness ----------------------------------------------
('nm', 'nm'),
('um', 'µm'),
('mm', 'mm'),
('cm', 'cm'),
('m', 'm'),
('mil', 'mil (0.001 in)'),
('in', 'in'),
('ft', 'ft'),
# --- Area ------------------------------------------------------------
('cm2', 'cm²'),
('m2', ''),
('in2', 'in²'),
('ft2', 'ft²'),
('dm2', 'dm²'),
# --- Electrical / current density ------------------------------------
('a', 'A'),
('ma', 'mA'),
('v', 'V'),
('asd_a_dm2', 'A/dm² (ASD)'),
('asd_a_ft2', 'A/ft² (ASF)'),
('dm2_l', 'dm²/L (load)'),
# --- Pressure --------------------------------------------------------
('pa', 'Pa'),
('kpa', 'kPa'),
('bar', 'bar'),
('psi', 'psi'),
('mmhg', 'mmHg'),
# --- Rate / flow / generation ----------------------------------------
('kg_day', 'kg/day'),
('l_day', 'L/day'),
('kg_month', 'kg/month'),
('l_min', 'L/min'),
('gpm', 'gpm'),
('cfm', 'cfm'),
# --- Exposure / occupational hygiene ---------------------------------
('mg_m3', 'mg/m³'),
('ug_m3', 'µg/m³'),
('dba', 'dBA'),
('lux', 'lux'),
# --- Plating-specific counts -----------------------------------------
('mto', 'MTO (metal turnover)'),
('cycles', 'cycles'),
('count', 'count'),
('each', 'each'),
('rpm', 'rpm'),
]
# Map free-text values produced before this list existed → selection keys.
# Keep keys lower-cased + stripped during lookup.
FP_UOM_LEGACY_MAP = {
# Concentration
'g/l': 'g_l',
'gpl': 'g_l',
'grams/l': 'g_l',
'g per l': 'g_l',
'mg/l': 'mg_l',
'ug/l': 'ug_l',
'µg/l': 'ug_l',
'kg/l': 'kg_l',
'oz/gal': 'oz_gal',
'oz/g': 'oz_gal',
'oz/gallon': 'oz_gal',
'oz/imp gal': 'oz_gal_imp',
'ml/l': 'ml_l',
'mol/l': 'mol_l',
'molar': 'mol_l',
'm': 'mol_l',
'n': 'n',
'normal': 'n',
'normality': 'n',
'ppm': 'ppm',
'ppb': 'ppb',
'%': 'pct',
'percent': 'pct',
'pct': 'pct',
'% w/w': 'pct_w',
'%(w/w)': 'pct_w',
'%w/w': 'pct_w',
'% v/v': 'pct_v',
'%v/v': 'pct_v',
'% v/w': 'pct_vw',
# Temperature
'c': 'c',
'°c': 'c',
'celsius': 'c',
'deg c': 'c',
'degc': 'c',
'f': 'f',
'°f': 'f',
'fahrenheit': 'f',
'deg f': 'f',
'degf': 'f',
'k': 'k',
'kelvin': 'k',
# Dimensionless
'ph': 'ph',
'su': 'su',
'standard units': 'su',
'ratio': 'ratio',
'-': 'none',
'none': 'none',
# Conductivity / turbidity
'us/cm': 'us_cm',
'µs/cm': 'us_cm',
'ms/cm': 'ms_cm',
'ntu': 'ntu',
# Time
'second': 's',
'seconds': 's',
'sec': 's',
'secs': 's',
's': 's',
'minute': 'min',
'minutes': 'min',
'min': 'min',
'mins': 'min',
'hour': 'h',
'hours': 'h',
'hr': 'h',
'hrs': 'h',
'h': 'h',
'day': 'day',
'days': 'day',
'd': 'day',
# Mass
'mg': 'mg',
'g': 'g',
'gr': 'g',
'gram': 'g',
'grams': 'g',
'kg': 'kg',
'kgs': 'kg',
'kilogram': 'kg',
'kilograms': 'kg',
't': 't',
'tonne': 't',
'tonnes': 't',
'metric ton': 't',
'oz': 'oz',
'ounce': 'oz',
'ounces': 'oz',
'lb': 'lb',
'lbs': 'lb',
'pound': 'lb',
'pounds': 'lb',
# Volume
'ml': 'ml',
'l': 'l',
'liter': 'l',
'liters': 'l',
'litre': 'l',
'litres': 'l',
'm3': 'm3',
'': 'm3',
'cubic meter': 'm3',
'gal': 'gal_us',
'gal_us': 'gal_us',
'us gal': 'gal_us',
'gallon': 'gal_us',
'gallons': 'gal_us',
'imp gal': 'gal_imp',
'imperial gallon': 'gal_imp',
'ft3': 'ft3',
'ft³': 'ft3',
'cubic feet': 'ft3',
'cu ft': 'ft3',
# Length
'nm': 'nm',
'um': 'um',
'µm': 'um',
'micron': 'um',
'mm': 'mm',
'cm': 'cm',
'mil': 'mil',
'in': 'in',
'inch': 'in',
'inches': 'in',
'"': 'in',
'ft': 'ft',
'feet': 'ft',
'foot': 'ft',
# Area
'cm2': 'cm2',
'cm²': 'cm2',
'm2': 'm2',
'': 'm2',
'in2': 'in2',
'in²': 'in2',
'sq in': 'in2',
'ft2': 'ft2',
'ft²': 'ft2',
'sq ft': 'ft2',
'dm2': 'dm2',
'dm²': 'dm2',
# Electrical
'a': 'a',
'amp': 'a',
'amps': 'a',
'ampere': 'a',
'amperes': 'a',
'ma': 'ma',
'milliamp': 'ma',
'milliamps': 'ma',
'v': 'v',
'volt': 'v',
'volts': 'v',
'a/dm2': 'asd_a_dm2',
'a/dm²': 'asd_a_dm2',
'asd': 'asd_a_dm2',
'a/ft2': 'asd_a_ft2',
'a/ft²': 'asd_a_ft2',
'asf': 'asd_a_ft2',
'dm2/l': 'dm2_l',
'dm²/l': 'dm2_l',
# Pressure
'pa': 'pa',
'kpa': 'kpa',
'bar': 'bar',
'psi': 'psi',
'mmhg': 'mmhg',
# Rate
'kg/day': 'kg_day',
'l/day': 'l_day',
'kg/month': 'kg_month',
'l/min': 'l_min',
'lpm': 'l_min',
'gpm': 'gpm',
'cfm': 'cfm',
# Exposure
'mg/m3': 'mg_m3',
'mg/m³': 'mg_m3',
'ug/m3': 'ug_m3',
'µg/m³': 'ug_m3',
'dba': 'dba',
'db': 'dba',
'lux': 'lux',
# Plating counts
'mto': 'mto',
'cycle': 'cycles',
'cycles': 'cycles',
'count': 'count',
'each': 'each',
'ea': 'each',
'pcs': 'each',
'pieces': 'each',
'rpm': 'rpm',
}
def fp_normalize_legacy_uom(raw_value):
"""Translate a legacy free-text UoM string to a selection key.
Returns the selection key, or None if no match (caller decides whether
to NULL the column or leave it).
"""
if raw_value is None:
return None
key = (raw_value or '').strip().lower()
if not key:
return None
return FP_UOM_LEGACY_MAP.get(key)
def fp_migrate_uom_column(env, table, column, label_for_log=None):
"""Walk a table's free-text uom column and rewrite values into the
selection keys. Unmapped values are set to NULL so the user is forced
to pick a valid one.
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
of FP_UOM_LEGACY_MAP via identity mappings like 'g_l''g_l').
Args:
env: Odoo environment.
table: SQL table name (e.g. 'fusion_plating_bath_parameter').
column: SQL column name (e.g. 'uom').
label_for_log: human-readable name for the migration log line.
"""
cr = env.cr
cr.execute(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = %s AND column_name = %s",
(table, column),
)
if not cr.fetchone():
return 0, 0 # table/column not present (module not installed)
cr.execute(f'SELECT id, "{column}" FROM "{table}" WHERE "{column}" IS NOT NULL')
rows = cr.fetchall()
valid_keys = {k for k, _ in FP_UOM_SELECTION}
cleared = 0
rewritten = 0
for row_id, raw in rows:
if raw in valid_keys:
continue # already a selection key
new_key = fp_normalize_legacy_uom(raw)
if new_key:
cr.execute(
f'UPDATE "{table}" SET "{column}" = %s WHERE id = %s',
(new_key, row_id),
)
rewritten += 1
else:
cr.execute(
f'UPDATE "{table}" SET "{column}" = NULL WHERE id = %s',
(row_id,),
)
cleared += 1
import logging
_logger = logging.getLogger(__name__)
_logger.info(
'Fusion Plating UoM migration — %s.%s%s: %s rewritten, %s cleared',
table, column, f' ({label_for_log})' if label_for_log else '',
rewritten, cleared,
)
return rewritten, cleared

View File

@@ -256,8 +256,9 @@ class FpBathTarget(models.Model):
target_min = fields.Float(string='Min') target_min = fields.Float(string='Min')
target_max = fields.Float(string='Max') target_max = fields.Float(string='Max')
uom = fields.Char( uom = fields.Char(
related='parameter_id.uom', related='parameter_id.uom_display',
readonly=True, readonly=True,
string='Unit',
) )
_sql_constraints = [ _sql_constraints = [

View File

@@ -47,8 +47,9 @@ class FpBathLogLine(models.Model):
readonly=True, readonly=True,
) )
uom = fields.Char( uom = fields.Char(
related='parameter_id.uom', related='parameter_id.uom_display',
readonly=True, readonly=True,
string='Unit',
) )
value = fields.Float( value = fields.Float(
string='Value', string='Value',
@@ -79,6 +80,28 @@ class FpBathLogLine(models.Model):
) )
# ========================================================================== # ==========================================================================
@api.onchange('parameter_id')
def _onchange_parameter_prefill_value(self):
"""Pre-fill `value` from the tank's setpoint when the parameter is a
temperature reading.
This means the operator (or backend user) hits "add reading", picks
Temperature, and the tank's `default_temperature` lands in the value
column automatically — they confirm with one tap or nudge with
keyboard arrows. Avoids retyping the same number every shift.
Fires only when value is currently empty so the user's edits aren't
clobbered if they go back and pick a different parameter.
"""
for rec in self:
if not rec.parameter_id or rec.value:
continue
if rec.parameter_id.parameter_type != 'temperature':
continue
tank = rec.log_id.bath_id.tank_id
if tank and tank.default_temperature:
rec.value = tank.default_temperature
@api.depends('parameter_id', 'log_id.bath_id') @api.depends('parameter_id', 'log_id.bath_id')
def _compute_targets(self): def _compute_targets(self):
"""Resolve target range: per-bath override first, parameter default second.""" """Resolve target range: per-bath override first, parameter default second."""

View File

@@ -3,7 +3,9 @@
# 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.
from odoo import fields, models from odoo import api, fields, models
from ._fp_uom_selection import FP_UOM_SELECTION
class FpBathParameter(models.Model): class FpBathParameter(models.Model):
@@ -49,23 +51,37 @@ class FpBathParameter(models.Model):
required=True, required=True,
default='concentration', default='concentration',
) )
uom = fields.Char( uom = fields.Selection(
FP_UOM_SELECTION,
string='Unit', string='Unit',
help='Display unit (e.g. "g/L", "°C", "pH", "MTO").', help='Pick the unit this parameter is measured in. Drives the unit '
'shown on every reading, target, and replenishment suggestion '
'derived from this parameter.',
)
uom_display = fields.Char(
string='Unit (display)',
compute='_compute_uom_display',
help='Resolved display string for the chosen unit '
'(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',
help='Default target minimum. Per-bath overrides are allowed.', help='Smallest acceptable reading, expressed in the unit selected '
'above. Anything below this is flagged Out of Spec. '
'Per-bath overrides allowed.',
) )
target_max = fields.Float( target_max = fields.Float(
string='Default Target Max', string='Default Target Max',
help='Default target maximum. Per-bath overrides are allowed.', help='Largest acceptable reading, expressed in the unit selected '
'above. Anything above this is flagged Out of Spec. '
'Per-bath overrides allowed.',
) )
target_value = fields.Float( target_value = fields.Float(
string='Default Setpoint / Optimum', string='Default Setpoint / Optimum',
help='The IDEAL operating value — what the heater/chiller controls ' help='The IDEAL operating value, expressed in the unit selected '
'toward, what dashboards compare against. Sits between ' 'above — what the heater/chiller controls toward, what '
'target_min and target_max. Per-sensor override via ' 'dashboards compare against. Sits between Target Min and '
'Target Max. Per-sensor override via '
'fp.tank.sensor.target_value_override.', 'fp.tank.sensor.target_value_override.',
) )
warning_tolerance = fields.Float( warning_tolerance = fields.Float(
@@ -86,6 +102,12 @@ class FpBathParameter(models.Model):
default=True, default=True,
) )
@api.depends('uom')
def _compute_uom_display(self):
labels = dict(FP_UOM_SELECTION)
for rec in self:
rec.uom_display = labels.get(rec.uom, '') if rec.uom else ''
_sql_constraints = [ _sql_constraints = [
( (
'fp_bath_parameter_code_uniq', 'fp_bath_parameter_code_uniq',

View File

@@ -215,23 +215,64 @@ class FpJobStep(models.Model):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def button_pause(self): def button_pause(self):
raise NotImplementedError(_( """Operator pause / break / end-of-shift. Closes the open timelog
"button_pause is not yet implemented (operator pause / break / " without finishing the step, flips state to 'paused'. button_start
"end-of-shift). Use button_finish to complete a step or set " will reopen a fresh timelog when resuming.
"state directly via privileged code." """
)) for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can pause."
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
step.state = 'paused'
step.message_post(body=_('Step paused by %s') % self.env.user.name)
return True
def button_resume(self):
"""Resume a paused step — thin alias over button_start so views
can show distinct labels (Resume vs Start) without duplicating
the state-machine logic."""
for step in self:
if step.state != 'paused':
raise UserError(_(
"Step '%s' is in state '%s' — only paused steps can resume."
) % (step.name, step.state))
return self.button_start()
def button_skip(self): def button_skip(self):
raise NotImplementedError(_( """Skip an opt-in step that wasn't activated for this job. Allowed
"button_skip is not yet implemented (skip an opt-in step that " from pending or ready only — a step that's already running shouldn't
"wasn't activated for this job)." be skipped without an audit narrative (use button_cancel for that).
)) """
for step in self:
if step.state not in ('pending', 'ready'):
raise UserError(_(
"Step '%s' is in state '%s' — only pending/ready steps can skip."
) % (step.name, step.state))
step.state = 'skipped'
step.message_post(body=_('Step skipped by %s') % self.env.user.name)
return True
def button_cancel(self): def button_cancel(self):
raise NotImplementedError(_( """Cancel a single step. Used when an operator realises mid-stream
"button_cancel is not yet implemented (cancelling a single step; " that a step doesn't apply to this job (e.g. a customer-specific
"cancelling the whole job runs through fp.job.action_cancel)." step that's not needed). Closes any open timelog so labour cost
)) already incurred is preserved.
"""
for step in self:
if step.state in ('done', 'cancelled'):
raise UserError(_(
"Step '%s' is in state '%s' — cannot cancel."
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
step.state = 'cancelled'
step.message_post(body=_('Step cancelled by %s') % self.env.user.name)
return True
def button_start(self): def button_start(self):
for step in self: for step in self:

View File

@@ -7,6 +7,7 @@ from odoo import api, fields, models, _
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from .fp_tz import fp_isoformat_utc from .fp_tz import fp_isoformat_utc
from ._fp_uom_selection import FP_UOM_SELECTION
class FpProcessNode(models.Model): class FpProcessNode(models.Model):
@@ -352,6 +353,7 @@ class FpProcessNode(models.Model):
('final_inspect', 'Final Inspection'), ('final_inspect', 'Final Inspection'),
('ship', 'Shipping'), ('ship', 'Shipping'),
('gating', 'Gating'), ('gating', 'Gating'),
('contract_review', 'Contract Review (QA-005)'),
], ],
string='Step Kind', string='Step Kind',
) )
@@ -652,9 +654,11 @@ class FpProcessNodeInput(models.Model):
string='Sequence', string='Sequence',
default=10, default=10,
) )
uom = fields.Char( uom = fields.Selection(
FP_UOM_SELECTION,
string='Unit', string='Unit',
help='Unit label (e.g. °C, min, psi).', help='Unit the operator is recording in (pick from the curated list — '
'avoids "kg" vs "kgs" vs "kilo" inconsistencies).',
) )
# ===== Sub 12a — kind + target ranges + compliance tag ================== # ===== Sub 12a — kind + target ranges + compliance tag ==================
@@ -668,9 +672,19 @@ class FpProcessNodeInput(models.Model):
'recorded when leaving the step (Sub 12b uses these in the ' 'recorded when leaving the step (Sub 12b uses these in the '
'Move Parts dialog).', 'Move Parts dialog).',
) )
target_min = fields.Float(string='Target Min') target_min = fields.Float(
target_max = fields.Float(string='Target Max') string='Target Min',
target_unit = fields.Char(string='Target Unit') help='Lower bound of the acceptable range, expressed in Target Unit.',
)
target_max = fields.Float(
string='Target Max',
help='Upper bound of the acceptable range, expressed in Target Unit.',
)
target_unit = fields.Selection(
FP_UOM_SELECTION,
string='Target Unit',
help='Unit Target Min / Target Max are measured in.',
)
compliance_tag = fields.Selection( compliance_tag = fields.Selection(
[ [
('none', 'None'), ('none', 'None'),

View File

@@ -90,6 +90,7 @@ class FpStepTemplate(models.Model):
('final_inspect', 'Final Inspection'), ('final_inspect', 'Final Inspection'),
('ship', 'Shipping'), ('ship', 'Shipping'),
('gating', 'Gating'), ('gating', 'Gating'),
('contract_review', 'Contract Review (QA-005)'),
], string='Step Kind', help='Drives sane-default input seeding.') ], string='Step Kind', help='Drives sane-default input seeding.')
input_template_ids = fields.One2many( input_template_ids = fields.One2many(
@@ -130,35 +131,39 @@ class FpStepTemplate(models.Model):
# ----- Sane defaults seeding --------------------------------------------- # ----- Sane defaults seeding ---------------------------------------------
# NB target_unit must be a valid FP_UOM_SELECTION key — it became a
# Selection in 19.0.12.1.0 (uom cleanup). Free-text values like
# 'HH:MM', '°F', 'sec', 'in', 'each' raise ValueError on create.
# Mapping cheatsheet: sec → 's', °F → 'f', °C → 'c', in → 'in',
# each → 'each', min → 'min'. Format-only strings ('HH:MM') get
# left blank since they're not units.
DEFAULT_INPUTS_BY_KIND = { DEFAULT_INPUTS_BY_KIND = {
'cleaning': [ 'cleaning': [
{'name': 'Actual Time', 'input_type': 'time_seconds', {'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 'sec', 'sequence': 10}, 'target_unit': 's', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature', {'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': '°F', 'sequence': 20}, 'target_unit': 'f', 'sequence': 20},
], ],
'etch': [ 'etch': [
{'name': 'Actual Time', 'input_type': 'time_seconds', {'name': 'Actual Time', 'input_type': 'time_seconds',
'target_unit': 'sec', 'sequence': 10}, 'target_unit': 's', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature', {'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': '°F', 'sequence': 20}, 'target_unit': 'f', 'sequence': 20},
], ],
'rinse': [], 'rinse': [],
'plate': [ 'plate': [
{'name': 'Actual Time', 'input_type': 'time_hms', {'name': 'Actual Time', 'input_type': 'time_hms',
'target_unit': 'min', 'sequence': 10}, 'target_unit': 'min', 'sequence': 10},
{'name': 'Actual Temperature', 'input_type': 'temperature', {'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': '°F', 'sequence': 20}, 'target_unit': 'f', 'sequence': 20},
{'name': 'Plating Thickness', 'input_type': 'thickness', {'name': 'Plating Thickness', 'input_type': 'thickness',
'target_unit': 'in', 'sequence': 30}, 'target_unit': 'in', 'sequence': 30},
], ],
'bake': [ 'bake': [
{'name': 'Time In', 'input_type': 'text', {'name': 'Time In', 'input_type': 'text', 'sequence': 10},
'target_unit': 'HH:MM', 'sequence': 10}, {'name': 'Time Out', 'input_type': 'text', 'sequence': 20},
{'name': 'Time Out', 'input_type': 'text',
'target_unit': 'HH:MM', 'sequence': 20},
{'name': 'Actual Temperature', 'input_type': 'temperature', {'name': 'Actual Temperature', 'input_type': 'temperature',
'target_unit': '°F', 'sequence': 30}, 'target_unit': 'f', 'sequence': 30},
], ],
'racking': [ 'racking': [
{'name': 'Actual Qty', 'input_type': 'number', {'name': 'Actual Qty', 'input_type': 'number',
@@ -196,6 +201,15 @@ class FpStepTemplate(models.Model):
'target_unit': 'each', 'sequence': 10}, 'target_unit': 'each', 'sequence': 10},
], ],
'gating': [], 'gating': [],
# Sub 4 + 12c follow-up — Contract Review step (Policy B).
# The shop-floor step itself is a tickbox; the heavy QA-005 form
# is opened via fp.contract.review (separate model). These
# inputs capture summary fields for the chronological CoC.
'contract_review': [
{'name': 'Reviewer Initials', 'input_type': 'text', 'sequence': 10},
{'name': 'Date Reviewed', 'input_type': 'date', 'sequence': 20},
{'name': 'QA-005 Approved', 'input_type': 'pass_fail', 'sequence': 30},
],
} }
def action_seed_default_inputs(self): def action_seed_default_inputs(self):

View File

@@ -5,6 +5,8 @@
from odoo import fields, models from odoo import fields, models
from ._fp_uom_selection import FP_UOM_SELECTION
class FpStepTemplateInput(models.Model): class FpStepTemplateInput(models.Model):
"""Operation measurement definition on a step library template. """Operation measurement definition on a step library template.
@@ -35,10 +37,13 @@ class FpStepTemplateInput(models.Model):
('thickness', 'Thickness'), ('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'), ('pass_fail', 'Pass / Fail'),
], string='Input Type', required=True, default='text') ], string='Input Type', required=True, default='text')
target_min = fields.Float(string='Target Min') target_min = fields.Float(string='Target Min',
target_max = fields.Float(string='Target Max') help='Lower bound of the acceptable range, expressed in Target Unit.')
target_unit = fields.Char(string='Target Unit', target_max = fields.Float(string='Target Max',
help='Display unit, e.g. "min", "°F", "A", "FT2", "in".') help='Upper bound of the acceptable range, expressed in Target Unit.')
target_unit = fields.Selection(FP_UOM_SELECTION, string='Target Unit',
help='Unit Target Min / Target Max are measured in. Pick from the '
'curated list to keep readings consistent across templates.')
required = fields.Boolean(string='Required', default=False, required = fields.Boolean(string='Required', default=False,
help='If True, sign-off is hard-blocked while this input is blank.') help='If True, sign-off is hard-blocked while this input is blank.')
hint = fields.Char(string='Hint') hint = fields.Char(string='Hint')

View File

@@ -19,7 +19,7 @@ class FpTank(models.Model):
_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, work_center_id, sequence, code' _order = 'facility_id, section_id, sequence, code'
name = fields.Char( name = fields.Char(
string='Tank Name', string='Tank Name',
@@ -51,9 +51,16 @@ class FpTank(models.Model):
ondelete='restrict', ondelete='restrict',
tracking=True, tracking=True,
) )
section_id = fields.Many2one(
'fusion.plating.tank.section',
string='Section',
ondelete='set null',
tracking=True,
help='Free-form grouping (e.g. Steel Line, Aluminum Line, Specialty Line).',
)
work_center_id = fields.Many2one( work_center_id = fields.Many2one(
'fusion.plating.work.center', 'fusion.plating.work.center',
string='Work Center', string='Production Line',
domain="[('facility_id','=',facility_id)]", domain="[('facility_id','=',facility_id)]",
ondelete='restrict', ondelete='restrict',
tracking=True, tracking=True,
@@ -126,6 +133,22 @@ class FpTank(models.Model):
tracking=True, tracking=True,
) )
# ----- Default temperature (used as pre-fill on bath log lines) -------
default_temperature = fields.Float(
string='Default Temperature',
digits=(6, 2),
tracking=True,
help='Operating temperature setpoint. Pre-fills the temperature '
'reading on new chemistry logs so the operator can confirm with '
'one tap. Use the up/down arrows on the input to nudge by 1 unit.',
)
default_temperature_uom = fields.Selection(
[('c', '°C'), ('f', '°F')],
string='Temperature Unit',
default='c',
tracking=True,
)
# ----- Relations ------------------------------------------------------ # ----- Relations ------------------------------------------------------
bath_ids = fields.One2many( bath_ids = fields.One2many(
'fusion.plating.bath', 'fusion.plating.bath',
@@ -138,16 +161,45 @@ class FpTank(models.Model):
compute='_compute_current_bath', compute='_compute_current_bath',
store=True, store=True,
) )
current_bath_process_id = fields.Many2one(
'fusion.plating.process.type',
string='Current Bath Process',
related='current_bath_id.process_type_id',
store=True,
help='Process derived from the active bath. The editable "Current '
'Process" overrides this when the operator needs to flag a '
'different process (e.g. between bath swaps).',
)
current_process_id = fields.Many2one( current_process_id = fields.Many2one(
'fusion.plating.process.type', 'fusion.plating.process.type',
string='Current Process', string='Current Process',
related='current_bath_id.process_type_id', ondelete='restrict',
store=True, tracking=True,
help='User-settable process flag. Defaults to the active bath\'s '
'process; can be overridden when operating off-recipe.',
) )
bath_count = fields.Integer( bath_count = fields.Integer(
compute='_compute_bath_count', compute='_compute_bath_count',
) )
# ----- Compositions ---------------------------------------------------
composition_ids = fields.One2many(
'fusion.plating.tank.composition',
'tank_id',
string='Compositions',
)
active_composition_id = fields.Many2one(
'fusion.plating.tank.composition',
string='Active Composition',
domain="[('tank_id', '=', id)]",
tracking=True,
help='The composition currently in service. Switching is logged in '
'the chatter for full audit history.',
)
composition_count = fields.Integer(
compute='_compute_composition_count',
)
_sql_constraints = [ _sql_constraints = [
( (
'fp_tank_code_facility_uniq', 'fp_tank_code_facility_uniq',
@@ -168,6 +220,20 @@ class FpTank(models.Model):
for rec in self: for rec in self:
rec.bath_count = len(rec.bath_ids) rec.bath_count = len(rec.bath_ids)
@api.depends('composition_ids')
def _compute_composition_count(self):
for rec in self:
rec.composition_count = len(rec.composition_ids)
@api.onchange('current_bath_process_id')
def _onchange_seed_current_process(self):
"""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 box while still allowing manual override."""
for rec in self:
if not rec.current_process_id and rec.current_bath_process_id:
rec.current_process_id = rec.current_bath_process_id
@api.model_create_multi @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
for vals in vals_list: for vals in vals_list:

View File

@@ -0,0 +1,216 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import _, api, fields, models
class FpTankComposition(models.Model):
"""A defined chemistry composition for a tank (e.g. "Composition A",
"High-P Mix", "Strike Solution").
A tank can carry multiple authored compositions; one is "active" at any
given time. Switching compositions is a tracked, audit-logged event so
the shop has a chronological record of "what did this tank actually
contain on Tuesday afternoon?"
Each composition has its own ingredient list with per-chemical
percentages. Changes to ingredients are also chatter-tracked.
"""
_name = 'fusion.plating.tank.composition'
_description = 'Fusion Plating — Tank Composition'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'tank_id, sequence, name'
name = fields.Char(
string='Composition',
required=True,
tracking=True,
)
code = fields.Char(
string='Code',
tracking=True,
help='Short identifier — "A", "B", "C".',
)
sequence = fields.Integer(
string='Sequence',
default=10,
)
tank_id = fields.Many2one(
'fusion.plating.tank',
string='Tank',
required=True,
ondelete='cascade',
tracking=True,
)
facility_id = fields.Many2one(
related='tank_id.facility_id',
store=True,
readonly=True,
)
is_active = fields.Boolean(
string='Currently Active',
compute='_compute_is_active',
help='True when this composition is the tank\'s active composition.',
)
description = fields.Text(
string='Description',
)
notes = fields.Html(
string='Notes',
)
ingredient_ids = fields.One2many(
'fusion.plating.tank.composition.ingredient',
'composition_id',
string='Ingredients',
copy=True,
tracking=True,
)
total_percentage = fields.Float(
string='Total %',
compute='_compute_total_percentage',
store=True,
)
active = fields.Boolean(default=True)
@api.depends('ingredient_ids', 'ingredient_ids.percentage')
def _compute_total_percentage(self):
for rec in self:
rec.total_percentage = sum(rec.ingredient_ids.mapped('percentage'))
@api.depends('tank_id', 'tank_id.active_composition_id')
def _compute_is_active(self):
for rec in self:
rec.is_active = rec.tank_id.active_composition_id.id == rec.id
def action_set_active(self):
"""Mark this composition as the tank's active composition. Logs to
both this composition's chatter and the tank's chatter so the audit
trail captures who flipped the switch and when.
"""
self.ensure_one()
old = self.tank_id.active_composition_id
if old.id == self.id:
return True
self.tank_id.active_composition_id = self.id
msg_tank = _(
'Active composition changed: %(old)s%(new)s by %(user)s'
) % {
'old': old.display_name or _('(none)'),
'new': self.display_name,
'user': self.env.user.name,
}
self.tank_id.message_post(body=msg_tank)
self.message_post(body=_('Activated by %s') % self.env.user.name)
return True
class FpTankCompositionIngredient(models.Model):
"""A single chemical entry in a tank composition.
Free-form chemical name (no FK to fusion.plating.chemical because core
must not depend on the safety module). Percentage is the share of the
composition; total % roll-up lives on the parent composition.
"""
_name = 'fusion.plating.tank.composition.ingredient'
_description = 'Fusion Plating — Tank Composition Ingredient'
_order = 'composition_id, sequence, id'
composition_id = fields.Many2one(
'fusion.plating.tank.composition',
string='Composition',
required=True,
ondelete='cascade',
index=True,
)
tank_id = fields.Many2one(
related='composition_id.tank_id',
store=True,
readonly=True,
)
sequence = fields.Integer(
string='Sequence',
default=10,
)
name = fields.Char(
string='Chemical',
required=True,
)
percentage = fields.Float(
string='Percentage',
digits=(6, 3),
required=True,
help='Share of this composition, in percent.',
)
uom = fields.Selection(
[
('pct', '% by Volume'),
('pct_w', '% by Weight'),
('g_l', 'g/L'),
('ml_l', 'mL/L'),
('oz_gal', 'oz/gal'),
],
string='Unit',
default='pct',
)
notes = fields.Char(
string='Notes',
)
# Mirror create/write/unlink to the parent composition's chatter so
# ingredient changes show up in the audit log even though this row
# doesn't carry mail.thread itself (kept lean for repeater UX).
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for rec in records:
rec.composition_id.message_post(body=_(
'Ingredient added: %(name)s%(pct)s %(uom)s'
) % {
'name': rec.name,
'pct': rec.percentage,
'uom': dict(rec._fields['uom'].selection).get(rec.uom, rec.uom),
})
return records
def write(self, vals):
# Capture before-state per-record so we can describe the diff.
snapshots = {
rec.id: {
'name': rec.name,
'percentage': rec.percentage,
'uom': rec.uom,
}
for rec in self
}
result = super().write(vals)
for rec in self:
before = snapshots.get(rec.id) or {}
changed = []
if 'name' in vals and before.get('name') != rec.name:
changed.append(_('name: %s%s') % (before.get('name'), rec.name))
if 'percentage' in vals and before.get('percentage') != rec.percentage:
changed.append(_('percentage: %s%s') % (
before.get('percentage'), rec.percentage,
))
if 'uom' in vals and before.get('uom') != rec.uom:
changed.append(_('unit: %s%s') % (before.get('uom'), rec.uom))
if changed:
rec.composition_id.message_post(body=_(
'Ingredient %(name)s updated — %(changes)s'
) % {
'name': rec.name,
'changes': '; '.join(changed),
})
return result
def unlink(self):
for rec in self:
rec.composition_id.message_post(body=_(
'Ingredient removed: %(name)s%(pct)s'
) % {
'name': rec.name,
'pct': rec.percentage,
})
return super().unlink()

View File

@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpTankSection(models.Model):
"""A user-defined grouping of tanks (e.g. "Steel Line", "Aluminum Line",
"Specialty Line").
Sections give the shop a familiar way to slice the tank list: every shop
organises its tanks differently — by metal, by chemistry family, by
physical aisle, or by customer programme — and a fixed taxonomy never
fits. Sections are free-form, renameable, and per-facility.
"""
_name = 'fusion.plating.tank.section'
_description = 'Fusion Plating — Tank Section'
_order = 'sequence, name'
name = fields.Char(
string='Section',
required=True,
translate=True,
)
sequence = fields.Integer(
string='Sequence',
default=10,
)
facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
ondelete='restrict',
)
color = fields.Integer(
string='Color',
default=0,
)
description = fields.Text(
string='Description',
translate=True,
)
active = fields.Boolean(default=True)
tank_ids = fields.One2many(
'fusion.plating.tank',
'section_id',
string='Tanks',
)
tank_count = fields.Integer(
compute='_compute_tank_count',
)
@api.depends('tank_ids')
def _compute_tank_count(self):
for rec in self:
rec.tank_count = len(rec.tank_ids)
def action_view_tanks(self):
self.ensure_one()
return {
'name': self.name,
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.tank',
'view_mode': 'list,kanban,form',
'domain': [('section_id', '=', self.id)],
'context': {'default_section_id': self.id},
}

View File

@@ -14,6 +14,15 @@ access_fp_work_center_manager,fp.work.center.manager,model_fusion_plating_work_c
access_fp_tank_operator,fp.tank.operator,model_fusion_plating_tank,group_fusion_plating_operator,1,0,0,0 access_fp_tank_operator,fp.tank.operator,model_fusion_plating_tank,group_fusion_plating_operator,1,0,0,0
access_fp_tank_supervisor,fp.tank.supervisor,model_fusion_plating_tank,group_fusion_plating_supervisor,1,1,0,0 access_fp_tank_supervisor,fp.tank.supervisor,model_fusion_plating_tank,group_fusion_plating_supervisor,1,1,0,0
access_fp_tank_manager,fp.tank.manager,model_fusion_plating_tank,group_fusion_plating_manager,1,1,1,1 access_fp_tank_manager,fp.tank.manager,model_fusion_plating_tank,group_fusion_plating_manager,1,1,1,1
access_fp_tank_section_operator,fp.tank.section.operator,model_fusion_plating_tank_section,group_fusion_plating_operator,1,0,0,0
access_fp_tank_section_supervisor,fp.tank.section.supervisor,model_fusion_plating_tank_section,group_fusion_plating_supervisor,1,1,0,0
access_fp_tank_section_manager,fp.tank.section.manager,model_fusion_plating_tank_section,group_fusion_plating_manager,1,1,1,1
access_fp_tank_composition_operator,fp.tank.composition.operator,model_fusion_plating_tank_composition,group_fusion_plating_operator,1,0,0,0
access_fp_tank_composition_supervisor,fp.tank.composition.supervisor,model_fusion_plating_tank_composition,group_fusion_plating_supervisor,1,1,1,0
access_fp_tank_composition_manager,fp.tank.composition.manager,model_fusion_plating_tank_composition,group_fusion_plating_manager,1,1,1,1
access_fp_tank_comp_ing_operator,fp.tank.composition.ingredient.operator,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_operator,1,0,0,0
access_fp_tank_comp_ing_supervisor,fp.tank.composition.ingredient.supervisor,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_supervisor,1,1,1,1
access_fp_tank_comp_ing_manager,fp.tank.composition.ingredient.manager,model_fusion_plating_tank_composition_ingredient,group_fusion_plating_manager,1,1,1,1
access_fp_bath_operator,fp.bath.operator,model_fusion_plating_bath,group_fusion_plating_operator,1,0,0,0 access_fp_bath_operator,fp.bath.operator,model_fusion_plating_bath,group_fusion_plating_operator,1,0,0,0
access_fp_bath_supervisor,fp.bath.supervisor,model_fusion_plating_bath,group_fusion_plating_supervisor,1,1,1,0 access_fp_bath_supervisor,fp.bath.supervisor,model_fusion_plating_bath,group_fusion_plating_supervisor,1,1,1,0
access_fp_bath_manager,fp.bath.manager,model_fusion_plating_bath,group_fusion_plating_manager,1,1,1,1 access_fp_bath_manager,fp.bath.manager,model_fusion_plating_bath,group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
14 access_fp_tank_operator fp.tank.operator model_fusion_plating_tank group_fusion_plating_operator 1 0 0 0
15 access_fp_tank_supervisor fp.tank.supervisor model_fusion_plating_tank group_fusion_plating_supervisor 1 1 0 0
16 access_fp_tank_manager fp.tank.manager model_fusion_plating_tank group_fusion_plating_manager 1 1 1 1
17 access_fp_tank_section_operator fp.tank.section.operator model_fusion_plating_tank_section group_fusion_plating_operator 1 0 0 0
18 access_fp_tank_section_supervisor fp.tank.section.supervisor model_fusion_plating_tank_section group_fusion_plating_supervisor 1 1 0 0
19 access_fp_tank_section_manager fp.tank.section.manager model_fusion_plating_tank_section group_fusion_plating_manager 1 1 1 1
20 access_fp_tank_composition_operator fp.tank.composition.operator model_fusion_plating_tank_composition group_fusion_plating_operator 1 0 0 0
21 access_fp_tank_composition_supervisor fp.tank.composition.supervisor model_fusion_plating_tank_composition group_fusion_plating_supervisor 1 1 1 0
22 access_fp_tank_composition_manager fp.tank.composition.manager model_fusion_plating_tank_composition group_fusion_plating_manager 1 1 1 1
23 access_fp_tank_comp_ing_operator fp.tank.composition.ingredient.operator model_fusion_plating_tank_composition_ingredient group_fusion_plating_operator 1 0 0 0
24 access_fp_tank_comp_ing_supervisor fp.tank.composition.ingredient.supervisor model_fusion_plating_tank_composition_ingredient group_fusion_plating_supervisor 1 1 1 1
25 access_fp_tank_comp_ing_manager fp.tank.composition.ingredient.manager model_fusion_plating_tank_composition_ingredient group_fusion_plating_manager 1 1 1 1
26 access_fp_bath_operator fp.bath.operator model_fusion_plating_bath group_fusion_plating_operator 1 0 0 0
27 access_fp_bath_supervisor fp.bath.supervisor model_fusion_plating_bath group_fusion_plating_supervisor 1 1 1 0
28 access_fp_bath_manager fp.bath.manager model_fusion_plating_bath group_fusion_plating_manager 1 1 1 1

View File

@@ -46,9 +46,8 @@
<field name="max_dose"/> <field name="max_dose"/>
</group> </group>
</group> </group>
<group string="Notes"> <separator string="Notes"/>
<field name="notes" nolabel="1" colspan="2"/> <field name="notes" colspan="2"/>
</group>
<group> <group>
<field name="active" widget="boolean_toggle"/> <field name="active" widget="boolean_toggle"/>
</group> </group>

View File

@@ -82,9 +82,8 @@
<field name="product_id"/> <field name="product_id"/>
</group> </group>
</group> </group>
<group string="Notes"> <separator string="Notes"/>
<field name="notes" nolabel="1"/> <field name="notes"/>
</group>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -48,12 +48,10 @@
<field name="training_record_attachment_id"/> <field name="training_record_attachment_id"/>
</group> </group>
</group> </group>
<group string="Revocation" invisible="state != 'revoked'"> <separator string="Revocation"/>
<field name="revoked_reason" nolabel="1" colspan="2"/> <field name="revoked_reason" colspan="2"/>
</group> <separator string="Notes"/>
<group string="Notes"> <field name="notes" colspan="2"/>
<field name="notes" nolabel="1" colspan="2"/>
</group>
</sheet> </sheet>
<chatter/> <chatter/>
</form> </form>

View File

@@ -211,21 +211,34 @@
</div> </div>
</div> </div>
<group> <group>
<group> <group string="Definition">
<field name="parameter_type"/> <field name="parameter_type"/>
<field name="uom"/> <field name="uom"/>
<field name="decimals"/> <field name="decimals"/>
</group> </group>
<group> <group string="Default Targets (in selected unit)">
<field name="target_min"/> <label for="target_min"/>
<field name="target_max"/> <div class="o_row">
<field name="target_min" nolabel="1" class="oe_inline"/>
<span class="text-muted ms-2"><field name="uom_display" nolabel="1" readonly="1" class="oe_inline"/></span>
</div>
<label for="target_max"/>
<div class="o_row">
<field name="target_max" nolabel="1" class="oe_inline"/>
<span class="text-muted ms-2"><field name="uom_display" nolabel="1" readonly="1" class="oe_inline"/></span>
</div>
<label for="target_value"/>
<div class="o_row">
<field name="target_value" nolabel="1" class="oe_inline"/>
<span class="text-muted ms-2"><field name="uom_display" nolabel="1" readonly="1" class="oe_inline"/></span>
</div>
<field name="warning_tolerance"/> <field name="warning_tolerance"/>
<field name="active" widget="boolean_toggle"/> <field name="active" widget="boolean_toggle"/>
</group> </group>
</group> </group>
<group string="Description"> <separator string="Description"/>
<field name="description" nolabel="1"/> <field name="description" nolabel="1"
</group> placeholder="What is this parameter, how is it measured, why does it matter?"/>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -82,9 +82,8 @@
<field name="current_part_count" readonly="1"/> <field name="current_part_count" readonly="1"/>
</group> </group>
</group> </group>
<group string="Notes"> <separator string="Notes"/>
<field name="notes" nolabel="1" colspan="2"/> <field name="notes" colspan="2"/>
</group>
</sheet> </sheet>
<chatter/> <chatter/>
</form> </form>

View File

@@ -10,20 +10,24 @@
<field name="name">fp.tank.list</field> <field name="name">fp.tank.list</field>
<field name="model">fusion.plating.tank</field> <field name="model">fusion.plating.tank</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<list string="Tanks"> <list string="Tanks" multi_edit="1" expand="1">
<field name="facility_id"/> <field name="sequence" widget="handle"/>
<field name="work_center_id"/>
<field name="code"/> <field name="code"/>
<field name="name"/> <field name="name"/>
<field name="section_id" optional="show"/>
<field name="facility_id" optional="hide"/>
<field name="work_center_id" optional="hide"/>
<field name="current_process_id"/> <field name="current_process_id"/>
<field name="default_temperature" optional="show"/>
<field name="default_temperature_uom" optional="show"/>
<field name="state" widget="badge" <field name="state" widget="badge"
decoration-success="state == 'in_use'" decoration-success="state == 'in_use'"
decoration-info="state == 'filled'" decoration-info="state == 'filled'"
decoration-warning="state in ('draining', 'maintenance')" decoration-warning="state in ('draining', 'maintenance')"
decoration-muted="state in ('empty', 'out_of_service')"/> decoration-muted="state in ('empty', 'out_of_service')"/>
<field name="material" optional="hide"/> <field name="material" optional="hide"/>
<field name="volume" optional="show"/> <field name="volume" optional="hide"/>
<field name="volume_uom" optional="show"/> <field name="volume_uom" optional="hide"/>
<field name="active" widget="boolean_toggle" optional="hide"/> <field name="active" widget="boolean_toggle" optional="hide"/>
</list> </list>
</field> </field>
@@ -69,12 +73,19 @@
<group> <group>
<group string="Location"> <group string="Location">
<field name="facility_id"/> <field name="facility_id"/>
<field name="section_id"
options="{'no_quick_create': False}"/>
<field name="work_center_id"/> <field name="work_center_id"/>
<field name="sequence"/> <field name="sequence"/>
</group> </group>
<group string="Current Bath"> <group string="Operating Setpoints">
<field name="current_bath_id" readonly="1"/> <field name="current_process_id"
<field name="current_process_id" readonly="1"/> help="Editable. Defaults to the active bath's process."/>
<label for="default_temperature"/>
<div class="o_row">
<field name="default_temperature" nolabel="1" class="oe_inline"/>
<field name="default_temperature_uom" nolabel="1" class="oe_inline"/>
</div>
<field name="qr_code"/> <field name="qr_code"/>
</group> </group>
</group> </group>
@@ -96,6 +107,62 @@
</group> </group>
</group> </group>
</page> </page>
<page string="Compositions">
<group>
<field name="active_composition_id"
options="{'no_create_edit': True}"/>
</group>
<field name="composition_ids" context="{'default_tank_id': id}">
<list decoration-bf="is_active">
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="total_percentage"
decoration-warning="total_percentage != 100.0 and total_percentage > 0"/>
<field name="is_active" string="Active"/>
<button name="action_set_active" type="object"
string="Set Active" class="btn-link"
icon="fa-check-circle"
invisible="is_active"/>
</list>
<form string="Composition">
<sheet>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Composition A"/></h1>
</div>
<group>
<group>
<field name="code"/>
<field name="sequence"/>
</group>
<group>
<field name="total_percentage"/>
<field name="is_active"/>
</group>
</group>
<field name="description" placeholder="Short description..."/>
<notebook>
<page string="Ingredients">
<field name="ingredient_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="percentage"/>
<field name="uom"/>
<field name="notes"/>
</list>
</field>
</page>
<page string="Notes">
<field name="notes"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</page>
<page string="Bath History"> <page string="Bath History">
<field name="bath_ids"> <field name="bath_ids">
<list decoration-muted="state == 'dumped'"> <list decoration-muted="state == 'dumped'">
@@ -126,6 +193,7 @@
<field name="state"/> <field name="state"/>
<field name="current_bath_id"/> <field name="current_bath_id"/>
<field name="current_process_id"/> <field name="current_process_id"/>
<field name="section_id"/>
<field name="facility_id"/> <field name="facility_id"/>
<field name="work_center_id"/> <field name="work_center_id"/>
<templates> <templates>
@@ -142,7 +210,7 @@
</div> </div>
<div class="mt-2 small"> <div class="mt-2 small">
<div><i class="fa fa-flask me-1 text-muted"/><field name="current_process_id"/></div> <div><i class="fa fa-flask me-1 text-muted"/><field name="current_process_id"/></div>
<div class="text-muted"><field name="work_center_id"/></div> <div class="text-muted"><field name="section_id"/></div>
</div> </div>
</div> </div>
</t> </t>
@@ -160,6 +228,7 @@
<field name="code"/> <field name="code"/>
<field name="qr_code"/> <field name="qr_code"/>
<field name="facility_id"/> <field name="facility_id"/>
<field name="section_id"/>
<field name="work_center_id"/> <field name="work_center_id"/>
<field name="current_process_id"/> <field name="current_process_id"/>
<separator/> <separator/>
@@ -170,8 +239,9 @@
<separator/> <separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/> <filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group> <group>
<filter string="Section" name="group_section" context="{'group_by':'section_id'}"/>
<filter string="Facility" name="group_facility" context="{'group_by':'facility_id'}"/> <filter string="Facility" name="group_facility" context="{'group_by':'facility_id'}"/>
<filter string="Work Center" name="group_wc" context="{'group_by':'work_center_id'}"/> <filter string="Production Line" name="group_wc" context="{'group_by':'work_center_id'}"/>
<filter string="Process" name="group_process" context="{'group_by':'current_process_id'}"/> <filter string="Process" name="group_process" context="{'group_by':'current_process_id'}"/>
<filter string="Status" name="group_state" context="{'group_by':'state'}"/> <filter string="Status" name="group_state" context="{'group_by':'state'}"/>
</group> </group>
@@ -182,8 +252,70 @@
<record id="action_fp_tank" model="ir.actions.act_window"> <record id="action_fp_tank" model="ir.actions.act_window">
<field name="name">Tanks</field> <field name="name">Tanks</field>
<field name="res_model">fusion.plating.tank</field> <field name="res_model">fusion.plating.tank</field>
<field name="view_mode">kanban,list,form</field> <field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="view_fp_tank_search"/> <field name="search_view_id" ref="view_fp_tank_search"/>
<field name="context">{'search_default_group_section': 1}</field>
</record> </record>
<!-- ==================================================================
Tank Sections — manageable from Configuration → Shop Setup
================================================================== -->
<record id="view_fp_tank_section_list" model="ir.ui.view">
<field name="name">fp.tank.section.list</field>
<field name="model">fusion.plating.tank.section</field>
<field name="arch" type="xml">
<list string="Tank Sections" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="facility_id"/>
<field name="tank_count"/>
<field name="active" widget="boolean_toggle" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_tank_section_form" model="ir.ui.view">
<field name="name">fp.tank.section.form</field>
<field name="model">fusion.plating.tank.section</field>
<field name="arch" type="xml">
<form string="Tank Section">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_tanks" type="object"
class="oe_stat_button" icon="fa-flask">
<field name="tank_count" widget="statinfo" string="Tanks"/>
</button>
</div>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Steel Line"/></h1>
</div>
<group>
<group>
<field name="facility_id"/>
<field name="sequence"/>
<field name="color" widget="color_picker"/>
</group>
<group>
<field name="active"/>
</group>
</group>
<field name="description" placeholder="What kinds of tanks belong in this section?"/>
</sheet>
</form>
</field>
</record>
<record id="action_fp_tank_section" model="ir.actions.act_window">
<field name="name">Tank Sections</field>
<field name="res_model">fusion.plating.tank.section</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fp_tank_sections"
name="Tank Sections"
parent="menu_fp_config_shop_setup"
action="action_fp_tank_section"
sequence="35"/>
</odoo> </odoo>

View File

@@ -42,10 +42,8 @@
<field name="mastery_required"/> <field name="mastery_required"/>
</group> </group>
</group> </group>
<group> <field name="description"
<field name="description"
placeholder="Short operator-facing description of what this role covers."/> placeholder="Short operator-facing description of what this role covers."/>
</group>
<div class="alert alert-info" role="alert"> <div class="alert alert-info" role="alert">
<i class="fa fa-info-circle me-1"/> <i class="fa fa-info-circle me-1"/>
<strong>Mastery Threshold</strong> controls auto-promotion: when an <strong>Mastery Threshold</strong> controls auto-promotion: when an

View File

@@ -49,9 +49,8 @@
<field name="logged_by_id"/> <field name="logged_by_id"/>
</group> </group>
</group> </group>
<group string="Notes"> <separator string="Notes"/>
<field name="notes" nolabel="1"/> <field name="notes"/>
</group>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -92,10 +92,9 @@
<field name="value_max"/> <field name="value_max"/>
<field name="value_uom"/> <field name="value_uom"/>
</group> </group>
<group string="Guidance"> <separator string="Guidance"/>
<field name="description" nolabel="1" <field name="description"
placeholder="Inspection guidance shown to the operator on tap..."/> placeholder="Inspection guidance shown to the operator on tap..."/>
</group>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -42,10 +42,8 @@
<field name="mastery_required"/> <field name="mastery_required"/>
</group> </group>
</group> </group>
<group> <field name="description"
<field name="description"
placeholder="Short operator-facing description of what this role covers."/> placeholder="Short operator-facing description of what this role covers."/>
</group>
<div class="alert alert-info" role="alert"> <div class="alert alert-info" role="alert">
<i class="fa fa-info-circle me-1"/> <i class="fa fa-info-circle me-1"/>
<strong>Mastery Threshold</strong> controls auto-promotion: when an <strong>Mastery Threshold</strong> controls auto-promotion: when an

View File

@@ -57,9 +57,7 @@
<field name="x_fc_is_rework" readonly="1"/> <field name="x_fc_is_rework" readonly="1"/>
<field name="x_fc_original_production_id" readonly="1"/> <field name="x_fc_original_production_id" readonly="1"/>
</group> </group>
<group> <field name="x_fc_rework_reason"/>
<field name="x_fc_rework_reason"/>
</group>
</group> </group>
</xpath> </xpath>

View File

@@ -145,9 +145,7 @@
</page> </page>
<page string="Void" name="void" <page string="Void" name="void"
invisible="state != 'voided'"> invisible="state != 'voided'">
<group> <field name="void_reason"/>
<field name="void_reason"/>
</group>
</page> </page>
<page string="Notes" name="notes"> <page string="Notes" name="notes">
<field name="notes"/> <field name="notes"/>

View File

@@ -39,10 +39,8 @@
company default, then a hardcoded AS9100/ISO 9001 company default, then a hardcoded AS9100/ISO 9001
statement. statement.
</p> </p>
<group> <field name="x_fc_cert_statement"
<field name="x_fc_cert_statement" nolabel="1"
placeholder="e.g. We certify these parts conform to MIL-DTL-5541F Class 1A and have been processed in accordance with…"/> placeholder="e.g. We certify these parts conform to MIL-DTL-5541F Class 1A and have been processed in accordance with…"/>
</group>
</page> </page>
</xpath> </xpath>
</field> </field>

View File

@@ -50,7 +50,7 @@
<field name="description">Filter-press cake from hexavalent chrome waste treatment system.</field> <field name="description">Filter-press cake from hexavalent chrome waste treatment system.</field>
<field name="physical_state">liquid</field> <field name="physical_state">liquid</field>
<field name="generation_rate">45.0</field> <field name="generation_rate">45.0</field>
<field name="generation_uom">kg/day</field> <field name="generation_uom">kg_day</field>
<field name="disposal_method">Licensed hazardous waste facility</field> <field name="disposal_method">Licensed hazardous waste facility</field>
</record> </record>
@@ -62,7 +62,7 @@
<field name="description">Spent sulphuric and hydrochloric acid from pickling tanks.</field> <field name="description">Spent sulphuric and hydrochloric acid from pickling tanks.</field>
<field name="physical_state">liquid</field> <field name="physical_state">liquid</field>
<field name="generation_rate">120.0</field> <field name="generation_rate">120.0</field>
<field name="generation_uom">L/day</field> <field name="generation_uom">l_day</field>
<field name="disposal_method">Acid reclamation</field> <field name="disposal_method">Acid reclamation</field>
</record> </record>
@@ -74,7 +74,7 @@
<field name="description">Sludge from black oxide line waste treatment.</field> <field name="description">Sludge from black oxide line waste treatment.</field>
<field name="physical_state">sludge</field> <field name="physical_state">sludge</field>
<field name="generation_rate">10.0</field> <field name="generation_rate">10.0</field>
<field name="generation_uom">kg/day</field> <field name="generation_uom">kg_day</field>
<field name="disposal_method">Stabilisation and secure landfill</field> <field name="disposal_method">Stabilisation and secure landfill</field>
</record> </record>
@@ -95,7 +95,7 @@
<field name="waste_stream_id" ref="demo_waste_stream_spent_acid"/> <field name="waste_stream_id" ref="demo_waste_stream_spent_acid"/>
<field name="ship_date" eval="(DateTime.today()).strftime('%Y-%m-%d')"/> <field name="ship_date" eval="(DateTime.today()).strftime('%Y-%m-%d')"/>
<field name="quantity">800.0</field> <field name="quantity">800.0</field>
<field name="uom">L</field> <field name="uom">l</field>
<field name="state">draft</field> <field name="state">draft</field>
<field name="notes" type="html"><p>Pending carrier assignment for spent acid pickup.</p></field> <field name="notes" type="html"><p>Pending carrier assignment for spent acid pickup.</p></field>
</record> </record>
@@ -107,7 +107,7 @@
<field name="spill_date" eval="(DateTime.today() - timedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S')"/> <field name="spill_date" eval="(DateTime.today() - timedelta(days=7)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="substance">Chromic Acid</field> <field name="substance">Chromic Acid</field>
<field name="quantity">5.0</field> <field name="quantity">5.0</field>
<field name="uom">L</field> <field name="uom">l</field>
<field name="location">Chrome line — tank overflow berm</field> <field name="location">Chrome line — tank overflow berm</field>
<field name="containment_action">Spill contained within secondary containment berm. Absorbent pads deployed. Area neutralised with soda ash.</field> <field name="containment_action">Spill contained within secondary containment berm. Absorbent pads deployed. Area neutralised with soda ash.</field>
<field name="regulator_notified" eval="True"/> <field name="regulator_notified" eval="True"/>
@@ -121,7 +121,7 @@
<field name="spill_date" eval="(DateTime.today() - timedelta(days=45)).strftime('%Y-%m-%d %H:%M:%S')"/> <field name="spill_date" eval="(DateTime.today() - timedelta(days=45)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="substance">Nickel Sulphate Solution</field> <field name="substance">Nickel Sulphate Solution</field>
<field name="quantity">2.0</field> <field name="quantity">2.0</field>
<field name="uom">L</field> <field name="uom">l</field>
<field name="location">East Annex — nickel line transfer pump</field> <field name="location">East Annex — nickel line transfer pump</field>
<field name="containment_action">Minor drip from pump seal. Caught by drip tray, cleaned immediately.</field> <field name="containment_action">Minor drip from pump seal. Caught by drip tray, cleaned immediately.</field>
<field name="regulator_notified" eval="False"/> <field name="regulator_notified" eval="False"/>

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models from odoo import fields, models
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
class FpDischargeLimit(models.Model): class FpDischargeLimit(models.Model):
_name = 'fusion.plating.discharge.limit' _name = 'fusion.plating.discharge.limit'
@@ -18,8 +20,11 @@ class FpDischargeLimit(models.Model):
('combined', 'Combined Sewer'), ('air', 'Air Emission'), ('other', 'Other')], ('combined', 'Combined Sewer'), ('air', 'Air Emission'), ('other', 'Other')],
string='Discharge Point', default='sanitary', required=True, string='Discharge Point', default='sanitary', required=True,
) )
limit_value = fields.Float(string='Limit', digits=(16, 4)) limit_value = fields.Float(string='Limit', digits=(16, 4),
uom = fields.Char(string='UoM') help='Numerical limit, expressed in the unit selected below.')
uom = fields.Selection(FP_UOM_SELECTION, string='UoM',
help='Unit the limit is enforced in (typical: mg/L for liquid '
'discharge, mg/m³ for air emissions).')
limit_type = fields.Selection( limit_type = fields.Selection(
[('max', 'Maximum'), ('min', 'Minimum'), ('range', 'Range'), ('ceiling', 'Hard Ceiling')], [('max', 'Maximum'), ('min', 'Minimum'), ('range', 'Range'), ('ceiling', 'Hard Ceiling')],
string='Limit Type', default='max', required=True, string='Limit Type', default='max', required=True,

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models from odoo import api, fields, models
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
class FpDischargeSampleLine(models.Model): class FpDischargeSampleLine(models.Model):
_name = 'fusion.plating.discharge.sample.line' _name = 'fusion.plating.discharge.sample.line'
@@ -12,8 +14,11 @@ class FpDischargeSampleLine(models.Model):
sample_id = fields.Many2one('fusion.plating.discharge.sample', string='Sample', required=True, ondelete='cascade') sample_id = fields.Many2one('fusion.plating.discharge.sample', string='Sample', required=True, ondelete='cascade')
limit_id = fields.Many2one('fusion.plating.discharge.limit', string='Limit', ondelete='restrict') limit_id = fields.Many2one('fusion.plating.discharge.limit', string='Limit', ondelete='restrict')
parameter = fields.Char(string='Parameter', related='limit_id.parameter', store=True, readonly=False) parameter = fields.Char(string='Parameter', related='limit_id.parameter', store=True, readonly=False)
value = fields.Float(string='Result', digits=(16, 4)) value = fields.Float(string='Result', digits=(16, 4),
uom = fields.Char(string='UoM') help='Measured value, expressed in the unit selected below.')
uom = fields.Selection(FP_UOM_SELECTION, string='UoM',
help='Unit of the measured value. Defaults to the limit\'s unit; '
'override only when the lab reported in a different unit.')
status = fields.Selection( status = fields.Selection(
[('ok', 'OK'), ('warning', 'Warning'), ('out_of_spec', 'Out of Spec'), ('pending', 'Pending')], [('ok', 'OK'), ('warning', 'Warning'), ('out_of_spec', 'Out of Spec'), ('pending', 'Pending')],
string='Status', compute='_compute_status', store=True, string='Status', compute='_compute_status', store=True,

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models from odoo import api, fields, models
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
class FpSpillRegister(models.Model): class FpSpillRegister(models.Model):
_name = 'fusion.plating.spill.register' _name = 'fusion.plating.spill.register'
@@ -16,8 +18,10 @@ class FpSpillRegister(models.Model):
spill_date = fields.Datetime(string='Spill Date', required=True, default=fields.Datetime.now, tracking=True) spill_date = fields.Datetime(string='Spill Date', required=True, default=fields.Datetime.now, tracking=True)
reported_by_id = fields.Many2one('res.users', string='Reported By', default=lambda s: s.env.user) reported_by_id = fields.Many2one('res.users', string='Reported By', default=lambda s: s.env.user)
substance = fields.Char(string='Substance', tracking=True) substance = fields.Char(string='Substance', tracking=True)
quantity = fields.Float(string='Quantity', digits=(16, 3)) quantity = fields.Float(string='Quantity', digits=(16, 3),
uom = fields.Char(string='UoM', default='L') help='Quantity spilled, expressed in the unit selected below.')
uom = fields.Selection(FP_UOM_SELECTION, string='UoM', default='l',
help='Unit of the spill quantity (L for liquids, kg for solids).')
location = fields.Char(string='Location') location = fields.Char(string='Location')
containment_action = fields.Text(string='Containment Action') containment_action = fields.Text(string='Containment Action')
regulator_notified = fields.Boolean(string='Regulator Notified', tracking=True) regulator_notified = fields.Boolean(string='Regulator Notified', tracking=True)

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
from odoo import api, fields, models from odoo import api, fields, models
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
class FpWasteManifest(models.Model): class FpWasteManifest(models.Model):
_name = 'fusion.plating.waste.manifest' _name = 'fusion.plating.waste.manifest'
@@ -15,8 +17,10 @@ class FpWasteManifest(models.Model):
facility_id = fields.Many2one('fusion.plating.facility', related='waste_stream_id.facility_id', store=True, readonly=True) facility_id = fields.Many2one('fusion.plating.facility', related='waste_stream_id.facility_id', store=True, readonly=True)
company_id = fields.Many2one('res.company', related='facility_id.company_id', store=True, readonly=True) company_id = fields.Many2one('res.company', related='facility_id.company_id', store=True, readonly=True)
ship_date = fields.Date(string='Ship Date', default=fields.Date.context_today, tracking=True) ship_date = fields.Date(string='Ship Date', default=fields.Date.context_today, tracking=True)
quantity = fields.Float(string='Quantity', digits=(16, 3)) quantity = fields.Float(string='Quantity', digits=(16, 3),
uom = fields.Char(string='UoM', default='kg') help='Quantity shipped, expressed in the unit selected below.')
uom = fields.Selection(FP_UOM_SELECTION, string='UoM', default='kg',
help='Unit of the shipped quantity (kg, L, m³, etc.).')
carrier_id = fields.Many2one('res.partner', string='Carrier', domain=[('is_company', '=', True)], tracking=True) carrier_id = fields.Many2one('res.partner', string='Carrier', domain=[('is_company', '=', True)], tracking=True)
receiver_id = fields.Many2one('res.partner', string='Receiver', domain=[('is_company', '=', True)], tracking=True) receiver_id = fields.Many2one('res.partner', string='Receiver', domain=[('is_company', '=', True)], tracking=True)
manifest_number = fields.Char(string='Manifest #', tracking=True) manifest_number = fields.Char(string='Manifest #', tracking=True)

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
from odoo import fields, models from odoo import fields, models
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
class FpWasteStream(models.Model): class FpWasteStream(models.Model):
_name = 'fusion.plating.waste.stream' _name = 'fusion.plating.waste.stream'
@@ -19,8 +21,12 @@ class FpWasteStream(models.Model):
[('liquid', 'Liquid'), ('solid', 'Solid'), ('sludge', 'Sludge'), ('gas', 'Gas')], [('liquid', 'Liquid'), ('solid', 'Solid'), ('sludge', 'Sludge'), ('gas', 'Gas')],
string='Physical State', default='liquid', string='Physical State', default='liquid',
) )
generation_rate = fields.Float(string='Generation Rate') generation_rate = fields.Float(string='Generation Rate',
generation_uom = fields.Char(string='Rate UoM', default='kg/day') help='Average rate this stream is produced at, expressed in the '
'rate unit below (typical: kg/day, L/day).')
generation_uom = fields.Selection(FP_UOM_SELECTION, string='Rate UoM',
default='kg_day',
help='Unit of the generation rate (kg/day, L/day, kg/month, etc.).')
disposal_method = fields.Char(string='Disposal Method') disposal_method = fields.Char(string='Disposal Method')
approved_carrier_id = fields.Many2one('res.partner', string='Approved Carrier', domain=[('is_company', '=', True)]) approved_carrier_id = fields.Many2one('res.partner', string='Approved Carrier', domain=[('is_company', '=', True)])
approved_facility_id = fields.Many2one('res.partner', string='Approved Receiving Facility', domain=[('is_company', '=', True)]) approved_facility_id = fields.Many2one('res.partner', string='Approved Receiving Facility', domain=[('is_company', '=', True)])

View File

@@ -43,7 +43,8 @@
<field name="regulator_id"/> <field name="regulator_id"/>
</group> </group>
</group> </group>
<group string="Notes"><field name="notes" nolabel="1"/></group> <separator string="Notes"/>
<field name="notes"/>
</sheet> </sheet>
<chatter/> <chatter/>
</form> </form>

View File

@@ -45,7 +45,8 @@
</group> </group>
</group> </group>
<group><field name="reference_url" widget="url"/></group> <group><field name="reference_url" widget="url"/></group>
<group string="Notes"><field name="notes" nolabel="1"/></group> <separator string="Notes"/>
<field name="notes"/>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -48,9 +48,8 @@
</list> </list>
</field> </field>
</group> </group>
<group string="Compliance Notes"> <separator string="Compliance Notes"/>
<field name="x_fp_compliance_notes" nolabel="1"/> <field name="x_fp_compliance_notes"/>
</group>
</page> </page>
</xpath> </xpath>
</field> </field>

View File

@@ -72,7 +72,8 @@
<field name="owner_id"/> <field name="owner_id"/>
<field name="status"/> <field name="status"/>
</group> </group>
<group string="Description"><field name="description" nolabel="1"/></group> <separator string="Description"/>
<field name="description"/>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -50,7 +50,8 @@
<field name="transferred_kg"/> <field name="transferred_kg"/>
</group> </group>
</group> </group>
<group string="Notes"><field name="notes" nolabel="1"/></group> <separator string="Notes"/>
<field name="notes"/>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -35,7 +35,8 @@
<field name="active" widget="boolean_toggle"/> <field name="active" widget="boolean_toggle"/>
</group> </group>
</group> </group>
<group string="Contact"><field name="contact_info" nolabel="1"/></group> <separator string="Contact"/>
<field name="contact_info"/>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Configurator', 'name': 'Fusion Plating — Configurator',
'version': '19.0.17.16.0', 'version': '19.0.18.2.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.', 'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """ 'description': """
@@ -50,11 +50,13 @@ Provides:
'views/sale_order_views.xml', 'views/sale_order_views.xml',
'views/res_partner_views.xml', 'views/res_partner_views.xml',
'views/fp_sale_description_template_views.xml', 'views/fp_sale_description_template_views.xml',
'views/fp_serial_views.xml',
'wizard/fp_direct_order_wizard_views.xml', 'wizard/fp_direct_order_wizard_views.xml',
'wizard/fp_add_from_so_wizard_views.xml', 'wizard/fp_add_from_so_wizard_views.xml',
'wizard/fp_add_from_quote_wizard_views.xml', 'wizard/fp_add_from_quote_wizard_views.xml',
'wizard/fp_quote_promote_wizard_views.xml', 'wizard/fp_quote_promote_wizard_views.xml',
'wizard/fp_part_catalog_import_wizard_views.xml', 'wizard/fp_part_catalog_import_wizard_views.xml',
'wizard/fp_serial_bulk_add_wizard_views.xml',
'views/fp_configurator_menu.xml', 'views/fp_configurator_menu.xml',
'data/fp_sale_description_template_data.xml', 'data/fp_sale_description_template_data.xml',
], ],

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 1 multi-serial — backfill the new M2M relations from the
# pre-existing single-M2O column on sale.order.line and account.move.line.
#
# x_fc_serial_id was historically a stored Many2one. Phase 1 made it a
# computed alias of `x_fc_serial_ids` (the new M2M). Existing rows have
# the old FK column populated but no rows in the M2M relation table.
# This migration walks the legacy column and inserts one M2M row per
# (line, serial) pair so smart buttons / reverse links continue to find
# the linked records.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""Backfill fp_sale_order_line_serial_rel + fp_account_move_line_serial_rel."""
backfill_table(cr, 'sale_order_line', 'fp_sale_order_line_serial_rel', 'line_id')
backfill_table(cr, 'account_move_line', 'fp_account_move_line_serial_rel', 'line_id')
def backfill_table(cr, source_table, m2m_table, line_col):
cr.execute(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = %s AND column_name = 'x_fc_serial_id'",
(source_table,),
)
if not cr.fetchone():
_logger.info("Phase 1 multi-serial: %s has no x_fc_serial_id column, skip", source_table)
return
# Make sure the M2M table exists (Odoo creates it on registry load,
# but the migration runs BEFORE the registry comes up on upgrade —
# use IF NOT EXISTS to be safe).
cr.execute(
f"""
CREATE TABLE IF NOT EXISTS "{m2m_table}" (
"{line_col}" integer NOT NULL REFERENCES "{source_table}"(id) ON DELETE CASCADE,
"serial_id" integer NOT NULL REFERENCES "fp_serial"(id) ON DELETE CASCADE,
PRIMARY KEY ("{line_col}", "serial_id")
)
"""
)
cr.execute(
f"""
INSERT INTO "{m2m_table}" ("{line_col}", "serial_id")
SELECT id, x_fc_serial_id FROM "{source_table}"
WHERE x_fc_serial_id IS NOT NULL
ON CONFLICT DO NOTHING
"""
)
_logger.info(
"Phase 1 multi-serial: backfilled %s rows from %s.x_fc_serial_id into %s",
cr.rowcount, source_table, m2m_table,
)

View File

@@ -19,12 +19,23 @@ class AccountMoveLine(models.Model):
help="Copied from sale.order.line on invoice creation so customer-" help="Copied from sale.order.line on invoice creation so customer-"
"facing invoice PDFs can render the customer's part number.", "facing invoice PDFs can render the customer's part number.",
) )
# ---- Sub 5 --------------------------------------------------------------- # ---- Sub 5 / Phase 1 multi-serial ---------------------------------------
x_fc_serial_ids = fields.Many2many(
'fp.serial',
relation='fp_account_move_line_serial_rel',
column1='line_id',
column2='serial_id',
string='Serial Numbers',
help='Copied from sale.order.line for traceability. Multi-serial '
'support added 2026-04-28.',
)
x_fc_serial_id = fields.Many2one( x_fc_serial_id = fields.Many2one(
'fp.serial', 'fp.serial',
string='Serial Number', string='Serial Number',
index=True, index=True,
help='Copied from sale.order.line for traceability.', help='Back-compat alias of the first serial in x_fc_serial_ids. '
'Kept so legacy invoice templates that read the singular '
'continue to render.',
) )
x_fc_job_number = fields.Char( x_fc_job_number = fields.Char(
string='Job #', index=True, string='Job #', index=True,

View File

@@ -444,6 +444,33 @@ class FpPartCatalog(models.Model):
'target': 'current', 'target': 'current',
} }
def action_open_default_simple_editor(self):
"""Open the Simple Recipe Editor for this part's default variant.
One-click path that skips the Composer's variants list — useful
when the part only has one variant and the user wants to dive
straight into editing.
"""
self.ensure_one()
if not self.default_process_id:
from odoo.exceptions import UserError
raise UserError(_(
'No default process variant for %s yet. Click Compose to '
'create the first variant.'
) % (self.display_name or self.part_number))
return self.default_process_id.action_open_simple_editor()
def action_open_default_tree_editor(self):
"""Open the Tree Editor for this part's default variant."""
self.ensure_one()
if not self.default_process_id:
from odoo.exceptions import UserError
raise UserError(_(
'No default process variant for %s yet. Click Compose to '
'create the first variant.'
) % (self.display_name or self.part_number))
return self.default_process_id.action_open_tree_editor()
def action_set_default_variant(self, variant_id): def action_set_default_variant(self, variant_id):
"""Flip the default variant for this part. """Flip the default variant for this part.

View File

@@ -58,6 +58,170 @@ class FpSerial(models.Model):
) )
notes = fields.Text(string='Notes') notes = fields.Text(string='Notes')
# ==================================================================
# Phase 2 (2026-04-28) — per-serial state machine
# ==================================================================
# Each physical part owns its own state independent of the parent
# job's qty roll-ups. When 30 parts arrive on one SO line, all 30
# serials are independently trackable through the shop. State
# auto-promotes from job-step transitions (see fp.job.button_*
# overrides in fusion_plating_jobs); operator can also flip a
# single serial manually (e.g. mark serial #5 scrapped after a
# plating defect).
state = fields.Selection(
[
('received', 'Received'),
('racked', 'Racked'),
('in_process', 'In Process'),
('inspected', 'Inspected'),
('packed', 'Packed'),
('shipped', 'Shipped'),
('returned', 'Returned'),
('scrapped', 'Scrapped'),
('on_hold', 'On Hold'),
],
string='Status',
default='received',
required=True,
tracking=True,
index=True,
help='Per-serial workflow state. Transitions auto-promote from '
'parent job step events; supervisors can also flip a single '
'serial manually (e.g. scrap one part out of a 30-part rack).',
)
state_color = fields.Integer(
string='Status Color',
compute='_compute_state_color',
help='Kanban / many2many_tags color index derived from state.',
)
last_state_change = fields.Datetime(
string='Last Status Change',
readonly=True,
help='Timestamp of the most recent state transition. Auto-stamped '
'by every state-changing action.',
)
scrap_reason = fields.Text(
string='Scrap / Return Reason',
help='Captured when state transitions to scrapped or returned. '
'Surfaces on per-serial CoC entries (Phase 4).',
)
# Reverse from move log — Phase 3 will populate this directly when
# operators record per-serial moves on the tablet. Defined here so
# views can already render the count column.
move_count = fields.Integer(
compute='_compute_move_count',
string='# Moves',
)
@api.depends('state')
def _compute_state_color(self):
# Odoo color-index mapping aligned with the standard kanban palette.
# 0 default · 1 red · 2 orange · 3 yellow · 4 green · 5 purple ·
# 6 magenta · 7 sky · 8 blue · 9 brown · 10 grey · 11 olive
mapping = {
'received': 8, # blue — fresh
'racked': 7, # sky — staged
'in_process': 3, # yellow — running
'inspected': 11, # olive — passed QC, ready to ship
'packed': 4, # green — boxed
'shipped': 4, # green — out the door
'returned': 2, # orange — back from customer
'scrapped': 1, # red
'on_hold': 1, # red — quality issue
}
for rec in self:
rec.state_color = mapping.get(rec.state, 0)
@api.depends_context('uid')
def _compute_move_count(self):
# Phase 3 will replace this with a real reverse link via
# fp.job.step.move.serial_ids (M2M added next phase).
# Defined here as 0-stub so views don't break on upgrade.
for rec in self:
rec.move_count = 0
# ------------------------------------------------------------------
# State transitions — log each one to chatter and stamp last_state_change
# ------------------------------------------------------------------
def _set_state(self, new_state, message=None):
"""Internal helper. Validates the source state, flips, stamps,
chatters. Raises UserError on illegal transitions."""
labels = dict(self._fields['state'].selection)
for rec in self:
old = rec.state
if old == new_state:
continue
# Terminal states are write-protected (operator must explicitly
# un-set via action_reopen if they really need to).
if old in ('shipped', 'scrapped') and new_state not in ('returned', 'received'):
from odoo.exceptions import UserError
raise UserError(_(
'Serial %(name)s is %(old)s — cannot transition to '
'%(new)s. Use Reopen if this is a correction.'
) % {
'name': rec.name,
'old': labels.get(old, old),
'new': labels.get(new_state, new_state),
})
rec.state = new_state
rec.last_state_change = fields.Datetime.now()
body = message or _('Status %(old)s%(new)s by %(user)s') % {
'old': labels.get(old, old),
'new': labels.get(new_state, new_state),
'user': self.env.user.name,
}
rec.message_post(body=body)
return True
def action_mark_racked(self):
return self._set_state('racked')
def action_mark_in_process(self):
return self._set_state('in_process')
def action_mark_inspected(self):
return self._set_state('inspected')
def action_mark_packed(self):
return self._set_state('packed')
def action_mark_shipped(self):
return self._set_state('shipped')
def action_mark_returned(self):
return self._set_state('returned')
def action_mark_on_hold(self):
return self._set_state('on_hold')
def action_release_hold(self):
"""Lift on_hold and return the serial to in_process. Used when a
hold is resolved without scrap (e.g. visual blemish was actually
within tolerance after re-inspection)."""
return self._set_state('in_process')
def action_mark_scrapped(self):
"""Scrap a single serial. Operator should fill scrap_reason next
— view enforces it via a wizard form. Phase 3 hooks this into
the move log so the parent job's qty_scrapped auto-increments."""
return self._set_state('scrapped')
def action_reopen(self):
"""Manager-only override — un-pin a terminal state when a
correction is needed (e.g. wrong serial marked shipped). Audit
trail preserved via chatter; never silently rewrites history."""
for rec in self:
if not self.env.user.has_group('fusion_plating.group_fusion_plating_manager'):
from odoo.exceptions import UserError
raise UserError(_(
'Only the Plating Manager group can reopen a terminal '
'serial state. Contact your shop manager.'
))
return self._set_state('in_process', message=_(
'Serial reopened by %s — terminal state reverted for correction.'
) % self.env.user.name)
# Reverse link to invoice lines — safe here because account.move.line # Reverse link to invoice lines — safe here because account.move.line
# lives in this same module. Production (mrp) and delivery (logistics) # lives in this same module. Production (mrp) and delivery (logistics)
# reverse links are defined in their own modules' fp_serial inherits # reverse links are defined in their own modules' fp_serial inherits

View File

@@ -3,7 +3,8 @@
# 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.
from odoo import api, fields, models from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class SaleOrderLine(models.Model): class SaleOrderLine(models.Model):
@@ -60,18 +61,29 @@ class SaleOrderLine(models.Model):
string='Linked Quote', string='Linked Quote',
help='Quote that seeded this line. Links back for audit trail.', help='Quote that seeded this line. Links back for audit trail.',
) )
# Sub 9 — process variant override per line. NULL means "use the # Sub 9 (polished 2026-04-28) — process variant per line. The picker
# part's default variant". Domain restricts to root recipe nodes # now lets the estimator pick ANY root recipe in the system: the
# owned by the chosen part. # part's own variants, another customer's variants, or a template
# marked is_template. Cross-part picks auto-clone onto this part on
# save (see _onchange_process_variant_clone) so per-line edits never
# bleed across customers.
x_fc_process_variant_id = fields.Many2one( x_fc_process_variant_id = fields.Many2one(
'fusion.plating.process.node', 'fusion.plating.process.node',
string='Process Variant', string='Process Variant',
domain="[('part_catalog_id', '=', x_fc_part_catalog_id), " domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
"('parent_id', '=', False), ('node_type', '=', 'recipe')]",
ondelete='set null', ondelete='set null',
help='Pick a specific process variant for this order. Leave blank ' help='Pick any recipe — the part\'s own variant, another part\'s '
'to use the part\'s default variant. Variants are managed via ' 'recipe, or a template from the library. If the chosen recipe '
'the Process Composer on the part form.', 'doesn\'t belong to this part, it will be cloned onto the part '
'when the order saves so per-line edits stay scoped. Use the '
'Customize button on the line to open the Process Composer.',
)
x_fc_save_as_default_process = fields.Boolean(
string='Save as Default for Part',
default=False,
help='When ticked, the chosen process variant becomes this part\'s '
'default on order save — future orders for the same part '
'pre-fill with this variant.',
) )
x_fc_archived = fields.Boolean( x_fc_archived = fields.Boolean(
string='Archived', string='Archived',
@@ -84,15 +96,61 @@ class SaleOrderLine(models.Model):
# NB: sale.order.line in Odoo 19 does not support `tracking=True` on # NB: sale.order.line in Odoo 19 does not support `tracking=True` on
# inherited fields — Odoo emits a warning and ignores it. Audit trail # inherited fields — Odoo emits a warning and ignores it. Audit trail
# for these values lives on fp.serial.mail.thread instead. # for these values lives on fp.serial.mail.thread instead.
#
# 2026-04-28 Phase 1 — multi-serial support. Customer can ship 30 parts
# with 30 distinct serials on a single line. The M2M is the source of
# truth; `x_fc_serial_id` (M2O) becomes a computed alias of the first
# serial so existing reports / smart buttons / downstream code that
# still reads the singular keep working unchanged.
x_fc_serial_ids = fields.Many2many(
'fp.serial',
relation='fp_sale_order_line_serial_rel',
column1='line_id',
column2='serial_id',
string='Serial Numbers',
copy=False,
help='Customer-supplied serial numbers for the parts on this line. '
'Use the Bulk Add Serials button to paste a list, range-fill '
'(SN-001..SN-030), or scan barcodes. Count must not exceed '
'the line quantity.',
)
x_fc_serial_id = fields.Many2one( x_fc_serial_id = fields.Many2one(
'fp.serial', 'fp.serial',
string='Serial Number', string='Primary Serial',
ondelete='set null', compute='_compute_primary_serial',
inverse='_inverse_primary_serial',
search='_search_primary_serial',
store=False,
copy=False, copy=False,
help='Customer-supplied serial number for this line. Optional. ' help='First of the line\'s serials — back-compat alias kept so '
'Typing a value offers to create a new fp.serial record on ' 'pre-Phase-1 code (reports, smart buttons, downstream M2M '
'the fly; use the Generate Serial button to auto-sequence.', 'reverse links) keeps working. Setting this prepends the '
'serial to the M2M.',
) )
x_fc_serial_count = fields.Integer(
string='# Serials',
compute='_compute_serial_count',
)
@api.depends('x_fc_serial_ids')
def _compute_primary_serial(self):
for line in self:
line.x_fc_serial_id = line.x_fc_serial_ids[:1]
def _inverse_primary_serial(self):
for line in self:
if not line.x_fc_serial_id:
continue
if line.x_fc_serial_id not in line.x_fc_serial_ids:
line.x_fc_serial_ids = [(4, line.x_fc_serial_id.id)]
def _search_primary_serial(self, operator, value):
return [('x_fc_serial_ids', operator, value)]
@api.depends('x_fc_serial_ids')
def _compute_serial_count(self):
for line in self:
line.x_fc_serial_count = len(line.x_fc_serial_ids)
x_fc_job_number = fields.Char( x_fc_job_number = fields.Char(
string='Job #', string='Job #',
copy=False, copy=False,
@@ -140,6 +198,27 @@ class SaleOrderLine(models.Model):
if line.x_fc_revision_pick_id: if line.x_fc_revision_pick_id:
line.x_fc_part_catalog_id = line.x_fc_revision_pick_id line.x_fc_part_catalog_id = line.x_fc_revision_pick_id
def _fp_apply_recipe_polish(self):
"""Post-write step: auto-clone any cross-part recipe pick and
honour the Save-as-Default toggle.
Called from create() and write() so the polish runs on every
save path — onchange alone doesn't cover programmatic creates
(the direct-order wizard, imports, the sale_mrp bridge, etc.).
"""
for line in self:
if not line.x_fc_part_catalog_id or not line.x_fc_process_variant_id:
continue
recipe = line.x_fc_process_variant_id
if (not recipe.part_catalog_id
or recipe.part_catalog_id.id != line.x_fc_part_catalog_id.id):
clone = line._fp_clone_recipe_to_part()
if clone and clone.id != recipe.id:
line.x_fc_process_variant_id = clone.id
recipe = clone
if line.x_fc_save_as_default_process and recipe.part_catalog_id:
line.x_fc_part_catalog_id.action_set_default_variant(recipe.id)
@api.model_create_multi @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
"""Default `x_fc_internal_description` from `name` when a caller """Default `x_fc_internal_description` from `name` when a caller
@@ -175,7 +254,9 @@ class SaleOrderLine(models.Model):
part = Part.browse(vals['x_fc_part_catalog_id']).exists() part = Part.browse(vals['x_fc_part_catalog_id']).exists()
if part and part.revision: if part and part.revision:
vals['x_fc_revision_snapshot'] = part.revision vals['x_fc_revision_snapshot'] = part.revision
return super().create(vals_list) lines = super().create(vals_list)
lines._fp_apply_recipe_polish()
return lines
def write(self, vals): def write(self, vals):
# Sub 5 — keep the revision snapshot in lockstep with the line's # Sub 5 — keep the revision snapshot in lockstep with the line's
@@ -190,7 +271,16 @@ class SaleOrderLine(models.Model):
for line in self: for line in self:
if line.x_fc_part_catalog_id.id != new_part.id: if line.x_fc_part_catalog_id.id != new_part.id:
line.x_fc_revision_snapshot = new_part.revision line.x_fc_revision_snapshot = new_part.revision
return super().write(vals) result = super().write(vals)
# Only run the polish when something relevant actually changed —
# avoids re-running on every unrelated write (e.g. price updates).
if any(k in vals for k in (
'x_fc_process_variant_id',
'x_fc_part_catalog_id',
'x_fc_save_as_default_process',
)):
self._fp_apply_recipe_polish()
return result
@api.onchange('x_fc_description_template_id') @api.onchange('x_fc_description_template_id')
def _onchange_description_template(self): def _onchange_description_template(self):
@@ -229,7 +319,12 @@ class SaleOrderLine(models.Model):
vals = super()._prepare_invoice_line(**optional_values) vals = super()._prepare_invoice_line(**optional_values)
if self.x_fc_part_catalog_id: if self.x_fc_part_catalog_id:
vals['x_fc_part_catalog_id'] = self.x_fc_part_catalog_id.id vals['x_fc_part_catalog_id'] = self.x_fc_part_catalog_id.id
if self.x_fc_serial_id: if self.x_fc_serial_ids:
# Carry the full M2M to the invoice line. Back-compat alias
# x_fc_serial_id will still resolve to the first one if any
# downstream code only reads the singular.
vals['x_fc_serial_ids'] = [(6, 0, self.x_fc_serial_ids.ids)]
elif self.x_fc_serial_id:
vals['x_fc_serial_id'] = self.x_fc_serial_id.id vals['x_fc_serial_id'] = self.x_fc_serial_id.id
if self.x_fc_job_number: if self.x_fc_job_number:
vals['x_fc_job_number'] = self.x_fc_job_number vals['x_fc_job_number'] = self.x_fc_job_number
@@ -241,13 +336,95 @@ class SaleOrderLine(models.Model):
@api.onchange('x_fc_part_catalog_id') @api.onchange('x_fc_part_catalog_id')
def _onchange_part_default_variant(self): def _onchange_part_default_variant(self):
"""Clear process variant when the part changes — domain would """When the part changes, pre-fill the variant from the part's
otherwise leave a stale value pointing at the wrong part.""" default_process_id (if set) so the line carries a sensible
starting point. The estimator can override after.
Previously cleared the variant entirely when the part changed
(because the variant picker was scoped to the part). Now that
the picker is system-wide, we instead pre-fill from the part's
default — much more useful.
"""
for line in self: for line in self:
if (line.x_fc_process_variant_id if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id:
and line.x_fc_process_variant_id.part_catalog_id line.x_fc_process_variant_id = line.x_fc_part_catalog_id.default_process_id
!= line.x_fc_part_catalog_id):
line.x_fc_process_variant_id = False def _fp_clone_recipe_to_part(self):
"""Deep-copy the picked recipe onto this line's part if it isn't
already scoped there. Returns the cloned (or unchanged) variant.
Edge cases handled:
* No recipe picked → no-op, return False.
* No part on the line → no-op (we need a part to scope the clone).
* Recipe already belongs to this part → no-op, return as-is.
* Recipe belongs to a different part / is a template / is unscoped
→ deep-copy via Odoo's standard recursive copy(), reparent the
clone onto this part, name-stamp it for traceability.
"""
self.ensure_one()
recipe = self.x_fc_process_variant_id
part = self.x_fc_part_catalog_id
if not recipe or not part:
return recipe
if recipe.part_catalog_id and recipe.part_catalog_id.id == part.id:
return recipe # already scoped — nothing to do
# Clone — Odoo's default copy() recurses through child_ids when the
# field has copy=True. fp.process.node sets that on its tree, so
# one call gets us a full sub-tree clone.
clone_name = recipe.name or _('Untitled Recipe')
# If the source carried a part scope, preface the clone name with
# the customer's part number for quick identification on the
# variant dropdown later.
if not clone_name.lower().endswith(part.part_number.lower() if part.part_number else ''):
clone_name = '%s%s' % (clone_name, part.part_number or part.display_name)
clone = recipe.copy({
'name': clone_name,
'part_catalog_id': part.id,
'is_template': False, # never propagate template flag
'is_default_variant': False, # estimator opts in via toggle
})
return clone
def action_customize_process(self):
"""Open the Process Composer for this line's process variant.
Auto-clones first if the variant isn't yet scoped to this part —
the operator should never edit a recipe that's shared across
customers (their edits would bleed). After cloning, the line
ends up pointing at the fresh per-part copy.
"""
self.ensure_one()
if not self.x_fc_part_catalog_id:
from odoo.exceptions import UserError
raise UserError(_(
'Pick a part on this line before customizing the process — '
'the recipe needs a part to scope the variant.'
))
if not self.x_fc_process_variant_id:
from odoo.exceptions import UserError
raise UserError(_(
'Pick a process variant on this line first. To start from '
'scratch, use the part\'s Compose button instead.'
))
clone_or_existing = self._fp_clone_recipe_to_part()
if clone_or_existing.id != self.x_fc_process_variant_id.id:
self.x_fc_process_variant_id = clone_or_existing.id
return {
'type': 'ir.actions.client',
'tag': 'fp_part_process_composer',
'name': _('Customize Process — %s') % (
self.x_fc_part_catalog_id.display_name
or self.x_fc_part_catalog_id.part_number
or '?'
),
'params': {
'part_id': self.x_fc_part_catalog_id.id,
'part_display': self.x_fc_part_catalog_id.display_name
or self.x_fc_part_catalog_id.part_number,
'focus_variant_id': clone_or_existing.id,
},
'target': 'current',
}
@api.onchange('x_fc_coating_config_id') @api.onchange('x_fc_coating_config_id')
def _onchange_coating_clears_thickness(self): def _onchange_coating_clears_thickness(self):
@@ -263,19 +440,55 @@ class SaleOrderLine(models.Model):
line.x_fc_thickness_id = False line.x_fc_thickness_id = False
def action_generate_serial(self): def action_generate_serial(self):
"""Create a fresh fp.serial for this line using the shop sequence.""" """Generate one new auto-sequenced serial and append it to the M2M.
Phase 1 polish: the legacy single-serial behaviour was "create one
serial and pin it to x_fc_serial_id". Now we append to the M2M so
repeated clicks add more serials (handy when the customer didn't
send any and the shop wants to assign N).
"""
self.ensure_one() self.ensure_one()
if self.x_fc_serial_id:
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.serial',
'res_id': self.x_fc_serial_id.id,
'view_mode': 'form',
}
seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000' seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000'
serial = self.env['fp.serial'].create({ serial = self.env['fp.serial'].create({
'name': seq, 'name': seq,
'sale_order_line_id': self.id, 'sale_order_line_id': self.id,
}) })
self.x_fc_serial_id = serial.id self.x_fc_serial_ids = [(4, serial.id)]
return False return False
def action_open_serial_bulk_add(self):
"""Open the Bulk Add Serials wizard for this line."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.serial.bulk.add.wizard',
'view_mode': 'form',
'target': 'new',
'name': _('Bulk Add Serials'),
'context': {
'default_target_model': 'sale.order.line',
'default_target_id': self.id,
'default_qty_expected': int(self.product_uom_qty or 0),
},
}
@api.constrains('x_fc_serial_ids', 'product_uom_qty')
def _check_serial_count_against_qty(self):
"""Block save when the operator has attached more serials than
the line quantity. Under-count is allowed (some customers ship
with serials only on a subset of parts).
"""
for line in self:
if line.x_fc_serial_ids and line.product_uom_qty:
n = len(line.x_fc_serial_ids)
if n > int(line.product_uom_qty):
raise ValidationError(_(
'Line "%(part)s": %(n)s serials attached but only '
'%(qty)s parts ordered. Either reduce the serial '
'list, increase the quantity, or split the line.'
) % {
'part': (line.x_fc_part_catalog_id.display_name
or line.product_id.display_name or ''),
'n': n,
'qty': int(line.product_uom_qty),
})

View File

@@ -44,6 +44,8 @@ access_fp_sale_desc_template_manager,fp.sale.description.template.manager,model_
access_fp_serial_user,fp.serial.user,model_fp_serial,base.group_user,1,0,0,0 access_fp_serial_user,fp.serial.user,model_fp_serial,base.group_user,1,0,0,0
access_fp_serial_estimator,fp.serial.estimator,model_fp_serial,fusion_plating_configurator.group_fp_estimator,1,1,1,0 access_fp_serial_estimator,fp.serial.estimator,model_fp_serial,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_serial_manager,fp.serial.manager,model_fp_serial,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_serial_manager,fp.serial.manager,model_fp_serial,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_serial_bulk_add_estimator,fp.serial.bulk.add.estimator,model_fp_serial_bulk_add_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bulk_add_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0 access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0 access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
44 access_fp_serial_user fp.serial.user model_fp_serial base.group_user 1 0 0 0
45 access_fp_serial_estimator fp.serial.estimator model_fp_serial fusion_plating_configurator.group_fp_estimator 1 1 1 0
46 access_fp_serial_manager fp.serial.manager model_fp_serial fusion_plating.group_fusion_plating_manager 1 1 1 1
47 access_fp_serial_bulk_add_estimator fp.serial.bulk.add.estimator model_fp_serial_bulk_add_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
48 access_fp_serial_bulk_add_manager fp.serial.bulk.add.manager model_fp_serial_bulk_add_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
49 access_fp_coating_thickness_user fp.coating.thickness.user model_fp_coating_thickness base.group_user 1 0 0 0
50 access_fp_coating_thickness_estimator fp.coating.thickness.estimator model_fp_coating_thickness fusion_plating_configurator.group_fp_estimator 1 1 1 0
51 access_fp_coating_thickness_manager fp.coating.thickness.manager model_fp_coating_thickness fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -188,6 +188,7 @@ export class FpPartProcessComposer extends Component {
} }
openRecipeEditor(rootId) { openRecipeEditor(rootId) {
// Tree editor — the original drag-and-drop hierarchy view.
const id = rootId || this.state.rootId; const id = rootId || this.state.rootId;
if (!id) return; if (!id) return;
this.action.doAction({ this.action.doAction({
@@ -199,6 +200,22 @@ export class FpPartProcessComposer extends Component {
}); });
} }
openRecipeSimpleEditor(rootId) {
// Simple Recipe Editor (Sub 12a) — flat 2-pane drag-drop layout.
// Lives alongside the tree editor; the user picks per-variant
// which one to open. Both edit the same underlying tree, so
// changes flow back-and-forth without conflict.
const id = rootId || this.state.rootId;
if (!id) return;
this.action.doAction({
type: "ir.actions.client",
tag: "fp_simple_recipe_editor",
name: `Process Editor (Simple) — ${(this.state.part && this.state.part.display) || ""}`,
context: { recipe_id: id, part_id: this.partId },
target: "current",
});
}
backToPart() { backToPart() {
this.action.doAction({ this.action.doAction({
type: "ir.actions.act_window", type: "ir.actions.act_window",

View File

@@ -83,8 +83,15 @@
<td class="text-end"> <td class="text-end">
<button class="btn btn-sm btn-primary me-1" <button class="btn btn-sm btn-primary me-1"
t-att-disabled="state.busy" t-att-disabled="state.busy"
t-on-click="() => this.openRecipeEditor(v.id)"> t-on-click="() => this.openRecipeEditor(v.id)"
<i class="fa fa-pencil"/> Edit title="Open the tree editor (drag-and-drop hierarchy view)">
<i class="fa fa-pencil"/> Tree
</button>
<button class="btn btn-sm btn-info me-1"
t-att-disabled="state.busy"
t-on-click="() => this.openRecipeSimpleEditor(v.id)"
title="Open the Simple Recipe Editor (flat 2-pane drag-drop)">
<i class="fa fa-list-alt"/> Simple
</button> </button>
<button class="btn btn-sm btn-secondary me-1" <button class="btn btn-sm btn-secondary me-1"
t-att-disabled="state.busy" t-att-disabled="state.busy"

View File

@@ -45,9 +45,8 @@
<field name="active" widget="boolean_toggle"/> <field name="active" widget="boolean_toggle"/>
</group> </group>
</group> </group>
<group string="Notes"> <separator string="Notes"/>
<field name="notes" nolabel="1" colspan="2"/> <field name="notes" colspan="2"/>
</group>
</sheet> </sheet>
<chatter/> <chatter/>
</form> </form>

View File

@@ -176,12 +176,26 @@
icon="fa-wrench" icon="fa-wrench"
class="btn-primary" class="btn-primary"
help="Open the Process Composer to manage this part's process variants."/> help="Open the Process Composer to manage this part's process variants."/>
<button name="action_open_default_simple_editor" type="object"
string="Edit Default (Simple)"
icon="fa-list-alt"
class="btn-info ms-1"
invisible="not default_process_id"
help="Jump straight to the Simple Recipe Editor for the default variant — flat 2-pane drag-drop layout."/>
<button name="action_open_default_tree_editor" type="object"
string="Edit Default (Tree)"
icon="fa-sitemap"
class="btn-secondary ms-1"
invisible="not default_process_id"
help="Jump straight to the Tree Editor for the default variant."/>
</div> </div>
<p class="text-muted mt-3"> <p class="text-muted mt-3">
The <strong>Compose</strong> button opens the Process Composer where you can add The <strong>Compose</strong> button opens the Process Composer where you can add
multiple process <em>variants</em> for this part — for example "Standard ENP", multiple process <em>variants</em> for this part — for example "Standard ENP",
"Selective Masking", "Rework". One variant is flagged as default; estimators "Selective Masking", "Rework". One variant is flagged as default; estimators
may pick a different variant on a per-order basis. may pick a different variant on a per-order basis. Each variant can be edited
in either the <strong>Tree</strong> or <strong>Simple</strong> view — same data,
two layouts.
</p> </p>
<field name="process_variant_ids" readonly="1"> <field name="process_variant_ids" readonly="1">
<list> <list>
@@ -189,6 +203,12 @@
<field name="variant_label"/> <field name="variant_label"/>
<field name="name"/> <field name="name"/>
<field name="estimated_duration" optional="hide"/> <field name="estimated_duration" optional="hide"/>
<button name="action_open_simple_editor" type="object"
string="Simple" icon="fa-list-alt"
class="btn-link"/>
<button name="action_open_tree_editor" type="object"
string="Tree" icon="fa-sitemap"
class="btn-link"/>
</list> </list>
</field> </field>
</page> </page>
@@ -207,9 +227,7 @@
<field name="has_threads"/> <field name="has_threads"/>
</group> </group>
</group> </group>
<group> <field name="masking_description" placeholder="e.g. Mask threaded holes, mask bore ID"/>
<field name="masking_description" placeholder="e.g. Mask threaded holes, mask bore ID"/>
</group>
</page> </page>
<page string="Attachments" name="attachments"> <page string="Attachments" name="attachments">
<group> <group>

View File

@@ -276,9 +276,7 @@
options="{'currency_field': 'currency_id'}"/> options="{'currency_field': 'currency_id'}"/>
</group> </group>
</group> </group>
<group> <field name="price_breakdown_html" readonly="1" colspan="2"/>
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
</group>
</div> </div>
</div> </div>
@@ -299,10 +297,9 @@
<field name="lost_date" readonly="1"/> <field name="lost_date" readonly="1"/>
</group> </group>
</group> </group>
<group string="Notes"> <separator string="Notes"/>
<field name="lost_details" nolabel="1" colspan="2" <field name="lost_details" colspan="2"
placeholder="What did we learn? (Price point competitor beat, spec we didn't meet, etc.)"/> placeholder="What did we learn? (Price point competitor beat, spec we didn't meet, etc.)"/>
</group>
</page> </page>
<page string="Notes" name="notes"> <page string="Notes" name="notes">
<field name="notes" placeholder="Internal notes about this quote..."/> <field name="notes" placeholder="Internal notes about this quote..."/>

View File

@@ -58,10 +58,9 @@
<field name="internal_description" nolabel="1" colspan="2" <field name="internal_description" nolabel="1" colspan="2"
placeholder="What the shop floor sees on the WO / traveler…"/> placeholder="What the shop floor sees on the WO / traveler…"/>
</group> </group>
<group string="Customer-Facing Description"> <separator string="Customer-Facing Description"/>
<field name="customer_facing_description" nolabel="1" colspan="2" <field name="customer_facing_description" colspan="2"
placeholder="Electroless nickel plating per AMS 2404, Class I, Type II…"/> placeholder="Electroless nickel plating per AMS 2404, Class I, Type II…"/>
</group>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -0,0 +1,191 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Phase 2 (2026-04-28) — relocates the fp.serial views from
fusion_plating_bridge_mrp (uninstalled in Sub 11) into configurator
where the model lives. Adds the new state machine to the form +
list with workflow buttons + status badge.
-->
<odoo>
<record id="view_fp_serial_form" model="ir.ui.view">
<field name="name">fp.serial.form</field>
<field name="model">fp.serial</field>
<field name="arch" type="xml">
<form string="Serial Number">
<header>
<button name="action_mark_racked" type="object"
string="Mark Racked" class="btn-primary"
invisible="state != 'received'"/>
<button name="action_mark_in_process" type="object"
string="Start Processing" class="btn-primary"
invisible="state != 'racked'"/>
<button name="action_mark_inspected" type="object"
string="Pass Inspection" class="btn-success"
invisible="state != 'in_process'"/>
<button name="action_mark_packed" type="object"
string="Mark Packed" class="btn-primary"
invisible="state != 'inspected'"/>
<button name="action_mark_shipped" type="object"
string="Mark Shipped" class="btn-success"
invisible="state != 'packed'"/>
<button name="action_mark_on_hold" type="object"
string="Hold" class="btn-warning"
invisible="state in ('on_hold', 'shipped', 'scrapped')"/>
<button name="action_release_hold" type="object"
string="Release Hold" class="btn-secondary"
invisible="state != 'on_hold'"/>
<button name="action_mark_scrapped" type="object"
string="Scrap" class="btn-danger"
invisible="state in ('shipped', 'scrapped')"
confirm="Mark this serial as scrapped? This is reversible only via Reopen."/>
<button name="action_mark_returned" type="object"
string="Returned by Customer" class="btn-secondary"
invisible="state != 'shipped'"/>
<button name="action_reopen" type="object"
string="Reopen" class="btn-secondary"
groups="fusion_plating.group_fusion_plating_manager"
invisible="state not in ('shipped', 'scrapped')"
confirm="Reopen a terminal-state serial? Manager-only override; the chatter audit will record who, when, why."/>
<field name="state" widget="statusbar"
statusbar_visible="received,racked,in_process,inspected,packed,shipped"/>
</header>
<sheet>
<widget name="web_ribbon" title="Scrapped" bg_color="text-bg-danger"
invisible="state != 'scrapped'"/>
<widget name="web_ribbon" title="On Hold" bg_color="text-bg-warning"
invisible="state != 'on_hold'"/>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="not sale_order_id">
<div class="o_stat_info">
<span class="o_stat_text">Sale Order</span>
</div>
</button>
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-money"
invisible="invoice_count == 0">
<field name="invoice_count" widget="statinfo" string="Invoices"/>
</button>
<button name="action_view_part" type="object"
class="oe_stat_button" icon="fa-cube"
invisible="not part_id">
<div class="o_stat_info">
<span class="o_stat_text">Part</span>
</div>
</button>
</div>
<div class="oe_title">
<label for="name" string="Serial #"/>
<h1><field name="name" placeholder="e.g. SN-12345"/></h1>
</div>
<group>
<group>
<field name="customer_id" readonly="1"/>
<field name="part_id" readonly="1"/>
<field name="sale_order_id" readonly="1"/>
<field name="sale_order_line_id" readonly="1"/>
</group>
<group>
<field name="last_state_change" readonly="1"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<separator string="Scrap / Return Reason"
invisible="state not in ('scrapped', 'returned', 'on_hold')"/>
<field name="scrap_reason"
invisible="state not in ('scrapped', 'returned', 'on_hold')"
placeholder="What happened? Cause / disposition / who decided..."/>
<separator string="Notes"/>
<field name="notes"/>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_serial_list" model="ir.ui.view">
<field name="name">fp.serial.list</field>
<field name="model">fp.serial</field>
<field name="arch" type="xml">
<list string="Serial Numbers"
decoration-success="state in ('inspected', 'packed', 'shipped')"
decoration-info="state in ('received', 'racked', 'in_process')"
decoration-warning="state in ('on_hold', 'returned')"
decoration-danger="state == 'scrapped'">
<field name="name"/>
<field name="state" widget="badge"
decoration-success="state in ('inspected', 'packed', 'shipped')"
decoration-info="state in ('received', 'racked', 'in_process')"
decoration-warning="state in ('on_hold', 'returned')"
decoration-danger="state == 'scrapped'"/>
<field name="customer_id"/>
<field name="part_id"/>
<field name="sale_order_id"/>
<field name="last_state_change" optional="show"/>
<field name="invoice_count" string="Invoices" optional="hide"/>
<field name="create_date" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_serial_search" model="ir.ui.view">
<field name="name">fp.serial.search</field>
<field name="model">fp.serial</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="customer_id"/>
<field name="part_id"/>
<field name="sale_order_id"/>
<separator/>
<filter name="state_received" string="Received"
domain="[('state','=','received')]"/>
<filter name="state_in_process" string="In Process"
domain="[('state','=','in_process')]"/>
<filter name="state_inspected" string="Inspected"
domain="[('state','=','inspected')]"/>
<filter name="state_packed" string="Packed"
domain="[('state','=','packed')]"/>
<filter name="state_shipped" string="Shipped"
domain="[('state','=','shipped')]"/>
<separator/>
<filter name="state_on_hold" string="On Hold"
domain="[('state','=','on_hold')]"/>
<filter name="state_scrapped" string="Scrapped"
domain="[('state','=','scrapped')]"/>
<filter name="state_returned" string="Returned"
domain="[('state','=','returned')]"/>
<separator/>
<filter name="active_only" string="In Flight (not shipped/scrapped)"
domain="[('state','not in',('shipped','scrapped'))]"/>
<group>
<filter name="group_state" string="Status"
context="{'group_by': 'state'}"/>
<filter name="group_customer" string="Customer"
context="{'group_by': 'customer_id'}"/>
<filter name="group_part" string="Part"
context="{'group_by': 'part_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_serial" model="ir.actions.act_window">
<field name="name">Serial Numbers</field>
<field name="res_model">fp.serial</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_serial_search"/>
</record>
<menuitem id="menu_fp_serial"
name="Serial Numbers"
parent="fusion_plating_configurator.menu_fp_sales"
action="action_fp_serial"
sequence="60"/>
</odoo>

View File

@@ -52,9 +52,7 @@
options="{'currency_field': 'currency_id'}"/> options="{'currency_field': 'currency_id'}"/>
</group> </group>
</group> </group>
<group> <field name="description" placeholder="Description of this treatment step..."/>
<field name="description" placeholder="Description of this treatment step..."/>
</group>
<group> <group>
<field name="active" widget="boolean_toggle"/> <field name="active" widget="boolean_toggle"/>
</group> </group>

View File

@@ -188,10 +188,9 @@
<field name="x_fc_internal_note" nolabel="1" <field name="x_fc_internal_note" nolabel="1"
placeholder="Internal notes for estimator / planner / shop floor..."/> placeholder="Internal notes for estimator / planner / shop floor..."/>
</group> </group>
<group string="External Notes (customer-visible)"> <separator string="External Notes (customer-visible)"/>
<field name="x_fc_external_note" nolabel="1" <field name="x_fc_external_note"
placeholder="Notes that appear on the acknowledgement and portal..."/> placeholder="Notes that appear on the acknowledgement and portal..."/>
</group>
</group> </group>
</page> </page>
</xpath> </xpath>
@@ -214,17 +213,33 @@
optional="hide"/> optional="hide"/>
<field name="x_fc_coating_config_id" optional="show"/> <field name="x_fc_coating_config_id" optional="show"/>
<field name="x_fc_process_variant_id" <field name="x_fc_process_variant_id"
string="Variant" string="Process / Recipe"
options="{'no_create': True}" options="{'no_quick_create': True}"
invisible="not x_fc_part_catalog_id" invisible="not x_fc_part_catalog_id"
optional="show"/> optional="show"/>
<field name="x_fc_save_as_default_process"
string="Set as Part Default"
widget="boolean_toggle"
invisible="not x_fc_process_variant_id"
optional="hide"/>
<button name="action_customize_process" type="object"
string="Customize" icon="fa-pencil-square-o"
class="btn-link"
invisible="not x_fc_process_variant_id"/>
<field name="x_fc_thickness_id" <field name="x_fc_thickness_id"
options="{'no_create': True}" options="{'no_create': True}"
invisible="not x_fc_coating_config_id" invisible="not x_fc_coating_config_id"
optional="show"/> optional="show"/>
<field name="x_fc_serial_id" <field name="x_fc_serial_ids"
options="{'no_create_edit': False}" widget="many2many_tags"
options="{'no_quick_create': False, 'color_field': 'state_color'}"
optional="show"/> optional="show"/>
<field name="x_fc_serial_count"
string="# SN"
optional="hide"/>
<button name="action_open_serial_bulk_add" type="object"
string="Bulk Add Serials" icon="fa-list-ol"
class="btn-link"/>
<field name="x_fc_job_number" optional="show"/> <field name="x_fc_job_number" optional="show"/>
<field name="x_fc_revision_pick_id" <field name="x_fc_revision_pick_id"
string="Revision" string="Revision"

View File

@@ -8,3 +8,4 @@ from . import fp_add_from_so_wizard
from . import fp_add_from_quote_wizard from . import fp_add_from_quote_wizard
from . import fp_quote_promote_wizard from . import fp_quote_promote_wizard
from . import fp_part_catalog_import_wizard from . import fp_part_catalog_import_wizard
from . import fp_serial_bulk_add_wizard

View File

@@ -60,17 +60,27 @@ class FpDirectOrderLine(models.Model):
string='Additional Treatments', string='Additional Treatments',
help='Extra pre/post treatments applied to this line.', help='Extra pre/post treatments applied to this line.',
) )
# Sub 9 — explicit per-line process variant override. NULL means # Sub 9 (polished 2026-04-28) — process variant per line. The picker
# "use the part's default variant". # now lets the estimator pick ANY root recipe in the system: the
# part's own variants, another customer's variants, or a template
# marked is_template. Cross-part picks auto-clone onto this part on
# save (see _fp_apply_recipe_polish) so per-line edits never bleed.
process_variant_id = fields.Many2one( process_variant_id = fields.Many2one(
'fusion.plating.process.node', 'fusion.plating.process.node',
string='Process Variant', string='Process Variant',
domain="[('part_catalog_id', '=', part_catalog_id), " domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
"('parent_id', '=', False), ('node_type', '=', 'recipe')]",
ondelete='set null', ondelete='set null',
help='Pick a specific process variant for this line. Leave blank ' help='Pick any recipe — the part\'s own variant, another part\'s '
'to use the part\'s default variant. Manage variants via the ' 'recipe, or a template from the library. Cross-part picks '
'Process Composer on the part form.', 'are cloned onto this part on save so per-line edits stay '
'scoped. Use the Customize button to open the Process '
'Composer for the chosen variant.',
)
save_as_default_process = fields.Boolean(
string='Set as Part Default',
help='When ticked, the chosen process variant becomes this part\'s '
'default on order submit — future orders for the same part '
'pre-fill with this variant.',
) )
# Read-only preview of the process tree that WILL drive WO generation # Read-only preview of the process tree that WILL drive WO generation
# for this line. Resolution priority: # for this line. Resolution priority:
@@ -116,26 +126,38 @@ class FpDirectOrderLine(models.Model):
@api.onchange('part_catalog_id') @api.onchange('part_catalog_id')
def _onchange_part_clears_variant(self): def _onchange_part_clears_variant(self):
"""Clear variant pick when the part changes (variants are part-scoped). """Pre-fill the line from the part's saved defaults when the part
changes.
2026-04-28 polish: variant is no longer cleared — instead it
pre-fills from the part's `default_process_id` so the estimator
gets a sensible starting point. (Domain is system-wide now, so
a stale value would still load fine; we just upgrade the UX.)
Pre-fill coating + treatments from the part's saved defaults so Pre-fill coating + treatments from the part's saved defaults so
Sarah doesn't re-pick the same coating every repeat customer. the estimator doesn't re-pick the same coating every repeat
Defaults only apply when the line currently has no coating set customer. Defaults only apply when the line currently has no
— editing an existing line with a chosen coating doesn't get coating set — editing an existing line with a chosen coating
clobbered. doesn't get clobbered.
For BRAND-NEW parts (no defaults saved yet) auto-tick For BRAND-NEW parts (no defaults saved yet) auto-tick
`push_to_defaults` so Sarah's first coating pick gets persisted `push_to_defaults` so the first coating pick gets persisted
back to the part. Without this Sarah has to remember to tick the back to the part. Without this, the estimator has to remember
toggle herself, and the second order doesn't pre-fill. to tick the toggle and the second order doesn't pre-fill.
Returns a warning popup explaining what's happening. Returns a warning popup explaining what's happening.
""" """
warning = None warning = None
for rec in self: for rec in self:
# Variant clear (original behaviour). # Pre-fill variant from the part's default (was: blanket clear).
if (rec.process_variant_id if rec.part_catalog_id and rec.part_catalog_id.default_process_id:
and rec.process_variant_id.part_catalog_id != rec.part_catalog_id): # Only overwrite when blank or pointing at a different part —
rec.process_variant_id = False # don't clobber a deliberate cross-part pick the estimator
# made before changing the part.
if (not rec.process_variant_id
or (rec.process_variant_id.part_catalog_id
and rec.process_variant_id.part_catalog_id.id
!= rec.part_catalog_id.id)):
rec.process_variant_id = rec.part_catalog_id.default_process_id
if not rec.part_catalog_id: if not rec.part_catalog_id:
continue continue
part = rec.part_catalog_id part = rec.part_catalog_id
@@ -266,17 +288,50 @@ class FpDirectOrderLine(models.Model):
compute='_compute_is_missing_info', compute='_compute_is_missing_info',
) )
# ---- Sub 5 — Serial / Job# / Thickness ------------------------------- # ---- Sub 5 / Phase 1 — Serials / Job# / Thickness --------------------
# These mirror the SO-line fields and are carried over when the wizard # These mirror the SO-line fields and are carried over when the wizard
# creates the sale order. Serial stays optional; Job# is left blank # creates the sale order. Serial stays optional; Job# is left blank
# here and gets auto-assigned by action_confirm on the SO. # here and gets auto-assigned by action_confirm on the SO.
#
# 2026-04-28 Phase 1 — multi-serial. M2M is the source of truth;
# serial_id stays as a computed alias so existing flows that read
# the singular continue to work.
serial_ids = fields.Many2many(
'fp.serial',
relation='fp_direct_order_line_serial_rel',
column1='line_id',
column2='serial_id',
string='Serial Numbers',
help='Customer-supplied serial numbers. Use Bulk Add to paste a '
'list, range-fill (SN-001..SN-030), or scan barcodes.',
)
serial_count = fields.Integer(
compute='_compute_serial_count',
string='# Serials',
)
serial_id = fields.Many2one( serial_id = fields.Many2one(
'fp.serial', 'fp.serial',
string='Serial Number', string='Primary Serial',
ondelete='set null', compute='_compute_primary_serial',
help='Optional. Typing a value offers to create a new serial on ' inverse='_inverse_primary_serial',
'the fly, or hit "Generate Serial" to auto-sequence.', store=False,
help='First of the line\'s serials — back-compat alias.',
) )
@api.depends('serial_ids')
def _compute_serial_count(self):
for rec in self:
rec.serial_count = len(rec.serial_ids)
@api.depends('serial_ids')
def _compute_primary_serial(self):
for rec in self:
rec.serial_id = rec.serial_ids[:1]
def _inverse_primary_serial(self):
for rec in self:
if rec.serial_id and rec.serial_id not in rec.serial_ids:
rec.serial_ids = [(4, rec.serial_id.id)]
job_number = fields.Char(string='Job #') job_number = fields.Char(string='Job #')
thickness_id = fields.Many2one( thickness_id = fields.Many2one(
'fp.coating.thickness', 'fp.coating.thickness',
@@ -309,14 +364,48 @@ class FpDirectOrderLine(models.Model):
rec.thickness_id = False rec.thickness_id = False
def action_generate_serial(self): def action_generate_serial(self):
"""Create an auto-sequenced fp.serial and assign it to this line.""" """Generate one auto-sequenced fp.serial and append to the M2M.
Phase 1: appends instead of replacing — repeated clicks add more.
"""
self.ensure_one() self.ensure_one()
if self.serial_id:
return False
seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000' seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000'
self.serial_id = self.env['fp.serial'].create({'name': seq}).id new_serial = self.env['fp.serial'].create({'name': seq})
self.serial_ids = [(4, new_serial.id)]
return False return False
def action_open_serial_bulk_add(self):
"""Open the Bulk Add Serials wizard for this Direct Order line."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.serial.bulk.add.wizard',
'view_mode': 'form',
'target': 'new',
'name': _('Bulk Add Serials'),
'context': {
'default_target_model': 'fp.direct.order.line',
'default_target_id': self.id,
'default_qty_expected': int(self.quantity or 0),
},
}
@api.constrains('serial_ids', 'quantity')
def _check_serial_count_against_qty(self):
for rec in self:
if rec.serial_ids and rec.quantity:
n = len(rec.serial_ids)
if n > int(rec.quantity):
raise UserError(_(
'Line "%(part)s": %(n)s serials attached but only '
'%(qty)s parts ordered. Reduce the serial list, '
'increase the quantity, or split the line.'
) % {
'part': (rec.part_catalog_id.display_name or ''),
'n': n,
'qty': int(rec.quantity),
})
# ---- Onchange ---- # ---- Onchange ----
@api.onchange('quote_id') @api.onchange('quote_id')
def _onchange_quote_id(self): def _onchange_quote_id(self):
@@ -504,3 +593,100 @@ class FpDirectOrderLine(models.Model):
else: else:
new_rev.drawing_attachment_ids = [(4, drawing_att.id)] new_rev.drawing_attachment_ids = [(4, drawing_att.id)]
return new_rev return new_rev
# ==================================================================
# 2026-04-28 polish — recipe handling shared with sale.order.line
# ==================================================================
def _fp_clone_recipe_to_part(self):
"""Deep-copy the picked recipe onto this line's part if it isn't
already scoped there. Returns the cloned (or unchanged) variant.
Mirrors `sale.order.line._fp_clone_recipe_to_part` — same
contract, same edge cases. The wizard runs this on every save
path (create/write) plus when Customize is clicked, so a
cross-part pick never leaks edits to the source recipe.
"""
self.ensure_one()
recipe = self.process_variant_id
part = self.part_catalog_id
if not recipe or not part:
return recipe
if recipe.part_catalog_id and recipe.part_catalog_id.id == part.id:
return recipe
clone_name = recipe.name or _('Untitled Recipe')
if part.part_number and part.part_number.lower() not in clone_name.lower():
clone_name = '%s%s' % (clone_name, part.part_number or part.display_name)
clone = recipe.copy({
'name': clone_name,
'part_catalog_id': part.id,
'is_template': False,
'is_default_variant': False,
})
return clone
def _fp_apply_recipe_polish(self):
"""Post-write step: auto-clone any cross-part recipe pick and
honour the Save-as-Default toggle. Called from create() and
write()."""
for line in self:
if not line.part_catalog_id or not line.process_variant_id:
continue
recipe = line.process_variant_id
if (not recipe.part_catalog_id
or recipe.part_catalog_id.id != line.part_catalog_id.id):
clone = line._fp_clone_recipe_to_part()
if clone and clone.id != recipe.id:
line.process_variant_id = clone.id
recipe = clone
if line.save_as_default_process and recipe.part_catalog_id:
line.part_catalog_id.action_set_default_variant(recipe.id)
def action_customize_process(self):
"""Open the Process Composer for this line's variant — auto-clones
first if the variant isn't yet scoped to this part."""
self.ensure_one()
if not self.part_catalog_id:
raise UserError(_(
'Pick a part on this line before customizing the process — '
'the recipe needs a part to scope the variant.'
))
if not self.process_variant_id:
raise UserError(_(
'Pick a process variant on this line first. To start from '
'scratch, use the part\'s Compose button instead.'
))
clone_or_existing = self._fp_clone_recipe_to_part()
if clone_or_existing.id != self.process_variant_id.id:
self.process_variant_id = clone_or_existing.id
return {
'type': 'ir.actions.client',
'tag': 'fp_part_process_composer',
'name': _('Customize Process — %s') % (
self.part_catalog_id.display_name
or self.part_catalog_id.part_number
or '?'
),
'params': {
'part_id': self.part_catalog_id.id,
'part_display': self.part_catalog_id.display_name
or self.part_catalog_id.part_number,
'focus_variant_id': clone_or_existing.id,
},
'target': 'current',
}
@api.model_create_multi
def create(self, vals_list):
lines = super().create(vals_list)
lines._fp_apply_recipe_polish()
return lines
def write(self, vals):
result = super().write(vals)
if any(k in vals for k in (
'process_variant_id',
'part_catalog_id',
'save_as_default_process',
)):
self._fp_apply_recipe_polish()
return result

View File

@@ -510,8 +510,13 @@ class FpDirectOrderWizard(models.Model):
'x_fc_is_one_off': line.is_one_off, 'x_fc_is_one_off': line.is_one_off,
'x_fc_quote_id': line.quote_id.id or False, 'x_fc_quote_id': line.quote_id.id or False,
'x_fc_process_variant_id': line.process_variant_id.id or False, 'x_fc_process_variant_id': line.process_variant_id.id or False,
# Sub 5 — carry serial / job# / thickness onto the SO line. 'x_fc_save_as_default_process': line.save_as_default_process,
# Revision snapshot auto-fills on SO-line create from the part. # Sub 5 / Phase 1 — carry serial M2M to the SO line.
# x_fc_serial_id is back-compat alias and auto-resolves
# from x_fc_serial_ids on SO-line read; passing both is
# safe (the alias setter just appends to the M2M).
'x_fc_serial_ids': ([(6, 0, line.serial_ids.ids)]
if line.serial_ids else False),
'x_fc_serial_id': line.serial_id.id or False, 'x_fc_serial_id': line.serial_id.id or False,
'x_fc_job_number': line.job_number or False, 'x_fc_job_number': line.job_number or False,
'x_fc_thickness_id': line.thickness_id.id or False, 'x_fc_thickness_id': line.thickness_id.id or False,

View File

@@ -156,14 +156,23 @@
optional="hide"/> optional="hide"/>
<field name="coating_config_id"/> <field name="coating_config_id"/>
<field name="process_variant_id" <field name="process_variant_id"
string="Variant" string="Process / Recipe"
options="{'no_create': True}" options="{'no_quick_create': True}"
invisible="not part_catalog_id" invisible="not part_catalog_id"
optional="show"/> optional="show"/>
<field name="save_as_default_process"
string="Set as Part Default"
widget="boolean_toggle"
invisible="not process_variant_id"
optional="hide"/>
<button name="action_customize_process" type="object"
string="Customize" icon="fa-pencil-square-o"
class="btn-link"
invisible="not process_variant_id"/>
<field name="effective_process_id" <field name="effective_process_id"
string="Process" string="Effective Process"
readonly="1" readonly="1"
optional="show"/> optional="hide"/>
<field name="effective_process_source" <field name="effective_process_source"
string="Process Source" string="Process Source"
readonly="1" readonly="1"
@@ -172,9 +181,16 @@
options="{'no_create': True}" options="{'no_create': True}"
invisible="not coating_config_id" invisible="not coating_config_id"
optional="show"/> optional="show"/>
<field name="serial_id" <field name="serial_ids"
options="{'no_create_edit': False}" widget="many2many_tags"
options="{'no_quick_create': False, 'color_field': 'state_color'}"
optional="show"/> optional="show"/>
<field name="serial_count"
string="# SN"
optional="hide"/>
<button name="action_open_serial_bulk_add" type="object"
string="Bulk Add Serials" icon="fa-list-ol"
class="btn-link"/>
<field name="job_number" optional="hide"/> <field name="job_number" optional="hide"/>
<field name="treatment_ids" <field name="treatment_ids"
widget="many2many_tags" widget="many2many_tags"
@@ -210,9 +226,17 @@
<field name="treatment_ids" <field name="treatment_ids"
widget="many2many_tags"/> widget="many2many_tags"/>
<field name="process_variant_id" <field name="process_variant_id"
string="Process Variant" string="Process / Recipe"
options="{'no_create': True}" options="{'no_quick_create': True}"
invisible="not part_catalog_id"/> invisible="not part_catalog_id"/>
<field name="save_as_default_process"
string="Set as Part Default"
widget="boolean_toggle"
invisible="not process_variant_id"/>
<button name="action_customize_process" type="object"
string="Customize Process" icon="fa-pencil-square-o"
class="btn-link"
invisible="not process_variant_id"/>
<field name="effective_process_id" <field name="effective_process_id"
string="Effective Process" string="Effective Process"
readonly="1"/> readonly="1"/>

View File

@@ -0,0 +1,253 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Bulk-add serial numbers to a sale.order.line or fp.direct.order.line.
Three input modes — operator picks one:
1. **Paste a list** — one per line, comma- or whitespace-separated.
2. **Range fill** — prefix + start..end (e.g. SN- + 1..30 → SN-001..SN-030).
3. **Scan barcodes** — repeated input (kept simple for Phase 1: the same
paste textarea works for a barcode reader that types-and-Enters).
Existing serials with the same `name` are reused (the company-uniqueness
SQL constraint on fp.serial would block dupes anyway). New ones are
created and linked to the source line via sale_order_line_id when the
target is a sale.order.line.
Target abstracted via target_model + target_id so one wizard works for
both the SO line and the Direct Order wizard line.
"""
import re
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
class FpSerialBulkAddWizard(models.TransientModel):
_name = 'fp.serial.bulk.add.wizard'
_description = 'Fusion Plating — Bulk Add Serials'
target_model = fields.Selection(
[
('sale.order.line', 'Sale Order Line'),
('fp.direct.order.line', 'Direct Order Line'),
],
string='Target Model', required=True, readonly=True,
)
target_id = fields.Integer(string='Target Record ID', required=True, readonly=True)
qty_expected = fields.Integer(
string='Line Quantity', readonly=True,
help='How many parts the target line is ordered for. The wizard '
'warns if you try to add more serials than this.',
)
mode = fields.Selection(
[
('paste', 'Paste a List'),
('range', 'Range Fill'),
],
string='Mode', default='paste', required=True,
)
# --- Paste mode ---
paste_text = fields.Text(
string='Serial List',
help='One serial per line, or comma-separated. Whitespace and '
'blank lines are ignored. Barcode scanners that emit one '
'serial + Enter at a time also work — just leave the cursor '
'in this box and scan.',
)
# --- Range mode ---
prefix = fields.Char(
string='Prefix',
default='SN-',
help='Text prefix prepended to each generated serial (e.g. "SN-", '
'"WO123-", or blank for pure numeric).',
)
start_number = fields.Integer(
string='Start',
default=1,
help='First number in the range (inclusive).',
)
end_number = fields.Integer(
string='End',
default=10,
help='Last number in the range (inclusive). Must be ≥ start.',
)
pad_width = fields.Integer(
string='Pad Width',
default=3,
help='Zero-pad numbers to this width (3 → 001, 002, ... 030). '
'Set to 0 to disable padding.',
)
suffix = fields.Char(
string='Suffix',
help='Optional text appended after the number (e.g. "-A").',
)
range_preview = fields.Text(
string='Preview',
compute='_compute_range_preview',
readonly=True,
)
@api.depends('mode', 'prefix', 'start_number', 'end_number', 'pad_width', 'suffix')
def _compute_range_preview(self):
for wiz in self:
if wiz.mode != 'range':
wiz.range_preview = ''
continue
try:
names = wiz._build_range_names()
except (UserError, ValidationError) as e:
wiz.range_preview = '%s' % (e.args[0] if e.args else str(e))
continue
if len(names) <= 6:
wiz.range_preview = '\n'.join(names)
else:
wiz.range_preview = (
'\n'.join(names[:3])
+ '\n ...\n'
+ '\n'.join(names[-3:])
+ '\n(%s total)' % len(names)
)
# ==================================================================
def _build_range_names(self):
"""Resolve range_mode fields into a list of serial names."""
self.ensure_one()
if self.end_number < self.start_number:
raise ValidationError(_(
'End (%s) is before Start (%s).'
) % (self.end_number, self.start_number))
count = self.end_number - self.start_number + 1
if count > 1000:
raise ValidationError(_(
'Range covers %s entries — too many. Cap at 1000 per call.'
) % count)
names = []
prefix = self.prefix or ''
suffix = self.suffix or ''
pad = max(self.pad_width or 0, 0)
for n in range(self.start_number, self.end_number + 1):
num = str(n).zfill(pad) if pad else str(n)
names.append(f'{prefix}{num}{suffix}')
return names
def _parse_paste_text(self):
"""Split paste_text into a clean ordered list of serial names.
Splits on newline or comma. Trims whitespace. Drops blanks.
Preserves first-occurrence order (paste duplicates collapse to
one, with the dupe count surfaced in the chatter audit).
"""
self.ensure_one()
if not self.paste_text:
return []
tokens = re.split(r'[\s,;]+', self.paste_text.strip())
seen = set()
ordered = []
for tok in tokens:
tok = tok.strip()
if not tok or tok in seen:
continue
seen.add(tok)
ordered.append(tok)
return ordered
def _resolve_target(self):
"""Return the browseable target record."""
self.ensure_one()
if self.target_model not in ('sale.order.line', 'fp.direct.order.line'):
raise UserError(_('Unsupported target model: %s') % self.target_model)
target = self.env[self.target_model].browse(self.target_id).exists()
if not target:
raise UserError(_('Target line not found.'))
return target
# ==================================================================
def action_apply(self):
"""Materialise serials and append them to the target line's M2M."""
self.ensure_one()
target = self._resolve_target()
# 1. Resolve the list of names from the chosen mode.
if self.mode == 'paste':
names = self._parse_paste_text()
if not names:
raise UserError(_(
'Paste at least one serial number, or switch to Range '
'Fill mode.'
))
elif self.mode == 'range':
names = self._build_range_names()
else:
raise UserError(_('Unsupported mode: %s') % self.mode)
# 2. Quantity sanity check — block if we'd exceed the line qty.
target_field = (
'x_fc_serial_ids' if self.target_model == 'sale.order.line'
else 'serial_ids'
)
existing_count = len(target[target_field])
proposed_total = existing_count + len(names)
if self.qty_expected and proposed_total > self.qty_expected:
raise UserError(_(
'%(new)s new serials + %(existing)s already on the line '
'= %(total)s, but the line is only ordered for '
'%(qty)s parts. Reduce the list or increase the line '
'quantity first.'
) % {
'new': len(names),
'existing': existing_count,
'total': proposed_total,
'qty': self.qty_expected,
})
# 3. Reuse existing fp.serial records by name (uniqueness per
# company is SQL-constrained anyway), create the rest.
Serial = self.env['fp.serial']
existing = Serial.search([
('name', 'in', names),
('company_id', '=', self.env.company.id),
])
existing_by_name = {s.name: s for s in existing}
to_create = []
link_kwargs = {}
if self.target_model == 'sale.order.line':
link_kwargs['sale_order_line_id'] = target.id
for name in names:
if name in existing_by_name:
continue
to_create.append(dict(name=name, **link_kwargs))
created = Serial.create(to_create) if to_create else Serial.browse([])
all_serials = existing + created
# Order-preserving: rebuild from the input order so paste/range
# ordering is preserved on the M2M (matters for paste_text — the
# operator typed them in physical-rack order).
serial_by_name = {s.name: s for s in all_serials}
ordered_ids = [serial_by_name[n].id for n in names if n in serial_by_name]
target[target_field] = [(4, sid) for sid in ordered_ids]
# 4. Audit on the target's chatter (SO line for sale.order.line;
# parent SO for the wizard line which has no chatter).
msg = _(
'+%(n)s serials added (%(reused)s reused, %(created)s new): '
'%(preview)s'
) % {
'n': len(names),
'reused': len(existing),
'created': len(created),
'preview': ', '.join(names[:5]) + ('...' if len(names) > 5 else ''),
}
if hasattr(target, 'message_post'):
target.message_post(body=msg)
elif self.target_model == 'fp.direct.order.line' and target.wizard_id:
target.wizard_id.message_post(body=msg)
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_serial_bulk_add_wizard_form" model="ir.ui.view">
<field name="name">fp.serial.bulk.add.wizard.form</field>
<field name="model">fp.serial.bulk.add.wizard</field>
<field name="arch" type="xml">
<form string="Bulk Add Serials">
<sheet>
<group>
<field name="target_model" invisible="1"/>
<field name="target_id" invisible="1"/>
<field name="qty_expected"/>
<field name="mode" widget="radio"/>
</group>
<!-- Paste mode -->
<group invisible="mode != 'paste'">
<separator string="Paste a List"/>
<field name="paste_text" nolabel="1"
placeholder="One per line, or comma-separated.&#10;Example:&#10;SN-001&#10;SN-002&#10;CUST-12345&#10;or scan barcodes — one per Enter."/>
</group>
<!-- Range mode -->
<group invisible="mode != 'range'">
<separator string="Range Fill"/>
<group>
<field name="prefix"/>
<field name="suffix"/>
<field name="pad_width"/>
</group>
<group>
<field name="start_number"/>
<field name="end_number"/>
</group>
<separator string="Preview"/>
<field name="range_preview" nolabel="1" readonly="1"/>
</group>
</sheet>
<footer>
<button name="action_apply" type="object"
string="Add Serials" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fp_serial_bulk_add_wizard" model="ir.actions.act_window">
<field name="name">Bulk Add Serials</field>
<field name="res_model">fp.serial.bulk.add.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -38,9 +38,7 @@
<field name="payment_term_id"/> <field name="payment_term_id"/>
</group> </group>
</group> </group>
<group> <field name="notes" placeholder="Internal notes about this customer's billing preferences..."/>
<field name="notes" placeholder="Internal notes about this customer's billing preferences..."/>
</group>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

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

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
{ {
'name': 'Fusion Plating — Native Jobs', 'name': 'Fusion Plating — Native Jobs',
'version': '19.0.7.0.0', 'version': '19.0.8.8.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.', 'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.', 'author': 'Nexa Systems Inc.',
@@ -62,8 +62,11 @@ full design rationale and §6.2 of the implementation plan for task list.
'views/fp_step_priority_views.xml', 'views/fp_step_priority_views.xml',
'views/jobs_in_shopfloor_menu.xml', 'views/jobs_in_shopfloor_menu.xml',
'views/legacy_menu_hide.xml', 'views/legacy_menu_hide.xml',
'wizards/fp_job_step_move_wizard_views.xml',
'wizards/fp_job_step_input_wizard_views.xml',
'report/report_fp_job_sticker.xml', 'report/report_fp_job_sticker.xml',
'report/report_fp_job_traveller.xml', 'report/report_fp_job_traveller.xml',
'report/report_fp_job_wo_detail.xml',
'report/report_fp_job_margin.xml', 'report/report_fp_job_margin.xml',
], ],
'assets': { 'assets': {

View File

@@ -155,6 +155,79 @@ class FpJob(models.Model):
'name': self.sale_order_id.name, 'name': self.sale_order_id.name,
} }
# All time logs across every step on this job — backs the Time Logs
# tab on the form so the manager sees the full labour audit without
# clicking into each step.
time_log_ids = fields.One2many(
'fp.job.step.timelog',
'job_id',
string='All Time Logs',
readonly=True,
)
# 2026-04-28 — link to the auto-created Sub 8 racking inspection so
# the job form can show a smart button + the manager can route into
# the inspection without leaving the job screen.
racking_inspection_ids = fields.One2many(
'fp.racking.inspection',
'x_fc_job_id',
string='Racking Inspections',
)
racking_inspection_id = fields.Many2one(
'fp.racking.inspection',
string='Racking Inspection',
compute='_compute_racking_inspection',
store=False,
help='The single racking inspection scoped to this job (Sub 8 '
'enforces uniqueness). Smart button on the form routes here.',
)
# Computed alongside racking_inspection_id so views can render the
# state badge without needing a related-on-non-stored field (which
# the ORM rejects). Selection mirrors fp.racking.inspection.state.
racking_inspection_state = fields.Selection(
[('draft', 'Draft'),
('inspecting', 'Inspecting'),
('done', 'Done'),
('discrepancy_flagged', 'Discrepancy Flagged')],
string='Racking Inspection Status',
compute='_compute_racking_inspection',
store=False,
)
@api.depends('racking_inspection_ids', 'racking_inspection_ids.state')
def _compute_racking_inspection(self):
for job in self:
ri = job.racking_inspection_ids[:1]
job.racking_inspection_id = ri
job.racking_inspection_state = ri.state if ri else False
def action_view_racking_inspection(self):
"""Open the racking inspection. Auto-create if missing (e.g. job
was created before Sub 8 shipped, or auto-create silently failed
at action_confirm time)."""
self.ensure_one()
if 'fp.racking.inspection' not in self.env:
from odoo.exceptions import UserError
raise UserError(_(
'Sub 8 racking inspection module not installed. '
'Install fusion_plating_receiving to enable.'
))
if not self.racking_inspection_id:
self._fp_create_racking_inspection()
self.invalidate_recordset(['racking_inspection_ids'])
ri = self.racking_inspection_id or self.racking_inspection_ids[:1]
if not ri:
from odoo.exceptions import UserError
raise UserError(_('Could not auto-create racking inspection.'))
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.racking.inspection',
'res_id': ri.id,
'view_mode': 'form',
'target': 'current',
'name': _('Racking Inspection — %s') % self.name,
}
def action_view_steps(self): def action_view_steps(self):
self.ensure_one() self.ensure_one()
return { return {
@@ -166,6 +239,53 @@ class FpJob(models.Model):
'context': {'default_job_id': self.id}, 'context': {'default_job_id': self.id},
} }
def action_open_move_wizard(self):
"""Header button — opens the Move wizard pre-filled with the
currently in-progress (or most recently in-progress) step as the
from-step. Lets the manager move the job forward without first
clicking into a specific step row.
"""
self.ensure_one()
active_step = self.step_ids.filtered(
lambda s: s.state == 'in_progress'
)[:1]
if not active_step:
active_step = self.step_ids.filtered(
lambda s: s.state in ('paused', 'ready')
).sorted('sequence')[:1]
if not active_step:
raise UserError(_(
'No in-progress, paused, or ready step found on this job. '
'Either every step is done or the job is still in draft.'
))
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.job.step.move.wizard',
'view_mode': 'form',
'target': 'new',
'name': _('Move Step — %s') % active_step.name,
'context': {
'default_from_step_id': active_step.id,
'default_job_id': self.id,
},
}
def action_print_traveller(self):
self.ensure_one()
return self.env.ref(
'fusion_plating_jobs.action_report_fp_job_traveller'
).report_action(self)
def action_print_wo_detail(self):
"""Print the Steelhead-style Work Order Detail PDF — chronological
chain-of-custody + per-step inputs + Certified By page. Use this
as the AS9100/Nadcap shippable audit document.
"""
self.ensure_one()
return self.env.ref(
'fusion_plating_jobs.action_report_fp_job_wo_detail'
).report_action(self)
def action_view_deliveries(self): def action_view_deliveries(self):
self.ensure_one() self.ensure_one()
if not self.delivery_id: if not self.delivery_id:
@@ -497,6 +617,38 @@ class FpJob(models.Model):
instructions.append(line) instructions.append(line)
step_num += 1 step_num += 1
# Map recipe_node.default_kind → step.kind so the
# downstream gates (Sub 8 racking soft-gate, Policy B
# contract-review gate) work even when the step gets
# renamed by the customer (e.g. "Hang on Bar" instead
# of "Racking"). Without this, gate detection falls
# back to fragile name matching.
_NODE_KIND_TO_STEP_KIND = {
'cleaning': 'wet',
'etch': 'wet',
'rinse': 'wet',
'plate': 'wet',
'dry': 'wet',
'wbf_test': 'wet',
'bake': 'bake',
'mask': 'mask',
'demask': 'mask',
'racking': 'rack',
'derack': 'rack',
'inspect': 'inspect',
'final_inspect': 'inspect',
'contract_review': 'other',
'gating': 'other',
'ship': 'other',
}
step_kind = 'other'
node_kind = (
node.default_kind
if 'default_kind' in node._fields else None
)
if node_kind and node_kind in _NODE_KIND_TO_STEP_KIND:
step_kind = _NODE_KIND_TO_STEP_KIND[node_kind]
vals = { vals = {
'job_id': job.id, 'job_id': job.id,
'name': node.name, 'name': node.name,
@@ -504,6 +656,7 @@ class FpJob(models.Model):
'duration_expected': node.estimated_duration or 0.0, 'duration_expected': node.estimated_duration or 0.0,
'sequence': seq_counter[0], 'sequence': seq_counter[0],
'recipe_node_id': node.id, 'recipe_node_id': node.id,
'kind': step_kind,
} }
if node.estimated_duration: if node.estimated_duration:
vals['dwell_time_minutes'] = node.estimated_duration vals['dwell_time_minutes'] = node.estimated_duration
@@ -636,12 +789,79 @@ class FpJob(models.Model):
) )
if pending_steps: if pending_steps:
pending_steps.write({'state': 'ready'}) pending_steps.write({'state': 'ready'})
# 2026-04-28 — auto-populate facility_id + manager_id so the
# job header surfaces them on the form. Page-1 audit found
# both empty on confirmed jobs.
job._fp_autofill_facility_and_manager()
job._fp_create_portal_job() job._fp_create_portal_job()
job._fp_create_qc_check_if_needed() job._fp_create_qc_check_if_needed()
job._fp_create_racking_inspection() job._fp_create_racking_inspection()
job._fp_fire_notification('job_confirmed') job._fp_fire_notification('job_confirmed')
return result return result
def _fp_autofill_facility_and_manager(self):
"""Populate facility_id + manager_id on confirm if empty.
Resolution order:
facility_id —
1. Already set → leave alone.
2. First step with a work_centre that has a facility → use it.
3. Recipe's process_type → facility (if process_type carries one).
4. Single-facility company → use that one.
manager_id —
1. Already set → leave alone.
2. Confirming user IS in the Plating Manager group → use them.
3. Sale order user_id (the salesperson who confirmed the SO).
4. The customer's account manager (partner.user_id).
5. Leave blank — no sensible default.
"""
self.ensure_one()
# ---- facility_id ----
if not self.facility_id:
facility = False
for s in self.step_ids:
if s.work_centre_id and 'facility_id' in s.work_centre_id._fields:
facility = s.work_centre_id.facility_id
if facility:
break
if not facility and self.recipe_id and 'process_type_id' in self.recipe_id._fields:
pt = self.recipe_id.process_type_id
if pt and 'facility_id' in pt._fields:
facility = pt.facility_id
if not facility:
Facility = self.env.get('fusion.plating.facility')
if Facility is not None:
facilities = Facility.search([
('company_id', '=', self.company_id.id),
])
if len(facilities) == 1:
facility = facilities
if facility:
self.facility_id = facility.id
self.message_post(body=_(
'Facility auto-set on confirm: %s'
) % facility.display_name)
# ---- manager_id ----
if not self.manager_id:
mgr = False
ManagerGroup = self.env.ref(
'fusion_plating.group_fusion_plating_manager',
raise_if_not_found=False,
)
if ManagerGroup and self.env.user in ManagerGroup.user_ids:
mgr = self.env.user
elif self.sale_order_id and self.sale_order_id.user_id:
mgr = self.sale_order_id.user_id
elif self.partner_id and self.partner_id.user_id:
mgr = self.partner_id.user_id
if mgr:
self.manager_id = mgr.id
self.message_post(body=_(
'Plating Manager auto-set on confirm: %s'
) % mgr.name)
def _fp_create_racking_inspection(self): def _fp_create_racking_inspection(self):
"""Auto-create a draft racking inspection on job confirm. """Auto-create a draft racking inspection on job confirm.

View File

@@ -413,3 +413,370 @@ class FpJobStep(models.Model):
'plate exit. Required by %s.' 'plate exit. Required by %s.'
)) % (bw.name, window_hrs, bw.bake_required_by)) )) % (bw.name, window_hrs, bw.bake_required_by))
return result return result
# ==================================================================
# Phase 2 multi-serial — auto-promote serials on step transitions
# ==================================================================
def _fp_promote_serials_on_start(self):
"""When this step transitions to in_progress, lift any serial
attached to the parent SO line out of `received` / `racked` and
into `in_process`. Idempotent — already-promoted serials are
skipped.
"""
for step in self:
job = step.job_id
if not job.sale_order_line_ids:
continue
serials = job.sale_order_line_ids.mapped('x_fc_serial_ids')
to_promote = serials.filtered(
lambda s: s.state in ('received', 'racked')
)
if to_promote:
# Use sudo on the helper so operator-tier users can promote
# serial state without needing direct write on fp.serial.
to_promote.sudo()._set_state('in_process', message=_(
'Promoted to In Process on step "%s" start by %s.'
) % (step.name, self.env.user.name))
def _fp_promote_serials_on_finish(self):
"""When the LAST step of this step's job finishes (sequenced
terminal step OR an explicit inspect/final-inspect kind), bump
in-flight serials to `inspected` so the shipper sees them ready
for packing. Conservative — only promotes from `in_process`."""
for step in self:
job = step.job_id
if not job.sale_order_line_ids:
continue
# Is this the highest-sequence non-cancelled step on the job?
siblings = job.step_ids.filtered(
lambda s: s.state not in ('cancelled', 'skipped')
)
if not siblings:
continue
last_seq = max(siblings.mapped('sequence'))
is_terminal = (step.sequence == last_seq) or (
step.kind == 'inspect' or 'final' in (step.name or '').lower()
)
if not is_terminal:
continue
serials = job.sale_order_line_ids.mapped('x_fc_serial_ids')
to_promote = serials.filtered(lambda s: s.state == 'in_process')
if to_promote:
to_promote.sudo()._set_state('inspected', message=_(
'Promoted to Inspected on step "%s" finish by %s.'
) % (step.name, self.env.user.name))
# ==================================================================
# Policy B (2026-04-28) — Contract Review enforcement
# ==================================================================
# When a recipe author drops a "Contract Review" step into a recipe,
# button_start opens the QA-005 audit form for the linked part (auto-
# creates one if missing) and button_finish blocks completion until
# the form is `complete` AND the current user is on the recipe's
# contract_review_user_ids approver list (when configured).
#
# Detection — case-insensitive match on the step name OR
# recipe_node_id mapped from a step.template with default_kind ==
# 'contract_review' (the simple-editor library entry).
def _fp_is_contract_review_step(self):
self.ensure_one()
if (self.name or '').strip().lower() in ('contract review', 'qa-005'):
return True
node = self.recipe_node_id
if not node:
return False
# Source template kind (when authored via simple editor library)
if 'source_template_id' in node._fields and node.source_template_id:
if node.source_template_id.default_kind == 'contract_review':
return True
if 'default_kind' in node._fields and node.default_kind == 'contract_review':
return True
return False
def _fp_resolve_contract_review_part(self):
"""Find the fp.part.catalog this step's job is for. Used by the
Contract Review hooks to auto-create / look up the QA-005 form.
Falls through to None when no part can be resolved (no SO line,
SO line without x_fc_part_catalog_id, etc.)."""
self.ensure_one()
for so_line in self.job_id.sale_order_line_ids:
if (so_line.x_fc_part_catalog_id
and 'fp.contract.review' in self.env):
return so_line.x_fc_part_catalog_id
return None
def _fp_open_contract_review(self):
"""Auto-create the QA-005 form for this step's part if missing,
return the act_window pointing at it. Called from button_start
on Contract Review steps."""
self.ensure_one()
part = self._fp_resolve_contract_review_part()
if not part:
return None
Review = self.env.get('fp.contract.review')
if Review is None:
return None # quality module not installed — skip
review = part.x_fc_contract_review_id
if not review:
review = Review.sudo().create({
'part_id': part.id,
'state': 'assistant_review',
})
part.sudo().write({
'x_fc_contract_review_id': review.id,
'x_fc_contract_review_dismissed': False,
})
self.job_id.message_post(body=_(
'Contract Review (QA-005) auto-created for %(part)s on '
'Contract Review step start by %(user)s.'
) % {
'part': part.display_name or part.part_number or '',
'user': self.env.user.name,
})
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.contract.review',
'res_id': review.id,
'view_mode': 'form',
'target': 'current',
'name': _('Contract Review — %s') % (
part.display_name or part.part_number or ''
),
}
def _fp_check_contract_review_complete(self):
"""Block button_finish on a Contract Review step until QA-005 is
signed off. Only enforced when the customer has
partner.x_fc_contract_review_required=True. Manager bypass via
context fp_skip_contract_review_gate=True."""
if self.env.context.get('fp_skip_contract_review_gate'):
return
for step in self:
if not step._fp_is_contract_review_step():
continue
part = step._fp_resolve_contract_review_part()
if not part or not part.partner_id.x_fc_contract_review_required:
continue
review = part.x_fc_contract_review_id
if not review or review.state != 'complete':
state_label = (
review.state if review else _('not started')
)
raise UserError(_(
'Contract Review for %(part)s is %(state)s — must be '
'"complete" before this step can finish. Open the '
'QA-005 form (smart button on the part), get both '
'sections signed off, then retry. Manager bypass: '
'fp_skip_contract_review_gate=True in context.'
) % {
'part': part.display_name or part.part_number or '',
'state': state_label,
})
# Approver-list gate (restored from pre-Sub-11). When the
# recipe author named approvers on the recipe root, only those
# users can finish the Contract Review step.
recipe = step.recipe_node_id and step.recipe_node_id.recipe_root_id
approvers = (recipe.contract_review_user_ids
if (recipe and 'contract_review_user_ids' in recipe._fields)
else False)
if approvers and self.env.user not in approvers:
raise UserError(_(
'Only authorised Contract Review approvers can finish '
'this step. Approvers: %s.\n\nContact your Plating '
'Manager to add yourself if this is wrong, or hand '
'the step to one of the approvers.'
) % ', '.join(approvers.mapped('name')))
# ==================================================================
# Sub 8 follow-up (2026-04-28) — Racking Inspection enforcement
# ==================================================================
# When the recipe-side "Racking" step starts, auto-promote the linked
# fp.racking.inspection from draft → inspecting and route the operator
# straight into the inspection form. When the same step finishes,
# block unless the inspection is in `done` or `discrepancy_flagged`
# (operator cleared every line). Manager bypass via context
# `fp_skip_racking_inspection_gate=True`.
def _fp_is_racking_step(self):
self.ensure_one()
if (self.name or '').strip().lower() in ('racking', 'rack'):
return True
node = self.recipe_node_id
if not node:
return False
if 'source_template_id' in node._fields and node.source_template_id:
if node.source_template_id.default_kind == 'racking':
return True
if 'default_kind' in node._fields and node.default_kind == 'racking':
return True
if self.kind == 'rack':
return True
return False
def _fp_open_racking_inspection(self):
"""Auto-promote draft → inspecting + return act_window for the
linked racking inspection. Auto-creates one if missing."""
self.ensure_one()
if 'fp.racking.inspection' not in self.env:
return None
# Reach the job's existing inspection (auto-created on action_confirm)
# or trigger a fresh create if none exists.
ri = self.job_id.racking_inspection_id
if not ri:
self.job_id._fp_create_racking_inspection()
self.job_id.invalidate_recordset(['racking_inspection_ids'])
ri = self.job_id.racking_inspection_id
if not ri:
return None
# Promote draft → inspecting. action_start raises if state isn't
# draft, so guard.
if ri.state == 'draft':
ri.sudo().action_start()
self.job_id.message_post(body=_(
'Racking inspection auto-promoted to "Inspecting" on '
'%(step)s start by %(user)s.'
) % {'step': self.name, 'user': self.env.user.name})
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.racking.inspection',
'res_id': ri.id,
'view_mode': 'form',
'target': 'current',
'name': _('Racking Inspection — %s') % self.job_id.name,
}
def _fp_check_racking_inspection_complete(self):
"""Soft gate — block button_finish on a Racking step until the
linked inspection is in a terminal state. discrepancy_flagged
counts as complete (the operator finished but flagged issues —
the discrepancy activity will route to the manager separately)."""
if self.env.context.get('fp_skip_racking_inspection_gate'):
return
for step in self:
if not step._fp_is_racking_step():
continue
ri = step.job_id.racking_inspection_id
if not ri:
# No inspection at all — still let it finish, but log a
# chatter warning so the manager sees the gap.
step.job_id.message_post(body=_(
'⚠️ Racking step "%s" finished without a racking '
'inspection on file. Sub 8 expected one to be '
'auto-created on job confirm.'
) % step.name)
continue
if ri.state not in ('done', 'discrepancy_flagged'):
state_label = dict(ri._fields['state'].selection).get(
ri.state, ri.state)
raise UserError(_(
'Racking inspection for %(job)s is "%(state)s" — must '
'be Done or Discrepancy Flagged before this step can '
'finish. Click the Racking Insp. smart button on the '
'job, complete the line check-off, then retry. '
'Manager bypass: fp_skip_racking_inspection_gate=True.'
) % {
'job': step.job_id.name,
'state': state_label,
})
def button_start(self):
# Policy B — Contract Review takes priority (auto-opens QA-005).
for step in self:
if step._fp_is_contract_review_step():
action = step._fp_open_contract_review()
if action:
super(FpJobStep, step).button_start()
if step.state == 'in_progress':
step._fp_promote_serials_on_start()
return action
# Sub 8 — Racking step auto-opens the inspection form.
for step in self:
if step._fp_is_racking_step():
action = step._fp_open_racking_inspection()
if action:
super(FpJobStep, step).button_start()
if step.state == 'in_progress':
step._fp_promote_serials_on_start()
return action
result = super().button_start()
for step in self:
if step.state == 'in_progress':
step._fp_promote_serials_on_start()
return result
def button_finish(self):
# Policy B — block until QA-005 complete (when customer requires it).
self._fp_check_contract_review_complete()
# Sub 8 — block until racking inspection is Done / Flagged.
self._fp_check_racking_inspection_complete()
result = super().button_finish()
for step in self:
if step.state == 'done':
step._fp_promote_serials_on_finish()
return result
# ==================================================================
# Per-row shortcut actions used by the job form's inline action column
# ==================================================================
def action_open_move_wizard(self):
"""Open the Move wizard with this step pre-filled as the from-step."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.job.step.move.wizard',
'view_mode': 'form',
'target': 'new',
'name': _('Move from %s') % self.name,
'context': {
'default_from_step_id': self.id,
'default_job_id': self.job_id.id,
},
}
def action_open_input_wizard(self):
"""Open the Input Recording wizard for this step."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.job.step.input.wizard',
'view_mode': 'form',
'target': 'new',
'name': _('Record Inputs — %s') % self.name,
'context': {
'default_step_id': self.id,
},
}
# ------------------------------------------------------------------
# Live duration helper — view binds to a non-stored compute that
# ticks each time the form re-reads. For a true live ticking clock
# we'd need an OWL widget; this gives "minutes since start" that's
# accurate at every record refresh, which is good enough for a
# backend manager's view.
# ------------------------------------------------------------------
duration_running_minutes = fields.Float(
string='Running Min',
compute='_compute_duration_running',
help='Minutes since the step\'s current open timelog started. '
'Re-reads on every form refresh; equals duration_actual once '
'the step is finished.',
)
@api.depends('state', 'date_started', 'time_log_ids',
'time_log_ids.date_started', 'time_log_ids.date_finished',
'duration_actual')
def _compute_duration_running(self):
now = fields.Datetime.now()
for step in self:
if step.state == 'in_progress':
# Sum closed intervals + (now - open interval start)
closed = sum(step.time_log_ids.mapped('duration_minutes'))
open_log = step.time_log_ids.filtered(
lambda l: not l.date_finished
)[:1]
running = 0.0
if open_log and open_log.date_started:
delta = (now - open_log.date_started).total_seconds() / 60.0
running = max(0.0, delta)
step.duration_running_minutes = closed + running
else:
step.duration_running_minutes = step.duration_actual or 0.0

View File

@@ -202,6 +202,14 @@
</tr> </tr>
</table> </table>
<!-- ===== ROUTING TABLE =====
Continues on subsequent pages — paperformat handles
page break automatically. Footer (Ship Order To +
Additional Notes) closes the document. -->
<!-- inline routing follows; footer appears below -->
<!-- (placed after the routing table — see end-of-template) -->
<!-- ===== ROUTING TABLE ===== --> <!-- ===== ROUTING TABLE ===== -->
<table class="bordered" style="margin-top: 4px;"> <table class="bordered" style="margin-top: 4px;">
<thead> <thead>
@@ -284,6 +292,37 @@
</t> </t>
</tbody> </tbody>
</table> </table>
<!-- ===== FOOTER — SHIP ORDER + NOTES ===== -->
<table class="bordered" style="margin-top: 6px;">
<tr>
<th style="width: 30%;">Ship Order To</th>
<th style="width: 70%;">Additional Notes</th>
</tr>
<tr>
<td style="vertical-align: top;">
<strong t-esc="(job.partner_id and job.partner_id.name) or '—'"/><br/>
<span t-esc="(job.partner_id and job.partner_id.street) or ''"/><br/>
<span t-if="job.partner_id and job.partner_id.street2"
t-esc="job.partner_id.street2"/>
<t t-if="job.partner_id and job.partner_id.street2"><br/></t>
<span t-esc="(job.partner_id and job.partner_id.city) or ''"/>,
<span t-esc="(job.partner_id and job.partner_id.state_id and job.partner_id.state_id.code) or ''"/>
<span t-esc="(job.partner_id and job.partner_id.zip) or ''"/><br/>
<span t-esc="(job.partner_id and job.partner_id.country_id and job.partner_id.country_id.code) or ''"/>
</td>
<td class="fp-trav-stamp" style="min-height: 18mm;">
<t t-if="'special_requirements' in job._fields and job.special_requirements">
<span t-esc="job.special_requirements"
style="white-space: pre-wrap; font-size: 7.5pt;"/>
</t>
</td>
</tr>
</table>
<div style="text-align: center; margin-top: 4px; font-size: 7pt; color: #666;">
<span t-esc="job.name"/>
</div>
</div> </div>
</t> </t>
</t> </t>

View File

@@ -0,0 +1,281 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
Steelhead-style "Work Order Detail" PDF — the post-job audit cert
that walks fp.job.step.move records chronologically, lists captured
inputs per step, and ends with a Certified By + Cert Statement
page. Bound to fp.job directly (not fp.certificate) so a manager
can print the audit document straight from the job form.
Layout mirrors the customer-shared `job card.pdf` (see CLAUDE.md
Sub 12c). Reuses the same per-customer cert-statement resolution
chain (partner.x_fc_cert_statement → company.x_fc_default_cert_statement
→ hardcoded boilerplate) so we don't fork two cert templates.
-->
<odoo>
<record id="paperformat_fp_wo_detail" model="report.paperformat">
<field name="name">FP Work Order Detail — A4 portrait</field>
<field name="format">A4</field>
<field name="orientation">Portrait</field>
<field name="margin_top">15</field>
<field name="margin_bottom">15</field>
<field name="margin_left">12</field>
<field name="margin_right">12</field>
<field name="header_spacing">8</field>
<field name="dpi">90</field>
</record>
<record id="action_report_fp_job_wo_detail" model="ir.actions.report">
<field name="name">Work Order Detail</field>
<field name="model">fp.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_jobs.report_fp_job_wo_detail_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_wo_detail_template</field>
<field name="print_report_name">'WO Detail - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_wo_detail"/>
</record>
<template id="report_fp_job_wo_detail_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<t t-call="web.external_layout">
<t t-set="company" t-value="job.company_id"/>
<t t-set="moves" t-value="job.move_ids.sorted('move_datetime')"/>
<div class="page fp-wo-detail">
<style>
.fp-wo-detail { font-family: Arial, sans-serif; font-size: 9pt; color: #000; }
.fp-wo-detail h1 { text-align: center; font-size: 18pt; margin: 0 0 6px 0; font-weight: bold; color: #1a4d80; }
.fp-wo-detail h3 { font-size: 11pt; margin: 8px 0 2px 0; font-weight: bold; }
.fp-wo-detail .fp-meta { font-size: 8.5pt; color: #444; margin-bottom: 4px; }
.fp-wo-detail table.bordered,
.fp-wo-detail table.bordered th,
.fp-wo-detail table.bordered td { border: 1px solid #000; border-collapse: collapse; }
.fp-wo-detail table.bordered { width: 100%; margin-bottom: 8px; }
.fp-wo-detail table.bordered th { background: #ededed; padding: 4px 6px; font-size: 8.5pt; text-align: left; }
.fp-wo-detail table.bordered td { padding: 4px 6px; vertical-align: top; font-size: 8.5pt; }
.fp-wo-detail .text-center { text-align: center; }
.fp-wo-detail hr.heavy { border: 0; border-top: 2px solid #000; margin: 8px 0; }
.fp-wo-detail .fp-spec { font-size: 10pt; font-weight: bold; margin: 8px 0 4px 0; }
.fp-wo-detail .fp-step-block { page-break-inside: avoid; margin-bottom: 6px; }
</style>
<h1>Work Order Detail</h1>
<!-- ===== HEADER — Prepared For + summary table ===== -->
<div style="margin-bottom: 8px;">
<strong>Prepared For:</strong>
<span style="font-size: 11pt;"
t-esc="(job.partner_id and job.partner_id.name) or '—'"/>
</div>
<table class="bordered">
<tr>
<th style="width: 20%;">Part Number</th>
<th style="width: 30%;">Description</th>
<th style="width: 8%;">Quantity</th>
<th style="width: 10%;">Work Order</th>
<th style="width: 14%;">PO Number</th>
<th style="width: 8%;">Packing List No</th>
<th style="width: 10%;">Date</th>
</tr>
<tr>
<td>
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
<span t-esc="job.part_catalog_id.part_number or '—'"/>
</t>
<t t-else="">
<span t-esc="(job.product_id and job.product_id.default_code) or '—'"/>
</t>
</td>
<td style="white-space: pre-wrap;">
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
<span t-esc="job.part_catalog_id.name or job.product_id.name or '—'"/>
</t>
<t t-else="">
<span t-esc="(job.product_id and job.product_id.name) or '—'"/>
</t>
<t t-if="'special_requirements' in job._fields and job.special_requirements">
<br/>
<span style="font-size: 7.5pt;"
t-esc="job.special_requirements"/>
</t>
</td>
<td class="text-center">
<span t-esc="job.qty"/>
</td>
<td class="text-center">
<span t-esc="job.name"/>
</td>
<td>
<span t-esc="(job.sale_order_id and job.sale_order_id.client_order_ref) or '—'"/>
</td>
<td/>
<td>
<span t-esc="(job.date_finished or job.date_started or job.create_date) and (job.date_finished or job.date_started or job.create_date).strftime('%Y-%m-%d') or ''"/>
</td>
</tr>
</table>
<div class="fp-spec">Specification(s):
<span style="font-weight: normal;"
t-esc="(job.recipe_id and job.recipe_id.name) or '—'"/>
</div>
<hr class="heavy"/>
<!-- ===== CHAIN-OF-CUSTODY WALK ===== -->
<t t-foreach="moves" t-as="mv">
<t t-set="dest" t-value="mv.to_step_id"/>
<t t-set="tank_code" t-value="(mv.to_tank_id and mv.to_tank_id.code) or (dest and dest.tank_id and dest.tank_id.code) or ''"/>
<div class="fp-step-block">
<h3>
<span t-esc="(dest and dest.name) or '—'"/>
<t t-if="tank_code"> (<span t-esc="tank_code"/>)</t>
</h3>
<div class="fp-meta">
<strong>Part Number:</strong>
<t t-if="'part_catalog_id' in job._fields and job.part_catalog_id">
<span t-esc="job.part_catalog_id.part_number or ''"/>
<t t-if="job.part_catalog_id.name">
<span> </span><span t-esc="job.part_catalog_id.name"/>
</t>
</t>
<t t-else="">
<span t-esc="(job.product_id and (job.product_id.default_code or job.product_id.name)) or ''"/>
</t>
<br/>
<strong>Moved By:</strong> <span t-esc="mv.moved_by_user_id.name"/>
<span> </span>
<strong>Time:</strong>
<span t-esc="mv.move_datetime and mv.move_datetime.strftime('%b %d, %Y %I:%M:%S %p') or ''"/>
</div>
<!-- Captured input values for this move -->
<t t-set="captured_values_by_input"
t-value="{v.node_input_id.id: v for v in mv.transition_input_value_ids}"/>
<t t-set="prompts" t-value="False"/>
<t t-if="dest and dest.recipe_node_id">
<t t-set="prompts"
t-value="dest.recipe_node_id.input_ids.filtered(lambda i: (i.kind or 'step_input') == 'step_input').sorted('sequence')"/>
</t>
<t t-if="not prompts and mv.transition_input_value_ids">
<t t-set="prompts"
t-value="mv.transition_input_value_ids.mapped('node_input_id')"/>
</t>
<t t-if="prompts and mv.transition_input_value_ids">
<table class="bordered">
<thead>
<tr>
<th style="width: 24%;">Name</th>
<th style="width: 30%;">Description</th>
<th style="width: 18%;">Value</th>
<th style="width: 28%;">Recorded By</th>
</tr>
</thead>
<tbody>
<t t-foreach="prompts" t-as="inp">
<t t-set="cv" t-value="captured_values_by_input.get(inp.id)"/>
<t t-if="cv">
<t t-set="actual_str" t-value="''"/>
<t t-if="cv.value_text">
<t t-set="actual_str" t-value="cv.value_text"/>
</t>
<t t-elif="cv.value_number">
<t t-set="actual_str"
t-value="('%s %s' % (cv.value_number, (inp.target_unit if 'target_unit' in inp._fields and inp.target_unit else ''))).strip()"/>
</t>
<t t-elif="cv.value_boolean is not False">
<t t-set="actual_str" t-value="'PASS' if cv.value_boolean else 'FAIL'"/>
</t>
<t t-elif="cv.value_date">
<t t-set="actual_str" t-value="cv.value_date.strftime('%Y-%m-%d %H:%M')"/>
</t>
<tr>
<td><span t-esc="inp.name"/></td>
<td>
<t t-if="'hint' in inp._fields and inp.hint">
<span t-esc="inp.hint"/>
</t>
</td>
<td>
<strong t-esc="actual_str"/>
</td>
<td>
<span t-esc="(mv.moved_by_user_id and mv.moved_by_user_id.name) or ''"/>
</td>
</tr>
</t>
</t>
</tbody>
</table>
</t>
</div>
</t>
<t t-if="not moves">
<p style="color: #888; font-style: italic;">
No move log entries yet — this job hasn't progressed
through any steps. Operators move the job forward
via the tablet or the backend Move wizard.
</p>
</t>
<!-- ===== CERTIFIED BY + CERT STATEMENT ===== -->
<p style="page-break-before: always;"/>
<t t-set="owner_sig" t-value="False"/>
<t t-if="'x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id">
<t t-set="_emp" t-value="company.x_fc_owner_user_id.employee_ids[:1]"/>
<t t-if="_emp and 'signature' in _emp._fields">
<t t-set="owner_sig" t-value="_emp['signature']"/>
</t>
</t>
<t t-set="sig_override" t-value="('x_fc_coc_signature_override' in company._fields and company.x_fc_coc_signature_override) or False"/>
<t t-set="signature_img" t-value="sig_override or owner_sig"/>
<t t-set="signer_name" t-value="(job.manager_id and job.manager_id.name) or ('x_fc_owner_user_id' in company._fields and company.x_fc_owner_user_id and company.x_fc_owner_user_id.name) or ''"/>
<t t-set="_cust_stmt" t-value="(job.partner_id and 'x_fc_cert_statement' in job.partner_id._fields and job.partner_id.x_fc_cert_statement) or False"/>
<t t-set="_co_stmt" t-value="('x_fc_default_cert_statement' in company._fields and company.x_fc_default_cert_statement) or False"/>
<t t-set="cert_statement" t-value="_cust_stmt or _co_stmt or 'We certify that the parts listed above have been processed in accordance with the specifications referenced and that all required tests have been performed. Records on file at our facility per AS9100 / ISO 9001 retention policy.'"/>
<table class="bordered">
<tr>
<td style="width: 50%; vertical-align: top; height: 40mm;">
<strong>Certified By:</strong><br/>
<t t-if="signature_img">
<img t-att-src="'data:image/png;base64,%s' % signature_img.decode()"
style="max-height: 22mm; max-width: 70mm;"/>
</t><br/>
<strong>Name:</strong> <span t-esc="signer_name"/>
</td>
<td style="width: 50%; vertical-align: top;">
<strong>Certification Statement:</strong>
<span style="font-size: 8.5pt;">
Ref. WO# <span t-esc="job.name"/>
</span>
<p style="font-size: 8pt; margin-top: 4px; white-space: pre-wrap;"
t-esc="cert_statement"/>
</td>
</tr>
<tr>
<td colspan="2" style="height: 25mm;">
<strong>Other Comments:</strong>
</td>
</tr>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -5,3 +5,15 @@ access_fp_job_node_override_manager,fp.job.node.override.manager,model_fp_job_no
access_fp_job_consumption_operator,fp.job.consumption.operator,model_fp_job_consumption,fusion_plating.group_fusion_plating_operator,1,1,1,0 access_fp_job_consumption_operator,fp.job.consumption.operator,model_fp_job_consumption,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_consumption,fusion_plating.group_fusion_plating_supervisor,1,1,1,0 access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_consumption,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1 access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_step_move_wiz_op,fp.job.step.move.wiz.operator,model_fp_job_step_move_wizard,fusion_plating.group_fusion_plating_operator,1,1,1,1
access_fp_job_step_move_wiz_sup,fp.job.step.move.wiz.supervisor,model_fp_job_step_move_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_job_step_move_wiz_mgr,fp.job.step.move.wiz.manager,model_fp_job_step_move_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_step_move_wiz_in_op,fp.job.step.move.wiz.in.operator,model_fp_job_step_move_wizard_input,fusion_plating.group_fusion_plating_operator,1,1,1,1
access_fp_job_step_move_wiz_in_sup,fp.job.step.move.wiz.in.supervisor,model_fp_job_step_move_wizard_input,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_job_step_move_wiz_in_mgr,fp.job.step.move.wiz.in.manager,model_fp_job_step_move_wizard_input,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_step_input_wiz_op,fp.job.step.input.wiz.operator,model_fp_job_step_input_wizard,fusion_plating.group_fusion_plating_operator,1,1,1,1
access_fp_job_step_input_wiz_sup,fp.job.step.input.wiz.supervisor,model_fp_job_step_input_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_job_step_input_wiz_mgr,fp.job.step.input.wiz.manager,model_fp_job_step_input_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_step_input_wiz_l_op,fp.job.step.input.wiz.l.operator,model_fp_job_step_input_wizard_line,fusion_plating.group_fusion_plating_operator,1,1,1,1
access_fp_job_step_input_wiz_l_sup,fp.job.step.input.wiz.l.supervisor,model_fp_job_step_input_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_job_step_input_wiz_l_mgr,fp.job.step.input.wiz.l.manager,model_fp_job_step_input_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_fp_job_consumption_operator fp.job.consumption.operator model_fp_job_consumption fusion_plating.group_fusion_plating_operator 1 1 1 0
6 access_fp_job_consumption_supervisor fp.job.consumption.supervisor model_fp_job_consumption fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_job_consumption_manager fp.job.consumption.manager model_fp_job_consumption fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_job_step_move_wiz_op fp.job.step.move.wiz.operator model_fp_job_step_move_wizard fusion_plating.group_fusion_plating_operator 1 1 1 1
9 access_fp_job_step_move_wiz_sup fp.job.step.move.wiz.supervisor model_fp_job_step_move_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 1
10 access_fp_job_step_move_wiz_mgr fp.job.step.move.wiz.manager model_fp_job_step_move_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
11 access_fp_job_step_move_wiz_in_op fp.job.step.move.wiz.in.operator model_fp_job_step_move_wizard_input fusion_plating.group_fusion_plating_operator 1 1 1 1
12 access_fp_job_step_move_wiz_in_sup fp.job.step.move.wiz.in.supervisor model_fp_job_step_move_wizard_input fusion_plating.group_fusion_plating_supervisor 1 1 1 1
13 access_fp_job_step_move_wiz_in_mgr fp.job.step.move.wiz.in.manager model_fp_job_step_move_wizard_input fusion_plating.group_fusion_plating_manager 1 1 1 1
14 access_fp_job_step_input_wiz_op fp.job.step.input.wiz.operator model_fp_job_step_input_wizard fusion_plating.group_fusion_plating_operator 1 1 1 1
15 access_fp_job_step_input_wiz_sup fp.job.step.input.wiz.supervisor model_fp_job_step_input_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 1
16 access_fp_job_step_input_wiz_mgr fp.job.step.input.wiz.manager model_fp_job_step_input_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
17 access_fp_job_step_input_wiz_l_op fp.job.step.input.wiz.l.operator model_fp_job_step_input_wizard_line fusion_plating.group_fusion_plating_operator 1 1 1 1
18 access_fp_job_step_input_wiz_l_sup fp.job.step.input.wiz.l.supervisor model_fp_job_step_input_wizard_line fusion_plating.group_fusion_plating_supervisor 1 1 1 1
19 access_fp_job_step_input_wiz_l_mgr fp.job.step.input.wiz.l.manager model_fp_job_step_input_wizard_line fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -49,9 +49,8 @@
<field name="logged_by_id"/> <field name="logged_by_id"/>
</group> </group>
</group> </group>
<group string="Notes"> <separator string="Notes"/>
<field name="notes" nolabel="1"/> <field name="notes"/>
</group>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -25,6 +25,110 @@
class="btn-secondary" class="btn-secondary"
icon="fa-sitemap" icon="fa-sitemap"
invisible="state == 'draft'"/> invisible="state == 'draft'"/>
<button name="action_open_move_wizard" type="object"
string="Move to Next Step"
class="btn-primary"
icon="fa-arrow-right"
invisible="state not in ('confirmed', 'in_progress')"/>
<button name="action_print_traveller" type="object"
string="Print Traveller"
class="btn-secondary"
icon="fa-print"
invisible="state == 'draft'"/>
<button name="action_print_wo_detail" type="object"
string="Print WO Detail"
class="btn-secondary"
icon="fa-file-text-o"
invisible="state in ('draft', 'cancelled')"/>
</xpath>
<!-- Replace the bare-bones Steps list with the action-rich
manager view. Per-row buttons mirror what an operator
sees on the tablet; Running Min ticks on every refresh
for the active step. -->
<xpath expr="//page[@name='steps']/field[@name='step_ids']" position="replace">
<field name="step_ids" mode="list">
<list editable="bottom"
decoration-info="state in ('ready', 'in_progress')"
decoration-success="state == 'done'"
decoration-warning="state == 'paused'"
decoration-muted="state in ('skipped', 'cancelled')">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="work_centre_id" optional="show"/>
<field name="tank_id" optional="hide"/>
<field name="kind" optional="hide"/>
<field name="state" widget="badge"
decoration-info="state in ('ready', 'in_progress')"
decoration-success="state == 'done'"
decoration-warning="state == 'paused'"
decoration-muted="state in ('skipped', 'cancelled')"/>
<field name="assigned_user_id" optional="show"/>
<field name="duration_expected" optional="show"/>
<field name="duration_running_minutes" string="Running Min" optional="show"/>
<field name="duration_actual" optional="show"/>
<button name="button_start" type="object"
string="Start" icon="fa-play"
class="btn-link text-success"
invisible="state not in ('ready', 'pending')"/>
<button name="button_resume" type="object"
string="Resume" icon="fa-play-circle"
class="btn-link text-success"
invisible="state != 'paused'"/>
<button name="button_pause" type="object"
string="Pause" icon="fa-pause"
class="btn-link text-warning"
invisible="state != 'in_progress'"/>
<button name="button_finish" type="object"
string="Finish" icon="fa-check"
class="btn-link text-primary"
invisible="state != 'in_progress'"/>
<button name="action_open_move_wizard" type="object"
string="Move" icon="fa-arrow-right"
class="btn-link"
invisible="state in ('done', 'cancelled', 'skipped')"/>
<button name="action_open_input_wizard" type="object"
string="Record Inputs" icon="fa-pencil-square-o"
class="btn-link"
invisible="state in ('cancelled', 'skipped')"/>
<button name="button_skip" type="object"
string="Skip" icon="fa-step-forward"
class="btn-link text-muted"
invisible="state not in ('pending', 'ready')"/>
</list>
</field>
</xpath>
<!-- New tabs in the notebook: Move Log + Time Logs.
Both read-only — operators write via the wizards;
these tabs are the audit window. -->
<xpath expr="//page[@name='source']" position="before">
<page string="Move Log" name="move_log">
<field name="move_ids" readonly="1">
<list create="false" edit="false" delete="false"
decoration-info="transfer_type == 'step'"
decoration-warning="transfer_type in ('hold', 'rework')"
decoration-danger="transfer_type == 'scrap'">
<field name="move_datetime"/>
<field name="from_step_id"/>
<field name="to_step_id"/>
<field name="transfer_type" widget="badge"/>
<field name="qty_moved"/>
<field name="moved_by_user_id"/>
</list>
</field>
</page>
<page string="Time Logs" name="time_logs">
<field name="time_log_ids" readonly="1">
<list create="false" edit="false" delete="false">
<field name="step_id"/>
<field name="user_id"/>
<field name="date_started"/>
<field name="date_finished"/>
<field name="duration_minutes"/>
</list>
</field>
</page>
</xpath> </xpath>
<!-- Inject a button_box at the top of the sheet, before the <!-- Inject a button_box at the top of the sheet, before the
@@ -67,6 +171,22 @@
<field name="quality_hold_count" widget="statinfo" <field name="quality_hold_count" widget="statinfo"
string="Holds"/> string="Holds"/>
</button> </button>
<button name="action_view_racking_inspection" type="object"
class="oe_stat_button" icon="fa-clipboard-check">
<div class="o_stat_info">
<field name="racking_inspection_state"
widget="badge"
class="o_stat_value"
decoration-success="racking_inspection_state == 'done'"
decoration-info="racking_inspection_state == 'inspecting'"
decoration-warning="racking_inspection_state == 'discrepancy_flagged'"
decoration-muted="racking_inspection_state == 'draft'"
invisible="not racking_inspection_state"/>
<span class="o_stat_value"
invisible="racking_inspection_state"></span>
<span class="o_stat_text">Racking Insp.</span>
</div>
</button>
<button name="action_view_certificates" type="object" <button name="action_view_certificates" type="object"
class="oe_stat_button" icon="fa-certificate" class="oe_stat_button" icon="fa-certificate"
invisible="certificate_count == 0"> invisible="certificate_count == 0">

View File

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

View File

@@ -0,0 +1,217 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Backend Step Input Recording wizard.
Operator-recorded measurements during a step (Soak Clean Time/Temp,
ElectroClean Amperage, E-Nickel Plate Temp, Plating Thickness, etc.)
that the customer's WO traveler ends with handwritten in pen.
These values are the per-step `step_input` prompts authored on the
recipe node (fp.step.template.input.kind == 'step_input'). On the
tablet they're captured via the QC checklist OWL component; the
backend wizard gives the manager the same capability without leaving
the job form.
Captured values land on a synthetic `fp.job.step.move` row with
transfer_type='step' (an in-place move, no destination change) so the
existing CoC chronological QWeb template renders them in the same
format as the tablet-captured values — single source of truth for
report rendering.
"""
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
# Same selection list as fp.step.template.input.input_type so authored
# rows + ad-hoc rows pick from the same vocabulary.
_FP_INPUT_TYPE_SELECTION = [
('text', 'Text'),
('number', 'Number'),
('boolean', 'Yes/No'),
('selection', 'Selection'),
('date', 'Date / Time'),
('signature', 'Signature'),
('time_hms', 'Time (HH:MM:SS)'),
('time_seconds', 'Time (seconds)'),
('temperature', 'Temperature'),
('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'),
]
class FpJobStepInputWizard(models.TransientModel):
_name = 'fp.job.step.input.wizard'
_description = 'Fusion Plating — Step Input Recording (Backend)'
step_id = fields.Many2one(
'fp.job.step', string='Step', required=True, readonly=True,
)
job_id = fields.Many2one(
related='step_id.job_id', string='Job', store=False, readonly=True,
)
line_ids = fields.One2many(
'fp.job.step.input.wizard.line', 'wizard_id',
string='Inputs',
)
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
ctx = self.env.context
step_id = ctx.get('default_step_id') or ctx.get('active_id')
if not step_id:
return defaults
step = self.env['fp.job.step'].browse(step_id)
if not step.exists() or not step.recipe_node_id:
return defaults
defaults['step_id'] = step.id
node = step.recipe_node_id
# Filter to step_input prompts only — transition inputs go on the
# Move wizard, not here.
inputs = node.input_ids
if 'kind' in inputs._fields:
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
defaults['line_ids'] = [(0, 0, {
'node_input_id': inp.id,
'name': inp.name,
'input_type': inp.input_type,
'target_min': getattr(inp, 'target_min', 0.0) or 0.0,
'target_max': getattr(inp, 'target_max', 0.0) or 0.0,
'target_unit': getattr(inp, 'target_unit', False) or False,
}) for inp in inputs]
return defaults
def action_commit(self):
self.ensure_one()
if not self.line_ids:
raise UserError(_(
'Add at least one input row before clicking Record. '
'Click "Add a line" in the table above to enter an '
'ad-hoc measurement.'
))
# Ad-hoc rows must have a prompt name — otherwise we can't tell
# what was being measured on the audit trail.
unnamed = self.line_ids.filtered(
lambda l: not l.node_input_id and not (l.name or '').strip()
)
if unnamed:
raise UserError(_(
'Every ad-hoc input row needs a Prompt label so the '
'audit trail captures what was measured. %s row(s) missing '
'a prompt.'
) % len(unnamed))
# Synthetic in-place move so the chronological CoC template picks
# up these values alongside transition-input values without a
# second QWeb branch.
Move = self.env['fp.job.step.move']
move = Move.create({
'job_id': self.step_id.job_id.id,
'from_step_id': self.step_id.id,
'to_step_id': self.step_id.id,
'transfer_type': 'step',
'qty_moved': int(self.step_id.job_id.qty or 1),
'moved_by_user_id': self.env.user.id,
})
ValueModel = self.env['fp.job.step.move.input.value']
captured = 0
for line in self.line_ids:
if not line._has_value():
continue
vals = {
'move_id': move.id,
'node_input_id': line.node_input_id.id or False,
'value_text': line.value_text or False,
'value_number': line.value_number or 0.0,
'value_boolean': line.value_boolean,
'value_date': line.value_date or False,
}
# For ad-hoc rows (no node_input_id), preserve the operator's
# typed prompt label in value_text so the chronological CoC
# report still shows what was measured. Format: "Prompt: value"
if not line.node_input_id and line.name:
if vals['value_text']:
vals['value_text'] = f"{line.name}: {vals['value_text']}"
elif vals['value_number']:
vals['value_text'] = (
f"{line.name}: {vals['value_number']}"
+ (f" {line.target_unit}" if line.target_unit else '')
)
else:
vals['value_text'] = line.name
ValueModel.create(vals)
captured += 1
if captured == 0:
move.unlink()
raise UserError(_(
'Enter at least one value before saving.'
))
self.step_id.message_post(body=_(
'%(n)s step input(s) recorded by %(user)s'
) % {'n': captured, 'user': self.env.user.name})
return {'type': 'ir.actions.act_window_close'}
class FpJobStepInputWizardLine(models.TransientModel):
_name = 'fp.job.step.input.wizard.line'
_description = 'Fusion Plating — Step Input Wizard Line'
wizard_id = fields.Many2one(
'fp.job.step.input.wizard', required=True, ondelete='cascade',
)
# 2026-04-28 fix — node_input_id is optional now so operators can
# record ad-hoc measurements when the recipe has no authored prompts
# (the screenshot case: a step with zero step_input definitions
# rendered an empty wizard with no way to add anything). Authored
# prompts pre-fill name + type as readonly; ad-hoc rows are fully
# editable.
node_input_id = fields.Many2one(
'fusion.plating.process.node.input', ondelete='set null',
)
name = fields.Char(string='Prompt')
# 2026-04-28 — convert input_type + target_unit from Char → Selection
# so operators pick from the curated dropdown. Free-text led to "kg"
# vs "kgs" vs "kilo" inconsistencies on the audit trail.
input_type = fields.Selection(
_FP_INPUT_TYPE_SELECTION,
string='Type',
)
target_min = fields.Float(string='Min')
target_max = fields.Float(string='Max')
target_unit = fields.Selection(
FP_UOM_SELECTION,
string='Unit',
help='Pick from the curated list — keeps every step\'s readings '
'in the same vocabulary across the shop.',
)
value_text = fields.Char(string='Text')
value_number = fields.Float(string='Number')
value_boolean = fields.Boolean(string='Yes/No')
value_date = fields.Datetime(string='Date / Time')
is_authored = fields.Boolean(
compute='_compute_is_authored',
help='True when this row originated from an authored recipe input. '
'Drives field readonly state — authored prompts are locked, '
'ad-hoc rows are fully editable.',
)
@api.depends('node_input_id')
def _compute_is_authored(self):
for rec in self:
rec.is_authored = bool(rec.node_input_id)
def _has_value(self):
self.ensure_one()
return any([
self.value_text,
self.value_number,
self.value_boolean,
self.value_date,
])

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_job_step_input_wizard_form" model="ir.ui.view">
<field name="name">fp.job.step.input.wizard.form</field>
<field name="model">fp.job.step.input.wizard</field>
<field name="arch" type="xml">
<form string="Record Step Inputs">
<sheet>
<group>
<field name="step_id" readonly="1"/>
<field name="job_id" readonly="1"/>
</group>
<separator string="Step Inputs"/>
<p class="text-muted" invisible="line_ids">
No authored prompts on this recipe step. Click
<strong>Add a line</strong> below to record one or
more ad-hoc measurements (operator name + value).
Authored prompts will appear here automatically once
the recipe gets `step_input` rows in the Process
Composer.
</p>
<field name="line_ids">
<list editable="bottom">
<field name="is_authored" column_invisible="1"/>
<field name="name"
readonly="is_authored"
placeholder="e.g. Oven Temp, Operator Initials, Bath Reading"/>
<field name="input_type"
readonly="is_authored"
placeholder="number / text / boolean / date"
optional="show"/>
<field name="target_min" readonly="is_authored" optional="hide"/>
<field name="target_max" readonly="is_authored" optional="hide"/>
<field name="target_unit" readonly="is_authored" optional="show"/>
<field name="value_text"/>
<field name="value_number"/>
<field name="value_boolean" widget="boolean_toggle"/>
<field name="value_date"/>
</list>
</field>
</sheet>
<footer>
<button name="action_commit" type="object"
string="Record" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fp_job_step_input_wizard" model="ir.actions.act_window">
<field name="name">Record Step Inputs</field>
<field name="res_model">fp.job.step.input.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,344 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Backend Move-to-Next wizard.
Mirrors the tablet's Move Parts dialog (`fusion_plating_shopfloor`'s
`move_parts_dialog.js`) so a manager running the whole job from the
backend form on a low-staffing day captures the same chain-of-custody
record the operator would create from the tablet — same `fp.job.step.move`
row + same `transition_input_value_ids` snapshot, same chatter trail,
same downstream report rendering.
Compliance prompts (transition inputs authored on the recipe node)
appear as editable rows on the wizard. Submit creates the move log,
finishes the from-step if it's still in_progress, and starts the
to-step.
"""
from odoo import _, api, fields, models
from odoo.exceptions import UserError
# Mirror the Selection on the Record Inputs wizard so both dialogs use
# the same Type vocabulary.
_FP_INPUT_TYPE_SELECTION = [
('text', 'Text'),
('number', 'Number'),
('boolean', 'Yes/No'),
('selection', 'Selection'),
('date', 'Date / Time'),
('signature', 'Signature'),
('time_hms', 'Time (HH:MM:SS)'),
('time_seconds', 'Time (seconds)'),
('temperature', 'Temperature'),
('thickness', 'Thickness'),
('pass_fail', 'Pass / Fail'),
]
class FpJobStepMoveWizard(models.TransientModel):
_name = 'fp.job.step.move.wizard'
_description = 'Fusion Plating — Move Step Wizard (Backend)'
job_id = fields.Many2one('fp.job', string='Job', required=True, readonly=True)
from_step_id = fields.Many2one(
'fp.job.step',
string='From Step',
required=True,
domain="[('job_id', '=', job_id)]",
)
to_step_id = fields.Many2one(
'fp.job.step',
string='To Step',
required=True,
domain="[('job_id', '=', job_id), ('id', '!=', from_step_id)]",
help='Defaults to the next sequenced step on this job.',
)
transfer_type = fields.Selection(
[
('step', 'Step'),
('hold', 'Hold'),
('scrap', 'Scrap'),
('rework', 'Rework'),
('split', 'Split'),
('return', 'Return'),
],
string='Transfer Type', default='step', required=True,
)
qty_moved = fields.Integer(string='Qty Moved', required=True, default=1)
to_location = fields.Selection(
[
('global', 'Global'),
('quarantine', 'Quarantine'),
('staging_a', 'Staging A'),
('staging_b', 'Staging B'),
('shipping_dock', 'Shipping Dock'),
('scrap_bin', 'Scrap Bin'),
],
string='To Location', default='global',
)
notes = fields.Text(string='Notes')
finish_from_step = fields.Boolean(
string='Finish From-Step',
default=True,
help='If the from-step is still in progress, finishing it on move '
'closes the timelog and stamps the audit trail.',
)
start_to_step = fields.Boolean(
string='Start To-Step',
default=True,
help='If the to-step is ready, start it after the move so the '
'next operator picks up an in-progress step.',
)
input_value_ids = fields.One2many(
'fp.job.step.move.wizard.input',
'wizard_id',
string='Compliance Prompts',
help='Authored transition inputs from the to-step\'s recipe node. '
'Capture the operator\'s answers — they snapshot to '
'fp.job.step.move.input.value when the wizard commits.',
)
# ==================================================================
@api.model
def default_get(self, fields_list):
defaults = super().default_get(fields_list)
ctx = self.env.context
from_step_id = ctx.get('default_from_step_id') or ctx.get('active_id')
if from_step_id and self.env.context.get('active_model') != 'fp.job.step':
# Came from job form button — active_id is the job, not the step
from_step_id = ctx.get('default_from_step_id')
if from_step_id:
from_step = self.env['fp.job.step'].browse(from_step_id)
if from_step.exists():
defaults['from_step_id'] = from_step.id
defaults['job_id'] = from_step.job_id.id
defaults['qty_moved'] = int(from_step.job_id.qty or 1)
# Next sequenced step that isn't done/cancelled
next_step = self.env['fp.job.step'].search([
('job_id', '=', from_step.job_id.id),
('sequence', '>', from_step.sequence),
('state', 'not in', ('done', 'cancelled', 'skipped')),
], order='sequence asc, id asc', limit=1)
if next_step:
defaults['to_step_id'] = next_step.id
# Pre-seed input_value_ids from authored prompts on
# both ends of the move so programmatic creators
# (script tests, RPC clients) get them too —
# @api.onchange only fires in interactive UI.
seen = set()
rows = []
if from_step.recipe_node_id:
inputs = from_step.recipe_node_id.input_ids
if 'kind' in inputs._fields:
inputs = inputs.filtered(
lambda i: i.kind == 'step_input')
for inp in inputs.sorted('sequence'):
if inp.id in seen:
continue
seen.add(inp.id)
rows.append((0, 0, {
'node_input_id': inp.id,
'name': '%s (Step Input)' % inp.name,
'input_type': inp.input_type,
}))
if next_step.recipe_node_id:
inputs = next_step.recipe_node_id.input_ids
if 'kind' in inputs._fields:
inputs = inputs.filtered(
lambda i: i.kind == 'transition_input')
for inp in inputs.sorted('sequence'):
if inp.id in seen:
continue
seen.add(inp.id)
rows.append((0, 0, {
'node_input_id': inp.id,
'name': '%s (Transition)' % inp.name,
'input_type': inp.input_type,
}))
if rows:
defaults['input_value_ids'] = rows
return defaults
@api.onchange('to_step_id', 'from_step_id')
def _onchange_to_step_seed_inputs(self):
"""Seed prompt rows from BOTH
* the to-step's recipe node `transition_input` prompts —
authored compliance fields fired on move-in.
* the from-step's recipe node `step_input` prompts —
measurements that should be captured BEFORE leaving the
from-step (operator answers "what did you actually run?"
while the data is fresh).
2026-04-28 fix — previously only transition_input was pulled,
which left the section empty for steps where the author only
defined step_input prompts. Operators tried to record inputs
at move time and got an unfillable form.
"""
for wiz in self:
wiz.input_value_ids = [(5, 0, 0)]
seen = set()
rows = []
# 1. From-step's step_input prompts — measurements captured
# while finalising the step.
if wiz.from_step_id and wiz.from_step_id.recipe_node_id:
from_node = wiz.from_step_id.recipe_node_id
inputs = from_node.input_ids
if 'kind' in inputs._fields:
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
for inp in inputs.sorted('sequence'):
if inp.id in seen:
continue
seen.add(inp.id)
rows.append((0, 0, {
'node_input_id': inp.id,
'name': '%s (Step Input)' % inp.name,
'input_type': inp.input_type,
}))
# 2. To-step's transition_input prompts — compliance gates
# fired on entry to the next step.
if wiz.to_step_id and wiz.to_step_id.recipe_node_id:
to_node = wiz.to_step_id.recipe_node_id
inputs = to_node.input_ids
if 'kind' in inputs._fields:
inputs = inputs.filtered(lambda i: i.kind == 'transition_input')
for inp in inputs.sorted('sequence'):
if inp.id in seen:
continue
seen.add(inp.id)
rows.append((0, 0, {
'node_input_id': inp.id,
'name': '%s (Transition)' % inp.name,
'input_type': inp.input_type,
}))
wiz.input_value_ids = rows
# ==================================================================
def action_commit(self):
self.ensure_one()
if not self.from_step_id or not self.to_step_id:
raise UserError(_('Pick both From and To steps before moving.'))
Move = self.env['fp.job.step.move']
move = Move.create({
'job_id': self.job_id.id,
'from_step_id': self.from_step_id.id,
'to_step_id': self.to_step_id.id,
'transfer_type': self.transfer_type,
'qty_moved': self.qty_moved,
'qty_available_at_move': self.qty_moved,
'to_location': self.to_location,
'moved_by_user_id': self.env.user.id,
})
# Snapshot captured prompt values into fp.job.step.move.input.value.
ValueModel = self.env['fp.job.step.move.input.value']
for line in self.input_value_ids:
if not line._has_value():
continue
vals = {
'move_id': move.id,
'node_input_id': line.node_input_id.id or False,
'value_text': line.value_text or False,
'value_number': line.value_number or 0.0,
'value_boolean': line.value_boolean,
'value_date': line.value_date or False,
}
# Ad-hoc rows (no node_input_id) — preserve the operator's typed
# prompt label in value_text so the chronological CoC report
# still shows what was measured.
if not line.node_input_id and line.name:
if vals['value_text']:
vals['value_text'] = f"{line.name}: {vals['value_text']}"
elif vals['value_number']:
vals['value_text'] = f"{line.name}: {vals['value_number']}"
else:
vals['value_text'] = line.name
ValueModel.create(vals)
# Finish from-step if requested AND it's still running.
if self.finish_from_step and self.from_step_id.state == 'in_progress':
self.from_step_id.button_finish()
# Start to-step if requested AND it's ready/paused.
if self.start_to_step and self.to_step_id.state in ('ready', 'paused', 'pending'):
# Auto-promote pending → ready when manager moves into it
if self.to_step_id.state == 'pending':
self.to_step_id.state = 'ready'
self.to_step_id.button_start()
# Surface the new move on the job's chatter so anyone watching
# the job form sees the activity in real time.
self.job_id.message_post(body=_(
'Moved %(qty)s parts: %(from)s%(to)s by %(user)s'
) % {
'qty': self.qty_moved,
'from': self.from_step_id.name,
'to': self.to_step_id.name,
'user': self.env.user.name,
})
if self.notes:
move.message_post(body=self.notes)
return {'type': 'ir.actions.act_window_close'}
class FpJobStepMoveWizardInput(models.TransientModel):
"""Repeater row mirroring fp.job.step.move.input.value.
Lives on the wizard so the operator/manager fills these inline,
then `action_commit` snapshots them into the real model. Keeping
a transient mirror means the wizard form can be filled, cancelled,
and reopened without polluting the chain-of-custody audit log.
2026-04-28 — `node_input_id` is now optional so operators can add
ad-hoc input rows directly from the Move dialog (operator types
the prompt label + value). Authored prompts still pre-fill
name + type as readonly; ad-hoc rows are fully editable. Same
pattern as the standalone Record Inputs wizard."""
_name = 'fp.job.step.move.wizard.input'
_description = 'Fusion Plating — Move Wizard Input Row'
wizard_id = fields.Many2one(
'fp.job.step.move.wizard',
required=True, ondelete='cascade',
)
node_input_id = fields.Many2one(
'fusion.plating.process.node.input',
string='Prompt',
ondelete='set null',
)
name = fields.Char(string='Prompt')
input_type = fields.Selection(
_FP_INPUT_TYPE_SELECTION,
string='Type',
)
value_text = fields.Char(string='Text Value')
value_number = fields.Float(string='Number Value')
value_boolean = fields.Boolean(string='Yes/No')
value_date = fields.Datetime(string='Date / Time')
is_authored = fields.Boolean(
compute='_compute_is_authored',
help='True when this row originated from an authored recipe input. '
'Drives field readonly state — authored prompts are locked, '
'ad-hoc rows are fully editable.',
)
@api.depends('node_input_id')
def _compute_is_authored(self):
for rec in self:
rec.is_authored = bool(rec.node_input_id)
def _has_value(self):
self.ensure_one()
return any([
self.value_text,
self.value_number,
self.value_boolean,
self.value_date,
])

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_job_step_move_wizard_form" model="ir.ui.view">
<field name="name">fp.job.step.move.wizard.form</field>
<field name="model">fp.job.step.move.wizard</field>
<field name="arch" type="xml">
<form string="Move Step">
<sheet>
<group>
<group>
<field name="job_id" readonly="1"/>
<field name="from_step_id"/>
<field name="to_step_id"/>
</group>
<group>
<field name="transfer_type"/>
<field name="qty_moved"/>
<field name="to_location"/>
</group>
</group>
<group>
<field name="finish_from_step"/>
<field name="start_to_step"/>
</group>
<separator string="Inputs (compliance + step measurements)"/>
<p class="text-muted" invisible="input_value_ids">
No authored prompts on either step. Click
<strong>Add a line</strong> below to record an
ad-hoc measurement (operator name + value). The
capture lands on the chronological CoC alongside
any authored prompts.
</p>
<field name="input_value_ids">
<list editable="bottom">
<field name="is_authored" column_invisible="1"/>
<field name="name"
readonly="is_authored"
placeholder="e.g. Oven Temp, Bath OK?, Operator Initials"/>
<field name="input_type"
readonly="is_authored"
optional="show"/>
<field name="value_text"/>
<field name="value_number"/>
<field name="value_boolean" widget="boolean_toggle"/>
<field name="value_date"/>
</list>
</field>
<separator string="Notes"/>
<field name="notes" nolabel="1"
placeholder="Optional context — why this move, what to watch for next..."/>
</sheet>
<footer>
<button name="action_commit" type="object"
string="Move" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fp_job_step_move_wizard" model="ir.actions.act_window">
<field name="name">Move Step</field>
<field name="res_model">fp.job.step.move.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -44,9 +44,8 @@
<field name="company_id" groups="base.group_multi_company"/> <field name="company_id" groups="base.group_multi_company"/>
</group> </group>
</group> </group>
<group string="Notes"> <separator string="Notes"/>
<field name="notes" nolabel="1"/> <field name="notes"/>
</group>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -46,12 +46,10 @@
<field name="mail_mail_id"/> <field name="mail_mail_id"/>
</group> </group>
</group> </group>
<group string="Attachments" invisible="not attachment_names"> <separator string="Attachments"/>
<field name="attachment_names" nolabel="1" colspan="2"/> <field name="attachment_names" colspan="2"/>
</group> <separator string="Error Details"/>
<group string="Error Details" invisible="status != 'failed'"> <field name="error_message" colspan="2"/>
<field name="error_message" nolabel="1" colspan="2"/>
</group>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -45,9 +45,8 @@
<field name="active"/> <field name="active"/>
</group> </group>
</group> </group>
<group string="Description"> <separator string="Description"/>
<field name="description" nolabel="1"/> <field name="description"/>
</group>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -16,7 +16,7 @@
<field name="code">AN_H2SO4</field> <field name="code">AN_H2SO4</field>
<field name="sequence">10</field> <field name="sequence">10</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">180.0</field> <field name="target_min">180.0</field>
<field name="target_max">220.0</field> <field name="target_max">220.0</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -29,7 +29,7 @@
<field name="code">AN_AL</field> <field name="code">AN_AL</field>
<field name="sequence">20</field> <field name="sequence">20</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">0.0</field> <field name="target_min">0.0</field>
<field name="target_max">15.0</field> <field name="target_max">15.0</field>
<field name="warning_tolerance">0.0</field> <field name="warning_tolerance">0.0</field>
@@ -42,7 +42,7 @@
<field name="code">AN_TEMP_II</field> <field name="code">AN_TEMP_II</field>
<field name="sequence">30</field> <field name="sequence">30</field>
<field name="parameter_type">temperature</field> <field name="parameter_type">temperature</field>
<field name="uom">°C</field> <field name="uom">c</field>
<field name="target_min">18.0</field> <field name="target_min">18.0</field>
<field name="target_max">22.0</field> <field name="target_max">22.0</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -55,7 +55,7 @@
<field name="code">AN_TEMP_III</field> <field name="code">AN_TEMP_III</field>
<field name="sequence">40</field> <field name="sequence">40</field>
<field name="parameter_type">temperature</field> <field name="parameter_type">temperature</field>
<field name="uom">°C</field> <field name="uom">c</field>
<field name="target_min">-2.0</field> <field name="target_min">-2.0</field>
<field name="target_max">5.0</field> <field name="target_max">5.0</field>
<field name="warning_tolerance">10.0</field> <field name="warning_tolerance">10.0</field>
@@ -68,7 +68,7 @@
<field name="code">AN_CD</field> <field name="code">AN_CD</field>
<field name="sequence">50</field> <field name="sequence">50</field>
<field name="parameter_type">other</field> <field name="parameter_type">other</field>
<field name="uom">A/dm²</field> <field name="uom">asd_a_dm2</field>
<field name="target_min">1.2</field> <field name="target_min">1.2</field>
<field name="target_max">2.5</field> <field name="target_max">2.5</field>
<field name="warning_tolerance">10.0</field> <field name="warning_tolerance">10.0</field>
@@ -81,7 +81,7 @@
<field name="code">AN_VOLT</field> <field name="code">AN_VOLT</field>
<field name="sequence">60</field> <field name="sequence">60</field>
<field name="parameter_type">other</field> <field name="parameter_type">other</field>
<field name="uom">V</field> <field name="uom">v</field>
<field name="target_min">15.0</field> <field name="target_min">15.0</field>
<field name="target_max">100.0</field> <field name="target_max">100.0</field>
<field name="warning_tolerance">10.0</field> <field name="warning_tolerance">10.0</field>
@@ -94,7 +94,7 @@
<field name="code">AN_CHROMIC</field> <field name="code">AN_CHROMIC</field>
<field name="sequence">70</field> <field name="sequence">70</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">30.0</field> <field name="target_min">30.0</field>
<field name="target_max">60.0</field> <field name="target_max">60.0</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -107,7 +107,7 @@
<field name="code">AN_SEAL_TEMP</field> <field name="code">AN_SEAL_TEMP</field>
<field name="sequence">80</field> <field name="sequence">80</field>
<field name="parameter_type">temperature</field> <field name="parameter_type">temperature</field>
<field name="uom">°C</field> <field name="uom">c</field>
<field name="target_min">95.0</field> <field name="target_min">95.0</field>
<field name="target_max">100.0</field> <field name="target_max">100.0</field>
<field name="warning_tolerance">3.0</field> <field name="warning_tolerance">3.0</field>
@@ -120,7 +120,7 @@
<field name="code">AN_SEAL_PH</field> <field name="code">AN_SEAL_PH</field>
<field name="sequence">90</field> <field name="sequence">90</field>
<field name="parameter_type">ph</field> <field name="parameter_type">ph</field>
<field name="uom">pH</field> <field name="uom">ph</field>
<field name="target_min">5.5</field> <field name="target_min">5.5</field>
<field name="target_max">6.5</field> <field name="target_max">6.5</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -133,7 +133,7 @@
<field name="code">AN_SEAL_NIAC</field> <field name="code">AN_SEAL_NIAC</field>
<field name="sequence">100</field> <field name="sequence">100</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">4.0</field> <field name="target_min">4.0</field>
<field name="target_max">6.0</field> <field name="target_max">6.0</field>
<field name="warning_tolerance">10.0</field> <field name="warning_tolerance">10.0</field>
@@ -146,7 +146,7 @@
<field name="code">AN_DYE_CONC</field> <field name="code">AN_DYE_CONC</field>
<field name="sequence">110</field> <field name="sequence">110</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">5.0</field> <field name="target_min">5.0</field>
<field name="target_max">15.0</field> <field name="target_max">15.0</field>
<field name="warning_tolerance">10.0</field> <field name="warning_tolerance">10.0</field>
@@ -159,7 +159,7 @@
<field name="code">AN_DYE_TEMP</field> <field name="code">AN_DYE_TEMP</field>
<field name="sequence">120</field> <field name="sequence">120</field>
<field name="parameter_type">temperature</field> <field name="parameter_type">temperature</field>
<field name="uom">°C</field> <field name="uom">c</field>
<field name="target_min">55.0</field> <field name="target_min">55.0</field>
<field name="target_max">65.0</field> <field name="target_max">65.0</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>

View File

@@ -13,7 +13,7 @@
<field name="name">Sodium Hydroxide (NaOH)</field> <field name="name">Sodium Hydroxide (NaOH)</field>
<field name="code">BOX_NAOH</field> <field name="code">BOX_NAOH</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">600.0</field> <field name="target_min">600.0</field>
<field name="target_max">800.0</field> <field name="target_max">800.0</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -25,7 +25,7 @@
<field name="name">Oxidizer (Nitrate/Nitrite)</field> <field name="name">Oxidizer (Nitrate/Nitrite)</field>
<field name="code">BOX_NITRATE</field> <field name="code">BOX_NITRATE</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">150.0</field> <field name="target_min">150.0</field>
<field name="target_max">250.0</field> <field name="target_max">250.0</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -37,7 +37,7 @@
<field name="name">Bath Temperature (Hot Process)</field> <field name="name">Bath Temperature (Hot Process)</field>
<field name="code">BOX_TEMP_HOT</field> <field name="code">BOX_TEMP_HOT</field>
<field name="parameter_type">temperature</field> <field name="parameter_type">temperature</field>
<field name="uom">°C</field> <field name="uom">c</field>
<field name="target_min">138.0</field> <field name="target_min">138.0</field>
<field name="target_max">143.0</field> <field name="target_max">143.0</field>
<field name="warning_tolerance">2.0</field> <field name="warning_tolerance">2.0</field>
@@ -49,7 +49,7 @@
<field name="name">Bath Temperature (Midtemp Process)</field> <field name="name">Bath Temperature (Midtemp Process)</field>
<field name="code">BOX_TEMP_MID</field> <field name="code">BOX_TEMP_MID</field>
<field name="parameter_type">temperature</field> <field name="parameter_type">temperature</field>
<field name="uom">°C</field> <field name="uom">c</field>
<field name="target_min">90.0</field> <field name="target_min">90.0</field>
<field name="target_max">100.0</field> <field name="target_max">100.0</field>
<field name="warning_tolerance">3.0</field> <field name="warning_tolerance">3.0</field>
@@ -61,7 +61,7 @@
<field name="name">Bath Temperature (Room Temp Process)</field> <field name="name">Bath Temperature (Room Temp Process)</field>
<field name="code">BOX_TEMP_RT</field> <field name="code">BOX_TEMP_RT</field>
<field name="parameter_type">temperature</field> <field name="parameter_type">temperature</field>
<field name="uom">°C</field> <field name="uom">c</field>
<field name="target_min">18.0</field> <field name="target_min">18.0</field>
<field name="target_max">28.0</field> <field name="target_max">28.0</field>
<field name="warning_tolerance">10.0</field> <field name="warning_tolerance">10.0</field>
@@ -85,7 +85,7 @@
<field name="name">pH (Room Temp Process)</field> <field name="name">pH (Room Temp Process)</field>
<field name="code">BOX_PH_RT</field> <field name="code">BOX_PH_RT</field>
<field name="parameter_type">ph</field> <field name="parameter_type">ph</field>
<field name="uom">pH</field> <field name="uom">ph</field>
<field name="target_min">8.0</field> <field name="target_min">8.0</field>
<field name="target_max">9.5</field> <field name="target_max">9.5</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -97,7 +97,7 @@
<field name="name">Rinse Water pH</field> <field name="name">Rinse Water pH</field>
<field name="code">BOX_RINSE_PH</field> <field name="code">BOX_RINSE_PH</field>
<field name="parameter_type">ph</field> <field name="parameter_type">ph</field>
<field name="uom">pH</field> <field name="uom">ph</field>
<field name="target_min">6.5</field> <field name="target_min">6.5</field>
<field name="target_max">7.5</field> <field name="target_max">7.5</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>

View File

@@ -16,7 +16,7 @@
<field name="code">CR_CRVI</field> <field name="code">CR_CRVI</field>
<field name="sequence">10</field> <field name="sequence">10</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">200.0</field> <field name="target_min">200.0</field>
<field name="target_max">280.0</field> <field name="target_max">280.0</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -29,7 +29,7 @@
<field name="code">CR_CRIII</field> <field name="code">CR_CRIII</field>
<field name="sequence">20</field> <field name="sequence">20</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">15.0</field> <field name="target_min">15.0</field>
<field name="target_max">25.0</field> <field name="target_max">25.0</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -42,7 +42,7 @@
<field name="code">CR_H2SO4</field> <field name="code">CR_H2SO4</field>
<field name="sequence">30</field> <field name="sequence">30</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">2.0</field> <field name="target_min">2.0</field>
<field name="target_max">3.0</field> <field name="target_max">3.0</field>
<field name="warning_tolerance">10.0</field> <field name="warning_tolerance">10.0</field>
@@ -55,7 +55,7 @@
<field name="code">CR_RATIO</field> <field name="code">CR_RATIO</field>
<field name="sequence">40</field> <field name="sequence">40</field>
<field name="parameter_type">ratio</field> <field name="parameter_type">ratio</field>
<field name="uom">:1</field> <field name="uom">ratio</field>
<field name="target_min">90.0</field> <field name="target_min">90.0</field>
<field name="target_max">110.0</field> <field name="target_max">110.0</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -68,7 +68,7 @@
<field name="code">CR_TEMP</field> <field name="code">CR_TEMP</field>
<field name="sequence">50</field> <field name="sequence">50</field>
<field name="parameter_type">temperature</field> <field name="parameter_type">temperature</field>
<field name="uom">°C</field> <field name="uom">c</field>
<field name="target_min">50.0</field> <field name="target_min">50.0</field>
<field name="target_max">55.0</field> <field name="target_max">55.0</field>
<field name="warning_tolerance">3.0</field> <field name="warning_tolerance">3.0</field>
@@ -81,7 +81,7 @@
<field name="code">CR_CD</field> <field name="code">CR_CD</field>
<field name="sequence">60</field> <field name="sequence">60</field>
<field name="parameter_type">other</field> <field name="parameter_type">other</field>
<field name="uom">A/dm²</field> <field name="uom">asd_a_dm2</field>
<field name="target_min">30.0</field> <field name="target_min">30.0</field>
<field name="target_max">60.0</field> <field name="target_max">60.0</field>
<field name="warning_tolerance">10.0</field> <field name="warning_tolerance">10.0</field>
@@ -94,7 +94,7 @@
<field name="code">CR_FE</field> <field name="code">CR_FE</field>
<field name="sequence">70</field> <field name="sequence">70</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">0.0</field> <field name="target_min">0.0</field>
<field name="target_max">10.0</field> <field name="target_max">10.0</field>
<field name="warning_tolerance">0.0</field> <field name="warning_tolerance">0.0</field>
@@ -107,7 +107,7 @@
<field name="code">CR_CU</field> <field name="code">CR_CU</field>
<field name="sequence">80</field> <field name="sequence">80</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">0.0</field> <field name="target_min">0.0</field>
<field name="target_max">2.0</field> <field name="target_max">2.0</field>
<field name="warning_tolerance">0.0</field> <field name="warning_tolerance">0.0</field>
@@ -120,7 +120,7 @@
<field name="code">CR_MIST</field> <field name="code">CR_MIST</field>
<field name="sequence">90</field> <field name="sequence">90</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">mg/L</field> <field name="uom">mg_l</field>
<field name="target_min">50.0</field> <field name="target_min">50.0</field>
<field name="target_max">100.0</field> <field name="target_max">100.0</field>
<field name="warning_tolerance">10.0</field> <field name="warning_tolerance">10.0</field>
@@ -133,7 +133,7 @@
<field name="code">CR_VOLT</field> <field name="code">CR_VOLT</field>
<field name="sequence">100</field> <field name="sequence">100</field>
<field name="parameter_type">other</field> <field name="parameter_type">other</field>
<field name="uom">V</field> <field name="uom">v</field>
<field name="target_min">5.0</field> <field name="target_min">5.0</field>
<field name="target_max">12.0</field> <field name="target_max">12.0</field>
<field name="warning_tolerance">10.0</field> <field name="warning_tolerance">10.0</field>

View File

@@ -15,7 +15,7 @@
<field name="code">EN_NI</field> <field name="code">EN_NI</field>
<field name="sequence">10</field> <field name="sequence">10</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">5.5</field> <field name="target_min">5.5</field>
<field name="target_max">6.5</field> <field name="target_max">6.5</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -28,7 +28,7 @@
<field name="code">EN_HYPO</field> <field name="code">EN_HYPO</field>
<field name="sequence">20</field> <field name="sequence">20</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">25.0</field> <field name="target_min">25.0</field>
<field name="target_max">30.0</field> <field name="target_max">30.0</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -41,7 +41,7 @@
<field name="code">EN_ORTHO</field> <field name="code">EN_ORTHO</field>
<field name="sequence">30</field> <field name="sequence">30</field>
<field name="parameter_type">concentration</field> <field name="parameter_type">concentration</field>
<field name="uom">g/L</field> <field name="uom">g_l</field>
<field name="target_min">0.0</field> <field name="target_min">0.0</field>
<field name="target_max">200.0</field> <field name="target_max">200.0</field>
<field name="warning_tolerance">10.0</field> <field name="warning_tolerance">10.0</field>
@@ -54,7 +54,7 @@
<field name="code">EN_PH</field> <field name="code">EN_PH</field>
<field name="sequence">40</field> <field name="sequence">40</field>
<field name="parameter_type">ph</field> <field name="parameter_type">ph</field>
<field name="uom">pH</field> <field name="uom">ph</field>
<field name="target_min">4.5</field> <field name="target_min">4.5</field>
<field name="target_max">5.2</field> <field name="target_max">5.2</field>
<field name="warning_tolerance">5.0</field> <field name="warning_tolerance">5.0</field>
@@ -67,7 +67,7 @@
<field name="code">EN_TEMP</field> <field name="code">EN_TEMP</field>
<field name="sequence">50</field> <field name="sequence">50</field>
<field name="parameter_type">temperature</field> <field name="parameter_type">temperature</field>
<field name="uom">°C</field> <field name="uom">c</field>
<field name="target_min">85.0</field> <field name="target_min">85.0</field>
<field name="target_max">92.0</field> <field name="target_max">92.0</field>
<field name="warning_tolerance">3.0</field> <field name="warning_tolerance">3.0</field>
@@ -80,7 +80,7 @@
<field name="code">EN_MTO</field> <field name="code">EN_MTO</field>
<field name="sequence">60</field> <field name="sequence">60</field>
<field name="parameter_type">count</field> <field name="parameter_type">count</field>
<field name="uom">MTO</field> <field name="uom">mto</field>
<field name="target_min">0.0</field> <field name="target_min">0.0</field>
<field name="target_max">8.0</field> <field name="target_max">8.0</field>
<field name="warning_tolerance">0.0</field> <field name="warning_tolerance">0.0</field>
@@ -93,7 +93,7 @@
<field name="code">EN_LOAD</field> <field name="code">EN_LOAD</field>
<field name="sequence">70</field> <field name="sequence">70</field>
<field name="parameter_type">ratio</field> <field name="parameter_type">ratio</field>
<field name="uom">dm²/L</field> <field name="uom">dm2_l</field>
<field name="target_min">0.5</field> <field name="target_min">0.5</field>
<field name="target_max">1.5</field> <field name="target_max">1.5</field>
<field name="warning_tolerance">10.0</field> <field name="warning_tolerance">10.0</field>

View File

@@ -110,9 +110,8 @@
invisible="not s20_evaluate_risk"/> invisible="not s20_evaluate_risk"/>
</group> </group>
</group> </group>
<group string="Comments"> <separator string="Comments"/>
<field name="s20_comments" nolabel="1" readonly="s20_locked"/> <field name="s20_comments" readonly="s20_locked"/>
</group>
<group string="Signature" invisible="not s20_locked"> <group string="Signature" invisible="not s20_locked">
<field name="s20_signed_by" readonly="1"/> <field name="s20_signed_by" readonly="1"/>
<field name="s20_signed_date" readonly="1"/> <field name="s20_signed_date" readonly="1"/>

View File

@@ -92,10 +92,9 @@
<field name="value_max"/> <field name="value_max"/>
<field name="value_uom"/> <field name="value_uom"/>
</group> </group>
<group string="Guidance"> <separator string="Guidance"/>
<field name="description" nolabel="1" <field name="description"
placeholder="Inspection guidance shown to the operator on tap..."/> placeholder="Inspection guidance shown to the operator on tap..."/>
</group>
</sheet> </sheet>
</form> </form>
</field> </field>

View File

@@ -83,9 +83,7 @@
<field name="current_process_node"/> <field name="current_process_node"/>
</group> </group>
</group> </group>
<group> <field name="description" placeholder="Describe the reason for the hold..."/>
<field name="description" placeholder="Describe the reason for the hold..."/>
</group>
<group string="Attachments"> <group string="Attachments">
<field name="attachment_ids" widget="many2many_binary" nolabel="1"/> <field name="attachment_ids" widget="many2many_binary" nolabel="1"/>
</group> </group>

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Receiving & Inspection', 'name': 'Fusion Plating — Receiving & Inspection',
'version': '19.0.3.5.0', 'version': '19.0.3.7.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.', 'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """ 'description': """

View File

@@ -8,6 +8,8 @@
# boxes (which is DIFFERENT from receiving — receiving is box count # boxes (which is DIFFERENT from receiving — receiving is box count
# only). One record per MO. # only). One record per MO.
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError from odoo.exceptions import UserError, ValidationError
@@ -151,6 +153,27 @@ class FpRackingInspection(models.Model):
'inspection_completed': fields.Datetime.now(), 'inspection_completed': fields.Datetime.now(),
}) })
if new_state == 'discrepancy_flagged': if new_state == 'discrepancy_flagged':
# 2026-04-28 — Activity must land on a real user.
# Resolve the assignee in priority order:
# 1. The job's plating manager (if set on fp.job)
# 2. The inspector who just flagged it
# 3. The current user (env.uid fallback)
# `activity_schedule` defaults to env.uid only when the
# record has a `user_id` field; fp.racking.inspection
# has `inspector_id` but not `user_id`, so we'd land on
# False if we let it default. Explicit assignment is
# the only safe path.
assignee = False
if (rec.x_fc_job_id and 'manager_id' in rec.x_fc_job_id._fields
and rec.x_fc_job_id.manager_id):
assignee = rec.x_fc_job_id.manager_id.id
elif rec.inspector_id:
assignee = rec.inspector_id.id
else:
assignee = self.env.uid
# 3-day deadline so it surfaces in "Overdue" dashboards
# if not addressed before plating starts.
deadline = fields.Date.today() + relativedelta(days=3)
rec.activity_schedule( rec.activity_schedule(
'mail.mail_activity_data_todo', 'mail.mail_activity_data_todo',
summary=_('Racking discrepancy on %s') % ( summary=_('Racking discrepancy on %s') % (
@@ -160,6 +183,8 @@ class FpRackingInspection(models.Model):
'%(n)d line(s) flagged — review before starting ' '%(n)d line(s) flagged — review before starting '
'the first plating WO.' 'the first plating WO.'
) % {'n': rec.flagged_count}, ) % {'n': rec.flagged_count},
user_id=assignee,
date_deadline=deadline,
) )
rec.message_post(body=_( rec.message_post(body=_(
'Inspection completed — %(ok)d ok / %(flag)d flagged.' 'Inspection completed — %(ok)d ok / %(flag)d flagged.'
@@ -214,6 +239,50 @@ class FpRackingInspectionLine(models.Model):
) )
notes = fields.Char(string='Notes') notes = fields.Char(string='Notes')
# 2026-04-28 — photos on a line (compliance need: damage evidence,
# box-by-box condition record). Many2many to ir.attachment so an
# operator can shoot multiple angles per box from the floor without
# leaving the form. Cascade-deleted with the line.
photo_ids = fields.Many2many(
'ir.attachment',
relation='fp_racking_insp_line_photo_rel',
column1='line_id',
column2='attachment_id',
string='Photos',
domain="[('mimetype', 'ilike', 'image/')]",
help='Damage / condition photos for this box. Click + to upload '
'one or more from the camera roll. Cascades on delete.',
)
photo_count = fields.Integer(
string='# Photos',
compute='_compute_photo_count',
)
@api.depends('photo_ids')
def _compute_photo_count(self):
for rec in self:
rec.photo_count = len(rec.photo_ids)
@api.model_create_multi
def create(self, vals_list):
# Auto-populate part_catalog_id from the parent inspection's job
# when the operator added a line without picking a part. The
# job's SO carries the customer's part — pre-fill the line so
# the audit trail captures it without requiring extra clicks.
for vals in vals_list:
if not vals.get('part_catalog_id') and vals.get('inspection_id'):
ri = self.env['fp.racking.inspection'].browse(
vals['inspection_id'])
if ri.exists() and ri.x_fc_job_id:
so = ri.x_fc_job_id.sale_order_id
if so:
line = so.order_line.filtered(
lambda l: l.x_fc_part_catalog_id
)[:1]
if line:
vals['part_catalog_id'] = line.x_fc_part_catalog_id.id
return super().create(vals_list)
@api.depends('qty_expected', 'qty_found') @api.depends('qty_expected', 'qty_found')
def _compute_qty_variance(self): def _compute_qty_variance(self):
for rec in self: for rec in self:

View File

@@ -54,19 +54,56 @@
<notebook> <notebook>
<page string="Inspection Lines" name="lines"> <page string="Inspection Lines" name="lines">
<field name="line_ids" readonly="state in ('done','discrepancy_flagged')"> <field name="line_ids" readonly="state in ('done','discrepancy_flagged')">
<list editable="bottom" <list decoration-warning="condition == 'minor' or qty_variance != 0"
decoration-warning="condition == 'minor' or qty_variance != 0"
decoration-danger="condition in ('major','reject')"> decoration-danger="condition in ('major','reject')">
<field name="sequence" widget="handle"/> <field name="sequence" widget="handle"/>
<field name="part_catalog_id"/> <field name="part_catalog_id"
<field name="part_number" readonly="1"/> string="Part"/>
<field name="part_revision" readonly="1"/> <field name="part_number"
<field name="qty_expected"/> string="Part #"
<field name="qty_found"/> readonly="1" optional="show"/>
<field name="qty_variance" readonly="1"/> <field name="part_revision"
string="Rev"
readonly="1" optional="show"/>
<field name="qty_expected"
string="Expected"/>
<field name="qty_found"
string="Counted"/>
<field name="qty_variance"
string="Δ"
readonly="1"/>
<field name="condition"/> <field name="condition"/>
<field name="notes"/> <field name="photo_count"
string="📷"
optional="show"/>
<field name="notes" optional="show"/>
</list> </list>
<form string="Inspection Line">
<sheet>
<group>
<group string="Part">
<field name="part_catalog_id"/>
<field name="part_number" readonly="1"/>
<field name="part_revision" readonly="1"/>
<field name="sequence"/>
</group>
<group string="Counts">
<field name="qty_expected"/>
<field name="qty_found"/>
<field name="qty_variance" readonly="1"/>
<field name="condition"/>
</group>
</group>
<separator string="Photos"/>
<field name="photo_ids"
widget="many2many_binary"
nolabel="1"
help="Drag &amp; drop or click to attach damage / condition photos for this box."/>
<separator string="Notes"/>
<field name="notes" nolabel="1"
placeholder="Box number, serial range, what was found, who flagged it, what photos cover..."/>
</sheet>
</form>
</field> </field>
</page> </page>
<page string="Notes" name="notes"> <page string="Notes" name="notes">

View File

@@ -88,7 +88,7 @@
<field name="facility_id" ref="fusion_plating.demo_facility_main"/> <field name="facility_id" ref="fusion_plating.demo_facility_main"/>
<field name="location">Acid Cabinet 2</field> <field name="location">Acid Cabinet 2</field>
<field name="container_size">20.0</field> <field name="container_size">20.0</field>
<field name="container_uom">L</field> <field name="container_uom">l</field>
<field name="quantity_on_hand">12.0</field> <field name="quantity_on_hand">12.0</field>
<field name="reorder_point">5.0</field> <field name="reorder_point">5.0</field>
</record> </record>
@@ -201,7 +201,7 @@
<field name="sample_type">personal_air</field> <field name="sample_type">personal_air</field>
<field name="substance">Chromium (VI)</field> <field name="substance">Chromium (VI)</field>
<field name="concentration">0.008</field> <field name="concentration">0.008</field>
<field name="uom">mg/m3</field> <field name="uom">mg_m3</field>
<field name="oel_reference">Ontario Reg. 833 TWA</field> <field name="oel_reference">Ontario Reg. 833 TWA</field>
<field name="oel_limit">0.025</field> <field name="oel_limit">0.025</field>
<field name="notes" type="html"><p>Personal air sample collected at chrome plating line 1 during normal operations.</p></field> <field name="notes" type="html"><p>Personal air sample collected at chrome plating line 1 during normal operations.</p></field>
@@ -214,7 +214,7 @@
<field name="sample_type">personal_air</field> <field name="sample_type">personal_air</field>
<field name="substance">Nickel (soluble compounds)</field> <field name="substance">Nickel (soluble compounds)</field>
<field name="concentration">0.05</field> <field name="concentration">0.05</field>
<field name="uom">mg/m3</field> <field name="uom">mg_m3</field>
<field name="oel_reference">Ontario Reg. 833 TWA</field> <field name="oel_reference">Ontario Reg. 833 TWA</field>
<field name="oel_limit">0.1</field> <field name="oel_limit">0.1</field>
<field name="notes" type="html"><p>Personal air sample collected at nickel plating station during tank maintenance.</p></field> <field name="notes" type="html"><p>Personal air sample collected at nickel plating station during tank maintenance.</p></field>

View File

@@ -5,6 +5,8 @@
from odoo import api, fields, models from odoo import api, fields, models
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
class FpChemical(models.Model): class FpChemical(models.Model):
"""Physical chemical container in the shop's chemical inventory. """Physical chemical container in the shop's chemical inventory.
@@ -52,11 +54,14 @@ class FpChemical(models.Model):
) )
container_size = fields.Float( container_size = fields.Float(
string='Container Size', string='Container Size',
help='Numerical size of one container, expressed in the unit '
'selected below (e.g. 200 with unit "L" for a 200 L drum).',
) )
container_uom = fields.Char( container_uom = fields.Selection(
FP_UOM_SELECTION,
string='Container UoM', string='Container UoM',
help='Free-text unit of measure for the container size, ' help='Unit of measure for the container size — pick from the '
'e.g. L, kg, lb, gal.', 'curated list to keep inventory consistent (L, kg, lb, gal).',
) )
quantity_on_hand = fields.Float( quantity_on_hand = fields.Float(
string='Quantity On Hand', string='Quantity On Hand',

View File

@@ -5,6 +5,8 @@
from odoo import api, fields, models from odoo import api, fields, models
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
class FpExposureMonitoring(models.Model): class FpExposureMonitoring(models.Model):
"""An exposure monitoring sample. """An exposure monitoring sample.
@@ -71,10 +73,13 @@ class FpExposureMonitoring(models.Model):
concentration = fields.Float( concentration = fields.Float(
string='Concentration', string='Concentration',
tracking=True, tracking=True,
help='Measured exposure level, expressed in the unit selected below.',
) )
uom = fields.Char( uom = fields.Selection(
FP_UOM_SELECTION,
string='Unit of Measure', string='Unit of Measure',
help='Free-text unit, e.g. mg/m3, ppm, dBA.', help='Unit the exposure was measured in (typical: mg/m³ or ppm for '
'air contaminants, dBA for noise).',
) )
oel_reference = fields.Char( oel_reference = fields.Char(
string='OEL Reference', string='OEL Reference',

View File

@@ -103,9 +103,8 @@
</div> </div>
</group> </group>
</group> </group>
<group string="Notes"> <separator string="Notes"/>
<field name="notes" nolabel="1"/> <field name="notes"/>
</group>
</sheet> </sheet>
<chatter/> <chatter/>
</form> </form>

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