feat(step-library): full plating workflow coverage + per-recipe configurability + audit
Implements 2026-04-29-step-library-audit-design.md. Bumps fusion_plating to 19.0.18.7.0, fusion_plating_jobs to 19.0.8.12.0, fusion_plating_reports to 19.0.10.2.0. LIBRARY EXPANSION - 8 new Step Kinds: Receiving, Electroclean, Strike, Salt Spray, Adhesion Test, Hardness Test, Packaging, Tank Replenishment - 4 new input types: photo, multi_point_thickness, bath_chemistry_panel, ph - DEFAULT_INPUTS_BY_KIND rewritten to seed audit-grade prompts on every kind (bath IDs, photos, multi-point thickness, signatures, etc.) - + Common Audit Fields one-click button on the library template form - Default Operator Instructions relabel + alert callout PER-RECIPE CONFIGURABILITY - collect (Boolean) per recipe-step input prompt — opt out without delete - collect_measurements (Boolean) master switch on recipe step — when off, wizard skips entirely - template_input_id (Many2one) traceability link from recipe to library - Recipe-step backend form view exposes the new fields with handle drag, toggle, target range, and library-source column RUNTIME WIRING - Step input wizard filters node.input_ids to step_input AND collect=True; short-circuits on collect_measurements=False - New input types: photo (image widget + ir.attachment), multi-point thickness (5 readings + auto avg, skips empty cells), bath chemistry panel (pH/conc/temp/bath bundle), pH (0-14 numeric) - Composite values JSON-serialized into value_text; photo via attachment CoC REPORT - Filters captured prompts to collect=True only - Renders new input types with appropriate format MIGRATION (post-migrate.py for 19.0.18.7.0) - Backfills collect=True on recipe-step inputs - Backfills collect_measurements=True on recipe steps - Re-runs action_seed_default_inputs on every existing template (idempotent, preserves user edits) - Backfills template_input_id by name-matching against source library template (handles JSONB vs varchar name columns) SEED DATA - 8 example templates (one per new kind) in fp_step_template_data.xml with noupdate=1 BATTLE TEST - bt_step_library_audit.py: 29 assertions all PASS on entech OWL EDITOR EXTENSION DEFERRED - The simple recipe editor's per-step Instructions/Measurements expansions were not implemented in this pass; users configure via the backend recipe-step form. Track follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -75,11 +75,55 @@ def _backfill_cloned_process_names(env):
|
||||
renamed += 1
|
||||
|
||||
|
||||
def _backfill_part_material_id(env):
|
||||
"""Pin existing parts AND quote configurators to a row in the
|
||||
shared material library.
|
||||
|
||||
Pre-Sub-12d, both models only had a `substrate_material` Selection.
|
||||
This sets `material_id` on every record that doesn't yet have one,
|
||||
matching by substrate_material → seed material XML id. Idempotent.
|
||||
"""
|
||||
Part = env['fp.part.catalog']
|
||||
Material = env['fp.part.material']
|
||||
if Part is None or Material is None:
|
||||
return
|
||||
# Map legacy Selection key → seed XML id (the generic per-category entry).
|
||||
xmlid_by_key = {
|
||||
'aluminium': 'fusion_plating_configurator.fp_material_aluminium',
|
||||
'steel': 'fusion_plating_configurator.fp_material_steel',
|
||||
'stainless': 'fusion_plating_configurator.fp_material_stainless',
|
||||
'copper': 'fusion_plating_configurator.fp_material_copper',
|
||||
'titanium': 'fusion_plating_configurator.fp_material_titanium',
|
||||
'other': 'fusion_plating_configurator.fp_material_other',
|
||||
}
|
||||
cache = {}
|
||||
for key, xmlid in xmlid_by_key.items():
|
||||
rec = env.ref(xmlid, raise_if_not_found=False)
|
||||
if rec:
|
||||
cache[key] = rec.id
|
||||
if not cache:
|
||||
return
|
||||
# Parts
|
||||
for part in Part.search([('material_id', '=', False)]):
|
||||
mid = cache.get(part.substrate_material)
|
||||
if mid:
|
||||
part.material_id = mid
|
||||
# Quote configurators (same Selection key → same library)
|
||||
Quote = env['fp.quote.configurator']
|
||||
if Quote is not None:
|
||||
for q in Quote.search([('material_id', '=', False)]):
|
||||
mid = cache.get(q.substrate_material)
|
||||
if mid:
|
||||
q.material_id = mid
|
||||
|
||||
|
||||
def post_init_hook(env):
|
||||
_backfill_currency(env)
|
||||
_backfill_cloned_process_names(env)
|
||||
_backfill_part_material_id(env)
|
||||
|
||||
|
||||
def post_upgrade_hook(env):
|
||||
_backfill_currency(env)
|
||||
_backfill_cloned_process_names(env)
|
||||
_backfill_part_material_id(env)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Configurator',
|
||||
'version': '19.0.18.3.2',
|
||||
'version': '19.0.18.6.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
|
||||
'description': """
|
||||
@@ -40,7 +40,10 @@ Provides:
|
||||
'data/fp_configurator_sequence_data.xml',
|
||||
'data/fp_sub5_sequence_data.xml',
|
||||
'data/fp_treatment_data.xml',
|
||||
'data/fp_part_material_data.xml',
|
||||
'views/fp_treatment_views.xml',
|
||||
'views/fp_part_material_views.xml',
|
||||
'views/fp_coating_thickness_views.xml',
|
||||
'views/fp_part_catalog_views.xml',
|
||||
'views/fp_process_node_part_scoped_views.xml',
|
||||
'views/fp_coating_config_views.xml',
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Seed materials. noupdate="1" so users can rename / archive without
|
||||
a module upgrade reverting their edits.
|
||||
-->
|
||||
<odoo noupdate="1">
|
||||
|
||||
<!-- Aluminium family -->
|
||||
<record id="fp_material_aluminium" model="fp.part.material">
|
||||
<field name="name">Aluminium</field>
|
||||
<field name="category">aluminium</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="fp_material_aluminium_6061" model="fp.part.material">
|
||||
<field name="name">Aluminium 6061</field>
|
||||
<field name="category">aluminium</field>
|
||||
<field name="sequence">11</field>
|
||||
<field name="notes">Common 6000-series alloy. Magnesium + silicon.</field>
|
||||
</record>
|
||||
<record id="fp_material_aluminium_6063" model="fp.part.material">
|
||||
<field name="name">Aluminium 6063</field>
|
||||
<field name="category">aluminium</field>
|
||||
<field name="sequence">12</field>
|
||||
<field name="notes">Architectural 6000-series alloy.</field>
|
||||
</record>
|
||||
<record id="fp_material_aluminium_7075" model="fp.part.material">
|
||||
<field name="name">Aluminium 7075</field>
|
||||
<field name="category">aluminium</field>
|
||||
<field name="sequence">13</field>
|
||||
<field name="notes">High-strength 7000-series. Aerospace.</field>
|
||||
</record>
|
||||
<record id="fp_material_aluminium_2024" model="fp.part.material">
|
||||
<field name="name">Aluminium 2024</field>
|
||||
<field name="category">aluminium</field>
|
||||
<field name="sequence">14</field>
|
||||
<field name="notes">2000-series. Copper alloy, aerospace.</field>
|
||||
</record>
|
||||
|
||||
<!-- Steel family -->
|
||||
<record id="fp_material_steel" model="fp.part.material">
|
||||
<field name="name">Steel</field>
|
||||
<field name="category">steel</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
<record id="fp_material_steel_1018" model="fp.part.material">
|
||||
<field name="name">Steel 1018</field>
|
||||
<field name="category">steel</field>
|
||||
<field name="sequence">21</field>
|
||||
<field name="notes">Low-carbon mild steel.</field>
|
||||
</record>
|
||||
<record id="fp_material_steel_4140" model="fp.part.material">
|
||||
<field name="name">Steel 4140</field>
|
||||
<field name="category">steel</field>
|
||||
<field name="sequence">22</field>
|
||||
<field name="notes">Chrome-moly alloy steel.</field>
|
||||
</record>
|
||||
|
||||
<!-- Stainless family -->
|
||||
<record id="fp_material_stainless" model="fp.part.material">
|
||||
<field name="name">Stainless Steel</field>
|
||||
<field name="category">stainless</field>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
<record id="fp_material_stainless_304" model="fp.part.material">
|
||||
<field name="name">Stainless 304</field>
|
||||
<field name="category">stainless</field>
|
||||
<field name="sequence">31</field>
|
||||
<field name="notes">Austenitic. General-purpose stainless.</field>
|
||||
</record>
|
||||
<record id="fp_material_stainless_316" model="fp.part.material">
|
||||
<field name="name">Stainless 316</field>
|
||||
<field name="category">stainless</field>
|
||||
<field name="sequence">32</field>
|
||||
<field name="notes">Marine-grade. Molybdenum-bearing.</field>
|
||||
</record>
|
||||
<record id="fp_material_stainless_17_4" model="fp.part.material">
|
||||
<field name="name">Stainless 17-4 PH</field>
|
||||
<field name="category">stainless</field>
|
||||
<field name="sequence">33</field>
|
||||
<field name="notes">Precipitation hardening.</field>
|
||||
</record>
|
||||
|
||||
<!-- Copper family -->
|
||||
<record id="fp_material_copper" model="fp.part.material">
|
||||
<field name="name">Copper</field>
|
||||
<field name="category">copper</field>
|
||||
<field name="sequence">40</field>
|
||||
</record>
|
||||
<record id="fp_material_brass_360" model="fp.part.material">
|
||||
<field name="name">Brass C360</field>
|
||||
<field name="category">copper</field>
|
||||
<field name="sequence">41</field>
|
||||
<field name="density">8.5</field>
|
||||
<field name="notes">Free-machining brass.</field>
|
||||
</record>
|
||||
<record id="fp_material_bronze" model="fp.part.material">
|
||||
<field name="name">Bronze</field>
|
||||
<field name="category">copper</field>
|
||||
<field name="sequence">42</field>
|
||||
<field name="density">8.8</field>
|
||||
</record>
|
||||
|
||||
<!-- Titanium family -->
|
||||
<record id="fp_material_titanium" model="fp.part.material">
|
||||
<field name="name">Titanium</field>
|
||||
<field name="category">titanium</field>
|
||||
<field name="sequence">50</field>
|
||||
</record>
|
||||
<record id="fp_material_titanium_grade_2" model="fp.part.material">
|
||||
<field name="name">Titanium Grade 2</field>
|
||||
<field name="category">titanium</field>
|
||||
<field name="sequence">51</field>
|
||||
<field name="notes">Commercially pure titanium.</field>
|
||||
</record>
|
||||
<record id="fp_material_titanium_grade_5" model="fp.part.material">
|
||||
<field name="name">Titanium Grade 5 (Ti-6Al-4V)</field>
|
||||
<field name="category">titanium</field>
|
||||
<field name="sequence">52</field>
|
||||
<field name="density">4.43</field>
|
||||
<field name="notes">Aerospace alloy.</field>
|
||||
</record>
|
||||
|
||||
<!-- Other -->
|
||||
<record id="fp_material_other" model="fp.part.material">
|
||||
<field name="name">Other</field>
|
||||
<field name="category">other</field>
|
||||
<field name="sequence">99</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -4,6 +4,7 @@
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import fp_treatment
|
||||
from . import fp_part_material
|
||||
from . import fp_part_catalog
|
||||
from . import fp_coating_thickness
|
||||
from . import fp_coating_config
|
||||
|
||||
@@ -19,6 +19,11 @@ class FpPartCatalog(models.Model):
|
||||
_description = 'Fusion Plating — Part Catalog'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'partner_id, part_number, revision desc'
|
||||
# Customers always type the part NUMBER in m2o pickers, never the part
|
||||
# name. Routing rec_name to part_number makes both quick-create and
|
||||
# "Create and edit..." land the typed string in the correct field.
|
||||
_rec_name = 'part_number'
|
||||
_rec_names_search = ['part_number', 'name']
|
||||
|
||||
display_name = fields.Char(
|
||||
string='Display Name',
|
||||
@@ -44,10 +49,26 @@ class FpPartCatalog(models.Model):
|
||||
revision_ids = fields.One2many(
|
||||
'fp.part.catalog', 'parent_part_id', string='Revision History',
|
||||
)
|
||||
# User-facing material picker. Customers want custom materials
|
||||
# (e.g. "Aluminium 6061", "Stainless 316") so this is a m2o into
|
||||
# `fp.part.material`. The legacy `substrate_material` Selection
|
||||
# below is now a stored compute that mirrors `material_id.category`,
|
||||
# which keeps pricing rules / portal / import wizard working
|
||||
# untouched (they still match against the category keys).
|
||||
material_id = fields.Many2one(
|
||||
'fp.part.material', string='Material', tracking=True,
|
||||
ondelete='restrict',
|
||||
help='Pick from the material library or create a custom entry '
|
||||
'(e.g. "Aluminium 6061", "Stainless 316", "Brass C360").',
|
||||
)
|
||||
substrate_material = fields.Selection(
|
||||
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
||||
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
||||
string='Substrate Material', default='steel',
|
||||
string='Material Category', default='steel',
|
||||
compute='_compute_substrate_material',
|
||||
store=True, readonly=False,
|
||||
help='Auto-derived from the selected material. Drives pricing '
|
||||
'rule matching and density defaults.',
|
||||
)
|
||||
geometry_source = fields.Selection(
|
||||
[('3d_model', '3D Model'), ('manual', 'Manual Measurements'), ('pdf_drawing', 'PDF Drawing')],
|
||||
@@ -76,6 +97,13 @@ class FpPartCatalog(models.Model):
|
||||
string='Surface Area UoM', default='sq_in',
|
||||
)
|
||||
weight = fields.Float(string='Weight (kg)', digits=(12, 4))
|
||||
x_fc_default_lead_time_days = fields.Integer(
|
||||
string='Default Lead Time (days)',
|
||||
help='Optional. How many days from the order\'s planned-start-date '
|
||||
'this part typically needs. Used as a smart default on order '
|
||||
'lines when no explicit deadline is set. Leave 0 to fall back '
|
||||
'to the order\'s customer deadline.',
|
||||
)
|
||||
dimensions_length = fields.Float(string='Length', digits=(12, 4))
|
||||
dimensions_width = fields.Float(string='Width', digits=(12, 4))
|
||||
dimensions_height = fields.Float(string='Height', digits=(12, 4))
|
||||
@@ -224,13 +252,34 @@ class FpPartCatalog(models.Model):
|
||||
'other': 7.85, # default to steel
|
||||
}
|
||||
|
||||
@api.depends('volume_mm3', 'substrate_material')
|
||||
@api.depends('material_id', 'material_id.category')
|
||||
def _compute_substrate_material(self):
|
||||
"""Mirror the m2o material's category onto the legacy field.
|
||||
|
||||
Editable: existing parts without a material_id keep whatever
|
||||
value they had (default 'steel'), and admins can still flip
|
||||
the category by hand if needed.
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.material_id:
|
||||
rec.substrate_material = rec.material_id.category
|
||||
elif not rec.substrate_material:
|
||||
rec.substrate_material = 'steel'
|
||||
|
||||
@api.depends('volume_mm3', 'substrate_material', 'material_id', 'material_id.density')
|
||||
def _compute_material_weight(self):
|
||||
for rec in self:
|
||||
if not rec.volume_mm3 or not rec.substrate_material:
|
||||
if not rec.volume_mm3:
|
||||
rec.material_weight_kg = 0.0
|
||||
continue
|
||||
# Prefer per-material density override; fall back to category default.
|
||||
if rec.material_id:
|
||||
density = rec.material_id.effective_density()
|
||||
elif rec.substrate_material:
|
||||
density = self._SUBSTRATE_DENSITY.get(rec.substrate_material, 7.85)
|
||||
else:
|
||||
rec.material_weight_kg = 0.0
|
||||
continue
|
||||
density = self._SUBSTRATE_DENSITY.get(rec.substrate_material, 7.85)
|
||||
# mm³ × g/cm³ × 1e-6 = kg
|
||||
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
|
||||
|
||||
@@ -292,6 +341,27 @@ class FpPartCatalog(models.Model):
|
||||
'Part number must be unique per customer.'),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Re-route the m2o-typed string into part_number.
|
||||
|
||||
Odoo 19's m2o "Create and edit..." passes the typed text via
|
||||
context as `default_name` regardless of the target model's
|
||||
`_rec_name`. Customers always type the part NUMBER in the part
|
||||
picker, so we swap it across when part_number wasn't provided
|
||||
explicitly. The legacy `default_name` is dropped so the Part
|
||||
Name field stays empty for the user to fill in (or leave blank).
|
||||
"""
|
||||
ctx = self.env.context
|
||||
if ctx.get('default_name') and not ctx.get('default_part_number'):
|
||||
# with_context merges, so explicitly clear default_name to
|
||||
# stop the typed string from also seeding the Part Name.
|
||||
self = self.with_context(
|
||||
default_part_number=ctx['default_name'],
|
||||
default_name=False,
|
||||
)
|
||||
return super().default_get(fields_list)
|
||||
|
||||
def write(self, vals):
|
||||
"""Track changes to attachments and propagate to linked configurators."""
|
||||
# Snapshot before write
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FpPartMaterial(models.Model):
|
||||
"""Custom material library.
|
||||
|
||||
Lets shops define their own materials (e.g. "Aluminium 6061",
|
||||
"Stainless 316", "Brass C360") instead of being limited to the
|
||||
fixed Selection. Each material maps to a `category` that drives
|
||||
legacy pricing-rule matching and the default density used for
|
||||
material-weight rollups.
|
||||
"""
|
||||
_name = 'fp.part.material'
|
||||
_description = 'Fusion Plating — Part Material'
|
||||
_order = 'sequence, name'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(string='Material', required=True, translate=False)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
category = fields.Selection(
|
||||
[('aluminium', 'Aluminium'), ('steel', 'Steel'),
|
||||
('stainless', 'Stainless Steel'), ('copper', 'Copper'),
|
||||
('titanium', 'Titanium'), ('other', 'Other')],
|
||||
string='Category', required=True, default='other',
|
||||
help='Used for pricing-rule matching and to pick a default '
|
||||
'density when one is not set explicitly.',
|
||||
)
|
||||
density = fields.Float(
|
||||
string='Density (g/cm³)', digits=(8, 4),
|
||||
help='Override the category default. Leave 0 to use the '
|
||||
'category density (Aluminium 2.70, Steel 7.85, '
|
||||
'Stainless 8.00, Copper 8.96, Titanium 4.51).',
|
||||
)
|
||||
notes = fields.Char(string='Notes', help='Internal note (alloy spec, source, etc.).')
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
_CATEGORY_DENSITY = {
|
||||
'aluminium': 2.70,
|
||||
'steel': 7.85,
|
||||
'stainless': 8.00,
|
||||
'copper': 8.96,
|
||||
'titanium': 4.51,
|
||||
'other': 7.85,
|
||||
}
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_part_material_name_uniq', 'unique(name)',
|
||||
'Material name must be unique.'),
|
||||
]
|
||||
|
||||
def effective_density(self):
|
||||
"""Return density override if set, else the category default."""
|
||||
self.ensure_one()
|
||||
if self.density and self.density > 0:
|
||||
return self.density
|
||||
return self._CATEGORY_DENSITY.get(self.category, 7.85)
|
||||
@@ -116,9 +116,13 @@ class FpQuoteConfigurator(models.Model):
|
||||
help='Surface area minus masked area, using the values on this quote.',
|
||||
)
|
||||
|
||||
@api.depends('volume_mm3', 'substrate_material')
|
||||
@api.depends('volume_mm3', 'substrate_material', 'material_id', 'material_id.density')
|
||||
def _compute_material_weight_kg(self):
|
||||
"""Compute weight from part volume × THIS QUOTE'S substrate density."""
|
||||
"""Compute weight from part volume × THIS QUOTE'S substrate density.
|
||||
|
||||
Prefer the per-material density override; fall back to the
|
||||
category default when only the legacy Selection is set.
|
||||
"""
|
||||
density_map = {
|
||||
'aluminium': 2.70,
|
||||
'steel': 7.85,
|
||||
@@ -128,10 +132,16 @@ class FpQuoteConfigurator(models.Model):
|
||||
'other': 7.85,
|
||||
}
|
||||
for rec in self:
|
||||
if not rec.volume_mm3 or not rec.substrate_material:
|
||||
if not rec.volume_mm3:
|
||||
rec.material_weight_kg = 0.0
|
||||
continue
|
||||
if rec.material_id:
|
||||
density = rec.material_id.effective_density()
|
||||
elif rec.substrate_material:
|
||||
density = density_map.get(rec.substrate_material, 7.85)
|
||||
else:
|
||||
rec.material_weight_kg = 0.0
|
||||
continue
|
||||
density = density_map.get(rec.substrate_material, 7.85)
|
||||
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
|
||||
|
||||
@api.depends('surface_area', 'surface_area_uom', 'masking_area_sqin')
|
||||
@@ -252,12 +262,35 @@ class FpQuoteConfigurator(models.Model):
|
||||
('complex', 'Complex'), ('very_complex', 'Very Complex')],
|
||||
string='Complexity', default='simple',
|
||||
)
|
||||
# Single source of truth: pick a material from the shared library.
|
||||
# `substrate_material` below is now a stored compute mirroring
|
||||
# `material_id.category` so legacy consumers (pricing rules, portal,
|
||||
# data exports) keep working unchanged.
|
||||
material_id = fields.Many2one(
|
||||
'fp.part.material', string='Material',
|
||||
ondelete='restrict',
|
||||
help='Picks from the shared material library — same picker as '
|
||||
'the Part Catalog. Create custom alloys (e.g. "Aluminium '
|
||||
'6061") on the fly.',
|
||||
)
|
||||
substrate_material = fields.Selection(
|
||||
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
||||
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
||||
string='Substrate', default='steel',
|
||||
string='Material Category',
|
||||
compute='_compute_substrate_material',
|
||||
store=True, readonly=False, default='steel',
|
||||
help='Auto-derived from the selected material. Drives pricing '
|
||||
'rule matching and density defaults.',
|
||||
)
|
||||
|
||||
@api.depends('material_id', 'material_id.category')
|
||||
def _compute_substrate_material(self):
|
||||
for rec in self:
|
||||
if rec.material_id:
|
||||
rec.substrate_material = rec.material_id.category
|
||||
elif not rec.substrate_material:
|
||||
rec.substrate_material = 'steel'
|
||||
|
||||
# ----- Options ----------------------------------------------------------
|
||||
rush_order = fields.Boolean(string='Rush Order')
|
||||
turnaround_days = fields.Integer(string='Turnaround (days)')
|
||||
@@ -302,7 +335,13 @@ class FpQuoteConfigurator(models.Model):
|
||||
self.surface_area_uom = cat.surface_area_uom
|
||||
self.complexity = cat.complexity
|
||||
self.masking_zones = cat.masking_zones
|
||||
self.substrate_material = cat.substrate_material
|
||||
# Pull the m2o material from the part — substrate_material
|
||||
# auto-derives via the compute. Fall back to the legacy
|
||||
# Selection only if the part has no material_id yet.
|
||||
if cat.material_id:
|
||||
self.material_id = cat.material_id
|
||||
else:
|
||||
self.substrate_material = cat.substrate_material
|
||||
# Copy masking area too (for effective-area calculation)
|
||||
self.masking_area_sqin = cat.masking_area_sqin
|
||||
|
||||
@@ -896,21 +935,26 @@ class FpQuoteConfigurator(models.Model):
|
||||
def action_save_to_catalog(self):
|
||||
"""Push this quote's geometry/material edits back to the master part catalog.
|
||||
|
||||
Writes: substrate_material, surface_area, surface_area_uom,
|
||||
masking_area_sqin, masking_zones, complexity.
|
||||
Writes: material_id (preferred) / substrate_material (fallback),
|
||||
surface_area, surface_area_uom, masking_area_sqin,
|
||||
masking_zones, complexity.
|
||||
Only available when a part catalog entry is linked.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.part_catalog_id:
|
||||
raise UserError(_('No part catalog entry linked to this configurator.'))
|
||||
self.part_catalog_id.write({
|
||||
'substrate_material': self.substrate_material,
|
||||
vals = {
|
||||
'surface_area': self.surface_area,
|
||||
'surface_area_uom': self.surface_area_uom,
|
||||
'masking_area_sqin': self.masking_area_sqin,
|
||||
'masking_zones': self.masking_zones,
|
||||
'complexity': self.complexity,
|
||||
})
|
||||
}
|
||||
if self.material_id:
|
||||
vals['material_id'] = self.material_id.id
|
||||
else:
|
||||
vals['substrate_material'] = self.substrate_material
|
||||
self.part_catalog_id.write(vals)
|
||||
self.message_post(
|
||||
body=Markup(_('Geometry and material saved back to part catalog <b>%s</b>.')) % self.part_catalog_id.name,
|
||||
message_type='notification',
|
||||
|
||||
@@ -124,6 +124,22 @@ class SaleOrder(models.Model):
|
||||
string='Deadline',
|
||||
compute='_compute_deadline_countdown',
|
||||
)
|
||||
x_fc_order_completion_date = fields.Date(
|
||||
string='Order Completion Date',
|
||||
compute='_compute_order_completion_date',
|
||||
store=True,
|
||||
help='When the LATEST line is actually due. Auto-rolled up from '
|
||||
'each line\'s effective deadline. Distinct from Customer '
|
||||
'Deadline (what we promised) — this reflects shop reality.',
|
||||
)
|
||||
x_fc_is_late_forecast = fields.Boolean(
|
||||
string='Late Forecast',
|
||||
compute='_compute_is_late_forecast',
|
||||
store=True,
|
||||
help='True when the rolled-up Order Completion Date sits past the '
|
||||
'Customer Deadline. Suppressed on blanket orders since their '
|
||||
'spans are intentionally long.',
|
||||
)
|
||||
x_fc_margin_amount = fields.Monetary(
|
||||
string='Margin',
|
||||
compute='_compute_margin', currency_field='currency_id',
|
||||
@@ -503,6 +519,38 @@ class SaleOrder(models.Model):
|
||||
'overdue %s' % phrase if past else 'in %s' % phrase
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'order_line.x_fc_effective_part_deadline',
|
||||
'order_line.x_fc_archived',
|
||||
)
|
||||
def _compute_order_completion_date(self):
|
||||
"""Roll up = max(line.x_fc_effective_part_deadline) over non-
|
||||
archived lines. Empty / all-archived order returns False."""
|
||||
for rec in self:
|
||||
dates = [
|
||||
line.x_fc_effective_part_deadline
|
||||
for line in rec.order_line
|
||||
if line.x_fc_effective_part_deadline and not line.x_fc_archived
|
||||
]
|
||||
rec.x_fc_order_completion_date = max(dates) if dates else False
|
||||
|
||||
@api.depends(
|
||||
'x_fc_order_completion_date',
|
||||
'commitment_date',
|
||||
'x_fc_is_blanket_order',
|
||||
)
|
||||
def _compute_is_late_forecast(self):
|
||||
for rec in self:
|
||||
if rec.x_fc_is_blanket_order:
|
||||
rec.x_fc_is_late_forecast = False
|
||||
continue
|
||||
commit = rec.commitment_date.date() if rec.commitment_date else False
|
||||
rec.x_fc_is_late_forecast = bool(
|
||||
rec.x_fc_order_completion_date
|
||||
and commit
|
||||
and rec.x_fc_order_completion_date > commit
|
||||
)
|
||||
|
||||
@api.depends('order_line.price_subtotal', 'amount_untaxed')
|
||||
def _compute_margin(self):
|
||||
"""Margin = untaxed total − rolled-up cost from coating configs.
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
@@ -63,7 +65,36 @@ class SaleOrderLine(models.Model):
|
||||
x_fc_treatment_ids = fields.Many2many(
|
||||
'fp.treatment', string='Additional Treatments',
|
||||
)
|
||||
x_fc_part_deadline = fields.Date(string='Part Deadline')
|
||||
x_fc_part_deadline = fields.Date(
|
||||
string='Part Deadline Override',
|
||||
help='Absolute-date manual override. When set, beats the days-offset '
|
||||
'and the part\'s default lead time. Leave blank to fall through '
|
||||
'to the offset, then part default, then the order\'s customer '
|
||||
'deadline.',
|
||||
)
|
||||
x_fc_part_deadline_offset_days = fields.Integer(
|
||||
string='Days Offset',
|
||||
help='Manual override expressed as "+N days from the order\'s '
|
||||
'customer deadline". Use this when you think in days rather '
|
||||
'than absolute dates. Ignored if Part Deadline Override is set.',
|
||||
)
|
||||
x_fc_effective_part_deadline = fields.Date(
|
||||
string='Effective Deadline',
|
||||
compute='_compute_effective_part_deadline',
|
||||
store=True,
|
||||
help='Computed deadline that actually drives shop scheduling. '
|
||||
'Resolution: explicit override → days offset → part default '
|
||||
'lead time → order customer deadline.',
|
||||
)
|
||||
x_fc_effective_internal_deadline = fields.Date(
|
||||
string='Shop Target',
|
||||
compute='_compute_effective_internal_deadline',
|
||||
store=True,
|
||||
help='Internal deadline for this line — effective customer '
|
||||
'deadline minus the order\'s shop buffer (commitment_date − '
|
||||
'internal_deadline gap). Clamped so it never exceeds the '
|
||||
'effective customer deadline.',
|
||||
)
|
||||
x_fc_rush_order = fields.Boolean(string='Rush')
|
||||
x_fc_wo_group_tag = fields.Char(
|
||||
string='Work Order Group',
|
||||
@@ -181,6 +212,94 @@ class SaleOrderLine(models.Model):
|
||||
def _compute_serial_count(self):
|
||||
for line in self:
|
||||
line.x_fc_serial_count = len(line.x_fc_serial_ids)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Effective deadlines (Sub 12d)
|
||||
# ------------------------------------------------------------------
|
||||
@api.depends(
|
||||
'x_fc_part_deadline',
|
||||
'x_fc_part_deadline_offset_days',
|
||||
'x_fc_part_catalog_id',
|
||||
'x_fc_part_catalog_id.x_fc_default_lead_time_days',
|
||||
'order_id.commitment_date',
|
||||
'order_id.x_fc_planned_start_date',
|
||||
)
|
||||
def _compute_effective_part_deadline(self):
|
||||
"""Resolution chain (first match wins):
|
||||
1. explicit absolute-date override (x_fc_part_deadline)
|
||||
2. days offset from commitment_date (x_fc_part_deadline_offset_days)
|
||||
3. part's default lead time from planned_start_date
|
||||
4. order's commitment_date (= customer profile cascade)
|
||||
5. planned_start_date as last resort (orphan order with no deadline)
|
||||
"""
|
||||
for line in self:
|
||||
order = line.order_id
|
||||
# commitment_date is a Datetime in Odoo standard; coerce to
|
||||
# date for arithmetic with our Date fields.
|
||||
commit_dt = order.commitment_date if order else False
|
||||
commit = commit_dt.date() if commit_dt else False
|
||||
start = (
|
||||
order.x_fc_planned_start_date if order
|
||||
else False
|
||||
) or fields.Date.context_today(line)
|
||||
|
||||
# 1. absolute-date override
|
||||
if line.x_fc_part_deadline:
|
||||
line.x_fc_effective_part_deadline = line.x_fc_part_deadline
|
||||
continue
|
||||
# 2. days offset from commitment
|
||||
if line.x_fc_part_deadline_offset_days and commit:
|
||||
line.x_fc_effective_part_deadline = (
|
||||
commit + timedelta(days=line.x_fc_part_deadline_offset_days)
|
||||
)
|
||||
continue
|
||||
# 3. part default lead time from planned_start
|
||||
part_lead = (
|
||||
line.x_fc_part_catalog_id
|
||||
and line.x_fc_part_catalog_id.x_fc_default_lead_time_days
|
||||
)
|
||||
if part_lead:
|
||||
line.x_fc_effective_part_deadline = (
|
||||
start + timedelta(days=part_lead)
|
||||
)
|
||||
continue
|
||||
# 4. order commitment (which itself derives from customer profile)
|
||||
if commit:
|
||||
line.x_fc_effective_part_deadline = commit
|
||||
continue
|
||||
# 5. last resort — planned start so the field is never null
|
||||
line.x_fc_effective_part_deadline = start
|
||||
|
||||
@api.depends(
|
||||
'x_fc_effective_part_deadline',
|
||||
'order_id.commitment_date',
|
||||
'order_id.x_fc_internal_deadline',
|
||||
)
|
||||
def _compute_effective_internal_deadline(self):
|
||||
"""Apply the order's customer-vs-internal buffer to the line's
|
||||
effective customer deadline. Buffer = commitment_date −
|
||||
x_fc_internal_deadline (the gap implied by customer profile).
|
||||
Clamp result so it never exceeds the customer deadline.
|
||||
"""
|
||||
for line in self:
|
||||
eff = line.x_fc_effective_part_deadline
|
||||
if not eff:
|
||||
line.x_fc_effective_internal_deadline = False
|
||||
continue
|
||||
order = line.order_id
|
||||
commit_dt = order.commitment_date if order else False
|
||||
commit = commit_dt.date() if commit_dt else False
|
||||
internal = order.x_fc_internal_deadline if order else False
|
||||
if commit and internal and commit >= internal:
|
||||
buffer_days = (commit - internal).days
|
||||
target = eff - timedelta(days=buffer_days)
|
||||
# Clamp: internal can never sit after customer date
|
||||
line.x_fc_effective_internal_deadline = (
|
||||
target if target <= eff else eff
|
||||
)
|
||||
else:
|
||||
# No buffer info → fall back to the customer date itself
|
||||
line.x_fc_effective_internal_deadline = eff
|
||||
x_fc_job_number = fields.Char(
|
||||
string='Job #',
|
||||
copy=False,
|
||||
|
||||
@@ -49,3 +49,6 @@ access_fp_serial_bulk_add_manager,fp.serial.bulk.add.manager,model_fp_serial_bul
|
||||
access_fp_coating_thickness_user,fp.coating.thickness.user,model_fp_coating_thickness,base.group_user,1,0,0,0
|
||||
access_fp_coating_thickness_estimator,fp.coating.thickness.estimator,model_fp_coating_thickness,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_coating_thickness_manager,fp.coating.thickness.manager,model_fp_coating_thickness,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
|
||||
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
|
||||
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,94 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
|
||||
Standalone views for fp.coating.thickness so SO-line m2o pickers
|
||||
can offer "Create and edit..." — the inline-on-coating-config
|
||||
editor was the only way to add thicknesses pre-Sub-12d.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_coating_thickness_list" model="ir.ui.view">
|
||||
<field name="name">fp.coating.thickness.list</field>
|
||||
<field name="model">fp.coating.thickness</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Coating Thicknesses" decoration-muted="not active">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="coating_config_id"/>
|
||||
<field name="value" string="Nominal"/>
|
||||
<field name="value_min" string="Min" optional="show"/>
|
||||
<field name="value_max" string="Max" optional="show"/>
|
||||
<field name="uom"/>
|
||||
<field name="display_name" string="Label"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_coating_thickness_form" model="ir.ui.view">
|
||||
<field name="name">fp.coating.thickness.form</field>
|
||||
<field name="model">fp.coating.thickness</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Coating Thickness">
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<label for="display_name" string="Thickness"/>
|
||||
<h2><field name="display_name" readonly="1" placeholder="Auto-generated from value + UoM"/></h2>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Spec">
|
||||
<field name="coating_config_id"
|
||||
options="{'no_create_edit': True}"/>
|
||||
<field name="value" string="Nominal"/>
|
||||
<field name="uom"/>
|
||||
</group>
|
||||
<group string="Acceptance Band (optional)">
|
||||
<field name="value_min" string="Min"/>
|
||||
<field name="value_max" string="Max"/>
|
||||
<div colspan="2" class="text-muted">
|
||||
Set Min/Max when the customer spec is a
|
||||
range (e.g. AMS-2404 Class 4 = 0.001"–0.0015").
|
||||
QC readings outside the band fail.
|
||||
</div>
|
||||
</group>
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_coating_thickness_search" model="ir.ui.view">
|
||||
<field name="name">fp.coating.thickness.search</field>
|
||||
<field name="model">fp.coating.thickness</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="coating_config_id"/>
|
||||
<field name="display_name"/>
|
||||
<field name="uom"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||
<group>
|
||||
<filter string="Coating" name="group_coating"
|
||||
context="{'group_by':'coating_config_id'}"/>
|
||||
<filter string="UoM" name="group_uom"
|
||||
context="{'group_by':'uom'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_coating_thickness" model="ir.actions.act_window">
|
||||
<field name="name">Coating Thicknesses</field>
|
||||
<field name="res_model">fp.coating.thickness</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fp_coating_thickness_search"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -111,4 +111,10 @@
|
||||
action="action_fp_treatment"
|
||||
sequence="40"/>
|
||||
|
||||
<menuitem id="menu_fp_part_materials"
|
||||
name="Materials"
|
||||
parent="menu_fp_configurator"
|
||||
action="action_fp_part_material"
|
||||
sequence="50"/>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
<field name="name" string="Part Name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="revision"/>
|
||||
<field name="substrate_material"/>
|
||||
<field name="material_id" string="Material"/>
|
||||
<field name="substrate_material" optional="hide"/>
|
||||
<field name="surface_area"/>
|
||||
<field name="complexity"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
@@ -116,7 +117,9 @@
|
||||
<field name="partner_id"/>
|
||||
<field name="revision"/>
|
||||
<field name="revision_number"/>
|
||||
<field name="substrate_material"/>
|
||||
<field name="material_id"
|
||||
options="{'no_quick_create': True}"/>
|
||||
<field name="substrate_material" invisible="1"/>
|
||||
<field name="geometry_source"/>
|
||||
<field name="is_latest_revision" invisible="1"/>
|
||||
<field name="parent_part_id" invisible="not parent_part_id"/>
|
||||
@@ -135,6 +138,7 @@
|
||||
<field name="effective_area_sqin" readonly="1"/>
|
||||
<field name="weight"/>
|
||||
<field name="material_weight_kg" readonly="1"/>
|
||||
<field name="x_fc_default_lead_time_days"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Quality & Delivery" name="quality_delivery">
|
||||
@@ -324,6 +328,7 @@
|
||||
<field name="name"/>
|
||||
<field name="part_number"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="material_id" string="Material"/>
|
||||
<separator/>
|
||||
<filter string="Aluminium" name="material_aluminium" domain="[('substrate_material','=','aluminium')]"/>
|
||||
<filter string="Steel" name="material_steel" domain="[('substrate_material','=','steel')]"/>
|
||||
@@ -340,7 +345,8 @@
|
||||
<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="Material" name="group_material" context="{'group_by':'material_id'}"/>
|
||||
<filter string="Material Category" name="group_material_category" context="{'group_by':'substrate_material'}"/>
|
||||
<filter string="Complexity" name="group_complexity" context="{'group_by':'complexity'}"/>
|
||||
</group>
|
||||
</search>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright 2026 Nexa Systems Inc.
|
||||
License OPL-1 (Odoo Proprietary License v1.0)
|
||||
Part of the Fusion Plating product family.
|
||||
-->
|
||||
<odoo>
|
||||
|
||||
<record id="view_fp_part_material_list" model="ir.ui.view">
|
||||
<field name="name">fp.part.material.list</field>
|
||||
<field name="model">fp.part.material</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Materials" editable="bottom" decoration-muted="not active">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="category"/>
|
||||
<field name="density" string="Density (g/cm³)"/>
|
||||
<field name="notes" optional="show"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_part_material_form" model="ir.ui.view">
|
||||
<field name="name">fp.part.material.form</field>
|
||||
<field name="model">fp.part.material</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Material">
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<label for="name" string="Material"/>
|
||||
<h1><field name="name" placeholder="e.g. Aluminium 6061"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="category"/>
|
||||
<field name="density"/>
|
||||
<field name="sequence"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="notes" placeholder="Alloy spec, source, supplier note..."/>
|
||||
</group>
|
||||
</group>
|
||||
<div class="text-muted">
|
||||
Leave Density at 0 to use the category default
|
||||
(Aluminium 2.70, Steel 7.85, Stainless 8.00,
|
||||
Copper 8.96, Titanium 4.51 g/cm³).
|
||||
</div>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fp_part_material_search" model="ir.ui.view">
|
||||
<field name="name">fp.part.material.search</field>
|
||||
<field name="model">fp.part.material</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="category"/>
|
||||
<separator/>
|
||||
<filter string="Aluminium" name="cat_aluminium" domain="[('category','=','aluminium')]"/>
|
||||
<filter string="Steel" name="cat_steel" domain="[('category','=','steel')]"/>
|
||||
<filter string="Stainless" name="cat_stainless" domain="[('category','=','stainless')]"/>
|
||||
<filter string="Copper" name="cat_copper" domain="[('category','=','copper')]"/>
|
||||
<filter string="Titanium" name="cat_titanium" domain="[('category','=','titanium')]"/>
|
||||
<filter string="Other" name="cat_other" domain="[('category','=','other')]"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||
<group>
|
||||
<filter string="Category" name="group_category" context="{'group_by':'category'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fp_part_material" model="ir.actions.act_window">
|
||||
<field name="name">Materials</field>
|
||||
<field name="res_model">fp.part.material</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_fp_part_material_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">No materials yet</p>
|
||||
<p>Define the materials your shop processes. Each material
|
||||
picks a category (Aluminium, Steel, etc.) used for pricing
|
||||
rules and density-based weight calculations.</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -251,7 +251,9 @@
|
||||
string="Effective Plating Area"
|
||||
readonly="1"/>
|
||||
<field name="thickness_requested"/>
|
||||
<field name="substrate_material"/>
|
||||
<field name="material_id"
|
||||
options="{'no_quick_create': True}"/>
|
||||
<field name="substrate_material" invisible="1"/>
|
||||
<field name="masking_zones"/>
|
||||
<field name="turnaround_days"/>
|
||||
</group>
|
||||
|
||||
@@ -123,8 +123,10 @@
|
||||
<field name="x_fc_process_variant_id" optional="show"
|
||||
string="Process"/>
|
||||
<field name="product_uom_qty" string="Qty"/>
|
||||
<field name="x_fc_part_deadline" optional="show"
|
||||
string="Part Deadline"/>
|
||||
<field name="x_fc_effective_part_deadline" optional="show"
|
||||
string="Effective Deadline"/>
|
||||
<field name="x_fc_part_deadline" optional="hide"
|
||||
string="Part Deadline Override"/>
|
||||
<field name="x_fc_rush_order" optional="hide"/>
|
||||
<field name="x_fc_job_number" optional="show"
|
||||
string="Job #"/>
|
||||
@@ -188,6 +190,16 @@
|
||||
<field name="x_fc_internal_deadline"/>
|
||||
<field name="commitment_date" string="Customer Deadline"/>
|
||||
<field name="x_fc_deadline_countdown" readonly="1"/>
|
||||
<label for="x_fc_order_completion_date"/>
|
||||
<div class="o_row">
|
||||
<field name="x_fc_order_completion_date"
|
||||
readonly="1" class="oe_inline"/>
|
||||
<span class="badge text-bg-danger ms-2"
|
||||
invisible="not x_fc_is_late_forecast">
|
||||
<i class="fa fa-exclamation-triangle me-1"/>Late
|
||||
</span>
|
||||
<field name="x_fc_is_late_forecast" invisible="1"/>
|
||||
</div>
|
||||
<field name="x_fc_is_blanket_order"/>
|
||||
<field name="x_fc_block_partial_shipments"/>
|
||||
</group>
|
||||
@@ -250,24 +262,25 @@
|
||||
widget="boolean_toggle"
|
||||
invisible="not x_fc_process_variant_id"
|
||||
optional="hide"/>
|
||||
<button name="action_customize_process" type="object"
|
||||
string="Customize" icon="fa-pencil-square-o"
|
||||
class="btn-link"
|
||||
invisible="not x_fc_process_variant_id"/>
|
||||
<field name="x_fc_thickness_id"
|
||||
options="{'no_create': True}"
|
||||
options="{'no_quick_create': True}"
|
||||
context="{'default_coating_config_id': x_fc_coating_config_id}"
|
||||
domain="[('coating_config_id', '=', x_fc_coating_config_id)]"
|
||||
invisible="not x_fc_coating_config_id"
|
||||
optional="show"/>
|
||||
<field name="x_fc_serial_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_quick_create': False, 'color_field': 'state_color'}"
|
||||
domain="[('part_id', '=', x_fc_part_catalog_id)]"
|
||||
optional="show"/>
|
||||
<field name="x_fc_serial_count"
|
||||
string="# SN"
|
||||
string="Serial Count"
|
||||
optional="hide"/>
|
||||
<button name="action_open_serial_bulk_add" type="object"
|
||||
string="Bulk Add Serials" icon="fa-list-ol"
|
||||
class="btn-link"/>
|
||||
title="Bulk add serials"
|
||||
icon="fa-list-ol"
|
||||
class="btn-link"
|
||||
invisible="not x_fc_part_catalog_id or x_fc_serial_count > 0"/>
|
||||
<field name="x_fc_job_number" optional="show"/>
|
||||
<field name="x_fc_revision_pick_id"
|
||||
string="Revision"
|
||||
@@ -278,7 +291,14 @@
|
||||
readonly="1"
|
||||
optional="hide"/>
|
||||
<field name="x_fc_treatment_ids" widget="many2many_tags" optional="hide"/>
|
||||
<field name="x_fc_part_deadline" optional="hide"/>
|
||||
<field name="x_fc_part_deadline" string="Part Deadline Override" optional="hide"/>
|
||||
<field name="x_fc_part_deadline_offset_days" string="Days Offset" optional="hide"/>
|
||||
<field name="x_fc_effective_part_deadline" string="Effective Deadline"
|
||||
optional="show"
|
||||
readonly="1"/>
|
||||
<field name="x_fc_effective_internal_deadline" string="Shop Target"
|
||||
optional="hide"
|
||||
readonly="1"/>
|
||||
<field name="x_fc_wo_group_tag" optional="hide"/>
|
||||
<field name="x_fc_start_at_node_id" optional="hide"/>
|
||||
<field name="x_fc_is_one_off" optional="hide"/>
|
||||
@@ -294,7 +314,8 @@
|
||||
<field name="model">sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Sale Orders" decoration-info="state == 'draft'"
|
||||
decoration-muted="state == 'cancel'">
|
||||
decoration-muted="state == 'cancel'"
|
||||
decoration-danger="x_fc_is_late_forecast">
|
||||
<header>
|
||||
<button name="%(action_fp_direct_order_wizard)d"
|
||||
type="action"
|
||||
@@ -308,6 +329,8 @@
|
||||
<field name="x_fc_customer_job_number" optional="show"/>
|
||||
<field name="x_fc_internal_deadline" optional="show"/>
|
||||
<field name="commitment_date" string="Customer Deadline" optional="show"/>
|
||||
<field name="x_fc_order_completion_date" string="Completion" optional="show"/>
|
||||
<field name="x_fc_is_late_forecast" optional="hide" widget="boolean_toggle"/>
|
||||
<field name="x_fc_deadline_countdown" optional="show"/>
|
||||
<field name="x_fc_wo_completion" optional="show"/>
|
||||
<field name="x_fc_planned_start_date" optional="hide"/>
|
||||
@@ -484,6 +507,8 @@
|
||||
<separator/>
|
||||
<filter name="blanket_orders" string="Blanket Orders"
|
||||
domain="[('x_fc_is_blanket_order', '=', True)]"/>
|
||||
<filter name="late_forecast" string="Will Be Late"
|
||||
domain="[('x_fc_is_late_forecast', '=', True)]"/>
|
||||
<filter name="rush_lines" string="Has Rush Line"
|
||||
domain="[('order_line.x_fc_rush_order', '=', True)]"/>
|
||||
<filter name="overdue" string="Overdue"
|
||||
|
||||
@@ -64,6 +64,7 @@ class FpAddFromSoWizard(models.TransientModel):
|
||||
'quantity': int(src.product_uom_qty) or 1,
|
||||
'unit_price': src.price_unit or 0.0,
|
||||
'part_deadline': src.x_fc_part_deadline,
|
||||
'part_deadline_offset_days': src.x_fc_part_deadline_offset_days,
|
||||
'rush_order': src.x_fc_rush_order,
|
||||
'wo_group_tag': src.x_fc_wo_group_tag or False,
|
||||
'line_description': src.name,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
@@ -222,8 +224,27 @@ class FpDirectOrderLine(models.Model):
|
||||
|
||||
# ---- Scheduling / fulfilment ----
|
||||
part_deadline = fields.Date(
|
||||
string='Part Deadline',
|
||||
help='Per-line deadline. Defaults to SO customer deadline if blank.',
|
||||
string='Part Deadline Override',
|
||||
help='Absolute-date manual override. Beats days-offset and the '
|
||||
'part default lead time. Leave blank to fall through.',
|
||||
)
|
||||
part_deadline_offset_days = fields.Integer(
|
||||
string='Days Offset',
|
||||
help='Manual override expressed as "+N days from the order\'s '
|
||||
'customer deadline". Ignored if Part Deadline Override is set.',
|
||||
)
|
||||
effective_part_deadline = fields.Date(
|
||||
string='Effective Deadline',
|
||||
compute='_compute_effective_part_deadline',
|
||||
store=True,
|
||||
help='Resolution: explicit override → days offset → part default '
|
||||
'lead time → order customer deadline.',
|
||||
)
|
||||
effective_internal_deadline = fields.Date(
|
||||
string='Shop Target',
|
||||
compute='_compute_effective_internal_deadline',
|
||||
store=True,
|
||||
help='Effective customer deadline minus the order\'s shop buffer.',
|
||||
)
|
||||
rush_order = fields.Boolean(string='Rush')
|
||||
wo_group_tag = fields.Char(
|
||||
@@ -323,6 +344,68 @@ class FpDirectOrderLine(models.Model):
|
||||
for rec in self:
|
||||
rec.serial_count = len(rec.serial_ids)
|
||||
|
||||
@api.depends(
|
||||
'part_deadline',
|
||||
'part_deadline_offset_days',
|
||||
'part_catalog_id',
|
||||
'part_catalog_id.x_fc_default_lead_time_days',
|
||||
'wizard_id.customer_deadline',
|
||||
'wizard_id.planned_start_date',
|
||||
)
|
||||
def _compute_effective_part_deadline(self):
|
||||
"""Mirror of SaleOrderLine._compute_effective_part_deadline."""
|
||||
for line in self:
|
||||
wiz = line.wizard_id
|
||||
commit = wiz.customer_deadline if wiz else False
|
||||
start = (
|
||||
wiz.planned_start_date if wiz else False
|
||||
) or fields.Date.context_today(line)
|
||||
|
||||
if line.part_deadline:
|
||||
line.effective_part_deadline = line.part_deadline
|
||||
continue
|
||||
if line.part_deadline_offset_days and commit:
|
||||
line.effective_part_deadline = (
|
||||
commit + timedelta(days=line.part_deadline_offset_days)
|
||||
)
|
||||
continue
|
||||
part_lead = (
|
||||
line.part_catalog_id
|
||||
and line.part_catalog_id.x_fc_default_lead_time_days
|
||||
)
|
||||
if part_lead:
|
||||
line.effective_part_deadline = (
|
||||
start + timedelta(days=part_lead)
|
||||
)
|
||||
continue
|
||||
if commit:
|
||||
line.effective_part_deadline = commit
|
||||
continue
|
||||
line.effective_part_deadline = start
|
||||
|
||||
@api.depends(
|
||||
'effective_part_deadline',
|
||||
'wizard_id.customer_deadline',
|
||||
'wizard_id.internal_deadline',
|
||||
)
|
||||
def _compute_effective_internal_deadline(self):
|
||||
for line in self:
|
||||
eff = line.effective_part_deadline
|
||||
if not eff:
|
||||
line.effective_internal_deadline = False
|
||||
continue
|
||||
wiz = line.wizard_id
|
||||
commit = wiz.customer_deadline if wiz else False
|
||||
internal = wiz.internal_deadline if wiz else False
|
||||
if commit and internal and commit >= internal:
|
||||
buffer_days = (commit - internal).days
|
||||
target = eff - timedelta(days=buffer_days)
|
||||
line.effective_internal_deadline = (
|
||||
target if target <= eff else eff
|
||||
)
|
||||
else:
|
||||
line.effective_internal_deadline = eff
|
||||
|
||||
@api.depends('serial_ids')
|
||||
def _compute_primary_serial(self):
|
||||
for rec in self:
|
||||
|
||||
@@ -557,6 +557,7 @@ class FpDirectOrderWizard(models.Model):
|
||||
'x_fc_coating_config_id': line.coating_config_id.id,
|
||||
'x_fc_treatment_ids': [(6, 0, line.treatment_ids.ids)],
|
||||
'x_fc_part_deadline': line.part_deadline,
|
||||
'x_fc_part_deadline_offset_days': line.part_deadline_offset_days,
|
||||
'x_fc_rush_order': line.rush_order,
|
||||
'x_fc_wo_group_tag': line.wo_group_tag or False,
|
||||
'x_fc_part_wo_description': line.part_wo_description or False,
|
||||
|
||||
@@ -165,10 +165,6 @@
|
||||
widget="boolean_toggle"
|
||||
invisible="not process_variant_id"
|
||||
optional="hide"/>
|
||||
<button name="action_customize_process" type="object"
|
||||
string="Customize" icon="fa-pencil-square-o"
|
||||
class="btn-link"
|
||||
invisible="not process_variant_id"/>
|
||||
<field name="effective_process_id"
|
||||
string="Effective Process"
|
||||
readonly="1"
|
||||
@@ -178,19 +174,24 @@
|
||||
readonly="1"
|
||||
optional="hide"/>
|
||||
<field name="thickness_id"
|
||||
options="{'no_create': True}"
|
||||
options="{'no_quick_create': True}"
|
||||
context="{'default_coating_config_id': coating_config_id}"
|
||||
domain="[('coating_config_id', '=', coating_config_id)]"
|
||||
invisible="not coating_config_id"
|
||||
optional="show"/>
|
||||
<field name="serial_ids"
|
||||
widget="many2many_tags"
|
||||
options="{'no_quick_create': False, 'color_field': 'state_color'}"
|
||||
domain="[('part_id', '=', part_catalog_id)]"
|
||||
optional="show"/>
|
||||
<field name="serial_count"
|
||||
string="# SN"
|
||||
string="Serial Count"
|
||||
optional="hide"/>
|
||||
<button name="action_open_serial_bulk_add" type="object"
|
||||
string="Bulk Add Serials" icon="fa-list-ol"
|
||||
class="btn-link"/>
|
||||
title="Bulk add serials"
|
||||
icon="fa-list-ol"
|
||||
class="btn-link"
|
||||
invisible="not part_catalog_id or serial_count > 0"/>
|
||||
<field name="job_number" optional="hide"/>
|
||||
<field name="treatment_ids"
|
||||
widget="many2many_tags"
|
||||
@@ -207,7 +208,20 @@
|
||||
widget="monetary"
|
||||
options="{'currency_field': 'currency_id'}"
|
||||
sum="Total"/>
|
||||
<field name="part_deadline"/>
|
||||
<field name="effective_part_deadline"
|
||||
string="Effective Deadline"
|
||||
optional="show"
|
||||
readonly="1"/>
|
||||
<field name="part_deadline"
|
||||
string="Part Deadline Override"
|
||||
optional="hide"/>
|
||||
<field name="part_deadline_offset_days"
|
||||
string="Days Offset"
|
||||
optional="hide"/>
|
||||
<field name="effective_internal_deadline"
|
||||
string="Shop Target"
|
||||
optional="hide"
|
||||
readonly="1"/>
|
||||
<field name="wo_group_tag" optional="show"/>
|
||||
<field name="rush_order" optional="hide"/>
|
||||
<field name="currency_id" column_invisible="1"/>
|
||||
|
||||
Reference in New Issue
Block a user