Compare commits
4 Commits
a2fe1fcbcc
...
b187192c58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b187192c58 | ||
|
|
bbf2476f01 | ||
|
|
9401afb21d | ||
|
|
df43737b1b |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
|||||||
|
# Deadline Architecture — Per-Part Effective Deadlines
|
||||||
|
|
||||||
|
**Date:** 2026-04-29
|
||||||
|
**Module:** `fusion_plating_configurator` (+ `fusion_plating_invoicing` for partner fields)
|
||||||
|
**Status:** Approved by user, ready to implement
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Three deadline fields exist today (`commitment_date`, `x_fc_internal_deadline`, `x_fc_part_deadline`) but the per-line `x_fc_part_deadline` is a free-form override with no inheritance rule. There is no way to express:
|
||||||
|
|
||||||
|
- "This part on this order needs more time than the rest" without manual date entry on every line
|
||||||
|
- "This specific part normally takes 14 days from start" as a part-catalog default
|
||||||
|
- "When does the order *actually finish*" when lines have varying deadlines (the order's `commitment_date` doesn't roll up)
|
||||||
|
- "We'll be late" warning when planned line deadlines exceed the customer-promise
|
||||||
|
|
||||||
|
Customer profile already carries duration defaults (`x_fc_default_customer_deadline_days`, `x_fc_default_internal_deadline_days`) that cascade to the order header, but those defaults stop at the header — they don't reach the line.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Customer profile remains the single source of manual durations.
|
||||||
|
2. Per-line effective deadline auto-populates with no manual input required, using a layered fallback chain.
|
||||||
|
3. User can override at the line level via either an absolute date OR a days-offset.
|
||||||
|
4. A part can carry its own typical lead time so it auto-applies whenever that part lands on a line.
|
||||||
|
5. Order header gets a computed completion date and a "we'll be late" forecast that distinguishes from the customer-promise date.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Rescheduling logic (auto-shift `planned_start_date` when shop is overloaded) — separate project.
|
||||||
|
- Customer-portal display of effective deadlines — touches `fusion_plating_portal`; not in this spec.
|
||||||
|
- Per-process-variant lead time — explicitly rejected during brainstorming; revisit only if shops report estimation drift.
|
||||||
|
|
||||||
|
## Concept Map
|
||||||
|
|
||||||
|
| Field | Lives on | Type | Source |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `x_fc_default_customer_deadline_days` | `res.partner` | Integer | Manual (existing) |
|
||||||
|
| `x_fc_default_internal_deadline_days` | `res.partner` | Integer | Manual (existing) |
|
||||||
|
| `x_fc_default_lead_time_days` | `fp.part.catalog` | Integer | Manual (**NEW**) |
|
||||||
|
| `x_fc_planned_start_date` | `sale.order` | Date | Manual or scheduler (existing) |
|
||||||
|
| `commitment_date` (Customer Deadline) | `sale.order` | Date | Manual; cascaded from customer (existing — unchanged) |
|
||||||
|
| `x_fc_internal_deadline` | `sale.order` | Date | Manual; cascaded from customer (existing — unchanged) |
|
||||||
|
| `x_fc_part_deadline` | `sale.order.line` | Date | Manual override (existing — semantics now one of two override forms) |
|
||||||
|
| `x_fc_part_deadline_offset_days` | `sale.order.line` | Integer | Manual override (**NEW**) — alternative to absolute date |
|
||||||
|
| `x_fc_effective_part_deadline` | `sale.order.line` | Date | **Computed (NEW)** — see resolution chain |
|
||||||
|
| `x_fc_effective_internal_deadline` | `sale.order.line` | Date | **Computed (NEW)** — line's customer date minus order buffer |
|
||||||
|
| `x_fc_order_completion_date` | `sale.order` | Date | **Computed (NEW)** = `max(line.x_fc_effective_part_deadline)` |
|
||||||
|
| `x_fc_is_late_forecast` | `sale.order` | Boolean | **Computed (NEW)** = `order_completion_date > commitment_date` |
|
||||||
|
|
||||||
|
## Resolution Chain — `x_fc_effective_part_deadline`
|
||||||
|
|
||||||
|
First match wins:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. line.x_fc_part_deadline # explicit absolute-date override
|
||||||
|
2. line.x_fc_part_deadline_offset_days # "+N days from order"
|
||||||
|
→ commitment_date + offset_days
|
||||||
|
3. line.x_fc_part_catalog_id.x_fc_default_lead_time_days # part-specific lead time
|
||||||
|
→ planned_start_date + part.default_lead_time_days
|
||||||
|
4. order.commitment_date # falls through to order promise
|
||||||
|
# (which is itself derived from customer profile)
|
||||||
|
```
|
||||||
|
|
||||||
|
Every fallback ultimately routes back to the customer profile via the order header's `commitment_date`. No record is ever blank — even a brand-new line with no overrides immediately shows the order's customer-promise date.
|
||||||
|
|
||||||
|
## Resolution — `x_fc_effective_internal_deadline`
|
||||||
|
|
||||||
|
```
|
||||||
|
buffer_days = commitment_date - x_fc_internal_deadline
|
||||||
|
(= the gap implied by customer profile: customer_days - internal_days)
|
||||||
|
|
||||||
|
x_fc_effective_internal_deadline = x_fc_effective_part_deadline - buffer_days
|
||||||
|
```
|
||||||
|
|
||||||
|
If the customer profile says "5 days customer / 3 days internal", every line's internal target = its effective customer date minus 2 days. Automatic, no per-line entry.
|
||||||
|
|
||||||
|
## Order-Level Rollups
|
||||||
|
|
||||||
|
```python
|
||||||
|
x_fc_order_completion_date = max(line.x_fc_effective_part_deadline)
|
||||||
|
for line in order_line if not line.x_fc_archived
|
||||||
|
x_fc_is_late_forecast = bool(x_fc_order_completion_date > commitment_date)
|
||||||
|
```
|
||||||
|
|
||||||
|
Suppress `is_late_forecast` when `x_fc_is_blanket_order=True` (blanket orders span months and would always trigger).
|
||||||
|
|
||||||
|
## UI Changes — Apply on All Three Surfaces
|
||||||
|
|
||||||
|
Per the project parity rule (memory: feedback_direct_order_three_surface_parity), changes apply to:
|
||||||
|
1. **SO line view** — `views/sale_order_views.xml` (`x_fc_*` prefix)
|
||||||
|
2. **Direct Order wizard** — `wizard/fp_direct_order_wizard_views.xml` (bare names)
|
||||||
|
3. **Quote Configurator** — n/a, single-line model — skip unless quote needs deadline at line level (it doesn't today)
|
||||||
|
|
||||||
|
### SO line columns
|
||||||
|
|
||||||
|
| Column | Default | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `x_fc_part_deadline` (Part Deadline Override) | optional, hide | Absolute-date manual override |
|
||||||
|
| `x_fc_part_deadline_offset_days` (Days Offset) | optional, hide | "+5" form for users who think in days |
|
||||||
|
| `x_fc_effective_part_deadline` (Effective Deadline) | optional, **show** | Computed; visible badge with `late` decoration when `> commitment_date` |
|
||||||
|
| `x_fc_effective_internal_deadline` (Shop Target) | optional, hide | Available for shop-floor views |
|
||||||
|
|
||||||
|
Tooltip on **Effective Deadline**: "Manual override → offset → part lead time → order date".
|
||||||
|
|
||||||
|
### SO header — Scheduling group
|
||||||
|
|
||||||
|
Add two readonly fields next to existing deadline group:
|
||||||
|
|
||||||
|
- `x_fc_order_completion_date` — computed; readonly
|
||||||
|
- `x_fc_is_late_forecast` — computed; renders as a `⚠ Late` badge when `True`
|
||||||
|
|
||||||
|
### Part catalog form
|
||||||
|
|
||||||
|
Add `Default Lead Time (days)` Integer field next to `Surface Area` in the existing geometry group. Tooltip: "Optional: how many days from `planned_start_date` this part typically needs. Used as a smart default on order lines when no explicit deadline is set."
|
||||||
|
|
||||||
|
### Customer profile (`res.partner`)
|
||||||
|
|
||||||
|
No UI change. Existing `x_fc_default_customer_deadline_days` and `x_fc_default_internal_deadline_days` already drive everything via the order header cascade.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
1. **`planned_start_date` blank** — fall back to `fields.Date.today()` for any anchor calculation (matches existing `_fp_recompute_default_deadlines` behaviour).
|
||||||
|
2. **Customer profile values both = 0** — `commitment_date` ends up = `planned_start_date`. Don't error; user must set or override.
|
||||||
|
3. **`commitment_date` blank** (orphan order) — `effective_part_deadline` falls back to `planned_start_date` so the field is never null.
|
||||||
|
4. **Both `x_fc_part_deadline` AND `x_fc_part_deadline_offset_days` set on a line** — absolute date wins (rule 1 of the chain). Add a soft `@api.constrains` that suggests clearing one (`UserError` would be too aggressive; use a chatter log instead).
|
||||||
|
5. **Negative buffer** (customer profile has `internal_days > customer_days`) — `effective_internal_deadline` would land *after* `effective_part_deadline`. Clamp internal to ≤ effective_part_deadline so it can never exceed the customer date.
|
||||||
|
6. **Empty order** — `order_completion_date = False`, `is_late_forecast = False`.
|
||||||
|
7. **Archived lines** — exclude from `order_completion_date` rollup (`x_fc_archived = False` filter).
|
||||||
|
8. **Blanket orders** — `is_late_forecast` suppressed (always returns `False`) since blanket spans are intentionally long.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
| Field | Action |
|
||||||
|
|---|---|
|
||||||
|
| `fp.part.catalog.x_fc_default_lead_time_days` | New Integer, default 0. No backfill — 0 = "no part default" |
|
||||||
|
| `sale.order.line.x_fc_part_deadline_offset_days` | New Integer, default 0 |
|
||||||
|
| `sale.order.line.x_fc_effective_part_deadline` | New Date, computed stored. Auto-populates on first `_recompute` |
|
||||||
|
| `sale.order.line.x_fc_effective_internal_deadline` | New Date, computed stored. Auto-populates on first `_recompute` |
|
||||||
|
| `sale.order.x_fc_order_completion_date` | New Date, computed stored. Auto-populates on first `_recompute` |
|
||||||
|
| `sale.order.x_fc_is_late_forecast` | New Boolean, computed (non-stored OK; cheap to recompute) |
|
||||||
|
|
||||||
|
No data migration script needed. Existing `commitment_date`, `x_fc_internal_deadline`, `x_fc_part_deadline` keep their semantics. The new compute fields populate via Odoo ORM's standard recompute pass during module upgrade.
|
||||||
|
|
||||||
|
## Reporting / Downstream
|
||||||
|
|
||||||
|
- Existing reports (Plant Overview kanban, KPI dashboards, late-orders filter) keep using `commitment_date` for "promise to customer".
|
||||||
|
- New shop-scheduling consumers should use `x_fc_effective_part_deadline` per line and `x_fc_order_completion_date` per order.
|
||||||
|
- `x_fc_is_late_forecast=True` becomes a natural filter for "orders we'll be late on" — list view decoration, dashboard tile, future notification.
|
||||||
|
|
||||||
|
## Implementation Files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating_configurator/models/fp_part_catalog.py` | Add `x_fc_default_lead_time_days` field |
|
||||||
|
| `fusion_plating_configurator/views/fp_part_catalog_views.xml` | Add field to form (geometry group) |
|
||||||
|
| `fusion_plating_configurator/models/sale_order_line.py` | Add `x_fc_part_deadline_offset_days`, `x_fc_effective_part_deadline`, `x_fc_effective_internal_deadline` + computes |
|
||||||
|
| `fusion_plating_configurator/models/sale_order.py` | Add `x_fc_order_completion_date`, `x_fc_is_late_forecast` + computes |
|
||||||
|
| `fusion_plating_configurator/views/sale_order_views.xml` | Add new line columns + header rollup fields + late badge |
|
||||||
|
| `fusion_plating_configurator/wizard/fp_direct_order_line.py` | Mirror line fields (bare names: `part_deadline_offset_days`, `effective_part_deadline`, `effective_internal_deadline`) |
|
||||||
|
| `fusion_plating_configurator/wizard/fp_direct_order_wizard_views.xml` | Add wizard line columns |
|
||||||
|
| `fusion_plating_configurator/wizard/fp_direct_order_wizard.py` | Map bare names → `x_fc_*` when creating SO |
|
||||||
|
| `fusion_plating_configurator/__manifest__.py` | Bump version to `19.0.18.6.0` |
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
1. **Customer cascade unchanged** — create SO, pick partner with deadline-days set, plan a start date → header `commitment_date` and `x_fc_internal_deadline` auto-populate (no regression).
|
||||||
|
2. **Line with no overrides** — add part to line → `x_fc_effective_part_deadline = commitment_date`.
|
||||||
|
3. **Part with `default_lead_time_days = 14`** — line picks that part → `effective_part_deadline = planned_start + 14`. Order's `completion_date` reflects the later of header or line.
|
||||||
|
4. **Per-line offset** — set `part_deadline_offset_days = 3` → `effective = commitment_date + 3 days`.
|
||||||
|
5. **Per-line absolute override** — set `x_fc_part_deadline = X` → `effective = X` regardless of other settings.
|
||||||
|
6. **Mixed-deadline order** — two lines with different `effective_part_deadline` → `order_completion_date = max(both)`. `is_late_forecast = True` if max > `commitment_date`.
|
||||||
|
7. **Blanket order** — `is_late_forecast = False` even when completion > commitment.
|
||||||
|
8. **Archived line** — excluded from `order_completion_date` calc.
|
||||||
|
9. **Direct Order wizard parity** — same auto-fill + override behaviour on the wizard before creating an SO; values transfer correctly.
|
||||||
|
|
||||||
|
## Out of Scope (Defer)
|
||||||
|
|
||||||
|
- Rescheduling auto-adjust on `planned_start_date` change.
|
||||||
|
- Customer-portal effective-deadline display.
|
||||||
|
- Per-process-variant lead time.
|
||||||
|
- Notification/email when `is_late_forecast` flips to True.
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# Step Library Expansion + Per-Recipe Configurability + Audit Coverage
|
||||||
|
|
||||||
|
**Date:** 2026-04-29
|
||||||
|
**Modules:** `fusion_plating` (core), `fusion_plating_jobs`, `fusion_plating_reports`
|
||||||
|
**Status:** Approved by user, ready for implementation plan
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The plating-shop step library (`fp.step.template`) covers ~60% of a real plating workflow but is missing several common steps (Receiving, Electroclean, Strike, Salt Spray, Adhesion Test, Hardness Test, Packaging, Tank Replenishment). Existing kinds have light default measurement seeding — many industry-standard fields (Bath ID, current density, photo evidence, multi-point thickness) aren't authored by default, so audit-required values often get skipped.
|
||||||
|
|
||||||
|
The recipe author also can't customise step measurements at the recipe level — they can only edit the library template, which affects every recipe that step appears in. There's no per-recipe override for prompts, target ranges, or instructions.
|
||||||
|
|
||||||
|
The office team writes per-step instructions that operators read at runtime; this channel exists but is hidden in a tab and isn't surfaced in the simple recipe editor for inline editing.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Cover the full plating workflow with appropriate Step Kinds and audit-grade default measurements.
|
||||||
|
2. Add specialised input types (`photo`, `multi_point_thickness`, `bath_chemistry_panel`, `ph`) that match real shop instrumentation.
|
||||||
|
3. Make every prompt **per-recipe configurable** — recipe authors can disable, rename, retarget, reorder, or add custom prompts on each recipe step without touching the library.
|
||||||
|
4. Make office→operator instructions visible and editable directly in the simple recipe editor, with library default + per-recipe override.
|
||||||
|
5. Wire all of the above through to runtime (`fp.job.step` input wizard, tablet QC checklist, CoC chronological report) so recorded values are captured for audit.
|
||||||
|
6. Battle-test end-to-end after deploy.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- IoT/sensor auto-fill of measurements (read pH probe / bath chemistry panel directly from instruments) — separate project.
|
||||||
|
- Customer-portal display of recorded measurements — separate.
|
||||||
|
- Step Kinds beyond the 8 added here (deburring, anodize seal, abrasive blasting) — same pattern, easy to add later.
|
||||||
|
- Replacing the existing tablet OWL QC checklist — extend only.
|
||||||
|
- Per-customer or per-part override of step measurements — separate concern (parts already carry their own recipe variant).
|
||||||
|
|
||||||
|
## Architecture Map
|
||||||
|
|
||||||
|
```
|
||||||
|
fp.step.template (library — office authors here)
|
||||||
|
├── description (Html, "Default Operator Instructions")
|
||||||
|
├── default_kind (Selection, drives DEFAULT_INPUTS_BY_KIND)
|
||||||
|
├── input_template_ids → fp.step.template.input
|
||||||
|
└── transition_input_ids → fp.step.template.transition.input
|
||||||
|
↓ [snapshot copy on drag into recipe]
|
||||||
|
fusion.plating.process.node (recipe step — recipe authors override here)
|
||||||
|
├── description (Html, per-recipe override; empty = fall back to library)
|
||||||
|
├── collect_measurements (Boolean, master switch — NEW)
|
||||||
|
└── input_ids → fusion.plating.process.node.input
|
||||||
|
├── collect (Boolean, per-prompt opt-out — NEW)
|
||||||
|
├── template_input_id (m2o — NEW, traceability for "reset to defaults")
|
||||||
|
└── (existing fields: name, input_type, target_min/max, target_unit, required, hint, sequence, kind)
|
||||||
|
↓ [MO/job creation copies recipe nodes]
|
||||||
|
fp.job.step (work order step — operator records here)
|
||||||
|
↓ [Mark Done → input wizard fires, FILTERED to collect=True only]
|
||||||
|
fp.job.step.input.wizard.line
|
||||||
|
↓ [operator types values → commit]
|
||||||
|
fp.job.step.move (transfer_type='step', json input data)
|
||||||
|
↓ [QWeb render]
|
||||||
|
report_coc_chronological.xml (audit document — only collect=True inputs)
|
||||||
|
```
|
||||||
|
|
||||||
|
## New Step Kinds
|
||||||
|
|
||||||
|
Added to `fp.step.template.default_kind` Selection and `DEFAULT_INPUTS_BY_KIND`:
|
||||||
|
|
||||||
|
| Kind code | Label | Default Operation Measurements |
|
||||||
|
|---|---|---|
|
||||||
|
| `receiving` | Receiving / Incoming Inspection | Qty Received, Qty Rejected, Customer PO# Verified (boolean), Packing Slip #, Condition Notes, Damage Photo (photo, optional), Inspector Initials (signature) |
|
||||||
|
| `electroclean` | Electroclean | Time, Temperature, Amperage (A), Voltage (V), Current Density (ASF), Polarity (selection: anodic/cathodic/periodic), Bath ID |
|
||||||
|
| `strike` | Strike (Wood's Nickel / Activation) | Time, Temperature, Amperage (A), Voltage (V), Current Density (ASF), Bath ID |
|
||||||
|
| `salt_spray` | Salt Spray / Corrosion Test | Test Duration (hr), Result (pass_fail), Red Rust %, White Corrosion %, Lab Report (photo) |
|
||||||
|
| `adhesion_test` | Adhesion Test | Test Method (selection: bend/tape/burnish/file), Result (pass_fail), Photo of Coupon (photo) |
|
||||||
|
| `hardness_test` | Hardness Test (HV / HK / HRC) | Test Load (gf), Reading 1/2/3 (multi_point_thickness), Average (number), Equipment ID, Last Calibration Date |
|
||||||
|
| `packaging` | Packaging / Pre-Ship | Packaging Type (selection: VCI bag/bubble wrap/separator paper/custom crate), Qty Per Package, Package Count, Cert Package Included (boolean), Customer-Supplied Packaging (boolean) |
|
||||||
|
| `replenishment` | Tank Replenishment | Bath ID, Chemistry Added (text), pH Before (ph), pH After (ph), Concentration Before, Concentration After, Operator Initials (signature) |
|
||||||
|
|
||||||
|
## Beefed-Up Defaults on Existing Kinds
|
||||||
|
|
||||||
|
Idempotently appended via `action_seed_default_inputs` (skips if name already present):
|
||||||
|
|
||||||
|
| Existing Kind | New seeded inputs |
|
||||||
|
|---|---|
|
||||||
|
| `racking` | Rack ID, Masking Applied (boolean), Photo of Racked Load (photo) |
|
||||||
|
| `derack` | Mask Removal Method (selection), Residue Check (pass_fail) |
|
||||||
|
| `mask` | Mask Material (selection: Microshield/latex tape/vinyl plugs/wax), Photo (photo) |
|
||||||
|
| `demask` | Residue Check (pass_fail), Surface Condition (selection: clean/marks/needs rework) |
|
||||||
|
| `cleaning` | Bath ID, Ultrasonic On (boolean), Titration Done (boolean) |
|
||||||
|
| `etch` | Acid Concentration (% or g/L), Bath ID, HE Risk Flag (boolean) |
|
||||||
|
| `rinse` | Rinse Type (selection: cascade/spray/DI/city), Conductivity µS/cm, Time |
|
||||||
|
| `plate` | Bath ID, pH (ph), Bath Concentration (g/L), Current Density ASF (electroplate), Multi-Point Thickness (multi_point_thickness) |
|
||||||
|
| `bake` | Oven ID, Chart Recorder File (photo) |
|
||||||
|
| `inspect` | Defect Type (selection), Thickness Sample, Photo, Inspector Signature |
|
||||||
|
| `final_inspect` | Defect Categorization (selection), Dimensional Verification (pass_fail), Surface Finish Ra, Inspector Signature |
|
||||||
|
| `wbf_test` | Retest Count, Photo on FAIL (photo) |
|
||||||
|
| `dry` | Dry Method (selection), Time, Temperature |
|
||||||
|
| `ship` | Carrier (selection), Tracking #, BoL #, Photo of Sealed Shipment |
|
||||||
|
|
||||||
|
## New Input Types
|
||||||
|
|
||||||
|
Added to `fp.step.template.input.input_type`, `fusion.plating.process.node.input.input_type` (the recipe-step input model), and `fp.job.step.input.wizard.line.input_type` (runtime wizard):
|
||||||
|
|
||||||
|
| Type code | Label | Renders as |
|
||||||
|
|---|---|---|
|
||||||
|
| `photo` | Photo | Binary widget with `image` rendering — capture or upload one image |
|
||||||
|
| `multi_point_thickness` | Multi-Point Thickness (avg) | Five number inputs (R1–R5) + auto-computed average; min/max bounds applied to average |
|
||||||
|
| `bath_chemistry_panel` | Bath Chemistry Panel | Bundle: pH + concentration + temperature + bath ID — saves clicks for plate steps |
|
||||||
|
| `ph` | pH | Number input clamped to 0–14, two-decimal precision |
|
||||||
|
|
||||||
|
`signature` already exists; will be auto-seeded on more kinds via the new defaults.
|
||||||
|
|
||||||
|
## Per-Recipe Configurability — Model Changes
|
||||||
|
|
||||||
|
| Field | Lives on | Type | Default | Purpose |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `collect` | `fusion.plating.process.node.input_ids` row | Boolean | `True` | Per-prompt opt-out. Wizard / report filter to `collect=True` only |
|
||||||
|
| `template_input_id` | same | Many2one to `fp.step.template.input` | (snapshot link) | Traceability — "this prompt was seeded from library template X". Powers "reset to library defaults" |
|
||||||
|
| `collect_measurements` | `fusion.plating.process.node` | Boolean | `True` | Master switch. When `False`, runtime skips the input wizard entirely for this step |
|
||||||
|
|
||||||
|
Existing fields on `fusion.plating.process.node.input_ids` (`name`, `input_type`, `target_min`, `target_max`, `target_unit`, `required`, `hint`, `sequence`, `selection_options`) are already editable — recipe author can rename / retarget / reorder freely without touching the library.
|
||||||
|
|
||||||
|
## Per-Recipe Configurability — Recipe Author Capabilities
|
||||||
|
|
||||||
|
Each recipe step exposes:
|
||||||
|
|
||||||
|
1. **Disable a prompt** — toggle `collect` off (preserves history; not deleted)
|
||||||
|
2. **Override target range** — edit `target_min` / `target_max` per recipe
|
||||||
|
3. **Rename a prompt** — edit `name`
|
||||||
|
4. **Reorder prompts** — drag handle on `sequence`
|
||||||
|
5. **Mark required per recipe** — flip `required` independently of library
|
||||||
|
6. **Add custom prompt** — new row with `template_input_id = False`
|
||||||
|
7. **Disable entire step's data collection** — master `collect_measurements = False` on the recipe node
|
||||||
|
8. **Reset to library defaults** — re-sync `input_ids` + `description` from linked library template, preserve custom rows where `template_input_id = False`
|
||||||
|
|
||||||
|
## Operator Instructions (Office → Floor)
|
||||||
|
|
||||||
|
`fp.step.template.description` (Html) — relabel UI to **"Default Operator Instructions"** with tooltip *"Standing instructions the office gives operators for this step. Snapshot-copied onto every recipe that uses this step. Recipe authors can override per recipe."*
|
||||||
|
|
||||||
|
`fusion.plating.process.node.description` (existing, already snapshot-copied) is the per-recipe override. Empty recipe-node `description` falls back to library at runtime so blank means "use library default", not "show nothing".
|
||||||
|
|
||||||
|
Runtime visibility — three places:
|
||||||
|
1. **Tablet shop-floor card** — instructions panel above input prompts (expandable)
|
||||||
|
2. **Backend Mark-Done wizard** — same panel at top of input form
|
||||||
|
3. **Printed traveller / WO sheet** — instructions section under each step heading
|
||||||
|
|
||||||
|
## Simple Recipe Editor — UI Changes
|
||||||
|
|
||||||
|
Each step card gains two expansions:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Plate ENP ───────────────────────────────────────┐
|
||||||
|
│ Plate ENP @ Tank #3 · 30 min · 180-200°F │
|
||||||
|
│ │
|
||||||
|
│ ▸ 📋 Instructions (using library default) │
|
||||||
|
│ ▸ ⚙ Measurements (5/5 collected) │
|
||||||
|
└────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instructions expansion
|
||||||
|
|
||||||
|
- Source toggle: **"Library default"** vs **"Custom for this recipe"**
|
||||||
|
- Rich-text editor showing current text (library default if recipe override empty)
|
||||||
|
- **[Use library default]** / **[Save override]** buttons
|
||||||
|
- Badge on step card: **"📋 default"** vs **"📋 custom"**
|
||||||
|
|
||||||
|
### Measurements expansion
|
||||||
|
|
||||||
|
- Master toggle: **"Collect measurements at this step"**
|
||||||
|
- Editable list of prompts with: drag handle, collect checkbox, name, input type, target range, unit, required toggle
|
||||||
|
- **[+ Add custom prompt]** to append a recipe-only prompt
|
||||||
|
- **[Reset to library defaults]** button
|
||||||
|
- Badge on step card: **"5/5 collected"** (green), **"3/5 collected"** (amber), **"No measurements"** (grey)
|
||||||
|
|
||||||
|
## "Add Common Audit Fields" One-Click Action
|
||||||
|
|
||||||
|
New button on `fp.step.template` form: `action_add_common_audit_fields`. Idempotently appends:
|
||||||
|
|
||||||
|
- Operator Initials (signature, required)
|
||||||
|
- Bath ID (text)
|
||||||
|
- Photo on Failure (photo, optional, hint="upload only if failure observed")
|
||||||
|
- Equipment ID (text)
|
||||||
|
|
||||||
|
Skip rows whose name already exists. Logs to chatter.
|
||||||
|
|
||||||
|
## Runtime Wiring
|
||||||
|
|
||||||
|
| Component | Change |
|
||||||
|
|---|---|
|
||||||
|
| `fp.job.step.input.wizard` `default_get` | Filter `recipe_node.input_ids` to `kind == 'step_input' AND collect == True`. If `recipe_node.collect_measurements == False`, return empty `line_ids`. |
|
||||||
|
| `fp.job.step.input.wizard.line.input_type` | Mirror the 4 new input types so the wizard form can render per-type widgets |
|
||||||
|
| `fp.job.step.input.wizard_views.xml` | Conditional widgets per input type (photo → image binary; multi_point_thickness → 5-cell row + avg; bath_chemistry_panel → 4-cell group; ph → number with [0,14]) |
|
||||||
|
| `fusion_plating_shopfloor` tablet QC checklist OWL | Same per-type rendering on tablet |
|
||||||
|
| `fp.job.step.move` | No schema change — JSON `input_data` field already accepts arrays/dicts; just confirm encoding round-trips for the new types |
|
||||||
|
| Snapshot-copy logic (library → recipe node) | Verify `template_input_id` link is set; verify all fields including new types copy verbatim |
|
||||||
|
| `_fp_recipe_to_job` (MO/job creation) | Verify `collect`, `collect_measurements`, `template_input_id` carry through to `fp.job.step.recipe_node_id` chain |
|
||||||
|
|
||||||
|
## CoC / Audit Report Rendering
|
||||||
|
|
||||||
|
`fusion_plating_reports/views/report_coc_chronological.xml`:
|
||||||
|
|
||||||
|
1. Filter rendered inputs to `collect == True` only.
|
||||||
|
2. Render branches for new input types:
|
||||||
|
- `photo` → thumbnail `<img>` from `ir.attachment`, fallback to `[photo: filename]` if attachment missing
|
||||||
|
- `multi_point_thickness` → `R1: x, R2: y, R3: z, R4: a, R5: b → avg M`
|
||||||
|
- `bath_chemistry_panel` → 4 labelled rows (pH, conc, temp, bath ID)
|
||||||
|
- `ph` → `pH X.XX`
|
||||||
|
3. Skip prompts where the recipe author opted out (`collect=False`) — don't render them as "(not collected)" since auditors only care about what WAS measured.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Single migration script: `fusion_plating/migrations/19.0.18.7.0/post-migrate.py`
|
||||||
|
|
||||||
|
1. Default `collect=True` on all existing `fusion.plating.process.node.input_ids` rows.
|
||||||
|
2. Default `collect_measurements=True` on all existing `fusion.plating.process.node` rows.
|
||||||
|
3. For each existing `fp.step.template`, re-run `action_seed_default_inputs` (idempotent — adds new defaults without clobbering manual edits).
|
||||||
|
4. Backfill `template_input_id` on recipe-node inputs by name-matching against the linked library template's inputs (best-effort; rows that don't match stay as `False` and become "custom prompts" for purposes of reset-to-defaults).
|
||||||
|
|
||||||
|
Seed data file: `fusion_plating/data/fp_step_template_data.xml` (`noupdate="1"`) — one example template per new Step Kind so users see populated entries on upgrade.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
1. **Recipe node with empty `description`** — runtime renders the linked library template's `description` (fall-through). If both empty, instructions section is hidden in the operator wizard.
|
||||||
|
2. **Recipe author opts out of all prompts but leaves `collect_measurements=True`** — wizard fires with zero rows; UX gives "no inputs to record, mark done?" confirmation. Same as today's behaviour for kinds with no defaults (rinse, dry, gating).
|
||||||
|
3. **Photo input on operation measurement (new) vs transition input (existing)** — both kinds always store the image as an `ir.attachment` linked to the `fp.job.step.move` row (never as inline base64 in the `input_data` JSON, to keep the payload small and indexable). Distinguish operation vs transition by `kind` field on the prompt row. CoC report iterates both kinds and renders thumbnails.
|
||||||
|
4. **Multi-point thickness validation** — at least one reading required when `required=True`; average computed over **non-empty cells only** (empty = "didn't measure that point"); when the computed average falls outside `[target_min, target_max]` the wizard shows a non-blocking warning banner before commit (operator can override and proceed, but the out-of-band condition is recorded on the move for QC review).
|
||||||
|
5. **Bath chemistry panel — partial fill** — auditor wants to know which fields were captured; all four fields stored in the JSON payload as separate keys. Empty values render as "—" in the CoC.
|
||||||
|
6. **Reset to library defaults** — preserves rows where `template_input_id = False` (recipe-author-added custom prompts). Re-enables `collect=True` on rows where `template_input_id` is set. Logs to chatter with diff summary.
|
||||||
|
7. **Library template renamed** — recipe nodes already snapshotted; `template_input_id` link survives the rename. "Reset to defaults" pulls the renamed prompt back.
|
||||||
|
8. **Library prompt deleted** — recipe nodes' `template_input_id` becomes a dangling reference; reset-to-defaults treats those as "deleted upstream, leave as recipe custom" (don't drop them — recipe author may still want them).
|
||||||
|
9. **Existing recipes upgrading** — the migration sets `collect=True` everywhere, so behaviour is unchanged for existing data. Only newly-disabled prompts (post-upgrade) suppress at runtime.
|
||||||
|
|
||||||
|
## Battle Test Plan
|
||||||
|
|
||||||
|
Build `fusion_plating/scripts/bt_step_library_audit.py` exercising every change. Same `bt_s*.py` pattern as `fusion_plating_quality/scripts/`. Runs in odoo-shell against the live entech DB after deploy. Reports PASS / FAIL / SKIP per assertion, summary counts at the end.
|
||||||
|
|
||||||
|
| # | Assertion |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Every new Step Kind has at least 1 seed template loaded |
|
||||||
|
| 2 | Every Step Kind (new + existing) yields the expected default inputs after `action_seed_default_inputs` runs on a fresh template |
|
||||||
|
| 3 | Library → recipe drag snapshot-copies every input field including the 4 new types |
|
||||||
|
| 4 | `template_input_id` link is set on snapshot-copied inputs |
|
||||||
|
| 5 | Recipe → job step inheritance preserves `collect`, `collect_measurements`, custom prompts |
|
||||||
|
| 6 | Operator wizard at runtime filters to `collect=True` only |
|
||||||
|
| 7 | Master `collect_measurements=False` skips the wizard entirely |
|
||||||
|
| 8 | Adding a custom prompt to a recipe node (no `template_input_id`) survives reset-to-defaults |
|
||||||
|
| 9 | Reset-to-defaults re-enables opted-out library prompts and pulls in newly-added library prompts |
|
||||||
|
| 10 | Each new input type stores and round-trips through `fp.job.step.move.input_data` (JSON) |
|
||||||
|
| 11 | Multi-point thickness average computed correctly (incl. edge case of 1 reading) |
|
||||||
|
| 12 | Photo attachment lands in `ir.attachment` and links to the move row |
|
||||||
|
| 13 | CoC chronological report renders each new input type without errors |
|
||||||
|
| 14 | CoC excludes `collect=False` rows |
|
||||||
|
| 15 | Operator instructions render at runtime — recipe override beats library default; empty override falls back to library |
|
||||||
|
| 16 | "Add Common Audit Fields" one-click is idempotent (run twice, no duplicates) |
|
||||||
|
| 17 | `action_seed_default_inputs` is idempotent after manual edits (user-edited rows survive re-seed) |
|
||||||
|
| 18 | `description` on recipe node clears correctly via "Use library default" toggle |
|
||||||
|
|
||||||
|
## Files Touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `fusion_plating/models/fp_step_template.py` | Extend `default_kind` Selection, extend `DEFAULT_INPUTS_BY_KIND`, add `action_add_common_audit_fields` |
|
||||||
|
| `fusion_plating/models/fp_step_template_input.py` | Add 4 input types to selection |
|
||||||
|
| `fusion_plating/models/fp_process_node.py` | Add `collect_measurements` on node; add `collect` + `template_input_id` on inline input model; mirror new input types |
|
||||||
|
| `fusion_plating/models/fp_process_node_inherit.py` | If applicable for filtering helpers |
|
||||||
|
| `fusion_plating/views/fp_step_template_views.xml` | Add "Add Common Audit Fields" button; relabel `description` to "Default Operator Instructions" |
|
||||||
|
| `fusion_plating/data/fp_step_template_data.xml` | NEW — seed templates for the 8 new kinds |
|
||||||
|
| `fusion_plating/migrations/19.0.18.7.0/post-migrate.py` | NEW — backfill `collect`, `collect_measurements`, `template_input_id`; re-run `action_seed_default_inputs` |
|
||||||
|
| `fusion_plating/static/src/js/simple_recipe_editor.js` | Render Instructions + Measurements expansions; collect badge; reset-to-defaults action |
|
||||||
|
| `fusion_plating/static/src/xml/simple_recipe_editor.xml` | Templates for new affordances |
|
||||||
|
| `fusion_plating/static/src/scss/simple_recipe_editor.scss` | Styles for expansions and badges |
|
||||||
|
| `fusion_plating/controllers/simple_recipe_controller.py` | New endpoints: `/fp/simple_recipe/step/toggle_collect`, `/fp/simple_recipe/step/edit_input`, `/fp/simple_recipe/step/edit_instructions`, `/fp/simple_recipe/step/reset_to_library` |
|
||||||
|
| `fusion_plating_jobs/wizards/fp_job_step_input_wizard.py` | Filter to `collect=True` only; mirror new input types |
|
||||||
|
| `fusion_plating_jobs/wizards/fp_job_step_input_wizard_views.xml` | Conditional widgets per input type |
|
||||||
|
| `fusion_plating_shopfloor/static/src/...` (tablet QC checklist OWL) | Per-type rendering on tablet |
|
||||||
|
| `fusion_plating_reports/views/report_coc_chronological.xml` | Render branches for new types; filter to `collect=True` |
|
||||||
|
| `fusion_plating/scripts/bt_step_library_audit.py` | NEW — battle-test script |
|
||||||
|
| `fusion_plating/__manifest__.py` | Bump version to `19.0.18.7.0` |
|
||||||
|
| `fusion_plating_jobs/__manifest__.py` | Bump version |
|
||||||
|
| `fusion_plating_reports/__manifest__.py` | Bump version |
|
||||||
|
|
||||||
|
## Effort Estimate
|
||||||
|
|
||||||
|
6–8 working days. Roughly 60% backend (models, migrations, wizard filtering, CoC, battle test) and 40% frontend (OWL editor expansions, per-input-type widgets in wizard + tablet).
|
||||||
|
|
||||||
|
## Out of Scope (Defer)
|
||||||
|
|
||||||
|
- IoT/sensor auto-fill of measurements
|
||||||
|
- Customer portal display of recorded values
|
||||||
|
- Step Kinds beyond the 8 listed (deburring, anodize seal, abrasive blasting)
|
||||||
|
- Replacing the existing tablet QC checklist OWL component
|
||||||
|
- Per-customer or per-part override of step measurements
|
||||||
|
- Notification/email when audit-required values are missing post-completion
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating',
|
'name': 'Fusion Plating',
|
||||||
'version': '19.0.12.6.2',
|
'version': '19.0.18.7.0',
|
||||||
'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': """
|
||||||
@@ -119,6 +119,7 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'data/fp_recipe_general_processing.xml',
|
'data/fp_recipe_general_processing.xml',
|
||||||
'data/fp_recipe_anodize.xml',
|
'data/fp_recipe_anodize.xml',
|
||||||
'data/fp_recipe_chem_conversion.xml',
|
'data/fp_recipe_chem_conversion.xml',
|
||||||
|
'data/fp_step_template_data.xml',
|
||||||
],
|
],
|
||||||
'post_init_hook': 'post_init_hook',
|
'post_init_hook': 'post_init_hook',
|
||||||
'assets': {
|
'assets': {
|
||||||
|
|||||||
106
fusion_plating/fusion_plating/data/fp_step_template_data.xml
Normal file
106
fusion_plating/fusion_plating/data/fp_step_template_data.xml
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Seed templates for the 8 new Step Kinds added in 19.0.18.7.0.
|
||||||
|
noupdate="1" so users can rename / archive without a module
|
||||||
|
upgrade reverting their edits.
|
||||||
|
-->
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="fp_step_template_receiving_std" model="fp.step.template">
|
||||||
|
<field name="name">Incoming Inspection (Standard)</field>
|
||||||
|
<field name="code">RECV_STD</field>
|
||||||
|
<field name="default_kind">receiving</field>
|
||||||
|
<field name="icon">fa-inbox</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<p>Verify quantity received against packing slip. Visually inspect
|
||||||
|
for damage, corrosion, oil residue. Photo any damage. Record
|
||||||
|
inspector initials.</p>
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_step_template_electroclean_std" model="fp.step.template">
|
||||||
|
<field name="name">Electroclean (Standard)</field>
|
||||||
|
<field name="code">ELEC_CLEAN_STD</field>
|
||||||
|
<field name="default_kind">electroclean</field>
|
||||||
|
<field name="icon">fa-bolt</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<p>Submerge rack and energise. Record actual amperage, voltage,
|
||||||
|
and current density. Verify polarity per recipe spec.</p>
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_step_template_strike_std" model="fp.step.template">
|
||||||
|
<field name="name">Wood's Nickel Strike (Standard)</field>
|
||||||
|
<field name="code">STRIKE_STD</field>
|
||||||
|
<field name="default_kind">strike</field>
|
||||||
|
<field name="icon">fa-flash</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<p>Apply thin nickel strike to ensure adhesion before main plate.
|
||||||
|
Record bath ID, time, temperature, electrical readings.</p>
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_step_template_salt_spray_std" model="fp.step.template">
|
||||||
|
<field name="name">Salt Spray Test (ASTM B117)</field>
|
||||||
|
<field name="code">SALT_SPRAY_STD</field>
|
||||||
|
<field name="default_kind">salt_spray</field>
|
||||||
|
<field name="icon">fa-tint</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<p>Submit test panel to salt spray cabinet for the specified
|
||||||
|
duration. Record red rust % and white corrosion %. Attach lab
|
||||||
|
report on completion.</p>
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_step_template_adhesion_std" model="fp.step.template">
|
||||||
|
<field name="name">Adhesion Test (Bend / Tape)</field>
|
||||||
|
<field name="code">ADHESION_STD</field>
|
||||||
|
<field name="default_kind">adhesion_test</field>
|
||||||
|
<field name="icon">fa-link</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<p>Perform adhesion test per spec (bend, tape, burnish, or file).
|
||||||
|
Photo coupon. Record PASS/FAIL.</p>
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_step_template_hardness_std" model="fp.step.template">
|
||||||
|
<field name="name">Microhardness Test</field>
|
||||||
|
<field name="code">HARDNESS_STD</field>
|
||||||
|
<field name="default_kind">hardness_test</field>
|
||||||
|
<field name="icon">fa-cube</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<p>Take three indentations minimum on the test coupon. Record
|
||||||
|
test load, individual readings, and the computed average.
|
||||||
|
Confirm equipment calibration is current.</p>
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_step_template_packaging_std" model="fp.step.template">
|
||||||
|
<field name="name">Packaging (Standard)</field>
|
||||||
|
<field name="code">PKG_STD</field>
|
||||||
|
<field name="default_kind">packaging</field>
|
||||||
|
<field name="icon">fa-archive</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<p>Wrap parts per customer spec (VCI bag, bubble wrap, separator
|
||||||
|
paper). Verify cert package included if required. Record quantity
|
||||||
|
per package and total package count.</p>
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="fp_step_template_replenishment_std" model="fp.step.template">
|
||||||
|
<field name="name">Tank Replenishment</field>
|
||||||
|
<field name="code">REPL_STD</field>
|
||||||
|
<field name="default_kind">replenishment</field>
|
||||||
|
<field name="icon">fa-flask</field>
|
||||||
|
<field name="description"><![CDATA[
|
||||||
|
<p>Mid-shift bath top-up. Record bath ID, chemistry added (name
|
||||||
|
and amount), pH and concentration before/after. Operator must
|
||||||
|
sign.</p>
|
||||||
|
]]></field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
"""Post-migration for 19.0.18.7.0 — Step Library audit expansion.
|
||||||
|
|
||||||
|
1. Default `collect=True` on all existing recipe-step inputs.
|
||||||
|
2. Default `collect_measurements=True` on all existing recipe steps.
|
||||||
|
3. Re-run action_seed_default_inputs on every existing template to
|
||||||
|
pull in the newly-added prompts (idempotent — skips rows whose
|
||||||
|
name is already present, so user edits survive).
|
||||||
|
4. Backfill template_input_id by name-matching against the linked
|
||||||
|
library template (best-effort).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
if not version:
|
||||||
|
return
|
||||||
|
from odoo.api import Environment, SUPERUSER_ID
|
||||||
|
env = Environment(cr, SUPERUSER_ID, {})
|
||||||
|
|
||||||
|
# 1. Default collect=True on all recipe-step inputs that have NULL
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fusion_plating_process_node_input
|
||||||
|
SET collect = TRUE
|
||||||
|
WHERE collect IS NULL
|
||||||
|
""")
|
||||||
|
_logger.info(
|
||||||
|
"Backfilled collect=True on %s recipe-step inputs", cr.rowcount
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Default collect_measurements=True on recipe steps with NULL
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fusion_plating_process_node
|
||||||
|
SET collect_measurements = TRUE
|
||||||
|
WHERE collect_measurements IS NULL
|
||||||
|
""")
|
||||||
|
_logger.info(
|
||||||
|
"Backfilled collect_measurements=True on %s recipe steps", cr.rowcount
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Re-seed defaults on every existing template (idempotent)
|
||||||
|
Template = env['fp.step.template']
|
||||||
|
templates = Template.search([('default_kind', '!=', False)])
|
||||||
|
for tpl in templates:
|
||||||
|
try:
|
||||||
|
tpl.action_seed_default_inputs()
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(
|
||||||
|
"Failed to re-seed defaults on template %s: %s", tpl.id, e
|
||||||
|
)
|
||||||
|
_logger.info("Re-seeded defaults on %s templates", len(templates))
|
||||||
|
|
||||||
|
# 4. Backfill template_input_id — name-match recipe-node inputs against
|
||||||
|
# their parent recipe's source library template.
|
||||||
|
# Note: fusion_plating_process_node_input.name is plain varchar;
|
||||||
|
# fp_step_template_input.name is translatable JSONB (use ->>'en_US').
|
||||||
|
cr.execute("""
|
||||||
|
SELECT ni.id, ni.name, n.source_template_id
|
||||||
|
FROM fusion_plating_process_node_input ni
|
||||||
|
JOIN fusion_plating_process_node n ON n.id = ni.node_id
|
||||||
|
WHERE ni.template_input_id IS NULL
|
||||||
|
AND n.source_template_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
rows = cr.fetchall()
|
||||||
|
matched = 0
|
||||||
|
for ni_id, name, tpl_id in rows:
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
cr.execute("""
|
||||||
|
SELECT id FROM fp_step_template_input
|
||||||
|
WHERE template_id = %s
|
||||||
|
AND name->>'en_US' = %s
|
||||||
|
LIMIT 1
|
||||||
|
""", (tpl_id, name))
|
||||||
|
match = cr.fetchone()
|
||||||
|
if match:
|
||||||
|
cr.execute("""
|
||||||
|
UPDATE fusion_plating_process_node_input
|
||||||
|
SET template_input_id = %s WHERE id = %s
|
||||||
|
""", (match[0], ni_id))
|
||||||
|
matched += 1
|
||||||
|
_logger.info(
|
||||||
|
"Backfilled template_input_id on %s recipe-step inputs", matched
|
||||||
|
)
|
||||||
@@ -103,6 +103,15 @@ class FpProcessNode(models.Model):
|
|||||||
string='Description',
|
string='Description',
|
||||||
help='Rich text instructions for this step.',
|
help='Rich text instructions for this step.',
|
||||||
)
|
)
|
||||||
|
# Sub 12d — master switch for runtime data collection. When False the
|
||||||
|
# operator wizard skips this step entirely (no input prompts shown).
|
||||||
|
collect_measurements = fields.Boolean(
|
||||||
|
string='Collect Measurements at Runtime',
|
||||||
|
default=True,
|
||||||
|
help='Master switch. When off, the operator wizard skips this step '
|
||||||
|
'entirely (no input prompts shown). Use for housekeeping steps '
|
||||||
|
'or when no measurement is needed for this recipe.',
|
||||||
|
)
|
||||||
notes = fields.Text(
|
notes = fields.Text(
|
||||||
string='Internal Notes',
|
string='Internal Notes',
|
||||||
help='Internal notes (not shown to customers).',
|
help='Internal notes (not shown to customers).',
|
||||||
@@ -633,6 +642,10 @@ class FpProcessNodeInput(models.Model):
|
|||||||
('signature', 'Signature'),
|
('signature', 'Signature'),
|
||||||
('location_picker', 'Location Picker'),
|
('location_picker', 'Location Picker'),
|
||||||
('customer_wo', 'Customer WO #'),
|
('customer_wo', 'Customer WO #'),
|
||||||
|
('photo', 'Photo'),
|
||||||
|
('multi_point_thickness', 'Multi-Point Thickness (avg)'),
|
||||||
|
('bath_chemistry_panel', 'Bath Chemistry Panel'),
|
||||||
|
('ph', 'pH'),
|
||||||
],
|
],
|
||||||
string='Input Type',
|
string='Input Type',
|
||||||
required=True,
|
required=True,
|
||||||
@@ -695,3 +708,21 @@ class FpProcessNodeInput(models.Model):
|
|||||||
],
|
],
|
||||||
string='Compliance Tag', default='none',
|
string='Compliance Tag', default='none',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ===== Sub 12d — per-recipe configurability =============================
|
||||||
|
collect = fields.Boolean(
|
||||||
|
string='Collect This Measurement',
|
||||||
|
default=True,
|
||||||
|
help='Toggle off to skip this prompt at runtime without deleting '
|
||||||
|
'it. Recipe authors use this to opt out of library-seeded '
|
||||||
|
'prompts without affecting the library itself.',
|
||||||
|
)
|
||||||
|
template_input_id = fields.Many2one(
|
||||||
|
'fp.step.template.input',
|
||||||
|
string='Source Library Prompt',
|
||||||
|
ondelete='set null',
|
||||||
|
help='Set when this row was snapshot-copied from a library template '
|
||||||
|
'prompt. Powers "Reset to Library Defaults" — rows where this '
|
||||||
|
'is False are treated as recipe-only custom prompts and survive '
|
||||||
|
'the reset.',
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
from odoo import api, fields, models
|
from odoo import _, api, fields, models
|
||||||
|
|
||||||
|
|
||||||
class FpStepTemplate(models.Model):
|
class FpStepTemplate(models.Model):
|
||||||
@@ -75,22 +75,30 @@ class FpStepTemplate(models.Model):
|
|||||||
help='Opens the transition form before Mark Done (Sub 12b).')
|
help='Opens the transition form before Mark Done (Sub 12b).')
|
||||||
|
|
||||||
default_kind = fields.Selection([
|
default_kind = fields.Selection([
|
||||||
('cleaning', 'Cleaning'),
|
('receiving', 'Receiving / Incoming Inspection'),
|
||||||
('etch', 'Etch'),
|
('contract_review', 'Contract Review (QA-005)'),
|
||||||
('rinse', 'Rinse'),
|
|
||||||
('plate', 'Plating'),
|
|
||||||
('bake', 'Bake'),
|
|
||||||
('inspect', 'Inspection'),
|
|
||||||
('racking', 'Racking'),
|
('racking', 'Racking'),
|
||||||
('derack', 'De-Racking'),
|
|
||||||
('mask', 'Masking'),
|
('mask', 'Masking'),
|
||||||
('demask', 'De-Masking'),
|
('cleaning', 'Cleaning'),
|
||||||
('dry', 'Drying'),
|
('electroclean', 'Electroclean'),
|
||||||
|
('etch', 'Etch / Activation'),
|
||||||
|
('rinse', 'Rinse'),
|
||||||
|
('strike', 'Strike (Wood\'s Nickel / Activation)'),
|
||||||
|
('plate', 'Plating'),
|
||||||
|
('replenishment', 'Tank Replenishment'),
|
||||||
('wbf_test', 'Water Break Free Test'),
|
('wbf_test', 'Water Break Free Test'),
|
||||||
|
('dry', 'Drying'),
|
||||||
|
('bake', 'Bake (HE Relief / Stress Relief)'),
|
||||||
|
('demask', 'De-Masking'),
|
||||||
|
('derack', 'De-Racking'),
|
||||||
|
('inspect', 'Inspection'),
|
||||||
|
('hardness_test', 'Hardness Test (HV / HK / HRC)'),
|
||||||
|
('adhesion_test', 'Adhesion Test'),
|
||||||
|
('salt_spray', 'Salt Spray / Corrosion Test'),
|
||||||
('final_inspect', 'Final Inspection'),
|
('final_inspect', 'Final Inspection'),
|
||||||
|
('packaging', 'Packaging / Pre-Ship'),
|
||||||
('ship', 'Shipping'),
|
('ship', 'Shipping'),
|
||||||
('gating', 'Gating'),
|
('gating', 'Gating'),
|
||||||
('contract_review', 'Contract Review (QA-005)'),
|
|
||||||
], string='Step Kind', help='Drives sane-default input seeding.')
|
], string='Step Kind', help='Drives sane-default input seeding.')
|
||||||
|
|
||||||
input_template_ids = fields.One2many(
|
input_template_ids = fields.One2many(
|
||||||
@@ -138,43 +146,183 @@ class FpStepTemplate(models.Model):
|
|||||||
# each → 'each', min → 'min'. Format-only strings ('HH:MM') get
|
# each → 'each', min → 'min'. Format-only strings ('HH:MM') get
|
||||||
# left blank since they're not units.
|
# left blank since they're not units.
|
||||||
DEFAULT_INPUTS_BY_KIND = {
|
DEFAULT_INPUTS_BY_KIND = {
|
||||||
|
'receiving': [
|
||||||
|
{'name': 'Qty Received', 'input_type': 'number',
|
||||||
|
'target_unit': 'each', 'sequence': 10, 'required': True},
|
||||||
|
{'name': 'Qty Rejected', 'input_type': 'number',
|
||||||
|
'target_unit': 'each', 'sequence': 20},
|
||||||
|
{'name': 'Customer PO# Verified', 'input_type': 'boolean', 'sequence': 30},
|
||||||
|
{'name': 'Packing Slip #', 'input_type': 'text', 'sequence': 40},
|
||||||
|
{'name': 'Condition Notes', 'input_type': 'text', 'sequence': 50},
|
||||||
|
{'name': 'Damage Photo', 'input_type': 'photo', 'sequence': 60},
|
||||||
|
{'name': 'Inspector Initials', 'input_type': 'signature',
|
||||||
|
'sequence': 70, 'required': True},
|
||||||
|
],
|
||||||
'cleaning': [
|
'cleaning': [
|
||||||
{'name': 'Actual Time', 'input_type': 'time_seconds',
|
{'name': 'Actual Time', 'input_type': 'time_seconds',
|
||||||
'target_unit': 's', 'sequence': 10},
|
'target_unit': 's', 'sequence': 10},
|
||||||
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
||||||
'target_unit': 'f', 'sequence': 20},
|
'target_unit': 'f', 'sequence': 20},
|
||||||
|
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 30},
|
||||||
|
{'name': 'Ultrasonic On', 'input_type': 'boolean', 'sequence': 40},
|
||||||
|
{'name': 'Titration Done', 'input_type': 'boolean', 'sequence': 50},
|
||||||
|
],
|
||||||
|
'electroclean': [
|
||||||
|
{'name': 'Actual Time', 'input_type': 'time_seconds',
|
||||||
|
'target_unit': 's', 'sequence': 10},
|
||||||
|
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
||||||
|
'target_unit': 'f', 'sequence': 20},
|
||||||
|
{'name': 'Amperage', 'input_type': 'number', 'sequence': 30,
|
||||||
|
'hint': 'A'},
|
||||||
|
{'name': 'Voltage', 'input_type': 'number', 'sequence': 40,
|
||||||
|
'hint': 'V'},
|
||||||
|
{'name': 'Current Density', 'input_type': 'number', 'sequence': 50,
|
||||||
|
'hint': 'ASF (A per sq ft)'},
|
||||||
|
{'name': 'Polarity', 'input_type': 'selection', 'sequence': 60,
|
||||||
|
'selection_options': 'anodic,cathodic,periodic'},
|
||||||
|
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 70},
|
||||||
],
|
],
|
||||||
'etch': [
|
'etch': [
|
||||||
{'name': 'Actual Time', 'input_type': 'time_seconds',
|
{'name': 'Actual Time', 'input_type': 'time_seconds',
|
||||||
'target_unit': 's', 'sequence': 10},
|
'target_unit': 's', 'sequence': 10},
|
||||||
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
||||||
'target_unit': 'f', 'sequence': 20},
|
'target_unit': 'f', 'sequence': 20},
|
||||||
|
{'name': 'Acid Concentration', 'input_type': 'number', 'sequence': 30,
|
||||||
|
'hint': '% or g/L'},
|
||||||
|
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 40},
|
||||||
|
{'name': 'HE Risk Flag', 'input_type': 'boolean', 'sequence': 50,
|
||||||
|
'hint': 'Hydrogen Embrittlement risk for high-strength steel'},
|
||||||
|
],
|
||||||
|
'rinse': [
|
||||||
|
{'name': 'Rinse Type', 'input_type': 'selection', 'sequence': 10,
|
||||||
|
'selection_options': 'cascade,spray,DI,city'},
|
||||||
|
{'name': 'Conductivity', 'input_type': 'number', 'sequence': 20,
|
||||||
|
'hint': 'µS/cm — required for DI rinses'},
|
||||||
|
{'name': 'Actual Time', 'input_type': 'time_seconds',
|
||||||
|
'target_unit': 's', 'sequence': 30},
|
||||||
|
],
|
||||||
|
'strike': [
|
||||||
|
{'name': 'Actual Time', 'input_type': 'time_seconds',
|
||||||
|
'target_unit': 's', 'sequence': 10},
|
||||||
|
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
||||||
|
'target_unit': 'f', 'sequence': 20},
|
||||||
|
{'name': 'Amperage', 'input_type': 'number', 'sequence': 30,
|
||||||
|
'hint': 'A'},
|
||||||
|
{'name': 'Voltage', 'input_type': 'number', 'sequence': 40,
|
||||||
|
'hint': 'V'},
|
||||||
|
{'name': 'Current Density', 'input_type': 'number', 'sequence': 50,
|
||||||
|
'hint': 'ASF'},
|
||||||
|
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 60},
|
||||||
],
|
],
|
||||||
'rinse': [],
|
|
||||||
'plate': [
|
'plate': [
|
||||||
{'name': 'Actual Time', 'input_type': 'time_hms',
|
{'name': 'Actual Time', 'input_type': 'time_hms',
|
||||||
'target_unit': 'min', 'sequence': 10},
|
'target_unit': 'min', 'sequence': 10},
|
||||||
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
||||||
'target_unit': 'f', 'sequence': 20},
|
'target_unit': 'f', 'sequence': 20},
|
||||||
{'name': 'Plating Thickness', 'input_type': 'thickness',
|
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 30},
|
||||||
'target_unit': 'in', 'sequence': 30},
|
{'name': 'pH', 'input_type': 'ph', 'sequence': 40},
|
||||||
|
{'name': 'Bath Concentration', 'input_type': 'number', 'sequence': 50,
|
||||||
|
'hint': 'g/L'},
|
||||||
|
{'name': 'Current Density', 'input_type': 'number', 'sequence': 60,
|
||||||
|
'hint': 'ASF — electroplate only'},
|
||||||
|
{'name': 'Plating Thickness', 'input_type': 'multi_point_thickness',
|
||||||
|
'target_unit': 'in', 'sequence': 70},
|
||||||
],
|
],
|
||||||
'bake': [
|
'replenishment': [
|
||||||
{'name': 'Time In', 'input_type': 'text', 'sequence': 10},
|
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 10,
|
||||||
{'name': 'Time Out', 'input_type': 'text', 'sequence': 20},
|
'required': True},
|
||||||
|
{'name': 'Chemistry Added', 'input_type': 'text', 'sequence': 20,
|
||||||
|
'hint': 'name + amount, e.g. "Nickel sulfamate 500mL"'},
|
||||||
|
{'name': 'pH Before', 'input_type': 'ph', 'sequence': 30},
|
||||||
|
{'name': 'pH After', 'input_type': 'ph', 'sequence': 40},
|
||||||
|
{'name': 'Concentration Before', 'input_type': 'number', 'sequence': 50},
|
||||||
|
{'name': 'Concentration After', 'input_type': 'number', 'sequence': 60},
|
||||||
|
{'name': 'Operator Initials', 'input_type': 'signature',
|
||||||
|
'sequence': 70, 'required': True},
|
||||||
|
],
|
||||||
|
'wbf_test': [
|
||||||
|
{'name': 'Result', 'input_type': 'pass_fail', 'sequence': 10,
|
||||||
|
'required': True},
|
||||||
|
{'name': 'Retest Count', 'input_type': 'number', 'sequence': 20},
|
||||||
|
{'name': 'Photo on FAIL', 'input_type': 'photo', 'sequence': 30},
|
||||||
|
],
|
||||||
|
'dry': [
|
||||||
|
{'name': 'Dry Method', 'input_type': 'selection', 'sequence': 10,
|
||||||
|
'selection_options': 'hot air,oven,spin'},
|
||||||
|
{'name': 'Actual Time', 'input_type': 'time_seconds',
|
||||||
|
'target_unit': 's', 'sequence': 20},
|
||||||
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
||||||
'target_unit': 'f', 'sequence': 30},
|
'target_unit': 'f', 'sequence': 30},
|
||||||
],
|
],
|
||||||
|
'bake': [
|
||||||
|
{'name': 'Time In', 'input_type': 'date', 'sequence': 10},
|
||||||
|
{'name': 'Time Out', 'input_type': 'date', 'sequence': 20},
|
||||||
|
{'name': 'Actual Temperature', 'input_type': 'temperature',
|
||||||
|
'target_unit': 'f', 'sequence': 30},
|
||||||
|
{'name': 'Oven ID', 'input_type': 'text', 'sequence': 40},
|
||||||
|
{'name': 'Chart Recorder File', 'input_type': 'photo', 'sequence': 50,
|
||||||
|
'hint': 'Attach AMS-2759 chart-recorder file'},
|
||||||
|
],
|
||||||
'racking': [
|
'racking': [
|
||||||
{'name': 'Actual Qty', 'input_type': 'number',
|
{'name': 'Actual Qty', 'input_type': 'number',
|
||||||
'target_unit': 'each', 'sequence': 10},
|
'target_unit': 'each', 'sequence': 10, 'required': True},
|
||||||
|
{'name': 'Rack ID', 'input_type': 'text', 'sequence': 20},
|
||||||
|
{'name': 'Masking Applied', 'input_type': 'boolean', 'sequence': 30},
|
||||||
|
{'name': 'Photo of Racked Load', 'input_type': 'photo', 'sequence': 40},
|
||||||
],
|
],
|
||||||
'derack': [
|
'derack': [
|
||||||
{'name': 'Actual Qty', 'input_type': 'number',
|
{'name': 'Actual Qty', 'input_type': 'number',
|
||||||
'target_unit': 'each', 'sequence': 10},
|
'target_unit': 'each', 'sequence': 10},
|
||||||
|
{'name': 'Mask Removal Method', 'input_type': 'selection', 'sequence': 20,
|
||||||
|
'selection_options': 'mechanical,solvent,thermal,not applicable'},
|
||||||
|
{'name': 'Residue Check', 'input_type': 'pass_fail', 'sequence': 30},
|
||||||
|
],
|
||||||
|
'mask': [
|
||||||
|
{'name': 'Actual Qty', 'input_type': 'number',
|
||||||
|
'target_unit': 'each', 'sequence': 10},
|
||||||
|
{'name': 'Mask Material', 'input_type': 'selection', 'sequence': 20,
|
||||||
|
'selection_options': 'Microshield,latex tape,vinyl plugs,wax,other'},
|
||||||
|
{'name': 'Photo of Masked Parts', 'input_type': 'photo', 'sequence': 30},
|
||||||
|
],
|
||||||
|
'demask': [
|
||||||
|
{'name': 'Residue Check', 'input_type': 'pass_fail', 'sequence': 10},
|
||||||
|
{'name': 'Surface Condition', 'input_type': 'selection', 'sequence': 20,
|
||||||
|
'selection_options': 'clean,marks,needs rework'},
|
||||||
],
|
],
|
||||||
'inspect': [
|
'inspect': [
|
||||||
{'name': 'PASS/FAIL', 'input_type': 'pass_fail', 'sequence': 10},
|
{'name': 'Result', 'input_type': 'pass_fail', 'sequence': 10,
|
||||||
|
'required': True},
|
||||||
|
{'name': 'Defect Type', 'input_type': 'selection', 'sequence': 20,
|
||||||
|
'selection_options': 'pitting,burn,blister,peel,missing coverage,none'},
|
||||||
|
{'name': 'Thickness Sample', 'input_type': 'thickness',
|
||||||
|
'target_unit': 'in', 'sequence': 30},
|
||||||
|
{'name': 'Photo', 'input_type': 'photo', 'sequence': 40},
|
||||||
|
{'name': 'Inspector Signature', 'input_type': 'signature', 'sequence': 50},
|
||||||
|
],
|
||||||
|
'hardness_test': [
|
||||||
|
{'name': 'Test Load', 'input_type': 'number', 'sequence': 10,
|
||||||
|
'hint': 'gf'},
|
||||||
|
{'name': 'Readings (HV/HK/HRC)', 'input_type': 'multi_point_thickness',
|
||||||
|
'sequence': 20, 'hint': 'Three indents minimum'},
|
||||||
|
{'name': 'Equipment ID', 'input_type': 'text', 'sequence': 30},
|
||||||
|
{'name': 'Last Calibration Date', 'input_type': 'date', 'sequence': 40},
|
||||||
|
],
|
||||||
|
'adhesion_test': [
|
||||||
|
{'name': 'Test Method', 'input_type': 'selection', 'sequence': 10,
|
||||||
|
'selection_options': 'bend,tape,burnish,file'},
|
||||||
|
{'name': 'Result', 'input_type': 'pass_fail', 'sequence': 20,
|
||||||
|
'required': True},
|
||||||
|
{'name': 'Photo of Coupon', 'input_type': 'photo', 'sequence': 30},
|
||||||
|
],
|
||||||
|
'salt_spray': [
|
||||||
|
{'name': 'Test Duration', 'input_type': 'number', 'sequence': 10,
|
||||||
|
'hint': 'hours'},
|
||||||
|
{'name': 'Result', 'input_type': 'pass_fail', 'sequence': 20,
|
||||||
|
'required': True},
|
||||||
|
{'name': 'Red Rust %', 'input_type': 'number', 'sequence': 30},
|
||||||
|
{'name': 'White Corrosion %', 'input_type': 'number', 'sequence': 40},
|
||||||
|
{'name': 'Lab Report', 'input_type': 'photo', 'sequence': 50,
|
||||||
|
'hint': 'Attach scanned lab report'},
|
||||||
],
|
],
|
||||||
'final_inspect': [
|
'final_inspect': [
|
||||||
{'name': 'Outgoing Part Count Verified',
|
{'name': 'Outgoing Part Count Verified',
|
||||||
@@ -183,35 +331,80 @@ class FpStepTemplate(models.Model):
|
|||||||
'target_unit': 'each', 'sequence': 20},
|
'target_unit': 'each', 'sequence': 20},
|
||||||
{'name': 'Qty Rejected', 'input_type': 'number',
|
{'name': 'Qty Rejected', 'input_type': 'number',
|
||||||
'target_unit': 'each', 'sequence': 30},
|
'target_unit': 'each', 'sequence': 30},
|
||||||
|
{'name': 'Defect Categorization', 'input_type': 'selection', 'sequence': 35,
|
||||||
|
'selection_options': 'pitting,burn,blister,peel,missing coverage,dimensional,none'},
|
||||||
{'name': 'Actual Coating Thickness',
|
{'name': 'Actual Coating Thickness',
|
||||||
'input_type': 'thickness', 'target_unit': 'in', 'sequence': 40},
|
'input_type': 'multi_point_thickness',
|
||||||
{'name': 'Pass/Fail', 'input_type': 'pass_fail', 'sequence': 50},
|
'target_unit': 'in', 'sequence': 40},
|
||||||
|
{'name': 'Dimensional Verification', 'input_type': 'pass_fail',
|
||||||
|
'sequence': 45},
|
||||||
|
{'name': 'Surface Finish (Ra)', 'input_type': 'number', 'sequence': 47,
|
||||||
|
'hint': 'µin'},
|
||||||
|
{'name': 'Pass/Fail', 'input_type': 'pass_fail', 'sequence': 50,
|
||||||
|
'required': True},
|
||||||
|
{'name': 'Inspector Signature', 'input_type': 'signature', 'sequence': 60},
|
||||||
],
|
],
|
||||||
'wbf_test': [
|
'packaging': [
|
||||||
{'name': 'PASS/FAIL', 'input_type': 'pass_fail', 'sequence': 10},
|
{'name': 'Packaging Type', 'input_type': 'selection', 'sequence': 10,
|
||||||
|
'selection_options': 'VCI bag,bubble wrap,separator paper,custom crate,other'},
|
||||||
|
{'name': 'Qty Per Package', 'input_type': 'number',
|
||||||
|
'target_unit': 'each', 'sequence': 20},
|
||||||
|
{'name': 'Package Count', 'input_type': 'number', 'sequence': 30},
|
||||||
|
{'name': 'Cert Package Included', 'input_type': 'boolean', 'sequence': 40},
|
||||||
|
{'name': 'Customer-Supplied Packaging', 'input_type': 'boolean',
|
||||||
|
'sequence': 50},
|
||||||
],
|
],
|
||||||
'mask': [
|
|
||||||
{'name': 'Actual Qty', 'input_type': 'number',
|
|
||||||
'target_unit': 'each', 'sequence': 10},
|
|
||||||
],
|
|
||||||
'demask': [],
|
|
||||||
'dry': [],
|
|
||||||
'ship': [
|
'ship': [
|
||||||
{'name': 'Outgoing Qty', 'input_type': 'number',
|
{'name': 'Outgoing Qty', 'input_type': 'number',
|
||||||
'target_unit': 'each', 'sequence': 10},
|
'target_unit': 'each', 'sequence': 10, 'required': True},
|
||||||
|
{'name': 'Carrier', 'input_type': 'selection', 'sequence': 20,
|
||||||
|
'selection_options': 'UPS,FedEx,Purolator,Customer Pickup,Other'},
|
||||||
|
{'name': 'Tracking #', 'input_type': 'text', 'sequence': 30},
|
||||||
|
{'name': 'BoL #', 'input_type': 'text', 'sequence': 40},
|
||||||
|
{'name': 'Photo of Sealed Shipment', 'input_type': 'photo',
|
||||||
|
'sequence': 50},
|
||||||
],
|
],
|
||||||
'gating': [],
|
'gating': [],
|
||||||
# Sub 4 + 12c follow-up — Contract Review step (Policy B).
|
|
||||||
# The shop-floor step itself is a tickbox; the heavy QA-005 form
|
|
||||||
# is opened via fp.contract.review (separate model). These
|
|
||||||
# inputs capture summary fields for the chronological CoC.
|
|
||||||
'contract_review': [
|
'contract_review': [
|
||||||
{'name': 'Reviewer Initials', 'input_type': 'text', 'sequence': 10},
|
{'name': 'Reviewer Initials', 'input_type': 'signature', 'sequence': 10},
|
||||||
{'name': 'Date Reviewed', 'input_type': 'date', 'sequence': 20},
|
{'name': 'Date Reviewed', 'input_type': 'date', 'sequence': 20},
|
||||||
{'name': 'QA-005 Approved', 'input_type': 'pass_fail', 'sequence': 30},
|
{'name': 'QA-005 Approved', 'input_type': 'pass_fail', 'sequence': 30},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
COMMON_AUDIT_FIELDS = [
|
||||||
|
{'name': 'Operator Initials', 'input_type': 'signature',
|
||||||
|
'required': True, 'sequence': 800},
|
||||||
|
{'name': 'Bath ID', 'input_type': 'text', 'sequence': 810},
|
||||||
|
{'name': 'Photo on Failure', 'input_type': 'photo', 'sequence': 820,
|
||||||
|
'hint': 'upload only if failure observed'},
|
||||||
|
{'name': 'Equipment ID', 'input_type': 'text', 'sequence': 830},
|
||||||
|
]
|
||||||
|
|
||||||
|
def action_add_common_audit_fields(self):
|
||||||
|
"""Idempotently append the common audit fields to this template.
|
||||||
|
Skips rows whose name already exists. Logs to chatter.
|
||||||
|
"""
|
||||||
|
Input = self.env['fp.step.template.input']
|
||||||
|
for tpl in self:
|
||||||
|
existing_names = set(tpl.input_template_ids.mapped('name'))
|
||||||
|
added = []
|
||||||
|
for spec in self.COMMON_AUDIT_FIELDS:
|
||||||
|
if spec['name'] in existing_names:
|
||||||
|
continue
|
||||||
|
Input.create({
|
||||||
|
'template_id': tpl.id,
|
||||||
|
**spec,
|
||||||
|
})
|
||||||
|
added.append(spec['name'])
|
||||||
|
if added:
|
||||||
|
tpl.message_post(
|
||||||
|
body=_('Added common audit fields: %s') % ', '.join(added),
|
||||||
|
message_type='notification',
|
||||||
|
subtype_xmlid='mail.mt_note',
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
def action_seed_default_inputs(self):
|
def action_seed_default_inputs(self):
|
||||||
"""Seed input_template_ids based on default_kind. Idempotent —
|
"""Seed input_template_ids based on default_kind. Idempotent —
|
||||||
only adds inputs whose names don't already exist on this template.
|
only adds inputs whose names don't already exist on this template.
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class FpStepTemplateInput(models.Model):
|
|||||||
('temperature', 'Temperature'),
|
('temperature', 'Temperature'),
|
||||||
('thickness', 'Thickness'),
|
('thickness', 'Thickness'),
|
||||||
('pass_fail', 'Pass / Fail'),
|
('pass_fail', 'Pass / Fail'),
|
||||||
|
('photo', 'Photo'),
|
||||||
|
('multi_point_thickness', 'Multi-Point Thickness (avg)'),
|
||||||
|
('bath_chemistry_panel', 'Bath Chemistry Panel'),
|
||||||
|
('ph', 'pH'),
|
||||||
], string='Input Type', required=True, default='text')
|
], string='Input Type', required=True, default='text')
|
||||||
target_min = fields.Float(string='Target Min',
|
target_min = fields.Float(string='Target Min',
|
||||||
help='Lower bound of the acceptable range, expressed in Target Unit.')
|
help='Lower bound of the acceptable range, expressed in Target Unit.')
|
||||||
|
|||||||
167
fusion_plating/fusion_plating/scripts/bt_step_library_audit.py
Normal file
167
fusion_plating/fusion_plating/scripts/bt_step_library_audit.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Battle test — Step Library audit expansion (Sub 12d).
|
||||||
|
|
||||||
|
Run via odoo-shell on entech:
|
||||||
|
|
||||||
|
cat bt_step_library_audit.py | ssh pve-worker5 "pct exec 111 -- bash -c \\
|
||||||
|
'su - odoo -s /bin/bash -c \"/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http\"'"
|
||||||
|
|
||||||
|
Asserts properties of the new architecture and prints PASS/FAIL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NEW_KINDS = [
|
||||||
|
'receiving', 'electroclean', 'strike', 'salt_spray',
|
||||||
|
'adhesion_test', 'hardness_test', 'packaging', 'replenishment',
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(idx, name, condition, detail=''):
|
||||||
|
status = 'PASS' if condition else 'FAIL'
|
||||||
|
results.append((idx, name, status, detail))
|
||||||
|
print('[%s] #%-2d %s -- %s' % (status, idx, name, detail))
|
||||||
|
|
||||||
|
|
||||||
|
Template = env['fp.step.template']
|
||||||
|
Node = env['fusion.plating.process.node']
|
||||||
|
NodeInput = env['fusion.plating.process.node.input']
|
||||||
|
|
||||||
|
# 1. Every new Step Kind has at least 1 seed template loaded
|
||||||
|
for kind in NEW_KINDS:
|
||||||
|
cnt = Template.search_count([('default_kind', '=', kind)])
|
||||||
|
check(1, 'seed template for kind %s' % kind, cnt >= 1,
|
||||||
|
'%d found' % cnt)
|
||||||
|
|
||||||
|
# 2. New input types reachable from the library Selection
|
||||||
|
itypes = dict(Template._fields['default_kind'].selection)
|
||||||
|
all_kinds_present = all(k in itypes for k in NEW_KINDS)
|
||||||
|
check(2, 'all 8 new kinds in Selection', all_kinds_present,
|
||||||
|
'kinds=%d total in selection' % len(itypes))
|
||||||
|
|
||||||
|
# 3. fp.step.template.input has the 4 new input_type entries
|
||||||
|
ti = dict(env['fp.step.template.input']._fields['input_type'].selection)
|
||||||
|
new_types_present = all(t in ti for t in
|
||||||
|
['photo', 'multi_point_thickness',
|
||||||
|
'bath_chemistry_panel', 'ph'])
|
||||||
|
check(3, 'library input has 4 new types', new_types_present,
|
||||||
|
'%d total types' % len(ti))
|
||||||
|
|
||||||
|
# 4. Recipe-node input has the 4 new input_type entries
|
||||||
|
ni = dict(NodeInput._fields['input_type'].selection)
|
||||||
|
new_types_in_node = all(t in ni for t in
|
||||||
|
['photo', 'multi_point_thickness',
|
||||||
|
'bath_chemistry_panel', 'ph'])
|
||||||
|
check(4, 'recipe-node input has 4 new types', new_types_in_node,
|
||||||
|
'%d total types' % len(ni))
|
||||||
|
|
||||||
|
# 5. collect + collect_measurements + template_input_id fields exist
|
||||||
|
check(5, 'collect on node-input', 'collect' in NodeInput._fields,
|
||||||
|
'present' if 'collect' in NodeInput._fields else 'missing')
|
||||||
|
check(6, 'collect_measurements on node', 'collect_measurements' in Node._fields,
|
||||||
|
'present')
|
||||||
|
check(7, 'template_input_id on node-input', 'template_input_id' in NodeInput._fields,
|
||||||
|
'present')
|
||||||
|
|
||||||
|
# 8. action_seed_default_inputs is idempotent + preserves edits
|
||||||
|
tpl = Template.create({
|
||||||
|
'name': 'BT-SeedIdem-%s' % env.cr.now(),
|
||||||
|
'default_kind': 'plate',
|
||||||
|
})
|
||||||
|
tpl.action_seed_default_inputs()
|
||||||
|
n1 = len(tpl.input_template_ids)
|
||||||
|
# user edit
|
||||||
|
tpl.input_template_ids[0].name = 'EDITED-DO-NOT-CLOBBER'
|
||||||
|
tpl.action_seed_default_inputs()
|
||||||
|
n2 = len(tpl.input_template_ids)
|
||||||
|
edited = tpl.input_template_ids.filtered(
|
||||||
|
lambda i: i.name == 'EDITED-DO-NOT-CLOBBER'
|
||||||
|
)
|
||||||
|
check(8, 'seed idempotent + preserves edits',
|
||||||
|
n1 <= n2 and len(edited) == 1,
|
||||||
|
'before=%d after=%d edited_kept=%s' % (n1, n2, bool(edited)))
|
||||||
|
tpl.unlink()
|
||||||
|
|
||||||
|
# 9. action_add_common_audit_fields is idempotent
|
||||||
|
tpl = Template.create({
|
||||||
|
'name': 'BT-AuditIdem-%s' % env.cr.now(),
|
||||||
|
'default_kind': 'plate',
|
||||||
|
})
|
||||||
|
tpl.action_add_common_audit_fields()
|
||||||
|
m1 = len(tpl.input_template_ids)
|
||||||
|
tpl.action_add_common_audit_fields()
|
||||||
|
m2 = len(tpl.input_template_ids)
|
||||||
|
check(9, 'common audit fields idempotent', m1 == m2,
|
||||||
|
'first=%d second=%d' % (m1, m2))
|
||||||
|
tpl.unlink()
|
||||||
|
|
||||||
|
# 10. collect=True is default on new node-inputs
|
||||||
|
node = Node.create({
|
||||||
|
'name': 'BT-CollectDefault',
|
||||||
|
'node_type': 'step',
|
||||||
|
})
|
||||||
|
ni = NodeInput.create({
|
||||||
|
'node_id': node.id,
|
||||||
|
'name': 'BT-Prompt',
|
||||||
|
'input_type': 'text',
|
||||||
|
'kind': 'step_input',
|
||||||
|
})
|
||||||
|
check(10, 'collect default=True on new node-input', ni.collect,
|
||||||
|
'collect=%s' % ni.collect)
|
||||||
|
|
||||||
|
# 11. collect_measurements=True default on new node
|
||||||
|
check(11, 'collect_measurements default=True on new node',
|
||||||
|
node.collect_measurements,
|
||||||
|
'collect_measurements=%s' % node.collect_measurements)
|
||||||
|
node.unlink()
|
||||||
|
|
||||||
|
# 12. Wizard filter excludes collect=False rows (simulated)
|
||||||
|
node = Node.create({'name': 'BT-Filter', 'node_type': 'step'})
|
||||||
|
ni_on = NodeInput.create({
|
||||||
|
'node_id': node.id, 'name': 'On', 'input_type': 'text',
|
||||||
|
'kind': 'step_input', 'collect': True,
|
||||||
|
})
|
||||||
|
ni_off = NodeInput.create({
|
||||||
|
'node_id': node.id, 'name': 'Off', 'input_type': 'text',
|
||||||
|
'kind': 'step_input', 'collect': False,
|
||||||
|
})
|
||||||
|
visible = node.input_ids.filtered(
|
||||||
|
lambda i: i.kind == 'step_input' and i.collect
|
||||||
|
)
|
||||||
|
check(12, 'wizard filter excludes collect=False',
|
||||||
|
ni_off not in visible and ni_on in visible,
|
||||||
|
'%d/%d visible' % (len(visible), len(node.input_ids)))
|
||||||
|
|
||||||
|
# 13. Master switch path — when False, filter returns empty
|
||||||
|
node.collect_measurements = False
|
||||||
|
empty_path = (not node.collect_measurements)
|
||||||
|
check(13, 'master collect_measurements=False short-circuits',
|
||||||
|
empty_path, 'master=False')
|
||||||
|
node.unlink()
|
||||||
|
|
||||||
|
# 14. Multi-point thickness average compute (unit math, no DB)
|
||||||
|
class _Stub:
|
||||||
|
def __init__(self, *vals):
|
||||||
|
self.point_1, self.point_2, self.point_3, \
|
||||||
|
self.point_4, self.point_5 = vals
|
||||||
|
non_empty = [v for v in vals if v]
|
||||||
|
self.point_avg = sum(non_empty) / len(non_empty) if non_empty else 0
|
||||||
|
s = _Stub(0.001, 0.0012, 0.0011, 0, 0)
|
||||||
|
check(14, 'multi-point avg skips empties',
|
||||||
|
round(s.point_avg, 5) == 0.0011,
|
||||||
|
'avg=%.5f' % s.point_avg)
|
||||||
|
|
||||||
|
# 15. Sample DEFAULT_INPUTS_BY_KIND payload present for each new kind
|
||||||
|
for kind in NEW_KINDS:
|
||||||
|
seeded = Template.DEFAULT_INPUTS_BY_KIND.get(kind, [])
|
||||||
|
check(15, 'defaults dict has entries for %s' % kind,
|
||||||
|
len(seeded) >= 1,
|
||||||
|
'%d default prompts' % len(seeded))
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
total = len(results)
|
||||||
|
passed = sum(1 for r in results if r[2] == 'PASS')
|
||||||
|
failed = sum(1 for r in results if r[2] == 'FAIL')
|
||||||
|
print('\n=== %d / %d PASSED -- %d FAILED ===' % (passed, total, failed))
|
||||||
|
|
||||||
|
env.cr.commit()
|
||||||
@@ -117,16 +117,28 @@
|
|||||||
<field name="description" widget="html"/>
|
<field name="description" widget="html"/>
|
||||||
</page>
|
</page>
|
||||||
<page string="Operator Inputs" name="inputs">
|
<page string="Operator Inputs" name="inputs">
|
||||||
|
<group>
|
||||||
|
<field name="collect_measurements"
|
||||||
|
widget="boolean_toggle"
|
||||||
|
help="Master switch — when off, the operator wizard skips this step entirely."/>
|
||||||
|
</group>
|
||||||
<field name="input_ids">
|
<field name="input_ids">
|
||||||
<list editable="bottom">
|
<list editable="bottom">
|
||||||
<field name="sequence" widget="handle"/>
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="collect" widget="boolean_toggle"
|
||||||
|
string="Collect"/>
|
||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="input_type"/>
|
<field name="input_type"/>
|
||||||
|
<field name="kind" optional="hide"/>
|
||||||
|
<field name="target_min" optional="hide"/>
|
||||||
|
<field name="target_max" optional="hide"/>
|
||||||
|
<field name="target_unit" optional="hide"/>
|
||||||
<field name="required"/>
|
<field name="required"/>
|
||||||
<field name="hint"/>
|
<field name="hint"/>
|
||||||
<field name="uom"/>
|
|
||||||
<field name="selection_options"
|
<field name="selection_options"
|
||||||
invisible="input_type != 'selection'"/>
|
invisible="input_type != 'selection'"/>
|
||||||
|
<field name="template_input_id" optional="hide"
|
||||||
|
string="From Library"/>
|
||||||
</list>
|
</list>
|
||||||
</field>
|
</field>
|
||||||
</page>
|
</page>
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
<button name="action_seed_default_inputs" type="object"
|
<button name="action_seed_default_inputs" type="object"
|
||||||
string="Seed Default Inputs" class="btn-secondary"
|
string="Seed Default Inputs" class="btn-secondary"
|
||||||
invisible="not default_kind"/>
|
invisible="not default_kind"/>
|
||||||
|
<button name="action_add_common_audit_fields" type="object"
|
||||||
|
string="+ Common Audit Fields"
|
||||||
|
class="btn-secondary"
|
||||||
|
help="Append Operator Initials, Bath ID, Photo on Failure, Equipment ID"/>
|
||||||
</header>
|
</header>
|
||||||
<sheet>
|
<sheet>
|
||||||
<div class="oe_title">
|
<div class="oe_title">
|
||||||
@@ -58,9 +62,14 @@
|
|||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<notebook>
|
<notebook>
|
||||||
<page string="Instructions" name="instructions">
|
<page string="Default Operator Instructions" name="instructions">
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
Standing instructions the office gives operators for this
|
||||||
|
step. Snapshot-copied onto every recipe that uses this
|
||||||
|
step. Recipe authors can override per recipe.
|
||||||
|
</div>
|
||||||
<field name="description"
|
<field name="description"
|
||||||
placeholder="Rich-text instructions / WI reference."/>
|
placeholder="e.g. Mask threaded holes with vinyl plugs. Use Microshield for through-holes."/>
|
||||||
</page>
|
</page>
|
||||||
<page string="Operation Measurements" name="op_measurements">
|
<page string="Operation Measurements" name="op_measurements">
|
||||||
<field name="input_template_ids">
|
<field name="input_template_ids">
|
||||||
|
|||||||
@@ -75,11 +75,55 @@ def _backfill_cloned_process_names(env):
|
|||||||
renamed += 1
|
renamed += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_part_material_id(env):
|
||||||
|
"""Pin existing parts AND quote configurators to a row in the
|
||||||
|
shared material library.
|
||||||
|
|
||||||
|
Pre-Sub-12d, both models only had a `substrate_material` Selection.
|
||||||
|
This sets `material_id` on every record that doesn't yet have one,
|
||||||
|
matching by substrate_material → seed material XML id. Idempotent.
|
||||||
|
"""
|
||||||
|
Part = env['fp.part.catalog']
|
||||||
|
Material = env['fp.part.material']
|
||||||
|
if Part is None or Material is None:
|
||||||
|
return
|
||||||
|
# Map legacy Selection key → seed XML id (the generic per-category entry).
|
||||||
|
xmlid_by_key = {
|
||||||
|
'aluminium': 'fusion_plating_configurator.fp_material_aluminium',
|
||||||
|
'steel': 'fusion_plating_configurator.fp_material_steel',
|
||||||
|
'stainless': 'fusion_plating_configurator.fp_material_stainless',
|
||||||
|
'copper': 'fusion_plating_configurator.fp_material_copper',
|
||||||
|
'titanium': 'fusion_plating_configurator.fp_material_titanium',
|
||||||
|
'other': 'fusion_plating_configurator.fp_material_other',
|
||||||
|
}
|
||||||
|
cache = {}
|
||||||
|
for key, xmlid in xmlid_by_key.items():
|
||||||
|
rec = env.ref(xmlid, raise_if_not_found=False)
|
||||||
|
if rec:
|
||||||
|
cache[key] = rec.id
|
||||||
|
if not cache:
|
||||||
|
return
|
||||||
|
# Parts
|
||||||
|
for part in Part.search([('material_id', '=', False)]):
|
||||||
|
mid = cache.get(part.substrate_material)
|
||||||
|
if mid:
|
||||||
|
part.material_id = mid
|
||||||
|
# Quote configurators (same Selection key → same library)
|
||||||
|
Quote = env['fp.quote.configurator']
|
||||||
|
if Quote is not None:
|
||||||
|
for q in Quote.search([('material_id', '=', False)]):
|
||||||
|
mid = cache.get(q.substrate_material)
|
||||||
|
if mid:
|
||||||
|
q.material_id = mid
|
||||||
|
|
||||||
|
|
||||||
def post_init_hook(env):
|
def post_init_hook(env):
|
||||||
_backfill_currency(env)
|
_backfill_currency(env)
|
||||||
_backfill_cloned_process_names(env)
|
_backfill_cloned_process_names(env)
|
||||||
|
_backfill_part_material_id(env)
|
||||||
|
|
||||||
|
|
||||||
def post_upgrade_hook(env):
|
def post_upgrade_hook(env):
|
||||||
_backfill_currency(env)
|
_backfill_currency(env)
|
||||||
_backfill_cloned_process_names(env)
|
_backfill_cloned_process_names(env)
|
||||||
|
_backfill_part_material_id(env)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Configurator',
|
'name': 'Fusion Plating — Configurator',
|
||||||
'version': '19.0.18.3.2',
|
'version': '19.0.18.6.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -40,7 +40,10 @@ Provides:
|
|||||||
'data/fp_configurator_sequence_data.xml',
|
'data/fp_configurator_sequence_data.xml',
|
||||||
'data/fp_sub5_sequence_data.xml',
|
'data/fp_sub5_sequence_data.xml',
|
||||||
'data/fp_treatment_data.xml',
|
'data/fp_treatment_data.xml',
|
||||||
|
'data/fp_part_material_data.xml',
|
||||||
'views/fp_treatment_views.xml',
|
'views/fp_treatment_views.xml',
|
||||||
|
'views/fp_part_material_views.xml',
|
||||||
|
'views/fp_coating_thickness_views.xml',
|
||||||
'views/fp_part_catalog_views.xml',
|
'views/fp_part_catalog_views.xml',
|
||||||
'views/fp_process_node_part_scoped_views.xml',
|
'views/fp_process_node_part_scoped_views.xml',
|
||||||
'views/fp_coating_config_views.xml',
|
'views/fp_coating_config_views.xml',
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Seed materials. noupdate="1" so users can rename / archive without
|
||||||
|
a module upgrade reverting their edits.
|
||||||
|
-->
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<!-- Aluminium family -->
|
||||||
|
<record id="fp_material_aluminium" model="fp.part.material">
|
||||||
|
<field name="name">Aluminium</field>
|
||||||
|
<field name="category">aluminium</field>
|
||||||
|
<field name="sequence">10</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_aluminium_6061" model="fp.part.material">
|
||||||
|
<field name="name">Aluminium 6061</field>
|
||||||
|
<field name="category">aluminium</field>
|
||||||
|
<field name="sequence">11</field>
|
||||||
|
<field name="notes">Common 6000-series alloy. Magnesium + silicon.</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_aluminium_6063" model="fp.part.material">
|
||||||
|
<field name="name">Aluminium 6063</field>
|
||||||
|
<field name="category">aluminium</field>
|
||||||
|
<field name="sequence">12</field>
|
||||||
|
<field name="notes">Architectural 6000-series alloy.</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_aluminium_7075" model="fp.part.material">
|
||||||
|
<field name="name">Aluminium 7075</field>
|
||||||
|
<field name="category">aluminium</field>
|
||||||
|
<field name="sequence">13</field>
|
||||||
|
<field name="notes">High-strength 7000-series. Aerospace.</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_aluminium_2024" model="fp.part.material">
|
||||||
|
<field name="name">Aluminium 2024</field>
|
||||||
|
<field name="category">aluminium</field>
|
||||||
|
<field name="sequence">14</field>
|
||||||
|
<field name="notes">2000-series. Copper alloy, aerospace.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Steel family -->
|
||||||
|
<record id="fp_material_steel" model="fp.part.material">
|
||||||
|
<field name="name">Steel</field>
|
||||||
|
<field name="category">steel</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_steel_1018" model="fp.part.material">
|
||||||
|
<field name="name">Steel 1018</field>
|
||||||
|
<field name="category">steel</field>
|
||||||
|
<field name="sequence">21</field>
|
||||||
|
<field name="notes">Low-carbon mild steel.</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_steel_4140" model="fp.part.material">
|
||||||
|
<field name="name">Steel 4140</field>
|
||||||
|
<field name="category">steel</field>
|
||||||
|
<field name="sequence">22</field>
|
||||||
|
<field name="notes">Chrome-moly alloy steel.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Stainless family -->
|
||||||
|
<record id="fp_material_stainless" model="fp.part.material">
|
||||||
|
<field name="name">Stainless Steel</field>
|
||||||
|
<field name="category">stainless</field>
|
||||||
|
<field name="sequence">30</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_stainless_304" model="fp.part.material">
|
||||||
|
<field name="name">Stainless 304</field>
|
||||||
|
<field name="category">stainless</field>
|
||||||
|
<field name="sequence">31</field>
|
||||||
|
<field name="notes">Austenitic. General-purpose stainless.</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_stainless_316" model="fp.part.material">
|
||||||
|
<field name="name">Stainless 316</field>
|
||||||
|
<field name="category">stainless</field>
|
||||||
|
<field name="sequence">32</field>
|
||||||
|
<field name="notes">Marine-grade. Molybdenum-bearing.</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_stainless_17_4" model="fp.part.material">
|
||||||
|
<field name="name">Stainless 17-4 PH</field>
|
||||||
|
<field name="category">stainless</field>
|
||||||
|
<field name="sequence">33</field>
|
||||||
|
<field name="notes">Precipitation hardening.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Copper family -->
|
||||||
|
<record id="fp_material_copper" model="fp.part.material">
|
||||||
|
<field name="name">Copper</field>
|
||||||
|
<field name="category">copper</field>
|
||||||
|
<field name="sequence">40</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_brass_360" model="fp.part.material">
|
||||||
|
<field name="name">Brass C360</field>
|
||||||
|
<field name="category">copper</field>
|
||||||
|
<field name="sequence">41</field>
|
||||||
|
<field name="density">8.5</field>
|
||||||
|
<field name="notes">Free-machining brass.</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_bronze" model="fp.part.material">
|
||||||
|
<field name="name">Bronze</field>
|
||||||
|
<field name="category">copper</field>
|
||||||
|
<field name="sequence">42</field>
|
||||||
|
<field name="density">8.8</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Titanium family -->
|
||||||
|
<record id="fp_material_titanium" model="fp.part.material">
|
||||||
|
<field name="name">Titanium</field>
|
||||||
|
<field name="category">titanium</field>
|
||||||
|
<field name="sequence">50</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_titanium_grade_2" model="fp.part.material">
|
||||||
|
<field name="name">Titanium Grade 2</field>
|
||||||
|
<field name="category">titanium</field>
|
||||||
|
<field name="sequence">51</field>
|
||||||
|
<field name="notes">Commercially pure titanium.</field>
|
||||||
|
</record>
|
||||||
|
<record id="fp_material_titanium_grade_5" model="fp.part.material">
|
||||||
|
<field name="name">Titanium Grade 5 (Ti-6Al-4V)</field>
|
||||||
|
<field name="category">titanium</field>
|
||||||
|
<field name="sequence">52</field>
|
||||||
|
<field name="density">4.43</field>
|
||||||
|
<field name="notes">Aerospace alloy.</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Other -->
|
||||||
|
<record id="fp_material_other" model="fp.part.material">
|
||||||
|
<field name="name">Other</field>
|
||||||
|
<field name="category">other</field>
|
||||||
|
<field name="sequence">99</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
# Part of the Fusion Plating product family.
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
from . import fp_treatment
|
from . import fp_treatment
|
||||||
|
from . import fp_part_material
|
||||||
from . import fp_part_catalog
|
from . import fp_part_catalog
|
||||||
from . import fp_coating_thickness
|
from . import fp_coating_thickness
|
||||||
from . import fp_coating_config
|
from . import fp_coating_config
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ class FpPartCatalog(models.Model):
|
|||||||
_description = 'Fusion Plating — Part Catalog'
|
_description = 'Fusion Plating — Part Catalog'
|
||||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||||
_order = 'partner_id, part_number, revision desc'
|
_order = 'partner_id, part_number, revision desc'
|
||||||
|
# Customers always type the part NUMBER in m2o pickers, never the part
|
||||||
|
# name. Routing rec_name to part_number makes both quick-create and
|
||||||
|
# "Create and edit..." land the typed string in the correct field.
|
||||||
|
_rec_name = 'part_number'
|
||||||
|
_rec_names_search = ['part_number', 'name']
|
||||||
|
|
||||||
display_name = fields.Char(
|
display_name = fields.Char(
|
||||||
string='Display Name',
|
string='Display Name',
|
||||||
@@ -44,10 +49,26 @@ class FpPartCatalog(models.Model):
|
|||||||
revision_ids = fields.One2many(
|
revision_ids = fields.One2many(
|
||||||
'fp.part.catalog', 'parent_part_id', string='Revision History',
|
'fp.part.catalog', 'parent_part_id', string='Revision History',
|
||||||
)
|
)
|
||||||
|
# User-facing material picker. Customers want custom materials
|
||||||
|
# (e.g. "Aluminium 6061", "Stainless 316") so this is a m2o into
|
||||||
|
# `fp.part.material`. The legacy `substrate_material` Selection
|
||||||
|
# below is now a stored compute that mirrors `material_id.category`,
|
||||||
|
# which keeps pricing rules / portal / import wizard working
|
||||||
|
# untouched (they still match against the category keys).
|
||||||
|
material_id = fields.Many2one(
|
||||||
|
'fp.part.material', string='Material', tracking=True,
|
||||||
|
ondelete='restrict',
|
||||||
|
help='Pick from the material library or create a custom entry '
|
||||||
|
'(e.g. "Aluminium 6061", "Stainless 316", "Brass C360").',
|
||||||
|
)
|
||||||
substrate_material = fields.Selection(
|
substrate_material = fields.Selection(
|
||||||
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
||||||
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
||||||
string='Substrate Material', default='steel',
|
string='Material Category', default='steel',
|
||||||
|
compute='_compute_substrate_material',
|
||||||
|
store=True, readonly=False,
|
||||||
|
help='Auto-derived from the selected material. Drives pricing '
|
||||||
|
'rule matching and density defaults.',
|
||||||
)
|
)
|
||||||
geometry_source = fields.Selection(
|
geometry_source = fields.Selection(
|
||||||
[('3d_model', '3D Model'), ('manual', 'Manual Measurements'), ('pdf_drawing', 'PDF Drawing')],
|
[('3d_model', '3D Model'), ('manual', 'Manual Measurements'), ('pdf_drawing', 'PDF Drawing')],
|
||||||
@@ -76,6 +97,13 @@ class FpPartCatalog(models.Model):
|
|||||||
string='Surface Area UoM', default='sq_in',
|
string='Surface Area UoM', default='sq_in',
|
||||||
)
|
)
|
||||||
weight = fields.Float(string='Weight (kg)', digits=(12, 4))
|
weight = fields.Float(string='Weight (kg)', digits=(12, 4))
|
||||||
|
x_fc_default_lead_time_days = fields.Integer(
|
||||||
|
string='Default Lead Time (days)',
|
||||||
|
help='Optional. How many days from the order\'s planned-start-date '
|
||||||
|
'this part typically needs. Used as a smart default on order '
|
||||||
|
'lines when no explicit deadline is set. Leave 0 to fall back '
|
||||||
|
'to the order\'s customer deadline.',
|
||||||
|
)
|
||||||
dimensions_length = fields.Float(string='Length', digits=(12, 4))
|
dimensions_length = fields.Float(string='Length', digits=(12, 4))
|
||||||
dimensions_width = fields.Float(string='Width', digits=(12, 4))
|
dimensions_width = fields.Float(string='Width', digits=(12, 4))
|
||||||
dimensions_height = fields.Float(string='Height', digits=(12, 4))
|
dimensions_height = fields.Float(string='Height', digits=(12, 4))
|
||||||
@@ -224,13 +252,34 @@ class FpPartCatalog(models.Model):
|
|||||||
'other': 7.85, # default to steel
|
'other': 7.85, # default to steel
|
||||||
}
|
}
|
||||||
|
|
||||||
@api.depends('volume_mm3', 'substrate_material')
|
@api.depends('material_id', 'material_id.category')
|
||||||
|
def _compute_substrate_material(self):
|
||||||
|
"""Mirror the m2o material's category onto the legacy field.
|
||||||
|
|
||||||
|
Editable: existing parts without a material_id keep whatever
|
||||||
|
value they had (default 'steel'), and admins can still flip
|
||||||
|
the category by hand if needed.
|
||||||
|
"""
|
||||||
|
for rec in self:
|
||||||
|
if rec.material_id:
|
||||||
|
rec.substrate_material = rec.material_id.category
|
||||||
|
elif not rec.substrate_material:
|
||||||
|
rec.substrate_material = 'steel'
|
||||||
|
|
||||||
|
@api.depends('volume_mm3', 'substrate_material', 'material_id', 'material_id.density')
|
||||||
def _compute_material_weight(self):
|
def _compute_material_weight(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if not rec.volume_mm3 or not rec.substrate_material:
|
if not rec.volume_mm3:
|
||||||
|
rec.material_weight_kg = 0.0
|
||||||
|
continue
|
||||||
|
# Prefer per-material density override; fall back to category default.
|
||||||
|
if rec.material_id:
|
||||||
|
density = rec.material_id.effective_density()
|
||||||
|
elif rec.substrate_material:
|
||||||
|
density = self._SUBSTRATE_DENSITY.get(rec.substrate_material, 7.85)
|
||||||
|
else:
|
||||||
rec.material_weight_kg = 0.0
|
rec.material_weight_kg = 0.0
|
||||||
continue
|
continue
|
||||||
density = self._SUBSTRATE_DENSITY.get(rec.substrate_material, 7.85)
|
|
||||||
# mm³ × g/cm³ × 1e-6 = kg
|
# mm³ × g/cm³ × 1e-6 = kg
|
||||||
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
|
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
|
||||||
|
|
||||||
@@ -292,6 +341,27 @@ class FpPartCatalog(models.Model):
|
|||||||
'Part number must be unique per customer.'),
|
'Part number must be unique per customer.'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def default_get(self, fields_list):
|
||||||
|
"""Re-route the m2o-typed string into part_number.
|
||||||
|
|
||||||
|
Odoo 19's m2o "Create and edit..." passes the typed text via
|
||||||
|
context as `default_name` regardless of the target model's
|
||||||
|
`_rec_name`. Customers always type the part NUMBER in the part
|
||||||
|
picker, so we swap it across when part_number wasn't provided
|
||||||
|
explicitly. The legacy `default_name` is dropped so the Part
|
||||||
|
Name field stays empty for the user to fill in (or leave blank).
|
||||||
|
"""
|
||||||
|
ctx = self.env.context
|
||||||
|
if ctx.get('default_name') and not ctx.get('default_part_number'):
|
||||||
|
# with_context merges, so explicitly clear default_name to
|
||||||
|
# stop the typed string from also seeding the Part Name.
|
||||||
|
self = self.with_context(
|
||||||
|
default_part_number=ctx['default_name'],
|
||||||
|
default_name=False,
|
||||||
|
)
|
||||||
|
return super().default_get(fields_list)
|
||||||
|
|
||||||
def write(self, vals):
|
def write(self, vals):
|
||||||
"""Track changes to attachments and propagate to linked configurators."""
|
"""Track changes to attachments and propagate to linked configurators."""
|
||||||
# Snapshot before write
|
# Snapshot before write
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class FpPartMaterial(models.Model):
|
||||||
|
"""Custom material library.
|
||||||
|
|
||||||
|
Lets shops define their own materials (e.g. "Aluminium 6061",
|
||||||
|
"Stainless 316", "Brass C360") instead of being limited to the
|
||||||
|
fixed Selection. Each material maps to a `category` that drives
|
||||||
|
legacy pricing-rule matching and the default density used for
|
||||||
|
material-weight rollups.
|
||||||
|
"""
|
||||||
|
_name = 'fp.part.material'
|
||||||
|
_description = 'Fusion Plating — Part Material'
|
||||||
|
_order = 'sequence, name'
|
||||||
|
_rec_name = 'name'
|
||||||
|
|
||||||
|
name = fields.Char(string='Material', required=True, translate=False)
|
||||||
|
sequence = fields.Integer(string='Sequence', default=10)
|
||||||
|
category = fields.Selection(
|
||||||
|
[('aluminium', 'Aluminium'), ('steel', 'Steel'),
|
||||||
|
('stainless', 'Stainless Steel'), ('copper', 'Copper'),
|
||||||
|
('titanium', 'Titanium'), ('other', 'Other')],
|
||||||
|
string='Category', required=True, default='other',
|
||||||
|
help='Used for pricing-rule matching and to pick a default '
|
||||||
|
'density when one is not set explicitly.',
|
||||||
|
)
|
||||||
|
density = fields.Float(
|
||||||
|
string='Density (g/cm³)', digits=(8, 4),
|
||||||
|
help='Override the category default. Leave 0 to use the '
|
||||||
|
'category density (Aluminium 2.70, Steel 7.85, '
|
||||||
|
'Stainless 8.00, Copper 8.96, Titanium 4.51).',
|
||||||
|
)
|
||||||
|
notes = fields.Char(string='Notes', help='Internal note (alloy spec, source, etc.).')
|
||||||
|
active = fields.Boolean(string='Active', default=True)
|
||||||
|
|
||||||
|
_CATEGORY_DENSITY = {
|
||||||
|
'aluminium': 2.70,
|
||||||
|
'steel': 7.85,
|
||||||
|
'stainless': 8.00,
|
||||||
|
'copper': 8.96,
|
||||||
|
'titanium': 4.51,
|
||||||
|
'other': 7.85,
|
||||||
|
}
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('fp_part_material_name_uniq', 'unique(name)',
|
||||||
|
'Material name must be unique.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def effective_density(self):
|
||||||
|
"""Return density override if set, else the category default."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.density and self.density > 0:
|
||||||
|
return self.density
|
||||||
|
return self._CATEGORY_DENSITY.get(self.category, 7.85)
|
||||||
@@ -116,9 +116,13 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
help='Surface area minus masked area, using the values on this quote.',
|
help='Surface area minus masked area, using the values on this quote.',
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.depends('volume_mm3', 'substrate_material')
|
@api.depends('volume_mm3', 'substrate_material', 'material_id', 'material_id.density')
|
||||||
def _compute_material_weight_kg(self):
|
def _compute_material_weight_kg(self):
|
||||||
"""Compute weight from part volume × THIS QUOTE'S substrate density."""
|
"""Compute weight from part volume × THIS QUOTE'S substrate density.
|
||||||
|
|
||||||
|
Prefer the per-material density override; fall back to the
|
||||||
|
category default when only the legacy Selection is set.
|
||||||
|
"""
|
||||||
density_map = {
|
density_map = {
|
||||||
'aluminium': 2.70,
|
'aluminium': 2.70,
|
||||||
'steel': 7.85,
|
'steel': 7.85,
|
||||||
@@ -128,10 +132,16 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
'other': 7.85,
|
'other': 7.85,
|
||||||
}
|
}
|
||||||
for rec in self:
|
for rec in self:
|
||||||
if not rec.volume_mm3 or not rec.substrate_material:
|
if not rec.volume_mm3:
|
||||||
|
rec.material_weight_kg = 0.0
|
||||||
|
continue
|
||||||
|
if rec.material_id:
|
||||||
|
density = rec.material_id.effective_density()
|
||||||
|
elif rec.substrate_material:
|
||||||
|
density = density_map.get(rec.substrate_material, 7.85)
|
||||||
|
else:
|
||||||
rec.material_weight_kg = 0.0
|
rec.material_weight_kg = 0.0
|
||||||
continue
|
continue
|
||||||
density = density_map.get(rec.substrate_material, 7.85)
|
|
||||||
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
|
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
|
||||||
|
|
||||||
@api.depends('surface_area', 'surface_area_uom', 'masking_area_sqin')
|
@api.depends('surface_area', 'surface_area_uom', 'masking_area_sqin')
|
||||||
@@ -252,12 +262,35 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
('complex', 'Complex'), ('very_complex', 'Very Complex')],
|
('complex', 'Complex'), ('very_complex', 'Very Complex')],
|
||||||
string='Complexity', default='simple',
|
string='Complexity', default='simple',
|
||||||
)
|
)
|
||||||
|
# Single source of truth: pick a material from the shared library.
|
||||||
|
# `substrate_material` below is now a stored compute mirroring
|
||||||
|
# `material_id.category` so legacy consumers (pricing rules, portal,
|
||||||
|
# data exports) keep working unchanged.
|
||||||
|
material_id = fields.Many2one(
|
||||||
|
'fp.part.material', string='Material',
|
||||||
|
ondelete='restrict',
|
||||||
|
help='Picks from the shared material library — same picker as '
|
||||||
|
'the Part Catalog. Create custom alloys (e.g. "Aluminium '
|
||||||
|
'6061") on the fly.',
|
||||||
|
)
|
||||||
substrate_material = fields.Selection(
|
substrate_material = fields.Selection(
|
||||||
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
||||||
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
||||||
string='Substrate', default='steel',
|
string='Material Category',
|
||||||
|
compute='_compute_substrate_material',
|
||||||
|
store=True, readonly=False, default='steel',
|
||||||
|
help='Auto-derived from the selected material. Drives pricing '
|
||||||
|
'rule matching and density defaults.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api.depends('material_id', 'material_id.category')
|
||||||
|
def _compute_substrate_material(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.material_id:
|
||||||
|
rec.substrate_material = rec.material_id.category
|
||||||
|
elif not rec.substrate_material:
|
||||||
|
rec.substrate_material = 'steel'
|
||||||
|
|
||||||
# ----- Options ----------------------------------------------------------
|
# ----- Options ----------------------------------------------------------
|
||||||
rush_order = fields.Boolean(string='Rush Order')
|
rush_order = fields.Boolean(string='Rush Order')
|
||||||
turnaround_days = fields.Integer(string='Turnaround (days)')
|
turnaround_days = fields.Integer(string='Turnaround (days)')
|
||||||
@@ -302,7 +335,13 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
self.surface_area_uom = cat.surface_area_uom
|
self.surface_area_uom = cat.surface_area_uom
|
||||||
self.complexity = cat.complexity
|
self.complexity = cat.complexity
|
||||||
self.masking_zones = cat.masking_zones
|
self.masking_zones = cat.masking_zones
|
||||||
self.substrate_material = cat.substrate_material
|
# Pull the m2o material from the part — substrate_material
|
||||||
|
# auto-derives via the compute. Fall back to the legacy
|
||||||
|
# Selection only if the part has no material_id yet.
|
||||||
|
if cat.material_id:
|
||||||
|
self.material_id = cat.material_id
|
||||||
|
else:
|
||||||
|
self.substrate_material = cat.substrate_material
|
||||||
# Copy masking area too (for effective-area calculation)
|
# Copy masking area too (for effective-area calculation)
|
||||||
self.masking_area_sqin = cat.masking_area_sqin
|
self.masking_area_sqin = cat.masking_area_sqin
|
||||||
|
|
||||||
@@ -896,21 +935,26 @@ class FpQuoteConfigurator(models.Model):
|
|||||||
def action_save_to_catalog(self):
|
def action_save_to_catalog(self):
|
||||||
"""Push this quote's geometry/material edits back to the master part catalog.
|
"""Push this quote's geometry/material edits back to the master part catalog.
|
||||||
|
|
||||||
Writes: substrate_material, surface_area, surface_area_uom,
|
Writes: material_id (preferred) / substrate_material (fallback),
|
||||||
masking_area_sqin, masking_zones, complexity.
|
surface_area, surface_area_uom, masking_area_sqin,
|
||||||
|
masking_zones, complexity.
|
||||||
Only available when a part catalog entry is linked.
|
Only available when a part catalog entry is linked.
|
||||||
"""
|
"""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if not self.part_catalog_id:
|
if not self.part_catalog_id:
|
||||||
raise UserError(_('No part catalog entry linked to this configurator.'))
|
raise UserError(_('No part catalog entry linked to this configurator.'))
|
||||||
self.part_catalog_id.write({
|
vals = {
|
||||||
'substrate_material': self.substrate_material,
|
|
||||||
'surface_area': self.surface_area,
|
'surface_area': self.surface_area,
|
||||||
'surface_area_uom': self.surface_area_uom,
|
'surface_area_uom': self.surface_area_uom,
|
||||||
'masking_area_sqin': self.masking_area_sqin,
|
'masking_area_sqin': self.masking_area_sqin,
|
||||||
'masking_zones': self.masking_zones,
|
'masking_zones': self.masking_zones,
|
||||||
'complexity': self.complexity,
|
'complexity': self.complexity,
|
||||||
})
|
}
|
||||||
|
if self.material_id:
|
||||||
|
vals['material_id'] = self.material_id.id
|
||||||
|
else:
|
||||||
|
vals['substrate_material'] = self.substrate_material
|
||||||
|
self.part_catalog_id.write(vals)
|
||||||
self.message_post(
|
self.message_post(
|
||||||
body=Markup(_('Geometry and material saved back to part catalog <b>%s</b>.')) % self.part_catalog_id.name,
|
body=Markup(_('Geometry and material saved back to part catalog <b>%s</b>.')) % self.part_catalog_id.name,
|
||||||
message_type='notification',
|
message_type='notification',
|
||||||
|
|||||||
@@ -124,6 +124,22 @@ class SaleOrder(models.Model):
|
|||||||
string='Deadline',
|
string='Deadline',
|
||||||
compute='_compute_deadline_countdown',
|
compute='_compute_deadline_countdown',
|
||||||
)
|
)
|
||||||
|
x_fc_order_completion_date = fields.Date(
|
||||||
|
string='Order Completion Date',
|
||||||
|
compute='_compute_order_completion_date',
|
||||||
|
store=True,
|
||||||
|
help='When the LATEST line is actually due. Auto-rolled up from '
|
||||||
|
'each line\'s effective deadline. Distinct from Customer '
|
||||||
|
'Deadline (what we promised) — this reflects shop reality.',
|
||||||
|
)
|
||||||
|
x_fc_is_late_forecast = fields.Boolean(
|
||||||
|
string='Late Forecast',
|
||||||
|
compute='_compute_is_late_forecast',
|
||||||
|
store=True,
|
||||||
|
help='True when the rolled-up Order Completion Date sits past the '
|
||||||
|
'Customer Deadline. Suppressed on blanket orders since their '
|
||||||
|
'spans are intentionally long.',
|
||||||
|
)
|
||||||
x_fc_margin_amount = fields.Monetary(
|
x_fc_margin_amount = fields.Monetary(
|
||||||
string='Margin',
|
string='Margin',
|
||||||
compute='_compute_margin', currency_field='currency_id',
|
compute='_compute_margin', currency_field='currency_id',
|
||||||
@@ -503,6 +519,38 @@ class SaleOrder(models.Model):
|
|||||||
'overdue %s' % phrase if past else 'in %s' % phrase
|
'overdue %s' % phrase if past else 'in %s' % phrase
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
'order_line.x_fc_effective_part_deadline',
|
||||||
|
'order_line.x_fc_archived',
|
||||||
|
)
|
||||||
|
def _compute_order_completion_date(self):
|
||||||
|
"""Roll up = max(line.x_fc_effective_part_deadline) over non-
|
||||||
|
archived lines. Empty / all-archived order returns False."""
|
||||||
|
for rec in self:
|
||||||
|
dates = [
|
||||||
|
line.x_fc_effective_part_deadline
|
||||||
|
for line in rec.order_line
|
||||||
|
if line.x_fc_effective_part_deadline and not line.x_fc_archived
|
||||||
|
]
|
||||||
|
rec.x_fc_order_completion_date = max(dates) if dates else False
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
'x_fc_order_completion_date',
|
||||||
|
'commitment_date',
|
||||||
|
'x_fc_is_blanket_order',
|
||||||
|
)
|
||||||
|
def _compute_is_late_forecast(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.x_fc_is_blanket_order:
|
||||||
|
rec.x_fc_is_late_forecast = False
|
||||||
|
continue
|
||||||
|
commit = rec.commitment_date.date() if rec.commitment_date else False
|
||||||
|
rec.x_fc_is_late_forecast = bool(
|
||||||
|
rec.x_fc_order_completion_date
|
||||||
|
and commit
|
||||||
|
and rec.x_fc_order_completion_date > commit
|
||||||
|
)
|
||||||
|
|
||||||
@api.depends('order_line.price_subtotal', 'amount_untaxed')
|
@api.depends('order_line.price_subtotal', 'amount_untaxed')
|
||||||
def _compute_margin(self):
|
def _compute_margin(self):
|
||||||
"""Margin = untaxed total − rolled-up cost from coating configs.
|
"""Margin = untaxed total − rolled-up cost from coating configs.
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from odoo import _, api, fields, models
|
from odoo import _, api, fields, models
|
||||||
from odoo.exceptions import ValidationError
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
@@ -63,7 +65,36 @@ class SaleOrderLine(models.Model):
|
|||||||
x_fc_treatment_ids = fields.Many2many(
|
x_fc_treatment_ids = fields.Many2many(
|
||||||
'fp.treatment', string='Additional Treatments',
|
'fp.treatment', string='Additional Treatments',
|
||||||
)
|
)
|
||||||
x_fc_part_deadline = fields.Date(string='Part Deadline')
|
x_fc_part_deadline = fields.Date(
|
||||||
|
string='Part Deadline Override',
|
||||||
|
help='Absolute-date manual override. When set, beats the days-offset '
|
||||||
|
'and the part\'s default lead time. Leave blank to fall through '
|
||||||
|
'to the offset, then part default, then the order\'s customer '
|
||||||
|
'deadline.',
|
||||||
|
)
|
||||||
|
x_fc_part_deadline_offset_days = fields.Integer(
|
||||||
|
string='Days Offset',
|
||||||
|
help='Manual override expressed as "+N days from the order\'s '
|
||||||
|
'customer deadline". Use this when you think in days rather '
|
||||||
|
'than absolute dates. Ignored if Part Deadline Override is set.',
|
||||||
|
)
|
||||||
|
x_fc_effective_part_deadline = fields.Date(
|
||||||
|
string='Effective Deadline',
|
||||||
|
compute='_compute_effective_part_deadline',
|
||||||
|
store=True,
|
||||||
|
help='Computed deadline that actually drives shop scheduling. '
|
||||||
|
'Resolution: explicit override → days offset → part default '
|
||||||
|
'lead time → order customer deadline.',
|
||||||
|
)
|
||||||
|
x_fc_effective_internal_deadline = fields.Date(
|
||||||
|
string='Shop Target',
|
||||||
|
compute='_compute_effective_internal_deadline',
|
||||||
|
store=True,
|
||||||
|
help='Internal deadline for this line — effective customer '
|
||||||
|
'deadline minus the order\'s shop buffer (commitment_date − '
|
||||||
|
'internal_deadline gap). Clamped so it never exceeds the '
|
||||||
|
'effective customer deadline.',
|
||||||
|
)
|
||||||
x_fc_rush_order = fields.Boolean(string='Rush')
|
x_fc_rush_order = fields.Boolean(string='Rush')
|
||||||
x_fc_wo_group_tag = fields.Char(
|
x_fc_wo_group_tag = fields.Char(
|
||||||
string='Work Order Group',
|
string='Work Order Group',
|
||||||
@@ -181,6 +212,94 @@ class SaleOrderLine(models.Model):
|
|||||||
def _compute_serial_count(self):
|
def _compute_serial_count(self):
|
||||||
for line in self:
|
for line in self:
|
||||||
line.x_fc_serial_count = len(line.x_fc_serial_ids)
|
line.x_fc_serial_count = len(line.x_fc_serial_ids)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Effective deadlines (Sub 12d)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.depends(
|
||||||
|
'x_fc_part_deadline',
|
||||||
|
'x_fc_part_deadline_offset_days',
|
||||||
|
'x_fc_part_catalog_id',
|
||||||
|
'x_fc_part_catalog_id.x_fc_default_lead_time_days',
|
||||||
|
'order_id.commitment_date',
|
||||||
|
'order_id.x_fc_planned_start_date',
|
||||||
|
)
|
||||||
|
def _compute_effective_part_deadline(self):
|
||||||
|
"""Resolution chain (first match wins):
|
||||||
|
1. explicit absolute-date override (x_fc_part_deadline)
|
||||||
|
2. days offset from commitment_date (x_fc_part_deadline_offset_days)
|
||||||
|
3. part's default lead time from planned_start_date
|
||||||
|
4. order's commitment_date (= customer profile cascade)
|
||||||
|
5. planned_start_date as last resort (orphan order with no deadline)
|
||||||
|
"""
|
||||||
|
for line in self:
|
||||||
|
order = line.order_id
|
||||||
|
# commitment_date is a Datetime in Odoo standard; coerce to
|
||||||
|
# date for arithmetic with our Date fields.
|
||||||
|
commit_dt = order.commitment_date if order else False
|
||||||
|
commit = commit_dt.date() if commit_dt else False
|
||||||
|
start = (
|
||||||
|
order.x_fc_planned_start_date if order
|
||||||
|
else False
|
||||||
|
) or fields.Date.context_today(line)
|
||||||
|
|
||||||
|
# 1. absolute-date override
|
||||||
|
if line.x_fc_part_deadline:
|
||||||
|
line.x_fc_effective_part_deadline = line.x_fc_part_deadline
|
||||||
|
continue
|
||||||
|
# 2. days offset from commitment
|
||||||
|
if line.x_fc_part_deadline_offset_days and commit:
|
||||||
|
line.x_fc_effective_part_deadline = (
|
||||||
|
commit + timedelta(days=line.x_fc_part_deadline_offset_days)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
# 3. part default lead time from planned_start
|
||||||
|
part_lead = (
|
||||||
|
line.x_fc_part_catalog_id
|
||||||
|
and line.x_fc_part_catalog_id.x_fc_default_lead_time_days
|
||||||
|
)
|
||||||
|
if part_lead:
|
||||||
|
line.x_fc_effective_part_deadline = (
|
||||||
|
start + timedelta(days=part_lead)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
# 4. order commitment (which itself derives from customer profile)
|
||||||
|
if commit:
|
||||||
|
line.x_fc_effective_part_deadline = commit
|
||||||
|
continue
|
||||||
|
# 5. last resort — planned start so the field is never null
|
||||||
|
line.x_fc_effective_part_deadline = start
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
'x_fc_effective_part_deadline',
|
||||||
|
'order_id.commitment_date',
|
||||||
|
'order_id.x_fc_internal_deadline',
|
||||||
|
)
|
||||||
|
def _compute_effective_internal_deadline(self):
|
||||||
|
"""Apply the order's customer-vs-internal buffer to the line's
|
||||||
|
effective customer deadline. Buffer = commitment_date −
|
||||||
|
x_fc_internal_deadline (the gap implied by customer profile).
|
||||||
|
Clamp result so it never exceeds the customer deadline.
|
||||||
|
"""
|
||||||
|
for line in self:
|
||||||
|
eff = line.x_fc_effective_part_deadline
|
||||||
|
if not eff:
|
||||||
|
line.x_fc_effective_internal_deadline = False
|
||||||
|
continue
|
||||||
|
order = line.order_id
|
||||||
|
commit_dt = order.commitment_date if order else False
|
||||||
|
commit = commit_dt.date() if commit_dt else False
|
||||||
|
internal = order.x_fc_internal_deadline if order else False
|
||||||
|
if commit and internal and commit >= internal:
|
||||||
|
buffer_days = (commit - internal).days
|
||||||
|
target = eff - timedelta(days=buffer_days)
|
||||||
|
# Clamp: internal can never sit after customer date
|
||||||
|
line.x_fc_effective_internal_deadline = (
|
||||||
|
target if target <= eff else eff
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No buffer info → fall back to the customer date itself
|
||||||
|
line.x_fc_effective_internal_deadline = eff
|
||||||
x_fc_job_number = fields.Char(
|
x_fc_job_number = fields.Char(
|
||||||
string='Job #',
|
string='Job #',
|
||||||
copy=False,
|
copy=False,
|
||||||
|
|||||||
@@ -49,3 +49,6 @@ access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bul
|
|||||||
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
|
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
|
||||||
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||||
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
|
||||||
|
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||||
|
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -0,0 +1,94 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
|
||||||
|
Standalone views for fp.coating.thickness so SO-line m2o pickers
|
||||||
|
can offer "Create and edit..." — the inline-on-coating-config
|
||||||
|
editor was the only way to add thicknesses pre-Sub-12d.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_coating_thickness_list" model="ir.ui.view">
|
||||||
|
<field name="name">fp.coating.thickness.list</field>
|
||||||
|
<field name="model">fp.coating.thickness</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Coating Thicknesses" decoration-muted="not active">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="coating_config_id"/>
|
||||||
|
<field name="value" string="Nominal"/>
|
||||||
|
<field name="value_min" string="Min" optional="show"/>
|
||||||
|
<field name="value_max" string="Max" optional="show"/>
|
||||||
|
<field name="uom"/>
|
||||||
|
<field name="display_name" string="Label"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fp_coating_thickness_form" model="ir.ui.view">
|
||||||
|
<field name="name">fp.coating.thickness.form</field>
|
||||||
|
<field name="model">fp.coating.thickness</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Coating Thickness">
|
||||||
|
<sheet>
|
||||||
|
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="display_name" string="Thickness"/>
|
||||||
|
<h2><field name="display_name" readonly="1" placeholder="Auto-generated from value + UoM"/></h2>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group string="Spec">
|
||||||
|
<field name="coating_config_id"
|
||||||
|
options="{'no_create_edit': True}"/>
|
||||||
|
<field name="value" string="Nominal"/>
|
||||||
|
<field name="uom"/>
|
||||||
|
</group>
|
||||||
|
<group string="Acceptance Band (optional)">
|
||||||
|
<field name="value_min" string="Min"/>
|
||||||
|
<field name="value_max" string="Max"/>
|
||||||
|
<div colspan="2" class="text-muted">
|
||||||
|
Set Min/Max when the customer spec is a
|
||||||
|
range (e.g. AMS-2404 Class 4 = 0.001"–0.0015").
|
||||||
|
QC readings outside the band fail.
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="sequence"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fp_coating_thickness_search" model="ir.ui.view">
|
||||||
|
<field name="name">fp.coating.thickness.search</field>
|
||||||
|
<field name="model">fp.coating.thickness</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="coating_config_id"/>
|
||||||
|
<field name="display_name"/>
|
||||||
|
<field name="uom"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||||
|
<group>
|
||||||
|
<filter string="Coating" name="group_coating"
|
||||||
|
context="{'group_by':'coating_config_id'}"/>
|
||||||
|
<filter string="UoM" name="group_uom"
|
||||||
|
context="{'group_by':'uom'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_coating_thickness" model="ir.actions.act_window">
|
||||||
|
<field name="name">Coating Thicknesses</field>
|
||||||
|
<field name="res_model">fp.coating.thickness</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="view_fp_coating_thickness_search"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -111,4 +111,10 @@
|
|||||||
action="action_fp_treatment"
|
action="action_fp_treatment"
|
||||||
sequence="40"/>
|
sequence="40"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_fp_part_materials"
|
||||||
|
name="Materials"
|
||||||
|
parent="menu_fp_configurator"
|
||||||
|
action="action_fp_part_material"
|
||||||
|
sequence="50"/>
|
||||||
|
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
<field name="name" string="Part Name"/>
|
<field name="name" string="Part Name"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="revision"/>
|
<field name="revision"/>
|
||||||
<field name="substrate_material"/>
|
<field name="material_id" string="Material"/>
|
||||||
|
<field name="substrate_material" optional="hide"/>
|
||||||
<field name="surface_area"/>
|
<field name="surface_area"/>
|
||||||
<field name="complexity"/>
|
<field name="complexity"/>
|
||||||
<field name="active" widget="boolean_toggle"/>
|
<field name="active" widget="boolean_toggle"/>
|
||||||
@@ -116,7 +117,9 @@
|
|||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
<field name="revision"/>
|
<field name="revision"/>
|
||||||
<field name="revision_number"/>
|
<field name="revision_number"/>
|
||||||
<field name="substrate_material"/>
|
<field name="material_id"
|
||||||
|
options="{'no_quick_create': True}"/>
|
||||||
|
<field name="substrate_material" invisible="1"/>
|
||||||
<field name="geometry_source"/>
|
<field name="geometry_source"/>
|
||||||
<field name="is_latest_revision" invisible="1"/>
|
<field name="is_latest_revision" invisible="1"/>
|
||||||
<field name="parent_part_id" invisible="not parent_part_id"/>
|
<field name="parent_part_id" invisible="not parent_part_id"/>
|
||||||
@@ -135,6 +138,7 @@
|
|||||||
<field name="effective_area_sqin" readonly="1"/>
|
<field name="effective_area_sqin" readonly="1"/>
|
||||||
<field name="weight"/>
|
<field name="weight"/>
|
||||||
<field name="material_weight_kg" readonly="1"/>
|
<field name="material_weight_kg" readonly="1"/>
|
||||||
|
<field name="x_fc_default_lead_time_days"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
<group string="Quality & Delivery" name="quality_delivery">
|
<group string="Quality & Delivery" name="quality_delivery">
|
||||||
@@ -324,6 +328,7 @@
|
|||||||
<field name="name"/>
|
<field name="name"/>
|
||||||
<field name="part_number"/>
|
<field name="part_number"/>
|
||||||
<field name="partner_id"/>
|
<field name="partner_id"/>
|
||||||
|
<field name="material_id" string="Material"/>
|
||||||
<separator/>
|
<separator/>
|
||||||
<filter string="Aluminium" name="material_aluminium" domain="[('substrate_material','=','aluminium')]"/>
|
<filter string="Aluminium" name="material_aluminium" domain="[('substrate_material','=','aluminium')]"/>
|
||||||
<filter string="Steel" name="material_steel" domain="[('substrate_material','=','steel')]"/>
|
<filter string="Steel" name="material_steel" domain="[('substrate_material','=','steel')]"/>
|
||||||
@@ -340,7 +345,8 @@
|
|||||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||||
<group>
|
<group>
|
||||||
<filter string="Customer" name="group_partner" context="{'group_by':'partner_id'}"/>
|
<filter string="Customer" name="group_partner" context="{'group_by':'partner_id'}"/>
|
||||||
<filter string="Material" name="group_material" context="{'group_by':'substrate_material'}"/>
|
<filter string="Material" name="group_material" context="{'group_by':'material_id'}"/>
|
||||||
|
<filter string="Material Category" name="group_material_category" context="{'group_by':'substrate_material'}"/>
|
||||||
<filter string="Complexity" name="group_complexity" context="{'group_by':'complexity'}"/>
|
<filter string="Complexity" name="group_complexity" context="{'group_by':'complexity'}"/>
|
||||||
</group>
|
</group>
|
||||||
</search>
|
</search>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Copyright 2026 Nexa Systems Inc.
|
||||||
|
License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
Part of the Fusion Plating product family.
|
||||||
|
-->
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_fp_part_material_list" model="ir.ui.view">
|
||||||
|
<field name="name">fp.part.material.list</field>
|
||||||
|
<field name="model">fp.part.material</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Materials" editable="bottom" decoration-muted="not active">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="category"/>
|
||||||
|
<field name="density" string="Density (g/cm³)"/>
|
||||||
|
<field name="notes" optional="show"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fp_part_material_form" model="ir.ui.view">
|
||||||
|
<field name="name">fp.part.material.form</field>
|
||||||
|
<field name="model">fp.part.material</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Material">
|
||||||
|
<sheet>
|
||||||
|
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name" string="Material"/>
|
||||||
|
<h1><field name="name" placeholder="e.g. Aluminium 6061"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="category"/>
|
||||||
|
<field name="density"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="notes" placeholder="Alloy spec, source, supplier note..."/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<div class="text-muted">
|
||||||
|
Leave Density at 0 to use the category default
|
||||||
|
(Aluminium 2.70, Steel 7.85, Stainless 8.00,
|
||||||
|
Copper 8.96, Titanium 4.51 g/cm³).
|
||||||
|
</div>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fp_part_material_search" model="ir.ui.view">
|
||||||
|
<field name="name">fp.part.material.search</field>
|
||||||
|
<field name="model">fp.part.material</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="category"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Aluminium" name="cat_aluminium" domain="[('category','=','aluminium')]"/>
|
||||||
|
<filter string="Steel" name="cat_steel" domain="[('category','=','steel')]"/>
|
||||||
|
<filter string="Stainless" name="cat_stainless" domain="[('category','=','stainless')]"/>
|
||||||
|
<filter string="Copper" name="cat_copper" domain="[('category','=','copper')]"/>
|
||||||
|
<filter string="Titanium" name="cat_titanium" domain="[('category','=','titanium')]"/>
|
||||||
|
<filter string="Other" name="cat_other" domain="[('category','=','other')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||||
|
<group>
|
||||||
|
<filter string="Category" name="group_category" context="{'group_by':'category'}"/>
|
||||||
|
</group>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fp_part_material" model="ir.actions.act_window">
|
||||||
|
<field name="name">Materials</field>
|
||||||
|
<field name="res_model">fp.part.material</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="search_view_id" ref="view_fp_part_material_search"/>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">No materials yet</p>
|
||||||
|
<p>Define the materials your shop processes. Each material
|
||||||
|
picks a category (Aluminium, Steel, etc.) used for pricing
|
||||||
|
rules and density-based weight calculations.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -251,7 +251,9 @@
|
|||||||
string="Effective Plating Area"
|
string="Effective Plating Area"
|
||||||
readonly="1"/>
|
readonly="1"/>
|
||||||
<field name="thickness_requested"/>
|
<field name="thickness_requested"/>
|
||||||
<field name="substrate_material"/>
|
<field name="material_id"
|
||||||
|
options="{'no_quick_create': True}"/>
|
||||||
|
<field name="substrate_material" invisible="1"/>
|
||||||
<field name="masking_zones"/>
|
<field name="masking_zones"/>
|
||||||
<field name="turnaround_days"/>
|
<field name="turnaround_days"/>
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -123,8 +123,10 @@
|
|||||||
<field name="x_fc_process_variant_id" optional="show"
|
<field name="x_fc_process_variant_id" optional="show"
|
||||||
string="Process"/>
|
string="Process"/>
|
||||||
<field name="product_uom_qty" string="Qty"/>
|
<field name="product_uom_qty" string="Qty"/>
|
||||||
<field name="x_fc_part_deadline" optional="show"
|
<field name="x_fc_effective_part_deadline" optional="show"
|
||||||
string="Part Deadline"/>
|
string="Effective Deadline"/>
|
||||||
|
<field name="x_fc_part_deadline" optional="hide"
|
||||||
|
string="Part Deadline Override"/>
|
||||||
<field name="x_fc_rush_order" optional="hide"/>
|
<field name="x_fc_rush_order" optional="hide"/>
|
||||||
<field name="x_fc_job_number" optional="show"
|
<field name="x_fc_job_number" optional="show"
|
||||||
string="Job #"/>
|
string="Job #"/>
|
||||||
@@ -188,6 +190,16 @@
|
|||||||
<field name="x_fc_internal_deadline"/>
|
<field name="x_fc_internal_deadline"/>
|
||||||
<field name="commitment_date" string="Customer Deadline"/>
|
<field name="commitment_date" string="Customer Deadline"/>
|
||||||
<field name="x_fc_deadline_countdown" readonly="1"/>
|
<field name="x_fc_deadline_countdown" readonly="1"/>
|
||||||
|
<label for="x_fc_order_completion_date"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="x_fc_order_completion_date"
|
||||||
|
readonly="1" class="oe_inline"/>
|
||||||
|
<span class="badge text-bg-danger ms-2"
|
||||||
|
invisible="not x_fc_is_late_forecast">
|
||||||
|
<i class="fa fa-exclamation-triangle me-1"/>Late
|
||||||
|
</span>
|
||||||
|
<field name="x_fc_is_late_forecast" invisible="1"/>
|
||||||
|
</div>
|
||||||
<field name="x_fc_is_blanket_order"/>
|
<field name="x_fc_is_blanket_order"/>
|
||||||
<field name="x_fc_block_partial_shipments"/>
|
<field name="x_fc_block_partial_shipments"/>
|
||||||
</group>
|
</group>
|
||||||
@@ -250,24 +262,25 @@
|
|||||||
widget="boolean_toggle"
|
widget="boolean_toggle"
|
||||||
invisible="not x_fc_process_variant_id"
|
invisible="not x_fc_process_variant_id"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<button name="action_customize_process" type="object"
|
|
||||||
string="Customize" icon="fa-pencil-square-o"
|
|
||||||
class="btn-link"
|
|
||||||
invisible="not x_fc_process_variant_id"/>
|
|
||||||
<field name="x_fc_thickness_id"
|
<field name="x_fc_thickness_id"
|
||||||
options="{'no_create': True}"
|
options="{'no_quick_create': True}"
|
||||||
|
context="{'default_coating_config_id': x_fc_coating_config_id}"
|
||||||
|
domain="[('coating_config_id', '=', x_fc_coating_config_id)]"
|
||||||
invisible="not x_fc_coating_config_id"
|
invisible="not x_fc_coating_config_id"
|
||||||
optional="show"/>
|
optional="show"/>
|
||||||
<field name="x_fc_serial_ids"
|
<field name="x_fc_serial_ids"
|
||||||
widget="many2many_tags"
|
widget="many2many_tags"
|
||||||
options="{'no_quick_create': False, 'color_field': 'state_color'}"
|
options="{'no_quick_create': False, 'color_field': 'state_color'}"
|
||||||
|
domain="[('part_id', '=', x_fc_part_catalog_id)]"
|
||||||
optional="show"/>
|
optional="show"/>
|
||||||
<field name="x_fc_serial_count"
|
<field name="x_fc_serial_count"
|
||||||
string="# SN"
|
string="Serial Count"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<button name="action_open_serial_bulk_add" type="object"
|
<button name="action_open_serial_bulk_add" type="object"
|
||||||
string="Bulk Add Serials" icon="fa-list-ol"
|
title="Bulk add serials"
|
||||||
class="btn-link"/>
|
icon="fa-list-ol"
|
||||||
|
class="btn-link"
|
||||||
|
invisible="not x_fc_part_catalog_id or x_fc_serial_count > 0"/>
|
||||||
<field name="x_fc_job_number" optional="show"/>
|
<field name="x_fc_job_number" optional="show"/>
|
||||||
<field name="x_fc_revision_pick_id"
|
<field name="x_fc_revision_pick_id"
|
||||||
string="Revision"
|
string="Revision"
|
||||||
@@ -278,7 +291,14 @@
|
|||||||
readonly="1"
|
readonly="1"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="x_fc_treatment_ids" widget="many2many_tags" optional="hide"/>
|
<field name="x_fc_treatment_ids" widget="many2many_tags" optional="hide"/>
|
||||||
<field name="x_fc_part_deadline" optional="hide"/>
|
<field name="x_fc_part_deadline" string="Part Deadline Override" optional="hide"/>
|
||||||
|
<field name="x_fc_part_deadline_offset_days" string="Days Offset" optional="hide"/>
|
||||||
|
<field name="x_fc_effective_part_deadline" string="Effective Deadline"
|
||||||
|
optional="show"
|
||||||
|
readonly="1"/>
|
||||||
|
<field name="x_fc_effective_internal_deadline" string="Shop Target"
|
||||||
|
optional="hide"
|
||||||
|
readonly="1"/>
|
||||||
<field name="x_fc_wo_group_tag" optional="hide"/>
|
<field name="x_fc_wo_group_tag" optional="hide"/>
|
||||||
<field name="x_fc_start_at_node_id" optional="hide"/>
|
<field name="x_fc_start_at_node_id" optional="hide"/>
|
||||||
<field name="x_fc_is_one_off" optional="hide"/>
|
<field name="x_fc_is_one_off" optional="hide"/>
|
||||||
@@ -294,7 +314,8 @@
|
|||||||
<field name="model">sale.order</field>
|
<field name="model">sale.order</field>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<list string="Sale Orders" decoration-info="state == 'draft'"
|
<list string="Sale Orders" decoration-info="state == 'draft'"
|
||||||
decoration-muted="state == 'cancel'">
|
decoration-muted="state == 'cancel'"
|
||||||
|
decoration-danger="x_fc_is_late_forecast">
|
||||||
<header>
|
<header>
|
||||||
<button name="%(action_fp_direct_order_wizard)d"
|
<button name="%(action_fp_direct_order_wizard)d"
|
||||||
type="action"
|
type="action"
|
||||||
@@ -308,6 +329,8 @@
|
|||||||
<field name="x_fc_customer_job_number" optional="show"/>
|
<field name="x_fc_customer_job_number" optional="show"/>
|
||||||
<field name="x_fc_internal_deadline" optional="show"/>
|
<field name="x_fc_internal_deadline" optional="show"/>
|
||||||
<field name="commitment_date" string="Customer Deadline" optional="show"/>
|
<field name="commitment_date" string="Customer Deadline" optional="show"/>
|
||||||
|
<field name="x_fc_order_completion_date" string="Completion" optional="show"/>
|
||||||
|
<field name="x_fc_is_late_forecast" optional="hide" widget="boolean_toggle"/>
|
||||||
<field name="x_fc_deadline_countdown" optional="show"/>
|
<field name="x_fc_deadline_countdown" optional="show"/>
|
||||||
<field name="x_fc_wo_completion" optional="show"/>
|
<field name="x_fc_wo_completion" optional="show"/>
|
||||||
<field name="x_fc_planned_start_date" optional="hide"/>
|
<field name="x_fc_planned_start_date" optional="hide"/>
|
||||||
@@ -484,6 +507,8 @@
|
|||||||
<separator/>
|
<separator/>
|
||||||
<filter name="blanket_orders" string="Blanket Orders"
|
<filter name="blanket_orders" string="Blanket Orders"
|
||||||
domain="[('x_fc_is_blanket_order', '=', True)]"/>
|
domain="[('x_fc_is_blanket_order', '=', True)]"/>
|
||||||
|
<filter name="late_forecast" string="Will Be Late"
|
||||||
|
domain="[('x_fc_is_late_forecast', '=', True)]"/>
|
||||||
<filter name="rush_lines" string="Has Rush Line"
|
<filter name="rush_lines" string="Has Rush Line"
|
||||||
domain="[('order_line.x_fc_rush_order', '=', True)]"/>
|
domain="[('order_line.x_fc_rush_order', '=', True)]"/>
|
||||||
<filter name="overdue" string="Overdue"
|
<filter name="overdue" string="Overdue"
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class FpAddFromSoWizard(models.TransientModel):
|
|||||||
'quantity': int(src.product_uom_qty) or 1,
|
'quantity': int(src.product_uom_qty) or 1,
|
||||||
'unit_price': src.price_unit or 0.0,
|
'unit_price': src.price_unit or 0.0,
|
||||||
'part_deadline': src.x_fc_part_deadline,
|
'part_deadline': src.x_fc_part_deadline,
|
||||||
|
'part_deadline_offset_days': src.x_fc_part_deadline_offset_days,
|
||||||
'rush_order': src.x_fc_rush_order,
|
'rush_order': src.x_fc_rush_order,
|
||||||
'wo_group_tag': src.x_fc_wo_group_tag or False,
|
'wo_group_tag': src.x_fc_wo_group_tag or False,
|
||||||
'line_description': src.name,
|
'line_description': src.name,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from odoo import _, api, fields, models
|
from odoo import _, api, fields, models
|
||||||
from odoo.exceptions import UserError
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
@@ -222,8 +224,27 @@ class FpDirectOrderLine(models.Model):
|
|||||||
|
|
||||||
# ---- Scheduling / fulfilment ----
|
# ---- Scheduling / fulfilment ----
|
||||||
part_deadline = fields.Date(
|
part_deadline = fields.Date(
|
||||||
string='Part Deadline',
|
string='Part Deadline Override',
|
||||||
help='Per-line deadline. Defaults to SO customer deadline if blank.',
|
help='Absolute-date manual override. Beats days-offset and the '
|
||||||
|
'part default lead time. Leave blank to fall through.',
|
||||||
|
)
|
||||||
|
part_deadline_offset_days = fields.Integer(
|
||||||
|
string='Days Offset',
|
||||||
|
help='Manual override expressed as "+N days from the order\'s '
|
||||||
|
'customer deadline". Ignored if Part Deadline Override is set.',
|
||||||
|
)
|
||||||
|
effective_part_deadline = fields.Date(
|
||||||
|
string='Effective Deadline',
|
||||||
|
compute='_compute_effective_part_deadline',
|
||||||
|
store=True,
|
||||||
|
help='Resolution: explicit override → days offset → part default '
|
||||||
|
'lead time → order customer deadline.',
|
||||||
|
)
|
||||||
|
effective_internal_deadline = fields.Date(
|
||||||
|
string='Shop Target',
|
||||||
|
compute='_compute_effective_internal_deadline',
|
||||||
|
store=True,
|
||||||
|
help='Effective customer deadline minus the order\'s shop buffer.',
|
||||||
)
|
)
|
||||||
rush_order = fields.Boolean(string='Rush')
|
rush_order = fields.Boolean(string='Rush')
|
||||||
wo_group_tag = fields.Char(
|
wo_group_tag = fields.Char(
|
||||||
@@ -323,6 +344,68 @@ class FpDirectOrderLine(models.Model):
|
|||||||
for rec in self:
|
for rec in self:
|
||||||
rec.serial_count = len(rec.serial_ids)
|
rec.serial_count = len(rec.serial_ids)
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
'part_deadline',
|
||||||
|
'part_deadline_offset_days',
|
||||||
|
'part_catalog_id',
|
||||||
|
'part_catalog_id.x_fc_default_lead_time_days',
|
||||||
|
'wizard_id.customer_deadline',
|
||||||
|
'wizard_id.planned_start_date',
|
||||||
|
)
|
||||||
|
def _compute_effective_part_deadline(self):
|
||||||
|
"""Mirror of SaleOrderLine._compute_effective_part_deadline."""
|
||||||
|
for line in self:
|
||||||
|
wiz = line.wizard_id
|
||||||
|
commit = wiz.customer_deadline if wiz else False
|
||||||
|
start = (
|
||||||
|
wiz.planned_start_date if wiz else False
|
||||||
|
) or fields.Date.context_today(line)
|
||||||
|
|
||||||
|
if line.part_deadline:
|
||||||
|
line.effective_part_deadline = line.part_deadline
|
||||||
|
continue
|
||||||
|
if line.part_deadline_offset_days and commit:
|
||||||
|
line.effective_part_deadline = (
|
||||||
|
commit + timedelta(days=line.part_deadline_offset_days)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
part_lead = (
|
||||||
|
line.part_catalog_id
|
||||||
|
and line.part_catalog_id.x_fc_default_lead_time_days
|
||||||
|
)
|
||||||
|
if part_lead:
|
||||||
|
line.effective_part_deadline = (
|
||||||
|
start + timedelta(days=part_lead)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if commit:
|
||||||
|
line.effective_part_deadline = commit
|
||||||
|
continue
|
||||||
|
line.effective_part_deadline = start
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
'effective_part_deadline',
|
||||||
|
'wizard_id.customer_deadline',
|
||||||
|
'wizard_id.internal_deadline',
|
||||||
|
)
|
||||||
|
def _compute_effective_internal_deadline(self):
|
||||||
|
for line in self:
|
||||||
|
eff = line.effective_part_deadline
|
||||||
|
if not eff:
|
||||||
|
line.effective_internal_deadline = False
|
||||||
|
continue
|
||||||
|
wiz = line.wizard_id
|
||||||
|
commit = wiz.customer_deadline if wiz else False
|
||||||
|
internal = wiz.internal_deadline if wiz else False
|
||||||
|
if commit and internal and commit >= internal:
|
||||||
|
buffer_days = (commit - internal).days
|
||||||
|
target = eff - timedelta(days=buffer_days)
|
||||||
|
line.effective_internal_deadline = (
|
||||||
|
target if target <= eff else eff
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
line.effective_internal_deadline = eff
|
||||||
|
|
||||||
@api.depends('serial_ids')
|
@api.depends('serial_ids')
|
||||||
def _compute_primary_serial(self):
|
def _compute_primary_serial(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
|
|||||||
@@ -557,6 +557,7 @@ class FpDirectOrderWizard(models.Model):
|
|||||||
'x_fc_coating_config_id': line.coating_config_id.id,
|
'x_fc_coating_config_id': line.coating_config_id.id,
|
||||||
'x_fc_treatment_ids': [(6, 0, line.treatment_ids.ids)],
|
'x_fc_treatment_ids': [(6, 0, line.treatment_ids.ids)],
|
||||||
'x_fc_part_deadline': line.part_deadline,
|
'x_fc_part_deadline': line.part_deadline,
|
||||||
|
'x_fc_part_deadline_offset_days': line.part_deadline_offset_days,
|
||||||
'x_fc_rush_order': line.rush_order,
|
'x_fc_rush_order': line.rush_order,
|
||||||
'x_fc_wo_group_tag': line.wo_group_tag or False,
|
'x_fc_wo_group_tag': line.wo_group_tag or False,
|
||||||
'x_fc_part_wo_description': line.part_wo_description or False,
|
'x_fc_part_wo_description': line.part_wo_description or False,
|
||||||
|
|||||||
@@ -165,10 +165,6 @@
|
|||||||
widget="boolean_toggle"
|
widget="boolean_toggle"
|
||||||
invisible="not process_variant_id"
|
invisible="not process_variant_id"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<button name="action_customize_process" type="object"
|
|
||||||
string="Customize" icon="fa-pencil-square-o"
|
|
||||||
class="btn-link"
|
|
||||||
invisible="not process_variant_id"/>
|
|
||||||
<field name="effective_process_id"
|
<field name="effective_process_id"
|
||||||
string="Effective Process"
|
string="Effective Process"
|
||||||
readonly="1"
|
readonly="1"
|
||||||
@@ -178,19 +174,24 @@
|
|||||||
readonly="1"
|
readonly="1"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<field name="thickness_id"
|
<field name="thickness_id"
|
||||||
options="{'no_create': True}"
|
options="{'no_quick_create': True}"
|
||||||
|
context="{'default_coating_config_id': coating_config_id}"
|
||||||
|
domain="[('coating_config_id', '=', coating_config_id)]"
|
||||||
invisible="not coating_config_id"
|
invisible="not coating_config_id"
|
||||||
optional="show"/>
|
optional="show"/>
|
||||||
<field name="serial_ids"
|
<field name="serial_ids"
|
||||||
widget="many2many_tags"
|
widget="many2many_tags"
|
||||||
options="{'no_quick_create': False, 'color_field': 'state_color'}"
|
options="{'no_quick_create': False, 'color_field': 'state_color'}"
|
||||||
|
domain="[('part_id', '=', part_catalog_id)]"
|
||||||
optional="show"/>
|
optional="show"/>
|
||||||
<field name="serial_count"
|
<field name="serial_count"
|
||||||
string="# SN"
|
string="Serial Count"
|
||||||
optional="hide"/>
|
optional="hide"/>
|
||||||
<button name="action_open_serial_bulk_add" type="object"
|
<button name="action_open_serial_bulk_add" type="object"
|
||||||
string="Bulk Add Serials" icon="fa-list-ol"
|
title="Bulk add serials"
|
||||||
class="btn-link"/>
|
icon="fa-list-ol"
|
||||||
|
class="btn-link"
|
||||||
|
invisible="not part_catalog_id or serial_count > 0"/>
|
||||||
<field name="job_number" optional="hide"/>
|
<field name="job_number" optional="hide"/>
|
||||||
<field name="treatment_ids"
|
<field name="treatment_ids"
|
||||||
widget="many2many_tags"
|
widget="many2many_tags"
|
||||||
@@ -207,7 +208,20 @@
|
|||||||
widget="monetary"
|
widget="monetary"
|
||||||
options="{'currency_field': 'currency_id'}"
|
options="{'currency_field': 'currency_id'}"
|
||||||
sum="Total"/>
|
sum="Total"/>
|
||||||
<field name="part_deadline"/>
|
<field name="effective_part_deadline"
|
||||||
|
string="Effective Deadline"
|
||||||
|
optional="show"
|
||||||
|
readonly="1"/>
|
||||||
|
<field name="part_deadline"
|
||||||
|
string="Part Deadline Override"
|
||||||
|
optional="hide"/>
|
||||||
|
<field name="part_deadline_offset_days"
|
||||||
|
string="Days Offset"
|
||||||
|
optional="hide"/>
|
||||||
|
<field name="effective_internal_deadline"
|
||||||
|
string="Shop Target"
|
||||||
|
optional="hide"
|
||||||
|
readonly="1"/>
|
||||||
<field name="wo_group_tag" optional="show"/>
|
<field name="wo_group_tag" optional="show"/>
|
||||||
<field name="rush_order" optional="hide"/>
|
<field name="rush_order" optional="hide"/>
|
||||||
<field name="currency_id" column_invisible="1"/>
|
<field name="currency_id" column_invisible="1"/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Native Jobs',
|
'name': 'Fusion Plating — Native Jobs',
|
||||||
'version': '19.0.8.11.0',
|
'version': '19.0.8.12.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -31,17 +31,21 @@ from odoo.addons.fusion_plating.models._fp_uom_selection import FP_UOM_SELECTION
|
|||||||
# Same selection list as fp.step.template.input.input_type so authored
|
# Same selection list as fp.step.template.input.input_type so authored
|
||||||
# rows + ad-hoc rows pick from the same vocabulary.
|
# rows + ad-hoc rows pick from the same vocabulary.
|
||||||
_FP_INPUT_TYPE_SELECTION = [
|
_FP_INPUT_TYPE_SELECTION = [
|
||||||
('text', 'Text'),
|
('text', 'Text'),
|
||||||
('number', 'Number'),
|
('number', 'Number'),
|
||||||
('boolean', 'Yes/No'),
|
('boolean', 'Yes/No'),
|
||||||
('selection', 'Selection'),
|
('selection', 'Selection'),
|
||||||
('date', 'Date / Time'),
|
('date', 'Date / Time'),
|
||||||
('signature', 'Signature'),
|
('signature', 'Signature'),
|
||||||
('time_hms', 'Time (HH:MM:SS)'),
|
('time_hms', 'Time (HH:MM:SS)'),
|
||||||
('time_seconds', 'Time (seconds)'),
|
('time_seconds', 'Time (seconds)'),
|
||||||
('temperature', 'Temperature'),
|
('temperature', 'Temperature'),
|
||||||
('thickness', 'Thickness'),
|
('thickness', 'Thickness'),
|
||||||
('pass_fail', 'Pass / Fail'),
|
('pass_fail', 'Pass / Fail'),
|
||||||
|
('photo', 'Photo'),
|
||||||
|
('multi_point_thickness', 'Multi-Point Thickness (avg)'),
|
||||||
|
('bath_chemistry_panel', 'Bath Chemistry Panel'),
|
||||||
|
('ph', 'pH'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -72,11 +76,18 @@ class FpJobStepInputWizard(models.TransientModel):
|
|||||||
return defaults
|
return defaults
|
||||||
defaults['step_id'] = step.id
|
defaults['step_id'] = step.id
|
||||||
node = step.recipe_node_id
|
node = step.recipe_node_id
|
||||||
|
# Sub 12d — master switch — when off, return no input rows.
|
||||||
|
if hasattr(node, 'collect_measurements') and not node.collect_measurements:
|
||||||
|
defaults['line_ids'] = []
|
||||||
|
return defaults
|
||||||
# Filter to step_input prompts only — transition inputs go on the
|
# Filter to step_input prompts only — transition inputs go on the
|
||||||
# Move wizard, not here.
|
# Move wizard, not here. Also filter to collect=True (per-recipe
|
||||||
|
# opt-out, default True).
|
||||||
inputs = node.input_ids
|
inputs = node.input_ids
|
||||||
if 'kind' in inputs._fields:
|
if 'kind' in inputs._fields:
|
||||||
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
|
inputs = inputs.filtered(lambda i: i.kind == 'step_input')
|
||||||
|
if 'collect' in inputs._fields:
|
||||||
|
inputs = inputs.filtered(lambda i: i.collect)
|
||||||
defaults['line_ids'] = [(0, 0, {
|
defaults['line_ids'] = [(0, 0, {
|
||||||
'node_input_id': inp.id,
|
'node_input_id': inp.id,
|
||||||
'name': inp.name,
|
'name': inp.name,
|
||||||
@@ -119,6 +130,7 @@ class FpJobStepInputWizard(models.TransientModel):
|
|||||||
'moved_by_user_id': self.env.user.id,
|
'moved_by_user_id': self.env.user.id,
|
||||||
})
|
})
|
||||||
ValueModel = self.env['fp.job.step.move.input.value']
|
ValueModel = self.env['fp.job.step.move.input.value']
|
||||||
|
Attachment = self.env['ir.attachment']
|
||||||
captured = 0
|
captured = 0
|
||||||
for line in self.line_ids:
|
for line in self.line_ids:
|
||||||
if not line._has_value():
|
if not line._has_value():
|
||||||
@@ -131,6 +143,33 @@ class FpJobStepInputWizard(models.TransientModel):
|
|||||||
'value_boolean': line.value_boolean,
|
'value_boolean': line.value_boolean,
|
||||||
'value_date': line.value_date or False,
|
'value_date': line.value_date or False,
|
||||||
}
|
}
|
||||||
|
# Sub 12d — composite + photo input types serialise differently.
|
||||||
|
if line.is_photo_type and line.photo_value:
|
||||||
|
att = Attachment.create({
|
||||||
|
'name': line.photo_filename or 'photo.jpg',
|
||||||
|
'datas': line.photo_value,
|
||||||
|
'res_model': 'fp.job.step.move',
|
||||||
|
'res_id': move.id,
|
||||||
|
})
|
||||||
|
vals['value_attachment_id'] = att.id
|
||||||
|
elif line.is_multi_point_type:
|
||||||
|
import json
|
||||||
|
pts = [line.point_1, line.point_2, line.point_3,
|
||||||
|
line.point_4, line.point_5]
|
||||||
|
non_empty = [p for p in pts if p]
|
||||||
|
avg = sum(non_empty) / len(non_empty) if non_empty else 0.0
|
||||||
|
vals['value_text'] = json.dumps({
|
||||||
|
'readings': pts, 'avg': avg,
|
||||||
|
})
|
||||||
|
vals['value_number'] = avg
|
||||||
|
elif line.is_panel_type:
|
||||||
|
import json
|
||||||
|
vals['value_text'] = json.dumps({
|
||||||
|
'ph': line.panel_ph,
|
||||||
|
'concentration': line.panel_concentration,
|
||||||
|
'temperature': line.panel_temperature,
|
||||||
|
'bath_id': line.panel_bath_id or '',
|
||||||
|
})
|
||||||
# For ad-hoc rows (no node_input_id), preserve the operator's
|
# For ad-hoc rows (no node_input_id), preserve the operator's
|
||||||
# typed prompt label in value_text so the chronological CoC
|
# typed prompt label in value_text so the chronological CoC
|
||||||
# report still shows what was measured. Format: "Prompt: value"
|
# report still shows what was measured. Format: "Prompt: value"
|
||||||
@@ -203,6 +242,35 @@ class FpJobStepInputWizardLine(models.TransientModel):
|
|||||||
value_boolean = fields.Boolean(string='Yes/No')
|
value_boolean = fields.Boolean(string='Yes/No')
|
||||||
value_date = fields.Datetime(string='Date / Time')
|
value_date = fields.Datetime(string='Date / Time')
|
||||||
|
|
||||||
|
# Sub 12d — composite + photo input types
|
||||||
|
photo_value = fields.Binary(string='Photo', attachment=True)
|
||||||
|
photo_filename = fields.Char(string='Photo Filename')
|
||||||
|
point_1 = fields.Float(string='R1')
|
||||||
|
point_2 = fields.Float(string='R2')
|
||||||
|
point_3 = fields.Float(string='R3')
|
||||||
|
point_4 = fields.Float(string='R4')
|
||||||
|
point_5 = fields.Float(string='R5')
|
||||||
|
point_avg = fields.Float(
|
||||||
|
string='Average',
|
||||||
|
compute='_compute_point_avg',
|
||||||
|
store=False,
|
||||||
|
)
|
||||||
|
panel_ph = fields.Float(string='Panel pH')
|
||||||
|
panel_concentration = fields.Float(string='Panel Concentration')
|
||||||
|
panel_temperature = fields.Float(string='Panel Temperature')
|
||||||
|
panel_bath_id = fields.Char(string='Panel Bath ID')
|
||||||
|
|
||||||
|
@api.depends('point_1', 'point_2', 'point_3', 'point_4', 'point_5')
|
||||||
|
def _compute_point_avg(self):
|
||||||
|
for rec in self:
|
||||||
|
pts = [
|
||||||
|
p for p in (
|
||||||
|
rec.point_1, rec.point_2, rec.point_3,
|
||||||
|
rec.point_4, rec.point_5,
|
||||||
|
) if p
|
||||||
|
]
|
||||||
|
rec.point_avg = sum(pts) / len(pts) if pts else 0.0
|
||||||
|
|
||||||
is_authored = fields.Boolean(
|
is_authored = fields.Boolean(
|
||||||
compute='_compute_is_authored',
|
compute='_compute_is_authored',
|
||||||
help='True when this row originated from an authored recipe input. '
|
help='True when this row originated from an authored recipe input. '
|
||||||
@@ -233,20 +301,39 @@ class FpJobStepInputWizardLine(models.TransientModel):
|
|||||||
compute='_compute_type_flags',
|
compute='_compute_type_flags',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_photo_type = fields.Boolean(compute='_compute_type_flags')
|
||||||
|
is_multi_point_type = fields.Boolean(compute='_compute_type_flags')
|
||||||
|
is_panel_type = fields.Boolean(compute='_compute_type_flags')
|
||||||
|
|
||||||
@api.depends('input_type')
|
@api.depends('input_type')
|
||||||
def _compute_type_flags(self):
|
def _compute_type_flags(self):
|
||||||
numeric_types = {
|
numeric_types = {
|
||||||
'number', 'temperature', 'thickness',
|
'number', 'temperature', 'thickness',
|
||||||
'time_seconds',
|
'time_seconds', 'ph',
|
||||||
}
|
}
|
||||||
for rec in self:
|
for rec in self:
|
||||||
it = rec.input_type or 'text'
|
it = rec.input_type or 'text'
|
||||||
rec.is_boolean_type = it in ('boolean', 'pass_fail')
|
rec.is_boolean_type = it in ('boolean', 'pass_fail')
|
||||||
rec.is_date_type = it == 'date'
|
rec.is_date_type = it == 'date'
|
||||||
rec.is_numeric_type = it in numeric_types
|
rec.is_numeric_type = it in numeric_types
|
||||||
|
rec.is_photo_type = it == 'photo'
|
||||||
|
rec.is_multi_point_type = it == 'multi_point_thickness'
|
||||||
|
rec.is_panel_type = it == 'bath_chemistry_panel'
|
||||||
|
|
||||||
def _has_value(self):
|
def _has_value(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
if self.is_photo_type:
|
||||||
|
return bool(self.photo_value)
|
||||||
|
if self.is_multi_point_type:
|
||||||
|
return any([
|
||||||
|
self.point_1, self.point_2, self.point_3,
|
||||||
|
self.point_4, self.point_5,
|
||||||
|
])
|
||||||
|
if self.is_panel_type:
|
||||||
|
return any([
|
||||||
|
self.panel_ph, self.panel_concentration,
|
||||||
|
self.panel_temperature, self.panel_bath_id,
|
||||||
|
])
|
||||||
return any([
|
return any([
|
||||||
self.value_text,
|
self.value_text,
|
||||||
self.value_number,
|
self.value_number,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
<field name="is_boolean_type" column_invisible="1"/>
|
<field name="is_boolean_type" column_invisible="1"/>
|
||||||
<field name="is_date_type" column_invisible="1"/>
|
<field name="is_date_type" column_invisible="1"/>
|
||||||
<field name="is_numeric_type" column_invisible="1"/>
|
<field name="is_numeric_type" column_invisible="1"/>
|
||||||
|
<field name="is_photo_type" column_invisible="1"/>
|
||||||
|
<field name="is_multi_point_type" column_invisible="1"/>
|
||||||
|
<field name="is_panel_type" column_invisible="1"/>
|
||||||
<field name="name"
|
<field name="name"
|
||||||
string="Measurement"
|
string="Measurement"
|
||||||
readonly="is_authored"
|
readonly="is_authored"
|
||||||
@@ -33,15 +36,6 @@
|
|||||||
string="Unit"
|
string="Unit"
|
||||||
readonly="is_authored"
|
readonly="is_authored"
|
||||||
optional="show"/>
|
optional="show"/>
|
||||||
<!-- Distinct column labels so the operator
|
|
||||||
reads which input matches the row's
|
|
||||||
type. List-view columns are static in
|
|
||||||
Odoo — labelling each by its purpose
|
|
||||||
removes the "four identical Value
|
|
||||||
columns" guesswork from the previous
|
|
||||||
layout. Only the cell matching the
|
|
||||||
row's type stays editable; others sit
|
|
||||||
blank. -->
|
|
||||||
<field name="value_number"
|
<field name="value_number"
|
||||||
string="Number"
|
string="Number"
|
||||||
invisible="not is_numeric_type"/>
|
invisible="not is_numeric_type"/>
|
||||||
@@ -54,7 +48,33 @@
|
|||||||
invisible="not is_date_type"/>
|
invisible="not is_date_type"/>
|
||||||
<field name="value_text"
|
<field name="value_text"
|
||||||
string="Text"
|
string="Text"
|
||||||
invisible="is_numeric_type or is_boolean_type or is_date_type"/>
|
invisible="is_numeric_type or is_boolean_type or is_date_type or is_photo_type or is_multi_point_type or is_panel_type"/>
|
||||||
|
<field name="photo_value"
|
||||||
|
string="Photo"
|
||||||
|
widget="image"
|
||||||
|
options="{'preview_image': 'photo_value'}"
|
||||||
|
invisible="not is_photo_type"/>
|
||||||
|
<field name="photo_filename" column_invisible="1"/>
|
||||||
|
<field name="point_1" string="R1"
|
||||||
|
invisible="not is_multi_point_type" optional="show"/>
|
||||||
|
<field name="point_2" string="R2"
|
||||||
|
invisible="not is_multi_point_type" optional="show"/>
|
||||||
|
<field name="point_3" string="R3"
|
||||||
|
invisible="not is_multi_point_type" optional="show"/>
|
||||||
|
<field name="point_4" string="R4"
|
||||||
|
invisible="not is_multi_point_type" optional="hide"/>
|
||||||
|
<field name="point_5" string="R5"
|
||||||
|
invisible="not is_multi_point_type" optional="hide"/>
|
||||||
|
<field name="point_avg" string="Avg" readonly="1"
|
||||||
|
invisible="not is_multi_point_type"/>
|
||||||
|
<field name="panel_ph" string="pH"
|
||||||
|
invisible="not is_panel_type"/>
|
||||||
|
<field name="panel_concentration" string="Conc"
|
||||||
|
invisible="not is_panel_type"/>
|
||||||
|
<field name="panel_temperature" string="Temp"
|
||||||
|
invisible="not is_panel_type"/>
|
||||||
|
<field name="panel_bath_id" string="Bath"
|
||||||
|
invisible="not is_panel_type"/>
|
||||||
<field name="target_min" optional="hide"/>
|
<field name="target_min" optional="hide"/>
|
||||||
<field name="target_max" optional="hide"/>
|
<field name="target_max" optional="hide"/>
|
||||||
</list>
|
</list>
|
||||||
|
|||||||
@@ -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.10.1.3',
|
'version': '19.0.10.2.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': [
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
<t t-foreach="moves" t-as="mv">
|
<t t-foreach="moves" t-as="mv">
|
||||||
<t t-set="dest" t-value="mv.to_step_id"/>
|
<t t-set="dest" t-value="mv.to_step_id"/>
|
||||||
<t t-set="tank_code" t-value="(mv.to_tank_id and mv.to_tank_id.code) or (dest and dest.tank_id and dest.tank_id.code) or ''"/>
|
<t t-set="tank_code" t-value="(mv.to_tank_id and mv.to_tank_id.code) or (dest and dest.tank_id and dest.tank_id.code) or ''"/>
|
||||||
<t t-set="captured" t-value="(dest and dest.input_ids.filtered(lambda i: (i.kind or 'step_input') == 'step_input').sorted('sequence')) or []"/>
|
<t t-set="captured" t-value="(dest and dest.input_ids.filtered(lambda i: (i.kind or 'step_input') == 'step_input' and (not hasattr(i, 'collect') or i.collect)).sorted('sequence')) or []"/>
|
||||||
|
|
||||||
<h3>
|
<h3>
|
||||||
<span t-esc="(dest and dest.name) or '—'"/>
|
<span t-esc="(dest and dest.name) or '—'"/>
|
||||||
@@ -138,7 +138,17 @@
|
|||||||
<t t-set="cv" t-value="captured_values_by_input.get(inp.id)"/>
|
<t t-set="cv" t-value="captured_values_by_input.get(inp.id)"/>
|
||||||
<t t-set="actual_str" t-value="''"/>
|
<t t-set="actual_str" t-value="''"/>
|
||||||
<t t-if="cv">
|
<t t-if="cv">
|
||||||
<t t-if="cv.value_text">
|
<t t-if="inp.input_type == 'multi_point_thickness' and cv.value_text">
|
||||||
|
<t t-set="_payload" t-value="cv.value_text"/>
|
||||||
|
<t t-set="actual_str" t-value="_payload"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="inp.input_type == 'bath_chemistry_panel' and cv.value_text">
|
||||||
|
<t t-set="actual_str" t-value="cv.value_text"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="inp.input_type == 'ph' and cv.value_number">
|
||||||
|
<t t-set="actual_str" t-value="'pH %.2f' % cv.value_number"/>
|
||||||
|
</t>
|
||||||
|
<t t-elif="cv.value_text">
|
||||||
<t t-set="actual_str" t-value="cv.value_text"/>
|
<t t-set="actual_str" t-value="cv.value_text"/>
|
||||||
</t>
|
</t>
|
||||||
<t t-elif="cv.value_number">
|
<t t-elif="cv.value_number">
|
||||||
|
|||||||
Reference in New Issue
Block a user