From 13e300d90eccf6da5bf510bc432b7e12c68bca84 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 28 Apr 2026 19:39:37 -0400 Subject: [PATCH] changes --- .../models/fp_tank_reading.py | 4 +- .../models/fp_tank_sensor.py | 4 +- fusion_plating/CLAUDE.md | 114 +++++ fusion_plating/fusion_plating/__init__.py | 128 +++++- fusion_plating/fusion_plating/__manifest__.py | 2 +- .../migrations/19.0.12.1.0/post-migrate.py | 56 +++ .../migrations/19.0.12.4.1/post-migrate.py | 97 ++++ .../migrations/19.0.12.5.0/post-migrate.py | 97 ++++ .../fusion_plating/models/__init__.py | 2 + .../models/_fp_uom_selection.py | 418 ++++++++++++++++++ .../fusion_plating/models/fp_bath.py | 3 +- .../fusion_plating/models/fp_bath_log_line.py | 25 +- .../models/fp_bath_parameter.py | 38 +- .../fusion_plating/models/fp_job_step.py | 67 ++- .../fusion_plating/models/fp_process_node.py | 24 +- .../fusion_plating/models/fp_step_template.py | 34 +- .../models/fp_step_template_input.py | 13 +- .../fusion_plating/models/fp_tank.py | 74 +++- .../models/fp_tank_composition.py | 216 +++++++++ .../fusion_plating/models/fp_tank_section.py | 69 +++ .../security/ir.model.access.csv | 9 + .../views/fp_bath_replenishment_views.xml | 5 +- .../views/fp_job_step_timelog_views.xml | 5 +- .../views/fp_operator_certification_views.xml | 10 +- .../views/fp_process_type_views.xml | 27 +- .../fusion_plating/views/fp_rack_views.xml | 5 +- .../fusion_plating/views/fp_tank_views.xml | 154 ++++++- .../views/fp_work_role_views.xml | 4 +- .../views/fp_job_consumption_views.xml | 5 +- .../views/fp_qc_template_views.xml | 5 +- .../views/fp_work_role_views.xml | 4 +- .../views/mrp_production_views.xml | 4 +- .../views/fp_certificate_views.xml | 4 +- .../views/res_partner_views.xml | 4 +- .../data/fp_demo_compliance_data.xml | 12 +- .../models/fp_discharge_limit.py | 9 +- .../models/fp_discharge_sample_line.py | 9 +- .../models/fp_spill_register.py | 8 +- .../models/fp_waste_manifest.py | 8 +- .../models/fp_waste_stream.py | 10 +- .../views/fp_compliance_event_views.xml | 3 +- .../views/fp_discharge_limit_views.xml | 3 +- .../views/fp_facility_views.xml | 5 +- .../views/fp_permit_views.xml | 3 +- .../views/fp_pollutant_inventory_views.xml | 3 +- .../views/fp_regulator_views.xml | 3 +- .../__manifest__.py | 4 +- .../migrations/19.0.18.0.0/post-migration.py | 60 +++ .../models/account_move_line.py | 15 +- .../models/fp_part_catalog.py | 27 ++ .../models/fp_serial.py | 164 +++++++ .../models/sale_order_line.py | 277 ++++++++++-- .../security/ir.model.access.csv | 2 + .../static/src/js/fp_part_process_composer.js | 17 + .../src/xml/fp_part_process_composer.xml | 11 +- .../views/fp_customer_price_list_views.xml | 5 +- .../views/fp_part_catalog_views.xml | 26 +- .../views/fp_quote_configurator_views.xml | 9 +- .../fp_sale_description_template_views.xml | 5 +- .../views/fp_serial_views.xml | 191 ++++++++ .../views/fp_treatment_views.xml | 4 +- .../views/sale_order_views.xml | 29 +- .../wizard/__init__.py | 1 + .../wizard/fp_direct_order_line.py | 242 ++++++++-- .../wizard/fp_direct_order_wizard.py | 9 +- .../wizard/fp_direct_order_wizard_views.xml | 40 +- .../wizard/fp_serial_bulk_add_wizard.py | 253 +++++++++++ .../fp_serial_bulk_add_wizard_views.xml | 57 +++ .../views/fp_invoice_strategy_views.xml | 4 +- .../fusion_plating_jobs/__init__.py | 1 + .../fusion_plating_jobs/__manifest__.py | 5 +- .../fusion_plating_jobs/models/fp_job.py | 220 +++++++++ .../fusion_plating_jobs/models/fp_job_step.py | 367 +++++++++++++++ .../report/report_fp_job_traveller.xml | 39 ++ .../report/report_fp_job_wo_detail.xml | 281 ++++++++++++ .../security/ir.model.access.csv | 12 + .../views/fp_job_consumption_views.xml | 5 +- .../views/fp_job_form_inherit.xml | 120 +++++ .../fusion_plating_jobs/wizards/__init__.py | 6 + .../wizards/fp_job_step_input_wizard.py | 217 +++++++++ .../fp_job_step_input_wizard_views.xml | 60 +++ .../wizards/fp_job_step_move_wizard.py | 344 ++++++++++++++ .../wizards/fp_job_step_move_wizard_views.xml | 70 +++ .../views/fp_chain_of_custody_views.xml | 5 +- .../views/fp_notification_log_views.xml | 10 +- .../views/fp_n299_level_views.xml | 5 +- .../data/fp_bath_parameter_data.xml | 24 +- .../data/fp_bath_parameter_data.xml | 14 +- .../data/fp_bath_parameter_data.xml | 20 +- .../data/fp_bath_parameter_data.xml | 14 +- .../views/fp_contract_review_views.xml | 5 +- .../views/fp_qc_template_views.xml | 5 +- .../views/fp_quality_hold_views.xml | 4 +- .../fusion_plating_receiving/__manifest__.py | 2 +- .../models/fp_racking_inspection.py | 69 +++ .../views/fp_racking_inspection_views.xml | 55 ++- .../data/fp_demo_safety_data.xml | 6 +- .../models/fp_chemical.py | 11 +- .../models/fp_exposure_monitoring.py | 9 +- .../views/fp_bake_window_views.xml | 5 +- .../views/fp_first_piece_gate_views.xml | 5 +- .../views/fp_shopfloor_station_views.xml | 5 +- .../views/technician_task_views.xml | 12 +- 103 files changed, 4959 insertions(+), 331 deletions(-) create mode 100644 fusion_plating/fusion_plating/migrations/19.0.12.1.0/post-migrate.py create mode 100644 fusion_plating/fusion_plating/migrations/19.0.12.4.1/post-migrate.py create mode 100644 fusion_plating/fusion_plating/migrations/19.0.12.5.0/post-migrate.py create mode 100644 fusion_plating/fusion_plating/models/_fp_uom_selection.py create mode 100644 fusion_plating/fusion_plating/models/fp_tank_composition.py create mode 100644 fusion_plating/fusion_plating/models/fp_tank_section.py create mode 100644 fusion_plating/fusion_plating_configurator/migrations/19.0.18.0.0/post-migration.py create mode 100644 fusion_plating/fusion_plating_configurator/views/fp_serial_views.xml create mode 100644 fusion_plating/fusion_plating_configurator/wizard/fp_serial_bulk_add_wizard.py create mode 100644 fusion_plating/fusion_plating_configurator/wizard/fp_serial_bulk_add_wizard_views.xml create mode 100644 fusion_plating/fusion_plating_jobs/report/report_fp_job_wo_detail.xml create mode 100644 fusion_plating/fusion_plating_jobs/wizards/__init__.py create mode 100644 fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard.py create mode 100644 fusion_plating/fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml create mode 100644 fusion_plating/fusion_plating_jobs/wizards/fp_job_step_move_wizard.py create mode 100644 fusion_plating/fusion_plating_jobs/wizards/fp_job_step_move_wizard_views.xml diff --git a/fusion_iot/fusion_plating_iot/models/fp_tank_reading.py b/fusion_iot/fusion_plating_iot/models/fp_tank_reading.py index 8d982d3f..ceed9f83 100644 --- a/fusion_iot/fusion_plating_iot/models/fp_tank_reading.py +++ b/fusion_iot/fusion_plating_iot/models/fp_tank_reading.py @@ -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 diff --git a/fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py b/fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py index 1007e8ed..aac211ee 100644 --- a/fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py +++ b/fusion_iot/fusion_plating_iot/models/fp_tank_sensor.py @@ -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', diff --git a/fusion_plating/CLAUDE.md b/fusion_plating/CLAUDE.md index b07dc718..6452f559 100644 --- a/fusion_plating/CLAUDE.md +++ b/fusion_plating/CLAUDE.md @@ -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: diff --git a/fusion_plating/fusion_plating/__init__.py b/fusion_plating/fusion_plating/__init__.py index 6729e338..9883be38 100644 --- a/fusion_plating/fusion_plating/__init__.py +++ b/fusion_plating/fusion_plating/__init__.py @@ -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'] diff --git a/fusion_plating/fusion_plating/__manifest__.py b/fusion_plating/fusion_plating/__manifest__.py index a8a4ea3d..8d7d8a89 100644 --- a/fusion_plating/fusion_plating/__manifest__.py +++ b/fusion_plating/fusion_plating/__manifest__.py @@ -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': """ diff --git a/fusion_plating/fusion_plating/migrations/19.0.12.1.0/post-migrate.py b/fusion_plating/fusion_plating/migrations/19.0.12.1.0/post-migrate.py new file mode 100644 index 00000000..4ee8cf49 --- /dev/null +++ b/fusion_plating/fusion_plating/migrations/19.0.12.1.0/post-migrate.py @@ -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), + ) diff --git a/fusion_plating/fusion_plating/migrations/19.0.12.4.1/post-migrate.py b/fusion_plating/fusion_plating/migrations/19.0.12.4.1/post-migrate.py new file mode 100644 index 00000000..caafe113 --- /dev/null +++ b/fusion_plating/fusion_plating/migrations/19.0.12.4.1/post-migrate.py @@ -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, + ) diff --git a/fusion_plating/fusion_plating/migrations/19.0.12.5.0/post-migrate.py b/fusion_plating/fusion_plating/migrations/19.0.12.5.0/post-migrate.py new file mode 100644 index 00000000..db4181e4 --- /dev/null +++ b/fusion_plating/fusion_plating/migrations/19.0.12.5.0/post-migrate.py @@ -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), + ) diff --git a/fusion_plating/fusion_plating/models/__init__.py b/fusion_plating/fusion_plating/models/__init__.py index e40391d4..e8ce4962 100644 --- a/fusion_plating/fusion_plating/models/__init__.py +++ b/fusion_plating/fusion_plating/models/__init__.py @@ -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 diff --git a/fusion_plating/fusion_plating/models/_fp_uom_selection.py b/fusion_plating/fusion_plating/models/_fp_uom_selection.py new file mode 100644 index 00000000..fcc4e37f --- /dev/null +++ b/fusion_plating/fusion_plating/models/_fp_uom_selection.py @@ -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 diff --git a/fusion_plating/fusion_plating/models/fp_bath.py b/fusion_plating/fusion_plating/models/fp_bath.py index b292d7d4..c62f24c1 100644 --- a/fusion_plating/fusion_plating/models/fp_bath.py +++ b/fusion_plating/fusion_plating/models/fp_bath.py @@ -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 = [ diff --git a/fusion_plating/fusion_plating/models/fp_bath_log_line.py b/fusion_plating/fusion_plating/models/fp_bath_log_line.py index c8b31d2f..90380137 100644 --- a/fusion_plating/fusion_plating/models/fp_bath_log_line.py +++ b/fusion_plating/fusion_plating/models/fp_bath_log_line.py @@ -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.""" diff --git a/fusion_plating/fusion_plating/models/fp_bath_parameter.py b/fusion_plating/fusion_plating/models/fp_bath_parameter.py index 0f6b618f..8ac41f19 100644 --- a/fusion_plating/fusion_plating/models/fp_bath_parameter.py +++ b/fusion_plating/fusion_plating/models/fp_bath_parameter.py @@ -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', diff --git a/fusion_plating/fusion_plating/models/fp_job_step.py b/fusion_plating/fusion_plating/models/fp_job_step.py index 8a201481..a846c4de 100644 --- a/fusion_plating/fusion_plating/models/fp_job_step.py +++ b/fusion_plating/fusion_plating/models/fp_job_step.py @@ -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: diff --git a/fusion_plating/fusion_plating/models/fp_process_node.py b/fusion_plating/fusion_plating/models/fp_process_node.py index a13e23a9..5bf1cd67 100644 --- a/fusion_plating/fusion_plating/models/fp_process_node.py +++ b/fusion_plating/fusion_plating/models/fp_process_node.py @@ -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'), diff --git a/fusion_plating/fusion_plating/models/fp_step_template.py b/fusion_plating/fusion_plating/models/fp_step_template.py index 25189410..506ad9e0 100644 --- a/fusion_plating/fusion_plating/models/fp_step_template.py +++ b/fusion_plating/fusion_plating/models/fp_step_template.py @@ -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): diff --git a/fusion_plating/fusion_plating/models/fp_step_template_input.py b/fusion_plating/fusion_plating/models/fp_step_template_input.py index 0df02ed8..3ef93436 100644 --- a/fusion_plating/fusion_plating/models/fp_step_template_input.py +++ b/fusion_plating/fusion_plating/models/fp_step_template_input.py @@ -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') diff --git a/fusion_plating/fusion_plating/models/fp_tank.py b/fusion_plating/fusion_plating/models/fp_tank.py index acd9b0f0..ed98582b 100644 --- a/fusion_plating/fusion_plating/models/fp_tank.py +++ b/fusion_plating/fusion_plating/models/fp_tank.py @@ -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: diff --git a/fusion_plating/fusion_plating/models/fp_tank_composition.py b/fusion_plating/fusion_plating/models/fp_tank_composition.py new file mode 100644 index 00000000..a2cbb958 --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_tank_composition.py @@ -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() diff --git a/fusion_plating/fusion_plating/models/fp_tank_section.py b/fusion_plating/fusion_plating/models/fp_tank_section.py new file mode 100644 index 00000000..840c9840 --- /dev/null +++ b/fusion_plating/fusion_plating/models/fp_tank_section.py @@ -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}, + } diff --git a/fusion_plating/fusion_plating/security/ir.model.access.csv b/fusion_plating/fusion_plating/security/ir.model.access.csv index 6b78505e..b6b56259 100644 --- a/fusion_plating/fusion_plating/security/ir.model.access.csv +++ b/fusion_plating/fusion_plating/security/ir.model.access.csv @@ -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 diff --git a/fusion_plating/fusion_plating/views/fp_bath_replenishment_views.xml b/fusion_plating/fusion_plating/views/fp_bath_replenishment_views.xml index 148ebbec..c08bd8bb 100644 --- a/fusion_plating/fusion_plating/views/fp_bath_replenishment_views.xml +++ b/fusion_plating/fusion_plating/views/fp_bath_replenishment_views.xml @@ -46,9 +46,8 @@ - - - + + diff --git a/fusion_plating/fusion_plating/views/fp_job_step_timelog_views.xml b/fusion_plating/fusion_plating/views/fp_job_step_timelog_views.xml index 45fd2837..49c263dc 100644 --- a/fusion_plating/fusion_plating/views/fp_job_step_timelog_views.xml +++ b/fusion_plating/fusion_plating/views/fp_job_step_timelog_views.xml @@ -82,9 +82,8 @@ - - - + + diff --git a/fusion_plating/fusion_plating/views/fp_operator_certification_views.xml b/fusion_plating/fusion_plating/views/fp_operator_certification_views.xml index 226f3bc2..4942b563 100644 --- a/fusion_plating/fusion_plating/views/fp_operator_certification_views.xml +++ b/fusion_plating/fusion_plating/views/fp_operator_certification_views.xml @@ -48,12 +48,10 @@ - - - - - - + + + + diff --git a/fusion_plating/fusion_plating/views/fp_process_type_views.xml b/fusion_plating/fusion_plating/views/fp_process_type_views.xml index 5cc5b743..9f21e38b 100644 --- a/fusion_plating/fusion_plating/views/fp_process_type_views.xml +++ b/fusion_plating/fusion_plating/views/fp_process_type_views.xml @@ -211,21 +211,34 @@ - + - - - + + - - - + + diff --git a/fusion_plating/fusion_plating/views/fp_rack_views.xml b/fusion_plating/fusion_plating/views/fp_rack_views.xml index 29d3d3c1..cc1dfc0b 100644 --- a/fusion_plating/fusion_plating/views/fp_rack_views.xml +++ b/fusion_plating/fusion_plating/views/fp_rack_views.xml @@ -82,9 +82,8 @@ - - - + + diff --git a/fusion_plating/fusion_plating/views/fp_tank_views.xml b/fusion_plating/fusion_plating/views/fp_tank_views.xml index 02f18d8c..1bb58716 100644 --- a/fusion_plating/fusion_plating/views/fp_tank_views.xml +++ b/fusion_plating/fusion_plating/views/fp_tank_views.xml @@ -10,20 +10,24 @@ fp.tank.list fusion.plating.tank - - - + + + + + + + - - + + @@ -69,12 +73,19 @@ + - - - + + + @@ -96,6 +107,62 @@ + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + Tank Sections + fusion.plating.tank.section + list,form + + + + diff --git a/fusion_plating/fusion_plating/views/fp_work_role_views.xml b/fusion_plating/fusion_plating/views/fp_work_role_views.xml index ba3cd763..b5e82aed 100644 --- a/fusion_plating/fusion_plating/views/fp_work_role_views.xml +++ b/fusion_plating/fusion_plating/views/fp_work_role_views.xml @@ -42,10 +42,8 @@ - - - @@ -299,10 +297,9 @@ - - + -
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_sale_description_template_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_sale_description_template_views.xml index 46325966..65c4f10d 100644 --- a/fusion_plating/fusion_plating_configurator/views/fp_sale_description_template_views.xml +++ b/fusion_plating/fusion_plating_configurator/views/fp_sale_description_template_views.xml @@ -58,10 +58,9 @@ - - + - diff --git a/fusion_plating/fusion_plating_configurator/views/fp_serial_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_serial_views.xml new file mode 100644 index 00000000..63439e78 --- /dev/null +++ b/fusion_plating/fusion_plating_configurator/views/fp_serial_views.xml @@ -0,0 +1,191 @@ + + + + + + fp.serial.form + fp.serial + +
+
+
+ + + +
+ + + +
+
+
+ + + + + + + + + + + + + + + + +
+ + +
+
+ + + fp.serial.list + fp.serial + + + + + + + + + + + + + + + + fp.serial.search + fp.serial + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Serial Numbers + fp.serial + list,form + + + + + +
diff --git a/fusion_plating/fusion_plating_configurator/views/fp_treatment_views.xml b/fusion_plating/fusion_plating_configurator/views/fp_treatment_views.xml index da91f435..859ed64f 100644 --- a/fusion_plating/fusion_plating_configurator/views/fp_treatment_views.xml +++ b/fusion_plating/fusion_plating_configurator/views/fp_treatment_views.xml @@ -52,9 +52,7 @@ options="{'currency_field': 'currency_id'}"/> - - - + diff --git a/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml b/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml index ae436d94..f068a557 100644 --- a/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml +++ b/fusion_plating/fusion_plating_configurator/views/sale_order_views.xml @@ -188,10 +188,9 @@ - - + -
@@ -214,17 +213,33 @@ optional="hide"/> + +