changes
This commit is contained in:
@@ -62,7 +62,7 @@ class FpTankReading(models.Model):
|
||||
'per-company without re-migrating history).',
|
||||
)
|
||||
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'
|
||||
else:
|
||||
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
|
||||
|
||||
@@ -239,7 +239,7 @@ class FpTankSensor(models.Model):
|
||||
rec.effective_target_unit = '°F'
|
||||
else:
|
||||
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)
|
||||
@@ -276,7 +276,7 @@ class FpTankSensor(models.Model):
|
||||
rec.last_reading_display_unit = '°F'
|
||||
else:
|
||||
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(
|
||||
'fp.tank.reading', 'sensor_id', string='Reading History',
|
||||
|
||||
@@ -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
|
||||
|
||||
Persona-driven shop-floor scenarios that surfaced bugs / workflow holes. Every scenario has:
|
||||
|
||||
@@ -27,7 +27,32 @@ def post_init_hook(env):
|
||||
_seed_default_timezone(env)
|
||||
_backfill_node_input_kind(env)
|
||||
_seed_step_library_if_empty(env)
|
||||
_backfill_contract_review_template(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):
|
||||
@@ -60,6 +85,12 @@ def _backfill_node_input_kind(env):
|
||||
# Mapping of recipe-step name → default_kind. Drives sane-default
|
||||
# input seeding on the starter library entries.
|
||||
_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',
|
||||
'electroclean': 'cleaning',
|
||||
'solvent clean': 'cleaning',
|
||||
@@ -73,23 +104,85 @@ _STARTER_KIND_BY_NAME = {
|
||||
'zincate': 'etch',
|
||||
'strip zincate': 'etch',
|
||||
'acid dip': 'etch',
|
||||
'hcl activation': 'etch',
|
||||
'water break test': 'wbf_test',
|
||||
'water break free test': 'wbf_test',
|
||||
'issue panels': 'mask',
|
||||
'masking': 'mask',
|
||||
'mask': 'mask',
|
||||
'racking': 'racking',
|
||||
'rack': 'racking',
|
||||
'e-nickel plate': 'plate',
|
||||
'e-nickel plating': 'plate',
|
||||
'electroless nickel plate': 'plate',
|
||||
'electroless nickel plating': 'plate',
|
||||
'enp': 'plate',
|
||||
'plate': 'plate',
|
||||
'plating': 'plate',
|
||||
'drying': 'dry',
|
||||
'dry': 'dry',
|
||||
'bake': 'bake',
|
||||
'oven baking': 'bake',
|
||||
'oven bake': 'bake',
|
||||
'baking': 'bake',
|
||||
'hydrogen embrittlement bake': 'bake',
|
||||
'he bake': 'bake',
|
||||
'de-rack': 'derack',
|
||||
'de-racking': 'derack',
|
||||
'deracking': 'derack',
|
||||
'derack': 'derack',
|
||||
'demask': 'demask',
|
||||
'de-mask': 'demask',
|
||||
'de-masking': 'demask',
|
||||
'demasking': 'demask',
|
||||
'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 / packaging': 'final_inspect',
|
||||
'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):
|
||||
"""Sub 12a — seed fp.step.template starter library.
|
||||
|
||||
@@ -135,7 +228,7 @@ def _create_template_from_node(env, node, seen):
|
||||
return
|
||||
seen.add(node.name.lower())
|
||||
|
||||
kind = _STARTER_KIND_BY_NAME.get(node.name.lower())
|
||||
kind = fp_resolve_step_kind(node.name)
|
||||
vals = {
|
||||
'name': node.name,
|
||||
'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."""
|
||||
Tpl = env['fp.step.template']
|
||||
minimal = [
|
||||
('Contract Review', 'contract_review'),
|
||||
('Soak Clean', 'cleaning'),
|
||||
('Electroclean', 'cleaning'),
|
||||
('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):
|
||||
"""Sub 12b — seed 4 starter rack tags."""
|
||||
Tag = env['fp.rack.tag']
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.11.3.0',
|
||||
'version': '19.0.12.5.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -8,7 +8,9 @@ from . import fp_process_type
|
||||
from . import fp_facility
|
||||
from . import fp_work_center
|
||||
from . import fp_work_centre
|
||||
from . import fp_tank_section
|
||||
from . import fp_tank
|
||||
from . import fp_tank_composition
|
||||
from . import fp_bath
|
||||
from . import fp_bath_log
|
||||
from . import fp_bath_log_line
|
||||
|
||||
418
fusion_plating/fusion_plating/models/_fp_uom_selection.py
Normal file
418
fusion_plating/fusion_plating/models/_fp_uom_selection.py
Normal 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', 'm³'),
|
||||
('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', 'm²'),
|
||||
('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',
|
||||
'm³': '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',
|
||||
'm²': '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
|
||||
@@ -256,8 +256,9 @@ class FpBathTarget(models.Model):
|
||||
target_min = fields.Float(string='Min')
|
||||
target_max = fields.Float(string='Max')
|
||||
uom = fields.Char(
|
||||
related='parameter_id.uom',
|
||||
related='parameter_id.uom_display',
|
||||
readonly=True,
|
||||
string='Unit',
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
|
||||
@@ -47,8 +47,9 @@ class FpBathLogLine(models.Model):
|
||||
readonly=True,
|
||||
)
|
||||
uom = fields.Char(
|
||||
related='parameter_id.uom',
|
||||
related='parameter_id.uom_display',
|
||||
readonly=True,
|
||||
string='Unit',
|
||||
)
|
||||
value = fields.Float(
|
||||
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')
|
||||
def _compute_targets(self):
|
||||
"""Resolve target range: per-bath override first, parameter default second."""
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models
|
||||
|
||||
from ._fp_uom_selection import FP_UOM_SELECTION
|
||||
|
||||
|
||||
class FpBathParameter(models.Model):
|
||||
@@ -49,23 +51,37 @@ class FpBathParameter(models.Model):
|
||||
required=True,
|
||||
default='concentration',
|
||||
)
|
||||
uom = fields.Char(
|
||||
uom = fields.Selection(
|
||||
FP_UOM_SELECTION,
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
string='Default Setpoint / Optimum',
|
||||
help='The IDEAL operating value — what the heater/chiller controls '
|
||||
'toward, what dashboards compare against. Sits between '
|
||||
'target_min and target_max. Per-sensor override via '
|
||||
help='The IDEAL operating value, expressed in the unit selected '
|
||||
'above — what the heater/chiller controls toward, what '
|
||||
'dashboards compare against. Sits between Target Min and '
|
||||
'Target Max. Per-sensor override via '
|
||||
'fp.tank.sensor.target_value_override.',
|
||||
)
|
||||
warning_tolerance = fields.Float(
|
||||
@@ -86,6 +102,12 @@ class FpBathParameter(models.Model):
|
||||
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 = [
|
||||
(
|
||||
'fp_bath_parameter_code_uniq',
|
||||
|
||||
@@ -215,23 +215,64 @@ class FpJobStep(models.Model):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def button_pause(self):
|
||||
raise NotImplementedError(_(
|
||||
"button_pause is not yet implemented (operator pause / break / "
|
||||
"end-of-shift). Use button_finish to complete a step or set "
|
||||
"state directly via privileged code."
|
||||
))
|
||||
"""Operator pause / break / end-of-shift. Closes the open timelog
|
||||
without finishing the step, flips state to 'paused'. button_start
|
||||
will reopen a fresh timelog when resuming.
|
||||
"""
|
||||
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):
|
||||
raise NotImplementedError(_(
|
||||
"button_skip is not yet implemented (skip an opt-in step that "
|
||||
"wasn't activated for this job)."
|
||||
))
|
||||
"""Skip an opt-in step that wasn't activated for this job. Allowed
|
||||
from pending or ready only — a step that's already running shouldn't
|
||||
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):
|
||||
raise NotImplementedError(_(
|
||||
"button_cancel is not yet implemented (cancelling a single step; "
|
||||
"cancelling the whole job runs through fp.job.action_cancel)."
|
||||
))
|
||||
"""Cancel a single step. Used when an operator realises mid-stream
|
||||
that a step doesn't apply to this job (e.g. a customer-specific
|
||||
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):
|
||||
for step in self:
|
||||
|
||||
@@ -7,6 +7,7 @@ from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from .fp_tz import fp_isoformat_utc
|
||||
from ._fp_uom_selection import FP_UOM_SELECTION
|
||||
|
||||
|
||||
class FpProcessNode(models.Model):
|
||||
@@ -352,6 +353,7 @@ class FpProcessNode(models.Model):
|
||||
('final_inspect', 'Final Inspection'),
|
||||
('ship', 'Shipping'),
|
||||
('gating', 'Gating'),
|
||||
('contract_review', 'Contract Review (QA-005)'),
|
||||
],
|
||||
string='Step Kind',
|
||||
)
|
||||
@@ -652,9 +654,11 @@ class FpProcessNodeInput(models.Model):
|
||||
string='Sequence',
|
||||
default=10,
|
||||
)
|
||||
uom = fields.Char(
|
||||
uom = fields.Selection(
|
||||
FP_UOM_SELECTION,
|
||||
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 ==================
|
||||
@@ -668,9 +672,19 @@ class FpProcessNodeInput(models.Model):
|
||||
'recorded when leaving the step (Sub 12b uses these in the '
|
||||
'Move Parts dialog).',
|
||||
)
|
||||
target_min = fields.Float(string='Target Min')
|
||||
target_max = fields.Float(string='Target Max')
|
||||
target_unit = fields.Char(string='Target Unit')
|
||||
target_min = fields.Float(
|
||||
string='Target Min',
|
||||
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(
|
||||
[
|
||||
('none', 'None'),
|
||||
|
||||
@@ -90,6 +90,7 @@ class FpStepTemplate(models.Model):
|
||||
('final_inspect', 'Final Inspection'),
|
||||
('ship', 'Shipping'),
|
||||
('gating', 'Gating'),
|
||||
('contract_review', 'Contract Review (QA-005)'),
|
||||
], string='Step Kind', help='Drives sane-default input seeding.')
|
||||
|
||||
input_template_ids = fields.One2many(
|
||||
@@ -130,35 +131,39 @@ class FpStepTemplate(models.Model):
|
||||
|
||||
# ----- 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 = {
|
||||
'cleaning': [
|
||||
{'name': 'Actual Time', 'input_type': 'time_seconds',
|
||||
'target_unit': 'sec', 'sequence': 10},
|
||||
'target_unit': 's', 'sequence': 10},
|
||||
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
||||
'target_unit': '°F', 'sequence': 20},
|
||||
'target_unit': 'f', 'sequence': 20},
|
||||
],
|
||||
'etch': [
|
||||
{'name': 'Actual Time', 'input_type': 'time_seconds',
|
||||
'target_unit': 'sec', 'sequence': 10},
|
||||
'target_unit': 's', 'sequence': 10},
|
||||
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
||||
'target_unit': '°F', 'sequence': 20},
|
||||
'target_unit': 'f', 'sequence': 20},
|
||||
],
|
||||
'rinse': [],
|
||||
'plate': [
|
||||
{'name': 'Actual Time', 'input_type': 'time_hms',
|
||||
'target_unit': 'min', 'sequence': 10},
|
||||
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
||||
'target_unit': '°F', 'sequence': 20},
|
||||
'target_unit': 'f', 'sequence': 20},
|
||||
{'name': 'Plating Thickness', 'input_type': 'thickness',
|
||||
'target_unit': 'in', 'sequence': 30},
|
||||
],
|
||||
'bake': [
|
||||
{'name': 'Time In', 'input_type': 'text',
|
||||
'target_unit': 'HH:MM', 'sequence': 10},
|
||||
{'name': 'Time Out', 'input_type': 'text',
|
||||
'target_unit': 'HH:MM', 'sequence': 20},
|
||||
{'name': 'Time In', 'input_type': 'text', 'sequence': 10},
|
||||
{'name': 'Time Out', 'input_type': 'text', 'sequence': 20},
|
||||
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
||||
'target_unit': '°F', 'sequence': 30},
|
||||
'target_unit': 'f', 'sequence': 30},
|
||||
],
|
||||
'racking': [
|
||||
{'name': 'Actual Qty', 'input_type': 'number',
|
||||
@@ -196,6 +201,15 @@ class FpStepTemplate(models.Model):
|
||||
'target_unit': 'each', 'sequence': 10},
|
||||
],
|
||||
'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):
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
from ._fp_uom_selection import FP_UOM_SELECTION
|
||||
|
||||
|
||||
class FpStepTemplateInput(models.Model):
|
||||
"""Operation measurement definition on a step library template.
|
||||
@@ -35,10 +37,13 @@ class FpStepTemplateInput(models.Model):
|
||||
('thickness', 'Thickness'),
|
||||
('pass_fail', 'Pass / Fail'),
|
||||
], string='Input Type', required=True, default='text')
|
||||
target_min = fields.Float(string='Target Min')
|
||||
target_max = fields.Float(string='Target Max')
|
||||
target_unit = fields.Char(string='Target Unit',
|
||||
help='Display unit, e.g. "min", "°F", "A", "FT2", "in".')
|
||||
target_min = fields.Float(string='Target Min',
|
||||
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. Pick from the '
|
||||
'curated list to keep readings consistent across templates.')
|
||||
required = fields.Boolean(string='Required', default=False,
|
||||
help='If True, sign-off is hard-blocked while this input is blank.')
|
||||
hint = fields.Char(string='Hint')
|
||||
|
||||
@@ -19,7 +19,7 @@ class FpTank(models.Model):
|
||||
_name = 'fusion.plating.tank'
|
||||
_description = 'Fusion Plating — Tank'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'facility_id, work_center_id, sequence, code'
|
||||
_order = 'facility_id, section_id, sequence, code'
|
||||
|
||||
name = fields.Char(
|
||||
string='Tank Name',
|
||||
@@ -51,9 +51,16 @@ class FpTank(models.Model):
|
||||
ondelete='restrict',
|
||||
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(
|
||||
'fusion.plating.work.center',
|
||||
string='Work Center',
|
||||
string='Production Line',
|
||||
domain="[('facility_id','=',facility_id)]",
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
@@ -126,6 +133,22 @@ class FpTank(models.Model):
|
||||
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 ------------------------------------------------------
|
||||
bath_ids = fields.One2many(
|
||||
'fusion.plating.bath',
|
||||
@@ -138,16 +161,45 @@ class FpTank(models.Model):
|
||||
compute='_compute_current_bath',
|
||||
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(
|
||||
'fusion.plating.process.type',
|
||||
string='Current Process',
|
||||
related='current_bath_id.process_type_id',
|
||||
store=True,
|
||||
ondelete='restrict',
|
||||
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(
|
||||
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 = [
|
||||
(
|
||||
'fp_tank_code_facility_uniq',
|
||||
@@ -168,6 +220,20 @@ class FpTank(models.Model):
|
||||
for rec in self:
|
||||
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
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
|
||||
216
fusion_plating/fusion_plating/models/fp_tank_composition.py
Normal file
216
fusion_plating/fusion_plating/models/fp_tank_composition.py
Normal 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()
|
||||
69
fusion_plating/fusion_plating/models/fp_tank_section.py
Normal file
69
fusion_plating/fusion_plating/models/fp_tank_section.py
Normal 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},
|
||||
}
|
||||
@@ -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_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_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_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
|
||||
|
||||
|
@@ -46,9 +46,8 @@
|
||||
<field name="max_dose"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes" colspan="2"/>
|
||||
<group>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
|
||||
@@ -82,9 +82,8 @@
|
||||
<field name="product_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1"/>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -48,12 +48,10 @@
|
||||
<field name="training_record_attachment_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Revocation" invisible="state != 'revoked'">
|
||||
<field name="revoked_reason" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<separator string="Revocation"/>
|
||||
<field name="revoked_reason" colspan="2"/>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes" colspan="2"/>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
|
||||
@@ -211,21 +211,34 @@
|
||||
</div>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<group string="Definition">
|
||||
<field name="parameter_type"/>
|
||||
<field name="uom"/>
|
||||
<field name="decimals"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="target_min"/>
|
||||
<field name="target_max"/>
|
||||
<group string="Default Targets (in selected unit)">
|
||||
<label for="target_min"/>
|
||||
<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="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Description">
|
||||
<field name="description" nolabel="1"/>
|
||||
</group>
|
||||
<separator string="Description"/>
|
||||
<field name="description" nolabel="1"
|
||||
placeholder="What is this parameter, how is it measured, why does it matter?"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -82,9 +82,8 @@
|
||||
<field name="current_part_count" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes" colspan="2"/>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
|
||||
@@ -10,20 +10,24 @@
|
||||
<field name="name">fp.tank.list</field>
|
||||
<field name="model">fusion.plating.tank</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Tanks">
|
||||
<field name="facility_id"/>
|
||||
<field name="work_center_id"/>
|
||||
<list string="Tanks" multi_edit="1" expand="1">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="code"/>
|
||||
<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="default_temperature" optional="show"/>
|
||||
<field name="default_temperature_uom" optional="show"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'in_use'"
|
||||
decoration-info="state == 'filled'"
|
||||
decoration-warning="state in ('draining', 'maintenance')"
|
||||
decoration-muted="state in ('empty', 'out_of_service')"/>
|
||||
<field name="material" optional="hide"/>
|
||||
<field name="volume" optional="show"/>
|
||||
<field name="volume_uom" optional="show"/>
|
||||
<field name="volume" optional="hide"/>
|
||||
<field name="volume_uom" optional="hide"/>
|
||||
<field name="active" widget="boolean_toggle" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
@@ -69,12 +73,19 @@
|
||||
<group>
|
||||
<group string="Location">
|
||||
<field name="facility_id"/>
|
||||
<field name="section_id"
|
||||
options="{'no_quick_create': False}"/>
|
||||
<field name="work_center_id"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
<group string="Current Bath">
|
||||
<field name="current_bath_id" readonly="1"/>
|
||||
<field name="current_process_id" readonly="1"/>
|
||||
<group string="Operating Setpoints">
|
||||
<field name="current_process_id"
|
||||
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"/>
|
||||
</group>
|
||||
</group>
|
||||
@@ -96,6 +107,62 @@
|
||||
</group>
|
||||
</group>
|
||||
</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">
|
||||
<field name="bath_ids">
|
||||
<list decoration-muted="state == 'dumped'">
|
||||
@@ -126,6 +193,7 @@
|
||||
<field name="state"/>
|
||||
<field name="current_bath_id"/>
|
||||
<field name="current_process_id"/>
|
||||
<field name="section_id"/>
|
||||
<field name="facility_id"/>
|
||||
<field name="work_center_id"/>
|
||||
<templates>
|
||||
@@ -142,7 +210,7 @@
|
||||
</div>
|
||||
<div class="mt-2 small">
|
||||
<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>
|
||||
</t>
|
||||
@@ -160,6 +228,7 @@
|
||||
<field name="code"/>
|
||||
<field name="qr_code"/>
|
||||
<field name="facility_id"/>
|
||||
<field name="section_id"/>
|
||||
<field name="work_center_id"/>
|
||||
<field name="current_process_id"/>
|
||||
<separator/>
|
||||
@@ -170,8 +239,9 @@
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||
<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="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="Status" name="group_state" context="{'group_by':'state'}"/>
|
||||
</group>
|
||||
@@ -182,8 +252,70 @@
|
||||
<record id="action_fp_tank" model="ir.actions.act_window">
|
||||
<field name="name">Tanks</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="context">{'search_default_group_section': 1}</field>
|
||||
</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>
|
||||
|
||||
@@ -42,10 +42,8 @@
|
||||
<field name="mastery_required"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description"
|
||||
<field name="description"
|
||||
placeholder="Short operator-facing description of what this role covers."/>
|
||||
</group>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
<strong>Mastery Threshold</strong> controls auto-promotion: when an
|
||||
|
||||
@@ -49,9 +49,8 @@
|
||||
<field name="logged_by_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1"/>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -92,10 +92,9 @@
|
||||
<field name="value_max"/>
|
||||
<field name="value_uom"/>
|
||||
</group>
|
||||
<group string="Guidance">
|
||||
<field name="description" nolabel="1"
|
||||
<separator string="Guidance"/>
|
||||
<field name="description"
|
||||
placeholder="Inspection guidance shown to the operator on tap..."/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -42,10 +42,8 @@
|
||||
<field name="mastery_required"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description"
|
||||
<field name="description"
|
||||
placeholder="Short operator-facing description of what this role covers."/>
|
||||
</group>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
<strong>Mastery Threshold</strong> controls auto-promotion: when an
|
||||
|
||||
@@ -57,9 +57,7 @@
|
||||
<field name="x_fc_is_rework" readonly="1"/>
|
||||
<field name="x_fc_original_production_id" readonly="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="x_fc_rework_reason"/>
|
||||
</group>
|
||||
<field name="x_fc_rework_reason"/>
|
||||
</group>
|
||||
</xpath>
|
||||
|
||||
|
||||
@@ -145,9 +145,7 @@
|
||||
</page>
|
||||
<page string="Void" name="void"
|
||||
invisible="state != 'voided'">
|
||||
<group>
|
||||
<field name="void_reason"/>
|
||||
</group>
|
||||
<field name="void_reason"/>
|
||||
</page>
|
||||
<page string="Notes" name="notes">
|
||||
<field name="notes"/>
|
||||
|
||||
@@ -39,10 +39,8 @@
|
||||
company default, then a hardcoded AS9100/ISO 9001
|
||||
statement.
|
||||
</p>
|
||||
<group>
|
||||
<field name="x_fc_cert_statement" nolabel="1"
|
||||
<field name="x_fc_cert_statement"
|
||||
placeholder="e.g. We certify these parts conform to MIL-DTL-5541F Class 1A and have been processed in accordance with…"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<field name="description">Filter-press cake from hexavalent chrome waste treatment system.</field>
|
||||
<field name="physical_state">liquid</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>
|
||||
</record>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<field name="description">Spent sulphuric and hydrochloric acid from pickling tanks.</field>
|
||||
<field name="physical_state">liquid</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>
|
||||
</record>
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<field name="description">Sludge from black oxide line waste treatment.</field>
|
||||
<field name="physical_state">sludge</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>
|
||||
</record>
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
<field name="waste_stream_id" ref="demo_waste_stream_spent_acid"/>
|
||||
<field name="ship_date" eval="(DateTime.today()).strftime('%Y-%m-%d')"/>
|
||||
<field name="quantity">800.0</field>
|
||||
<field name="uom">L</field>
|
||||
<field name="uom">l</field>
|
||||
<field name="state">draft</field>
|
||||
<field name="notes" type="html"><p>Pending carrier assignment for spent acid pickup.</p></field>
|
||||
</record>
|
||||
@@ -107,7 +107,7 @@
|
||||
<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="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="containment_action">Spill contained within secondary containment berm. Absorbent pads deployed. Area neutralised with soda ash.</field>
|
||||
<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="substance">Nickel Sulphate Solution</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="containment_action">Minor drip from pump seal. Caught by drip tray, cleaned immediately.</field>
|
||||
<field name="regulator_notified" eval="False"/>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
from odoo import fields, models
|
||||
|
||||
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
|
||||
|
||||
|
||||
class FpDischargeLimit(models.Model):
|
||||
_name = 'fusion.plating.discharge.limit'
|
||||
@@ -18,8 +20,11 @@ class FpDischargeLimit(models.Model):
|
||||
('combined', 'Combined Sewer'), ('air', 'Air Emission'), ('other', 'Other')],
|
||||
string='Discharge Point', default='sanitary', required=True,
|
||||
)
|
||||
limit_value = fields.Float(string='Limit', digits=(16, 4))
|
||||
uom = fields.Char(string='UoM')
|
||||
limit_value = fields.Float(string='Limit', digits=(16, 4),
|
||||
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(
|
||||
[('max', 'Maximum'), ('min', 'Minimum'), ('range', 'Range'), ('ceiling', 'Hard Ceiling')],
|
||||
string='Limit Type', default='max', required=True,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
from odoo import api, fields, models
|
||||
|
||||
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
|
||||
|
||||
|
||||
class FpDischargeSampleLine(models.Model):
|
||||
_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')
|
||||
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)
|
||||
value = fields.Float(string='Result', digits=(16, 4))
|
||||
uom = fields.Char(string='UoM')
|
||||
value = fields.Float(string='Result', digits=(16, 4),
|
||||
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(
|
||||
[('ok', 'OK'), ('warning', 'Warning'), ('out_of_spec', 'Out of Spec'), ('pending', 'Pending')],
|
||||
string='Status', compute='_compute_status', store=True,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
from odoo import api, fields, models
|
||||
|
||||
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
|
||||
|
||||
|
||||
class FpSpillRegister(models.Model):
|
||||
_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)
|
||||
reported_by_id = fields.Many2one('res.users', string='Reported By', default=lambda s: s.env.user)
|
||||
substance = fields.Char(string='Substance', tracking=True)
|
||||
quantity = fields.Float(string='Quantity', digits=(16, 3))
|
||||
uom = fields.Char(string='UoM', default='L')
|
||||
quantity = fields.Float(string='Quantity', digits=(16, 3),
|
||||
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')
|
||||
containment_action = fields.Text(string='Containment Action')
|
||||
regulator_notified = fields.Boolean(string='Regulator Notified', tracking=True)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
from odoo import api, fields, models
|
||||
|
||||
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
|
||||
|
||||
|
||||
class FpWasteManifest(models.Model):
|
||||
_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)
|
||||
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)
|
||||
quantity = fields.Float(string='Quantity', digits=(16, 3))
|
||||
uom = fields.Char(string='UoM', default='kg')
|
||||
quantity = fields.Float(string='Quantity', digits=(16, 3),
|
||||
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)
|
||||
receiver_id = fields.Many2one('res.partner', string='Receiver', domain=[('is_company', '=', True)], tracking=True)
|
||||
manifest_number = fields.Char(string='Manifest #', tracking=True)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
from odoo import fields, models
|
||||
|
||||
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
|
||||
|
||||
|
||||
class FpWasteStream(models.Model):
|
||||
_name = 'fusion.plating.waste.stream'
|
||||
@@ -19,8 +21,12 @@ class FpWasteStream(models.Model):
|
||||
[('liquid', 'Liquid'), ('solid', 'Solid'), ('sludge', 'Sludge'), ('gas', 'Gas')],
|
||||
string='Physical State', default='liquid',
|
||||
)
|
||||
generation_rate = fields.Float(string='Generation Rate')
|
||||
generation_uom = fields.Char(string='Rate UoM', default='kg/day')
|
||||
generation_rate = fields.Float(string='Generation Rate',
|
||||
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')
|
||||
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)])
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
<field name="regulator_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes"><field name="notes" nolabel="1"/></group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
</group>
|
||||
</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>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -48,9 +48,8 @@
|
||||
</list>
|
||||
</field>
|
||||
</group>
|
||||
<group string="Compliance Notes">
|
||||
<field name="x_fp_compliance_notes" nolabel="1"/>
|
||||
</group>
|
||||
<separator string="Compliance Notes"/>
|
||||
<field name="x_fp_compliance_notes"/>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
<field name="owner_id"/>
|
||||
<field name="status"/>
|
||||
</group>
|
||||
<group string="Description"><field name="description" nolabel="1"/></group>
|
||||
<separator string="Description"/>
|
||||
<field name="description"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -50,7 +50,8 @@
|
||||
<field name="transferred_kg"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes"><field name="notes" nolabel="1"/></group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Contact"><field name="contact_info" nolabel="1"/></group>
|
||||
<separator string="Contact"/>
|
||||
<field name="contact_info"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.17.16.0',
|
||||
'version': '19.0.18.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
@@ -50,11 +50,13 @@ Provides:
|
||||
'views/sale_order_views.xml',
|
||||
'views/res_partner_views.xml',
|
||||
'views/fp_sale_description_template_views.xml',
|
||||
'views/fp_serial_views.xml',
|
||||
'wizard/fp_direct_order_wizard_views.xml',
|
||||
'wizard/fp_add_from_so_wizard_views.xml',
|
||||
'wizard/fp_add_from_quote_wizard_views.xml',
|
||||
'wizard/fp_quote_promote_wizard_views.xml',
|
||||
'wizard/fp_part_catalog_import_wizard_views.xml',
|
||||
'wizard/fp_serial_bulk_add_wizard_views.xml',
|
||||
'views/fp_configurator_menu.xml',
|
||||
'data/fp_sale_description_template_data.xml',
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -19,12 +19,23 @@ class AccountMoveLine(models.Model):
|
||||
help="Copied from sale.order.line on invoice creation so customer-"
|
||||
"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(
|
||||
'fp.serial',
|
||||
string='Serial Number',
|
||||
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(
|
||||
string='Job #', index=True,
|
||||
|
||||
@@ -444,6 +444,33 @@ class FpPartCatalog(models.Model):
|
||||
'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):
|
||||
"""Flip the default variant for this part.
|
||||
|
||||
|
||||
@@ -58,6 +58,170 @@ class FpSerial(models.Model):
|
||||
)
|
||||
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
|
||||
# lives in this same module. Production (mrp) and delivery (logistics)
|
||||
# reverse links are defined in their own modules' fp_serial inherits
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# 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):
|
||||
@@ -60,18 +61,29 @@ class SaleOrderLine(models.Model):
|
||||
string='Linked Quote',
|
||||
help='Quote that seeded this line. Links back for audit trail.',
|
||||
)
|
||||
# Sub 9 — process variant override per line. NULL means "use the
|
||||
# part's default variant". Domain restricts to root recipe nodes
|
||||
# owned by the chosen part.
|
||||
# Sub 9 (polished 2026-04-28) — process variant per line. The picker
|
||||
# now lets the estimator pick ANY root recipe in the system: the
|
||||
# part's own variants, another customer's variants, or a template
|
||||
# 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(
|
||||
'fusion.plating.process.node',
|
||||
string='Process Variant',
|
||||
domain="[('part_catalog_id', '=', x_fc_part_catalog_id), "
|
||||
"('parent_id', '=', False), ('node_type', '=', 'recipe')]",
|
||||
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
|
||||
ondelete='set null',
|
||||
help='Pick a specific process variant for this order. Leave blank '
|
||||
'to use the part\'s default variant. Variants are managed via '
|
||||
'the Process Composer on the part form.',
|
||||
help='Pick any recipe — the part\'s own variant, another part\'s '
|
||||
'recipe, or a template from the library. If the chosen recipe '
|
||||
'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(
|
||||
string='Archived',
|
||||
@@ -84,15 +96,61 @@ class SaleOrderLine(models.Model):
|
||||
# NB: sale.order.line in Odoo 19 does not support `tracking=True` on
|
||||
# inherited fields — Odoo emits a warning and ignores it. Audit trail
|
||||
# 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(
|
||||
'fp.serial',
|
||||
string='Serial Number',
|
||||
ondelete='set null',
|
||||
string='Primary Serial',
|
||||
compute='_compute_primary_serial',
|
||||
inverse='_inverse_primary_serial',
|
||||
search='_search_primary_serial',
|
||||
store=False,
|
||||
copy=False,
|
||||
help='Customer-supplied serial number for this line. Optional. '
|
||||
'Typing a value offers to create a new fp.serial record on '
|
||||
'the fly; use the Generate Serial button to auto-sequence.',
|
||||
help='First of the line\'s serials — back-compat alias kept so '
|
||||
'pre-Phase-1 code (reports, smart buttons, downstream M2M '
|
||||
'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(
|
||||
string='Job #',
|
||||
copy=False,
|
||||
@@ -140,6 +198,27 @@ class SaleOrderLine(models.Model):
|
||||
if 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
|
||||
def create(self, vals_list):
|
||||
"""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()
|
||||
if part and 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):
|
||||
# Sub 5 — keep the revision snapshot in lockstep with the line's
|
||||
@@ -190,7 +271,16 @@ class SaleOrderLine(models.Model):
|
||||
for line in self:
|
||||
if line.x_fc_part_catalog_id.id != new_part.id:
|
||||
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')
|
||||
def _onchange_description_template(self):
|
||||
@@ -229,7 +319,12 @@ class SaleOrderLine(models.Model):
|
||||
vals = super()._prepare_invoice_line(**optional_values)
|
||||
if self.x_fc_part_catalog_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
|
||||
if 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')
|
||||
def _onchange_part_default_variant(self):
|
||||
"""Clear process variant when the part changes — domain would
|
||||
otherwise leave a stale value pointing at the wrong part."""
|
||||
"""When the part changes, pre-fill the variant from the part's
|
||||
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:
|
||||
if (line.x_fc_process_variant_id
|
||||
and line.x_fc_process_variant_id.part_catalog_id
|
||||
!= line.x_fc_part_catalog_id):
|
||||
line.x_fc_process_variant_id = False
|
||||
if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id:
|
||||
line.x_fc_process_variant_id = line.x_fc_part_catalog_id.default_process_id
|
||||
|
||||
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')
|
||||
def _onchange_coating_clears_thickness(self):
|
||||
@@ -263,19 +440,55 @@ class SaleOrderLine(models.Model):
|
||||
line.x_fc_thickness_id = False
|
||||
|
||||
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()
|
||||
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'
|
||||
serial = self.env['fp.serial'].create({
|
||||
'name': seq,
|
||||
'sale_order_line_id': self.id,
|
||||
})
|
||||
self.x_fc_serial_id = serial.id
|
||||
self.x_fc_serial_ids = [(4, serial.id)]
|
||||
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),
|
||||
})
|
||||
|
||||
@@ -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_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_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_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
|
||||
|
||||
|
@@ -188,6 +188,7 @@ export class FpPartProcessComposer extends Component {
|
||||
}
|
||||
|
||||
openRecipeEditor(rootId) {
|
||||
// Tree editor — the original drag-and-drop hierarchy view.
|
||||
const id = rootId || this.state.rootId;
|
||||
if (!id) return;
|
||||
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() {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.act_window",
|
||||
|
||||
@@ -83,8 +83,15 @@
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-primary me-1"
|
||||
t-att-disabled="state.busy"
|
||||
t-on-click="() => this.openRecipeEditor(v.id)">
|
||||
<i class="fa fa-pencil"/> Edit
|
||||
t-on-click="() => this.openRecipeEditor(v.id)"
|
||||
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 class="btn btn-sm btn-secondary me-1"
|
||||
t-att-disabled="state.busy"
|
||||
|
||||
@@ -45,9 +45,8 @@
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes" colspan="2"/>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
|
||||
@@ -176,12 +176,26 @@
|
||||
icon="fa-wrench"
|
||||
class="btn-primary"
|
||||
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>
|
||||
<p class="text-muted mt-3">
|
||||
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",
|
||||
"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>
|
||||
<field name="process_variant_ids" readonly="1">
|
||||
<list>
|
||||
@@ -189,6 +203,12 @@
|
||||
<field name="variant_label"/>
|
||||
<field name="name"/>
|
||||
<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>
|
||||
</field>
|
||||
</page>
|
||||
@@ -207,9 +227,7 @@
|
||||
<field name="has_threads"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="masking_description" placeholder="e.g. Mask threaded holes, mask bore ID"/>
|
||||
</group>
|
||||
<field name="masking_description" placeholder="e.g. Mask threaded holes, mask bore ID"/>
|
||||
</page>
|
||||
<page string="Attachments" name="attachments">
|
||||
<group>
|
||||
|
||||
@@ -276,9 +276,7 @@
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<field name="price_breakdown_html" readonly="1" colspan="2"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -299,10 +297,9 @@
|
||||
<field name="lost_date" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="lost_details" nolabel="1" colspan="2"
|
||||
<separator string="Notes"/>
|
||||
<field name="lost_details" colspan="2"
|
||||
placeholder="What did we learn? (Price point competitor beat, spec we didn't meet, etc.)"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Notes" name="notes">
|
||||
<field name="notes" placeholder="Internal notes about this quote..."/>
|
||||
|
||||
@@ -58,10 +58,9 @@
|
||||
<field name="internal_description" nolabel="1" colspan="2"
|
||||
placeholder="What the shop floor sees on the WO / traveler…"/>
|
||||
</group>
|
||||
<group string="Customer-Facing Description">
|
||||
<field name="customer_facing_description" nolabel="1" colspan="2"
|
||||
<separator string="Customer-Facing Description"/>
|
||||
<field name="customer_facing_description" colspan="2"
|
||||
placeholder="Electroless nickel plating per AMS 2404, Class I, Type II…"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -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>
|
||||
@@ -52,9 +52,7 @@
|
||||
options="{'currency_field': 'currency_id'}"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description" placeholder="Description of this treatment step..."/>
|
||||
</group>
|
||||
<field name="description" placeholder="Description of this treatment step..."/>
|
||||
<group>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
|
||||
@@ -188,10 +188,9 @@
|
||||
<field name="x_fc_internal_note" nolabel="1"
|
||||
placeholder="Internal notes for estimator / planner / shop floor..."/>
|
||||
</group>
|
||||
<group string="External Notes (customer-visible)">
|
||||
<field name="x_fc_external_note" nolabel="1"
|
||||
<separator string="External Notes (customer-visible)"/>
|
||||
<field name="x_fc_external_note"
|
||||
placeholder="Notes that appear on the acknowledgement and portal..."/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
@@ -214,17 +213,33 @@
|
||||
optional="hide"/>
|
||||
<field name="x_fc_coating_config_id" optional="show"/>
|
||||
<field name="x_fc_process_variant_id"
|
||||
string="Variant"
|
||||
options="{'no_create': True}"
|
||||
string="Process / Recipe"
|
||||
options="{'no_quick_create': True}"
|
||||
invisible="not x_fc_part_catalog_id"
|
||||
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"
|
||||
options="{'no_create': True}"
|
||||
invisible="not x_fc_coating_config_id"
|
||||
optional="show"/>
|
||||
<field name="x_fc_serial_id"
|
||||
options="{'no_create_edit': False}"
|
||||
<field name="x_fc_serial_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_quick_create': False, 'color_field': 'state_color'}"
|
||||
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_revision_pick_id"
|
||||
string="Revision"
|
||||
|
||||
@@ -8,3 +8,4 @@ from . import fp_add_from_so_wizard
|
||||
from . import fp_add_from_quote_wizard
|
||||
from . import fp_quote_promote_wizard
|
||||
from . import fp_part_catalog_import_wizard
|
||||
from . import fp_serial_bulk_add_wizard
|
||||
|
||||
@@ -60,17 +60,27 @@ class FpDirectOrderLine(models.Model):
|
||||
string='Additional Treatments',
|
||||
help='Extra pre/post treatments applied to this line.',
|
||||
)
|
||||
# Sub 9 — explicit per-line process variant override. NULL means
|
||||
# "use the part's default variant".
|
||||
# Sub 9 (polished 2026-04-28) — process variant per line. The picker
|
||||
# now lets the estimator pick ANY root recipe in the system: the
|
||||
# part's own variants, another customer's variants, or a template
|
||||
# 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(
|
||||
'fusion.plating.process.node',
|
||||
string='Process Variant',
|
||||
domain="[('part_catalog_id', '=', part_catalog_id), "
|
||||
"('parent_id', '=', False), ('node_type', '=', 'recipe')]",
|
||||
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
|
||||
ondelete='set null',
|
||||
help='Pick a specific process variant for this line. Leave blank '
|
||||
'to use the part\'s default variant. Manage variants via the '
|
||||
'Process Composer on the part form.',
|
||||
help='Pick any recipe — the part\'s own variant, another part\'s '
|
||||
'recipe, or a template from the library. Cross-part picks '
|
||||
'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
|
||||
# for this line. Resolution priority:
|
||||
@@ -116,26 +126,38 @@ class FpDirectOrderLine(models.Model):
|
||||
|
||||
@api.onchange('part_catalog_id')
|
||||
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
|
||||
Sarah doesn't re-pick the same coating every repeat customer.
|
||||
Defaults only apply when the line currently has no coating set
|
||||
— editing an existing line with a chosen coating doesn't get
|
||||
clobbered.
|
||||
the estimator doesn't re-pick the same coating every repeat
|
||||
customer. Defaults only apply when the line currently has no
|
||||
coating set — editing an existing line with a chosen coating
|
||||
doesn't get clobbered.
|
||||
|
||||
For BRAND-NEW parts (no defaults saved yet) auto-tick
|
||||
`push_to_defaults` so Sarah's first coating pick gets persisted
|
||||
back to the part. Without this Sarah has to remember to tick the
|
||||
toggle herself, and the second order doesn't pre-fill.
|
||||
`push_to_defaults` so the first coating pick gets persisted
|
||||
back to the part. Without this, the estimator has to remember
|
||||
to tick the toggle and the second order doesn't pre-fill.
|
||||
Returns a warning popup explaining what's happening.
|
||||
"""
|
||||
warning = None
|
||||
for rec in self:
|
||||
# Variant clear (original behaviour).
|
||||
if (rec.process_variant_id
|
||||
and rec.process_variant_id.part_catalog_id != rec.part_catalog_id):
|
||||
rec.process_variant_id = False
|
||||
# Pre-fill variant from the part's default (was: blanket clear).
|
||||
if rec.part_catalog_id and rec.part_catalog_id.default_process_id:
|
||||
# Only overwrite when blank or pointing at a different part —
|
||||
# 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:
|
||||
continue
|
||||
part = rec.part_catalog_id
|
||||
@@ -266,17 +288,50 @@ class FpDirectOrderLine(models.Model):
|
||||
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
|
||||
# creates the sale order. Serial stays optional; Job# is left blank
|
||||
# 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(
|
||||
'fp.serial',
|
||||
string='Serial Number',
|
||||
ondelete='set null',
|
||||
help='Optional. Typing a value offers to create a new serial on '
|
||||
'the fly, or hit "Generate Serial" to auto-sequence.',
|
||||
string='Primary Serial',
|
||||
compute='_compute_primary_serial',
|
||||
inverse='_inverse_primary_serial',
|
||||
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 #')
|
||||
thickness_id = fields.Many2one(
|
||||
'fp.coating.thickness',
|
||||
@@ -309,14 +364,48 @@ class FpDirectOrderLine(models.Model):
|
||||
rec.thickness_id = False
|
||||
|
||||
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()
|
||||
if self.serial_id:
|
||||
return False
|
||||
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
|
||||
|
||||
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 ----
|
||||
@api.onchange('quote_id')
|
||||
def _onchange_quote_id(self):
|
||||
@@ -504,3 +593,100 @@ class FpDirectOrderLine(models.Model):
|
||||
else:
|
||||
new_rev.drawing_attachment_ids = [(4, drawing_att.id)]
|
||||
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
|
||||
|
||||
@@ -510,8 +510,13 @@ class FpDirectOrderWizard(models.Model):
|
||||
'x_fc_is_one_off': line.is_one_off,
|
||||
'x_fc_quote_id': line.quote_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.
|
||||
# Revision snapshot auto-fills on SO-line create from the part.
|
||||
'x_fc_save_as_default_process': line.save_as_default_process,
|
||||
# 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_job_number': line.job_number or False,
|
||||
'x_fc_thickness_id': line.thickness_id.id or False,
|
||||
|
||||
@@ -156,14 +156,23 @@
|
||||
optional="hide"/>
|
||||
<field name="coating_config_id"/>
|
||||
<field name="process_variant_id"
|
||||
string="Variant"
|
||||
options="{'no_create': True}"
|
||||
string="Process / Recipe"
|
||||
options="{'no_quick_create': True}"
|
||||
invisible="not part_catalog_id"
|
||||
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"
|
||||
string="Process"
|
||||
string="Effective Process"
|
||||
readonly="1"
|
||||
optional="show"/>
|
||||
optional="hide"/>
|
||||
<field name="effective_process_source"
|
||||
string="Process Source"
|
||||
readonly="1"
|
||||
@@ -172,9 +181,16 @@
|
||||
options="{'no_create': True}"
|
||||
invisible="not coating_config_id"
|
||||
optional="show"/>
|
||||
<field name="serial_id"
|
||||
options="{'no_create_edit': False}"
|
||||
<field name="serial_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_quick_create': False, 'color_field': 'state_color'}"
|
||||
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="treatment_ids"
|
||||
widget="many2many_tags"
|
||||
@@ -210,9 +226,17 @@
|
||||
<field name="treatment_ids"
|
||||
widget="many2many_tags"/>
|
||||
<field name="process_variant_id"
|
||||
string="Process Variant"
|
||||
options="{'no_create': True}"
|
||||
string="Process / Recipe"
|
||||
options="{'no_quick_create': True}"
|
||||
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"
|
||||
string="Effective Process"
|
||||
readonly="1"/>
|
||||
|
||||
@@ -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'}
|
||||
@@ -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. Example: SN-001 SN-002 CUST-12345 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>
|
||||
@@ -38,9 +38,7 @@
|
||||
<field name="payment_term_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="notes" placeholder="Internal notes about this customer's billing preferences..."/>
|
||||
</group>
|
||||
<field name="notes" placeholder="Internal notes about this customer's billing preferences..."/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
from . import models
|
||||
from . import report
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.7.0.0',
|
||||
'version': '19.0.8.8.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'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/jobs_in_shopfloor_menu.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_traveller.xml',
|
||||
'report/report_fp_job_wo_detail.xml',
|
||||
'report/report_fp_job_margin.xml',
|
||||
],
|
||||
'assets': {
|
||||
|
||||
@@ -155,6 +155,79 @@ class FpJob(models.Model):
|
||||
'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):
|
||||
self.ensure_one()
|
||||
return {
|
||||
@@ -166,6 +239,53 @@ class FpJob(models.Model):
|
||||
'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):
|
||||
self.ensure_one()
|
||||
if not self.delivery_id:
|
||||
@@ -497,6 +617,38 @@ class FpJob(models.Model):
|
||||
instructions.append(line)
|
||||
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 = {
|
||||
'job_id': job.id,
|
||||
'name': node.name,
|
||||
@@ -504,6 +656,7 @@ class FpJob(models.Model):
|
||||
'duration_expected': node.estimated_duration or 0.0,
|
||||
'sequence': seq_counter[0],
|
||||
'recipe_node_id': node.id,
|
||||
'kind': step_kind,
|
||||
}
|
||||
if node.estimated_duration:
|
||||
vals['dwell_time_minutes'] = node.estimated_duration
|
||||
@@ -636,12 +789,79 @@ class FpJob(models.Model):
|
||||
)
|
||||
if pending_steps:
|
||||
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_qc_check_if_needed()
|
||||
job._fp_create_racking_inspection()
|
||||
job._fp_fire_notification('job_confirmed')
|
||||
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):
|
||||
"""Auto-create a draft racking inspection on job confirm.
|
||||
|
||||
|
||||
@@ -413,3 +413,370 @@ class FpJobStep(models.Model):
|
||||
'plate exit. Required by %s.'
|
||||
)) % (bw.name, window_hrs, bw.bake_required_by))
|
||||
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
|
||||
|
||||
@@ -202,6 +202,14 @@
|
||||
</tr>
|
||||
</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 ===== -->
|
||||
<table class="bordered" style="margin-top: 4px;">
|
||||
<thead>
|
||||
@@ -284,6 +292,37 @@
|
||||
</t>
|
||||
</tbody>
|
||||
</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>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
@@ -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>
|
||||
@@ -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_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_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
|
||||
|
||||
|
@@ -49,9 +49,8 @@
|
||||
<field name="logged_by_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1"/>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -25,6 +25,110 @@
|
||||
class="btn-secondary"
|
||||
icon="fa-sitemap"
|
||||
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>
|
||||
|
||||
<!-- Inject a button_box at the top of the sheet, before the
|
||||
@@ -67,6 +171,22 @@
|
||||
<field name="quality_hold_count" widget="statinfo"
|
||||
string="Holds"/>
|
||||
</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"
|
||||
class="oe_stat_button" icon="fa-certificate"
|
||||
invisible="certificate_count == 0">
|
||||
|
||||
6
fusion_plating/fusion_plating_jobs/wizards/__init__.py
Normal file
6
fusion_plating/fusion_plating_jobs/wizards/__init__.py
Normal 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
|
||||
@@ -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,
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
])
|
||||
@@ -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>
|
||||
@@ -44,9 +44,8 @@
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1"/>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -46,12 +46,10 @@
|
||||
<field name="mail_mail_id"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Attachments" invisible="not attachment_names">
|
||||
<field name="attachment_names" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<group string="Error Details" invisible="status != 'failed'">
|
||||
<field name="error_message" nolabel="1" colspan="2"/>
|
||||
</group>
|
||||
<separator string="Attachments"/>
|
||||
<field name="attachment_names" colspan="2"/>
|
||||
<separator string="Error Details"/>
|
||||
<field name="error_message" colspan="2"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -45,9 +45,8 @@
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Description">
|
||||
<field name="description" nolabel="1"/>
|
||||
</group>
|
||||
<separator string="Description"/>
|
||||
<field name="description"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<field name="code">AN_H2SO4</field>
|
||||
<field name="sequence">10</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_max">220.0</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -29,7 +29,7 @@
|
||||
<field name="code">AN_AL</field>
|
||||
<field name="sequence">20</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_max">15.0</field>
|
||||
<field name="warning_tolerance">0.0</field>
|
||||
@@ -42,7 +42,7 @@
|
||||
<field name="code">AN_TEMP_II</field>
|
||||
<field name="sequence">30</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_max">22.0</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -55,7 +55,7 @@
|
||||
<field name="code">AN_TEMP_III</field>
|
||||
<field name="sequence">40</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_max">5.0</field>
|
||||
<field name="warning_tolerance">10.0</field>
|
||||
@@ -68,7 +68,7 @@
|
||||
<field name="code">AN_CD</field>
|
||||
<field name="sequence">50</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_max">2.5</field>
|
||||
<field name="warning_tolerance">10.0</field>
|
||||
@@ -81,7 +81,7 @@
|
||||
<field name="code">AN_VOLT</field>
|
||||
<field name="sequence">60</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_max">100.0</field>
|
||||
<field name="warning_tolerance">10.0</field>
|
||||
@@ -94,7 +94,7 @@
|
||||
<field name="code">AN_CHROMIC</field>
|
||||
<field name="sequence">70</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_max">60.0</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -107,7 +107,7 @@
|
||||
<field name="code">AN_SEAL_TEMP</field>
|
||||
<field name="sequence">80</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_max">100.0</field>
|
||||
<field name="warning_tolerance">3.0</field>
|
||||
@@ -120,7 +120,7 @@
|
||||
<field name="code">AN_SEAL_PH</field>
|
||||
<field name="sequence">90</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_max">6.5</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -133,7 +133,7 @@
|
||||
<field name="code">AN_SEAL_NIAC</field>
|
||||
<field name="sequence">100</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_max">6.0</field>
|
||||
<field name="warning_tolerance">10.0</field>
|
||||
@@ -146,7 +146,7 @@
|
||||
<field name="code">AN_DYE_CONC</field>
|
||||
<field name="sequence">110</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_max">15.0</field>
|
||||
<field name="warning_tolerance">10.0</field>
|
||||
@@ -159,7 +159,7 @@
|
||||
<field name="code">AN_DYE_TEMP</field>
|
||||
<field name="sequence">120</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_max">65.0</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<field name="name">Sodium Hydroxide (NaOH)</field>
|
||||
<field name="code">BOX_NAOH</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_max">800.0</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -25,7 +25,7 @@
|
||||
<field name="name">Oxidizer (Nitrate/Nitrite)</field>
|
||||
<field name="code">BOX_NITRATE</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_max">250.0</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -37,7 +37,7 @@
|
||||
<field name="name">Bath Temperature (Hot Process)</field>
|
||||
<field name="code">BOX_TEMP_HOT</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_max">143.0</field>
|
||||
<field name="warning_tolerance">2.0</field>
|
||||
@@ -49,7 +49,7 @@
|
||||
<field name="name">Bath Temperature (Midtemp Process)</field>
|
||||
<field name="code">BOX_TEMP_MID</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_max">100.0</field>
|
||||
<field name="warning_tolerance">3.0</field>
|
||||
@@ -61,7 +61,7 @@
|
||||
<field name="name">Bath Temperature (Room Temp Process)</field>
|
||||
<field name="code">BOX_TEMP_RT</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_max">28.0</field>
|
||||
<field name="warning_tolerance">10.0</field>
|
||||
@@ -85,7 +85,7 @@
|
||||
<field name="name">pH (Room Temp Process)</field>
|
||||
<field name="code">BOX_PH_RT</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_max">9.5</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -97,7 +97,7 @@
|
||||
<field name="name">Rinse Water pH</field>
|
||||
<field name="code">BOX_RINSE_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_max">7.5</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<field name="code">CR_CRVI</field>
|
||||
<field name="sequence">10</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_max">280.0</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -29,7 +29,7 @@
|
||||
<field name="code">CR_CRIII</field>
|
||||
<field name="sequence">20</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_max">25.0</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -42,7 +42,7 @@
|
||||
<field name="code">CR_H2SO4</field>
|
||||
<field name="sequence">30</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_max">3.0</field>
|
||||
<field name="warning_tolerance">10.0</field>
|
||||
@@ -55,7 +55,7 @@
|
||||
<field name="code">CR_RATIO</field>
|
||||
<field name="sequence">40</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_max">110.0</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -68,7 +68,7 @@
|
||||
<field name="code">CR_TEMP</field>
|
||||
<field name="sequence">50</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_max">55.0</field>
|
||||
<field name="warning_tolerance">3.0</field>
|
||||
@@ -81,7 +81,7 @@
|
||||
<field name="code">CR_CD</field>
|
||||
<field name="sequence">60</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_max">60.0</field>
|
||||
<field name="warning_tolerance">10.0</field>
|
||||
@@ -94,7 +94,7 @@
|
||||
<field name="code">CR_FE</field>
|
||||
<field name="sequence">70</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_max">10.0</field>
|
||||
<field name="warning_tolerance">0.0</field>
|
||||
@@ -107,7 +107,7 @@
|
||||
<field name="code">CR_CU</field>
|
||||
<field name="sequence">80</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_max">2.0</field>
|
||||
<field name="warning_tolerance">0.0</field>
|
||||
@@ -120,7 +120,7 @@
|
||||
<field name="code">CR_MIST</field>
|
||||
<field name="sequence">90</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_max">100.0</field>
|
||||
<field name="warning_tolerance">10.0</field>
|
||||
@@ -133,7 +133,7 @@
|
||||
<field name="code">CR_VOLT</field>
|
||||
<field name="sequence">100</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_max">12.0</field>
|
||||
<field name="warning_tolerance">10.0</field>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<field name="code">EN_NI</field>
|
||||
<field name="sequence">10</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_max">6.5</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -28,7 +28,7 @@
|
||||
<field name="code">EN_HYPO</field>
|
||||
<field name="sequence">20</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_max">30.0</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -41,7 +41,7 @@
|
||||
<field name="code">EN_ORTHO</field>
|
||||
<field name="sequence">30</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_max">200.0</field>
|
||||
<field name="warning_tolerance">10.0</field>
|
||||
@@ -54,7 +54,7 @@
|
||||
<field name="code">EN_PH</field>
|
||||
<field name="sequence">40</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_max">5.2</field>
|
||||
<field name="warning_tolerance">5.0</field>
|
||||
@@ -67,7 +67,7 @@
|
||||
<field name="code">EN_TEMP</field>
|
||||
<field name="sequence">50</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_max">92.0</field>
|
||||
<field name="warning_tolerance">3.0</field>
|
||||
@@ -80,7 +80,7 @@
|
||||
<field name="code">EN_MTO</field>
|
||||
<field name="sequence">60</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_max">8.0</field>
|
||||
<field name="warning_tolerance">0.0</field>
|
||||
@@ -93,7 +93,7 @@
|
||||
<field name="code">EN_LOAD</field>
|
||||
<field name="sequence">70</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_max">1.5</field>
|
||||
<field name="warning_tolerance">10.0</field>
|
||||
|
||||
@@ -110,9 +110,8 @@
|
||||
invisible="not s20_evaluate_risk"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Comments">
|
||||
<field name="s20_comments" nolabel="1" readonly="s20_locked"/>
|
||||
</group>
|
||||
<separator string="Comments"/>
|
||||
<field name="s20_comments" readonly="s20_locked"/>
|
||||
<group string="Signature" invisible="not s20_locked">
|
||||
<field name="s20_signed_by" readonly="1"/>
|
||||
<field name="s20_signed_date" readonly="1"/>
|
||||
|
||||
@@ -92,10 +92,9 @@
|
||||
<field name="value_max"/>
|
||||
<field name="value_uom"/>
|
||||
</group>
|
||||
<group string="Guidance">
|
||||
<field name="description" nolabel="1"
|
||||
<separator string="Guidance"/>
|
||||
<field name="description"
|
||||
placeholder="Inspection guidance shown to the operator on tap..."/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
|
||||
@@ -83,9 +83,7 @@
|
||||
<field name="current_process_node"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description" placeholder="Describe the reason for the hold..."/>
|
||||
</group>
|
||||
<field name="description" placeholder="Describe the reason for the hold..."/>
|
||||
<group string="Attachments">
|
||||
<field name="attachment_ids" widget="many2many_binary" nolabel="1"/>
|
||||
</group>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Receiving & Inspection',
|
||||
'version': '19.0.3.5.0',
|
||||
'version': '19.0.3.7.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
|
||||
'description': """
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
# boxes (which is DIFFERENT from receiving — receiving is box count
|
||||
# only). One record per MO.
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
@@ -151,6 +153,27 @@ class FpRackingInspection(models.Model):
|
||||
'inspection_completed': fields.Datetime.now(),
|
||||
})
|
||||
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(
|
||||
'mail.mail_activity_data_todo',
|
||||
summary=_('Racking discrepancy on %s') % (
|
||||
@@ -160,6 +183,8 @@ class FpRackingInspection(models.Model):
|
||||
'%(n)d line(s) flagged — review before starting '
|
||||
'the first plating WO.'
|
||||
) % {'n': rec.flagged_count},
|
||||
user_id=assignee,
|
||||
date_deadline=deadline,
|
||||
)
|
||||
rec.message_post(body=_(
|
||||
'Inspection completed — %(ok)d ok / %(flag)d flagged.'
|
||||
@@ -214,6 +239,50 @@ class FpRackingInspectionLine(models.Model):
|
||||
)
|
||||
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')
|
||||
def _compute_qty_variance(self):
|
||||
for rec in self:
|
||||
|
||||
@@ -54,19 +54,56 @@
|
||||
<notebook>
|
||||
<page string="Inspection Lines" name="lines">
|
||||
<field name="line_ids" readonly="state in ('done','discrepancy_flagged')">
|
||||
<list editable="bottom"
|
||||
decoration-warning="condition == 'minor' or qty_variance != 0"
|
||||
<list decoration-warning="condition == 'minor' or qty_variance != 0"
|
||||
decoration-danger="condition in ('major','reject')">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="part_catalog_id"/>
|
||||
<field name="part_number" readonly="1"/>
|
||||
<field name="part_revision" readonly="1"/>
|
||||
<field name="qty_expected"/>
|
||||
<field name="qty_found"/>
|
||||
<field name="qty_variance" readonly="1"/>
|
||||
<field name="part_catalog_id"
|
||||
string="Part"/>
|
||||
<field name="part_number"
|
||||
string="Part #"
|
||||
readonly="1" optional="show"/>
|
||||
<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="notes"/>
|
||||
<field name="photo_count"
|
||||
string="📷"
|
||||
optional="show"/>
|
||||
<field name="notes" optional="show"/>
|
||||
</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 & 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>
|
||||
</page>
|
||||
<page string="Notes" name="notes">
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<field name="facility_id" ref="fusion_plating.demo_facility_main"/>
|
||||
<field name="location">Acid Cabinet 2</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="reorder_point">5.0</field>
|
||||
</record>
|
||||
@@ -201,7 +201,7 @@
|
||||
<field name="sample_type">personal_air</field>
|
||||
<field name="substance">Chromium (VI)</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_limit">0.025</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="substance">Nickel (soluble compounds)</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_limit">0.1</field>
|
||||
<field name="notes" type="html"><p>Personal air sample collected at nickel plating station during tank maintenance.</p></field>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
|
||||
|
||||
|
||||
class FpChemical(models.Model):
|
||||
"""Physical chemical container in the shop's chemical inventory.
|
||||
@@ -52,11 +54,14 @@ class FpChemical(models.Model):
|
||||
)
|
||||
container_size = fields.Float(
|
||||
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',
|
||||
help='Free-text unit of measure for the container size, '
|
||||
'e.g. L, kg, lb, gal.',
|
||||
help='Unit of measure for the container size — pick from the '
|
||||
'curated list to keep inventory consistent (L, kg, lb, gal).',
|
||||
)
|
||||
quantity_on_hand = fields.Float(
|
||||
string='Quantity On Hand',
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
|
||||
|
||||
|
||||
class FpExposureMonitoring(models.Model):
|
||||
"""An exposure monitoring sample.
|
||||
@@ -71,10 +73,13 @@ class FpExposureMonitoring(models.Model):
|
||||
concentration = fields.Float(
|
||||
string='Concentration',
|
||||
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',
|
||||
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(
|
||||
string='OEL Reference',
|
||||
|
||||
@@ -103,9 +103,8 @@
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1"/>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user