Merge feat/fp-native-job-model into main
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:
@@ -124,15 +124,15 @@ class TestFpWorkCentre(TransactionCase):
|
|||||||
self.assertEqual(wc.kind, 'wet_line')
|
self.assertEqual(wc.kind, 'wet_line')
|
||||||
self.assertTrue(wc.active)
|
self.assertTrue(wc.active)
|
||||||
|
|
||||||
def test_facility_required_for_active_centre(self):
|
def test_facility_optional_at_create(self):
|
||||||
# Active centre without facility raises on confirm path
|
# Facility is soft-required (warning at confirm, not constraint
|
||||||
# (we treat facility as soft-required: warning, not constraint)
|
# at create) — verify a centre without facility still creates.
|
||||||
wc = self.env['fp.work.centre'].create({
|
wc = self.env['fp.work.centre'].create({
|
||||||
'name': 'Test',
|
'name': 'Test',
|
||||||
'code': 'T',
|
'code': 'T',
|
||||||
'kind': 'other',
|
'kind': 'other',
|
||||||
})
|
})
|
||||||
self.assertFalse(wc.facility_id) # allowed at create time
|
self.assertFalse(wc.facility_id)
|
||||||
|
|
||||||
def test_kind_selection_values(self):
|
def test_kind_selection_values(self):
|
||||||
kinds = dict(
|
kinds = dict(
|
||||||
@@ -221,7 +221,7 @@ from . import fp_work_centre
|
|||||||
Modify `fusion_plating/security/ir.model.access.csv` — append:
|
Modify `fusion_plating/security/ir.model.access.csv` — append:
|
||||||
|
|
||||||
```csv
|
```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_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_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
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?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.
|
<!-- Sequence for fp.job. Format: WH/JOB/00001 onwards.
|
||||||
Migrated mrp.production records keep their WH/MO/... names. -->
|
Migrated mrp.production records keep their WH/MO/... names. -->
|
||||||
<record id="seq_fp_job" model="ir.sequence">
|
<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:
|
Modify `fusion_plating/security/ir.model.access.csv` — append:
|
||||||
|
|
||||||
```csv
|
```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_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_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_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`:
|
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',
|
'job_id', 'line_id',
|
||||||
string='Source SO Lines',
|
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(
|
recipe_id = fields.Many2one(
|
||||||
'fusion.plating.process.node',
|
'fusion.plating.process.node',
|
||||||
string='Recipe',
|
string='Recipe',
|
||||||
@@ -552,26 +550,22 @@ Modify `fusion_plating/models/fp_job.py` — add fields after `company_id`:
|
|||||||
string='Start at Node',
|
string='Start at Node',
|
||||||
help='Rework: start the job at this recipe node (skip earlier).',
|
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(
|
invoice_ids = fields.Many2many(
|
||||||
'account.move',
|
'account.move',
|
||||||
'fp_job_account_move_rel',
|
'fp_job_account_move_rel',
|
||||||
'job_id', 'move_id',
|
'job_id', 'move_id',
|
||||||
string='Invoices',
|
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)**
|
- [ ] **Step 2: Add cost rollup fields (computed)**
|
||||||
|
|
||||||
Append:
|
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:
|
Modify `fusion_plating/security/ir.model.access.csv` — append:
|
||||||
|
|
||||||
```csv
|
```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_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_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_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:
|
Modify `fusion_plating/security/ir.model.access.csv` — append:
|
||||||
|
|
||||||
```csv
|
```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_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
|
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):
|
**Strategy revision (vs. original plan):** original said "rename bridge_mrp → jobs."
|
||||||
- Module rename + manifest cleanup
|
Renaming is destructive on entech (a live system). Instead, **build the new module
|
||||||
- `_fp_auto_create_job` on `sale.order.action_confirm`
|
in parallel**:
|
||||||
- `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
|
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 1–7 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.2–8.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 1–14)
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -96,50 +96,57 @@ process tree with cost/time aggregates.
|
|||||||
|
|
||||||
Replaces `mrp.production` for plating jobs. One record per shop-floor job.
|
Replaces `mrp.production` for plating jobs. One record per shop-floor job.
|
||||||
|
|
||||||
| Field | Type | Notes |
|
**Module ownership:** `fp.job` lives in `fusion_plating` core. Cross-module fields
|
||||||
|---|---|---|
|
(referencing models from `fusion_plating_configurator`, `_portal`, `_logistics`,
|
||||||
| `name` | Char | Sequence: `WH/JOB/00033`. The legacy "WH/MO/00033" labels stay only on migrated records (see §7). |
|
`_quality`, `_bridge_mrp`) **cannot** live in core without inverting the dependency
|
||||||
| `state` | Selection | `draft`, `confirmed`, `in_progress`, `done`, `cancelled`, `on_hold` |
|
graph. Each owning module extends `fp.job` via `_inherit` to add its field. The
|
||||||
| `partner_id` | Many2one(res.partner) | Customer; copied from SO |
|
Phase 2 module `fusion_plating_jobs` becomes the umbrella that pulls all the
|
||||||
| `product_id` | Many2one(product.product) | Reference part product (for inventory only) |
|
extensions together. Ownership is called out in the **Module** column below.
|
||||||
| `part_catalog_id` | Many2one(fp.part.catalog) | The actual part being plated; primary identifier |
|
|
||||||
| `qty` | Float | Quantity to plate |
|
| Field | Type | Module | Notes |
|
||||||
| `qty_done` | Float | Quantity completed |
|
|---|---|---|---|
|
||||||
| `qty_scrapped` | Float | Quantity scrapped (rolled up from holds) |
|
| `name` | Char | core | Sequence: `WH/JOB/00033`. The legacy "WH/MO/00033" labels stay only on migrated records (see §7). |
|
||||||
| `date_deadline` | Datetime | Promised completion date |
|
| `state` | Selection | core | `draft`, `confirmed`, `in_progress`, `done`, `cancelled`, `on_hold` |
|
||||||
| `date_planned_start` | Datetime | Planned start |
|
| `partner_id` | Many2one(res.partner) | core | Customer; copied from SO |
|
||||||
| `date_started` | Datetime | Actual start (first step start) |
|
| `product_id` | Many2one(product.product) | core | Reference part product (for inventory only) |
|
||||||
| `date_finished` | Datetime | Actual completion |
|
| `qty` | Float | core | Quantity to plate |
|
||||||
| `origin` | Char | SO name for traceability |
|
| `qty_done` | Float | core | Quantity completed |
|
||||||
| `sale_order_id` | Many2one(sale.order) | Source SO |
|
| `qty_scrapped` | Float | core | Quantity scrapped (rolled up from holds) |
|
||||||
| `sale_order_line_ids` | Many2many(sale.order.line) | Lines that fed this job (group_tag collapse) |
|
| `date_deadline` | Datetime | core | Promised completion date |
|
||||||
| `recipe_id` | Many2one(fusion.plating.process.node) | The recipe template used |
|
| `date_planned_start` | Datetime | core | Planned start |
|
||||||
| `step_ids` | One2many(fp.job.step, job_id) | The operations |
|
| `date_started` | Datetime | core | Actual start (first step start) |
|
||||||
| `step_count` | Integer | Computed |
|
| `date_finished` | Datetime | core | Actual completion |
|
||||||
| `step_done_count` | Integer | Computed |
|
| `origin` | Char | core | SO name for traceability |
|
||||||
| `step_progress_pct` | Float | Computed: `step_done_count / step_count * 100` |
|
| `sale_order_id` | Many2one(sale.order) | core | Source SO (sale_management is in core depends) |
|
||||||
| `current_step_id` | Many2one(fp.job.step) | The operation currently in progress (or next ready) |
|
| `sale_order_line_ids` | Many2many(sale.order.line) | core | Lines that fed this job (group_tag collapse) |
|
||||||
| `coating_config_id` | Many2one(fp.coating.config) | The coating spec |
|
| `recipe_id` | Many2one(fusion.plating.process.node) | core | The recipe template used |
|
||||||
| `facility_id` | Many2one(fp.facility) | Hard gate at confirm |
|
| `step_ids` | One2many(fp.job.step, job_id) | core | The operations |
|
||||||
| `manager_id` | Many2one(res.users) | Plating manager |
|
| `step_count` | Integer | core | Computed |
|
||||||
| `priority` | Selection | `low`, `normal`, `high`, `rush` (operator-relevant ordering) |
|
| `step_done_count` | Integer | core | Computed |
|
||||||
| `customer_spec_id` | Many2one(fp.customer.spec) | Optional spec |
|
| `step_progress_pct` | Float | core | Computed: `step_done_count / step_count * 100` |
|
||||||
| `portal_job_id` | Many2one(fp.portal.job) | Customer portal binding (renamed from `x_fc_portal_job_id`) |
|
| `current_step_id` | Many2one(fp.job.step) | core | The operation currently in progress (or next ready) |
|
||||||
| `delivery_id` | Many2one(fp.delivery) | The shipment |
|
| `facility_id` | Many2one(fusion.plating.facility) | core | Hard gate at confirm |
|
||||||
| `invoice_ids` | Many2many(account.move) | Linked invoices |
|
| `manager_id` | Many2one(res.users) | core | Plating manager |
|
||||||
| `certificate_ids` | One2many(fp.certificate, job_id) | Certs generated |
|
| `priority` | Selection | core | `low`, `normal`, `high`, `rush` (operator-relevant ordering) |
|
||||||
| `batch_ids` | One2many(fp.batch, job_id) | Batches that ran through |
|
| `invoice_ids` | Many2many(account.move) | core | Linked invoices (account is reachable via sale_management → sale → account) |
|
||||||
| `quality_hold_ids` | One2many(fp.quality.hold, job_id) | Holds raised |
|
| `quoted_revenue` | Monetary | core | From SO |
|
||||||
| `consumption_ids` | One2many(fp.job.consumption, job_id) | Consumables |
|
| `actual_cost` | Monetary | core | Computed from steps + consumables |
|
||||||
| `qc_check_id` | Many2one(fp.quality.check) | Active QC check |
|
| `margin` | Monetary | core | Computed |
|
||||||
| `quoted_revenue` | Monetary | From SO |
|
| `margin_pct` | Float | core | Computed |
|
||||||
| `actual_cost` | Monetary | Computed from steps + consumables |
|
| `start_at_node_id` | Many2one(fusion.plating.process.node) | core | Rework: start at this recipe node |
|
||||||
| `margin` | Monetary | Computed |
|
| `current_location` | Char | core | Computed: "Queued: Bath 3" / "In progress: Oven A" / "Ready to ship" |
|
||||||
| `margin_pct` | Float | Computed |
|
| `mail.thread, mail.activity.mixin` | Inherits | core | Chatter |
|
||||||
| `start_at_node_id` | Many2one(fusion.plating.process.node) | Rework: start at this recipe node |
|
| `part_catalog_id` | Many2one(fp.part.catalog) | **`fusion_plating_configurator`** (`_inherit = 'fp.job'`) | The actual part being plated; primary identifier |
|
||||||
| `override_ids` | One2many(fp.job.node.override, job_id) | Per-job opt-in/out |
|
| `coating_config_id` | Many2one(fp.coating.config) | **`fusion_plating_configurator`** | The coating spec |
|
||||||
| `current_location` | Char | Computed: "Queued: Bath 3" / "In progress: Oven A" / "Ready to ship" |
|
| `customer_spec_id` | Many2one(fusion.plating.customer.spec) | **`fusion_plating_quality`** | Optional spec |
|
||||||
| `mail.thread, mail.activity.mixin` | Inherits | Chatter |
|
| `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:**
|
**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 |
|
| `facility_id` | Many2one(fp.facility) | Which facility |
|
||||||
| `kind` | Selection | `wet_line`, `bake`, `mask`, `rack`, `inspect`, `other` |
|
| `kind` | Selection | `wet_line`, `bake`, `mask`, `rack`, `inspect`, `other` |
|
||||||
| `cost_per_hour` | Monetary | For margin calculations |
|
| `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 | |
|
| `active` | Boolean | |
|
||||||
|
|
||||||
This replaces `x_fc_mrp_workcenter_id` mapping that the recipe operations have today.
|
This replaces `x_fc_mrp_workcenter_id` mapping that the recipe operations have today.
|
||||||
|
|||||||
@@ -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.2–1.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.1–2.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
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.8.0.0',
|
'version': '19.0.8.7.1',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -82,6 +82,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'security/fp_security.xml',
|
'security/fp_security.xml',
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/fp_sequence_data.xml',
|
'data/fp_sequence_data.xml',
|
||||||
|
'data/fp_job_sequences.xml',
|
||||||
'data/fp_process_category_data.xml',
|
'data/fp_process_category_data.xml',
|
||||||
'views/fp_process_type_views.xml',
|
'views/fp_process_type_views.xml',
|
||||||
'views/fp_work_center_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/fp_operator_certification_views.xml',
|
||||||
'views/res_config_settings_views.xml',
|
'views/res_config_settings_views.xml',
|
||||||
'views/fp_menu.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_alum_basic.xml',
|
||||||
'data/fp_recipe_enp_steel_basic.xml',
|
'data/fp_recipe_enp_steel_basic.xml',
|
||||||
'data/fp_recipe_enp_sp.xml',
|
'data/fp_recipe_enp_sp.xml',
|
||||||
|
|||||||
17
fusion_plating/fusion_plating/data/fp_job_sequences.xml
Normal file
17
fusion_plating/fusion_plating/data/fp_job_sequences.xml
Normal 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>
|
||||||
@@ -7,6 +7,7 @@ from . import fp_process_category
|
|||||||
from . import fp_process_type
|
from . import fp_process_type
|
||||||
from . import fp_facility
|
from . import fp_facility
|
||||||
from . import fp_work_center
|
from . import fp_work_center
|
||||||
|
from . import fp_work_centre
|
||||||
from . import fp_tank
|
from . import fp_tank
|
||||||
from . import fp_bath
|
from . import fp_bath
|
||||||
from . import fp_bath_log
|
from . import fp_bath_log
|
||||||
@@ -15,6 +16,9 @@ from . import fp_bath_parameter
|
|||||||
from . import fp_bath_replenishment_rule
|
from . import fp_bath_replenishment_rule
|
||||||
from . import fp_process_node
|
from . import fp_process_node
|
||||||
from . import fp_rack
|
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_operator_certification
|
||||||
from . import fp_tz
|
from . import fp_tz
|
||||||
from . import res_company
|
from . import res_company
|
||||||
|
|||||||
264
fusion_plating/fusion_plating/models/fp_job.py
Normal file
264
fusion_plating/fusion_plating/models/fp_job.py
Normal 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
|
||||||
234
fusion_plating/fusion_plating/models/fp_job_step.py
Normal file
234
fusion_plating/fusion_plating/models/fp_job_step.py
Normal 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
|
||||||
42
fusion_plating/fusion_plating/models/fp_job_step_timelog.py
Normal file
42
fusion_plating/fusion_plating/models/fp_job_step_timelog.py
Normal 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
|
||||||
58
fusion_plating/fusion_plating/models/fp_work_centre.py
Normal file
58
fusion_plating/fusion_plating/models/fp_work_centre.py
Normal 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.'),
|
||||||
|
]
|
||||||
@@ -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_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_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_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
|
||||||
|
|||||||
|
4
fusion_plating/fusion_plating/tests/__init__.py
Normal file
4
fusion_plating/fusion_plating/tests/__init__.py
Normal 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
|
||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
31
fusion_plating/fusion_plating/tests/test_fp_work_centre.py
Normal file
31
fusion_plating/fusion_plating/tests/test_fp_work_centre.py
Normal 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)
|
||||||
135
fusion_plating/fusion_plating/views/fp_job_step_views.xml
Normal file
135
fusion_plating/fusion_plating/views/fp_job_step_views.xml
Normal 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>
|
||||||
124
fusion_plating/fusion_plating/views/fp_job_views.xml
Normal file
124
fusion_plating/fusion_plating/views/fp_job_views.xml
Normal 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>
|
||||||
16
fusion_plating/fusion_plating/views/fp_jobs_menu.xml
Normal file
16
fusion_plating/fusion_plating/views/fp_jobs_menu.xml
Normal 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>
|
||||||
50
fusion_plating/fusion_plating/views/fp_work_centre_views.xml
Normal file
50
fusion_plating/fusion_plating/views/fp_work_centre_views.xml
Normal 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>
|
||||||
@@ -81,6 +81,13 @@ class SaleOrder(models.Model):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def action_confirm(self):
|
def action_confirm(self):
|
||||||
res = super().action_confirm()
|
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:
|
for so in self:
|
||||||
try:
|
try:
|
||||||
so._fp_auto_create_mo()
|
so._fp_auto_create_mo()
|
||||||
|
|||||||
51
fusion_plating/fusion_plating_jobs/README.md
Normal file
51
fusion_plating/fusion_plating_jobs/README.md
Normal 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 1–5.
|
||||||
|
|
||||||
|
## 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.
|
||||||
4
fusion_plating/fusion_plating_jobs/__init__.py
Normal file
4
fusion_plating/fusion_plating_jobs/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
|
from . import report
|
||||||
|
from . import controllers
|
||||||
71
fusion_plating/fusion_plating_jobs/__manifest__.py
Normal file
71
fusion_plating/fusion_plating_jobs/__manifest__.py
Normal 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',
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
38
fusion_plating/fusion_plating_jobs/controllers/job_scan.py
Normal file
38
fusion_plating/fusion_plating_jobs/controllers/job_scan.py
Normal 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
|
||||||
|
)
|
||||||
29
fusion_plating/fusion_plating_jobs/models/__init__.py
Normal file
29
fusion_plating/fusion_plating_jobs/models/__init__.py
Normal 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
|
||||||
Binary file not shown.
47
fusion_plating/fusion_plating_jobs/models/account_move.py
Normal file
47
fusion_plating/fusion_plating_jobs/models/account_move.py
Normal 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,
|
||||||
|
)
|
||||||
26
fusion_plating/fusion_plating_jobs/models/fp_batch.py
Normal file
26
fusion_plating/fusion_plating_jobs/models/fp_batch.py
Normal 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',
|
||||||
|
)
|
||||||
19
fusion_plating/fusion_plating_jobs/models/fp_certificate.py
Normal file
19
fusion_plating/fusion_plating_jobs/models/fp_certificate.py
Normal 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.",
|
||||||
|
)
|
||||||
19
fusion_plating/fusion_plating_jobs/models/fp_delivery.py
Normal file
19
fusion_plating/fusion_plating_jobs/models/fp_delivery.py
Normal 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.',
|
||||||
|
)
|
||||||
768
fusion_plating/fusion_plating_jobs/models/fp_job.py
Normal file
768
fusion_plating/fusion_plating_jobs/models/fp_job.py
Normal 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.',
|
||||||
|
)
|
||||||
@@ -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.',
|
||||||
|
)
|
||||||
62
fusion_plating/fusion_plating_jobs/models/fp_job_step.py
Normal file
62
fusion_plating/fusion_plating_jobs/models/fp_job_step.py
Normal 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
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
)
|
||||||
21
fusion_plating/fusion_plating_jobs/models/fp_portal_job.py
Normal file
21
fusion_plating/fusion_plating_jobs/models/fp_portal_job.py
Normal 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).',
|
||||||
|
)
|
||||||
25
fusion_plating/fusion_plating_jobs/models/fp_quality_hold.py
Normal file
25
fusion_plating/fusion_plating_jobs/models/fp_quality_hold.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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.',
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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.',
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
)
|
||||||
123
fusion_plating/fusion_plating_jobs/models/sale_order.py
Normal file
123
fusion_plating/fusion_plating_jobs/models/sale_order.py
Normal 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
|
||||||
3
fusion_plating/fusion_plating_jobs/report/__init__.py
Normal file
3
fusion_plating/fusion_plating_jobs/report/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
51
fusion_plating/fusion_plating_jobs/scripts/README.md
Normal file
51
fusion_plating/fusion_plating_jobs/scripts/README.md
Normal 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.
|
||||||
8
fusion_plating/fusion_plating_jobs/scripts/__init__.py
Normal file
8
fusion_plating/fusion_plating_jobs/scripts/__init__.py
Normal 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.
|
||||||
@@ -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.'
|
||||||
|
)
|
||||||
@@ -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.')
|
||||||
292
fusion_plating/fusion_plating_jobs/scripts/cleanup_demo_data.py
Normal file
292
fusion_plating/fusion_plating_jobs/scripts/cleanup_demo_data.py
Normal 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`.')
|
||||||
487
fusion_plating/fusion_plating_jobs/scripts/migrate_to_fp_jobs.py
Normal file
487
fusion_plating/fusion_plating_jobs/scripts/migrate_to_fp_jobs.py
Normal 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.'
|
||||||
|
)
|
||||||
434
fusion_plating/fusion_plating_jobs/scripts/seed_demo_data.py
Normal file
434
fusion_plating/fusion_plating_jobs/scripts/seed_demo_data.py
Normal 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`.')
|
||||||
777
fusion_plating/fusion_plating_jobs/scripts/seed_direct_orders.py
Normal file
777
fusion_plating/fusion_plating_jobs/scripts/seed_direct_orders.py
Normal 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.")
|
||||||
@@ -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`.')
|
||||||
@@ -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`.')
|
||||||
@@ -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.")
|
||||||
@@ -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
|
||||||
|
@@ -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>
|
||||||
2
fusion_plating/fusion_plating_jobs/tests/__init__.py
Normal file
2
fusion_plating/fusion_plating_jobs/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import test_fp_job_extensions
|
||||||
@@ -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())
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Reports',
|
'name': 'Fusion Plating — Reports',
|
||||||
'version': '19.0.7.14.0',
|
'version': '19.0.7.17.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||||
'depends': [
|
'depends': [
|
||||||
|
|||||||
@@ -22,25 +22,42 @@ class FpWoScanController(http.Controller):
|
|||||||
def wo_scan_redirect(self, wo_id, **kwargs):
|
def wo_scan_redirect(self, wo_id, **kwargs):
|
||||||
"""Redirect a scanned sticker to the right backend form.
|
"""Redirect a scanned sticker to the right backend form.
|
||||||
|
|
||||||
Stickers are printed from two sources — mrp.workorder (WO) and
|
Resolution order:
|
||||||
mrp.production (MO) — and both embed their own numeric id in
|
1. fp.job mapped from this MO id via legacy_mrp_production_id
|
||||||
the QR. Try the MO table first (operators live on the MO
|
(post-migration: physical stickers still encode the old MO
|
||||||
form — customer, SO, all WOs visible) and fall back to WO.
|
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()
|
env = request.env
|
||||||
WO = request.env['mrp.workorder'].sudo()
|
|
||||||
|
|
||||||
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:
|
if mo:
|
||||||
return request.redirect(
|
return request.redirect(
|
||||||
'/odoo/action-mrp.mrp_production_action/%d' % mo.id
|
'/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:
|
if wo:
|
||||||
return request.redirect(
|
return request.redirect(
|
||||||
'/odoo/action-mrp.action_mrp_workorder/%d' % wo.id
|
'/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')
|
return request.redirect('/odoo/manufacturing/work-orders')
|
||||||
|
|||||||
@@ -369,6 +369,24 @@
|
|||||||
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
|
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
|
||||||
</record>
|
</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) -->
|
<!-- 15. Packing Slip (Portrait + Landscape) -->
|
||||||
<!-- ============================================================= -->
|
<!-- ============================================================= -->
|
||||||
|
|||||||
@@ -5,38 +5,70 @@
|
|||||||
|
|
||||||
Parts-box identification sticker — printed on a 4x3" label.
|
Parts-box identification sticker — printed on a 4x3" label.
|
||||||
|
|
||||||
Bound to BOTH mrp.production (MO) and mrp.workorder (WO) because
|
Bound to mrp.production (MO), mrp.workorder (WO), fp.job, and
|
||||||
the shop talks in "WO #" terms (Steelhead legacy) but the data
|
sale.order. The shop talks in "WO #" terms (Steelhead legacy) but
|
||||||
hangs off the MO record. The inner template normalises either
|
the data may hang off any of those records. The inner template
|
||||||
input to the same set of resolved variables:
|
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 #"
|
* _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
|
* _so, _line — the originating sale order / line
|
||||||
* _part — fp.part.catalog
|
* _part — fp.part.catalog
|
||||||
* _coating — fp.coating.config
|
* _coating — fp.coating.config
|
||||||
* _process — the resolved fusion.plating.process.node tree
|
* _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>
|
<odoo>
|
||||||
|
|
||||||
<!-- ========== Shared inner template ========== -->
|
<!-- ========== Shared inner template ========== -->
|
||||||
<template id="report_fp_wo_sticker_inner">
|
<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="_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="_scan_path" t-value="_scan_path or '/fp/wo/'"/>
|
||||||
<t t-set="_so" t-value="_mo and env['sale.order'].sudo().search(
|
<t t-set="_scan_url" t-value="_base_url + _scan_path + str(_scan_id)"/>
|
||||||
[('name', '=', _mo.origin)], limit=1) or False"/>
|
<!-- Each variable: prefer the outer-supplied value, otherwise
|
||||||
<t t-set="_line" t-value="(_mo and 'x_fc_sale_order_line_ids' in _mo._fields
|
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])
|
and _mo.x_fc_sale_order_line_ids[:1])
|
||||||
or (_so and _so.order_line[:1])
|
or (_so and _so.order_line[:1])
|
||||||
or False"/>
|
or False"/>
|
||||||
<t t-set="_part" t-value="_line and _line.x_fc_part_catalog_id or False"/>
|
<t t-set="_part" t-value="_part or (_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="_coating" t-value="_coating or (_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="_process" t-value="_process
|
||||||
|
or (_part and _part.default_process_id)
|
||||||
or (_coating and _coating.recipe_id)
|
or (_coating and _coating.recipe_id)
|
||||||
or False"/>
|
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 (_line and _line.x_fc_part_deadline)
|
||||||
or False"/>
|
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
|
<!-- Inline the QR as base64 data URI so wkhtmltopdf doesn't need
|
||||||
to fetch /report/barcode/ over the network during rendering. -->
|
to fetch /report/barcode/ over the network during rendering. -->
|
||||||
<t t-set="_qr_src" t-value="env['ir.actions.report'].barcode_data_uri(
|
<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-label">PO (RO):</td>
|
||||||
<td class="fp-sticker-value">
|
<td class="fp-sticker-value">
|
||||||
<span class="fp-sticker-strong"
|
<span class="fp-sticker-strong"
|
||||||
t-esc="(_so and _so.x_fc_po_number) or '-'"/>
|
t-esc="_po_number"/>
|
||||||
<t t-if="_mo">
|
<t t-if="_mo_ref">
|
||||||
<span class="fp-sticker-muted">
|
<span class="fp-sticker-muted">
|
||||||
(<span t-esc="_mo.name"/>)
|
(<span t-esc="_mo_ref"/>)
|
||||||
</span>
|
</span>
|
||||||
</t>
|
</t>
|
||||||
</td>
|
</td>
|
||||||
@@ -252,7 +284,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="fp-sticker-label">Customer:</td>
|
<td class="fp-sticker-label">Customer:</td>
|
||||||
<td class="fp-sticker-value">
|
<td class="fp-sticker-value">
|
||||||
<span t-esc="(_so and _so.partner_id.name) or '-'"/>
|
<span t-esc="_partner_name"/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -274,8 +306,17 @@
|
|||||||
<span class="fp-sticker-strong"
|
<span class="fp-sticker-strong"
|
||||||
t-esc="_part.part_number"/>
|
t-esc="_part.part_number"/>
|
||||||
<t t-if="_part.revision">
|
<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">
|
<span class="fp-sticker-muted">
|
||||||
Rev <span t-esc="_part.revision"/>
|
Rev <span t-esc="_rev_clean"/>
|
||||||
</span>
|
</span>
|
||||||
</t>
|
</t>
|
||||||
</t>
|
</t>
|
||||||
@@ -295,7 +336,6 @@
|
|||||||
<td class="fp-sticker-label">Qty:</td>
|
<td class="fp-sticker-label">Qty:</td>
|
||||||
<td class="fp-sticker-value">
|
<td class="fp-sticker-value">
|
||||||
<span class="fp-sticker-strong">
|
<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 t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -303,8 +343,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="fp-sticker-label">Notes:</td>
|
<td class="fp-sticker-label">Notes:</td>
|
||||||
<td class="fp-sticker-value">
|
<td class="fp-sticker-value">
|
||||||
<t t-esc="(_so and _so.x_fc_internal_note
|
<t t-esc="_internal_note"/>
|
||||||
and _so.x_fc_internal_note.striptags()[:100]) or '-'"/>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -312,10 +351,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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 ========== -->
|
<!-- ========== Outer template — mrp.workorder entry ========== -->
|
||||||
<template id="report_fp_wo_sticker">
|
<template id="report_fp_wo_sticker">
|
||||||
<t t-call="web.html_container">
|
<t t-call="web.html_container">
|
||||||
<t t-foreach="docs" t-as="doc">
|
<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="_order_id" t-value="doc.id"/>
|
||||||
<t t-set="_scan_id" t-value="doc.id"/>
|
<t t-set="_scan_id" t-value="doc.id"/>
|
||||||
<t t-set="_mo" t-value="doc.production_id"/>
|
<t t-set="_mo" t-value="doc.production_id"/>
|
||||||
@@ -328,6 +389,7 @@
|
|||||||
<template id="report_fp_mo_sticker">
|
<template id="report_fp_mo_sticker">
|
||||||
<t t-call="web.html_container">
|
<t t-call="web.html_container">
|
||||||
<t t-foreach="docs" t-as="doc">
|
<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
|
<!-- Shop floor talks in "WO #" regardless of Odoo's MO/WO
|
||||||
split. QR always encodes the numeric id so scans
|
split. QR always encodes the numeric id so scans
|
||||||
resolve cleanly via /fp/wo/<id>. -->
|
resolve cleanly via /fp/wo/<id>. -->
|
||||||
@@ -339,4 +401,39 @@
|
|||||||
</t>
|
</t>
|
||||||
</template>
|
</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>
|
</odoo>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.14.4.0',
|
'version': '19.0.24.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||||
'first-piece inspection gates.',
|
'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_bake_window_views.xml',
|
||||||
'views/fp_first_piece_gate_views.xml',
|
'views/fp_first_piece_gate_views.xml',
|
||||||
'views/fp_plant_overview_views.xml',
|
'views/fp_plant_overview_views.xml',
|
||||||
|
'views/tank_status_template.xml',
|
||||||
'views/fp_menu.xml',
|
'views/fp_menu.xml',
|
||||||
],
|
],
|
||||||
'demo': [
|
'demo': [
|
||||||
@@ -61,11 +62,24 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
# and variables directly (Odoo 19 forbids @import in custom SCSS,
|
# and variables directly (Odoo 19 forbids @import in custom SCSS,
|
||||||
# so tokens are resolved via bundle concatenation order).
|
# so tokens are resolved via bundle concatenation order).
|
||||||
'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss',
|
'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/fusion_plating_shopfloor.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/plant_overview.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/process_tree.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss',
|
'fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/fp_kanbans.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/shopfloor_tablet.xml',
|
||||||
'fusion_plating_shopfloor/static/src/xml/plant_overview.xml',
|
'fusion_plating_shopfloor/static/src/xml/plant_overview.xml',
|
||||||
'fusion_plating_shopfloor/static/src/xml/process_tree.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/process_tree.js',
|
||||||
'fusion_plating_shopfloor/static/src/js/manager_dashboard.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,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
|
|||||||
@@ -4,3 +4,4 @@
|
|||||||
|
|
||||||
from . import shopfloor_controller
|
from . import shopfloor_controller
|
||||||
from . import manager_controller
|
from . import manager_controller
|
||||||
|
from . import tank_status
|
||||||
|
|||||||
Binary file not shown.
@@ -2,7 +2,20 @@
|
|||||||
# Copyright 2026 Nexa Systems Inc.
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
"""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
|
import logging
|
||||||
|
|
||||||
@@ -15,26 +28,44 @@ from odoo.http import request
|
|||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class FpManagerDashboardController(http.Controller):
|
# --- helpers -----------------------------------------------------------------
|
||||||
"""Manager-level view: unassigned jobs, in-progress jobs, team workload.
|
|
||||||
|
|
||||||
All endpoints require the user to be a manager or above. The UI locks
|
_NEG_JOB_STATES = ('done', 'cancelled')
|
||||||
the menu behind group_fusion_plating_manager.
|
_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')
|
@http.route('/fp/manager/overview', type='jsonrpc', auth='user')
|
||||||
def overview(self, facility_id=None, known_hash=None):
|
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:
|
try:
|
||||||
return self._overview_payload(facility_id, known_hash)
|
return self._overview_payload(facility_id, known_hash)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
@@ -43,187 +74,122 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
|
|
||||||
def _overview_payload(self, facility_id, known_hash):
|
def _overview_payload(self, facility_id, known_hash):
|
||||||
env = request.env
|
env = request.env
|
||||||
MrpWO = env.get('mrp.workorder')
|
Job = env['fp.job']
|
||||||
Production = env.get('mrp.production')
|
Step = env['fp.job.step']
|
||||||
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
|
|
||||||
|
|
||||||
# ---- Column 1: Unassigned ("Setup Pending") --------------------
|
# Pull in-flight jobs (confirmed / in_progress / on_hold)
|
||||||
# A WO stays here until the manager has set EVERY field
|
domain = [('state', 'in', _ACTIVE_JOB_STATES)]
|
||||||
# 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)]
|
|
||||||
if facility_id:
|
if facility_id:
|
||||||
domain_active_states.append(
|
domain.append(('facility_id', '=', int(facility_id)))
|
||||||
('workcenter_id.x_fc_facility_id', '=', int(facility_id)))
|
jobs = Job.search(domain, order='priority desc, date_deadline asc, id desc')
|
||||||
all_active_wos = MrpWO.search(domain_active_states, order='sequence, id')
|
|
||||||
# Split: not-release-ready → Unassigned/Setup column; rest → In Progress
|
# Compute release-readiness per step in a single pass
|
||||||
if 'x_fc_is_release_ready' in MrpWO._fields:
|
all_steps = jobs.mapped('step_ids').filtered(
|
||||||
unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_is_release_ready)
|
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
|
||||||
elif has_assign:
|
)
|
||||||
unassigned_wos = all_active_wos.filtered(lambda w: not w.x_fc_assigned_user_id)
|
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:
|
else:
|
||||||
unassigned_wos = all_active_wos
|
active_jobs |= job
|
||||||
|
|
||||||
# Roll up to MO level
|
def _job_card(job, only_open=True):
|
||||||
def _group_by_mo(wos):
|
partner = job.partner_id
|
||||||
groups = {}
|
steps_iter = job.step_ids
|
||||||
for wo in wos:
|
if only_open:
|
||||||
mo_id = wo.production_id.id
|
steps_iter = steps_iter.filtered(
|
||||||
groups.setdefault(mo_id, []).append(wo)
|
lambda s: s.state in ('pending', 'ready', 'in_progress', 'paused'),
|
||||||
return groups
|
)
|
||||||
|
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 {
|
return {
|
||||||
'mo_id': mo.id,
|
'job_id': job.id,
|
||||||
'mo_name': mo.name,
|
'job_name': job.name or '',
|
||||||
'so_name': so_name,
|
'so_name': job.origin or '',
|
||||||
'customer': partner.name if partner else '',
|
'customer': partner.name if partner else '',
|
||||||
'product': mo.product_id.display_name if mo.product_id else '',
|
'product': job.product_id.display_name if job.product_id else '',
|
||||||
'qty_total': int(mo.product_qty or 0),
|
'qty_total': int(job.qty or 0),
|
||||||
'date_planned': fp_format(request.env, mo.date_start, fmt='%Y-%m-%d'),
|
'date_planned': fp_format(
|
||||||
'recipe': mo.x_fc_recipe_id.name if mo.x_fc_recipe_id else '',
|
request.env, job.date_planned_start or job.date_deadline,
|
||||||
'priority_any': max(
|
fmt='%Y-%m-%d',
|
||||||
[int(w.x_fc_priority or '0') for w in wos] + [0]
|
|
||||||
),
|
),
|
||||||
'current_location': mo.x_fc_current_location or '',
|
'recipe': job.recipe_id.name if job.recipe_id else '',
|
||||||
'wos': [
|
'priority_any': _priority_int(job.priority),
|
||||||
{
|
'current_location': job.current_location or '',
|
||||||
'id': w.id,
|
'steps': step_rows,
|
||||||
'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
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unassigned_cards = []
|
unassigned_cards = [_job_card(j) for j in unassigned_jobs]
|
||||||
for mo_id, wos in _group_by_mo(unassigned_wos).items():
|
active_cards = [_job_card(j) for j in active_jobs]
|
||||||
mo = Production.browse(mo_id)
|
|
||||||
unassigned_cards.append(_mo_card(mo, wos))
|
|
||||||
|
|
||||||
# ---- Column 2: In Progress -------------------------------------
|
# ---- Column 3: Team --------------------------------------------
|
||||||
# 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) -----------
|
|
||||||
operator_group = env.ref(
|
operator_group = env.ref(
|
||||||
'fusion_plating.group_fusion_plating_operator', raise_if_not_found=False,
|
'fusion_plating.group_fusion_plating_operator', raise_if_not_found=False,
|
||||||
)
|
)
|
||||||
team = []
|
team = []
|
||||||
if operator_group and has_assign:
|
if operator_group:
|
||||||
for user in operator_group.user_ids.sorted('name'):
|
for user in operator_group.user_ids.sorted('name'):
|
||||||
open_wos = MrpWO.search([
|
open_steps = Step.search([
|
||||||
('x_fc_assigned_user_id', '=', user.id),
|
('assigned_user_id', '=', user.id),
|
||||||
('state', 'not in', ACTIVE_NEG_STATES),
|
('state', 'in', ('ready', 'in_progress', 'paused')),
|
||||||
])
|
])
|
||||||
team.append({
|
team.append({
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
'name': user.name,
|
'name': user.name,
|
||||||
'open_count': len(open_wos),
|
'open_count': len(open_steps),
|
||||||
'in_progress_count': len(
|
'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',
|
'avatar_url': f'/web/image/res.users/{user.id}/avatar_128',
|
||||||
})
|
})
|
||||||
|
|
||||||
# ---- Pickers: operators (with presence + role data) -----------
|
# ---- Operators picker (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.
|
|
||||||
clocked_in_user_ids = (
|
clocked_in_user_ids = (
|
||||||
env['hr.employee']._fp_clocked_in_user_ids()
|
env['hr.employee']._fp_clocked_in_user_ids()
|
||||||
if 'hr.employee' in env and hasattr(
|
if 'hr.employee' in env and hasattr(
|
||||||
@@ -237,7 +203,11 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
operators = []
|
operators = []
|
||||||
for u in operator_users:
|
for u in operator_users:
|
||||||
emp = u.employee_id
|
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 = (
|
lead_role_ids = (
|
||||||
emp.x_fc_lead_hand_role_ids.ids
|
emp.x_fc_lead_hand_role_ids.ids
|
||||||
if emp and 'x_fc_lead_hand_role_ids' in emp._fields
|
if emp and 'x_fc_lead_hand_role_ids' in emp._fields
|
||||||
@@ -250,12 +220,12 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
'role_ids': role_ids,
|
'role_ids': role_ids,
|
||||||
'lead_hand_role_ids': lead_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'])
|
present_count = sum(1 for o in operators if o['is_clocked_in'])
|
||||||
presence = {
|
presence = {
|
||||||
'clocked_in': present_count,
|
'clocked_in': present_count,
|
||||||
'total': len(operators),
|
'total': len(operators),
|
||||||
}
|
}
|
||||||
|
|
||||||
Tank = env.get('fusion.plating.tank')
|
Tank = env.get('fusion.plating.tank')
|
||||||
tanks = [
|
tanks = [
|
||||||
{
|
{
|
||||||
@@ -267,36 +237,32 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
for t in (Tank.search([]) if Tank is not None else [])
|
for t in (Tank.search([]) if Tank is not None else [])
|
||||||
]
|
]
|
||||||
|
|
||||||
# KPI summary — every query must use STORED fields only, otherwise
|
# ---- KPI summary ----------------------------------------------
|
||||||
# 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.
|
|
||||||
SO = env['sale.order']
|
SO = env['sale.order']
|
||||||
so_fields = SO._fields
|
so_fields = SO._fields
|
||||||
if ('x_fc_receiving_status' in so_fields
|
if ('x_fc_receiving_status' in so_fields
|
||||||
and 'x_fc_assigned_manager_id' in so_fields):
|
and 'x_fc_assigned_manager_id' in so_fields):
|
||||||
pending_accept_domain = [
|
pending_accept_sos = SO.search_count([
|
||||||
('state', '=', 'sale'),
|
('state', '=', 'sale'),
|
||||||
('x_fc_receiving_status', '=', 'inspected'),
|
('x_fc_receiving_status', '=', 'inspected'),
|
||||||
('x_fc_assigned_manager_id', '=', False),
|
('x_fc_assigned_manager_id', '=', False),
|
||||||
]
|
])
|
||||||
pending_accept_sos = SO.search_count(pending_accept_domain)
|
|
||||||
else:
|
else:
|
||||||
pending_accept_sos = 0
|
pending_accept_sos = 0
|
||||||
|
|
||||||
# KPI counts derived from the in-memory split we already have —
|
# Ready-to-ship: jobs that are done but the portal job hasn't
|
||||||
# don't re-query (the release-ready filter is a Python compute,
|
# been marked ready_to_ship yet (or no portal mirror at all).
|
||||||
# not a stored column, so SQL search_count can't see it).
|
ready_to_ship_jobs = Job.search_count([('state', '=', 'done')])
|
||||||
|
|
||||||
kpis = {
|
kpis = {
|
||||||
'unassigned_wos': len(unassigned_wos),
|
'unassigned_steps': len(all_steps.filtered(
|
||||||
'active_wos': len(active_wos),
|
lambda s: not readiness_by_step.get(s.id, (False, ''))[0],
|
||||||
'ready_to_ship_mos': Production.search_count([
|
)),
|
||||||
('state', '=', 'done'),
|
'active_steps': len(all_steps.filtered(
|
||||||
]) if 'x_fc_portal_job_id' not in Production._fields
|
lambda s: readiness_by_step.get(s.id, (False, ''))[0]
|
||||||
else Production.search_count([
|
and s.state in ('ready', 'in_progress'),
|
||||||
('state', '=', 'done'),
|
)),
|
||||||
('x_fc_portal_job_id.state', '=', 'ready_to_ship'),
|
'ready_to_ship_jobs': ready_to_ship_jobs,
|
||||||
]),
|
|
||||||
'pending_accept_sos': pending_accept_sos,
|
'pending_accept_sos': pending_accept_sos,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,45 +291,85 @@ class FpManagerDashboardController(http.Controller):
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Assign a worker to a WO
|
# Assign a worker to a step
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
|
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
|
||||||
def assign_worker(self, workorder_id, user_id):
|
def assign_worker(self, step_id=None, user_id=None, workorder_id=None, **kwargs):
|
||||||
wo = request.env['mrp.workorder'].browse(int(workorder_id))
|
"""Assign an operator to a step. ``step_id`` is the canonical
|
||||||
if not wo.exists():
|
kwarg; ``workorder_id`` is accepted as a deprecated alias for
|
||||||
return {'ok': False, 'error': 'Work order not found.'}
|
one release so any caller we missed doesn't break.
|
||||||
wo.x_fc_assigned_user_id = int(user_id) if user_id else False
|
"""
|
||||||
wo.message_post(
|
if step_id is None and workorder_id is not None:
|
||||||
body=Markup('Worker assigned: <b>%s</b>') % (wo.x_fc_assigned_user_id.name or 'Unassigned'),
|
_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')
|
@http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user')
|
||||||
def assign_tank(self, workorder_id, tank_id):
|
def assign_tank(self, step_id=None, tank_id=None, workorder_id=None, **kwargs):
|
||||||
wo = request.env['mrp.workorder'].browse(int(workorder_id))
|
"""Swap the tank on a step. ``step_id`` is the canonical kwarg;
|
||||||
if not wo.exists():
|
``workorder_id`` is accepted as a deprecated alias.
|
||||||
return {'ok': False, 'error': 'Work order not found.'}
|
"""
|
||||||
wo.x_fc_tank_id = int(tank_id) if tank_id else False
|
if step_id is None and workorder_id is not None:
|
||||||
wo.message_post(
|
_logger.warning(
|
||||||
body=Markup('Tank assigned: <b>%s</b>') % (wo.x_fc_tank_id.name or 'Unassigned'),
|
"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')
|
@http.route('/fp/manager/take_over', type='jsonrpc', auth='user')
|
||||||
def take_over(self, workorder_id):
|
def take_over(self, step_id=None, workorder_id=None, **kwargs):
|
||||||
wo = request.env['mrp.workorder'].browse(int(workorder_id))
|
"""Manager takes over a step. ``step_id`` is the canonical kwarg;
|
||||||
if not wo.exists():
|
``workorder_id`` is accepted as a deprecated alias.
|
||||||
return {'ok': False, 'error': 'Work order not found.'}
|
"""
|
||||||
|
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
|
user = request.env.user
|
||||||
previous = wo.x_fc_assigned_user_id.name or '—'
|
previous = step.assigned_user_id.name or '—'
|
||||||
wo.x_fc_assigned_user_id = user.id
|
step.assigned_user_id = user.id
|
||||||
wo.message_post(
|
step.message_post(
|
||||||
body=Markup('Manager takeover: <b>%s</b> replaces %s.') % (user.name, previous),
|
body=Markup('Manager takeover: <b>%s</b> replaces %s.') % (
|
||||||
|
user.name, previous,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return {'ok': True, 'user_name': user.name}
|
return {'ok': True, 'user_name': user.name}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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`)
|
||||||
10100
fusion_plating/fusion_plating_shopfloor/static/lib/jsQR/jsQR.js
Normal file
10100
fusion_plating/fusion_plating_shopfloor/static/lib/jsQR/jsQR.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
1
fusion_plating/fusion_plating_shopfloor/static/lib/zxing/zxing.min.js
vendored
Normal file
1
fusion_plating/fusion_plating_shopfloor/static/lib/zxing/zxing.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1,21 +1,27 @@
|
|||||||
/** @odoo-module **/
|
/** @odoo-module **/
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Fusion Plating — Manager Dashboard (OWL client action)
|
// Fusion Plating — Manager Desk (OWL client action)
|
||||||
// Copyright 2026 Nexa Systems Inc.
|
// Copyright 2026 Nexa Systems Inc.
|
||||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
//
|
//
|
||||||
// Manager-level view: assign workers, swap tanks, cover no-shows, drill
|
// 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 { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
|
||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { QrScanner } from "./qr_scanner";
|
||||||
|
|
||||||
export class ManagerDashboard extends Component {
|
export class ManagerDashboard extends Component {
|
||||||
static template = "fusion_plating_shopfloor.ManagerDashboard";
|
static template = "fusion_plating_shopfloor.ManagerDashboard";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
|
static components = { QrScanner };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
@@ -25,7 +31,7 @@ export class ManagerDashboard extends Component {
|
|||||||
overview: null,
|
overview: null,
|
||||||
loadError: "", // visible error instead of stuck spinner
|
loadError: "", // visible error instead of stuck spinner
|
||||||
mode: "quick", // quick | detailed
|
mode: "quick", // quick | detailed
|
||||||
expandedMoId: null,
|
expandedJobId: null,
|
||||||
message: "",
|
message: "",
|
||||||
messageType: "info",
|
messageType: "info",
|
||||||
isFetching: false, // pulses the "updating" dot in the header
|
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";
|
this.state.mode = this.state.mode === "quick" ? "detailed" : "quick";
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleCard(moId) {
|
toggleCard(jobId) {
|
||||||
this.state.expandedMoId = this.state.expandedMoId === moId ? null : moId;
|
this.state.expandedJobId = this.state.expandedJobId === jobId ? null : jobId;
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleOffShift() {
|
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:
|
* Buckets, top-down, each kept in original (alphabetical) order:
|
||||||
* 1. Qualified for this role AND clocked in — primary picks
|
* 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
|
* Each option carries a `bucket` so the template can render a tiny
|
||||||
* green/grey dot and (for buckets 3-4) a soft helper label.
|
* 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 all = (this.state.overview && this.state.overview.operators) || [];
|
||||||
const roleId = wo && wo.role_id;
|
const roleId = step && step.role_id;
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const op of all) {
|
for (const op of all) {
|
||||||
const qualified = roleId && op.role_ids && op.role_ids.includes(roleId);
|
const qualified = roleId && op.role_ids && op.role_ids.includes(roleId);
|
||||||
@@ -180,15 +186,15 @@ export class ManagerDashboard extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------- Actions
|
// ---------------------------------------------------------- Actions
|
||||||
async onAssignWorker(wo, userIdRaw) {
|
async onAssignWorker(step, userIdRaw) {
|
||||||
const userId = parseInt(userIdRaw) || null;
|
const userId = parseInt(userIdRaw) || null;
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/manager/assign_worker", {
|
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) {
|
if (res && res.ok) {
|
||||||
this.setMessage(
|
this.setMessage(
|
||||||
`Assigned ${res.user_name || 'unassigned'} to ${wo.name}`,
|
`Assigned ${res.user_name || 'unassigned'} to ${step.name}`,
|
||||||
"success",
|
"success",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -198,15 +204,15 @@ export class ManagerDashboard extends Component {
|
|||||||
await this.refresh();
|
await this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
async onAssignTank(wo, tankIdRaw) {
|
async onAssignTank(step, tankIdRaw) {
|
||||||
const tankId = parseInt(tankIdRaw) || null;
|
const tankId = parseInt(tankIdRaw) || null;
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/manager/assign_tank", {
|
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) {
|
if (res && res.ok) {
|
||||||
this.setMessage(
|
this.setMessage(
|
||||||
`Tank ${res.tank_name || 'cleared'} for ${wo.name}`,
|
`Tank ${res.tank_name || 'cleared'} for ${step.name}`,
|
||||||
"success",
|
"success",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -216,13 +222,13 @@ export class ManagerDashboard extends Component {
|
|||||||
await this.refresh();
|
await this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
async onTakeOver(wo) {
|
async onTakeOver(step) {
|
||||||
try {
|
try {
|
||||||
const res = await rpc("/fp/manager/take_over", {
|
const res = await rpc("/fp/manager/take_over", {
|
||||||
workorder_id: wo.id,
|
step_id: step.id,
|
||||||
});
|
});
|
||||||
if (res && res.ok) {
|
if (res && res.ok) {
|
||||||
this.setMessage(`You now own ${wo.name}.`, "success");
|
this.setMessage(`You now own ${step.name}.`, "success");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setMessage(`Takeover failed: ${err.message || err}`, "danger");
|
this.setMessage(`Takeover failed: ${err.message || err}`, "danger");
|
||||||
@@ -244,11 +250,11 @@ export class ManagerDashboard extends Component {
|
|||||||
this.action.doAction({
|
this.action.doAction({
|
||||||
type: "ir.actions.act_window",
|
type: "ir.actions.act_window",
|
||||||
name: "Operator Queue",
|
name: "Operator Queue",
|
||||||
res_model: "mrp.workorder",
|
res_model: "fp.job.step",
|
||||||
views: [[false, "list"], [false, "form"]],
|
views: [[false, "list"], [false, "form"]],
|
||||||
domain: [
|
domain: [
|
||||||
["x_fc_assigned_user_id", "=", userId],
|
["assigned_user_id", "=", userId],
|
||||||
["state", "in", ["ready", "progress", "waiting"]],
|
["state", "in", ["ready", "in_progress", "paused"]],
|
||||||
],
|
],
|
||||||
target: "current",
|
target: "current",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,8 +4,13 @@
|
|||||||
// Copyright 2026 Nexa Systems Inc.
|
// Copyright 2026 Nexa Systems Inc.
|
||||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
// License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
//
|
//
|
||||||
// Steelhead-style multi-column kanban showing all active work orders grouped
|
// Multi-column kanban showing all active fp.job.step rows grouped by
|
||||||
// by work centre / station. Auto-refreshes every 30 s.
|
// 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:
|
// Odoo 19 conventions:
|
||||||
// * Backend OWL component: `static template` + `static props = ["*"]`
|
// * 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 { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { QrScanner } from "./qr_scanner";
|
||||||
|
|
||||||
export class PlantOverview extends Component {
|
export class PlantOverview extends Component {
|
||||||
static template = "fusion_plating_shopfloor.PlantOverview";
|
static template = "fusion_plating_shopfloor.PlantOverview";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
|
static components = { QrScanner };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
@@ -133,7 +140,7 @@ export class PlantOverview extends Component {
|
|||||||
onCardDragStart(card, col, ev) {
|
onCardDragStart(card, col, ev) {
|
||||||
this._draggedCard = {
|
this._draggedCard = {
|
||||||
id: card.id,
|
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,
|
source_wc_id: col.work_center_id,
|
||||||
el: ev.target,
|
el: ev.target,
|
||||||
};
|
};
|
||||||
@@ -251,9 +258,10 @@ export class PlantOverview extends Component {
|
|||||||
if (!card.id) {
|
if (!card.id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Try opening the work order form if MRP is available, otherwise
|
// Cards are fp.job.step rows. The model is overridable per-card
|
||||||
// fall back to bake window or first-piece gate
|
// so we keep working if a future card type joins the kanban
|
||||||
const model = card.source_model || "mrp.workorder";
|
// (e.g. a quality hold drop-zone column).
|
||||||
|
const model = card.source_model || "fp.job.step";
|
||||||
this.action.doAction({
|
this.action.doAction({
|
||||||
type: "ir.actions.act_window",
|
type: "ir.actions.act_window",
|
||||||
res_model: model,
|
res_model: model,
|
||||||
@@ -281,14 +289,21 @@ export class PlantOverview extends Component {
|
|||||||
|
|
||||||
getStateClass(state) {
|
getStateClass(state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "progress":
|
// Native fp.job.step states
|
||||||
|
case "in_progress":
|
||||||
return "o_fp_card_progress";
|
return "o_fp_card_progress";
|
||||||
case "ready":
|
case "ready":
|
||||||
return "o_fp_card_ready";
|
return "o_fp_card_ready";
|
||||||
|
case "paused":
|
||||||
|
return "o_fp_card_pending";
|
||||||
case "done":
|
case "done":
|
||||||
return "o_fp_card_done";
|
return "o_fp_card_done";
|
||||||
case "pending":
|
case "pending":
|
||||||
return "o_fp_card_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:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,33 @@
|
|||||||
// Fusion Plating — Process Tree (horizontal hierarchical view)
|
// Fusion Plating — Process Tree (horizontal hierarchical view)
|
||||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
// 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
|
// horizontal bracket tree. Cards render dark, identical card style across
|
||||||
// all depths; connector lines are drawn from CSS so the layout stays in
|
// all depths; connector lines are drawn from CSS so the layout stays in
|
||||||
// pure flexbox.
|
// 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:
|
// Action context:
|
||||||
// production_id — required; the MO whose recipe to render
|
// job_id — required; the fp.job whose recipe to render
|
||||||
// back_workorder_id — optional; if set, the back button returns to
|
// production_id — legacy alias for job_id (still accepted)
|
||||||
// that WO instead of Plant Overview
|
// 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 { Component, useState, onMounted } from "@odoo/owl";
|
||||||
import { registry } from "@web/core/registry";
|
import { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { QrScanner } from "./qr_scanner";
|
||||||
|
|
||||||
export class ProcessTree extends Component {
|
export class ProcessTree extends Component {
|
||||||
static template = "fusion_plating_shopfloor.ProcessTree";
|
static template = "fusion_plating_shopfloor.ProcessTree";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
|
static components = { QrScanner };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
@@ -50,19 +58,25 @@ export class ProcessTree extends Component {
|
|||||||
const a = this.props.action || {};
|
const a = this.props.action || {};
|
||||||
return { ...(a.context || {}), ...(a.params || {}) };
|
return { ...(a.context || {}), ...(a.params || {}) };
|
||||||
}
|
}
|
||||||
get productionId() { return this._ctx.production_id || null; }
|
get jobId() {
|
||||||
get backWorkorderId() { return this._ctx.back_workorder_id || null; }
|
// 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() {
|
get backLabel() {
|
||||||
return this.backWorkorderId ? "Back to Work Order" : "Plant Overview";
|
return this.backStepId ? "Back to Step" : "Plant Overview";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Data ---------------------------------------------------------------
|
// ---- Data ---------------------------------------------------------------
|
||||||
|
|
||||||
async loadTree() {
|
async loadTree() {
|
||||||
const prodId = this.productionId;
|
const jobId = this.jobId;
|
||||||
if (!prodId) {
|
if (!jobId) {
|
||||||
this.notification.add(
|
this.notification.add(
|
||||||
"No manufacturing order specified for the process tree.",
|
"No job specified for the process tree.",
|
||||||
{ type: "warning" },
|
{ type: "warning" },
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -70,7 +84,7 @@ export class ProcessTree extends Component {
|
|||||||
this.state.loading = true;
|
this.state.loading = true;
|
||||||
try {
|
try {
|
||||||
const r = await rpc("/fp/shopfloor/process_tree", {
|
const r = await rpc("/fp/shopfloor/process_tree", {
|
||||||
production_id: prodId,
|
job_id: jobId,
|
||||||
});
|
});
|
||||||
if (r) {
|
if (r) {
|
||||||
this.state.productionName = r.production_name || "";
|
this.state.productionName = r.production_name || "";
|
||||||
@@ -95,25 +109,29 @@ export class ProcessTree extends Component {
|
|||||||
// ---- Navigation ---------------------------------------------------------
|
// ---- Navigation ---------------------------------------------------------
|
||||||
|
|
||||||
onNodeClick(node) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
this.action.doAction({
|
this.action.doAction({
|
||||||
type: "ir.actions.act_window",
|
type: "ir.actions.act_window",
|
||||||
res_model: "mrp.workorder",
|
res_model: "fp.job.step",
|
||||||
res_id: node.workorder_id,
|
res_id: stepId,
|
||||||
views: [[false, "form"]],
|
views: [[false, "form"]],
|
||||||
target: "current",
|
target: "current",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onBack() {
|
onBack() {
|
||||||
const woId = this.backWorkorderId;
|
const stepId = this.backStepId;
|
||||||
if (woId) {
|
if (stepId) {
|
||||||
this.action.doAction({
|
this.action.doAction({
|
||||||
type: "ir.actions.act_window",
|
type: "ir.actions.act_window",
|
||||||
res_model: "mrp.workorder",
|
res_model: "fp.job.step",
|
||||||
res_id: parseInt(woId, 10),
|
res_id: parseInt(stepId, 10),
|
||||||
views: [[false, "form"]],
|
views: [[false, "form"]],
|
||||||
target: "current",
|
target: "current",
|
||||||
});
|
});
|
||||||
@@ -131,7 +149,9 @@ export class ProcessTree extends Component {
|
|||||||
if (node.state) {
|
if (node.state) {
|
||||||
parts.push(`o_fp_pt_state_${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");
|
parts.push("o_fp_pt_clickable");
|
||||||
}
|
}
|
||||||
if (this.isHighlight(node)) {
|
if (this.isHighlight(node)) {
|
||||||
@@ -140,9 +160,13 @@ export class ProcessTree extends Component {
|
|||||||
return parts.join(" ");
|
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) {
|
isHighlight(node) {
|
||||||
return node.state === "ready"
|
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 === "progress"
|
||||||
|| node.state === "waiting";
|
|| node.state === "waiting";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
// Copyright 2026 Nexa Systems Inc.
|
// Copyright 2026 Nexa Systems Inc.
|
||||||
// License OPL-1 (Odoo Proprietary License v1.0)
|
// 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:
|
// Odoo 19 conventions:
|
||||||
// * Backend OWL component using `static template` + `static props = ["*"]`.
|
// * Backend OWL component using `static template` + `static props = ["*"]`.
|
||||||
// * RPC via standalone `rpc()` from @web/core/network/rpc.
|
// * 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 { registry } from "@web/core/registry";
|
||||||
import { rpc } from "@web/core/network/rpc";
|
import { rpc } from "@web/core/network/rpc";
|
||||||
import { useService } from "@web/core/utils/hooks";
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { QrScanner } from "./qr_scanner";
|
||||||
|
|
||||||
export class ShopfloorTablet extends Component {
|
export class ShopfloorTablet extends Component {
|
||||||
static template = "fusion_plating_shopfloor.ShopfloorTablet";
|
static template = "fusion_plating_shopfloor.ShopfloorTablet";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
|
static components = { QrScanner };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
|
|||||||
@@ -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).
|
// Action colour — Odoo's primary. Same in both bundles (brand purple).
|
||||||
$fp-accent : var(--o-action, #714B67);
|
$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 --------------------------------
|
// ---------- Elevation — explicit rgba shadows --------------------------------
|
||||||
// Explicit rgba values (not color-mix) so they render identically across
|
// Explicit rgba values (not color-mix) so they render identically across
|
||||||
// browsers and themes. In dark mode the shadows still work against the
|
// browsers and themes. In dark mode the shadows still work against the
|
||||||
|
|||||||
@@ -157,14 +157,17 @@
|
|||||||
&:active { transform: scale(0.97); }
|
&:active { transform: scale(0.97); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary — filled with the accent, white text. Force specificity
|
// Primary — filled with the accent (brand purple), white text. White
|
||||||
// high enough to beat Bootstrap's .btn-primary which loads later.
|
// 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,
|
||||||
.btn.btn-primary:focus,
|
.btn.btn-primary:focus,
|
||||||
.btn.btn-primary:active {
|
.btn.btn-primary:active {
|
||||||
background-color: $fp-accent !important;
|
background-color: $fp-accent !important;
|
||||||
border-color: $fp-accent !important;
|
border-color: $fp-accent !important;
|
||||||
color: #ffffff !important;
|
color: #ffffff !important; // intentional: filled accent button
|
||||||
|
|
||||||
@include fp-hover-only {
|
@include fp-hover-only {
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -430,17 +433,19 @@
|
|||||||
.o_fp_mgr_card_body {
|
.o_fp_mgr_card_body {
|
||||||
padding: $fp-space-3 $fp-space-4 $fp-space-4;
|
padding: $fp-space-3 $fp-space-4 $fp-space-4;
|
||||||
display: flex; flex-direction: column; gap: $fp-space-2;
|
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
|
// Flex with wrap so narrow viewports drop actions below the info naturally
|
||||||
// instead of squishing everything into a single broken grid line.
|
// instead of squishing everything into a single broken grid line.
|
||||||
.o_fp_mgr_wo_row {
|
.o_fp_mgr_step_row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: $fp-space-3;
|
gap: $fp-space-3;
|
||||||
@@ -453,7 +458,7 @@
|
|||||||
font-size: $fp-text-sm;
|
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
|
flex: 1 1 280px; // grows but never narrower than 280px
|
||||||
min-width: 0; // allows children to shrink properly
|
min-width: 0; // allows children to shrink properly
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -461,8 +466,8 @@
|
|||||||
gap: $fp-space-1;
|
gap: $fp-space-1;
|
||||||
color: $fp-ink;
|
color: $fp-ink;
|
||||||
|
|
||||||
// Title row — kind badge + WO name + step number
|
// Title row — kind badge + step name + sequence
|
||||||
.o_fp_mgr_wo_title {
|
.o_fp_mgr_step_title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $fp-space-2;
|
gap: $fp-space-2;
|
||||||
@@ -472,7 +477,7 @@
|
|||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
// Meta row — workcenter / role / set equipment
|
// Meta row — workcenter / role / set equipment
|
||||||
.o_fp_mgr_wo_meta {
|
.o_fp_mgr_step_meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $fp-space-2;
|
gap: $fp-space-2;
|
||||||
@@ -482,7 +487,7 @@
|
|||||||
i { margin-right: 2px; }
|
i { margin-right: 2px; }
|
||||||
}
|
}
|
||||||
// Chip row — what's still missing for the manager to set
|
// Chip row — what's still missing for the manager to set
|
||||||
.o_fp_mgr_wo_needs {
|
.o_fp_mgr_step_needs {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,7 +496,7 @@
|
|||||||
// takes the remaining horizontal space (the dropdown then grows to
|
// takes the remaining horizontal space (the dropdown then grows to
|
||||||
// fill); flex-wrap so on narrow widths the dropdown sits on its own
|
// fill); flex-wrap so on narrow widths the dropdown sits on its own
|
||||||
// line and the buttons go below at 50/50.
|
// line and the buttons go below at 50/50.
|
||||||
.o_fp_mgr_wo_actions {
|
.o_fp_mgr_step_actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -526,7 +531,7 @@
|
|||||||
&:focus { @include fp-focus-ring; border-color: $fp-accent; }
|
&:focus { @include fp-focus-ring; border-color: $fp-accent; }
|
||||||
}
|
}
|
||||||
.o_fp_mgr_btn,
|
.o_fp_mgr_btn,
|
||||||
.o_fp_mgr_wo_row .btn {
|
.o_fp_mgr_step_row .btn {
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
padding: 0 $fp-space-3;
|
padding: 0 $fp-space-3;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -544,13 +549,13 @@
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
// Mobile / narrow tablet: dropdown takes full width on its own
|
// Mobile / narrow tablet: dropdown takes full width on its own
|
||||||
// line; the two buttons split 50/50 underneath.
|
// line; the two buttons split 50/50 underneath.
|
||||||
.o_fp_mgr_wo_actions {
|
.o_fp_mgr_step_actions {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
}
|
}
|
||||||
.o_fp_mgr_picker { flex: 1 1 100%; }
|
.o_fp_mgr_picker { flex: 1 1 100%; }
|
||||||
.o_fp_mgr_btn,
|
.o_fp_mgr_btn,
|
||||||
.o_fp_mgr_wo_row .btn {
|
.o_fp_mgr_step_row .btn {
|
||||||
flex: 1 1 calc(50% - #{$fp-space-2});
|
flex: 1 1 calc(50% - #{$fp-space-2});
|
||||||
min-height: $fp-touch-min;
|
min-height: $fp-touch-min;
|
||||||
}
|
}
|
||||||
@@ -575,18 +580,21 @@
|
|||||||
&.o_fp_chip_danger { @include fp-pill(--bs-danger); }
|
&.o_fp_chip_danger { @include fp-pill(--bs-danger); }
|
||||||
&.o_fp_chip_muted { background-color: $fp-card-soft; color: $fp-ink-mute; }
|
&.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.
|
// mask vs wet vs bake at a glance.
|
||||||
&.o_fp_chip_kind {
|
&.o_fp_chip_kind {
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
font-weight: $fp-weight-bold;
|
font-weight: $fp-weight-bold;
|
||||||
}
|
}
|
||||||
&.o_fp_chip_kind_wet { background-color: rgba(13, 110, 253, .15); color: #0d6efd; }
|
// Kind chip hues live in _fp_shopfloor_tokens.scss with both light
|
||||||
&.o_fp_chip_kind_bake { background-color: rgba(220, 53, 69, .15); color: #dc3545; }
|
// and dark variants. Background translucency is computed off the
|
||||||
&.o_fp_chip_kind_mask { background-color: rgba(255, 193, 7, .20); color: #997404; }
|
// hue so dark mode lifts the text without losing the colour code.
|
||||||
&.o_fp_chip_kind_rack { background-color: rgba(108, 117, 125, .15); color: #495057; }
|
&.o_fp_chip_kind_wet { background-color: color-mix(in srgb, #{$fp-kind-wet} 15%, transparent); color: $fp-kind-wet; }
|
||||||
&.o_fp_chip_kind_inspect { background-color: rgba(25, 135, 84, .15); color: #198754; }
|
&.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; }
|
&.o_fp_chip_kind_other { background-color: $fp-card-soft; color: $fp-ink-mute; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ $pt-line-width : 2px;
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
.o_fp_pt_header_actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $fp-space-2;
|
||||||
|
}
|
||||||
.o_fp_pt_back {
|
.o_fp_pt_back {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<!--
|
<!--
|
||||||
Copyright 2026 Nexa Systems Inc. · License OPL-1
|
Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||||
Fusion Plating — Manager Desk
|
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">
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
t-att-disabled="state.isFetching">
|
t-att-disabled="state.isFetching">
|
||||||
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
|
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
|
||||||
</button>
|
</button>
|
||||||
|
<QrScanner cssClass="'btn'"/>
|
||||||
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
|
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
|
||||||
t-on-click="toggleMode">
|
t-on-click="toggleMode">
|
||||||
<t t-if="state.mode === 'quick'">Quick View</t>
|
<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_strip" t-if="state.overview">
|
||||||
<div class="o_fp_kpi o_fp_kpi_warning">
|
<div class="o_fp_kpi o_fp_kpi_warning">
|
||||||
<i class="fa fa-user-times"/>
|
<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_value"><t t-esc="state.overview.kpis.unassigned_steps"/></div>
|
||||||
<div class="o_fp_kpi_label">Unassigned WOs</div>
|
<div class="o_fp_kpi_label">Unassigned Steps</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_kpi o_fp_kpi_success">
|
<div class="o_fp_kpi o_fp_kpi_success">
|
||||||
<i class="fa fa-cogs"/>
|
<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 class="o_fp_kpi_label">In Progress</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_kpi o_fp_kpi_info">
|
<div class="o_fp_kpi o_fp_kpi_info">
|
||||||
<i class="fa fa-truck"/>
|
<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 class="o_fp_kpi_label">Ready to Ship</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_kpi o_fp_kpi_warning">
|
<div class="o_fp_kpi o_fp_kpi_warning">
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
<!-- ============ Workload grid ============ -->
|
<!-- ============ Workload grid ============ -->
|
||||||
<div class="o_fp_manager_grid" t-if="state.overview">
|
<div class="o_fp_manager_grid" t-if="state.overview">
|
||||||
|
|
||||||
<!-- Unassigned -->
|
<!-- Needs a Worker -->
|
||||||
<section class="o_fp_panel o_fp_panel_unassigned">
|
<section class="o_fp_panel o_fp_panel_unassigned">
|
||||||
<div class="o_fp_panel_head">
|
<div class="o_fp_panel_head">
|
||||||
<h3><i class="fa fa-inbox"/>Needs a Worker</h3>
|
<h3><i class="fa fa-inbox"/>Needs a Worker</h3>
|
||||||
@@ -102,17 +103,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div t-if="!state.overview.unassigned.length" class="o_fp_empty">
|
<div t-if="!state.overview.unassigned.length" class="o_fp_empty">
|
||||||
<i class="fa fa-check-circle text-success"/>
|
<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>
|
||||||
<div class="o_fp_mgr_card_list" t-if="state.overview.unassigned.length">
|
<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"
|
<div class="o_fp_mgr_card"
|
||||||
t-att-data-priority="card.priority_any">
|
t-att-data-priority="card.priority_any">
|
||||||
<div class="o_fp_mgr_card_head"
|
<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>
|
||||||
<div class="o_fp_mgr_card_title">
|
<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>
|
<span class="text-muted ms-2 small">· <t t-esc="card.so_name"/></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_mgr_card_sub">
|
<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 >= 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 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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_mgr_card_body"
|
<div class="o_fp_mgr_card_body"
|
||||||
t-if="state.expandedMoId === card.mo_id or state.mode === 'detailed'">
|
t-if="state.expandedJobId === card.job_id or state.mode === 'detailed'">
|
||||||
<t t-foreach="card.wos" t-as="wo" t-key="wo.id">
|
<t t-foreach="card.steps" t-as="step" t-key="step.id">
|
||||||
<div class="o_fp_mgr_wo_row">
|
<div class="o_fp_mgr_step_row">
|
||||||
<!-- LEFT: information stack (badge, name, meta, needs) -->
|
<!-- LEFT: information stack (badge, name, meta, needs) -->
|
||||||
<div class="o_fp_mgr_wo_info">
|
<div class="o_fp_mgr_step_info">
|
||||||
<div class="o_fp_mgr_wo_title">
|
<div class="o_fp_mgr_step_title">
|
||||||
<span t-attf-class="o_fp_chip o_fp_chip_kind o_fp_chip_kind_{{ wo.wo_kind }}"
|
<span t-attf-class="o_fp_chip o_fp_chip_kind o_fp_chip_kind_{{ step.kind }}"
|
||||||
t-esc="wo.wo_kind_label || wo.wo_kind"/>
|
t-esc="step.kind_label || step.kind"/>
|
||||||
<span t-esc="wo.name"/>
|
<span t-esc="step.name"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_mgr_wo_meta">
|
<div class="o_fp_mgr_step_meta">
|
||||||
<span><i class="fa fa-cog"/><t t-esc="wo.workcenter"/></span>
|
<span><i class="fa fa-cog"/><t t-esc="step.workcenter"/></span>
|
||||||
<span t-if="wo.role_name">· <i class="fa fa-id-badge"/><t t-esc="wo.role_name"/></span>
|
<span t-if="step.role_name">· <i class="fa fa-id-badge"/><t t-esc="step.role_name"/></span>
|
||||||
<span t-if="wo.bath">· <i class="fa fa-flask"/><t t-esc="wo.bath"/></span>
|
<span t-if="step.bath">· <i class="fa fa-flask"/><t t-esc="step.bath"/></span>
|
||||||
<span t-if="wo.oven">· <i class="fa fa-fire"/><t t-esc="wo.oven"/></span>
|
<span t-if="step.oven">· <i class="fa fa-fire"/><t t-esc="step.oven"/></span>
|
||||||
<span t-if="wo.rack">· <i class="fa fa-th"/><t t-esc="wo.rack"/></span>
|
<span t-if="step.rack">· <i class="fa fa-th"/><t t-esc="step.rack"/></span>
|
||||||
<span t-if="wo.masking_material">· <i class="fa fa-tag"/><t t-esc="wo.masking_material"/></span>
|
<span t-if="step.masking_material">· <i class="fa fa-tag"/><t t-esc="step.masking_material"/></span>
|
||||||
</div>
|
</div>
|
||||||
<div t-if="wo.missing_for_release"
|
<div t-if="step.missing_for_release"
|
||||||
class="o_fp_mgr_wo_needs">
|
class="o_fp_mgr_step_needs">
|
||||||
<span class="o_fp_chip o_fp_chip_warning">
|
<span class="o_fp_chip o_fp_chip_warning">
|
||||||
<i class="fa fa-exclamation-circle me-1"/>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT: action group (pickers + buttons) -->
|
<!-- 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"
|
<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>
|
<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"
|
<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-att-data-bucket="op.bucket">
|
||||||
<t t-if="op.is_clocked_in">●</t>
|
<t t-if="op.is_clocked_in">●</t>
|
||||||
<t t-else="">○</t>
|
<t t-else="">○</t>
|
||||||
@@ -173,26 +176,26 @@
|
|||||||
</option>
|
</option>
|
||||||
</t>
|
</t>
|
||||||
</select>
|
</select>
|
||||||
<select t-if="wo.wo_kind === 'wet'"
|
<select t-if="step.kind === 'wet'"
|
||||||
class="o_fp_mgr_picker"
|
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>
|
<option value="">— Tank —</option>
|
||||||
<t t-foreach="state.overview.tanks" t-as="tnk" t-key="tnk.id">
|
<t t-foreach="state.overview.tanks" t-as="tnk" t-key="tnk.id">
|
||||||
<option t-att-value="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-esc="tnk.name"/>
|
||||||
<t t-if="tnk.current_bath"> · <t t-esc="tnk.current_bath"/></t>
|
<t t-if="tnk.current_bath"> · <t t-esc="tnk.current_bath"/></t>
|
||||||
</option>
|
</option>
|
||||||
</t>
|
</t>
|
||||||
</select>
|
</select>
|
||||||
<button class="btn o_fp_mgr_btn"
|
<button class="btn o_fp_mgr_btn"
|
||||||
t-on-click="() => this.onTakeOver(wo)"
|
t-on-click="() => this.onTakeOver(step)"
|
||||||
title="Assign this WO to yourself">
|
title="Assign this step to yourself">
|
||||||
<i class="fa fa-user me-1"/>Take Over
|
<i class="fa fa-user me-1"/>Take Over
|
||||||
</button>
|
</button>
|
||||||
<button class="btn o_fp_mgr_btn"
|
<button class="btn o_fp_mgr_btn"
|
||||||
t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
|
t-on-click="() => this.openRecord('fp.job.step', step.id)">
|
||||||
<i class="fa fa-external-link me-1"/>Open WO
|
<i class="fa fa-external-link me-1"/>Open Step
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,14 +217,14 @@
|
|||||||
<div>Nothing running right now.</div>
|
<div>Nothing running right now.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_mgr_card_list" t-if="state.overview.active.length">
|
<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"
|
<div class="o_fp_mgr_card"
|
||||||
t-att-data-priority="card.priority_any">
|
t-att-data-priority="card.priority_any">
|
||||||
<div class="o_fp_mgr_card_head"
|
<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>
|
||||||
<div class="o_fp_mgr_card_title">
|
<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>
|
<span class="text-muted ms-2 small">· <t t-esc="card.so_name"/></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_mgr_card_sub">
|
<div class="o_fp_mgr_card_sub">
|
||||||
@@ -232,33 +235,35 @@
|
|||||||
<div class="o_fp_mgr_card_chips">
|
<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 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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_mgr_card_body"
|
<div class="o_fp_mgr_card_body"
|
||||||
t-if="state.expandedMoId === card.mo_id or state.mode === 'detailed'">
|
t-if="state.expandedJobId === card.job_id or state.mode === 'detailed'">
|
||||||
<t t-foreach="card.wos" t-as="wo" t-key="wo.id">
|
<t t-foreach="card.steps" t-as="step" t-key="step.id">
|
||||||
<div class="o_fp_mgr_wo_row">
|
<div class="o_fp_mgr_step_row">
|
||||||
<div class="o_fp_mgr_wo_info">
|
<div class="o_fp_mgr_step_info">
|
||||||
<t t-esc="wo.name"/>
|
<t t-esc="step.name"/>
|
||||||
<span class="text-muted ms-2">
|
<span class="text-muted ms-2">
|
||||||
<t t-esc="wo.workcenter"/>
|
<t t-esc="step.workcenter"/>
|
||||||
<t t-if="wo.assigned_user_name">
|
<t t-if="step.assigned_user_name">
|
||||||
· <i class="fa fa-user"/>
|
· <i class="fa fa-user"/>
|
||||||
<t t-esc="wo.assigned_user_name"/>
|
<t t-esc="step.assigned_user_name"/>
|
||||||
</t>
|
</t>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span t-att-class="'o_fp_chip o_fp_chip_' + (wo.state === 'progress' ? 'success' : 'info')">
|
<span t-att-class="'o_fp_chip o_fp_chip_' + (step.state === 'in_progress' || step.state === 'progress' ? 'success' : 'info')">
|
||||||
<t t-esc="wo.state"/>
|
<t t-esc="step.state"/>
|
||||||
</span>
|
</span>
|
||||||
<button class="btn"
|
<button class="btn"
|
||||||
t-on-click="() => this.onTakeOver(wo)">
|
t-on-click="() => this.onTakeOver(step)">
|
||||||
Take Over
|
Take Over
|
||||||
</button>
|
</button>
|
||||||
<button class="btn"
|
<button class="btn"
|
||||||
t-on-click="() => this.openRecord('mrp.workorder', wo.id)">
|
t-on-click="() => this.openRecord('fp.job.step', step.id)">
|
||||||
Open
|
Open
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
title="Refresh">
|
title="Refresh">
|
||||||
<i t-att-class="state.loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"/>
|
<i t-att-class="state.loading ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"/>
|
||||||
</button>
|
</button>
|
||||||
|
<QrScanner cssClass="'btn btn-outline-secondary'"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
t-if="node.qty_total"
|
t-if="node.qty_total"
|
||||||
t-esc="qtyLabel(node)"/>
|
t-esc="qtyLabel(node)"/>
|
||||||
<i class="o_fp_pt_card_open fa fa-external-link"
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,6 +106,9 @@
|
|||||||
<span t-if="state.recipe"> · <i class="fa fa-flask me-1"/><t t-esc="state.recipe"/></span>
|
<span t-if="state.recipe"> · <i class="fa fa-flask me-1"/><t t-esc="state.recipe"/></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="o_fp_pt_header_actions">
|
||||||
|
<QrScanner cssClass="'btn btn-outline-secondary'"/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ========== LOADING ========== -->
|
<!-- ========== LOADING ========== -->
|
||||||
|
|||||||
@@ -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' && this.onManualSubmit()"/>
|
||||||
|
<button class="btn btn-primary mt-2"
|
||||||
|
t-on-click="() => this.onManualSubmit()">Open</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -42,8 +42,9 @@
|
|||||||
</t>
|
</t>
|
||||||
</select>
|
</select>
|
||||||
<button class="o_fp_scan_toggle" t-on-click="toggleScan">
|
<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>
|
</button>
|
||||||
|
<QrScanner cssClass="'o_fp_scan_toggle'" label="'Camera'"/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@
|
|||||||
Active: <strong t-esc="state.overview.active_wo.name"/>
|
Active: <strong t-esc="state.overview.active_wo.name"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_active_wo_meta">
|
<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"/>
|
· <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"/>
|
· 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>
|
<t t-if="state.overview.active_wo.workcenter"> @ <t t-esc="state.overview.active_wo.workcenter"/></t>
|
||||||
@@ -97,8 +98,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="o_fp_big_button"
|
<button class="o_fp_big_button"
|
||||||
t-on-click="() => openRecord('mrp.workorder', state.overview.active_wo.id)">
|
t-on-click="() => openRecord('fp.job.step', state.overview.active_wo.id)">
|
||||||
Open WO
|
Open Step
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user