Merge feat/fp-native-job-model into main
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled

Native fp.job / fp.job.step model replacing the mrp.production /
mrp.workorder bridge for the Fusion Plating shop. Coexists with
fusion_plating_bridge_mrp during the migration; cutover is gated on
the x_fc_use_native_jobs settings flag.

Highlights from 61 commits:
- New fusion_plating_jobs module with fp.job, fp.job.step, recipe
  expansion, lifecycle hooks, smart buttons, traveller / margin /
  sticker reports, and migration tooling.
- Operator UI consolidated into fusion_plating_shopfloor: Manager
  Desk, Plant Overview, Process Tree, Tablet Station — all bound to
  fp.job / fp.job.step, theme-token compliant in light + dark mode.
- QR scanner OWL component (vendored ZXing-js + jsQR fallback +
  iOS native-camera photo capture).
- /fp/job/<id> + /fp/wo/<id> migration-aware redirects.
- /fp/tank/<id> NFC tank status page.
- Sticker template restored to the canonical ENTECH layout, now
  reused by fp.job + sale.order (one sticker per line with a part).
- Comprehensive workflow seed data (quotation -> paid invoice).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-26 10:48:11 -04:00
100 changed files with 20618 additions and 1184 deletions

View File

@@ -124,15 +124,15 @@ class TestFpWorkCentre(TransactionCase):
self.assertEqual(wc.kind, 'wet_line')
self.assertTrue(wc.active)
def test_facility_required_for_active_centre(self):
# Active centre without facility raises on confirm path
# (we treat facility as soft-required: warning, not constraint)
def test_facility_optional_at_create(self):
# Facility is soft-required (warning at confirm, not constraint
# at create) — verify a centre without facility still creates.
wc = self.env['fp.work.centre'].create({
'name': 'Test',
'code': 'T',
'kind': 'other',
})
self.assertFalse(wc.facility_id) # allowed at create time
self.assertFalse(wc.facility_id)
def test_kind_selection_values(self):
kinds = dict(
@@ -221,7 +221,7 @@ from . import fp_work_centre
Modify `fusion_plating/security/ir.model.access.csv` — append:
```csv
access_fp_work_centre_user,fp.work.centre.user,model_fp_work_centre,base.group_user,1,0,0,0
access_fp_work_centre_operator,fp.work.centre.operator,model_fp_work_centre,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_work_centre_supervisor,fp.work.centre.supervisor,model_fp_work_centre,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_work_centre_manager,fp.work.centre.manager,model_fp_work_centre,fusion_plating.group_fusion_plating_manager,1,1,1,1
```
@@ -453,7 +453,10 @@ Create `fusion_plating/data/fp_job_sequences.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- noupdate="1" is REQUIRED — without it, every -u fusion_plating
resets number_next back to 1, which corrupts the live sequence
on every module update. Matches the convention in fp_sequence_data.xml. -->
<odoo noupdate="1">
<!-- Sequence for fp.job. Format: WH/JOB/00001 onwards.
Migrated mrp.production records keep their WH/MO/... names. -->
<record id="seq_fp_job" model="ir.sequence">
@@ -477,7 +480,6 @@ Modify `fusion_plating/__manifest__.py` — add `'data/fp_job_sequences.xml'` to
Modify `fusion_plating/security/ir.model.access.csv` — append:
```csv
access_fp_job_user,fp.job.user,model_fp_job,base.group_user,1,0,0,0
access_fp_job_operator,fp.job.operator,model_fp_job,fusion_plating.group_fusion_plating_operator,1,1,0,0
access_fp_job_supervisor,fp.job.supervisor,model_fp_job,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_manager,fp.job.manager,model_fp_job,fusion_plating.group_fusion_plating_manager,1,1,1,1
@@ -514,11 +516,20 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
---
### Task 1.4: Add SO/origin/extension fields to `fp.job`
### Task 1.4: Add core-safe extension fields to `fp.job`
The full field list from spec §5.1 — added in chunks so each commit is reviewable.
**Scope reduction (2026-04-25):** Originally this task added all spec §5.1 fields.
But the dependency audit during Task 1.4 implementation revealed that 6 of those
fields point to models in modules that depend on `fusion_plating` core (configurator,
quality, portal, logistics, bridge_mrp). Adding them in core would invert the
dependency graph. **Per the updated spec §5.1**, those fields are deferred to their
owning modules via `_inherit = 'fp.job'` and re-bundled by `fusion_plating_jobs` in
Phase 2.
- [ ] **Step 1: Add SO + recipe + portal/delivery fields**
This task now lands ONLY the fields whose target models are reachable from core's
existing `depends` (sale_management → sale → account, and our own process.node):
- [ ] **Step 1: Add SO + recipe core-safe fields**
Modify `fusion_plating/models/fp_job.py` — add fields after `company_id`:
@@ -529,19 +540,6 @@ Modify `fusion_plating/models/fp_job.py` — add fields after `company_id`:
'job_id', 'line_id',
string='Source SO Lines',
)
part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part',
index=True,
)
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Coating Configuration',
)
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Customer Spec',
)
recipe_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe',
@@ -552,26 +550,22 @@ Modify `fusion_plating/models/fp_job.py` — add fields after `company_id`:
string='Start at Node',
help='Rework: start the job at this recipe node (skip earlier).',
)
portal_job_id = fields.Many2one(
'fusion.plating.portal.job',
string='Portal Job',
)
delivery_id = fields.Many2one(
'fusion.plating.delivery',
string='Delivery',
)
invoice_ids = fields.Many2many(
'account.move',
'fp_job_account_move_rel',
'job_id', 'move_id',
string='Invoices',
)
qc_check_id = fields.Many2one(
'fp.quality.check',
string='Active QC Check',
)
```
**Deferred to bridge modules (DO NOT add in this task):**
- `part_catalog_id`, `coating_config_id` → owned by `fusion_plating_configurator`
- `customer_spec_id` → owned by `fusion_plating_quality`
- `portal_job_id` → owned by `fusion_plating_portal`
- `delivery_id` → owned by `fusion_plating_logistics`
- `qc_check_id` → owned by `fusion_plating_jobs` (Phase 2; the underlying model
`fusion.plating.quality.check` currently lives in `fusion_plating_bridge_mrp`)
- [ ] **Step 2: Add cost rollup fields (computed)**
Append:
@@ -899,7 +893,6 @@ Modify `fusion_plating/models/fp_job.py` — add field after `qc_check_id`:
Modify `fusion_plating/security/ir.model.access.csv` — append:
```csv
access_fp_job_step_user,fp.job.step.user,model_fp_job_step,base.group_user,1,0,0,0
access_fp_job_step_operator,fp.job.step.operator,model_fp_job_step,fusion_plating.group_fusion_plating_operator,1,1,0,0
access_fp_job_step_supervisor,fp.job.step.supervisor,model_fp_job_step,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_step_manager,fp.job.step.manager,model_fp_job_step,fusion_plating.group_fusion_plating_manager,1,1,1,1
@@ -1200,7 +1193,6 @@ Replace `button_start` and `button_finish` to manage timelogs and `duration_actu
Modify `fusion_plating/security/ir.model.access.csv` — append:
```csv
access_fp_job_step_timelog_user,fp.job.step.timelog.user,model_fp_job_step_timelog,base.group_user,1,0,0,0
access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1
```
@@ -1650,19 +1642,55 @@ If any item fails, stop. Don't start Phase 2 with a broken foundation.
---
## Phase 2 (outline only — detail before starting)
## Phase 2 (detailed 2026-04-25 after Phase 1 landed)
**Goal:** Move SO→MO bridge logic and recipe→WO generator to native job model. Strip MRP coupling from `fusion_plating_bridge_mrp`; rename to `fusion_plating_jobs`.
**Goal:** Build `fusion_plating_jobs` alongside `fusion_plating_bridge_mrp`. The
new module routes SO confirm → `fp.job`, runs the recipe → `fp.job.step` generator,
auto-creates portal jobs / deliveries / certs against the native models, and adds
the 6 cross-module fields deferred from Phase 1.
Key tasks (detail before starting):
- Module rename + manifest cleanup
- `_fp_auto_create_job` on `sale.order.action_confirm`
- `fp.job._generate_steps_from_recipe` (port logic from `_generate_workorders_from_recipe`)
- Migrate every `x_fc_*` field on `mrp.production` and `mrp.workorder` to native fields on `fp.job` / `fp.job.step`
- Quality check, racking inspection, cert generator, delivery hooks rebound to job events
- Drop `sale_mrp` from `__manifest__.py` depends
**Strategy revision (vs. original plan):** original said "rename bridge_mrp → jobs."
Renaming is destructive on entech (a live system). Instead, **build the new module
in parallel**:
Estimated: 5 days.
- `fusion_plating_bridge_mrp` STAYS installed and primary. Operators keep using
the existing MO-based flow. No regression risk.
- `fusion_plating_jobs` is NEW. It creates `fp.job` records on SO confirm only
when a settings flag (`x_fc_use_native_jobs`) is True. Default: False.
- Both modules can be installed simultaneously without conflict.
- Phase 9 cutover flips the flag for entech, deprecating bridge_mrp's MO creation.
- Phase 10 burn-in keeps bridge_mrp installed read-only as a safety net.
- Eventual deprecation of bridge_mrp = future task, not blocked by this work.
Branch strategy: same `feat/fp-native-job-model` branch.
### Task breakdown
| # | Task | Detail | Effort |
|---|---|---|---|
| 2.1 | Create `fusion_plating_jobs` skeleton | New module dir, manifest with all needed depends (fusion_plating + configurator + portal + logistics + quality + certificates), empty `models/__init__.py`, security ACL stub. Verify clean install on entech. | 0.5d |
| 2.2 | Add cross-module fields to `fp.job` via `_inherit` | The 6 deferred fields (part_catalog_id, coating_config_id, customer_spec_id, portal_job_id, delivery_id, qc_check_id) added in jobs module. Tests. | 0.5d |
| 2.3 | Port `fusion.plating.job.node.override` to jobs module | Move from bridge_mrp; rebind from `mrp.production` to `fp.job`. Keep the bridge_mrp version of this model alive on `mrp.production` for now (parallel). Tests. | 0.5d |
| 2.4 | Recipe → steps generator on `fp.job` | Port `_generate_workorders_from_recipe` from bridge_mrp into a new `fp.job._generate_steps_from_recipe` method. Walks recipe, creates `fp.job.step`. Tests. | 1d |
| 2.5 | Add settings flag `x_fc_use_native_jobs` + SO confirm hook | New flag on res.config.settings (default False). When True, `sale.order.action_confirm` creates `fp.job` instead of `mrp.production`. Tests cover both flag values. | 0.5d |
| 2.6 | Portal job binding from `fp.job` | `fusion.plating.portal.job` gains `x_fc_job_id` Many2one. Auto-create portal job on `fp.job.action_confirm`. Tests. | 0.25d |
| 2.7 | Quality check auto-create | When customer has `x_fc_requires_qc=True`, fp.job.action_confirm spawns a `fusion.plating.quality.check` linked to the job. Tests. | 0.25d |
| 2.8 | Delivery + cert auto-create on done | `fp.job.button_mark_done` creates `fusion.plating.delivery` (draft) and triggers cert generator (CoC + thickness report) like bridge_mrp does for MO done. Tests. | 0.5d |
| 2.9 | Account.move (invoice) hook | When invoice posts, find the linked `fp.job` (via SO origin), update portal_job state to 'complete' and stamp invoice_ref. Mirrors bridge_mrp. Tests. | 0.25d |
| 2.10 | Drop `sale_mrp` from jobs module's depends | Verify zero remaining `sale_mrp`-dependent code paths in jobs. Note: bridge_mrp keeps its sale_mrp dep until cutover. | 0.25d |
| 2.11 | Tag `phase-2-complete` + demo checklist | Full test run, push, tag, demo path on entech with the flag flipped on a test SO. | 0.25d |
**Total: ~5 days engineering, plus review cycles.**
### Demo target after Phase 2
A manager on entech can:
1. Open a fresh sale.order, add a plating line.
2. Toggle `x_fc_use_native_jobs=True` in settings (or per-SO override).
3. Confirm the SO → instead of MO appearing, a `WH/JOB/00001` lands in the new menu.
4. Recipe steps auto-generate as `fp.job.step` rows.
5. Operator (still in old UI for now) doesn't see the fp.job — but a manager can drive it through the admin views.
6. Toggle off the flag → next SO confirm goes back to MO. Bridge_mrp untouched.
---

View File

@@ -0,0 +1,385 @@
# Native Job Model — Cutover Runbook (Phases 8, 9, 10)
**Date:** 2026-04-25
**Owner:** Nexa Systems
**Status:** Draft. Verify each step on entech-clone before live cutover.
**Predecessor:** Phases 17 complete (commits up to current HEAD on
`feat/fp-native-job-model`). Spec:
`docs/superpowers/specs/2026-04-25-fp-native-job-model-design.md`. Plan:
`docs/superpowers/plans/2026-04-25-fp-native-job-model.md`.
This runbook covers the operational phases of the migration:
- **Phase 8** — End-to-end testing on a clone of entech (~5 days)
- **Phase 9** — Live cutover weekend (4 hour window)
- **Phase 10** — 2-week burn-in with rollback safety net
---
## Phase 8 — E2E testing on entech-clone (5 days)
### 8.1 Prepare the clone
1. **Snapshot live entech:** `pct snapshot 111 pre_fp_jobs_clone` on pve-worker5.
2. **Spin up a sibling LXC** (e.g. `entech-clone` at LXC 511 / pve-worker5).
- Restore from the snapshot
- Configure new IP: 10.200.1.27 (so it doesn't compete with live entech 10.200.1.26)
- Update `odoo.conf` to a separate database name e.g. `admin_clone`
3. **Update Tailscale:** add `entech-clone` to your Tailscale ACL so SSH works.
4. **Verify clone independence:** any DB writes on entech-clone must NOT bleed
to live entech. Different DB name, different IP.
### 8.2 Pre-migration audit
Run on entech-clone:
```bash
ssh pve-worker5 "pct exec 511 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin_clone\"' < /mnt/extra-addons/custom/fusion_plating_jobs/scripts/audit_pre_migration.py"
```
Expected output: counts of MOs, WOs, dependent records, data quality flags.
**Capture the baseline numbers** in `phase8_baseline.txt` for diffing later.
### 8.3 Run migration
```bash
ssh pve-worker5 "pct exec 511 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin_clone\"' < /mnt/extra-addons/custom/fusion_plating_jobs/scripts/migrate_to_fp_jobs.py"
```
Watch for errors in the output. Audit log at `/tmp/fp_jobs_migration.log`.
### 8.4 Post-migration audit
```bash
ssh pve-worker5 "pct exec 511 -- bash -c 'su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin_clone\"' < /mnt/extra-addons/custom/fusion_plating_jobs/scripts/audit_post_migration.py"
```
Verify:
- `fp.job` count == `mrp.production` count (every MO has a mirror)
- `fp.job.step` count == `mrp.workorder` count
- Dependent x_fc_*_id counts match production_id / workorder_id counts
If any mismatch, dig into the audit log for errors.
### 8.5 Smoke test the new flow
Manual on the clone via browser:
1. Toggle `x_fc_use_native_jobs=True` in Settings → Fusion Plating Jobs.
2. Create a new SO with a plating line.
3. Confirm the SO. Verify a `WH/JOB/...` record appears in **Plating Jobs (new)** menu.
4. Verify the recipe steps generated correctly.
5. Open a step, click Start, then Finish. Verify timelog row, duration_actual,
cost_total all populate.
6. Print the new Job Sticker (6×4"). Verify QR scans to `/fp/job/<id>` and
redirects to the form.
7. Print the Job Traveller. Verify all steps listed.
8. Click **Mark Done** on the job. Verify state=done, draft delivery created,
draft cert created (best-effort).
### 8.6 Replay 30 days of activity
Identify the last 30 days of MO activity on entech (pre-clone) and replay
those operator actions through the new flow on the clone. Look for:
- Operations that succeeded on the legacy flow but error on native
- Reports that render differently
- Cost / margin numbers that differ between legacy and native
Diff certificates byte-for-byte: render 100 random CoC PDFs on legacy and on
migrated native job. They should be visually identical. Any differences are
audit-grade red flags (Nadcap / aerospace).
### 8.7 Performance baseline
Measure on the clone:
- Plant Overview load time with N active steps (grouped by work_centre)
- Job form open time with 50-step recipe
- Job traveller PDF render time
- Job sticker PDF render time
- Migration script runtime (target: < 30 min on entech-scale data)
If anything is significantly slower than the legacy MO/WO flow, investigate
indexes (M2M tables, related stores) before cutover.
### 8.8 Rollback test
On the clone, simulate a rollback:
1. Restore the pre-cutover snapshot.
2. Verify legacy MO/WO data is intact.
3. Verify the `fusion_plating_jobs` module is still installed but inert
(flag is False).
4. Verify nothing in bridge_mrp / fusion_plating_reports / shopfloor /
notifications regressed.
Rollback safety is the most important thing to prove before live cutover.
### 8.9 Sign-off criteria
Before scheduling Phase 9:
- [ ] All Phase 1+2 tests pass (50+ tests)
- [ ] Migration script runs cleanly on clone with 0 errors in audit log
- [ ] Pre/post audit counts match
- [ ] 100 sample CoCs byte-identical
- [ ] All performance baselines within 20% of legacy
- [ ] Rollback test successful
If any item fails, identify the gap, fix in `feat/fp-native-job-model`, and
re-run §§ 8.28.8.
---
## Phase 9 — Cutover weekend (1 calendar day, ~4 hours active work)
### 9.1 Pre-cutover communication (T-7 days)
- Email entech operators: "Saturday MM/DD evening: ~4 hours offline for
system upgrade. Sunday morning normal."
- Brief 2-3 plating managers on the new menu and the demo path.
- Confirm Saturday on-site presence: 1 manager + 1 tech (you).
### 9.2 Friday 6pm — stop new work
- Operators wrap up active jobs. No new SO confirms. No new WOs started.
- Verify no in_progress WOs left running. Pause any timers.
### 9.3 Friday 8pm — backup
```bash
# Full DB dump
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"pg_dump admin\" > /var/backups/admin_pre_fp_jobs_$(date +%Y%m%d).sql'"
# Filesystem snapshot
ssh pve-worker5 "pct snapshot 111 pre_fp_jobs_cutover"
```
Tag the current commit:
```bash
cd /Users/gurpreet/Github/Odoo-Modules
git tag -a pre-cutover-$(date +%Y%m%d) -m "Pre-cutover backup point"
git push origin pre-cutover-$(date +%Y%m%d)
```
### 9.4 Friday 9pm — deploy + migrate
1. Deploy the latest `fusion_plating_jobs` to entech (it should already be
installed from Phase 7 development; just refresh).
```bash
# Sync feat/fp-native-job-model branch state to entech if not already
# (skip if entech is already on this branch)
```
2. Update the module:
```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_jobs --stop-after-init\" && systemctl start odoo'"
```
3. Run the migration:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl stop odoo && su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin\"' < /mnt/extra-addons/custom/fusion_plating_jobs/scripts/migrate_to_fp_jobs.py"
```
4. Verify with the post-audit script.
5. Toggle the cutover flag:
```bash
# Via odoo shell:
env['ir.config_parameter'].sudo().set_param('fusion_plating_jobs.use_native_jobs', 'True')
env.cr.commit()
```
6. Restart Odoo.
### 9.5 Friday 10pm — smoke test
Same as §8.5 but on live entech. If anything fails, restore backup
(§9.7) and abort.
### 9.6 Saturday/Sunday — buffer
Shop is offline weekends. Use the time to:
- Fix anything that surfaced during smoke test
- Run additional spot checks on historical jobs
- Verify that print menus default to the new reports for new jobs
- Test sticker scans on a phone
### 9.7 Rollback procedure (if needed by Sunday evening)
If unrecoverable issues:
```bash
# Stop Odoo
ssh pve-worker5 "pct exec 111 -- systemctl stop odoo"
# Restore DB
ssh pve-worker5 "pct exec 111 -- bash -c 'su - postgres -c \"dropdb admin && createdb admin && psql admin < /var/backups/admin_pre_fp_jobs_<date>.sql\"'"
# Or restore container snapshot (faster, but loses any post-snapshot DB writes)
ssh pve-worker5 "pct rollback 111 pre_fp_jobs_cutover"
# Start Odoo
ssh pve-worker5 "pct exec 111 -- systemctl start odoo"
# Communicate to operators that we're back on the legacy flow
```
After day 7, rollback becomes "forward fix only" — too much new shop activity
to restore.
### 9.8 Monday 7am — operators back on
- 1 manager + 1 tech on site for the first 2 hours
- Walk operators through the new menu (Plating Jobs (new) → Jobs)
- Watch for confusion or errors
- Field tickets as they come in
---
## Phase 10 — Burn-in (2 weeks calendar, ~1 day active work)
### 10.1 Daily monitoring (Days 114)
Check daily:
- Odoo error log: `tail -f /var/log/odoo/odoo-server.log | grep -i error`
- Job creation rate: `SELECT COUNT(*) FROM fp_job WHERE create_date > now() - interval '1 day'`
- Step creation rate: `SELECT COUNT(*) FROM fp_job_step WHERE create_date > now() - interval '1 day'`
- Failed lifecycle hooks: `grep -c "failed to" /var/log/odoo/odoo-server.log`
- Operator support tickets
Run audit_post_migration.py weekly to catch any drift.
### 10.2 Forward-fix
Anything that surfaces during burn-in goes through the standard PR/review
workflow on `feat/fp-native-job-model` (or a new follow-up branch). The
underlying data layer is locked — fixes are mostly UI/report polish.
### 10.3 Day 14 — drop legacy snapshots
After 14 days of stable operation:
```bash
# Drop the pre-cutover snapshot
ssh pve-worker5 "pct delsnapshot 111 pre_fp_jobs_cutover"
# Optional: archive the SQL backup off-site
mv /var/backups/admin_pre_fp_jobs_*.sql /off-site/long-term-archive/
```
### 10.4 Bridge_mrp deprecation
`fusion_plating_bridge_mrp` is still installed and inert (the SO confirm
hook only fires when `x_fc_use_native_jobs=False`, which it never is post-
cutover). Options for full deprecation:
A) Leave it installed forever. Zero impact.
B) Archive (set `installable=False` in its manifest, so a future re-install
wouldn't activate it).
C) Uninstall (write a uninstall hook that drops the bridge tables but
preserves the data already migrated to fp.job).
Recommend (A) for the first 6 months, then revisit.
### 10.5 Phase-end polish
The list of deferred Minor items from Phase 1-7 reviews:
- `currency_id required=True` on fp.work.centre and fp.job (and ondelete
policies on M2Os uniformly across both core and jobs)
- `tracking=True` on fp.job.manager_id, facility_id
- `digits='Product Unit of Measure'` on qty
- `_('New')` translation safety in create
- Field labels: "Reference Product" → cleaner string
- Recipe boolean tests on fp.job.step
- `index=True` on M2Os queried frequently (recipe_id, partner_id)
- Author/website/maintainer block in fusion_plating_jobs manifest
- i18n wrapping (`_()`) on user-visible strings
- `_compute_state_ready` for fp.job.step pending → ready transition (Task 1.5
TODO)
- `button_pause` / `button_skip` / `button_cancel` real implementations
- Operator UI rewrite (Plant Overview, Tablet Station, Manager Dashboard,
Process Tree OWL component) — Phase 6 deferral
These can be batched into one polish PR after burn-in completes (Day 14+).
---
## Appendix A — Communication templates
### Email to operators (T-7)
> Subject: System maintenance Saturday — ~4 hours
>
> Team — we're upgrading the Fusion Plating Jobs system Saturday MM/DD
> from 9pm Friday through Saturday morning. The shop will be offline during
> that window. By Monday 7am everything will be normal except you'll see a
> new "Plating Jobs (new)" menu in addition to the existing menus. Same data,
> better workflow. Manager + tech will be on site Monday morning to help.
>
> No action needed from you. Just don't start any new jobs after 6pm Friday.
>
> Questions? Reply or ping the manager.
### Manager briefing (T-3)
Walk through:
1. The new menu structure
2. The settings flag and how to toggle it
3. The migration script and rollback procedure
4. What to do if an operator reports a bug Monday morning
---
## Appendix B — Open decisions for the user before Phase 9
Schedule the cutover weekend with at least 4 weeks notice. Confirm:
1. Date of cutover weekend
2. Which manager will be on-site Monday morning
3. Whether to keep the legacy menus visible after cutover (recommend: yes,
for the first 14 days, then hide via group permission)
4. Whether to send the operator email template above as-is or customize
5. Acceptance criteria for "burn-in complete" (recommend: 14 days zero
critical errors, zero operator support tickets that map to migration
issues)
---
## Appendix C — File checklist before Phase 8 starts
Verify these are present (committed to feat/fp-native-job-model):
- [x] `fusion_plating_jobs/__manifest__.py` — version >= 19.0.2.0.0, depends on 9 modules
- [x] `fusion_plating_jobs/models/fp_job.py` — _inherit with all extension fields, hooks, helpers, legacy_id
- [x] `fusion_plating_jobs/models/fp_job_node_override.py` — override model
- [x] `fusion_plating_jobs/models/sale_order.py` — SO confirm hook
- [x] `fusion_plating_jobs/models/res_config_settings.py` — flag
- [x] `fusion_plating_jobs/models/fp_portal_job.py` — x_fc_job_id link
- [x] `fusion_plating_jobs/models/fp_batch.py` — x_fc_step_id / x_fc_job_id
- [x] `fusion_plating_jobs/models/fp_quality_hold.py` — x_fc_job_id / x_fc_step_id
- [x] `fusion_plating_jobs/models/fp_certificate.py` — x_fc_job_id
- [x] `fusion_plating_jobs/models/fp_thickness_reading.py` — x_fc_job_id / x_fc_step_id
- [x] `fusion_plating_jobs/models/fp_delivery.py` — x_fc_job_id
- [x] `fusion_plating_jobs/models/fp_racking_inspection.py` — x_fc_job_id
- [x] `fusion_plating_jobs/models/account_move.py` — invoice → job hook
- [x] `fusion_plating_jobs/models/fp_notification_trigger.py` — job_confirmed/job_complete events
- [x] `fusion_plating_jobs/models/fusion_plating_kpi_value.py` — x_fc_source tag
- [x] `fusion_plating_jobs/views/res_config_settings_views.xml` — settings UI
- [x] `fusion_plating_jobs/report/report_fp_job_sticker.xml` — sticker
- [x] `fusion_plating_jobs/report/report_fp_job_traveller.xml` — traveller
- [x] `fusion_plating_jobs/controllers/job_scan.py` — /fp/job/<id>
- [x] `fusion_plating_jobs/controllers/process_tree.py` — /fp/jobs/process_tree
- [x] `fusion_plating_jobs/scripts/audit_pre_migration.py`
- [x] `fusion_plating_jobs/scripts/migrate_to_fp_jobs.py`
- [x] `fusion_plating_jobs/scripts/audit_post_migration.py`
- [x] `fusion_plating_jobs/scripts/README.md`
- [x] `fusion_plating_jobs/README.md` — Phase 6 deferrals doc
- [x] `fusion_plating_jobs/security/ir.model.access.csv` — ACL rows
- [x] `fusion_plating_jobs/tests/test_fp_job_extensions.py` — comprehensive test suite
If anything in this list is missing, fix before Phase 8.

View File

@@ -96,50 +96,57 @@ process tree with cost/time aggregates.
Replaces `mrp.production` for plating jobs. One record per shop-floor job.
| Field | Type | Notes |
|---|---|---|
| `name` | Char | Sequence: `WH/JOB/00033`. The legacy "WH/MO/00033" labels stay only on migrated records (see §7). |
| `state` | Selection | `draft`, `confirmed`, `in_progress`, `done`, `cancelled`, `on_hold` |
| `partner_id` | Many2one(res.partner) | Customer; copied from SO |
| `product_id` | Many2one(product.product) | Reference part product (for inventory only) |
| `part_catalog_id` | Many2one(fp.part.catalog) | The actual part being plated; primary identifier |
| `qty` | Float | Quantity to plate |
| `qty_done` | Float | Quantity completed |
| `qty_scrapped` | Float | Quantity scrapped (rolled up from holds) |
| `date_deadline` | Datetime | Promised completion date |
| `date_planned_start` | Datetime | Planned start |
| `date_started` | Datetime | Actual start (first step start) |
| `date_finished` | Datetime | Actual completion |
| `origin` | Char | SO name for traceability |
| `sale_order_id` | Many2one(sale.order) | Source SO |
| `sale_order_line_ids` | Many2many(sale.order.line) | Lines that fed this job (group_tag collapse) |
| `recipe_id` | Many2one(fusion.plating.process.node) | The recipe template used |
| `step_ids` | One2many(fp.job.step, job_id) | The operations |
| `step_count` | Integer | Computed |
| `step_done_count` | Integer | Computed |
| `step_progress_pct` | Float | Computed: `step_done_count / step_count * 100` |
| `current_step_id` | Many2one(fp.job.step) | The operation currently in progress (or next ready) |
| `coating_config_id` | Many2one(fp.coating.config) | The coating spec |
| `facility_id` | Many2one(fp.facility) | Hard gate at confirm |
| `manager_id` | Many2one(res.users) | Plating manager |
| `priority` | Selection | `low`, `normal`, `high`, `rush` (operator-relevant ordering) |
| `customer_spec_id` | Many2one(fp.customer.spec) | Optional spec |
| `portal_job_id` | Many2one(fp.portal.job) | Customer portal binding (renamed from `x_fc_portal_job_id`) |
| `delivery_id` | Many2one(fp.delivery) | The shipment |
| `invoice_ids` | Many2many(account.move) | Linked invoices |
| `certificate_ids` | One2many(fp.certificate, job_id) | Certs generated |
| `batch_ids` | One2many(fp.batch, job_id) | Batches that ran through |
| `quality_hold_ids` | One2many(fp.quality.hold, job_id) | Holds raised |
| `consumption_ids` | One2many(fp.job.consumption, job_id) | Consumables |
| `qc_check_id` | Many2one(fp.quality.check) | Active QC check |
| `quoted_revenue` | Monetary | From SO |
| `actual_cost` | Monetary | Computed from steps + consumables |
| `margin` | Monetary | Computed |
| `margin_pct` | Float | Computed |
| `start_at_node_id` | Many2one(fusion.plating.process.node) | Rework: start at this recipe node |
| `override_ids` | One2many(fp.job.node.override, job_id) | Per-job opt-in/out |
| `current_location` | Char | Computed: "Queued: Bath 3" / "In progress: Oven A" / "Ready to ship" |
| `mail.thread, mail.activity.mixin` | Inherits | Chatter |
**Module ownership:** `fp.job` lives in `fusion_plating` core. Cross-module fields
(referencing models from `fusion_plating_configurator`, `_portal`, `_logistics`,
`_quality`, `_bridge_mrp`) **cannot** live in core without inverting the dependency
graph. Each owning module extends `fp.job` via `_inherit` to add its field. The
Phase 2 module `fusion_plating_jobs` becomes the umbrella that pulls all the
extensions together. Ownership is called out in the **Module** column below.
| Field | Type | Module | Notes |
|---|---|---|---|
| `name` | Char | core | Sequence: `WH/JOB/00033`. The legacy "WH/MO/00033" labels stay only on migrated records (see §7). |
| `state` | Selection | core | `draft`, `confirmed`, `in_progress`, `done`, `cancelled`, `on_hold` |
| `partner_id` | Many2one(res.partner) | core | Customer; copied from SO |
| `product_id` | Many2one(product.product) | core | Reference part product (for inventory only) |
| `qty` | Float | core | Quantity to plate |
| `qty_done` | Float | core | Quantity completed |
| `qty_scrapped` | Float | core | Quantity scrapped (rolled up from holds) |
| `date_deadline` | Datetime | core | Promised completion date |
| `date_planned_start` | Datetime | core | Planned start |
| `date_started` | Datetime | core | Actual start (first step start) |
| `date_finished` | Datetime | core | Actual completion |
| `origin` | Char | core | SO name for traceability |
| `sale_order_id` | Many2one(sale.order) | core | Source SO (sale_management is in core depends) |
| `sale_order_line_ids` | Many2many(sale.order.line) | core | Lines that fed this job (group_tag collapse) |
| `recipe_id` | Many2one(fusion.plating.process.node) | core | The recipe template used |
| `step_ids` | One2many(fp.job.step, job_id) | core | The operations |
| `step_count` | Integer | core | Computed |
| `step_done_count` | Integer | core | Computed |
| `step_progress_pct` | Float | core | Computed: `step_done_count / step_count * 100` |
| `current_step_id` | Many2one(fp.job.step) | core | The operation currently in progress (or next ready) |
| `facility_id` | Many2one(fusion.plating.facility) | core | Hard gate at confirm |
| `manager_id` | Many2one(res.users) | core | Plating manager |
| `priority` | Selection | core | `low`, `normal`, `high`, `rush` (operator-relevant ordering) |
| `invoice_ids` | Many2many(account.move) | core | Linked invoices (account is reachable via sale_management → sale → account) |
| `quoted_revenue` | Monetary | core | From SO |
| `actual_cost` | Monetary | core | Computed from steps + consumables |
| `margin` | Monetary | core | Computed |
| `margin_pct` | Float | core | Computed |
| `start_at_node_id` | Many2one(fusion.plating.process.node) | core | Rework: start at this recipe node |
| `current_location` | Char | core | Computed: "Queued: Bath 3" / "In progress: Oven A" / "Ready to ship" |
| `mail.thread, mail.activity.mixin` | Inherits | core | Chatter |
| `part_catalog_id` | Many2one(fp.part.catalog) | **`fusion_plating_configurator`** (`_inherit = 'fp.job'`) | The actual part being plated; primary identifier |
| `coating_config_id` | Many2one(fp.coating.config) | **`fusion_plating_configurator`** | The coating spec |
| `customer_spec_id` | Many2one(fusion.plating.customer.spec) | **`fusion_plating_quality`** | Optional spec |
| `portal_job_id` | Many2one(fusion.plating.portal.job) | **`fusion_plating_portal`** | Customer portal binding |
| `delivery_id` | Many2one(fusion.plating.delivery) | **`fusion_plating_logistics`** | The shipment |
| `qc_check_id` | Many2one(fusion.plating.quality.check) | **`fusion_plating_jobs`** (Phase 2) | Active QC check; model lives in current bridge_mrp, will move to jobs module |
| `certificate_ids` | One2many(fp.certificate, job_id) | **`fusion_plating_certificates`** | Certs generated |
| `batch_ids` | One2many(fp.batch, job_id) | **`fusion_plating_batch`** | Batches that ran through |
| `quality_hold_ids` | One2many(fp.quality.hold, job_id) | **`fusion_plating_quality`** | Holds raised |
| `consumption_ids` | One2many(fp.job.consumption, job_id) | **`fusion_plating_jobs`** (Phase 2) | Consumables |
| `override_ids` | One2many(fp.job.node.override, job_id) | **`fusion_plating_jobs`** (Phase 2) | Per-job opt-in/out |
**State machine:**
```
@@ -220,7 +227,8 @@ domain-specific (a tank line, a bake oven, a rack station — not assembly cells
| `facility_id` | Many2one(fp.facility) | Which facility |
| `kind` | Selection | `wet_line`, `bake`, `mask`, `rack`, `inspect`, `other` |
| `cost_per_hour` | Monetary | For margin calculations |
| `default_bath_id, default_tank_id, default_oven_id` | Many2one | Single-line shop convenience |
| `default_bath_id, default_tank_id` | Many2one(`fusion.plating.bath`/`.tank`) | Single-line shop convenience |
| `default_oven_id` | Many2one(`fusion.plating.bake.oven`) | **Deferred to `fusion_plating_jobs` bridge module via `_inherit`**`bake.oven` is defined in `fusion_plating_shopfloor` which `fusion_plating` core cannot depend on. Bridge module *can* depend on shopfloor and adds this field there. |
| `active` | Boolean | |
This replaces `x_fc_mrp_workcenter_id` mapping that the recipe operations have today.

View File

@@ -0,0 +1,371 @@
# Overnight Progress Summary — Native Job Model Migration
**Date:** 2026-04-25 (work performed Apr 25 evening through Apr 26 early morning)
**Branch:** `feat/fp-native-job-model`
**Tags:** `phase-1-complete`, `phase-2-complete`
**Test status (last verified on entech):** 50 tests passing (Phase 1+2)
**Test status (Phase 3-7):** untested due to Tailscale SSH lockout mid-session
---
## TL;DR
You went to bed asking me to "keep coding through all the phases." I did. The
data layer of the native job migration is complete on the branch and pushed
to GitHub. The cutover runbook is written. The full operator UI rewrite is
deferred to post-cutover hardening (it's a 5-day OWL/JS rewrite that genuinely
needs in-browser testing on entech).
**Bottom line:** the legacy `mrp.production`/`mrp.workorder` flow on entech
is **untouched**. The new `fp.job`/`fp.job.step` flow exists in parallel,
gated behind a settings flag (`x_fc_use_native_jobs`, default False). Nothing
operators do today changes. When you're ready to cutover, follow the runbook
in `docs/superpowers/specs/2026-04-25-fp-native-job-cutover-runbook.md`.
---
## Critical context: Tailscale SSH lockout mid-session
Around Phase 5 my SSH calls to `pve-worker5` started returning a Tailscale
re-authentication URL. I couldn't access entech for the rest of the night.
This means:
- **Phase 1 + 2 (Tasks 1.2 through 2.5):** tested live on entech. 50 tests pass.
- **Phase 3 onwards:** **NOT tested on entech.** Code is committed locally and
pushed to GitHub, but never installed/run on entech.
- **Migration script (Phase 7):** **NEVER executed.** Just authored.
**First thing you should do when you wake up:**
1. Re-authenticate Tailscale (the URL was in the implementer's earlier output
blocks). Or, run `tailscale up` from your Mac.
2. Pull the latest branch on entech.
3. Run the test suite: `odoo --update=base -u fusion_plating_jobs --test-tags fusion_plating,fusion_plating_jobs --stop-after-init`
4. Triage anything that fails.
---
## Commits added overnight
```
97861df refactor(jobs): gate fp.job lifecycle hooks on fp_jobs_migration context
<docs> feat(jobs): Phase 8/9/10 cutover runbook
f9fab69 feat(jobs): Phase 7 — migration script + legacy id fields
7137622 feat(jobs): Phase 6 lean — scan controller + process-tree JSON endpoint
c528d58 feat(jobs): Phase 5 — fp.job reports (sticker + traveller)
51a5cbb feat(jobs): Phase 4 light refactors — notifications, KPI source tag
b359be3 feat(jobs): Phase 3 light refactors — parallel job/step links on dependent models
dd88afd feat(jobs): add lifecycle hooks — portal/QC/delivery/invoice (Tasks 2.6-2.9)
294cea0 feat(jobs): add x_fc_use_native_jobs flag + SO confirm hook (Task 2.5)
3b7eae9 feat(jobs): add fp.job._generate_steps_from_recipe (Task 2.4)
4c68327 feat(jobs): add fp.job.node.override for per-job opt-in/out decisions
36b9f30 refactor(jobs): drop index=True on part_catalog_id for consistency
6e57b35 feat(jobs): add cross-module fields to fp.job via _inherit (Task 2.2)
4341a03 feat(jobs): add fusion_plating_jobs module skeleton (Phase 2 Task 2.1)
```
Plus the cutover runbook commit (no code).
All pushed to `origin/feat/fp-native-job-model`.
---
## What's complete
### Phase 1 — Core models (Phase 1 Tasks 1.21.9, tagged)
- `fp.work.centre` — replaces `mrp.workcenter` for plating
- `fp.job` — replaces `mrp.production`
- `fp.job.step` — replaces `mrp.workorder`
- `fp.job.step.timelog` — granular timer tracking
- Sequence `WH/JOB/00001+` (`noupdate=1`)
- Manager-only admin views ("Plating Jobs (new)" menu)
- 28 unit tests passing on entech
### Phase 2 — Native jobs bridge module (Tasks 2.12.10, tagged)
- New module `fusion_plating_jobs` alongside `fusion_plating_bridge_mrp`
(parallel coexistence, no destructive renames)
- 5 cross-module fields on `fp.job` via `_inherit` (part_catalog,
coating_config, customer_spec, portal_job, delivery)
- `fp.job.node.override` model for per-job opt-in/out
- Recipe → fp.job.step generator (`_generate_steps_from_recipe`)
- Settings flag `x_fc_use_native_jobs` + SO confirm hook
- Lifecycle hooks: portal job, QC check, delivery, certificates, invoice
- 50 unit tests total passing on entech
### Phase 3 — Light refactors batch A (untested locally)
- Parallel `x_fc_job_id` / `x_fc_step_id` Many2ones added via `_inherit` on:
- `fusion.plating.batch`
- `fusion.plating.quality.hold`
- `fp.certificate`
- `fp.thickness.reading`
- `fusion.plating.delivery`
- `fp.racking.inspection`
- Racking inspection auto-create on job confirm (best-effort, skips if
legacy production_id required field can't be satisfied)
### Phase 4 — Light refactors batch B (untested locally)
- Notifications: `job_confirmed` and `job_complete` events added to
`fp.notification.template`. Hooked from `fp.job.action_confirm` and
`button_mark_done`.
- KPI value source tag: `x_fc_source` selection on `fusion.plating.kpi.value`
- Verified `fusion_plating_aerospace`, `_nuclear`, `_cgp`, `_safety` don't
reference `mrp.production`/`mrp.workorder` (no refactor needed)
- Configurator integration was already complete via Task 2.5
### Phase 5 — Reports (untested locally)
- New `Job Sticker` paperformat (6×4") + QWeb template + report action,
bound to `fp.job`. QR encodes `/fp/job/<id>`.
- New `Job Traveller` (A4 portrait) report bound to `fp.job`. Lists all
steps with sequence, work centre, kind, expected/actual minutes, state,
sign-off column.
- Both reports coexist with `fusion_plating_reports`' MO/WO bindings.
- Deferred (use existing during migration; rebind at cutover): BoL, packing
slip, invoice (read from SO), WO Margin (cost rollup).
### Phase 6 lean — controllers (untested locally)
- `/fp/job/<id>` HTTP scan-redirect controller. Manager → form, operator →
also form (process tree action stub).
- `/fp/jobs/process_tree` JSON-RPC endpoint serializing recipe + step state
for an OWL renderer.
- **Deferred to post-cutover:** Plant Overview kanban, Tablet Station UI,
Manager Dashboard, Process Tree OWL component. Documented in
`fusion_plating_jobs/README.md`.
### Phase 7 — Migration script (untested, never executed)
- `legacy_mrp_production_id` (Integer index) on `fp.job`
- `legacy_mrp_workorder_id` on `fp.job.step`
- Three scripts in `fusion_plating_jobs/scripts/`:
- `audit_pre_migration.py` — pre-cutover row counts and data quality
- `migrate_to_fp_jobs.py` — main migration. Idempotent. Uses context flag
`fp_jobs_migration=True` to skip lifecycle side-effects during
migration (would otherwise create duplicate portal jobs / inspections
/ certs).
- `audit_post_migration.py` — post-cutover verification
### Phase 8/9/10 — Cutover runbook (doc only)
- `docs/superpowers/specs/2026-04-25-fp-native-job-cutover-runbook.md`
- Phase 8 — 5-day E2E test plan on entech-clone
- Phase 9 — Cutover weekend runbook (Friday 6pm → Monday 7am)
- Phase 10 — 2-week burn-in monitoring + rollback
---
## What's NOT complete (deferred or pending verification)
### Pending entech test (HIGH priority — first thing in the morning)
After Tailscale re-auth, run on entech:
```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 --update=base -u fusion_plating_jobs --test-tags fusion_plating,fusion_plating_jobs --stop-after-init\" 2>&1 | tail -30 && systemctl start odoo'"
```
Expected: **all tests pass** (28 from Phase 1 + 22 from Phase 2 + ~15 from
Phases 3-7 = ~65 tests). If anything fails, it's likely a model-name
mismatch I couldn't verify without entech access.
Most likely failure points:
- Field name guesses on `fusion.plating.process.node` (`estimated_duration`,
`opt_in_out`, `requires_signoff`, etc. — verified by greps but not by
runtime instantiation)
- `fusion.plating.work.center.x_fc_fp_work_centre_id` doesn't exist (the
Phase 2 generator falls back to code lookup; should be fine)
- `fp.notification.template.trigger_event` — Selection extension via
`selection_add` should work but I didn't verify
- Migration script: completely untested
### Operator UI rewrite (deferred to post-cutover)
The full Phase 6 — Plant Overview kanban, Tablet Station, Manager Dashboard,
Process Tree OWL component — was scoped at 6 days of OWL/JS work. With
Tailscale blocked I couldn't iterate in a browser, so I shipped the
data-layer pieces (controller endpoints, scan-redirect) and deferred the
visible UI. Plan in the cutover runbook §10.5.
### Phase-end polish (deferred)
Documented in cutover runbook §10.5. Items include:
- `currency_id required=True` and explicit `ondelete=` policies uniformly
across both Phase 1 core fields and Phase 2 _inherit fields
- `tracking=True` on `fp.job.manager_id`, `facility_id`
- `digits='Product Unit of Measure'` on `qty`
- `_('New')` translation safety in `create()`
- Author/website/maintainer block in `fusion_plating_jobs/__manifest__.py`
(Nexa Systems convention; install warning currently emits)
- i18n wrapping on user-visible strings
- `_compute_state_ready` for fp.job.step pending → ready (TODO from Task 1.5)
- `button_pause` / `button_skip` / `button_cancel` real implementations
(currently raise NotImplementedError)
---
## Architecture decisions made autonomously overnight
These deviated from or extended the original spec/plan. Document them so you
can roll back if disagreement.
1. **Phase 2 strategy revised: parallel coexistence vs. rename.** Original
plan said "rename `fusion_plating_bridge_mrp``fusion_plating_jobs`."
That's destructive on a live system — every existing record's xmlid
prefix would need to be migrated. Instead I built `fusion_plating_jobs`
as a NEW module alongside `fusion_plating_bridge_mrp`. Both can be
installed simultaneously. The settings flag controls which path SO
confirm takes. Cutover (Phase 9) flips the flag. This is documented in
the plan §6.2.
2. **Phase 6 scoped down to lean.** Original Phase 6 was the full operator UI
rewrite (6 days). I shipped the data-layer pieces (scan controller, JSON
endpoint) and deferred the visible UI to post-cutover. Documented in
`fusion_plating_jobs/README.md` and the cutover runbook §10.5.
3. **`qc_check_id` field on fp.job remains deferred.** Spec §5.1 lists it.
The target model `fusion.plating.quality.check` lives in
`fusion_plating_bridge_mrp` and we deliberately don't depend on bridge_mrp
from the new jobs module (avoids tying our future to bridge's lifecycle).
Phase 2 Task 2.7 originally meant to address this; I kept it deferred.
The QC auto-create still works via runtime model detection (best-effort).
4. **Migration context flag.** I added an `fp_jobs_migration` context check to
`fp.job.action_confirm` and `button_mark_done` so the migration script can
skip lifecycle side-effects. Without this, the script would double-create
portal jobs / racking inspections / certs / notifications.
5. **`_sql_constraints``models.Constraint`.** Discovered during Task 2.3
that Odoo 19 deprecates `_sql_constraints` in favor of
`_unique_field = models.Constraint(...)`. Used the new form on
`fp.job.node.override` and any other models I added. Phase 1's
`_sql_constraints` on `fp.work.centre` still works but emits a warning;
it's on the polish list.
6. **Bridge_mrp left untouched as a constraint.** Even when the constraint
was awkward (e.g. when both modules' SO confirm hooks would run with
flag=True). Documented as a Phase 9 cutover task to either gate
bridge_mrp's hook on the inverse flag, or uninstall its action_confirm
override entirely.
---
## Files I touched / didn't touch
### Created (all in `fusion_plating/fusion_plating_jobs/`):
- `__init__.py`, `__manifest__.py`, `README.md`
- `models/__init__.py`, `models/fp_job.py`, `models/fp_job_node_override.py`,
`models/sale_order.py`, `models/res_config_settings.py`,
`models/account_move.py`, `models/fp_portal_job.py`, `models/fp_batch.py`,
`models/fp_quality_hold.py`, `models/fp_certificate.py`,
`models/fp_thickness_reading.py`, `models/fp_delivery.py`,
`models/fp_racking_inspection.py`, `models/fp_notification_trigger.py`,
`models/fusion_plating_kpi_value.py`
- `views/res_config_settings_views.xml`
- `report/__init__.py`, `report/report_fp_job_sticker.xml`,
`report/report_fp_job_traveller.xml`
- `controllers/__init__.py`, `controllers/job_scan.py`,
`controllers/process_tree.py`
- `scripts/__init__.py`, `scripts/README.md`,
`scripts/audit_pre_migration.py`, `scripts/migrate_to_fp_jobs.py`,
`scripts/audit_post_migration.py`
- `security/ir.model.access.csv`
- `tests/__init__.py`, `tests/test_fp_job_extensions.py`
### Created in `docs/superpowers/specs/`:
- `2026-04-25-fp-native-job-cutover-runbook.md`
- `2026-04-25-overnight-progress-summary.md` (this file)
### Modified:
- `docs/superpowers/specs/2026-04-25-fp-native-job-model-design.md` (during
earlier Phase 1 work; locked decisions section)
- `docs/superpowers/plans/2026-04-25-fp-native-job-model.md` (during earlier
Phase 1 + Phase 2 task breakdown; ACL convention fix; spec field deferral
documentation)
### Did NOT touch (per constraints):
- `fusion_plating/fusion_plating/` (Phase 1 core — locked)
- `fusion_plating/fusion_plating_bridge_mrp/` (legacy MRP bridge — must keep
working for entech operators)
- `fusion_plating/fusion_plating_configurator/`,
`fusion_plating_portal/`, `fusion_plating_logistics/`,
`fusion_plating_quality/`, `fusion_plating_certificates/`,
`fusion_plating_batch/`, `fusion_plating_receiving/`,
`fusion_plating_kpi/`, `fusion_plating_notifications/`,
`fusion_plating_reports/`, `fusion_plating_shopfloor/` — original modules
- Anything else in the monorepo
---
## Recommended morning checklist
1. **Re-auth Tailscale** (the URL was in earlier subagent output if needed; or `tailscale up`)
2. **Pull the branch on Mac:**
```bash
cd /Users/gurpreet/Github/Odoo-Modules
git fetch origin
git status # should show clean tree on feat/fp-native-job-model
```
3. **Sync the branch state to entech:**
```bash
# The branch is already pushed to GitHub. To get it on entech:
ssh pve-worker5 "pct exec 111 -- bash -c 'cd /mnt/extra-addons/custom && git fetch origin feat/fp-native-job-model && git checkout feat/fp-native-job-model && git pull'"
# If entech doesn't have a git checkout, sync via base64+pct exec for the new files
# in fusion_plating_jobs/
```
4. **Run the full test suite on entech:**
```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 --update=base -u fusion_plating_jobs --test-tags fusion_plating,fusion_plating_jobs --stop-after-init\" 2>&1 | tail -40 && systemctl start odoo'"
```
Expected: **all tests pass.** If anything fails, paste the error and I'll fix.
5. **Smoke test the new flow manually** (browser):
- Log in as a manager.
- **Settings → Fusion Plating Jobs → Use Native Plating Jobs** flag — DON'T turn on yet.
- Open **Plating Jobs (new)** menu.
- Create a Work Centre, then a Job, then add Steps. Confirm. Mark a step
started, then finished.
- Print the Job Sticker. Verify QR.
- Print the Job Traveller.
6. **Read the cutover runbook:**
`docs/superpowers/specs/2026-04-25-fp-native-job-cutover-runbook.md`
7. **When ready,** schedule a Phase 8 test (entech-clone) with at least 1
week notice. Then Phase 9 cutover with at least 4 weeks notice.
---
## Honest assessment
The code is consistent with the architecture decisions in the spec. The
parallel-coexistence strategy means even if I have a bug in the migration
script, **bridge_mrp keeps working** and the production system isn't
affected.
What I'd worry about most:
- **Migration script field-name accuracy.** I made best-effort guesses about
the `x_fc_*` field names on bridge_mrp's `mrp.production` and
`mrp.workorder`. If those names are different from what I assumed, the
migration silently skips fields. A pre-migration audit run on
entech-clone will surface this.
- **Lifecycle hook coverage during migration.** The `fp_jobs_migration`
context flag I added bypasses portal/QC/cert/inspection creation. If
there's another hook I missed (e.g. a `create()` override), it will fire
during migration and may double-create. The audit_post_migration script
will catch counts that don't match.
- **Phase 3 racking inspection auto-create.** Currently degrades silently
when there's no MO. After cutover with the flag flipped, jobs won't have
MOs, so racking inspection won't auto-create. Need to either modify
`fp.racking.inspection.production_id` to be optional, or add a
`x_fc_job_id`-keyed create path.
What I'm confident in:
- Phase 1 is rock solid. 28 tests pass. Models are clean. Code reviewed.
- Phase 2 is rock solid. 22 more tests pass. Reviewed.
- Phase 3-5 are likely correct (defensive `_fields` checks throughout) but
unverified on entech.
---
Sleep well. Branch is safe. Production is safe. 14 commits ahead of where
you went to bed, all atomic and reversible if needed.
— Claude

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.8.0.0',
'version': '19.0.8.7.1',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -82,6 +82,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'security/fp_security.xml',
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'data/fp_job_sequences.xml',
'data/fp_process_category_data.xml',
'views/fp_process_type_views.xml',
'views/fp_work_center_views.xml',
@@ -95,6 +96,10 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_operator_certification_views.xml',
'views/res_config_settings_views.xml',
'views/fp_menu.xml',
'views/fp_work_centre_views.xml',
'views/fp_job_views.xml',
'views/fp_job_step_views.xml',
'views/fp_jobs_menu.xml',
'data/fp_recipe_enp_alum_basic.xml',
'data/fp_recipe_enp_steel_basic.xml',
'data/fp_recipe_enp_sp.xml',

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- noupdate="1" is REQUIRED — without it, every -u fusion_plating
resets number_next back to 1, which corrupts the live sequence
on every module update. Matches the convention in fp_sequence_data.xml. -->
<odoo noupdate="1">
<!-- Sequence for fp.job. Format: WH/JOB/00001 onwards.
Migrated mrp.production records keep their WH/MO/... names. -->
<record id="seq_fp_job" model="ir.sequence">
<field name="name">Plating Job Sequence</field>
<field name="code">fp.job</field>
<field name="prefix">WH/JOB/</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -7,6 +7,7 @@ from . import fp_process_category
from . import fp_process_type
from . import fp_facility
from . import fp_work_center
from . import fp_work_centre
from . import fp_tank
from . import fp_bath
from . import fp_bath_log
@@ -15,6 +16,9 @@ from . import fp_bath_parameter
from . import fp_bath_replenishment_rule
from . import fp_process_node
from . import fp_rack
from . import fp_job
from . import fp_job_step
from . import fp_job_step_timelog
from . import fp_operator_certification
from . import fp_tz
from . import res_company

View File

@@ -0,0 +1,264 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job — native plating job model.
#
# Replaces mrp.production for plating. One record per shop-floor job.
# Header data lives here; per-operation detail on fp.job.step (Task 1.5).
# Recipe template (fusion.plating.process.node) is unchanged — this
# model just instantiates from it via fp.job.step.recipe_node_id.
#
# State machine:
# draft -> confirmed -> in_progress -> done
# | ^
# v |
# cancelled (rework reverts here)
# on_hold can be entered from confirmed or in_progress.
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpJob(models.Model):
_name = 'fp.job'
_description = 'Plating Job'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'priority desc, date_deadline asc, id desc'
_rec_name = 'name'
name = fields.Char(
required=True,
copy=False,
readonly=True,
default=lambda self: _('New'),
index=True,
)
state = fields.Selection(
[
('draft', 'Draft'),
('confirmed', 'Confirmed'),
('in_progress', 'In Progress'),
('on_hold', 'On Hold'),
('done', 'Done'),
('cancelled', 'Cancelled'),
],
default='draft',
required=True,
tracking=True,
index=True,
)
priority = fields.Selection(
[
('low', 'Low'),
('normal', 'Normal'),
('high', 'High'),
('rush', 'Rush'),
],
default='normal',
tracking=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Customer',
required=True,
tracking=True,
)
product_id = fields.Many2one('product.product', string='Reference Product')
qty = fields.Float(string='Quantity', required=True, default=1.0)
qty_done = fields.Float(string='Quantity Completed')
qty_scrapped = fields.Float(string='Quantity Scrapped')
date_deadline = fields.Datetime(string='Deadline', tracking=True)
date_planned_start = fields.Datetime(string='Planned Start')
date_started = fields.Datetime(string='Actual Start', readonly=True)
date_finished = fields.Datetime(string='Actual Finish', readonly=True)
origin = fields.Char(string='Source SO', help='Sale Order name for traceability.')
sale_order_id = fields.Many2one('sale.order', string='Sale Order')
facility_id = fields.Many2one('fusion.plating.facility', string='Facility')
manager_id = fields.Many2one('res.users', string='Plating Manager')
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
required=True,
)
# ------------------------------------------------------------------
# Source / recipe / invoicing — core-safe (target models reachable
# via current depends: sale_management → sale → account, and our
# own fusion.plating.process.node).
#
# Plating-specific extensions (part_catalog_id, coating_config_id,
# customer_spec_id, portal_job_id, delivery_id, qc_check_id) are
# deferred to their owning modules via _inherit = 'fp.job' to avoid
# inverting the dependency graph. See spec §5.1.
# ------------------------------------------------------------------
sale_order_line_ids = fields.Many2many(
'sale.order.line',
'fp_job_sale_order_line_rel',
'job_id', 'line_id',
string='Source SO Lines',
)
recipe_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe',
domain=[('node_type', '=', 'recipe')],
)
start_at_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Start at Node',
help='Rework: start the job at this recipe node (skip earlier).',
)
invoice_ids = fields.Many2many(
'account.move',
'fp_job_account_move_rel',
'job_id', 'move_id',
string='Invoices',
)
# ------------------------------------------------------------------
# Cost rollup — actual_cost stays at 0 until Task 1.5 wires step
# time × work_centre.cost_per_hour. quoted_revenue is a manual entry
# for now (will be filled by the SO → job hook in Phase 2).
# ------------------------------------------------------------------
currency_id = fields.Many2one(
'res.currency',
required=True,
default=lambda self: self.env.company.currency_id,
)
quoted_revenue = fields.Monetary(
currency_field='currency_id',
help='From source SO.',
)
actual_cost = fields.Monetary(
currency_field='currency_id',
compute='_compute_costs', store=True,
)
margin = fields.Monetary(
currency_field='currency_id',
compute='_compute_costs', store=True,
)
margin_pct = fields.Float(
compute='_compute_costs', store=True,
)
@api.depends('quoted_revenue')
def _compute_costs(self):
"""Cost rollup for the job header.
TODO(Task 1.5): when fp.job.step lands, expand @api.depends to
include 'step_ids.cost_total' so actual_cost rolls up
step time × work_centre.cost_per_hour automatically.
"""
for job in self:
job.actual_cost = 0.0
job.margin = job.quoted_revenue - job.actual_cost
job.margin_pct = (
(job.margin / job.quoted_revenue * 100.0)
if job.quoted_revenue else 0.0
)
# ------------------------------------------------------------------
# current_location — operator-readable status string. Stub here;
# full "Queued: Bath 3" / "In progress: Oven A" rendering needs
# fp.job.step + fp.work.centre, which lands in Tasks 1.5/1.6.
# ------------------------------------------------------------------
current_location = fields.Char(
compute='_compute_current_location',
help='Human-readable: "Queued: Bath 3" / "In progress: Oven A" / "Ready to ship".',
)
def _compute_current_location(self):
for job in self:
if job.state == 'draft':
job.current_location = 'Not started'
elif job.state == 'cancelled':
job.current_location = 'Cancelled'
elif job.state == 'done':
job.current_location = 'Done'
else:
job.current_location = job.state.replace('_', ' ').title()
# ------------------------------------------------------------------
# Steps — One2many to fp.job.step (Task 1.5)
# ------------------------------------------------------------------
step_ids = fields.One2many(
'fp.job.step',
'job_id',
string='Steps',
)
# step_count + step_done_count are stored (drive list views / stat
# buttons in Task 1.8). step_progress_pct stays non-stored — it's a
# cheap derivative. Odoo flags as inconsistent when stored and
# non-stored fields share a compute method, so they get distinct
# methods below.
step_count = fields.Integer(compute='_compute_step_counts', store=True)
step_done_count = fields.Integer(compute='_compute_step_counts', store=True)
step_progress_pct = fields.Float(compute='_compute_step_progress_pct')
current_step_id = fields.Many2one(
'fp.job.step',
compute='_compute_current_step',
)
@api.depends('step_ids', 'step_ids.state')
def _compute_step_counts(self):
for job in self:
job.step_count = len(job.step_ids)
job.step_done_count = len(job.step_ids.filtered(lambda s: s.state == 'done'))
@api.depends('step_count', 'step_done_count')
def _compute_step_progress_pct(self):
for job in self:
job.step_progress_pct = (
(job.step_done_count / job.step_count * 100.0)
if job.step_count else 0.0
)
@api.depends('step_ids.state', 'step_ids.sequence')
def _compute_current_step(self):
for job in self:
in_prog = job.step_ids.filtered(lambda s: s.state == 'in_progress')
if in_prog:
job.current_step_id = in_prog.sorted('sequence')[:1]
continue
ready = job.step_ids.filtered(lambda s: s.state == 'ready')
if ready:
job.current_step_id = ready.sorted('sequence')[:1]
continue
job.current_step_id = False
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('fp.job') or _('New')
return super().create(vals_list)
# ------------------------------------------------------------------
# State machine — actions
# ------------------------------------------------------------------
# TODO(fp.job state-machine completeness): action_hold, action_resume,
# action_revert_to_confirmed (rework path) — to be added when shopfloor
# / rework workflows are wired up. For now, draft → confirmed and the
# cancel paths are the only enforced transitions; everything else is
# an explicit `state` write by privileged code.
def action_confirm(self):
for job in self:
if job.state != 'draft':
raise UserError(_(
"Job %s is in state '%s' - only draft jobs can be confirmed."
) % (job.name, job.state))
job.state = 'confirmed'
return True
def action_cancel(self):
for job in self:
if job.state == 'done':
raise UserError(_(
"Job %s is done — cannot cancel."
) % job.name)
if job.state == 'cancelled':
raise UserError(_(
"Job %s is already cancelled."
) % job.name)
job.state = 'cancelled'
return True

View File

@@ -0,0 +1,234 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job.step — one operation within a plating job.
#
# Replaces mrp.workorder. Each step instantiates from a recipe
# operation node (recipe_node_id). Container nodes (recipe,
# sub_process) and step nodes (instructions) are NOT rows here —
# they live on the recipe template and are used at view-render time
# to display hierarchy. See spec §5.2 (Option A — operations only).
#
# State machine:
# pending → ready → in_progress → done
# ↓ ↓ ↑
# skipped paused
# ↓
# cancelled
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpJobStep(models.Model):
_name = 'fp.job.step'
_description = 'Plating Job Step'
_inherit = ['mail.thread']
_order = 'job_id, sequence, id'
job_id = fields.Many2one(
'fp.job',
required=True,
ondelete='cascade',
index=True,
)
name = fields.Char(required=True)
sequence = fields.Integer(default=10)
state = fields.Selection(
[
('pending', 'Pending'),
('ready', 'Ready'),
('in_progress', 'In Progress'),
('paused', 'Paused'),
('done', 'Done'),
('skipped', 'Skipped'),
('cancelled', 'Cancelled'),
],
default='pending',
required=True,
tracking=True,
index=True,
)
recipe_node_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe Operation',
domain=[('node_type', '=', 'operation')],
)
work_centre_id = fields.Many2one('fp.work.centre', index=True)
kind = fields.Selection(
[
('wet', 'Wet'),
('bake', 'Bake'),
('mask', 'Mask'),
('rack', 'Rack'),
('inspect', 'Inspect'),
('other', 'Other'),
],
default='other',
)
assigned_user_id = fields.Many2one('res.users', tracking=True)
started_by_user_id = fields.Many2one('res.users', readonly=True)
finished_by_user_id = fields.Many2one('res.users', readonly=True)
date_started = fields.Datetime(readonly=True)
date_finished = fields.Datetime(readonly=True)
duration_expected = fields.Float(string='Expected Minutes')
duration_actual = fields.Float(string='Actual Minutes', readonly=True)
instructions = fields.Html(string='Step Instructions')
time_log_ids = fields.One2many(
'fp.job.step.timelog',
'step_id',
string='Time Logs',
)
# ------------------------------------------------------------------
# Equipment + audit (Task 1.6)
# oven_id is deferred to a bridge module — fusion.plating.bake.oven
# lives in fusion_plating_shopfloor and core can't depend on it.
# masking_material_id is deferred — fusion.plating.masking.material
# does not yet exist in any installed module; will be added when
# the masking model lands (likely in fusion_plating_process_en
# or a future fusion_plating_masking module).
# ------------------------------------------------------------------
bath_id = fields.Many2one('fusion.plating.bath')
tank_id = fields.Many2one('fusion.plating.tank')
rack_id = fields.Many2one('fusion.plating.rack')
signoff_user_id = fields.Many2one('res.users', readonly=True)
facility_id = fields.Many2one(
'fusion.plating.facility',
related='work_centre_id.facility_id',
store=True,
)
# ------------------------------------------------------------------
# Plating spec (Task 1.6)
# ------------------------------------------------------------------
thickness_target = fields.Float(string='Target Thickness')
thickness_uom = fields.Selection(
[('um', 'µm'), ('mil', 'mil'), ('inch', 'in')],
default='um',
)
dwell_time_minutes = fields.Float()
bake_setpoint_temp = fields.Float(string='Bake Setpoint °C')
bake_actual_duration = fields.Float(string='Bake Actual Minutes')
bake_chart_recorder_ref = fields.Char(string='Bake Chart Recorder Ref')
# ------------------------------------------------------------------
# Recipe-related (Task 1.6)
# ------------------------------------------------------------------
requires_signoff = fields.Boolean(
related='recipe_node_id.requires_signoff',
store=True,
)
auto_complete = fields.Boolean(
related='recipe_node_id.auto_complete',
store=True,
)
is_manual = fields.Boolean(
related='recipe_node_id.is_manual',
store=True,
)
customer_visible = fields.Boolean(
related='recipe_node_id.customer_visible',
store=True,
)
# ------------------------------------------------------------------
# Cost rollup (Task 1.6)
# cost_per_hour comes from fp.work.centre (Task 1.2 added it there).
# cost_total recomputes when duration_actual or rate changes.
# duration_actual is set by button_finish as the sum of timelog
# row durations (see fp.job.step.timelog).
# ------------------------------------------------------------------
cost_per_hour = fields.Monetary(
related='work_centre_id.cost_per_hour',
currency_field='currency_id',
)
cost_total = fields.Monetary(
compute='_compute_cost_total',
store=True,
currency_field='currency_id',
)
currency_id = fields.Many2one(
'res.currency',
related='work_centre_id.currency_id',
)
@api.depends('duration_actual', 'cost_per_hour')
def _compute_cost_total(self):
for step in self:
step.cost_total = (step.duration_actual / 60.0) * step.cost_per_hour
# ------------------------------------------------------------------
# State machine — actions
# ------------------------------------------------------------------
# Implemented: button_start (ready/paused → in_progress),
# button_finish (in_progress → done).
# Stubs (raise NotImplementedError; wiring deferred):
# button_pause (in_progress → paused)
# button_resume (covered by button_start when state='paused')
# button_skip (pending/ready → skipped)
# button_cancel (any non-done → cancelled)
# Predecessor-driven transition pending → ready will be wired
# alongside first-step / dependency logic in a future task.
# ------------------------------------------------------------------
def button_pause(self):
raise NotImplementedError(_(
"button_pause is not yet implemented (operator pause / break / "
"end-of-shift). Use button_finish to complete a step or set "
"state directly via privileged code."
))
def button_skip(self):
raise NotImplementedError(_(
"button_skip is not yet implemented (skip an opt-in step that "
"wasn't activated for this job)."
))
def button_cancel(self):
raise NotImplementedError(_(
"button_cancel is not yet implemented (cancelling a single step; "
"cancelling the whole job runs through fp.job.action_cancel)."
))
def button_start(self):
for step in self:
if step.state not in ('ready', 'paused'):
raise UserError(_(
"Step '%s' is in state '%s' — only ready/paused steps can start."
) % (step.name, step.state))
now = fields.Datetime.now()
step.state = 'in_progress'
# First-start audit (mirrors button_finish first-finish guard)
if not step.date_started:
step.date_started = now
step.started_by_user_id = self.env.user
# Open a fresh timelog row for this start interval — uses the
# same `now` as the first-start stamp so the step and its
# first log share a single instant.
self.env['fp.job.step.timelog'].create({
'step_id': step.id,
'user_id': self.env.user.id,
'date_started': now,
})
return True
def button_finish(self):
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can finish."
) % (step.name, step.state))
now = fields.Datetime.now()
# Close the open timelog (the one with no date_finished)
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
open_log.write({'date_finished': now})
step.state = 'done'
# First-finish audit (mirrors button_start first-start guard)
if not step.date_finished:
step.date_finished = now
step.finished_by_user_id = self.env.user
# Sum of all interval durations becomes duration_actual
step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes'))
return True

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job.step.timelog — granular start/stop intervals for a step.
#
# Each step.button_start() opens a fresh timelog row. Each
# step.button_finish() (or button_pause once added) closes the open
# row. duration_actual on fp.job.step is the sum of these intervals.
#
# Replicates Odoo MRP's mrp.workorder.time_ids granularity natively
# (without depending on the mrp module).
from odoo import api, fields, models
class FpJobStepTimeLog(models.Model):
_name = 'fp.job.step.timelog'
_description = 'Plating Job Step Time Log'
_order = 'date_started desc'
step_id = fields.Many2one(
'fp.job.step',
required=True,
ondelete='cascade',
index=True,
)
user_id = fields.Many2one('res.users', required=True)
date_started = fields.Datetime(required=True)
date_finished = fields.Datetime()
duration_minutes = fields.Float(
compute='_compute_duration', store=True,
)
@api.depends('date_started', 'date_finished')
def _compute_duration(self):
for log in self:
if log.date_started and log.date_finished:
delta = log.date_finished - log.date_started
log.duration_minutes = delta.total_seconds() / 60.0
else:
log.duration_minutes = 0.0

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.work.centre — native plating work-centre model.
#
# Replaces mrp.workcenter for the plating flow. Plating work centres
# are domain-specific (a tank line, a bake oven, a rack station — not
# assembly cells). Each centre has a 'kind' that drives release-ready
# validation on fp.job.step (e.g. wet_line -> bath+tank required).
from odoo import fields, models
class FpWorkCentre(models.Model):
_name = 'fp.work.centre'
_description = 'Plating Work Centre'
_order = 'sequence, code, name'
name = fields.Char(required=True)
code = fields.Char(required=True, help='Short code used on stickers and reports.')
sequence = fields.Integer(default=10)
facility_id = fields.Many2one(
'fusion.plating.facility',
string='Facility',
)
kind = fields.Selection(
[
('wet_line', 'Wet Line'),
('bake', 'Bake Oven'),
('mask', 'Masking'),
('rack', 'Racking'),
('inspect', 'Inspection'),
('other', 'Other'),
],
required=True,
default='other',
)
cost_per_hour = fields.Monetary(
currency_field='currency_id',
help='Used for fp.job.step cost rollups.',
)
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id,
)
default_bath_id = fields.Many2one('fusion.plating.bath')
default_tank_id = fields.Many2one('fusion.plating.tank')
# NOTE: `default_oven_id` from the spec/plan is omitted here — the
# `fusion.plating.bake.oven` model lives in fusion_plating_shopfloor,
# which the core module cannot depend on. The bridge module that
# introduces fp.job/fp.job.step (Task 1.x) can re-introduce this
# field via _inherit if/when the bake-oven coupling is needed.
active = fields.Boolean(default=True)
_sql_constraints = [
('unique_code', 'UNIQUE(code)', 'Work centre code must be unique.'),
]

View File

@@ -44,3 +44,15 @@ access_fp_replenishment_suggestion_manager,fp.replenishment.suggestion.manager,m
access_fp_operator_cert_operator,fp.operator.cert.operator,model_fp_operator_certification,group_fusion_plating_operator,1,0,0,0
access_fp_operator_cert_supervisor,fp.operator.cert.supervisor,model_fp_operator_certification,group_fusion_plating_supervisor,1,1,1,0
access_fp_operator_cert_manager,fp.operator.cert.manager,model_fp_operator_certification,group_fusion_plating_manager,1,1,1,1
access_fp_work_centre_operator,fp.work.centre.operator,model_fp_work_centre,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_work_centre_supervisor,fp.work.centre.supervisor,model_fp_work_centre,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_work_centre_manager,fp.work.centre.manager,model_fp_work_centre,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_operator,fp.job.operator,model_fp_job,fusion_plating.group_fusion_plating_operator,1,1,0,0
access_fp_job_supervisor,fp.job.supervisor,model_fp_job,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_manager,fp.job.manager,model_fp_job,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_step_operator,fp.job.step.operator,model_fp_job_step,fusion_plating.group_fusion_plating_operator,1,1,0,0
access_fp_job_step_supervisor,fp.job.step.supervisor,model_fp_job_step,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_step_manager,fp.job.step.manager,model_fp_job_step,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_step_timelog_operator,fp.job.step.timelog.operator,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_job_step_timelog_supervisor,fp.job.step.timelog.supervisor,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_step_timelog_manager,fp.job.step.timelog.manager,model_fp_job_step_timelog,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
44 access_fp_operator_cert_operator fp.operator.cert.operator model_fp_operator_certification group_fusion_plating_operator 1 0 0 0
45 access_fp_operator_cert_supervisor fp.operator.cert.supervisor model_fp_operator_certification group_fusion_plating_supervisor 1 1 1 0
46 access_fp_operator_cert_manager fp.operator.cert.manager model_fp_operator_certification group_fusion_plating_manager 1 1 1 1
47 access_fp_work_centre_operator fp.work.centre.operator model_fp_work_centre fusion_plating.group_fusion_plating_operator 1 0 0 0
48 access_fp_work_centre_supervisor fp.work.centre.supervisor model_fp_work_centre fusion_plating.group_fusion_plating_supervisor 1 1 1 0
49 access_fp_work_centre_manager fp.work.centre.manager model_fp_work_centre fusion_plating.group_fusion_plating_manager 1 1 1 1
50 access_fp_job_operator fp.job.operator model_fp_job fusion_plating.group_fusion_plating_operator 1 1 0 0
51 access_fp_job_supervisor fp.job.supervisor model_fp_job fusion_plating.group_fusion_plating_supervisor 1 1 1 0
52 access_fp_job_manager fp.job.manager model_fp_job fusion_plating.group_fusion_plating_manager 1 1 1 1
53 access_fp_job_step_operator fp.job.step.operator model_fp_job_step fusion_plating.group_fusion_plating_operator 1 1 0 0
54 access_fp_job_step_supervisor fp.job.step.supervisor model_fp_job_step fusion_plating.group_fusion_plating_supervisor 1 1 1 0
55 access_fp_job_step_manager fp.job.step.manager model_fp_job_step fusion_plating.group_fusion_plating_manager 1 1 1 1
56 access_fp_job_step_timelog_operator fp.job.step.timelog.operator model_fp_job_step_timelog fusion_plating.group_fusion_plating_operator 1 1 1 0
57 access_fp_job_step_timelog_supervisor fp.job.step.timelog.supervisor model_fp_job_step_timelog fusion_plating.group_fusion_plating_supervisor 1 1 1 0
58 access_fp_job_step_timelog_manager fp.job.step.timelog.manager model_fp_job_step_timelog fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import test_fp_work_centre
from . import test_fp_job_state_machine
from . import test_fp_job_step_state_machine

View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError
class TestFpJobStateMachine(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Test Customer'})
self.product = self.env['product.product'].create({'name': 'Widget'})
def _make_job(self, **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 10.0,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
def test_create_lands_in_draft(self):
job = self._make_job()
self.assertEqual(job.state, 'draft')
self.assertTrue(job.name and job.name.startswith('WH/JOB/'))
def test_action_confirm_moves_to_confirmed(self):
job = self._make_job()
job.action_confirm()
self.assertEqual(job.state, 'confirmed')
def test_cannot_confirm_twice(self):
job = self._make_job()
job.action_confirm()
with self.assertRaises(UserError):
job.action_confirm()
def test_cancel_from_draft(self):
job = self._make_job()
job.action_cancel()
self.assertEqual(job.state, 'cancelled')
def test_cannot_confirm_after_cancel(self):
job = self._make_job()
job.action_cancel()
with self.assertRaises(UserError):
job.action_confirm()
def test_cannot_cancel_done(self):
# Done jobs cannot be cancelled — covers the UserError branch in
# action_cancel.
job = self._make_job()
job.action_confirm()
# Force the state to 'done' for the test (no public action yet —
# done is set by step-completion logic landing in Task 1.5+).
job.state = 'done'
with self.assertRaises(UserError):
job.action_cancel()
def test_cannot_cancel_already_cancelled(self):
# Idempotent re-cancel is now an explicit error.
job = self._make_job()
job.action_cancel()
with self.assertRaises(UserError):
job.action_cancel()
def test_current_location_for_draft(self):
job = self._make_job()
self.assertEqual(job.current_location, 'Not started')
def test_current_location_for_done(self):
job = self._make_job()
# Force state to 'done' (no public action yet)
job.state = 'done'
# Recompute — Odoo's compute is auto on read
self.assertEqual(job.current_location, 'Done')
def test_margin_zero_when_no_revenue(self):
job = self._make_job()
self.assertEqual(job.actual_cost, 0.0)
self.assertEqual(job.margin, 0.0)
self.assertEqual(job.margin_pct, 0.0)
def test_margin_with_revenue(self):
job = self._make_job(quoted_revenue=1000.0)
self.assertEqual(job.quoted_revenue, 1000.0)
self.assertEqual(job.actual_cost, 0.0)
self.assertEqual(job.margin, 1000.0)
self.assertEqual(job.margin_pct, 100.0)
def test_current_location_for_confirmed(self):
job = self._make_job()
job.action_confirm()
# Forces compute via field read; expect title-cased state
self.assertEqual(job.current_location, 'Confirmed')

View File

@@ -0,0 +1,159 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError
class TestFpJobStepStateMachine(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Cust'})
self.product = self.env['product.product'].create({'name': 'Widget'})
self.wc = self.env['fp.work.centre'].create({
'name': 'WC', 'code': 'WC', 'kind': 'wet_line',
})
self.job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
})
def _make_step(self, **kw):
vals = {
'job_id': self.job.id,
'name': 'Plating Bath',
'sequence': 10,
'work_centre_id': self.wc.id,
}
vals.update(kw)
return self.env['fp.job.step'].create(vals)
def test_step_starts_pending(self):
step = self._make_step()
self.assertEqual(step.state, 'pending')
def test_button_start_requires_ready_or_paused(self):
step = self._make_step()
# state is 'pending' — should raise
with self.assertRaises(UserError):
step.button_start()
def test_button_start_moves_ready_to_in_progress(self):
step = self._make_step()
step.state = 'ready'
step.button_start()
self.assertEqual(step.state, 'in_progress')
self.assertTrue(step.date_started)
self.assertEqual(step.started_by_user_id, self.env.user)
def test_button_finish_requires_in_progress(self):
step = self._make_step()
with self.assertRaises(UserError):
step.button_finish() # state is pending
def test_button_finish_moves_to_done(self):
step = self._make_step()
step.state = 'ready'
step.button_start()
step.button_finish()
self.assertEqual(step.state, 'done')
self.assertTrue(step.date_finished)
self.assertEqual(step.finished_by_user_id, self.env.user)
def test_job_step_counts_update(self):
# Add 3 steps; finish 1; verify computed counts on job header.
s1 = self._make_step(name='Step 1', sequence=10)
s2 = self._make_step(name='Step 2', sequence=20)
s3 = self._make_step(name='Step 3', sequence=30)
self.assertEqual(self.job.step_count, 3)
self.assertEqual(self.job.step_done_count, 0)
s1.state = 'done'
# Force recompute
self.job.invalidate_recordset(['step_done_count', 'step_progress_pct'])
self.assertEqual(self.job.step_done_count, 1)
self.assertAlmostEqual(self.job.step_progress_pct, 33.33, places=1)
def test_facility_id_related_from_work_centre(self):
# Work centre with a facility -> step inherits via related field.
facility = self.env['fusion.plating.facility'].create({
'name': 'Test Facility',
'code': 'TFAC',
})
wc = self.env['fp.work.centre'].create({
'name': 'WC2', 'code': 'WC2', 'kind': 'wet_line',
'facility_id': facility.id,
})
step = self._make_step(work_centre_id=wc.id)
self.assertEqual(step.facility_id, facility)
def test_thickness_uom_default(self):
step = self._make_step()
self.assertEqual(step.thickness_uom, 'um')
def test_cost_total_zero_when_no_duration(self):
step = self._make_step()
self.assertEqual(step.cost_total, 0.0)
def test_cost_total_with_duration_and_rate(self):
wc = self.env['fp.work.centre'].create({
'name': 'WC3', 'code': 'WC3', 'kind': 'wet_line',
'cost_per_hour': 60.0, # $1/min
})
step = self._make_step(work_centre_id=wc.id)
# Force duration_actual since we don't have timelogs in 1.6
step.duration_actual = 30.0
# Recompute happens on read after a write to a depends field
self.assertEqual(step.cost_total, 30.0)
def test_cost_total_recomputes_when_rate_changes(self):
# Insurance test: verify @api.depends('cost_per_hour') triggers
# through the related-from-work_centre chain. If a future Odoo
# upgrade breaks related-depends, this test catches it.
wc = self.env['fp.work.centre'].create({
'name': 'WC4', 'code': 'WC4', 'kind': 'wet_line',
'cost_per_hour': 60.0,
})
step = self._make_step(work_centre_id=wc.id)
step.duration_actual = 30.0
self.assertEqual(step.cost_total, 30.0)
# Change the rate; cost_total should recompute.
wc.cost_per_hour = 120.0
# Force recompute via invalidate (Odoo recomputes on next read).
step.invalidate_recordset(['cost_total'])
self.assertEqual(step.cost_total, 60.0)
class TestFpJobStepTimeLog(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Cust'})
self.product = self.env['product.product'].create({'name': 'Widget'})
self.wc = self.env['fp.work.centre'].create({
'name': 'WC', 'code': 'WC', 'kind': 'wet_line',
})
self.job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
})
self.step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'S',
'sequence': 10,
'work_centre_id': self.wc.id,
'state': 'ready',
})
def test_start_creates_timelog(self):
self.step.button_start()
self.assertEqual(len(self.step.time_log_ids), 1)
self.assertFalse(self.step.time_log_ids[0].date_finished)
self.assertEqual(self.step.time_log_ids[0].user_id, self.env.user)
def test_finish_closes_timelog(self):
self.step.button_start()
self.step.button_finish()
log = self.step.time_log_ids[0]
self.assertTrue(log.date_finished)
self.assertGreaterEqual(log.duration_minutes, 0.0)
# duration_actual on the step should match the sum of timelog durations
self.assertEqual(self.step.duration_actual, log.duration_minutes)

View File

@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestFpWorkCentre(TransactionCase):
def test_create_work_centre_minimal(self):
wc = self.env['fp.work.centre'].create({
'name': 'Bath Line 1',
'code': 'BL1',
'kind': 'wet_line',
})
self.assertEqual(wc.name, 'Bath Line 1')
self.assertEqual(wc.kind, 'wet_line')
self.assertTrue(wc.active)
def test_facility_optional_at_create(self):
# Facility is soft-required (warning at confirm, not constraint
# at create) — verify a centre without facility still creates.
wc = self.env['fp.work.centre'].create({
'name': 'Test',
'code': 'T',
'kind': 'other',
})
self.assertFalse(wc.facility_id)
def test_kind_selection_values(self):
kinds = dict(
self.env['fp.work.centre']._fields['kind'].selection
)
for k in ('wet_line', 'bake', 'mask', 'rack', 'inspect', 'other'):
self.assertIn(k, kinds)

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_job_step_list" model="ir.ui.view">
<field name="name">fp.job.step.list</field>
<field name="model">fp.job.step</field>
<field name="arch" type="xml">
<list decoration-info="state in ('ready', 'in_progress')"
decoration-success="state == 'done'"
decoration-muted="state in ('skipped', 'cancelled')">
<field name="job_id"/>
<field name="sequence"/>
<field name="name"/>
<field name="work_centre_id"/>
<field name="kind"/>
<field name="state"/>
<field name="assigned_user_id"/>
<field name="duration_actual"/>
</list>
</field>
</record>
<record id="view_fp_job_step_search" model="ir.ui.view">
<field name="name">fp.job.step.search</field>
<field name="model">fp.job.step</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="job_id"/>
<field name="work_centre_id"/>
<field name="assigned_user_id"/>
<separator/>
<filter name="state_pending" string="Pending" domain="[('state','=','pending')]"/>
<filter name="state_ready" string="Ready" domain="[('state','=','ready')]"/>
<filter name="state_in_progress" string="In Progress" domain="[('state','=','in_progress')]"/>
<filter name="state_done" string="Done" domain="[('state','=','done')]"/>
<separator/>
<filter name="kind_wet" string="Wet" domain="[('kind','=','wet')]"/>
<filter name="kind_bake" string="Bake" domain="[('kind','=','bake')]"/>
<filter name="kind_inspect" string="Inspect" domain="[('kind','=','inspect')]"/>
<group>
<filter name="group_state" string="Status" context="{'group_by': 'state'}"/>
<filter name="group_work_centre" string="Work Centre" context="{'group_by': 'work_centre_id'}"/>
<filter name="group_job" string="Job" context="{'group_by': 'job_id'}"/>
</group>
</search>
</field>
</record>
<record id="view_fp_job_step_form" model="ir.ui.view">
<field name="name">fp.job.step.form</field>
<field name="model">fp.job.step</field>
<field name="arch" type="xml">
<form>
<header>
<button name="button_start" type="object"
string="Start" class="btn-primary"
invisible="state not in ('ready', 'paused')"/>
<button name="button_finish" type="object"
string="Finish" class="btn-success"
invisible="state != 'in_progress'"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name"/></h1>
</div>
<group>
<group>
<field name="job_id"/>
<field name="sequence"/>
<field name="work_centre_id"/>
<field name="kind"/>
<field name="recipe_node_id"/>
<field name="assigned_user_id"/>
</group>
<group>
<field name="duration_expected"/>
<field name="duration_actual" readonly="1"/>
<field name="cost_per_hour"/>
<field name="cost_total"/>
<field name="currency_id" invisible="1"/>
</group>
</group>
<notebook>
<page string="Equipment" name="equipment">
<group>
<field name="bath_id"/>
<field name="tank_id"/>
<field name="rack_id"/>
</group>
</page>
<page string="Plating Spec" name="spec">
<group>
<field name="thickness_target"/>
<field name="thickness_uom"/>
<field name="dwell_time_minutes"/>
<field name="bake_setpoint_temp"/>
<field name="bake_actual_duration"/>
<field name="bake_chart_recorder_ref"/>
</group>
</page>
<page string="Audit" name="audit">
<group>
<field name="started_by_user_id" readonly="1"/>
<field name="date_started" readonly="1"/>
<field name="finished_by_user_id" readonly="1"/>
<field name="date_finished" readonly="1"/>
<field name="signoff_user_id" readonly="1"/>
</group>
<field name="time_log_ids" readonly="1">
<list create="false" edit="false" delete="false">
<field name="user_id"/>
<field name="date_started"/>
<field name="date_finished"/>
<field name="duration_minutes"/>
</list>
</field>
</page>
<page string="Instructions" name="instructions">
<field name="instructions" nolabel="1"/>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_fp_job_step" model="ir.actions.act_window">
<field name="name">Job Steps</field>
<field name="res_model">fp.job.step</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_job_step_search"/>
</record>
</odoo>

View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_job_list" model="ir.ui.view">
<field name="name">fp.job.list</field>
<field name="model">fp.job</field>
<field name="arch" type="xml">
<list decoration-info="state=='confirmed'"
decoration-success="state=='done'"
decoration-muted="state=='cancelled'">
<field name="name"/>
<field name="partner_id"/>
<field name="qty"/>
<field name="date_deadline"/>
<field name="state"/>
<field name="step_progress_pct" widget="progressbar"/>
<field name="current_location"/>
</list>
</field>
</record>
<record id="view_fp_job_form" model="ir.ui.view">
<field name="name">fp.job.form</field>
<field name="model">fp.job</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_confirm" type="object"
string="Confirm" class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_cancel" type="object"
string="Cancel"
invisible="state in ('done', 'cancelled')"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,confirmed,in_progress,done"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="partner_id"/>
<field name="product_id"/>
<field name="qty"/>
<field name="priority"/>
</group>
<group>
<field name="date_deadline"/>
<field name="date_planned_start"/>
<field name="date_started" readonly="1"/>
<field name="date_finished" readonly="1"/>
<field name="facility_id"/>
<field name="manager_id"/>
</group>
</group>
<notebook>
<page string="Steps" name="steps">
<field name="step_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="work_centre_id"/>
<field name="kind"/>
<field name="state"/>
<field name="assigned_user_id"/>
<field name="duration_expected"/>
<field name="duration_actual" readonly="1"/>
</list>
</field>
</page>
<page string="Source" name="source">
<group>
<field name="origin"/>
<field name="sale_order_id"/>
<field name="recipe_id"/>
<field name="start_at_node_id"/>
</group>
</page>
<page string="Costs" name="costs">
<group>
<field name="quoted_revenue"/>
<field name="actual_cost"/>
<field name="margin"/>
<field name="margin_pct"/>
<field name="currency_id" invisible="1"/>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_fp_job_search" model="ir.ui.view">
<field name="name">fp.job.search</field>
<field name="model">fp.job</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="partner_id"/>
<separator/>
<filter name="state_draft" string="Draft" domain="[('state','=','draft')]"/>
<filter name="state_confirmed" string="Confirmed" domain="[('state','=','confirmed')]"/>
<filter name="state_in_progress" string="In Progress" domain="[('state','=','in_progress')]"/>
<filter name="state_done" string="Done" domain="[('state','=','done')]"/>
<separator/>
<filter name="rush" string="Rush" domain="[('priority','=','rush')]"/>
<group>
<filter name="group_state" string="Status" context="{'group_by': 'state'}"/>
<filter name="group_partner" string="Customer" context="{'group_by': 'partner_id'}"/>
<filter name="group_facility" string="Facility" context="{'group_by': 'facility_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fp_job" model="ir.actions.act_window">
<field name="name">Plating Jobs</field>
<field name="res_model">fp.job</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_job_search"/>
</record>
</odoo>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Native job model — admin/manager menus.
"All Jobs" and "Steps" used to live under a separate "Jobs"
submenu but the user moved them under Shop Floor instead
(see fusion_plating_jobs/views/jobs_in_shopfloor_menu.xml).
Only Work Centres stays in core (under Configuration). -->
<menuitem id="menu_fp_jobs_work_centres"
name="Work Centres"
parent="menu_fp_config"
action="action_fp_work_centre"
sequence="55"
groups="fusion_plating.group_fusion_plating_manager"/>
</odoo>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_fp_work_centre_list" model="ir.ui.view">
<field name="name">fp.work.centre.list</field>
<field name="model">fp.work.centre</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="code"/>
<field name="name"/>
<field name="kind"/>
<field name="facility_id"/>
<field name="cost_per_hour"/>
<field name="active"/>
</list>
</field>
</record>
<record id="view_fp_work_centre_form" model="ir.ui.view">
<field name="name">fp.work.centre.form</field>
<field name="model">fp.work.centre</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="code"/>
<field name="name"/>
<field name="kind"/>
<field name="facility_id"/>
<field name="active"/>
</group>
<group>
<field name="cost_per_hour"/>
<field name="currency_id" invisible="1"/>
<field name="default_bath_id"/>
<field name="default_tank_id"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fp_work_centre" model="ir.actions.act_window">
<field name="name">Work Centres</field>
<field name="res_model">fp.work.centre</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -81,6 +81,13 @@ class SaleOrder(models.Model):
# ------------------------------------------------------------------
def action_confirm(self):
res = super().action_confirm()
# Cutover gate (2026-04-25): when the native job model is the
# primary, skip MO creation here — fusion_plating_jobs handles
# SO → fp.job. Both modules' SO-confirm hooks would otherwise
# run on every confirm and create duplicate work.
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True':
return res
for so in self:
try:
so._fp_auto_create_mo()

View File

@@ -0,0 +1,51 @@
# fusion_plating_jobs
Native plating job bridge — wires `fp.job` and `fp.job.step` (defined in
`fusion_plating` core, Phase 1 of the migration spec dated 2026-04-25)
into the rest of the Fusion Plating module family: configurator, portal,
logistics, quality, certificates, batches, KPI, notifications, reports.
Coexists with `fusion_plating_bridge_mrp` during the migration period.
The `x_fc_use_native_jobs` settings flag (default: `False`) toggles the
behaviour. When `False`, SO confirm continues to create `mrp.production`
records through `bridge_mrp`. When `True`, SO confirm creates `fp.job`
records here.
See `docs/superpowers/specs/2026-04-25-fp-native-job-model-design.md`
for full design rationale and §6 of the implementation plan for phase
breakdown.
## Phase 6 — deferred items
Phase 6 originally scoped the full operator UI rewrite. With Tailscale
SSH to entech currently unavailable we cannot live-test OWL/JS in the
browser, so Phase 6 ships a lean version: the data-layer endpoints land
now, the rendering UI lands later.
Deferred to post-cutover hardening:
- **Plant Overview kanban** over `fp.job.step` — replaces
`fusion_plating_shopfloor`'s `mrp.workorder` kanban.
- **Tablet Station UI** rewrite over `fp.job` / `fp.job.step`.
- **Manager Dashboard** rewrite.
- **Process Tree OWL component** — currently a stub:
`/fp/jobs/process_tree` returns the serialized recipe tree as JSON,
but the OWL component to render it is not built.
Rationale: these are large OWL/JS components that need live in-browser
verification on entech. Under the migration's parallel-coexistence
strategy, operators continue using the existing shopfloor UI (bound to
`mrp.workorder`) until cutover. After cutover, the operator UI rewrite
becomes its own focused project — the data layer (`fp.job`,
`fp.job.step`, time logs, timestamps) is fully in place from
Phase 15.
## Phase 6 — what shipped
- `/fp/job/<id>` — scan-redirect controller. The fp.job sticker QR
encodes this URL. Routes managers to the `fp.job` form; routes
operators to the same form for now (will swap to the process tree
client action once the OWL component lands).
- `/fp/jobs/process_tree` — JSON-RPC endpoint that returns the recipe
tree for a job, with each node tagged by its matching `fp.job.step`
state, ready for an OWL component to consume.

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import report
from . import controllers

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.5.1.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'price': 0.00,
'currency': 'CAD',
'description': """
Native Plating Job Bridge
=========================
Bridges fp.job and fp.job.step (defined in fusion_plating core, Phase 1 of
the migration spec dated 2026-04-25) to the rest of the Fusion Plating
module family — configurator, portal, logistics, quality, certificates.
Coexists with fusion_plating_bridge_mrp during the migration period.
Activate native jobs via the x_fc_use_native_jobs settings flag (default:
False). When False, SO confirm continues to create mrp.production records
through bridge_mrp. When True, SO confirm creates fp.job records here.
19.0.4.0.0 (2026-04-24): Operator UI consolidation. The parallel
OWL/controller stack (job_process_tree, job_plant_overview,
job_manager_dashboard, job_tablet) was removed. The canonical
operator-facing UIs now live in fusion_plating_shopfloor and bind
directly to fp.job / fp.job.step. This module retains lifecycle hooks,
SO → fp.job creation, reports, and the QR-scan redirect.
See docs/superpowers/specs/2026-04-25-fp-native-job-model-design.md for
full design rationale and §6.2 of the implementation plan for task list.
""",
'depends': [
'fusion_plating', # fp.job, fp.job.step, fp.work.centre
'fusion_plating_batch', # fusion.plating.batch (Phase 3)
'fusion_plating_certificates', # fp.certificate, fp.thickness.reading
'fusion_plating_configurator', # fp.part.catalog, fp.coating.config
'fusion_plating_kpi', # fusion.plating.kpi.value (Phase 4)
'fusion_plating_logistics', # fusion.plating.delivery
'fusion_plating_notifications', # fp.notification.template (Phase 4)
'fusion_plating_portal', # fusion.plating.portal.job
'fusion_plating_quality', # fusion.plating.customer.spec, fusion.plating.quality.hold
'fusion_plating_receiving', # fp.racking.inspection (Phase 3)
'fusion_plating_reports', # paperformat helpers, customer_line_header (Phase 5)
'fusion_plating_shopfloor', # canonical operator UI consoles
],
'data': [
'security/legacy_groups.xml',
'security/ir.model.access.csv',
'views/res_config_settings_views.xml',
'views/fp_job_form_inherit.xml',
'views/jobs_in_shopfloor_menu.xml',
'views/legacy_menu_hide.xml',
'report/report_fp_job_sticker.xml',
'report/report_fp_job_traveller.xml',
'report/report_fp_job_margin.xml',
],
'assets': {
# No bundled JS/SCSS — the canonical operator UIs live in
# fusion_plating_shopfloor (consolidated 2026-04-24).
},
'installable': True,
'application': False,
'auto_install': False,
'license': 'OPL-1',
}

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Consolidated 2026-04-24: the parallel OWL/controller stack was
# removed. job_scan is the only controller retained — it powers the
# QR-sticker scan redirect for fp.job records.
from . import job_scan

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# /fp/job/<id> — scan-redirect endpoint for native fp.job stickers.
#
# The fp.job sticker (Phase 5) embeds a QR encoding this URL. When a
# warehouse user scans it, this controller redirects them to either
# the fp.job form (for managers) or the upcoming process-tree client
# action (for operators — Phase 6 expansion).
from odoo import http
from odoo.http import request
class FpJobScanController(http.Controller):
@http.route('/fp/job/<int:job_id>', type='http', auth='user', website=False)
def fp_job_scan(self, job_id, **kwargs):
Job = request.env['fp.job'].sudo()
job = Job.browse(job_id).exists()
if not job:
return request.redirect('/odoo/plating-jobs')
# If user is a plating manager → land on the form.
# Otherwise (operator) → land on process tree client action
# (will be wired once process tree is added).
user = request.env.user
is_manager = user.has_group('fusion_plating.group_fusion_plating_manager')
if is_manager:
return request.redirect(
'/odoo/action-fusion_plating.action_fp_job/%d' % job.id
)
# Operator path: same form for now (process tree action will replace
# this once it's registered).
return request.redirect(
'/odoo/action-fusion_plating.action_fp_job/%d' % job.id
)

View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 2 of the native plating job model migration. Models are added
# task-by-task in Tasks 2.2 onwards.
from . import fp_job
from . import fp_job_step
from . import fp_job_node_override
from . import fp_portal_job
from . import account_move
from . import res_config_settings
from . import sale_order
# Phase 3 — parallel job/step links on dependent modules' models.
from . import fp_batch
from . import fp_quality_hold
from . import fp_certificate
from . import fp_thickness_reading
from . import fp_delivery
from . import fp_racking_inspection
# Phase 4 — light refactors batch B (notifications, KPI source tag).
from . import fp_notification_trigger
from . import fusion_plating_kpi_value
# Phase 5 — Job Margin report.
from . import report_fp_job_margin

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# When an invoice is posted, find the linked fp.job (via origin) and
# update the portal job state to 'complete' + stamp invoice_ref.
import logging
from odoo import models
_logger = logging.getLogger(__name__)
class AccountMove(models.Model):
_inherit = 'account.move'
def action_post(self):
result = super().action_post()
for invoice in self.filtered(
lambda m: m.move_type in ('out_invoice', 'out_refund')
):
invoice._fp_link_to_job()
return result
def _fp_link_to_job(self):
self.ensure_one()
if not self.invoice_origin:
return
Job = self.env['fp.job'].sudo()
# Walk SO -> fp.job
SO = self.env['sale.order'].sudo()
so = SO.search([('name', '=', self.invoice_origin)], limit=1)
if not so:
return
job = Job.search([('sale_order_id', '=', so.id)], limit=1)
if not job or not job.portal_job_id:
return
portal = job.portal_job_id
if 'state' in portal._fields:
portal.state = 'complete'
if 'invoice_ref' in portal._fields:
portal.invoice_ref = self.name
_logger.info(
'Invoice %s linked to fp.job %s portal %s',
self.name, job.name, portal.name,
)

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 — parallel job/step links on fusion.plating.batch.
# The legacy workorder_id link to mrp.workorder stays in place.
from odoo import fields, models
class FusionPlatingBatch(models.Model):
_inherit = 'fusion.plating.batch'
x_fc_step_id = fields.Many2one(
'fp.job.step',
string='Plating Step',
index=True,
help='Native fp.job.step link. Coexists with the legacy '
'workorder_id link to mrp.workorder.',
)
x_fc_job_id = fields.Many2one(
'fp.job',
related='x_fc_step_id.job_id',
store=True,
string='Plating Job',
)

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 — parallel job link on fp.certificate.
# Coexists with bridge_mrp's production_id link.
from odoo import fields, models
class FpCertificate(models.Model):
_inherit = 'fp.certificate'
x_fc_job_id = fields.Many2one(
'fp.job',
string='Plating Job',
index=True,
help="Native fp.job link. Coexists with bridge_mrp's production_id.",
)

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 — parallel job link on fusion.plating.delivery.
# Coexists with the legacy job_ref Char.
from odoo import fields, models
class FusionPlatingDelivery(models.Model):
_inherit = 'fusion.plating.delivery'
x_fc_job_id = fields.Many2one(
'fp.job',
string='Plating Job',
index=True,
help='Native fp.job link. Coexists with the legacy job_ref Char.',
)

View File

@@ -0,0 +1,768 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job extension — cross-module fields that couldn't live in core
# because their target models are in dependent modules. Per spec §5.1
# this module is the umbrella that re-bundles the cross-module
# extensions for the native job flow.
#
# qc_check_id is deferred to Task 2.7 (the underlying QC model still
# lives in fusion_plating_bridge_mrp; we'll address its sourcing then).
import logging
from markupsafe import Markup
from odoo import api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class FpJob(models.Model):
_inherit = 'fp.job'
part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part',
ondelete='restrict',
)
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Coating Configuration',
ondelete='restrict',
)
customer_spec_id = fields.Many2one(
'fusion.plating.customer.spec',
string='Customer Spec',
ondelete='set null',
)
portal_job_id = fields.Many2one(
'fusion.plating.portal.job',
string='Portal Job',
ondelete='set null',
)
delivery_id = fields.Many2one(
'fusion.plating.delivery',
string='Delivery',
ondelete='set null',
)
override_ids = fields.One2many(
'fp.job.node.override',
'job_id',
string='Recipe Overrides',
)
# Phase 7 — migration idempotency key. Populated by
# scripts/migrate_to_fp_jobs.py to mark a fp.job as the mirror of a
# specific mrp.production. Used to skip already-migrated MOs on
# subsequent runs. Cleared after the 2-week shadow period.
legacy_mrp_production_id = fields.Integer(
string='Legacy MRP Production ID',
index=True,
help='Database id of the source mrp.production record this job '
'was migrated from. Used by the migration script for '
'idempotency. Cleared post-cutover.',
)
# ------------------------------------------------------------------
# Smart-button counts (Feature A — operator workflow)
#
# Compute counts for each downstream model so the form view can
# render an oe_stat_button row similar to sale.order. Cross-module
# models are runtime-detected so this still works when one of the
# bridge modules is uninstalled.
# ------------------------------------------------------------------
sale_order_count = fields.Integer(compute='_compute_smart_counts')
delivery_count = fields.Integer(compute='_compute_smart_counts')
invoice_count = fields.Integer(compute='_compute_smart_counts')
payment_count = fields.Integer(compute='_compute_smart_counts')
quality_hold_count = fields.Integer(compute='_compute_smart_counts')
certificate_count = fields.Integer(compute='_compute_smart_counts')
timelog_count = fields.Integer(compute='_compute_smart_counts')
portal_job_count = fields.Integer(compute='_compute_smart_counts')
@api.depends(
'sale_order_id', 'delivery_id', 'portal_job_id', 'step_ids',
'step_ids.time_log_ids', 'origin', 'partner_id',
)
def _compute_smart_counts(self):
AccountMove = self.env.get('account.move')
AccountPayment = self.env.get('account.payment')
QualityHold = self.env.get('fusion.plating.quality.hold')
Certificate = self.env.get('fp.certificate')
for job in self:
job.sale_order_count = 1 if job.sale_order_id else 0
job.delivery_count = 1 if job.delivery_id else 0
job.portal_job_count = 1 if job.portal_job_id else 0
# Invoices via origin (the SO name)
if AccountMove is not None and job.origin:
job.invoice_count = AccountMove.search_count([
('invoice_origin', '=', job.origin),
('move_type', 'in', ('out_invoice', 'out_refund')),
])
else:
job.invoice_count = 0
# Payments — find invoices for this SO, then payments
# reconciled against them.
if (AccountMove is not None and AccountPayment is not None
and job.origin):
inv_ids = AccountMove.search([
('invoice_origin', '=', job.origin),
('move_type', 'in', ('out_invoice', 'out_refund')),
]).ids
if inv_ids:
job.payment_count = AccountPayment.search_count([
('reconciled_invoice_ids', 'in', inv_ids),
])
else:
job.payment_count = 0
else:
job.payment_count = 0
if QualityHold is not None:
job.quality_hold_count = QualityHold.search_count([
('x_fc_job_id', '=', job.id),
])
else:
job.quality_hold_count = 0
if Certificate is not None:
job.certificate_count = Certificate.search_count([
('x_fc_job_id', '=', job.id),
])
else:
job.certificate_count = 0
job.timelog_count = sum(
len(s.time_log_ids) for s in job.step_ids
)
# ------------------------------------------------------------------
# Smart-button actions
# ------------------------------------------------------------------
def action_view_sale_order(self):
self.ensure_one()
if not self.sale_order_id:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': self.sale_order_id.id,
'view_mode': 'form',
'name': self.sale_order_id.name,
}
def action_view_steps(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.job.step',
'view_mode': 'list,form',
'domain': [('job_id', '=', self.id)],
'name': 'Steps — %s' % self.name,
'context': {'default_job_id': self.id},
}
def action_view_deliveries(self):
self.ensure_one()
if not self.delivery_id:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.delivery',
'res_id': self.delivery_id.id,
'view_mode': 'form',
'name': self.delivery_id.name,
}
def action_view_invoices(self):
self.ensure_one()
if not self.origin:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [
('invoice_origin', '=', self.origin),
('move_type', 'in', ('out_invoice', 'out_refund')),
],
'name': 'Invoices — %s' % self.name,
}
def action_view_payments(self):
self.ensure_one()
if not self.origin:
return {'type': 'ir.actions.act_window_close'}
AccountMove = self.env.get('account.move')
if AccountMove is None:
return {'type': 'ir.actions.act_window_close'}
inv_ids = AccountMove.search([
('invoice_origin', '=', self.origin),
('move_type', 'in', ('out_invoice', 'out_refund')),
]).ids
return {
'type': 'ir.actions.act_window',
'res_model': 'account.payment',
'view_mode': 'list,form',
'domain': (
[('reconciled_invoice_ids', 'in', inv_ids)]
if inv_ids else [('id', '=', 0)]
),
'name': 'Payments — %s' % self.name,
}
def action_view_quality_holds(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.quality.hold',
'view_mode': 'list,form',
'domain': [('x_fc_job_id', '=', self.id)],
'name': 'Quality Holds — %s' % self.name,
'context': {'default_x_fc_job_id': self.id},
}
def action_view_certificates(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.certificate',
'view_mode': 'list,form',
'domain': [('x_fc_job_id', '=', self.id)],
'name': 'Certificates — %s' % self.name,
'context': {'default_x_fc_job_id': self.id},
}
def action_view_timelogs(self):
self.ensure_one()
step_ids = self.step_ids.ids
return {
'type': 'ir.actions.act_window',
'res_model': 'fp.job.step.timelog',
'view_mode': 'list,form',
'domain': (
[('step_id', 'in', step_ids)]
if step_ids else [('id', '=', 0)]
),
'name': 'Time Logs — %s' % self.name,
}
def action_view_portal_job(self):
self.ensure_one()
if not self.portal_job_id:
return {'type': 'ir.actions.act_window_close'}
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.plating.portal.job',
'res_id': self.portal_job_id.id,
'view_mode': 'form',
'name': self.portal_job_id.name,
}
# ------------------------------------------------------------------
# Recipe → fp.job.step generation (Task 2.4)
#
# Native port of fusion_plating_bridge_mrp's
# _generate_workorders_from_recipe. Walks the recipe tree, creates
# one fp.job.step per 'operation' node, formats child 'step' nodes
# as step instructions on chatter, respects opt-in/out overrides
# from fp.job.node.override.
#
# Adaptations from the original:
# - Creates fp.job.step (not mrp.workorder)
# - Maps fusion.plating.work.center → fp.work.centre via code
# fallback (no forward link exists yet)
# - Uses native field names (job_id, work_centre_id, etc.)
# - Drops work_role_id (not on fp.job.step yet — Task 2.6+)
# - Drops _fp_autofill_default_equipment (not yet on step)
# ------------------------------------------------------------------
def _generate_steps_from_recipe(self):
"""Generate fp.job.step records from the assigned recipe.
Walks the recipe tree, creates one step per 'operation' node,
and formats child 'step' nodes as step instructions on the
chatter. Respects opt-in/out overrides from override_ids.
"""
Step = self.env['fp.job.step']
Node = self.env['fusion.plating.process.node']
for job in self:
if not job.recipe_id:
continue # No recipe assigned
if job.step_ids:
continue # Steps already exist — don't duplicate
# Build lookup of overrides keyed by node ID
override_map = {ov.node_id.id: ov.included for ov in job.override_ids}
# Start-at-node: if set, the allowed set is the union of:
# 1. start_node and all its descendants
# 2. each ancestor of start_node
# 3. at each ancestor level, any LATER-sequence sibling and
# all of its descendants
start_node = job.start_at_node_id
allowed_ids = None # None = include everything
if start_node:
descendants = Node.search([('id', 'child_of', start_node.id)])
allowed_ids = set(descendants.ids)
cur = start_node
while cur.parent_id:
parent = cur.parent_id
allowed_ids.add(parent.id)
later_sibs = parent.child_ids.filtered(
lambda n: n.sequence > cur.sequence
)
for sib in later_sibs:
sib_descendants = Node.search([
('id', 'child_of', sib.id),
])
allowed_ids |= set(sib_descendants.ids)
cur = parent
step_vals_list = []
wo_steps = {} # {sequence: instruction text}
seq_counter = [10]
def _is_node_included(node):
"""Determine if a node should be included based on
opt-in/out logic, per-job overrides, and start-at-node
filter.
"""
nid = node.id
if allowed_ids is not None and nid not in allowed_ids:
return False
opt = node.opt_in_out or 'disabled'
if opt == 'disabled':
return True
if nid in override_map:
return override_map[nid]
if opt == 'opt_in':
return False # Default excluded
return True # opt_out → default included
def _resolve_work_centre(legacy_wc):
"""Map fusion.plating.work.center → fp.work.centre.
The legacy work-centre model does not (yet) have a forward
link to the new fp.work.centre. Try a forward link
(x_fc_fp_work_centre_id) if some bridge module added one;
otherwise fall back to a code lookup.
"""
if not legacy_wc:
return self.env['fp.work.centre']
# Forward link, if any
if (
'x_fc_fp_work_centre_id' in legacy_wc._fields
and legacy_wc.x_fc_fp_work_centre_id
):
return legacy_wc.x_fc_fp_work_centre_id
# Code fallback (legacy code is unique-per-facility,
# native code is globally unique — first match wins)
if legacy_wc.code:
found = self.env['fp.work.centre'].search(
[('code', '=', legacy_wc.code)], limit=1,
)
if found:
return found
return self.env['fp.work.centre']
def walk_node(node):
if not _is_node_included(node):
return
if node.node_type == 'operation':
work_centre = _resolve_work_centre(node.work_center_id)
if not work_centre:
_logger.warning(
'Job %s: operation "%s" has no mapped fp.work.centre — '
'creating step without work centre.',
job.name, node.name,
)
# Collect step instructions from child 'step' nodes
instructions = []
step_num = 1
for child in node.child_ids.sorted('sequence'):
if child.node_type == 'step' and _is_node_included(child):
line = '%d. %s' % (step_num, child.name)
if child.estimated_duration:
line += ' (%.0f min)' % child.estimated_duration
instructions.append(line)
step_num += 1
vals = {
'job_id': job.id,
'name': node.name,
'work_centre_id': work_centre.id if work_centre else False,
'duration_expected': node.estimated_duration or 0.0,
'sequence': seq_counter[0],
'recipe_node_id': node.id,
}
if node.estimated_duration:
vals['dwell_time_minutes'] = node.estimated_duration
# Pull thickness target from the coating config when
# this is a plating step (matched by node name keyword).
coating = job.coating_config_id
name_l = (node.name or '').lower()
is_plating_node = (
'plat' in name_l or 'nickel' in name_l
or 'chrome' in name_l or 'anodiz' in name_l
)
if coating and is_plating_node:
if (
'thickness_max' in coating._fields
and coating.thickness_max
):
vals['thickness_target'] = coating.thickness_max
if (
'thickness_uom' in coating._fields
and coating.thickness_uom
):
# fp.coating.config uses long-form uom names
# (mils / microns / inches); fp.job.step uses
# short codes (mil / um / inch). Map between
# them. Unknown values fall through to the
# step's default ('um').
_UOM_MAP = {
'mils': 'mil',
'mil': 'mil',
'microns': 'um',
'micron': 'um',
'um': 'um',
'inches': 'inch',
'inch': 'inch',
'in': 'inch',
}
mapped = _UOM_MAP.get(coating.thickness_uom)
if mapped:
vals['thickness_uom'] = mapped
step_vals_list.append(vals)
if instructions:
wo_steps[seq_counter[0]] = '\n'.join(instructions)
seq_counter[0] += 10
elif node.node_type in ('recipe', 'sub_process'):
for child in node.child_ids.sorted('sequence'):
walk_node(child)
# 'step' nodes at top level are handled by their parent operation
# Walk from recipe root
walk_node(job.recipe_id)
# Bulk create
if step_vals_list:
created = Step.create(step_vals_list)
for step in created:
instr_text = wo_steps.get(step.sequence)
if instr_text:
step.message_post(
body=Markup(
'<b>Recipe steps:</b><br/><pre>%s</pre>'
) % instr_text,
subtype_xmlid='mail.mt_note',
)
job.message_post(
body=('%d steps generated from recipe "%s".') % (
len(step_vals_list), job.recipe_id.name,
),
)
return True
# ------------------------------------------------------------------
# UI — Process Tree client action (Phase 6)
# ------------------------------------------------------------------
def action_open_process_tree(self):
"""Open the OWL process-tree visualization for this job.
Launches the fp_process_tree client action (defined in
fusion_plating_shopfloor) with job_id in context. The component
fetches /fp/shopfloor/process_tree and renders the recipe ->
sub_process -> operation hierarchy as cards with per-step state
badges.
Consolidated 2026-04-24: this points at the canonical shopfloor
client action; the parallel fp_job_process_tree was removed.
"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_process_tree',
'context': {'job_id': self.id},
'name': 'Process Tree — %s' % (self.name or ''),
'target': 'current',
}
# ------------------------------------------------------------------
# Lifecycle hooks (Tasks 2.6, 2.7, 2.8)
#
# On confirm: create the portal-job mirror record and (when the
# customer requires QC) a fusion.plating.quality.check.
# On done: create a draft fusion.plating.delivery and best-effort
# trigger fp.certificate auto-generation.
#
# The QC and certificate models live in modules this module does NOT
# depend on by design (bridge_mrp). We runtime-detect those models so
# the hooks degrade gracefully when those modules are absent.
# ------------------------------------------------------------------
def action_confirm(self):
result = super().action_confirm()
# During migration, lifecycle side-effects are skipped — the
# migration script directly rebinds existing portal/QC/inspection
# records via x_fc_job_id. See scripts/migrate_to_fp_jobs.py.
if self.env.context.get('fp_jobs_migration'):
return result
for job in self:
job._fp_create_portal_job()
job._fp_create_qc_check_if_needed()
job._fp_create_racking_inspection()
job._fp_fire_notification('job_confirmed')
return result
def _fp_create_racking_inspection(self):
"""Auto-create a draft racking inspection on job confirm.
Mirrors bridge_mrp's behaviour for MO confirm. Best-effort: the
legacy fp.racking.inspection model still requires a production_id
(mrp.production), so we can only create one when this job is
bound to an MO via bridge_mrp. Otherwise we skip cleanly — Phase
9 will flip the required-FK to fp.job.
"""
self.ensure_one()
if 'fp.racking.inspection' not in self.env:
return
Inspection = self.env['fp.racking.inspection'].sudo()
# The model still requires production_id today. If the job has
# no MO link (which it won't in pure-native mode), skip rather
# than crash. The link exists when fusion_plating_bridge_mrp is
# installed and a production was created in parallel.
production = False
if 'production_id' in self._fields and self.production_id:
production = self.production_id
elif 'mrp_production_id' in self._fields and getattr(
self, 'mrp_production_id', False):
production = self.mrp_production_id
if not production:
_logger.debug(
"Job %s: no MO link — skipping racking-inspection auto-create "
"(required production_id not yet on fp.job).", self.name,
)
return
try:
vals = {'production_id': production.id}
if 'x_fc_job_id' in Inspection._fields:
vals['x_fc_job_id'] = self.id
Inspection.create(vals)
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create racking inspection: %s",
self.name, e,
)
def _fp_create_portal_job(self):
"""Create the fusion.plating.portal.job mirror record."""
self.ensure_one()
if self.portal_job_id:
return # already exists — idempotent
Portal = self.env['fusion.plating.portal.job'].sudo()
portal = Portal.create({
'name': self.name,
'partner_id': self.partner_id.id,
'state': 'in_progress',
'x_fc_job_id': self.id,
})
self.portal_job_id = portal.id
def _fp_create_qc_check_if_needed(self):
"""If customer has x_fc_requires_qc=True, create a QC check.
The fusion.plating.quality.check model lives in
fusion_plating_bridge_mrp; we runtime-detect it to avoid a
depends-on-bridge_mrp cycle. If the model isn't registered, log
a warning and skip — bridge_mrp can be installed later without
breaking this flow.
"""
self.ensure_one()
partner = self.partner_id
wants_qc = (
'x_fc_requires_qc' in partner._fields
and partner.x_fc_requires_qc
)
if not wants_qc:
return
if 'fusion.plating.quality.check' not in self.env:
_logger.warning(
"Job %s: customer wants QC but fusion.plating.quality.check "
"model not registered (bridge_mrp deferral).", self.name,
)
return
QC = self.env['fusion.plating.quality.check'].sudo()
# Try to create with the most likely required fields. If the
# model has a different schema than expected, this may need
# adjustment when bridge_mrp's QC model lands here.
try:
qc_vals = {
'partner_id': partner.id,
'state': 'pending',
}
# Try the new field name first; fallback to mrp-bound.
if 'job_id' in QC._fields:
qc_vals['job_id'] = self.id
elif 'production_id' in QC._fields:
# bridge_mrp's QC binds to production. We can't fill that
# from here — leave it null and let a manual link happen.
pass
QC.create(qc_vals)
except Exception as e:
_logger.warning(
"Job %s: failed to create QC check: %s", self.name, e,
)
# ------------------------------------------------------------------
# button_mark_done — Task 2.8
# ------------------------------------------------------------------
def button_mark_done(self):
"""Transition the job to 'done' and trigger downstream side effects.
- Sets state='done', date_finished=now
- Auto-creates a draft fusion.plating.delivery
- Triggers certificate auto-generation (best-effort)
"""
# During migration, side-effects are skipped — see action_confirm.
skip_side_effects = self.env.context.get('fp_jobs_migration')
for job in self:
if job.state == 'done':
continue
if job.state == 'cancelled':
raise UserError(
"Job %s is cancelled — cannot mark done." % job.name
)
job.state = 'done'
job.date_finished = fields.Datetime.now()
if not skip_side_effects:
job._fp_create_delivery()
job._fp_create_certificates()
job._fp_fire_notification('job_complete')
return True
# ------------------------------------------------------------------
# Notifications dispatch (Phase 4)
#
# Fires fp.notification.template records whose trigger_event matches
# the given event name. Best-effort: silently skips if the
# fusion_plating_notifications module is not installed (model not
# registered) and logs (without raising) on any send failure so the
# job lifecycle is never blocked by an email problem.
# ------------------------------------------------------------------
def _fp_fire_notification(self, event):
"""Best-effort notification dispatch for fp.job lifecycle events.
Looks up fp.notification.template records with the matching
trigger_event and dispatches via the central _dispatch helper
provided by fusion_plating_notifications. Silently no-ops when
that module isn't installed.
"""
self.ensure_one()
if 'fp.notification.template' not in self.env:
return
Template = self.env['fp.notification.template'].sudo()
try:
# The notifications module exposes a model-level _dispatch
# helper that handles template lookup, recipient resolution
# (Sub 6 contact routing), attachment rendering, and audit
# logging in one go. Pass partner explicitly since fp.job's
# partner_id is the customer.
Template._dispatch(event, self, partner=self.partner_id)
except Exception as e:
_logger.warning(
"Job %s: notification %s dispatch failed: %s",
self.name, event, e,
)
def _fp_create_delivery(self):
"""Create a draft fusion.plating.delivery linked to this job."""
self.ensure_one()
if self.delivery_id:
return
Delivery = self.env['fusion.plating.delivery'].sudo()
# Verify the model has a job link field. The current delivery
# model uses `job_ref` (Char) as a soft reference; some forks
# may add `x_fc_job_id` (Many2one).
if 'x_fc_job_id' in Delivery._fields:
ref_field = 'x_fc_job_id'
ref_value = self.id
elif 'job_ref' in Delivery._fields:
ref_field = 'job_ref'
ref_value = self.name
else:
_logger.warning(
"Job %s: fusion.plating.delivery has no job link field; "
"delivery created without job back-reference.", self.name,
)
ref_field = None
ref_value = None
try:
vals = {
'partner_id': self.partner_id.id,
}
if ref_field:
vals[ref_field] = ref_value
delivery = Delivery.create(vals)
self.delivery_id = delivery.id
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create delivery: %s", self.name, e,
)
def _fp_create_certificates(self):
"""Trigger cert auto-create on job done.
Best-effort: if fp.certificate has the right fields, create a
draft CoC. Otherwise log + skip.
"""
self.ensure_one()
if 'fp.certificate' not in self.env:
return
Cert = self.env['fp.certificate'].sudo()
try:
vals = {
'partner_id': self.partner_id.id,
}
if 'certificate_type' in Cert._fields:
vals['certificate_type'] = 'coc'
if 'state' in Cert._fields:
vals['state'] = 'draft'
# Add job link if Cert has the field
if 'x_fc_job_id' in Cert._fields:
vals['x_fc_job_id'] = self.id
elif 'job_id' in Cert._fields:
vals['job_id'] = self.id
elif 'sale_order_id' in Cert._fields and self.sale_order_id:
vals['sale_order_id'] = self.sale_order_id.id
Cert.create(vals)
except Exception as e:
_logger.warning(
"Job %s: failed to auto-create cert: %s", self.name, e,
)
class FpJobStep(models.Model):
"""Phase 7 — adds the migration idempotency key on fp.job.step.
Populated by scripts/migrate_to_fp_jobs.py to mark a step as the
mirror of a specific mrp.workorder. Used to skip already-migrated
WOs on subsequent runs.
"""
_inherit = 'fp.job.step'
legacy_mrp_workorder_id = fields.Integer(
string='Legacy MRP Work Order ID',
index=True,
help='Database id of the source mrp.workorder this step was '
'migrated from. Used by the migration script for '
'idempotency. Cleared post-cutover.',
)

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# fp.job.node.override — per-job opt-in/out decisions for opt_in/opt_out
# recipe nodes. Mirrors fusion.plating.job.node.override from bridge_mrp,
# but bound to fp.job instead of mrp.production.
#
# bridge_mrp keeps its version alive so legacy MO-flow keeps working.
# Both coexist during the migration period.
from odoo import fields, models
class FpJobNodeOverride(models.Model):
_name = 'fp.job.node.override'
_description = 'Plating Job Recipe Node Override'
_order = 'job_id, node_id'
job_id = fields.Many2one(
'fp.job',
required=True,
ondelete='cascade',
index=True,
)
node_id = fields.Many2one(
'fusion.plating.process.node',
string='Recipe Node',
required=True,
domain="[('opt_in_out', 'in', ('opt_in', 'opt_out'))]",
)
included = fields.Boolean(
string='Included',
default=True,
help='When True, this opt-in/out node is included in step generation.',
)
_unique_job_node = models.Constraint(
'unique(job_id, node_id)',
'A job can only have one override per recipe node.',
)

View File

@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Real implementations for the state-machine action stubs that
# fusion_plating core's fp.job.step shipped as NotImplementedError
# placeholders. Per spec §5.2 state machine.
from odoo import _, fields, models
from odoo.exceptions import UserError
class FpJobStep(models.Model):
_inherit = 'fp.job.step'
def button_pause(self):
"""Pause an in-progress step (operator break, end of shift).
Closes the open timelog row, sums duration_actual, transitions
state to 'paused'. button_start re-opens a fresh timelog when
the operator resumes.
"""
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can pause."
) % (step.name, step.state))
now = fields.Datetime.now()
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
if open_log:
open_log.write({'date_finished': now})
step.state = 'paused'
step.duration_actual = sum(step.time_log_ids.mapped('duration_minutes'))
return True
def button_skip(self):
"""Skip a pending/ready step (e.g. opt-in step the planner
decided not to activate for this job).
"""
for step in self:
if step.state not in ('pending', 'ready'):
raise UserError(_(
"Step '%s' is in state '%s' — only pending/ready steps can be skipped."
) % (step.name, step.state))
step.state = 'skipped'
return True
def button_cancel(self):
"""Cancel a single step. Use fp.job.action_cancel to cancel
the whole job.
"""
for step in self:
if step.state == 'done':
raise UserError(_(
"Step '%s' is done — cannot cancel."
) % step.name)
if step.state == 'cancelled':
raise UserError(_(
"Step '%s' is already cancelled."
) % step.name)
step.state = 'cancelled'
return True

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Adds 'job_confirmed' and 'job_complete' trigger events to the
# fp.notification.template selection. Fired from fp.job lifecycle
# hooks (action_confirm, button_mark_done).
#
# bridge_mrp's existing 'mo_confirmed' / 'mo_complete' triggers
# stay alive for the legacy MO flow.
from odoo import fields, models
class FpNotificationTemplate(models.Model):
_inherit = 'fp.notification.template'
trigger_event = fields.Selection(
selection_add=[
('job_confirmed', 'Plating Job Confirmed'),
('job_complete', 'Plating Job Complete'),
],
ondelete={
'job_confirmed': 'cascade',
'job_complete': 'cascade',
},
)

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Add a back-reference from fusion.plating.portal.job to the native
# fp.job. Coexists with any future x_fc_production_id (legacy
# mrp.production link) added by bridge_mrp.
from odoo import fields, models
class FusionPlatingPortalJob(models.Model):
_inherit = 'fusion.plating.portal.job'
x_fc_job_id = fields.Many2one(
'fp.job',
string='Plating Job',
index=True,
help='Native fp.job link. Coexists with x_fc_production_id (legacy '
'mrp.production link).',
)

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 — parallel job/step links on fusion.plating.quality.hold.
# Coexists with bridge_mrp's existing production_id link.
from odoo import fields, models
class FusionPlatingQualityHold(models.Model):
_inherit = 'fusion.plating.quality.hold'
x_fc_job_id = fields.Many2one(
'fp.job',
string='Plating Job',
index=True,
help="Native fp.job link. Coexists with bridge_mrp's production_id "
"link.",
)
x_fc_step_id = fields.Many2one(
'fp.job.step',
string='Plating Step',
index=True,
)

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 — parallel job link on fp.racking.inspection.
# Coexists with the legacy production_id (mrp.production) link.
from odoo import fields, models
class FpRackingInspection(models.Model):
_inherit = 'fp.racking.inspection'
x_fc_job_id = fields.Many2one(
'fp.job',
string='Plating Job',
index=True,
help='Native fp.job link. Coexists with the legacy production_id.',
)

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Phase 3 — parallel job/step links on fp.thickness.reading.
from odoo import fields, models
class FpThicknessReading(models.Model):
_inherit = 'fp.thickness.reading'
x_fc_job_id = fields.Many2one(
'fp.job',
string='Plating Job',
index=True,
)
x_fc_step_id = fields.Many2one(
'fp.job.step',
string='Plating Step',
index=True,
)

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Tags KPI values by source: 'mrp' (legacy bridge_mrp rollups) vs
# 'jobs' (native fp.job rollups). Lets Phase 9 / Phase 10 dashboards
# show both side-by-side or filter to one.
from odoo import fields, models
class FusionPlatingKpiValue(models.Model):
_inherit = 'fusion.plating.kpi.value'
x_fc_source = fields.Selection(
[
('mrp', 'MRP (legacy)'),
('jobs', 'Native Jobs'),
],
string='Data Source',
default='mrp',
index=True,
help='Which data path produced this KPI value. Phase 9+ '
'rollups from fp.job/fp.job.step set this to jobs.',
)

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Native fp.job margin report — replaces report_wo_margin which binds
# to mrp.production. Uses fp.job.step.cost_total (already computed in
# Phase 1: duration_actual / 60 * cost_per_hour).
from odoo import api, models
class ReportFpJobMargin(models.AbstractModel):
_name = 'report.fusion_plating_jobs.report_fp_job_margin'
_description = 'Plating Job Margin Report'
@api.model
def _get_report_values(self, docids, data=None):
Job = self.env['fp.job']
jobs = Job.browse(docids)
rows = []
for job in jobs:
step_rows = []
total_labour = 0.0
total_minutes = 0.0
for step in job.step_ids.sorted('sequence'):
step_rows.append({
'sequence': step.sequence,
'name': step.name,
'work_centre': step.work_centre_id.name if step.work_centre_id else '-',
'duration_expected': step.duration_expected,
'duration_actual': step.duration_actual,
'rate': step.cost_per_hour,
'cost': step.cost_total,
})
total_labour += step.cost_total
total_minutes += step.duration_actual
rows.append({
'job': job,
'steps': step_rows,
'total_minutes': total_minutes,
'total_labour': total_labour,
'quoted_revenue': job.quoted_revenue,
'actual_cost': job.actual_cost,
'margin': job.margin,
'margin_pct': job.margin_pct,
})
return {
'doc_ids': docids,
'doc_model': 'fp.job',
'docs': jobs,
'rows': rows,
}

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# x_fc_use_native_jobs — company-level setting that controls whether
# SO confirmation creates a native fp.job record (this module) or
# the legacy mrp.production / mrp.workorder records (bridge_mrp).
#
# Default: False (legacy MO flow). Phase 9 cutover flips this to True
# on entech.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
x_fc_use_native_jobs = fields.Boolean(
string='Use Native Plating Jobs',
config_parameter='fusion_plating_jobs.use_native_jobs',
help='When enabled, SO confirmation creates fp.job records '
'instead of mrp.production. Phase-2 migration toggle.',
)

View File

@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# sale.order.action_confirm hook — creates fp.job records when the
# x_fc_use_native_jobs setting is True. Mirrors bridge_mrp's
# _fp_auto_create_mo but creates fp.job instead of mrp.production.
#
# When the setting is False (default), this hook is a no-op and
# bridge_mrp's MO-creation hook handles the flow.
import logging
from odoo import _, api, fields, models
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
result = super().action_confirm()
# Only run when the native flag is on
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_plating_jobs.use_native_jobs') == 'True':
for so in self:
so._fp_auto_create_job()
return result
def _fp_auto_create_job(self):
"""Create fp.job(s) from the SO's plating lines.
Lines that share a `x_fc_wo_group_tag` collapse into one job;
untagged lines get one job per line. Mirrors bridge_mrp's
_fp_auto_create_mo grouping logic.
"""
self.ensure_one()
Job = self.env['fp.job'].sudo()
# Idempotency: skip if a job already references this SO
existing = Job.search([('sale_order_id', '=', self.id)], limit=1)
if existing:
return
# Find plating lines (those with a part_catalog_id or coating_config_id)
plating_lines = self.order_line.filtered(
lambda l: (
('x_fc_part_catalog_id' in l._fields and l.x_fc_part_catalog_id)
or ('x_fc_coating_config_id' in l._fields and l.x_fc_coating_config_id)
)
)
if not plating_lines:
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
return
# Group by x_fc_wo_group_tag (untagged → distinct group per line)
groups = {} # tag → recordset of lines
untagged_idx = 0
for line in plating_lines:
tag = (
'x_fc_wo_group_tag' in line._fields and line.x_fc_wo_group_tag
) or False
if not tag:
untagged_idx += 1
tag = '__untagged_%d' % untagged_idx
groups[tag] = groups.get(tag, self.env['sale.order.line']) | line
# Create a job per group
for tag, lines in groups.items():
first_line = lines[0]
qty = sum(lines.mapped('product_uom_qty'))
part = (
'x_fc_part_catalog_id' in first_line._fields
and first_line.x_fc_part_catalog_id
or False
)
coating = (
'x_fc_coating_config_id' in first_line._fields
and first_line.x_fc_coating_config_id
or False
)
# Recipe lookup: from coating, fallback to part
recipe = False
if coating and 'recipe_id' in coating._fields and coating.recipe_id:
recipe = coating.recipe_id
if not recipe and part and 'default_process_id' in part._fields and part.default_process_id:
recipe = part.default_process_id
if not recipe and part and 'recipe_id' in part._fields and part.recipe_id:
recipe = part.recipe_id
vals = {
'partner_id': self.partner_id.id,
'product_id': first_line.product_id.id if first_line.product_id else False,
'qty': qty,
'origin': self.name,
'sale_order_id': self.id,
'sale_order_line_ids': [(6, 0, lines.ids)],
'date_deadline': self.commitment_date or self.date_order,
}
if part:
vals['part_catalog_id'] = part.id
if coating:
vals['coating_config_id'] = coating.id
if recipe:
vals['recipe_id'] = recipe.id
# Customer spec / facility / manager — copy from SO if present
if 'x_fc_customer_spec_id' in self._fields and self.x_fc_customer_spec_id:
vals['customer_spec_id'] = self.x_fc_customer_spec_id.id
if 'x_fc_facility_id' in self._fields and self.x_fc_facility_id:
vals['facility_id'] = self.x_fc_facility_id.id
if 'x_fc_manager_id' in self._fields and self.x_fc_manager_id:
vals['manager_id'] = self.x_fc_manager_id.id
# Quoted revenue: sum line totals
vals['quoted_revenue'] = sum(lines.mapped('price_subtotal'))
job = Job.create(vals)
_logger.info(
'SO %s: created fp.job %s (qty=%s, recipe=%s)',
self.name, job.name, qty, (recipe.name if recipe else '-'),
)
return True

View File

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

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="action_report_fp_job_margin" model="ir.actions.report">
<field name="name">Job Margin Report</field>
<field name="model">fp.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_jobs.report_fp_job_margin_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_margin_template</field>
<field name="print_report_name">'Job Margin - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
</record>
<template id="report_fp_job_margin_template">
<t t-call="web.html_container">
<t t-foreach="rows" t-as="row">
<t t-call="web.external_layout">
<div class="page">
<h2>Job Margin — <span t-esc="row['job'].name"/></h2>
<table class="table table-sm" style="margin-top: 1em; max-width: 600px;">
<tr><th>Customer</th><td><span t-esc="row['job'].partner_id.name"/></td></tr>
<tr><th>Recipe</th><td><span t-esc="row['job'].recipe_id.name or '-'"/></td></tr>
<tr><th>Quantity</th><td><span t-esc="row['job'].qty"/></td></tr>
<tr><th>Status</th><td><span t-esc="row['job'].state"/></td></tr>
</table>
<h3 style="margin-top: 1.5em;">Step Breakdown</h3>
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>#</th>
<th>Step</th>
<th>Work Centre</th>
<th class="text-end">Expected (min)</th>
<th class="text-end">Actual (min)</th>
<th class="text-end">Rate / hr</th>
<th class="text-end">Cost</th>
</tr>
</thead>
<tbody>
<t t-foreach="row['steps']" t-as="step">
<tr>
<td><span t-esc="step['sequence']"/></td>
<td><span t-esc="step['name']"/></td>
<td><span t-esc="step['work_centre']"/></td>
<td class="text-end"><span t-esc="step['duration_expected']"/></td>
<td class="text-end"><span t-esc="step['duration_actual']"/></td>
<td class="text-end"><span t-field="step['rate']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
<td class="text-end"><span t-field="step['cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
</tr>
</t>
<tr style="font-weight: bold; background: #f3f3f3;">
<td colspan="3">Totals</td>
<td></td>
<td class="text-end"><span t-esc="row['total_minutes']"/></td>
<td></td>
<td class="text-end"><span t-field="row['total_labour']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td>
</tr>
</tbody>
</table>
<h3 style="margin-top: 1.5em;">Margin Summary</h3>
<table class="table table-sm" style="max-width: 400px;">
<tr><th>Quoted Revenue</th><td class="text-end"><span t-field="row['quoted_revenue']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
<tr><th>Actual Cost</th><td class="text-end"><span t-field="row['actual_cost']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
<tr style="font-weight: bold;"><th>Margin</th><td class="text-end"><span t-field="row['margin']" t-options="{'widget': 'monetary', 'display_currency': row['job'].currency_id}"/></td></tr>
<tr><th>Margin %</th><td class="text-end"><span t-esc="round(row['margin_pct'], 1)"/>%</td></tr>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Native fp.job sticker — reuses the canonical box-sticker design from
fusion_plating_reports.report_fp_wo_sticker_inner. The visual layout
(logo + WO# stack on the left, big QR on the right, 7-row body table
underneath, all wrapped in a 2px border) is the one shop staff have
been printing since the mrp.production days; we just feed it from
fp.job fields here instead of mrp.production.
-->
<odoo>
<record id="paperformat_fp_job_sticker" model="report.paperformat">
<field name="name">FP Job Sticker (6x4")</field>
<field name="format">custom</field>
<field name="page_width">152</field>
<field name="page_height">102</field>
<field name="orientation">Portrait</field>
<field name="margin_top">0</field>
<field name="margin_bottom">0</field>
<field name="margin_left">0</field>
<field name="margin_right">0</field>
<field name="header_line" eval="False"/>
<field name="header_spacing">0</field>
<field name="disable_shrinking" eval="True"/>
<field name="dpi">300</field>
</record>
<record id="action_report_fp_job_sticker" model="ir.actions.report">
<field name="name">Job Sticker</field>
<field name="model">fp.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_template</field>
<field name="print_report_name">'Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
</record>
<template id="report_fp_job_sticker_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<!-- Defaults block initialises every var the inner
reads (so `_so or ...` doesn't NameError). We
then override the ones we have data for. -->
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<!-- Pre-resolve the variables the shared inner template
expects, sourcing them from fp.job's native fields. -->
<t t-set="_order_id" t-value="job.name"/>
<t t-set="_scan_id" t-value="job.id"/>
<t t-set="_scan_path" t-value="'/fp/job/'"/>
<t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="job.sale_order_id"/>
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
<t t-set="_process" t-value="job.recipe_id or False"/>
<t t-set="_due" t-value="job.date_deadline or False"/>
<t t-set="_qty" t-value="job.qty"/>
<t t-set="_partner_name" t-value="job.partner_id.name"/>
<!-- The fp.job's own name (WH/JOB/00033) is already
printed in the header as "WO #...", so suppress
the muted "(WH/MO/...)" suffix on the PO row. -->
<t t-set="_mo_ref" t-value="''"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Native fp.job traveller — minimal portrait A4 listing all steps.
-->
<odoo>
<record id="action_report_fp_job_traveller" model="ir.actions.report">
<field name="name">Job Traveller</field>
<field name="model">fp.job</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_jobs.report_fp_job_traveller_template</field>
<field name="report_file">fusion_plating_jobs.report_fp_job_traveller_template</field>
<field name="print_report_name">'Traveller - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
<field name="binding_type">report</field>
</record>
<template id="report_fp_job_traveller_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="job">
<t t-call="web.external_layout">
<div class="page">
<h1>Job Traveller — <span t-esc="job.name"/></h1>
<table class="table table-sm" style="margin-top: 1em;">
<tr><th>Customer</th><td><span t-esc="job.partner_id.name"/></td></tr>
<tr><th>SO</th><td><span t-esc="job.sale_order_id.name or '-'"/></td></tr>
<tr><th>Qty</th><td><span t-esc="job.qty"/></td></tr>
<tr><th>Recipe</th><td><span t-esc="job.recipe_id.name or '-'"/></td></tr>
<tr><th>Deadline</th><td><span t-esc="job.date_deadline and job.date_deadline.strftime('%b %d, %Y') or '-'"/></td></tr>
<tr><th>Status</th><td><span t-esc="job.state"/></td></tr>
</table>
<h2 style="margin-top: 2em;">Steps</h2>
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>#</th>
<th>Operation</th>
<th>Work Centre</th>
<th>Kind</th>
<th>Expected (min)</th>
<th>Actual (min)</th>
<th>State</th>
<th>Operator Sign-off</th>
</tr>
</thead>
<tbody>
<t t-foreach="job.step_ids.sorted('sequence')" t-as="step">
<tr>
<td><span t-esc="step.sequence"/></td>
<td><span t-esc="step.name"/></td>
<td><span t-esc="step.work_centre_id.name or ''"/></td>
<td><span t-esc="step.kind"/></td>
<td><span t-esc="step.duration_expected"/></td>
<td><span t-esc="step.duration_actual"/></td>
<td><span t-esc="step.state"/></td>
<td style="border-bottom: 1px solid #999; min-width: 100px;"></td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -0,0 +1,51 @@
# Native job migration scripts
## migrate_to_fp_jobs.py
Copies live `mrp.production` / `mrp.workorder` records into the native
`fp.job` / `fp.job.step` model. Idempotent — safe to run multiple times.
### Usage
Run from the host (e.g. entech) using `odoo shell`:
```bash
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\" < /mnt/extra-addons/custom/fusion_plating_jobs/scripts/migrate_to_fp_jobs.py'"
```
Or interactively from `odoo shell` (Python `exec` builtin, not a shell call):
```python
exec(open('/mnt/extra-addons/custom/fusion_plating_jobs/scripts/migrate_to_fp_jobs.py').read())
```
### What it does
1. For every `mrp.production` record, creates a parallel `fp.job` with the same name and fields. Skips MOs that already have a fp.job mirror (`fp.job.legacy_mrp_production_id == mo.id`).
2. For every `mrp.workorder` record, creates a parallel `fp.job.step`. Skips already-migrated WOs.
3. Migrates `mrp.workorder.time_ids` to `fp.job.step.timelog`.
4. Rebinds cross-references on dependent models (batches, holds, certs, deliveries, portal jobs, racking inspections).
5. Audit log written to `/tmp/fp_jobs_migration.log` and to a chatter post on each migrated job.
### Safety
- Idempotent. Re-running skips already-migrated records.
- Read-only on legacy MO/WO records. Original data untouched.
- Cross-reference rebinds add new x_fc_job_id / x_fc_step_id values without removing legacy production_id / workorder_id values. Both stay populated for the 2-week shadow period.
- Wrap in a transaction (default for `odoo shell`); if anything fails, rollback.
### Pre-migration audit
Run `audit_pre_migration.py` first to see what's about to happen. The
script uses Python's `exec` builtin to load the file inside the running
shell session — no shell exec involved.
Reports counts of MO/WO/dependent records and any data-quality concerns
(MOs with no recipe, WOs with no work centre, etc).
### Post-migration audit
Run `audit_post_migration.py` after to verify counts match.
Reports row counts on fp.job, fp.job.step, and confirms all dependent
records have new x_fc_*_id values.

View File

@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# This package holds standalone migration / audit scripts for the native
# job model rollout. Scripts under this directory are NOT imported at
# module load time — they are invoked manually from `odoo shell` by the
# cutover engineer. See README.md in this directory for usage.

View File

@@ -0,0 +1,170 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Post-migration audit. Verifies migration counts match expectations.
# Read-only — does NOT modify data. Run from `odoo shell`.
import logging
_logger = logging.getLogger('fp_jobs_migration')
def run(env):
"""Compare row counts between source MRP tables and target fp.job
/ fp.job.step tables, plus dependent-model x_fc_*_id linkage.
"""
cr = env.cr
print('=== Post-migration audit ===')
cr.execute("SELECT COUNT(*) FROM fp_job")
job_total = cr.fetchone()[0]
cr.execute(
"SELECT COUNT(*) FROM fp_job WHERE legacy_mrp_production_id IS NOT NULL"
)
job_migrated = cr.fetchone()[0]
cr.execute("SELECT COUNT(*) FROM mrp_production")
mo_total = cr.fetchone()[0]
print(
'mrp.production: %d, fp.job: %d (migrated: %d)'
% (mo_total, job_total, job_migrated)
)
if job_migrated < mo_total:
print('WARNING: %d MOs not migrated' % (mo_total - job_migrated))
cr.execute("SELECT COUNT(*) FROM fp_job_step")
step_total = cr.fetchone()[0]
cr.execute(
"SELECT COUNT(*) FROM fp_job_step WHERE legacy_mrp_workorder_id IS NOT NULL"
)
step_migrated = cr.fetchone()[0]
cr.execute("SELECT COUNT(*) FROM mrp_workorder")
wo_total = cr.fetchone()[0]
print(
'mrp.workorder: %d, fp.job.step: %d (migrated: %d)'
% (wo_total, step_total, step_migrated)
)
if step_migrated < wo_total:
print('WARNING: %d WOs not migrated' % (wo_total - step_migrated))
# Cross-references — for each dependent model, show counts of records
# with the LEGACY production_id set vs the NEW x_fc_job_id set. After
# migration, the second column should match the first (we don't clear
# production_id during shadow period).
if 'fp.quality.hold' in env:
cr.execute(
"SELECT COUNT(*) FROM fp_quality_hold WHERE production_id IS NOT NULL"
)
with_mo = cr.fetchone()[0]
cr.execute(
"SELECT COUNT(*) FROM fp_quality_hold WHERE x_fc_job_id IS NOT NULL"
)
with_job = cr.fetchone()[0]
print(
'fp.quality.hold: with production_id=%d, with x_fc_job_id=%d'
% (with_mo, with_job)
)
if 'fusion.plating.quality.hold' in env:
cr.execute(
"SELECT COUNT(*) FROM fusion_plating_quality_hold "
"WHERE production_id IS NOT NULL"
)
with_mo = cr.fetchone()[0]
cr.execute(
"SELECT COUNT(*) FROM fusion_plating_quality_hold "
"WHERE x_fc_job_id IS NOT NULL"
)
with_job = cr.fetchone()[0]
print(
'fusion.plating.quality.hold: with production_id=%d, with x_fc_job_id=%d'
% (with_mo, with_job)
)
if 'fp.certificate' in env:
cr.execute(
"SELECT COUNT(*) FROM fp_certificate WHERE production_id IS NOT NULL"
)
with_mo = cr.fetchone()[0]
cr.execute(
"SELECT COUNT(*) FROM fp_certificate WHERE x_fc_job_id IS NOT NULL"
)
with_job = cr.fetchone()[0]
print(
'fp.certificate: with production_id=%d, with x_fc_job_id=%d'
% (with_mo, with_job)
)
if 'fp.thickness.reading' in env:
cr.execute(
"SELECT COUNT(*) FROM fp_thickness_reading "
"WHERE production_id IS NOT NULL"
)
with_mo = cr.fetchone()[0]
cr.execute(
"SELECT COUNT(*) FROM fp_thickness_reading "
"WHERE x_fc_job_id IS NOT NULL"
)
with_job = cr.fetchone()[0]
print(
'fp.thickness.reading: with production_id=%d, with x_fc_job_id=%d'
% (with_mo, with_job)
)
if 'fusion.plating.batch' in env:
cr.execute(
"SELECT COUNT(*) FROM fusion_plating_batch "
"WHERE workorder_id IS NOT NULL"
)
with_wo = cr.fetchone()[0]
cr.execute(
"SELECT COUNT(*) FROM fusion_plating_batch "
"WHERE x_fc_step_id IS NOT NULL"
)
with_step = cr.fetchone()[0]
print(
'fusion.plating.batch: with workorder_id=%d, with x_fc_step_id=%d'
% (with_wo, with_step)
)
if 'fp.racking.inspection' in env:
cr.execute(
"SELECT COUNT(*) FROM fp_racking_inspection "
"WHERE production_id IS NOT NULL"
)
with_mo = cr.fetchone()[0]
cr.execute(
"SELECT COUNT(*) FROM fp_racking_inspection "
"WHERE x_fc_job_id IS NOT NULL"
)
with_job = cr.fetchone()[0]
print(
'fp.racking.inspection: with production_id=%d, with x_fc_job_id=%d'
% (with_mo, with_job)
)
if 'fusion.plating.delivery' in env:
cr.execute(
"SELECT COUNT(*) FROM fusion_plating_delivery "
"WHERE job_ref IS NOT NULL"
)
with_ref = cr.fetchone()[0]
cr.execute(
"SELECT COUNT(*) FROM fusion_plating_delivery "
"WHERE x_fc_job_id IS NOT NULL"
)
with_job = cr.fetchone()[0]
print(
'fusion.plating.delivery: with job_ref=%d, with x_fc_job_id=%d'
% (with_ref, with_job)
)
print('=== End post-migration audit ===')
try:
run(env) # noqa: F821 — `env` is provided by odoo shell
except NameError:
print(
'This script expects to run inside `odoo shell` where `env` is defined.'
)

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Pre-migration audit. Reports row counts and data-quality concerns
# before running migrate_to_fp_jobs.py. Read-only — does NOT modify data.
#
# Run from `odoo shell` where `env` is in scope. See ./README.md.
import logging
_logger = logging.getLogger('fp_jobs_migration')
def run(env):
"""Print a snapshot of what migrate_to_fp_jobs.py would touch.
All queries are SELECT-only. Safe to run on production at any time.
"""
cr = env.cr
print('=== Pre-migration audit ===')
# Core MRP counts
cr.execute("SELECT COUNT(*) FROM mrp_production")
mo_total = cr.fetchone()[0]
print('mrp.production total:', mo_total)
cr.execute("SELECT state, COUNT(*) FROM mrp_production GROUP BY state ORDER BY 1")
print('mrp.production by state:', cr.fetchall())
cr.execute("SELECT COUNT(*) FROM mrp_workorder")
wo_total = cr.fetchone()[0]
print('mrp.workorder total:', wo_total)
cr.execute("SELECT state, COUNT(*) FROM mrp_workorder GROUP BY state ORDER BY 1")
print('mrp.workorder by state:', cr.fetchall())
# Already migrated?
cr.execute("SELECT COUNT(*) FROM fp_job")
job_total = cr.fetchone()[0]
print('fp.job already exists:', job_total)
cr.execute("SELECT COUNT(*) FROM fp_job_step")
step_total = cr.fetchone()[0]
print('fp.job.step already exists:', step_total)
# Data quality
if 'x_fc_recipe_id' in env['mrp.production']._fields:
cr.execute(
"SELECT COUNT(*) FROM mrp_production WHERE x_fc_recipe_id IS NULL"
)
no_recipe = cr.fetchone()[0]
print('MOs without x_fc_recipe_id:', no_recipe)
cr.execute(
"SELECT COUNT(*) FROM mrp_workorder WHERE workcenter_id IS NULL"
)
no_wc = cr.fetchone()[0]
print('WOs without workcenter_id:', no_wc)
# Dependent records — check by model registry (truthful even when
# the schema names differ from defaults).
if 'fp.quality.hold' in env:
cr.execute(
"SELECT COUNT(*) FROM fp_quality_hold WHERE production_id IS NOT NULL"
)
print('fp.quality.hold rows with production_id:', cr.fetchone()[0])
if 'fp.certificate' in env:
cr.execute(
"SELECT COUNT(*) FROM fp_certificate WHERE production_id IS NOT NULL"
)
print('fp.certificate rows with production_id:', cr.fetchone()[0])
if 'fp.thickness.reading' in env:
cr.execute(
"SELECT COUNT(*) FROM fp_thickness_reading WHERE production_id IS NOT NULL"
)
print(
'fp.thickness.reading rows with production_id:',
cr.fetchone()[0],
)
if 'fusion.plating.batch' in env:
cr.execute(
"SELECT COUNT(*) FROM fusion_plating_batch WHERE workorder_id IS NOT NULL"
)
print(
'fusion.plating.batch rows with workorder_id:',
cr.fetchone()[0],
)
if 'fusion.plating.portal.job' in env:
cr.execute("SELECT COUNT(*) FROM fusion_plating_portal_job")
print('fusion.plating.portal.job total:', cr.fetchone()[0])
if 'fp.racking.inspection' in env:
cr.execute(
"SELECT COUNT(*) FROM fp_racking_inspection WHERE production_id IS NOT NULL"
)
print(
'fp.racking.inspection rows with production_id:',
cr.fetchone()[0],
)
if 'fusion.plating.delivery' in env:
cr.execute(
"SELECT COUNT(*) FROM fusion_plating_delivery WHERE job_ref IS NOT NULL"
)
print(
'fusion.plating.delivery rows with job_ref:',
cr.fetchone()[0],
)
print('=== End pre-migration audit ===')
# Run when the script is exec'd from odoo shell (env is in scope).
try:
run(env) # noqa: F821 — `env` is provided by odoo shell
except NameError:
print('This script expects to run inside `odoo shell` where `env` is defined.')

View File

@@ -0,0 +1,292 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# DESTRUCTIVE: deletes ALL fp.job, fp.job.step, fp.job.step.timelog,
# mrp.production, mrp.workorder, sale.order, account.move (invoices),
# account.payment, stock.picking, stock.move, fusion.plating.quote.request
# records and their dependent data (deliveries, certs, thickness readings,
# holds, portal jobs, racking inspections). Preserves masters (partners,
# parts, recipes, coating configs, baths, tanks, work centres, users,
# groups, settings).
#
# Use only on demo/dev environments. Take a Proxmox snapshot first.
def run(env):
print('=== Cleanup starting ===')
# Walk dependents bottom-up so FK cascades don't bite us.
# 1. Time logs (cascades on step delete, but be explicit)
n = env['fp.job.step.timelog'].search_count([])
env['fp.job.step.timelog'].sudo().search([]).unlink()
print(' Deleted %d fp.job.step.timelog rows' % n)
# 2. fp.job.node.override (cascades on job delete)
n = env['fp.job.node.override'].search_count([])
env['fp.job.node.override'].sudo().search([]).unlink()
print(' Deleted %d fp.job.node.override rows' % n)
# 3. Deliveries linked to jobs OR with job_ref set OR linked to a SO that
# we will delete. Delete ALL deliveries — they're test data.
if 'fusion.plating.delivery' in env:
deliveries = env['fusion.plating.delivery'].sudo().search([])
n = len(deliveries)
deliveries.unlink()
print(' Deleted %d fusion.plating.delivery rows' % n)
# 4. Certificates linked to jobs/MOs
if 'fp.certificate' in env:
certs = env['fp.certificate'].sudo().search([])
n = len(certs)
certs.unlink()
print(' Deleted %d fp.certificate rows' % n)
# 5. Thickness readings
if 'fp.thickness.reading' in env:
tr = env['fp.thickness.reading'].sudo().search([])
n = len(tr)
tr.unlink()
print(' Deleted %d fp.thickness.reading rows' % n)
# 6. Quality holds linked to jobs/MOs
if 'fusion.plating.quality.hold' in env:
holds = env['fusion.plating.quality.hold'].sudo().search([])
n = len(holds)
holds.unlink()
print(' Deleted %d fusion.plating.quality.hold rows' % n)
# 7. Portal jobs (linked to jobs OR legacy production)
if 'fusion.plating.portal.job' in env:
portals = env['fusion.plating.portal.job'].sudo().search([])
n = len(portals)
portals.unlink()
print(' Deleted %d fusion.plating.portal.job rows' % n)
# 8. Racking inspections — required FK to mrp.production, so delete
# BEFORE we kill the productions.
if 'fp.racking.inspection' in env:
insps = env['fp.racking.inspection'].sudo().search([])
n = len(insps)
insps.unlink()
print(' Deleted %d fp.racking.inspection rows' % n)
# 9. Receiving records (required FK to sale.order — delete before SOs)
if 'fp.receiving' in env:
recs = env['fp.receiving'].sudo().search([])
n = len(recs)
recs.unlink()
print(' Deleted %d fp.receiving rows' % n)
# 10. fp.job.step (cascade-safe via job_id, but be explicit)
n = env['fp.job.step'].search_count([])
env['fp.job.step'].sudo().search([]).unlink()
print(' Deleted %d fp.job.step rows' % n)
# 11. fp.job
n = env['fp.job'].search_count([])
env['fp.job'].sudo().search([]).unlink()
print(' Deleted %d fp.job rows' % n)
# 12. mrp.workorder (legacy)
n = env['mrp.workorder'].search_count([])
env['mrp.workorder'].sudo().search([]).unlink()
print(' Deleted %d mrp.workorder rows' % n)
# 13. mrp.production (legacy) — force state via SQL so unlink() bypasses
# Odoo's _unlink_except_done guard (which forbids deleting done MOs)
# and the action_cancel guard (which forbids cancelling done MOs).
# Demo data only.
n = env['mrp.production'].search_count([])
if n:
# 'cancel' state is the only state mrp.production._unlink_except_done
# explicitly permits.
env.cr.execute("UPDATE mrp_production SET state='cancel'")
# Also clear stock moves' state so cascaded checks pass
env.cr.execute(
"UPDATE stock_move SET state='cancel' "
"WHERE raw_material_production_id IN (SELECT id FROM mrp_production) "
"OR production_id IN (SELECT id FROM mrp_production)"
)
env.invalidate_all()
env['mrp.production'].sudo().search([]).unlink()
print(' Deleted %d mrp.production rows' % n)
# 14. Account payments (must come before invoices — payment is reconciled
# against move lines)
Payment = env['account.payment'].sudo()
payments = Payment.search([])
n = len(payments)
if payments:
for p in payments:
if p.state == 'paid':
try:
p.action_draft()
except Exception:
env.cr.execute(
"UPDATE account_payment SET state='draft' WHERE id=%s",
(p.id,),
)
try:
p.action_cancel()
except Exception:
pass
# Clear reconciliation links pointing at the payment moves
env.cr.execute(
"DELETE FROM account_partial_reconcile "
"WHERE debit_move_id IN (SELECT id FROM account_move_line WHERE move_id IN ("
" SELECT move_id FROM account_payment WHERE id = ANY(%s))) "
"OR credit_move_id IN (SELECT id FROM account_move_line WHERE move_id IN ("
" SELECT move_id FROM account_payment WHERE id = ANY(%s)))",
(payments.ids, payments.ids),
)
env.cr.execute(
"DELETE FROM account_payment WHERE id = ANY(%s)",
(payments.ids,),
)
print(' Deleted %d account.payment rows' % n)
# 15. Invoices (account.move with out_invoice / out_refund / in_invoice
# / in_refund move types). Posted ones must be drafted/cancelled first.
Move = env['account.move'].sudo()
invoices = Move.search([
('move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')),
])
n = len(invoices)
if invoices:
for inv in invoices:
if inv.state == 'posted':
try:
inv.button_draft()
except Exception:
env.cr.execute(
"UPDATE account_move SET state='draft' WHERE id=%s",
(inv.id,),
)
try:
inv.button_cancel()
except Exception:
env.cr.execute(
"UPDATE account_move SET state='cancel' WHERE id=%s",
(inv.id,),
)
env.invalidate_all()
# Force-clear reconciliation links so unlink doesn't trip on
# partial_reconcile_id
env.cr.execute(
"DELETE FROM account_partial_reconcile "
"WHERE debit_move_id IN (SELECT id FROM account_move_line WHERE move_id = ANY(%s)) "
"OR credit_move_id IN (SELECT id FROM account_move_line WHERE move_id = ANY(%s))",
(invoices.ids, invoices.ids),
)
env.cr.execute(
"DELETE FROM account_move_line WHERE move_id = ANY(%s)",
(invoices.ids,),
)
env.cr.execute(
"DELETE FROM account_move WHERE id = ANY(%s)",
(invoices.ids,),
)
print(' Deleted %d account.move (invoice) rows' % n)
# 16. Stock pickings + moves (any leftovers from MOs / SOs)
pickings = env['stock.picking'].sudo().search([])
n = len(pickings)
if pickings:
for pk in pickings:
if pk.state not in ('cancel', 'draft'):
try:
pk.action_cancel()
except Exception:
pass
env.cr.execute(
"UPDATE stock_picking SET state='cancel' WHERE id = ANY(%s)",
(pickings.ids,),
)
env.cr.execute(
"DELETE FROM stock_move_line WHERE picking_id = ANY(%s)",
(pickings.ids,),
)
env.cr.execute(
"DELETE FROM stock_move WHERE picking_id = ANY(%s)",
(pickings.ids,),
)
env.cr.execute(
"DELETE FROM stock_picking WHERE id = ANY(%s)",
(pickings.ids,),
)
print(' Deleted %d stock.picking rows' % n)
# Any remaining orphan stock.move rows
moves = env['stock.move'].sudo().search([])
n = len(moves)
if moves:
env.cr.execute(
"DELETE FROM stock_move_line WHERE move_id = ANY(%s)",
(moves.ids,),
)
env.cr.execute(
"DELETE FROM stock_move WHERE id = ANY(%s)",
(moves.ids,),
)
print(' Deleted %d stock.move rows' % n)
# 17. Sale orders (cancel any non-cancel state first). Delete ALL —
# demo data only.
sos = env['sale.order'].sudo().search([])
n = len(sos)
if sos:
for so in sos:
if so.state not in ('cancel', 'draft'):
try:
so.action_cancel()
except Exception:
env.cr.execute(
"UPDATE sale_order SET state='cancel' WHERE id=%s",
(so.id,),
)
env.invalidate_all()
# Drop SO lines explicitly to avoid FK trip on unlink
env.cr.execute(
"DELETE FROM sale_order_line WHERE order_id = ANY(%s)",
(sos.ids,),
)
env.cr.execute(
"DELETE FROM sale_order WHERE id = ANY(%s)",
(sos.ids,),
)
print(' Deleted %d sale.order rows' % n)
# 18. Quote requests
if 'fusion.plating.quote.request' in env:
qrs = env['fusion.plating.quote.request'].sudo().search([])
n = len(qrs)
if qrs:
try:
qrs.unlink()
except Exception:
env.cr.execute(
"DELETE FROM fusion_plating_quote_request WHERE id = ANY(%s)",
(qrs.ids,),
)
print(' Deleted %d fusion.plating.quote.request rows' % n)
# 19. Reset sequences for SO and invoices so new ones start fresh
for code in ('sale.order', 'account.move.invoice'):
seq = env['ir.sequence'].sudo().search([('code', '=', code)], limit=1)
if seq:
seq.number_next = 1
# 20. Reset fp.job sequence so new ones start from JOB/00001
seq = env['ir.sequence'].sudo().search([('code', '=', 'fp.job')], limit=1)
if seq:
seq.number_next = 1
print(' Reset fp.job sequence to start at 1')
env.cr.commit()
print('=== Cleanup complete ===')
try:
run(env)
except NameError:
print('Run inside `odoo shell`.')

View File

@@ -0,0 +1,487 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Native job migration: copies mrp.production / mrp.workorder records
# into fp.job / fp.job.step. Idempotent. Run from `odoo shell`.
#
# Strategy:
# 1. Verify the legacy_mrp_production_id / legacy_mrp_workorder_id
# idempotency-key fields exist on fp.job / fp.job.step. If missing,
# bail (the user must upgrade fusion_plating_jobs first).
# 2. For each MO: skip if already mirrored; else create fp.job with
# same name, partner, qty, dates, state, etc.
# 3. For each WO under MO: skip if already mirrored; else create
# fp.job.step with same name, work centre (mapped via legacy
# code), sequence, durations, state.
# 4. Time logs: copy mrp.workorder.time_ids if available.
# 5. Rebind cross-references on dependent models (defensive — only
# writes a value when the field exists on both sides AND the
# target field is currently empty).
# 6. Write audit log to /tmp/fp_jobs_migration.log.
#
# This is NOT an Odoo upgrade hook — it is an explicit cutover step.
# Run from `odoo shell -d <db>` so the surrounding transaction can be
# rolled back manually if the operator spots a problem (`env.cr.rollback()`).
# At the end of run() we env.cr.commit() — the operator can comment that
# out if they want to inspect changes before persisting.
#
# See ./README.md for usage.
import logging
from datetime import datetime
_logger = logging.getLogger('fp_jobs_migration')
# Map of mrp.production.state -> fp.job.state.
# fp.job.state values are defined in fusion_plating core (Phase 1 spec).
JOB_STATE_MAP = {
'draft': 'draft',
'confirmed': 'confirmed',
'progress': 'in_progress',
'to_close': 'in_progress',
'done': 'done',
'cancel': 'cancelled',
}
# Map of mrp.workorder.state -> fp.job.step.state
STEP_STATE_MAP = {
'pending': 'pending',
'waiting': 'pending',
'ready': 'ready',
'progress': 'in_progress',
'done': 'done',
'cancel': 'cancelled',
}
def map_work_centre(env, mrp_wc):
"""Find the fp.work.centre that corresponds to a mrp.workcenter.
Strategy: match by code. If no match, return False (the step will
have no work centre — operator can fix manually post-cutover).
"""
if not mrp_wc:
return False
if not mrp_wc.code:
return False
fp_wc = env['fp.work.centre'].search(
[('code', '=', mrp_wc.code)], limit=1,
)
return fp_wc.id if fp_wc else False
def _resolve_partner(env, mo):
"""Best-effort partner lookup for the MO.
Order of preference:
1. mo.x_fc_customer_id (some custom modules add this)
2. partner from sale.order matching mo.origin
3. mo.picking_type_id.warehouse_id.partner_id (warehouse address)
4. The company partner (last-resort placeholder for orphan MOs)
"""
if 'x_fc_customer_id' in mo._fields and mo.x_fc_customer_id:
return mo.x_fc_customer_id.id
if mo.origin:
so = env['sale.order'].search([('name', '=', mo.origin)], limit=1)
if so:
return so.partner_id.id
# Warehouse partner fallback (works for internal/transfer MOs)
if 'picking_type_id' in mo._fields and mo.picking_type_id:
wh = mo.picking_type_id.warehouse_id
if wh and wh.partner_id:
return wh.partner_id.id
# Last resort: company partner. This is a placeholder for orphan
# demo/legacy MOs that have no SO link and no warehouse partner.
# Audit log will flag these so they can be reassigned manually.
return mo.company_id.partner_id.id if mo.company_id else env.company.partner_id.id
def migrate_mo(env, mo, audit):
"""Migrate one mrp.production -> fp.job. Idempotent."""
Job = env['fp.job']
existing = Job.search(
[('legacy_mrp_production_id', '=', mo.id)], limit=1,
)
if existing:
audit['mo_skipped'] += 1
return existing
vals = {
'name': mo.name, # preserve WH/MO/00033 format
'partner_id': _resolve_partner(env, mo),
'product_id': mo.product_id.id if mo.product_id else False,
'qty': mo.product_qty,
'date_deadline': mo.date_deadline,
'date_planned_start': mo.date_start,
'date_finished': mo.date_finished,
'origin': mo.origin,
'state': JOB_STATE_MAP.get(mo.state, 'draft'),
'legacy_mrp_production_id': mo.id,
}
# Optional fields — only set when the source has them
if 'x_fc_facility_id' in mo._fields and mo.x_fc_facility_id:
if 'facility_id' in Job._fields:
vals['facility_id'] = mo.x_fc_facility_id.id
if 'x_fc_manager_id' in mo._fields and mo.x_fc_manager_id:
if 'manager_id' in Job._fields:
vals['manager_id'] = mo.x_fc_manager_id.id
if 'x_fc_recipe_id' in mo._fields and mo.x_fc_recipe_id:
if 'recipe_id' in Job._fields:
vals['recipe_id'] = mo.x_fc_recipe_id.id
if 'x_fc_portal_job_id' in mo._fields and mo.x_fc_portal_job_id:
if 'portal_job_id' in Job._fields:
vals['portal_job_id'] = mo.x_fc_portal_job_id.id
if 'x_fc_part_catalog_id' in mo._fields and mo.x_fc_part_catalog_id:
if 'part_catalog_id' in Job._fields:
vals['part_catalog_id'] = mo.x_fc_part_catalog_id.id
if 'x_fc_coating_config_id' in mo._fields and mo.x_fc_coating_config_id:
if 'coating_config_id' in Job._fields:
vals['coating_config_id'] = mo.x_fc_coating_config_id.id
# Bypass any auto-create lifecycle hooks while migrating — the source
# MO already had its hooks run when it was originally created. We
# don't want a second portal job / racking inspection / etc.
job = Job.with_context(
fp_jobs_migration=True,
tracking_disable=True,
mail_create_nosubscribe=True,
mail_create_nolog=True,
).create(vals)
audit['mo_migrated'] += 1
audit['jobs_created'].append(job.id)
return job
def migrate_wo(env, wo, job, audit):
"""Migrate one mrp.workorder -> fp.job.step. Idempotent."""
Step = env['fp.job.step']
existing = Step.search(
[('legacy_mrp_workorder_id', '=', wo.id)], limit=1,
)
if existing:
audit['wo_skipped'] += 1
return existing
wc_id = map_work_centre(env, wo.workcenter_id)
vals = {
'job_id': job.id,
'name': wo.name,
'sequence': wo.sequence or 10,
'state': STEP_STATE_MAP.get(wo.state, 'pending'),
'work_centre_id': wc_id,
'duration_expected': wo.duration_expected or 0.0,
'duration_actual': wo.duration or 0.0,
'date_started': wo.date_start,
'date_finished': wo.date_finished,
'legacy_mrp_workorder_id': wo.id,
}
if 'x_fc_recipe_node_id' in wo._fields and wo.x_fc_recipe_node_id:
if 'recipe_node_id' in Step._fields:
vals['recipe_node_id'] = wo.x_fc_recipe_node_id.id
if 'x_fc_assigned_user_id' in wo._fields and wo.x_fc_assigned_user_id:
if 'assigned_user_id' in Step._fields:
vals['assigned_user_id'] = wo.x_fc_assigned_user_id.id
if 'x_fc_thickness_target' in wo._fields and wo.x_fc_thickness_target:
if 'thickness_target' in Step._fields:
vals['thickness_target'] = wo.x_fc_thickness_target
if 'x_fc_dwell_time_minutes' in wo._fields and wo.x_fc_dwell_time_minutes:
if 'dwell_time_minutes' in Step._fields:
vals['dwell_time_minutes'] = wo.x_fc_dwell_time_minutes
step = Step.with_context(
fp_jobs_migration=True,
tracking_disable=True,
).create(vals)
audit['wo_migrated'] += 1
# Migrate time logs — only if both sides have a time-log model
if 'time_ids' in wo._fields and wo.time_ids \
and 'fp.job.step.timelog' in env:
TimeLog = env['fp.job.step.timelog']
for tl in wo.time_ids:
try:
TimeLog.create({
'step_id': step.id,
'user_id': tl.user_id.id if tl.user_id else env.user.id,
'date_started': tl.date_start,
'date_finished': tl.date_end,
})
except Exception as e:
_logger.warning(
'Failed to migrate time log %s on WO %s: %s',
tl.id, wo.name, e,
)
return step
def _safe_set(record, fname, value):
"""Set a field only when (a) the field exists and (b) is currently empty.
Returns True if a write happened, False otherwise. Catches exceptions
individually so one bad record doesn't sink the whole batch.
"""
if fname not in record._fields:
return False
current = record[fname]
# Many2one .id is 0 / False when empty; Char/Text empty string also OK
if current:
return False
try:
record[fname] = value
return True
except Exception as e:
_logger.warning(
'Failed to set %s.%s on id=%s: %s',
record._name, fname, record.id, e,
)
return False
def rebind_dependents(env, mo, job, audit):
"""Update cross-references on dependent models.
Only writes when:
- the target model is registered in env
- the target field exists on the model
- the target field is currently empty (idempotent)
Legacy production_id / workorder_id values are LEFT INTACT so the
shadow period can read both old and new linkages.
"""
# Build a step lookup by legacy WO id (used for batches and any other
# WO-scoped dependents).
step_by_wo = {}
if mo.workorder_ids:
Step = env['fp.job.step']
steps = Step.search([
('legacy_mrp_workorder_id', 'in', mo.workorder_ids.ids),
])
for s in steps:
step_by_wo[s.legacy_mrp_workorder_id] = s
# ---- fusion.plating.batch (workorder_id → x_fc_step_id) ----
if 'fusion.plating.batch' in env:
Batch = env['fusion.plating.batch']
for wo in mo.workorder_ids:
step = step_by_wo.get(wo.id)
if not step:
continue
batches = Batch.search([('workorder_id', '=', wo.id)])
for batch in batches:
if _safe_set(batch, 'x_fc_step_id', step.id):
audit['batches_rebound'] += 1
# batch may also have x_fc_job_id (the job-level link)
_safe_set(batch, 'x_fc_job_id', job.id)
# ---- fp.quality.hold (production_id → x_fc_job_id) ----
if 'fp.quality.hold' in env:
Hold = env['fp.quality.hold']
if 'production_id' in Hold._fields:
holds = Hold.search([('production_id', '=', mo.id)])
for h in holds:
if _safe_set(h, 'x_fc_job_id', job.id):
audit['holds_rebound'] += 1
# If the hold also has workorder_id, rebind to step
if 'workorder_id' in Hold._fields and h.workorder_id:
step = step_by_wo.get(h.workorder_id.id)
if step:
_safe_set(h, 'x_fc_step_id', step.id)
# ---- fusion.plating.quality.hold (legacy fallback name) ----
if 'fusion.plating.quality.hold' in env:
Hold2 = env['fusion.plating.quality.hold']
if 'production_id' in Hold2._fields:
holds = Hold2.search([('production_id', '=', mo.id)])
for h in holds:
if _safe_set(h, 'x_fc_job_id', job.id):
audit['holds_rebound'] += 1
if 'workorder_id' in Hold2._fields and h.workorder_id:
step = step_by_wo.get(h.workorder_id.id)
if step:
_safe_set(h, 'x_fc_step_id', step.id)
# ---- fp.certificate (production_id → x_fc_job_id) ----
if 'fp.certificate' in env:
Cert = env['fp.certificate']
if 'production_id' in Cert._fields:
certs = Cert.search([('production_id', '=', mo.id)])
for c in certs:
if _safe_set(c, 'x_fc_job_id', job.id):
audit['certs_rebound'] += 1
# ---- fp.thickness.reading (production_id → x_fc_job_id, optional step) ----
if 'fp.thickness.reading' in env:
TR = env['fp.thickness.reading']
if 'production_id' in TR._fields:
readings = TR.search([('production_id', '=', mo.id)])
for r in readings:
if _safe_set(r, 'x_fc_job_id', job.id):
audit['readings_rebound'] += 1
if 'workorder_id' in TR._fields and r.workorder_id:
step = step_by_wo.get(r.workorder_id.id)
if step:
_safe_set(r, 'x_fc_step_id', step.id)
# ---- fusion.plating.portal.job (mo.x_fc_portal_job_id → x_fc_job_id) ----
if 'fusion.plating.portal.job' in env \
and 'x_fc_portal_job_id' in mo._fields \
and mo.x_fc_portal_job_id:
portal = mo.x_fc_portal_job_id
if _safe_set(portal, 'x_fc_job_id', job.id):
audit['portals_rebound'] += 1
# ---- fp.racking.inspection (production_id → x_fc_job_id) ----
if 'fp.racking.inspection' in env:
Insp = env['fp.racking.inspection']
if 'production_id' in Insp._fields:
insps = Insp.search([('production_id', '=', mo.id)])
for i in insps:
if _safe_set(i, 'x_fc_job_id', job.id):
audit['inspections_rebound'] += 1
# ---- fusion.plating.delivery (job_ref Char → x_fc_job_id Many2one) ----
if 'fusion.plating.delivery' in env:
Delivery = env['fusion.plating.delivery']
if 'job_ref' in Delivery._fields:
deliveries = Delivery.search([('job_ref', '=', mo.name)])
for d in deliveries:
if _safe_set(d, 'x_fc_job_id', job.id):
audit['deliveries_rebound'] += 1
def run(env):
"""Main entry point. Call as `run(env)` from `odoo shell`.
Returns the audit dict (also written to /tmp/fp_jobs_migration.log).
Commits the transaction at the end. To dry-run, comment out
`env.cr.commit()` below or pass `--no-http` and `env.cr.rollback()`
after inspecting the result.
"""
audit = {
'started_at': datetime.now().isoformat(),
'mo_migrated': 0,
'mo_skipped': 0,
'wo_migrated': 0,
'wo_skipped': 0,
'batches_rebound': 0,
'holds_rebound': 0,
'certs_rebound': 0,
'readings_rebound': 0,
'portals_rebound': 0,
'inspections_rebound': 0,
'deliveries_rebound': 0,
'errors': [],
'jobs_created': [],
}
# Verify the idempotency-key fields exist before doing anything.
# If they're missing, the operator forgot to upgrade
# fusion_plating_jobs to v19.0.2.0.0+ and we'd create duplicates on
# every run.
if 'legacy_mrp_production_id' not in env['fp.job']._fields:
msg = (
'fp.job.legacy_mrp_production_id field missing — upgrade '
'fusion_plating_jobs to v19.0.2.0.0+ before running this '
'script.'
)
print(msg)
_logger.error(msg)
return None
if 'legacy_mrp_workorder_id' not in env['fp.job.step']._fields:
msg = (
'fp.job.step.legacy_mrp_workorder_id field missing — upgrade '
'fusion_plating_jobs to v19.0.2.0.0+ before running this '
'script.'
)
print(msg)
_logger.error(msg)
return None
print('=== Migration starting ===')
# The fp_jobs_migration context flag tells fp.job.action_confirm and
# fp.job.button_mark_done to skip lifecycle side-effects (creating
# portal jobs, QC checks, racking inspections, deliveries, certs,
# notifications). The migration script rebinds existing records via
# x_fc_job_id directly — so the side-effects would create duplicates.
env = env(context=dict(env.context, fp_jobs_migration=True))
MO = env['mrp.production']
all_mos = MO.search([])
print('Migrating %d MOs and their WOs...' % len(all_mos))
for mo in all_mos:
# Wrap each MO migration in a savepoint so a failure on one
# MO doesn't abort the whole transaction (which would cascade
# "current transaction is aborted" errors on every subsequent
# MO and prevent any successful migration from committing).
try:
with env.cr.savepoint():
job = migrate_mo(env, mo, audit)
for wo in mo.workorder_ids:
try:
with env.cr.savepoint():
migrate_wo(env, wo, job, audit)
except Exception as e:
audit['errors'].append({
'wo': wo.id,
'wo_name': wo.name,
'mo': mo.id,
'error': str(e),
})
_logger.error(
'Migration failed for WO %s (MO %s): %s',
wo.name, mo.name, e,
)
rebind_dependents(env, mo, job, audit)
except Exception as e:
audit['errors'].append({
'mo': mo.id,
'name': mo.name,
'error': str(e),
})
_logger.error('Migration failed for MO %s: %s', mo.name, e)
audit['finished_at'] = datetime.now().isoformat()
print('=== Migration finished ===')
print('MOs migrated:', audit['mo_migrated'],
'(skipped:', audit['mo_skipped'], ')')
print('WOs migrated:', audit['wo_migrated'],
'(skipped:', audit['wo_skipped'], ')')
print('Batches rebound:', audit['batches_rebound'])
print('Holds rebound:', audit['holds_rebound'])
print('Certs rebound:', audit['certs_rebound'])
print('Readings rebound:', audit['readings_rebound'])
print('Portals rebound:', audit['portals_rebound'])
print('Inspections rebound:', audit['inspections_rebound'])
print('Deliveries rebound:', audit['deliveries_rebound'])
print('Errors:', len(audit['errors']))
# Write audit log
try:
with open('/tmp/fp_jobs_migration.log', 'a') as f:
f.write('\n=== Migration run at %s ===\n' % audit['started_at'])
for k, v in audit.items():
if k == 'jobs_created':
f.write('%s: %d records\n' % (k, len(v)))
elif k == 'errors':
f.write('errors: %d\n' % len(v))
for err in v:
f.write(' %s\n' % err)
else:
f.write('%s: %s\n' % (k, v))
except Exception as e:
print('Could not write audit log:', e)
# Commit. Comment this out to dry-run.
env.cr.commit()
return audit
# Run when exec'd from odoo shell
try:
result = run(env) # noqa: F821 — `env` is provided by odoo shell
except NameError:
print(
'This script expects to run inside `odoo shell` where `env` is defined.'
)

View File

@@ -0,0 +1,434 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Seeds 5-8 fp.job rows in each lifecycle state to simulate a live
# shop floor. Run after cleanup_demo_data.py.
#
# Strategy:
# 1. Find seedable customer/part combos. Prefer parts with a coating
# (so the SO-confirm flow runs end-to-end), but fall back to
# direct fp.job creation with the only available recipe so we get
# customer variety.
# 2. For each target state, create N jobs and manipulate their
# lifecycle state + step state to simulate a live shop.
#
# Usage: load this file from inside `odoo shell` via the standard
# pattern documented in scripts/README.md.
from datetime import datetime, timedelta
import random
random.seed(42) # reproducible
def _build_combos(env):
"""Return two lists:
- via_so: (partner, part, coating) - for SO-confirm flow
- direct: (partner, part_or_None, recipe) - for direct fp.job create
`via_so` requires a part with x_fc_default_coating_config_id whose
recipe_id is set. `direct` covers all other customers/parts.
"""
via_so = []
direct = []
# Prefer the canonical recipe; fall back to any recipe with operations.
recipe = env['fusion.plating.process.node'].search([
('node_type', '=', 'recipe'),
('name', '=', 'ENP-ALUM-BASIC'),
], limit=1)
if not recipe:
recipe = env['fusion.plating.process.node'].search([
('node_type', '=', 'recipe'),
], limit=1)
if not recipe:
print('ERROR: no recipes found. Cannot seed.')
return via_so, direct, None
parts = env['fp.part.catalog'].search([])
for p in parts:
if not p.partner_id:
continue
if p.x_fc_default_coating_config_id and p.x_fc_default_coating_config_id.recipe_id:
via_so.append((p.partner_id, p, p.x_fc_default_coating_config_id))
else:
direct.append((p.partner_id, p, recipe))
return via_so, direct, recipe
def _create_so(env, partner, part, coating, qty, deadline_offset_days):
"""Create + confirm a SO with one plating line. Returns (so, job)."""
# fp.part.catalog has no product_id field — use a generic product
# for the SO line. Plating-specific fields (x_fc_part_catalog_id,
# x_fc_coating_config_id) carry the real linkage.
fallback_product = env['product.product'].search(
[('sale_ok', '=', True)], limit=1)
if not fallback_product:
fallback_product = env['product.product'].search([], limit=1)
line_vals = {
'product_id': fallback_product.id,
'product_uom_qty': qty,
'price_unit': 50.0 + qty * 2,
}
SOL_fields = env['sale.order.line']._fields
if 'x_fc_part_catalog_id' in SOL_fields:
line_vals['x_fc_part_catalog_id'] = part.id
if 'x_fc_coating_config_id' in SOL_fields:
line_vals['x_fc_coating_config_id'] = coating.id
so = env['sale.order'].sudo().create({
'partner_id': partner.id,
'client_order_ref': 'SEED-%s' % datetime.now().strftime('%H%M%S%f')[:10],
'commitment_date': datetime.now() + timedelta(days=deadline_offset_days),
'order_line': [(0, 0, line_vals)],
})
try:
so.action_confirm()
except Exception as e:
print(' WARN: SO confirm failed for %s (%s) - %s' % (so.name, partner.name, e))
return so, env['fp.job']
job = env['fp.job'].sudo().search([('sale_order_id', '=', so.id)], limit=1)
return so, job
def _create_job_direct(env, partner, part, recipe, qty, deadline_offset_days):
"""Direct fp.job create (skips the SO-confirm hook)."""
Job = env['fp.job'].sudo()
vals = {
'partner_id': partner.id,
'qty': qty,
'date_deadline': datetime.now() + timedelta(days=deadline_offset_days),
'recipe_id': recipe.id,
'priority': random.choice(['low', 'normal', 'normal', 'high']),
'quoted_revenue': 50.0 + qty * 2,
}
if part:
vals['part_catalog_id'] = part.id
# fp.part.catalog has no product_id field — leave fp.job.product_id
# null. It's an optional field used as a "Reference Product".
return Job.create(vals)
def _operators(env):
g = env.ref('fusion_plating.group_fusion_plating_operator',
raise_if_not_found=False)
if not g:
return env['res.users']
# Odoo 19: group <-> users m2m field on res.users is `all_group_ids`
return env['res.users'].search([('all_group_ids', 'in', g.id)])
def _confirm_and_steps(env, job):
"""Drive a draft job through action_confirm + step generation."""
if not job:
return
if job.state == 'draft':
try:
job.action_confirm()
except Exception as e:
print(' WARN: job %s action_confirm failed: %s' % (job.name, e))
return
if job.recipe_id and not job.step_ids:
try:
job._generate_steps_from_recipe()
except Exception as e:
print(' WARN: job %s step gen failed: %s' % (job.name, e))
def run(env):
print('=== Seeding fresh demo data ===')
via_so, direct, recipe = _build_combos(env)
print(' via_so combos: %d' % len(via_so))
print(' direct combos: %d' % len(direct))
print(' recipe: %s' % (recipe.name if recipe else 'NONE'))
if not recipe:
return
if not direct and not via_so:
print('ERROR: no combos available. Cannot seed.')
return
operators = _operators(env)
print(' operators: %d' % len(operators))
counts = {
'draft': 5,
'confirmed': 6,
'in_progress': 8,
'on_hold': 3,
'done': 6,
'cancelled': 3,
}
via_so_idx = 0
direct_idx = 0
def _next_via_so():
nonlocal via_so_idx
if not via_so:
return None
c = via_so[via_so_idx % len(via_so)]
via_so_idx += 1
return c
def _next_direct():
nonlocal direct_idx
if not direct:
return None
c = direct[direct_idx % len(direct)]
direct_idx += 1
return c
def _next_combo(prefer_so=False):
if prefer_so and via_so:
return ('so', _next_via_so())
if direct:
return ('direct', _next_direct())
if via_so:
return ('so', _next_via_so())
return (None, None)
created = {state: [] for state in counts}
# 1. DRAFT - direct create, do NOT confirm
print('-- Creating draft jobs --')
for i in range(counts['draft']):
kind, combo = _next_combo()
if not combo:
break
partner, part, coating_or_recipe = combo
if kind == 'so':
job = _create_job_direct(
env, partner, part, coating_or_recipe.recipe_id,
qty=random.choice([1, 5, 10, 25, 50]),
deadline_offset_days=random.randint(7, 30),
)
if part.x_fc_default_coating_config_id:
job.coating_config_id = part.x_fc_default_coating_config_id.id
else:
job = _create_job_direct(
env, partner, part, coating_or_recipe,
qty=random.choice([1, 5, 10, 25, 50]),
deadline_offset_days=random.randint(7, 30),
)
created['draft'].append(job)
print(' draft: %s (%s)' % (job.name, partner.name))
# 2. CONFIRMED
print('-- Creating confirmed jobs --')
for i in range(counts['confirmed']):
prefer_so = (i % 2 == 0)
kind, combo = _next_combo(prefer_so=prefer_so)
if not combo:
break
partner, part, coating_or_recipe = combo
if kind == 'so':
so, job = _create_so(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10, 25, 50, 100]),
deadline_offset_days=random.randint(5, 25),
)
_confirm_and_steps(env, job)
else:
job = _create_job_direct(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10, 25, 50, 100]),
deadline_offset_days=random.randint(5, 25),
)
_confirm_and_steps(env, job)
if job:
created['confirmed'].append(job)
print(' confirmed: %s (%s, %d steps)' % (
job.name, partner.name, len(job.step_ids)))
# 3. IN_PROGRESS
print('-- Creating in_progress jobs --')
for i in range(counts['in_progress']):
kind, combo = _next_combo()
if not combo:
break
partner, part, coating_or_recipe = combo
if kind == 'so':
so, job = _create_so(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10, 25, 50]),
deadline_offset_days=random.randint(3, 15),
)
else:
job = _create_job_direct(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10, 25, 50]),
deadline_offset_days=random.randint(3, 15),
)
if not job:
continue
_confirm_and_steps(env, job)
job.state = 'in_progress'
job.date_started = datetime.now() - timedelta(days=random.randint(1, 5))
steps = job.step_ids.sorted('sequence')
if not steps:
print(' WARN: in_progress job %s has no steps' % job.name)
created['in_progress'].append(job)
continue
for s in steps:
if operators:
s.assigned_user_id = operators[
random.randrange(len(operators))
]
n_done = max(1, int(len(steps) * random.uniform(0.3, 0.6)))
for s in steps[:n_done]:
s.state = 'done'
s.date_started = datetime.now() - timedelta(
hours=random.randint(2, 48))
s.date_finished = s.date_started + timedelta(
minutes=random.randint(15, 240))
s.duration_actual = (
s.date_finished - s.date_started).total_seconds() / 60.0
s.started_by_user_id = s.assigned_user_id or env.user
s.finished_by_user_id = s.assigned_user_id or env.user
if n_done < len(steps):
cur = steps[n_done]
cur.state = 'in_progress'
cur.date_started = datetime.now() - timedelta(
minutes=random.randint(5, 90))
cur.started_by_user_id = cur.assigned_user_id or env.user
env['fp.job.step.timelog'].sudo().create({
'step_id': cur.id,
'user_id': (cur.assigned_user_id.id
if cur.assigned_user_id else env.user.id),
'date_started': cur.date_started,
})
if n_done + 1 < len(steps):
steps[n_done + 1].state = 'ready'
created['in_progress'].append(job)
print(' in_progress: %s (%s, %d/%d done)' % (
job.name, partner.name, n_done, len(steps)))
# 4. ON_HOLD
print('-- Creating on_hold jobs --')
for i in range(counts['on_hold']):
kind, combo = _next_combo()
if not combo:
break
partner, part, coating_or_recipe = combo
if kind == 'so':
so, job = _create_so(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10, 25]),
deadline_offset_days=random.randint(5, 20),
)
else:
job = _create_job_direct(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10, 25]),
deadline_offset_days=random.randint(5, 20),
)
if not job:
continue
_confirm_and_steps(env, job)
steps = job.step_ids.sorted('sequence')
for s in steps[:2]:
s.state = 'done'
s.date_finished = datetime.now() - timedelta(days=1)
s.date_started = s.date_finished - timedelta(minutes=60)
s.duration_actual = 60.0
if len(steps) > 2:
steps[2].state = 'paused'
steps[2].date_started = datetime.now() - timedelta(hours=4)
job.state = 'on_hold'
created['on_hold'].append(job)
print(' on_hold: %s (%s)' % (job.name, partner.name))
# 5. DONE
print('-- Creating done jobs --')
for i in range(counts['done']):
kind, combo = _next_combo()
if not combo:
break
partner, part, coating_or_recipe = combo
if kind == 'so':
so, job = _create_so(
env, partner, part, coating_or_recipe,
qty=random.choice([1, 5, 10, 25]),
deadline_offset_days=random.randint(-5, 5),
)
else:
job = _create_job_direct(
env, partner, part, coating_or_recipe,
qty=random.choice([1, 5, 10, 25]),
deadline_offset_days=random.randint(-5, 5),
)
if not job:
continue
_confirm_and_steps(env, job)
steps = job.step_ids.sorted('sequence')
for j, s in enumerate(steps):
s.state = 'done'
offset = (len(steps) - j) * 30
s.date_started = datetime.now() - timedelta(minutes=offset + 30)
s.date_finished = datetime.now() - timedelta(minutes=offset)
s.duration_actual = 30.0
if operators:
op = operators[random.randrange(len(operators))]
s.assigned_user_id = op
s.started_by_user_id = op
s.finished_by_user_id = op
# Set state directly to avoid downstream side effects (delivery
# + cert auto-create) on demo data.
job.state = 'done'
job.date_finished = datetime.now() - timedelta(
hours=random.randint(1, 48))
job.date_started = datetime.now() - timedelta(days=2)
created['done'].append(job)
print(' done: %s (%s)' % (job.name, partner.name))
# 6. CANCELLED
print('-- Creating cancelled jobs --')
for i in range(counts['cancelled']):
kind, combo = _next_combo()
if not combo:
break
partner, part, coating_or_recipe = combo
if kind == 'so':
so, job = _create_so(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10]),
deadline_offset_days=random.randint(10, 30),
)
else:
job = _create_job_direct(
env, partner, part, coating_or_recipe,
qty=random.choice([5, 10]),
deadline_offset_days=random.randint(10, 30),
)
if not job:
continue
_confirm_and_steps(env, job)
try:
job.action_cancel()
except Exception:
job.state = 'cancelled'
created['cancelled'].append(job)
print(' cancelled: %s (%s)' % (job.name, partner.name))
env.cr.commit()
print()
print('=== Seed summary ===')
for state, jobs in created.items():
print(' %s: %d jobs' % (state, len(jobs)))
print()
print('=== Verification ===')
Job = env['fp.job']
for state in counts:
print(' fp.job state=%s: actual=%d' % (
state, Job.search_count([('state', '=', state)])))
try:
run(env)
except NameError:
print('Run inside `odoo shell`.')

View File

@@ -0,0 +1,777 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# seed_direct_orders.py
# =====================
# Creates 8-12 sale orders that originate from the estimator's
# direct-order-entry path -- i.e. via fp.direct.order.wizard.action_create_order
# -- instead of plain sale.order.create. This exercises the wizard
# code path which currently has zero seeded data.
#
# The wizard:
# - Validates PO# / PO doc OR po_pending flag (we use po_pending for some)
# - Creates the SO in DRAFT state with one SO line per wizard line
# - Returns an action with res_id pointing at the new SO
# - Does NOT auto-confirm (Sub 1 deliberately removed auto-confirm)
#
# So this script:
# 1. Builds a wizard with realistic header fields + 1-3 lines
# 2. Calls action_create_order() to materialise the draft SO
# 3. Calls so.action_confirm() to fire job creation (the ON-confirm
# _fp_auto_create_job hook builds the fp.job + steps)
# 4. Optionally advances the resulting job/SO across workflow states,
# reusing the helpers from seed_workflow_states.py
#
# Distribution of 8-12 orders across states (matches client request):
# - 3 stay at "Confirmed / Job just generated steps"
# - 3 advance to "Job In Progress (mid)"
# - 2 advance to "Job Done / Delivery Scheduled"
# - 2 advance all the way to "Delivered + Invoice Posted"
# - 1-2 advance to "Paid"
# Total = 11-12 orders.
#
# Each order is wrapped in its own savepoint -- failure on one doesn't
# nuke the whole run. Savepoint names are alphanumeric only because
# Postgres rejects parens/dots in identifiers.
#
# Usage: see scripts/README.md.
from datetime import datetime, timedelta
import base64
import random
import logging
random.seed(2027)
_logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------- #
# Combo / context helpers (mirror seed_workflow_states.py) #
# ---------------------------------------------------------------------- #
def _build_combos(env):
"""List of (partner, part, coating) tuples that have a recipe."""
combos = []
parts = env["fp.part.catalog"].search([
("x_fc_default_coating_config_id", "!=", False),
("x_fc_default_coating_config_id.recipe_id", "!=", False),
("partner_id", "!=", False),
])
for p in parts:
combos.append((p.partner_id, p, p.x_fc_default_coating_config_id))
random.shuffle(combos)
return combos
def _operators(env):
g = env.ref("fusion_plating.group_fusion_plating_operator",
raise_if_not_found=False)
if not g:
return env["res.users"]
return env["res.users"].search([("all_group_ids", "in", g.id)])
def _managers(env):
g = env.ref("fusion_plating.group_fusion_plating_manager",
raise_if_not_found=False)
if not g:
return env["res.users"]
return env["res.users"].search([("all_group_ids", "in", g.id)])
def _employees(env):
return env["hr.employee"].search([])
def _resolve_payment_term(env):
pt = env["account.payment.term"].search(
[("name", "=", "30 Days")], limit=1)
if not pt:
pt = env["account.payment.term"].search([], limit=1)
return pt
def _resolve_journals(env):
sales = env["account.journal"].search([("type", "=", "sale")], limit=1)
bank = env["account.journal"].search([("type", "=", "bank")], limit=1)
return sales, bank
def _resolve_facility(env):
return env["fusion.plating.facility"].search([], limit=1)
def _selection_values(model, fname):
"""Return the list of valid keys for a Selection field, or []."""
fld = model._fields.get(fname)
if not fld or fld.type != "selection":
return []
sel = fld.selection
if callable(sel):
try:
sel = sel(model)
except Exception:
return []
return [k for (k, _label) in sel] if sel else []
# ---------------------------------------------------------------------- #
# Wizard build #
# ---------------------------------------------------------------------- #
def _pick_treatments(env, n):
"""Return a recordset of n treatments (or empty if treatments missing)."""
Treatment = env.get("fp.treatment")
if Treatment is None:
return env["fp.treatment"].browse([]) if "fp.treatment" in env else None
pool = env["fp.treatment"].search([], limit=20)
if not pool or n <= 0:
return env["fp.treatment"].browse([])
n = min(n, len(pool))
picked = random.sample(list(pool), n)
return env["fp.treatment"].browse([t.id for t in picked])
def _build_wizard(env, partner, lines_data, ctx, idx):
"""Build a fp.direct.order.wizard with realistic header + lines.
`lines_data` is a list of dicts, one per line, each with keys:
part, coating, quantity, unit_price, ...
"""
Wizard = env["fp.direct.order.wizard"].sudo()
WLine = env["fp.direct.order.line"].sudo()
addrs = partner.address_get(["invoice", "delivery"])
cust_dl = (datetime.now() + timedelta(days=random.randint(7, 30))).date()
int_dl = cust_dl - timedelta(days=3)
plan_start = (datetime.now() + timedelta(days=random.randint(1, 5))).date()
po_exp = cust_dl - timedelta(days=random.randint(2, 7))
delivery_methods = _selection_values(Wizard, "delivery_method")
invoice_strategies = _selection_values(Wizard, "invoice_strategy")
notes_pool = [
"Direct entry by estimator -- repeat customer, standard ENP per "
"AMS-2404. Rush capacity if available.",
"Customer phoned in PO -- bulk re-order of last month's run. Use "
"same recipe, same masking. Confirm thickness on first piece.",
"Standing order -- expedite if over 10% of capacity is free. "
"Mask threads as before. CoC + thickness report required.",
"Estimator entry, customer requires same-day acknowledgment. "
"Watch for hex / barrel mix on the racks.",
"Direct re-order, shipper to call ahead before pickup. Pack in "
"original boxes. No partial shipments.",
]
po_pending = (random.random() < 0.30)
has_po_doc = (random.random() < 0.40) if not po_pending else False
wiz_vals = {
"partner_id": partner.id,
"partner_invoice_id": addrs.get("invoice") or partner.id,
"partner_shipping_id": addrs.get("delivery") or partner.id,
"customer_job_number": "CJN-D%04d" % idx,
"planned_start_date": plan_start,
"internal_deadline": int_dl,
"customer_deadline": cust_dl,
"is_blanket_order": (random.random() < 0.20),
"block_partial_shipments": (random.random() < 0.30),
"po_pending": po_pending,
"po_expected_date": po_exp if po_pending else False,
"po_number": False if po_pending else "PO-D%04d" % idx,
"notes": random.choice(notes_pool),
}
if delivery_methods:
wiz_vals["delivery_method"] = random.choice(delivery_methods)
if invoice_strategies:
strat = random.choice(invoice_strategies)
wiz_vals["invoice_strategy"] = strat
if strat == "deposit":
wiz_vals["deposit_percent"] = random.choice([15.0, 25.0, 33.0])
elif strat == "progress":
wiz_vals["progress_initial_percent"] = random.choice(
[40.0, 50.0, 60.0])
# Attach a fake PO doc if we need one
if has_po_doc and not po_pending:
fake_pdf = b"%PDF-1.4 fake po placeholder for seed data\n%%EOF\n"
wiz_vals["po_attachment_file"] = base64.b64encode(fake_pdf).decode()
wiz_vals["po_attachment_filename"] = "po_seed_%04d.pdf" % idx
elif not po_pending and not has_po_doc:
# Wizard requires either a PO doc OR po_pending -- force a doc
# if we got here with neither. Better to attach than to fail.
fake_pdf = b"%PDF-1.4 fake po placeholder for seed data\n%%EOF\n"
wiz_vals["po_attachment_file"] = base64.b64encode(fake_pdf).decode()
wiz_vals["po_attachment_filename"] = "po_seed_%04d.pdf" % idx
wizard = Wizard.create(wiz_vals)
# Build a shared wo_group_tag for ~30% of orders so multiple lines
# roll up into one job (tests the multi-line-collapse path)
use_group_tag = (len(lines_data) > 1) and (random.random() < 0.30)
group_tag = "G%d" % random.randint(1, 9) if use_group_tag else False
surface_area_uoms = _selection_values(WLine, "surface_area_uom")
line_descs = [
"Mask threads, ENP per AMS-2404 Class 4. Pack in vendor boxes.",
"Standard ENP, 0.0005-0.001 inch thickness. Bake 4hr @ 400F.",
"Re-work job: strip + replate. Verify base before activation.",
"Heavy duty ENP, mid-phos. Mask all threaded holes per drawing.",
"Light ENP barrier, mil-spec. Customer requires CoC + thickness.",
]
int_descs = [
"Mask 1/4-20 threads. ENP per AMS-2404 Class 4 mid-phos. "
"Watch for racking marks.",
"Standard alkaline EN bath. Target 0.0005 in. Spot-check 5 pcs "
"with Fischerscope before bake.",
"Strip in nitric, neutralise, activate. Replate to drawing spec. "
"First-piece check required.",
"Heavy ENP -- expect 6+ hr in tank. Mask blind holes per "
"engineering note. Bake 4hr @ 400F.",
"Light barrier coat for corrosion. CoC + thickness report on "
"delivery. No exceptions on cleanliness.",
]
wo_descs = [
"ENP plating, mask threads, pack in vendor boxes.",
"Standard ENP run, mid-phos, bake 4hr.",
"Strip + replate, verify base material first.",
"Heavy ENP, 6+hr tank, mask all blind holes.",
"Light ENP barrier, full QC pack-out.",
]
for ld in lines_data:
part = ld["part"]
coating = ld["coating"]
qty = ld["quantity"]
price = ld["unit_price"]
treatments = _pick_treatments(env, random.randint(0, 2))
line_vals = {
"wizard_id": wizard.id,
"part_catalog_id": part.id,
"coating_config_id": coating.id,
"quantity": qty,
"unit_price": price,
"line_description": random.choice(line_descs),
"internal_description": random.choice(int_descs),
"part_wo_description": random.choice(wo_descs),
"rush_order": (random.random() < 0.15),
"is_one_off": False,
"push_to_defaults": False,
}
if treatments:
line_vals["treatment_ids"] = [(6, 0, treatments.ids)]
if group_tag:
line_vals["wo_group_tag"] = group_tag
# Per-line deadline within the order window
line_vals["part_deadline"] = (
cust_dl - timedelta(days=random.randint(0, 3))
)
WLine.create(line_vals)
return wizard
def _create_so_via_wizard(env, partner, combos_for_partner, n_lines, idx):
"""Build wizard, run action_create_order, return the SO record."""
if not combos_for_partner:
return None
# Allow up to n_lines distinct combos for this partner; if the partner
# only has one, just repeat it (different qty / price).
chosen = []
pool = list(combos_for_partner)
random.shuffle(pool)
while len(chosen) < n_lines and pool:
chosen.append(pool.pop())
while len(chosen) < n_lines:
chosen.append(random.choice(combos_for_partner))
lines_data = []
for (_partner, part, coating) in chosen:
lines_data.append({
"part": part,
"coating": coating,
"quantity": random.randint(5, 100),
"unit_price": round(random.uniform(50.0, 300.0), 2),
})
wizard = _build_wizard(env, partner, lines_data, None, idx)
action = wizard.action_create_order()
if not action or not action.get("res_id"):
return None
return env["sale.order"].browse(action["res_id"])
# ---------------------------------------------------------------------- #
# State-advancement helpers (adapted from seed_workflow_states.py) #
# ---------------------------------------------------------------------- #
def _ensure_steps(env, job):
if not job or not job.recipe_id or job.step_ids:
return
try:
job._generate_steps_from_recipe()
except Exception as e:
_logger.warning("Step gen failed for %s: %s", job.name, e)
def _populate_job(env, job, ctx):
if not job:
return
vals = {}
if ctx["facility"] and not job.facility_id:
vals["facility_id"] = ctx["facility"].id
if ctx["managers"] and not job.manager_id:
vals["manager_id"] = random.choice(ctx["managers"]).id
if not job.priority or job.priority == "normal":
vals["priority"] = random.choices(
["low", "normal", "high", "rush"],
weights=[10, 70, 15, 5],
)[0]
if vals:
job.write(vals)
def _assign_step_users(env, job, ctx, n_done=0, current_idx=None):
operators = ctx["operators"]
steps = job.step_ids.sorted("sequence")
if not steps:
return
for s in steps:
if operators and not s.assigned_user_id:
s.assigned_user_id = operators[
random.randrange(len(operators))]
base = datetime.now() - timedelta(hours=len(steps) * 2)
for i, s in enumerate(steps[:n_done]):
start = base + timedelta(hours=i * 2)
finish = start + timedelta(minutes=random.randint(20, 90))
uid = (s.assigned_user_id.id
if s.assigned_user_id else env.user.id)
s.write({
"state": "done",
"date_started": start,
"date_finished": finish,
"duration_actual": (finish - start).total_seconds() / 60.0,
"started_by_user_id": uid,
"finished_by_user_id": uid,
})
env["fp.job.step.timelog"].sudo().create({
"step_id": s.id,
"user_id": uid,
"date_started": start,
"date_finished": finish,
"duration_minutes": (finish - start).total_seconds() / 60.0,
})
if current_idx is not None and current_idx < len(steps):
cur = steps[current_idx]
if cur.state != "done":
start = datetime.now() - timedelta(
minutes=random.randint(5, 90))
uid = (cur.assigned_user_id.id
if cur.assigned_user_id else env.user.id)
cur.write({
"state": "in_progress",
"date_started": start,
"started_by_user_id": uid,
})
env["fp.job.step.timelog"].sudo().create({
"step_id": cur.id,
"user_id": uid,
"date_started": start,
})
if current_idx is not None and current_idx + 1 < len(steps):
nxt = steps[current_idx + 1]
if nxt.state == "pending":
nxt.write({"state": "ready"})
def _make_delivery_full(env, delivery, partner, ctx, state,
scheduled_offset_days=1):
if not delivery:
return
employees = ctx["employees"]
facility = ctx["facility"]
vals = {
"delivery_address_id": partner.id,
"contact_name": partner.name,
"contact_phone": partner.phone or (
"555-%04d" % random.randint(1000, 9999)),
"scheduled_date": datetime.now() + timedelta(
days=scheduled_offset_days),
}
if "x_fc_box_count_out" in delivery._fields:
vals["x_fc_box_count_out"] = random.randint(1, 5)
if employees and "assigned_driver_id" in delivery._fields:
vals["assigned_driver_id"] = employees[
random.randrange(len(employees))].id
if facility and "source_facility_id" in delivery._fields:
vals["source_facility_id"] = facility.id
if "notes" in delivery._fields:
vals["notes"] = (
"<p>Direct-order delivery -- pack in original boxes per "
"customer SOP.</p>")
delivery.write(vals)
delivery.write({"state": state})
if state == "delivered":
delivery.write({"delivered_at": datetime.now() - timedelta(
hours=random.randint(1, 48))})
def _issue_certificate(env, job, so, part, ctx):
cert = env["fp.certificate"].search(
[("x_fc_job_id", "=", job.id)], limit=1)
if not cert:
cert = env["fp.certificate"].sudo().create({
"partner_id": job.partner_id.id,
"certificate_type": "coc",
"state": "draft",
"x_fc_job_id": job.id,
"sale_order_id": so.id if so else False,
})
vals = {
"state": "issued",
"issue_date": datetime.now().date(),
"issued_by_id": env.user.id,
"entech_wo_number": job.name,
"customer_job_no": (so.x_fc_customer_job_number
if so and "x_fc_customer_job_number" in so._fields
else (so.client_order_ref if so else "")),
"po_number": (so.x_fc_po_number
if so and "x_fc_po_number" in so._fields else ""),
"quantity_shipped": int(job.qty or 1),
"part_number": part.part_number or part.name,
"process_description": "Electroless Nickel Plating, MIL-C-26074",
}
if "spec_min_mils" in cert._fields and part.x_fc_default_coating_config_id:
c = part.x_fc_default_coating_config_id
if c.thickness_min:
vals["spec_min_mils"] = c.thickness_min
if c.thickness_max:
vals["spec_max_mils"] = c.thickness_max
vals["spec_reference"] = c.spec_reference or "AMS-2404"
cert.write(vals)
for i in range(5):
env["fp.thickness.reading"].sudo().create({
"certificate_id": cert.id,
"reading_number": i + 1,
"nip_mils": round(random.uniform(0.95, 1.15), 3),
"ni_percent": round(random.uniform(88.0, 92.0), 2),
"p_percent": round(random.uniform(8.0, 12.0), 2),
"position_label": "Pos %d" % (i + 1),
"reading_datetime": datetime.now() - timedelta(
minutes=30 - i * 5),
"operator_id": env.user.id,
"x_fc_job_id": job.id,
"equipment_model": "Fischerscope X-Ray XDV-SD",
"calibration_std_ref": "CAL-2026-04-01",
})
return cert
def _create_invoice(env, so, ctx, post=False):
inv_recordset = so._create_invoices()
if not inv_recordset:
return env["account.move"]
inv = (inv_recordset[0] if hasattr(inv_recordset, "ids")
else env["account.move"].browse(inv_recordset))
inv_vals = {
"invoice_date": (datetime.now() - timedelta(
days=random.randint(0, 5))).date(),
"invoice_date_due": (datetime.now() + timedelta(
days=random.randint(15, 30))).date(),
}
if not inv.invoice_payment_term_id:
inv_vals["invoice_payment_term_id"] = ctx["payment_term"].id
inv.write(inv_vals)
if post:
inv.action_post()
return inv
def _register_payment(env, inv, ctx, validate=True):
bank = ctx["bank_journal"]
pml = bank.inbound_payment_method_line_ids[:1]
wizard = env["account.payment.register"].with_context(
active_model="account.move",
active_ids=inv.ids,
).create({
"amount": inv.amount_total,
"journal_id": bank.id,
"payment_method_line_id": pml.id if pml else False,
"payment_date": (datetime.now() - timedelta(
days=random.randint(0, 7))).date(),
})
wizard.action_create_payments()
pmt = env["account.payment"].search(
[("partner_id", "=", inv.partner_id.id),
("amount", "=", inv.amount_total)],
order="id desc", limit=1)
if pmt and validate:
try:
pmt.action_validate()
except Exception as e:
_logger.warning("Payment validate failed: %s", e)
try:
pmt.write({"state": "paid"})
except Exception as e2:
_logger.warning("Payment direct write paid failed: %s", e2)
return pmt
# ---------------------------------------------------------------------- #
# Per-state advancement #
# ---------------------------------------------------------------------- #
def _advance_to_confirmed(env, so, ctx):
"""Confirm SO; populate job + steps but leave it at draft job state."""
if so.state != "sale":
try:
# Wizard stayed in draft. Sub 1 design: SO is left in draft
# and reviewed before confirmation. We confirm here to
# exercise the downstream auto-create-job hook.
so.action_confirm()
except Exception as e:
_logger.warning("SO confirm failed for %s: %s", so.name, e)
return False
jobs = env["fp.job"].search([("sale_order_id", "=", so.id)])
for job in jobs:
if job.state == "draft":
try:
job.action_confirm()
except Exception as e:
_logger.warning("Job confirm failed: %s", e)
continue
_ensure_steps(env, job)
_populate_job(env, job, ctx)
_assign_step_users(env, job, ctx, n_done=0, current_idx=None)
return True
def _advance_to_in_progress_mid(env, so, ctx):
if not _advance_to_confirmed(env, so, ctx):
return False
jobs = env["fp.job"].search([("sale_order_id", "=", so.id)])
for job in jobs:
total = len(job.step_ids)
n_done = max(1, total // 2) if total else 0
_assign_step_users(env, job, ctx, n_done=n_done,
current_idx=n_done)
job.write({
"state": "in_progress",
"date_started": datetime.now() - timedelta(
days=random.randint(2, 7)),
})
return True
def _advance_to_delivered(env, so, ctx, deliver_state="scheduled"):
"""Drive job to done + delivery to scheduled or delivered."""
if not _advance_to_confirmed(env, so, ctx):
return False
jobs = env["fp.job"].search([("sale_order_id", "=", so.id)])
if not jobs:
return False
for job in jobs:
_assign_step_users(env, job, ctx,
n_done=len(job.step_ids), current_idx=None)
job.write({
"state": "in_progress",
"date_started": datetime.now() - timedelta(
days=random.randint(3, 10)),
})
try:
job.button_mark_done()
except Exception as e:
_logger.warning("Job mark_done failed: %s", e)
continue
if job.delivery_id:
offset = (-2 if deliver_state == "delivered"
else random.randint(1, 5))
_make_delivery_full(env, job.delivery_id, so.partner_id, ctx,
state=deliver_state,
scheduled_offset_days=offset)
if deliver_state == "delivered":
# Issue cert for first part on the SO
first_line = so.order_line[:1]
part = (first_line.x_fc_part_catalog_id
if first_line and "x_fc_part_catalog_id"
in first_line._fields else False)
if part:
_issue_certificate(env, job, so, part, ctx)
return True
def _advance_to_invoice_posted(env, so, ctx):
if not _advance_to_delivered(env, so, ctx, deliver_state="delivered"):
return False
inv = _create_invoice(env, so, ctx, post=True)
return bool(inv and inv.state == "posted")
def _advance_to_paid(env, so, ctx):
if not _advance_to_delivered(env, so, ctx, deliver_state="delivered"):
return False
inv = _create_invoice(env, so, ctx, post=True)
if not (inv and inv.state == "posted"):
return False
pmt = _register_payment(env, inv, ctx, validate=True)
return bool(pmt)
# ---------------------------------------------------------------------- #
# Per-order entry point #
# ---------------------------------------------------------------------- #
def _create_direct_order(env, partner, combos_for_partner, ctx,
n_lines, advance_to, idx):
"""Create one wizard-originated order, advance to target state.
Returns the SO record (or None on failure).
"""
so = _create_so_via_wizard(env, partner, combos_for_partner, n_lines, idx)
if not so:
return None
if advance_to == "confirmed":
if not _advance_to_confirmed(env, so, ctx):
return None
elif advance_to == "in_progress_mid":
if not _advance_to_in_progress_mid(env, so, ctx):
return None
elif advance_to == "delivered":
# Job done + delivery scheduled (not yet delivered)
if not _advance_to_delivered(env, so, ctx,
deliver_state="scheduled"):
return None
elif advance_to == "invoiced":
if not _advance_to_invoice_posted(env, so, ctx):
return None
elif advance_to == "paid":
if not _advance_to_paid(env, so, ctx):
return None
return so
# ---------------------------------------------------------------------- #
# Main runner #
# ---------------------------------------------------------------------- #
# Plan: 11 orders distributed across 5 states.
ORDER_PLAN = [
("confirmed", 3),
("in_progress_mid", 3),
("delivered", 2),
("invoiced", 2),
("paid", 1),
]
def run(env):
print("=" * 70)
print("seed_direct_orders.py - estimator wizard path seeding")
print("=" * 70)
combos = _build_combos(env)
if not combos:
print("ERROR: no parts with coating + recipe + partner. Cannot seed.")
return
print("Customer/part combos: %d" % len(combos))
# Group combos by partner so we can build multi-line orders for the
# same customer (more realistic than one part per partner).
by_partner = {}
for (partner, part, coating) in combos:
by_partner.setdefault(partner.id, []).append((partner, part, coating))
partner_ids = list(by_partner.keys())
random.shuffle(partner_ids)
operators = _operators(env)
managers = _managers(env)
employees = _employees(env)
facility = _resolve_facility(env)
payment_term = _resolve_payment_term(env)
sales_journal, bank_journal = _resolve_journals(env)
print("Operators: %d, Managers: %d, Employees: %d" % (
len(operators), len(managers), len(employees)))
print("Facility: %s, PaymentTerm: %s" % (
facility.name if facility else "NONE",
payment_term.name if payment_term else "NONE"))
if not (payment_term and sales_journal and bank_journal):
print("ERROR: missing required masters; cannot proceed.")
return
ctx = {
"payment_term": payment_term,
"sales_journal": sales_journal,
"bank_journal": bank_journal,
"operators": operators,
"managers": managers,
"employees": employees,
"facility": facility,
}
results = {state: 0 for (state, _n) in ORDER_PLAN}
failures = []
seq = 0
partner_cursor = 0
for (state, count) in ORDER_PLAN:
print()
print("-- target state: %s (count %d) --" % (state, count))
for _i in range(count):
seq += 1
# Round-robin partners to maximise variety
partner_id = partner_ids[partner_cursor % len(partner_ids)]
partner_cursor += 1
partner = env["res.partner"].browse(partner_id)
combos_for_partner = by_partner[partner_id]
n_lines = random.randint(1, 3)
sp = "direct_order_%d" % seq
env.cr.execute("SAVEPOINT %s" % sp)
try:
so = _create_direct_order(env, partner, combos_for_partner,
ctx, n_lines, state, seq)
if so:
env.cr.execute("RELEASE SAVEPOINT %s" % sp)
results[state] += 1
print(" [%d] %s -> %s (state=%s, partner=%s)"
% (seq, so.name, state, so.state, partner.name))
else:
env.cr.execute("ROLLBACK TO SAVEPOINT %s" % sp)
failures.append((seq, partner.name,
"wizard returned no SO"))
print(" [%d] FAILED for %s (no SO)"
% (seq, partner.name))
except Exception as e:
try:
env.cr.execute("ROLLBACK TO SAVEPOINT %s" % sp)
except Exception:
pass
failures.append((seq, partner.name, str(e)[:120]))
print(" [%d] EXCEPTION for %s: %s"
% (seq, partner.name, str(e)[:120]))
env.cr.commit()
print()
print("=" * 70)
print("DIRECT-ORDER SEED RESULTS")
print("=" * 70)
total = 0
for state, n in results.items():
print(" %-20s %d" % (state, n))
total += n
print(" %-20s %d" % ("TOTAL CREATED", total))
if failures:
print()
print("FAILURES (%d):" % len(failures))
for (seq, partner, reason) in failures:
print(" #%d %-30s %s" % (seq, partner, reason))
print()
try:
run(env)
except NameError:
print("Run inside odoo shell.")

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Assigns existing fp.coating.config rows (with recipes) to parts that
# don't have one yet. Round-robin distribution. Idempotent.
#
# Field name on fp.part.catalog is x_fc_default_coating_config_id.
COATING_FIELD = 'x_fc_default_coating_config_id'
def run(env):
print('=== Assigning coatings to bare parts ===')
Part = env['fp.part.catalog']
Coating = env['fp.coating.config']
coatings_with_recipe = Coating.search([('recipe_id', '!=', False)])
if not coatings_with_recipe:
print(' No coatings with recipes available — abort')
return
print(f' Coatings with recipes: {len(coatings_with_recipe)}')
bare_parts = Part.search([(COATING_FIELD, '=', False)])
print(f' Parts without coating: {len(bare_parts)}')
# Assign first 20 bare parts (or all if fewer)
to_assign = bare_parts[:20]
n = 0
for i, part in enumerate(to_assign):
coating = coatings_with_recipe[i % len(coatings_with_recipe)]
part[COATING_FIELD] = coating.id
n += 1
print(f' {part.name!r} -> {coating.name!r}')
env.cr.commit()
print()
print(f'=== Done. Assigned coatings to {n} parts ===')
final_count = Part.search_count([(COATING_FIELD, '!=', False)])
print(f' Parts with coating now: {final_count}')
try:
run(env)
except NameError:
print('Run inside `odoo shell`.')

View File

@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Seeds fp.work.centre rows that mirror the existing legacy
# fusion.plating.work.center records (by code). Then backfills the
# work_centre_id on every existing fp.job.step that has a
# recipe_node_id whose underlying recipe operation points to a legacy
# work centre we just created.
#
# Idempotent: skip rows that already exist by code.
KIND_KEYWORDS = [
('bake', ['bake', 'oven']),
('rack', ['rack']),
('inspect',['inspect', 'qc', 'first', 'last']),
('mask', ['mask']),
('wet_line',['bath', 'plat', 'nickel', 'chrome', 'anodiz', 'rinse', 'tank', 'strip', 'etch']),
]
def _classify_kind(name, code):
text = (name + ' ' + (code or '')).lower()
for kind, keywords in KIND_KEYWORDS:
if any(k in text for k in keywords):
return kind
return 'other'
def run(env):
print('=== Seeding fp.work.centre from legacy fusion.plating.work.center ===')
Native = env['fp.work.centre']
Legacy = env['fusion.plating.work.center']
# 1. Create one fp.work.centre per legacy work centre (matched by code)
legacy_centres = Legacy.search([])
print(f' Legacy centres found: {len(legacy_centres)}')
created = 0
for legacy in legacy_centres:
if not legacy.code:
print(f' SKIP (no code): {legacy.name}')
continue
existing = Native.search([('code', '=', legacy.code)], limit=1)
if existing:
continue
kind = _classify_kind(legacy.name or '', legacy.code or '')
vals = {
'code': legacy.code,
'name': legacy.name,
'kind': kind,
'active': True,
'facility_id': legacy.facility_id.id if legacy.facility_id else False,
}
if hasattr(legacy, 'cost_per_hour'):
vals['cost_per_hour'] = legacy.cost_per_hour
Native.sudo().create(vals)
created += 1
print(f' Native work centres created: {created}')
print(f' Native total now: {Native.search_count([])}')
# 2. Backfill work_centre_id on existing fp.job.step rows
print()
print('=== Backfilling work_centre_id on existing fp.job.step rows ===')
Step = env['fp.job.step']
unbound = Step.search([('work_centre_id', '=', False), ('recipe_node_id', '!=', False)])
print(f' Steps to backfill: {len(unbound)}')
bound = 0
no_legacy = 0
no_match = 0
for step in unbound:
legacy_wc = step.recipe_node_id.work_center_id
if not legacy_wc or not legacy_wc.code:
no_legacy += 1
continue
match = Native.search([('code', '=', legacy_wc.code)], limit=1)
if not match:
no_match += 1
continue
step.work_centre_id = match.id
bound += 1
print(f' Bound: {bound}')
print(f' Recipe op without legacy work centre: {no_legacy}')
print(f' No matching native code: {no_match}')
env.cr.commit()
print()
print('=== Done ===')
try:
run(env)
except NameError:
print('Run inside `odoo shell`.')

View File

@@ -0,0 +1,785 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# seed_workflow_states.py
# =======================
# Builds 7-8 sale orders in EACH lifecycle state from quotation through
# paid invoice. The goal is a realistic dataset that exercises the full
# pipeline so we can validate UI, reports, KPIs, and notifications.
#
# Workflow walkthrough findings (run end-to-end on entech 2026-04-25):
#
# STAGE AUTO-CREATES REQUIRED FIELDS
# ----------------------------------------------------------------------
# sale.order draft nothing partner_id, order_line
# commitment_date is optional but
# we always set it for realism
# sale.order sent nothing write state="sent"
# sale.order action_confirm fp.job (state=DRAFT, client_order_ref recommended;
# step_count=0, recipe payment_term_id REQUIRED for
# resolved from coating) downstream invoice posting
# fp.job action_confirm portal_job_id state moves draft -> confirmed
# fp.job._generate_steps_ step_ids populated must be called explicitly;
# from_recipe() action_confirm does NOT do it
# fp.job button_mark_done delivery (draft) + all steps must be done first;
# cert (draft, type=coc) sets state=done, date_finished
# fp.delivery scheduled -- scheduled_date, contact_name,
# contact_phone, delivery_address_id,
# x_fc_box_count_out,
# assigned_driver_id (hr.employee)
# fp.delivery delivered -- delivered_at
# fp.certificate issued -- issue_date, issued_by_id,
# entech_wo_number, customer_job_no,
# po_number, quantity_shipped,
# part_number,
# thickness_reading_ids (3-5 rows)
# account.move (draft) via so._create_invoices() invoice_date, invoice_date_due,
# invoice_payment_term_id REQUIRED
# or post fails
# account.move action_post name=INV/YYYY/NNNNN invoice_date_due
# account.payment.register account.payment partner_type, journal_id,
# wizard (state=in_process) payment_method_line_id,
# amount, payment_date
# account.payment state=paid on payment; ALL upstream prerequisites
# action_validate invoice payment_state= above
# in_payment (not paid - (Odoo 19 design - paid state
# that requires bank requires bank reconciliation)
# statement reconciliation)
#
# Strategy:
# - Each order is wrapped in its OWN savepoint. If anything fails, we
# ROLLBACK that savepoint and continue to the next order.
# - We commit at the end of each stage so partial successes still land.
# - Customer/part variety: spread across 10+ partners, cycle through all
# parts that have coatings. Operators round-robin across the 20.
# - Date variety: past 60 days for delivered/paid; future 1-30 days for
# active jobs.
#
# Usage (entech): see scripts/README.md.
from datetime import datetime, timedelta
import random
import logging
random.seed(2026)
_logger = logging.getLogger(__name__)
# Stage targets - these are the seed counts per stage requested by spec.
# Adjust here if you want fewer/more.
TARGETS = {
"so_draft": 8,
"so_sent": 7,
"job_confirmed_no_steps_started": 8,
"job_in_progress_early": 7,
"job_in_progress_mid": 8,
"job_on_hold": 5,
"job_done_delivery_draft": 7,
"delivery_scheduled": 7,
"delivery_en_route": 5,
"delivery_delivered": 8,
"invoice_draft": 7,
"invoice_posted": 7,
"paid": 7,
}
def _pick_combo(combos, idx):
return combos[idx % len(combos)]
def _build_combos(env):
"""List of (partner, part, coating) for SO-confirm seeding."""
combos = []
parts = env["fp.part.catalog"].search([
("x_fc_default_coating_config_id", "!=", False),
("x_fc_default_coating_config_id.recipe_id", "!=", False),
("partner_id", "!=", False),
])
for p in parts:
combos.append((p.partner_id, p, p.x_fc_default_coating_config_id))
random.shuffle(combos)
return combos
def _operators(env):
g = env.ref("fusion_plating.group_fusion_plating_operator",
raise_if_not_found=False)
if not g:
return env["res.users"]
return env["res.users"].search([("all_group_ids", "in", g.id)])
def _managers(env):
g = env.ref("fusion_plating.group_fusion_plating_manager",
raise_if_not_found=False)
if not g:
return env["res.users"]
return env["res.users"].search([("all_group_ids", "in", g.id)])
def _employees(env):
return env["hr.employee"].search([])
def _resolve_product(env):
"""Find the right plating-service product to use for SO lines."""
p = env["product.product"].search(
[("name", "=", "Plating Service")], limit=1)
if p:
return p
p = env["product.product"].search(
[("sale_ok", "=", True), ("type", "=", "service")], limit=1)
if p:
return p
return env["product.product"].search([("sale_ok", "=", True)], limit=1)
def _resolve_payment_term(env):
"""Net 30 by default."""
pt = env["account.payment.term"].search(
[("name", "=", "30 Days")], limit=1)
if not pt:
pt = env["account.payment.term"].search([], limit=1)
return pt
def _resolve_journals(env):
sales = env["account.journal"].search([("type", "=", "sale")], limit=1)
bank = env["account.journal"].search([("type", "=", "bank")], limit=1)
return sales, bank
def _resolve_facility(env):
return env["fusion.plating.facility"].search([], limit=1)
# ----------------------------------------------------------------------
def _make_so(env, partner, part, coating, qty, price, ctx):
"""Create a draft SO with one detailed plating line."""
SOL_fields = env["sale.order.line"]._fields
line_vals = {
"product_id": ctx["product"].id,
"product_uom_qty": qty,
"price_unit": price,
"name": "ENP plating service: %s rev %s" % (
part.part_number or part.name, part.revision or "A"),
}
if "x_fc_part_catalog_id" in SOL_fields:
line_vals["x_fc_part_catalog_id"] = part.id
if "x_fc_coating_config_id" in SOL_fields:
line_vals["x_fc_coating_config_id"] = coating.id
if "x_fc_internal_description" in SOL_fields:
line_vals["x_fc_internal_description"] = (
"Internal: %s, %.1f mils target, mil-spec" % (
coating.name, coating.thickness_max or 1.0))
so_vals = {
"partner_id": partner.id,
"partner_invoice_id": partner.id,
"partner_shipping_id": partner.id,
"client_order_ref": "CUST-PO-%05d" % random.randint(10000, 99999),
"commitment_date": datetime.now() + timedelta(
days=random.randint(7, 30)),
"validity_date": (datetime.now() + timedelta(days=30)).date(),
"payment_term_id": ctx["payment_term"].id,
"order_line": [(0, 0, line_vals)],
}
SO_fields = env["sale.order"]._fields
if "x_fc_po_number" in SO_fields:
so_vals["x_fc_po_number"] = "PO-%05d" % random.randint(10000, 99999)
if "x_fc_part_catalog_id" in SO_fields:
so_vals["x_fc_part_catalog_id"] = part.id
if "x_fc_coating_config_id" in SO_fields:
so_vals["x_fc_coating_config_id"] = coating.id
if "x_fc_internal_note" in SO_fields:
so_vals["x_fc_internal_note"] = (
"<p>Customer is OK with rush production if capacity allows.</p>")
if "x_fc_external_note" in SO_fields:
so_vals["x_fc_external_note"] = (
"<p>Please confirm receipt of parts before processing.</p>")
return env["sale.order"].sudo().create(so_vals)
def _populate_job(env, job, ctx, fill_facility=True, fill_manager=True):
"""Fill out fp.job extra fields after creation."""
if not job:
return
vals = {}
if fill_facility and ctx["facility"] and not job.facility_id:
vals["facility_id"] = ctx["facility"].id
if fill_manager and ctx["managers"] and not job.manager_id:
vals["manager_id"] = random.choice(ctx["managers"]).id
if not job.priority or job.priority == "normal":
vals["priority"] = random.choices(
["low", "normal", "high", "rush"],
weights=[10, 70, 15, 5],
)[0]
if vals:
job.write(vals)
def _ensure_steps(env, job):
"""Force step generation. action_confirm doesn t do this on its own."""
if not job:
return
if not job.recipe_id:
return
if job.step_ids:
return
try:
job._generate_steps_from_recipe()
except Exception as e:
_logger.warning("Job %s step gen failed: %s", job.name, e)
def _assign_step_users(env, job, ctx, n_done=0, current_idx=None):
"""Assign operators to all steps; mark first n_done as done, and
optionally one step at current_idx as in_progress.
"""
operators = ctx["operators"]
steps = job.step_ids.sorted("sequence")
if not steps:
return
for s in steps:
if operators and not s.assigned_user_id:
s.assigned_user_id = operators[
random.randrange(len(operators))]
base = datetime.now() - timedelta(hours=len(steps) * 2)
for i, s in enumerate(steps[:n_done]):
start = base + timedelta(hours=i * 2)
finish = start + timedelta(minutes=random.randint(20, 90))
s.write({
"state": "done",
"date_started": start,
"date_finished": finish,
"duration_actual": (finish - start).total_seconds() / 60.0,
"started_by_user_id": s.assigned_user_id.id if s.assigned_user_id else env.user.id,
"finished_by_user_id": s.assigned_user_id.id if s.assigned_user_id else env.user.id,
})
env["fp.job.step.timelog"].sudo().create({
"step_id": s.id,
"user_id": s.assigned_user_id.id if s.assigned_user_id else env.user.id,
"date_started": start,
"date_finished": finish,
"duration_minutes": (finish - start).total_seconds() / 60.0,
})
if current_idx is not None and current_idx < len(steps):
cur = steps[current_idx]
if cur.state != "done":
start = datetime.now() - timedelta(
minutes=random.randint(5, 90))
cur.write({
"state": "in_progress",
"date_started": start,
"started_by_user_id": cur.assigned_user_id.id if cur.assigned_user_id else env.user.id,
})
env["fp.job.step.timelog"].sudo().create({
"step_id": cur.id,
"user_id": cur.assigned_user_id.id if cur.assigned_user_id else env.user.id,
"date_started": start,
})
if current_idx is not None and current_idx + 1 < len(steps):
next_step = steps[current_idx + 1]
if next_step.state == "pending":
next_step.write({"state": "ready"})
def _fill_step_realistic_data(env, job):
for s in job.step_ids:
kind = s.kind
if kind == "bake":
if not s.bake_setpoint_temp:
s.bake_setpoint_temp = random.choice([375.0, 400.0, 425.0])
if not s.bake_actual_duration and s.state == "done":
s.bake_actual_duration = random.uniform(3.5, 4.5)
elif kind == "wet":
if not s.thickness_target:
s.thickness_target = round(random.uniform(0.5, 2.0), 2)
s.thickness_uom = "mil"
def _make_delivery_full(env, delivery, partner, ctx, state, scheduled_offset_days=1):
"""Fill delivery with realistic logistics fields and advance state."""
if not delivery:
return
employees = ctx["employees"]
facility = ctx["facility"]
vals = {
"delivery_address_id": partner.id,
"contact_name": partner.name,
"contact_phone": partner.phone or "555-%04d" % random.randint(1000, 9999),
"scheduled_date": datetime.now() + timedelta(days=scheduled_offset_days),
}
if "x_fc_box_count_out" in delivery._fields:
vals["x_fc_box_count_out"] = random.randint(1, 5)
if employees and "assigned_driver_id" in delivery._fields:
vals["assigned_driver_id"] = employees[
random.randrange(len(employees))].id
if facility and "source_facility_id" in delivery._fields:
vals["source_facility_id"] = facility.id
if "notes" in delivery._fields:
vals["notes"] = (
"<p>Standard delivery - handle with care, parts plated to spec.</p>")
delivery.write(vals)
delivery.write({"state": state})
if state == "delivered":
delivery.write({"delivered_at": datetime.now() - timedelta(
hours=random.randint(1, 48))})
def _issue_certificate(env, job, so, part, ctx):
cert = env["fp.certificate"].search(
[("x_fc_job_id", "=", job.id)], limit=1)
if not cert:
cert = env["fp.certificate"].sudo().create({
"partner_id": job.partner_id.id,
"certificate_type": "coc",
"state": "draft",
"x_fc_job_id": job.id,
"sale_order_id": so.id if so else False,
})
vals = {
"state": "issued",
"issue_date": datetime.now().date(),
"issued_by_id": env.user.id,
"entech_wo_number": job.name,
"customer_job_no": so.client_order_ref if so else "",
"po_number": so.x_fc_po_number if so and "x_fc_po_number" in so._fields else "",
"quantity_shipped": int(job.qty or 1),
"part_number": part.part_number or part.name,
"process_description": "Electroless Nickel Plating, MIL-C-26074",
}
if "spec_min_mils" in cert._fields and part.x_fc_default_coating_config_id:
c = part.x_fc_default_coating_config_id
if c.thickness_min:
vals["spec_min_mils"] = c.thickness_min
if c.thickness_max:
vals["spec_max_mils"] = c.thickness_max
vals["spec_reference"] = c.spec_reference or "AMS-2404"
cert.write(vals)
for i in range(5):
env["fp.thickness.reading"].sudo().create({
"certificate_id": cert.id,
"reading_number": i + 1,
"nip_mils": round(random.uniform(0.95, 1.15), 3),
"ni_percent": round(random.uniform(88.0, 92.0), 2),
"p_percent": round(random.uniform(8.0, 12.0), 2),
"position_label": "Pos %d" % (i + 1),
"reading_datetime": datetime.now() - timedelta(
minutes=30 - i * 5),
"operator_id": env.user.id,
"x_fc_job_id": job.id,
"equipment_model": "Fischerscope X-Ray XDV-SD",
"calibration_std_ref": "CAL-2026-04-01",
})
return cert
def _create_quality_hold(env, job, ctx):
if "fusion.plating.quality.hold" not in env:
return
Hold = env["fusion.plating.quality.hold"].sudo()
steps = job.step_ids.sorted("sequence")
affected_step = None
for s in steps:
if s.state in ("paused", "in_progress"):
affected_step = s
break
vals = {
"hold_reason": random.choice(
["out_of_spec", "damaged", "contamination", "process_deviation"]),
"qty_on_hold": max(1, int((job.qty or 1) // 4)),
"qty_original": int(job.qty or 1),
"description": "Sample inspection caught dimensional drift on first-piece. Holding for engineering review.",
"state": "on_hold",
}
if "x_fc_job_id" in Hold._fields:
vals["x_fc_job_id"] = job.id
if affected_step and "x_fc_step_id" in Hold._fields:
vals["x_fc_step_id"] = affected_step.id
if ctx["facility"] and "facility_id" in Hold._fields:
vals["facility_id"] = ctx["facility"].id
if "operator_id" in Hold._fields and ctx["operators"]:
vals["operator_id"] = random.choice(ctx["operators"]).id
if "part_ref" in Hold._fields:
vals["part_ref"] = job.part_catalog_id.part_number if job.part_catalog_id else ""
try:
Hold.create(vals)
except Exception as e:
_logger.warning("Hold create failed for %s: %s", job.name, e)
def _create_invoice(env, so, ctx, post=False):
inv_recordset = so._create_invoices()
if not inv_recordset:
return env["account.move"]
inv = inv_recordset[0] if hasattr(inv_recordset, "ids") else env["account.move"].browse(inv_recordset)
inv_vals = {
"invoice_date": (datetime.now() - timedelta(
days=random.randint(0, 5))).date(),
"invoice_date_due": (datetime.now() + timedelta(
days=random.randint(15, 30))).date(),
}
if not inv.invoice_payment_term_id:
inv_vals["invoice_payment_term_id"] = ctx["payment_term"].id
inv.write(inv_vals)
if post:
inv.action_post()
return inv
def _register_payment(env, inv, ctx, validate=True):
bank = ctx["bank_journal"]
pml = bank.inbound_payment_method_line_ids[:1]
wizard = env["account.payment.register"].with_context(
active_model="account.move",
active_ids=inv.ids,
).create({
"amount": inv.amount_total,
"journal_id": bank.id,
"payment_method_line_id": pml.id if pml else False,
"payment_date": (datetime.now() - timedelta(
days=random.randint(0, 7))).date(),
})
wizard.action_create_payments()
pmt = env["account.payment"].search(
[("partner_id", "=", inv.partner_id.id),
("amount", "=", inv.amount_total)],
order="id desc", limit=1)
if pmt and validate:
try:
pmt.action_validate()
except Exception as e:
_logger.warning("Payment validate failed: %s", e)
try:
pmt.write({"state": "paid"})
except Exception as e2:
_logger.warning("Payment direct write paid failed: %s", e2)
return pmt
# ----------------------------------------------------------------------
def _stage(env, label, fn, n, combos, idx_holder, ctx, results):
print("-- %s (target %d) --" % (label, n))
success = 0
sp_safe = "".join(c if c.isalnum() else "_" for c in label).strip("_")
sp_label = "seed_" + sp_safe
for i in range(n):
partner, part, coating = _pick_combo(combos, idx_holder[0])
idx_holder[0] += 1
sp = "%s_%d" % (sp_label, i)
env.cr.execute("SAVEPOINT %s" % sp)
try:
if fn(env, partner, part, coating, ctx):
env.cr.execute("RELEASE SAVEPOINT %s" % sp)
success += 1
else:
env.cr.execute("ROLLBACK TO SAVEPOINT %s" % sp)
except Exception as e:
print(" WARN [%s #%d]: %s" % (label, i, e))
try:
env.cr.execute("ROLLBACK TO SAVEPOINT %s" % sp)
except Exception:
pass
results[label] = success
print(" -> %d/%d succeeded" % (success, n))
# -------------------- Stage handlers --------------------
def stage_so_draft(env, partner, part, coating, ctx):
so = _make_so(env, partner, part, coating,
qty=random.choice([5, 10, 25, 50, 100]),
price=random.uniform(75.0, 350.0), ctx=ctx)
return bool(so)
def stage_so_sent(env, partner, part, coating, ctx):
so = _make_so(env, partner, part, coating,
qty=random.choice([10, 25, 50, 100]),
price=random.uniform(75.0, 350.0), ctx=ctx)
so.write({"state": "sent"})
return True
def stage_job_confirmed(env, partner, part, coating, ctx):
so = _make_so(env, partner, part, coating,
qty=random.choice([10, 25, 50]),
price=random.uniform(100.0, 350.0), ctx=ctx)
so.action_confirm()
job = env["fp.job"].search([("sale_order_id", "=", so.id)], limit=1)
if not job:
return False
if job.state == "draft":
job.action_confirm()
_ensure_steps(env, job)
_populate_job(env, job, ctx)
_assign_step_users(env, job, ctx, n_done=0, current_idx=None)
_fill_step_realistic_data(env, job)
return True
def stage_job_in_progress_early(env, partner, part, coating, ctx):
so = _make_so(env, partner, part, coating,
qty=random.choice([10, 25, 50]),
price=random.uniform(100.0, 300.0), ctx=ctx)
so.action_confirm()
job = env["fp.job"].search([("sale_order_id", "=", so.id)], limit=1)
if not job:
return False
if job.state == "draft":
job.action_confirm()
_ensure_steps(env, job)
_populate_job(env, job, ctx)
n_done = random.choice([1, 2])
_assign_step_users(env, job, ctx, n_done=n_done, current_idx=n_done)
_fill_step_realistic_data(env, job)
job.write({
"state": "in_progress",
"date_started": datetime.now() - timedelta(
days=random.randint(1, 4)),
})
return True
def stage_job_in_progress_mid(env, partner, part, coating, ctx):
so = _make_so(env, partner, part, coating,
qty=random.choice([10, 25, 50]),
price=random.uniform(100.0, 300.0), ctx=ctx)
so.action_confirm()
job = env["fp.job"].search([("sale_order_id", "=", so.id)], limit=1)
if not job:
return False
if job.state == "draft":
job.action_confirm()
_ensure_steps(env, job)
_populate_job(env, job, ctx)
total = len(job.step_ids)
n_done = max(1, total // 2) if total else 0
_assign_step_users(env, job, ctx, n_done=n_done, current_idx=n_done)
_fill_step_realistic_data(env, job)
job.write({
"state": "in_progress",
"date_started": datetime.now() - timedelta(
days=random.randint(2, 7)),
})
return True
def stage_job_on_hold(env, partner, part, coating, ctx):
so = _make_so(env, partner, part, coating,
qty=random.choice([10, 25, 50]),
price=random.uniform(100.0, 300.0), ctx=ctx)
so.action_confirm()
job = env["fp.job"].search([("sale_order_id", "=", so.id)], limit=1)
if not job:
return False
if job.state == "draft":
job.action_confirm()
_ensure_steps(env, job)
_populate_job(env, job, ctx)
total = len(job.step_ids)
n_done = min(2, max(1, total // 3)) if total else 0
_assign_step_users(env, job, ctx, n_done=n_done, current_idx=n_done)
if total > n_done:
cur = job.step_ids.sorted("sequence")[n_done]
cur.write({"state": "paused"})
_fill_step_realistic_data(env, job)
job.write({"state": "on_hold"})
_create_quality_hold(env, job, ctx)
return True
def stage_job_done_delivery_draft(env, partner, part, coating, ctx):
so = _make_so(env, partner, part, coating,
qty=random.choice([5, 10, 25]),
price=random.uniform(80.0, 250.0), ctx=ctx)
so.action_confirm()
job = env["fp.job"].search([("sale_order_id", "=", so.id)], limit=1)
if not job:
return False
if job.state == "draft":
job.action_confirm()
_ensure_steps(env, job)
_populate_job(env, job, ctx)
_assign_step_users(env, job, ctx,
n_done=len(job.step_ids),
current_idx=None)
_fill_step_realistic_data(env, job)
job.write({
"state": "in_progress",
"date_started": datetime.now() - timedelta(
days=random.randint(3, 10)),
})
job.button_mark_done()
return True
def stage_delivery_scheduled(env, partner, part, coating, ctx):
if not stage_job_done_delivery_draft(env, partner, part, coating, ctx):
return False
job = env["fp.job"].search(
[("partner_id", "=", partner.id)],
order="id desc", limit=1)
if not job or not job.delivery_id:
return False
_make_delivery_full(env, job.delivery_id, partner, ctx,
state="scheduled",
scheduled_offset_days=random.randint(1, 5))
return True
def stage_delivery_en_route(env, partner, part, coating, ctx):
if not stage_job_done_delivery_draft(env, partner, part, coating, ctx):
return False
job = env["fp.job"].search(
[("partner_id", "=", partner.id)],
order="id desc", limit=1)
if not job or not job.delivery_id:
return False
_make_delivery_full(env, job.delivery_id, partner, ctx,
state="en_route",
scheduled_offset_days=0)
return True
def stage_delivery_delivered(env, partner, part, coating, ctx):
if not stage_job_done_delivery_draft(env, partner, part, coating, ctx):
return False
job = env["fp.job"].search(
[("partner_id", "=", partner.id)],
order="id desc", limit=1)
if not job or not job.delivery_id:
return False
_make_delivery_full(env, job.delivery_id, partner, ctx,
state="delivered",
scheduled_offset_days=-2)
so = job.sale_order_id
_issue_certificate(env, job, so, part, ctx)
return True
def stage_invoice_draft(env, partner, part, coating, ctx):
if not stage_delivery_delivered(env, partner, part, coating, ctx):
return False
job = env["fp.job"].search(
[("partner_id", "=", partner.id)],
order="id desc", limit=1)
so = job.sale_order_id
if not so:
return False
inv = _create_invoice(env, so, ctx, post=False)
return bool(inv)
def stage_invoice_posted(env, partner, part, coating, ctx):
if not stage_delivery_delivered(env, partner, part, coating, ctx):
return False
job = env["fp.job"].search(
[("partner_id", "=", partner.id)],
order="id desc", limit=1)
so = job.sale_order_id
if not so:
return False
inv = _create_invoice(env, so, ctx, post=True)
return inv and inv.state == "posted"
def stage_paid(env, partner, part, coating, ctx):
if not stage_delivery_delivered(env, partner, part, coating, ctx):
return False
job = env["fp.job"].search(
[("partner_id", "=", partner.id)],
order="id desc", limit=1)
so = job.sale_order_id
if not so:
return False
inv = _create_invoice(env, so, ctx, post=True)
if not inv or inv.state != "posted":
return False
pmt = _register_payment(env, inv, ctx, validate=True)
return bool(pmt)
# ----------------------------------------------------------------------
def run(env):
print("=" * 70)
print("seed_workflow_states.py - full pipeline seeding")
print("=" * 70)
combos = _build_combos(env)
print("Customer/part combos: %d" % len(combos))
if not combos:
print("ERROR: no parts with coating + recipe + partner. Cannot seed.")
return
operators = _operators(env)
managers = _managers(env)
employees = _employees(env)
facility = _resolve_facility(env)
product = _resolve_product(env)
payment_term = _resolve_payment_term(env)
sales_journal, bank_journal = _resolve_journals(env)
print("Operators: %d, Managers: %d, Employees: %d" % (
len(operators), len(managers), len(employees)))
print("Facility: %s, Product: %s, PaymentTerm: %s" % (
facility.name if facility else "NONE",
product.name if product else "NONE",
payment_term.name if payment_term else "NONE"))
print("Sales journal: %s, Bank journal: %s" % (
sales_journal.name if sales_journal else "NONE",
bank_journal.name if bank_journal else "NONE"))
if not (product and payment_term and sales_journal and bank_journal):
print("ERROR: missing required masters; cannot proceed.")
return
ctx = {
"product": product,
"payment_term": payment_term,
"sales_journal": sales_journal,
"bank_journal": bank_journal,
"operators": operators,
"managers": managers,
"employees": employees,
"facility": facility,
}
idx_holder = [0]
results = {}
stages = [
("Quotation (sale.order draft)", stage_so_draft, TARGETS["so_draft"]),
("Quote Sent (sale.order sent)", stage_so_sent, TARGETS["so_sent"]),
("Order Confirmed Job Just Started", stage_job_confirmed, TARGETS["job_confirmed_no_steps_started"]),
("Job In Progress Early", stage_job_in_progress_early, TARGETS["job_in_progress_early"]),
("Job In Progress Mid", stage_job_in_progress_mid, TARGETS["job_in_progress_mid"]),
("Job On Hold", stage_job_on_hold, TARGETS["job_on_hold"]),
("Job Done Delivery Draft", stage_job_done_delivery_draft, TARGETS["job_done_delivery_draft"]),
("Delivery Scheduled", stage_delivery_scheduled, TARGETS["delivery_scheduled"]),
("Delivery En Route", stage_delivery_en_route, TARGETS["delivery_en_route"]),
("Delivered", stage_delivery_delivered, TARGETS["delivery_delivered"]),
("Invoice Draft", stage_invoice_draft, TARGETS["invoice_draft"]),
("Invoice Posted", stage_invoice_posted, TARGETS["invoice_posted"]),
("Paid", stage_paid, TARGETS["paid"]),
]
for label, fn, n in stages:
_stage(env, label, fn, n, combos, idx_holder, ctx, results)
env.cr.commit()
print()
print("=" * 70)
print("SEED RESULTS")
print("=" * 70)
for label, count in results.items():
print(" %-45s %d" % (label, count))
print()
try:
run(env)
except NameError:
print("Run inside odoo shell.")

View File

@@ -0,0 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_job_node_override_operator,fp.job.node.override.operator,model_fp_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_job_node_override_supervisor,fp.job.node.override.supervisor,model_fp_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_node_override_manager,fp.job.node.override.manager,model_fp_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_job_node_override_operator fp.job.node.override.operator model_fp_job_node_override fusion_plating.group_fusion_plating_operator 1 0 0 0
3 access_fp_job_node_override_supervisor fp.job.node.override.supervisor model_fp_job_node_override fusion_plating.group_fusion_plating_supervisor 1 1 1 0
4 access_fp_job_node_override_manager fp.job.node.override.manager model_fp_job_node_override fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="0">
<!-- Hidden group used to gate legacy MO/WO menus that have been
replaced by fp.job equivalents. Nobody is in this group by
default, so the legacy menus are invisible to all users. An
admin can manually add themselves via Settings > Users if
they need to access historical MO/WO data. -->
<record id="group_fusion_plating_legacy_menus" model="res.groups">
<field name="name">Plating Legacy Menus</field>
<field name="comment">Internal group to hide legacy MO/WO menus that have been replaced by the native fp.job model. Add a user to this group only if they need to navigate historical mrp.production / mrp.workorder records directly.</field>
</record>
</odoo>

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_fp_job_extensions

View File

@@ -0,0 +1,683 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase
class TestFpJobExtensions(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Cust'})
self.product = self.env['product.product'].create({'name': 'Widget'})
def _make_job(self, **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
def test_part_catalog_id_field_exists(self):
# Field added by fusion_plating_jobs via _inherit. Verify the
# field is registered on the model.
self.assertIn('part_catalog_id', self.env['fp.job']._fields)
self.assertEqual(
self.env['fp.job']._fields['part_catalog_id'].comodel_name,
'fp.part.catalog',
)
def test_coating_config_id_field_exists(self):
self.assertIn('coating_config_id', self.env['fp.job']._fields)
self.assertEqual(
self.env['fp.job']._fields['coating_config_id'].comodel_name,
'fp.coating.config',
)
def test_customer_spec_id_field_exists(self):
self.assertIn('customer_spec_id', self.env['fp.job']._fields)
self.assertEqual(
self.env['fp.job']._fields['customer_spec_id'].comodel_name,
'fusion.plating.customer.spec',
)
def test_portal_job_id_field_exists(self):
self.assertIn('portal_job_id', self.env['fp.job']._fields)
self.assertEqual(
self.env['fp.job']._fields['portal_job_id'].comodel_name,
'fusion.plating.portal.job',
)
def test_delivery_id_field_exists(self):
self.assertIn('delivery_id', self.env['fp.job']._fields)
self.assertEqual(
self.env['fp.job']._fields['delivery_id'].comodel_name,
'fusion.plating.delivery',
)
def test_can_create_job_with_all_fields_set(self):
# End-to-end: create matching records in each target model
# and assign them to a fp.job. Verifies no schema-level issues.
catalog = self.env['fp.part.catalog'].create({
'name': 'TestPart',
'partner_id': self.partner.id,
'part_number': 'TEST-001',
})
# fp.coating.config requires a process_type_id
process_type = self.env['fusion.plating.process.type'].search([], limit=1)
if not process_type:
process_type = self.env['fusion.plating.process.type'].create({
'name': 'TestProcess',
})
coating = self.env['fp.coating.config'].create({
'name': 'TestCoat',
'process_type_id': process_type.id,
})
# fusion.plating.customer.spec requires name + code + spec_type (has default)
spec = self.env['fusion.plating.customer.spec'].create({
'name': 'TestSpec',
'code': 'TEST-SPEC-001',
})
# fusion.plating.portal.job requires name + partner_id
portal = self.env['fusion.plating.portal.job'].create({
'name': 'TestPortal',
'partner_id': self.partner.id,
})
# fusion.plating.delivery requires name (has default) + partner_id
delivery = self.env['fusion.plating.delivery'].create({
'partner_id': self.partner.id,
})
job = self._make_job(
part_catalog_id=catalog.id,
coating_config_id=coating.id,
customer_spec_id=spec.id,
portal_job_id=portal.id,
delivery_id=delivery.id,
)
self.assertEqual(job.part_catalog_id, catalog)
self.assertEqual(job.coating_config_id, coating)
self.assertEqual(job.customer_spec_id, spec)
self.assertEqual(job.portal_job_id, portal)
self.assertEqual(job.delivery_id, delivery)
class TestFpJobNodeOverride(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
self.product = self.env['product.product'].create({'name': 'W'})
self.job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
})
# Create a recipe + opt-in node
self.recipe = self.env['fusion.plating.process.node'].create({
'name': 'TestRecipe',
'node_type': 'recipe',
})
self.opt_in_node = self.env['fusion.plating.process.node'].create({
'name': 'OptInOp',
'node_type': 'operation',
'parent_id': self.recipe.id,
'opt_in_out': 'opt_in',
})
def test_create_override(self):
ovr = self.env['fp.job.node.override'].create({
'job_id': self.job.id,
'node_id': self.opt_in_node.id,
'included': True,
})
self.assertEqual(ovr.job_id, self.job)
self.assertTrue(ovr.included)
def test_unique_constraint(self):
from psycopg2 import IntegrityError
from odoo.tools import mute_logger
self.env['fp.job.node.override'].create({
'job_id': self.job.id,
'node_id': self.opt_in_node.id,
'included': True,
})
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
with self.env.cr.savepoint():
self.env['fp.job.node.override'].create({
'job_id': self.job.id,
'node_id': self.opt_in_node.id,
'included': False,
})
def test_override_ids_one2many(self):
ovr = self.env['fp.job.node.override'].create({
'job_id': self.job.id,
'node_id': self.opt_in_node.id,
})
self.job.invalidate_recordset(['override_ids'])
self.assertIn(ovr, self.job.override_ids)
class TestFpJobStepsGenerator(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
self.product = self.env['product.product'].create({'name': 'W'})
self.wc = self.env['fp.work.centre'].create({
'name': 'Bath', 'code': 'BATH', 'kind': 'wet_line',
})
# Build a simple recipe: recipe → 2 operations + 1 opt-in op
self.recipe = self.env['fusion.plating.process.node'].create({
'name': 'TestRecipe',
'node_type': 'recipe',
})
# Legacy work centre (recipe nodes still point at the legacy model).
# Match the new fp.work.centre.code so the resolver picks it up.
facility = self.env['fusion.plating.facility'].search([], limit=1)
if not facility:
facility = self.env['fusion.plating.facility'].create({
'name': 'TestFacility',
'code': 'TF',
})
legacy_wc = self.env['fusion.plating.work.center'].search(
[('code', '=', 'BATH')], limit=1)
if not legacy_wc:
legacy_wc = self.env['fusion.plating.work.center'].create({
'name': 'Bath',
'code': 'BATH',
'facility_id': facility.id,
})
self.legacy_wc = legacy_wc
self.op1 = self.env['fusion.plating.process.node'].create({
'name': 'Plating Bath',
'node_type': 'operation',
'parent_id': self.recipe.id,
'sequence': 10,
'estimated_duration': 30.0,
'work_center_id': self.legacy_wc.id,
})
self.op2 = self.env['fusion.plating.process.node'].create({
'name': 'Bake',
'node_type': 'operation',
'parent_id': self.recipe.id,
'sequence': 20,
'estimated_duration': 60.0,
})
self.opt_in = self.env['fusion.plating.process.node'].create({
'name': 'Optional Inspect',
'node_type': 'operation',
'parent_id': self.recipe.id,
'sequence': 30,
'opt_in_out': 'opt_in',
})
def _make_job(self, **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
'recipe_id': self.recipe.id,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
def test_generator_creates_steps(self):
job = self._make_job()
job._generate_steps_from_recipe()
# 2 ops by default; opt_in skipped without an override
self.assertEqual(len(job.step_ids), 2)
def test_generator_idempotent(self):
job = self._make_job()
job._generate_steps_from_recipe()
first_count = len(job.step_ids)
job._generate_steps_from_recipe()
self.assertEqual(len(job.step_ids), first_count)
def test_generator_skips_no_recipe(self):
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
})
job._generate_steps_from_recipe()
self.assertFalse(job.step_ids)
def test_generator_respects_opt_in_override(self):
job = self._make_job()
self.env['fp.job.node.override'].create({
'job_id': job.id,
'node_id': self.opt_in.id,
'included': True,
})
job._generate_steps_from_recipe()
# 3 steps: 2 default + 1 opted-in
self.assertEqual(len(job.step_ids), 3)
def test_generator_recipe_node_link(self):
job = self._make_job()
job._generate_steps_from_recipe()
first_step = job.step_ids.sorted('sequence')[0]
self.assertEqual(first_step.recipe_node_id, self.op1)
self.assertEqual(first_step.duration_expected, 30.0)
# Work centre resolved by code from legacy model
self.assertEqual(first_step.work_centre_id, self.wc)
class TestSoConfirmHook(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
self.product = self.env['product.product'].create({'name': 'P'})
self.ICP = self.env['ir.config_parameter'].sudo()
def _make_so_with_plating_line(self, **line_vals):
# client_order_ref satisfies the fusion_plating_invoicing PO# gate.
so_vals = {
'partner_id': self.partner.id,
'client_order_ref': 'TEST-PO-001',
}
so = self.env['sale.order'].create(so_vals)
line_defaults = {
'order_id': so.id,
'product_id': self.product.id,
'product_uom_qty': 5.0,
'price_unit': 10.0,
}
line_defaults.update(line_vals)
self.env['sale.order.line'].create(line_defaults)
return so
def test_flag_off_no_job_created(self):
# Default flag is False
self.ICP.set_param('fusion_plating_jobs.use_native_jobs', 'False')
so = self._make_so_with_plating_line()
so.action_confirm()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertFalse(jobs)
def test_flag_on_creates_job(self):
self.ICP.set_param('fusion_plating_jobs.use_native_jobs', 'True')
# Need a plating line — add x_fc_part_catalog_id if available
if 'x_fc_part_catalog_id' in self.env['sale.order.line']._fields:
partner_for_part = self.env['res.partner'].create({'name': 'PartOwner'})
part = self.env['fp.part.catalog'].create({
'name': 'TPart', 'part_number': 'TP-1',
'partner_id': partner_for_part.id,
})
so = self._make_so_with_plating_line(x_fc_part_catalog_id=part.id)
so.action_confirm()
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
self.assertEqual(len(jobs), 1)
self.assertEqual(jobs.qty, 5.0)
self.assertEqual(jobs.part_catalog_id, part)
self.assertEqual(jobs.origin, so.name)
else:
self.skipTest('x_fc_part_catalog_id field not present on sale.order.line')
def test_flag_on_idempotent(self):
self.ICP.set_param('fusion_plating_jobs.use_native_jobs', 'True')
if 'x_fc_part_catalog_id' in self.env['sale.order.line']._fields:
partner_for_part = self.env['res.partner'].create({'name': 'PO'})
part = self.env['fp.part.catalog'].create({
'name': 'IdemPart', 'part_number': 'IP-1',
'partner_id': partner_for_part.id,
})
so = self._make_so_with_plating_line(x_fc_part_catalog_id=part.id)
so.action_confirm()
count_after_first = self.env['fp.job'].search_count(
[('sale_order_id', '=', so.id)])
# Calling action_confirm again should NOT create a duplicate
so._fp_auto_create_job()
count_after_second = self.env['fp.job'].search_count(
[('sale_order_id', '=', so.id)])
self.assertEqual(count_after_first, count_after_second)
else:
self.skipTest('x_fc_part_catalog_id field not present')
class TestJobLifecycleHooks(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
self.product = self.env['product.product'].create({'name': 'P'})
def _make_job(self, **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
def test_confirm_creates_portal_job(self):
job = self._make_job()
job.action_confirm()
self.assertTrue(job.portal_job_id)
self.assertEqual(job.portal_job_id.partner_id, self.partner)
def test_confirm_idempotent_portal_job(self):
job = self._make_job()
job.action_confirm()
portal_id = job.portal_job_id.id
# Second call (e.g. via a re-trigger) shouldn't create a duplicate
job._fp_create_portal_job()
self.assertEqual(job.portal_job_id.id, portal_id)
def test_button_mark_done_sets_state(self):
job = self._make_job()
job.action_confirm()
job.button_mark_done()
self.assertEqual(job.state, 'done')
self.assertTrue(job.date_finished)
def test_button_mark_done_creates_delivery(self):
job = self._make_job()
job.action_confirm()
job.button_mark_done()
self.assertTrue(job.delivery_id)
def test_button_mark_done_blocks_when_cancelled(self):
from odoo.exceptions import UserError
job = self._make_job()
job.action_cancel()
with self.assertRaises(UserError):
job.button_mark_done()
class TestPhase3Refactors(TransactionCase):
"""Phase 3 — verify parallel job/step links exist on the dependent
modules' models. Field-presence is enough; the migration logic is
Phase 9's concern."""
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
self.product = self.env['product.product'].create({'name': 'P'})
def test_fusion_plating_batch_has_x_fc_step_id(self):
self.assertIn('x_fc_step_id', self.env['fusion.plating.batch']._fields)
self.assertIn('x_fc_job_id', self.env['fusion.plating.batch']._fields)
# Verify comodels
self.assertEqual(
self.env['fusion.plating.batch']._fields['x_fc_step_id'].comodel_name,
'fp.job.step',
)
self.assertEqual(
self.env['fusion.plating.batch']._fields['x_fc_job_id'].comodel_name,
'fp.job',
)
def test_fusion_plating_quality_hold_has_x_fc_job_id(self):
self.assertIn(
'x_fc_job_id',
self.env['fusion.plating.quality.hold']._fields,
)
self.assertIn(
'x_fc_step_id',
self.env['fusion.plating.quality.hold']._fields,
)
def test_fp_certificate_has_x_fc_job_id(self):
self.assertIn('x_fc_job_id', self.env['fp.certificate']._fields)
self.assertEqual(
self.env['fp.certificate']._fields['x_fc_job_id'].comodel_name,
'fp.job',
)
def test_fp_thickness_reading_has_x_fc_job_id(self):
self.assertIn('x_fc_job_id', self.env['fp.thickness.reading']._fields)
self.assertIn('x_fc_step_id', self.env['fp.thickness.reading']._fields)
def test_fusion_plating_delivery_has_x_fc_job_id(self):
self.assertIn(
'x_fc_job_id',
self.env['fusion.plating.delivery']._fields,
)
self.assertEqual(
self.env['fusion.plating.delivery']._fields['x_fc_job_id'].comodel_name,
'fp.job',
)
def test_fp_racking_inspection_has_x_fc_job_id(self):
self.assertIn(
'x_fc_job_id',
self.env['fp.racking.inspection']._fields,
)
def test_racking_inspection_helper_skips_without_mo(self):
"""The auto-create helper should silently skip when the job
has no production_id (pure-native mode). Should NOT raise."""
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
})
# action_confirm should run cleanly even though we cannot
# satisfy the model's required production_id today.
job.action_confirm()
# No exception is the assertion. No inspection should exist
# for this job since the helper skipped.
if 'x_fc_job_id' in self.env['fp.racking.inspection']._fields:
inspections = self.env['fp.racking.inspection'].search(
[('x_fc_job_id', '=', job.id)],
)
self.assertFalse(inspections)
class TestPhase4Refactors(TransactionCase):
"""Phase 4 — light refactors batch B (notifications, KPI source tag).
Configurator integration is already covered by Task 2.5's SO confirm
hook (which reads x_fc_part_catalog_id / x_fc_coating_config_id from
sale.order.line — see TestSoConfirmHook above).
"""
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
self.product = self.env['product.product'].create({'name': 'P'})
def test_kpi_value_has_source_field(self):
if 'fusion.plating.kpi.value' in self.env:
self.assertIn(
'x_fc_source',
self.env['fusion.plating.kpi.value']._fields,
)
def test_notification_template_has_job_triggers(self):
if 'fp.notification.template' in self.env:
triggers = dict(
self.env['fp.notification.template']
._fields['trigger_event'].selection
)
self.assertIn('job_confirmed', triggers)
self.assertIn('job_complete', triggers)
def test_action_confirm_calls_fire_notification(self):
# Smoke test — creates a job, confirms it, verifies no exception
# thrown by the notification path even when no templates exist.
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
})
job.action_confirm() # Should not raise even with no templates
self.assertEqual(job.state, 'confirmed')
class TestReports(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
self.product = self.env['product.product'].create({'name': 'P'})
def test_sticker_report_action_exists(self):
action = self.env.ref('fusion_plating_jobs.action_report_fp_job_sticker', raise_if_not_found=False)
self.assertTrue(action)
self.assertEqual(action.model, 'fp.job')
def test_traveller_report_action_exists(self):
action = self.env.ref('fusion_plating_jobs.action_report_fp_job_traveller', raise_if_not_found=False)
self.assertTrue(action)
self.assertEqual(action.model, 'fp.job')
def test_sticker_renders_for_a_job(self):
# Smoke test: the QWeb template should render without error.
job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
})
report = self.env.ref('fusion_plating_jobs.action_report_fp_job_sticker')
# Render HTML (faster than PDF; doesn't need wkhtmltopdf)
html, _ = report._render_qweb_html(report.report_name, job.ids)
self.assertIn(job.name, html.decode() if isinstance(html, bytes) else html)
class TestPhase6Controllers(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'C'})
self.product = self.env['product.product'].create({'name': 'P'})
self.job = self.env['fp.job'].create({
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1.0,
})
def test_scan_controller_route_registered(self):
# Verify the QR-scan controller is registered. The parallel
# process_tree / plant_overview / manager_dashboard / tablet
# controllers were consolidated into fusion_plating_shopfloor on
# 2026-04-24; the only controller left in this module is
# job_scan (the QR-sticker scan redirect).
from odoo.addons.fusion_plating_jobs.controllers import job_scan
self.assertTrue(hasattr(job_scan, 'FpJobScanController'))
def test_process_tree_endpoint_logic(self):
# The native process_tree endpoint now lives in
# fusion_plating_shopfloor (consolidated 2026-04-24). This test
# verifies the recipe-node → step lookup that the endpoint
# depends on still works for fp.job rows seeded from a recipe.
recipe = self.env['fusion.plating.process.node'].create({
'name': 'R', 'node_type': 'recipe',
})
op = self.env['fusion.plating.process.node'].create({
'name': 'Op1', 'node_type': 'operation',
'parent_id': recipe.id, 'sequence': 10,
})
self.job.recipe_id = recipe.id
self.env['fp.job.step'].create({
'job_id': self.job.id, 'name': 'Op1', 'sequence': 10,
'recipe_node_id': op.id,
})
step_by_node = {s.recipe_node_id.id: s for s in self.job.step_ids if s.recipe_node_id}
self.assertIn(op.id, step_by_node)
self.assertEqual(step_by_node[op.id].name, 'Op1')
class TestFpJobSmartButtons(TransactionCase):
"""Feature A — verify smart-button count fields and action methods
are wired on fp.job. Runtime-detect tests confirm the methods exist
without requiring downstream models to be installed."""
def test_smart_count_fields_exist(self):
for f in (
'sale_order_count', 'delivery_count', 'invoice_count',
'payment_count', 'quality_hold_count', 'certificate_count',
'timelog_count', 'portal_job_count',
):
self.assertIn(f, self.env['fp.job']._fields)
def test_smart_action_methods_exist(self):
Job = self.env['fp.job']
for m in (
'action_view_sale_order', 'action_view_steps',
'action_view_deliveries', 'action_view_invoices',
'action_view_payments', 'action_view_quality_holds',
'action_view_certificates', 'action_view_timelogs',
'action_view_portal_job',
):
self.assertTrue(
hasattr(Job, m),
'fp.job missing action method %s' % m,
)
def test_smart_counts_compute_for_empty_job(self):
partner = self.env['res.partner'].create({'name': 'C'})
product = self.env['product.product'].create({'name': 'W'})
job = self.env['fp.job'].create({
'partner_id': partner.id,
'product_id': product.id,
'qty': 1.0,
})
# All counts should be 0 on a freshly-created job (no SO,
# no delivery, no portal job, no holds, etc.)
self.assertEqual(job.sale_order_count, 0)
self.assertEqual(job.delivery_count, 0)
self.assertEqual(job.invoice_count, 0)
self.assertEqual(job.payment_count, 0)
self.assertEqual(job.quality_hold_count, 0)
self.assertEqual(job.certificate_count, 0)
self.assertEqual(job.timelog_count, 0)
self.assertEqual(job.portal_job_count, 0)
class TestPhase7Migration(TransactionCase):
"""Phase 7 — verify the migration script idempotency-key fields are
in place and the script files are present + parse as valid Python.
We cannot run the migration end-to-end in a unit test (it would need
a populated MO/WO snapshot). Instead we assert the scaffolding is
solid: fields exist, files are well-formed.
"""
def test_legacy_id_field_on_fp_job(self):
self.assertIn(
'legacy_mrp_production_id',
self.env['fp.job']._fields,
)
# Should be Integer (we store the raw db id, not a Many2one — the
# source MO may be archived later without breaking the link).
self.assertEqual(
self.env['fp.job']._fields['legacy_mrp_production_id'].type,
'integer',
)
def test_legacy_id_field_on_fp_job_step(self):
self.assertIn(
'legacy_mrp_workorder_id',
self.env['fp.job.step']._fields,
)
self.assertEqual(
self.env['fp.job.step']._fields['legacy_mrp_workorder_id'].type,
'integer',
)
def test_migration_script_files_exist_and_parse(self):
# Sanity check that the script files we ship are valid Python.
# Catches syntax errors that would otherwise only surface on the
# cutover engineer's screen at the worst possible moment.
import ast
from pathlib import Path
scripts_dir = (
Path(__file__).parent.parent / 'scripts'
)
for script in (
'audit_pre_migration.py',
'migrate_to_fp_jobs.py',
'audit_post_migration.py',
):
path = scripts_dir / script
self.assertTrue(path.exists(), '%s missing' % script)
with open(path) as f:
ast.parse(f.read()) # Will raise SyntaxError if invalid
def test_scripts_dir_is_a_python_package(self):
# __init__.py exists so Odoo's autodiscovery doesn't trip and the
# dir is importable for hypothetical future post-migration hooks.
from pathlib import Path
init = (
Path(__file__).parent.parent / 'scripts' / '__init__.py'
)
self.assertTrue(init.exists())

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!--
Adds a "Process Tree" header button + smart-button row to the
fp.job form. The fp.job form in core has no button_box yet, so
we inject one at the top of the sheet (xpath //sheet position
"inside" with a sibling reference at the start).
Smart buttons appear only when the underlying count is > 0
(except Steps, which always shows since every confirmed job
has steps). Pattern follows the existing oe_stat_button row
from sale.order / mrp.production.
Process Tree header button is hidden while the job is in draft
(no recipe-derived steps yet).
-->
<record id="view_fp_job_form_jobs_inherit" model="ir.ui.view">
<field name="name">fp.job.form.jobs.inherit</field>
<field name="model">fp.job</field>
<field name="inherit_id" ref="fusion_plating.view_fp_job_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_open_process_tree" type="object"
string="Process Tree"
class="btn-secondary"
icon="fa-sitemap"
invisible="state == 'draft'"/>
</xpath>
<!-- Inject a button_box at the top of the sheet, before the
oe_title block. Smart buttons drill into the matching
records the way sale.order does. -->
<xpath expr="//sheet/div[hasclass('oe_title')]" position="before">
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_order" type="object"
class="oe_stat_button" icon="fa-shopping-cart"
invisible="sale_order_count == 0">
<field name="sale_order_count" widget="statinfo"
string="Sale Order"/>
</button>
<button name="action_view_steps" type="object"
class="oe_stat_button" icon="fa-list-ol">
<field name="step_count" widget="statinfo"
string="Steps"/>
</button>
<button name="action_view_deliveries" type="object"
class="oe_stat_button" icon="fa-truck"
invisible="delivery_count == 0">
<field name="delivery_count" widget="statinfo"
string="Delivery"/>
</button>
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-file-text-o"
invisible="invoice_count == 0">
<field name="invoice_count" widget="statinfo"
string="Invoices"/>
</button>
<button name="action_view_payments" type="object"
class="oe_stat_button" icon="fa-money"
invisible="payment_count == 0">
<field name="payment_count" widget="statinfo"
string="Payments"/>
</button>
<button name="action_view_quality_holds" type="object"
class="oe_stat_button" icon="fa-pause-circle"
invisible="quality_hold_count == 0">
<field name="quality_hold_count" widget="statinfo"
string="Holds"/>
</button>
<button name="action_view_certificates" type="object"
class="oe_stat_button" icon="fa-certificate"
invisible="certificate_count == 0">
<field name="certificate_count" widget="statinfo"
string="Certificates"/>
</button>
<button name="action_view_timelogs" type="object"
class="oe_stat_button" icon="fa-clock-o"
invisible="timelog_count == 0">
<field name="timelog_count" widget="statinfo"
string="Time Logs"/>
</button>
<button name="action_view_portal_job" type="object"
class="oe_stat_button" icon="fa-globe"
invisible="portal_job_count == 0">
<field name="portal_job_count" widget="statinfo"
string="Portal Job"/>
</button>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!--
Add "All Jobs" 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.
-->
<menuitem id="menu_fp_jobs_all_jobs"
name="All Jobs"
parent="fusion_plating_shopfloor.menu_fp_shopfloor"
action="fusion_plating.action_fp_job"
sequence="15"/>
<menuitem id="menu_fp_jobs_steps"
name="Steps"
parent="fusion_plating_shopfloor.menu_fp_shopfloor"
action="fusion_plating.action_fp_job_step"
sequence="17"
groups="fusion_plating.group_fusion_plating_supervisor"/>
</odoo>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="0">
<!-- After the shopfloor consolidation (2026-04-24) the shopfloor
operator UIs are the canonical native fp.job / fp.job.step
consoles. Only bridge_mrp's Production Priorities menu (still
bound to mrp.workorder) remains legacy.
The group_fusion_plating_legacy_menus group is preserved so a
site that needs to bring legacy menus back can simply add a
user to the group. -->
<!-- Reset group_ids on the 3 shopfloor menus that used to be
hidden — they are now the canonical UIs and should be visible
to all users (subject to the original groups= attribute on
each menuitem in fusion_plating_shopfloor/views/fp_menu.xml). -->
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_manager" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating.group_fusion_plating_manager')])]"/>
</record>
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [])]"/>
</record>
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_tablet" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [])]"/>
</record>
<!-- bridge_mrp: Production Priorities is mrp.workorder ordering UI;
fp.job has its own priority field on the header. Hidden from
operators / supervisors / managers; only the legacy group sees it. -->
<record id="fusion_plating_bridge_mrp.menu_fp_workorder_priority" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating_jobs.group_fusion_plating_legacy_menus')])]"/>
</record>
</odoo>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_res_config_settings_jobs" model="ir.ui.view">
<field name="name">res.config.settings.fp.jobs</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Plating Jobs" string="Fusion Plating Jobs" name="fusion_plating_jobs">
<block title="Native Job Migration" name="fp_jobs_migration">
<setting id="fp_use_native_jobs"
string="Use Native Plating Jobs"
help="When enabled, SO confirmation creates fp.job records instead of mrp.production. Phase-2 migration toggle.">
<field name="x_fc_use_native_jobs"/>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.7.14.0',
'version': '19.0.7.17.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -22,25 +22,42 @@ class FpWoScanController(http.Controller):
def wo_scan_redirect(self, wo_id, **kwargs):
"""Redirect a scanned sticker to the right backend form.
Stickers are printed from two sources — mrp.workorder (WO) and
mrp.production (MO) — and both embed their own numeric id in
the QR. Try the MO table first (operators live on the MO
form — customer, SO, all WOs visible) and fall back to WO.
Resolution order:
1. fp.job mapped from this MO id via legacy_mrp_production_id
(post-migration: physical stickers still encode the old MO
id, but the canonical record is now an fp.job)
2. mrp.production with this id (pre-migration callers, or if
the legacy mapping wasn't run)
3. mrp.workorder with this id (older stickers that encoded
the WO id rather than the MO id)
4. fall back to the jobs list so staff can search manually.
"""
MO = request.env['mrp.production'].sudo()
WO = request.env['mrp.workorder'].sudo()
env = request.env
mo = MO.browse(wo_id).exists()
# 1) New native model — preferred when migration has run.
if 'fp.job' in env and 'legacy_mrp_production_id' in env['fp.job']._fields:
job = env['fp.job'].sudo().search(
[('legacy_mrp_production_id', '=', wo_id)], limit=1)
if job:
return request.redirect(
'/odoo/action-fusion_plating.action_fp_job/%d' % job.id
)
# 2) Legacy MO form (pre-migration or non-migrated records).
mo = env['mrp.production'].sudo().browse(wo_id).exists()
if mo:
return request.redirect(
'/odoo/action-mrp.mrp_production_action/%d' % mo.id
)
wo = WO.browse(wo_id).exists()
# 3) Legacy WO form.
wo = env['mrp.workorder'].sudo().browse(wo_id).exists()
if wo:
return request.redirect(
'/odoo/action-mrp.action_mrp_workorder/%d' % wo.id
)
# Neither resolved — land on the WO list so staff can search manually.
# 4) Fall back: native jobs list if it exists, otherwise WO list.
if 'fp.job' in env:
return request.redirect('/odoo/plating-jobs')
return request.redirect('/odoo/manufacturing/work-orders')

View File

@@ -369,6 +369,24 @@
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
</record>
<!-- Same sticker bound to sale.order — prints one sticker per
order line that carries a part, so estimators / receiving can
hand them to the floor before fp.jobs even exist. Uses the
same paperformat (6x4") so estimators don't need to think
about page size; the output PDF is multi-page if the SO has
multiple plating lines. -->
<record id="action_report_fp_so_sticker" model="ir.actions.report">
<field name="name">WO Box Sticker</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_so_sticker</field>
<field name="report_file">fusion_plating_reports.report_fp_so_sticker</field>
<field name="print_report_name">'WO Sticker - %s' % (object.name or '').replace('/', '-')</field>
<field name="binding_model_id" ref="sale.model_sale_order"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
</record>
<!-- ============================================================= -->
<!-- 15. Packing Slip (Portrait + Landscape) -->
<!-- ============================================================= -->

View File

@@ -5,38 +5,70 @@
Parts-box identification sticker — printed on a 4x3" label.
Bound to BOTH mrp.production (MO) and mrp.workorder (WO) because
the shop talks in "WO #" terms (Steelhead legacy) but the data
hangs off the MO record. The inner template normalises either
input to the same set of resolved variables:
Bound to mrp.production (MO), mrp.workorder (WO), fp.job, and
sale.order. The shop talks in "WO #" terms (Steelhead legacy) but
the data may hang off any of those records. The inner template
normalises every input to the same set of resolved variables and
accepts either pre-resolved values from the outer template OR
resolves them itself from `_mo` when called from an mrp.* context.
Variables an outer template MAY pre-set (otherwise falls back to
`_mo`-based resolution):
* _order_id — number to print as "WO #"
* _mo — the mrp.production record
* _scan_id — id encoded into the QR URL
* _scan_path — '/fp/job/' or '/fp/wo/' prefix (default '/fp/wo/')
* _mo — the mrp.production record (or False)
* _so, _line — the originating sale order / line
* _part — fp.part.catalog
* _coating — fp.coating.config
* _process — the resolved fusion.plating.process.node tree
* _scan_url — base_url + /fp/wo/<id> (encoded into the QR)
* _due — datetime/date for "Due Date" row
* _qty — float for "Qty" row
* _po_number — overrides _so.x_fc_po_number
* _partner_name — overrides _so.partner_id.name
* _mo_ref — string shown muted in "(WH/MO/...)" — '' to hide
* _internal_note— free text for "Notes" row
-->
<odoo>
<!-- ========== Shared inner template ========== -->
<template id="report_fp_wo_sticker_inner">
<t t-set="_base_url" t-value="env['ir.config_parameter'].sudo().get_param('web.base.url', '')"/>
<t t-set="_scan_url" t-value="_base_url + '/fp/wo/' + str(_scan_id)"/>
<t t-set="_so" t-value="_mo and env['sale.order'].sudo().search(
[('name', '=', _mo.origin)], limit=1) or False"/>
<t t-set="_line" t-value="(_mo and 'x_fc_sale_order_line_ids' in _mo._fields
<t t-set="_scan_path" t-value="_scan_path or '/fp/wo/'"/>
<t t-set="_scan_url" t-value="_base_url + _scan_path + str(_scan_id)"/>
<!-- Each variable: prefer the outer-supplied value, otherwise
resolve from _mo. This lets fp.job / sale.order outers feed
pre-resolved data while keeping the original mrp.production /
mrp.workorder callers working untouched. -->
<t t-set="_so" t-value="_so or (_mo and env['sale.order'].sudo().search(
[('name', '=', _mo.origin)], limit=1)) or False"/>
<t t-set="_line" t-value="_line
or (_mo and 'x_fc_sale_order_line_ids' in _mo._fields
and _mo.x_fc_sale_order_line_ids[:1])
or (_so and _so.order_line[:1])
or False"/>
<t t-set="_part" t-value="_line and _line.x_fc_part_catalog_id or False"/>
<t t-set="_coating" t-value="_line and _line.x_fc_coating_config_id or False"/>
<t t-set="_process" t-value="(_part and _part.default_process_id)
<t t-set="_part" t-value="_part or (_line and _line.x_fc_part_catalog_id) or False"/>
<t t-set="_coating" t-value="_coating or (_line and _line.x_fc_coating_config_id) or False"/>
<t t-set="_process" t-value="_process
or (_part and _part.default_process_id)
or (_coating and _coating.recipe_id)
or False"/>
<t t-set="_due" t-value="(_mo and (_mo.date_deadline or _mo.date_finished))
<t t-set="_due" t-value="_due
or (_mo and (_mo.date_deadline or _mo.date_finished))
or (_line and _line.x_fc_part_deadline)
or False"/>
<t t-set="_qty" t-value="_qty if _qty is not None and _qty is not False
else (_mo and _mo.product_qty) or 0"/>
<t t-set="_po_number" t-value="_po_number or (_so and _so.x_fc_po_number) or '-'"/>
<t t-set="_partner_name" t-value="_partner_name or (_so and _so.partner_id.name) or '-'"/>
<!-- _mo_ref controls the muted "(WH/MO/00033)" suffix next to PO.
Outer can pass '' to hide it (e.g. fp.job already shows its
own name in the header). Defaults to _mo.name. -->
<t t-set="_mo_ref" t-value="_mo_ref if _mo_ref is not None and _mo_ref is not False
else (_mo and _mo.name) or ''"/>
<t t-set="_internal_note" t-value="_internal_note
or (_so and _so.x_fc_internal_note
and _so.x_fc_internal_note.striptags()[:100])
or '-'"/>
<!-- Inline the QR as base64 data URI so wkhtmltopdf doesn't need
to fetch /report/barcode/ over the network during rendering. -->
<t t-set="_qr_src" t-value="env['ir.actions.report'].barcode_data_uri(
@@ -241,10 +273,10 @@
<td class="fp-sticker-label">PO (RO):</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong"
t-esc="(_so and _so.x_fc_po_number) or '-'"/>
<t t-if="_mo">
t-esc="_po_number"/>
<t t-if="_mo_ref">
<span class="fp-sticker-muted">
(<span t-esc="_mo.name"/>)
(<span t-esc="_mo_ref"/>)
</span>
</t>
</td>
@@ -252,7 +284,7 @@
<tr>
<td class="fp-sticker-label">Customer:</td>
<td class="fp-sticker-value">
<span t-esc="(_so and _so.partner_id.name) or '-'"/>
<span t-esc="_partner_name"/>
</td>
</tr>
<tr>
@@ -274,8 +306,17 @@
<span class="fp-sticker-strong"
t-esc="_part.part_number"/>
<t t-if="_part.revision">
<!-- Some parts store the revision with a
"Rev " prefix already (e.g. "Rev 1"),
others store just the value ("1", "A").
Strip a leading "Rev " (case insensitive)
so we don't print "Rev Rev 1". -->
<t t-set="_rev_clean" t-value="_part.revision.strip()"/>
<t t-if="_rev_clean.lower().startswith('rev ')">
<t t-set="_rev_clean" t-value="_rev_clean[4:].strip()"/>
</t>
<span class="fp-sticker-muted">
Rev <span t-esc="_part.revision"/>
Rev <span t-esc="_rev_clean"/>
</span>
</t>
</t>
@@ -295,7 +336,6 @@
<td class="fp-sticker-label">Qty:</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong">
<t t-set="_qty" t-value="_mo and _mo.product_qty or 0"/>
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
</span>
</td>
@@ -303,8 +343,7 @@
<tr>
<td class="fp-sticker-label">Notes:</td>
<td class="fp-sticker-value">
<t t-esc="(_so and _so.x_fc_internal_note
and _so.x_fc_internal_note.striptags()[:100]) or '-'"/>
<t t-esc="_internal_note"/>
</td>
</tr>
</table>
@@ -312,10 +351,32 @@
</div>
</template>
<!-- =====================================================
Reusable defaults block — every outer template t-calls
this BEFORE the sticker inner so `_so`, `_line`, etc.
are always defined. The inner's `_so or fallback`
pattern relies on these names existing in scope.
===================================================== -->
<template id="report_fp_wo_sticker_defaults">
<t t-set="_so" t-value="False"/>
<t t-set="_line" t-value="False"/>
<t t-set="_part" t-value="False"/>
<t t-set="_coating" t-value="False"/>
<t t-set="_process" t-value="False"/>
<t t-set="_due" t-value="False"/>
<t t-set="_qty" t-value="False"/>
<t t-set="_po_number" t-value="False"/>
<t t-set="_partner_name" t-value="False"/>
<t t-set="_mo_ref" t-value="False"/>
<t t-set="_internal_note" t-value="False"/>
<t t-set="_scan_path" t-value="False"/>
</template>
<!-- ========== Outer template — mrp.workorder entry ========== -->
<template id="report_fp_wo_sticker">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="doc.id"/>
<t t-set="_scan_id" t-value="doc.id"/>
<t t-set="_mo" t-value="doc.production_id"/>
@@ -328,6 +389,7 @@
<template id="report_fp_mo_sticker">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<!-- Shop floor talks in "WO #" regardless of Odoo's MO/WO
split. QR always encodes the numeric id so scans
resolve cleanly via /fp/wo/<id>. -->
@@ -339,4 +401,39 @@
</t>
</template>
<!-- ========== Outer template — sale.order entry ==========
Prints one box sticker per order line that has a part. Lines
without x_fc_part_catalog_id (service lines, freight, etc.) are
skipped — they don't go through plating so they don't need a
box sticker.
The "WO #" header shows "<SO>/<line seq>" so the sticker
remains identifiable before the fp.job is generated. The QR
encodes /fp/so-line/<line.id> — the controller can decide
whether to land on the parent SO, the line, or (later) the
spawned job. -->
<template id="report_fp_so_sticker">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="so">
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
t-as="line">
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
<t t-set="_order_id" t-value="so.name + ' / ' + str(line.sequence or line.id)"/>
<t t-set="_scan_id" t-value="line.id"/>
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
<t t-set="_mo" t-value="False"/>
<t t-set="_so" t-value="so"/>
<t t-set="_line" t-value="line"/>
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
<t t-set="_qty" t-value="line.product_uom_qty"/>
<t t-set="_partner_name" t-value="so.partner_id.name"/>
<t t-set="_mo_ref" t-value="''"/>
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
</t>
</t>
</t>
</template>
</odoo>

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.14.4.0',
'version': '19.0.24.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
@@ -50,6 +50,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'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_menu.xml',
],
'demo': [
@@ -61,11 +62,24 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
# and variables directly (Odoo 19 forbids @import in custom SCSS,
# so tokens are resolved via bundle concatenation order).
'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss',
'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss',
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',
'fusion_plating_shopfloor/static/src/scss/process_tree.scss',
'fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss',
'fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss',
# ZXing-js (vendored) — primary QR decoder. Robust to the
# perspective skew, motion blur, and glare that beat jsQR
# on phone cameras. Same engine the iOS Camera app uses
# under the hood. UMD bundle exposes `window.ZXing`.
'fusion_plating_shopfloor/static/lib/zxing/zxing.min.js',
# jsQR (vendored) — fallback decoder. Faster than ZXing but
# less tolerant; only used if ZXing fails to load.
'fusion_plating_shopfloor/static/lib/jsQR/jsQR.js',
# qr_scanner.js MUST load before its consumers so the
# `import { QrScanner } from "./qr_scanner"` resolves.
'fusion_plating_shopfloor/static/src/js/qr_scanner.js',
'fusion_plating_shopfloor/static/src/xml/qr_scanner.xml',
'fusion_plating_shopfloor/static/src/xml/shopfloor_tablet.xml',
'fusion_plating_shopfloor/static/src/xml/plant_overview.xml',
'fusion_plating_shopfloor/static/src/xml/process_tree.xml',
@@ -75,6 +89,12 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/js/process_tree.js',
'fusion_plating_shopfloor/static/src/js/manager_dashboard.js',
],
'web.assets_frontend': [
# Tank status page (rendered via web.frontend_layout for
# NFC tap-to-view from a phone). Tokens loaded first.
'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss',
'fusion_plating_shopfloor/static/src/scss/tank_status.scss',
],
},
'installable': True,
'application': False,

View File

@@ -4,3 +4,4 @@
from . import shopfloor_controller
from . import manager_controller
from . import tank_status

View File

@@ -2,7 +2,20 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""JSON-RPC endpoints for the Manager Dashboard (client action)."""
"""JSON-RPC endpoints for the Manager Desk (client action).
Native fp.job / fp.job.step edition. Speaks fp.job/fp.job.step
end-to-end — payload keys, variables, and RPC kwargs all use the
job/step vocabulary.
Manager Desk ergonomics:
- Column 1 ("Needs a Worker") = jobs that have at least one step
missing the bits a manager has to set before an operator can tap
Start (worker, work centre, kind-specific equipment).
- Column 2 ("In Progress") = jobs whose steps are release-ready or
actively running.
- Column 3 ("Team") = operators with their open / in-progress counts.
"""
import logging
@@ -15,26 +28,44 @@ from odoo.http import request
_logger = logging.getLogger(__name__)
class FpManagerDashboardController(http.Controller):
"""Manager-level view: unassigned jobs, in-progress jobs, team workload.
# --- helpers -----------------------------------------------------------------
All endpoints require the user to be a manager or above. The UI locks
the menu behind group_fusion_plating_manager.
"""
_NEG_JOB_STATES = ('done', 'cancelled')
_ACTIVE_JOB_STATES = ('confirmed', 'in_progress', 'on_hold')
# A step needs an operator and (for wet/bake/mask) the right equipment
# before the operator can tap Start.
def _step_release_readiness(step):
"""Return (is_release_ready, missing_str) for a fp.job.step."""
missing = []
if not step.assigned_user_id:
missing.append('worker')
if not step.work_centre_id:
missing.append('work centre')
if step.kind == 'wet':
if not step.bath_id:
missing.append('bath')
if not step.tank_id:
missing.append('tank')
elif step.kind == 'rack':
if not step.rack_id:
missing.append('rack')
return (not missing, ', '.join(missing))
def _priority_int(priority):
"""fp.job.priority → int 0/1/2."""
return {'rush': 2, 'high': 1, 'normal': 0, 'low': 0}.get(priority, 0)
class FpManagerDashboardController(http.Controller):
"""Manager-level view: unassigned jobs, in-progress jobs, team workload."""
# ------------------------------------------------------------------
# Overview snapshot — used on initial load + 30s auto-refresh
# Overview snapshot — used on initial load + 8s auto-refresh
# ------------------------------------------------------------------
@http.route('/fp/manager/overview', type='jsonrpc', auth='user')
def overview(self, facility_id=None, known_hash=None):
"""Build the manager dashboard payload.
`known_hash`: if the client sends back the hash of its last
overview, we compare and return `{'unchanged': True}` when
nothing has moved. Keeps the UI flicker-free between polls
while still catching every shop-floor change within a few
seconds.
"""
try:
return self._overview_payload(facility_id, known_hash)
except Exception as exc: # noqa: BLE001
@@ -43,187 +74,122 @@ class FpManagerDashboardController(http.Controller):
def _overview_payload(self, facility_id, known_hash):
env = request.env
MrpWO = env.get('mrp.workorder')
Production = env.get('mrp.production')
if MrpWO is None or Production is None:
return {
'ok': True,
'kpis': {'unassigned_wos': 0, 'active_wos': 0,
'ready_to_ship_mos': 0, 'pending_accept_sos': 0},
'unassigned': [], 'active': [], 'team': [],
'operators': [], 'tanks': [],
'user_name': env.user.name,
'mrp_missing': True,
'payload_hash': '',
}
# The assignment field lives in fusion_plating_bridge_mrp. If it's
# missing, the dashboard still renders but the worker pickers are
# effectively read-only.
has_assign = 'x_fc_assigned_user_id' in MrpWO._fields
Job = env['fp.job']
Step = env['fp.job.step']
# ---- Column 1: Unassigned ("Setup Pending") --------------------
# A WO stays here until the manager has set EVERY field
# button_start would block on (operator + per-kind equipment).
# Without this, picking a worker would auto-jump the row to
# "In Progress" before bath/tank/oven/rack/material are set.
# We compute release-readiness in Python after the SQL search
# because x_fc_is_release_ready is a non-stored compute.
ACTIVE_NEG_STATES = ('done', 'cancel')
domain_active_states = [('state', 'not in', ACTIVE_NEG_STATES)]
# Pull in-flight jobs (confirmed / in_progress / on_hold)
domain = [('state', 'in', _ACTIVE_JOB_STATES)]
if facility_id:
domain_active_states.append(
('workcenter_id.x_fc_facility_id', '=', int(facility_id)))
all_active_wos = MrpWO.search(domain_active_states, order='sequence, id')
# Split: not-release-ready → Unassigned/Setup column; rest → In Progress
if 'x_fc_is_release_ready' in MrpWO._fields:
unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_is_release_ready)
elif has_assign:
unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_assigned_user_id)
domain.append(('facility_id', '=', int(facility_id)))
jobs = Job.search(domain, order='priority desc, date_deadline asc, id desc')
# Compute release-readiness per step in a single pass
all_steps = jobs.mapped('step_ids').filtered(
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
)
readiness_by_step = {}
for step in all_steps:
ready, missing = _step_release_readiness(step)
readiness_by_step[step.id] = (ready, missing)
# Bucket jobs: "needs a worker" vs "in progress".
# A job lands in unassigned iff at least one of its open steps
# is NOT release-ready. Otherwise it goes to in_progress.
unassigned_jobs = jobs.browse([])
active_jobs = jobs.browse([])
for job in jobs:
open_steps = job.step_ids.filtered(
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
)
if not open_steps:
continue
not_ready = any(not readiness_by_step.get(s.id, (False, ''))[0]
for s in open_steps)
if not_ready:
unassigned_jobs |= job
else:
unassigned_wos = all_active_wos
active_jobs |= job
# Roll up to MO level
def _group_by_mo(wos):
groups = {}
for wo in wos:
mo_id = wo.production_id.id
groups.setdefault(mo_id, []).append(wo)
return groups
def _job_card(job, only_open=True):
partner = job.partner_id
steps_iter = job.step_ids
if only_open:
steps_iter = steps_iter.filtered(
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
)
steps_iter = steps_iter.sorted('sequence')
step_rows = []
for s in steps_iter:
ready, missing = readiness_by_step.get(s.id, (False, ''))
step_rows.append({
'id': s.id,
'name': s.name or '',
'workcenter': s.work_centre_id.name or '',
'state': s.state,
'sequence': s.sequence or 0,
'duration_expected': s.duration_expected or 0,
'bath': s.bath_id.name or '',
'tank': s.tank_id.name or '',
'tank_id': s.tank_id.id if s.tank_id else False,
'priority': str(_priority_int(job.priority)),
'assigned_user_id': s.assigned_user_id.id or False,
'assigned_user_name': s.assigned_user_id.name or '',
'role_id': False,
'role_name': '',
'kind': s.kind or 'other',
'kind_label': dict(s._fields['kind'].selection).get(
s.kind, '',
) if s.kind else '',
'is_release_ready': ready,
'missing_for_release': missing,
'oven': '',
'rack': s.rack_id.name or '',
'masking_material': '',
})
def _mo_card(mo, wos):
so_name = mo.origin or ''
partner = mo.x_fc_portal_job_id.partner_id if mo.x_fc_portal_job_id else None
return {
'mo_id': mo.id,
'mo_name': mo.name,
'so_name': so_name,
'job_id': job.id,
'job_name': job.name or '',
'so_name': job.origin or '',
'customer': partner.name if partner else '',
'product': mo.product_id.display_name if mo.product_id else '',
'qty_total': int(mo.product_qty or 0),
'date_planned': fp_format(request.env, mo.date_start, fmt='%Y-%m-%d'),
'recipe': mo.x_fc_recipe_id.name if mo.x_fc_recipe_id else '',
'priority_any': max(
[int(w.x_fc_priority or '0') for w in wos] + [0]
'product': job.product_id.display_name if job.product_id else '',
'qty_total': int(job.qty or 0),
'date_planned': fp_format(
request.env, job.date_planned_start or job.date_deadline,
fmt='%Y-%m-%d',
),
'current_location': mo.x_fc_current_location or '',
'wos': [
{
'id': w.id,
'name': w.display_name or w.name,
'workcenter': w.workcenter_id.name or '',
'state': w.state,
'sequence': w.sequence or 0,
'duration_expected': w.duration_expected or 0,
'bath': w.x_fc_bath_id.name or '',
'tank': w.x_fc_tank_id.name or '',
'tank_id': w.x_fc_tank_id.id if w.x_fc_tank_id else False,
'priority': w.x_fc_priority or '0',
'assigned_user_id': (
w.x_fc_assigned_user_id.id
if w.x_fc_assigned_user_id else False
),
'assigned_user_name': (
w.x_fc_assigned_user_id.name or ''
if w.x_fc_assigned_user_id else ''
),
# Role required by this step. Used by the
# Manager Desk worker dropdown to surface
# qualified operators first.
'role_id': (
w.x_fc_work_role_id.id
if w.x_fc_work_role_id else False
),
'role_name': (
w.x_fc_work_role_id.name or ''
if w.x_fc_work_role_id else ''
),
# WO kind classification + what's still missing
# before the WO can be released to the operator.
# Manager Desk uses these to render the kind
# badge and the "needs: bath, tank" hint chips.
'wo_kind': (
w.x_fc_wo_kind
if 'x_fc_wo_kind' in w._fields else 'other'
),
'wo_kind_label': dict(
w._fields['x_fc_wo_kind'].selection
).get(w.x_fc_wo_kind, '') if 'x_fc_wo_kind' in w._fields else '',
'is_release_ready': (
w.x_fc_is_release_ready
if 'x_fc_is_release_ready' in w._fields else False
),
'missing_for_release': (
w.x_fc_missing_for_release or ''
if 'x_fc_missing_for_release' in w._fields else ''
),
# Surface oven, rack, masking material so the
# manager can see at a glance what's set.
'oven': (
w.x_fc_oven_id.name or ''
if 'x_fc_oven_id' in w._fields and w.x_fc_oven_id
else ''
),
'rack': (
w.x_fc_rack_id.name or ''
if 'x_fc_rack_id' in w._fields and w.x_fc_rack_id
else ''
),
'masking_material': (
dict(w._fields['x_fc_masking_material'].selection).get(
w.x_fc_masking_material, ''
) if 'x_fc_masking_material' in w._fields and w.x_fc_masking_material
else ''
),
}
for w in wos
],
'recipe': job.recipe_id.name if job.recipe_id else '',
'priority_any': _priority_int(job.priority),
'current_location': job.current_location or '',
'steps': step_rows,
}
unassigned_cards = []
for mo_id, wos in _group_by_mo(unassigned_wos).items():
mo = Production.browse(mo_id)
unassigned_cards.append(_mo_card(mo, wos))
unassigned_cards = [_job_card(j) for j in unassigned_jobs]
active_cards = [_job_card(j) for j in active_jobs]
# ---- Column 2: In Progress -------------------------------------
# Release-ready WOs (everything the manager needed to set is
# filled in) — operator can tap Start on the iPad.
if 'x_fc_is_release_ready' in MrpWO._fields:
active_wos = all_active_wos.filtered(lambda w: w.x_fc_is_release_ready)
elif has_assign:
active_wos = all_active_wos.filtered(lambda w: w.x_fc_assigned_user_id)
else:
active_wos = MrpWO # empty
active_cards = []
for mo_id, wos in _group_by_mo(active_wos).items():
mo = Production.browse(mo_id)
active_cards.append(_mo_card(mo, wos))
# ---- Column 3: Team (operators + their current load) -----------
# ---- Column 3: Team --------------------------------------------
operator_group = env.ref(
'fusion_plating.group_fusion_plating_operator', raise_if_not_found=False,
)
team = []
if operator_group and has_assign:
if operator_group:
for user in operator_group.user_ids.sorted('name'):
open_wos = MrpWO.search([
('x_fc_assigned_user_id', '=', user.id),
('state', 'not in', ACTIVE_NEG_STATES),
open_steps = Step.search([
('assigned_user_id', '=', user.id),
('state', 'in', ('ready', 'in_progress', 'paused')),
])
team.append({
'user_id': user.id,
'name': user.name,
'open_count': len(open_wos),
'open_count': len(open_steps),
'in_progress_count': len(
open_wos.filtered(lambda w: w.state == 'progress')
open_steps.filtered(lambda s: s.state == 'in_progress'),
),
'avatar_url': f'/web/image/res.users/{user.id}/avatar_128',
})
# ---- Pickers: operators (with presence + role data) -----------
# We send richer operator records so the Manager Desk dropdown can
# group qualified-and-present at the top, then lead hands, then
# off-shift workers (greyed). Without this the manager has to
# remember who's clocked in and who can do what.
# ---- Operators picker (with presence + role data) --------------
clocked_in_user_ids = (
env['hr.employee']._fp_clocked_in_user_ids()
if 'hr.employee' in env and hasattr(
@@ -237,7 +203,11 @@ class FpManagerDashboardController(http.Controller):
operators = []
for u in operator_users:
emp = u.employee_id
role_ids = emp.x_fc_work_role_ids.ids if emp else []
role_ids = (
emp.x_fc_work_role_ids.ids
if emp and 'x_fc_work_role_ids' in emp._fields
else []
)
lead_role_ids = (
emp.x_fc_lead_hand_role_ids.ids
if emp and 'x_fc_lead_hand_role_ids' in emp._fields
@@ -250,12 +220,12 @@ class FpManagerDashboardController(http.Controller):
'role_ids': role_ids,
'lead_hand_role_ids': lead_role_ids,
})
# Headline counts so the manager sees at-a-glance who's on shift.
present_count = sum(1 for o in operators if o['is_clocked_in'])
presence = {
'clocked_in': present_count,
'total': len(operators),
}
Tank = env.get('fusion.plating.tank')
tanks = [
{
@@ -267,36 +237,32 @@ class FpManagerDashboardController(http.Controller):
for t in (Tank.search([]) if Tank is not None else [])
]
# KPI summary — every query must use STORED fields only, otherwise
# Odoo raises "Cannot convert … to SQL because it is not stored".
# x_fc_workflow_stage is computed (non-stored); replicate the
# "awaiting assignment" stage directly via its stored antecedents.
# ---- KPI summary ----------------------------------------------
SO = env['sale.order']
so_fields = SO._fields
if ('x_fc_receiving_status' in so_fields
and 'x_fc_assigned_manager_id' in so_fields):
pending_accept_domain = [
pending_accept_sos = SO.search_count([
('state', '=', 'sale'),
('x_fc_receiving_status', '=', 'inspected'),
('x_fc_assigned_manager_id', '=', False),
]
pending_accept_sos = SO.search_count(pending_accept_domain)
])
else:
pending_accept_sos = 0
# KPI counts derived from the in-memory split we already have —
# don't re-query (the release-ready filter is a Python compute,
# not a stored column, so SQL search_count can't see it).
# Ready-to-ship: jobs that are done but the portal job hasn't
# been marked ready_to_ship yet (or no portal mirror at all).
ready_to_ship_jobs = Job.search_count([('state', '=', 'done')])
kpis = {
'unassigned_wos': len(unassigned_wos),
'active_wos': len(active_wos),
'ready_to_ship_mos': Production.search_count([
('state', '=', 'done'),
]) if 'x_fc_portal_job_id' not in Production._fields
else Production.search_count([
('state', '=', 'done'),
('x_fc_portal_job_id.state', '=', 'ready_to_ship'),
]),
'unassigned_steps': len(all_steps.filtered(
lambda s: not readiness_by_step.get(s.id, (False, ''))[0],
)),
'active_steps': len(all_steps.filtered(
lambda s: readiness_by_step.get(s.id, (False, ''))[0]
and s.state in ('ready', 'in_progress'),
)),
'ready_to_ship_jobs': ready_to_ship_jobs,
'pending_accept_sos': pending_accept_sos,
}
@@ -325,45 +291,85 @@ class FpManagerDashboardController(http.Controller):
return payload
# ------------------------------------------------------------------
# Assign a worker to a WO
# Assign a worker to a step
# ------------------------------------------------------------------
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
def assign_worker(self, workorder_id, user_id):
wo = request.env['mrp.workorder'].browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': 'Work order not found.'}
wo.x_fc_assigned_user_id = int(user_id) if user_id else False
wo.message_post(
body=Markup('Worker assigned: <b>%s</b>') % (wo.x_fc_assigned_user_id.name or 'Unassigned'),
def assign_worker(self, step_id=None, user_id=None, workorder_id=None, **kwargs):
"""Assign an operator to a step. ``step_id`` is the canonical
kwarg; ``workorder_id`` is accepted as a deprecated alias for
one release so any caller we missed doesn't break.
"""
if step_id is None and workorder_id is not None:
_logger.warning(
"workorder_id kwarg is deprecated; use step_id "
"(/fp/manager/assign_worker)",
)
return {'ok': True, 'user_name': wo.x_fc_assigned_user_id.name or ''}
step_id = workorder_id
if not step_id:
return {'ok': False, 'error': 'step_id required'}
step = request.env['fp.job.step'].browse(int(step_id))
if not step.exists():
return {'ok': False, 'error': 'Step not found.'}
step.assigned_user_id = int(user_id) if user_id else False
step.message_post(
body=Markup('Worker assigned: <b>%s</b>') % (
step.assigned_user_id.name or 'Unassigned'
),
)
return {'ok': True, 'user_name': step.assigned_user_id.name or ''}
# ------------------------------------------------------------------
# Reassign or swap tank on a WO
# Reassign or swap tank on a step
# ------------------------------------------------------------------
@http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user')
def assign_tank(self, workorder_id, tank_id):
wo = request.env['mrp.workorder'].browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': 'Work order not found.'}
wo.x_fc_tank_id = int(tank_id) if tank_id else False
wo.message_post(
body=Markup('Tank assigned: <b>%s</b>') % (wo.x_fc_tank_id.name or 'Unassigned'),
def assign_tank(self, step_id=None, tank_id=None, workorder_id=None, **kwargs):
"""Swap the tank on a step. ``step_id`` is the canonical kwarg;
``workorder_id`` is accepted as a deprecated alias.
"""
if step_id is None and workorder_id is not None:
_logger.warning(
"workorder_id kwarg is deprecated; use step_id "
"(/fp/manager/assign_tank)",
)
return {'ok': True, 'tank_name': wo.x_fc_tank_id.name or ''}
step_id = workorder_id
if not step_id:
return {'ok': False, 'error': 'step_id required'}
step = request.env['fp.job.step'].browse(int(step_id))
if not step.exists():
return {'ok': False, 'error': 'Step not found.'}
step.tank_id = int(tank_id) if tank_id else False
step.message_post(
body=Markup('Tank assigned: <b>%s</b>') % (
step.tank_id.name or 'Unassigned'
),
)
return {'ok': True, 'tank_name': step.tank_id.name or ''}
# ------------------------------------------------------------------
# Manager takes over a WO (no-show coverage)
# Manager takes over a step (no-show coverage)
# ------------------------------------------------------------------
@http.route('/fp/manager/take_over', type='jsonrpc', auth='user')
def take_over(self, workorder_id):
wo = request.env['mrp.workorder'].browse(int(workorder_id))
if not wo.exists():
return {'ok': False, 'error': 'Work order not found.'}
def take_over(self, step_id=None, workorder_id=None, **kwargs):
"""Manager takes over a step. ``step_id`` is the canonical kwarg;
``workorder_id`` is accepted as a deprecated alias.
"""
if step_id is None and workorder_id is not None:
_logger.warning(
"workorder_id kwarg is deprecated; use step_id "
"(/fp/manager/take_over)",
)
step_id = workorder_id
if not step_id:
return {'ok': False, 'error': 'step_id required'}
step = request.env['fp.job.step'].browse(int(step_id))
if not step.exists():
return {'ok': False, 'error': 'Step not found.'}
user = request.env.user
previous = wo.x_fc_assigned_user_id.name or ''
wo.x_fc_assigned_user_id = user.id
wo.message_post(
body=Markup('Manager takeover: <b>%s</b> replaces %s.') % (user.name, previous),
previous = step.assigned_user_id.name or ''
step.assigned_user_id = user.id
step.message_post(
body=Markup('Manager takeover: <b>%s</b> replaces %s.') % (
user.name, previous,
),
)
return {'ok': True, 'user_name': user.name}

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# /fp/tank/<id> — mobile-friendly tank status page. Linked from NFC
# tags on the physical tank. The operator taps the tag with a phone,
# the tag's URL opens this page in their default browser.
#
# Auth is `user` so an operator must be logged in (no public exposure
# of bath chemistry / job-customer data). Operators stay logged in on
# the shopfloor tablet, so this is friction-free in practice.
#
# Why URL-based and not Web NFC API: Web NFC is Chrome-Android only;
# iOS Safari does not expose any NFC API. iOS instead reads the URL
# off the tag's NDEF record and opens it in the default browser. As
# long as the tag stores the URL, both platforms Just Work.
from odoo import http
from odoo.http import request
class FpTankStatusController(http.Controller):
@http.route(
'/fp/tank/<int:tank_id>',
type='http',
auth='user',
website=False,
)
def fp_tank_status(self, tank_id, **kwargs):
Tank = request.env['fusion.plating.tank'].sudo()
tank = Tank.browse(tank_id).exists()
if not tank:
return request.render(
'fusion_plating_shopfloor.tank_status_not_found',
{'tank_id': tank_id},
)
# Find the active step on this tank (in progress or paused).
# fp.job.step.tank_id was added in fusion_plating core.
Step = request.env['fp.job.step'].sudo()
active_step = Step.search([
('tank_id', '=', tank.id),
('state', 'in', ('in_progress', 'paused')),
], order='date_started desc', limit=1)
# Up to 5 ready steps for this tank — the operator's "what's
# coming next" signal.
ready_steps = Step.search([
('tank_id', '=', tank.id),
('state', '=', 'ready'),
], order='sequence asc', limit=5)
# Most recent bath log. Readings are line-level
# (fusion.plating.bath.log.line), keyed by parameter_code (pH,
# temperature, nickel, etc.). The template iterates the lines.
bath_log = request.env['fusion.plating.bath.log'].sudo().search(
[('tank_id', '=', tank.id)],
order='log_date desc, create_date desc',
limit=1,
)
return request.render(
'fusion_plating_shopfloor.tank_status_page',
{
'tank': tank,
'active_step': active_step,
'ready_steps': ready_steps,
'bath_log': bath_log,
},
)

View File

@@ -0,0 +1,9 @@
jsQR is released under the Apache License, Version 2.0.
Copyright (c) 2017 Cosmo Wolfe (https://github.com/cozmo/jsQR)
Vendored into Fusion Plating to provide QR decoding on browsers that
lack the native BarcodeDetector API (notably iOS Safari < 17 and the
in-app browsers in Messages / WhatsApp / etc).
Upstream: https://github.com/cozmo/jsQR
File: dist/jsQR.js (UMD bundle, exposes global `jsQR`)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,12 @@
ZXing-js (@zxing/library) is released under the Apache License, Version 2.0.
Copyright (c) ZXing Authors and Adrian Toșcă (https://github.com/zxing-js/library)
Vendored into Fusion Plating because jsQR — while faster — fails on
phone-camera frames with mild perspective skew, motion blur, or glare.
ZXing's HybridBinarizer + perspective transform consistently decode
the same frames jsQR rejects, matching what the iOS Camera app does
under the hood.
Upstream: https://github.com/zxing-js/library
File: umd/index.min.js (UMD bundle, exposes global `ZXing`)
Version: 0.21.3

File diff suppressed because one or more lines are too long

View File

@@ -1,21 +1,27 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Manager Dashboard (OWL client action)
// Fusion Plating — Manager Desk (OWL client action)
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Manager-level view: assign workers, swap tanks, cover no-shows, drill
// into detail when needed. Three columns: Unassigned / In Progress / Team.
// into detail when needed. Three columns: Needs a Worker / In Progress / Team.
//
// Native fp.job / fp.job.step edition. Speaks job/step end-to-end —
// payload keys, variables, and RPC kwargs all use the job/step
// vocabulary.
// =============================================================================
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner";
export class ManagerDashboard extends Component {
static template = "fusion_plating_shopfloor.ManagerDashboard";
static props = ["*"];
static components = { QrScanner };
setup() {
this.notification = useService("notification");
@@ -25,7 +31,7 @@ export class ManagerDashboard extends Component {
overview: null,
loadError: "", // visible error instead of stuck spinner
mode: "quick", // quick | detailed
expandedMoId: null,
expandedJobId: null,
message: "",
messageType: "info",
isFetching: false, // pulses the "updating" dot in the header
@@ -130,8 +136,8 @@ export class ManagerDashboard extends Component {
this.state.mode = this.state.mode === "quick" ? "detailed" : "quick";
}
toggleCard(moId) {
this.state.expandedMoId = this.state.expandedMoId === moId ? null : moId;
toggleCard(jobId) {
this.state.expandedJobId = this.state.expandedJobId === jobId ? null : jobId;
}
toggleOffShift() {
@@ -139,7 +145,7 @@ export class ManagerDashboard extends Component {
}
/**
* Sort + filter the operator list for a specific WO's dropdown.
* Sort + filter the operator list for a specific step's dropdown.
*
* Buckets, top-down, each kept in original (alphabetical) order:
* 1. Qualified for this role AND clocked in — primary picks
@@ -151,9 +157,9 @@ export class ManagerDashboard extends Component {
* Each option carries a `bucket` so the template can render a tiny
* green/grey dot and (for buckets 3-4) a soft helper label.
*/
operatorsForWO(wo) {
operatorsForStep(step) {
const all = (this.state.overview && this.state.overview.operators) || [];
const roleId = wo && wo.role_id;
const roleId = step && step.role_id;
const out = [];
for (const op of all) {
const qualified = roleId && op.role_ids && op.role_ids.includes(roleId);
@@ -180,15 +186,15 @@ export class ManagerDashboard extends Component {
}
// ---------------------------------------------------------- Actions
async onAssignWorker(wo, userIdRaw) {
async onAssignWorker(step, userIdRaw) {
const userId = parseInt(userIdRaw) || null;
try {
const res = await rpc("/fp/manager/assign_worker", {
workorder_id: wo.id, user_id: userId,
step_id: step.id, user_id: userId,
});
if (res && res.ok) {
this.setMessage(
`Assigned ${res.user_name || 'unassigned'} to ${wo.name}`,
`Assigned ${res.user_name || 'unassigned'} to ${step.name}`,
"success",
);
}
@@ -198,15 +204,15 @@ export class ManagerDashboard extends Component {
await this.refresh();
}
async onAssignTank(wo, tankIdRaw) {
async onAssignTank(step, tankIdRaw) {
const tankId = parseInt(tankIdRaw) || null;
try {
const res = await rpc("/fp/manager/assign_tank", {
workorder_id: wo.id, tank_id: tankId,
step_id: step.id, tank_id: tankId,
});
if (res && res.ok) {
this.setMessage(
`Tank ${res.tank_name || 'cleared'} for ${wo.name}`,
`Tank ${res.tank_name || 'cleared'} for ${step.name}`,
"success",
);
}
@@ -216,13 +222,13 @@ export class ManagerDashboard extends Component {
await this.refresh();
}
async onTakeOver(wo) {
async onTakeOver(step) {
try {
const res = await rpc("/fp/manager/take_over", {
workorder_id: wo.id,
step_id: step.id,
});
if (res && res.ok) {
this.setMessage(`You now own ${wo.name}.`, "success");
this.setMessage(`You now own ${step.name}.`, "success");
}
} catch (err) {
this.setMessage(`Takeover failed: ${err.message || err}`, "danger");
@@ -244,11 +250,11 @@ export class ManagerDashboard extends Component {
this.action.doAction({
type: "ir.actions.act_window",
name: "Operator Queue",
res_model: "mrp.workorder",
res_model: "fp.job.step",
views: [[false, "list"], [false, "form"]],
domain: [
["x_fc_assigned_user_id", "=", userId],
["state", "in", ["ready", "progress", "waiting"]],
["assigned_user_id", "=", userId],
["state", "in", ["ready", "in_progress", "paused"]],
],
target: "current",
});

View File

@@ -4,8 +4,13 @@
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Steelhead-style multi-column kanban showing all active work orders grouped
// by work centre / station. Auto-refreshes every 30 s.
// Multi-column kanban showing all active fp.job.step rows grouped by
// fp.work.centre. Auto-refreshes every 30 s. Drag-drop between columns
// reassigns step.work_centre_id.
//
// Native fp.job / fp.job.step edition (consolidated 2026-04-24). The
// data layer underneath now points at fp.job.step (cards) / fp.work.centre
// (columns); the visual design and RPC URL paths are unchanged.
//
// Odoo 19 conventions:
// * Backend OWL component: `static template` + `static props = ["*"]`
@@ -17,10 +22,12 @@ import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner";
export class PlantOverview extends Component {
static template = "fusion_plating_shopfloor.PlantOverview";
static props = ["*"];
static components = { QrScanner };
setup() {
this.notification = useService("notification");
@@ -133,7 +140,7 @@ export class PlantOverview extends Component {
onCardDragStart(card, col, ev) {
this._draggedCard = {
id: card.id,
source_model: card.source_model || "mrp.workorder",
source_model: card.source_model || "fp.job.step",
source_wc_id: col.work_center_id,
el: ev.target,
};
@@ -251,9 +258,10 @@ export class PlantOverview extends Component {
if (!card.id) {
return;
}
// Try opening the work order form if MRP is available, otherwise
// fall back to bake window or first-piece gate
const model = card.source_model || "mrp.workorder";
// Cards are fp.job.step rows. The model is overridable per-card
// so we keep working if a future card type joins the kanban
// (e.g. a quality hold drop-zone column).
const model = card.source_model || "fp.job.step";
this.action.doAction({
type: "ir.actions.act_window",
res_model: model,
@@ -281,14 +289,21 @@ export class PlantOverview extends Component {
getStateClass(state) {
switch (state) {
case "progress":
// Native fp.job.step states
case "in_progress":
return "o_fp_card_progress";
case "ready":
return "o_fp_card_ready";
case "paused":
return "o_fp_card_pending";
case "done":
return "o_fp_card_done";
case "pending":
return "o_fp_card_pending";
// Legacy MRP states still recognised so a server still
// serving the old payload renders cleanly.
case "progress":
return "o_fp_card_progress";
default:
return "";
}

View File

@@ -3,25 +3,33 @@
// Fusion Plating — Process Tree (horizontal hierarchical view)
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Renders the MO's recipe (recipe → sub_process → operation → state) as a
// Renders an fp.job's recipe (recipe → sub_process → operation → step) as a
// horizontal bracket tree. Cards render dark, identical card style across
// all depths; connector lines are drawn from CSS so the layout stays in
// pure flexbox.
//
// Native fp.job / fp.job.step edition (consolidated 2026-04-24). The data
// layer underneath now points at fp.job + fp.job.step, but the visual
// design is unchanged.
//
// Action context:
// production_id — required; the MO whose recipe to render
// back_workorder_idoptional; if set, the back button returns to
// that WO instead of Plant Overview
// job_id — required; the fp.job whose recipe to render
// production_id legacy alias for job_id (still accepted)
// back_step_id — optional; if set, the back button returns to
// that step's form instead of Plant Overview
// back_workorder_id — legacy alias for back_step_id
// =============================================================================
import { Component, useState, onMounted } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner";
export class ProcessTree extends Component {
static template = "fusion_plating_shopfloor.ProcessTree";
static props = ["*"];
static components = { QrScanner };
setup() {
this.notification = useService("notification");
@@ -50,19 +58,25 @@ export class ProcessTree extends Component {
const a = this.props.action || {};
return { ...(a.context || {}), ...(a.params || {}) };
}
get productionId() { return this._ctx.production_id || null; }
get backWorkorderId() { return this._ctx.back_workorder_id || null; }
get jobId() {
// job_id is the canonical key; production_id is kept as an alias
// for legacy callers that still encode that name in their URLs.
return this._ctx.job_id || this._ctx.production_id || null;
}
get backStepId() {
return this._ctx.back_step_id || this._ctx.back_workorder_id || null;
}
get backLabel() {
return this.backWorkorderId ? "Back to Work Order" : "Plant Overview";
return this.backStepId ? "Back to Step" : "Plant Overview";
}
// ---- Data ---------------------------------------------------------------
async loadTree() {
const prodId = this.productionId;
if (!prodId) {
const jobId = this.jobId;
if (!jobId) {
this.notification.add(
"No manufacturing order specified for the process tree.",
"No job specified for the process tree.",
{ type: "warning" },
);
return;
@@ -70,7 +84,7 @@ export class ProcessTree extends Component {
this.state.loading = true;
try {
const r = await rpc("/fp/shopfloor/process_tree", {
production_id: prodId,
job_id: jobId,
});
if (r) {
this.state.productionName = r.production_name || "";
@@ -95,25 +109,29 @@ export class ProcessTree extends Component {
// ---- Navigation ---------------------------------------------------------
onNodeClick(node) {
if (!node || !node.workorder_id) {
// Operation cards with a matching fp.job.step are clickable —
// they open the underlying step form. node.workorder_id is the
// legacy template key that now carries the step id.
const stepId = node && (node.step_id || node.workorder_id);
if (!stepId) {
return;
}
this.action.doAction({
type: "ir.actions.act_window",
res_model: "mrp.workorder",
res_id: node.workorder_id,
res_model: "fp.job.step",
res_id: stepId,
views: [[false, "form"]],
target: "current",
});
}
onBack() {
const woId = this.backWorkorderId;
if (woId) {
const stepId = this.backStepId;
if (stepId) {
this.action.doAction({
type: "ir.actions.act_window",
res_model: "mrp.workorder",
res_id: parseInt(woId, 10),
res_model: "fp.job.step",
res_id: parseInt(stepId, 10),
views: [[false, "form"]],
target: "current",
});
@@ -131,7 +149,9 @@ export class ProcessTree extends Component {
if (node.state) {
parts.push(`o_fp_pt_state_${node.state}`);
}
if (node.workorder_id) {
// step_id is the canonical clickable hint; workorder_id is the
// legacy alias. Either one means we have a real step to open.
if (node.step_id || node.workorder_id) {
parts.push("o_fp_pt_clickable");
}
if (this.isHighlight(node)) {
@@ -140,9 +160,13 @@ export class ProcessTree extends Component {
return parts.join(" ");
}
/** A node should pulse-highlight if it is the live position of the MO. */
/** Live-position highlight: ready / in_progress / paused. */
isHighlight(node) {
return node.state === "ready"
|| node.state === "in_progress"
|| node.state === "paused"
// Tolerate the legacy MRP states a node might still
// briefly carry on first render (progress/waiting).
|| node.state === "progress"
|| node.state === "waiting";
}

View File

@@ -0,0 +1,595 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Reusable QR Scanner OWL Component
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Decoder selection — strongest available wins:
// 1. Native BarcodeDetector API (Android Chrome, iOS Safari 17+, desktop
// Chrome / Edge — fastest, hardware
// accelerated, no JS in the hot path)
// 2. Vendored jsQR fallback (every other browser including iOS
// Safari < 17 and the in-app webviews
// in Messages / WhatsApp / LinkedIn,
// which is what we hit in practice on
// phones today)
// 3. Manual paste (last resort: HTTP origin or no camera
// permission — typing the URL still
// works)
//
// The component renders a single button. On click, opens a modal that
// streams the rear camera into a <video> element, draws each frame into
// an offscreen <canvas>, and feeds the ImageData to whichever decoder
// is available. Detected URLs matching /fp/job/<id> (or /fp/wo/<id> as
// a legacy alias from older mrp.workorder stickers) open the matching
// fp.job form via the action service.
//
// Used by Manager Desk, Tablet Station, Plant Overview, and Process Tree
// headers — see each component's `static components = { QrScanner }`.
// =============================================================================
import { Component, useState, useRef, onWillUnmount } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
// Hint type values from zxing-js (DecodeHintType enum):
// 2 = POSSIBLE_FORMATS, 3 = TRY_HARDER
const ZXING_HINT_POSSIBLE_FORMATS = 2;
const ZXING_HINT_TRY_HARDER = 3;
export class QrScanner extends Component {
static template = "fusion_plating_shopfloor.QrScanner";
static props = {
label: { type: String, optional: true },
cssClass: { type: String, optional: true },
};
static defaultProps = {
label: "Scan",
cssClass: "btn btn-secondary",
};
setup() {
this.notification = useService("notification");
this.videoRef = useRef("video");
this.state = useState({
open: false,
error: null,
manualUrl: "",
detected: "", // last decoded value (for user feedback)
// canScan / decoder are recomputed in open() — don't trust
// setup-time values because vendored libs may attach to
// window asynchronously after the bundle finishes parsing.
canScan: false,
decoder: "none",
statusLine: "", // visible diagnostic shown in modal
});
this.stream = null;
this.decodeLoopActive = false;
// Reusable offscreen canvas for the jsQR path. Allocated lazily
// on first frame so we don't pay the cost when the modal never
// opens or when the native decoder is in use.
this._canvas = null;
this._ctx = null;
onWillUnmount(() => this._stopCamera());
}
/**
* Check what decoder is available right now and update state. Run
* at every open() — not just setup() — because a stale bundle in
* the browser cache can flip results between page loads.
*
* Preference order:
* 1. ZXing-js (window.ZXing.BrowserMultiFormatReader) — the most
* tolerant; handles perspective skew, motion blur, and glare
* that defeat jsQR on phone cameras. This is the default.
* 2. Native BarcodeDetector — fast, hardware-backed, but only
* available on Android Chrome and iOS Safari 17+. Skipped
* now that ZXing is the primary path; left as a code branch
* in case ZXing fails to load.
* 3. jsQR — kept as a last-resort JS fallback.
*/
_detectCapabilities() {
const hasZXing = typeof window !== "undefined"
&& window.ZXing
&& typeof window.ZXing.BrowserMultiFormatReader === "function";
const hasJsQR = typeof window !== "undefined"
&& typeof window.jsQR === "function";
const hasNative = typeof BarcodeDetector !== "undefined";
this.state.canScan = hasZXing || hasJsQR || hasNative;
this.state.decoder = hasZXing ? "zxing"
: hasJsQR ? "jsqr"
: hasNative ? "native"
: "none";
// Build a one-line status the user can read in the modal so
// it's obvious whether the decoder loaded. Helps diagnose
// "nothing happens" reports without round-tripping through
// Safari Web Inspector.
this.state.statusLine = (
"Decoder: " + this.state.decoder +
(hasNative ? " (native)" : "") +
(!hasNative && hasJsQR ? " (jsQR)" : "") +
(!this.state.canScan ? " — paste URL below" : "")
);
}
async open() {
this._detectCapabilities();
this.state.open = true;
this.state.error = null;
this.state.detected = "";
await this._startCamera();
}
close() {
this.state.open = false;
this._stopCamera();
}
async _startCamera() {
if (!this.state.canScan) {
// No decoder at all — paste UI is the only path.
return;
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
this.state.error = "Camera access not available on this browser. Use the URL input below.";
return;
}
try {
// Request a 1280x720 rear-camera stream when possible. The
// browser will downgrade if the device can't deliver it.
// Higher resolution gives jsQR more pixels per QR module
// and dramatically improves decode rate on phones.
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: "environment" },
width: { ideal: 1280 },
height: { ideal: 720 },
},
audio: false,
});
// Wait one paint tick so the t-ref resolves to the <video>
await new Promise((r) => requestAnimationFrame(r));
const v = this.videoRef.el;
if (!v) {
this.state.error = "Video element not mounted";
return;
}
// iOS Safari requires playsinline + muted on the element
// BEFORE it has a source, otherwise the stream stays black
// and play() rejects with "user interaction required".
v.setAttribute("playsinline", "true");
v.setAttribute("muted", "true");
v.muted = true;
v.srcObject = this.stream;
if (this.state.decoder === "zxing") {
// CRITICAL: do NOT call v.play() here. ZXing's
// decodeFromVideoElementContinuously registers a
// "playing" event listener and then calls play()
// itself; if play() has already happened, the
// "playing" event fired before the listener attached
// and ZXing waits forever. Leaving the video paused
// here lets ZXing drive the play -> playing -> decode
// sequence cleanly.
this._zxingDecodeLoop();
} else {
// Native BarcodeDetector / jsQR loops both poll the
// video themselves, so they need it actively playing.
await v.play();
if (this.state.decoder === "native") {
this._nativeDecodeLoop();
} else if (this.state.decoder === "jsqr") {
this._jsQRDecodeLoop();
}
}
} catch (e) {
this.state.error = "Couldn't access camera: " + (e.message || e);
}
}
_stopCamera() {
this.decodeLoopActive = false;
// Stop ZXing's internal decode loop if it's running. reset()
// is the documented teardown for BrowserMultiFormatReader.
if (this._zxingReader) {
try {
this._zxingReader.reset();
} catch (e) {
// Some versions throw on double-reset; safe to ignore.
}
this._zxingReader = null;
}
if (this.stream) {
this.stream.getTracks().forEach((t) => t.stop());
this.stream = null;
}
}
/**
* Decode loop using the browser's BarcodeDetector. Cheapest path —
* the browser does the work off the JS thread. Only runs on
* Android Chrome, iOS Safari 17+, and desktop Chrome / Edge.
*/
async _nativeDecodeLoop() {
const detector = new BarcodeDetector({ formats: ["qr_code"] });
this.decodeLoopActive = true;
const v = this.videoRef.el;
if (!v) return;
const tick = async () => {
if (!this.decodeLoopActive || !this.state.open) return;
try {
if (v.readyState >= 2) {
const codes = await detector.detect(v);
if (codes.length > 0) {
this._handleCode(codes[0].rawValue);
return;
}
}
} catch (e) {
// Decode errors are noisy and recoverable — try the
// next frame. Real failures (camera revoked, etc.)
// surface via _startCamera's catch.
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
/**
* Continuous QR decode using ZXing-js. We feed our existing
* <video> element (already wired to the getUserMedia stream)
* straight into ZXing's continuous reader, which manages its
* own per-frame timing and decode pipeline (HybridBinarizer +
* perspective transform) — the same algorithm family the iOS
* Camera app uses internally.
*
* The vendored bundle exposes these instance methods on
* ZXing.BrowserMultiFormatReader:
* - decodeFromVideoElement(el) -> one-shot
* - decodeFromVideoElementContinuously(el, cb) -> loop
* The continuous form callbacks `(result, err)` per frame:
* `result` truthy on hit, `err` is usually a NotFoundException
* (no code in this frame) which we ignore.
*
* Cleanup: `reader.reset()` stops the loop and releases internal
* state. We call it from _stopCamera() so closing the modal is
* clean.
*/
_zxingDecodeLoop() {
this.decodeLoopActive = true;
const v = this.videoRef.el;
if (!v) {
this.state.statusLine = "Decoder: zxing — video element missing";
return;
}
const Z = window.ZXing;
// Pass hints via the constructor — assignment to .hints
// afterward doesn't work because decodeBitmap reads from
// this._hints (set by MultiFormatReader.setHints during
// construction). TRY_HARDER makes the QR finder more
// aggressive about perspective and contrast.
const hints = new Map();
if (Z.BarcodeFormat && Z.BarcodeFormat.QR_CODE !== undefined) {
hints.set(ZXING_HINT_POSSIBLE_FORMATS, [Z.BarcodeFormat.QR_CODE]);
}
hints.set(ZXING_HINT_TRY_HARDER, true);
// Second arg is timeBetweenScansMillis — drop from 500 default
// to 100 so we attempt ~10 decodes/sec instead of ~2.
const reader = new Z.BrowserMultiFormatReader(hints, 100);
this._zxingReader = reader;
// Live status — ZXing manages its own timing internally so we
// count callbacks instead of rAF ticks. Hits is what matters.
let callbacks = 0;
let lastStatus = 0;
let lastResult = "—";
const refreshStatus = () => {
const now = performance.now();
if (now - lastStatus > 400) {
lastStatus = now;
this.state.statusLine =
"zxing · cb" + callbacks +
" " + (v.videoWidth || 0) + "x" + (v.videoHeight || 0) +
" rs" + v.readyState +
" r:" + lastResult;
}
};
try {
reader.decodeFromVideoElementContinuously(v, (result, err) => {
callbacks++;
if (result) {
lastResult = "found";
refreshStatus();
const text = result.getText ? result.getText() : result.text;
this._handleCode(text);
return;
}
if (err) {
const name = err.name || (err.constructor && err.constructor.name) || "";
if (name.indexOf("NotFound") >= 0) {
lastResult = "no_code";
} else if (name.indexOf("Checksum") >= 0 || name.indexOf("Format") >= 0) {
// Found something QR-shaped but couldn't read it
// (blurry / damaged) — keep trying next frame.
lastResult = "partial";
} else {
lastResult = "err:" + (err.message || name).slice(0, 40);
}
}
refreshStatus();
});
} catch (e) {
this.state.statusLine = "zxing init failed: " +
((e && e.message) || String(e)).slice(0, 80);
}
}
/**
* Decode loop using the vendored jsQR library. Draws each video
* frame into an offscreen canvas, pulls ImageData, and runs jsQR
* synchronously. jsQR is ~250KB but pure JS, so it works on every
* browser that gives us getUserMedia.
*
* Throttled to one decode per ~100ms to stay responsive without
* pegging mid-range phones. Updates a live status line so the
* operator can see exactly what the loop is doing — frames seen,
* decode attempts, video resolution. Critical for diagnosing
* "scan does nothing" reports without round-tripping through
* Safari Web Inspector.
*/
_jsQRDecodeLoop() {
this.decodeLoopActive = true;
const v = this.videoRef.el;
if (!v) {
this.state.statusLine = "Decoder: jsqr — video element missing";
return;
}
if (!this._canvas) {
this._canvas = document.createElement("canvas");
this._ctx = this._canvas.getContext("2d", { willReadFrequently: true });
}
let frames = 0;
let attempts = 0;
let lastDecode = 0;
let lastStatus = 0;
let lastResult = "—"; // "found" | "no_code" | "empty" | error msg
let firstNonZeroPixel = -1; // sanity check that drawImage works
const MIN_INTERVAL_MS = 100;
const STATUS_INTERVAL_MS = 500;
const tick = (now) => {
if (!this.decodeLoopActive || !this.state.open) return;
frames++;
if (
v.readyState >= 2 &&
v.videoWidth && v.videoHeight &&
(now - lastDecode) >= MIN_INTERVAL_MS
) {
lastDecode = now;
attempts++;
try {
const w = v.videoWidth;
const h = v.videoHeight;
// Use the native video resolution directly — no
// downscaling. jsQR's runtime cost is acceptable
// even at 1080p, and downsampling can blur the
// finder patterns just enough to defeat detection
// when the QR is small in the frame.
if (this._canvas.width !== w) this._canvas.width = w;
if (this._canvas.height !== h) this._canvas.height = h;
this._ctx.drawImage(v, 0, 0, w, h);
const imageData = this._ctx.getImageData(0, 0, w, h);
if (firstNonZeroPixel < 0) {
// One-time sanity check: confirm drawImage is
// actually painting the video onto the canvas.
// If every pixel is 0,0,0 we'd never decode
// anything regardless of jsQR settings (this
// can happen with tainted canvases on some
// older WebKit builds).
for (let i = 0; i < imageData.data.length; i += 4) {
if (imageData.data[i] | imageData.data[i + 1] | imageData.data[i + 2]) {
firstNonZeroPixel = i;
break;
}
}
if (firstNonZeroPixel < 0) firstNonZeroPixel = -2;
}
const code = window.jsQR(imageData.data, w, h, {
inversionAttempts: "attemptBoth",
});
if (code && code.data) {
lastResult = "found";
this._handleCode(code.data);
return;
}
lastResult = code ? "empty" : "no_code";
} catch (e) {
lastResult = "error: " +
(e.message || String(e)).slice(0, 60);
}
}
if (now - lastStatus > STATUS_INTERVAL_MS) {
lastStatus = now;
this.state.statusLine =
"jsqr · f" + frames +
" a" + attempts +
" " + (v.videoWidth || 0) + "x" + (v.videoHeight || 0) +
" rs" + v.readyState +
" px:" + (firstNonZeroPixel === -2
? "BLANK" : firstNonZeroPixel < 0 ? "?" : "ok") +
" r:" + lastResult;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
onManualSubmit() {
if (this.state.manualUrl) {
this._handleCode(this.state.manualUrl);
}
}
/**
* Decode a QR from a still photo taken via the iOS / Android
* native camera UI. Triggered when the user taps "Take photo"
* (the <input type=file capture=environment> in the template
* backs this).
*
* Works on every browser that supports file inputs — including
* iOS Chrome / Safari, where the live-video decode path in ZXing
* has been unreliable. iOS hands us a JPEG that's been autofocused
* and properly exposed; we just need to run ONE decode on it
* rather than a noisy decode loop.
*/
async onPhotoCapture(ev) {
const file = ev.target.files && ev.target.files[0];
// Reset so the same file can be picked twice in a row.
ev.target.value = "";
if (!file) return;
this.state.error = null;
this.state.statusLine = "Decoding photo…";
// Load the file into an <img> via Object URL.
const url = URL.createObjectURL(file);
try {
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error("Failed to load photo"));
i.src = url;
});
// Draw onto a canvas at native resolution.
if (!this._canvas) {
this._canvas = document.createElement("canvas");
this._ctx = this._canvas.getContext("2d", { willReadFrequently: true });
}
const w = img.naturalWidth;
const h = img.naturalHeight;
this._canvas.width = w;
this._canvas.height = h;
this._ctx.drawImage(img, 0, 0, w, h);
// Try ZXing first (more tolerant), then jsQR as fallback.
const text = this._decodeStillFromCanvas(this._canvas);
if (text) {
this._handleCode(text);
return;
}
this.state.error = "Couldn't read a QR in that photo. " +
"Try moving closer or improving lighting.";
this.state.statusLine = "";
} catch (e) {
this.state.error = "Photo decode failed: " + (e.message || e);
} finally {
URL.revokeObjectURL(url);
}
}
/**
* Single-shot decode of a canvas. Uses whichever decoder is
* available, in order of robustness.
*/
_decodeStillFromCanvas(canvas) {
const Z = window.ZXing;
if (Z && typeof Z.BrowserMultiFormatReader === "function") {
try {
const hints = new Map();
if (Z.BarcodeFormat && Z.BarcodeFormat.QR_CODE !== undefined) {
hints.set(ZXING_HINT_POSSIBLE_FORMATS, [Z.BarcodeFormat.QR_CODE]);
}
hints.set(ZXING_HINT_TRY_HARDER, true);
const reader = new Z.BrowserMultiFormatReader(hints);
// decodeFromImageElement / decodeFromCanvas — try the
// canvas-friendly path: build a luminance source +
// binary bitmap manually and call MultiFormatReader.
const luminance = new Z.HTMLCanvasElementLuminanceSource(canvas);
const binarizer = new Z.HybridBinarizer(luminance);
const bitmap = new Z.BinaryBitmap(binarizer);
const result = new Z.MultiFormatReader();
result.setHints(hints);
const decoded = result.decode(bitmap);
const text = decoded && (decoded.getText ? decoded.getText() : decoded.text);
if (text) return text;
} catch (e) {
// ZXing miss — fall through to jsQR.
}
}
if (typeof window.jsQR === "function") {
try {
const ctx = canvas.getContext("2d");
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = window.jsQR(data.data, canvas.width, canvas.height, {
inversionAttempts: "attemptBoth",
});
if (code && code.data) return code.data;
} catch (e) {
// fall through
}
}
return null;
}
/**
* Route a decoded value to the right backend page.
*
* Stickers encode either /fp/job/<fp.job.id> (new) or
* /fp/wo/<mrp.production.id|mrp.workorder.id> (legacy — still on
* physical boxes from before the migration). Both URLs are
* handled by server-side controllers (job_scan.py / wo_scan.py)
* that resolve the correct record and redirect to its form.
*
* Rather than guessing ID spaces in the browser, we just navigate
* to the URL and let the controllers do the routing. This means:
* - new stickers (/fp/job/<id>) -> fp.job form
* - old stickers (/fp/wo/<id>) -> fp.job (via legacy_mrp_production_id)
* or mrp.production fallback
* - plain ids pasted manually -> assumed to be fp.job
* - anything else -> show the decoded text as an
* error so the operator knows
* decode worked but the value
* isn't a sticker URL.
*/
_handleCode(rawValue) {
const value = (rawValue || "").trim();
this.state.detected = value.slice(0, 120);
// Path or full URL containing /fp/job/<n> or /fp/wo/<n>.
const pathMatch = value.match(/\/fp\/(?:job|wo)\/\d+/);
let target = null;
if (pathMatch) {
// If the decoded value is a full URL, keep its origin so we
// don't break links that point at a different host.
// Otherwise navigate to the path on the current origin.
try {
const u = new URL(value);
target = u.origin + pathMatch[0];
} catch (e) {
target = pathMatch[0];
}
} else if (/^\d+$/.test(value)) {
// Bare numeric id pasted manually -> treat as fp.job id.
target = "/fp/job/" + value;
} else if (/^https?:\/\//i.test(value)) {
// Some other URL on (presumably) this Odoo. Let the user
// see what was decoded; don't blindly navigate to arbitrary
// off-host URLs.
this.state.error =
"Decoded URL doesn't look like a sticker: " + value.slice(0, 80);
return;
} else {
this.state.error =
"QR doesn't look like a job sticker. Got: " + value.slice(0, 80);
this.state.manualUrl = "";
return;
}
this._stopCamera();
this.state.open = false;
this.notification.add("Opening " + target, { type: "success" });
// Full navigation — the server-side controller resolves the id
// to the right record (works for both new fp.job stickers and
// legacy mrp.production / mrp.workorder stickers).
window.location.href = target;
}
}

View File

@@ -4,6 +4,11 @@
// Copyright 2026 Nexa Systems Inc.
// License OPL-1 (Odoo Proprietary License v1.0)
//
// Native fp.job / fp.job.step edition (consolidated 2026-04-24). Start /
// Finish buttons drive fp.job.step.button_start / button_finish through
// the existing /fp/shopfloor/start_wo / stop_wo URLs (now internally
// step-bound). The visual design is unchanged.
//
// Odoo 19 conventions:
// * Backend OWL component using `static template` + `static props = ["*"]`.
// * RPC via standalone `rpc()` from @web/core/network/rpc.
@@ -14,10 +19,12 @@ import { Component, useState, onMounted, onWillUnmount, useRef } from "@odoo/owl
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner";
export class ShopfloorTablet extends Component {
static template = "fusion_plating_shopfloor.ShopfloorTablet";
static props = ["*"];
static components = { QrScanner };
setup() {
this.notification = useService("notification");

View File

@@ -86,6 +86,37 @@ $fp-ink-faint : var(--fp-ink-faint, $_fp-ink-faint-hex);
// Action colour — Odoo's primary. Same in both bundles (brand purple).
$fp-accent : var(--o-action, #714B67);
// ---------- Kind chip colours (domain semantic) ------------------------------
// Used by Manager Desk + any place we surface WO kind (wet / bake / mask /
// rack / inspect / other). Light theme: solid hue text on translucent
// background of the same hue. Dark theme: lightened hue so the text stays
// legible against $fp-card / $fp-card-soft surfaces. Background translucency
// is generated at the call site via color-mix() so the hue stays linked.
$_fp-kind-wet-hex : #0d6efd; // blue
$_fp-kind-bake-hex : #dc3545; // red
$_fp-kind-mask-hex : #b18307; // amber (darker than warning yellow)
$_fp-kind-rack-hex : #495057; // grey
$_fp-kind-inspect-hex : #198754; // green
$_fp-kind-other-hex : #6c757d; // muted grey
@if $o-webclient-color-scheme == dark {
// Lighten chip text for legibility on dark backgrounds
$_fp-kind-wet-hex : #6ea8fe !global;
$_fp-kind-bake-hex : #ea868f !global;
$_fp-kind-mask-hex : #ffd866 !global;
$_fp-kind-rack-hex : #adb5bd !global;
$_fp-kind-inspect-hex : #75b798 !global;
$_fp-kind-other-hex : #adb5bd !global;
}
$fp-kind-wet : var(--fp-kind-wet, $_fp-kind-wet-hex);
$fp-kind-bake : var(--fp-kind-bake, $_fp-kind-bake-hex);
$fp-kind-mask : var(--fp-kind-mask, $_fp-kind-mask-hex);
$fp-kind-rack : var(--fp-kind-rack, $_fp-kind-rack-hex);
$fp-kind-inspect : var(--fp-kind-inspect, $_fp-kind-inspect-hex);
$fp-kind-other : var(--fp-kind-other, $_fp-kind-other-hex);
// ---------- Elevation — explicit rgba shadows --------------------------------
// Explicit rgba values (not color-mix) so they render identically across
// browsers and themes. In dark mode the shadows still work against the

View File

@@ -157,14 +157,17 @@
&:active { transform: scale(0.97); }
}
// Primary — filled with the accent, white text. Force specificity
// high enough to beat Bootstrap's .btn-primary which loads later.
// Primary — filled with the accent (brand purple), white text. White
// is correct in BOTH light and dark bundles because $fp-accent is
// the same hue in both — it doesn't flip with theme. Force
// specificity high enough to beat Bootstrap's .btn-primary which
// loads later.
.btn.btn-primary,
.btn.btn-primary:focus,
.btn.btn-primary:active {
background-color: $fp-accent !important;
border-color: $fp-accent !important;
color: #ffffff !important;
color: #ffffff !important; // intentional: filled accent button
@include fp-hover-only {
&:hover {
@@ -430,17 +433,19 @@
.o_fp_mgr_card_body {
padding: $fp-space-3 $fp-space-4 $fp-space-4;
display: flex; flex-direction: column; gap: $fp-space-2;
background-color: color-mix(in srgb, var(--bs-body-color) 3%, transparent);
// Subtle inset against the card surface — uses the soft surface
// token so it tints correctly in both light and dark bundles.
background-color: $fp-card-soft;
}
// -------------------------------------------------------------------------
// WO row inside expanded card
// Step row inside expanded card
// -------------------------------------------------------------------------
// WO row = info column (vertical stack) + actions column (pickers + buttons)
// Step row = info column (vertical stack) + actions column (pickers + buttons)
// Flex with wrap so narrow viewports drop actions below the info naturally
// instead of squishing everything into a single broken grid line.
.o_fp_mgr_wo_row {
.o_fp_mgr_step_row {
display: flex;
flex-wrap: wrap;
gap: $fp-space-3;
@@ -453,7 +458,7 @@
font-size: $fp-text-sm;
}
.o_fp_mgr_wo_info {
.o_fp_mgr_step_info {
flex: 1 1 280px; // grows but never narrower than 280px
min-width: 0; // allows children to shrink properly
display: flex;
@@ -461,8 +466,8 @@
gap: $fp-space-1;
color: $fp-ink;
// Title row — kind badge + WO name + step number
.o_fp_mgr_wo_title {
// Title row — kind badge + step name + sequence
.o_fp_mgr_step_title {
display: flex;
align-items: center;
gap: $fp-space-2;
@@ -472,7 +477,7 @@
line-height: 1.25;
}
// Meta row — workcenter / role / set equipment
.o_fp_mgr_wo_meta {
.o_fp_mgr_step_meta {
display: flex;
align-items: center;
gap: $fp-space-2;
@@ -482,7 +487,7 @@
i { margin-right: 2px; }
}
// Chip row — what's still missing for the manager to set
.o_fp_mgr_wo_needs {
.o_fp_mgr_step_needs {
margin-top: 2px;
}
}
@@ -491,7 +496,7 @@
// takes the remaining horizontal space (the dropdown then grows to
// fill); flex-wrap so on narrow widths the dropdown sits on its own
// line and the buttons go below at 50/50.
.o_fp_mgr_wo_actions {
.o_fp_mgr_step_actions {
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -526,7 +531,7 @@
&:focus { @include fp-focus-ring; border-color: $fp-accent; }
}
.o_fp_mgr_btn,
.o_fp_mgr_wo_row .btn {
.o_fp_mgr_step_row .btn {
min-height: 40px;
padding: 0 $fp-space-3;
border: none;
@@ -544,13 +549,13 @@
@media (max-width: 900px) {
// Mobile / narrow tablet: dropdown takes full width on its own
// line; the two buttons split 50/50 underneath.
.o_fp_mgr_wo_actions {
.o_fp_mgr_step_actions {
flex: 1 1 100%;
justify-content: stretch;
}
.o_fp_mgr_picker { flex: 1 1 100%; }
.o_fp_mgr_btn,
.o_fp_mgr_wo_row .btn {
.o_fp_mgr_step_row .btn {
flex: 1 1 calc(50% - #{$fp-space-2});
min-height: $fp-touch-min;
}
@@ -575,18 +580,21 @@
&.o_fp_chip_danger { @include fp-pill(--bs-danger); }
&.o_fp_chip_muted { background-color: $fp-card-soft; color: $fp-ink-mute; }
// WO-kind colour bands so the manager can spot
// Step-kind colour bands so the manager can spot
// mask vs wet vs bake at a glance.
&.o_fp_chip_kind {
text-transform: none;
letter-spacing: normal;
font-weight: $fp-weight-bold;
}
&.o_fp_chip_kind_wet { background-color: rgba(13, 110, 253, .15); color: #0d6efd; }
&.o_fp_chip_kind_bake { background-color: rgba(220, 53, 69, .15); color: #dc3545; }
&.o_fp_chip_kind_mask { background-color: rgba(255, 193, 7, .20); color: #997404; }
&.o_fp_chip_kind_rack { background-color: rgba(108, 117, 125, .15); color: #495057; }
&.o_fp_chip_kind_inspect { background-color: rgba(25, 135, 84, .15); color: #198754; }
// Kind chip hues live in _fp_shopfloor_tokens.scss with both light
// and dark variants. Background translucency is computed off the
// hue so dark mode lifts the text without losing the colour code.
&.o_fp_chip_kind_wet { background-color: color-mix(in srgb, #{$fp-kind-wet} 15%, transparent); color: $fp-kind-wet; }
&.o_fp_chip_kind_bake { background-color: color-mix(in srgb, #{$fp-kind-bake} 15%, transparent); color: $fp-kind-bake; }
&.o_fp_chip_kind_mask { background-color: color-mix(in srgb, #{$fp-kind-mask} 20%, transparent); color: $fp-kind-mask; }
&.o_fp_chip_kind_rack { background-color: color-mix(in srgb, #{$fp-kind-rack} 15%, transparent); color: $fp-kind-rack; }
&.o_fp_chip_kind_inspect { background-color: color-mix(in srgb, #{$fp-kind-inspect} 15%, transparent); color: $fp-kind-inspect; }
&.o_fp_chip_kind_other { background-color: $fp-card-soft; color: $fp-ink-mute; }
}

View File

@@ -71,6 +71,12 @@ $pt-line-width : 2px;
top: 0;
z-index: 5;
}
.o_fp_pt_header_actions {
margin-left: auto;
display: flex;
align-items: center;
gap: $fp-space-2;
}
.o_fp_pt_back {
display: inline-flex;
align-items: center;

View File

@@ -0,0 +1,133 @@
// =============================================================================
// Fusion Plating — Reusable QR Scanner Modal
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Mobile-first modal that overlays the page. The video element fills
// the body with a fixed aspect ratio so the layout doesn't jump as
// the camera initialises.
//
// All surfaces resolve from the shop-floor design tokens
// (_fp_shopfloor_tokens.scss) so light + dark modes both work without
// extra rules.
// =============================================================================
.o_fp_qr_modal_backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.o_fp_qr_modal {
background: $fp-card;
color: $fp-ink;
border-radius: $fp-radius-lg;
box-shadow: $fp-elev-3;
// Wrap min() in #{...} so dart-sass doesn't try to compute it at
// compile time (it can't combine 420px and 92vw — the clamp/min
// functions are CSS-runtime, not SCSS). Pass through verbatim.
width: #{"min(420px, 92vw)"};
max-width: 92vw;
overflow: hidden;
font-family: $fp-font-stack;
}
.o_fp_qr_modal_head {
display: flex;
align-items: center;
justify-content: space-between;
padding: $fp-space-3 $fp-space-4;
border-bottom: 1px solid $fp-border;
h3 {
margin: 0;
font-size: $fp-text-lg;
color: $fp-ink;
}
}
.o_fp_qr_modal_body {
padding: $fp-space-4;
display: flex;
flex-direction: column;
gap: $fp-space-3;
}
.o_fp_qr_video {
width: 100%;
aspect-ratio: 4 / 3;
background: #000;
border-radius: $fp-radius-md;
object-fit: cover;
}
.o_fp_qr_photo_row {
display: flex;
justify-content: center;
}
.o_fp_qr_photo_btn {
cursor: pointer;
width: 100%;
text-align: center;
padding: $fp-space-3;
font-weight: 600;
}
.o_fp_qr_error,
.o_fp_qr_warn,
.o_fp_qr_detected,
.o_fp_qr_status {
padding: $fp-space-2 $fp-space-3;
border-radius: $fp-radius-sm;
background: $fp-card-soft;
color: $fp-ink-soft;
font-size: $fp-text-sm;
word-break: break-all;
}
.o_fp_qr_status {
border-left: 3px solid $fp-accent;
color: $fp-ink;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
}
.o_fp_qr_error {
border-left: 3px solid $fp-bad;
}
.o_fp_qr_detected {
border-left: 3px solid $fp-ok;
color: $fp-ink;
.o_fp_qr_detected_val {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 600;
}
}
.o_fp_qr_manual {
border-top: 1px solid $fp-border;
padding-top: $fp-space-3;
label {
font-size: $fp-text-sm;
color: $fp-ink-mute;
margin-bottom: 4px;
}
.form-control {
background: $fp-card-soft;
border: 1px solid $fp-border;
color: $fp-ink;
&:focus {
@include fp-focus-ring;
border-color: $fp-accent;
}
}
}

View File

@@ -0,0 +1,222 @@
// =============================================================================
// Fusion Plating — Tank Status (NFC tap-to-view)
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Mobile-first stylesheet for /fp/tank/<id>. Renders inside
// web.frontend_layout. Uses the shop-floor design tokens so light +
// dark themes both work without an extra rule set.
// =============================================================================
.o_fp_tank_status {
max-width: 720px;
margin: 0 auto;
padding: $fp-space-4;
color: $fp-ink;
font-family: $fp-font-stack;
background: $fp-page;
min-height: 100vh;
box-sizing: border-box;
}
.o_fp_tank_head {
text-align: center;
margin-bottom: $fp-space-5;
h1 {
font-size: $fp-text-2xl;
margin: 0 0 $fp-space-2;
color: $fp-ink;
i {
color: $fp-accent;
margin-right: $fp-space-2;
}
}
}
.o_fp_tank_meta {
color: $fp-ink-mute;
font-size: $fp-text-sm;
span + span::before {
content: " · ";
padding: 0 4px;
}
strong {
color: $fp-ink-soft;
margin-right: 4px;
}
}
.o_fp_tank_section {
background: $fp-card-soft;
border-radius: $fp-radius-md;
padding: $fp-space-4;
margin-bottom: $fp-space-4;
h2 {
font-size: $fp-text-md;
margin: 0 0 $fp-space-3;
color: $fp-ink-soft;
i {
margin-right: $fp-space-2;
color: $fp-accent;
}
}
}
.o_fp_tank_card {
background: $fp-card;
border: 1px solid $fp-border;
border-radius: $fp-radius-sm;
padding: $fp-space-3 $fp-space-4;
box-shadow: $fp-elev-1;
margin-bottom: $fp-space-2;
color: $fp-ink;
}
.o_fp_tank_card_compact {
display: flex;
flex-direction: column;
gap: 2px;
}
.o_fp_tank_card_title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $fp-space-2;
gap: $fp-space-2;
}
.o_fp_tank_card_meta {
display: grid;
grid-template-columns: 1fr;
gap: $fp-space-2;
font-size: $fp-text-sm;
color: $fp-ink-soft;
span strong {
color: $fp-ink-mute;
margin-right: 6px;
}
}
.o_fp_tank_card_sub {
color: $fp-ink-mute;
font-size: $fp-text-sm;
}
.o_fp_tank_empty {
color: $fp-ink-mute;
font-style: italic;
text-align: center;
padding: $fp-space-3 0;
}
// State / status pills — use the same translucent-tint pattern as the
// other shop-floor surfaces so they read at a glance on a phone.
.o_fp_state_badge {
padding: 2px 8px;
border-radius: $fp-radius-pill;
font-size: $fp-text-xs;
text-transform: uppercase;
font-weight: $fp-weight-semibold;
letter-spacing: 0.5px;
background: color-mix(in srgb, #{$fp-ink-soft} 14%, transparent);
color: $fp-ink-soft;
&[data-state="in_progress"] {
background: color-mix(in srgb, #{$fp-accent} 18%, transparent);
color: $fp-accent;
}
&[data-state="paused"] {
background: color-mix(in srgb, #{$fp-warn} 18%, transparent);
color: $fp-warn;
}
&[data-state="ok"] {
background: color-mix(in srgb, #{$fp-ok} 18%, transparent);
color: $fp-ok;
}
&[data-state="warning"] {
background: color-mix(in srgb, #{$fp-warn} 18%, transparent);
color: $fp-warn;
}
&[data-state="out_of_spec"] {
background: color-mix(in srgb, #{$fp-bad} 18%, transparent);
color: $fp-bad;
}
}
// Bath chemistry grid — one cell per parameter reading.
.o_fp_tank_chem_grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: $fp-space-2;
margin-top: $fp-space-3;
border-top: 1px solid $fp-border;
padding-top: $fp-space-3;
}
.o_fp_tank_chem_cell {
background: $fp-card-soft;
border-radius: $fp-radius-sm;
padding: $fp-space-2 $fp-space-3;
text-align: center;
&[data-status="ok"] {
box-shadow: inset 0 0 0 1px color-mix(in srgb, #{$fp-ok} 40%, transparent);
}
&[data-status="warning"] {
box-shadow: inset 0 0 0 1px color-mix(in srgb, #{$fp-warn} 40%, transparent);
}
&[data-status="out_of_spec"] {
box-shadow: inset 0 0 0 1px color-mix(in srgb, #{$fp-bad} 50%, transparent);
}
}
.o_fp_tank_chem_label {
font-size: $fp-text-xs;
color: $fp-ink-mute;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.o_fp_tank_chem_value {
font-size: $fp-text-lg;
font-weight: $fp-weight-semibold;
color: $fp-ink;
margin-top: 2px;
small {
font-size: $fp-text-xs;
font-weight: $fp-weight-medium;
color: $fp-ink-mute;
margin-left: 2px;
}
}
.o_fp_tank_chem_range {
font-size: $fp-text-xs;
color: $fp-ink-faint;
margin-top: 2px;
}
.o_fp_tank_foot {
text-align: center;
color: $fp-ink-faint;
font-size: $fp-text-xs;
margin-top: $fp-space-6;
p {
margin: 0;
}
}

View File

@@ -2,7 +2,7 @@
<!--
Copyright 2026 Nexa Systems Inc. · License OPL-1
Fusion Plating — Manager Desk
Rebuilt 2026-04 with the shop-floor design system.
Native fp.job / fp.job.step edition. Speaks job/step end-to-end.
-->
<templates xml:space="preserve">
@@ -44,6 +44,7 @@
t-att-disabled="state.isFetching">
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
</button>
<QrScanner cssClass="'btn'"/>
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
t-on-click="toggleMode">
<t t-if="state.mode === 'quick'">Quick View</t>
@@ -71,17 +72,17 @@
<div class="o_fp_kpi_strip" t-if="state.overview">
<div class="o_fp_kpi o_fp_kpi_warning">
<i class="fa fa-user-times"/>
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.unassigned_wos"/></div>
<div class="o_fp_kpi_label">Unassigned WOs</div>
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.unassigned_steps"/></div>
<div class="o_fp_kpi_label">Unassigned Steps</div>
</div>
<div class="o_fp_kpi o_fp_kpi_success">
<i class="fa fa-cogs"/>
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.active_wos"/></div>
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.active_steps"/></div>
<div class="o_fp_kpi_label">In Progress</div>
</div>
<div class="o_fp_kpi o_fp_kpi_info">
<i class="fa fa-truck"/>
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.ready_to_ship_mos"/></div>
<div class="o_fp_kpi_value"><t t-esc="state.overview.kpis.ready_to_ship_jobs"/></div>
<div class="o_fp_kpi_label">Ready to Ship</div>
</div>
<div class="o_fp_kpi o_fp_kpi_warning">
@@ -94,7 +95,7 @@
<!-- ============ Workload grid ============ -->
<div class="o_fp_manager_grid" t-if="state.overview">
<!-- Unassigned -->
<!-- Needs a Worker -->
<section class="o_fp_panel o_fp_panel_unassigned">
<div class="o_fp_panel_head">
<h3><i class="fa fa-inbox"/>Needs a Worker</h3>
@@ -102,17 +103,17 @@
</div>
<div t-if="!state.overview.unassigned.length" class="o_fp_empty">
<i class="fa fa-check-circle text-success"/>
<div>Every active WO has a worker assigned.</div>
<div>Every active step has a worker assigned.</div>
</div>
<div class="o_fp_mgr_card_list" t-if="state.overview.unassigned.length">
<t t-foreach="state.overview.unassigned" t-as="card" t-key="card.mo_id">
<t t-foreach="state.overview.unassigned" t-as="card" t-key="card.job_id">
<div class="o_fp_mgr_card"
t-att-data-priority="card.priority_any">
<div class="o_fp_mgr_card_head"
t-on-click="() => this.toggleCard(card.mo_id)">
t-on-click="() => this.toggleCard(card.job_id)">
<div>
<div class="o_fp_mgr_card_title">
<t t-esc="card.mo_name"/>
<t t-esc="card.job_name"/>
<span class="text-muted ms-2 small">· <t t-esc="card.so_name"/></span>
</div>
<div class="o_fp_mgr_card_sub">
@@ -126,46 +127,48 @@
<span t-if="card.priority_any >= 2" class="o_fp_chip o_fp_chip_danger">HOT</span>
<span t-if="card.priority_any == 1" class="o_fp_chip o_fp_chip_warning">Urgent</span>
<span class="o_fp_chip o_fp_chip_muted">
<t t-esc="card.wos.length"/> WO
<t t-esc="card.steps.length"/>
<t t-if="card.steps.length === 1"> Step</t>
<t t-else=""> Steps</t>
</span>
</div>
</div>
<div class="o_fp_mgr_card_body"
t-if="state.expandedMoId === card.mo_id or state.mode === 'detailed'">
<t t-foreach="card.wos" t-as="wo" t-key="wo.id">
<div class="o_fp_mgr_wo_row">
t-if="state.expandedJobId === card.job_id or state.mode === 'detailed'">
<t t-foreach="card.steps" t-as="step" t-key="step.id">
<div class="o_fp_mgr_step_row">
<!-- LEFT: information stack (badge, name, meta, needs) -->
<div class="o_fp_mgr_wo_info">
<div class="o_fp_mgr_wo_title">
<span t-attf-class="o_fp_chip o_fp_chip_kind o_fp_chip_kind_{{ wo.wo_kind }}"
t-esc="wo.wo_kind_label || wo.wo_kind"/>
<span t-esc="wo.name"/>
<div class="o_fp_mgr_step_info">
<div class="o_fp_mgr_step_title">
<span t-attf-class="o_fp_chip o_fp_chip_kind o_fp_chip_kind_{{ step.kind }}"
t-esc="step.kind_label || step.kind"/>
<span t-esc="step.name"/>
</div>
<div class="o_fp_mgr_wo_meta">
<span><i class="fa fa-cog"/><t t-esc="wo.workcenter"/></span>
<span t-if="wo.role_name">· <i class="fa fa-id-badge"/><t t-esc="wo.role_name"/></span>
<span t-if="wo.bath">· <i class="fa fa-flask"/><t t-esc="wo.bath"/></span>
<span t-if="wo.oven">· <i class="fa fa-fire"/><t t-esc="wo.oven"/></span>
<span t-if="wo.rack">· <i class="fa fa-th"/><t t-esc="wo.rack"/></span>
<span t-if="wo.masking_material">· <i class="fa fa-tag"/><t t-esc="wo.masking_material"/></span>
<div class="o_fp_mgr_step_meta">
<span><i class="fa fa-cog"/><t t-esc="step.workcenter"/></span>
<span t-if="step.role_name">· <i class="fa fa-id-badge"/><t t-esc="step.role_name"/></span>
<span t-if="step.bath">· <i class="fa fa-flask"/><t t-esc="step.bath"/></span>
<span t-if="step.oven">· <i class="fa fa-fire"/><t t-esc="step.oven"/></span>
<span t-if="step.rack">· <i class="fa fa-th"/><t t-esc="step.rack"/></span>
<span t-if="step.masking_material">· <i class="fa fa-tag"/><t t-esc="step.masking_material"/></span>
</div>
<div t-if="wo.missing_for_release"
class="o_fp_mgr_wo_needs">
<div t-if="step.missing_for_release"
class="o_fp_mgr_step_needs">
<span class="o_fp_chip o_fp_chip_warning">
<i class="fa fa-exclamation-circle me-1"/>
Needs: <t t-esc="wo.missing_for_release"/>
Needs: <t t-esc="step.missing_for_release"/>
</span>
</div>
</div>
<!-- RIGHT: action group (pickers + buttons) -->
<div class="o_fp_mgr_wo_actions">
<div class="o_fp_mgr_step_actions">
<select class="o_fp_mgr_picker"
t-on-change="(ev) => this.onAssignWorker(wo, ev.target.value)">
t-on-change="(ev) => this.onAssignWorker(step, ev.target.value)">
<option value="">— Assign worker —</option>
<t t-foreach="operatorsForWO(wo)" t-as="op" t-key="op.id">
<t t-foreach="operatorsForStep(step)" t-as="op" t-key="op.id">
<option t-att-value="op.id"
t-att-selected="wo.assigned_user_id === op.id"
t-att-selected="step.assigned_user_id === op.id"
t-att-data-bucket="op.bucket">
<t t-if="op.is_clocked_in"></t>
<t t-else=""></t>
@@ -173,26 +176,26 @@
</option>
</t>
</select>
<select t-if="wo.wo_kind === 'wet'"
<select t-if="step.kind === 'wet'"
class="o_fp_mgr_picker"
t-on-change="(ev) => this.onAssignTank(wo, ev.target.value)">
t-on-change="(ev) => this.onAssignTank(step, ev.target.value)">
<option value="">— Tank —</option>
<t t-foreach="state.overview.tanks" t-as="tnk" t-key="tnk.id">
<option t-att-value="tnk.id"
t-att-selected="wo.tank_id === tnk.id">
t-att-selected="step.tank_id === tnk.id">
<t t-esc="tnk.name"/>
<t t-if="tnk.current_bath"> · <t t-esc="tnk.current_bath"/></t>
</option>
</t>
</select>
<button class="btn o_fp_mgr_btn"
t-on-click="() => this.onTakeOver(wo)"
title="Assign this WO to yourself">
t-on-click="() => this.onTakeOver(step)"
title="Assign this step to yourself">
<i class="fa fa-user me-1"/>Take Over
</button>
<button class="btn o_fp_mgr_btn"
t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
<i class="fa fa-external-link me-1"/>Open WO
t-on-click="() => this.openRecord('fp.job.step', step.id)">
<i class="fa fa-external-link me-1"/>Open Step
</button>
</div>
</div>
@@ -214,14 +217,14 @@
<div>Nothing running right now.</div>
</div>
<div class="o_fp_mgr_card_list" t-if="state.overview.active.length">
<t t-foreach="state.overview.active" t-as="card" t-key="card.mo_id">
<t t-foreach="state.overview.active" t-as="card" t-key="card.job_id">
<div class="o_fp_mgr_card"
t-att-data-priority="card.priority_any">
<div class="o_fp_mgr_card_head"
t-on-click="() => this.toggleCard(card.mo_id)">
t-on-click="() => this.toggleCard(card.job_id)">
<div>
<div class="o_fp_mgr_card_title">
<t t-esc="card.mo_name"/>
<t t-esc="card.job_name"/>
<span class="text-muted ms-2 small">· <t t-esc="card.so_name"/></span>
</div>
<div class="o_fp_mgr_card_sub">
@@ -232,33 +235,35 @@
<div class="o_fp_mgr_card_chips">
<span t-if="card.priority_any >= 2" class="o_fp_chip o_fp_chip_danger">HOT</span>
<span class="o_fp_chip o_fp_chip_success">
<t t-esc="card.wos.length"/> WO
<t t-esc="card.steps.length"/>
<t t-if="card.steps.length === 1"> Step</t>
<t t-else=""> Steps</t>
</span>
</div>
</div>
<div class="o_fp_mgr_card_body"
t-if="state.expandedMoId === card.mo_id or state.mode === 'detailed'">
<t t-foreach="card.wos" t-as="wo" t-key="wo.id">
<div class="o_fp_mgr_wo_row">
<div class="o_fp_mgr_wo_info">
<t t-esc="wo.name"/>
t-if="state.expandedJobId === card.job_id or state.mode === 'detailed'">
<t t-foreach="card.steps" t-as="step" t-key="step.id">
<div class="o_fp_mgr_step_row">
<div class="o_fp_mgr_step_info">
<t t-esc="step.name"/>
<span class="text-muted ms-2">
<t t-esc="wo.workcenter"/>
<t t-if="wo.assigned_user_name">
<t t-esc="step.workcenter"/>
<t t-if="step.assigned_user_name">
· <i class="fa fa-user"/>
<t t-esc="wo.assigned_user_name"/>
<t t-esc="step.assigned_user_name"/>
</t>
</span>
</div>
<span t-att-class="'o_fp_chip o_fp_chip_' + (wo.state === 'progress' ? 'success' : 'info')">
<t t-esc="wo.state"/>
<span t-att-class="'o_fp_chip o_fp_chip_' + (step.state === 'in_progress' || step.state === 'progress' ? 'success' : 'info')">
<t t-esc="step.state"/>
</span>
<button class="btn"
t-on-click="() => this.onTakeOver(wo)">
t-on-click="() => this.onTakeOver(step)">
Take Over
</button>
<button class="btn"
t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
t-on-click="() => this.openRecord('fp.job.step', step.id)">
Open
</button>
</div>

View File

@@ -43,6 +43,7 @@
title="Refresh">
<i t-att-class="state.loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"/>
</button>
<QrScanner cssClass="'btn btn-outline-secondary'"/>
</div>
</div>

View File

@@ -61,7 +61,7 @@
t-if="node.qty_total"
t-esc="qtyLabel(node)"/>
<i class="o_fp_pt_card_open fa fa-external-link"
t-if="node.workorder_id"/>
t-if="node.step_id or node.workorder_id"/>
</div>
</div>
@@ -106,6 +106,9 @@
<span t-if="state.recipe"> · <i class="fa fa-flask me-1"/><t t-esc="state.recipe"/></span>
</div>
</div>
<div class="o_fp_pt_header_actions">
<QrScanner cssClass="'btn btn-outline-secondary'"/>
</div>
</div>
<!-- ========== LOADING ========== -->

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc. · License OPL-1
Fusion Plating — Reusable QR Scanner template
The video element is rendered whenever ANY decoder is available
(state.canScan = native BarcodeDetector OR vendored jsQR). The
paste-URL fallback is shown unconditionally as a secondary path
so a tablet with broken camera permissions still has a way in.
-->
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.QrScanner">
<button t-att-class="props.cssClass + ' o_fp_qr_btn'"
t-on-click="() => this.open()">
<i class="fa fa-qrcode me-1"/>
<t t-esc="props.label"/>
</button>
<div t-if="state.open" class="o_fp_qr_modal_backdrop"
t-on-click="close">
<div class="o_fp_qr_modal" t-on-click.stop="">
<div class="o_fp_qr_modal_head">
<h3>Scan job QR</h3>
<button class="btn btn-sm btn-light" t-on-click="close">
<i class="fa fa-times"/>
</button>
</div>
<div class="o_fp_qr_modal_body">
<div t-if="state.statusLine" class="o_fp_qr_status">
<i class="fa fa-info-circle me-1"/>
<span t-esc="state.statusLine"/>
</div>
<div t-if="!state.canScan and !state.error"
class="o_fp_qr_warn">
Live decoding isn't supported in this browser.
Paste the sticker URL below.
</div>
<video t-if="state.canScan" t-ref="video"
class="o_fp_qr_video" muted="true" playsinline="true"/>
<!-- Take-a-photo fallback. The native file input
with capture=environment opens the iOS / Android
camera UI directly and returns a JPEG when the
user taps the shutter. We then run ONE decode
on that high-quality still — far more reliable
on iOS than the live-video path. -->
<div class="o_fp_qr_photo_row">
<label class="btn btn-outline-secondary o_fp_qr_photo_btn">
<i class="fa fa-camera me-1"/>
Take photo of QR
<input type="file"
accept="image/*"
capture="environment"
class="d-none"
t-on-change="onPhotoCapture"/>
</label>
</div>
<div t-if="state.detected" class="o_fp_qr_detected">
<i class="fa fa-check-circle me-1"/>
<span>Detected: </span>
<span class="o_fp_qr_detected_val" t-esc="state.detected"/>
</div>
<div t-if="state.error" class="o_fp_qr_error">
<i class="fa fa-exclamation-triangle me-1"/>
<span t-esc="state.error"/>
</div>
<div class="o_fp_qr_manual">
<label class="form-label">Or paste sticker URL</label>
<input class="form-control" t-model="state.manualUrl"
placeholder="https://entech/.../fp/job/123"
t-on-keyup="(e) => e.key === 'Enter' &amp;&amp; this.onManualSubmit()"/>
<button class="btn btn-primary mt-2"
t-on-click="() => this.onManualSubmit()">Open</button>
</div>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -42,8 +42,9 @@
</t>
</select>
<button class="o_fp_scan_toggle" t-on-click="toggleScan">
<i class="fa fa-qrcode me-1"/>Scan
<i class="fa fa-qrcode me-1"/>Code
</button>
<QrScanner cssClass="'o_fp_scan_toggle'" label="'Camera'"/>
</div>
</header>
@@ -89,7 +90,7 @@
Active: <strong t-esc="state.overview.active_wo.name"/>
</div>
<div class="o_fp_active_wo_meta">
MO <t t-esc="state.overview.active_wo.mo_name"/>
Job <t t-esc="state.overview.active_wo.mo_name"/>
· <t t-esc="state.overview.active_wo.product_name"/>
· Qty <t t-esc="state.overview.active_wo.qty_done"/>/<t t-esc="state.overview.active_wo.qty_total"/>
<t t-if="state.overview.active_wo.workcenter"> @ <t t-esc="state.overview.active_wo.workcenter"/></t>
@@ -97,8 +98,8 @@
</div>
</div>
<button class="o_fp_big_button"
t-on-click="() => openRecord('mrp.workorder', state.overview.active_wo.id)">
Open WO
t-on-click="() => openRecord('fp.job.step', state.overview.active_wo.id)">
Open Step
</button>
</div>

View File

@@ -0,0 +1,165 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc. · License OPL-1
Fusion Plating — Tank Status (NFC tap-to-view) page
Rendered by /fp/tank/<id>. Mobile-first layout with big touch
targets so an operator can read the tank's current state from a
phone after tapping the NFC tag.
-->
<odoo>
<template id="tank_status_page">
<t t-call="web.frontend_layout">
<t t-set="head">
<meta name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"/>
</t>
<div class="o_fp_tank_status">
<header class="o_fp_tank_head">
<h1>
<i class="fa fa-flask"/>
<span t-esc="tank.name"/>
</h1>
<div class="o_fp_tank_meta">
<span t-if="tank.code"><strong>Code:</strong> <span t-esc="tank.code"/></span>
<span t-if="tank.current_bath_id"><strong>Bath:</strong> <span t-esc="tank.current_bath_id.name"/></span>
<span t-if="tank.facility_id"><strong>Facility:</strong> <span t-esc="tank.facility_id.name"/></span>
<span t-if="tank.work_center_id"><strong>Work Centre:</strong> <span t-esc="tank.work_center_id.name"/></span>
</div>
</header>
<section class="o_fp_tank_section o_fp_tank_section_active">
<h2>
<i t-if="active_step" class="fa fa-cog fa-spin"/>
<i t-if="not active_step" class="fa fa-circle-o"/>
Current Job
</h2>
<div t-if="active_step" class="o_fp_tank_card">
<div class="o_fp_tank_card_title">
<strong><span t-esc="active_step.job_id.name"/></strong>
<span class="o_fp_state_badge"
t-att-data-state="active_step.state">
<span t-esc="active_step.state"/>
</span>
</div>
<div class="o_fp_tank_card_meta">
<span>
<strong>Customer:</strong>
<span t-esc="active_step.job_id.partner_id.name"/>
</span>
<span>
<strong>Step:</strong>
<span t-esc="active_step.name"/>
</span>
<span t-if="active_step.assigned_user_id">
<strong>Operator:</strong>
<span t-esc="active_step.assigned_user_id.name"/>
</span>
<span t-if="active_step.duration_expected">
<strong>Expected:</strong>
<span t-esc="int(active_step.duration_expected)"/> min
</span>
<span t-if="active_step.thickness_target">
<strong>Target thickness:</strong>
<span t-esc="active_step.thickness_target"/>
<span t-esc="active_step.thickness_uom or ''"/>
</span>
<span t-if="active_step.date_started">
<strong>Started:</strong>
<span t-esc="active_step.date_started"/>
</span>
</div>
</div>
<div t-if="not active_step" class="o_fp_tank_empty">
Tank is idle.
</div>
</section>
<section class="o_fp_tank_section">
<h2><i class="fa fa-clock-o"/>Up Next</h2>
<div t-if="ready_steps" class="o_fp_tank_list">
<t t-foreach="ready_steps" t-as="step">
<div class="o_fp_tank_card o_fp_tank_card_compact">
<strong><span t-esc="step.job_id.name"/></strong>
<span class="o_fp_tank_card_sub">
<span t-esc="step.job_id.partner_id.name"/>
· <span t-esc="step.name"/>
<t t-if="step.duration_expected">
· <span t-esc="int(step.duration_expected)"/> min
</t>
</span>
</div>
</t>
</div>
<div t-if="not ready_steps" class="o_fp_tank_empty">
No queued work for this tank.
</div>
</section>
<section t-if="bath_log" class="o_fp_tank_section">
<h2><i class="fa fa-tint"/>Bath Chemistry</h2>
<div class="o_fp_tank_card">
<div class="o_fp_tank_card_meta">
<span>
<strong>Status:</strong>
<span class="o_fp_state_badge"
t-att-data-state="bath_log.status"
t-esc="bath_log.status or '—'"/>
</span>
<span t-if="bath_log.log_date">
<strong>Last sampled:</strong>
<span t-esc="bath_log.log_date"/>
</span>
<span t-if="bath_log.operator_id">
<strong>Sampled by:</strong>
<span t-esc="bath_log.operator_id.name"/>
</span>
</div>
<div t-if="bath_log.line_ids"
class="o_fp_tank_chem_grid">
<t t-foreach="bath_log.line_ids" t-as="line">
<div class="o_fp_tank_chem_cell"
t-att-data-status="line.status">
<div class="o_fp_tank_chem_label">
<span t-esc="line.parameter_id.name or line.parameter_code or '—'"/>
</div>
<div class="o_fp_tank_chem_value">
<span t-esc="line.value"/>
<small t-if="line.uom" t-esc="line.uom"/>
</div>
<div class="o_fp_tank_chem_range"
t-if="line.target_min or line.target_max">
target
<t t-if="line.target_min"><span t-esc="line.target_min"/></t>
<t t-if="line.target_min and line.target_max"> </t>
<t t-if="line.target_max"><span t-esc="line.target_max"/></t>
</div>
</div>
</t>
</div>
</div>
</section>
<footer class="o_fp_tank_foot">
<p>Tap the NFC tag again or scan a part-box QR for job details.</p>
</footer>
</div>
</t>
</template>
<template id="tank_status_not_found">
<t t-call="web.frontend_layout">
<div class="o_fp_tank_status">
<header class="o_fp_tank_head">
<h1>
<i class="fa fa-exclamation-triangle"/>
Tank not found
</h1>
</header>
<section class="o_fp_tank_section">
<p>No tank with id <strong t-esc="tank_id"/>.</p>
</section>
</div>
</t>
</template>
</odoo>