Compare commits

...

12 Commits

Author SHA1 Message Date
gsinghpal
aec7659a2e fix(configurator): address code review findings — pricing engine + views
- Fix thickness factor: now scales linearly (thickness * factor), not
  multiplicatively. Default factor=1.0 means price scales 1:1 with mils.
- Fix batch_size: setup fee now multiplied by ceil(qty/batch_size) batches
- Fix hardcoded $ in price breakdown HTML: uses currency_id.symbol
- Add coating_config_id.certification_level to @api.depends
- Remove readonly on x_fc_receiving_status (placeholder until receiving module)
- Add currency_id to treatment list view for Monetary widget

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:43:02 -04:00
gsinghpal
a337a510c1 feat(configurator): seed data — common pre/post treatments
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:37:03 -04:00
gsinghpal
a5761b9863 feat(configurator): menu restructure — Sales as default landing in Fusion Plating
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:36:47 -04:00
gsinghpal
d3e2614620 feat(configurator): fp.quote.configurator — pricing engine + SO creation
Add the core configurator model that collects part geometry, coating
config, and pricing inputs, calculates a price from matching pricing
rules (scored by specificity), and creates sale orders on confirmation.

- fp.quote.configurator model with mail.thread, sequence numbering
- Stored computed price with full breakdown HTML table
- Estimator override price support
- Auto-population from part catalog and coating config onchanges
- Surface area normalization (sq in/ft/cm/m)
- Specificity-scored rule matching (coating > substrate > cert level)
- action_create_quotation creates SO with FP-SERVICE product
- Form/list/search views with statusbar and chatter
- ACL: operator (read), estimator (read/write/create), manager (full)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:35:08 -04:00
gsinghpal
5143245f57 feat(configurator): sale.order plating extensions + custom list/form views
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:29:24 -04:00
gsinghpal
2fa7f2aa2e feat(configurator): fp.pricing.rule — formula-based pricing engine with complexity surcharges
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:28:43 -04:00
gsinghpal
2e80fd3ca1 feat(configurator): fp.coating.config — coating configuration templates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:25:48 -04:00
gsinghpal
87325e2caf feat(configurator): fp.part.catalog — customer part library with views
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:24:54 -04:00
gsinghpal
73b7325b46 feat(configurator): module scaffold + fp.treatment model with views and ACL
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:21:55 -04:00
gsinghpal
dde970a2f5 docs: Phase 1 implementation plan — configurator + sales integration
10 tasks covering: module scaffold, 7 models (treatment, part catalog,
coating config, pricing rule, complexity surcharge, configurator, SO
extensions), security groups, menu restructure, seed data, integration test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:14:30 -04:00
gsinghpal
d424dfdb19 docs: address spec review findings — 5 critical, 8 major issues fixed
- Add model naming convention table (fp.* for new, fusion.plating.* for existing)
- Add fusion_plating_certificates as dedicated module with fp.thickness.reading model
- Fix complexity_surcharge: companion model instead of JSON text field
- Add recipe_id domain constraint [('node_type', '=', 'recipe')]
- Align security groups with existing 4-level privilege hierarchy
- Add currency_id to all monetary models
- Clarify fp.quote.configurator as persistent model with state lifecycle
- Fix canonical model names (fusion.plating.portal.job, fusion.plating.delivery)
- Add auto-population rules for invoice strategy and configurator defaults
- Lighten bridge_mrp deps: gates as mixins in receiving/invoicing modules
- Add deployment strategy for fusion_tasks (same server, not standalone)
- Add data migration section for existing quote request coexistence
- Add work centre mapping note (fusion.plating.work.center ↔ mrp.workcenter)
- Change x_fc_account_hold_date to Datetime for audit precision
- Add bilingual CoC implementation note (QWeb, not ir.translation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:05:42 -04:00
gsinghpal
f69b3ac855 docs: EN Tech end-to-end workflow design spec
Complete design covering 5 new modules + updates to existing:
- fusion_plating_configurator (3D viewer, pricing engine, part catalog)
- fusion_plating_receiving (inspection, damage logging, PO matching)
- fusion_plating_invoicing (deposit/progress/net/COD, account holds)
- fusion_plating_notifications (auto-email, certificate assembly)
- fusion_tasks fork (local delivery dispatch, GPS tracking)
Plus: sales integration, certificate registry, 9-stage workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:57:56 -04:00
24 changed files with 4267 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,854 @@
# EN Tech Plating — End-to-End Workflow Design Spec
**Date:** 2026-04-12
**Author:** Nexa Systems (Claude-assisted)
**Client:** EN Technologies (Electroless Nickel Technologies Inc.)
**Status:** Approved for implementation planning
---
## 1. Overview
Complete end-to-end ERP workflow for an electroless nickel plating shop, replacing Steelhead Software. The system covers customer inquiry through invoicing, with a quotation configurator, parts receiving/inspection, flexible invoicing strategies, automated email notifications, certificate management, and local delivery dispatch.
### Model Naming Convention
| Context | Convention | Example |
|---------|-----------|---------|
| New custom models (all new modules) | `fp.*` prefix | `fp.part.catalog`, `fp.certificate` |
| Existing custom models (already in codebase) | Keep `fusion.plating.*` | `fusion.plating.portal.job`, `fusion.plating.delivery` |
| New fields on standard Odoo models | `x_fc_*` prefix | `x_fc_po_number` on `sale.order` |
| Legacy fields (from Studio era) | `x_studio_*` | Preserved, not renamed |
The `fp.*` prefix is the new short-form convention for all models created in the new modules. Existing `fusion.plating.*` models are NOT renamed — they keep their current `_name`. All references in this spec use canonical `_name` values.
### Architecture: Approach 2 — Dedicated Modules per Sub-System
| Module | Purpose | Location |
|--------|---------|----------|
| `fusion_plating_configurator` | Quotation configurator, 3D viewer, pricing engine, part catalog | `fusion-plating/` |
| `fusion_plating_receiving` | Parts receiving, inspection, damage logging, PO matching | `fusion-plating/` |
| `fusion_plating_invoicing` | Invoice strategy engine (deposit/progress/net/COD), account holds | `fusion-plating/` |
| `fusion_plating_notifications` | Auto-email engine, notification templates, audit log | `fusion-plating/` |
| `fusion_plating_certificates` | Certificate registry (CoC, thickness, Nadcap, customer-specific) | `fusion-plating/` |
| `fusion_tasks` | Local delivery dispatch (forked from Westin, stripped of claims) | `Entech Plating/` |
Plus updates to existing modules:
- `fusion_plating_bridge_mrp` — Recipe-to-WO generation (lightweight; gates live in their own modules)
- `fusion_plating_portal` — Customer-facing configurator UI, 3D preview
- `fusion_plating_reports` — CoC template updates, thickness report template
- `fusion_plating_quality` — Thickness reading model for Fischerscope data
---
## 2. Sales Integration — Fusion Plating as Single Hub
The Fusion Plating app becomes the single workspace. Users never leave it. Sale orders are managed with custom plating-specific views.
### Menu Structure
```
Fusion Plating (app)
├── Sales (default landing)
│ ├── Quotations (sale.order, state=draft/sent)
│ ├── Sale Orders (sale.order, state=sale)
│ ├── Customers (res.partner, customer_rank > 0)
│ └── Part Catalog (fp.part.catalog)
├── Configurator
│ ├── New Quote (fp.quote.configurator, persistent model)
│ ├── Coating Configurations (fp.coating.config)
│ └── Pricing Rules (fp.pricing.rule)
├── Manufacturing
│ ├── Manufacturing Orders
│ ├── Work Orders
│ └── Plant Overview
├── Receiving & Inspection
├── Shipping & Delivery
│ ├── Deliveries (fp.delivery)
│ ├── Local Delivery Tasks (fusion.delivery.task)
│ └── Routes (fp.route)
├── Certificates
│ ├── All Certificates (fp.certificate)
│ ├── Certificates of Conformance (filtered: type=coc)
│ └── Thickness Reports (filtered: type=thickness_report)
├── Quality
├── Portal Jobs
├── Reports
└── Configuration
```
### Sale Order Extensions (fields on `sale.order`)
- `x_fc_configurator_id` — link back to configurator session
- `x_fc_part_catalog_id` — customer part being ordered
- `x_fc_coating_config_id` — coating configuration
- `x_fc_po_number` — customer PO reference (Char)
- `x_fc_po_attachment_id` — uploaded PO document
- `x_fc_po_received` — Boolean
- `x_fc_po_override` — Boolean (manager override — proceed without PO)
- `x_fc_po_override_reason` — Text
- `x_fc_invoice_strategy` — Selection (deposit, progress, net_terms, cod_prepay)
- `x_fc_deposit_percent` — Float
- `x_fc_rush_order` — Boolean
- `x_fc_delivery_method` — Selection (local_delivery, shipping_partner, customer_pickup)
- `x_fc_receiving_status` — Selection (not_received, partial, received, inspected) — computed
- Smart buttons: Portal Job, Manufacturing Order, Delivery, Receiving, Invoices, Certificates
### Custom Sale Order Views
- **List**: Customer, PO#, Part, Coating, Qty, Total, Receiving Status, Job Status, Delivery Method
- **Form**: inherits sale.order form, adds plating tabs (Part Details, Coating Config, Receiving, Job Tracking)
- **Kanban**: cards grouped by stage (Draft → Quoted → PO Received → Parts Received → In Production → Shipped → Invoiced)
### Permission-Based Visibility
The existing codebase defines a 4-level privilege hierarchy in `fusion_plating/security/fp_security.xml`: Operator → Supervisor → Manager → Administrator. These new groups are **role-based** and work **alongside** (not replacing) the existing privilege levels. A user has both a privilege level (what they can do: read/write/create/delete) and one or more roles (what they can see: which menus appear).
| Role Group | Menu Visibility | Required Privilege Level |
|------------|----------------|------------------------|
| `fp_group_estimator` | Sales, Configurator, Customers, Part Catalog | Supervisor+ |
| `fp_group_shop_manager` | Everything (full menu) | Manager+ |
| `fp_group_shop_floor` | Manufacturing, Work Orders, Plant Overview only | Operator+ |
| `fp_group_receiving` | Receiving & Inspection, can view Sales (read-only) | Operator+ |
| `fp_group_shipping` | Shipping & Delivery, can view Sales (read-only) | Operator+ |
| `fp_group_quality` | Quality, can view Manufacturing | Supervisor+ |
| `fp_group_accounting` | Sales (invoicing fields), Reports | Supervisor+ |
Users are assigned to one or more role groups. The existing privilege hierarchy controls CRUD permissions; role groups control menu/view visibility. `fp_group_shop_manager` implies all other role groups (full access).
Standard Odoo groups are still required for underlying model access (e.g. `sales_team.group_sale_salesman` for SO access).
---
## 3. `fusion_plating_configurator` — Quotation Configurator & Pricing Engine
### Users
- **Primary:** Internal estimator (full control, detailed configurator, price override)
- **Secondary:** Portal customer (simplified self-service, estimated pricing, 3D preview, lead gen)
### Core Models
#### `fp.part.catalog` — Customer Part Library
| Field | Type | Description |
|-------|------|-------------|
| `partner_id` | Many2one res.partner | Customer (required) |
| `name` | Char | Part name/description |
| `part_number` | Char | Customer's part number |
| `revision` | Char | Revision letter/number |
| `substrate_material` | Selection | aluminium, steel, stainless, copper, titanium, other |
| `geometry_source` | Selection | 3d_model, manual, pdf_drawing |
| `model_attachment_id` | Many2one ir.attachment | STEP/STL/IGES file |
| `drawing_attachment_ids` | Many2many ir.attachment | PDF drawings |
| `surface_area` | Float | Surface area value |
| `surface_area_uom` | Selection | sq_in, sq_ft, sq_cm, sq_m |
| `weight` | Float | For shipping cost calc |
| `dimensions_length` | Float | Manual measurement |
| `dimensions_width` | Float | Manual measurement |
| `dimensions_height` | Float | Manual measurement |
| `complexity` | Selection | simple, moderate, complex, very_complex |
| `masking_zones` | Integer | Number of areas requiring masking |
| `masking_description` | Text | e.g. "mask threaded holes" |
| `has_blind_holes` | Boolean | Complexity flag |
| `has_recesses` | Boolean | Complexity flag |
| `has_threads` | Boolean | Complexity flag |
| `notes` | Html | |
| `active` | Boolean | Archivable |
#### `fp.coating.config` — Coating Configuration Template
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | e.g. "EN Mid-Phos AMS 2404" |
| `process_type_id` | Many2one fusion.plating.process.type | Process type |
| `recipe_id` | Many2one fusion.plating.process.node | Default recipe. **Domain: `[('node_type', '=', 'recipe')]`** |
| `phosphorus_level` | Selection | low_phos, mid_phos, high_phos, na |
| `thickness_min` | Float | Min thickness |
| `thickness_max` | Float | Max thickness |
| `thickness_uom` | Selection | mils, microns, inches |
| `spec_reference` | Char | e.g. "AMS 2404" |
| `certification_level` | Selection | commercial, mil_spec, nadcap, nuclear |
| `pre_treatment_ids` | Many2many fp.treatment | Bead blast, zincate, etc. |
| `post_treatment_ids` | Many2many fp.treatment | Bake, passivate, chromate, etc. |
| `active` | Boolean | |
#### `fp.treatment` — Pre/Post Treatment
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | e.g. "Bead Blast", "Zincate", "Bake" |
| `treatment_type` | Selection | pre, post |
| `default_duration_minutes` | Float | Estimated duration |
| `currency_id` | Many2one res.currency | Company currency (default) |
| `default_cost` | Monetary | Cost per application |
#### `fp.pricing.rule` — Formula-Based Pricing Engine
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Rule description |
| `coating_config_id` | Many2one fp.coating.config | Optional filter (global if blank) |
| `substrate_material` | Selection | Optional filter |
| `certification_level` | Selection | Optional filter |
| `pricing_method` | Selection | per_sqin, per_sqft, per_piece, flat_rate, formula |
| `currency_id` | Many2one res.currency | Company currency (default) |
| `base_rate` | Monetary | $ per unit |
| `thickness_factor` | Float | Multiplier per mil of thickness |
| `complexity_surcharge_ids` | One2many fp.pricing.complexity.surcharge | Surcharges by complexity level |
| `masking_rate_per_zone` | Monetary | $ per masking area |
| `setup_fee` | Monetary | One-time per batch |
| `minimum_charge` | Monetary | Floor price |
| `rush_surcharge_percent` | Float | Rush premium % |
| `sequence` | Integer | Priority — first matching rule wins |
| `active` | Boolean | |
#### `fp.pricing.complexity.surcharge` — Complexity-Based Surcharge Line
| Field | Type | Description |
|-------|------|-------------|
| `rule_id` | Many2one fp.pricing.rule | Parent rule (cascade) |
| `complexity` | Selection | simple, moderate, complex, very_complex |
| `surcharge_percent` | Float | Surcharge % for this complexity level |
#### `fp.quote.configurator` — The Configurator Session (Persistent Model)
This is a `models.Model` (NOT transient). Records persist for audit trail, re-quoting, and linking back from sale orders. The SO links back via `x_fc_configurator_id`.
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Auto-sequence (CFG-00001) |
| `state` | Selection | draft, confirmed, cancelled |
| `partner_id` | Many2one res.partner | Customer |
| `part_catalog_id` | Many2one fp.part.catalog | For repeat parts |
| `coating_config_id` | Many2one fp.coating.config | Coating selection |
| `quantity` | Integer | Number of parts |
| `batch_size` | Integer | Parts per rack/barrel |
| `surface_area` | Float | From catalog or entered |
| `thickness_requested` | Float | |
| `masking_zones` | Integer | |
| `complexity` | Selection | simple, moderate, complex, very_complex |
| `rush_order` | Boolean | |
| `turnaround_days` | Integer | |
| `delivery_method` | Selection | local_delivery, shipping_partner, customer_pickup |
| `currency_id` | Many2one res.currency | Company currency (default) |
| `shipping_fee` | Monetary | |
| `delivery_fee` | Monetary | |
| `calculated_price` | Monetary | Computed from pricing rules |
| `price_breakdown_html` | Html | Rendered breakdown |
| `estimator_override_price` | Monetary | Final price (defaults to calculated) |
| `sale_order_id` | Many2one sale.order | Created SO (set on "Create Quotation") |
| `notes` | Text | |
**Lifecycle:** draft → (estimator builds quote) → confirmed (when SO created) → cancelled (if abandoned). Confirmed records are read-only. Re-quoting creates a new configurator record.
### Price Calculation Flow
```
Part Catalog (surface area, complexity, masking)
+ Coating Config (process, thickness, spec level)
+ Pricing Rules (matched by coating + substrate + cert level)
+ Quantity / Batch Size
+ Rush surcharge (if applicable)
+ Delivery / Shipping fees
= Calculated Price
→ Estimator reviews & overrides if needed
→ Final quote price
```
### 3D Viewer
- OWL component using **Three.js** for STL rendering and **OCCT (OpenCascade) WASM** for STEP parsing
- Renders on both backend configurator form and portal page
- Features: wireframe/solid toggle, rotate/zoom, surface area highlight
- Server-side surface area calculation: Python `trimesh` (STL) / `cadquery`/`OCP` (STEP)
- Fallback: manual measurements if server can't parse the file
- Roadmap: Claude Vision for PDF drawing measurement extraction
### Portal Side (Customer-Facing)
- Simplified wizard: upload part → select coating type → see estimated price range
- Uses same `fp.quote.configurator` model with restricted fields
- Customer sees estimated price range (not exact), 3D preview
- Submits → creates `fp.quote.request` with configurator data attached
- Internal estimator sees customer's config and refines it
### Configurator → Sale Order Flow
1. Estimator opens Configurator → builds quote
2. Clicks "Create Quotation" → sale.order created with all x_fc_* fields
3. SO line(s) auto-created (product = service product per coating type, qty, price = estimator's final price)
4. Estimator reviews SO → sends quotation
5. Customer accepts → confirms SO → triggers downstream flow
---
## 4. `fusion_plating_receiving` — Parts Receiving & Inspection
### Core Models
#### `fp.receiving` — Receiving Record
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Auto-sequence (RCV-00001) |
| `sale_order_id` | Many2one sale.order | Required. **Design decision:** one receiving record per SO. If a customer ships parts for multiple SOs in one box, create separate receiving records per SO (receiver splits the count). This keeps the SO↔receiving link clean and avoids Many2many complexity. |
| `partner_id` | Many2one res.partner | Related from SO |
| `po_number` | Char | Related from SO x_fc_po_number |
| `received_by_id` | Many2one res.users | Who logged it |
| `received_date` | Datetime | Default=now |
| `state` | Selection | draft, inspecting, accepted, discrepancy, resolved |
| `expected_qty` | Integer | From SO line |
| `received_qty` | Integer | Entered by receiver |
| `qty_match` | Boolean | Computed: received == expected |
| `carrier_name` | Char | Who delivered |
| `carrier_tracking` | Char | Inbound tracking # |
| `notes` | Html | |
| `line_ids` | One2many fp.receiving.line | Per-part detail |
| `damage_ids` | One2many fp.receiving.damage | Damage log |
| `attachment_ids` | Many2many ir.attachment | Photos |
#### `fp.receiving.line` — Per-Part Receiving Detail
| Field | Type | Description |
|-------|------|-------------|
| `receiving_id` | Many2one fp.receiving | Cascade |
| `part_catalog_id` | Many2one fp.part.catalog | Optional |
| `part_number` | Char | |
| `description` | Char | |
| `expected_qty` | Integer | |
| `received_qty` | Integer | |
| `condition` | Selection | good, damaged, mixed |
| `notes` | Text | |
#### `fp.receiving.damage` — Damage Log Entry
| Field | Type | Description |
|-------|------|-------------|
| `receiving_id` | Many2one fp.receiving | Cascade |
| `description` | Text | What's damaged |
| `severity` | Selection | cosmetic, functional, rejected |
| `photo_ids` | Many2many ir.attachment | |
| `action_required` | Selection | none, notify_customer, return_parts, proceed_as_is |
| `customer_notified` | Boolean | |
| `customer_response` | Text | |
| `resolved` | Boolean | |
### Workflow
```
SO Confirmed → Receiving record auto-created (state=draft)
→ Parts arrive → receiver enters qty, inspects condition
→ Match + good → state=accepted
→ Mismatch or damage → state=discrepancy
→ SO flagged, follow-up activity created
→ Customer contacted → resolution logged → state=resolved
→ Accepted/Resolved → SO x_fc_receiving_status = 'received'
→ Manufacturing can proceed
```
### Manufacturing Gate
- `mrp.production.action_confirm()` checks `sale_order.x_fc_receiving_status`
- If not received → warning dialog (manager can override)
- Soft gate — warns, doesn't hard-block (flexibility for handshake deals, urgent jobs)
---
## 5. `fusion_plating_invoicing` — Invoice Strategy Engine
### Invoice Strategies
| Strategy | Behaviour |
|----------|-----------|
| `deposit` | Deposit invoice for X% on SO confirmation. Balance after shipping. |
| `progress` | Invoice per MO as each completes. |
| `net_terms` | Single invoice after shipping. Payment on terms. |
| `cod_prepay` | Full invoice on SO confirmation. Manufacturing blocked until paid. |
### Core Models
#### `fp.invoice.strategy.default` — Customer-Level Default
| Field | Type | Description |
|-------|------|-------------|
| `partner_id` | Many2one res.partner | Unique per customer |
| `default_strategy` | Selection | deposit, progress, net_terms, cod_prepay |
| `default_deposit_percent` | Float | e.g. 50.0 |
| `payment_term_id` | Many2one account.payment.term | |
| `notes` | Text | |
### Auto-Population Rules
When a customer is selected on a new SO:
1. Look up `fp.invoice.strategy.default` for that `partner_id`
2. If found → auto-fill `x_fc_invoice_strategy` and `x_fc_deposit_percent` from the default
3. If not found → leave blank (estimator must select manually)
4. Estimator can always override per order
When a coating config is selected in the configurator:
1. Auto-fill `thickness_requested` from `coating_config.thickness_min` (default to minimum)
2. Auto-fill surface area UOM from company default setting
When a part catalog entry is selected:
1. Auto-fill `surface_area`, `complexity`, `masking_zones`, `substrate_material` from the catalog entry
2. These can be overridden per-quote if the part has changed
### Account Hold (extends `res.partner`)
| Field | Type | Description |
|-------|------|-------------|
| `x_fc_account_hold` | Boolean | Manually set by accounting |
| `x_fc_account_hold_reason` | Text | Why hold was placed |
| `x_fc_account_hold_date` | Datetime | When placed (Datetime for audit precision) |
| `x_fc_account_hold_by_id` | Many2one res.users | Who placed it |
### Account Hold Behaviour
| Action | Hold Active | Result |
|--------|-------------|--------|
| Create new SO | Yes | Warning banner. SO can still be created. |
| Confirm SO | Yes | Blocked. Manager override available. |
| Create invoice | Yes | Blocked. Manager override available. |
| Ship / mark delivered | Yes | Blocked. Manager override available. |
| Customer visits portal | Yes | No visible indication. |
Roadmap: auto-hold computed from account.move aging.
### Invoice Automation
- **Deposit:** SO confirmed → auto-create deposit invoice (X%) → balance invoice after shipping
- **Progress:** Each MO done → invoice for that MO's portion → final invoice for remaining balance
- **Net terms:** Delivery complete → auto-create full invoice → payment terms applied
- **COD/Prepay:** SO confirmed → auto-create full invoice → MO blocked until payment reconciled
### Shipping Method Price Adjustment
- Method changes after invoicing:
- Draft invoice → amend the line
- Posted invoice → supplementary invoice or credit note
---
## 6. `fusion_plating_notifications` — Auto-Email Engine
### Notification Triggers
| Trigger Event | Email Name | Attachments | Recipient |
|---------------|-----------|-------------|-----------|
| Quotation sent | Quote Ready | Quote PDF | Customer contact |
| SO confirmed | Order Confirmation | SO PDF | Customer contact |
| Parts received | Parts Received | — | Customer contact |
| MO complete | Ready for Pickup/Ship | — | Customer contact |
| Delivery shipped (carrier) | Shipment Notification | CoC, Thickness Report, Invoice | Customer contact |
| Delivery completed (local) | Delivery Confirmation | CoC, Thickness Report, Invoice, POD | Customer contact |
| Invoice posted | Invoice Notification | Invoice PDF | Billing contact |
| Deposit invoice created | Deposit Required | Deposit Invoice PDF | Billing contact |
### Core Models
#### `fp.notification.template` — Configurable Email Templates
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Template name |
| `trigger_event` | Selection | Event type |
| `mail_template_id` | Many2one mail.template | Actual Odoo template |
| `active` | Boolean | Can disable specific notifications |
| `attach_coc` | Boolean | |
| `attach_thickness_report` | Boolean | |
| `attach_invoice` | Boolean | |
| `attach_packing_list` | Boolean | |
| `attach_pod` | Boolean | |
| `cc_internal_ids` | Many2many res.users | Internal CCs |
#### `fp.notification.log` — Audit Trail
| Field | Type | Description |
|-------|------|-------------|
| `template_id` | Many2one fp.notification.template | |
| `trigger_event` | Selection | |
| `sale_order_id` | Many2one sale.order | |
| `partner_id` | Many2one res.partner | |
| `sent_date` | Datetime | |
| `recipient_email` | Char | |
| `attachment_names` | Text | Comma-separated list |
| `status` | Selection | sent, failed, bounced |
| `error_message` | Text | |
| `mail_mail_id` | Many2one mail.mail | |
### Document Assembly (Shipment Email)
1. Find portal job linked to SO/MO
2. Generate CoC PDF (bilingual EN/FR, customer logo, Nadcap badge if applicable)
3. Attach thickness report if available
4. Attach invoice PDF
5. Include tracking info in email body (carrier tracking # or driver ETA)
6. Send to customer contact
7. Log in fp.notification.log
### CoC Report Updates
- Customer logo placement (from partner.image_1920)
- Nadcap badge (conditional)
- EN Tech branding (replace Steelhead)
- Recorded thickness field
- Process description with spec references
- Bilingual certification statement
- Quantities: Shipped/Exp, NC Qty columns
- Configurable certifying authority signature
### Thickness / Measurement Report (NEW template)
Based on Fischerscope XDAL 600 output:
- EN Tech header
- Equipment info (model, product, application)
- Microscope image (attached photo)
- Reading data table (NiP mils, Ni %, P %)
- Statistical summary (Mean, Std Dev, CoV%, Range)
- Calibration standard reference
- Operator, date/time
- Data entry: manual for now, future Fischerscope CSV import
### Work Centre Mapping Note
The codebase has two work centre models: `fusion.plating.work.center` (core) and `mrp.workcenter` (standard MRP). Recipe nodes reference `fusion.plating.work.center`; MRP work orders use `mrp.workcenter`. The recipe-to-WO generation logic in `fusion_plating_bridge_mrp` must map between them. Each `fusion.plating.work.center` should have an `x_fc_mrp_workcenter_id` field linking to the corresponding `mrp.workcenter`. This mapping field should be added to the core module.
---
## 7. `fusion_plating_certificates` — Certificate Registry
**Module owner:** `fusion_plating_certificates` (NEW dedicated module).
**Dependencies:** `fusion_plating`, `fusion_plating_portal`, `fusion_plating_reports`, `mrp`
This module owns `fp.certificate` and `fp.thickness.reading`. It depends on `fusion_plating_portal` for the `fusion.plating.portal.job` link and on `fusion_plating_reports` for report generation.
### Model: `fp.certificate`
| Field | Type | Description |
|-------|------|-------------|
| `name` | Char | Auto-sequence (CERT-00001) |
| `certificate_type` | Selection | coc, thickness_report, mill_test, nadcap_cert, customer_specific |
| `partner_id` | Many2one res.partner | Customer |
| `sale_order_id` | Many2one sale.order | |
| `production_id` | Many2one mrp.production | |
| `portal_job_id` | Many2one fusion.plating.portal.job | Uses canonical model name |
| `part_number` | Char | Denormalized for fast search |
| `process_description` | Char | e.g. "ELECTROLESS NICKEL PLATING PER AMS 2404" |
| `spec_reference` | Char | |
| `po_number` | Char | Customer PO ref |
| `entech_wo_number` | Char | Internal WO # |
| `quantity_shipped` | Integer | |
| `issued_by_id` | Many2one res.users | |
| `certified_by_id` | Many2one res.users | Signing authority |
| `issue_date` | Date | Default=today |
| `attachment_id` | Many2one ir.attachment | Generated PDF |
| `thickness_reading_ids` | One2many fp.thickness.reading | Linked measurements |
| `state` | Selection | draft, issued, voided |
| `void_reason` | Text | |
| `notes` | Html | |
### Model: `fp.thickness.reading` — Fischerscope Measurement Data
| Field | Type | Description |
|-------|------|-------------|
| `certificate_id` | Many2one fp.certificate | Parent certificate (cascade) |
| `production_id` | Many2one mrp.production | Link to MO (independent of cert) |
| `reading_number` | Integer | Reading sequence (n=1, n=2, n=3) |
| `nip_mils` | Float(10,4) | NiP thickness in mils |
| `ni_percent` | Float(6,3) | Nickel content % |
| `p_percent` | Float(6,4) | Phosphorus content % |
| `position_label` | Char | Where on the part this reading was taken |
| `equipment_model` | Char | e.g. "Fischerscope XDAL 600" |
| `product_ref` | Char | e.g. "2805031 / NiP/Al-alloys 2805030" |
| `calibration_std_ref` | Char | e.g. "NiP/Al STD SET SN 100174568" |
| `microscope_image_id` | Many2one ir.attachment | Microscope photo |
| `operator_id` | Many2one res.users | Who took the reading |
| `reading_datetime` | Datetime | When reading was taken |
| `measuring_time_seconds` | Integer | e.g. 120 |
**Statistical fields** (computed from reading lines per certificate):
- `mean_nip_mils`, `stddev_nip_mils`, `cov_percent`, `range_nip_mils` — computed on `fp.certificate` from its `thickness_reading_ids`
### Auto-Creation
When CoC or thickness report is generated, `fp.certificate` record auto-created with PDF attached.
### Views
- **List** (default, newest first): Issue Date, Cert #, Type, Customer, Part #, PO #, Entech WO#, Process, Qty, Issued By, Status
- **Search**: Quick filters by Customer, Certificate Type, Date Range, Part Number, PO Number. Group by: Customer, Type, Month, Issued By.
- **Form**: Certificate type badge, state buttons (Issue/Void), customer info, part details, tabs for Thickness Readings, Attachments, Notes. "Regenerate PDF" and "Send to Customer" buttons.
### CoC Bilingual Implementation
The bilingual EN/FR certification statement uses QWeb template logic with `t-if` on a `bilingual` flag (default: True for Canadian compliance). The English and French text blocks are both rendered in the same template — not using Odoo's `ir.translation` system, since both languages must appear on the same document simultaneously.
---
## 8. `fusion_tasks` (Entech Plating) — Local Delivery Dispatch
### Fork & Strip Strategy
**Remove:**
- Cross-instance sync (fusion.task.sync.config, shadow tasks)
- `fusion_claims.*` config parameters → rename to `fusion_tasks.*`
- `sales_team` dependency
- Irrelevant task types (repair, troubleshoot, assessment, ltc_visit, maintenance, installation)
- All sync-related fields (x_fc_sync_*)
**Keep:**
- Google Maps integration (Leaflet.js map view)
- GPS tracking (fusion.technician.location → fusion.driver.location)
- Geocoding (_geocode_address())
- Route planning / scheduling / conflict avoidance
- Push notifications (fusion.push.subscription)
- Map view JS/SCSS/XML
### Adapted Model: `fusion.delivery.task`
Renamed from `fusion.technician.task`.
**Task Types** (reduced):
- `delivery` — outbound delivery
- `pickup` — collect parts from customer
- `return` — return rejected/damaged parts
- `rush` — same-day urgent
**Status Workflow:**
- `pending``scheduled``en_route``delivered` (or `failed`)
**Key Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `delivery_id` | Many2one fusion.plating.delivery | Links to logistics |
| `sale_order_id` | Many2one sale.order | |
| `portal_job_id` | Many2one fusion.plating.portal.job | Uses canonical model name |
| `partner_id` | Many2one res.partner | Customer |
| `driver_id` | Many2one hr.employee | Renamed from technician_id |
| `vehicle_id` | Many2one fusion.plating.vehicle | From logistics module |
| `packages_count` | Integer | Number of boxes/crates |
| `weight_total` | Float | Total weight |
| `requires_signature` | Boolean | POD required |
| `requires_photo` | Boolean | Photo proof required |
| `coc_attachment_id` | Many2one ir.attachment | CoC to hand to customer |
**Delivery Integration:**
- `action_mark_delivered()`: logs GPS + timestamp, captures signature/photo, updates fp.delivery → delivered, cascades to portal job → shipped, triggers shipment notification email
- `action_mark_failed()`: logs reason, creates follow-up activity
### Dependencies & Deployment
```python
'depends': ['base', 'mail', 'hr', 'fusion_plating_logistics'],
```
**Deployment strategy:** `fusion_tasks` lives in a separate repo (`Entech Plating/`) but is deployed to the SAME server as the fusion_plating modules. Both repos are copied to `/mnt/extra-addons/custom/` on the target server. The `fusion_plating_logistics` dependency is therefore always available at install time. There is no standalone driver-only install scenario — drivers access the system via the same Odoo instance. The standalone "Delivery Dispatch" menu (below) provides a driver-focused view without needing a separate deployment.
### Menu Placement
Inside Fusion Plating: Shipping & Delivery → Local Delivery Tasks, Driver Map
Standalone (optional): Delivery Dispatch app for drivers.
---
## 9. Complete End-to-End Workflow
### Stage 1: Customer Inquiry
- **Portal path:** Customer uploads 3D/PDF on portal → sees estimated price → submits → fp.quote.request created
- **Email/phone path:** Estimator creates fp.quote.request manually, uploads customer's files
- **Modules:** fusion_plating_portal, fusion_plating_configurator
### Stage 2: Quotation
- Estimator opens Configurator inside Fusion Plating app
- Selects/creates part in Part Catalog
- 3D model → auto surface area + 3D preview; PDF → manual measurements
- Selects Coating Configuration
- Pricing engine calculates from rules; estimator reviews/overrides
- Adds delivery/shipping fees, sets invoice strategy
- "Create Quotation" → sale.order with all x_fc_* fields
- Sends quotation → email notification with Quote PDF
- **Modules:** fusion_plating_configurator, fusion_plating_notifications
### Stage 3: Order Confirmation
- Customer accepts + submits PO (email or portal)
- PO number entered on SO, PO document uploaded (or manager override for handshake deals)
- SO confirmed:
- Account hold check → blocked if hold active (manager override)
- Invoice strategy fires (deposit/COD → auto-invoice; net_terms/progress → no invoice yet)
- Email: Order Confirmation + SO PDF
- Receiving record auto-created (draft)
- Portal job auto-created (received)
- **Modules:** fusion_plating_invoicing, fusion_plating_receiving, fusion_plating_bridge_mrp, fusion_plating_notifications
### Stage 4: Parts Receiving
- Parts arrive → receiver opens receiving record
- Counts parts, inspects condition
- Good → accepted; damage/mismatch → discrepancy → follow-up → resolved
- Email: Parts Received
- Portal job → in_progress
- **Modules:** fusion_plating_receiving, fusion_plating_notifications
### Stage 5: Manufacturing Planning
- MO created from SO (standard sale_mrp)
- Receiving gate: warns if parts not received
- COD gate: warns if prepay not paid
- Planner assigns recipe, configures opt-in/out steps
- Recipe → Work Orders generated (one WO per operation node, steps = WO instructions)
- **Modules:** fusion_plating_bridge_mrp
### Stage 6: Manufacturing Execution
- Operators work WOs on shopfloor (Plant Overview kanban, timers, bath/tank assignment)
- Quality holds if needed
- All WOs done → MO done:
- Portal job → ready_to_ship
- fp.delivery auto-created (draft)
- Thickness readings entered
- CoC + thickness report generated → fp.certificate records created
- Progress invoicing: if strategy=progress, invoice this MO's portion
- **Modules:** fusion_plating_shopfloor, fusion_plating_bridge_mrp, fusion_plating_quality, fusion_plating_invoicing
### Stage 7: Shipping / Local Delivery
**Shipping Partner (Purolator, FedEx, UPS, etc.):**
- fp.delivery scheduled with carrier + tracking #
- Packing list generated, delivery marked shipped
- Module: fusion_plating_logistics
**Local Delivery (EN Tech driver):**
- fusion.delivery.task created, driver + vehicle assigned
- Driver Map shows live GPS tracking
- Driver delivers → signature/photo POD → cascades to fp.delivery
- Module: fusion_tasks (Entech Plating)
**Customer Pickup:**
- Email: Ready for Pickup
- Customer arrives → parts released → signature → fp.delivery marked delivered
**All paths:**
- Portal job → shipped
- Email: CoC + Thickness Report + Invoice + Tracking/ETA
- **Modules:** fusion_plating_logistics, fusion_tasks, fusion_plating_notifications
### Stage 8: Invoicing & Payment
- Strategy determines timing:
- deposit → balance invoice after shipping
- progress → final invoice for remaining balance
- net_terms → full invoice after shipping
- cod_prepay → already invoiced & paid
- Delivery method change after invoice → supplementary invoice or credit note
- Invoice posted → portal job → complete → email with Invoice PDF
- **Modules:** fusion_plating_invoicing, fusion_plating_bridge_mrp, fusion_plating_notifications
### Stage 9: Customer Portal
- Full job lifecycle visible: progress bar (received → complete)
- Documents tab: CoC, thickness report, invoice — downloadable
- Part catalog: saved parts with 3D preview
- Order history: past orders, re-order from catalog
- Quote request history, tracking info, notification history
- **Modules:** fusion_plating_portal
---
## 10. Module Dependency Graph
```
fusion_plating (core)
├── fusion_plating_configurator
│ └── depends: fusion_plating, sale_management
├── fusion_plating_receiving
│ └── depends: fusion_plating, sale_management
│ └── provides: mrp.production gate mixin (overrides action_confirm)
├── fusion_plating_invoicing
│ └── depends: fusion_plating, sale_management, account
│ └── provides: invoice strategy automation, account hold on res.partner
├── fusion_plating_notifications
│ └── depends: fusion_plating, fusion_plating_reports, mail
├── fusion_plating_certificates
│ └── depends: fusion_plating, fusion_plating_portal,
│ fusion_plating_reports, mrp
├── fusion_plating_bridge_mrp (lighter — gates live in receiving/invoicing)
│ └── depends: fusion_plating, fusion_plating_configurator, mrp
│ └── soft-depends: fusion_plating_receiving, fusion_plating_invoicing
├── fusion_plating_portal
│ └── depends: fusion_plating, fusion_plating_configurator,
│ fusion_plating_notifications, portal
├── fusion_plating_logistics
│ └── depends: fusion_plating
└── fusion_tasks (Entech Plating — separate repo, same server)
└── depends: fusion_plating_logistics, hr, mail
```
**Note on bridge_mrp:** The receiving gate and invoice strategy gates are implemented as lightweight mixins within `fusion_plating_receiving` and `fusion_plating_invoicing` respectively (each overrides `mrp.production` independently). This avoids funnelling all dependencies through bridge_mrp. Bridge_mrp focuses on recipe-to-WO generation and the configurator link.
---
## 11. Pricing Variables Reference
All 10 pricing variables that drive the configurator:
1. **Surface area** — more area = more chemistry consumed
2. **Coating type** — EN, chrome, anodize, black oxide (different bath costs)
3. **Thickness spec** — more passes/dwell time
4. **Substrate material** — aluminium needs zincate pre-treatment
5. **Quantity / batch size** — more parts per rack = lower per-unit cost
6. **Part complexity** — blind holes, recesses, masking areas
7. **Masking requirements** — labour-intensive
8. **Spec / certification level** — Nadcap/aerospace = more QC overhead
9. **Turnaround time** — rush = premium
10. **Pre/post treatment** — bead blast, bake, passivate
---
## 12. Key Architectural Decisions
| Decision | Resolution |
|----------|------------|
| Configurator primary user | Internal estimator; portal is simplified lead-gen |
| 3D file handling | STEP/STL auto surface area calc + 3D preview; PDF manual (Claude Vision roadmap) |
| Pricing model | Formula-calculated with estimator override |
| Part catalog | Customer part library for repeat business + one-off support |
| PO requirement | Required before manufacturing, but manager override available |
| Invoice strategies | All 4 supported (deposit, progress, net_terms, cod_prepay), configurable per order |
| Account hold | Manual for now, auto from aging on roadmap |
| Shipping decision | Set at quote time, changeable later with price adjustment |
| Local delivery | Fork fusion_tasks, strip claims, keep GPS/maps |
| Certificate management | Unified fp.certificate registry with filters, auto-creation on report generation |
| Recipe → WO mapping | One WO per operation node, steps become WO instructions |
---
## 13. Data Migration: Existing Quote Request Flow
The existing `fusion.plating.quote.request` model has an `action_create_sale_order()` method that creates basic SOs. The new configurator introduces a parallel, richer path.
**Coexistence strategy:**
- The existing `action_create_sale_order()` on `fusion.plating.quote.request` remains functional — it is the "quick path" for simple quotes that don't need the full configurator
- The new configurator is the "full path" for detailed quotes with part catalog, coating config, and pricing rules
- When a quote request comes in via portal, the estimator chooses: use the configurator (creates `fp.quote.configurator` → SO) or use the quick path (existing `action_create_sale_order()`)
- Both paths create SOs with `x_fc_*` fields. The quick path leaves configurator-specific fields blank; the full path populates everything
- No existing data needs migration — the two paths coexist
---
## 14. Roadmap Items (Not in Initial Build)
- Claude Vision for PDF drawing measurement extraction
- Auto account hold computed from invoice aging
- Fischerscope CSV import (auto-populate thickness readings)
- Multi-driver route optimization
- Customer-specific certificate templates
- Product configurator on portal (dynamic pricing preview)
- Tags on recipe nodes
- Dashboard transitions on recipe nodes
- Treatment groups / choices on recipe nodes

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import models

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.1.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
Fusion Plating — Configurator
==============================
Part of the Fusion Plating product family by Nexa Systems Inc.
Provides:
- Customer part catalog with geometry and material data
- Coating configuration templates (process, thickness, spec)
- Pre/post treatment library
- Formula-based pricing engine with complexity surcharges
- Configurator sessions that generate sale orders
- Custom sale order views with plating-specific fields
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'license': 'OPL-1',
'price': 0.00,
'currency': 'CAD',
'depends': [
'fusion_plating',
'sale_management',
],
'data': [
'security/fp_configurator_security.xml',
'security/ir.model.access.csv',
'data/fp_configurator_sequence_data.xml',
'data/fp_treatment_data.xml',
'views/fp_treatment_views.xml',
'views/fp_part_catalog_views.xml',
'views/fp_coating_config_views.xml',
'views/fp_pricing_rule_views.xml',
'views/fp_quote_configurator_views.xml',
'views/sale_order_views.xml',
'views/fp_configurator_menu.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -0,0 +1,17 @@
<?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 noupdate="1">
<record id="seq_fp_quote_configurator" model="ir.sequence">
<field name="name">Fusion Plating: Configurator</field>
<field name="code">fp.quote.configurator</field>
<field name="prefix">CFG-</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@@ -0,0 +1,61 @@
<?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 noupdate="1">
<!-- Pre-treatments -->
<record id="treatment_alkaline_clean" model="fp.treatment">
<field name="name">Alkaline Clean</field>
<field name="treatment_type">pre</field>
<field name="sequence">10</field>
<field name="default_duration_minutes">15</field>
</record>
<record id="treatment_acid_etch" model="fp.treatment">
<field name="name">Acid Etch</field>
<field name="treatment_type">pre</field>
<field name="sequence">20</field>
<field name="default_duration_minutes">10</field>
</record>
<record id="treatment_zincate" model="fp.treatment">
<field name="name">Zincate (Aluminium)</field>
<field name="treatment_type">pre</field>
<field name="sequence">30</field>
<field name="default_duration_minutes">5</field>
</record>
<record id="treatment_bead_blast" model="fp.treatment">
<field name="name">Bead Blast</field>
<field name="treatment_type">pre</field>
<field name="sequence">40</field>
<field name="default_duration_minutes">20</field>
</record>
<record id="treatment_degrease" model="fp.treatment">
<field name="name">Solvent Degrease</field>
<field name="treatment_type">pre</field>
<field name="sequence">50</field>
<field name="default_duration_minutes">10</field>
</record>
<!-- Post-treatments -->
<record id="treatment_bake" model="fp.treatment">
<field name="name">Hydrogen Embrittlement Bake</field>
<field name="treatment_type">post</field>
<field name="sequence">10</field>
<field name="default_duration_minutes">240</field>
</record>
<record id="treatment_passivate" model="fp.treatment">
<field name="name">Passivate</field>
<field name="treatment_type">post</field>
<field name="sequence">20</field>
<field name="default_duration_minutes">30</field>
</record>
<record id="treatment_chromate_seal" model="fp.treatment">
<field name="name">Chromate Seal</field>
<field name="treatment_type">post</field>
<field name="sequence">30</field>
<field name="default_duration_minutes">15</field>
</record>
</odoo>

View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from . import fp_treatment
from . import fp_part_catalog
from . import fp_coating_config
from . import fp_pricing_complexity_surcharge
from . import fp_pricing_rule
from . import fp_quote_configurator
from . import sale_order

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
class FpCoatingConfig(models.Model):
"""Coating configuration template.
Defines a specific coating setup: process type, phosphorus level,
thickness range, spec reference, and required pre/post treatments.
Used by the configurator to drive pricing and recipe selection.
"""
_name = 'fp.coating.config'
_description = 'Fusion Plating — Coating Configuration'
_order = 'sequence, name'
name = fields.Char(string='Configuration', required=True, help='e.g. "EN Mid-Phos AMS 2404"')
process_type_id = fields.Many2one(
'fusion.plating.process.type', string='Process Type', required=True, ondelete='restrict',
)
recipe_id = fields.Many2one(
'fusion.plating.process.node', string='Default Recipe',
domain="[('node_type', '=', 'recipe')]",
help='Default recipe template for this coating configuration.',
)
phosphorus_level = fields.Selection(
[('low_phos', 'Low Phosphorus (2-5%)'), ('mid_phos', 'Mid Phosphorus (6-9%)'),
('high_phos', 'High Phosphorus (10-13%)'), ('na', 'N/A')],
string='Phosphorus Level', default='na', help='EN-specific. Set to N/A for non-EN processes.',
)
thickness_min = fields.Float(string='Min Thickness', digits=(10, 4))
thickness_max = fields.Float(string='Max Thickness', digits=(10, 4))
thickness_uom = fields.Selection(
[('mils', 'mils'), ('microns', 'microns'), ('inches', 'inches')],
string='Thickness UoM', default='mils',
)
spec_reference = fields.Char(string='Spec Reference', help='e.g. "AMS 2404", "E499-303-00-005"')
certification_level = fields.Selection(
[('commercial', 'Commercial'), ('mil_spec', 'Mil-Spec'),
('nadcap', 'Nadcap'), ('nuclear', 'Nuclear (CSA N299)')],
string='Certification Level', default='commercial',
)
pre_treatment_ids = fields.Many2many(
'fp.treatment', 'fp_coating_config_pre_treatment_rel', 'config_id', 'treatment_id',
string='Pre-Treatments', domain="[('treatment_type', '=', 'pre')]",
)
post_treatment_ids = fields.Many2many(
'fp.treatment', 'fp_coating_config_post_treatment_rel', 'config_id', 'treatment_id',
string='Post-Treatments', domain="[('treatment_type', '=', 'post')]",
)
sequence = fields.Integer(string='Sequence', default=10)
description = fields.Text(string='Description')
active = fields.Boolean(string='Active', default=True)

View File

@@ -0,0 +1,65 @@
# -*- 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 fields, models
class FpPartCatalog(models.Model):
"""Customer part library.
Stores geometry, material, and complexity data for parts that
customers send repeatedly. New orders reference existing catalog
entries for instant re-quoting; one-off parts create new entries.
"""
_name = 'fp.part.catalog'
_description = 'Fusion Plating — Part Catalog'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'partner_id, part_number, name'
name = fields.Char(string='Part Name', required=True, tracking=True)
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True, ondelete='cascade',
tracking=True, domain="[('customer_rank', '>', 0)]",
)
part_number = fields.Char(string='Part Number', tracking=True, help="Customer's part number (e.g. VS-R392007E01).")
revision = fields.Char(string='Revision', help='Revision letter or number (e.g. Rev: 1B).')
substrate_material = fields.Selection(
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
string='Substrate Material', default='steel',
)
geometry_source = fields.Selection(
[('3d_model', '3D Model'), ('manual', 'Manual Measurements'), ('pdf_drawing', 'PDF Drawing')],
string='Geometry Source', default='manual',
)
model_attachment_id = fields.Many2one('ir.attachment', string='3D Model File', help='STEP, STL, or IGES file.')
drawing_attachment_ids = fields.Many2many(
'ir.attachment', 'fp_part_catalog_drawing_rel', 'part_catalog_id', 'attachment_id', string='PDF Drawings',
)
surface_area = fields.Float(string='Surface Area', digits=(12, 4))
surface_area_uom = fields.Selection(
[('sq_in', 'sq in'), ('sq_ft', 'sq ft'), ('sq_cm', 'sq cm'), ('sq_m', 'sq m')],
string='Surface Area UoM', default='sq_in',
)
weight = fields.Float(string='Weight (kg)', digits=(12, 4))
dimensions_length = fields.Float(string='Length', digits=(12, 4))
dimensions_width = fields.Float(string='Width', digits=(12, 4))
dimensions_height = fields.Float(string='Height', digits=(12, 4))
complexity = fields.Selection(
[('simple', 'Simple'), ('moderate', 'Moderate'), ('complex', 'Complex'), ('very_complex', 'Very Complex')],
string='Complexity', default='simple',
)
masking_zones = fields.Integer(string='Masking Zones', help='Number of areas requiring masking.')
masking_description = fields.Text(string='Masking Description', help='e.g. "Mask threaded holes, mask bore ID"')
has_blind_holes = fields.Boolean(string='Has Blind Holes')
has_recesses = fields.Boolean(string='Has Recesses')
has_threads = fields.Boolean(string='Has Threads')
notes = fields.Html(string='Notes')
active = fields.Boolean(string='Active', default=True)
_sql_constraints = [
('fp_part_catalog_partner_partnum_uniq', 'unique(partner_id, part_number)',
'Part number must be unique per customer.'),
]

View File

@@ -0,0 +1,25 @@
# -*- 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 fields, models
class FpPricingComplexitySurcharge(models.Model):
"""Complexity-based surcharge line on a pricing rule."""
_name = 'fp.pricing.complexity.surcharge'
_description = 'Fusion Plating — Pricing Complexity Surcharge'
_order = 'complexity'
rule_id = fields.Many2one('fp.pricing.rule', string='Pricing Rule', required=True, ondelete='cascade')
complexity = fields.Selection(
[('simple', 'Simple'), ('moderate', 'Moderate'), ('complex', 'Complex'), ('very_complex', 'Very Complex')],
string='Complexity', required=True,
)
surcharge_percent = fields.Float(string='Surcharge %', help='Additional percentage on top of base price.')
_sql_constraints = [
('fp_pricing_surcharge_rule_complexity_uniq', 'unique(rule_id, complexity)',
'Only one surcharge per complexity level per rule.'),
]

View File

@@ -0,0 +1,54 @@
# -*- 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 fields, models
class FpPricingRule(models.Model):
"""Formula-based pricing rule.
Rules are matched by coating config, substrate material, and
certification level. The first matching rule (by sequence) wins.
Global rules (no filters set) act as fallbacks.
"""
_name = 'fp.pricing.rule'
_description = 'Fusion Plating — Pricing Rule'
_order = 'sequence, id'
name = fields.Char(string='Rule Name', required=True)
coating_config_id = fields.Many2one('fp.coating.config', string='Coating Config',
help='Leave blank for a global rule.')
substrate_material = fields.Selection(
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
string='Substrate Material', help='Leave blank to match all materials.',
)
certification_level = fields.Selection(
[('commercial', 'Commercial'), ('mil_spec', 'Mil-Spec'),
('nadcap', 'Nadcap'), ('nuclear', 'Nuclear (CSA N299)')],
string='Certification Level', help='Leave blank to match all levels.',
)
pricing_method = fields.Selection(
[('per_sqin', 'Per Square Inch'), ('per_sqft', 'Per Square Foot'),
('per_piece', 'Per Piece'), ('flat_rate', 'Flat Rate')],
string='Pricing Method', required=True, default='per_sqin',
)
currency_id = fields.Many2one('res.currency', string='Currency',
default=lambda self: self.env.company.currency_id)
base_rate = fields.Monetary(string='Base Rate', currency_field='currency_id',
help='Price per unit (sq in, sq ft, piece, or flat).')
thickness_factor = fields.Float(string='Thickness Factor', default=1.0,
help='Multiplier per mil of coating thickness. 1.0 = no adjustment.')
complexity_surcharge_ids = fields.One2many('fp.pricing.complexity.surcharge', 'rule_id',
string='Complexity Surcharges')
masking_rate_per_zone = fields.Monetary(string='Masking Rate / Zone', currency_field='currency_id')
setup_fee = fields.Monetary(string='Setup Fee', currency_field='currency_id',
help='One-time setup fee per batch.')
minimum_charge = fields.Monetary(string='Minimum Charge', currency_field='currency_id',
help='Floor price.')
rush_surcharge_percent = fields.Float(string='Rush Surcharge %')
sequence = fields.Integer(string='Sequence', default=10)
active = fields.Boolean(string='Active', default=True)
notes = fields.Text(string='Notes')

View File

@@ -0,0 +1,354 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
import math
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class FpQuoteConfigurator(models.Model):
"""Persistent configurator session.
Collects part geometry, coating config, and pricing inputs.
Calculates a price from matching pricing rules. The estimator
can override the calculated price. Creates a sale.order when confirmed.
"""
_name = 'fp.quote.configurator'
_description = 'Fusion Plating — Quote Configurator'
_inherit = ['mail.thread']
_order = 'create_date desc'
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
state = fields.Selection(
[('draft', 'Draft'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled')],
string='Status', default='draft', tracking=True,
)
partner_id = fields.Many2one(
'res.partner', string='Customer', required=True,
domain="[('customer_rank', '>', 0)]",
)
part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part (Catalog)',
domain="[('partner_id', '=', partner_id)]",
help="Select from this customer's part catalog, or leave blank for a one-off.",
)
coating_config_id = fields.Many2one(
'fp.coating.config', string='Coating Configuration', required=True,
)
quantity = fields.Integer(string='Quantity', default=1, required=True)
batch_size = fields.Integer(string='Batch Size', help='Parts per rack or barrel load.')
# ----- Geometry (auto-filled from catalog or entered manually) ----------
surface_area = fields.Float(string='Surface Area', digits=(12, 4))
surface_area_uom = fields.Selection(
[('sq_in', 'sq in'), ('sq_ft', 'sq ft'), ('sq_cm', 'sq cm'), ('sq_m', 'sq m')],
string='Area UoM', default='sq_in',
)
thickness_requested = fields.Float(string='Requested Thickness', digits=(10, 4))
masking_zones = fields.Integer(string='Masking Zones')
complexity = fields.Selection(
[('simple', 'Simple'), ('moderate', 'Moderate'),
('complex', 'Complex'), ('very_complex', 'Very Complex')],
string='Complexity', default='simple',
)
substrate_material = fields.Selection(
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
string='Substrate', default='steel',
)
# ----- Options ----------------------------------------------------------
rush_order = fields.Boolean(string='Rush Order')
turnaround_days = fields.Integer(string='Turnaround (days)')
delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'),
('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
string='Delivery Method', default='shipping_partner',
)
# ----- Pricing ----------------------------------------------------------
currency_id = fields.Many2one(
'res.currency', string='Currency',
default=lambda self: self.env.company.currency_id,
)
shipping_fee = fields.Monetary(string='Shipping Fee', currency_field='currency_id')
delivery_fee = fields.Monetary(string='Delivery Fee', currency_field='currency_id')
calculated_price = fields.Monetary(
string='Calculated Price', currency_field='currency_id',
compute='_compute_price', store=True,
)
price_breakdown_html = fields.Html(
string='Price Breakdown', compute='_compute_price', store=True,
)
estimator_override_price = fields.Monetary(
string='Final Price', currency_field='currency_id',
help='Estimator can override the calculated price.',
)
# ----- SO link ----------------------------------------------------------
sale_order_id = fields.Many2one('sale.order', string='Sale Order', readonly=True, copy=False)
notes = fields.Text(string='Notes')
# -------------------------------------------------------------------------
# Auto-population from catalog
# -------------------------------------------------------------------------
@api.onchange('part_catalog_id')
def _onchange_part_catalog_id(self):
if self.part_catalog_id:
cat = self.part_catalog_id
self.surface_area = cat.surface_area
self.surface_area_uom = cat.surface_area_uom
self.complexity = cat.complexity
self.masking_zones = cat.masking_zones
self.substrate_material = cat.substrate_material
@api.onchange('coating_config_id')
def _onchange_coating_config_id(self):
if self.coating_config_id:
self.thickness_requested = self.coating_config_id.thickness_min
# -------------------------------------------------------------------------
# Price calculation
# -------------------------------------------------------------------------
@api.depends(
'surface_area', 'surface_area_uom', 'thickness_requested',
'masking_zones', 'complexity', 'substrate_material',
'quantity', 'batch_size', 'rush_order',
'shipping_fee', 'delivery_fee',
'coating_config_id', 'coating_config_id.certification_level',
)
def _compute_price(self):
for rec in self:
if not rec.coating_config_id or not rec.surface_area:
rec.calculated_price = 0
rec.price_breakdown_html = ''
continue
rule = rec._find_matching_rule()
if not rule:
rec.calculated_price = 0
rec.price_breakdown_html = '<p class="text-muted">No matching pricing rule found.</p>'
continue
# --- Base calculation ---
area = rec._normalize_surface_area_to_sqin()
if rule.pricing_method == 'per_sqin':
unit_price = area * rule.base_rate
elif rule.pricing_method == 'per_sqft':
unit_price = (area / 144.0) * rule.base_rate
elif rule.pricing_method == 'per_piece':
unit_price = rule.base_rate
else: # flat_rate
unit_price = rule.base_rate
# --- Thickness scaling ---
# thickness_factor is a per-mil multiplier. A factor of 1.0
# means linear scaling by thickness (e.g. 3 mils = 3x price).
# A factor of 0.8 gives a volume discount (3 mils = 2.4x).
thickness = rec.thickness_requested or 1.0
unit_price *= thickness * rule.thickness_factor
# --- Complexity surcharge ---
surcharge_pct = 0
for line in rule.complexity_surcharge_ids:
if line.complexity == rec.complexity:
surcharge_pct = line.surcharge_percent
break
unit_price *= (1 + surcharge_pct / 100.0)
# --- Masking ---
masking_cost = (rec.masking_zones or 0) * rule.masking_rate_per_zone
# --- Quantity + batch setup fees ---
num_batches = (
math.ceil(rec.quantity / rec.batch_size) if rec.batch_size
else 1
)
total_setup = rule.setup_fee * num_batches
subtotal = (unit_price * rec.quantity) + masking_cost + total_setup
# --- Rush surcharge ---
rush_amount = 0
if rec.rush_order and rule.rush_surcharge_percent:
rush_amount = subtotal * (rule.rush_surcharge_percent / 100.0)
subtotal += rush_amount
# --- Minimum charge ---
if subtotal < rule.minimum_charge:
subtotal = rule.minimum_charge
# --- Delivery/shipping fees ---
total = subtotal + (rec.shipping_fee or 0) + (rec.delivery_fee or 0)
rec.calculated_price = total
# --- Build breakdown HTML ---
sym = rec.currency_id.symbol or '$'
lines = []
method_label = dict(
rule._fields['pricing_method'].selection
).get(rule.pricing_method, '')
lines.append(
'<tr><td>Base (%s)</td><td class="text-end">%s%.2f x %d</td></tr>'
% (method_label, sym, unit_price, rec.quantity)
)
if masking_cost:
lines.append(
'<tr><td>Masking (%d zones)</td><td class="text-end">%s%.2f</td></tr>'
% (rec.masking_zones, sym, masking_cost)
)
if total_setup:
lines.append(
'<tr><td>Setup Fee (x%d batches)</td><td class="text-end">%s%.2f</td></tr>'
% (num_batches, sym, total_setup)
)
if rush_amount:
lines.append(
'<tr><td>Rush Surcharge (%.0f%%)</td><td class="text-end">%s%.2f</td></tr>'
% (rule.rush_surcharge_percent, sym, rush_amount)
)
if rec.shipping_fee:
lines.append(
'<tr><td>Shipping</td><td class="text-end">%s%.2f</td></tr>'
% (sym, rec.shipping_fee)
)
if rec.delivery_fee:
lines.append(
'<tr><td>Delivery</td><td class="text-end">%s%.2f</td></tr>'
% (sym, rec.delivery_fee)
)
lines.append(
'<tr class="fw-bold"><td>Total</td><td class="text-end">%s%.2f</td></tr>'
% (sym, total)
)
rec.price_breakdown_html = (
'<table class="table table-sm"><thead><tr>'
'<th>Item</th><th class="text-end">Amount</th></tr></thead>'
'<tbody>%s</tbody></table>'
'<p class="text-muted small">Rule: %s (seq %d)</p>'
% (''.join(lines), rule.name, rule.sequence)
)
def _find_matching_rule(self):
"""Find the best pricing rule matching this configurator's filters.
Scores rules by specificity -- most specific match wins.
If no rule matches filters, returns None.
"""
rules = self.env['fp.pricing.rule'].search(
[('active', '=', True)], order='sequence, id'
)
cert_level = (
self.coating_config_id.certification_level
if self.coating_config_id else False
)
best = None
best_score = -1
for rule in rules:
score = 0
if rule.coating_config_id:
if rule.coating_config_id != self.coating_config_id:
continue
score += 4
if rule.substrate_material:
if rule.substrate_material != self.substrate_material:
continue
score += 2
if rule.certification_level:
if rule.certification_level != cert_level:
continue
score += 1
if score > best_score:
best_score = score
best = rule
return best
def _normalize_surface_area_to_sqin(self):
"""Convert surface area to square inches for calculation."""
area = self.surface_area or 0
uom = self.surface_area_uom
if uom == 'sq_ft':
return area * 144.0
elif uom == 'sq_cm':
return area * 0.155
elif uom == 'sq_m':
return area * 1550.0
return area # sq_in
# -------------------------------------------------------------------------
# Actions
# -------------------------------------------------------------------------
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code(
'fp.quote.configurator') or 'New'
return super().create(vals_list)
def action_create_quotation(self):
"""Create a sale.order from this configurator session."""
self.ensure_one()
if self.state != 'draft':
raise UserError(_('Only draft configurators can create quotations.'))
if self.sale_order_id:
raise UserError(_('A quotation has already been created for this configurator.'))
price = self.estimator_override_price or self.calculated_price
# Find or create a generic service product for plating
product = self.env['product.product'].search(
[('default_code', '=', 'FP-SERVICE')], limit=1
)
if not product:
product = self.env['product.product'].create({
'name': 'Plating Service',
'default_code': 'FP-SERVICE',
'type': 'service',
'list_price': 0,
'sale_ok': True,
'purchase_ok': False,
})
coating_name = self.coating_config_id.name if self.coating_config_id else ''
part_name = self.part_catalog_id.name if self.part_catalog_id else 'Custom Part'
so_vals = {
'partner_id': self.partner_id.id,
'x_fc_configurator_id': self.id,
'x_fc_part_catalog_id': self.part_catalog_id.id if self.part_catalog_id else False,
'x_fc_coating_config_id': self.coating_config_id.id,
'x_fc_rush_order': self.rush_order,
'x_fc_delivery_method': self.delivery_method,
'origin': self.name,
'order_line': [(0, 0, {
'product_id': product.id,
'name': '%s%s (x%d)' % (coating_name, part_name, self.quantity),
'product_uom_qty': self.quantity,
'price_unit': price / self.quantity if self.quantity else price,
})],
}
so = self.env['sale.order'].create(so_vals)
self.write({
'sale_order_id': so.id,
'state': 'confirmed',
})
self.message_post(
body=_('Sale Order <a href="/odoo/sale-order/%s">%s</a> created.') % (so.id, so.name),
)
return {
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'res_id': so.id,
'view_mode': 'form',
'target': 'current',
}
def action_cancel(self):
self.write({'state': 'cancelled'})

View File

@@ -0,0 +1,51 @@
# -*- 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 fields, models
class FpTreatment(models.Model):
"""Pre- or post-treatment step (bead blast, zincate, bake, passivate, etc.).
Used by coating configurations to specify which preparation and
finishing steps are required for a given process.
"""
_name = 'fp.treatment'
_description = 'Fusion Plating — Treatment'
_order = 'treatment_type, sequence, name'
name = fields.Char(
string='Treatment',
required=True,
help='e.g. "Bead Blast", "Zincate", "Hydrogen Embrittlement Bake"',
)
treatment_type = fields.Selection(
[('pre', 'Pre-Treatment'), ('post', 'Post-Treatment')],
string='Type',
required=True,
default='pre',
)
sequence = fields.Integer(string='Sequence', default=10)
default_duration_minutes = fields.Float(
string='Default Duration (min)',
help='Estimated duration per application in minutes.',
)
currency_id = fields.Many2one(
'res.currency',
string='Currency',
default=lambda self: self.env.company.currency_id,
)
default_cost = fields.Monetary(
string='Default Cost',
currency_field='currency_id',
help='Default cost per application. Can be overridden on pricing rules.',
)
description = fields.Text(string='Description')
active = fields.Boolean(string='Active', default=True)
_sql_constraints = [
('fp_treatment_name_type_uniq', 'unique(name, treatment_type)',
'Treatment name must be unique per type.'),
]

View File

@@ -0,0 +1,38 @@
# -*- 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 SaleOrder(models.Model):
_inherit = 'sale.order'
x_fc_configurator_id = fields.Many2one('fp.quote.configurator', string='Configurator', copy=False)
x_fc_part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
x_fc_coating_config_id = fields.Many2one('fp.coating.config', string='Coating Configuration')
x_fc_po_number = fields.Char(string='Customer PO #', tracking=True)
x_fc_po_attachment_id = fields.Many2one('ir.attachment', string='PO Document')
x_fc_po_received = fields.Boolean(string='PO Received', tracking=True)
x_fc_po_override = fields.Boolean(string='PO Override',
help='Manager override — proceed without formal PO (handshake deal).')
x_fc_po_override_reason = fields.Text(string='Override Reason')
x_fc_invoice_strategy = fields.Selection(
[('deposit', 'Deposit'), ('progress', 'Progress Billing'),
('net_terms', 'Net Terms'), ('cod_prepay', 'COD / Prepay')],
string='Invoice Strategy', tracking=True,
)
x_fc_deposit_percent = fields.Float(string='Deposit %',
help='Deposit percentage if strategy is Deposit.')
x_fc_rush_order = fields.Boolean(string='Rush Order', tracking=True)
x_fc_delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'), ('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
string='Delivery Method', tracking=True,
)
x_fc_receiving_status = fields.Selection(
[('not_received', 'Not Received'), ('partial', 'Partial'),
('received', 'Received'), ('inspected', 'Inspected')],
string='Receiving Status', default='not_received', tracking=True,
)

View File

@@ -0,0 +1,25 @@
<?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="group_fp_estimator" model="res.groups">
<field name="name">Estimator</field>
<field name="category_id" ref="fusion_plating.module_category_fusion_plating"/>
<field name="implied_ids" eval="[(4, ref('fusion_plating.group_fusion_plating_supervisor'))]"/>
</record>
<record id="group_fp_shop_manager" model="res.groups">
<field name="name">Shop Manager</field>
<field name="category_id" ref="fusion_plating.module_category_fusion_plating"/>
<field name="implied_ids" eval="[
(4, ref('fusion_plating.group_fusion_plating_manager')),
(4, ref('group_fp_estimator')),
]"/>
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
</odoo>

View File

@@ -0,0 +1,19 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_treatment_operator,fp.treatment.operator,model_fp_treatment,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_treatment_supervisor,fp.treatment.supervisor,model_fp_treatment,fusion_plating.group_fusion_plating_supervisor,1,1,0,0
access_fp_treatment_manager,fp.treatment.manager,model_fp_treatment,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_catalog_operator,fp.part.catalog.operator,model_fp_part_catalog,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_part_catalog_estimator,fp.part.catalog.estimator,model_fp_part_catalog,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_part_catalog_manager,fp.part.catalog.manager,model_fp_part_catalog,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_coating_config_operator,fp.coating.config.operator,model_fp_coating_config,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_coating_config_estimator,fp.coating.config.estimator,model_fp_coating_config,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_coating_config_manager,fp.coating.config.manager,model_fp_coating_config,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_pricing_rule_operator,fp.pricing.rule.operator,model_fp_pricing_rule,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_pricing_rule_estimator,fp.pricing.rule.estimator,model_fp_pricing_rule,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_pricing_rule_manager,fp.pricing.rule.manager,model_fp_pricing_rule,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_pricing_surcharge_operator,fp.pricing.complexity.surcharge.operator,model_fp_pricing_complexity_surcharge,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_pricing_surcharge_estimator,fp.pricing.complexity.surcharge.estimator,model_fp_pricing_complexity_surcharge,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_pricing_surcharge_manager,fp.pricing.complexity.surcharge.manager,model_fp_pricing_complexity_surcharge,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_quote_configurator_operator,fp.quote.configurator.operator,model_fp_quote_configurator,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_quote_configurator_estimator,fp.quote.configurator.estimator,model_fp_quote_configurator,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_quote_configurator_manager,fp.quote.configurator.manager,model_fp_quote_configurator,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_treatment_operator fp.treatment.operator model_fp_treatment fusion_plating.group_fusion_plating_operator 1 0 0 0
3 access_fp_treatment_supervisor fp.treatment.supervisor model_fp_treatment fusion_plating.group_fusion_plating_supervisor 1 1 0 0
4 access_fp_treatment_manager fp.treatment.manager model_fp_treatment fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_part_catalog_operator fp.part.catalog.operator model_fp_part_catalog fusion_plating.group_fusion_plating_operator 1 0 0 0
6 access_fp_part_catalog_estimator fp.part.catalog.estimator model_fp_part_catalog fusion_plating_configurator.group_fp_estimator 1 1 1 0
7 access_fp_part_catalog_manager fp.part.catalog.manager model_fp_part_catalog fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_coating_config_operator fp.coating.config.operator model_fp_coating_config fusion_plating.group_fusion_plating_operator 1 0 0 0
9 access_fp_coating_config_estimator fp.coating.config.estimator model_fp_coating_config fusion_plating_configurator.group_fp_estimator 1 1 1 0
10 access_fp_coating_config_manager fp.coating.config.manager model_fp_coating_config fusion_plating.group_fusion_plating_manager 1 1 1 1
11 access_fp_pricing_rule_operator fp.pricing.rule.operator model_fp_pricing_rule fusion_plating.group_fusion_plating_operator 1 0 0 0
12 access_fp_pricing_rule_estimator fp.pricing.rule.estimator model_fp_pricing_rule fusion_plating_configurator.group_fp_estimator 1 1 1 0
13 access_fp_pricing_rule_manager fp.pricing.rule.manager model_fp_pricing_rule fusion_plating.group_fusion_plating_manager 1 1 1 1
14 access_fp_pricing_surcharge_operator fp.pricing.complexity.surcharge.operator model_fp_pricing_complexity_surcharge fusion_plating.group_fusion_plating_operator 1 0 0 0
15 access_fp_pricing_surcharge_estimator fp.pricing.complexity.surcharge.estimator model_fp_pricing_complexity_surcharge fusion_plating_configurator.group_fp_estimator 1 1 1 0
16 access_fp_pricing_surcharge_manager fp.pricing.complexity.surcharge.manager model_fp_pricing_complexity_surcharge fusion_plating.group_fusion_plating_manager 1 1 1 1
17 access_fp_quote_configurator_operator fp.quote.configurator.operator model_fp_quote_configurator fusion_plating.group_fusion_plating_operator 1 0 0 0
18 access_fp_quote_configurator_estimator fp.quote.configurator.estimator model_fp_quote_configurator fusion_plating_configurator.group_fp_estimator 1 1 1 0
19 access_fp_quote_configurator_manager fp.quote.configurator.manager model_fp_quote_configurator fusion_plating.group_fusion_plating_manager 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,123 @@
<?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>
<!-- ===== Coating Configuration List View ===== -->
<record id="view_fp_coating_config_list" model="ir.ui.view">
<field name="name">fp.coating.config.list</field>
<field name="model">fp.coating.config</field>
<field name="arch" type="xml">
<list string="Coating Configurations" decoration-muted="not active">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="process_type_id"/>
<field name="phosphorus_level"/>
<field name="thickness_min"/>
<field name="thickness_max"/>
<field name="spec_reference"/>
<field name="certification_level"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Coating Configuration Form View ===== -->
<record id="view_fp_coating_config_form" model="ir.ui.view">
<field name="name">fp.coating.config.form</field>
<field name="model">fp.coating.config</field>
<field name="arch" type="xml">
<form string="Coating Configuration">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. EN Mid-Phos AMS 2404"/></h1>
</div>
<group>
<group>
<field name="process_type_id"/>
<field name="recipe_id"/>
<field name="phosphorus_level"/>
<field name="certification_level"/>
<field name="sequence"/>
</group>
<group>
<field name="thickness_min"/>
<field name="thickness_max"/>
<field name="thickness_uom"/>
<field name="spec_reference"/>
</group>
</group>
<notebook>
<page string="Treatments" name="treatments">
<group>
<group string="Pre-Treatments">
<field name="pre_treatment_ids" widget="many2many_tags" nolabel="1"/>
</group>
<group string="Post-Treatments">
<field name="post_treatment_ids" widget="many2many_tags" nolabel="1"/>
</group>
</group>
</page>
<page string="Description" name="description">
<field name="description" placeholder="Detailed description of this coating configuration..."/>
</page>
</notebook>
<group>
<field name="active" widget="boolean_toggle"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Coating Configuration Search View ===== -->
<record id="view_fp_coating_config_search" model="ir.ui.view">
<field name="name">fp.coating.config.search</field>
<field name="model">fp.coating.config</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="process_type_id"/>
<field name="spec_reference"/>
<separator/>
<filter string="Commercial" name="commercial" domain="[('certification_level','=','commercial')]"/>
<filter string="Mil-Spec" name="mil_spec" domain="[('certification_level','=','mil_spec')]"/>
<filter string="Nadcap" name="nadcap" domain="[('certification_level','=','nadcap')]"/>
<filter string="Nuclear" name="nuclear" domain="[('certification_level','=','nuclear')]"/>
<separator/>
<filter string="Low Phosphorus" name="low_phos" domain="[('phosphorus_level','=','low_phos')]"/>
<filter string="Mid Phosphorus" name="mid_phos" domain="[('phosphorus_level','=','mid_phos')]"/>
<filter string="High Phosphorus" name="high_phos" domain="[('phosphorus_level','=','high_phos')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Process Type" name="group_process_type" context="{'group_by':'process_type_id'}"/>
<filter string="Certification Level" name="group_cert_level" context="{'group_by':'certification_level'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_coating_config" model="ir.actions.act_window">
<field name="name">Coating Configurations</field>
<field name="res_model">fp.coating.config</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_coating_config_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No coating configurations defined yet
</p>
<p>
Define coating setups with process type, phosphorus level,
thickness range, spec reference, and required treatments.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,80 @@
<?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>
<!-- ===== SALES submenu under Fusion Plating root ===== -->
<menuitem id="menu_fp_sales"
name="Sales"
parent="fusion_plating.menu_fp_root"
sequence="1"
groups="group_fp_estimator"/>
<menuitem id="menu_fp_quotations"
name="Quotations"
parent="menu_fp_sales"
action="action_fp_quotations"
sequence="10"/>
<menuitem id="menu_fp_sale_orders"
name="Sale Orders"
parent="menu_fp_sales"
action="action_fp_sale_orders"
sequence="20"/>
<menuitem id="menu_fp_customers"
name="Customers"
parent="menu_fp_sales"
action="action_fp_customers"
sequence="30"/>
<menuitem id="menu_fp_part_catalog"
name="Part Catalog"
parent="menu_fp_sales"
action="action_fp_part_catalog"
sequence="40"/>
<!-- ===== CONFIGURATOR submenu ===== -->
<menuitem id="menu_fp_configurator"
name="Configurator"
parent="fusion_plating.menu_fp_root"
sequence="2"
groups="group_fp_estimator"/>
<menuitem id="menu_fp_new_quote"
name="New Quote"
parent="menu_fp_configurator"
action="action_fp_quote_configurator"
sequence="10"/>
<menuitem id="menu_fp_coating_configs"
name="Coating Configurations"
parent="menu_fp_configurator"
action="action_fp_coating_config"
sequence="20"/>
<menuitem id="menu_fp_pricing_rules"
name="Pricing Rules"
parent="menu_fp_configurator"
action="action_fp_pricing_rule"
sequence="30"/>
<menuitem id="menu_fp_treatments"
name="Treatments"
parent="menu_fp_configurator"
action="action_fp_treatment"
sequence="40"/>
<!-- ===== Customers action (for menu) ===== -->
<record id="action_fp_customers" model="ir.actions.act_window">
<field name="name">Customers</field>
<field name="res_model">res.partner</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('customer_rank', '>', 0)]</field>
<field name="context">{'default_customer_rank': 1}</field>
</record>
</odoo>

View File

@@ -0,0 +1,141 @@
<?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>
<!-- ===== Part Catalog List View ===== -->
<record id="view_fp_part_catalog_list" model="ir.ui.view">
<field name="name">fp.part.catalog.list</field>
<field name="model">fp.part.catalog</field>
<field name="arch" type="xml">
<list string="Part Catalog" decoration-muted="not active">
<field name="partner_id"/>
<field name="part_number"/>
<field name="revision"/>
<field name="substrate_material"/>
<field name="surface_area"/>
<field name="complexity"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Part Catalog Form View ===== -->
<record id="view_fp_part_catalog_form" model="ir.ui.view">
<field name="name">fp.part.catalog.form</field>
<field name="model">fp.part.catalog</field>
<field name="arch" type="xml">
<form string="Part Catalog">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Valve Body Housing"/></h1>
<field name="part_number" placeholder="Customer part number (e.g. VS-R392007E01)"/>
</div>
<group>
<group>
<field name="partner_id"/>
<field name="revision"/>
<field name="substrate_material"/>
<field name="geometry_source"/>
</group>
<group>
<field name="surface_area"/>
<field name="surface_area_uom"/>
<field name="weight"/>
</group>
</group>
<notebook>
<page string="Dimensions &amp; Complexity" name="dimensions">
<group>
<group>
<field name="dimensions_length"/>
<field name="dimensions_width"/>
<field name="dimensions_height"/>
</group>
<group>
<field name="complexity"/>
<field name="masking_zones"/>
<field name="has_blind_holes"/>
<field name="has_recesses"/>
<field name="has_threads"/>
</group>
</group>
<group>
<field name="masking_description" placeholder="e.g. Mask threaded holes, mask bore ID"/>
</group>
</page>
<page string="Attachments" name="attachments">
<group>
<field name="model_attachment_id"/>
<field name="drawing_attachment_ids" widget="many2many_binary"/>
</group>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Additional notes about this part..."/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- ===== Part Catalog Search View ===== -->
<record id="view_fp_part_catalog_search" model="ir.ui.view">
<field name="name">fp.part.catalog.search</field>
<field name="model">fp.part.catalog</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="part_number"/>
<field name="partner_id"/>
<separator/>
<filter string="Aluminium" name="material_aluminium" domain="[('substrate_material','=','aluminium')]"/>
<filter string="Steel" name="material_steel" domain="[('substrate_material','=','steel')]"/>
<filter string="Stainless Steel" name="material_stainless" domain="[('substrate_material','=','stainless')]"/>
<filter string="Copper" name="material_copper" domain="[('substrate_material','=','copper')]"/>
<filter string="Titanium" name="material_titanium" domain="[('substrate_material','=','titanium')]"/>
<filter string="Other" name="material_other" domain="[('substrate_material','=','other')]"/>
<separator/>
<filter string="Simple" name="complexity_simple" domain="[('complexity','=','simple')]"/>
<filter string="Moderate" name="complexity_moderate" domain="[('complexity','=','moderate')]"/>
<filter string="Complex" name="complexity_complex" domain="[('complexity','=','complex')]"/>
<filter string="Very Complex" name="complexity_very_complex" domain="[('complexity','=','very_complex')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Customer" name="group_partner" context="{'group_by':'partner_id'}"/>
<filter string="Material" name="group_material" context="{'group_by':'substrate_material'}"/>
<filter string="Complexity" name="group_complexity" context="{'group_by':'complexity'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_part_catalog" model="ir.actions.act_window">
<field name="name">Part Catalog</field>
<field name="res_model">fp.part.catalog</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_part_catalog_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No parts in the catalog yet
</p>
<p>
Add customer parts with geometry, material, and complexity data
for instant re-quoting on repeat orders.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,131 @@
<?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>
<!-- ===== Pricing Rule List View ===== -->
<record id="view_fp_pricing_rule_list" model="ir.ui.view">
<field name="name">fp.pricing.rule.list</field>
<field name="model">fp.pricing.rule</field>
<field name="arch" type="xml">
<list string="Pricing Rules" decoration-muted="not active">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="coating_config_id"/>
<field name="substrate_material"/>
<field name="certification_level"/>
<field name="pricing_method"/>
<field name="currency_id" column_invisible="1"/>
<field name="base_rate"/>
<field name="minimum_charge"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Pricing Rule Form View ===== -->
<record id="view_fp_pricing_rule_form" model="ir.ui.view">
<field name="name">fp.pricing.rule.form</field>
<field name="model">fp.pricing.rule</field>
<field name="arch" type="xml">
<form string="Pricing Rule">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. EN Mid-Phos Aluminium — Commercial"/></h1>
</div>
<group string="Filters">
<group>
<field name="coating_config_id"/>
<field name="substrate_material"/>
<field name="certification_level"/>
</group>
<group>
<div class="text-muted" colspan="2">
Leave filter fields blank to create a global rule
that matches any configuration.
</div>
</group>
</group>
<group string="Pricing">
<group>
<field name="pricing_method"/>
<field name="currency_id" invisible="1"/>
<field name="base_rate"/>
<field name="thickness_factor"/>
</group>
<group>
<field name="masking_rate_per_zone"/>
<field name="setup_fee"/>
<field name="minimum_charge"/>
<field name="rush_surcharge_percent"/>
</group>
</group>
<notebook>
<page string="Complexity Surcharges" name="surcharges">
<field name="complexity_surcharge_ids">
<list editable="bottom">
<field name="complexity"/>
<field name="surcharge_percent"/>
</list>
</field>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Internal notes about this pricing rule..."/>
</page>
</notebook>
<group>
<field name="sequence"/>
<field name="active" widget="boolean_toggle"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Pricing Rule Search View ===== -->
<record id="view_fp_pricing_rule_search" model="ir.ui.view">
<field name="name">fp.pricing.rule.search</field>
<field name="model">fp.pricing.rule</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="coating_config_id"/>
<separator/>
<filter string="Per Square Inch" name="per_sqin" domain="[('pricing_method','=','per_sqin')]"/>
<filter string="Per Square Foot" name="per_sqft" domain="[('pricing_method','=','per_sqft')]"/>
<filter string="Per Piece" name="per_piece" domain="[('pricing_method','=','per_piece')]"/>
<filter string="Flat Rate" name="flat_rate" domain="[('pricing_method','=','flat_rate')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Coating Config" name="group_coating_config" context="{'group_by':'coating_config_id'}"/>
<filter string="Pricing Method" name="group_pricing_method" context="{'group_by':'pricing_method'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_pricing_rule" model="ir.actions.act_window">
<field name="name">Pricing Rules</field>
<field name="res_model">fp.pricing.rule</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_pricing_rule_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No pricing rules defined yet
</p>
<p>
Define formula-based pricing rules matched by coating
configuration, substrate material, and certification level.
The first matching rule (by sequence) wins.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,171 @@
<?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>
<!-- ===== Configurator Form View ===== -->
<record id="view_fp_quote_configurator_form" model="ir.ui.view">
<field name="name">fp.quote.configurator.form</field>
<field name="model">fp.quote.configurator</field>
<field name="arch" type="xml">
<form string="Quote Configurator">
<header>
<button name="action_create_quotation"
string="Create Quotation"
type="object"
class="btn-primary"
confirm="This will create a Sale Order from this configurator session. Continue?"
invisible="state != 'draft'"/>
<button name="action_cancel"
string="Cancel"
type="object"
invisible="state != 'draft'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed"/>
</header>
<sheet>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Customer + Part / Coating + Quantity -->
<group>
<group string="Customer &amp; Part">
<field name="partner_id"/>
<field name="part_catalog_id"/>
</group>
<group string="Coating &amp; Quantity">
<field name="coating_config_id"/>
<field name="quantity"/>
<field name="batch_size"/>
</group>
</group>
<!-- Geometry / Options -->
<group>
<group string="Geometry">
<field name="surface_area"/>
<field name="surface_area_uom"/>
<field name="thickness_requested"/>
<field name="substrate_material"/>
</group>
<group string="Options">
<field name="complexity"/>
<field name="masking_zones"/>
<field name="rush_order"/>
<field name="turnaround_days"/>
</group>
</group>
<!-- Delivery / Fees -->
<group>
<group string="Delivery &amp; Fees">
<field name="delivery_method"/>
<field name="shipping_fee"/>
<field name="delivery_fee"/>
</group>
<group>
<field name="currency_id" invisible="1"/>
</group>
</group>
<separator string="Pricing"/>
<group>
<group>
<field name="calculated_price" widget="monetary" readonly="1"
class="fw-bold fs-4"/>
</group>
<group>
<field name="estimator_override_price" widget="monetary"/>
</group>
</group>
<group>
<field name="price_breakdown_html" readonly="1" nolabel="1" colspan="2"/>
</group>
<notebook>
<page string="Sale Order" name="sale_order">
<group>
<field name="sale_order_id" readonly="1"/>
</group>
</page>
<page string="Notes" name="notes">
<field name="notes" placeholder="Internal notes about this quote..."/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- ===== Configurator List View ===== -->
<record id="view_fp_quote_configurator_list" model="ir.ui.view">
<field name="name">fp.quote.configurator.list</field>
<field name="model">fp.quote.configurator</field>
<field name="arch" type="xml">
<list string="Quote Configurators"
decoration-info="state == 'draft'"
decoration-muted="state == 'cancelled'"
default_order="create_date desc">
<field name="create_date" string="Date"/>
<field name="name"/>
<field name="partner_id"/>
<field name="coating_config_id"/>
<field name="surface_area"/>
<field name="quantity"/>
<field name="currency_id" column_invisible="1"/>
<field name="calculated_price"/>
<field name="estimator_override_price" string="Final Price"/>
<field name="state" widget="badge"
decoration-success="state == 'confirmed'"
decoration-info="state == 'draft'"
decoration-danger="state == 'cancelled'"/>
</list>
</field>
</record>
<!-- ===== Configurator Search View ===== -->
<record id="view_fp_quote_configurator_search" model="ir.ui.view">
<field name="name">fp.quote.configurator.search</field>
<field name="model">fp.quote.configurator</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="partner_id"/>
<field name="coating_config_id"/>
<separator/>
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
<group>
<filter string="Customer" name="group_customer" context="{'group_by': 'partner_id'}"/>
<filter string="Coating Config" name="group_coating" context="{'group_by': 'coating_config_id'}"/>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_quote_configurator" model="ir.actions.act_window">
<field name="name">Quote Configurator</field>
<field name="res_model">fp.quote.configurator</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_quote_configurator_search"/>
<field name="context">{'search_default_draft': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new quote configurator session
</p>
<p>
Select a customer and coating configuration, enter part geometry,
and the pricing engine will calculate a quote. The estimator can
override the calculated price before creating a sale order.
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,96 @@
<?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>
<!-- ===== Treatment List View ===== -->
<record id="view_fp_treatment_list" model="ir.ui.view">
<field name="name">fp.treatment.list</field>
<field name="model">fp.treatment</field>
<field name="arch" type="xml">
<list string="Treatments">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="treatment_type"/>
<field name="default_duration_minutes"/>
<field name="currency_id" column_invisible="1"/>
<field name="default_cost"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<!-- ===== Treatment Form View ===== -->
<record id="view_fp_treatment_form" model="ir.ui.view">
<field name="name">fp.treatment.form</field>
<field name="model">fp.treatment</field>
<field name="arch" type="xml">
<form string="Treatment">
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Bead Blast"/></h1>
</div>
<group>
<group>
<field name="treatment_type"/>
<field name="sequence"/>
</group>
<group>
<field name="default_duration_minutes"/>
<field name="currency_id" invisible="1"/>
<field name="default_cost"/>
</group>
</group>
<group>
<field name="description" placeholder="Description of this treatment step..."/>
</group>
<group>
<field name="active" widget="boolean_toggle"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ===== Treatment Search View ===== -->
<record id="view_fp_treatment_search" model="ir.ui.view">
<field name="name">fp.treatment.search</field>
<field name="model">fp.treatment</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<separator/>
<filter string="Pre-Treatment" name="pre" domain="[('treatment_type','=','pre')]"/>
<filter string="Post-Treatment" name="post" domain="[('treatment_type','=','post')]"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<group>
<filter string="Type" name="group_type" context="{'group_by':'treatment_type'}"/>
</group>
</search>
</field>
</record>
<!-- ===== Window Action ===== -->
<record id="action_fp_treatment" model="ir.actions.act_window">
<field name="name">Treatments</field>
<field name="res_model">fp.treatment</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_fp_treatment_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No treatments defined yet
</p>
<p>
Add pre-treatment steps (bead blast, zincate, acid etch) and
post-treatment steps (bake, passivate, chromate seal).
</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,98 @@
<?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>
<!-- ===== Inherit SO Form — add Plating tab ===== -->
<record id="view_sale_order_form_fp" model="ir.ui.view">
<field name="name">sale.order.form.fp.configurator</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Plating" name="plating_tab">
<group>
<group string="Part &amp; Coating">
<field name="x_fc_configurator_id" readonly="1"/>
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
</group>
<group string="Customer PO">
<field name="x_fc_po_number"/>
<field name="x_fc_po_attachment_id"/>
<field name="x_fc_po_received"/>
<field name="x_fc_po_override"
groups="fusion_plating.group_fusion_plating_manager"/>
<field name="x_fc_po_override_reason"
invisible="not x_fc_po_override"/>
</group>
</group>
<group>
<group string="Invoicing">
<field name="x_fc_invoice_strategy"/>
<field name="x_fc_deposit_percent"
invisible="x_fc_invoice_strategy != 'deposit'"/>
</group>
<group string="Delivery">
<field name="x_fc_rush_order"/>
<field name="x_fc_delivery_method"/>
<field name="x_fc_receiving_status"/><!-- Will become computed when fusion_plating_receiving is installed -->
</group>
</group>
</page>
</xpath>
</field>
</record>
<!-- ===== Custom SO List View for Fusion Plating ===== -->
<record id="view_sale_order_list_fp" model="ir.ui.view">
<field name="name">sale.order.list.fp</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<list string="Sale Orders" decoration-info="state == 'draft'"
decoration-muted="state == 'cancel'">
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_po_number"/>
<field name="x_fc_part_catalog_id" optional="show"/>
<field name="x_fc_coating_config_id" optional="show"/>
<field name="amount_total" sum="Total"/>
<field name="x_fc_receiving_status" widget="badge"
decoration-warning="x_fc_receiving_status == 'not_received'"
decoration-success="x_fc_receiving_status in ('received','inspected')"/>
<field name="x_fc_delivery_method" optional="show"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<!-- ===== Window Action — Quotations (for Fusion Plating menu) ===== -->
<record id="action_fp_quotations" model="ir.actions.act_window">
<field name="name">Quotations</field>
<field name="res_model">sale.order</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('state', 'in', ('draft', 'sent'))]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')})]"/>
<field name="context">{'default_x_fc_delivery_method': 'shipping_partner'}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new quotation
</p>
</field>
</record>
<!-- ===== Window Action — Confirmed Sale Orders ===== -->
<record id="action_fp_sale_orders" model="ir.actions.act_window">
<field name="name">Sale Orders</field>
<field name="res_model">sale.order</field>
<field name="view_mode">list,form,kanban</field>
<field name="domain">[('state', 'in', ('sale', 'done'))]</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')})]"/>
</record>
</odoo>