Compare commits
21 Commits
9e6b88f60e
...
edf3f95854
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edf3f95854 | ||
|
|
80887d6098 | ||
|
|
5d5964a327 | ||
|
|
80f80fb707 | ||
|
|
bfc138251a | ||
|
|
7dab5fb9c6 | ||
|
|
8d4c85cc52 | ||
|
|
fc17754996 | ||
|
|
0371624afb | ||
|
|
eed1c4619d | ||
|
|
170398ab6f | ||
|
|
d4e95dcd47 | ||
|
|
e1fedf7231 | ||
|
|
9a2975b154 | ||
|
|
271a995455 | ||
|
|
056178b433 | ||
|
|
2285c9def1 | ||
|
|
6afc9e3c0d | ||
|
|
b06d28e7f6 | ||
|
|
7b90f210b9 | ||
|
|
c75d2bde5a |
@@ -0,0 +1,784 @@
|
||||
# Recipe Cleanup + Receiving Enforcement Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix recipe 3620 ENP-ALUM-BASIC's duplicate-sequence bug, delete all 24 per-part clone recipes, backfill `kind=other` nodes via an extended name resolver, add an auto-classify hook on every node create/write, and make `no_parts` cards always land in the Receiving column.
|
||||
|
||||
**Architecture:** One migration in `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` does all the data work in 5 phases (resequence 3620 → backfill kinds → delete clones → recompute step.area_kind → recompute job.active_step_id + card_state). Two code-side changes: extend `fp_resolve_step_kind()` with new aliases + parenthetical stripping, add `_fp_autoclassify_kind()` to `fusion.plating.process.node.create/write` so future authoring + recipe duplication self-correct.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md](../specs/2026-05-24-recipe-cleanup-design.md)
|
||||
|
||||
**Tech Stack:** Odoo 19, Python (ORM/migrations), PostgreSQL.
|
||||
|
||||
---
|
||||
|
||||
## File Inventory
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `fusion_plating/__init__.py` | Extend `_STARTER_KIND_BY_NAME` aliases; add parenthetical-strip to `fp_resolve_step_kind()`; expose `RESOLVER_KIND_TO_ACTIVE_KIND` map |
|
||||
| `fusion_plating/models/fp_process_node.py` | `_fp_autoclassify_kind()` helper + create/write hooks |
|
||||
| `fusion_plating/__manifest__.py` | Version bump to `19.0.21.3.0` |
|
||||
| `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` | NEW — 5-phase data migration |
|
||||
| `fusion_plating_jobs/__manifest__.py` | Version bump to `19.0.10.26.0` |
|
||||
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | `no_parts` → receiving column override in `_resolve_card_area` |
|
||||
| `fusion_plating_shopfloor/__manifest__.py` | Version bump to `19.0.33.1.4` |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend `fp_resolve_step_kind()` with new aliases + parenthetical stripping
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating/__init__.py:208-304`
|
||||
|
||||
- [ ] **Step 1: Add `re` to imports**
|
||||
|
||||
At the top of `fusion_plating/__init__.py`, after the existing `import logging` line, add:
|
||||
|
||||
```python
|
||||
import re
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend `_STARTER_KIND_BY_NAME`**
|
||||
|
||||
Find the dict at line 208. Inside the dict (before the closing `}`), add the following keys (preserve the existing entries):
|
||||
|
||||
```python
|
||||
# 2026-05-24 — Recipe cleanup additions (live-step fix follow-up).
|
||||
# Blasting variants
|
||||
'blasting': 'blast',
|
||||
'bead blast': 'blast',
|
||||
'bead blasting': 'blast',
|
||||
'media blast': 'blast',
|
||||
'media blasting': 'blast',
|
||||
# Inspection variants the resolver didn't know
|
||||
'adhesion test coupon': 'inspect',
|
||||
'adhesion testing': 'inspect',
|
||||
'corrosion testing': 'inspect',
|
||||
'lab testing': 'inspect',
|
||||
'check sulfamate nickel area': 'inspect',
|
||||
'pre-measurements': 'inspect',
|
||||
'pre measurements': 'inspect',
|
||||
'hot water porosity': 'inspect',
|
||||
# Strip / chemical conversion / plugging (wet line)
|
||||
'strip process': 'wet_process',
|
||||
'strip process - al': 'wet_process',
|
||||
'nickel strip - aluminum line': 'wet_process',
|
||||
'chemical conversion': 'wet_process',
|
||||
'trivalent chromate conversion': 'wet_process',
|
||||
'plug the threaded holes': 'mask',
|
||||
# Misc wet line variants seen on entech recipes
|
||||
'air dry': 'dry',
|
||||
'desmut': 'etch',
|
||||
'soak clean': 'cleaning',
|
||||
'cleaner': 'cleaning',
|
||||
'nickel strike': 'plate',
|
||||
'nickel strip': 'plate',
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add parenthetical stripping inside `fp_resolve_step_kind()`**
|
||||
|
||||
Find the function around line 288. Replace its body:
|
||||
|
||||
```python
|
||||
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.
|
||||
|
||||
Handles parenthetical suffixes like "(Standard)", "(If Required)",
|
||||
"(A-14 / A)" by stripping them before the second lookup attempt.
|
||||
|
||||
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]
|
||||
# Parenthetical strip — "Masking (If Required)" → "Masking",
|
||||
# "Incoming Inspection (Standard)" → "Incoming Inspection".
|
||||
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
|
||||
if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
|
||||
return _STARTER_KIND_BY_NAME[bare]
|
||||
# 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
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `RESOLVER_KIND_TO_ACTIVE_KIND` translation map**
|
||||
|
||||
Right after the `fp_resolve_step_kind` function (around line 305), add:
|
||||
|
||||
```python
|
||||
# Translates the resolver's kind output to the active fp.step.kind.code
|
||||
# values. The resolver still returns the OLD vocabulary (cleaning,
|
||||
# electroclean, etch, rinse, strike, dry, wbf_test) which were
|
||||
# deactivated in 19.0.20.6.0 — those roll up to the active wet_process
|
||||
# kind. Other codes pass through 1:1.
|
||||
RESOLVER_KIND_TO_ACTIVE_KIND = {
|
||||
# Wet-line kinds → wet_process (active rollup)
|
||||
'cleaning': 'wet_process',
|
||||
'electroclean': 'wet_process',
|
||||
'etch': 'wet_process',
|
||||
'rinse': 'wet_process',
|
||||
'strike': 'wet_process',
|
||||
'dry': 'wet_process',
|
||||
'wbf_test': 'wet_process',
|
||||
# 1:1 mappings (kind exists and is active)
|
||||
'contract_review': 'contract_review',
|
||||
'mask': 'mask',
|
||||
'racking': 'racking',
|
||||
'plate': 'plate',
|
||||
'bake': 'bake',
|
||||
'derack': 'derack',
|
||||
'demask': 'demask',
|
||||
'inspect': 'inspect',
|
||||
'final_inspect': 'final_inspect',
|
||||
'ship': 'ship',
|
||||
'gating': 'gating',
|
||||
'blast': 'blast',
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Confirm import structure (no commit yet)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -n "^import re\|^from\|^import" fusion_plating/fusion_plating/__init__.py | head -5
|
||||
```
|
||||
Expected: `import re` appears before `from . import controllers`.
|
||||
|
||||
Commit happens in Task 3.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Auto-classify hook on `fusion.plating.process.node`
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating/models/fp_process_node.py`
|
||||
|
||||
- [ ] **Step 1: Find an insertion point near the existing `create/write/copy` methods**
|
||||
|
||||
In [`fusion_plating/models/fp_process_node.py`](../../fusion_plating/models/fp_process_node.py), find the `copy()` method around line 789 (it's at the bottom of the FpProcessNode class). The autoclassify helper goes near it, and the create/write overrides slot in alongside copy.
|
||||
|
||||
- [ ] **Step 2: Add the helper + create/write overrides**
|
||||
|
||||
In `FpProcessNode`, add this block right before the `copy()` method at line ~787. Insert AFTER all the other fields/methods but BEFORE `copy()`:
|
||||
|
||||
```python
|
||||
# ---- Auto-classify kind from name (2026-05-24) ----------------------
|
||||
# Safety net: when a node's kind is the catch-all 'other' AND its
|
||||
# name resolves via fp_resolve_step_kind(), upgrade kind_id to the
|
||||
# resolved active kind. Runs on create() and on write() when name
|
||||
# or kind_id changes. Prevents recipe authoring + recipe duplication
|
||||
# from silently leaving nodes as 'other' (which then routes them to
|
||||
# the wrong Shop Floor column).
|
||||
#
|
||||
# Skip with context flag fp_skip_kind_autoclassify=True for admin
|
||||
# workflows that need to keep kind=other despite a known name.
|
||||
|
||||
def _fp_autoclassify_kind(self):
|
||||
"""Upgrade kind_id when current is 'other' and name resolves."""
|
||||
if self.env.context.get('fp_skip_kind_autoclassify'):
|
||||
return
|
||||
from odoo.addons.fusion_plating import (
|
||||
fp_resolve_step_kind,
|
||||
RESOLVER_KIND_TO_ACTIVE_KIND,
|
||||
)
|
||||
Kind = self.env['fp.step.kind']
|
||||
other = Kind.search([('code', '=', 'other')], limit=1)
|
||||
if not other:
|
||||
return
|
||||
for node in self:
|
||||
if not node.name or node.kind_id != other:
|
||||
continue
|
||||
resolver_code = fp_resolve_step_kind(node.name)
|
||||
if not resolver_code:
|
||||
continue
|
||||
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
|
||||
if not target_code:
|
||||
continue
|
||||
target = Kind.search([('code', '=', target_code)], limit=1)
|
||||
if target:
|
||||
node.with_context(
|
||||
fp_skip_kind_autoclassify=True,
|
||||
).write({'kind_id': target.id})
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
nodes = super().create(vals_list)
|
||||
nodes._fp_autoclassify_kind()
|
||||
return nodes
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if 'name' in vals or 'kind_id' in vals:
|
||||
self._fp_autoclassify_kind()
|
||||
return res
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the file parses (no commit yet)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python3 -c "import ast; ast.parse(open('fusion_plating/fusion_plating/models/fp_process_node.py').read()); print('OK')"
|
||||
```
|
||||
Expected: `OK`.
|
||||
|
||||
Commit happens in Task 3.
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Version bump fusion_plating + commit Phase 1
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Bump the version**
|
||||
|
||||
In [`fusion_plating/__manifest__.py`](../../fusion_plating/__manifest__.py), change:
|
||||
|
||||
```python
|
||||
'version': '19.0.21.2.0',
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
'version': '19.0.21.3.0',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit Phase 1**
|
||||
|
||||
```bash
|
||||
git add fusion_plating/fusion_plating/__init__.py \
|
||||
fusion_plating/fusion_plating/models/fp_process_node.py \
|
||||
fusion_plating/fusion_plating/__manifest__.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(fusion_plating): extend resolver + auto-classify hook on process node
|
||||
|
||||
Resolver (fp_resolve_step_kind) extensions:
|
||||
- New aliases: blasting/bead blast/media blast variants, adhesion
|
||||
testing, corrosion testing, lab testing, strip process, chemical
|
||||
conversion, trivalent chromate, plug the threaded holes, air dry,
|
||||
desmut, soak clean, cleaner, nickel strike/strip
|
||||
- Parenthetical suffix stripping — "Masking (If Required)" resolves
|
||||
through "masking", "Incoming Inspection (Standard)" through
|
||||
"incoming inspection"
|
||||
- New RESOLVER_KIND_TO_ACTIVE_KIND map translates the resolver's
|
||||
vocabulary (cleaning/electroclean/etch/rinse/strike/dry/wbf_test
|
||||
→ wet_process) so the resolver output lands on active kinds only
|
||||
|
||||
Auto-classify hook on fusion.plating.process.node:
|
||||
- _fp_autoclassify_kind() upgrades kind_id when current is 'other'
|
||||
AND name resolves via the resolver. Idempotent — never overrides
|
||||
a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify
|
||||
- Wired into create() and write() (only fires when name or kind_id
|
||||
changed on write)
|
||||
- Side-effects: recipe duplication via copy() auto-corrects newly
|
||||
copied nodes; Simple/Tree editor authoring auto-classifies as soon
|
||||
as the name is saved
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Write the 19.0.10.26.0 migration
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`
|
||||
|
||||
- [ ] **Step 1: Create the migration directory**
|
||||
|
||||
```bash
|
||||
mkdir -p fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the migration file**
|
||||
|
||||
Create `fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""19.0.10.26.0 — Recipe cleanup + per-part clone delete.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md
|
||||
|
||||
Phases (in order):
|
||||
1. Resequence recipe 3620 ENP-ALUM-BASIC operations + delete the
|
||||
duplicate empty ENP-Alum Line sub_process (id 4056).
|
||||
2. Backfill kind on all kind=other nodes via the extended
|
||||
fp_resolve_step_kind() resolver + RESOLVER_KIND_TO_ACTIVE_KIND
|
||||
translation.
|
||||
3. Delete all 24 per-part clone recipes (name ILIKE '% — %').
|
||||
CASCADE handles child nodes; SET NULL handles fp.job /
|
||||
fp.job.step / fp.coating.config / fp.pricing.rule /
|
||||
fp.part.catalog references.
|
||||
4. Recompute fp.job.step.area_kind on all rows.
|
||||
5. Recompute fp.job.active_step_id + card_state on in-flight jobs.
|
||||
|
||||
All phases idempotent — re-running -u is safe.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo.api import Environment, SUPERUSER_ID
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Recipe 3620's ops in the desired final order. Maps the existing node
|
||||
# id (as documented in the spec) to its target sequence. The user
|
||||
# decided mask-first-then-rack per spec Section "Mask vs Rack order".
|
||||
RECIPE_3620_RESEQUENCE = [
|
||||
# (node_id, new_sequence, expected_name)
|
||||
(3853, 10, 'Contract Review'),
|
||||
(3854, 20, 'Incoming Inspection (Standard)'),
|
||||
(3877, 30, 'Masking'),
|
||||
(3855, 40, 'Racking'),
|
||||
(3858, 50, 'Ready for processing'),
|
||||
(3859, 60, 'ENP-Alum Line'),
|
||||
(3861, 70, 'De-Masking'),
|
||||
(3864, 80, 'Oven baking'),
|
||||
(3867, 90, 'De-racking'),
|
||||
(4067, 100, 'Oven bake (Post de-rack)'),
|
||||
(3873, 110, 'Post-plate Inspection'),
|
||||
(3876, 120, 'Final Inspection'),
|
||||
]
|
||||
|
||||
# Empty duplicate ENP-Alum Line sub_process on recipe 3620 (no
|
||||
# children — the real one is id 3859 with E-Nickel Plating as child).
|
||||
RECIPE_3620_DUPLICATE_TO_DELETE = 4056
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
env = Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
# ============================================================
|
||||
# Phase 1 — Resequence recipe 3620 + delete duplicate sub_process
|
||||
# ============================================================
|
||||
Node = env['fusion.plating.process.node']
|
||||
recipe_3620 = Node.browse(3620).exists()
|
||||
if not recipe_3620:
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Recipe 3620 ENP-ALUM-BASIC not found; '
|
||||
'skipping resequence phase'
|
||||
)
|
||||
else:
|
||||
# Verify the expected nodes exist, then resequence them.
|
||||
# We do this idempotently — only update if the sequence
|
||||
# differs from the target.
|
||||
renumbered = 0
|
||||
for node_id, new_seq, expected_name in RECIPE_3620_RESEQUENCE:
|
||||
node = Node.browse(node_id).exists()
|
||||
if not node:
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Recipe 3620: expected node %s '
|
||||
'("%s") not found; skipping',
|
||||
node_id, expected_name,
|
||||
)
|
||||
continue
|
||||
if node.sequence != new_seq:
|
||||
# Skip the autoclassify hook on this write (nothing
|
||||
# changes about kind_id; we're only touching sequence).
|
||||
node.with_context(
|
||||
fp_skip_kind_autoclassify=True,
|
||||
).write({'sequence': new_seq})
|
||||
renumbered += 1
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Recipe 3620: %s nodes resequenced',
|
||||
renumbered,
|
||||
)
|
||||
|
||||
# Delete the empty duplicate ENP-Alum Line sub_process.
|
||||
dup = Node.browse(RECIPE_3620_DUPLICATE_TO_DELETE).exists()
|
||||
if dup:
|
||||
if dup.child_ids:
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Duplicate sub_process %s has '
|
||||
'%s children — NOT deleting (safety check). '
|
||||
'Expected an empty node.',
|
||||
dup.id, len(dup.child_ids),
|
||||
)
|
||||
else:
|
||||
dup.unlink()
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Deleted empty duplicate '
|
||||
'ENP-Alum Line sub_process (id %s)',
|
||||
RECIPE_3620_DUPLICATE_TO_DELETE,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Phase 2 — Backfill kind on all kind=other nodes via resolver
|
||||
# ============================================================
|
||||
from odoo.addons.fusion_plating import (
|
||||
fp_resolve_step_kind,
|
||||
RESOLVER_KIND_TO_ACTIVE_KIND,
|
||||
)
|
||||
Kind = env['fp.step.kind']
|
||||
other_kind = Kind.search([('code', '=', 'other')], limit=1)
|
||||
if not other_kind:
|
||||
_logger.error(
|
||||
'[recipe-cleanup] No "other" kind found; skipping kind '
|
||||
'backfill phase'
|
||||
)
|
||||
else:
|
||||
# Build a cache of code → kind.id so we don't search per-row
|
||||
kind_by_code = {k.code: k.id for k in Kind.search([])}
|
||||
affected_nodes = Node.search([
|
||||
('kind_id', '=', other_kind.id),
|
||||
('name', '!=', False),
|
||||
('node_type', 'in', ('operation', 'step', 'sub_process')),
|
||||
])
|
||||
fixed = 0
|
||||
for node in affected_nodes:
|
||||
resolver_code = fp_resolve_step_kind(node.name)
|
||||
if not resolver_code:
|
||||
continue
|
||||
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
|
||||
if not target_code or target_code not in kind_by_code:
|
||||
continue
|
||||
node.with_context(
|
||||
fp_skip_kind_autoclassify=True,
|
||||
).write({'kind_id': kind_by_code[target_code]})
|
||||
fixed += 1
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 2: backfilled kind on %s nodes '
|
||||
'(of %s currently kind=other)',
|
||||
fixed, len(affected_nodes),
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Phase 3 — Delete all 24 per-part clone recipes
|
||||
# ============================================================
|
||||
# Identify by name pattern. The configurator names clones
|
||||
# "BASE_NAME — PART_NUMBER Rev X" with an em-dash separator.
|
||||
# No base recipe uses em-dash in its name.
|
||||
clone_recipes = Node.search([
|
||||
('node_type', '=', 'recipe'),
|
||||
('name', 'ilike', '% — %'),
|
||||
])
|
||||
if clone_recipes:
|
||||
# Log what we're about to delete for forensic visibility.
|
||||
clone_names = [c.name for c in clone_recipes]
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 3: deleting %s clone recipes: %s',
|
||||
len(clone_recipes),
|
||||
', '.join(clone_names[:10])
|
||||
+ (' …' if len(clone_names) > 10 else ''),
|
||||
)
|
||||
clone_recipes.unlink()
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 3: deleted %s clone recipes '
|
||||
'(CASCADE removed their child nodes; FK SET NULL applied '
|
||||
'to historical fp.job + fp.job.step references)',
|
||||
len(clone_recipes),
|
||||
)
|
||||
else:
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 3: no clone recipes found '
|
||||
'(already deleted on a prior run, or none exist)'
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Phase 4 — Recompute area_kind on all fp.job.step rows
|
||||
# ============================================================
|
||||
# After Phase 2, many recipe nodes have new kinds. After Phase 3,
|
||||
# some fp.job.step rows have NULL recipe_node_id (FK SET NULL'd
|
||||
# when the clone got deleted). Recompute picks up the new kinds
|
||||
# for active recipes and falls back to catch-all 'plating' for
|
||||
# orphans (all historical / terminal jobs — won't show on board).
|
||||
Step = env['fp.job.step']
|
||||
steps = Step.search([])
|
||||
if steps:
|
||||
steps._compute_area_kind()
|
||||
steps.flush_recordset(['area_kind'])
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 4: recomputed area_kind on %s steps',
|
||||
len(steps),
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Phase 5 — Recompute active_step_id + card_state on in-flight jobs
|
||||
# ============================================================
|
||||
Job = env['fp.job']
|
||||
jobs = Job.search([
|
||||
('state', 'in', ('confirmed', 'in_progress')),
|
||||
])
|
||||
if jobs:
|
||||
jobs._compute_active_step_id()
|
||||
jobs._compute_card_state()
|
||||
jobs.flush_recordset(['active_step_id', 'card_state'])
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 5: recomputed active_step_id + '
|
||||
'card_state on %s in-flight jobs',
|
||||
len(jobs),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the file parses**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
python3 -c "import ast; ast.parse(open('fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py').read()); print('OK')"
|
||||
```
|
||||
Expected: `OK`.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Version bump fusion_plating_jobs
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating_jobs/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Bump the version**
|
||||
|
||||
In [`fusion_plating_jobs/__manifest__.py`](../../fusion_plating_jobs/__manifest__.py), change:
|
||||
|
||||
```python
|
||||
'version': '19.0.10.25.0',
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
'version': '19.0.10.26.0',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: No commit yet — grouped with Task 6's commit.**
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `no_parts` cards always show in Receiving column
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_plating_shopfloor/controllers/plant_kanban.py:165-180`
|
||||
- Modify: `fusion_plating_shopfloor/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Update `_resolve_card_area`**
|
||||
|
||||
In [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../fusion_plating_shopfloor/controllers/plant_kanban.py), find `_resolve_card_area` (around line 165). Replace its body with:
|
||||
|
||||
```python
|
||||
def _resolve_card_area(job):
|
||||
"""Pick the column a card lives in.
|
||||
|
||||
Active-step area_kind wins, EXCEPT for no_parts cards which always
|
||||
land in Receiving regardless of active step — the receiver is who
|
||||
needs to act, and they work the Receiving column. With the live-step
|
||||
priority chain (see fp.job._compute_active_step_id), active_step_id
|
||||
is False only when the job has NO steps at all (recipe not assigned)
|
||||
OR every step is `done`. Done jobs are filtered off the board
|
||||
upstream, so the orphan fallback fires only for truly orphaned cards.
|
||||
|
||||
See spec 2026-05-24-recipe-cleanup-design.md Change 6.
|
||||
"""
|
||||
# no_parts cards belong in Receiving regardless of where the active
|
||||
# step is — the receiver is who acts.
|
||||
if job.card_state == 'no_parts':
|
||||
return 'receiving'
|
||||
if job.active_step_id and job.active_step_id.area_kind:
|
||||
return job.active_step_id.area_kind
|
||||
# Orphan fallback — represents a data integrity issue, not a
|
||||
# normal state. Cards here have NO steps assigned at all.
|
||||
return 'receiving'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Bump fusion_plating_shopfloor manifest**
|
||||
|
||||
In [`fusion_plating_shopfloor/__manifest__.py`](../../fusion_plating_shopfloor/__manifest__.py):
|
||||
|
||||
```python
|
||||
'version': '19.0.33.1.3',
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
'version': '19.0.33.1.4',
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit Phase 2 (Tasks 4-6)**
|
||||
|
||||
```bash
|
||||
git add fusion_plating/fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py \
|
||||
fusion_plating/fusion_plating_jobs/__manifest__.py \
|
||||
fusion_plating/fusion_plating_shopfloor/controllers/plant_kanban.py \
|
||||
fusion_plating/fusion_plating_shopfloor/__manifest__.py
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(jobs+shopfloor): recipe cleanup migration + no_parts column fix
|
||||
|
||||
Migration 19.0.10.26.0/post-migrate.py runs in 5 phases:
|
||||
1. Resequence recipe 3620 ENP-ALUM-BASIC ops (fixes the duplicate-
|
||||
sequence bug that caused WO-30057 to skip Receiving)
|
||||
2. Backfill kind on all kind=other nodes via the extended resolver
|
||||
from fusion_plating 19.0.21.3.0
|
||||
3. Delete all 24 per-part clone recipes
|
||||
4. Recompute fp.job.step.area_kind on all steps
|
||||
5. Recompute fp.job.active_step_id + card_state on in-flight jobs
|
||||
|
||||
Plant kanban: no_parts cards now always land in the Receiving column
|
||||
regardless of active_step area_kind. The receiver works Receiving;
|
||||
that's where the card belongs when parts haven't arrived.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Deploy to entech + verify
|
||||
|
||||
- [ ] **Step 1: Fetch + check concurrent commits**
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git log HEAD..origin/main --oneline
|
||||
```
|
||||
Expected: empty (we're ahead, not behind). If anything shows, rebase first.
|
||||
|
||||
- [ ] **Step 2: Copy modified files to entech**
|
||||
|
||||
```bash
|
||||
for f in \
|
||||
fusion_plating/__init__.py \
|
||||
fusion_plating/models/fp_process_node.py \
|
||||
fusion_plating/__manifest__.py \
|
||||
fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py \
|
||||
fusion_plating_jobs/__manifest__.py \
|
||||
fusion_plating_shopfloor/controllers/plant_kanban.py \
|
||||
fusion_plating_shopfloor/__manifest__.py; do
|
||||
echo "Copying $f"
|
||||
cat "$f" | ssh pve-worker5 "pct exec 111 -- bash -c \"mkdir -p \\\$(dirname /mnt/extra-addons/custom/$f) && cat > /mnt/extra-addons/custom/$f\""
|
||||
done
|
||||
echo "=== ALL COPIED ==="
|
||||
```
|
||||
|
||||
(Run from `/Users/gurpreet/Github/Odoo-Modules/fusion_plating/` so the file paths line up.)
|
||||
|
||||
- [ ] **Step 3: Upgrade modules + restart**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init\" 2>&1 | tail -60 && systemctl start odoo && sleep 3 && systemctl is-active odoo'"
|
||||
```
|
||||
|
||||
Expected log lines (in order):
|
||||
- `[recipe-cleanup] Recipe 3620: N nodes resequenced`
|
||||
- `[recipe-cleanup] Deleted empty duplicate ENP-Alum Line sub_process (id 4056)`
|
||||
- `[recipe-cleanup] Phase 2: backfilled kind on N nodes …`
|
||||
- `[recipe-cleanup] Phase 3: deleting 24 clone recipes: …`
|
||||
- `[recipe-cleanup] Phase 3: deleted 24 clone recipes …`
|
||||
- `[recipe-cleanup] Phase 4: recomputed area_kind on N steps`
|
||||
- `[recipe-cleanup] Phase 5: recomputed active_step_id + card_state on N in-flight jobs`
|
||||
- Service prints `active` at the end.
|
||||
|
||||
No tracebacks. If you see one, STOP and report it.
|
||||
|
||||
- [ ] **Step 4: SQL spot-check — clones deleted**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT COUNT(*) AS clones_remaining FROM fusion_plating_process_node WHERE node_type='\\''recipe'\\'' AND name ILIKE '\\''% — %'\\'';\" | sudo -u postgres psql -d admin'"
|
||||
```
|
||||
Expected: `clones_remaining = 0`.
|
||||
|
||||
- [ ] **Step 5: SQL spot-check — recipe 3620 resequenced**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT sequence, name FROM fusion_plating_process_node WHERE parent_id = 3620 AND node_type IN ('\\''operation'\\'', '\\''sub_process'\\'') ORDER BY sequence;\" | sudo -u postgres psql -d admin'"
|
||||
```
|
||||
Expected output (12 unique-sequence rows):
|
||||
```
|
||||
10 | Contract Review
|
||||
20 | Incoming Inspection (Standard)
|
||||
30 | Masking
|
||||
40 | Racking
|
||||
50 | Ready for processing
|
||||
60 | ENP-Alum Line
|
||||
70 | De-Masking
|
||||
80 | Oven baking
|
||||
90 | De-racking
|
||||
100 | Oven bake (Post de-rack)
|
||||
110 | Post-plate Inspection
|
||||
120 | Final Inspection
|
||||
```
|
||||
NO duplicate sequences. NO second ENP-Alum Line row.
|
||||
|
||||
- [ ] **Step 6: SQL spot-check — kind=other nodes backfilled**
|
||||
|
||||
```bash
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'echo \"SELECT n.name, COUNT(*) AS still_other FROM fusion_plating_process_node n JOIN fp_step_kind k ON k.id = n.kind_id WHERE k.code = '\\''other'\\'' AND n.node_type IN ('\\''operation'\\'', '\\''step'\\'', '\\''sub_process'\\'') GROUP BY n.name ORDER BY still_other DESC;\" | sudo -u postgres psql -d admin'"
|
||||
```
|
||||
Expected: very few rows, only names like `ENP-Alum Line - HP` (sub_process with no clear category) or genuinely-niche operation names. Should NOT include `Contract Review`, `Masking`, `Racking`, `Incoming Inspection`, `E-Nickel Plating`, `Final Inspection`, `Shipping`, `Bake`, `Blasting`, `De-Masking`, `De-racking`, `Hot Water Porosity`, etc.
|
||||
|
||||
- [ ] **Step 7: End-to-end smoke**
|
||||
|
||||
On the entech UI:
|
||||
1. Open Plating → Sales & Quoting → Sale Orders → New
|
||||
2. Add a customer + a part whose default recipe is `ENP-ALUM-BASIC` (id 3620)
|
||||
3. Confirm the SO
|
||||
4. Check the new WO on Plating → Operations → Plating Jobs:
|
||||
a. Recipe should be a fresh clone named `ENP-ALUM-BASIC — <PART#> Rev <X>`
|
||||
b. The clone's first 4 operations should be: Contract Review (10), Incoming Inspection (20), Masking (30), Racking (40)
|
||||
5. Open Shop Floor — the job card should be in the **Receiving** column (because card_state='no_parts' AND/OR because Incoming Inspection is now the next live step after Contract Review auto-completes)
|
||||
6. Open Plating → Configuration → Recipes & Steps → Recipes — confirm no recipe has " — " in its name (the clones are gone)
|
||||
|
||||
- [ ] **Step 8: Autoclassify hook smoke**
|
||||
|
||||
In the Simple Editor on any recipe:
|
||||
1. Drop a new step, type name "Masking" without picking a kind
|
||||
2. Save
|
||||
3. Refresh the page
|
||||
4. Confirm the step's kind reads "Masking" (not "Other")
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Commit spec + plan, push to origin
|
||||
|
||||
- [ ] **Step 1: Stage and commit the spec + plan docs**
|
||||
|
||||
```bash
|
||||
git add fusion_plating/docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md \
|
||||
fusion_plating/docs/superpowers/plans/2026-05-24-recipe-cleanup-plan.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(plating): spec + plan for recipe cleanup + receiving enforcement
|
||||
|
||||
Spec documents:
|
||||
- Root cause 1: duplicate sequences on recipe 3620 ENP-ALUM-BASIC
|
||||
- Root cause 2: 24 per-part clone recipes carrying the broken order
|
||||
- Root cause 3: ~10 kind=other stragglers across base recipes
|
||||
- Root cause 4: recipe duplication has no kind safety net
|
||||
|
||||
Implementation shipped in commits referenced from the plan's task list.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Final fetch + push**
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git log HEAD..origin/main --oneline # expect empty
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
If anything fails on entech:
|
||||
|
||||
1. `git reset --hard <prior-commit>` locally, force-copy the prior files back to entech.
|
||||
2. Force-rerun the prior version's post-migrate by setting `ir_module_module.latest_version` back to `19.0.10.25.0` for fusion_plating_jobs and `19.0.21.2.0` for fusion_plating, then `-u`.
|
||||
|
||||
(Migration is idempotent so re-running the broken version is safe; you may need to manually re-create the deleted clones from a DB backup if rollback needed clones back — out of scope per "we don't need to worry about current data".)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,384 @@
|
||||
# Recipe Cleanup + Receiving Enforcement
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Modules:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_shopfloor`
|
||||
**Status:** Approved, awaiting implementation plan.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
User created SO-30057, confirmed it, and the resulting WO-30057 went **straight to the Plating column** on the Shop Floor board — skipping Receiving entirely. The card-state was `no_parts` (correctly: parts hadn't arrived yet) but the column resolved to `plating`, so:
|
||||
|
||||
- The receiver, who watches the Receiving column, never sees the job
|
||||
- The Masking operator sees a card they can't start
|
||||
- The parts physically can't move forward because nobody knows they need to be received
|
||||
|
||||
The auto-complete contract-review logic (`_fp_autocomplete_repeat_order_contract_review`) is **NOT the bug** — it correctly marks Contract Review as done when the part has a complete QA-005 history. The real problems are deeper.
|
||||
|
||||
## Root causes
|
||||
|
||||
### Root cause 1 — `ENP-ALUM-BASIC` (id 3620) has DUPLICATE SEQUENCES
|
||||
|
||||
```
|
||||
seq 10: Contract Review (id 3853, kind=contract_review)
|
||||
seq 10: Masking (id 3877, kind=mask) ← TIE
|
||||
seq 20: Incoming Insp. (id 3854, kind=receiving)
|
||||
seq 20: Racking (id 3855, kind=racking) ← TIE
|
||||
seq 40: ENP-Alum Line (id 3859, sub_process, has E-Nickel Plating child)
|
||||
seq 40: ENP-Alum Line (id 4056, sub_process, empty) ← DUPLICATE
|
||||
seq 50: De-Masking
|
||||
seq 60: Oven baking
|
||||
...
|
||||
```
|
||||
|
||||
When this base recipe is cloned per-part by the configurator (`fp.process.node.copy()`), tied sequences resolve by id. So in the clone:
|
||||
|
||||
- Position 10: Contract Review (id 3853 < id 3877 → wins)
|
||||
- Position 20: Masking (the second one at 10 → promoted to 20)
|
||||
- Position 30: Incoming Inspection (one of the seq-20 ties → promoted to 30)
|
||||
- Position 40: Racking (the other seq-20 → promoted to 40)
|
||||
|
||||
After Contract Review auto-completes, the live step is **Masking** (kind=mask, area=masking) — which our prior live-step fix routes to the Masking column, not Receiving. The clone for WO-30057 (recipe 4649) followed exactly this pattern.
|
||||
|
||||
### Root cause 2 — 24 per-part clone recipes accumulated, all carrying the broken ordering
|
||||
|
||||
Each clone is its own `fusion.plating.process.node` row with `node_type='recipe'` and a name like `BASE_NAME — PART_NUMBER Rev X`. There are 24 such clones on entech. Several are referenced by historical jobs (24 cancelled + 7 done jobs use them), but all those jobs are terminal — none are in-flight.
|
||||
|
||||
### Root cause 3 — ~10 nodes across base recipes still have `kind=other`
|
||||
|
||||
Mostly niche names the existing `fp_resolve_step_kind()` resolver doesn't know:
|
||||
|
||||
| Recipe | Node | Currently | Should be |
|
||||
|---|---|---|---|
|
||||
| 3645 ENP-STEEL-MP-BASIC | Blasting (If Required) | other | blast |
|
||||
| 3645 ENP-STEEL-MP-BASIC | Adhesion Test Coupon | other | inspect |
|
||||
| 3689 ENP-SP | Adhesion Test Coupon | other | inspect |
|
||||
| 3689 ENP-SP | Adhesion Testing | other | inspect |
|
||||
| 3689 ENP-SP | Corrosion Testing | other | inspect |
|
||||
| 3689 ENP-SP | Lab Testing | other | inspect |
|
||||
| 3945 ENP ALUM BASIC HP SC2 | ENP-Alum Line - HP | other | other (intentional — sub_process) |
|
||||
| 3782 Chemical Conversion Process | Strip Process - AL | other | wet_process |
|
||||
| 3782 Chemical Conversion Process | Plug The Threaded Holes | other | mask |
|
||||
| 3782 Chemical Conversion Process | Chemical Conversion (sub_process) | other | wet_process |
|
||||
| 3782 Chemical Conversion Process | Trivalent Chromate Conversion (A-14 / A) | other | wet_process |
|
||||
|
||||
### Root cause 4 — Recipe duplication has no kind safety net
|
||||
|
||||
`fp.process.node.copy()` uses the standard Odoo deep-copy which inherits all fields including `kind_id`. So if the source has bad kinds, the clone inherits bad kinds. Even after we fix the base recipes, future authoring mistakes will propagate.
|
||||
|
||||
---
|
||||
|
||||
## Approved fix
|
||||
|
||||
### Change 1 — Delete all 24 per-part clone recipes
|
||||
|
||||
Identify clones by name pattern (em-dash with spaces — the configurator's separator): `name ILIKE '% — %' AND node_type='recipe'`.
|
||||
|
||||
FK constraints verified:
|
||||
- `fp.job.recipe_id` → SET NULL (historical job loses recipe ref, step data persists)
|
||||
- `fp.job.start_at_node_id` → SET NULL
|
||||
- `fp.job.step.recipe_node_id` → SET NULL
|
||||
- `fusion.plating.process.node.parent_id` → CASCADE (child nodes auto-deleted)
|
||||
- `fp.coating.config.recipe_id` → SET NULL
|
||||
- `fp.pricing.rule.recipe_id` → SET NULL
|
||||
- `fp.part.catalog.default_process_id` → SET NULL
|
||||
- Zero rows in the 2 RESTRICT FKs (`fp.quote.configurator.recipe_id`, `fp.job.node.override.node_id`) point at clones → no blockers
|
||||
|
||||
One DELETE statement:
|
||||
|
||||
```sql
|
||||
DELETE FROM fusion_plating_process_node
|
||||
WHERE node_type = 'recipe'
|
||||
AND name ILIKE '% — %';
|
||||
```
|
||||
|
||||
CASCADE handles all child operations + steps + sub_processes via the `parent_id` chain. SET NULL handles all the historical job references.
|
||||
|
||||
### Change 2 — Fix recipe 3620 ENP-ALUM-BASIC
|
||||
|
||||
**a. Resequence operations** so each has a unique sequence and Receiving precedes physical work:
|
||||
|
||||
| New sequence | Operation | id | Was at |
|
||||
|---|---|---|---|
|
||||
| 10 | Contract Review | 3853 | 10 |
|
||||
| 20 | Incoming Inspection (Standard) | 3854 | 20 (tied) |
|
||||
| 30 | Masking | 3877 | 10 (tied) |
|
||||
| 40 | Racking | 3855 | 20 (tied) |
|
||||
| 50 | Ready for processing | 3858 | 30 |
|
||||
| 60 | ENP-Alum Line | 3859 | 40 (tied) |
|
||||
| 70 | De-Masking | 3861 | 50 |
|
||||
| 80 | Oven baking | 3864 | 60 |
|
||||
| 90 | De-racking | 3867 | 70 |
|
||||
| 100 | Oven bake (Post de-rack) | 4067 | 80 |
|
||||
| 110 | Post-plate Inspection | 3873 | 90 |
|
||||
| 120 | Final Inspection | 3876 | 120 |
|
||||
|
||||
Per the user decision (mask first, then rack — matches the existing De-Masking step's position between Plating and Bake; de-mask before de-rack would be illogical).
|
||||
|
||||
**b. Delete duplicate empty ENP-Alum Line sub_process** (id 4056, no children). The real one (id 3859, contains E-Nickel Plating) survives.
|
||||
|
||||
### Change 3 — Extend `fp_resolve_step_kind()`
|
||||
|
||||
In [`fusion_plating/__init__.py`](../../../fusion_plating/__init__.py):
|
||||
|
||||
**a. Add aliases to `_STARTER_KIND_BY_NAME`:**
|
||||
|
||||
```python
|
||||
# Blasting variants
|
||||
'blasting': 'blast',
|
||||
'bead blast': 'blast',
|
||||
'bead blasting': 'blast',
|
||||
'media blast': 'blast',
|
||||
'media blasting': 'blast',
|
||||
# Inspection variants the resolver didn't know
|
||||
'adhesion test coupon': 'inspect',
|
||||
'adhesion testing': 'inspect',
|
||||
'corrosion testing': 'inspect',
|
||||
'lab testing': 'inspect',
|
||||
# Strip + chemical conversion + plugging (mostly wet line)
|
||||
'strip process': 'wet_process',
|
||||
'strip process - al': 'wet_process',
|
||||
'nickel strip - aluminum line': 'wet_process',
|
||||
'chemical conversion': 'wet_process',
|
||||
'trivalent chromate conversion': 'wet_process',
|
||||
'plug the threaded holes': 'mask',
|
||||
```
|
||||
|
||||
**b. Add parenthetical stripping** to `fp_resolve_step_kind()` so `"Incoming Inspection (Standard)"`, `"Blasting (If Required)"`, `"Trivalent Chromate Conversion (A-14 / A)"` etc. resolve through their base name. Strip first, look up second, fall through to the resolver's other rules:
|
||||
|
||||
```python
|
||||
def fp_resolve_step_kind(name):
|
||||
if not name:
|
||||
return None
|
||||
key = name.strip().lower()
|
||||
if key in _STARTER_KIND_BY_NAME:
|
||||
return _STARTER_KIND_BY_NAME[key]
|
||||
# NEW: strip parenthetical suffixes — "Masking (If Required)" →
|
||||
# "Masking", "Incoming Inspection (Standard)" → "Incoming
|
||||
# Inspection".
|
||||
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
|
||||
if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
|
||||
return _STARTER_KIND_BY_NAME[bare]
|
||||
if key.startswith('ready for ') or key.startswith('ready '):
|
||||
return 'gating'
|
||||
return None
|
||||
```
|
||||
|
||||
**c. Translate resolver kinds to active `fp.step.kind.code` values.** Several resolver outputs (`cleaning`, `electroclean`, `etch`, `rinse`, `strike`, `dry`, `wbf_test`) map to kinds that are inactive in the dropdown — those should roll up to the active `wet_process` kind. Add a translation in the migration:
|
||||
|
||||
```python
|
||||
RESOLVER_KIND_TO_ACTIVE_KIND = {
|
||||
# Wet-line kinds → wet_process (active rollup)
|
||||
'cleaning': 'wet_process',
|
||||
'electroclean': 'wet_process',
|
||||
'etch': 'wet_process',
|
||||
'rinse': 'wet_process',
|
||||
'strike': 'wet_process',
|
||||
'dry': 'wet_process',
|
||||
'wbf_test': 'wet_process',
|
||||
# 1:1 mappings (kind exists and is active)
|
||||
'contract_review': 'contract_review',
|
||||
'mask': 'mask',
|
||||
'racking': 'racking',
|
||||
'plate': 'plate',
|
||||
'bake': 'bake',
|
||||
'derack': 'derack',
|
||||
'demask': 'demask',
|
||||
'inspect': 'inspect',
|
||||
'final_inspect': 'final_inspect',
|
||||
'ship': 'ship',
|
||||
'gating': 'gating',
|
||||
'blast': 'blast',
|
||||
}
|
||||
```
|
||||
|
||||
### Change 4 — Backfill `kind=other` nodes via the extended resolver
|
||||
|
||||
For every `fusion.plating.process.node` where `kind.code='other'` and `name` is set:
|
||||
- Call `fp_resolve_step_kind(name)`
|
||||
- Translate via `RESOLVER_KIND_TO_ACTIVE_KIND`
|
||||
- If a match: look up `fp.step.kind` by code, write `kind_id`
|
||||
- If no match: leave as-is (admin can pick later)
|
||||
|
||||
Idempotent — only affects nodes currently at `kind=other`.
|
||||
|
||||
### Change 5 — Auto-classify hook on `fusion.plating.process.node`
|
||||
|
||||
In [`fusion_plating/models/fp_process_node.py`](../../../fusion_plating/models/fp_process_node.py), add a post-write helper that runs after `create()` and `write()`:
|
||||
|
||||
```python
|
||||
def _fp_autoclassify_kind(self):
|
||||
"""If kind_id is 'other' AND name resolves via fp_resolve_step_kind,
|
||||
upgrade to the resolved active kind. Idempotent — never overrides
|
||||
a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify=True.
|
||||
"""
|
||||
if self.env.context.get('fp_skip_kind_autoclassify'):
|
||||
return
|
||||
from odoo.addons.fusion_plating import fp_resolve_step_kind
|
||||
Kind = self.env['fp.step.kind']
|
||||
other = Kind.search([('code', '=', 'other')], limit=1)
|
||||
if not other:
|
||||
return
|
||||
for node in self:
|
||||
if not node.name or node.kind_id != other:
|
||||
continue
|
||||
resolver_code = fp_resolve_step_kind(node.name)
|
||||
if not resolver_code:
|
||||
continue
|
||||
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
|
||||
if not target_code:
|
||||
continue
|
||||
target = Kind.search([('code', '=', target_code)], limit=1)
|
||||
if target:
|
||||
node.with_context(fp_skip_kind_autoclassify=True).write(
|
||||
{'kind_id': target.id},
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
nodes = super().create(vals_list)
|
||||
nodes._fp_autoclassify_kind()
|
||||
return nodes
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
# Only re-run autoclassify when name OR kind_id changed
|
||||
if 'name' in vals or 'kind_id' in vals:
|
||||
self._fp_autoclassify_kind()
|
||||
return res
|
||||
```
|
||||
|
||||
Two side-effects this guarantees:
|
||||
- Recipe duplication via `copy()` → after super().copy() runs, the hook fires on the new node and upgrades the kind if applicable. So future per-part clones get correct kinds even if the source was sloppy.
|
||||
- Authors typing a step name in the Simple/Tree editor → kind auto-upgrades as soon as the name is saved (provided they hadn't already picked a specific kind).
|
||||
|
||||
### Change 6 — `no_parts` cards always land in Receiving column
|
||||
|
||||
In [`fusion_plating_shopfloor/controllers/plant_kanban.py:165`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py):
|
||||
|
||||
```python
|
||||
def _resolve_card_area(job):
|
||||
"""..."""
|
||||
# NEW — Defect: no_parts cards belong in Receiving regardless of
|
||||
# active step. The receiver is who acts; the receiver works the
|
||||
# Receiving column.
|
||||
if job.card_state == 'no_parts':
|
||||
return 'receiving'
|
||||
if job.active_step_id and job.active_step_id.area_kind:
|
||||
return job.active_step_id.area_kind
|
||||
return 'receiving'
|
||||
```
|
||||
|
||||
Belt-and-suspenders so even if a job slips through with a bad area_kind or before kinds are recomputed, "no parts" cards still show where they belong.
|
||||
|
||||
### Change 7 — Unified migration
|
||||
|
||||
New file: `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py`. Runs AFTER fusion_plating's data files load (so the resolver extensions are available).
|
||||
|
||||
Phases, in order:
|
||||
|
||||
1. **Resequence recipe 3620** ops + delete duplicate empty `ENP-Alum Line` sub_process (id 4056).
|
||||
2. **Backfill `kind=other` nodes** using the extended resolver + active-kind translation. Affects ~10 nodes across recipes 3645/3689/3945/3782.
|
||||
3. **Delete the 24 clone recipes** — single DELETE on `fusion_plating_process_node` where `name ILIKE '% — %' AND node_type='recipe'`. CASCADE cleans up children; SET NULL handles job refs.
|
||||
4. **Recompute `fp.job.step.area_kind`** on all rows. After the kind-backfill + clone delete, some steps lose their `recipe_node_id` (NULL); those fall to the catch-all `'plating'`. Acceptable — those are all done/cancelled jobs.
|
||||
5. **Recompute `fp.job.active_step_id` + `card_state`** on in-flight jobs (currently 0 on entech, but defensive).
|
||||
|
||||
All phases idempotent — re-running `-u` is safe.
|
||||
|
||||
### Change 8 — Version bumps
|
||||
|
||||
| Module | From | To |
|
||||
|---|---|---|
|
||||
| `fusion_plating` | `19.0.21.2.0` | `19.0.21.3.0` (resolver + autoclassify hook + new aliases) |
|
||||
| `fusion_plating_jobs` | `19.0.10.25.0` | `19.0.10.26.0` (migration only) |
|
||||
| `fusion_plating_shopfloor` | `19.0.33.1.3` | `19.0.33.1.4` (no_parts override) |
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (explicit)
|
||||
|
||||
- **Reordering the other 6 base recipes.** Only recipe 3620 has the documented duplicate-sequence problem. The others have sane sequences and acceptable ordering.
|
||||
- **Backfilling historical jobs' `area_kind`.** All 31 historical jobs are terminal (cancelled/done). They drop off the live board so their stored area_kind is decorative.
|
||||
- **Manual kind picks for the ~5 nodes left as `other`** (e.g. `ENP-Alum Line - HP` sub_process). The resolver can't classify them reliably; admin can pick manually if needed.
|
||||
- **Removing the per-part clone path itself.** The configurator still clones recipes per-part — that's the intended flow. We're just removing existing clones; future SOs will create fresh clones from the fixed base recipes.
|
||||
- **Battle test for this fix.** The flow (SO confirm → job create → recipe clone → step gen → auto-complete → card-area resolve) is covered by manual smoke. A scripted battle test for this would duplicate significant configurator + auto-complete logic — disproportionate to the fix size.
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
### Manual smoke (after deploy)
|
||||
|
||||
1. **Confirm clones gone:**
|
||||
```sql
|
||||
SELECT COUNT(*) FROM fusion_plating_process_node
|
||||
WHERE node_type='recipe' AND name ILIKE '% — %';
|
||||
-- expected: 0
|
||||
```
|
||||
|
||||
2. **Confirm 3620 reordered:**
|
||||
```sql
|
||||
SELECT sequence, name FROM fusion_plating_process_node
|
||||
WHERE parent_id=3620 ORDER BY sequence;
|
||||
-- expected: 10=Contract Review, 20=Incoming Inspection, 30=Masking,
|
||||
-- 40=Racking, 50=Ready for processing, 60=ENP-Alum Line,
|
||||
-- 70=De-Masking, 80=Oven baking, 90=De-racking,
|
||||
-- 100=Oven bake (Post de-rack), 110=Post-plate Inspection,
|
||||
-- 120=Final Inspection
|
||||
-- NO duplicate sequences. ENP-Alum Line appears ONCE (not twice).
|
||||
```
|
||||
|
||||
3. **Confirm kinds backfilled:**
|
||||
```sql
|
||||
SELECT n.name, k.code FROM fusion_plating_process_node n
|
||||
JOIN fp_step_kind k ON k.id = n.kind_id
|
||||
WHERE k.code = 'other'
|
||||
AND n.node_type IN ('operation','step')
|
||||
ORDER BY n.name;
|
||||
-- expected: only ENP-Alum Line - HP (or similar genuinely-other
|
||||
-- nodes that resolver can't classify) — NOT Adhesion Test
|
||||
-- Coupon, Corrosion Testing, Lab Testing, Plug The Threaded
|
||||
-- Holes, etc.
|
||||
```
|
||||
|
||||
4. **End-to-end flow:**
|
||||
a. Create a new SO with a part whose default recipe is `ENP-ALUM-BASIC`.
|
||||
b. Confirm the SO.
|
||||
c. Check: the cloned recipe has Contract Review at sequence 10, Incoming Inspection at sequence 20, Masking at 30, Racking at 40.
|
||||
d. Open Shop Floor — the job card should be in the **Receiving** column (because card_state='no_parts' from the no_parts override OR because Incoming Inspection is the active step after Contract Review auto-completes).
|
||||
e. Mark Incoming Inspection done → card moves to Masking column.
|
||||
|
||||
5. **Auto-classify hook:**
|
||||
a. Open the Simple Editor on any recipe.
|
||||
b. Drop a new step, type name "Masking" (don't pick a kind).
|
||||
c. Save the recipe.
|
||||
d. Refresh the page.
|
||||
e. Confirm the kind dropdown shows "Masking" (not "Other").
|
||||
|
||||
---
|
||||
|
||||
## Roll-out
|
||||
|
||||
1. Implement Changes 1-8 in one branch.
|
||||
2. Local dev test — no local container available, so skip; verify directly on entech.
|
||||
3. Deploy to entech via the standard `pct exec 111` flow.
|
||||
4. SQL spot-checks per the test plan.
|
||||
5. Manual smoke (steps 4 + 5).
|
||||
6. Commit + push.
|
||||
|
||||
---
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `fusion_plating/__init__.py` | Extend `_STARTER_KIND_BY_NAME`, add parenthetical-strip in `fp_resolve_step_kind()` |
|
||||
| `fusion_plating/models/fp_process_node.py` | `_fp_autoclassify_kind()` helper + hooks in `create()` and `write()` |
|
||||
| `fusion_plating/__manifest__.py` | Version bump to `19.0.21.3.0` |
|
||||
| `fusion_plating_jobs/migrations/19.0.10.26.0/post-migrate.py` | NEW — 5-phase migration (Changes 2, 4, 1, recompute, recompute) |
|
||||
| `fusion_plating_jobs/__manifest__.py` | Version bump to `19.0.10.26.0` |
|
||||
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | `no_parts` → receiving override in `_resolve_card_area` |
|
||||
| `fusion_plating_shopfloor/__manifest__.py` | Version bump to `19.0.33.1.4` |
|
||||
|
||||
Estimated diff: ~250 lines added, ~20 modified.
|
||||
@@ -0,0 +1,635 @@
|
||||
# Shop Floor — Live Step + Kind/Library Cleanup
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Modules:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_shopfloor`
|
||||
**Status:** Revised after step-library audit. Awaiting implementation plan.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
All 7 jobs on entech are stuck in the **Receiving** column of the Shop Floor
|
||||
plant kanban, each tagged with a purple "📋 QA-005 review" chip, even though
|
||||
every step on every one of them is `done`. The board doesn't reflect shop
|
||||
state.
|
||||
|
||||
Investigation surfaced **four code defects**, a **structural vocabulary
|
||||
mismatch** between the user-extensible step kind taxonomy and the hardcoded
|
||||
`area_kind` mapping, **gaps in the kind taxonomy** (no `blast` kind, three
|
||||
relevant kinds inactive), and **30 step-library templates missing codes,
|
||||
descriptions, and meaningful icons**.
|
||||
|
||||
### Defect 1 — `_compute_card_state` edge case mislabels done jobs
|
||||
|
||||
[`fusion_plating_jobs/models/fp_job.py:261-267`](../../../fusion_plating_jobs/models/fp_job.py)
|
||||
|
||||
A job whose `active_step_id` is False (all steps done OR no steps at all)
|
||||
defaults to `'contract_review'` regardless of `job.state`. Done jobs get a
|
||||
QA-005 chip they don't deserve.
|
||||
|
||||
### Defect 2 — `_compute_active_step_id` is too narrow
|
||||
|
||||
[`fusion_plating_jobs/models/fp_job.py:386-391`](../../../fusion_plating_jobs/models/fp_job.py)
|
||||
|
||||
Only matches `state == 'in_progress'`. Between-step / paused / ready jobs
|
||||
have `active_step_id = False`. Combined with Defect 3, these teleport to
|
||||
Receiving.
|
||||
|
||||
### Defect 3 — column-resolve fallback is `'receiving'`
|
||||
|
||||
[`fusion_plating_shopfloor/controllers/plant_kanban.py:161-170`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py)
|
||||
|
||||
When `active_step_id` is False this fallback fires for every non-running
|
||||
job. Receiving becomes a parking lot.
|
||||
|
||||
### Defect 4 — done jobs aren't filtered off the board
|
||||
|
||||
Done + cancelled jobs stay visible forever. The 7 stuck cards on entech are
|
||||
all `state='done'` jobs that shipped weeks ago.
|
||||
|
||||
### Defect 5 (structural) — kind→area_kind vocabulary mismatch
|
||||
|
||||
`fp.step.kind` is a user-extensible taxonomy (28 records, 12 active in the
|
||||
dropdown post 2026-05-24 dedup). `kind_id` is `required=True` on both
|
||||
`fp.step.template` and `fusion.plating.process.node`, defaulting to
|
||||
`code='other'`.
|
||||
|
||||
`fp.job.step._compute_area_kind` reads `recipe_node.default_kind` (the kind
|
||||
code) through the hardcoded `_STEP_KIND_TO_AREA` dict in
|
||||
[`fp_job_step.py:25-73`](../../../fusion_plating_jobs/models/fp_job_step.py).
|
||||
|
||||
The two vocabularies overlap on **7 of 28 codes**. Adoption on entech:
|
||||
|
||||
| `kind.code` | Nodes | Mapping exists? | Falls to |
|
||||
|---|---|---|---|
|
||||
| `other` | 240 | ❌ | `'plating'` |
|
||||
| `racking` | 122 | ✅ | `'racking'` ✓ |
|
||||
| `wet_process` | 105 | ❌ | `'plating'` (lucky — wet line IS plating) |
|
||||
| `bake` | 103 | ✅ | `'baking'` ✓ |
|
||||
| `mask` | 92 | ❌ (dict has `'masking'`) | `'plating'` (wrong) |
|
||||
| `inspect` | 52 | ❌ (dict has `'inspection'`) | `'plating'` (wrong) |
|
||||
| `plate` | 35 | ❌ (dict has `'e_nickel_plate'`) | `'plating'` (lucky) |
|
||||
| `final_inspect` | 31 | ❌ (dict has `'final_inspection'`) | `'plating'` (wrong) |
|
||||
| `contract_review` | 17 | ✅ | `'receiving'` ✓ |
|
||||
| `receiving` | 16 | ✅ | `'receiving'` ✓ |
|
||||
| `ship` | 3 | ❌ (dict has `'shipping'`) | `'plating'` (wrong) |
|
||||
|
||||
The structural fix: make `area_kind` a required field on `fp.step.kind`
|
||||
itself so each kind self-declares its column.
|
||||
|
||||
### Defect 6 (taxonomy) — kinds that should exist but don't / are inactive
|
||||
|
||||
| Kind | Currently | Needed because |
|
||||
|---|---|---|
|
||||
| `blast` | Does not exist | 11 recipe nodes named "Blasting" can't be classified correctly. There's no kind that maps to the Blasting column. |
|
||||
| `derack` | Exists but `active=False` | 23+ recipe nodes named "De-racking" / "DeRacking" need their own kind for tablet routing clarity (`area_kind='de_racking'`). |
|
||||
| `demask` | Exists but `active=False` | 33 recipe nodes named "De-Masking" are misclassified as `mask` → land in Masking column. Per spec §D4 De-Masking folds into De-Racking. |
|
||||
| `gating` | Exists but `active=False` | 50+ "Ready For X" recipe nodes are unclassified gates. Without `gating` they fall back to `other` → catch-all. |
|
||||
|
||||
### Defect 7 (library) — 30 step-library templates missing metadata
|
||||
|
||||
Step Library audit (38 active templates):
|
||||
|
||||
| Field | Has it | Missing |
|
||||
|---|---|---|
|
||||
| `code` | 8 | 30 |
|
||||
| `description` | 8 | 30 |
|
||||
| Meaningful icon (not `fa-cog`) | 13 | 25 |
|
||||
| `material_callout` | 0 | 38 |
|
||||
| `process_type_id` | 0 | 38 |
|
||||
|
||||
The 8 well-formed templates (`RECV_STD`, `ELEC_CLEAN_STD`, `STRIKE_STD`, etc.)
|
||||
came from the XML data file. The remaining 30 came from
|
||||
`_seed_step_library_if_empty()` (programmatic seed from ENP-ALUM-BASIC recipe)
|
||||
without their library-management metadata.
|
||||
|
||||
Several library templates are also classified to the wrong kind. Examples:
|
||||
|
||||
| Template | Currently `kind` | Should be `kind` |
|
||||
|---|---|---|
|
||||
| Blasting | `other` | `blast` (kind we're creating) |
|
||||
| De-Masking | `mask` | `demask` (per spec §D4) |
|
||||
| Ready for Plating / Ready for processing | `plate` / `other` | `gating` |
|
||||
| Pre-Measurements / Check Sulfamate Nickel Area | `other` | `inspect` |
|
||||
| Nickel Strip / Nickel Strip - Steel Line | `plate` | `wet_process` (it's a strip, not plating) |
|
||||
|
||||
### Defect 8 (recipe nodes) — in-the-wild misclassifications
|
||||
|
||||
Once kinds are fixed and library is corrected, the EXISTING ~880 recipe
|
||||
nodes still point at the wrong kind in well-defined patterns:
|
||||
|
||||
| Pattern | Affected nodes | Re-point to |
|
||||
|---|---|---|
|
||||
| `name = 'Blasting'` AND `kind = other` | 11 | `kind = blast` |
|
||||
| `name ILIKE 'Ready %'` AND `kind != gating` | ~50+ | `kind = gating` |
|
||||
| `name ILIKE '%De-Masking%' OR '%DeMasking%'` AND `kind = mask` | 33 | `kind = demask` |
|
||||
| `name = 'Scheduling'` AND `kind = other` | 5 | `kind = gating` |
|
||||
| `name ILIKE '%Nickel Strip%'` AND `kind = plate` | ~10 | `kind = wet_process` |
|
||||
| `name ILIKE '%Pre-Measurement%' OR '%Check Sulfamate%'` AND `kind = other` | ~10 | `kind = inspect` |
|
||||
|
||||
These are auto-migratable because the patterns are unambiguous. The harder
|
||||
calls (e.g. "Post Plate Inspection" — `inspect` or `final_inspect`?) stay
|
||||
manual.
|
||||
|
||||
---
|
||||
|
||||
## Approved fix
|
||||
|
||||
### Change 1 — `_compute_active_step_id` priority chain
|
||||
|
||||
Replace the single-state filter with a priority lookup over `step_ids`
|
||||
sorted by sequence. First match wins:
|
||||
|
||||
```
|
||||
in_progress > paused > ready > first pending
|
||||
```
|
||||
|
||||
If every step is `done` (or no steps exist), returns False — handled by
|
||||
Change 2.
|
||||
|
||||
**Why this order:**
|
||||
|
||||
- `in_progress` is the most informative.
|
||||
- `paused` means someone was working and stopped; the card belongs at that station so the next operator can pick it up.
|
||||
- `ready` is the next-up step waiting on an operator.
|
||||
- The first `pending` after a `done` is the "next gate" — where the card visually waits.
|
||||
|
||||
**File:** [`fusion_plating_jobs/models/fp_job.py`](../../../fusion_plating_jobs/models/fp_job.py)
|
||||
|
||||
### Change 2 — `_compute_card_state` edge case
|
||||
|
||||
Replace the buggy "no active step → contract_review" fallback with:
|
||||
|
||||
```python
|
||||
if not job.active_step_id:
|
||||
if job.state == 'done':
|
||||
job.card_state = 'done'
|
||||
elif job._fp_inbound_not_received():
|
||||
job.card_state = 'no_parts'
|
||||
else:
|
||||
job.card_state = 'ready' # no steps yet — recipe not assigned
|
||||
continue
|
||||
```
|
||||
|
||||
**File:** [`fusion_plating_jobs/models/fp_job.py`](../../../fusion_plating_jobs/models/fp_job.py)
|
||||
|
||||
### Change 3 — Board state filter
|
||||
|
||||
Add `('state', 'in', ('confirmed', 'in_progress'))` to the `fp.job` search
|
||||
domain in `/fp/landing/plant_kanban`. Done + cancelled jobs disappear from
|
||||
the board; they remain reachable elsewhere.
|
||||
|
||||
**File:** [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py)
|
||||
|
||||
### Change 4 — Column-resolve fallback (comment only)
|
||||
|
||||
`_resolve_card_area`'s `'receiving'` fallback stays but updates inline
|
||||
comment to explain the new semantics (truly orphaned cards only).
|
||||
|
||||
**File:** [`fusion_plating_shopfloor/controllers/plant_kanban.py`](../../../fusion_plating_shopfloor/controllers/plant_kanban.py)
|
||||
|
||||
### Change 5 — `fp.step.kind.area_kind` field (structural)
|
||||
|
||||
Add a required Selection field to `fp.step.kind`. Each kind self-declares
|
||||
which plant-view column its steps belong in.
|
||||
|
||||
```python
|
||||
area_kind = fields.Selection(
|
||||
[
|
||||
('receiving', 'Receiving'),
|
||||
('masking', 'Masking'),
|
||||
('blasting', 'Blasting'),
|
||||
('racking', 'Racking'),
|
||||
('plating', 'Plating'),
|
||||
('baking', 'Baking'),
|
||||
('de_racking', 'De-Racking'),
|
||||
('inspection', 'Final Inspection'),
|
||||
('shipping', 'Shipping'),
|
||||
],
|
||||
string='Shop Floor Column',
|
||||
required=True,
|
||||
index=True,
|
||||
tracking=True,
|
||||
help='Determines which column on the Shop Floor plant kanban shows '
|
||||
'cards whose active step uses this kind.',
|
||||
)
|
||||
```
|
||||
|
||||
**File:** [`fusion_plating/models/fp_step_kind.py`](../../../fusion_plating/models/fp_step_kind.py)
|
||||
|
||||
### Change 6 — `_compute_area_kind` priority chain
|
||||
|
||||
Simplify `fp.job.step._compute_area_kind`:
|
||||
|
||||
```python
|
||||
@api.depends(
|
||||
'work_centre_id.area_kind',
|
||||
'recipe_node_id.kind_id.area_kind',
|
||||
)
|
||||
def _compute_area_kind(self):
|
||||
for step in self:
|
||||
# 1. work_centre.area_kind (explicit operator setup)
|
||||
if step.work_centre_id and step.work_centre_id.area_kind:
|
||||
step.area_kind = step.work_centre_id.area_kind
|
||||
continue
|
||||
# 2. recipe_node.kind_id.area_kind (kind taxonomy is authoritative)
|
||||
node = step.recipe_node_id
|
||||
if node and node.kind_id and node.kind_id.area_kind:
|
||||
step.area_kind = node.kind_id.area_kind
|
||||
continue
|
||||
# 3. Catch-all — data integrity issue if we land here
|
||||
step.area_kind = 'plating'
|
||||
```
|
||||
|
||||
The legacy `_STEP_KIND_TO_AREA` dict is deleted.
|
||||
|
||||
**File:** [`fusion_plating_jobs/models/fp_job_step.py`](../../../fusion_plating_jobs/models/fp_job_step.py)
|
||||
|
||||
### Change 7 — Step Kind UI surfaces `area_kind`
|
||||
|
||||
- **Form view** ([`fp_step_kind_views.xml`](../../../fusion_plating/views/fp_step_kind_views.xml)) — add `area_kind` as a prominent picker next to `code` + `name`, with a help-text inline ("Cards whose active step uses this kind appear in this column on the Shop Floor board").
|
||||
- **List view** — add `area_kind` as a chip column.
|
||||
- **Simple Editor kind picker** ([`simple_recipe_editor.xml:506-522`](../../../fusion_plating/static/src/xml/simple_recipe_editor.xml)) — option label becomes "Masking — Masking column" so authors see the routing at pick time. Requires updating `kindOptions` payload in [`simple_recipe_controller.py`](../../../fusion_plating/controllers/simple_recipe_controller.py) to include `area_kind` + a human-readable column label per kind.
|
||||
|
||||
### Change 8 — Step Kind taxonomy expansion (Cat A)
|
||||
|
||||
XML data file additions / updates in
|
||||
[`fusion_plating/data/fp_step_kind_data.xml`](../../../fusion_plating/data/fp_step_kind_data.xml):
|
||||
|
||||
```xml
|
||||
<!-- NEW: Blasting kind -->
|
||||
<record id="step_kind_blast" model="fp.step.kind">
|
||||
<field name="code">blast</field>
|
||||
<field name="name">Blasting / Media Blast</field>
|
||||
<field name="sequence">35</field>
|
||||
<field name="icon">fa-bullseye</field>
|
||||
<field name="area_kind">blasting</field>
|
||||
</record>
|
||||
|
||||
<!-- Activate existing kinds + set area_kind. The records already exist
|
||||
from 19.0.20.6.0 with active=False; here we flip + classify.
|
||||
noupdate=1 protects user edits, so use a one-shot migration to
|
||||
do the flip on existing installs (Change 10). -->
|
||||
```
|
||||
|
||||
Migration (Change 10) handles the flip on existing installs since the data
|
||||
file has `noupdate="1"`:
|
||||
|
||||
```python
|
||||
# Activate kinds that were dropped in 19.0.20.6.0 but are needed
|
||||
# for the area_kind taxonomy to be complete.
|
||||
for code, area in (
|
||||
('derack', 'de_racking'),
|
||||
('demask', 'de_racking'),
|
||||
('gating', 'receiving'),
|
||||
):
|
||||
cr.execute("""
|
||||
UPDATE fp_step_kind
|
||||
SET active = TRUE, area_kind = %s
|
||||
WHERE code = %s AND active = FALSE
|
||||
""", (area, code))
|
||||
```
|
||||
|
||||
### Change 9 — Step Template metadata backfill + additions (Cat B)
|
||||
|
||||
Migration backfills metadata on the 30 templates seeded without it.
|
||||
Idempotent — only fills NULL/empty fields, doesn't overwrite human edits.
|
||||
|
||||
```python
|
||||
TEMPLATE_BACKFILL = {
|
||||
# name : (code, icon, kind_code, description_snippet)
|
||||
'Acid Dip': ('ACID_DIP_STD', 'fa-flask', 'wet_process', 'Short acid immersion to activate the substrate before plating.'),
|
||||
'Air Dry': ('AIR_DRY_STD', 'fa-sun-o', 'wet_process', 'Air drying step between wet-line operations.'),
|
||||
'Bake': ('BAKE_STD', 'fa-fire', 'bake', 'Post-plate bake for hydrogen embrittlement relief.'),
|
||||
'Blasting': ('BLAST_STD', 'fa-bullseye', 'blast', 'Media or bead blasting to prepare the substrate.'),
|
||||
'Check Sulfamate Nickel Area': ('CHECK_SN_AREA', 'fa-search', 'inspect', 'Quick visual area check on the sulfamate nickel line.'),
|
||||
'Contract Review': ('CR_STD', 'fa-file-text-o', 'contract_review', 'QA-005 contract review gate. Required when the customer flag is on.'),
|
||||
'De-Masking': ('DEMASK_STD', 'fa-eraser', 'demask', 'Remove masking material after plating. Folds into De-Racking column.'),
|
||||
'DeRacking': ('DERACK_STD', 'fa-th', 'derack', 'Remove parts from racks for inspection / packaging.'),
|
||||
'Desmut': ('DESMUT_STD', 'fa-flask', 'wet_process', 'Remove smut from aluminium surfaces after etching.'),
|
||||
'Drying': ('DRYING_STD', 'fa-sun-o', 'wet_process', 'Drying step (oven or air) at the end of the wet line.'),
|
||||
'E-Nickel Plating': ('ENP_STD', 'fa-diamond', 'plate', 'Electroless nickel plate operation. Time and temp per recipe.'),
|
||||
'Electroclean': ('ECLEAN_STD', 'fa-bolt', 'wet_process', 'Anodic / cathodic electrocleaning step on the cleaning line.'),
|
||||
'Etch': ('ETCH_STD', 'fa-flask', 'wet_process', 'Chemical etching to prepare the substrate.'),
|
||||
'Final Inspection': ('FINAL_INSP_STD','fa-check-circle','final_inspect','Final visual + dimensional QA before packing.'),
|
||||
'HCl Activation': ('HCL_ACT_STD', 'fa-flask', 'wet_process', 'HCl activation dip prior to strike or plate.'),
|
||||
'Inspection': ('INSP_STD', 'fa-search', 'inspect', 'In-process inspection step.'),
|
||||
'Masking': ('MASK_STD', 'fa-paint-brush', 'mask', 'Apply masking to areas that should not be plated.'),
|
||||
'Nickel Strip (S-1)': ('NI_STRIP_S1', 'fa-undo', 'wet_process', 'Chemical strip of prior nickel deposit (rework path).'),
|
||||
'Nickel Strip - Steel Line': ('NI_STRIP_SL','fa-undo', 'wet_process', 'Chemical strip on the steel line (rework path).'),
|
||||
'Post-plate Inspection': ('POST_INSP_STD', 'fa-check-circle','inspect', 'Post-plate inspection — thickness sample + visual.'),
|
||||
'Pre-Measurements': ('PRE_MEAS_STD', 'fa-tachometer', 'inspect', 'Pre-process dimensional measurements (FAIR start point).'),
|
||||
'Racking': ('RACK_STD', 'fa-th', 'racking', 'Load parts onto racks for plating.'),
|
||||
'Ready for Plating': ('GATE_PLATE', 'fa-flag', 'gating', 'Gating step — parts staged ready for the plating line.'),
|
||||
'Ready for processing': ('GATE_PROC', 'fa-flag', 'gating', 'Generic gating step — parts staged ready for the next operation.'),
|
||||
'Rinse': ('RINSE_STD', 'fa-tint', 'wet_process', 'Rinse step between wet-line operations.'),
|
||||
'Shipping': ('SHIP_STD', 'fa-paper-plane', 'ship', 'Final shipping / hand-off to logistics.'),
|
||||
'Soak Clean': ('SOAK_CLEAN_STD','fa-bathtub', 'wet_process', 'Soak cleaning step at the start of the wet line.'),
|
||||
'Surface Activation': ('SURF_ACT_STD', 'fa-flask', 'wet_process', 'Surface activation dip prior to plate.'),
|
||||
'Water Break Test': ('WBF_TEST_STD', 'fa-tint', 'wet_process', 'Water-break test for surface cleanliness.'),
|
||||
'Zincate': ('ZINCATE_STD', 'fa-flask', 'wet_process', 'Zincate immersion on aluminium prior to plate.'),
|
||||
}
|
||||
```
|
||||
|
||||
New templates (XML data file additions, `noupdate="1"`):
|
||||
|
||||
| Name | Code | Kind | Why add |
|
||||
|---|---|---|---|
|
||||
| `Hot Water Porosity Test (A-15)` | `HWP_A15` | `inspect` | 7 recipe nodes use it — should be in the library |
|
||||
| `Final Inspection / Packaging` | `FINAL_PKG_STD` | `final_inspect` | 3 recipe nodes use it; library has separate inspection + packaging but not the combined one |
|
||||
|
||||
**Files:**
|
||||
- [`fusion_plating/data/fp_step_template_data.xml`](../../../fusion_plating/data/fp_step_template_data.xml) — 2 new template records
|
||||
- Migration (Change 10) — TEMPLATE_BACKFILL loop, idempotent
|
||||
|
||||
### Change 10 — Unified migration
|
||||
|
||||
New file: [`fusion_plating/migrations/19.0.21.2.0/pre-migrate.py`](../../../fusion_plating/migrations/19.0.21.2.0/pre-migrate.py)
|
||||
|
||||
Pre-migrate runs BEFORE the `area_kind NOT NULL` constraint hits the
|
||||
schema, so it fills values first.
|
||||
|
||||
```python
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
KIND_TO_AREA = {
|
||||
'other': 'plating', # catch-all default
|
||||
'wet_process': 'plating',
|
||||
'receiving': 'receiving',
|
||||
'contract_review':'receiving',
|
||||
'gating': 'receiving',
|
||||
'racking': 'racking',
|
||||
'derack': 'de_racking',
|
||||
'mask': 'masking',
|
||||
'demask': 'de_racking', # spec §D4
|
||||
'cleaning': 'plating',
|
||||
'electroclean': 'plating',
|
||||
'etch': 'plating',
|
||||
'rinse': 'plating',
|
||||
'strike': 'plating',
|
||||
'plate': 'plating',
|
||||
'replenishment': 'plating',
|
||||
'wbf_test': 'plating',
|
||||
'dry': 'plating',
|
||||
'bake': 'baking',
|
||||
'inspect': 'inspection',
|
||||
'final_inspect': 'inspection',
|
||||
'hardness_test': 'inspection',
|
||||
'adhesion_test': 'inspection',
|
||||
'salt_spray': 'inspection',
|
||||
'packaging': 'shipping',
|
||||
'ship': 'shipping',
|
||||
'blast': 'blasting',
|
||||
'bead_blast': 'blasting',
|
||||
'media_blast': 'blasting',
|
||||
}
|
||||
|
||||
def migrate(cr, version):
|
||||
# Phase 1 — seed area_kind on existing kinds BEFORE NOT NULL hits.
|
||||
for code, area in KIND_TO_AREA.items():
|
||||
cr.execute("""
|
||||
UPDATE fp_step_kind SET area_kind = %s
|
||||
WHERE code = %s AND (area_kind IS NULL OR area_kind = '')
|
||||
""", (area, code))
|
||||
# Anything still NULL: default to 'plating' to clear the constraint.
|
||||
cr.execute("""
|
||||
UPDATE fp_step_kind SET area_kind = 'plating'
|
||||
WHERE area_kind IS NULL OR area_kind = ''
|
||||
""")
|
||||
_logger.info('[live-step-fix] kind.area_kind seeded')
|
||||
|
||||
# Phase 2 — activate the three inactive kinds we need (Cat A).
|
||||
for code in ('derack', 'demask', 'gating'):
|
||||
cr.execute("""
|
||||
UPDATE fp_step_kind SET active = TRUE
|
||||
WHERE code = %s AND active = FALSE
|
||||
""", (code,))
|
||||
_logger.info('[live-step-fix] derack/demask/gating activated')
|
||||
```
|
||||
|
||||
New file: [`fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py`](../../../fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py)
|
||||
|
||||
Post-migrate runs AFTER schema sync, so all fields exist with values.
|
||||
|
||||
```python
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Library template metadata backfill — copied from spec Change 9.
|
||||
TEMPLATE_BACKFILL = { ... } # full dict per Change 9
|
||||
|
||||
# Recipe node patterns to repoint (Cat C).
|
||||
NODE_REPOINTING = [
|
||||
# (name_filter_sql, current_kind_code, new_kind_code, description)
|
||||
("name = 'Blasting'", 'other', 'blast', 'Blasting → blast'),
|
||||
("name ILIKE 'Ready %%'", None, 'gating', 'Ready For X → gating'),
|
||||
("name ILIKE '%%De-Masking%%' OR name ILIKE '%%DeMasking%%'", 'mask', 'demask', 'De-Masking → demask'),
|
||||
("name = 'Scheduling'", 'other', 'gating', 'Scheduling → gating'),
|
||||
("name ILIKE '%%Nickel Strip%%'", 'plate', 'wet_process', 'Nickel Strip → wet_process'),
|
||||
("name ILIKE '%%Pre-Measurement%%' OR name ILIKE '%%Check Sulfamate%%'", 'other', 'inspect', 'Pre-Meas/Check Sulfamate → inspect'),
|
||||
]
|
||||
|
||||
def migrate(cr, version):
|
||||
from odoo.api import Environment, SUPERUSER_ID
|
||||
env = Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
# Phase 1 — template metadata backfill (Cat B). Idempotent.
|
||||
Tpl = env['fp.step.template']
|
||||
Kind = env['fp.step.kind']
|
||||
fixed = 0
|
||||
for name, (code, icon, kind_code, desc) in TEMPLATE_BACKFILL.items():
|
||||
tpl = Tpl.search([('name', '=', name)], limit=1)
|
||||
if not tpl:
|
||||
continue
|
||||
vals = {}
|
||||
if not tpl.code:
|
||||
vals['code'] = code
|
||||
if not tpl.description or tpl.description in ('', '<p><br></p>'):
|
||||
vals['description'] = f'<p>{desc}</p>'
|
||||
if tpl.icon == 'fa-cog':
|
||||
vals['icon'] = icon
|
||||
kind = Kind.search([('code', '=', kind_code)], limit=1)
|
||||
if kind and tpl.kind_id.code != kind_code:
|
||||
vals['kind_id'] = kind.id
|
||||
if vals:
|
||||
tpl.write(vals)
|
||||
fixed += 1
|
||||
_logger.info('[live-step-fix] template backfill: %s templates updated', fixed)
|
||||
|
||||
# Phase 2 — recipe node repointing (Cat C). Pattern-driven SQL.
|
||||
for filter_sql, cur_code, new_code, desc in NODE_REPOINTING:
|
||||
params = []
|
||||
sql = f"""
|
||||
UPDATE fusion_plating_process_node n
|
||||
SET kind_id = (SELECT id FROM fp_step_kind WHERE code = %s LIMIT 1)
|
||||
FROM fp_step_kind k
|
||||
WHERE n.kind_id = k.id
|
||||
AND ({filter_sql})
|
||||
"""
|
||||
params.append(new_code)
|
||||
if cur_code is not None:
|
||||
sql += " AND k.code = %s"
|
||||
params.append(cur_code)
|
||||
sql += " AND k.code != %s"
|
||||
params.append(new_code)
|
||||
cr.execute(sql, params)
|
||||
_logger.info('[live-step-fix] repointed %s nodes: %s',
|
||||
cr.rowcount, desc)
|
||||
|
||||
# Phase 3 — recompute area_kind on all fp.job.step rows.
|
||||
steps = env['fp.job.step'].search([])
|
||||
steps._compute_area_kind()
|
||||
steps.flush_recordset(['area_kind'])
|
||||
_logger.info('[live-step-fix] recomputed area_kind on %s steps', len(steps))
|
||||
|
||||
# Phase 4 — recompute active_step_id + card_state on in-flight jobs.
|
||||
jobs = env['fp.job'].search([('state', 'in', ('confirmed', 'in_progress'))])
|
||||
jobs._compute_active_step_id()
|
||||
jobs._compute_card_state()
|
||||
jobs.flush_recordset(['active_step_id', 'card_state'])
|
||||
_logger.info('[live-step-fix] recomputed jobs: %s', len(jobs))
|
||||
```
|
||||
|
||||
Idempotent across the board: phase 1 only fills NULLs / fa-cog defaults;
|
||||
phase 2 includes `AND k.code != %s` so re-running won't re-do already
|
||||
correct rows; phases 3-4 are pure recomputes.
|
||||
|
||||
### Change 11 — Version bumps
|
||||
|
||||
| Module | From | To |
|
||||
|---|---|---|
|
||||
| `fusion_plating` | `19.0.21.1.3` | `19.0.21.2.0` (schema change on fp.step.kind + data file additions) |
|
||||
| `fusion_plating_jobs` | `19.0.10.23.0` | `19.0.10.24.0` (compute change + migration) |
|
||||
| `fusion_plating_shopfloor` | `19.0.33.1.2` | `19.0.33.1.3` (controller filter + comment) |
|
||||
|
||||
---
|
||||
|
||||
## What this approach replaces
|
||||
|
||||
| Dropped from the original (pre-restructure) spec | Why |
|
||||
|---|---|
|
||||
| `_RESOLVER_KIND_TO_AREA` translation dict | Kind self-declares its column — no translation needed |
|
||||
| `_resolve_area_kind_from_name` helper | Kind taxonomy is authoritative; name resolution is unnecessary |
|
||||
| `_STARTER_KIND_BY_NAME` extensions for column routing | The starter resolver is for `default_kind` seeding (Sub 12a library), not column routing — stays as-is for that purpose |
|
||||
| Parenthetical stripping regex | Not needed when we read the kind directly |
|
||||
| Backfill of `default_kind` on existing recipe nodes via name resolver | Recipe nodes already have `kind_id` populated by 19.0.20.6.0 pre-migrate |
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
### Manual smoke (on entech after deploy)
|
||||
|
||||
1. Open Shop Floor tablet/desktop — confirm the 7 done jobs are GONE from the board.
|
||||
2. Plating → Configuration → Recipes & Steps → **Step Kind catalog** — confirm:
|
||||
- `blast` exists, active, area_kind=`blasting`
|
||||
- `derack`, `demask`, `gating` are now `active=True`, area_kinds correct
|
||||
- Every kind has area_kind set
|
||||
3. Plating → Configuration → Recipes & Steps → **Step Library** — confirm:
|
||||
- All 38 templates now have a code, description, meaningful icon
|
||||
- `Hot Water Porosity Test (A-15)` and `Final Inspection / Packaging` are listed
|
||||
- "Blasting" is `kind=blast`, "De-Masking" is `kind=demask`, "Ready for ..." are `kind=gating`
|
||||
4. Open the Simple Recipe Editor; click "+ Add new kind" — confirm area_kind picker is visible/required in the inline-create flow.
|
||||
5. Create a fresh test job from any recipe (e.g. ENP-ALUM-BASIC):
|
||||
a. Confirm it lands in Receiving column with `card_state='ready'`.
|
||||
b. Walk through all steps — confirm column transitions follow area_kind sequence.
|
||||
c. Mark job done → confirm card drops off the board.
|
||||
6. Verify a step with `state='paused'` keeps the card at its column.
|
||||
|
||||
### Spot-check existing data
|
||||
|
||||
```sql
|
||||
-- Every node should have a kind with area_kind set.
|
||||
SELECT n.id, n.name, k.code, k.area_kind
|
||||
FROM fusion_plating_process_node n
|
||||
JOIN fp_step_kind k ON k.id = n.kind_id
|
||||
WHERE k.area_kind IS NULL OR k.area_kind = '';
|
||||
-- expected: 0 rows
|
||||
|
||||
-- Blasting nodes should now use blast kind.
|
||||
SELECT k.code, COUNT(*) FROM fusion_plating_process_node n
|
||||
JOIN fp_step_kind k ON k.id = n.kind_id
|
||||
WHERE n.name = 'Blasting' GROUP BY k.code;
|
||||
-- expected: all rows have k.code = 'blast'
|
||||
|
||||
-- Ready For X gating nodes.
|
||||
SELECT k.code, COUNT(*) FROM fusion_plating_process_node n
|
||||
JOIN fp_step_kind k ON k.id = n.kind_id
|
||||
WHERE n.name ILIKE 'Ready %' GROUP BY k.code;
|
||||
-- expected: all rows have k.code = 'gating'
|
||||
|
||||
-- De-Masking nodes use demask.
|
||||
SELECT k.code, COUNT(*) FROM fusion_plating_process_node n
|
||||
JOIN fp_step_kind k ON k.id = n.kind_id
|
||||
WHERE n.name ILIKE '%De-Masking%' OR n.name ILIKE '%DeMasking%'
|
||||
GROUP BY k.code;
|
||||
-- expected: all rows have k.code = 'demask'
|
||||
|
||||
-- Template code coverage.
|
||||
SELECT COUNT(*) FROM fp_step_template
|
||||
WHERE active = TRUE AND (code IS NULL OR code = '');
|
||||
-- expected: 0
|
||||
```
|
||||
|
||||
### Automated battle test
|
||||
|
||||
New script: `fusion_plating_quality/scripts/bt_s24_between_steps.py` covering
|
||||
the live-step priority chain end-to-end (see prior version of the spec for
|
||||
full pseudocode — unchanged).
|
||||
|
||||
### Existing tests
|
||||
|
||||
Existing tests in `fusion_plating_shopfloor/tests/` and
|
||||
`fusion_plating_jobs/tests/` may need updates for:
|
||||
- The new `state` filter in `/fp/landing/plant_kanban`.
|
||||
- The new `active_step_id` priority chain.
|
||||
|
||||
Re-run all `bt_s*.py` scripts to confirm no regressions in S1-S23.
|
||||
|
||||
---
|
||||
|
||||
## Roll-out
|
||||
|
||||
1. Implement Changes 1-11 in a single branch.
|
||||
2. Local dev test (`docker exec odoo-dev-app odoo -d fusion-dev -u fusion_plating,fusion_plating_jobs,fusion_plating_shopfloor --stop-after-init`).
|
||||
3. Deploy to entech using the standard `pct exec 111` flow. Pre-migrate seeds run automatically.
|
||||
4. Verify on entech with manual smoke + SQL spot-checks.
|
||||
5. Commit + push to GitHub.
|
||||
|
||||
---
|
||||
|
||||
## Non-goals (explicit)
|
||||
|
||||
- **Re-assigning historical steps to `work_centre_id`.** The 85+ steps with NULL `work_centre_id` stay that way. The kind→area_kind lookup gives them correct `area_kind` without needing a work_centre.
|
||||
- **Recipe authoring UX changes** beyond the kind picker hint. Required-field enforcement on `kind_id` already exists.
|
||||
- **Removing the "Other" kind.** Stays as a catch-all default mapped to `'plating'`.
|
||||
- **Card_state precedence rework.** Rules 1-13 stay; only the edge-case fallback changes.
|
||||
- **Mini-timeline rendering.** Separate compute (`mini_timeline_json`), out of scope.
|
||||
- **Hidden-but-recent done jobs.** No "recent shipments" filter.
|
||||
- **Subjective node re-classification.** "Post Plate Inspection" stays whatever the recipe author picked (`inspect` vs `final_inspect`). Only the unambiguous patterns in Change 10 phase 2 are auto-migrated.
|
||||
- **process_type_id / material_callout backfill on templates.** Out of scope for this spec — those need recipe-author input per template.
|
||||
|
||||
---
|
||||
|
||||
## Files touched (summary)
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `fusion_plating/models/fp_step_kind.py` | New `area_kind` Selection field (Change 5) |
|
||||
| `fusion_plating/views/fp_step_kind_views.xml` | Add area_kind to form + list (Change 7) |
|
||||
| `fusion_plating/controllers/simple_recipe_controller.py` | Include area_kind + label in kindOptions (Change 7) |
|
||||
| `fusion_plating/static/src/xml/simple_recipe_editor.xml` | Kind picker shows "→ Column" suffix (Change 7) |
|
||||
| `fusion_plating/data/fp_step_kind_data.xml` | New `step_kind_blast` record (Change 8) |
|
||||
| `fusion_plating/data/fp_step_template_data.xml` | New `Hot Water Porosity Test` + `Final Inspection / Packaging` templates (Change 9) |
|
||||
| `fusion_plating/migrations/19.0.21.2.0/pre-migrate.py` | NEW — seed area_kind, activate kinds (Change 10 phase 1-2) |
|
||||
| `fusion_plating/__manifest__.py` | Version bump (Change 11) |
|
||||
| `fusion_plating_jobs/models/fp_job.py` | Rewrite `_compute_active_step_id` (Change 1) + `_compute_card_state` edge case (Change 2) |
|
||||
| `fusion_plating_jobs/models/fp_job_step.py` | Simplify `_compute_area_kind` (Change 6); drop `_STEP_KIND_TO_AREA` dict |
|
||||
| `fusion_plating_jobs/migrations/19.0.10.24.0/post-migrate.py` | NEW — template backfill + node repointing + recomputes (Change 10 phase 1-4) |
|
||||
| `fusion_plating_jobs/__manifest__.py` | Version bump (Change 11) |
|
||||
| `fusion_plating_shopfloor/controllers/plant_kanban.py` | Add state filter (Change 3) + comment (Change 4) |
|
||||
| `fusion_plating_shopfloor/__manifest__.py` | Version bump (Change 11) |
|
||||
| `fusion_plating_quality/scripts/bt_s24_between_steps.py` | NEW — battle test |
|
||||
|
||||
Estimated diff: ~400 lines added (most in the migration data tables), ~30 modified, ~50 deleted (the `_STEP_KIND_TO_AREA` dict goes away).
|
||||
@@ -0,0 +1,425 @@
|
||||
# Job Workspace — Per-Kind Step Actions
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Modules:** `fusion_plating_jobs`, `fusion_plating_shopfloor`
|
||||
**Status:** Approved, awaiting implementation plan.
|
||||
**Sub-project:** A of 2. Sub-B (Record Inputs tablet polish — `inputmode`, prefill,
|
||||
date/time pickers, signature pad, camera) is brainstormed but DEFERRED.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Operator opens WO-30057 in the Job Workspace tablet view. Step 1 (Contract Review)
|
||||
shows ✓ (auto-completed from prior QA-005). Steps 2-12 each show only a bare
|
||||
`○ Step N <name>` row — **no Start button, no action of any kind**. The operator
|
||||
has no way to advance the job from this screen, even though every step is
|
||||
`state='ready'` and `can_start=True` on the backend.
|
||||
|
||||
### Root cause
|
||||
|
||||
In [`job_workspace.xml:105`](../../../fusion_plating_shopfloor/static/src/xml/job_workspace.xml),
|
||||
the expanded step-detail block is gated:
|
||||
|
||||
```xml
|
||||
<t t-if="isStepActive(step) or step.blocker_kind !== 'none' or step.override_excluded">
|
||||
<div class="o_fp_ws_step_detail">
|
||||
...
|
||||
<!-- Start button is INSIDE this parent gate -->
|
||||
<div t-if="step.can_start and !isStepActive(step) and step.blocker_kind === 'none'">
|
||||
<button t-on-click="() => this.onStartStep(step.id)">Start</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
```
|
||||
|
||||
`isStepActive` returns true only when `step.state === 'in_progress'`. For a
|
||||
`state='ready'` step with no blocker, the parent `<t t-if>` is false — the whole
|
||||
detail block (incl. the inner Start button) never renders. **Dead code.**
|
||||
|
||||
### Secondary gaps
|
||||
|
||||
Even if Start were reachable, certain step kinds need different actions, not a
|
||||
generic Start/Finish chain:
|
||||
|
||||
| Kind | Today (broken) | What it actually needs |
|
||||
|---|---|---|
|
||||
| `contract_review` | Hidden Start button | **Open QA-005 Form** button (uses existing `_fp_open_contract_review`) |
|
||||
| `gating` | Hidden Start, then operator clicks Finish too | **1-click "Mark Passed"** (no work to do — it's an admin gate) |
|
||||
| `requires_rack_assignment=True` | Hidden Start | Start should open the **Rack Parts** dialog first |
|
||||
| `state='paused'` | Hidden Start | Should show **Resume** + Finish + Record Inputs |
|
||||
| all kinds, `state='in_progress'` | Shows Finish, Record Inputs | Missing a **Pause** button |
|
||||
|
||||
### Operator can't see what's coming
|
||||
|
||||
The recipe-author info (thickness target, dwell time, bake temp, sign-off
|
||||
required) currently only renders on the active step. Operators can't read ahead
|
||||
to know what they're about to start. CLAUDE.md S20 "Tablet usability pass" called
|
||||
this out for the per-step kanban; the same gap exists in the Job Workspace.
|
||||
|
||||
---
|
||||
|
||||
## Approved fix
|
||||
|
||||
### Change 1 — Template restructure
|
||||
|
||||
Replace the parent-gated detail block in [`job_workspace.xml:88-170`](../../../fusion_plating_shopfloor/static/src/xml/job_workspace.xml)
|
||||
with three independent rendering layers per step:
|
||||
|
||||
```xml
|
||||
<div t-att-class="...">
|
||||
<!-- [ALWAYS] Line 1: icon + step# + name + meta -->
|
||||
<div class="o_fp_ws_step_l1">
|
||||
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
|
||||
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
|
||||
<span class="o_fp_ws_step_name" t-esc="step.name"/>
|
||||
<span t-if="step.state === 'in_progress'" class="o_fp_ws_step_badge">ACTIVE</span>
|
||||
<span t-if="step.state === 'paused'" class="o_fp_ws_step_badge o_fp_ws_step_badge_paused">PAUSED</span>
|
||||
<span class="o_fp_ws_step_meta">...assignee, duration...</span>
|
||||
</div>
|
||||
|
||||
<!-- [NON-TERMINAL] Read-ahead detail: chips + instructions + GateViz -->
|
||||
<div class="o_fp_ws_step_detail"
|
||||
t-if="step.state not in ('done', 'skipped', 'cancelled')">
|
||||
<!-- chips (thickness/dwell/bake/signoff) -->
|
||||
<div class="o_fp_ws_step_chips">
|
||||
<span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">🎯 Thickness ...</span>
|
||||
<span t-if="step.dwell_time_minutes" class="o_fp_chip o_fp_chip_info">⏱ Dwell ...</span>
|
||||
<span t-if="step.bake_setpoint_temp" class="o_fp_chip o_fp_chip_warning">🔥 Bake ...°</span>
|
||||
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">✎ Sign-off</span>
|
||||
</div>
|
||||
|
||||
<!-- recipe instructions -->
|
||||
<div t-if="step.instructions" class="o_fp_ws_step_instr"><t t-esc="step.instructions"/></div>
|
||||
|
||||
<!-- opt-out -->
|
||||
<div t-if="step.override_excluded" class="o_fp_ws_step_excluded">
|
||||
<i class="fa fa-ban"/> Skipped per recipe override
|
||||
</div>
|
||||
|
||||
<!-- blocker viz -->
|
||||
<GateViz t-if="step.blocker_kind !== 'none'"
|
||||
canStart="false"
|
||||
blockerKind="step.blocker_kind"
|
||||
blockerReason="step.blocker_reason"
|
||||
jumpTargetModel="step.blocker_jump_target_model"
|
||||
jumpTargetId="step.blocker_jump_target_id"
|
||||
onJump.bind="onJumpToBlocker"/>
|
||||
</div>
|
||||
|
||||
<!-- [ACTIONABLE] Action row — per-kind buttons per the dispatcher -->
|
||||
<div class="o_fp_ws_step_actions"
|
||||
t-if="!step.override_excluded
|
||||
and step.blocker_kind === 'none'
|
||||
and step.state not in ('done', 'skipped', 'cancelled')">
|
||||
<t t-foreach="getStepActions(step)" t-as="action" t-key="action.key">
|
||||
<button t-att-class="action.cssClass"
|
||||
t-on-click="() => this.dispatchStepAction(step, action.key)">
|
||||
<i t-att-class="action.icon"/> <t t-esc="action.label"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Keep the existing `isStepActive(step)` helper for the ACTIVE badge but **don't**
|
||||
let it gate the detail block.
|
||||
|
||||
### Change 2 — `getStepActions(step)` per-kind dispatcher
|
||||
|
||||
New JS helper in [`job_workspace.js`](../../../fusion_plating_shopfloor/static/src/js/job_workspace.js).
|
||||
Returns an array of action descriptors based on `step.state` + `step.kind` +
|
||||
`step.requires_rack_assignment` + `step.requires_signoff`:
|
||||
|
||||
```js
|
||||
getStepActions(step) {
|
||||
// Done/skipped/cancelled → no actions (caller already hides)
|
||||
if (['done', 'skipped', 'cancelled'].includes(step.state)) return [];
|
||||
// Blocked → no actions (caller already shows GateViz)
|
||||
if (step.blocker_kind && step.blocker_kind !== 'none') return [];
|
||||
if (step.override_excluded) return [];
|
||||
|
||||
const actions = [];
|
||||
if (step.state === 'in_progress') {
|
||||
actions.push({ key: 'record_inputs', label: 'Record Inputs', icon: 'fa fa-pencil', cssClass: 'btn btn-secondary' });
|
||||
actions.push({ key: 'pause', label: 'Pause', icon: 'fa fa-pause', cssClass: 'btn btn-light' });
|
||||
actions.push({
|
||||
key: 'finish',
|
||||
label: step.requires_signoff ? 'Finish & Sign Off' : 'Finish',
|
||||
icon: 'fa fa-check', cssClass: 'btn btn-success'
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
if (step.state === 'paused') {
|
||||
actions.push({ key: 'resume', label: 'Resume', icon: 'fa fa-play', cssClass: 'btn btn-primary' });
|
||||
actions.push({ key: 'record_inputs', label: 'Record Inputs', icon: 'fa fa-pencil', cssClass: 'btn btn-secondary' });
|
||||
actions.push({
|
||||
key: 'finish',
|
||||
label: step.requires_signoff ? 'Finish & Sign Off' : 'Finish',
|
||||
icon: 'fa fa-check', cssClass: 'btn btn-success'
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
// state in ('pending', 'ready') — entry-point per kind
|
||||
if (step.kind === 'contract_review') {
|
||||
actions.push({ key: 'open_contract_review', label: 'Open QA-005 Form',
|
||||
icon: 'fa fa-file-text-o', cssClass: 'btn btn-primary' });
|
||||
return actions;
|
||||
}
|
||||
if (step.kind === 'gating') {
|
||||
actions.push({ key: 'mark_passed', label: 'Mark Passed',
|
||||
icon: 'fa fa-check-circle', cssClass: 'btn btn-success' });
|
||||
return actions;
|
||||
}
|
||||
if (step.requires_rack_assignment) {
|
||||
actions.push({ key: 'start_with_rack', label: 'Start (Assign Rack)',
|
||||
icon: 'fa fa-server', cssClass: 'btn btn-primary' });
|
||||
return actions;
|
||||
}
|
||||
// Default
|
||||
actions.push({ key: 'start', label: 'Start', icon: 'fa fa-play', cssClass: 'btn btn-primary' });
|
||||
return actions;
|
||||
}
|
||||
```
|
||||
|
||||
### Change 3 — `dispatchStepAction(step, key)`
|
||||
|
||||
Single router method that delegates to handler methods:
|
||||
|
||||
```js
|
||||
async dispatchStepAction(step, key) {
|
||||
switch (key) {
|
||||
case 'start': return this.onStartStep(step.id);
|
||||
case 'resume': return this.onResumeStep(step); // button_resume — distinct from button_start
|
||||
case 'pause': return this.onPauseStep(step);
|
||||
case 'record_inputs': return this.onRecordInputs(step);
|
||||
case 'finish': return this.onFinishStep(step);
|
||||
case 'mark_passed': return this.onMarkPassed(step);
|
||||
case 'open_contract_review': return this.onOpenContractReview(step);
|
||||
case 'start_with_rack': return this.onStartWithRack(step);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Change 4 — New JS handlers
|
||||
|
||||
**`onPauseStep(step)`** — calls `fp.job.step.button_pause` via ORM RPC.
|
||||
(No `/fp/shopfloor/pause_wo` HTTP endpoint exists; the legacy stop_wo
|
||||
endpoint's docstring claims pause isn't implemented but `button_pause`
|
||||
does exist in `fusion_plating/models/fp_job_step.py:320`. Using ORM
|
||||
RPC sidesteps the need to add a new HTTP route.)
|
||||
|
||||
```js
|
||||
async onPauseStep(step) {
|
||||
const reason = window.prompt(`Pause reason for "${step.name}"?`, '');
|
||||
if (reason === null) return; // operator cancelled
|
||||
try {
|
||||
await rpc('/web/dataset/call_kw', {
|
||||
model: 'fp.job.step', method: 'button_pause',
|
||||
args: [[step.id]],
|
||||
kwargs: { reason: reason || 'no reason given' },
|
||||
});
|
||||
this.notification.add('Step paused.', { type: 'success' });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: 'danger' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`onResumeStep(step)`** — calls `fp.job.step.button_resume` via ORM RPC.
|
||||
Distinct from `onStartStep` because the model has separate methods:
|
||||
`button_start` is for state=ready → in_progress; `button_resume` is for
|
||||
state=paused → in_progress (preserves accrued time + reason audit).
|
||||
|
||||
```js
|
||||
async onResumeStep(step) {
|
||||
try {
|
||||
await rpc('/web/dataset/call_kw', {
|
||||
model: 'fp.job.step', method: 'button_resume',
|
||||
args: [[step.id]], kwargs: {},
|
||||
});
|
||||
this.notification.add('Step resumed.', { type: 'success' });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: 'danger' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`onMarkPassed(step)`** — calls a new ORM method `action_mark_gating_passed`
|
||||
which does `button_start` + `button_finish` in one server call:
|
||||
|
||||
```js
|
||||
async onMarkPassed(step) {
|
||||
try {
|
||||
await rpc('/web/dataset/call_kw', {
|
||||
model: 'fp.job.step', method: 'action_mark_gating_passed',
|
||||
args: [[step.id]], kwargs: {},
|
||||
});
|
||||
this.notification.add('Gate marked passed.', { type: 'success' });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: 'danger' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`onOpenContractReview(step)`** — calls the existing `_fp_open_contract_review`
|
||||
helper on `fp.job.step` (per CLAUDE.md Policy B section). Returns an act_window
|
||||
that the action service opens. After dialog close, refresh:
|
||||
|
||||
```js
|
||||
async onOpenContractReview(step) {
|
||||
try {
|
||||
const result = await rpc('/web/dataset/call_kw', {
|
||||
model: 'fp.job.step', method: '_fp_open_contract_review',
|
||||
args: [[step.id]], kwargs: {},
|
||||
});
|
||||
if (result) {
|
||||
await this.action.doAction(result, { onClose: () => this.refresh() });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || "Couldn't open QA-005", { type: 'danger' });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`onStartWithRack(step)`** — opens the existing Rack Parts dialog from
|
||||
`move_controller.py`. On commit (rack assigned + parts loaded), calls
|
||||
`onStartStep(step.id)`. Implementation reuses `FpRackPartsDialog` from
|
||||
`fusion_plating_shopfloor/static/src/js/rack_parts_dialog.js`:
|
||||
|
||||
```js
|
||||
async onStartWithRack(step) {
|
||||
this.dialog.add(FpRackPartsDialog, {
|
||||
jobId: this.state.jobId,
|
||||
stepId: step.id,
|
||||
partRef: this.state.data.job.part_number || '',
|
||||
defaultQty: this.state.data.job.qty || 1,
|
||||
onCommitted: async () => {
|
||||
// Rack assigned → now start the step
|
||||
await this.onStartStep(step.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Change 5 — New backend method `action_mark_gating_passed`
|
||||
|
||||
In [`fusion_plating_jobs/models/fp_job_step.py`](../../../fusion_plating_jobs/models/fp_job_step.py),
|
||||
add:
|
||||
|
||||
```python
|
||||
def action_mark_gating_passed(self):
|
||||
"""1-click pass for gating steps (kind=='gating'). Performs
|
||||
button_start() then button_finish() in the same transaction.
|
||||
Posts chatter ("Gate marked passed by <user>") on the parent job.
|
||||
|
||||
Only valid for state in (ready, pending, paused) — defensive
|
||||
NOOP otherwise (idempotent on repeat clicks).
|
||||
"""
|
||||
for step in self:
|
||||
if step.state in ('done', 'skipped', 'cancelled'):
|
||||
continue
|
||||
kind_code = step.recipe_node_id.kind_id.code if (
|
||||
step.recipe_node_id and step.recipe_node_id.kind_id
|
||||
) else None
|
||||
if kind_code != 'gating':
|
||||
raise UserError(_(
|
||||
"action_mark_gating_passed is only valid for gating "
|
||||
"steps (this step has kind=%s).") % (kind_code or 'unknown'))
|
||||
if step.state not in ('ready', 'pending', 'paused'):
|
||||
continue
|
||||
# Resume if paused, then start, then finish — bypass the input
|
||||
# gate (gating steps have no required inputs by design).
|
||||
if step.state == 'paused':
|
||||
step.button_resume()
|
||||
if step.state != 'in_progress':
|
||||
step.button_start()
|
||||
step.with_context(
|
||||
fp_skip_required_inputs_gate=True,
|
||||
).button_finish()
|
||||
step.job_id.message_post(body=_(
|
||||
'Gate "%(name)s" marked passed by %(user)s.'
|
||||
) % {'name': step.name, 'user': self.env.user.name})
|
||||
return True
|
||||
```
|
||||
|
||||
### Change 6 — Verify controller payload has `requires_rack_assignment`
|
||||
|
||||
The workspace controller payload at
|
||||
[`workspace_controller.py:75-95`](../../../fusion_plating_shopfloor/controllers/workspace_controller.py)
|
||||
already includes `kind`, `state`, `can_start`, `requires_signoff`,
|
||||
`blocker_kind`. Verify `requires_rack_assignment` is included; if not, add it:
|
||||
|
||||
```python
|
||||
'requires_rack_assignment': bool(getattr(step, 'requires_rack_assignment', False)),
|
||||
```
|
||||
|
||||
### Change 7 — Version bumps
|
||||
|
||||
| Module | From | To |
|
||||
|---|---|---|
|
||||
| `fusion_plating_jobs` | `19.0.10.26.0` | `19.0.10.27.0` (new `action_mark_gating_passed` method) |
|
||||
| `fusion_plating_shopfloor` | `19.0.33.1.4` | `19.0.33.1.5` (JS + XML restructure + controller payload) |
|
||||
|
||||
No data migration needed — purely behavioural / UX.
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
### Manual smoke (after deploy)
|
||||
|
||||
1. Open WO-30057 in Job Workspace.
|
||||
2. Confirm Step 1 (Contract Review, done) shows ✓ + name, NO buttons.
|
||||
3. Confirm Step 2 (Masking, ready) shows **Start** button.
|
||||
4. Click Start → confirm step transitions to `in_progress` → buttons swap to Record Inputs, Pause, Finish.
|
||||
5. Click Pause → confirm prompt → confirm step transitions to `paused` → buttons swap to Resume, Record Inputs, Finish.
|
||||
6. Click Resume → confirm back to `in_progress` + correct buttons.
|
||||
7. Click Finish → confirm step completes → next step (Incoming Inspection, ready) now shows Start.
|
||||
8. Locate a job with a Contract Review step that hasn't been auto-completed (rare — most parts have prior QA-005). Confirm **Open QA-005 Form** button. Click → form opens. Submit → refresh → step completes.
|
||||
9. Locate or create a job with a Gating step (kind='gating'). Confirm **✓ Mark Passed** button. Click → step jumps from ready to done in one click.
|
||||
10. Find a step where `requires_signoff=True`. Click Finish → signature pad opens (existing behaviour). Sign → step completes.
|
||||
11. Find a blocked step (predecessor not done). Confirm GateViz renders, NO action buttons.
|
||||
12. Find an opt-out step (`override_excluded=True`). Confirm "Skipped per recipe override" notice, NO action buttons.
|
||||
|
||||
### Smoke for chip / instructions visibility
|
||||
|
||||
13. On any in-flight job, confirm chips (🎯 thickness, ⏱ dwell, 🔥 bake, ✎ sign-off) + recipe instructions render on **every non-done step** (not just the active one). Operator can read ahead.
|
||||
|
||||
### Battle test followup
|
||||
|
||||
Defer to Sub B (no new automated test for this UX-only change — covered by manual smoke).
|
||||
|
||||
---
|
||||
|
||||
## Out of scope (explicit)
|
||||
|
||||
- **`inputmode` attributes / number keyboards / prefill / date/time pickers /
|
||||
signature pad in Record Inputs / camera capture** — all deferred to Sub B
|
||||
(record-inputs tablet polish).
|
||||
- **Auditing every kind's default input prompts** — deferred to Sub B. The
|
||||
existing dialog renders all 15 input_types; Sub B verifies each is good UX.
|
||||
- **Skip step button** — supervisor-only, accessible via backend form. Not
|
||||
adding to operator workspace.
|
||||
- **Reassign step** — supervisor-only.
|
||||
- **Per-recipe ordering or kind fixes** — already covered by recent recipe
|
||||
cleanup spec.
|
||||
|
||||
---
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `fusion_plating_shopfloor/static/src/xml/job_workspace.xml` | Template restructure — always-visible action row + non-terminal detail block (Change 1) |
|
||||
| `fusion_plating_shopfloor/static/src/js/job_workspace.js` | `getStepActions`, `dispatchStepAction`, `onPauseStep`, `onMarkPassed`, `onOpenContractReview`, `onStartWithRack` (Changes 2-4) |
|
||||
| `fusion_plating_shopfloor/static/src/scss/job_workspace.scss` | Minor styling for the action row (consistent spacing across button counts) |
|
||||
| `fusion_plating_shopfloor/controllers/workspace_controller.py` | Add `requires_rack_assignment` to step payload if missing (Change 6) |
|
||||
| `fusion_plating_shopfloor/__manifest__.py` | Bump to `19.0.33.1.5` (Change 7) |
|
||||
| `fusion_plating_jobs/models/fp_job_step.py` | Add `action_mark_gating_passed()` method (Change 5) |
|
||||
| `fusion_plating_jobs/__manifest__.py` | Bump to `19.0.10.27.0` (Change 7) |
|
||||
|
||||
Estimated diff: ~200 lines added, ~50 modified, ~10 deleted.
|
||||
@@ -0,0 +1,610 @@
|
||||
# Post-Shop Cert + Shipping Job States
|
||||
|
||||
**Date:** 2026-05-25
|
||||
**Status:** Approved for implementation (brainstorming gate)
|
||||
**Author:** Brainstorming session (gsinghpal)
|
||||
**Triggering incident:** Job WO-30058 (SO-30058) finished all recipe steps on entech and **disappeared from the Shop Floor kanban**. CoC was auto-spawned in `draft` but nobody was notified, no surface listed pending certs for the Quality Manager, and there was no kanban column for "completed-but-not-shipped" jobs. Operators reported jobs they had finished feeling "lost" — same risk that a job could leave the building without a CoC.
|
||||
|
||||
## Goal
|
||||
|
||||
Three things, decided as one unit of work:
|
||||
|
||||
1. **Stop completed-but-uncertified jobs from disappearing** from the Plant Kanban — they must stay visible to shop staff so jobs aren't forgotten or shipped without paperwork.
|
||||
2. **Give the Quality Manager a dedicated surface** (Quality Dashboard tab + email + in-app activity) for CoC issuance, with hard ACL gating so Technicians cannot self-issue.
|
||||
3. **Model the actual lifecycle** in `fp.job.state` so reporting, queries, and future workflow gates derive from a single source of truth instead of cross-module joins.
|
||||
|
||||
## Out of scope (deferred to follow-on work)
|
||||
|
||||
- **Shipping label printing, carrier dispatch, tracking-number capture, BoL generation.** These remain manual for now; the new `awaiting_ship` state is a parking column so jobs are visible to the shipping crew. `awaiting_ship → done` is a manual button click. (User: *"lets first finish this certification step then we will look into shipping"*.)
|
||||
- **Auto-transition from `awaiting_ship → done` on `delivery.action_mark_delivered`.** Scaffolded as a future hook in the design; not wired in this scope.
|
||||
- **RMA-aware regression** (job re-opening on RMA receive). Already handled by existing RMA flow — not touched here.
|
||||
- **Per-cert-type ACL** (e.g. only QM can issue Nadcap, but Manager can issue CoC). Out of scope; single QM-or-higher gate for all cert types in v1.
|
||||
|
||||
## Decisions reached during brainstorming
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| D1 | Use **Approach A** — add two new `fp.job.state` values (`awaiting_cert`, `awaiting_ship`) | Cleanest semantics; `state='done'` will once again mean "fully complete and shipped". Single source of truth for kanban, dashboard, reporting, and future delivery automation. |
|
||||
| D2 | **Auto-advance** `in_progress → awaiting_cert` when every recipe step is terminal AND a cert is required | No new operator button; removes risk of forgetting to advance. Hooks into existing `all_steps_terminal` computed field. |
|
||||
| D3 | **Auto-advance** `awaiting_cert → awaiting_ship` from `fp.certificate.action_issue` when every required cert is `issued` | The QM clicking Issue is the natural trigger; no separate "mark inspection complete" button needed. The recipe's final-inspection step already captures inspection data via custom prompts. |
|
||||
| D4 | **Cert void regresses state** (`awaiting_ship → awaiting_cert` if a previously-issued cert is voided) | Defensive against late-discovered issues. Re-fires `cert_awaiting_issuance` notification under a `cert_voided_re_notify` event so dedupe doesn't suppress it. |
|
||||
| D5 | **Manual `awaiting_ship → done` button** in this scope; auto-hook on delivery deferred | User explicitly scoped shipping work out. The button is repurposed from the existing milestone-advance "Mark Done" button (renamed "Mark Shipped"), restricted to Manager/Owner. |
|
||||
| D6 | **ACL gate on `action_issue`** — Manager / Quality Manager / Owner only | User: *"certifications can only be issued by Manager, Quality Manager and Owner, i don't want technicians to issue certifications"*. Two-layer enforcement: Python `AccessError` + view-level `groups=` on the Issue button. |
|
||||
| D7 | **Group-membership resolved via `all_group_ids`** (transitive) — Owners reach QM authority via implication chain | Rule 23 (CLAUDE.md). Owners don't carry `group_fp_quality_manager` directly; the `all_group_ids` lookup catches them via implication. |
|
||||
| D8 | **Notification fires on the auto-transition**, not on the operator's last step-finish click | Decouples notification from operator UX; transition is the authoritative trigger so all paths (UI, RPC, scripted) notify consistently. |
|
||||
| D9 | **Belt-and-suspenders: in-app `mail.activity`** in addition to email | If email bounces / spam-folders, the red activity badge on the QM's home is the floor-of-truth signal. Activity auto-resolves on `awaiting_ship` transition. |
|
||||
| D10 | **Jobs that don't require certs skip `awaiting_cert`** and land directly in `awaiting_ship` | Visibility consistency — every completed-but-unshipped job is in the Shipping column, regardless of cert requirements. No silent path to `done`. |
|
||||
| D11 | **Plant kanban shows the two new states in `Final inspection` and `Shipping` columns**; old per-step `area_kind` logic untouched for `in_progress` | Repurposes the two right-most columns that are currently almost always empty. State drives column for the new values; nothing else changes. |
|
||||
| D12 | **Gates from `button_mark_done` (bake/qty/QC) move UP into `fp.job.step.button_finish` on the LAST step** instead of running at auto-transition time | The auto-transition itself must not raise — if it raised when the operator finishes their last step, the step would finish but the auto-advance would silently fail with no error path. Better: when finishing the last step, surface the pending gates as UserError on the finish click. Operator fixes (qty, bake, QC), retries finish, transition fires cleanly. Step-completion gate is implied (the step IS finishing). |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─ STATE MACHINE ─────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ confirmed → in_progress → awaiting_cert → awaiting_ship → done│
|
||||
│ │ ▲ ▲ ▲ │
|
||||
│ │ │ │ │ │
|
||||
│ all steps terminal QM Issue (last cert) Mark Shipped│ │
|
||||
│ + cert required button │ │
|
||||
│ │ │ │
|
||||
│ └──── (no cert required) ─────────────────┘ │
|
||||
│ │
|
||||
│ awaiting_ship ──(cert voided after issue)──► awaiting_cert
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ KANBAN VISIBILITY (PLANT VIEW) ────────────────────────────────┐
|
||||
│ │
|
||||
│ Receiving · Masking · Blasting · Racking · Plating │
|
||||
│ · Baking · De-Racking · ★Final inspection · ★Shipping │
|
||||
│ ▲ ▲ │
|
||||
│ │ │ │
|
||||
│ awaiting_cert awaiting_ship │
|
||||
│ (column = state, not active_step) │
|
||||
│ │
|
||||
│ Domain widens: state IN (confirmed, in_progress, │
|
||||
│ awaiting_cert, awaiting_ship) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ QUALITY DASHBOARD ─────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Holds | Checks | NCRs | CAPAs | RMAs | ★Certificates │
|
||||
│ (new 6th tab) │
|
||||
│ │
|
||||
│ Tab content: kanban grouped by state, Draft folded open, │
|
||||
│ filters (My Customer / Today / Overdue >24h / │
|
||||
│ Missing Fischerscope), buttons Open Cert / Open Job. │
|
||||
│ │
|
||||
│ Header KPI: "Certificates Awaiting Issuance: N (M overdue)" │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ NOTIFICATION (when state hits awaiting_cert) ──────────────────┐
|
||||
│ │
|
||||
│ Email via fp.notification.template, event: │
|
||||
│ cert_awaiting_issuance (first notification) │
|
||||
│ cert_voided_re_notify (re-fires after cert void) │
|
||||
│ │
|
||||
│ Recipients: every active non-share user with all_group_ids │
|
||||
│ containing QM | Manager | Owner. Resolved by helper │
|
||||
│ _fp_resolve_cert_authority_users() (see Rule 13l pattern). │
|
||||
│ │
|
||||
│ + mail.activity ("To Do") assigned to one QM, round-robin │
|
||||
│ by last_activity_at. Auto-marks done on awaiting_ship. │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Schema changes (additive)
|
||||
|
||||
### `fp.job` model
|
||||
|
||||
| Change | Type | Notes |
|
||||
|---|---|---|
|
||||
| `state` selection — add `('awaiting_cert', 'Awaiting Cert')` and `('awaiting_ship', 'Awaiting Ship')` | Selection extension | Selection extension via `_inherit` keeps tracking + chatter audit working. Sequence: confirmed → in_progress → awaiting_cert → awaiting_ship → done → cancelled. |
|
||||
| New method `_fp_check_advance_post_shop()` | Method | Called from step-state-change hooks (specifically from `fp.job.step.button_finish` after `super()`). If all `step_ids` are in `('done','skipped','cancelled')` AND state is `in_progress`: transitions to `awaiting_cert` (if `_resolve_required_cert_types()` non-empty) or `awaiting_ship` (if empty). Idempotent. Does NOT raise — gates moved into step.button_finish per D12. |
|
||||
| Hardened `fp.job.step.button_finish` for the LAST open step | Method | When finishing a step that would leave all steps terminal, run the bake-window / qty-reconciliation / QC gates from the old `button_mark_done` BEFORE allowing the finish. On failure: raise UserError, step stays open, operator fixes + retries. Same manager-bypass context flags as today (`fp_skip_bake_gate`, `fp_skip_qty_reconcile`, `fp_skip_qc_gate`). |
|
||||
| New method `_fp_check_advance_after_cert_issue()` | Method | Called from `fp.certificate.action_issue`. If every required cert for the job is `issued`: transition `awaiting_cert → awaiting_ship`. Idempotent. |
|
||||
| New method `_fp_check_regress_after_cert_void()` | Method | Called from `fp.certificate.write({'state':'voided'})`. If any required cert is no longer `issued`: transition `awaiting_ship → awaiting_cert` and re-fire notification under `cert_voided_re_notify`. |
|
||||
| New method `button_mark_shipped()` | Method | Manual transition `awaiting_ship → done`. Restricted via `groups=` on the form button (Manager/Owner). Reuses the existing `button_mark_done` body for side effects (delivery wiring, notifications, chatter), but **does not include the step/QC/bake/qty gates** (those already passed when we transitioned to `awaiting_cert`). |
|
||||
| Repurpose existing `button_mark_done()` | Method | Becomes an internal-only method called from the auto-transitions. Visible operator action is now `button_mark_shipped`. Existing callers remain valid but are now triggered by the state machine rather than direct user clicks. |
|
||||
|
||||
### `fp.certificate` model
|
||||
|
||||
| Change | Type | Notes |
|
||||
|---|---|---|
|
||||
| Hardened `action_issue()` | Method | Adds Python-side `AccessError` if user lacks QM/Manager/Owner. Calls `job._fp_check_advance_after_cert_issue()` after successful issue. Manager bypass via context flag `fp_skip_cert_authority_gate=True` (posts chatter audit). |
|
||||
| `write({'state':'voided'})` override | Method | Calls `job._fp_check_regress_after_cert_void()` after the void completes. |
|
||||
| `x_fc_age_hours` | Float, non-stored, computed | Drives the Quality Dashboard age chip + overdue filter. `(now - create_date).total_seconds() / 3600`. |
|
||||
|
||||
### `fp.notification.template` data
|
||||
|
||||
| Change | Notes |
|
||||
|---|---|
|
||||
| New selection values on `trigger_event`: `('cert_awaiting_issuance', 'Cert Awaiting Issuance')`, `('cert_voided_re_notify', 'Cert Voided — Please Re-Issue')` | Loaded via `data/fp_notification_events_data.xml`. |
|
||||
| Two seeded `fp.notification.template` records (one per event) | `data/fp_cert_authority_templates.xml`. Default body shown under "Notification changes" further below. Editable via UI (Plating → Configuration → Quality & Documents → Notification Templates). |
|
||||
| New recipient resolver helper `_fp_resolve_cert_authority_users(job)` on `fp.notification.template` | Wraps the group-membership search (Rule 13l pattern). Dispatched when `trigger_event` is `cert_awaiting_issuance` or `cert_voided_re_notify`. |
|
||||
|
||||
### `mail.activity` integration
|
||||
|
||||
| Change | Notes |
|
||||
|---|---|
|
||||
| New `mail.activity.type` xmlid `fusion_plating_jobs.activity_type_issue_coc` | Title: "Issue CoC". Default summary template: `Issue CoC for {{ job.display_wo_name }}`. |
|
||||
| `_fp_schedule_cert_activity(job)` helper on `fp.job` | Picks one QM from `_fp_resolve_cert_authority_users`, sorted by `login_date asc nulls first` — the QM who logged in least recently (likely least busy / hasn't been on the system in a while). Creates the activity. Auto-resolves on `awaiting_ship` transition. `login_date` chosen over a custom `last_activity_at` field because it's standard on `res.users` and always populated. |
|
||||
|
||||
## Plant Kanban changes
|
||||
|
||||
### Controller — `fusion_plating_shopfloor/controllers/plant_kanban.py`
|
||||
|
||||
```python
|
||||
# Line 73-75 — widen the domain
|
||||
domain = [
|
||||
('state', 'in', ('confirmed', 'in_progress',
|
||||
'awaiting_cert', 'awaiting_ship')),
|
||||
]
|
||||
|
||||
# Line ~165 — extend _resolve_card_area
|
||||
def _resolve_card_area(job):
|
||||
if job.card_state == 'no_parts':
|
||||
return 'receiving'
|
||||
if job.state == 'awaiting_cert':
|
||||
return 'inspection'
|
||||
if job.state == 'awaiting_ship':
|
||||
return 'shipping'
|
||||
if job.active_step_id and job.active_step_id.area_kind:
|
||||
return job.active_step_id.area_kind
|
||||
return 'receiving' # orphan fallback (unchanged)
|
||||
```
|
||||
|
||||
### Card-state catalog — `fusion_plating_jobs/models/fp_job.py`
|
||||
|
||||
Two new values added to the `_compute_card_state` precedence chain. Inserted BEFORE the existing `done` rule (which is now unreachable from `state='done'` jobs anyway because they're filtered off the board):
|
||||
|
||||
```python
|
||||
# Before existing Rule 8 (done):
|
||||
if job.state == 'awaiting_cert':
|
||||
job.card_state = 'awaiting_cert'
|
||||
continue
|
||||
if job.state == 'awaiting_ship':
|
||||
job.card_state = 'awaiting_ship'
|
||||
continue
|
||||
```
|
||||
|
||||
### Chip rendering — `_state_chip()` in plant_kanban.py
|
||||
|
||||
```python
|
||||
if card_state == 'awaiting_cert':
|
||||
return {'label': _('🏷️ Awaiting CoC'), 'kind': 'awaiting_cert'}
|
||||
if card_state == 'awaiting_ship':
|
||||
return {'label': _('📦 Ready to ship'), 'kind': 'awaiting_ship'}
|
||||
```
|
||||
|
||||
### Sort priority — `_SORT_PRIORITY` in plant_kanban.py
|
||||
|
||||
```python
|
||||
'awaiting_cert': 3.5, # right after awaiting_signoff
|
||||
'awaiting_ship': 8.5, # right after running
|
||||
```
|
||||
|
||||
(Floats are fine — `_sort_key` returns a tuple sorted ascending, no integer assumption.)
|
||||
|
||||
### SCSS — `fusion_plating_shopfloor/static/src/scss/_plant_card.scss`
|
||||
|
||||
Add two new state modifier classes following the existing pattern:
|
||||
|
||||
```scss
|
||||
.o_fp_plant_card.state-awaiting_cert {
|
||||
background-color: var(--fp-state-awaiting-cert-bg, #fff3cd);
|
||||
border-left: 4px solid var(--fp-state-awaiting-cert-border, #ff9800);
|
||||
}
|
||||
.o_fp_plant_card.state-awaiting_ship {
|
||||
background-color: var(--fp-state-awaiting-ship-bg, #d1f1d4);
|
||||
border-left: 4px solid var(--fp-state-awaiting-ship-border, #2e7d32);
|
||||
}
|
||||
```
|
||||
|
||||
Tokens go in `_plant_tokens.scss` first (per Rule 8), with `@if $o-webclient-color-scheme == dark` darker variants (per Rule 9).
|
||||
|
||||
### KPI strip + filter chips — `fusion_plating_shopfloor/static/src/js/plant_kanban.js`
|
||||
|
||||
```javascript
|
||||
// Two new KPI tiles
|
||||
{ key: 'awaiting_cert', label: _t('Awaiting CoC'),
|
||||
count: kpis.awaiting_cert, kind: 'awaiting_cert' },
|
||||
{ key: 'awaiting_ship', label: _t('Ready to Ship'),
|
||||
count: kpis.awaiting_ship, kind: 'awaiting_ship' },
|
||||
|
||||
// Two new filter chip
|
||||
{ key: 'awaiting_cert', label: _t('Awaiting CoC') },
|
||||
{ key: 'awaiting_ship', label: _t('Ready to Ship') },
|
||||
```
|
||||
|
||||
Server-side KPI compute (in `plant_kanban` endpoint):
|
||||
|
||||
```python
|
||||
'awaiting_cert': sum(1 for j in jobs if j.state == 'awaiting_cert'),
|
||||
'awaiting_ship': sum(1 for j in jobs if j.state == 'awaiting_ship'),
|
||||
```
|
||||
|
||||
### Mini-timeline strip — `_compute_mini_timeline_json` in fp_job.py
|
||||
|
||||
When `state='awaiting_cert'`: the `inspection` dot renders as `current` with `variant='awaiting_cert'`; all 7 earlier dots render `done`; shipping dot renders `upcoming`. Same shape when `state='awaiting_ship'` — shipping is `current` with `variant='awaiting_ship'`, inspection is `done`. Lets the QM see at a glance "this card has cleared the whole line, just waiting on paperwork/shipping."
|
||||
|
||||
## Quality Dashboard changes
|
||||
|
||||
### Counts endpoint — `fusion_plating_quality/controllers/fp_quality_dashboard.py`
|
||||
|
||||
Extend the existing `/fp/quality/dashboard/counts` response with one block:
|
||||
|
||||
```python
|
||||
Cert = env['fp.certificate']
|
||||
return {
|
||||
# existing blocks (holds, checks, ncrs, capas, rmas) unchanged
|
||||
'certificates': {
|
||||
'open': Cert.search_count([('state', '=', 'draft')]),
|
||||
'overdue': Cert.search_count([
|
||||
('state', '=', 'draft'),
|
||||
('create_date', '<', d1), # >24h = overdue
|
||||
]),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Tab UI — `fusion_plating_quality/static/src/{js,xml,scss}/fp_quality_dashboard.*`
|
||||
|
||||
Sixth tab "Certificates" with:
|
||||
|
||||
- **Kanban view** grouped by `state` (Draft / Issued / Voided), Draft folded open by default.
|
||||
- **Card content** (per cert): WO# + part# (large), customer name, cert-type chip (CoC / CoC+Thickness / Nadcap), age chip (`Created 4h ago`, red past 24h), status badges (🟢 Thickness PDF ready · 📋 Inspection prompts captured · ⚠ Missing spec ref).
|
||||
- **Two card actions**: `Open Cert` (cert form) · `Open Job` (source job, lets QM audit inspection prompts before issuing).
|
||||
- **Filters above kanban**: My Customer / Today / Overdue (>24h) / Missing Fischerscope (status='pending' from S19) / High-severity Customer.
|
||||
- **Group-by option**: Customer.
|
||||
|
||||
**Header KPI** card on the dashboard gains: `Certificates Awaiting Issuance: <count>` with overdue sub-count in red.
|
||||
|
||||
**Header KPI strip** across all 6 tabs stays glanceable — six small tiles in a row.
|
||||
|
||||
### Menu entry
|
||||
|
||||
No new menu — the Quality Dashboard already lives at Plating → Quality → Dashboard. New tab is a sibling render inside the existing client action.
|
||||
|
||||
### Deep-link from notification email
|
||||
|
||||
The email body includes `{{ url('/odoo/action-fp_quality_dashboard?tab=certificates') }}`. New URL param `?tab=certificates` is parsed by the OWL action's `setup()` to focus the right tab on load.
|
||||
|
||||
## ACL changes
|
||||
|
||||
### `fp.certificate.action_issue` Python guard
|
||||
|
||||
```python
|
||||
def action_issue(self):
|
||||
if not self.env.context.get('fp_skip_cert_authority_gate'):
|
||||
cert_authority_gids = [
|
||||
self.env.ref('fusion_plating.group_fp_quality_manager').id,
|
||||
self.env.ref('fusion_plating.group_fp_manager').id,
|
||||
self.env.ref('fusion_plating.group_fp_owner').id,
|
||||
]
|
||||
if not (set(self.env.user.all_group_ids.ids)
|
||||
& set(cert_authority_gids)):
|
||||
raise AccessError(_(
|
||||
"Only Quality Managers, Managers, and Owners can issue "
|
||||
"certificates. Ask your QM to review and issue this CoC."
|
||||
))
|
||||
else:
|
||||
self.message_post(body=Markup(_(
|
||||
'Cert authority gate <b>bypassed</b> by '
|
||||
'<b>%(u)s</b> (context flag fp_skip_cert_authority_gate).'
|
||||
)) % {'u': self.env.user.name})
|
||||
# existing issue logic...
|
||||
```
|
||||
|
||||
### View-level button gating
|
||||
|
||||
`fp_certificate_views.xml` — Issue button on form:
|
||||
|
||||
```xml
|
||||
<button name="action_issue" string="Issue" type="object"
|
||||
invisible="state != 'draft'"
|
||||
groups="fusion_plating.group_fp_quality_manager,
|
||||
fusion_plating.group_fp_manager,
|
||||
fusion_plating.group_fp_owner"/>
|
||||
```
|
||||
|
||||
### Tablet impact
|
||||
|
||||
S18-era cert flow ran any operator session. Post-change: remove the Issue affordance from operator-facing tablet surfaces. The cert form is still openable (read-only for Technicians); the action button is hidden. Surface a "Send to QM" hint on the job workspace footer when state is `awaiting_cert`, but the actual notification fires automatically — no operator click required.
|
||||
|
||||
## Notification changes
|
||||
|
||||
### New trigger events
|
||||
|
||||
Add to `fp.notification.template.trigger_event` selection:
|
||||
|
||||
```python
|
||||
('cert_awaiting_issuance', _('Cert Awaiting Issuance')),
|
||||
('cert_voided_re_notify', _('Cert Voided — Please Re-Issue')),
|
||||
```
|
||||
|
||||
### Seeded template — `data/fp_cert_authority_templates.xml`
|
||||
|
||||
Single template per event (subject, body, recipient_resolver = `cert_authority_group_members`). Default body (per the Architecture diagram above and the section that follows on the recipient resolver):
|
||||
|
||||
```
|
||||
Subject: 🏷️ Job {{ object.display_wo_name }} ready for CoC issuance
|
||||
|
||||
Hi {{ recipient.name|first_name }},
|
||||
|
||||
Job {{ object.display_wo_name }} ({{ object.partner_id.name }})
|
||||
has finished the shop floor and is awaiting CoC issuance.
|
||||
|
||||
Part: {{ object.part_catalog_id.part_number }}
|
||||
Quantity: {{ object.qty_done }}
|
||||
Recipe: {{ object.recipe_id.name }}
|
||||
|
||||
Review the inspection prompts captured by the operator on the Final
|
||||
Inspection step, then issue the CoC from the Quality Dashboard:
|
||||
|
||||
→ {{ url('/odoo/action-fp_quality_dashboard?tab=certificates') }}
|
||||
|
||||
Or open the job directly:
|
||||
|
||||
→ {{ object.x_fc_record_url }}
|
||||
```
|
||||
|
||||
Marked `noupdate="1"` so admin edits in the UI survive `-u` (Rule 22).
|
||||
|
||||
### Recipient resolver — `_fp_resolve_cert_authority_users()`
|
||||
|
||||
```python
|
||||
@api.model
|
||||
def _fp_resolve_cert_authority_users(self, job=None):
|
||||
"""Return active, non-share users with QM | Manager | Owner role
|
||||
(transitive via all_group_ids). See Rule 13l for the rationale —
|
||||
direct user_ids on group records does NOT include implied
|
||||
memberships."""
|
||||
gids = [
|
||||
self.env.ref('fusion_plating.group_fp_quality_manager').id,
|
||||
self.env.ref('fusion_plating.group_fp_manager').id,
|
||||
self.env.ref('fusion_plating.group_fp_owner').id,
|
||||
]
|
||||
return self.env['res.users'].sudo().search([
|
||||
('all_group_ids', 'in', gids),
|
||||
('share', '=', False),
|
||||
('active', '=', True),
|
||||
])
|
||||
```
|
||||
|
||||
### Throttling / dedupe
|
||||
|
||||
`fp.notification.log` dedupe key is `(template_id, source_record_id, event)`. The two events have distinct keys, so a `cert_voided_re_notify` after `cert_awaiting_issuance` re-fires (correct — different signal). Repeated `cert_awaiting_issuance` for the same job is suppressed (correct — already on the QM's radar).
|
||||
|
||||
### `mail.activity` belt + suspenders
|
||||
|
||||
After firing the notification, schedule one activity per job (not per QM — round-robin assignment to a single QM, who can re-assign if needed). Auto-resolves when state transitions to `awaiting_ship`.
|
||||
|
||||
```python
|
||||
def _fp_schedule_cert_activity(self):
|
||||
self.ensure_one()
|
||||
activity_type = self.env.ref(
|
||||
'fusion_plating_jobs.activity_type_issue_coc',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not activity_type:
|
||||
return
|
||||
qm = self.env['fp.notification.template']._fp_resolve_cert_authority_users(self)
|
||||
if not qm:
|
||||
return
|
||||
# Round-robin: pick the QM who logged in least recently (likely
|
||||
# least busy). NULL login_date sorts first.
|
||||
qm = qm.sorted(lambda u: u.login_date or fields.Datetime.from_string('1970-01-01'))[:1]
|
||||
self.activity_schedule(
|
||||
activity_type_id=activity_type.id,
|
||||
user_id=qm.id,
|
||||
summary=_('Issue CoC for %s') % (self.display_wo_name or self.name),
|
||||
note=_('Job has finished the shop floor. Review the inspection '
|
||||
'prompts captured on the final step, then issue the CoC.'),
|
||||
)
|
||||
```
|
||||
|
||||
Auto-resolve on `awaiting_ship` transition:
|
||||
|
||||
```python
|
||||
def _fp_resolve_cert_activities(self):
|
||||
self.ensure_one()
|
||||
activity_type = self.env.ref(
|
||||
'fusion_plating_jobs.activity_type_issue_coc',
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
if not activity_type:
|
||||
return
|
||||
self.activity_ids.filtered(
|
||||
lambda a: a.activity_type_id == activity_type
|
||||
).action_feedback(feedback=_('Cert issued — auto-resolved.'))
|
||||
```
|
||||
|
||||
## Migration plan
|
||||
|
||||
### Pre-migration state assessment
|
||||
|
||||
```sql
|
||||
-- Count jobs currently affected
|
||||
SELECT state, count(*)
|
||||
FROM fp_job
|
||||
WHERE state IN ('confirmed', 'in_progress', 'done')
|
||||
GROUP BY state;
|
||||
|
||||
-- Jobs in-progress with all steps terminal (will migrate to awaiting_cert or awaiting_ship)
|
||||
SELECT j.id, j.name, j.state,
|
||||
count(s.id) AS total_steps,
|
||||
count(s.id) FILTER (WHERE s.state IN ('done','skipped','cancelled')) AS terminal_steps
|
||||
FROM fp_job j
|
||||
LEFT JOIN fp_job_step s ON s.job_id = j.id
|
||||
WHERE j.state = 'in_progress'
|
||||
GROUP BY j.id, j.name, j.state
|
||||
HAVING count(s.id) > 0
|
||||
AND count(s.id) = count(s.id) FILTER (WHERE s.state IN ('done','skipped','cancelled'));
|
||||
|
||||
-- Done jobs with draft certs (would need backfill in the new world, but we leave them alone)
|
||||
SELECT j.id, j.name, count(c.id) AS draft_certs
|
||||
FROM fp_job j
|
||||
JOIN fp_certificate c ON c.x_fc_job_id = j.id AND c.state = 'draft'
|
||||
WHERE j.state = 'done'
|
||||
GROUP BY j.id, j.name;
|
||||
```
|
||||
|
||||
### Migration script — `fusion_plating_jobs/migrations/19.0.11.0.0/post-migrate.py`
|
||||
|
||||
```python
|
||||
def migrate(cr, version):
|
||||
"""Backfill new states for jobs caught mid-transition by the upgrade.
|
||||
|
||||
Rules:
|
||||
- in_progress + all steps terminal + draft cert exists → awaiting_cert
|
||||
- in_progress + all steps terminal + no cert required → awaiting_ship
|
||||
- done jobs LEFT ALONE — they're historical (already shipped)
|
||||
"""
|
||||
cr.execute("""
|
||||
UPDATE fp_job
|
||||
SET state = 'awaiting_cert'
|
||||
WHERE id IN (
|
||||
SELECT j.id
|
||||
FROM fp_job j
|
||||
JOIN fp_job_step s ON s.job_id = j.id
|
||||
WHERE j.state = 'in_progress'
|
||||
GROUP BY j.id
|
||||
HAVING count(*) FILTER (
|
||||
WHERE s.state NOT IN ('done','skipped','cancelled')
|
||||
) = 0
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM fp_certificate c
|
||||
WHERE c.x_fc_job_id = fp_job.id AND c.state = 'draft'
|
||||
);
|
||||
""")
|
||||
cr.execute("""
|
||||
UPDATE fp_job
|
||||
SET state = 'awaiting_ship'
|
||||
WHERE id IN (
|
||||
SELECT j.id
|
||||
FROM fp_job j
|
||||
JOIN fp_job_step s ON s.job_id = j.id
|
||||
WHERE j.state = 'in_progress'
|
||||
GROUP BY j.id
|
||||
HAVING count(*) FILTER (
|
||||
WHERE s.state NOT IN ('done','skipped','cancelled')
|
||||
) = 0
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM fp_certificate c
|
||||
WHERE c.x_fc_job_id = fp_job.id
|
||||
AND c.state IN ('draft', 'issued')
|
||||
);
|
||||
""")
|
||||
```
|
||||
|
||||
Idempotent: re-running on a fresh upgrade is a no-op because no `in_progress` job will match the all-terminal predicate after the first run.
|
||||
|
||||
### Card_state recompute
|
||||
|
||||
After the migration script runs, force a recompute of `fp.job.card_state` (stored compute) so the kanban renders correctly:
|
||||
|
||||
```python
|
||||
# In post-migrate.py, after the state UPDATEs:
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
affected = env['fp.job'].search([
|
||||
('state', 'in', ('awaiting_cert', 'awaiting_ship')),
|
||||
])
|
||||
affected.invalidate_recordset(['card_state'])
|
||||
affected._compute_card_state()
|
||||
```
|
||||
|
||||
## Edge cases + defensive design
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Job with no recipe steps at all | `_fp_check_advance_post_shop` returns early (nothing to check). State stays at whatever it was — operator can still manually move it via the existing milestone-advance button. |
|
||||
| Recipe doesn't have a Final Inspection step | Card still lands in Final Inspection column (state drives column, not recipe shape). The recipe author probably forgot to add the step — Quality Dashboard surface catches it. |
|
||||
| Customer not flagged for any cert | `_resolve_required_cert_types()` returns empty set → state transitions `in_progress → awaiting_ship` directly. Card visible in Shipping column. No notification fires (no QM action needed). |
|
||||
| QM voids a cert AFTER `awaiting_ship` | `_fp_check_regress_after_cert_void()` flips state back to `awaiting_cert`, re-fires `cert_voided_re_notify` (different dedupe key from initial notification, so not suppressed). Card visibly moves left from Shipping back to Final Inspection. |
|
||||
| Operator marks a step as `cancelled` mid-flow that bricks the all-terminal check | `cancelled` IS terminal per the existing logic — so cancelling the last open step DOES trigger the transition. Operator-error path: if a manager opens a previously-terminal step (e.g. `_fp_reopen_step`) the state should regress to `in_progress`. Add inverse trigger on step reopen. |
|
||||
| Cron-triggered cert issuance (e.g. some future auto-issue) with no `env.user` | The Python guard hits `self.env.user` which would be the cron user. Need cron-safe bypass: use `fp_skip_cert_authority_gate=True` in the cron's `with_context(...)` call. Documented but no cron exists today. |
|
||||
| Migration: existing `state='done'` jobs that haven't shipped | Left alone — historically completed jobs are out of scope. Backfill action (`action_backfill_missing_certs` in fp_job.py) already exists for cert gaps; that path is unchanged. |
|
||||
| Multiple QMs assigned a single activity | Round-robin picks ONE QM (oldest `login_date`). They can reassign via the activity widget if needed. Activity auto-resolves on `awaiting_ship` regardless of which QM acted. |
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Smoke test (entech-style, scriptable)
|
||||
|
||||
```python
|
||||
# scripts/bt_post_shop_states.py
|
||||
# 1. Create SO + job with cert-requiring customer
|
||||
# 2. Walk every step to terminal → assert state='awaiting_cert'
|
||||
# 3. Assert card appears in plant_kanban under 'inspection' column
|
||||
# 4. Assert email + activity scheduled on a QM
|
||||
# 5. As a Technician, call cert.action_issue() → assert AccessError
|
||||
# 6. As a QM, call cert.action_issue() → state='issued', job state→'awaiting_ship'
|
||||
# 7. Assert card moves to 'shipping' column, activity auto-resolves
|
||||
# 8. Void the cert → assert state back to 'awaiting_cert', activity re-scheduled
|
||||
# 9. Re-issue → 'awaiting_ship' again
|
||||
# 10. Click button_mark_shipped (as Manager) → state='done', card off board
|
||||
```
|
||||
|
||||
### Unit-level
|
||||
|
||||
- `fp.job._fp_check_advance_post_shop` — exhaustive matrix of (state, all_terminal, certs_required) → expected new state
|
||||
- `fp.certificate.action_issue` ACL — separate tests per role (Tech/Mgr/QM/Owner/Sales Rep)
|
||||
- `_fp_resolve_cert_authority_users` — entech-realistic group setup, confirm Owners are returned via implication
|
||||
|
||||
### Manual QA on entech
|
||||
|
||||
1. Pick a currently-done WO-30058 (the triggering job) → run migration → confirm it stays at `state='done'` (untouched).
|
||||
2. Find an `in_progress` job with all steps terminal — confirm migration script moves it to the right state.
|
||||
3. Walk a fresh SO end-to-end: confirm card visibility at each column transition.
|
||||
4. Try issuing a cert as a Technician via the JS-rpc console → confirm AccessError.
|
||||
|
||||
## Files touched
|
||||
|
||||
### `fusion_plating_jobs/`
|
||||
- `models/fp_job.py` — state extension, new methods, repurpose button_mark_done, new button_mark_shipped, hooks for cert state changes, mini-timeline update, card-state extension
|
||||
- `views/fp_job_views.xml` — Mark Shipped button (replaces Mark Done), groups gating
|
||||
- `data/fp_activity_types_data.xml` — new mail.activity.type `activity_type_issue_coc`
|
||||
- `migrations/19.0.<next>/post-migrate.py` — state backfill
|
||||
- `__manifest__.py` — version bump, data file additions
|
||||
|
||||
### `fusion_plating_certificates/`
|
||||
- `models/fp_certificate.py` — Python ACL guard in action_issue, write override for state=voided, x_fc_age_hours computed
|
||||
- `views/fp_certificate_views.xml` — `groups=` on Issue button
|
||||
- `__manifest__.py` — version bump
|
||||
|
||||
### `fusion_plating_shopfloor/`
|
||||
- `controllers/plant_kanban.py` — domain widen, column resolution, chip rendering, KPI compute, sort priority
|
||||
- `static/src/js/plant_kanban.js` — KPI tile + filter chip additions
|
||||
- `static/src/scss/_plant_card.scss` + `_plant_tokens.scss` — new state modifier classes (light + dark via `$o-webclient-color-scheme`)
|
||||
- `__manifest__.py` — version bump
|
||||
|
||||
### `fusion_plating_quality/`
|
||||
- `controllers/fp_quality_dashboard.py` — certificates block in counts response
|
||||
- `static/src/{js,xml,scss}/fp_quality_dashboard.*` — sixth tab, header KPI, deep-link `?tab=certificates` parsing
|
||||
- `__manifest__.py` — version bump (and add `fusion_plating_certificates` to depends if not already)
|
||||
|
||||
### `fusion_plating_notifications/`
|
||||
- `models/fp_notification_template.py` — new trigger_event selection values, recipient resolver helper
|
||||
- `data/fp_cert_authority_templates.xml` — seeded templates for both events
|
||||
- `__manifest__.py` — version bump
|
||||
|
||||
## Open questions for implementation phase
|
||||
|
||||
1. **Where exactly does the auto-transition fire?** Most likely `fp.job.step.write` post-hook when `state` changes — needs centralization so all step-completion paths (button_finish, action_skip, action_cancel, etc.) trigger consistently. Implementation plan should validate by grepping for every site that sets `step.state`.
|
||||
2. **Should `button_mark_shipped` add ANY gates** (e.g. delivery exists / draft cert isn't lingering)? Default answer: no — the state machine has already validated correctness; the button is just "yes, shipped". But worth re-confirming.
|
||||
3. **Tablet "Send to QM" footer hint** — exact wording and link target. Minor UX; can be settled during implementation.
|
||||
4. **Mini-timeline dot for `done` (state at end-of-lifecycle)** — currently the timeline is always 9 dots regardless of state. After shipping the card is off the board, so this only matters for historical viewers. Probably no change needed; flag for implementation review.
|
||||
|
||||
## Status & deployment notes
|
||||
|
||||
Target version bumps (suggestion, finalize at implementation time):
|
||||
- `fusion_plating_jobs` 19.0.10.24.0 → 19.0.11.0.0 (state extension is a minor-version bump per existing convention)
|
||||
- `fusion_plating_certificates` 19.0.5.4.0 → 19.0.6.0.0
|
||||
- `fusion_plating_shopfloor` 19.0.34.0.0 → 19.0.35.0.0
|
||||
- `fusion_plating_quality` 19.0.5.0.0 → 19.0.6.0.0
|
||||
- `fusion_plating_notifications` minor bump
|
||||
|
||||
Deploy order: notifications → jobs (post-migrate runs here) → certificates → shopfloor → quality. Each gets its own `-u` step to keep blast radius small per [CLAUDE.md → Sub 12 build order rule 11](../../CLAUDE.md).
|
||||
@@ -4,6 +4,7 @@
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from . import controllers
|
||||
from . import models
|
||||
@@ -34,6 +35,86 @@ def post_init_hook(env):
|
||||
_migrate_legacy_uom_columns(env)
|
||||
_seed_starter_recipes_once(env)
|
||||
_fp_post_init_role_migration(env)
|
||||
_fp_apply_office_user_menu_visibility(env)
|
||||
|
||||
|
||||
# Top-level app menus that technicians should NOT see. Each entry is an
|
||||
# xmlid; env.ref(..., raise_if_not_found=False) silently skips menus
|
||||
# from uninstalled modules so this is safe across configurations.
|
||||
# Kept visible to technicians (NOT in this list): Discuss, To-do,
|
||||
# Plating, AI, Maintenance, Time Off. Settings/Apps/Tests are admin-
|
||||
# restricted upstream — also not in this list.
|
||||
# See security/fp_menu_visibility.xml for the design rationale.
|
||||
MENU_HIDE_FROM_TECHNICIANS = [
|
||||
'calendar.mail_menu_calendar',
|
||||
'contacts.menu_contacts',
|
||||
'crm.crm_menu_root',
|
||||
'sale.sale_menu_root',
|
||||
'spreadsheet_dashboard.spreadsheet_dashboard_menu_root',
|
||||
'fusion_ringcentral.menu_rc_root',
|
||||
'fusion_faxes.menu_fusion_faxes_root',
|
||||
'fusion_tasks.menu_field_service_root',
|
||||
'fusion_clock.menu_fusion_clock_root',
|
||||
'account.menu_finance',
|
||||
'accountant.menu_accounting',
|
||||
'project.menu_main_pm',
|
||||
'hr_timesheet.timesheet_menu_root',
|
||||
'planning.planning_menu_root',
|
||||
'fusion_shipping.menu_fusion_shipping_root',
|
||||
'website.menu_website_configuration',
|
||||
'purchase.menu_purchase_root',
|
||||
'stock.menu_stock_root',
|
||||
'sign.menu_document',
|
||||
'hr.menu_hr_root',
|
||||
'hr_work_entry_enterprise.menu_hr_payroll_root',
|
||||
'hr_attendance.menu_hr_attendance_root',
|
||||
'hr_recruitment.menu_hr_recruitment_root',
|
||||
'hr_expense.menu_hr_expense_root',
|
||||
'iot.iot_menu_root',
|
||||
'utm.menu_link_tracker_root',
|
||||
'base.menu_management',
|
||||
]
|
||||
|
||||
|
||||
def _fp_apply_office_user_menu_visibility(env):
|
||||
"""Set group_ids = [group_fp_office_user] on every menu in
|
||||
MENU_HIDE_FROM_TECHNICIANS that exists in this DB.
|
||||
|
||||
Field is `group_ids` on ir.ui.menu in Odoo 19 (was `groups_id` in
|
||||
earlier versions — Odoo 18 renamed it). Same naming-rename pattern
|
||||
as res.users (CLAUDE.md Critical Rule 13c).
|
||||
|
||||
Idempotent: if a menu already has only the office_user group, no
|
||||
change is made. If it has additional groups (e.g. a previous custom
|
||||
restriction), they're REPLACED — the design accepts this trade-off
|
||||
because office_user is implied by every fp role above Technician,
|
||||
so non-fp users keep their access on entech.
|
||||
|
||||
Cross-module xmlids: env.ref(..., raise_if_not_found=False) returns
|
||||
None for menus from uninstalled modules, which we silently skip.
|
||||
"""
|
||||
office = env.ref(
|
||||
'fusion_plating.group_fp_office_user', raise_if_not_found=False,
|
||||
)
|
||||
if not office:
|
||||
_logger.warning(
|
||||
'[menu-visibility] group_fp_office_user not found; skipping'
|
||||
)
|
||||
return
|
||||
touched = 0
|
||||
for xmlid in MENU_HIDE_FROM_TECHNICIANS:
|
||||
menu = env.ref(xmlid, raise_if_not_found=False)
|
||||
if not menu:
|
||||
continue
|
||||
current_ids = set(menu.group_ids.ids)
|
||||
if current_ids == {office.id}:
|
||||
continue # already locked-down, nothing to do
|
||||
menu.sudo().group_ids = [(6, 0, [office.id])]
|
||||
touched += 1
|
||||
_logger.info(
|
||||
'[menu-visibility] restricted %s menu(s) to group_fp_office_user',
|
||||
touched,
|
||||
)
|
||||
|
||||
|
||||
def _fp_post_init_role_migration(env):
|
||||
@@ -282,6 +363,38 @@ _STARTER_KIND_BY_NAME = {
|
||||
'ready for post-plate inspection': 'gating',
|
||||
'ready for final inspection': 'gating',
|
||||
'ready for shipping': 'gating',
|
||||
# 2026-05-24 — Recipe cleanup additions (spec
|
||||
# 2026-05-24-recipe-cleanup-design.md). Covers names the existing
|
||||
# resolver didn't know that turned up in the entech recipes audit.
|
||||
# Blasting variants
|
||||
'blasting': 'blast',
|
||||
'bead blast': 'blast',
|
||||
'bead blasting': 'blast',
|
||||
'media blast': 'blast',
|
||||
'media blasting': 'blast',
|
||||
# Inspection variants
|
||||
'adhesion test coupon': 'inspect',
|
||||
'adhesion testing': 'inspect',
|
||||
'corrosion testing': 'inspect',
|
||||
'lab testing': 'inspect',
|
||||
'check sulfamate nickel area': 'inspect',
|
||||
'pre-measurements': 'inspect',
|
||||
'pre measurements': 'inspect',
|
||||
'hot water porosity': 'inspect',
|
||||
# Strip / chemical conversion / plugging (wet line)
|
||||
'strip process': 'wet_process',
|
||||
'strip process - al': 'wet_process',
|
||||
'nickel strip - aluminum line': 'wet_process',
|
||||
'chemical conversion': 'wet_process',
|
||||
'trivalent chromate conversion': 'wet_process',
|
||||
'plug the threaded holes': 'mask',
|
||||
# Misc wet-line variants seen on entech recipes
|
||||
'air dry': 'dry',
|
||||
'desmut': 'etch',
|
||||
'soak clean': 'cleaning',
|
||||
'cleaner': 'cleaning',
|
||||
'nickel strike': 'plate',
|
||||
'nickel strip': 'plate',
|
||||
}
|
||||
|
||||
|
||||
@@ -290,6 +403,9 @@ def fp_resolve_step_kind(name):
|
||||
case. Used by both the seeder and the migration backfill so we don't
|
||||
have two slightly-different lookup paths.
|
||||
|
||||
Handles parenthetical suffixes like "(Standard)", "(If Required)",
|
||||
"(A-14 / A)" by stripping them and re-trying the lookup.
|
||||
|
||||
Returns the kind str or None when no match.
|
||||
"""
|
||||
if not name:
|
||||
@@ -297,6 +413,12 @@ def fp_resolve_step_kind(name):
|
||||
key = name.strip().lower()
|
||||
if key in _STARTER_KIND_BY_NAME:
|
||||
return _STARTER_KIND_BY_NAME[key]
|
||||
# Parenthetical strip — "Masking (If Required)" → "masking",
|
||||
# "Incoming Inspection (Standard)" → "incoming inspection",
|
||||
# "Trivalent Chromate Conversion (A-14 / A)" → "trivalent chromate conversion".
|
||||
bare = re.sub(r'\s*\([^)]*\)\s*', ' ', key).strip()
|
||||
if bare and bare != key and bare in _STARTER_KIND_BY_NAME:
|
||||
return _STARTER_KIND_BY_NAME[bare]
|
||||
# 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 '):
|
||||
@@ -304,6 +426,44 @@ def fp_resolve_step_kind(name):
|
||||
return None
|
||||
|
||||
|
||||
# Translates resolver kind output to the active fp.step.kind.code values.
|
||||
# The resolver still returns the OLD vocabulary (cleaning, electroclean,
|
||||
# etch, rinse, strike, dry, wbf_test) which were deactivated in
|
||||
# 19.0.20.6.0 — those roll up to the active wet_process kind. Other
|
||||
# codes pass through 1:1. Used by the auto-classify hook on
|
||||
# fusion.plating.process.node + the recipe-cleanup migration
|
||||
# (fusion_plating_jobs 19.0.10.26.0).
|
||||
RESOLVER_KIND_TO_ACTIVE_KIND = {
|
||||
# Wet-line kinds → wet_process (active rollup)
|
||||
'cleaning': 'wet_process',
|
||||
'electroclean': 'wet_process',
|
||||
'etch': 'wet_process',
|
||||
'rinse': 'wet_process',
|
||||
'strike': 'wet_process',
|
||||
'dry': 'wet_process',
|
||||
'wbf_test': 'wet_process',
|
||||
'wet_process': 'wet_process', # the alias added in 19.0.21.3.0
|
||||
# for "Strip Process - AL", "Chemical
|
||||
# Conversion", "Trivalent Chromate
|
||||
# Conversion" maps DIRECTLY to
|
||||
# 'wet_process' — this passthrough
|
||||
# entry lets those land correctly.
|
||||
# 1:1 mappings (kind exists and is active)
|
||||
'contract_review': 'contract_review',
|
||||
'mask': 'mask',
|
||||
'racking': 'racking',
|
||||
'plate': 'plate',
|
||||
'bake': 'bake',
|
||||
'derack': 'derack',
|
||||
'demask': 'demask',
|
||||
'inspect': 'inspect',
|
||||
'final_inspect': 'final_inspect',
|
||||
'ship': 'ship',
|
||||
'gating': 'gating',
|
||||
'blast': 'blast',
|
||||
}
|
||||
|
||||
|
||||
def _seed_step_library_if_empty(env):
|
||||
"""Sub 12a — seed fp.step.template starter library.
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.21.1.3',
|
||||
'version': '19.0.21.4.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
@@ -82,6 +82,13 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'security/fp_security.xml',
|
||||
'security/fp_security_v2.xml',
|
||||
'security/ir.model.access.csv',
|
||||
# Menu visibility — loads after fp_security_v2.xml so the role
|
||||
# group xmlids exist when we add office_user to their
|
||||
# implied_ids. Loads after fp_menu.xml in spirit BUT references
|
||||
# cross-module menus (calendar, sale, hr, etc.) which exist by
|
||||
# the time fusion_plating loads, so safe to load here at
|
||||
# security-config time.
|
||||
'security/fp_menu_visibility.xml',
|
||||
'data/fp_landing_data.xml',
|
||||
'data/fp_sequence_data.xml',
|
||||
'data/fp_job_sequences.xml',
|
||||
|
||||
@@ -484,8 +484,15 @@ class SimpleRecipeController(http.Controller):
|
||||
type='jsonrpc', auth='user')
|
||||
def kinds_list(self):
|
||||
"""Sub 14b — Step Kind dropdown options for the inline library
|
||||
form. User-extensible via /fp/simple_recipe/kinds/create."""
|
||||
form. User-extensible via /fp/simple_recipe/kinds/create.
|
||||
|
||||
2026-05-24 — payload now includes `area_kind` + a humanized
|
||||
`area_kind_label` so the Simple Editor picker can render
|
||||
"Masking — Masking column" and authors see which Shop Floor
|
||||
column they're routing the step to.
|
||||
"""
|
||||
Kind = request.env['fp.step.kind']
|
||||
area_labels = dict(Kind._fields['area_kind'].selection)
|
||||
return {
|
||||
'kinds': [
|
||||
{
|
||||
@@ -494,6 +501,8 @@ class SimpleRecipeController(http.Controller):
|
||||
'name': k.name or '',
|
||||
'icon': k.icon or '',
|
||||
'sequence': k.sequence,
|
||||
'area_kind': k.area_kind or '',
|
||||
'area_kind_label': area_labels.get(k.area_kind, ''),
|
||||
}
|
||||
for k in Kind.search(
|
||||
[('active', '=', True)], order='sequence, name',
|
||||
|
||||
@@ -18,7 +18,15 @@
|
||||
(covers all bath-based steps).
|
||||
- `mask` covers Masking + De-Masking, `racking` covers
|
||||
Racking + De-Racking — operators differentiate by the
|
||||
step name. -->
|
||||
step name.
|
||||
|
||||
2026-05-24 update (19.0.21.2.0 — Shop Floor live-step fix):
|
||||
- New `area_kind` field on fp.step.kind drives plant-view
|
||||
column routing. Every record below carries an
|
||||
area_kind. New `blast` kind for the Blasting column.
|
||||
- `derack`, `demask`, `gating` get re-activated via the
|
||||
pre-migrate (they're listed under "ACTIVE KINDS" here
|
||||
now since they're meant to be active going forward). -->
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ACTIVE KINDS — visible in dropdown -->
|
||||
@@ -29,13 +37,7 @@
|
||||
<field name="name">Other</field>
|
||||
<field name="sequence">5</field>
|
||||
<field name="icon">fa-circle-o</field>
|
||||
</record>
|
||||
|
||||
<record id="step_kind_wet_process" model="fp.step.kind">
|
||||
<field name="code">wet_process</field>
|
||||
<field name="name">Wet Process (Clean / Rinse / Etch / Dry / etc.)</field>
|
||||
<field name="sequence">55</field>
|
||||
<field name="icon">fa-tint</field>
|
||||
<field name="area_kind">plating</field>
|
||||
</record>
|
||||
|
||||
<record id="step_kind_receiving" model="fp.step.kind">
|
||||
@@ -43,144 +45,182 @@
|
||||
<field name="name">Receiving / Incoming Inspection</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="icon">fa-truck</field>
|
||||
<field name="area_kind">receiving</field>
|
||||
</record>
|
||||
<record id="step_kind_contract_review" model="fp.step.kind">
|
||||
<field name="code">contract_review</field>
|
||||
<field name="name">Contract Review (QA-005)</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="icon">fa-file-text-o</field>
|
||||
<field name="area_kind">receiving</field>
|
||||
</record>
|
||||
<record id="step_kind_racking" model="fp.step.kind">
|
||||
<field name="code">racking</field>
|
||||
<field name="name">Racking</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="icon">fa-server</field>
|
||||
<field name="area_kind">racking</field>
|
||||
</record>
|
||||
<record id="step_kind_blast" model="fp.step.kind">
|
||||
<field name="code">blast</field>
|
||||
<field name="name">Blasting / Media Blast</field>
|
||||
<field name="sequence">35</field>
|
||||
<field name="icon">fa-bullseye</field>
|
||||
<field name="area_kind">blasting</field>
|
||||
</record>
|
||||
<record id="step_kind_mask" model="fp.step.kind">
|
||||
<field name="code">mask</field>
|
||||
<field name="name">Masking</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="icon">fa-eye-slash</field>
|
||||
<field name="area_kind">masking</field>
|
||||
</record>
|
||||
<record id="step_kind_cleaning" model="fp.step.kind">
|
||||
<field name="code">cleaning</field>
|
||||
<field name="name">Cleaning</field>
|
||||
<field name="sequence">50</field>
|
||||
<field name="icon">fa-tint</field>
|
||||
<field name="area_kind">plating</field>
|
||||
</record>
|
||||
<record id="step_kind_wet_process" model="fp.step.kind">
|
||||
<field name="code">wet_process</field>
|
||||
<field name="name">Wet Process (Clean / Rinse / Etch / Dry / etc.)</field>
|
||||
<field name="sequence">55</field>
|
||||
<field name="icon">fa-tint</field>
|
||||
<field name="area_kind">plating</field>
|
||||
</record>
|
||||
<record id="step_kind_electroclean" model="fp.step.kind">
|
||||
<field name="code">electroclean</field>
|
||||
<field name="name">Electroclean</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="icon">fa-bolt</field>
|
||||
<field name="area_kind">plating</field>
|
||||
</record>
|
||||
<record id="step_kind_etch" model="fp.step.kind">
|
||||
<field name="code">etch</field>
|
||||
<field name="name">Etch / Activation</field>
|
||||
<field name="sequence">70</field>
|
||||
<field name="icon">fa-flask</field>
|
||||
<field name="area_kind">plating</field>
|
||||
</record>
|
||||
<record id="step_kind_rinse" model="fp.step.kind">
|
||||
<field name="code">rinse</field>
|
||||
<field name="name">Rinse</field>
|
||||
<field name="sequence">80</field>
|
||||
<field name="icon">fa-tint</field>
|
||||
<field name="area_kind">plating</field>
|
||||
</record>
|
||||
<record id="step_kind_strike" model="fp.step.kind">
|
||||
<field name="code">strike</field>
|
||||
<field name="name">Strike (Wood's Nickel / Activation)</field>
|
||||
<field name="sequence">90</field>
|
||||
<field name="icon">fa-bolt</field>
|
||||
<field name="area_kind">plating</field>
|
||||
</record>
|
||||
<record id="step_kind_plate" model="fp.step.kind">
|
||||
<field name="code">plate</field>
|
||||
<field name="name">Plating</field>
|
||||
<field name="sequence">100</field>
|
||||
<field name="icon">fa-shield</field>
|
||||
<field name="area_kind">plating</field>
|
||||
</record>
|
||||
<record id="step_kind_replenishment" model="fp.step.kind">
|
||||
<field name="code">replenishment</field>
|
||||
<field name="name">Tank Replenishment</field>
|
||||
<field name="sequence">110</field>
|
||||
<field name="icon">fa-plus-circle</field>
|
||||
<field name="area_kind">plating</field>
|
||||
</record>
|
||||
<record id="step_kind_wbf_test" model="fp.step.kind">
|
||||
<field name="code">wbf_test</field>
|
||||
<field name="name">Water Break Free Test</field>
|
||||
<field name="sequence">120</field>
|
||||
<field name="icon">fa-check-square-o</field>
|
||||
<field name="area_kind">plating</field>
|
||||
</record>
|
||||
<record id="step_kind_dry" model="fp.step.kind">
|
||||
<field name="code">dry</field>
|
||||
<field name="name">Drying</field>
|
||||
<field name="sequence">130</field>
|
||||
<field name="icon">fa-sun-o</field>
|
||||
<field name="area_kind">plating</field>
|
||||
</record>
|
||||
<record id="step_kind_bake" model="fp.step.kind">
|
||||
<field name="code">bake</field>
|
||||
<field name="name">Bake (HE Relief / Stress Relief)</field>
|
||||
<field name="sequence">140</field>
|
||||
<field name="icon">fa-fire</field>
|
||||
<field name="area_kind">baking</field>
|
||||
</record>
|
||||
<record id="step_kind_demask" model="fp.step.kind">
|
||||
<field name="code">demask</field>
|
||||
<field name="name">De-Masking</field>
|
||||
<field name="sequence">150</field>
|
||||
<field name="icon">fa-eye</field>
|
||||
<field name="area_kind">de_racking</field>
|
||||
</record>
|
||||
<record id="step_kind_derack" model="fp.step.kind">
|
||||
<field name="code">derack</field>
|
||||
<field name="name">De-Racking</field>
|
||||
<field name="sequence">160</field>
|
||||
<field name="icon">fa-server</field>
|
||||
<field name="area_kind">de_racking</field>
|
||||
</record>
|
||||
<record id="step_kind_inspect" model="fp.step.kind">
|
||||
<field name="code">inspect</field>
|
||||
<field name="name">Inspection</field>
|
||||
<field name="sequence">170</field>
|
||||
<field name="icon">fa-search</field>
|
||||
<field name="area_kind">inspection</field>
|
||||
</record>
|
||||
<record id="step_kind_hardness_test" model="fp.step.kind">
|
||||
<field name="code">hardness_test</field>
|
||||
<field name="name">Hardness Test (HV / HK / HRC)</field>
|
||||
<field name="sequence">180</field>
|
||||
<field name="icon">fa-tachometer</field>
|
||||
<field name="area_kind">inspection</field>
|
||||
</record>
|
||||
<record id="step_kind_adhesion_test" model="fp.step.kind">
|
||||
<field name="code">adhesion_test</field>
|
||||
<field name="name">Adhesion Test</field>
|
||||
<field name="sequence">190</field>
|
||||
<field name="icon">fa-link</field>
|
||||
<field name="area_kind">inspection</field>
|
||||
</record>
|
||||
<record id="step_kind_salt_spray" model="fp.step.kind">
|
||||
<field name="code">salt_spray</field>
|
||||
<field name="name">Salt Spray / Corrosion Test</field>
|
||||
<field name="sequence">200</field>
|
||||
<field name="icon">fa-cloud</field>
|
||||
<field name="area_kind">inspection</field>
|
||||
</record>
|
||||
<record id="step_kind_final_inspect" model="fp.step.kind">
|
||||
<field name="code">final_inspect</field>
|
||||
<field name="name">Final Inspection</field>
|
||||
<field name="sequence">210</field>
|
||||
<field name="icon">fa-check-circle</field>
|
||||
<field name="area_kind">inspection</field>
|
||||
</record>
|
||||
<record id="step_kind_packaging" model="fp.step.kind">
|
||||
<field name="code">packaging</field>
|
||||
<field name="name">Packaging / Pre-Ship</field>
|
||||
<field name="sequence">220</field>
|
||||
<field name="icon">fa-archive</field>
|
||||
<field name="area_kind">shipping</field>
|
||||
</record>
|
||||
<record id="step_kind_ship" model="fp.step.kind">
|
||||
<field name="code">ship</field>
|
||||
<field name="name">Shipping</field>
|
||||
<field name="sequence">230</field>
|
||||
<field name="icon">fa-paper-plane</field>
|
||||
<field name="area_kind">shipping</field>
|
||||
</record>
|
||||
<record id="step_kind_gating" model="fp.step.kind">
|
||||
<field name="code">gating</field>
|
||||
<field name="name">Gating</field>
|
||||
<field name="sequence">240</field>
|
||||
<field name="icon">fa-pause-circle</field>
|
||||
<field name="area_kind">receiving</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================
|
||||
@@ -955,5 +995,8 @@
|
||||
|
||||
<!-- gating: intentionally no default inputs -->
|
||||
|
||||
<!-- blast: intentionally no default inputs (operator picks
|
||||
approach by step name + recipe instructions) -->
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -103,4 +103,30 @@
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<!-- 2026-05-24 additions (19.0.21.2.0 — Shop Floor live-step fix) -->
|
||||
|
||||
<record id="fp_step_template_hwp_a15" model="fp.step.template">
|
||||
<field name="name">Hot Water Porosity Test (A-15)</field>
|
||||
<field name="code">HWP_A15</field>
|
||||
<field name="kind_id" ref="step_kind_inspect"/>
|
||||
<field name="icon">fa-tint</field>
|
||||
<field name="description"><![CDATA[
|
||||
<p>Hot-water porosity test for plated samples. Verify continuity
|
||||
of the deposit across the test panel; record any porosity sites
|
||||
and attach a photo when a defect is found.</p>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
<record id="fp_step_template_final_pkg_std" model="fp.step.template">
|
||||
<field name="name">Final Inspection / Packaging</field>
|
||||
<field name="code">FINAL_PKG_STD</field>
|
||||
<field name="kind_id" ref="step_kind_final_inspect"/>
|
||||
<field name="icon">fa-check-circle</field>
|
||||
<field name="description"><![CDATA[
|
||||
<p>Combined final visual + dimensional inspection followed by
|
||||
packaging into the customer's original boxes for shipment.
|
||||
Verify part count, attach certs, photo the sealed load.</p>
|
||||
]]></field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""19.0.21.2.0 — Shop Floor live-step + kind taxonomy.
|
||||
|
||||
Seeds fp.step.kind.area_kind on existing kinds BEFORE the required
|
||||
NOT NULL constraint on the new field hits the schema. Also activates
|
||||
the three kinds (derack/demask/gating) that were deactivated in
|
||||
19.0.20.6.0 but are needed for the full area_kind taxonomy.
|
||||
|
||||
Idempotent: only fills NULL / inactive rows.
|
||||
|
||||
See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
|
||||
"""
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
KIND_TO_AREA = {
|
||||
'other': 'plating',
|
||||
'wet_process': 'plating',
|
||||
'receiving': 'receiving',
|
||||
'contract_review': 'receiving',
|
||||
'gating': 'receiving',
|
||||
'racking': 'racking',
|
||||
'derack': 'de_racking',
|
||||
'mask': 'masking',
|
||||
'demask': 'de_racking',
|
||||
'cleaning': 'plating',
|
||||
'electroclean': 'plating',
|
||||
'etch': 'plating',
|
||||
'rinse': 'plating',
|
||||
'strike': 'plating',
|
||||
'plate': 'plating',
|
||||
'replenishment': 'plating',
|
||||
'wbf_test': 'plating',
|
||||
'dry': 'plating',
|
||||
'bake': 'baking',
|
||||
'inspect': 'inspection',
|
||||
'final_inspect': 'inspection',
|
||||
'hardness_test': 'inspection',
|
||||
'adhesion_test': 'inspection',
|
||||
'salt_spray': 'inspection',
|
||||
'packaging': 'shipping',
|
||||
'ship': 'shipping',
|
||||
'blast': 'blasting',
|
||||
'bead_blast': 'blasting',
|
||||
'media_blast': 'blasting',
|
||||
}
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
# Phase 1 — Pre-create the column NULL-permitting so we can seed it
|
||||
# BEFORE Odoo's schema sync tries to enforce NOT NULL.
|
||||
cr.execute(
|
||||
"ALTER TABLE fp_step_kind "
|
||||
"ADD COLUMN IF NOT EXISTS area_kind VARCHAR"
|
||||
)
|
||||
|
||||
# Phase 2 — Seed area_kind on existing kinds. Idempotent: only fills
|
||||
# NULLs, so re-running -u is safe.
|
||||
seeded = 0
|
||||
for code, area in KIND_TO_AREA.items():
|
||||
cr.execute(
|
||||
"UPDATE fp_step_kind SET area_kind = %s "
|
||||
"WHERE code = %s "
|
||||
"AND (area_kind IS NULL OR area_kind = '')",
|
||||
(area, code),
|
||||
)
|
||||
seeded += cr.rowcount
|
||||
_logger.info(
|
||||
'[live-step-fix] kind.area_kind seeded on %s known-code rows',
|
||||
seeded,
|
||||
)
|
||||
|
||||
# Phase 3 — Fallback: any user-created custom kinds not in our seed
|
||||
# map → 'plating'. Clears the NOT NULL constraint for any leftover.
|
||||
cr.execute(
|
||||
"UPDATE fp_step_kind SET area_kind = 'plating' "
|
||||
"WHERE area_kind IS NULL OR area_kind = ''"
|
||||
)
|
||||
if cr.rowcount:
|
||||
_logger.info(
|
||||
'[live-step-fix] %s unknown kinds defaulted to plating',
|
||||
cr.rowcount,
|
||||
)
|
||||
|
||||
# Phase 4 — Activate kinds we need for full coverage.
|
||||
activated = 0
|
||||
for code in ('derack', 'demask', 'gating'):
|
||||
cr.execute(
|
||||
"UPDATE fp_step_kind SET active = TRUE "
|
||||
"WHERE code = %s AND active = FALSE",
|
||||
(code,),
|
||||
)
|
||||
activated += cr.rowcount
|
||||
_logger.info(
|
||||
'[live-step-fix] %s kinds activated (derack/demask/gating)',
|
||||
activated,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""19.0.21.4.0 — Apply office-user menu visibility on -u.
|
||||
|
||||
post_init_hook only fires on FIRST install (CLAUDE.md Rule 13d).
|
||||
This script runs the same helper on every -u so existing installs
|
||||
get the menu restrictions applied without needing to uninstall +
|
||||
reinstall. Idempotent — the helper checks current state and skips
|
||||
already-restricted menus.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo.api import Environment, SUPERUSER_ID
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
from odoo.addons.fusion_plating import _fp_apply_office_user_menu_visibility
|
||||
env = Environment(cr, SUPERUSER_ID, {})
|
||||
_fp_apply_office_user_menu_visibility(env)
|
||||
@@ -784,6 +784,62 @@ class FpProcessNode(models.Model):
|
||||
return self.action_open_simple_editor()
|
||||
return self.action_open_tree_editor()
|
||||
|
||||
# ---- Auto-classify kind from name (2026-05-24, spec
|
||||
# docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md) -------
|
||||
# Safety net: when a node's kind is the catch-all 'other' AND its
|
||||
# name resolves via fp_resolve_step_kind(), upgrade kind_id to the
|
||||
# resolved active kind. Runs on create() and on write() when name
|
||||
# or kind_id changes. Prevents recipe authoring + recipe
|
||||
# duplication from silently leaving nodes as 'other' (which then
|
||||
# routes them to the wrong Shop Floor column).
|
||||
#
|
||||
# Skip with context flag fp_skip_kind_autoclassify=True for admin
|
||||
# workflows that need to keep kind=other despite a known name.
|
||||
|
||||
def _fp_autoclassify_kind(self):
|
||||
"""Upgrade kind_id when current is 'other' and name resolves."""
|
||||
if self.env.context.get('fp_skip_kind_autoclassify'):
|
||||
return
|
||||
from odoo.addons.fusion_plating import (
|
||||
fp_resolve_step_kind,
|
||||
RESOLVER_KIND_TO_ACTIVE_KIND,
|
||||
)
|
||||
Kind = self.env['fp.step.kind']
|
||||
other = Kind.search([('code', '=', 'other')], limit=1)
|
||||
if not other:
|
||||
return
|
||||
# Cache active-kind ids by code so we don't re-search per row.
|
||||
kind_by_code = {}
|
||||
for node in self:
|
||||
if not node.name or node.kind_id != other:
|
||||
continue
|
||||
resolver_code = fp_resolve_step_kind(node.name)
|
||||
if not resolver_code:
|
||||
continue
|
||||
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
|
||||
if not target_code:
|
||||
continue
|
||||
if target_code not in kind_by_code:
|
||||
tgt = Kind.search([('code', '=', target_code)], limit=1)
|
||||
kind_by_code[target_code] = tgt.id if tgt else False
|
||||
target_id = kind_by_code[target_code]
|
||||
if target_id:
|
||||
node.with_context(
|
||||
fp_skip_kind_autoclassify=True,
|
||||
).write({'kind_id': target_id})
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
nodes = super().create(vals_list)
|
||||
nodes._fp_autoclassify_kind()
|
||||
return nodes
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if 'name' in vals or 'kind_id' in vals:
|
||||
self._fp_autoclassify_kind()
|
||||
return res
|
||||
|
||||
# ---- Copy (deep-duplicate) -----------------------------------------------
|
||||
|
||||
def copy(self, default=None):
|
||||
|
||||
@@ -34,6 +34,31 @@ class FpStepKind(models.Model):
|
||||
string='Icon',
|
||||
default='fa-cog',
|
||||
)
|
||||
# 2026-05-24 — Shop Floor live-step fix.
|
||||
# Each kind self-declares which plant-view column its steps land in.
|
||||
# Replaces the hardcoded _STEP_KIND_TO_AREA dict (removed from
|
||||
# fusion_plating_jobs/models/fp_job_step.py). Pre-migrate
|
||||
# 19.0.21.2.0 seeds existing rows before NOT NULL hits the schema.
|
||||
area_kind = fields.Selection(
|
||||
[
|
||||
('receiving', 'Receiving'),
|
||||
('masking', 'Masking'),
|
||||
('blasting', 'Blasting'),
|
||||
('racking', 'Racking'),
|
||||
('plating', 'Plating'),
|
||||
('baking', 'Baking'),
|
||||
('de_racking', 'De-Racking'),
|
||||
('inspection', 'Final Inspection'),
|
||||
('shipping', 'Shipping'),
|
||||
],
|
||||
string='Shop Floor Column',
|
||||
required=True,
|
||||
index=True,
|
||||
help='Determines which column on the Shop Floor plant kanban shows '
|
||||
'cards whose active step uses this kind. Step kinds drive '
|
||||
'routing automatically — picking a kind tells the system both '
|
||||
'what gates fire AND where the card lives.',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', string='Company',
|
||||
default=lambda self: self.env.company,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?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.
|
||||
|
||||
2026-05-24 — Hide non-essential app menus from Technicians.
|
||||
|
||||
Per user request: technicians should see ONLY the apps they actually
|
||||
need on the tablet — Discuss, To-do, Plating, AI, Maintenance, Time
|
||||
Off. Every other top-level app menu is restricted to a new "office
|
||||
user" group implied by every fp role ABOVE technician.
|
||||
|
||||
THIS FILE only declares the office_user group + the implied_ids
|
||||
chain. The actual menu group-restriction is applied via a
|
||||
post_init_hook / post-migrate script (see fusion_plating/__init__.py
|
||||
and migrations/19.0.21.4.0/post-migrate.py), because cross-module
|
||||
<menuitem id="other_module.X" groups="..."/> overrides require the
|
||||
other module in `depends`, which would lock us into hard
|
||||
dependencies on calendar/sale/hr/etc. The hook uses
|
||||
env.ref(..., raise_if_not_found=False) — modules that aren't
|
||||
installed are silently skipped.
|
||||
|
||||
Why a separate office_user group instead of !technician?
|
||||
Manager → ... → Technician via implied_ids, so a Manager IS a
|
||||
technician for group-matching purposes. A "!technician" filter would
|
||||
hide menus from managers too. The office_user pattern flips that:
|
||||
we add a new group that's implied by manager+ (and explicitly NOT
|
||||
by technician), then require it on the menus we want to hide.
|
||||
-->
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- New marker group: "Office User" — implied by every non- -->
|
||||
<!-- technician fp role. -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="group_fp_office_user" model="res.groups">
|
||||
<field name="name">Plating: Office User (sees back-office menus)</field>
|
||||
<field name="privilege_id"
|
||||
ref="fusion_plating.res_groups_privilege_fusion_plating"/>
|
||||
<field name="sequence">90</field>
|
||||
<field name="comment">Marker group that controls visibility of
|
||||
non-tablet app menus (Calendar, Sales, Inventory, etc.).
|
||||
Implied by every fp role above Technician (Owner, Manager,
|
||||
Quality Manager, Shop Manager, Sales Rep, Estimator).
|
||||
Pure Technicians don't have it, so they only see the
|
||||
tablet apps (Plating, Discuss, To-do, AI, Maintenance,
|
||||
Time Off).</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Add office_user to implied_ids of each above-technician role -->
|
||||
<!-- These records UPDATE existing groups (additive Command.link) -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="group_fp_sales_rep" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
|
||||
</record>
|
||||
<record id="group_fp_shop_manager_v2" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
|
||||
</record>
|
||||
<record id="group_fp_manager" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
|
||||
</record>
|
||||
<record id="group_fp_quality_manager" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
|
||||
</record>
|
||||
<record id="group_fp_owner" model="res.groups">
|
||||
<field name="implied_ids" eval="[(4, ref('group_fp_office_user'))]"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -509,6 +509,7 @@
|
||||
<t t-foreach="state.kindOptions || []" t-as="k" t-key="k.id">
|
||||
<option t-att-value="k.code" t-att-selected="k.code === state.libraryEditor.default_kind">
|
||||
<t t-esc="k.name"/>
|
||||
<t t-if="k.area_kind_label"> — <t t-esc="k.area_kind_label"/> column</t>
|
||||
</option>
|
||||
</t>
|
||||
<!-- Manager-only inline create. The
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="area_kind" decoration-info="True"/>
|
||||
<field name="icon"/>
|
||||
<field name="template_count"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
@@ -47,6 +48,8 @@
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="area_kind" widget="badge"
|
||||
help="Cards whose active step uses this kind appear in this column on the Shop Floor plant kanban."/>
|
||||
<field name="sequence"/>
|
||||
<field name="company_id"
|
||||
groups="base.group_multi_company"/>
|
||||
@@ -94,8 +97,13 @@
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="area_kind"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||
<group>
|
||||
<filter string="Shop Floor Column" name="group_area_kind"
|
||||
context="{'group_by': 'area_kind'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.10.24.2',
|
||||
'version': '19.0.10.31.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""19.0.10.25.0 — Template metadata backfill + recipe-node repointing.
|
||||
|
||||
Runs AFTER fusion_plating's pre-migrate 19.0.21.2.0 (which seeds
|
||||
kind.area_kind and activates derack/demask/gating). At this point:
|
||||
- All kinds have area_kind set.
|
||||
- blast / derack / demask / gating exist and are active.
|
||||
- XML data files have loaded (new templates exist).
|
||||
|
||||
This migration:
|
||||
1. Backfills code / description / icon / kind_id on the ~30 library
|
||||
templates seeded without metadata.
|
||||
2. Repoints existing recipe nodes from wrong kinds to correct ones
|
||||
using unambiguous name patterns.
|
||||
3. Recomputes area_kind on all fp.job.step rows.
|
||||
4. Recomputes active_step_id + card_state on in-flight jobs.
|
||||
|
||||
All phases idempotent — re-running -u is safe.
|
||||
|
||||
See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo.api import Environment, SUPERUSER_ID
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# (name : (code, icon, kind_code, description_snippet))
|
||||
TEMPLATE_BACKFILL = {
|
||||
'Acid Dip': ('ACID_DIP_STD', 'fa-flask', 'wet_process', 'Short acid immersion to activate the substrate before plating.'),
|
||||
'Air Dry': ('AIR_DRY_STD', 'fa-sun-o', 'wet_process', 'Air drying step between wet-line operations.'),
|
||||
'Bake': ('BAKE_STD', 'fa-fire', 'bake', 'Post-plate bake for hydrogen embrittlement relief.'),
|
||||
'Blasting': ('BLAST_STD', 'fa-bullseye', 'blast', 'Media or bead blasting to prepare the substrate.'),
|
||||
'Check Sulfamate Nickel Area': ('CHECK_SN_AREA', 'fa-search', 'inspect', 'Quick visual area check on the sulfamate nickel line.'),
|
||||
'Contract Review': ('CR_STD', 'fa-file-text-o', 'contract_review', 'QA-005 contract review gate. Required when the customer flag is on.'),
|
||||
'De-Masking': ('DEMASK_STD', 'fa-eraser', 'demask', 'Remove masking material after plating. Folds into De-Racking column.'),
|
||||
'DeRacking': ('DERACK_STD', 'fa-th', 'derack', 'Remove parts from racks for inspection / packaging.'),
|
||||
'Desmut': ('DESMUT_STD', 'fa-flask', 'wet_process', 'Remove smut from aluminium surfaces after etching.'),
|
||||
'Drying': ('DRYING_STD', 'fa-sun-o', 'wet_process', 'Drying step (oven or air) at the end of the wet line.'),
|
||||
'E-Nickel Plating': ('ENP_STD', 'fa-diamond', 'plate', 'Electroless nickel plate operation. Time and temp per recipe.'),
|
||||
'Electroclean': ('ECLEAN_STD', 'fa-bolt', 'wet_process', 'Anodic / cathodic electrocleaning step on the cleaning line.'),
|
||||
'Etch': ('ETCH_STD', 'fa-flask', 'wet_process', 'Chemical etching to prepare the substrate.'),
|
||||
'Final Inspection': ('FINAL_INSP_STD', 'fa-check-circle', 'final_inspect', 'Final visual + dimensional QA before packing.'),
|
||||
'HCl Activation': ('HCL_ACT_STD', 'fa-flask', 'wet_process', 'HCl activation dip prior to strike or plate.'),
|
||||
'Inspection': ('INSP_STD', 'fa-search', 'inspect', 'In-process inspection step.'),
|
||||
'Masking': ('MASK_STD', 'fa-paint-brush', 'mask', 'Apply masking to areas that should not be plated.'),
|
||||
'Nickel Strip (S-1)': ('NI_STRIP_S1', 'fa-undo', 'wet_process', 'Chemical strip of prior nickel deposit (rework path).'),
|
||||
'Nickel Strip - Steel Line': ('NI_STRIP_SL', 'fa-undo', 'wet_process', 'Chemical strip on the steel line (rework path).'),
|
||||
'Post-plate Inspection': ('POST_INSP_STD', 'fa-check-circle', 'inspect', 'Post-plate inspection - thickness sample + visual.'),
|
||||
'Pre-Measurements': ('PRE_MEAS_STD', 'fa-tachometer', 'inspect', 'Pre-process dimensional measurements (FAIR start point).'),
|
||||
'Racking': ('RACK_STD', 'fa-th', 'racking', 'Load parts onto racks for plating.'),
|
||||
'Ready for Plating': ('GATE_PLATE', 'fa-flag', 'gating', 'Gating step - parts staged ready for the plating line.'),
|
||||
'Ready for processing': ('GATE_PROC', 'fa-flag', 'gating', 'Generic gating step - parts staged ready for the next operation.'),
|
||||
'Rinse': ('RINSE_STD', 'fa-tint', 'wet_process', 'Rinse step between wet-line operations.'),
|
||||
'Shipping': ('SHIP_STD', 'fa-paper-plane', 'ship', 'Final shipping / hand-off to logistics.'),
|
||||
'Soak Clean': ('SOAK_CLEAN_STD', 'fa-bathtub', 'wet_process', 'Soak cleaning step at the start of the wet line.'),
|
||||
'Surface Activation': ('SURF_ACT_STD', 'fa-flask', 'wet_process', 'Surface activation dip prior to plate.'),
|
||||
'Water Break Test': ('WBF_TEST_STD', 'fa-tint', 'wet_process', 'Water-break test for surface cleanliness.'),
|
||||
'Zincate': ('ZINCATE_STD', 'fa-flask', 'wet_process', 'Zincate immersion on aluminium prior to plate.'),
|
||||
}
|
||||
|
||||
# (filter_sql, current_kind_code, new_kind_code, description)
|
||||
# current_kind_code=None means "any kind that isn't the target"
|
||||
NODE_REPOINTING = [
|
||||
("n.name = 'Blasting'", 'other', 'blast', 'Blasting -> blast'),
|
||||
("n.name ILIKE 'Ready %%'", None, 'gating', 'Ready For X -> gating'),
|
||||
# De-Masking: anchored ILIKE (must start with "De-Masking" or
|
||||
# "DeMasking") so we don't match "Ready For De-Masking" which the
|
||||
# earlier 'Ready %' rule already moved to gating. cur_code=None
|
||||
# catches both 'mask' (the 34 lazy/legacy ones) and 'other' (4
|
||||
# older nodes never reclassified after the demask kind was added).
|
||||
# The trailing AND k.code != %s safeguard skips already-correct rows.
|
||||
("(n.name ILIKE 'De-Masking%%' OR n.name ILIKE 'DeMasking%%')", None, 'demask', 'De-Masking -> demask'),
|
||||
("n.name = 'Scheduling'", 'other', 'gating', 'Scheduling -> gating'),
|
||||
("n.name ILIKE '%%Nickel Strip%%'", 'plate', 'wet_process', 'Nickel Strip -> wet_process'),
|
||||
("n.name ILIKE '%%Pre-Measurement%%' OR n.name ILIKE '%%Check Sulfamate%%'", 'other', 'inspect', 'Pre-Meas/Check Sulfamate -> inspect'),
|
||||
]
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
env = Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
# Phase 1 — Template metadata backfill. Idempotent: only fills
|
||||
# NULL/empty fields, doesn't overwrite admin edits.
|
||||
Tpl = env['fp.step.template']
|
||||
Kind = env['fp.step.kind']
|
||||
fixed_tpl = 0
|
||||
for name, (code, icon, kind_code, desc) in TEMPLATE_BACKFILL.items():
|
||||
tpl = Tpl.search([('name', '=', name)], limit=1)
|
||||
if not tpl:
|
||||
continue
|
||||
vals = {}
|
||||
if not tpl.code:
|
||||
vals['code'] = code
|
||||
cur_desc = (tpl.description or '').strip()
|
||||
if cur_desc in ('', '<p><br></p>', '<p></p>'):
|
||||
vals['description'] = '<p>%s</p>' % desc
|
||||
if tpl.icon == 'fa-cog':
|
||||
vals['icon'] = icon
|
||||
kind = Kind.search([('code', '=', kind_code)], limit=1)
|
||||
if kind and tpl.kind_id.code != kind_code:
|
||||
vals['kind_id'] = kind.id
|
||||
if vals:
|
||||
tpl.write(vals)
|
||||
fixed_tpl += 1
|
||||
_logger.info(
|
||||
'[live-step-fix] template metadata backfilled: %s templates updated',
|
||||
fixed_tpl,
|
||||
)
|
||||
|
||||
# Phase 2 — Recipe node repointing. Idempotent: AND k.code != %s
|
||||
# ensures already-correct rows are skipped on re-run.
|
||||
for filter_sql, cur_code, new_code, desc in NODE_REPOINTING:
|
||||
params = [new_code]
|
||||
sql = (
|
||||
"UPDATE fusion_plating_process_node n "
|
||||
"SET kind_id = (SELECT id FROM fp_step_kind WHERE code = %s LIMIT 1) "
|
||||
"FROM fp_step_kind k "
|
||||
"WHERE n.kind_id = k.id "
|
||||
"AND (" + filter_sql + ")"
|
||||
)
|
||||
if cur_code is not None:
|
||||
sql += " AND k.code = %s"
|
||||
params.append(cur_code)
|
||||
sql += " AND k.code != %s"
|
||||
params.append(new_code)
|
||||
cr.execute(sql, params)
|
||||
_logger.info(
|
||||
'[live-step-fix] repointed %s nodes: %s',
|
||||
cr.rowcount, desc,
|
||||
)
|
||||
|
||||
# Phase 3 — Recompute area_kind on every fp.job.step row.
|
||||
steps = env['fp.job.step'].search([])
|
||||
if steps:
|
||||
steps._compute_area_kind()
|
||||
steps.flush_recordset(['area_kind'])
|
||||
_logger.info(
|
||||
'[live-step-fix] recomputed area_kind on %s steps', len(steps),
|
||||
)
|
||||
|
||||
# Phase 4 — Recompute active_step_id + card_state on in-flight jobs.
|
||||
jobs = env['fp.job'].search([
|
||||
('state', 'in', ('confirmed', 'in_progress')),
|
||||
])
|
||||
if jobs:
|
||||
jobs._compute_active_step_id()
|
||||
jobs._compute_card_state()
|
||||
jobs.flush_recordset(['active_step_id', 'card_state'])
|
||||
_logger.info(
|
||||
'[live-step-fix] recomputed active_step_id + card_state on %s jobs',
|
||||
len(jobs),
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""19.0.10.26.0 - Recipe cleanup + per-part clone delete.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md
|
||||
|
||||
Phases (in order):
|
||||
1. Resequence recipe 3620 ENP-ALUM-BASIC operations + delete the
|
||||
duplicate empty ENP-Alum Line sub_process (id 4056).
|
||||
2. Backfill kind on all kind=other nodes via the extended
|
||||
fp_resolve_step_kind() resolver + RESOLVER_KIND_TO_ACTIVE_KIND
|
||||
translation.
|
||||
3. Delete all per-part clone recipes (name ILIKE '% - %').
|
||||
CASCADE handles child nodes; SET NULL handles fp.job /
|
||||
fp.job.step / fp.coating.config / fp.pricing.rule /
|
||||
fp.part.catalog references.
|
||||
4. Recompute fp.job.step.area_kind on all rows.
|
||||
5. Recompute fp.job.active_step_id + card_state on in-flight jobs.
|
||||
|
||||
All phases idempotent - re-running -u is safe.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from odoo.api import Environment, SUPERUSER_ID
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Recipe 3620's ops in the desired final order. Maps the existing node
|
||||
# id (as documented in the spec) to its target sequence. The user
|
||||
# decided mask-first-then-rack (matches the existing De-Masking step's
|
||||
# position between Plating and Bake; de-mask before de-rack would be
|
||||
# illogical).
|
||||
RECIPE_3620_RESEQUENCE = [
|
||||
# (node_id, new_sequence, expected_name)
|
||||
(3853, 10, 'Contract Review'),
|
||||
(3854, 20, 'Incoming Inspection (Standard)'),
|
||||
(3877, 30, 'Masking'),
|
||||
(3855, 40, 'Racking'),
|
||||
(3858, 50, 'Ready for processing'),
|
||||
(3859, 60, 'ENP-Alum Line'),
|
||||
(3861, 70, 'De-Masking'),
|
||||
(3864, 80, 'Oven baking'),
|
||||
(3867, 90, 'De-racking'),
|
||||
(4067, 100, 'Oven bake (Post de-rack)'),
|
||||
(3873, 110, 'Post-plate Inspection'),
|
||||
(3876, 120, 'Final Inspection'),
|
||||
]
|
||||
|
||||
# Empty duplicate ENP-Alum Line sub_process on recipe 3620 (no
|
||||
# children - the real one is id 3859 with E-Nickel Plating as child).
|
||||
RECIPE_3620_DUPLICATE_TO_DELETE = 4056
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
env = Environment(cr, SUPERUSER_ID, {})
|
||||
|
||||
# ============================================================
|
||||
# Phase 1 - Resequence recipe 3620 + delete duplicate sub_process
|
||||
# ============================================================
|
||||
Node = env['fusion.plating.process.node']
|
||||
recipe_3620 = Node.browse(3620).exists()
|
||||
if not recipe_3620:
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Recipe 3620 ENP-ALUM-BASIC not found; '
|
||||
'skipping resequence phase'
|
||||
)
|
||||
else:
|
||||
# Resequence idempotently - only update if sequence differs.
|
||||
renumbered = 0
|
||||
for node_id, new_seq, expected_name in RECIPE_3620_RESEQUENCE:
|
||||
node = Node.browse(node_id).exists()
|
||||
if not node:
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Recipe 3620: expected node %s '
|
||||
'("%s") not found; skipping',
|
||||
node_id, expected_name,
|
||||
)
|
||||
continue
|
||||
if node.sequence != new_seq:
|
||||
# Skip autoclassify - we only touch sequence here.
|
||||
node.with_context(
|
||||
fp_skip_kind_autoclassify=True,
|
||||
).write({'sequence': new_seq})
|
||||
renumbered += 1
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Recipe 3620: %s nodes resequenced',
|
||||
renumbered,
|
||||
)
|
||||
|
||||
# Delete the empty duplicate ENP-Alum Line sub_process.
|
||||
dup = Node.browse(RECIPE_3620_DUPLICATE_TO_DELETE).exists()
|
||||
if dup:
|
||||
if dup.child_ids:
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Duplicate sub_process %s has '
|
||||
'%s children - NOT deleting (safety check). '
|
||||
'Expected an empty node.',
|
||||
dup.id, len(dup.child_ids),
|
||||
)
|
||||
else:
|
||||
dup.unlink()
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Deleted empty duplicate '
|
||||
'ENP-Alum Line sub_process (id %s)',
|
||||
RECIPE_3620_DUPLICATE_TO_DELETE,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Phase 2 - Backfill kind on all kind=other nodes via resolver
|
||||
# ============================================================
|
||||
from odoo.addons.fusion_plating import (
|
||||
fp_resolve_step_kind,
|
||||
RESOLVER_KIND_TO_ACTIVE_KIND,
|
||||
)
|
||||
Kind = env['fp.step.kind']
|
||||
other_kind = Kind.search([('code', '=', 'other')], limit=1)
|
||||
if not other_kind:
|
||||
_logger.error(
|
||||
'[recipe-cleanup] No "other" kind found; skipping kind '
|
||||
'backfill phase'
|
||||
)
|
||||
else:
|
||||
# Cache code -> kind.id so we don't search per-row.
|
||||
kind_by_code = {k.code: k.id for k in Kind.search([])}
|
||||
affected_nodes = Node.search([
|
||||
('kind_id', '=', other_kind.id),
|
||||
('name', '!=', False),
|
||||
('node_type', 'in', ('operation', 'step', 'sub_process')),
|
||||
])
|
||||
fixed = 0
|
||||
for node in affected_nodes:
|
||||
resolver_code = fp_resolve_step_kind(node.name)
|
||||
if not resolver_code:
|
||||
continue
|
||||
target_code = RESOLVER_KIND_TO_ACTIVE_KIND.get(resolver_code)
|
||||
if not target_code or target_code not in kind_by_code:
|
||||
continue
|
||||
node.with_context(
|
||||
fp_skip_kind_autoclassify=True,
|
||||
).write({'kind_id': kind_by_code[target_code]})
|
||||
fixed += 1
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 2: backfilled kind on %s nodes '
|
||||
'(of %s currently kind=other)',
|
||||
fixed, len(affected_nodes),
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Phase 3 - Delete all per-part clone recipes
|
||||
# ============================================================
|
||||
# Identify by name pattern. The configurator names clones
|
||||
# "BASE_NAME - PART_NUMBER Rev X" with an em-dash separator
|
||||
# (U+2014). No base recipe uses em-dash in its name.
|
||||
#
|
||||
# IMPORTANT: delete one-clone-at-a-time with savepoint per clone.
|
||||
# Batch unlink (clone_recipes.unlink()) tripped a PostgreSQL FK
|
||||
# cascade ordering bug on entech (insert-or-update on parent_id
|
||||
# during the cascade chain). Per-clone unlink with intermediate
|
||||
# cleanup avoids that path entirely and lets one bad clone fail
|
||||
# without rolling back the others.
|
||||
clone_recipes = Node.search([
|
||||
('node_type', '=', 'recipe'),
|
||||
('name', 'ilike', '% — %'),
|
||||
])
|
||||
if not clone_recipes:
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 3: no clone recipes found '
|
||||
'(already deleted on a prior run, or none exist)'
|
||||
)
|
||||
else:
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 3: deleting %s clone recipes one '
|
||||
'at a time (per-clone savepoint)', len(clone_recipes),
|
||||
)
|
||||
deleted = 0
|
||||
failed = []
|
||||
for clone in clone_recipes:
|
||||
cid, cname = clone.id, clone.name
|
||||
cr.execute('SAVEPOINT delete_clone')
|
||||
try:
|
||||
clone.unlink()
|
||||
cr.execute('RELEASE SAVEPOINT delete_clone')
|
||||
deleted += 1
|
||||
except Exception as e:
|
||||
cr.execute('ROLLBACK TO SAVEPOINT delete_clone')
|
||||
failed.append((cid, cname, type(e).__name__, str(e)[:120]))
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Phase 3: failed to delete '
|
||||
'clone %s ("%s"): %s — continuing',
|
||||
cid, cname, type(e).__name__,
|
||||
)
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 3: deleted %s/%s clones '
|
||||
'(%s failures retained for manual review)',
|
||||
deleted, len(clone_recipes), len(failed),
|
||||
)
|
||||
if failed:
|
||||
for cid, cname, errtype, errmsg in failed:
|
||||
_logger.warning(
|
||||
'[recipe-cleanup] Phase 3 leftover: id=%s name=%r '
|
||||
'err=%s: %s', cid, cname, errtype, errmsg,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Phase 4 - Recompute area_kind on all fp.job.step rows
|
||||
# ============================================================
|
||||
Step = env['fp.job.step']
|
||||
steps = Step.search([])
|
||||
if steps:
|
||||
steps._compute_area_kind()
|
||||
steps.flush_recordset(['area_kind'])
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 4: recomputed area_kind on %s steps',
|
||||
len(steps),
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Phase 5 - Recompute active_step_id + card_state on in-flight jobs
|
||||
# ============================================================
|
||||
Job = env['fp.job']
|
||||
jobs = Job.search([
|
||||
('state', 'in', ('confirmed', 'in_progress')),
|
||||
])
|
||||
if jobs:
|
||||
jobs._compute_active_step_id()
|
||||
jobs._compute_card_state()
|
||||
jobs.flush_recordset(['active_step_id', 'card_state'])
|
||||
_logger.info(
|
||||
'[recipe-cleanup] Phase 5: recomputed active_step_id + '
|
||||
'card_state on %s in-flight jobs',
|
||||
len(jobs),
|
||||
)
|
||||
@@ -144,9 +144,14 @@ class FpJob(models.Model):
|
||||
active_step_id = fields.Many2one(
|
||||
'fp.job.step',
|
||||
compute='_compute_active_step_id',
|
||||
store=True,
|
||||
index=True,
|
||||
string='Active Step',
|
||||
help='Currently in-progress step (lowest sequence if multiple — '
|
||||
'defensive). Drives JobWorkspace landing focus.',
|
||||
help='Currently the live step under the priority chain '
|
||||
'(in_progress > paused > ready > pending). Drives '
|
||||
'JobWorkspace landing focus + card_state. Stored so '
|
||||
'card_state\'s `active_step_id.area_kind` dependency '
|
||||
'can search back to dependent jobs without erroring.',
|
||||
)
|
||||
|
||||
# ===== 2026-05-23 Plant-view kanban — card_state + mini_timeline ====
|
||||
@@ -257,13 +262,21 @@ class FpJob(models.Model):
|
||||
def _compute_card_state(self):
|
||||
"""Dispatch matching spec §6.2 / §9.3 explicit precedence list."""
|
||||
for job in self:
|
||||
# Edge: no active step (all pending or all done)
|
||||
# Edge: no live step (all steps done OR no steps at all).
|
||||
# - job.state='done' → 'done' (defensive — done jobs are
|
||||
# filtered off the Shop Floor board upstream, but the
|
||||
# field still needs a value).
|
||||
# - confirmed + parts not yet received → 'no_parts'.
|
||||
# - else → 'ready' (job awaiting work, no steps yet OR
|
||||
# recipe not assigned).
|
||||
if not job.active_step_id:
|
||||
if (job.state == 'confirmed'
|
||||
if job.state == 'done':
|
||||
job.card_state = 'done'
|
||||
elif (job.state == 'confirmed'
|
||||
and job._fp_inbound_not_received()):
|
||||
job.card_state = 'no_parts'
|
||||
else:
|
||||
job.card_state = 'contract_review'
|
||||
job.card_state = 'ready'
|
||||
continue
|
||||
|
||||
step = job.active_step_id
|
||||
@@ -384,11 +397,32 @@ class FpJob(models.Model):
|
||||
|
||||
@api.depends('step_ids.state', 'step_ids.sequence')
|
||||
def _compute_active_step_id(self):
|
||||
"""Pick the "live" step — first match by priority then sequence.
|
||||
|
||||
Priority order:
|
||||
in_progress > paused > ready > first pending
|
||||
|
||||
in_progress is the most informative (someone is actively
|
||||
working on it). paused means someone was working and stopped —
|
||||
the card belongs at that station so the next operator can
|
||||
pick it up. ready is the next-up step waiting for an operator.
|
||||
The first pending after a done step is the "next gate" —
|
||||
where the card visually waits between steps.
|
||||
|
||||
Returns False only when every step is `done` (job finished)
|
||||
or when there are no steps at all (recipe not assigned).
|
||||
|
||||
See spec 2026-05-24-shopfloor-live-step-fix-design.md Change 1.
|
||||
"""
|
||||
PRIORITY_STATES = ('in_progress', 'paused', 'ready', 'pending')
|
||||
for job in self:
|
||||
active = job.step_ids.filtered(
|
||||
lambda s: s.state == 'in_progress'
|
||||
).sorted('sequence')
|
||||
job.active_step_id = active[:1].id if active else False
|
||||
ordered = job.step_ids.sorted('sequence')
|
||||
live = job.env['fp.job.step']
|
||||
for state in PRIORITY_STATES:
|
||||
live = ordered.filtered(lambda s: s.state == state)
|
||||
if live:
|
||||
break
|
||||
job.active_step_id = live[:1].id if live else False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sub 14 — Configurable workflow state (status bar milestone)
|
||||
|
||||
@@ -17,60 +17,11 @@ from odoo.exceptions import UserError
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Mapping from fp.process.node.default_kind → fp.work.centre.area_kind.
|
||||
# Used as fallback by fp.job.step.area_kind compute when the step has no
|
||||
# work_centre_id or its work_centre has no area_kind set. Authoritative
|
||||
# source per the plant-view spec §4.2.
|
||||
# 2026-05-23 — Shop Floor plant-view redesign.
|
||||
_STEP_KIND_TO_AREA = {
|
||||
# Receiving (admin / pre-physical-work)
|
||||
'receiving': 'receiving',
|
||||
'incoming_inspection': 'receiving',
|
||||
'contract_review': 'receiving',
|
||||
'gating': 'receiving',
|
||||
'ready_for_processing': 'receiving',
|
||||
# Masking
|
||||
'masking': 'masking',
|
||||
# Blasting
|
||||
'blasting': 'blasting',
|
||||
'bead_blast': 'blasting',
|
||||
'media_blast': 'blasting',
|
||||
# Racking
|
||||
'racking': 'racking',
|
||||
# Plating (everything wet — rolled up into one column per spec D3)
|
||||
'soak_clean': 'plating',
|
||||
'electroclean': 'plating',
|
||||
'acid_dip': 'plating',
|
||||
'etch': 'plating',
|
||||
'desmut': 'plating',
|
||||
'zincate': 'plating',
|
||||
'rinse': 'plating',
|
||||
'water_break_test': 'plating',
|
||||
'activation': 'plating',
|
||||
'e_nickel_plate': 'plating',
|
||||
'chrome': 'plating',
|
||||
'anodize': 'plating',
|
||||
'black_oxide': 'plating',
|
||||
'drying': 'plating',
|
||||
# Baking
|
||||
'bake': 'baking',
|
||||
'oven_bake': 'baking',
|
||||
'post_bake_relief': 'baking',
|
||||
# De-Racking (folds in de-masking per spec D4)
|
||||
'de_rack': 'de_racking',
|
||||
'de_mask': 'de_racking',
|
||||
'unrack': 'de_racking',
|
||||
# Final inspection (post-plate inspection / FAIR / thickness QC)
|
||||
'inspection': 'inspection',
|
||||
'final_inspection': 'inspection',
|
||||
'post_plate_inspection':'inspection',
|
||||
'thickness_qc': 'inspection',
|
||||
'fair': 'inspection',
|
||||
'dimensional_check': 'inspection',
|
||||
# Shipping
|
||||
'shipping': 'shipping',
|
||||
'pack_ship': 'shipping',
|
||||
}
|
||||
# 2026-05-24 — Shop Floor live-step fix (19.0.10.24.0):
|
||||
# The legacy `_STEP_KIND_TO_AREA` dict that lived here was removed.
|
||||
# fp.step.kind now self-declares its area_kind, so the kind taxonomy
|
||||
# IS the source of truth for Shop Floor column routing.
|
||||
# See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
|
||||
|
||||
|
||||
class FpJobStep(models.Model):
|
||||
@@ -169,21 +120,40 @@ class FpJobStep(models.Model):
|
||||
store=True,
|
||||
index=True,
|
||||
help='Which Shop Floor column this step belongs to. Resolved as: '
|
||||
'(1) work_centre.area_kind if set; else (2) fallback to '
|
||||
'_STEP_KIND_TO_AREA[recipe_node.default_kind]; else (3) the '
|
||||
'safe catch-all "plating". Drives plant-view kanban grouping.',
|
||||
'(1) work_centre.area_kind if set; else (2) the area_kind on '
|
||||
'recipe_node.kind_id; else (3) the safe catch-all "plating". '
|
||||
'Drives plant-view kanban grouping.',
|
||||
)
|
||||
|
||||
@api.depends('work_centre_id.area_kind', 'recipe_node_id.default_kind')
|
||||
@api.depends(
|
||||
'work_centre_id.area_kind',
|
||||
'recipe_node_id.kind_id.area_kind',
|
||||
)
|
||||
def _compute_area_kind(self):
|
||||
"""Resolve the plant-view column this step belongs in.
|
||||
|
||||
Priority chain:
|
||||
1. work_centre.area_kind (explicit operator setup wins)
|
||||
2. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
|
||||
3. catch-all 'plating' (data integrity issue if we land here)
|
||||
|
||||
The legacy _STEP_KIND_TO_AREA dict was removed — fp.step.kind
|
||||
now self-declares its area_kind, so the kind taxonomy IS the
|
||||
source of truth. See spec
|
||||
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
|
||||
"""
|
||||
for step in self:
|
||||
# 1. Explicit work_centre wins
|
||||
if step.work_centre_id and step.work_centre_id.area_kind:
|
||||
step.area_kind = step.work_centre_id.area_kind
|
||||
continue
|
||||
kind = step.recipe_node_id.default_kind if step.recipe_node_id else False
|
||||
if kind and kind in _STEP_KIND_TO_AREA:
|
||||
step.area_kind = _STEP_KIND_TO_AREA[kind]
|
||||
# 2. Kind taxonomy
|
||||
node = step.recipe_node_id
|
||||
if node and node.kind_id and node.kind_id.area_kind:
|
||||
step.area_kind = node.kind_id.area_kind
|
||||
continue
|
||||
# 3. Catch-all — only reached for orphaned steps (no
|
||||
# work_centre AND no recipe_node).
|
||||
step.area_kind = 'plating'
|
||||
|
||||
last_activity_at = fields.Datetime(
|
||||
@@ -827,6 +797,13 @@ class FpJobStep(models.Model):
|
||||
per-step data trail; finishing a step with missing prompts breaks
|
||||
the audit chain.
|
||||
|
||||
2026-05-24: also blocks orphaned steps (recipe_node_id NULL —
|
||||
happens when the source recipe was deleted, e.g. a per-part clone
|
||||
cleanup). Without a recipe link there's no way to verify required
|
||||
prompts; defaulting to "let it through" was a silent compliance
|
||||
gap. Managers can bypass via the same flag, audit chatter records
|
||||
the override.
|
||||
|
||||
Manager bypass via context fp_skip_required_inputs_gate=True
|
||||
(e.g. paper-form catch-up or documented customer deviation).
|
||||
Bypasses are posted to chatter naming the user.
|
||||
@@ -839,6 +816,17 @@ class FpJobStep(models.Model):
|
||||
)) % (step.name, self.env.user.name))
|
||||
return
|
||||
for step in self:
|
||||
# Orphan-step block — NULL recipe_node means we can't list
|
||||
# required prompts, so we conservatively refuse to finish.
|
||||
if not step.recipe_node_id:
|
||||
raise UserError(_(
|
||||
'Step "%(step)s" cannot be finished — this step has '
|
||||
'no recipe link (the source recipe was deleted or the '
|
||||
'job was created before recipes were assigned). '
|
||||
'Required-input verification is impossible without '
|
||||
'the recipe. Escalate to a manager — they can bypass '
|
||||
'with an audit-chatter entry.'
|
||||
) % {'step': step.name})
|
||||
missing = step._fp_missing_required_step_inputs()
|
||||
if not missing:
|
||||
continue
|
||||
@@ -901,7 +889,8 @@ class FpJobStep(models.Model):
|
||||
skipped.
|
||||
"""
|
||||
for step in self:
|
||||
job = step.job_id
|
||||
# sudo() — technicians lack sale.order ACL (Rule 13m).
|
||||
job = step.sudo().job_id
|
||||
if not job.sale_order_line_ids:
|
||||
continue
|
||||
serials = job.sale_order_line_ids.mapped('x_fc_serial_ids')
|
||||
@@ -921,7 +910,8 @@ class FpJobStep(models.Model):
|
||||
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
|
||||
# sudo() — technicians lack sale.order ACL (Rule 13m).
|
||||
job = step.sudo().job_id
|
||||
if not job.sale_order_line_ids:
|
||||
continue
|
||||
# Is this the highest-sequence non-cancelled step on the job?
|
||||
@@ -976,7 +966,8 @@ class FpJobStep(models.Model):
|
||||
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:
|
||||
# sudo() — technicians lack sale.order ACL (Rule 13m).
|
||||
for so_line in self.sudo().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
|
||||
@@ -1177,7 +1168,11 @@ class FpJobStep(models.Model):
|
||||
for step in self:
|
||||
if step._fp_is_contract_review_step():
|
||||
continue
|
||||
so = step.job_id.sale_order_id
|
||||
# sudo() — technicians don't have sale.order ACL but the
|
||||
# gate's purpose is checking a denormalized state field.
|
||||
# Rule 13m: cross-module reads in tablet/floor controllers
|
||||
# must sudo() the source recordset.
|
||||
so = step.sudo().job_id.sale_order_id
|
||||
if not so:
|
||||
# Internal rework / no SO — gate doesn't apply.
|
||||
continue
|
||||
@@ -1427,6 +1422,50 @@ class FpJobStep(models.Model):
|
||||
},
|
||||
}
|
||||
|
||||
def action_mark_gating_passed(self):
|
||||
"""1-click pass for gating steps (kind=='gating').
|
||||
|
||||
Performs button_start (or button_resume if paused) followed by
|
||||
button_finish in the same transaction. Posts a chatter audit on
|
||||
the parent job naming the user.
|
||||
|
||||
Only valid for kind='gating' steps in state in (ready, pending,
|
||||
paused). NOOPs on already-terminal steps for idempotency. Raises
|
||||
UserError if called on a non-gating step (defensive — UI dispatcher
|
||||
only renders Mark Passed for gating kinds).
|
||||
|
||||
Bypasses the S21 required-inputs gate (gating steps have no
|
||||
required inputs by design — they're admin gates).
|
||||
|
||||
Spec: 2026-05-24-workspace-step-actions-design.md Change 5.
|
||||
"""
|
||||
for step in self:
|
||||
if step.state in ('done', 'skipped', 'cancelled'):
|
||||
continue
|
||||
kind_code = (
|
||||
step.recipe_node_id.kind_id.code
|
||||
if step.recipe_node_id and step.recipe_node_id.kind_id
|
||||
else None
|
||||
)
|
||||
if kind_code != 'gating':
|
||||
raise UserError(_(
|
||||
"Mark Passed is only valid for gating steps. "
|
||||
"This step's kind is %s."
|
||||
) % (kind_code or 'unknown'))
|
||||
if step.state not in ('ready', 'pending', 'paused'):
|
||||
continue
|
||||
if step.state == 'paused':
|
||||
step.button_resume()
|
||||
if step.state != 'in_progress':
|
||||
step.button_start()
|
||||
step.with_context(
|
||||
fp_skip_required_inputs_gate=True,
|
||||
).button_finish()
|
||||
step.job_id.message_post(body=_(
|
||||
'Gate "%(name)s" marked passed by %(user)s.'
|
||||
) % {'name': step.name, 'user': self.env.user.name})
|
||||
return True
|
||||
|
||||
def _fp_contract_review_redirect(self):
|
||||
"""Return an ir.actions.act_window opening the part's QA-005
|
||||
Contract Review form, or False to indicate "no redirect needed".
|
||||
|
||||
@@ -236,6 +236,52 @@ export class FpRecordInputsDialog extends Component {
|
||||
return "any";
|
||||
}
|
||||
|
||||
// Stepper helpers — give the operator a tap-to-increment / -decrement
|
||||
// pair next to each numeric input so they don't have to open the
|
||||
// keyboard for small adjustments. Field is one of: value_number,
|
||||
// value_min, value_max. Increment uses stepFor() so taps move in the
|
||||
// same decimal magnitude the recipe spec was written in. Clamps at 0
|
||||
// (typical qty/time/temp on a plating shop floor doesn't go negative;
|
||||
// if a recipe needs negatives, the operator can still type the value
|
||||
// by tapping the input).
|
||||
_stepDelta(row) {
|
||||
const s = this.stepFor(row);
|
||||
if (s === "any") return 1;
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) || n <= 0 ? 1 : n;
|
||||
}
|
||||
|
||||
_stepRound(n, delta) {
|
||||
// Avoid floating-point fuzz (0.1+0.2=0.30000004). Round to the
|
||||
// delta's decimal precision.
|
||||
const decimals = (String(delta).split(".")[1] || "").length;
|
||||
if (!decimals) return Math.round(n);
|
||||
const factor = Math.pow(10, decimals);
|
||||
return Math.round(n * factor) / factor;
|
||||
}
|
||||
|
||||
onIncrement(row, field) {
|
||||
const cur = parseFloat(row[field]) || 0;
|
||||
const delta = this._stepDelta(row);
|
||||
row[field] = this._stepRound(cur + delta, delta);
|
||||
}
|
||||
|
||||
onDecrement(row, field) {
|
||||
const cur = parseFloat(row[field]) || 0;
|
||||
const delta = this._stepDelta(row);
|
||||
row[field] = Math.max(0, this._stepRound(cur - delta, delta));
|
||||
}
|
||||
|
||||
inputModeFor(row) {
|
||||
// Tablet keyboard hint — show numeric keypad instead of full
|
||||
// keyboard when the operator does tap the input. 'decimal' is
|
||||
// safer than 'numeric' because it includes the decimal point
|
||||
// (needed for pH, thickness, temperature).
|
||||
const t = row.input_type || "";
|
||||
if (t === "time_seconds" || t === "time_hms") return "numeric";
|
||||
return "decimal";
|
||||
}
|
||||
|
||||
_fpCountDecimals(n) {
|
||||
if (n === null || n === undefined || n === "" || n === 0) return 0;
|
||||
const s = String(n);
|
||||
|
||||
@@ -801,3 +801,49 @@ $rid-warn : var(--fp-rid-warn, #{$_fp-rid-warn-hex});
|
||||
color: $rid-required;
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric stepper (tap-to-increment / tap-to-decrement around the input).
|
||||
// Operator can still tap the input to open the keypad (inputmode="decimal"
|
||||
// gives them the number keypad on iPad).
|
||||
.o_fp_ri_stepper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.o_fp_ri_stepper_btn {
|
||||
flex: 0 0 auto;
|
||||
width: 3.2rem;
|
||||
border: 1px solid #cdd0d4;
|
||||
background: #f5f6f8;
|
||||
color: #1d1d1f;
|
||||
font-size: 1.3rem;
|
||||
cursor: pointer;
|
||||
transition: background 80ms;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover, &:active { background: #e5e7eb; }
|
||||
|
||||
&.o_fp_ri_stepper_minus {
|
||||
border-top-left-radius: 6px;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-right: 0;
|
||||
}
|
||||
&.o_fp_ri_stepper_plus {
|
||||
border-top-right-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ri_stepper_input {
|
||||
flex: 1 1 auto;
|
||||
border-radius: 0;
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
min-width: 4rem;
|
||||
}
|
||||
|
||||
@@ -120,11 +120,26 @@
|
||||
<!-- Numeric — single value (no range defined) -->
|
||||
<div t-if="isNumeric(row) and !hasRangeEntry(row)"
|
||||
class="o_fp_ri_numeric">
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_number"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
<div class="o_fp_ri_stepper">
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
|
||||
t-on-click="() => this.onDecrement(row, 'value_number')"
|
||||
aria-label="Decrease">
|
||||
<i class="fa fa-minus"/>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
|
||||
t-att-inputmode="inputModeFor(row)"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_number"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
|
||||
t-on-click="() => this.onIncrement(row, 'value_number')"
|
||||
aria-label="Increase">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
<t t-set="hint" t-value="rangeHint(row)"/>
|
||||
<span t-if="hint"
|
||||
class="o_fp_ri_range_hint"
|
||||
@@ -138,22 +153,52 @@
|
||||
Constrained to numeric so it doesn't duplicate
|
||||
the pass_fail+range branch above. -->
|
||||
<div t-if="isNumeric(row) and hasRangeEntry(row)" class="o_fp_ri_dual">
|
||||
<label class="o_fp_ri_dual_field">
|
||||
<div class="o_fp_ri_dual_field">
|
||||
<span class="o_fp_ri_dual_label">Min Reading</span>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_min"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
</label>
|
||||
<label class="o_fp_ri_dual_field">
|
||||
<div class="o_fp_ri_stepper">
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
|
||||
t-on-click="() => this.onDecrement(row, 'value_min')"
|
||||
aria-label="Decrease">
|
||||
<i class="fa fa-minus"/>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
|
||||
t-att-inputmode="inputModeFor(row)"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_min"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
|
||||
t-on-click="() => this.onIncrement(row, 'value_min')"
|
||||
aria-label="Increase">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_ri_dual_field">
|
||||
<span class="o_fp_ri_dual_label">Max Reading</span>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_max"
|
||||
t-att-placeholder="row.target_max or '0.00'"/>
|
||||
</label>
|
||||
<div class="o_fp_ri_stepper">
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
|
||||
t-on-click="() => this.onDecrement(row, 'value_max')"
|
||||
aria-label="Decrease">
|
||||
<i class="fa fa-minus"/>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
|
||||
t-att-inputmode="inputModeFor(row)"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_max"
|
||||
t-att-placeholder="row.target_max or '0.00'"/>
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
|
||||
t-on-click="() => this.onIncrement(row, 'value_max')"
|
||||
aria-label="Increase">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set="dhint" t-value="dualRangeHint(row)"/>
|
||||
<span t-if="dhint"
|
||||
class="o_fp_ri_range_hint o_fp_ri_dual_hint"
|
||||
@@ -169,22 +214,52 @@
|
||||
prompt (e.g. Bore inspection: 0.005-0.007 in). -->
|
||||
<t t-if="isPassFail(row) and hasRangeEntry(row)">
|
||||
<div class="o_fp_ri_dual">
|
||||
<label class="o_fp_ri_dual_field">
|
||||
<div class="o_fp_ri_dual_field">
|
||||
<span class="o_fp_ri_dual_label">Min Reading</span>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_min"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
</label>
|
||||
<label class="o_fp_ri_dual_field">
|
||||
<div class="o_fp_ri_stepper">
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
|
||||
t-on-click="() => this.onDecrement(row, 'value_min')"
|
||||
aria-label="Decrease">
|
||||
<i class="fa fa-minus"/>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
|
||||
t-att-inputmode="inputModeFor(row)"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_min"
|
||||
t-att-placeholder="row.target_min or '0.00'"/>
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
|
||||
t-on-click="() => this.onIncrement(row, 'value_min')"
|
||||
aria-label="Increase">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_ri_dual_field">
|
||||
<span class="o_fp_ri_dual_label">Max Reading</span>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_max"
|
||||
t-att-placeholder="row.target_max or '0.00'"/>
|
||||
</label>
|
||||
<div class="o_fp_ri_stepper">
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_minus"
|
||||
t-on-click="() => this.onDecrement(row, 'value_max')"
|
||||
aria-label="Decrease">
|
||||
<i class="fa fa-minus"/>
|
||||
</button>
|
||||
<input type="number"
|
||||
class="o_fp_ri_input o_fp_ri_input_numeric o_fp_ri_stepper_input"
|
||||
t-att-inputmode="inputModeFor(row)"
|
||||
t-att-step="stepFor(row)"
|
||||
t-model.number="row.value_max"
|
||||
t-att-placeholder="row.target_max or '0.00'"/>
|
||||
<button type="button"
|
||||
class="o_fp_ri_stepper_btn o_fp_ri_stepper_plus"
|
||||
t-on-click="() => this.onIncrement(row, 'value_max')"
|
||||
aria-label="Increase">
|
||||
<i class="fa fa-plus"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<t t-set="dhint" t-value="dualRangeHint(row)"/>
|
||||
<span t-if="dhint"
|
||||
class="o_fp_ri_range_hint o_fp_ri_dual_hint"
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!--
|
||||
Add "All Jobs" and "Steps" as children of fusion_plating_shopfloor's
|
||||
Add "Work Orders" and "Steps" as children of fusion_plating_shopfloor's
|
||||
Shop Floor menu. We can reference shopfloor's xmlid here because
|
||||
fusion_plating_jobs declares it as a depend.
|
||||
|
||||
Sequences fit between Tablet Station (10) and Bake Windows (20)
|
||||
in shopfloor's existing fp_menu.xml.
|
||||
|
||||
Renamed 2026-05-25 — "All Jobs" → "Work Orders" for consistency
|
||||
with the rest of the UI (SO smart button is "WO", tablet cards
|
||||
show "WO # 00001", KPI tile says "Work Orders"). xmlid kept
|
||||
as menu_fp_jobs_all_jobs so bookmarks and inherits don't break.
|
||||
-->
|
||||
|
||||
<menuitem id="menu_fp_jobs_all_jobs"
|
||||
name="All Jobs"
|
||||
name="Work Orders"
|
||||
parent="fusion_plating_shopfloor.menu_fp_shopfloor"
|
||||
action="fusion_plating.action_fp_job"
|
||||
sequence="15"/>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Battle test S24 — Live step priority chain + board state filter.
|
||||
|
||||
Run end-to-end via odoo shell with stdin redirection:
|
||||
|
||||
ssh pve-worker5 "pct exec 111 -- bash -c 'su - odoo -s /bin/bash -c \\
|
||||
\"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\" \\
|
||||
< /mnt/extra-addons/custom/fusion_plating_quality/scripts/bt_s24_between_steps.py'"
|
||||
|
||||
Asserts:
|
||||
1. Job between steps (one done, next pending) has live active_step_id
|
||||
pointing at the next pending step, NOT False.
|
||||
2. Card column resolves to that pending step's area_kind, NOT receiving.
|
||||
3. Paused steps still count as active.
|
||||
4. state='done' jobs are excluded from the live-board search domain.
|
||||
|
||||
See docs/superpowers/specs/2026-05-24-shopfloor-live-step-fix-design.md.
|
||||
"""
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_card_area(job):
|
||||
"""Mirror of plant_kanban._resolve_card_area for test purposes."""
|
||||
if job.active_step_id and job.active_step_id.area_kind:
|
||||
return job.active_step_id.area_kind
|
||||
return 'receiving'
|
||||
|
||||
|
||||
def run():
|
||||
partner = env['res.partner'].search([('customer_rank', '>', 0)], limit=1)
|
||||
if not partner:
|
||||
raise AssertionError(
|
||||
'No customer partner found — seed test data first'
|
||||
)
|
||||
|
||||
recipe = env['fusion.plating.process.node'].search([
|
||||
('node_type', '=', 'recipe'),
|
||||
('child_ids', '!=', False),
|
||||
], limit=1)
|
||||
if not recipe:
|
||||
raise AssertionError('No recipe found — seed test data first')
|
||||
|
||||
job = env['fp.job'].create({
|
||||
'partner_id': partner.id,
|
||||
'recipe_id': recipe.id,
|
||||
'qty': 1,
|
||||
})
|
||||
job._fp_generate_steps_from_recipe()
|
||||
steps = job.step_ids.sorted('sequence')
|
||||
if len(steps) < 3:
|
||||
raise AssertionError('Need at least 3 steps for the test')
|
||||
|
||||
# === Phase A — between-step assertion ===
|
||||
s1 = steps[0]
|
||||
s2 = steps[1]
|
||||
s1.button_start()
|
||||
s1.button_finish()
|
||||
job.invalidate_recordset(['active_step_id', 'card_state'])
|
||||
if job.active_step_id.id != s2.id:
|
||||
raise AssertionError(
|
||||
'Expected active_step_id = %s (next pending), got %s'
|
||||
% (s2.id, job.active_step_id.id)
|
||||
)
|
||||
if _resolve_card_area(job) != s2.area_kind:
|
||||
raise AssertionError(
|
||||
'Card column should match s2.area_kind=%s, got %s'
|
||||
% (s2.area_kind, _resolve_card_area(job))
|
||||
)
|
||||
_logger.info('[bt_s24] Phase A OK — between-step routing correct')
|
||||
|
||||
# === Phase B — paused step assertion ===
|
||||
s2.button_start()
|
||||
s2.button_pause('lunch break')
|
||||
job.invalidate_recordset(['active_step_id', 'card_state'])
|
||||
if job.active_step_id.id != s2.id:
|
||||
raise AssertionError(
|
||||
'Paused step should remain the live step, got %s'
|
||||
% job.active_step_id.id
|
||||
)
|
||||
_logger.info('[bt_s24] Phase B OK — paused step stays live')
|
||||
|
||||
# === Phase C — done job filter ===
|
||||
for s in steps:
|
||||
if s.state != 'done':
|
||||
if s.state == 'paused':
|
||||
s.button_resume()
|
||||
if s.state != 'in_progress':
|
||||
s.button_start()
|
||||
s.button_finish()
|
||||
job.with_context(
|
||||
fp_skip_step_gate=True,
|
||||
fp_skip_qty_reconcile=True,
|
||||
fp_skip_bake_gate=True,
|
||||
).button_mark_done()
|
||||
if job.state != 'done':
|
||||
raise AssertionError('job did not transition to done')
|
||||
|
||||
jobs_on_board = env['fp.job'].search([
|
||||
('state', 'in', ('confirmed', 'in_progress')),
|
||||
])
|
||||
if job.id in jobs_on_board.ids:
|
||||
raise AssertionError(
|
||||
'Done job %s should be filtered off board' % job.id
|
||||
)
|
||||
_logger.info('[bt_s24] Phase C OK — done jobs filtered off board')
|
||||
|
||||
_logger.info('[bt_s24] ALL ASSERTIONS PASSED')
|
||||
|
||||
|
||||
run()
|
||||
@@ -5,10 +5,9 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Shop Floor',
|
||||
'version': '19.0.33.1.1',
|
||||
'version': '19.0.33.2.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||
'first-piece inspection gates.',
|
||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer.',
|
||||
'description': """
|
||||
Fusion Plating — Shop Floor
|
||||
===========================
|
||||
@@ -53,7 +52,6 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'views/res_users_views.xml',
|
||||
'views/fp_bake_oven_views.xml',
|
||||
'views/fp_bake_window_views.xml',
|
||||
'views/fp_first_piece_gate_views.xml',
|
||||
'views/fp_plant_overview_views.xml',
|
||||
'views/tank_status_template.xml',
|
||||
'views/fp_tablet_session_event_views.xml',
|
||||
@@ -114,6 +112,16 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
||||
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/job_workspace.js',
|
||||
# ---- Damage dialog for receiving (Spec C1+C2 2026-05-24) ----
|
||||
# Loaded AFTER job_workspace.js because job_workspace imports
|
||||
# FpDamageDialog; the asset bundler doesn't care about JS module
|
||||
# order (ES modules resolve at runtime) but keep adjacent for
|
||||
# readability.
|
||||
'fusion_plating_shopfloor/static/src/xml/fp_damage_dialog.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/fp_damage_dialog.js',
|
||||
# ---- Finish block dialog (required-inputs gate + manager bypass) ----
|
||||
'fusion_plating_shopfloor/static/src/xml/fp_finish_block_dialog.xml',
|
||||
'fusion_plating_shopfloor/static/src/js/fp_finish_block_dialog.js',
|
||||
# ---- Shop Floor Landing (Phase 3 — tablet redesign) ----
|
||||
'fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss',
|
||||
'fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml',
|
||||
|
||||
@@ -65,9 +65,13 @@ class PlantKanbanController(http.Controller):
|
||||
else env['fp.work.centre'])
|
||||
paired_area = paired.area_kind if paired else None
|
||||
|
||||
# Base domain — every job with active recipe steps
|
||||
# Base domain — only in-flight jobs.
|
||||
# 2026-05-24 (spec 2026-05-24-shopfloor-live-step-fix-design.md
|
||||
# Defect 4 / Change 3): done + cancelled jobs drop off the live
|
||||
# board. They stay reachable via smart buttons, the Plating Jobs
|
||||
# backend list, and history reports.
|
||||
domain = [
|
||||
('state', 'in', ('confirmed', 'in_progress', 'done')),
|
||||
('state', 'in', ('confirmed', 'in_progress')),
|
||||
]
|
||||
filters = filters or {}
|
||||
if filters.get('overdue'):
|
||||
@@ -161,12 +165,26 @@ def fields_today_ts():
|
||||
def _resolve_card_area(job):
|
||||
"""Pick the column a card lives in.
|
||||
|
||||
Active-step area_kind wins. When there's no active step the card
|
||||
lives in Receiving (covers contract_review + no_parts edge cases).
|
||||
Active-step area_kind wins, EXCEPT for no_parts cards which always
|
||||
land in Receiving regardless of active step — the receiver is who
|
||||
needs to act, and they work the Receiving column. With the
|
||||
live-step priority chain (see fp.job._compute_active_step_id),
|
||||
active_step_id is False only when the job has NO steps at all
|
||||
(recipe not assigned) OR every step is `done`. Done jobs are
|
||||
filtered off the board upstream (state-domain in plant_kanban),
|
||||
so the orphan fallback fires only for truly orphaned cards.
|
||||
|
||||
See specs 2026-05-24-shopfloor-live-step-fix-design.md Change 4
|
||||
and 2026-05-24-recipe-cleanup-design.md Change 6.
|
||||
"""
|
||||
# no_parts cards belong in Receiving regardless of where the
|
||||
# active step is — the receiver is who acts.
|
||||
if job.card_state == 'no_parts':
|
||||
return 'receiving'
|
||||
if job.active_step_id and job.active_step_id.area_kind:
|
||||
return job.active_step_id.area_kind
|
||||
# Fallback: receiving column
|
||||
# Orphan fallback — represents a data integrity issue, not a
|
||||
# normal state. Cards here have NO steps assigned at all.
|
||||
return 'receiving'
|
||||
|
||||
|
||||
|
||||
@@ -641,8 +641,8 @@ class FpShopfloorController(http.Controller):
|
||||
# /fp/landing/kanban. The Tablet Station menu now points at the new
|
||||
# surface. This endpoint stays live as long as the legacy
|
||||
# fp_shopfloor_tablet OWL component is still registered — it consumes
|
||||
# the rich payload (my_queue, active_wo, baths, bake_windows, gates,
|
||||
# holds, pending_qcs, stations). Phase 5 cleanup will retire both the
|
||||
# the rich payload (my_queue, active_wo, baths, bake_windows, holds,
|
||||
# pending_qcs, stations). Phase 5 cleanup will retire both the
|
||||
# legacy component and this endpoint together.
|
||||
@http.route('/fp/shopfloor/tablet_overview', type='jsonrpc', auth='user')
|
||||
def tablet_overview(self, station_id=None, facility_id=None):
|
||||
@@ -673,7 +673,6 @@ class FpShopfloorController(http.Controller):
|
||||
|
||||
Step = env['fp.job.step']
|
||||
BakeWindow = env['fusion.plating.bake.window']
|
||||
Gate = env['fusion.plating.first.piece.gate']
|
||||
Hold = env['fusion.plating.quality.hold']
|
||||
|
||||
def _fac_dom(dom):
|
||||
@@ -704,7 +703,6 @@ class FpShopfloorController(http.Controller):
|
||||
awaiting = BakeWindow.search_count(bake_dom + [('state', '=', 'awaiting_bake')])
|
||||
in_progress_bakes = BakeWindow.search_count(bake_dom + [('state', '=', 'bake_in_progress')])
|
||||
missed = BakeWindow.search_count(bake_dom + [('state', '=', 'missed_window')])
|
||||
pending_gates = Gate.search_count(_fac_dom([('result', '=', 'pending')]))
|
||||
hold_dom = [('state', 'in', ('on_hold', 'under_review'))]
|
||||
if my_job_ids_for_kpi and 'x_fc_job_id' in Hold._fields:
|
||||
hold_dom.append(('x_fc_job_id', 'in', my_job_ids_for_kpi))
|
||||
@@ -715,7 +713,6 @@ class FpShopfloorController(http.Controller):
|
||||
{'label': 'In Progress', 'value': steps_progress, 'tone': 'success', 'icon': 'fa-cogs'},
|
||||
{'label': 'Awaiting Bake', 'value': awaiting, 'tone': 'warning', 'icon': 'fa-fire'},
|
||||
{'label': 'Missed Windows', 'value': missed, 'tone': 'danger' if missed else 'muted', 'icon': 'fa-exclamation-triangle'},
|
||||
{'label': 'First-Piece', 'value': pending_gates, 'tone': 'info', 'icon': 'fa-flag-checkered'},
|
||||
{'label': 'Quality Holds', 'value': open_holds, 'tone': 'danger' if open_holds else 'muted', 'icon': 'fa-pause-circle'},
|
||||
]
|
||||
|
||||
@@ -873,23 +870,6 @@ class FpShopfloorController(http.Controller):
|
||||
for bw in bws
|
||||
]
|
||||
|
||||
# -- First-piece gates -------------------------------------------
|
||||
gate_domain = _fac_dom([('result', 'in', ('pending', 'fail'))])
|
||||
gates = Gate.search(gate_domain, order='first_piece_produced desc', limit=6)
|
||||
gates_data = [
|
||||
{
|
||||
'id': g.id,
|
||||
'name': g.name,
|
||||
'part_ref': g.part_ref or '',
|
||||
'customer': g.customer_ref or '',
|
||||
'bath': g.bath_id.name or '',
|
||||
'result': g.result,
|
||||
'first_piece': fp_format(request.env, g.first_piece_produced),
|
||||
'inspector': g.inspector_id.name or '',
|
||||
}
|
||||
for g in gates
|
||||
]
|
||||
|
||||
# -- Quality holds -----------------------------------------------
|
||||
# v19.0.24.3.0 — scope holds to operator's jobs so Carlos's
|
||||
# sidebar isn't flooded with plant-wide HOLD-XXXX from other
|
||||
@@ -986,7 +966,6 @@ class FpShopfloorController(http.Controller):
|
||||
'active_wo': active_wo,
|
||||
'baths': baths_data,
|
||||
'bake_windows': bw_data,
|
||||
'gates': gates_data,
|
||||
'holds': holds_data,
|
||||
'pending_qcs': pending_qcs,
|
||||
'stations': stations,
|
||||
@@ -1007,26 +986,6 @@ class FpShopfloorController(http.Controller):
|
||||
})
|
||||
return {'ok': True}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Mark a first-piece gate result from the tablet
|
||||
# ----------------------------------------------------------------------
|
||||
@http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user')
|
||||
def mark_gate(self, gate_id, result):
|
||||
env = request.env
|
||||
gate = env['fusion.plating.first.piece.gate'].browse(int(gate_id))
|
||||
if not gate.exists():
|
||||
return {'ok': False, 'error': 'Gate not found.'}
|
||||
try:
|
||||
if result == 'pass':
|
||||
gate.action_mark_pass()
|
||||
elif result == 'fail':
|
||||
gate.action_mark_fail()
|
||||
else:
|
||||
return {'ok': False, 'error': f'Unknown result {result}'}
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
|
||||
return {'ok': True, 'state': gate.result}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Operator queue snapshot (legacy fusion.plating.operator.queue helper)
|
||||
# ----------------------------------------------------------------------
|
||||
@@ -1403,7 +1362,6 @@ class FpShopfloorController(http.Controller):
|
||||
'due_today': 25,
|
||||
'priority': 20,
|
||||
'due_soon': 15,
|
||||
'first_piece': 10,
|
||||
'normal': 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import _, fields, http
|
||||
from odoo.addons.fusion_plating.models.fp_tz import fp_user_tz
|
||||
from odoo.exceptions import AccessDenied, UserError
|
||||
from odoo.http import request
|
||||
|
||||
@@ -198,9 +199,13 @@ class FpTabletController(http.Controller):
|
||||
# Attempt the real Odoo session swap via the custom auth manager.
|
||||
# session.authenticate validates credentials through _check_credentials,
|
||||
# issues a new sid, sets the cookie, returns the user dict.
|
||||
# NB: Odoo 19's Session.authenticate signature is (env, credential) —
|
||||
# passing request.db (a string) raises TypeError: 'str' object is not
|
||||
# callable on the internal env(user=None, su=False) reset. Pass
|
||||
# request.env instead.
|
||||
try:
|
||||
request.session.authenticate(
|
||||
request.db,
|
||||
request.env,
|
||||
{'type': 'fp_tablet_pin',
|
||||
'login': target.login,
|
||||
'pin': pin},
|
||||
@@ -355,8 +360,9 @@ class FpTabletController(http.Controller):
|
||||
'needs_kiosk_relogin': True}
|
||||
|
||||
try:
|
||||
# Odoo 19 signature: (env, credential) — see unlock_session note.
|
||||
request.session.authenticate(
|
||||
request.db,
|
||||
request.env,
|
||||
{'type': 'password',
|
||||
'login': kiosk_login,
|
||||
'password': kiosk_password},
|
||||
@@ -450,12 +456,18 @@ class FpTabletController(http.Controller):
|
||||
raise_if_not_found=False,
|
||||
)
|
||||
kiosk_uid = kiosk.id if kiosk else None
|
||||
# tz_name — resolved per fp_tz.fp_user_tz (kiosk user.tz → company
|
||||
# x_fc_default_tz → UTC). Frontend uses Intl.DateTimeFormat with
|
||||
# this tz so the lock-screen clock follows the FP regional
|
||||
# setting, not the iPad's system tz (caught 2026-05-25 when the
|
||||
# workspace timer-offset bug surfaced the broader pattern).
|
||||
return {
|
||||
'ok': True,
|
||||
'company': _lock_company_payload(request.env),
|
||||
'tiles': tiles,
|
||||
'kiosk_uid': kiosk_uid,
|
||||
'current_uid': request.env.uid,
|
||||
'tz_name': str(fp_user_tz(request.env)),
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
|
||||
@@ -20,7 +20,8 @@ Companion plan: docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan
|
||||
import logging
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format
|
||||
from odoo.addons.fusion_plating.models.fp_tz import fp_format, fp_isoformat_utc
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -80,15 +81,21 @@ class FpWorkspaceController(http.Controller):
|
||||
'work_centre_name': step.work_centre_id.name or '',
|
||||
'duration_actual': step.duration_actual or 0,
|
||||
'duration_expected': step.duration_expected or 0,
|
||||
'date_started_iso': fp_format(
|
||||
env, step.date_started, fmt='%Y-%m-%d %H:%M:%S',
|
||||
) if step.date_started else '',
|
||||
# fp_isoformat_utc — preserves UTC with explicit +00:00
|
||||
# offset so the JS timer parses it as UTC (not local wall
|
||||
# time). fp_format would convert to user tz first, then
|
||||
# the JS would re-interpret that wall time as UTC and
|
||||
# offset the timer by the user's tz offset (4h on EDT).
|
||||
'date_started_iso': fp_isoformat_utc(step.date_started),
|
||||
'instructions': step.instructions or '',
|
||||
'thickness_target': step.thickness_target or 0,
|
||||
'thickness_uom': step.thickness_uom or '',
|
||||
'dwell_time_minutes': step.dwell_time_minutes or 0,
|
||||
'bake_setpoint_temp': step.bake_setpoint_temp or 0,
|
||||
'requires_signoff': bool(getattr(step, 'requires_signoff', False)),
|
||||
'requires_rack_assignment': bool(getattr(
|
||||
step, 'requires_rack_assignment', False,
|
||||
)),
|
||||
'can_start': bool(step.can_start) if 'can_start' in step._fields else (
|
||||
step.state in ('ready', 'paused') and step.blocker_kind == 'none'
|
||||
),
|
||||
@@ -130,6 +137,58 @@ class FpWorkspaceController(http.Controller):
|
||||
else job.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
|
||||
)
|
||||
|
||||
# ---- Receivings (pre-recipe box-count gate) ---------------------
|
||||
# Spec C1+C2 (2026-05-24): tablet UI for box count + per-line
|
||||
# received qty + damage log. Renders a card above the step list
|
||||
# for any receiving in state draft/counted. Closed receivings drop
|
||||
# off (recipe takes over). See fp.receiving model + the receiver
|
||||
# persona note in CLAUDE.md Sub 8.
|
||||
receivings_payload = []
|
||||
so = job.sale_order_id if 'sale_order_id' in job._fields else False
|
||||
if so and 'x_fc_receiving_ids' in so._fields:
|
||||
for rec in so.x_fc_receiving_ids.filtered(
|
||||
lambda r: r.state in ('draft', 'counted')
|
||||
):
|
||||
receivings_payload.append({
|
||||
'id': rec.id,
|
||||
'name': rec.name or '',
|
||||
'state': rec.state,
|
||||
'state_label': dict(rec._fields['state'].selection).get(
|
||||
rec.state, rec.state,
|
||||
),
|
||||
'box_count_in': int(rec.box_count_in or 0),
|
||||
'expected_qty': int(rec.expected_qty or 0),
|
||||
'received_qty': int(rec.received_qty or 0),
|
||||
'received_date': fp_format(
|
||||
env, rec.received_date, fmt='%Y-%m-%d %H:%M',
|
||||
) if rec.received_date else '',
|
||||
'received_by_name': rec.received_by_id.name or '',
|
||||
'lines': [{
|
||||
'id': line.id,
|
||||
'part_number': line.part_number or (
|
||||
line.part_catalog_id.part_number
|
||||
if line.part_catalog_id else ''
|
||||
),
|
||||
'description': line.description or '',
|
||||
'expected_qty': int(line.expected_qty or 0),
|
||||
'received_qty': int(line.received_qty or 0),
|
||||
'condition': line.condition or 'good',
|
||||
} for line in rec.line_ids],
|
||||
'damages': [{
|
||||
'id': dmg.id,
|
||||
'severity': dmg.severity or 'cosmetic',
|
||||
'severity_label': dict(
|
||||
dmg._fields['severity'].selection,
|
||||
).get(dmg.severity, dmg.severity),
|
||||
'description': dmg.description or '',
|
||||
'action_required': dmg.action_required or 'none',
|
||||
'action_label': dict(
|
||||
dmg._fields['action_required'].selection,
|
||||
).get(dmg.action_required, dmg.action_required),
|
||||
'photo_count': len(dmg.photo_ids),
|
||||
} for dmg in rec.damage_ids],
|
||||
})
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'job': {
|
||||
@@ -186,6 +245,14 @@ class FpWorkspaceController(http.Controller):
|
||||
for m in chatter
|
||||
],
|
||||
'required_certs': required_certs,
|
||||
'receivings': receivings_payload,
|
||||
# 2026-05-24 — is_manager surfaces to the JS so it can offer
|
||||
# the manager-bypass affordance (e.g. on the required-inputs
|
||||
# gate dialog). Server-side endpoints re-check the group
|
||||
# before honouring any bypass param — this is for UI only.
|
||||
'is_manager': env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager',
|
||||
),
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
@@ -253,6 +320,77 @@ class FpWorkspaceController(http.Controller):
|
||||
'attachment_id': attachment_id,
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/finish_step — structured-error finish with manager bypass
|
||||
# ======================================================================
|
||||
# Wraps step.button_finish with structured response shape so the
|
||||
# frontend can render the FpFinishBlockDialog (Cancel / Record /
|
||||
# Bypass) instead of a generic toast. Manager bypass requires the
|
||||
# caller to set bypass_required_inputs=True AND the server confirms
|
||||
# the user has the fusion_plating_manager group — trusting the
|
||||
# context flag alone would let any operator bypass via raw RPC.
|
||||
|
||||
@http.route('/fp/workspace/finish_step', type='jsonrpc', auth='user')
|
||||
def finish_step(self, step_id, bypass_required_inputs=False):
|
||||
env = request.env
|
||||
step = env['fp.job.step'].browse(int(step_id))
|
||||
if not step.exists():
|
||||
return {'ok': False, 'error': 'Step not found'}
|
||||
|
||||
# Server-side bypass authorization — can't trust the client.
|
||||
is_manager = env.user.has_group(
|
||||
'fusion_plating.group_fusion_plating_manager',
|
||||
)
|
||||
if bypass_required_inputs and not is_manager:
|
||||
return {
|
||||
'ok': False,
|
||||
'error': 'Manager privilege required to bypass required inputs.',
|
||||
'gate': 'permission_denied',
|
||||
'bypass_available': False,
|
||||
}
|
||||
|
||||
ctx = {}
|
||||
if bypass_required_inputs:
|
||||
ctx['fp_skip_required_inputs_gate'] = True
|
||||
|
||||
try:
|
||||
step.with_context(**ctx).button_finish()
|
||||
except UserError as e:
|
||||
msg = str(e.args[0]) if e.args else str(e)
|
||||
# Classify the gate so the JS knows whether to show the
|
||||
# block dialog (required_inputs) vs a plain toast.
|
||||
gate = 'other'
|
||||
if 'required input' in msg.lower() or 'no recipe link' in msg.lower():
|
||||
gate = 'required_inputs'
|
||||
elif 'sign-off' in msg.lower() or 'sign off' in msg.lower():
|
||||
gate = 'signoff'
|
||||
elif 'predecessor' in msg.lower():
|
||||
gate = 'predecessor'
|
||||
elif 'contract review' in msg.lower():
|
||||
gate = 'contract_review'
|
||||
# Collect the per-prompt list when the gate is required_inputs
|
||||
# AND the step has a recipe_node (orphan steps have nothing to list).
|
||||
missing = []
|
||||
if gate == 'required_inputs' and step.recipe_node_id:
|
||||
try:
|
||||
for p in step._fp_missing_required_step_inputs():
|
||||
missing.append({'id': p.id, 'name': p.name or ''})
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
'ok': False,
|
||||
'error': msg,
|
||||
'gate': gate,
|
||||
'missing_prompts': missing,
|
||||
'orphan_step': not bool(step.recipe_node_id),
|
||||
'bypass_available': is_manager and gate == 'required_inputs',
|
||||
}
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/finish_step unexpected error")
|
||||
return {'ok': False, 'error': str(exc), 'gate': 'unexpected'}
|
||||
|
||||
return {'ok': True}
|
||||
|
||||
# ======================================================================
|
||||
# /fp/workspace/sign_off — capture signature + finish step atomically
|
||||
# ======================================================================
|
||||
@@ -340,3 +478,147 @@ class FpWorkspaceController(http.Controller):
|
||||
'next_milestone_action': job.next_milestone_action or '',
|
||||
'next_milestone_label': job.next_milestone_label or '',
|
||||
}
|
||||
|
||||
# ======================================================================
|
||||
# Receiving — pre-recipe box-count + damage log (Spec C1+C2 2026-05-24)
|
||||
# ======================================================================
|
||||
# Mirrors the backend fp.receiving form just enough for the receiver
|
||||
# persona to count boxes, log damage with photos, and close the
|
||||
# receiving from the tablet workspace. No new backend models — wraps
|
||||
# action_mark_counted / action_close and fp.receiving.damage CRUD.
|
||||
|
||||
@http.route('/fp/workspace/receiving_save_lines',
|
||||
type='jsonrpc', auth='user')
|
||||
def receiving_save_lines(self, receiving_id, box_count_in=None,
|
||||
lines=None):
|
||||
"""Bulk-save box_count_in (on the receiving) + per-line
|
||||
received_qty/condition. Called on input blur for autosave.
|
||||
|
||||
`lines` is a list of {id, received_qty, condition}.
|
||||
"""
|
||||
env = request.env
|
||||
rec = env['fp.receiving'].browse(int(receiving_id))
|
||||
if not rec.exists():
|
||||
return {'ok': False, 'error': 'Receiving not found'}
|
||||
if rec.state not in ('draft', 'counted'):
|
||||
return {'ok': False, 'error': (
|
||||
'Receiving is %s — only Awaiting Parts / Counted are editable.'
|
||||
) % rec.state}
|
||||
try:
|
||||
if box_count_in is not None:
|
||||
rec.box_count_in = int(box_count_in or 0)
|
||||
for line_dict in (lines or []):
|
||||
line = env['fp.receiving.line'].browse(int(line_dict['id']))
|
||||
if not line.exists() or line.receiving_id.id != rec.id:
|
||||
continue # stale/foreign line id — skip silently
|
||||
line.write({
|
||||
'received_qty': int(line_dict.get('received_qty') or 0),
|
||||
'condition': line_dict.get('condition') or 'good',
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/receiving_save_lines failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
return {'ok': True}
|
||||
|
||||
@http.route('/fp/workspace/receiving_mark_counted',
|
||||
type='jsonrpc', auth='user')
|
||||
def receiving_mark_counted(self, receiving_id):
|
||||
"""Receiver finished counting → advance state draft → counted."""
|
||||
env = request.env
|
||||
rec = env['fp.receiving'].browse(int(receiving_id))
|
||||
if not rec.exists():
|
||||
return {'ok': False, 'error': 'Receiving not found'}
|
||||
try:
|
||||
rec.action_mark_counted()
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0])}
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/receiving_mark_counted failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
return {'ok': True, 'state': rec.state}
|
||||
|
||||
@http.route('/fp/workspace/receiving_close',
|
||||
type='jsonrpc', auth='user')
|
||||
def receiving_close(self, receiving_id):
|
||||
"""Close the receiving → advance state counted → closed.
|
||||
Clears the no_parts card_state on the linked job(s).
|
||||
"""
|
||||
env = request.env
|
||||
rec = env['fp.receiving'].browse(int(receiving_id))
|
||||
if not rec.exists():
|
||||
return {'ok': False, 'error': 'Receiving not found'}
|
||||
try:
|
||||
rec.action_close()
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0])}
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/receiving_close failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
return {'ok': True, 'state': rec.state}
|
||||
|
||||
@http.route('/fp/workspace/damage_create',
|
||||
type='jsonrpc', auth='user')
|
||||
def damage_create(self, receiving_id, description,
|
||||
severity='cosmetic', action_required='none',
|
||||
photos=None):
|
||||
"""Create a fp.receiving.damage row. `photos` is a list of
|
||||
{filename, data_base64}; each gets attached as ir.attachment
|
||||
and added to damage.photo_ids.
|
||||
"""
|
||||
env = request.env
|
||||
rec = env['fp.receiving'].browse(int(receiving_id))
|
||||
if not rec.exists():
|
||||
return {'ok': False, 'error': 'Receiving not found'}
|
||||
if not description or not description.strip():
|
||||
return {'ok': False, 'error': 'Description is required'}
|
||||
try:
|
||||
damage = env['fp.receiving.damage'].create({
|
||||
'receiving_id': rec.id,
|
||||
'description': description.strip(),
|
||||
'severity': severity or 'cosmetic',
|
||||
'action_required': action_required or 'none',
|
||||
})
|
||||
# Attach photos (base64 from camera / file picker). Failure
|
||||
# on a single attach doesn't roll back the damage row —
|
||||
# operator can re-upload via the back office form if needed.
|
||||
photo_atts = env['ir.attachment']
|
||||
for p in (photos or []):
|
||||
try:
|
||||
att = env['ir.attachment'].create({
|
||||
'name': p.get('filename') or 'damage.jpg',
|
||||
'datas': p.get('data_base64') or '',
|
||||
'res_model': 'fp.receiving.damage',
|
||||
'res_id': damage.id,
|
||||
})
|
||||
photo_atts |= att
|
||||
except Exception:
|
||||
_logger.exception(
|
||||
'damage_create: photo attach failed for damage %s',
|
||||
damage.id,
|
||||
)
|
||||
if photo_atts:
|
||||
damage.photo_ids = [(6, 0, photo_atts.ids)]
|
||||
except UserError as e:
|
||||
return {'ok': False, 'error': str(e.args[0])}
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/damage_create failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
return {'ok': True, 'damage_id': damage.id}
|
||||
|
||||
@http.route('/fp/workspace/damage_delete',
|
||||
type='jsonrpc', auth='user')
|
||||
def damage_delete(self, damage_id):
|
||||
"""Remove a damage row (operator changed their mind, or logged
|
||||
in error). CASCADE removes the attachment link rows; the
|
||||
ir.attachment records themselves persist (might be referenced
|
||||
elsewhere)."""
|
||||
env = request.env
|
||||
dmg = env['fp.receiving.damage'].browse(int(damage_id))
|
||||
if not dmg.exists():
|
||||
return {'ok': False, 'error': 'Damage row not found'}
|
||||
try:
|
||||
dmg.unlink()
|
||||
except Exception as exc:
|
||||
_logger.exception("workspace/damage_delete failed")
|
||||
return {'ok': False, 'error': str(exc)}
|
||||
return {'ok': True}
|
||||
|
||||
@@ -120,39 +120,8 @@
|
||||
<field name="notes" type="html"><p>Window missed — operator shift change, parts left on rack. Flagged for quality review.</p></field>
|
||||
</record>
|
||||
|
||||
<!-- ========== FIRST PIECE GATES ========== -->
|
||||
<record id="demo_fpg_1" model="fusion.plating.first.piece.gate">
|
||||
<field name="bath_id" ref="fusion_plating.demo_bath_en_mp"/>
|
||||
<field name="part_ref">P/N 4422-B — Hydraulic Cylinder Rod</field>
|
||||
<field name="customer_ref">WO-8841</field>
|
||||
<field name="routing_first_run" eval="True"/>
|
||||
<field name="first_piece_produced" eval="(DateTime.now() - timedelta(hours=3)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="first_piece_inspected" eval="(DateTime.now() - timedelta(hours=2, minutes=30)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="result">pass</field>
|
||||
<field name="rest_of_lot_released" eval="True"/>
|
||||
<field name="notes" type="html"><p>Thickness: 0.0005" ± 0.0001" — within spec. Adhesion bend test passed. Lot released for full production.</p></field>
|
||||
</record>
|
||||
|
||||
<record id="demo_fpg_2" model="fusion.plating.first.piece.gate">
|
||||
<field name="bath_id" ref="fusion_plating.demo_bath_cr_hard"/>
|
||||
<field name="part_ref">P/N 7810-A — Landing Gear Pin</field>
|
||||
<field name="customer_ref">WO-8835</field>
|
||||
<field name="routing_first_run" eval="False"/>
|
||||
<field name="first_piece_produced" eval="(DateTime.now() - timedelta(hours=4)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="result">pending</field>
|
||||
<field name="rest_of_lot_released" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="demo_fpg_3" model="fusion.plating.first.piece.gate">
|
||||
<field name="bath_id" ref="fusion_plating.demo_bath_an_typeii"/>
|
||||
<field name="part_ref">P/N 3300-F — Enclosure Panel</field>
|
||||
<field name="customer_ref">WO-8830</field>
|
||||
<field name="routing_first_run" eval="True"/>
|
||||
<field name="first_piece_produced" eval="(DateTime.now() - timedelta(days=1, hours=2)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="first_piece_inspected" eval="(DateTime.now() - timedelta(days=1, hours=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="result">fail</field>
|
||||
<field name="rest_of_lot_released" eval="False"/>
|
||||
<field name="notes" type="html"><p>Colour variation on test coupon — dye bath concentration too low. Bath adjusted and retested before proceeding.</p></field>
|
||||
</record>
|
||||
<!-- First-piece gate demo records retired with the fp.first.piece.gate
|
||||
model removal (19.0.33.2.0, 2026-05-25). The feature was a skeleton
|
||||
(manual create, no enforcement, no job link) with 0 entech rows. -->
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -14,13 +14,4 @@
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="seq_fp_first_piece_gate" model="ir.sequence">
|
||||
<field name="name">Fusion Plating: First-Piece Gate</field>
|
||||
<field name="code">fusion.plating.first.piece.gate</field>
|
||||
<field name="prefix">FPG/%(year)s/</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""19.0.33.2.0 — Drop fp.first.piece.gate model + all dependents.
|
||||
|
||||
The first-piece gate model was a skeleton: manual-create only, no
|
||||
enforcement gate, no FK to fp.job, 0 production rows on entech after
|
||||
months. Audit on 2026-05-25 concluded REMOVE.
|
||||
|
||||
Per Rule "Removing menus/records — Odoo does NOT auto-delete orphans":
|
||||
deleting <menuitem> / <record> tags from XML does NOT remove the
|
||||
corresponding DB rows. We have to explicitly drop them here.
|
||||
|
||||
Pre-migrate (vs post-migrate): runs BEFORE the new XML loads, so
|
||||
there's no window where the loader tries to reference the deleted
|
||||
model/action/view records and fails. Also runs before model __init__,
|
||||
so the registry never sees the (now-removed) Python class try to
|
||||
match against an existing-but-renamed DB table.
|
||||
"""
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
_logger.info("Dropping fp.first.piece.gate model + all dependents")
|
||||
|
||||
# ---- 1. Drop the table (CASCADE wipes any inbound FKs we missed) ----
|
||||
cr.execute("""
|
||||
DROP TABLE IF EXISTS fusion_plating_first_piece_gate CASCADE
|
||||
""")
|
||||
|
||||
# ---- 2. ir.model row (cascades to ir.model.fields, ir.model.access,
|
||||
# and ir.model.constraint via Odoo's own FK rules) -----------
|
||||
cr.execute("""
|
||||
DELETE FROM ir_model
|
||||
WHERE model = 'fusion.plating.first.piece.gate'
|
||||
""")
|
||||
|
||||
# ---- 3. Orphan ir.ui.menu — the menuitem was removed from
|
||||
# fp_menu.xml; without explicit delete it'd linger ----------
|
||||
cr.execute("""
|
||||
DELETE FROM ir_ui_menu
|
||||
WHERE id IN (
|
||||
SELECT res_id FROM ir_model_data
|
||||
WHERE module = 'fusion_plating_shopfloor'
|
||||
AND model = 'ir.ui.menu'
|
||||
AND name = 'menu_fp_shopfloor_first_piece'
|
||||
)
|
||||
""")
|
||||
|
||||
# ---- 4. Orphan ir.actions.act_window ------------------------------
|
||||
cr.execute("""
|
||||
DELETE FROM ir_act_window
|
||||
WHERE id IN (
|
||||
SELECT res_id FROM ir_model_data
|
||||
WHERE module = 'fusion_plating_shopfloor'
|
||||
AND model = 'ir.actions.act_window'
|
||||
AND name = 'action_fp_first_piece_gate'
|
||||
)
|
||||
""")
|
||||
|
||||
# ---- 5. Drop the ir.sequence row + its companion postgres
|
||||
# sequence (Odoo creates a real PG seq named "fusion_plating_first_piece_gate") ----
|
||||
cr.execute("""
|
||||
DELETE FROM ir_sequence
|
||||
WHERE code = 'fusion.plating.first.piece.gate'
|
||||
""")
|
||||
cr.execute("""
|
||||
DROP SEQUENCE IF EXISTS fusion_plating_first_piece_gate
|
||||
""")
|
||||
|
||||
# ---- 6. Drop ALL remaining ir.model.data rows tagged for the
|
||||
# retired model so noupdate=1 demo seeds, view xmlids, etc.
|
||||
# don't ghost-haunt future upgrades ------------------------
|
||||
cr.execute("""
|
||||
DELETE FROM ir_model_data
|
||||
WHERE module = 'fusion_plating_shopfloor'
|
||||
AND ( name = 'menu_fp_shopfloor_first_piece'
|
||||
OR name = 'action_fp_first_piece_gate'
|
||||
OR name = 'seq_fp_first_piece_gate'
|
||||
OR name LIKE 'view_fp_first_piece%'
|
||||
OR name LIKE 'demo_fpg_%')
|
||||
""")
|
||||
|
||||
_logger.info(
|
||||
"fp.first.piece.gate removal complete: table dropped, ir.model "
|
||||
"row gone, menu/action/sequence/views/demo xmlids purged."
|
||||
)
|
||||
@@ -5,7 +5,6 @@
|
||||
from . import fp_shopfloor_station
|
||||
from . import fp_bake_oven
|
||||
from . import fp_bake_window
|
||||
from . import fp_first_piece_gate
|
||||
from . import fp_operator_queue
|
||||
from . import fp_tank
|
||||
from . import res_users
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpFirstPieceGate(models.Model):
|
||||
"""First-piece inspection gate per routing.
|
||||
|
||||
Aerospace, nuclear and many automotive customers require that the FIRST
|
||||
piece off a routing be inspected and dispositioned BEFORE the rest of
|
||||
the lot is allowed to run. This model captures that gate.
|
||||
"""
|
||||
_name = 'fusion.plating.first.piece.gate'
|
||||
_description = 'Fusion Plating — First-Piece Inspection Gate'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'first_piece_produced desc, id desc'
|
||||
|
||||
name = fields.Char(
|
||||
string='Reference',
|
||||
required=True,
|
||||
copy=False,
|
||||
readonly=True,
|
||||
default=lambda self: self._default_name(),
|
||||
tracking=True,
|
||||
)
|
||||
bath_id = fields.Many2one(
|
||||
'fusion.plating.bath',
|
||||
string='Bath',
|
||||
ondelete='restrict',
|
||||
tracking=True,
|
||||
)
|
||||
facility_id = fields.Many2one(
|
||||
'fusion.plating.facility',
|
||||
related='bath_id.facility_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
part_ref = fields.Char(
|
||||
string='Part Reference',
|
||||
tracking=True,
|
||||
)
|
||||
customer_ref = fields.Char(
|
||||
string='Customer Reference',
|
||||
tracking=True,
|
||||
)
|
||||
routing_first_run = fields.Boolean(
|
||||
string='First Run of Routing',
|
||||
help='Tick if this is the first time this routing runs this part.',
|
||||
)
|
||||
|
||||
first_piece_produced = fields.Datetime(
|
||||
string='First Piece Produced',
|
||||
tracking=True,
|
||||
)
|
||||
first_piece_inspected = fields.Datetime(
|
||||
string='First Piece Inspected',
|
||||
tracking=True,
|
||||
)
|
||||
inspector_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Inspector',
|
||||
tracking=True,
|
||||
)
|
||||
result = fields.Selection(
|
||||
[
|
||||
('pending', 'Pending'),
|
||||
('pass', 'Pass'),
|
||||
('fail', 'Fail'),
|
||||
],
|
||||
string='Result',
|
||||
default='pending',
|
||||
required=True,
|
||||
tracking=True,
|
||||
)
|
||||
rest_of_lot_released = fields.Boolean(
|
||||
string='Rest of Lot Released',
|
||||
tracking=True,
|
||||
)
|
||||
notes = fields.Html(
|
||||
string='Notes',
|
||||
)
|
||||
status_color = fields.Integer(
|
||||
compute='_compute_status_color',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# ==========================================================================
|
||||
@api.model
|
||||
def _default_name(self):
|
||||
seq = self.env['ir.sequence'].next_by_code('fusion.plating.first.piece.gate')
|
||||
return seq or '/'
|
||||
|
||||
@api.depends('result', 'rest_of_lot_released')
|
||||
def _compute_status_color(self):
|
||||
for rec in self:
|
||||
if rec.result == 'fail':
|
||||
rec.status_color = 1 # red
|
||||
elif rec.result == 'pass' and rec.rest_of_lot_released:
|
||||
rec.status_color = 4 # green
|
||||
elif rec.result == 'pass':
|
||||
rec.status_color = 3 # yellow — passed but not released
|
||||
else:
|
||||
rec.status_color = 5 # purple — pending
|
||||
|
||||
# ==========================================================================
|
||||
# Actions
|
||||
# ==========================================================================
|
||||
def action_mark_pass(self):
|
||||
self.write({
|
||||
'result': 'pass',
|
||||
'first_piece_inspected': fields.Datetime.now(),
|
||||
'inspector_id': self.env.user.id,
|
||||
})
|
||||
|
||||
def action_mark_fail(self):
|
||||
self.write({
|
||||
'result': 'fail',
|
||||
'first_piece_inspected': fields.Datetime.now(),
|
||||
'inspector_id': self.env.user.id,
|
||||
})
|
||||
|
||||
def action_release_lot(self):
|
||||
self.filtered(lambda r: r.result == 'pass').write({
|
||||
'rest_of_lot_released': True,
|
||||
})
|
||||
@@ -65,20 +65,7 @@ class FpOperatorQueue(models.TransientModel):
|
||||
'source_id': bw.id,
|
||||
})
|
||||
|
||||
gate_domain = [('result', '=', 'pending')]
|
||||
if facility_id:
|
||||
gate_domain.append(('facility_id', '=', facility_id))
|
||||
gates = self.env['fusion.plating.first.piece.gate'].search(gate_domain)
|
||||
for g in gates:
|
||||
rows.append({
|
||||
'operator_id': user_id,
|
||||
'facility_id': g.facility_id.id,
|
||||
'label': f"First piece: {g.name}",
|
||||
'description': f"Part {g.part_ref or '?'}",
|
||||
'priority': 80,
|
||||
'source_model': 'fusion.plating.first.piece.gate',
|
||||
'source_id': g.id,
|
||||
})
|
||||
# First-piece-gate aggregation retired (19.0.33.2.0) with the model.
|
||||
|
||||
# ----- MRP work orders (if fusion_plating_bridge_mrp installed) -----
|
||||
# Show two buckets, in this order:
|
||||
|
||||
@@ -17,27 +17,22 @@ class FpTank(models.Model):
|
||||
x_fp_shopfloor_queue_size = fields.Integer(
|
||||
string='Shopfloor Queue',
|
||||
compute='_compute_shopfloor_queue_size',
|
||||
help='Number of bake windows + first-piece gates currently waiting on '
|
||||
'this tank\'s current bath.',
|
||||
help='Number of bake windows currently waiting on this tank\'s '
|
||||
'current bath.',
|
||||
)
|
||||
|
||||
def _compute_shopfloor_queue_size(self):
|
||||
# First-piece-gate contribution dropped 19.0.33.2.0 with the model.
|
||||
BakeWindow = self.env['fusion.plating.bake.window']
|
||||
Gate = self.env['fusion.plating.first.piece.gate']
|
||||
for rec in self:
|
||||
bath_ids = rec.bath_ids.ids
|
||||
if not bath_ids:
|
||||
rec.x_fp_shopfloor_queue_size = 0
|
||||
continue
|
||||
count = BakeWindow.search_count([
|
||||
rec.x_fp_shopfloor_queue_size = BakeWindow.search_count([
|
||||
('bath_id', 'in', bath_ids),
|
||||
('state', 'in', ('awaiting_bake', 'bake_in_progress')),
|
||||
])
|
||||
count += Gate.search_count([
|
||||
('bath_id', 'in', bath_ids),
|
||||
('result', '=', 'pending'),
|
||||
])
|
||||
rec.x_fp_shopfloor_queue_size = count
|
||||
|
||||
def action_open_tablet_view(self):
|
||||
"""Open the tablet client action focused on this tank."""
|
||||
|
||||
@@ -8,9 +8,6 @@ access_fp_bake_oven_manager,fp.bake.oven.manager,model_fusion_plating_bake_oven,
|
||||
access_fp_bake_window_operator,fp.bake.window.operator,model_fusion_plating_bake_window,fusion_plating.group_fp_technician,1,1,1,0
|
||||
access_fp_bake_window_supervisor,fp.bake.window.supervisor,model_fusion_plating_bake_window,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_bake_window_manager,fp.bake.window.manager,model_fusion_plating_bake_window,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_first_piece_gate_operator,fp.first.piece.gate.operator,model_fusion_plating_first_piece_gate,fusion_plating.group_fp_technician,1,1,1,0
|
||||
access_fp_first_piece_gate_supervisor,fp.first.piece.gate.supervisor,model_fusion_plating_first_piece_gate,fusion_plating.group_fp_shop_manager_v2,1,1,1,0
|
||||
access_fp_first_piece_gate_manager,fp.first.piece.gate.manager,model_fusion_plating_first_piece_gate,fusion_plating.group_fp_manager,1,1,1,1
|
||||
access_fp_operator_queue_operator,fp.operator.queue.operator,model_fusion_plating_operator_queue,fusion_plating.group_fp_technician,1,1,1,1
|
||||
access_fp_operator_queue_supervisor,fp.operator.queue.supervisor,model_fusion_plating_operator_queue,fusion_plating.group_fp_shop_manager_v2,1,1,1,1
|
||||
access_fp_operator_queue_manager,fp.operator.queue.manager,model_fusion_plating_operator_queue,fusion_plating.group_fp_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,142 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — FpDamageDialog
|
||||
//
|
||||
// Tablet-friendly modal for logging damage during receiving. Captures:
|
||||
// - Severity (Cosmetic / Functional / Rejected) — pill picker
|
||||
// - Description (required textarea)
|
||||
// - Action Required (None / Notify / Return / Proceed) — pill picker
|
||||
// - Photos — both camera capture (capture="environment") AND file picker
|
||||
//
|
||||
// Wired from FpJobWorkspace via onAddDamage. POSTs to
|
||||
// /fp/workspace/damage_create on Save; caller refreshes after onCreated().
|
||||
//
|
||||
// Spec C1+C2 2026-05-24 (receiving tablet UI).
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState, useRef } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
const SEVERITIES = [
|
||||
{ code: "cosmetic", label: "Cosmetic", cssClass: "o_fp_dmg_sev_cosmetic" },
|
||||
{ code: "functional", label: "Functional", cssClass: "o_fp_dmg_sev_functional" },
|
||||
{ code: "rejected", label: "Rejected", cssClass: "o_fp_dmg_sev_rejected" },
|
||||
];
|
||||
|
||||
const ACTIONS = [
|
||||
{ code: "none", label: "None" },
|
||||
{ code: "notify_customer", label: "Notify Customer" },
|
||||
{ code: "return_parts", label: "Return Parts" },
|
||||
{ code: "proceed_as_is", label: "Proceed As-Is" },
|
||||
];
|
||||
|
||||
export class FpDamageDialog extends Component {
|
||||
static template = "fusion_plating_shopfloor.FpDamageDialog";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
close: Function,
|
||||
receivingId: Number,
|
||||
onCreated: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.fileInputRef = useRef("fileInput");
|
||||
this.cameraInputRef = useRef("cameraInput");
|
||||
this.state = useState({
|
||||
severity: "cosmetic",
|
||||
description: "",
|
||||
actionRequired: "none",
|
||||
photos: [], // [{filename, data_base64, preview_url}]
|
||||
saving: false,
|
||||
});
|
||||
this.severities = SEVERITIES;
|
||||
this.actions = ACTIONS;
|
||||
}
|
||||
|
||||
setSeverity(code) { this.state.severity = code; }
|
||||
setAction(code) { this.state.actionRequired = code; }
|
||||
|
||||
triggerCamera() { this.cameraInputRef.el?.click(); }
|
||||
triggerFile() { this.fileInputRef.el?.click(); }
|
||||
|
||||
async onFilesPicked(ev) {
|
||||
const files = Array.from(ev.target.files || []);
|
||||
for (const file of files) {
|
||||
try {
|
||||
const base64 = await this._readAsBase64(file);
|
||||
this.state.photos.push({
|
||||
filename: file.name || `damage_${Date.now()}.jpg`,
|
||||
data_base64: base64,
|
||||
preview_url: URL.createObjectURL(file),
|
||||
});
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
`Couldn't read ${file.name}: ${err.message}`,
|
||||
{ type: "danger" },
|
||||
);
|
||||
}
|
||||
}
|
||||
ev.target.value = ""; // reset so picking the same file twice re-fires
|
||||
}
|
||||
|
||||
_readAsBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
// strip the "data:image/jpeg;base64," prefix — backend wants raw base64
|
||||
const dataUrl = reader.result || "";
|
||||
const idx = String(dataUrl).indexOf(",");
|
||||
resolve(idx >= 0 ? dataUrl.slice(idx + 1) : dataUrl);
|
||||
};
|
||||
reader.onerror = () => reject(reader.error || new Error("read failed"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
removePhoto(index) {
|
||||
const p = this.state.photos[index];
|
||||
if (p && p.preview_url) {
|
||||
try { URL.revokeObjectURL(p.preview_url); } catch { /* noop */ }
|
||||
}
|
||||
this.state.photos.splice(index, 1);
|
||||
}
|
||||
|
||||
async onSave() {
|
||||
if (!this.state.description.trim()) {
|
||||
this.notification.add("Description is required.", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
this.state.saving = true;
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/damage_create", {
|
||||
receiving_id: this.props.receivingId,
|
||||
description: this.state.description.trim(),
|
||||
severity: this.state.severity,
|
||||
action_required: this.state.actionRequired,
|
||||
photos: this.state.photos.map((p) => ({
|
||||
filename: p.filename,
|
||||
data_base64: p.data_base64,
|
||||
})),
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Damage logged.", { type: "success" });
|
||||
if (this.props.onCreated) await this.props.onCreated(res);
|
||||
this.props.close();
|
||||
} else {
|
||||
this.notification.add(
|
||||
(res && res.error) || "Save failed",
|
||||
{ type: "danger" },
|
||||
);
|
||||
this.state.saving = false;
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
this.state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onCancel() { this.props.close(); }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/** @odoo-module **/
|
||||
// =============================================================================
|
||||
// Fusion Plating — FpFinishBlockDialog
|
||||
//
|
||||
// Shown when /fp/workspace/finish_step returns ok=false with gate='required_inputs'.
|
||||
// Non-managers see: Cancel + Record Inputs.
|
||||
// Managers see: Cancel + Record Inputs + ⚠ Bypass & Finish (audit).
|
||||
//
|
||||
// On Record Inputs → caller opens the Record Inputs dialog for the step.
|
||||
// On Bypass → caller calls finish_step again with bypass_required_inputs=true.
|
||||
//
|
||||
// Spec: workspace step actions follow-up 2026-05-24.
|
||||
// =============================================================================
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { Dialog } from "@web/core/dialog/dialog";
|
||||
|
||||
export class FpFinishBlockDialog extends Component {
|
||||
static template = "fusion_plating_shopfloor.FpFinishBlockDialog";
|
||||
static components = { Dialog };
|
||||
static props = {
|
||||
close: Function,
|
||||
stepName: String,
|
||||
// Server-classified gate. 'required_inputs' is the only one the
|
||||
// current Record/Bypass UI handles — other gates fall back to
|
||||
// showing the message + a Cancel button only.
|
||||
gate: String,
|
||||
message: String,
|
||||
missingPrompts: { type: Array, optional: true },
|
||||
orphanStep: { type: Boolean, optional: true },
|
||||
canBypass: { type: Boolean, optional: true },
|
||||
// Callbacks
|
||||
onRecordInputs: { type: Function, optional: true },
|
||||
onBypass: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({ bypassing: false });
|
||||
}
|
||||
|
||||
onCancel() { this.props.close(); }
|
||||
|
||||
async onRecord() {
|
||||
this.props.close();
|
||||
if (this.props.onRecordInputs) await this.props.onRecordInputs();
|
||||
}
|
||||
|
||||
async onBypassClick() {
|
||||
if (!window.confirm(
|
||||
`Bypass required inputs and finish "${this.props.stepName}"?\n\n` +
|
||||
`This will be logged to the job chatter naming you as the ` +
|
||||
`bypassing manager. Use only for documented deviations.`
|
||||
)) return;
|
||||
this.state.bypassing = true;
|
||||
try {
|
||||
if (this.props.onBypass) await this.props.onBypass();
|
||||
this.props.close();
|
||||
} finally {
|
||||
this.state.bypassing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,14 @@ import { GateViz } from "./components/gate_viz";
|
||||
import { FpSignaturePad } from "./components/signature_pad";
|
||||
import { FpHoldComposer } from "./components/hold_composer";
|
||||
import { FpTabletLock } from "./tablet_lock";
|
||||
import { FpRackPartsDialog } from "./rack_parts_dialog";
|
||||
import { FpDamageDialog } from "./fp_damage_dialog";
|
||||
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
|
||||
|
||||
export class FpJobWorkspace extends Component {
|
||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||
static props = ["*"];
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock };
|
||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog };
|
||||
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
@@ -43,21 +46,90 @@ export class FpJobWorkspace extends Component {
|
||||
data: null,
|
||||
jobId: null,
|
||||
focusStepId: null,
|
||||
// Reactive monotonic tick — bumped every 1s by _tickInterval so
|
||||
// the active-step timer re-renders without an RPC. The template
|
||||
// reads tickNow and re-runs formatActiveStepElapsed each second.
|
||||
tickNow: Date.now(),
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const params = (this.props.action && this.props.action.params) || {};
|
||||
this.state.jobId = params.job_id || null;
|
||||
this.state.focusStepId = params.focus_step_id || null;
|
||||
// After Hand Off + PIN relogin the action remounts without
|
||||
// params (action params aren't URL-encoded), so jobId is
|
||||
// null and refresh() exits early — workspace was stuck on
|
||||
// "Loading Job Workspace…" indefinitely. Fall back to the
|
||||
// plant kanban so the operator lands somewhere usable.
|
||||
if (!this.state.jobId) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_plant_kanban",
|
||||
target: "current",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.refresh();
|
||||
// If load failed (job no longer accessible, server error, etc.)
|
||||
// also redirect — leaving the user on a perma-loading screen
|
||||
// with no recovery path is worse than dropping them at the
|
||||
// kanban where they can re-enter.
|
||||
if (!this.state.data) {
|
||||
this.action.doAction({
|
||||
type: "ir.actions.client",
|
||||
tag: "fp_plant_kanban",
|
||||
target: "current",
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._refreshInterval = setInterval(() => this.refresh(), 15000);
|
||||
// 1s tick — pure client-side; no RPC. Drives the live timer
|
||||
// on the active step's badge area.
|
||||
this._tickInterval = setInterval(() => {
|
||||
this.state.tickNow = Date.now();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this._refreshInterval) clearInterval(this._refreshInterval);
|
||||
if (this._tickInterval) clearInterval(this._tickInterval);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Live timer helpers (Spec 2026-05-24 follow-up) -------------------
|
||||
// formatActiveStepElapsed reads step.date_started_iso (UTC string from
|
||||
// the payload), parses to ms, and returns HH:MM:SS elapsed since.
|
||||
// Returns '' when no start time. Re-runs every 1s via state.tickNow.
|
||||
|
||||
formatActiveStepElapsed(step) {
|
||||
if (!step || !step.date_started_iso) return "";
|
||||
// Controller now sends fp_isoformat_utc — a proper ISO-8601 with
|
||||
// explicit +00:00 offset. Parse directly. (Previously appended
|
||||
// "Z" to a fp_format string, which had been converted to user's
|
||||
// local tz, so the timer was offset by the tz delta — 4h on EDT.)
|
||||
const startedMs = Date.parse(step.date_started_iso);
|
||||
if (!startedMs || isNaN(startedMs)) return "";
|
||||
// touch state.tickNow so OWL re-evaluates this getter every tick
|
||||
const now = this.state.tickNow || Date.now();
|
||||
const totalSec = Math.max(0, Math.floor((now - startedMs) / 1000));
|
||||
const h = Math.floor(totalSec / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
const pad = (n) => (n < 10 ? "0" + n : "" + n);
|
||||
return pad(h) + ":" + pad(m) + ":" + pad(s);
|
||||
}
|
||||
|
||||
isActiveStepOvertime(step) {
|
||||
// True when elapsed > 1.5× expected duration (drives red colour).
|
||||
// duration_expected is in minutes.
|
||||
if (!step || !step.date_started_iso || !step.duration_expected) return false;
|
||||
const startedMs = Date.parse(step.date_started_iso);
|
||||
if (!startedMs || isNaN(startedMs)) return false;
|
||||
const now = this.state.tickNow || Date.now();
|
||||
const elapsedMin = (now - startedMs) / 1000 / 60;
|
||||
return elapsedMin > step.duration_expected * 1.5;
|
||||
}
|
||||
|
||||
// ---- Data refresh ------------------------------------------------------
|
||||
async refresh() {
|
||||
if (!this.state.jobId) return;
|
||||
@@ -138,6 +210,79 @@ export class FpJobWorkspace extends Component {
|
||||
return step.state === "in_progress";
|
||||
}
|
||||
|
||||
// ---- Per-kind action dispatcher (Spec A 2026-05-24) -------------------
|
||||
// getStepActions returns the list of action descriptors to render for a
|
||||
// step based on its current state + kind. dispatchStepAction routes the
|
||||
// selected action key to the right handler. See spec
|
||||
// 2026-05-24-workspace-step-actions-design.md.
|
||||
|
||||
getStepActions(step) {
|
||||
// Terminal states render no action buttons (template also guards).
|
||||
if (["done", "skipped", "cancelled"].includes(step.state)) return [];
|
||||
// Blocked / opt-out steps show GateViz / notice, no actions.
|
||||
if (step.blocker_kind && step.blocker_kind !== "none") return [];
|
||||
if (step.override_excluded) return [];
|
||||
|
||||
const actions = [];
|
||||
if (step.state === "in_progress") {
|
||||
actions.push({ key: "record_inputs", label: "Record Inputs",
|
||||
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
||||
actions.push({ key: "pause", label: "Pause",
|
||||
icon: "fa fa-pause", cssClass: "btn btn-light" });
|
||||
actions.push({
|
||||
key: "finish",
|
||||
label: step.requires_signoff ? "Finish & Sign Off" : "Finish",
|
||||
icon: "fa fa-check", cssClass: "btn btn-success",
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
if (step.state === "paused") {
|
||||
actions.push({ key: "resume", label: "Resume",
|
||||
icon: "fa fa-play", cssClass: "btn btn-primary" });
|
||||
actions.push({ key: "record_inputs", label: "Record Inputs",
|
||||
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
||||
actions.push({
|
||||
key: "finish",
|
||||
label: step.requires_signoff ? "Finish & Sign Off" : "Finish",
|
||||
icon: "fa fa-check", cssClass: "btn btn-success",
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
// state in ('pending', 'ready') — entry-point per kind.
|
||||
if (step.kind === "contract_review") {
|
||||
actions.push({ key: "open_contract_review", label: "Open QA-005 Form",
|
||||
icon: "fa fa-file-text-o", cssClass: "btn btn-primary" });
|
||||
return actions;
|
||||
}
|
||||
if (step.kind === "gating") {
|
||||
actions.push({ key: "mark_passed", label: "Mark Passed",
|
||||
icon: "fa fa-check-circle", cssClass: "btn btn-success" });
|
||||
return actions;
|
||||
}
|
||||
if (step.requires_rack_assignment) {
|
||||
actions.push({ key: "start_with_rack", label: "Start (Assign Rack)",
|
||||
icon: "fa fa-server", cssClass: "btn btn-primary" });
|
||||
return actions;
|
||||
}
|
||||
// Default — plain Start
|
||||
actions.push({ key: "start", label: "Start",
|
||||
icon: "fa fa-play", cssClass: "btn btn-primary" });
|
||||
return actions;
|
||||
}
|
||||
|
||||
async dispatchStepAction(step, key) {
|
||||
switch (key) {
|
||||
case "start": return this.onStartStep(step.id);
|
||||
case "resume": return this.onResumeStep(step);
|
||||
case "pause": return this.onPauseStep(step);
|
||||
case "record_inputs": return this.onRecordInputs(step);
|
||||
case "finish": return this.onFinishStep(step);
|
||||
case "mark_passed": return this.onMarkPassed(step);
|
||||
case "open_contract_review": return this.onOpenContractReview(step);
|
||||
case "start_with_rack": return this.onStartWithRack(step);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Step actions ------------------------------------------------------
|
||||
async onStartStep(stepId) {
|
||||
try {
|
||||
@@ -198,16 +343,224 @@ export class FpJobWorkspace extends Component {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Plain finish — no signature required
|
||||
// Plain finish — route through /fp/workspace/finish_step which
|
||||
// returns structured errors so we can show the FpFinishBlockDialog
|
||||
// for required-inputs failures (with manager bypass option).
|
||||
await this._callFinishStep(step, /* bypass */ false);
|
||||
}
|
||||
|
||||
async _callFinishStep(step, bypassRequiredInputs) {
|
||||
try {
|
||||
const res = await fpRpc("/fp/shopfloor/stop_wo", {
|
||||
workorder_id: step.id, finish: true,
|
||||
const res = await rpc("/fp/workspace/finish_step", {
|
||||
step_id: step.id,
|
||||
bypass_required_inputs: !!bypassRequiredInputs,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Step finished.", { type: "success" });
|
||||
this.notification.add(
|
||||
bypassRequiredInputs
|
||||
? "Step finished (required-inputs bypassed; audit logged)."
|
||||
: "Step finished.",
|
||||
{ type: "success" },
|
||||
);
|
||||
await this.refresh();
|
||||
return;
|
||||
}
|
||||
// Structured-error branch — show block dialog for the
|
||||
// required_inputs gate (it has the rich Record / Bypass UX).
|
||||
// Other gates fall back to a plain notification.
|
||||
if (res && res.gate === "required_inputs") {
|
||||
this.dialog.add(FpFinishBlockDialog, {
|
||||
stepName: step.name,
|
||||
gate: res.gate,
|
||||
message: res.error || "Cannot finish step.",
|
||||
missingPrompts: res.missing_prompts || [],
|
||||
orphanStep: !!res.orphan_step,
|
||||
canBypass: !!res.bypass_available,
|
||||
onRecordInputs: async () => this.onRecordInputs(step),
|
||||
onBypass: async () => this._callFinishStep(step, true),
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.notification.add(
|
||||
(res && res.error) || "Finish failed",
|
||||
{ type: "danger" },
|
||||
);
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || String(err), { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- New per-kind handlers (Spec A 2026-05-24) ------------------------
|
||||
|
||||
async onPauseStep(step) {
|
||||
const reason = window.prompt(`Pause reason for "${step.name}"?`, "");
|
||||
if (reason === null) return; // operator cancelled
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "fp.job.step", method: "button_pause",
|
||||
args: [[step.id]],
|
||||
kwargs: { reason: reason || "no reason given" },
|
||||
});
|
||||
this.notification.add("Step paused.", { type: "success" });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || "Pause failed", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onResumeStep(step) {
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "fp.job.step", method: "button_resume",
|
||||
args: [[step.id]], kwargs: {},
|
||||
});
|
||||
this.notification.add("Step resumed.", { type: "success" });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || "Resume failed", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onMarkPassed(step) {
|
||||
try {
|
||||
await rpc("/web/dataset/call_kw", {
|
||||
model: "fp.job.step", method: "action_mark_gating_passed",
|
||||
args: [[step.id]], kwargs: {},
|
||||
});
|
||||
this.notification.add("Gate marked passed.", { type: "success" });
|
||||
await this.refresh();
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || "Mark passed failed", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onOpenContractReview(step) {
|
||||
try {
|
||||
const result = await rpc("/web/dataset/call_kw", {
|
||||
model: "fp.job.step", method: "_fp_open_contract_review",
|
||||
args: [[step.id]], kwargs: {},
|
||||
});
|
||||
if (result) {
|
||||
await this.action.doAction(result, { onClose: () => this.refresh() });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || "Couldn't open QA-005", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onStartWithRack(step) {
|
||||
const job = this.state.data.job;
|
||||
this.dialog.add(FpRackPartsDialog, {
|
||||
jobId: this.state.jobId,
|
||||
stepId: step.id,
|
||||
partRef: job.part_number || "",
|
||||
defaultQty: job.qty || 1,
|
||||
onCommitted: async () => {
|
||||
// Rack assigned → now start the step
|
||||
await this.onStartStep(step.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Receiving handlers (Spec C1+C2 2026-05-24) -----------------------
|
||||
// The receiver card at the top of the workspace lets the dock receiver
|
||||
// count boxes, set per-line received quantities + condition, log damage
|
||||
// (with camera photos), and transition draft → counted → closed. Once
|
||||
// closed, the linked job's no_parts gate clears + the card disappears.
|
||||
|
||||
async onReceivingBoxCountBlur(rcv, ev) {
|
||||
const newVal = parseInt(ev.target.value, 10) || 0;
|
||||
if (newVal === (rcv.box_count_in || 0)) return;
|
||||
rcv.box_count_in = newVal;
|
||||
try {
|
||||
await rpc("/fp/workspace/receiving_save_lines", {
|
||||
receiving_id: rcv.id, box_count_in: newVal, lines: [],
|
||||
});
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || "Save failed", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onReceivingLineQtyBlur(rcv, ln, ev) {
|
||||
const newVal = parseInt(ev.target.value, 10) || 0;
|
||||
if (newVal === (ln.received_qty || 0)) return;
|
||||
ln.received_qty = newVal;
|
||||
await this._saveReceivingLine(rcv, ln);
|
||||
}
|
||||
|
||||
async onReceivingLineCondChange(rcv, ln, ev) {
|
||||
ln.condition = ev.target.value || "good";
|
||||
await this._saveReceivingLine(rcv, ln);
|
||||
}
|
||||
|
||||
async _saveReceivingLine(rcv, ln) {
|
||||
try {
|
||||
await rpc("/fp/workspace/receiving_save_lines", {
|
||||
receiving_id: rcv.id,
|
||||
lines: [{ id: ln.id, received_qty: ln.received_qty, condition: ln.condition }],
|
||||
});
|
||||
} catch (err) {
|
||||
this.notification.add(err.message || "Save failed", { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onReceivingMarkCounted(rcv) {
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/receiving_mark_counted", {
|
||||
receiving_id: rcv.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add(`Receiving ${rcv.name} marked counted.`, { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Finish failed", { type: "danger" });
|
||||
this.notification.add((res && res.error) || "Mark counted failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async onReceivingClose(rcv) {
|
||||
// No confirmation — Mark Counted is already a deliberate prior
|
||||
// step, and the native browser confirm() popup looks out of place
|
||||
// on the tablet UI. If a receiver hits Close prematurely, an
|
||||
// admin can reset via fp.receiving.action_reset_to_counted from
|
||||
// the back office.
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/receiving_close", {
|
||||
receiving_id: rcv.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add(`Receiving ${rcv.name} closed.`, { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Close failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
onAddDamage(rcv) {
|
||||
this.dialog.add(FpDamageDialog, {
|
||||
receivingId: rcv.id,
|
||||
onCreated: () => this.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
async onDeleteDamage(rcv, dmg) {
|
||||
if (!window.confirm(`Remove ${dmg.severity_label} damage: "${dmg.description}"?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await rpc("/fp/workspace/damage_delete", {
|
||||
damage_id: dmg.id,
|
||||
});
|
||||
if (res && res.ok) {
|
||||
this.notification.add("Damage entry removed.", { type: "success" });
|
||||
await this.refresh();
|
||||
} else {
|
||||
this.notification.add((res && res.error) || "Delete failed", { type: "danger" });
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(err.message, { type: "danger" });
|
||||
|
||||
@@ -196,7 +196,12 @@ export class PlantOverview extends Component {
|
||||
this.state.facilityName = result.facility_name || "Plant 1";
|
||||
this.state.columns = result.columns || [];
|
||||
this.state.racks = result.racks || [];
|
||||
this.state.lastRefresh = new Date().toLocaleTimeString();
|
||||
// Prefer the server-formatted timestamp — it's in the
|
||||
// FP-configured tz (fp_format → user.tz → company tz).
|
||||
// Browser tz fallback only kicks in if the endpoint
|
||||
// drops the field for some reason.
|
||||
this.state.lastRefresh = result.server_time
|
||||
|| new Date().toLocaleTimeString();
|
||||
}
|
||||
} catch (err) {
|
||||
this.notification.add(
|
||||
|
||||
@@ -239,16 +239,8 @@ export class ShopfloorTablet extends Component {
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async onGateResult(gateId, result) {
|
||||
try {
|
||||
await rpc("/fp/shopfloor/mark_gate", { gate_id: gateId, result });
|
||||
this.setMessage(`First-piece marked ${result.toUpperCase()}.`,
|
||||
result === "pass" ? "success" : "danger");
|
||||
} catch (err) {
|
||||
this.setMessage(`Gate update failed: ${err.message || err}`, "danger");
|
||||
}
|
||||
await this.refresh();
|
||||
}
|
||||
// onGateResult / /fp/shopfloor/mark_gate retired with the
|
||||
// fp.first.piece.gate model removal (19.0.33.2.0).
|
||||
|
||||
async onQueueItemClick(row) {
|
||||
if (row.source_model && row.source_id) {
|
||||
|
||||
@@ -45,10 +45,17 @@ export class FpTabletLock extends Component {
|
||||
loadingTiles: false,
|
||||
// 2026-05-24 redesign — clock + company branding
|
||||
// Seeded synchronously so the first render shows real values
|
||||
// (no flash of empty content).
|
||||
clockText: this._formatTime(new Date()),
|
||||
dateText: this._formatDate(new Date()),
|
||||
// (no flash of empty content). tz=null on first render falls
|
||||
// back to browser tz; _loadTiles() then sets state.tz from
|
||||
// res.tz_name (FP regional setting) and the next 60s tick
|
||||
// recomputes with the right tz.
|
||||
clockText: this._formatTime(new Date(), null),
|
||||
dateText: this._formatDate(new Date(), null),
|
||||
company: null,
|
||||
// FP-configured tz from /fp/tablet/tiles. Set after _loadTiles
|
||||
// so all subsequent clock ticks render in the shop's tz, not
|
||||
// the iPad's system tz.
|
||||
tz: null,
|
||||
// Kiosk identity from bootstrap so we can tell when the
|
||||
// current browser session belongs to a tech (= unlocked) vs.
|
||||
// the kiosk (= locked).
|
||||
@@ -72,8 +79,8 @@ export class FpTabletLock extends Component {
|
||||
// 60s is enough; the displayed precision is minute-level only.
|
||||
this._clockInterval = setInterval(() => {
|
||||
const now = new Date();
|
||||
this.state.clockText = this._formatTime(now);
|
||||
this.state.dateText = this._formatDate(now);
|
||||
this.state.clockText = this._formatTime(now, this.state.tz);
|
||||
this.state.dateText = this._formatDate(now, this.state.tz);
|
||||
}, 60000);
|
||||
// If we're already on a TECH session (uid != kiosk), start
|
||||
// the idle/ceiling timer immediately. This handles the case
|
||||
@@ -109,6 +116,13 @@ export class FpTabletLock extends Component {
|
||||
this.state.company = res.company || null;
|
||||
this.state.kioskUid = res.kiosk_uid || null;
|
||||
this.state.currentUid = res.current_uid || null;
|
||||
this.state.tz = res.tz_name || null;
|
||||
// Re-render the clock immediately with the FP tz so the
|
||||
// user doesn't see browser-tz for up to 60s before the
|
||||
// next tick.
|
||||
const now = new Date();
|
||||
this.state.clockText = this._formatTime(now, this.state.tz);
|
||||
this.state.dateText = this._formatDate(now, this.state.tz);
|
||||
// Decorate each tile with an animation-delay (50ms staggered,
|
||||
// capped at 300ms so the screen doesn't take 3s to settle on
|
||||
// shops with 20+ operators).
|
||||
@@ -188,23 +202,27 @@ export class FpTabletLock extends Component {
|
||||
|
||||
// === 2026-05-24 redesign helpers =====================================
|
||||
|
||||
_formatTime(d) {
|
||||
// 12-hour H:MM AM/PM. Per project rule 20 this MUST live in JS,
|
||||
// not the template — padStart isn't in OWL scope. Hour is NOT
|
||||
// zero-padded (1:05 PM, not 01:05 PM) to match phone-clock idiom.
|
||||
let h = d.getHours();
|
||||
const meridiem = h >= 12 ? "PM" : "AM";
|
||||
h = h % 12 || 12; // 0 → 12
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
return h + ":" + mm + " " + meridiem;
|
||||
_formatTime(d, tz) {
|
||||
// 12-hour H:MM AM/PM in the FP-configured tz (falls back to
|
||||
// browser tz if tz is null — first paint before _loadTiles).
|
||||
// Per project rule 20 this MUST live in JS, not the template.
|
||||
// Hour is NOT zero-padded (1:05 PM, not 01:05 PM) to match
|
||||
// phone-clock idiom.
|
||||
const opts = { hour: "numeric", minute: "2-digit", hour12: true };
|
||||
if (tz) opts.timeZone = tz;
|
||||
// en-US picks 12h-with-AM/PM reliably; locale-default could vary.
|
||||
return new Intl.DateTimeFormat("en-US", opts).format(d);
|
||||
}
|
||||
|
||||
_formatDate(d) {
|
||||
// 'SATURDAY · MAY 23' style. Uses Intl for locale-correct weekday
|
||||
// + month abbreviations, then upcases for the industrial tracking.
|
||||
const weekday = d.toLocaleDateString(undefined, { weekday: "long" });
|
||||
const month = d.toLocaleDateString(undefined, { month: "short" });
|
||||
const day = d.getDate();
|
||||
_formatDate(d, tz) {
|
||||
// 'SATURDAY · MAY 23' style in the FP-configured tz.
|
||||
const wkOpts = { weekday: "long" };
|
||||
const moOpts = { month: "short" };
|
||||
const dyOpts = { day: "numeric" };
|
||||
if (tz) { wkOpts.timeZone = moOpts.timeZone = dyOpts.timeZone = tz; }
|
||||
const weekday = new Intl.DateTimeFormat(undefined, wkOpts).format(d);
|
||||
const month = new Intl.DateTimeFormat(undefined, moOpts).format(d);
|
||||
const day = new Intl.DateTimeFormat(undefined, dyOpts).format(d);
|
||||
return (weekday + " · " + month + " " + day).toUpperCase();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
// _column_header.scss — depends on _plant_tokens.scss
|
||||
|
||||
.o_fp_col_header {
|
||||
padding: 6px 8px;
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
background: $plant-card-bg;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 6px 6px 0 0;
|
||||
border-bottom: 0;
|
||||
// No own background or outer border — the parent .col carries them
|
||||
// (full-height column-as-card layout). Just a bottom divider so the
|
||||
// header reads as a distinct band above the scrollable body.
|
||||
background: transparent;
|
||||
border-bottom: 1px solid $plant-card-border;
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.mine {
|
||||
background: linear-gradient(180deg, $plant-mine-bg 0%, $plant-card-bg 100%);
|
||||
border-color: $plant-mine-border;
|
||||
// .col already paints the mine gradient + gold border.
|
||||
border-bottom-color: $plant-mine-border;
|
||||
}
|
||||
|
||||
.col-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||
|
||||
@@ -317,3 +317,393 @@ $_ws-text-hex: #1d1d1f;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PRE-RECIPE RECEIVING CARD (Spec C1+C2 2026-05-24)
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ws_rcv {
|
||||
background: $_ws-card-hex;
|
||||
border: 2px solid #f1c40f; // amber — draws receiver's eye
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
|
||||
&.o_fp_ws_rcv_counted {
|
||||
border-color: #27ae60; // green when counted
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
border-bottom: 1px solid $_ws-border-hex;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_icon {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_status {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
background: #fef3c7;
|
||||
color: #78350f;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
|
||||
.o_fp_ws_rcv_counted & {
|
||||
background: #d1fae5;
|
||||
color: #064e3b;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #555);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_box_input {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
max-width: 12rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_lines {
|
||||
background: $_ws-page-hex;
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_lines_head {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary, #666);
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid $_ws-border-hex;
|
||||
|
||||
&:last-child { border-bottom: 0; }
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_line_part {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_line_qty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_qty_input {
|
||||
width: 6rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_cond_select {
|
||||
width: 8rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_summary {
|
||||
background: $_ws-page-hex;
|
||||
border-radius: 6px;
|
||||
padding: 0.8rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_summary_parts {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_damage_section {
|
||||
border-top: 1px dashed $_ws-border-hex;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_damage_head {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #666);
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_damage_empty {
|
||||
font-style: italic;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_damage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
background: $_ws-page-hex;
|
||||
border-left: 4px solid #fbbf24;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
|
||||
&.o_fp_ws_rcv_damage_functional {
|
||||
border-left-color: #f97316;
|
||||
}
|
||||
&.o_fp_ws_rcv_damage_rejected {
|
||||
border-left-color: #dc2626;
|
||||
background: rgba(220, 38, 38, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_damage_sev {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: rgba(0,0,0,0.06);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_damage_desc {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_damage_photos {
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_damage_x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
|
||||
&:hover { color: #dc2626; }
|
||||
}
|
||||
|
||||
.o_fp_ws_rcv_actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6rem;
|
||||
padding-top: 0.4rem;
|
||||
|
||||
.btn { font-size: 1rem; padding: 0.5rem 1.2rem; }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DAMAGE DIALOG (Spec C1+C2 2026-05-24)
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_dmg_dialog .modal-body { padding: 1.5rem; }
|
||||
|
||||
.o_fp_dmg_body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.o_fp_dmg_field { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.o_fp_dmg_label { font-weight: 600; color: var(--text-secondary, #555); }
|
||||
.o_fp_dmg_req { color: #dc2626; }
|
||||
|
||||
.o_fp_dmg_pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.o_fp_dmg_pill {
|
||||
background: $_ws-page-hex;
|
||||
border: 2px solid $_ws-border-hex;
|
||||
color: $_ws-text-hex;
|
||||
padding: 0.6rem 1.1rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 80ms, border-color 80ms;
|
||||
|
||||
&:hover { background: rgba(0,0,0,0.04); }
|
||||
|
||||
&.o_fp_dmg_pill_active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
&.o_fp_dmg_sev_functional.o_fp_dmg_pill_active {
|
||||
background: #f97316;
|
||||
border-color: #f97316;
|
||||
}
|
||||
&.o_fp_dmg_sev_rejected.o_fp_dmg_pill_active {
|
||||
background: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_dmg_textarea { font-size: 1rem; }
|
||||
|
||||
.o_fp_dmg_photo_buttons {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
.btn { padding: 0.6rem 1rem; }
|
||||
}
|
||||
|
||||
.o_fp_dmg_photo_grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.o_fp_dmg_photo_tile {
|
||||
position: relative;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border: 1px solid $_ws-border-hex;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: $_ws-page-hex;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_dmg_photo_x {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
border: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LIVE TIMER on active step (2026-05-24 follow-up)
|
||||
// Ticks every 1s via state.tickNow; flips red when > 1.5x expected duration.
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_ws_step_timer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: #d1fae5;
|
||||
color: #064e3b;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
margin-left: 0.4rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.o_fp_ws_step_timer_over {
|
||||
background: #fee2e2;
|
||||
color: #7f1d1d;
|
||||
animation: o_fp_ws_timer_pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes o_fp_ws_timer_pulse {
|
||||
0%, 100% { opacity: 1.0; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FINISH BLOCK DIALOG (required-inputs gate, manager bypass)
|
||||
// =============================================================================
|
||||
|
||||
.o_fp_finish_block .modal-body { padding: 1.5rem; }
|
||||
|
||||
.o_fp_finish_block_body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.o_fp_finish_block_step {
|
||||
font-size: 1.1rem;
|
||||
color: #b45309;
|
||||
background: #fef3c7;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.o_fp_finish_block_msg {
|
||||
color: var(--text-secondary, #333);
|
||||
}
|
||||
|
||||
.o_fp_finish_block_list {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1rem;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
padding: 0.3rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fp_finish_block_action_note {
|
||||
color: var(--text-secondary, #555);
|
||||
font-style: italic;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
.o_fp_plant_kanban {
|
||||
padding: 8px;
|
||||
background: $plant-bg;
|
||||
min-height: 100vh;
|
||||
// Full viewport height + flex column so .board can grow to fill all
|
||||
// remaining vertical space. min-height: 100vh would let .board's
|
||||
// intrinsic height bubble up and put the horizontal scrollbar
|
||||
// mid-page; height + flex pins the scrollbar to the viewport bottom.
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: $plant-text;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
|
||||
@@ -14,9 +20,8 @@
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
// Keep header at natural height; board takes remaining space.
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -97,35 +102,49 @@
|
||||
// (~280px) sits comfortably with breathing room. On a 1920px
|
||||
// desktop ~6 columns visible; on a 1366px tablet ~4 visible with
|
||||
// smooth horizontal scroll. User explicitly accepted side-scrolling.
|
||||
//
|
||||
// flex: 1 + min-height: 0 — the board fills all remaining vertical
|
||||
// space below the sticky header, so the horizontal scrollbar pins
|
||||
// to the viewport bottom (not mid-page where the board's natural
|
||||
// height would have ended).
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, minmax(300px, 1fr));
|
||||
gap: 8px;
|
||||
min-height: 520px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px; // room for the horizontal scrollbar
|
||||
padding-bottom: 4px; // room for the horizontal scrollbar
|
||||
}
|
||||
// Each .col is now a proper bordered card that runs full board
|
||||
// height — same visual treatment as Trello / Asana columns. The
|
||||
// header (.o_fp_col_header) sits at the top with a divider; the
|
||||
// scrollable body (.col-scroll) takes the rest. Previously .col
|
||||
// had bg = page-bg (invisible) so columns visually ended at the
|
||||
// header card — empty columns looked unbounded.
|
||||
.col {
|
||||
background: $plant-bg;
|
||||
background: $plant-card-bg;
|
||||
border: 1px solid $plant-card-border;
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0; // grid-track minmax handles sizing
|
||||
min-width: 0; // grid-track minmax handles sizing
|
||||
min-height: 0; // allow flex children to scroll inside
|
||||
overflow: hidden; // contain rounded corners over inner content
|
||||
&.mine {
|
||||
background: linear-gradient(180deg, $plant-mine-bg 0%, $plant-card-bg 100%);
|
||||
border: 1px solid $plant-mine-border;
|
||||
border-color: $plant-mine-border;
|
||||
}
|
||||
}
|
||||
.col-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0; // critical — without this the children
|
||||
// push the column past its parent height
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 2px;
|
||||
max-height: calc(100vh - 260px);
|
||||
min-height: 200px;
|
||||
padding: 6px;
|
||||
}
|
||||
.col-empty {
|
||||
font-size: 10px;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.FpDamageDialog">
|
||||
<Dialog title="'Log Damage'" size="'lg'" contentClass="'o_fp_dmg_dialog'">
|
||||
<div class="o_fp_dmg_body">
|
||||
|
||||
<!-- Severity pill picker -->
|
||||
<div class="o_fp_dmg_field">
|
||||
<label class="o_fp_dmg_label">Severity</label>
|
||||
<div class="o_fp_dmg_pills">
|
||||
<t t-foreach="severities" t-as="sv" t-key="sv.code">
|
||||
<button type="button"
|
||||
t-att-class="'o_fp_dmg_pill ' + sv.cssClass +
|
||||
(state.severity === sv.code ? ' o_fp_dmg_pill_active' : '')"
|
||||
t-on-click="() => this.setSeverity(sv.code)">
|
||||
<t t-esc="sv.label"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description textarea -->
|
||||
<div class="o_fp_dmg_field">
|
||||
<label class="o_fp_dmg_label">Description <span class="o_fp_dmg_req">*</span></label>
|
||||
<textarea class="form-control o_fp_dmg_textarea"
|
||||
rows="3"
|
||||
placeholder="e.g. dent on top box, two parts crushed"
|
||||
t-model="state.description"/>
|
||||
</div>
|
||||
|
||||
<!-- Action required pill picker -->
|
||||
<div class="o_fp_dmg_field">
|
||||
<label class="o_fp_dmg_label">Action Required</label>
|
||||
<div class="o_fp_dmg_pills">
|
||||
<t t-foreach="actions" t-as="ac" t-key="ac.code">
|
||||
<button type="button"
|
||||
t-att-class="'o_fp_dmg_pill ' +
|
||||
(state.actionRequired === ac.code ? 'o_fp_dmg_pill_active' : '')"
|
||||
t-on-click="() => this.setAction(ac.code)">
|
||||
<t t-esc="ac.label"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos: camera capture + file picker -->
|
||||
<div class="o_fp_dmg_field">
|
||||
<label class="o_fp_dmg_label">Photos</label>
|
||||
<div class="o_fp_dmg_photo_buttons">
|
||||
<button type="button" class="btn btn-primary"
|
||||
t-on-click="() => this.triggerCamera()">
|
||||
<i class="fa fa-camera me-1"/> Take Photo
|
||||
</button>
|
||||
<button type="button" class="btn btn-light"
|
||||
t-on-click="() => this.triggerFile()">
|
||||
<i class="fa fa-upload me-1"/> Upload
|
||||
</button>
|
||||
<!-- Hidden inputs -->
|
||||
<input type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
t-ref="cameraInput"
|
||||
style="display: none"
|
||||
t-on-change="onFilesPicked"/>
|
||||
<input type="file"
|
||||
accept="image/*"
|
||||
multiple="multiple"
|
||||
t-ref="fileInput"
|
||||
style="display: none"
|
||||
t-on-change="onFilesPicked"/>
|
||||
</div>
|
||||
<div t-if="state.photos.length" class="o_fp_dmg_photo_grid">
|
||||
<t t-foreach="state.photos" t-as="p" t-key="p_index">
|
||||
<div class="o_fp_dmg_photo_tile">
|
||||
<img t-att-src="p.preview_url" t-att-alt="p.filename"/>
|
||||
<button type="button" class="o_fp_dmg_photo_x"
|
||||
t-on-click="() => this.removePhoto(p_index)">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-light"
|
||||
t-on-click="() => this.onCancel()"
|
||||
t-att-disabled="state.saving">Cancel</button>
|
||||
<button class="btn btn-success"
|
||||
t-on-click="() => this.onSave()"
|
||||
t-att-disabled="state.saving">
|
||||
<i class="fa fa-check me-1"/>
|
||||
<t t-if="state.saving">Saving…</t>
|
||||
<t t-else="">Save Damage</t>
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_plating_shopfloor.FpFinishBlockDialog">
|
||||
<Dialog title="'Cannot Finish Step'" size="'md'" contentClass="'o_fp_finish_block'">
|
||||
<div class="o_fp_finish_block_body">
|
||||
|
||||
<div class="o_fp_finish_block_step">
|
||||
<i class="fa fa-exclamation-circle me-2"/>
|
||||
<strong t-esc="props.stepName"/>
|
||||
</div>
|
||||
|
||||
<!-- Orphan step (NULL recipe link) — different copy -->
|
||||
<t t-if="props.orphanStep">
|
||||
<div class="o_fp_finish_block_msg">
|
||||
This step has <strong>no recipe link</strong> (the source
|
||||
recipe was deleted or the job was created before recipes
|
||||
were assigned). Required-input verification can't happen
|
||||
without the recipe.
|
||||
</div>
|
||||
<div class="o_fp_finish_block_action_note">
|
||||
<i class="fa fa-user-md me-1"/>
|
||||
Escalate to a manager — they can bypass with an
|
||||
audit-chatter entry.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Standard missing-prompts case -->
|
||||
<t t-elif="props.missingPrompts and props.missingPrompts.length">
|
||||
<div class="o_fp_finish_block_msg">
|
||||
<t t-esc="props.missingPrompts.length"/> required input(s)
|
||||
haven't been recorded yet:
|
||||
</div>
|
||||
<ul class="o_fp_finish_block_list">
|
||||
<t t-foreach="props.missingPrompts" t-as="p" t-key="p.id">
|
||||
<li>
|
||||
<i class="fa fa-square-o me-1"/>
|
||||
<t t-esc="p.name"/>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
|
||||
<!-- Fallback: server returned something else -->
|
||||
<t t-else="">
|
||||
<div class="o_fp_finish_block_msg" t-esc="props.message"/>
|
||||
</t>
|
||||
|
||||
</div>
|
||||
<t t-set-slot="footer">
|
||||
<button class="btn btn-light"
|
||||
t-on-click="() => this.onCancel()"
|
||||
t-att-disabled="state.bypassing">Cancel</button>
|
||||
<button t-if="!props.orphanStep and props.onRecordInputs"
|
||||
class="btn btn-primary"
|
||||
t-on-click="() => this.onRecord()"
|
||||
t-att-disabled="state.bypassing">
|
||||
<i class="fa fa-pencil me-1"/> Record Inputs
|
||||
</button>
|
||||
<button t-if="props.canBypass"
|
||||
class="btn btn-danger"
|
||||
t-on-click="() => this.onBypassClick()"
|
||||
t-att-disabled="state.bypassing">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>
|
||||
<t t-if="state.bypassing">Bypassing…</t>
|
||||
<t t-else="">Bypass & Finish (Audit)</t>
|
||||
</button>
|
||||
</t>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -79,6 +79,140 @@
|
||||
|
||||
<!-- STEP LIST -->
|
||||
<div class="o_fp_ws_steps">
|
||||
|
||||
<!-- PRE-RECIPE: RECEIVING CARDS (Spec C1+C2 2026-05-24)
|
||||
Renders one card per fp.receiving in state
|
||||
draft/counted on the linked SO. Card disappears
|
||||
once receiving is closed; recipe takes over. -->
|
||||
<t t-foreach="state.data.receivings || []"
|
||||
t-as="rcv" t-key="rcv.id">
|
||||
<div t-att-class="'o_fp_ws_rcv o_fp_ws_rcv_' + rcv.state">
|
||||
<div class="o_fp_ws_rcv_head">
|
||||
<span class="o_fp_ws_rcv_icon">📦</span>
|
||||
<span class="o_fp_ws_rcv_title">
|
||||
Receiving <t t-esc="rcv.name"/>
|
||||
</span>
|
||||
<span class="o_fp_ws_rcv_status"
|
||||
t-esc="rcv.state_label"/>
|
||||
</div>
|
||||
|
||||
<!-- DRAFT mode: editable inputs -->
|
||||
<t t-if="rcv.state === 'draft'">
|
||||
<div class="o_fp_ws_rcv_field">
|
||||
<label>Boxes received</label>
|
||||
<input type="number"
|
||||
class="form-control o_fp_ws_rcv_box_input"
|
||||
inputmode="numeric"
|
||||
t-att-value="rcv.box_count_in || ''"
|
||||
t-on-blur="(ev) => this.onReceivingBoxCountBlur(rcv, ev)"/>
|
||||
</div>
|
||||
|
||||
<div t-if="rcv.lines.length" class="o_fp_ws_rcv_lines">
|
||||
<div class="o_fp_ws_rcv_lines_head">PARTS</div>
|
||||
<t t-foreach="rcv.lines" t-as="ln" t-key="ln.id">
|
||||
<div class="o_fp_ws_rcv_line">
|
||||
<div class="o_fp_ws_rcv_line_part">
|
||||
<strong t-esc="ln.part_number or 'Part'"/>
|
||||
<span t-if="ln.description" class="text-muted">
|
||||
— <t t-esc="ln.description"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_ws_rcv_line_qty">
|
||||
<span class="text-muted">Expected: <t t-esc="ln.expected_qty"/></span>
|
||||
<label>Received
|
||||
<input type="number"
|
||||
class="form-control o_fp_ws_rcv_qty_input"
|
||||
inputmode="numeric"
|
||||
t-att-value="ln.received_qty || ''"
|
||||
t-on-blur="(ev) => this.onReceivingLineQtyBlur(rcv, ln, ev)"/>
|
||||
</label>
|
||||
<select class="form-select o_fp_ws_rcv_cond_select"
|
||||
t-on-change="(ev) => this.onReceivingLineCondChange(rcv, ln, ev)">
|
||||
<option value="good" t-att-selected="ln.condition === 'good'">Good</option>
|
||||
<option value="damaged" t-att-selected="ln.condition === 'damaged'">Damaged</option>
|
||||
<option value="mixed" t-att-selected="ln.condition === 'mixed'">Mixed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- COUNTED mode: read-only summary -->
|
||||
<t t-if="rcv.state === 'counted'">
|
||||
<div class="o_fp_ws_rcv_summary">
|
||||
<div>
|
||||
✅ <strong t-esc="rcv.box_count_in"/> box(es) counted
|
||||
<t t-if="rcv.received_date">
|
||||
at <t t-esc="rcv.received_date"/>
|
||||
</t>
|
||||
<t t-if="rcv.received_by_name">
|
||||
by <t t-esc="rcv.received_by_name"/>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="rcv.lines.length" class="o_fp_ws_rcv_summary_parts">
|
||||
<t t-foreach="rcv.lines" t-as="ln" t-key="ln.id">
|
||||
<span class="o_fp_chip o_fp_chip_info">
|
||||
<t t-esc="ln.part_number"/>: <t t-esc="ln.received_qty"/> / <t t-esc="ln.expected_qty"/>
|
||||
</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- DAMAGE LOG (visible in both draft + counted) -->
|
||||
<div class="o_fp_ws_rcv_damage_section">
|
||||
<div class="o_fp_ws_rcv_damage_head">
|
||||
DAMAGE LOG
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-light ms-2"
|
||||
t-on-click="() => this.onAddDamage(rcv)">
|
||||
<i class="fa fa-plus"/> Add Damage
|
||||
</button>
|
||||
</div>
|
||||
<div t-if="!rcv.damages.length"
|
||||
class="o_fp_ws_rcv_damage_empty text-muted">
|
||||
No damage logged.
|
||||
</div>
|
||||
<t t-foreach="rcv.damages" t-as="dmg" t-key="dmg.id">
|
||||
<div t-att-class="'o_fp_ws_rcv_damage o_fp_ws_rcv_damage_' + dmg.severity">
|
||||
<span class="o_fp_ws_rcv_damage_sev"
|
||||
t-esc="dmg.severity_label"/>
|
||||
<span class="o_fp_ws_rcv_damage_desc"
|
||||
t-esc="dmg.description"/>
|
||||
<span t-if="dmg.action_required and dmg.action_required !== 'none'"
|
||||
class="o_fp_chip o_fp_chip_warning"
|
||||
t-esc="dmg.action_label"/>
|
||||
<span t-if="dmg.photo_count"
|
||||
class="o_fp_ws_rcv_damage_photos">
|
||||
<i class="fa fa-camera"/> <t t-esc="dmg.photo_count"/>
|
||||
</span>
|
||||
<button type="button"
|
||||
class="o_fp_ws_rcv_damage_x"
|
||||
t-on-click="() => this.onDeleteDamage(rcv, dmg)"
|
||||
title="Remove damage entry">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Footer: state-transition button -->
|
||||
<div class="o_fp_ws_rcv_actions">
|
||||
<button t-if="rcv.state === 'draft'"
|
||||
class="btn btn-primary"
|
||||
t-on-click="() => this.onReceivingMarkCounted(rcv)">
|
||||
<i class="fa fa-check"/> Mark Counted
|
||||
</button>
|
||||
<button t-if="rcv.state === 'counted'"
|
||||
class="btn btn-success"
|
||||
t-on-click="() => this.onReceivingClose(rcv)">
|
||||
<i class="fa fa-check-circle"/> Close Receiving
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div t-if="!state.data.steps.length" class="o_fp_ws_empty">
|
||||
<i class="fa fa-exclamation-circle fa-2x"/>
|
||||
<div>Recipe not generated for this WO.</div>
|
||||
@@ -86,26 +220,39 @@
|
||||
|
||||
<t t-foreach="state.data.steps" t-as="step" t-key="step.id">
|
||||
<div t-att-class="'o_fp_ws_step ' + step.state +
|
||||
(isStepActive(step) ? ' active' : '') +
|
||||
(step.state === 'in_progress' ? ' active' : '') +
|
||||
(step.state === 'paused' ? ' paused' : '') +
|
||||
(step.override_excluded ? ' excluded' : '') +
|
||||
(step.blocker_kind !== 'none' ? ' blocked' : '')"
|
||||
t-att-data-step-id="step.id">
|
||||
|
||||
<!-- ALWAYS visible: line 1 (icon + step# + name + badges + live timer + meta) -->
|
||||
<div class="o_fp_ws_step_l1">
|
||||
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
|
||||
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
|
||||
<span class="o_fp_ws_step_name" t-esc="step.name"/>
|
||||
<span t-if="isStepActive(step)" class="o_fp_ws_step_badge">ACTIVE</span>
|
||||
<span t-if="step.state === 'in_progress'" class="o_fp_ws_step_badge">ACTIVE</span>
|
||||
<span t-if="step.state === 'paused'" class="o_fp_ws_step_badge o_fp_ws_step_badge_paused">PAUSED</span>
|
||||
<!-- Live ticking HH:MM:SS timer for in_progress steps.
|
||||
Re-renders every 1s via state.tickNow.
|
||||
Flips red when > 1.5x expected duration. -->
|
||||
<span t-if="step.state === 'in_progress' and step.date_started_iso"
|
||||
t-att-class="'o_fp_ws_step_timer' + (isActiveStepOvertime(step) ? ' o_fp_ws_step_timer_over' : '')">
|
||||
<i class="fa fa-clock-o me-1"/>
|
||||
<t t-esc="formatActiveStepElapsed(step)"/>
|
||||
</span>
|
||||
<span class="o_fp_ws_step_meta">
|
||||
<t t-if="step.assigned_user_name"><t t-esc="step.assigned_user_name"/></t>
|
||||
<t t-if="step.duration_actual"> · <t t-esc="Math.round(step.duration_actual)"/> min</t>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<t t-if="isStepActive(step) or step.blocker_kind !== 'none' or step.override_excluded">
|
||||
<!-- NON-TERMINAL: read-ahead detail (chips + instructions + opt-out + GateViz) -->
|
||||
<t t-if="!['done', 'skipped', 'cancelled'].includes(step.state)">
|
||||
<div class="o_fp_ws_step_detail">
|
||||
<!-- Recipe chips (only on active step) -->
|
||||
<div class="o_fp_ws_step_chips" t-if="isStepActive(step)">
|
||||
<!-- Recipe chips: visible on every non-done step so operator reads ahead -->
|
||||
<div class="o_fp_ws_step_chips"
|
||||
t-if="step.thickness_target or step.dwell_time_minutes or step.bake_setpoint_temp or step.requires_signoff or step.requires_rack_assignment">
|
||||
<span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">
|
||||
🎯 Thickness <t t-esc="step.thickness_target"/> <t t-esc="step.thickness_uom or 'mils'"/>
|
||||
</span>
|
||||
@@ -118,10 +265,13 @@
|
||||
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">
|
||||
✎ Sign-off required
|
||||
</span>
|
||||
<span t-if="step.requires_rack_assignment" class="o_fp_chip o_fp_chip_info">
|
||||
🔧 Rack assignment
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Recipe author instructions (only on active step) -->
|
||||
<div t-if="step.instructions and isStepActive(step)"
|
||||
<!-- Recipe author instructions -->
|
||||
<div t-if="step.instructions"
|
||||
class="o_fp_ws_step_instr">
|
||||
<t t-esc="step.instructions"/>
|
||||
</div>
|
||||
@@ -140,30 +290,15 @@
|
||||
jumpTargetId="step.blocker_jump_target_id"
|
||||
onJump.bind="onJumpToBlocker"/>
|
||||
|
||||
<!-- Action buttons (only when unblocked) -->
|
||||
<!-- Action buttons: dispatched per-kind via getStepActions -->
|
||||
<div class="o_fp_ws_step_actions"
|
||||
t-if="isStepActive(step) and step.blocker_kind === 'none'">
|
||||
<button class="btn btn-secondary me-2"
|
||||
t-on-click="() => this.onRecordInputs(step)">
|
||||
<i class="fa fa-pencil"/> Record Inputs
|
||||
</button>
|
||||
<button t-if="step.requires_signoff"
|
||||
class="btn btn-success"
|
||||
t-on-click="() => this.onFinishStep(step)">
|
||||
<i class="fa fa-check"/> Finish & Sign Off
|
||||
</button>
|
||||
<button t-else=""
|
||||
class="btn btn-success"
|
||||
t-on-click="() => this.onFinishStep(step)">
|
||||
<i class="fa fa-check"/> Finish
|
||||
</button>
|
||||
</div>
|
||||
<div class="o_fp_ws_step_actions"
|
||||
t-if="step.can_start and !isStepActive(step) and step.blocker_kind === 'none'">
|
||||
<button class="btn btn-primary"
|
||||
t-on-click="() => this.onStartStep(step.id)">
|
||||
<i class="fa fa-play"/> Start
|
||||
</button>
|
||||
t-if="!step.override_excluded and step.blocker_kind === 'none'">
|
||||
<t t-foreach="getStepActions(step)" t-as="action" t-key="action.key">
|
||||
<button t-att-class="action.cssClass + ' me-2'"
|
||||
t-on-click="() => this.dispatchStepAction(step, action.key)">
|
||||
<i t-att-class="action.icon"/> <t t-esc="action.label"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<!-- KPI strip -->
|
||||
<div t-if="state.data" class="kpi-strip">
|
||||
<FpKpiTile value="state.data.kpis.active_jobs"
|
||||
label="'Active Jobs'"
|
||||
label="'Work Orders'"
|
||||
kind="'good'"
|
||||
active="!!state.filters.all"
|
||||
onClick="() => this.toggleFilter('all')"/>
|
||||
|
||||
@@ -342,53 +342,9 @@
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="o_fp_panel">
|
||||
<div class="o_fp_panel_head">
|
||||
<h3><i class="fa fa-flag-checkered"/>First-Piece Gates</h3>
|
||||
<span class="o_fp_panel_count"><t t-esc="state.overview.gates.length"/></span>
|
||||
</div>
|
||||
<div t-if="!state.overview.gates.length" class="o_fp_empty">
|
||||
<i class="fa fa-flag-checkered"/>
|
||||
<div>No pending first-piece inspections.</div>
|
||||
</div>
|
||||
<ul class="o_fp_bake_list" t-if="state.overview.gates.length">
|
||||
<t t-foreach="state.overview.gates" t-as="g" t-key="g.id">
|
||||
<li class="o_fp_bake_row" t-att-data-state="g.result">
|
||||
<div class="o_fp_bake_main">
|
||||
<div class="o_fp_bake_name">
|
||||
<t t-esc="g.name"/>
|
||||
<span class="text-muted ms-1"> — <t t-esc="g.part_ref"/></span>
|
||||
</div>
|
||||
<div class="o_fp_bake_meta">
|
||||
<t t-esc="g.customer"/>
|
||||
<t t-if="g.bath"> · Bath <t t-esc="g.bath"/></t>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fp_bake_time">
|
||||
<span t-att-class="'o_fp_chip o_fp_chip_' + stateBadge(g.result)">
|
||||
<t t-esc="g.result"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_bake_actions">
|
||||
<button t-if="g.result === 'pending'"
|
||||
class="btn btn-success"
|
||||
t-on-click="() => this.onGateResult(g.id, 'pass')">
|
||||
Pass
|
||||
</button>
|
||||
<button t-if="g.result === 'pending'"
|
||||
class="btn btn-danger"
|
||||
t-on-click="() => this.onGateResult(g.id, 'fail')">
|
||||
Fail
|
||||
</button>
|
||||
<button class="btn btn-light"
|
||||
t-on-click="() => this.openRecord('fusion.plating.first.piece.gate', g.id)">
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- First-piece gate panel retired with the fp.first.piece.gate
|
||||
model removal (19.0.33.2.0). The feature was never wired
|
||||
up — manual create, no enforcement, no rows in production. -->
|
||||
|
||||
<!-- ===== Pending QC banner (S19 follow-up) ===== -->
|
||||
<!-- Shows whenever Carlos's job has an open QC. Tap -->
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_first_piece_gate_list" model="ir.ui.view">
|
||||
<field name="name">fp.first.piece.gate.list</field>
|
||||
<field name="model">fusion.plating.first.piece.gate</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="First-Piece Gates"
|
||||
decoration-success="result == 'pass' and rest_of_lot_released"
|
||||
decoration-warning="result == 'pass' and not rest_of_lot_released"
|
||||
decoration-danger="result == 'fail'"
|
||||
decoration-muted="result == 'pending'">
|
||||
<field name="name"/>
|
||||
<field name="bath_id"/>
|
||||
<field name="part_ref"/>
|
||||
<field name="customer_ref" optional="show"/>
|
||||
<field name="routing_first_run" widget="boolean_toggle" optional="hide"/>
|
||||
<field name="first_piece_produced" optional="show"/>
|
||||
<field name="first_piece_inspected" optional="show"/>
|
||||
<field name="inspector_id" optional="show"/>
|
||||
<field name="result" widget="badge"
|
||||
decoration-success="result == 'pass'"
|
||||
decoration-danger="result == 'fail'"
|
||||
decoration-muted="result == 'pending'"/>
|
||||
<field name="rest_of_lot_released" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_first_piece_gate_form" model="ir.ui.view">
|
||||
<field name="name">fp.first.piece.gate.form</field>
|
||||
<field name="model">fusion.plating.first.piece.gate</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="First-Piece Gate">
|
||||
<header>
|
||||
<button name="action_mark_pass" string="Mark Pass" type="object"
|
||||
class="oe_highlight" invisible="result != 'pending'"/>
|
||||
<button name="action_mark_fail" string="Mark Fail" type="object"
|
||||
invisible="result != 'pending'"/>
|
||||
<button name="action_release_lot" string="Release Lot" type="object"
|
||||
invisible="result != 'pass' or rest_of_lot_released"/>
|
||||
<field name="result" widget="statusbar"
|
||||
statusbar_visible="pending,pass"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Job">
|
||||
<field name="bath_id"/>
|
||||
<field name="facility_id" readonly="1"/>
|
||||
<field name="part_ref"/>
|
||||
<field name="customer_ref"/>
|
||||
<field name="routing_first_run"/>
|
||||
</group>
|
||||
<group string="Inspection">
|
||||
<field name="first_piece_produced"/>
|
||||
<field name="first_piece_inspected"/>
|
||||
<field name="inspector_id"/>
|
||||
<field name="rest_of_lot_released"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Notes"/>
|
||||
<field name="notes"/>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!--
|
||||
Kanban rebuilt 2026-04 to match the Plant Overview card design.
|
||||
Shared base styles in fp_kanbans.scss (.o_fp_kcard); the
|
||||
wrapping .o_fp_fpg_kanban scopes per-result stripe colours.
|
||||
-->
|
||||
<record id="view_fp_first_piece_gate_kanban" model="ir.ui.view">
|
||||
<field name="name">fp.first.piece.gate.kanban</field>
|
||||
<field name="model">fusion.plating.first.piece.gate</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban default_group_by="result" class="o_fp_fpg_kanban">
|
||||
<field name="id"/>
|
||||
<field name="name"/>
|
||||
<field name="part_ref"/>
|
||||
<field name="customer_ref"/>
|
||||
<field name="bath_id"/>
|
||||
<field name="first_piece_produced"/>
|
||||
<field name="inspector_id"/>
|
||||
<field name="result"/>
|
||||
<field name="rest_of_lot_released"/>
|
||||
<templates>
|
||||
<t t-name="card">
|
||||
<div class="o_fp_kcard"
|
||||
t-att-data-result="record.result.raw_value">
|
||||
<div class="o_fp_kcard_title">
|
||||
<field name="name"/>
|
||||
</div>
|
||||
<div class="o_fp_kcard_sub" t-if="record.part_ref.raw_value">
|
||||
<field name="part_ref"/>
|
||||
</div>
|
||||
<div class="o_fp_kcard_meta">
|
||||
<span t-if="record.bath_id.raw_value">
|
||||
<i class="fa fa-flask me-1"/><field name="bath_id"/>
|
||||
</span>
|
||||
<span class="o_fp_kcard_meta_sep"
|
||||
t-if="record.bath_id.raw_value and record.customer_ref.raw_value">·</span>
|
||||
<span t-if="record.customer_ref.raw_value">
|
||||
<field name="customer_ref"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_kcard_meta"
|
||||
t-if="record.inspector_id.raw_value or record.first_piece_produced.raw_value">
|
||||
<span t-if="record.inspector_id.raw_value">
|
||||
<i class="fa fa-user me-1"/><field name="inspector_id"/>
|
||||
</span>
|
||||
<span class="o_fp_kcard_meta_sep"
|
||||
t-if="record.inspector_id.raw_value and record.first_piece_produced.raw_value">·</span>
|
||||
<span t-if="record.first_piece_produced.raw_value">
|
||||
<field name="first_piece_produced"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_fp_kcard_footer">
|
||||
<span t-att-class="'o_fp_kcard_chip ' + (
|
||||
record.result.raw_value === 'pass' ? 'tone-success'
|
||||
: record.result.raw_value === 'fail' ? 'tone-danger'
|
||||
: record.result.raw_value === 'pending' ? 'tone-warning'
|
||||
: 'tone-muted')">
|
||||
<field name="result"/>
|
||||
</span>
|
||||
<span class="o_fp_fpg_released"
|
||||
t-if="record.rest_of_lot_released.raw_value">
|
||||
<i class="fa fa-check"/> Released
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_first_piece_gate_search" model="ir.ui.view">
|
||||
<field name="name">fp.first.piece.gate.search</field>
|
||||
<field name="model">fusion.plating.first.piece.gate</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="First-Piece Gates">
|
||||
<field name="name"/>
|
||||
<field name="part_ref"/>
|
||||
<field name="customer_ref"/>
|
||||
<field name="bath_id"/>
|
||||
<field name="inspector_id"/>
|
||||
<separator/>
|
||||
<filter string="Pending" name="pending" domain="[('result','=','pending')]"/>
|
||||
<filter string="Passed" name="passed" domain="[('result','=','pass')]"/>
|
||||
<filter string="Failed" name="failed" domain="[('result','=','fail')]"/>
|
||||
<separator/>
|
||||
<filter string="Lot Released" name="released" domain="[('rest_of_lot_released','=',True)]"/>
|
||||
<filter string="Lot On Hold" name="on_hold"
|
||||
domain="[('result','=','pass'),('rest_of_lot_released','=',False)]"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||
<group>
|
||||
<filter string="Result" name="group_result" context="{'group_by':'result'}"/>
|
||||
<filter string="Customer" name="group_customer" context="{'group_by':'customer_ref'}"/>
|
||||
<filter string="Inspector" name="group_inspector" context="{'group_by':'inspector_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_first_piece_gate" model="ir.actions.act_window">
|
||||
<field name="name">First-Piece Gates</field>
|
||||
<field name="res_model">fusion.plating.first.piece.gate</field>
|
||||
<field name="view_mode">kanban,list,form</field>
|
||||
<field name="search_view_id" ref="view_fp_first_piece_gate_search"/>
|
||||
<field name="context">{'search_default_pending': 1}</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -43,12 +43,6 @@
|
||||
action="action_fp_bake_window"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_fp_shopfloor_first_piece"
|
||||
name="First-Piece Gates"
|
||||
parent="menu_fp_shopfloor"
|
||||
action="action_fp_first_piece_gate"
|
||||
sequence="30"/>
|
||||
|
||||
<!-- Phase 2 — both under Shop Setup. -->
|
||||
<menuitem id="menu_fp_shopfloor_stations_cfg"
|
||||
name="Shopfloor Stations"
|
||||
|
||||
@@ -1054,54 +1054,7 @@ if env['fusion.plating.bake.window'].search_count([]) < 6:
|
||||
})
|
||||
LOG(" +3 additional bake windows (awaiting / in-progress)")
|
||||
|
||||
# First-piece inspection gates — seed 4 variants
|
||||
Gate = env['fusion.plating.first.piece.gate']
|
||||
if Gate.search_count([]) < 4:
|
||||
Gate.create({
|
||||
'bath_id': bath_en.id,
|
||||
'part_ref': 'HW-TOR-5501',
|
||||
'customer_ref': 'Honeywell Toronto',
|
||||
'routing_first_run': True,
|
||||
'first_piece_produced': datetime.now() - timedelta(minutes=35),
|
||||
'result': 'pending',
|
||||
})
|
||||
Gate.create({
|
||||
'bath_id': bath_en.id,
|
||||
'part_ref': 'AP-WGL-7200',
|
||||
'customer_ref': 'Amphenol Canada',
|
||||
'routing_first_run': False,
|
||||
'first_piece_produced': datetime.now() - timedelta(hours=2),
|
||||
'first_piece_inspected': datetime.now() - timedelta(hours=1, minutes=40),
|
||||
'inspector_id': env.user.id,
|
||||
'result': 'pass',
|
||||
'rest_of_lot_released': True,
|
||||
})
|
||||
Gate.create({
|
||||
'bath_id': bath_en.id,
|
||||
'part_ref': 'MG-WG-8801',
|
||||
'customer_ref': 'Magellan Aerospace',
|
||||
'routing_first_run': True,
|
||||
'first_piece_produced': datetime.now() - timedelta(hours=4),
|
||||
'first_piece_inspected': datetime.now() - timedelta(hours=3, minutes=30),
|
||||
'inspector_id': env.user.id,
|
||||
'result': 'pass',
|
||||
'rest_of_lot_released': False, # passed but awaiting release
|
||||
'notes': '<p>Thickness 1.95 mils — within tolerance. Lot released pending planner signoff.</p>',
|
||||
})
|
||||
Gate.create({
|
||||
'bath_id': bath_en.id,
|
||||
'part_ref': 'CY-STR-240',
|
||||
'customer_ref': 'Cyclone Manufacturing',
|
||||
'routing_first_run': True,
|
||||
'first_piece_produced': datetime.now() - timedelta(hours=6),
|
||||
'first_piece_inspected': datetime.now() - timedelta(hours=5, minutes=30),
|
||||
'inspector_id': env.user.id,
|
||||
'result': 'fail',
|
||||
'notes': '<p>Thickness 0.8 mils — below spec (min 1.2). Rework required.</p>',
|
||||
})
|
||||
LOG(" 4 first-piece gates: 1 pending / 1 passed+released / 1 passed-holding / 1 failed")
|
||||
else:
|
||||
LOG(f" Already has {Gate.search_count([])} first-piece gates — skipping")
|
||||
# First-piece-gate seeding retired with the model (19.0.33.2.0).
|
||||
|
||||
# Quality holds on active MOs — gives the Shop Floor quality-holds panel content
|
||||
Hold = env['fusion.plating.quality.hold']
|
||||
@@ -1374,7 +1327,6 @@ LOG(f" Bath logs: {env['fusion.plating.bath.log'].search_count([])}")
|
||||
LOG(f" Replenishments: {env['fusion.plating.bath.replenishment.suggestion'].search_count([])}")
|
||||
LOG(f" Bake windows: {env['fusion.plating.bake.window'].search_count([])}")
|
||||
LOG(f" Stations: {env['fusion.plating.shopfloor.station'].search_count([])}")
|
||||
LOG(f" First-piece: {env['fusion.plating.first.piece.gate'].search_count([])}")
|
||||
LOG(f" Quality holds: {env['fusion.plating.quality.hold'].search_count([])}")
|
||||
LOG(f" Racks: {env['fusion.plating.rack'].search_count([])}")
|
||||
LOG(f" Operator certs: {env['fp.operator.certification'].search_count([])}")
|
||||
|
||||
@@ -853,17 +853,7 @@ if BakeWin is not None and job:
|
||||
'bake window auto-created',
|
||||
f'{len(bw)} record(s) for {job.name}')
|
||||
|
||||
# First-piece gate auto-created?
|
||||
FPG = env.get('fusion.plating.first.piece.gate')
|
||||
if FPG is not None:
|
||||
# FPG model may not have production_id either; try common link fields
|
||||
fpg = FPG.search([]) # take any recent
|
||||
fpg_for_mo = fpg.filtered(
|
||||
lambda g: getattr(g, 'production_id', False) and g.production_id.id == mo.id
|
||||
) if 'production_id' in FPG._fields else fpg.browse([])
|
||||
finding('PASS' if fpg_for_mo else 'WARN',
|
||||
'first-piece gate',
|
||||
f'{len(fpg_for_mo)} for MO (coating-driven; OK if 0)')
|
||||
# First-piece-gate check retired with the model (19.0.33.2.0).
|
||||
|
||||
# Each operator can see their OWN assigned WOs via the tablet
|
||||
# (queue is a TransientModel; tablet calls build_for_user on load)
|
||||
|
||||
Reference in New Issue
Block a user