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:
gsinghpal
2026-04-29 22:13:54 -04:00
parent bbf2476f01
commit b187192c58
34 changed files with 1690 additions and 110 deletions

View File

@@ -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)

View File

@@ -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',

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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',

View File

@@ -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.

View File

@@ -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,

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
49 access_fp_coating_thickness_user fp.coating.thickness.user model_fp_coating_thickness base.group_user 1 0 0 0
50 access_fp_coating_thickness_estimator fp.coating.thickness.estimator model_fp_coating_thickness fusion_plating_configurator.group_fp_estimator 1 1 1 0
51 access_fp_coating_thickness_manager fp.coating.thickness.manager model_fp_coating_thickness fusion_plating.group_fusion_plating_manager 1 1 1 1
52 access_fp_part_material_user fp.part.material.user model_fp_part_material base.group_user 1 0 0 0
53 access_fp_part_material_estimator fp.part.material.estimator model_fp_part_material fusion_plating_configurator.group_fp_estimator 1 1 1 0
54 access_fp_part_material_manager fp.part.material.manager model_fp_part_material fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 &amp; 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 &gt; 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"

View File

@@ -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,

View File

@@ -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:

View File

@@ -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,

View File

@@ -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 &gt; 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"/>