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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user