Split 49 modules/suites into independent git repos; untrack from monorepo
Each top-level module/suite folder is now its own private repo on GitHub (gsinghpal/<name>) and gitea (admin/<name>), with a fresh single initial commit. The monorepo no longer tracks them (added to .gitignore + git rm --cached); working-tree files are retained on disk and managed in their own repos. The monorepo keeps shared root files (CLAUDE.md, docs/, scripts/, tools/, AGENTS.md, WIP/obsolete dirs) and full history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from . import fp_part_material
|
||||
from . import fp_part_catalog
|
||||
from . import fp_pricing_complexity_surcharge
|
||||
from . import fp_pricing_rule
|
||||
from . import fp_sale_description_template
|
||||
from . import fp_part_description_version
|
||||
from . import fp_additional_charge_type
|
||||
from . import fp_so_job_sort
|
||||
from . import fp_quote_configurator
|
||||
from . import fp_serial
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
from . import account_move_line
|
||||
from . import fp_sale_assembly
|
||||
from . import res_partner
|
||||
from . import fp_process_node
|
||||
from . import product_pricelist
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,76 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
#
|
||||
# Sub 2 Task 19 - propagate the customer part reference from SO line to
|
||||
# invoice line so customer-facing invoice PDFs can print the part number
|
||||
# via the shared fusion_plating_reports.customer_line_header macro.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = 'account.move.line'
|
||||
|
||||
def fp_customer_description(self):
|
||||
"""Strip the "[code] product_name" prefix from line.name.
|
||||
|
||||
Mirror of sale.order.line.fp_customer_description so the shared
|
||||
customer_line_description QWeb macro renders cleanly on invoice
|
||||
PDFs too.
|
||||
"""
|
||||
self.ensure_one()
|
||||
name = (self.name or '').strip()
|
||||
if not self.product_id or not name:
|
||||
return name
|
||||
code = self.product_id.default_code or ''
|
||||
pname = self.product_id.name or ''
|
||||
prefixes = []
|
||||
if code and pname:
|
||||
prefixes.append(f'[{code}] {pname}')
|
||||
if pname:
|
||||
prefixes.append(pname)
|
||||
for prefix in prefixes:
|
||||
if name.startswith(prefix):
|
||||
tail = name[len(prefix):]
|
||||
return tail.lstrip(' \t\r\n---:').strip()
|
||||
return name
|
||||
|
||||
x_fc_part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog',
|
||||
string='Part',
|
||||
help="Copied from sale.order.line on invoice creation so customer-"
|
||||
"facing invoice PDFs can render the customer's part number.",
|
||||
)
|
||||
# ---- Sub 5 / Phase 1 multi-serial ---------------------------------------
|
||||
x_fc_serial_ids = fields.Many2many(
|
||||
'fp.serial',
|
||||
relation='fp_account_move_line_serial_rel',
|
||||
column1='line_id',
|
||||
column2='serial_id',
|
||||
string='Serial Numbers',
|
||||
help='Copied from sale.order.line for traceability. Multi-serial '
|
||||
'support added 2026-04-28.',
|
||||
)
|
||||
x_fc_serial_id = fields.Many2one(
|
||||
'fp.serial',
|
||||
string='Serial Number',
|
||||
index=True,
|
||||
help='Back-compat alias of the first serial in x_fc_serial_ids. '
|
||||
'Kept so legacy invoice templates that read the singular '
|
||||
'continue to render.',
|
||||
)
|
||||
x_fc_job_number = fields.Char(
|
||||
string='Job #', index=True,
|
||||
help='Copied from sale.order.line.',
|
||||
)
|
||||
x_fc_thickness_range = fields.Char(
|
||||
string='Thickness',
|
||||
help='Carried from the SO line - prints on the invoice PDF.',
|
||||
)
|
||||
# x_fc_customer_spec_id added by fusion_plating_quality.
|
||||
x_fc_revision_snapshot = fields.Char(
|
||||
string='Revision (snapshot)',
|
||||
help='Revision letter from the source SO line.',
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpAdditionalChargeType(models.Model):
|
||||
"""A configurable, reusable 'additional charge' label (Tooling, Rush,
|
||||
Setup, …) picked on the order-entry summary. Searchable + quick-create.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-29-configurable-charge-tax-lot-pricing-design.md
|
||||
"""
|
||||
_name = 'fp.additional.charge.type'
|
||||
_description = 'Fusion Plating - Additional Charge Type'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(string='Charge Type', required=True)
|
||||
default_amount = fields.Monetary(
|
||||
string='Default Amount', currency_field='currency_id',
|
||||
help='Optional amount pre-filled when this type is picked on an '
|
||||
'order. The operator can override it.',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,80 +0,0 @@
|
||||
# -*- 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 FpPartDescriptionVersion(models.Model):
|
||||
"""Immutable per-part snapshot of the internal + customer-facing
|
||||
description entered on an order. A new version is written on sale
|
||||
order confirm whenever the description changes
|
||||
(fp.part.catalog._fp_save_description_version). The latest version
|
||||
auto-loads into the next order line for the part.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-29-part-description-history-design.md
|
||||
"""
|
||||
_name = 'fp.part.description.version'
|
||||
_description = 'Fusion Plating - Part Description Version'
|
||||
_order = 'part_catalog_id, version_no desc, id desc'
|
||||
|
||||
part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part', required=True,
|
||||
ondelete='cascade', index=True,
|
||||
)
|
||||
internal_description = fields.Text(string='Internal Description')
|
||||
customer_facing_description = fields.Text(string='Customer-Facing Description')
|
||||
sale_order_id = fields.Many2one(
|
||||
'sale.order', string='Sale Order', ondelete='set null')
|
||||
sale_order_line_id = fields.Many2one(
|
||||
'sale.order.line', string='Order Line', ondelete='set null')
|
||||
source_date = fields.Date(string='Date')
|
||||
version_no = fields.Integer(string='Version', readonly=True)
|
||||
name = fields.Char(string='Reference', readonly=True)
|
||||
is_latest = fields.Boolean(string='Latest', default=False, index=True)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer',
|
||||
related='part_catalog_id.partner_id', store=True, readonly=True,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
@api.model
|
||||
def _fp_build_name(self, vals):
|
||||
"""Title = '<SO name> · <date>', with a '(n)' suffix if a version
|
||||
with that title already exists for the part (e.g. two lines of the
|
||||
same part on one order)."""
|
||||
so = (self.env['sale.order'].browse(vals['sale_order_id'])
|
||||
if vals.get('sale_order_id') else None)
|
||||
order_ref = so.name if so and so.name else _('Manual')
|
||||
d = vals.get('source_date') or fields.Date.context_today(self)
|
||||
base = '%s · %s' % (order_ref, d)
|
||||
part_id = vals.get('part_catalog_id')
|
||||
if part_id:
|
||||
dup = self.search_count([
|
||||
('part_catalog_id', '=', part_id),
|
||||
('name', '=like', base + '%'),
|
||||
])
|
||||
if dup:
|
||||
base = '%s (%d)' % (base, dup + 1)
|
||||
return base
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
part_id = vals.get('part_catalog_id')
|
||||
if part_id and not vals.get('version_no'):
|
||||
prev = self.search(
|
||||
[('part_catalog_id', '=', part_id)],
|
||||
order='version_no desc', limit=1)
|
||||
vals['version_no'] = (prev.version_no or 0) + 1
|
||||
if not vals.get('name'):
|
||||
vals['name'] = self._fp_build_name(vals)
|
||||
vals['is_latest'] = True
|
||||
records = super().create(vals_list)
|
||||
# Exactly one latest per part - flip prior latest rows off.
|
||||
for rec in records:
|
||||
rec.part_catalog_id.description_version_ids.filtered(
|
||||
lambda v, r=rec: v.id != r.id and v.is_latest
|
||||
).write({'is_latest': False})
|
||||
return records
|
||||
@@ -1,61 +0,0 @@
|
||||
# -*- 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)
|
||||
@@ -1,34 +0,0 @@
|
||||
# -*- 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 FpPricingComplexitySurcharge(models.Model):
|
||||
"""Complexity-based surcharge line on a pricing rule."""
|
||||
_name = 'fp.pricing.complexity.surcharge'
|
||||
_description = 'Fusion Plating - Pricing Complexity Surcharge'
|
||||
_order = 'complexity'
|
||||
|
||||
rule_id = fields.Many2one('fp.pricing.rule', string='Pricing Rule', required=True, ondelete='cascade')
|
||||
complexity = fields.Selection(
|
||||
[('simple', 'Simple'), ('moderate', 'Moderate'), ('complex', 'Complex'), ('very_complex', 'Very Complex')],
|
||||
string='Complexity', required=True,
|
||||
)
|
||||
surcharge_percent = fields.Float(string='Surcharge %', help='Additional percentage on top of base price.')
|
||||
|
||||
@api.depends('complexity', 'surcharge_percent')
|
||||
def _compute_display_name(self):
|
||||
labels = dict(self._fields['complexity'].selection)
|
||||
for rec in self:
|
||||
label = labels.get(rec.complexity, rec.complexity or '')
|
||||
if rec.surcharge_percent:
|
||||
label = '%s +%g%%' % (label, rec.surcharge_percent)
|
||||
rec.display_name = label or 'Surcharge'
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_pricing_surcharge_rule_complexity_uniq', 'unique(rule_id, complexity)',
|
||||
'Only one surcharge per complexity level per rule.'),
|
||||
]
|
||||
@@ -1,55 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpPricingRule(models.Model):
|
||||
"""Formula-based pricing rule.
|
||||
|
||||
Rules are matched by coating config, substrate material, and
|
||||
certification level. The first matching rule (by sequence) wins.
|
||||
Global rules (no filters set) act as fallbacks.
|
||||
"""
|
||||
_name = 'fp.pricing.rule'
|
||||
_description = 'Fusion Plating - Pricing Rule'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Rule Name', required=True)
|
||||
# coating_config_id removed. Spec + recipe match keys live on
|
||||
# fusion_plating_quality.fp_pricing_rule_inherit. Material +
|
||||
# cert_level (below) remain as generic filters.
|
||||
substrate_material = fields.Selection(
|
||||
[('aluminium', 'Aluminium'), ('steel', 'Steel'), ('stainless', 'Stainless Steel'),
|
||||
('copper', 'Copper'), ('titanium', 'Titanium'), ('other', 'Other')],
|
||||
string='Substrate Material', help='Leave blank to match all materials.',
|
||||
)
|
||||
certification_level = fields.Selection(
|
||||
[('commercial', 'Commercial'), ('mil_spec', 'Mil-Spec'),
|
||||
('nadcap', 'Nadcap'), ('nuclear', 'Nuclear (CSA N299)')],
|
||||
string='Certification Level', help='Leave blank to match all levels.',
|
||||
)
|
||||
pricing_method = fields.Selection(
|
||||
[('per_sqin', 'Per Square Inch'), ('per_sqft', 'Per Square Foot'),
|
||||
('per_piece', 'Per Piece'), ('flat_rate', 'Flat Rate')],
|
||||
string='Pricing Method', required=True, default='per_sqin',
|
||||
)
|
||||
currency_id = fields.Many2one('res.currency', string='Currency',
|
||||
required=True, default=lambda self: self.env.company.currency_id)
|
||||
base_rate = fields.Monetary(string='Base Rate', currency_field='currency_id',
|
||||
help='Price per unit (sq in, sq ft, piece, or flat).')
|
||||
thickness_factor = fields.Float(string='Thickness Factor', default=1.0,
|
||||
help='Multiplier per mil of coating thickness. 1.0 = no adjustment.')
|
||||
complexity_surcharge_ids = fields.One2many('fp.pricing.complexity.surcharge', 'rule_id',
|
||||
string='Complexity Surcharges')
|
||||
masking_rate_per_zone = fields.Monetary(string='Masking Rate / Zone', currency_field='currency_id')
|
||||
setup_fee = fields.Monetary(string='Setup Fee', currency_field='currency_id',
|
||||
help='One-time setup fee per batch.')
|
||||
minimum_charge = fields.Monetary(string='Minimum Charge', currency_field='currency_id',
|
||||
help='Floor price.')
|
||||
rush_surcharge_percent = fields.Float(string='Rush Surcharge %')
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
notes = fields.Text(string='Notes')
|
||||
@@ -1,119 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
"""Configurator-side extensions to fusion.plating.process.node.
|
||||
|
||||
Lives here (not in core fusion_plating) so the core module doesn't have
|
||||
to depend on the configurator. Any field that references a model defined
|
||||
in configurator - like fp.pricing.rule, fp.part.catalog - must be
|
||||
declared here.
|
||||
"""
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class FpProcessNode(models.Model):
|
||||
_inherit = 'fusion.plating.process.node'
|
||||
|
||||
# ---- Existing: pricing rule linkage (Steelhead "Use Price Builders") ----
|
||||
pricing_rule_ids = fields.Many2many(
|
||||
'fp.pricing.rule',
|
||||
relation='fp_process_node_pricing_rule_rel',
|
||||
column1='node_id',
|
||||
column2='rule_id',
|
||||
string='Price Builders',
|
||||
help='Pricing rules to apply when this recipe is selected on a '
|
||||
'quotation (mirrors Steelhead "Use Price Builders").',
|
||||
)
|
||||
|
||||
# ---- Sub 3: part ownership + template reference + treatment UoM --------
|
||||
part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog',
|
||||
string='Part',
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
help='Populated on nodes that belong to a specific part\'s '
|
||||
'composed process tree. NULL on shared templates.',
|
||||
)
|
||||
cloned_from_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Cloned From',
|
||||
ondelete='set null',
|
||||
help='On a part-cloned node, points back at the source template '
|
||||
'node it was copied from.',
|
||||
)
|
||||
treatment_uom = fields.Selection(
|
||||
[
|
||||
('lbs', 'Lbs (weight-based)'),
|
||||
('sq_in', 'Sq in (area-based)'),
|
||||
],
|
||||
string='Treatment UoM',
|
||||
help='How this process step is measured for costing / pricing. '
|
||||
'Picks which physical property of the part to multiply by '
|
||||
'the per-unit rate: weight (Lbs) or surface area (Sq in).',
|
||||
)
|
||||
|
||||
# ---- Process Variants (per-part) ----------------------------------------
|
||||
# A part can carry multiple recipe-root trees ("variants"). Examples:
|
||||
# "Standard ENP", "Selective Masking", "Rework". Each order line picks a
|
||||
# variant; the MO walker resolves through it. One variant per part is the
|
||||
# default - used when the order line doesn't pick one explicitly.
|
||||
#
|
||||
# Variant identification only applies to root nodes (parent_id IS NULL,
|
||||
# node_type='recipe') with a part_catalog_id set. Non-root nodes carry
|
||||
# these fields too because they sit on the same model, but they're only
|
||||
# meaningful on roots.
|
||||
is_default_variant = fields.Boolean(
|
||||
string='Default Variant',
|
||||
help='When ticked, this variant is used by default for new orders '
|
||||
'of this part. Exactly one variant per part is the default.',
|
||||
)
|
||||
variant_label = fields.Char(
|
||||
string='Variant Label',
|
||||
help='Friendly label shown in the variant picker '
|
||||
'(e.g. "Standard ENP", "Selective Masking", "Rework").',
|
||||
)
|
||||
|
||||
# ---- Linked Parts (cloned recipes) --------------------------------------
|
||||
# On a shared template recipe, count + open all part-cloned recipe
|
||||
# roots that were copied from this template (cloned_from_id == self).
|
||||
# Only meaningful on shared templates (part_catalog_id IS NULL,
|
||||
# node_type='recipe').
|
||||
cloned_recipe_count = fields.Integer(
|
||||
string='Linked Part Recipes',
|
||||
compute='_compute_cloned_recipe_count',
|
||||
)
|
||||
|
||||
def _compute_cloned_recipe_count(self):
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
groups = Node._read_group(
|
||||
domain=[
|
||||
('cloned_from_id', 'in', self.ids),
|
||||
('node_type', '=', 'recipe'),
|
||||
('part_catalog_id', '!=', False),
|
||||
],
|
||||
groupby=['cloned_from_id'],
|
||||
aggregates=['__count'],
|
||||
)
|
||||
counts = {src.id: count for src, count in groups}
|
||||
for rec in self:
|
||||
rec.cloned_recipe_count = counts.get(rec.id, 0)
|
||||
|
||||
def action_open_cloned_recipes(self):
|
||||
"""Open the list of part-cloned recipe roots that came from this
|
||||
template (i.e. cloned_from_id == self)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Linked Parts - %s', self.name),
|
||||
'res_model': 'fusion.plating.process.node',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [
|
||||
('cloned_from_id', '=', self.id),
|
||||
('node_type', '=', 'recipe'),
|
||||
('part_catalog_id', '!=', False),
|
||||
],
|
||||
'context': {
|
||||
'search_default_group_part': 1,
|
||||
},
|
||||
}
|
||||
@@ -1,988 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
import math
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FpQuoteConfigurator(models.Model):
|
||||
"""Persistent configurator session.
|
||||
|
||||
Collects part geometry, coating config, and pricing inputs.
|
||||
Calculates a price from matching pricing rules. The estimator
|
||||
can override the calculated price. Creates a sale.order when confirmed.
|
||||
"""
|
||||
_name = 'fp.quote.configurator'
|
||||
_description = 'Fusion Plating - Quote Configurator'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'create_date desc'
|
||||
|
||||
name = fields.Char(string='Reference', readonly=True, copy=False, default='New')
|
||||
state = fields.Selection(
|
||||
[('draft', 'Draft'),
|
||||
('confirmed', 'Won (SO Created)'),
|
||||
('lost', 'Lost'),
|
||||
('expired', 'Expired'),
|
||||
('cancelled', 'Cancelled')],
|
||||
string='Status', default='draft', tracking=True,
|
||||
)
|
||||
|
||||
# ---- Win/Loss tracking (T3.2) ----
|
||||
lost_reason = fields.Selection(
|
||||
[('price', 'Price'),
|
||||
('lead_time', 'Lead Time'),
|
||||
('tech_capability', 'Technical Capability'),
|
||||
('spec_mismatch', 'Spec / Certification Mismatch'),
|
||||
('no_bid', 'No-Bid'),
|
||||
('no_response', 'Customer No-Response'),
|
||||
('competitor', 'Lost to Competitor'),
|
||||
('other', 'Other')],
|
||||
string='Lost Reason', tracking=True,
|
||||
)
|
||||
lost_competitor_name = fields.Char(string='Competitor', tracking=True)
|
||||
lost_details = fields.Text(string='Loss Notes')
|
||||
won_date = fields.Date(string='Won Date', readonly=True)
|
||||
lost_date = fields.Date(string='Lost Date', readonly=True)
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer', required=True,
|
||||
domain="[('customer_rank', '>', 0)]",
|
||||
context={'default_customer_rank': 1}, # inline-created customers get rank=1 so they stay visible in this picker
|
||||
)
|
||||
part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part (Catalog)',
|
||||
domain="[('partner_id', '=', partner_id)]",
|
||||
help="Select from this customer's part catalog, or leave blank for a one-off.",
|
||||
)
|
||||
model_attachment_id = fields.Many2one(
|
||||
related='part_catalog_id.model_attachment_id',
|
||||
string='3D Model',
|
||||
readonly=True,
|
||||
)
|
||||
drawing_attachment_ids = fields.Many2many(
|
||||
related='part_catalog_id.drawing_attachment_ids',
|
||||
string='Drawings',
|
||||
readonly=True,
|
||||
)
|
||||
# -- Physical part properties (intrinsic, related from part catalog) --
|
||||
bbox_summary_in = fields.Char(
|
||||
related='part_catalog_id.bbox_summary_in', string='Dimensions (in)',
|
||||
readonly=True,
|
||||
)
|
||||
volume_mm3 = fields.Float(
|
||||
related='part_catalog_id.volume_mm3', string='Volume (mm³)',
|
||||
readonly=True,
|
||||
)
|
||||
hole_count = fields.Integer(
|
||||
related='part_catalog_id.hole_count', string='Holes',
|
||||
readonly=True,
|
||||
)
|
||||
hole_summary = fields.Char(
|
||||
related='part_catalog_id.hole_summary', string='Hole Summary',
|
||||
readonly=True,
|
||||
)
|
||||
is_manifold = fields.Boolean(
|
||||
related='part_catalog_id.is_manifold', string='Watertight',
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# -- Quote-editable fields that drive weight / effective area --
|
||||
# These are independent from part catalog (working copy for this quote)
|
||||
masking_area_sqin = fields.Float(
|
||||
string='Masking Area (sq in)',
|
||||
digits=(12, 4),
|
||||
help='Surface area excluded from plating (masked surfaces).',
|
||||
)
|
||||
# Computed using CONFIGURATOR's substrate + part catalog's volume
|
||||
# so changing substrate on the quote updates the weight live.
|
||||
material_weight_kg = fields.Float(
|
||||
string='Weight (kg)',
|
||||
digits=(12, 4),
|
||||
compute='_compute_material_weight_kg',
|
||||
store=False,
|
||||
help='Computed from part volume × this quote\'s substrate density. '
|
||||
'Changing substrate on the quote updates weight immediately.',
|
||||
)
|
||||
# Computed using CONFIGURATOR's surface_area and masking_area
|
||||
effective_area_sqin = fields.Float(
|
||||
string='Effective Plating Area (sq in)',
|
||||
digits=(12, 4),
|
||||
compute='_compute_effective_area_sqin',
|
||||
store=False,
|
||||
help='Surface area minus masked area, using the values on this quote.',
|
||||
)
|
||||
|
||||
@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.
|
||||
|
||||
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,
|
||||
'stainless': 8.00,
|
||||
'copper': 8.96,
|
||||
'titanium': 4.51,
|
||||
'other': 7.85,
|
||||
}
|
||||
for rec in self:
|
||||
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
|
||||
rec.material_weight_kg = round(rec.volume_mm3 * density * 1e-6, 4)
|
||||
|
||||
@api.depends('surface_area', 'surface_area_uom', 'masking_area_sqin')
|
||||
def _compute_effective_area_sqin(self):
|
||||
"""Surface area minus masking area, using THIS QUOTE'S values."""
|
||||
for rec in self:
|
||||
uom = rec.surface_area_uom or 'sq_in'
|
||||
if uom == 'sq_in':
|
||||
area_sqin = rec.surface_area or 0.0
|
||||
elif uom == 'sq_ft':
|
||||
area_sqin = (rec.surface_area or 0.0) * 144.0
|
||||
elif uom == 'sq_cm':
|
||||
area_sqin = (rec.surface_area or 0.0) / 6.4516
|
||||
elif uom == 'sq_m':
|
||||
area_sqin = (rec.surface_area or 0.0) * 1550.0
|
||||
else:
|
||||
area_sqin = rec.surface_area or 0.0
|
||||
rec.effective_area_sqin = max(0.0, area_sqin - (rec.masking_area_sqin or 0.0))
|
||||
drawing_count = fields.Integer(
|
||||
string='Drawings',
|
||||
compute='_compute_drawing_count',
|
||||
)
|
||||
first_drawing_id = fields.Many2one(
|
||||
'ir.attachment', string='First Drawing',
|
||||
compute='_compute_first_drawing',
|
||||
inverse='_inverse_first_drawing',
|
||||
)
|
||||
|
||||
@api.depends('part_catalog_id.drawing_attachment_ids')
|
||||
def _compute_drawing_count(self):
|
||||
for rec in self:
|
||||
rec.drawing_count = len(rec.part_catalog_id.drawing_attachment_ids) if rec.part_catalog_id else 0
|
||||
|
||||
@api.depends('part_catalog_id.drawing_attachment_ids')
|
||||
def _compute_first_drawing(self):
|
||||
for rec in self:
|
||||
atts = rec.part_catalog_id.drawing_attachment_ids if rec.part_catalog_id else False
|
||||
rec.first_drawing_id = atts[0] if atts else False
|
||||
|
||||
def _inverse_first_drawing(self):
|
||||
"""When user clears or replaces the first drawing in the configurator,
|
||||
propagate that change to the part catalog's drawing list."""
|
||||
for rec in self:
|
||||
if not rec.part_catalog_id:
|
||||
continue
|
||||
atts = rec.part_catalog_id.drawing_attachment_ids
|
||||
current_first = atts[0] if atts else False
|
||||
new_first = rec.first_drawing_id
|
||||
# Cleared
|
||||
if current_first and not new_first:
|
||||
rec.part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [(3, current_first.id)],
|
||||
})
|
||||
# Replaced
|
||||
elif new_first and current_first and new_first.id != current_first.id:
|
||||
rec.part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [
|
||||
(3, current_first.id), (4, new_first.id),
|
||||
],
|
||||
})
|
||||
# Added (no current first, new value set)
|
||||
elif new_first and not current_first:
|
||||
rec.part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [(4, new_first.id)],
|
||||
})
|
||||
|
||||
# -- Quick file upload (creates/updates part catalog automatically) --
|
||||
upload_3d_file = fields.Binary(
|
||||
string='Upload 3D File',
|
||||
attachment=False,
|
||||
help='Upload a STEP, IGES, or STL file. Auto-creates or updates the part catalog entry.',
|
||||
)
|
||||
upload_3d_filename = fields.Char(string='3D Filename')
|
||||
upload_drawing = fields.Binary(
|
||||
string='Upload Drawing',
|
||||
attachment=False,
|
||||
help='Upload a PDF drawing. Attaches to the part catalog entry.',
|
||||
)
|
||||
upload_drawing_filename = fields.Char(string='Drawing Filename')
|
||||
|
||||
# -- RFQ / PO document tracking (from the beginning of the quote) --
|
||||
rfq_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='RFQ Document', copy=False, tracking=True,
|
||||
help="Customer's original Request for Quote document (PDF). "
|
||||
"Transferred to the sale order on quotation.",
|
||||
)
|
||||
po_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='Customer PO', copy=False, tracking=True,
|
||||
help='Customer PO document if already received. '
|
||||
'Transferred to the sale order on quotation.',
|
||||
)
|
||||
po_number_preliminary = fields.Char(
|
||||
string='PO Number', copy=False, tracking=True,
|
||||
help='Customer PO number if already known. '
|
||||
'Transferred to the sale order on quotation.',
|
||||
)
|
||||
upload_rfq_file = fields.Binary(string='Upload RFQ', attachment=False)
|
||||
upload_rfq_filename = fields.Char(string='RFQ Filename')
|
||||
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
|
||||
upload_po_filename = fields.Char(string='PO Filename')
|
||||
|
||||
# Renamed from coating_config_id (Phase E - Promote Customer Spec).
|
||||
# Now points at the recipe directly. The quote's specification
|
||||
# (customer-facing audit ref) is added by quality inherit as
|
||||
# customer_spec_id.
|
||||
recipe_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Recipe',
|
||||
required=True,
|
||||
domain="[('node_type', '=', 'recipe'), ('parent_id', '=', False)]",
|
||||
)
|
||||
quantity = fields.Integer(string='Quantity', default=1, required=True)
|
||||
batch_size = fields.Integer(string='Batch Size', help='Parts per rack or barrel load.')
|
||||
|
||||
# ----- Geometry (auto-filled from catalog or entered manually) ----------
|
||||
surface_area = fields.Float(string='Surface Area', digits=(12, 4))
|
||||
surface_area_uom = fields.Selection(
|
||||
[('sq_in', 'sq in'), ('sq_ft', 'sq ft'), ('sq_cm', 'sq cm'), ('sq_m', 'sq m')],
|
||||
string='Area UoM', default='sq_in',
|
||||
)
|
||||
thickness_requested = fields.Float(string='Requested Thickness', digits=(10, 4))
|
||||
masking_zones = fields.Integer(string='Masking Zones')
|
||||
complexity = fields.Selection(
|
||||
[('simple', 'Simple'), ('moderate', 'Moderate'),
|
||||
('complex', 'Complex'), ('very_complex', 'Very Complex')],
|
||||
string='Complexity', default='simple',
|
||||
)
|
||||
# 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='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)')
|
||||
delivery_method = fields.Selection(
|
||||
[('local_delivery', 'Local Delivery'),
|
||||
('shipping_partner', 'Shipping Partner'),
|
||||
('customer_pickup', 'Customer Pickup')],
|
||||
string='Delivery Method', default='shipping_partner',
|
||||
)
|
||||
|
||||
# ----- Pricing ----------------------------------------------------------
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
required=True, default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
shipping_fee = fields.Monetary(string='Shipping Fee', currency_field='currency_id')
|
||||
delivery_fee = fields.Monetary(string='Delivery Fee', currency_field='currency_id')
|
||||
calculated_price = fields.Monetary(
|
||||
string='Calculated Price', currency_field='currency_id',
|
||||
compute='_compute_price', store=True,
|
||||
)
|
||||
price_breakdown_html = fields.Html(
|
||||
string='Price Breakdown', compute='_compute_price', store=True,
|
||||
)
|
||||
estimator_override_price = fields.Monetary(
|
||||
string='Final Price', currency_field='currency_id',
|
||||
help='Estimator can override the calculated price.',
|
||||
)
|
||||
|
||||
# ----- SO link ----------------------------------------------------------
|
||||
sale_order_id = fields.Many2one('sale.order', string='Sale Order', readonly=True, copy=False)
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Auto-population from catalog
|
||||
# -------------------------------------------------------------------------
|
||||
@api.onchange('part_catalog_id')
|
||||
def _onchange_part_catalog_id(self):
|
||||
if self.part_catalog_id:
|
||||
cat = self.part_catalog_id
|
||||
self.surface_area = cat.surface_area
|
||||
self.surface_area_uom = cat.surface_area_uom
|
||||
self.complexity = cat.complexity
|
||||
self.masking_zones = cat.masking_zones
|
||||
# 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
|
||||
|
||||
@api.onchange('recipe_id')
|
||||
def _onchange_recipe_id(self):
|
||||
if self.recipe_id and self.recipe_id.thickness_min:
|
||||
self.thickness_requested = self.recipe_id.thickness_min
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Price calculation
|
||||
# -------------------------------------------------------------------------
|
||||
@api.depends(
|
||||
'surface_area', 'surface_area_uom', 'thickness_requested',
|
||||
'masking_zones', 'complexity', 'substrate_material',
|
||||
'quantity', 'batch_size', 'rush_order',
|
||||
'shipping_fee', 'delivery_fee',
|
||||
'recipe_id',
|
||||
)
|
||||
def _compute_price(self):
|
||||
for rec in self:
|
||||
if not rec.recipe_id or not rec.surface_area:
|
||||
rec.calculated_price = 0
|
||||
rec.price_breakdown_html = ''
|
||||
continue
|
||||
|
||||
rule = rec._find_matching_rule()
|
||||
if not rule:
|
||||
rec.calculated_price = 0
|
||||
rec.price_breakdown_html = '<p class="text-muted">No matching pricing rule found.</p>'
|
||||
continue
|
||||
|
||||
# --- Base calculation ---
|
||||
area = rec._normalize_surface_area_to_sqin()
|
||||
if rule.pricing_method == 'per_sqin':
|
||||
unit_price = area * rule.base_rate
|
||||
elif rule.pricing_method == 'per_sqft':
|
||||
unit_price = (area / 144.0) * rule.base_rate
|
||||
elif rule.pricing_method == 'per_piece':
|
||||
unit_price = rule.base_rate
|
||||
else: # flat_rate
|
||||
unit_price = rule.base_rate
|
||||
|
||||
# --- Thickness scaling ---
|
||||
# thickness_factor is a per-mil multiplier. A factor of 1.0
|
||||
# means linear scaling by thickness (e.g. 3 mils = 3x price).
|
||||
# A factor of 0.8 gives a volume discount (3 mils = 2.4x).
|
||||
thickness = rec.thickness_requested or 1.0
|
||||
unit_price *= thickness * rule.thickness_factor
|
||||
|
||||
# --- Complexity surcharge ---
|
||||
surcharge_pct = 0
|
||||
for line in rule.complexity_surcharge_ids:
|
||||
if line.complexity == rec.complexity:
|
||||
surcharge_pct = line.surcharge_percent
|
||||
break
|
||||
unit_price *= (1 + surcharge_pct / 100.0)
|
||||
|
||||
# --- Masking ---
|
||||
masking_cost = (rec.masking_zones or 0) * rule.masking_rate_per_zone
|
||||
|
||||
# --- Quantity + batch setup fees ---
|
||||
num_batches = (
|
||||
math.ceil(rec.quantity / rec.batch_size) if rec.batch_size
|
||||
else 1
|
||||
)
|
||||
total_setup = rule.setup_fee * num_batches
|
||||
subtotal = (unit_price * rec.quantity) + masking_cost + total_setup
|
||||
|
||||
# --- Rush surcharge ---
|
||||
rush_amount = 0
|
||||
if rec.rush_order and rule.rush_surcharge_percent:
|
||||
rush_amount = subtotal * (rule.rush_surcharge_percent / 100.0)
|
||||
subtotal += rush_amount
|
||||
|
||||
# --- Minimum charge ---
|
||||
if subtotal < rule.minimum_charge:
|
||||
subtotal = rule.minimum_charge
|
||||
|
||||
# --- Delivery/shipping fees ---
|
||||
total = subtotal + (rec.shipping_fee or 0) + (rec.delivery_fee or 0)
|
||||
|
||||
rec.calculated_price = total
|
||||
|
||||
# --- Build breakdown HTML ---
|
||||
sym = rec.currency_id.symbol or '$'
|
||||
lines = []
|
||||
method_label = dict(
|
||||
rule._fields['pricing_method'].selection
|
||||
).get(rule.pricing_method, '')
|
||||
lines.append(
|
||||
'<tr><td>Base (%s)</td><td class="text-end">%s%.2f x %d</td></tr>'
|
||||
% (method_label, sym, unit_price, rec.quantity)
|
||||
)
|
||||
if masking_cost:
|
||||
lines.append(
|
||||
'<tr><td>Masking (%d zones)</td><td class="text-end">%s%.2f</td></tr>'
|
||||
% (rec.masking_zones, sym, masking_cost)
|
||||
)
|
||||
if total_setup:
|
||||
lines.append(
|
||||
'<tr><td>Setup Fee (x%d batches)</td><td class="text-end">%s%.2f</td></tr>'
|
||||
% (num_batches, sym, total_setup)
|
||||
)
|
||||
if rush_amount:
|
||||
lines.append(
|
||||
'<tr><td>Rush Surcharge (%.0f%%)</td><td class="text-end">%s%.2f</td></tr>'
|
||||
% (rule.rush_surcharge_percent, sym, rush_amount)
|
||||
)
|
||||
if rec.shipping_fee:
|
||||
lines.append(
|
||||
'<tr><td>Shipping</td><td class="text-end">%s%.2f</td></tr>'
|
||||
% (sym, rec.shipping_fee)
|
||||
)
|
||||
if rec.delivery_fee:
|
||||
lines.append(
|
||||
'<tr><td>Delivery</td><td class="text-end">%s%.2f</td></tr>'
|
||||
% (sym, rec.delivery_fee)
|
||||
)
|
||||
lines.append(
|
||||
'<tr class="fw-bold"><td>Total</td><td class="text-end">%s%.2f</td></tr>'
|
||||
% (sym, total)
|
||||
)
|
||||
|
||||
rec.price_breakdown_html = (
|
||||
'<table class="table table-sm"><thead><tr>'
|
||||
'<th>Item</th><th class="text-end">Amount</th></tr></thead>'
|
||||
'<tbody>%s</tbody></table>'
|
||||
'<p class="text-muted small">Rule: %s (seq %d)</p>'
|
||||
% (''.join(lines), rule.name, rule.sequence)
|
||||
)
|
||||
|
||||
def _find_matching_rule(self):
|
||||
"""Find the best pricing rule matching this configurator's filters.
|
||||
|
||||
Scores rules by specificity - most specific match wins.
|
||||
If no rule matches filters, returns None.
|
||||
|
||||
When the chosen recipe has `pricing_rule_ids` configured, the
|
||||
search is constrained to those rules ("Use Price Builders"
|
||||
semantics). Otherwise the whole active rule set is considered.
|
||||
|
||||
Spec-tier scoring is added by an inherit in
|
||||
fusion_plating_quality (where customer.spec lives).
|
||||
"""
|
||||
recipe = self.recipe_id or False
|
||||
builder_rules = (
|
||||
recipe.pricing_rule_ids if recipe else self.env['fp.pricing.rule']
|
||||
)
|
||||
if builder_rules:
|
||||
rules = builder_rules.filtered('active').sorted(
|
||||
lambda r: (r.sequence, r.id)
|
||||
)
|
||||
else:
|
||||
rules = self.env['fp.pricing.rule'].search(
|
||||
[('active', '=', True)], order='sequence, id'
|
||||
)
|
||||
|
||||
best = None
|
||||
best_score = -1
|
||||
for rule in rules:
|
||||
score = 0
|
||||
if rule.substrate_material:
|
||||
if rule.substrate_material != self.substrate_material:
|
||||
continue
|
||||
score += 2
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = rule
|
||||
return best
|
||||
|
||||
def _normalize_surface_area_to_sqin(self):
|
||||
"""Convert surface area to square inches for calculation."""
|
||||
area = self.surface_area or 0
|
||||
uom = self.surface_area_uom
|
||||
if uom == 'sq_ft':
|
||||
return area * 144.0
|
||||
elif uom == 'sq_cm':
|
||||
return area * 0.155
|
||||
elif uom == 'sq_m':
|
||||
return area * 1550.0
|
||||
return area # sq_in
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Actions
|
||||
# -------------------------------------------------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', 'New') == 'New':
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code(
|
||||
'fp.quote.configurator') or 'New'
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_promote_to_direct_order(self):
|
||||
"""Sub 10 - push this quote onto a Direct Order draft.
|
||||
|
||||
Replaces the legacy 1-line-SO creation. The estimator picks an
|
||||
existing draft for the customer (consolidating multiple quotes
|
||||
onto one PO) or spawns a fresh draft. The quote stays in
|
||||
`draft` state until the Direct Order is confirmed; that confirm
|
||||
flips the quote to `won` and back-links the SO.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state != 'draft':
|
||||
raise UserError(_('Only draft quotes can be promoted.'))
|
||||
if self.sale_order_id:
|
||||
raise UserError(_(
|
||||
'A sale order has already been created for this quote.'
|
||||
))
|
||||
if not self.part_catalog_id:
|
||||
raise UserError(_(
|
||||
'Pick a part catalog entry before promoting this quote.'
|
||||
))
|
||||
if not self.recipe_id:
|
||||
raise UserError(_(
|
||||
'Pick a recipe before promoting this quote.'
|
||||
))
|
||||
existing_line = self.env['fp.direct.order.line'].search([
|
||||
('quote_id', '=', self.id),
|
||||
('wizard_id.state', '=', 'draft'),
|
||||
], limit=1)
|
||||
if existing_line:
|
||||
raise UserError(_(
|
||||
'This quote is already on draft "%s". Open that draft '
|
||||
'and remove its line if you want to move it elsewhere.'
|
||||
) % existing_line.wizard_id.name)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Add Quote to Direct Order'),
|
||||
'res_model': 'fp.quote.promote.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_quote_id': self.id},
|
||||
}
|
||||
|
||||
def action_create_quotation(self):
|
||||
"""LEGACY (Sub 10): kept for backwards-compat with any in-flight
|
||||
records or external triggers. New flow is via
|
||||
action_promote_to_direct_order.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state != 'draft':
|
||||
raise UserError(_('Only draft configurators can create quotations.'))
|
||||
if self.sale_order_id:
|
||||
raise UserError(_('A quotation has already been created for this configurator.'))
|
||||
|
||||
price = self.estimator_override_price or self.calculated_price
|
||||
|
||||
# Find or create a generic service product for plating
|
||||
product = self.env['product.product'].search(
|
||||
[('default_code', '=', 'FP-SERVICE')], limit=1
|
||||
)
|
||||
if not product:
|
||||
product = self.env['product.product'].create({
|
||||
'name': 'Plating Service',
|
||||
'default_code': 'FP-SERVICE',
|
||||
'type': 'service',
|
||||
'list_price': 0,
|
||||
'sale_ok': True,
|
||||
'purchase_ok': False,
|
||||
})
|
||||
|
||||
recipe_name = self.recipe_id.name if self.recipe_id else ''
|
||||
part_name = self.part_catalog_id.name if self.part_catalog_id else 'Custom Part'
|
||||
|
||||
so_vals = {
|
||||
'partner_id': self.partner_id.id,
|
||||
'x_fc_configurator_id': self.id,
|
||||
'x_fc_part_catalog_id': self.part_catalog_id.id if self.part_catalog_id else False,
|
||||
'x_fc_rush_order': self.rush_order,
|
||||
'x_fc_delivery_method': self.delivery_method,
|
||||
# Transfer RFQ / PO documents from configurator (if any)
|
||||
'x_fc_rfq_attachment_id': self.rfq_attachment_id.id if self.rfq_attachment_id else False,
|
||||
'x_fc_po_attachment_id': self.po_attachment_id.id if self.po_attachment_id else False,
|
||||
'x_fc_po_number': self.po_number_preliminary or False,
|
||||
'x_fc_po_received': bool(self.po_attachment_id),
|
||||
# Mirror the PO# into Odoo's standard client_order_ref so
|
||||
# the customer portal, every standard report, and every
|
||||
# third-party integration can read the PO without knowing
|
||||
# about our custom field.
|
||||
'client_order_ref': self.po_number_preliminary or False,
|
||||
'origin': self.name,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': '%s - %s (x%d)' % (recipe_name, part_name, self.quantity),
|
||||
'product_uom_qty': self.quantity,
|
||||
'price_unit': price / self.quantity if self.quantity else price,
|
||||
# Propagate part + recipe to the LINE.
|
||||
# fusion_plating_jobs._fp_auto_create_job filters lines
|
||||
# by x_fc_part_catalog_id; without it, no fp.job spawns.
|
||||
# Spec carry-over to SO line is handled by the quality
|
||||
# inherit (sale_order_line_inherit.create override).
|
||||
'x_fc_part_catalog_id': (
|
||||
self.part_catalog_id.id if self.part_catalog_id else False
|
||||
),
|
||||
'x_fc_process_variant_id': (
|
||||
self.recipe_id.id if self.recipe_id else False
|
||||
),
|
||||
})],
|
||||
}
|
||||
so = self.env['sale.order'].create(so_vals)
|
||||
self.write({
|
||||
'sale_order_id': so.id,
|
||||
'state': 'confirmed',
|
||||
'won_date': fields.Date.today(),
|
||||
})
|
||||
self.message_post(
|
||||
body=Markup(_('Sale Order <a href="/odoo/sale-order/%s">%s</a> created.')) % (so.id, so.name),
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': so.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
@api.onchange('upload_3d_file')
|
||||
def _onchange_upload_3d_file(self):
|
||||
"""When a 3D file is uploaded, auto-create/update part catalog entry."""
|
||||
if not self.upload_3d_file or not self.partner_id:
|
||||
return
|
||||
import base64
|
||||
import os
|
||||
|
||||
fname = self.upload_3d_filename or 'model.step'
|
||||
raw = base64.b64decode(self.upload_3d_file)
|
||||
|
||||
# Create attachment
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_3d_file,
|
||||
'mimetype': 'application/octet-stream',
|
||||
})
|
||||
|
||||
# Auto-create or update part catalog with revision tracking
|
||||
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
|
||||
if self.part_catalog_id and self.part_catalog_id.model_attachment_id:
|
||||
# Part already has a 3D model - create a new revision
|
||||
old_part = self.part_catalog_id
|
||||
old_part.is_latest_revision = False
|
||||
root = old_part.parent_part_id or old_part
|
||||
from .fp_part_catalog import _bump_revision_label
|
||||
new_label = _bump_revision_label(old_part.revision)
|
||||
new_part = old_part.copy({
|
||||
'revision': new_label,
|
||||
'revision_date': fields.Datetime.now(),
|
||||
'revision_note': f'Updated 3D model: {fname}',
|
||||
'parent_part_id': root.id,
|
||||
'is_latest_revision': True,
|
||||
'model_attachment_id': att.id,
|
||||
})
|
||||
self.part_catalog_id = new_part.id
|
||||
new_part._compute_surface_area_from_model()
|
||||
self.surface_area = new_part.surface_area
|
||||
self.surface_area_uom = new_part.surface_area_uom
|
||||
elif self.part_catalog_id:
|
||||
# Part exists but no 3D model yet - just attach
|
||||
self.part_catalog_id.model_attachment_id = att.id
|
||||
self.part_catalog_id._compute_surface_area_from_model()
|
||||
self.surface_area = self.part_catalog_id.surface_area
|
||||
self.surface_area_uom = self.part_catalog_id.surface_area_uom
|
||||
else:
|
||||
# No part catalog - create new entry
|
||||
part = self.env['fp.part.catalog'].create({
|
||||
'name': part_name,
|
||||
'partner_id': self.partner_id.id,
|
||||
'part_number': fname,
|
||||
'model_attachment_id': att.id,
|
||||
})
|
||||
self.part_catalog_id = part.id
|
||||
part._compute_surface_area_from_model()
|
||||
self.surface_area = part.surface_area
|
||||
self.surface_area_uom = part.surface_area_uom
|
||||
|
||||
# Post to chatter so user sees confirmation (only if record is saved)
|
||||
if self.id and not isinstance(self.id, models.NewId):
|
||||
self.sudo().message_post(
|
||||
body=Markup(_('3D model attached: <b>%s</b> - surface area: %.4f %s')) % (
|
||||
fname, self.surface_area, self.surface_area_uom or ''),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
# Clear the upload field (data is now on the part catalog)
|
||||
self.upload_3d_file = False
|
||||
self.upload_3d_filename = False
|
||||
|
||||
@api.onchange('upload_drawing')
|
||||
def _onchange_upload_drawing(self):
|
||||
"""When a drawing is uploaded, attach to part catalog entry."""
|
||||
if not self.upload_drawing or not self.partner_id:
|
||||
return
|
||||
|
||||
fname = self.upload_drawing_filename or 'drawing.pdf'
|
||||
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_drawing,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
|
||||
if self.part_catalog_id:
|
||||
self.part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [(4, att.id)],
|
||||
})
|
||||
part = self.part_catalog_id
|
||||
else:
|
||||
import os
|
||||
part_name = os.path.splitext(fname)[0].replace('_', ' ').replace('-', ' ').title()
|
||||
part = self.env['fp.part.catalog'].create({
|
||||
'name': part_name,
|
||||
'partner_id': self.partner_id.id,
|
||||
'part_number': fname,
|
||||
'drawing_attachment_ids': [(4, att.id)],
|
||||
})
|
||||
self.part_catalog_id = part.id
|
||||
|
||||
# Post to chatter so user sees confirmation (only if record is saved)
|
||||
if self.id and not isinstance(self.id, models.NewId):
|
||||
self.sudo().message_post(
|
||||
body=Markup(_('Drawing attached: <b>%s</b> (linked to part %s)')) % (
|
||||
fname, part.name),
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
self.upload_drawing = False
|
||||
self.upload_drawing_filename = False
|
||||
|
||||
@api.onchange('upload_rfq_file')
|
||||
def _onchange_upload_rfq_file(self):
|
||||
"""When an RFQ file is uploaded, create attachment + link it."""
|
||||
if not self.upload_rfq_file:
|
||||
return
|
||||
fname = self.upload_rfq_filename or 'rfq.pdf'
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_rfq_file,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.rfq_attachment_id = att.id
|
||||
self.upload_rfq_file = False
|
||||
self.upload_rfq_filename = False
|
||||
|
||||
@api.onchange('upload_po_file')
|
||||
def _onchange_upload_po_file(self):
|
||||
"""When a PO file is uploaded, create attachment + link it."""
|
||||
if not self.upload_po_file:
|
||||
return
|
||||
fname = self.upload_po_filename or 'po.pdf'
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_po_file,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.po_attachment_id = att.id
|
||||
self.upload_po_file = False
|
||||
self.upload_po_filename = False
|
||||
|
||||
def action_view_rfq(self):
|
||||
self.ensure_one()
|
||||
if not self.rfq_attachment_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.rfq_attachment_id.id,
|
||||
'title': _('RFQ - %s') % (self.rfq_attachment_id.name or ''),
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_po(self):
|
||||
self.ensure_one()
|
||||
if not self.po_attachment_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.po_attachment_id.id,
|
||||
'title': _('PO - %s') % (self.po_attachment_id.name or ''),
|
||||
},
|
||||
}
|
||||
|
||||
def action_recalculate_price(self):
|
||||
"""Recalculate surface area from 3D model and recompute price."""
|
||||
self.ensure_one()
|
||||
msg = _('No 3D model to calculate from.')
|
||||
# Recalculate surface area from part catalog's 3D model
|
||||
if self.part_catalog_id and self.part_catalog_id.model_attachment_id:
|
||||
result = self.part_catalog_id._compute_surface_area_from_model()
|
||||
if not result.get('error'):
|
||||
self.surface_area = self.part_catalog_id.surface_area
|
||||
self.surface_area_uom = self.part_catalog_id.surface_area_uom
|
||||
msg = _('Surface area: %.4f %s | Price: %.2f %s') % (
|
||||
self.surface_area, self.surface_area_uom or '',
|
||||
self.calculated_price, self.currency_id.symbol or '$',
|
||||
)
|
||||
else:
|
||||
msg = result['error']
|
||||
# Post result to chatter so user sees it after form reload
|
||||
self.message_post(
|
||||
body=msg,
|
||||
message_type='notification',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return False
|
||||
|
||||
def action_cancel(self):
|
||||
self.write({'state': 'cancelled'})
|
||||
|
||||
def action_reset_draft(self):
|
||||
self.write({'state': 'draft', 'won_date': False, 'lost_date': False})
|
||||
|
||||
def action_mark_lost(self):
|
||||
"""Move this quote to 'lost' state. Caller should populate
|
||||
`lost_reason` first - a simple validation enforces that."""
|
||||
for rec in self:
|
||||
if not rec.lost_reason:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Please set a Lost Reason before marking this quote lost.'
|
||||
))
|
||||
rec.write({
|
||||
'state': 'lost',
|
||||
'lost_date': fields.Date.today(),
|
||||
})
|
||||
rec.message_post(
|
||||
body=_('Quote marked lost - reason: %s') % dict(
|
||||
rec._fields['lost_reason'].selection
|
||||
).get(rec.lost_reason, rec.lost_reason),
|
||||
)
|
||||
|
||||
def action_mark_expired(self):
|
||||
for rec in self:
|
||||
rec.write({'state': 'expired', 'lost_date': fields.Date.today()})
|
||||
|
||||
def action_open_3d_fullscreen(self):
|
||||
"""Open the 3D model viewer in a full-screen dialog (same window)."""
|
||||
self.ensure_one()
|
||||
att = self.model_attachment_id
|
||||
if not att:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_3d_viewer_open',
|
||||
'params': {
|
||||
'attachment_id': att.id,
|
||||
'name': att.name or '',
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_sale_order(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': self.sale_order_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_view_part_catalog(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.part.catalog',
|
||||
'res_id': self.part_catalog_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_save_to_catalog(self):
|
||||
"""Push this quote's geometry/material edits back to the master part catalog.
|
||||
|
||||
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.'))
|
||||
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',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Saved to Catalog'),
|
||||
'message': _('Part catalog updated with quote geometry and substrate.'),
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_drawings(self):
|
||||
"""Open the first drawing in the PDF preview dialog (matches RFQ/PO behavior)."""
|
||||
self.ensure_one()
|
||||
if self.first_drawing_id:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.first_drawing_id.id,
|
||||
'title': _('Drawing - %s') % (self.first_drawing_id.name or ''),
|
||||
},
|
||||
}
|
||||
# No drawing: fall back to part catalog
|
||||
if not self.part_catalog_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Drawings - %s') % self.part_catalog_id.name,
|
||||
'res_model': 'fp.part.catalog',
|
||||
'res_id': self.part_catalog_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
# -*- 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 FpSaleAssembly(models.Model):
|
||||
"""Hierarchical kit / assembly on a sale order line.
|
||||
|
||||
A sale.order.line can carry child parts that make up an assembly.
|
||||
Useful when the customer sends a kit (e.g. housing + cover + two
|
||||
bolts) and each sub-part needs its own receive count + processing
|
||||
status but they all bill as one kit.
|
||||
|
||||
Phase D11 shipped minimal: just the data model. Full UX (hierarchy
|
||||
kanban, procurement tracking) is a follow-on.
|
||||
"""
|
||||
_name = 'fp.sale.assembly'
|
||||
_description = 'Fusion Plating - Sales Order Assembly'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Assembly Name', required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
sale_order_line_id = fields.Many2one(
|
||||
'sale.order.line', string='Parent SO Line',
|
||||
required=True, ondelete='cascade',
|
||||
)
|
||||
order_id = fields.Many2one(
|
||||
'sale.order', related='sale_order_line_id.order_id',
|
||||
store=True, readonly=True,
|
||||
)
|
||||
partner_id = fields.Many2one(
|
||||
related='order_id.partner_id', store=True, readonly=True,
|
||||
)
|
||||
line_ids = fields.One2many(
|
||||
'fp.sale.assembly.line', 'assembly_id',
|
||||
string='Assembly Lines',
|
||||
)
|
||||
ship_to = fields.Char(string='Ship To')
|
||||
count = fields.Integer(string='Count', default=1)
|
||||
procured_count = fields.Integer(
|
||||
string='Procured Count',
|
||||
compute='_compute_procured_count',
|
||||
)
|
||||
completed_at = fields.Datetime(string='Completed At')
|
||||
|
||||
@api.depends('line_ids.procured_qty')
|
||||
def _compute_procured_count(self):
|
||||
for rec in self:
|
||||
rec.procured_count = sum(rec.line_ids.mapped('procured_qty'))
|
||||
|
||||
|
||||
class FpSaleAssemblyLine(models.Model):
|
||||
_name = 'fp.sale.assembly.line'
|
||||
_description = 'Fusion Plating - Assembly Line'
|
||||
_order = 'sequence, id'
|
||||
|
||||
name = fields.Char(string='Part Number', required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
assembly_id = fields.Many2one(
|
||||
'fp.sale.assembly', required=True, ondelete='cascade',
|
||||
)
|
||||
part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part',
|
||||
)
|
||||
qty_per_assembly = fields.Float(string='Qty / Assembly', default=1.0)
|
||||
procured_qty = fields.Float(string='Procured Qty', default=0.0)
|
||||
@@ -1,82 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FpSaleDescriptionTemplate(models.Model):
|
||||
"""Saved description snippets - most often attached to a specific part.
|
||||
|
||||
Real-world usage: a plating shop keeps 3-5 canned descriptions PER
|
||||
PART because the same customer part runs with different masking,
|
||||
packaging, or spec-callout variations. With 3,500 parts and 5
|
||||
variants each, that's ~17,500 rows - so descriptions are scoped
|
||||
primarily by part, with optional fallback to customer / coating /
|
||||
global.
|
||||
|
||||
When a user creates a new order:
|
||||
1. If a part is picked, show templates for that part first.
|
||||
2. Else show templates for the customer.
|
||||
3. Else show templates for the coating.
|
||||
4. Else show global (generic) templates.
|
||||
"""
|
||||
_name = 'fp.sale.description.template'
|
||||
_description = 'Fusion Plating - Sale Order Line Description Template'
|
||||
_order = 'part_catalog_id, sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Template Name', required=True,
|
||||
help='Short name shown in the picker (e.g. "Standard masking", "With threaded holes masked").',
|
||||
)
|
||||
# Sub 2 - dual descriptions. Replaces the legacy `description` field
|
||||
# (dropped in Phase C / Task 27). Migration Step 3 duplicated the old
|
||||
# value into both columns; Step 6 drops the old column.
|
||||
internal_description = fields.Text(
|
||||
string='Internal Description',
|
||||
required=True,
|
||||
help='What the shop floor sees on the WO / traveler. Never on '
|
||||
'customer documents.',
|
||||
)
|
||||
customer_facing_description = fields.Text(
|
||||
string='Customer-Facing Description',
|
||||
required=True,
|
||||
help='Prints on the SO, invoice, packing slip, and BoL.',
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part',
|
||||
ondelete='cascade', index=True,
|
||||
help='If set, this description belongs to one specific customer '
|
||||
'part - it only appears in the picker when this part is on '
|
||||
'the order. Leave blank for generic fallback templates.',
|
||||
)
|
||||
# Related fields - surface the part's partner for search & grouping
|
||||
# without writing it twice.
|
||||
partner_id = fields.Many2one(
|
||||
'res.partner', string='Customer',
|
||||
related='part_catalog_id.partner_id', store=True, readonly=True,
|
||||
)
|
||||
# coating_config_id removed; templates can be customer- or part-
|
||||
# scoped. Spec-scoped templates are a future enhancement.
|
||||
tag = fields.Selection(
|
||||
[('standard', 'Standard'),
|
||||
('masking', 'Masking / Selective'),
|
||||
('rework', 'Rework / Strip'),
|
||||
('aerospace', 'Aerospace'),
|
||||
('nuclear', 'Nuclear'),
|
||||
('packaging', 'Special Packaging'),
|
||||
('other', 'Other')],
|
||||
string='Category', default='standard',
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
usage_count = fields.Integer(
|
||||
string='Used', default=0, readonly=True,
|
||||
help='Bumped each time this template is applied on an order line.',
|
||||
)
|
||||
|
||||
def _register_usage(self):
|
||||
"""Called by the wizard when the template is applied."""
|
||||
for rec in self:
|
||||
rec.usage_count = (rec.usage_count or 0) + 1
|
||||
@@ -1,315 +0,0 @@
|
||||
# -*- 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 FpSerial(models.Model):
|
||||
"""Serial number registry.
|
||||
|
||||
One record per "occurrence of a part on an order line". The same part
|
||||
ordered six months later gets a different serial. The serial is the
|
||||
common thread linking the SO line to the MO, Delivery, and Invoice
|
||||
records it spawns downstream.
|
||||
|
||||
Most serials are customer-supplied (pass-through from the customer's
|
||||
own end-user); a smaller share are shop-generated via the sequence.
|
||||
The registry is optional - SO lines can carry no serial at all.
|
||||
"""
|
||||
_name = 'fp.serial'
|
||||
_description = 'Fusion Plating - Serial Number'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'create_date desc, id desc'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(
|
||||
required=True,
|
||||
tracking=True,
|
||||
help='Customer-supplied serial (most common) or shop-generated '
|
||||
'sequence value. Typed-in values are accepted as-is.',
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True,
|
||||
default=lambda s: s.env.company,
|
||||
)
|
||||
sale_order_line_id = fields.Many2one(
|
||||
'sale.order.line',
|
||||
string='Source Sale Order Line',
|
||||
ondelete='set null',
|
||||
copy=False,
|
||||
tracking=True,
|
||||
)
|
||||
sale_order_id = fields.Many2one(
|
||||
related='sale_order_line_id.order_id',
|
||||
store=True,
|
||||
string='Sale Order',
|
||||
)
|
||||
customer_id = fields.Many2one(
|
||||
related='sale_order_line_id.order_id.partner_id',
|
||||
store=True,
|
||||
string='Customer',
|
||||
)
|
||||
part_id = fields.Many2one(
|
||||
related='sale_order_line_id.x_fc_part_catalog_id',
|
||||
store=True,
|
||||
string='Part',
|
||||
)
|
||||
notes = fields.Text(string='Notes')
|
||||
|
||||
# ==================================================================
|
||||
# Phase 2 (2026-04-28) - per-serial state machine
|
||||
# ==================================================================
|
||||
# Each physical part owns its own state independent of the parent
|
||||
# job's qty roll-ups. When 30 parts arrive on one SO line, all 30
|
||||
# serials are independently trackable through the shop. State
|
||||
# auto-promotes from job-step transitions (see fp.job.button_*
|
||||
# overrides in fusion_plating_jobs); operator can also flip a
|
||||
# single serial manually (e.g. mark serial #5 scrapped after a
|
||||
# plating defect).
|
||||
state = fields.Selection(
|
||||
[
|
||||
('received', 'Received'),
|
||||
('racked', 'Racked'),
|
||||
('in_process', 'In Process'),
|
||||
('inspected', 'Inspected'),
|
||||
('packed', 'Packed'),
|
||||
('shipped', 'Shipped'),
|
||||
('returned', 'Returned'),
|
||||
('scrapped', 'Scrapped'),
|
||||
('on_hold', 'On Hold'),
|
||||
],
|
||||
string='Status',
|
||||
default='received',
|
||||
required=True,
|
||||
tracking=True,
|
||||
index=True,
|
||||
help='Per-serial workflow state. Transitions auto-promote from '
|
||||
'parent job step events; supervisors can also flip a single '
|
||||
'serial manually (e.g. scrap one part out of a 30-part rack).',
|
||||
)
|
||||
state_color = fields.Integer(
|
||||
string='Status Color',
|
||||
compute='_compute_state_color',
|
||||
help='Kanban / many2many_tags color index derived from state.',
|
||||
)
|
||||
last_state_change = fields.Datetime(
|
||||
string='Last Status Change',
|
||||
readonly=True,
|
||||
help='Timestamp of the most recent state transition. Auto-stamped '
|
||||
'by every state-changing action.',
|
||||
)
|
||||
scrap_reason = fields.Text(
|
||||
string='Scrap / Return Reason',
|
||||
help='Captured when state transitions to scrapped or returned. '
|
||||
'Surfaces on per-serial CoC entries (Phase 4).',
|
||||
)
|
||||
|
||||
# Reverse from move log - Phase 3 will populate this directly when
|
||||
# operators record per-serial moves on the tablet. Defined here so
|
||||
# views can already render the count column.
|
||||
move_count = fields.Integer(
|
||||
compute='_compute_move_count',
|
||||
string='# Moves',
|
||||
)
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_state_color(self):
|
||||
# Odoo color-index mapping aligned with the standard kanban palette.
|
||||
# 0 default · 1 red · 2 orange · 3 yellow · 4 green · 5 purple ·
|
||||
# 6 magenta · 7 sky · 8 blue · 9 brown · 10 grey · 11 olive
|
||||
mapping = {
|
||||
'received': 8, # blue - fresh
|
||||
'racked': 7, # sky - staged
|
||||
'in_process': 3, # yellow - running
|
||||
'inspected': 11, # olive - passed QC, ready to ship
|
||||
'packed': 4, # green - boxed
|
||||
'shipped': 4, # green - out the door
|
||||
'returned': 2, # orange - back from customer
|
||||
'scrapped': 1, # red
|
||||
'on_hold': 1, # red - quality issue
|
||||
}
|
||||
for rec in self:
|
||||
rec.state_color = mapping.get(rec.state, 0)
|
||||
|
||||
@api.depends_context('uid')
|
||||
def _compute_move_count(self):
|
||||
# Phase 3 will replace this with a real reverse link via
|
||||
# fp.job.step.move.serial_ids (M2M added next phase).
|
||||
# Defined here as 0-stub so views don't break on upgrade.
|
||||
for rec in self:
|
||||
rec.move_count = 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State transitions - log each one to chatter and stamp last_state_change
|
||||
# ------------------------------------------------------------------
|
||||
def _set_state(self, new_state, message=None):
|
||||
"""Internal helper. Validates the source state, flips, stamps,
|
||||
chatters. Raises UserError on illegal transitions."""
|
||||
labels = dict(self._fields['state'].selection)
|
||||
for rec in self:
|
||||
old = rec.state
|
||||
if old == new_state:
|
||||
continue
|
||||
# Terminal states are write-protected (operator must explicitly
|
||||
# un-set via action_reopen if they really need to).
|
||||
if old in ('shipped', 'scrapped') and new_state not in ('returned', 'received'):
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Serial %(name)s is %(old)s - cannot transition to '
|
||||
'%(new)s. Use Reopen if this is a correction.'
|
||||
) % {
|
||||
'name': rec.name,
|
||||
'old': labels.get(old, old),
|
||||
'new': labels.get(new_state, new_state),
|
||||
})
|
||||
rec.state = new_state
|
||||
rec.last_state_change = fields.Datetime.now()
|
||||
body = message or _('Status %(old)s → %(new)s by %(user)s') % {
|
||||
'old': labels.get(old, old),
|
||||
'new': labels.get(new_state, new_state),
|
||||
'user': self.env.user.name,
|
||||
}
|
||||
rec.message_post(body=body)
|
||||
return True
|
||||
|
||||
def action_mark_racked(self):
|
||||
return self._set_state('racked')
|
||||
|
||||
def action_mark_in_process(self):
|
||||
return self._set_state('in_process')
|
||||
|
||||
def action_mark_inspected(self):
|
||||
return self._set_state('inspected')
|
||||
|
||||
def action_mark_packed(self):
|
||||
return self._set_state('packed')
|
||||
|
||||
def action_mark_shipped(self):
|
||||
return self._set_state('shipped')
|
||||
|
||||
def action_mark_returned(self):
|
||||
return self._set_state('returned')
|
||||
|
||||
def action_mark_on_hold(self):
|
||||
return self._set_state('on_hold')
|
||||
|
||||
def action_release_hold(self):
|
||||
"""Lift on_hold and return the serial to in_process. Used when a
|
||||
hold is resolved without scrap (e.g. visual blemish was actually
|
||||
within tolerance after re-inspection)."""
|
||||
return self._set_state('in_process')
|
||||
|
||||
def action_mark_scrapped(self):
|
||||
"""Scrap a single serial. Operator should fill scrap_reason next
|
||||
- view enforces it via a wizard form. Phase 3 hooks this into
|
||||
the move log so the parent job's qty_scrapped auto-increments."""
|
||||
return self._set_state('scrapped')
|
||||
|
||||
def action_reopen(self):
|
||||
"""Manager-only override - un-pin a terminal state when a
|
||||
correction is needed (e.g. wrong serial marked shipped). Audit
|
||||
trail preserved via chatter; never silently rewrites history."""
|
||||
for rec in self:
|
||||
if not self.env.user.has_group('fusion_plating.group_fp_manager'):
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Only the Plating Manager group can reopen a terminal '
|
||||
'serial state. Contact your shop manager.'
|
||||
))
|
||||
return self._set_state('in_process', message=_(
|
||||
'Serial reopened by %s - terminal state reverted for correction.'
|
||||
) % self.env.user.name)
|
||||
|
||||
# Reverse link to invoice lines - safe here because account.move.line
|
||||
# lives in this same module. Production (mrp) and delivery (logistics)
|
||||
# reverse links are defined in their own modules' fp_serial inherits
|
||||
# to keep module load order consistent.
|
||||
invoice_line_ids = fields.One2many(
|
||||
'account.move.line', 'x_fc_serial_id',
|
||||
string='Invoice Lines',
|
||||
)
|
||||
invoice_ids = fields.Many2many(
|
||||
'account.move',
|
||||
compute='_compute_invoice_ids',
|
||||
string='Invoices',
|
||||
)
|
||||
|
||||
invoice_count = fields.Integer(compute='_compute_counts')
|
||||
# production_count / delivery_count are declared in the inheriting
|
||||
# modules (bridge_mrp / logistics) so the O2Ms exist alongside them.
|
||||
|
||||
_sql_constraints = [
|
||||
('fp_serial_name_company_uniq',
|
||||
'unique(company_id, name)',
|
||||
'Serial number must be unique within the company.'),
|
||||
]
|
||||
|
||||
# ---- Computes ------------------------------------------------------------
|
||||
|
||||
@api.depends('invoice_line_ids.move_id')
|
||||
def _compute_counts(self):
|
||||
# Base compute sets invoice_count only. bridge_mrp + logistics
|
||||
# override this to also populate production_count / delivery_count.
|
||||
for rec in self:
|
||||
rec.invoice_count = len(rec.invoice_line_ids.mapped('move_id'))
|
||||
|
||||
@api.depends('invoice_line_ids.move_id')
|
||||
def _compute_invoice_ids(self):
|
||||
for rec in self:
|
||||
rec.invoice_ids = rec.invoice_line_ids.mapped('move_id')
|
||||
|
||||
# ---- Actions -------------------------------------------------------------
|
||||
|
||||
def action_view_sale_order(self):
|
||||
self.ensure_one()
|
||||
if not self.sale_order_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'res_id': self.sale_order_id.id,
|
||||
'view_mode': 'form',
|
||||
}
|
||||
|
||||
def action_view_productions(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Manufacturing Orders'),
|
||||
'res_model': 'mrp.production',
|
||||
'domain': [('id', 'in', self.production_ids.ids)],
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
def action_view_deliveries(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Deliveries'),
|
||||
'res_model': 'fusion.plating.delivery',
|
||||
'domain': [('id', 'in', self.delivery_ids.ids)],
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
def action_view_invoices(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Invoices'),
|
||||
'res_model': 'account.move',
|
||||
'domain': [('id', 'in', self.invoice_ids.ids)],
|
||||
'view_mode': 'list,form',
|
||||
}
|
||||
|
||||
def action_view_part(self):
|
||||
self.ensure_one()
|
||||
if not self.part_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.part.catalog',
|
||||
'res_id': self.part_id.id,
|
||||
'view_mode': 'form',
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
# -*- 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 FpSoJobSort(models.Model):
|
||||
"""A user-defined grouping bucket for sale orders ("Job Sorting").
|
||||
|
||||
Same pattern as `fusion.plating.tank.section` - every shop slices its
|
||||
SO backlog differently (by customer programme, by priority, by
|
||||
fabricator group, by week, etc.). Sections are free-form, renameable,
|
||||
quick-creatable from the M2O dropdown, and let users group the SO
|
||||
list with fold/expand sections.
|
||||
"""
|
||||
_name = 'fp.so.job.sort'
|
||||
_description = 'Fusion Plating - Sale Order Job Sort'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(
|
||||
string='Job Sorting',
|
||||
required=True,
|
||||
translate=True,
|
||||
)
|
||||
sequence = fields.Integer(string='Sequence', default=10)
|
||||
color = fields.Integer(string='Color', default=0)
|
||||
fold = fields.Boolean(
|
||||
string='Folded by Default',
|
||||
help='When set, this section appears collapsed in the grouped '
|
||||
'SO list so the body rows are hidden until expanded.',
|
||||
)
|
||||
description = fields.Text(string='Description', translate=True)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
sale_order_ids = fields.One2many(
|
||||
'sale.order', 'x_fc_job_sort_id', string='Sale Orders',
|
||||
)
|
||||
sale_order_count = fields.Integer(
|
||||
compute='_compute_sale_order_count',
|
||||
)
|
||||
|
||||
@api.depends('sale_order_ids')
|
||||
def _compute_sale_order_count(self):
|
||||
for rec in self:
|
||||
rec.sale_order_count = len(rec.sale_order_ids)
|
||||
|
||||
def action_view_sale_orders(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'sale.order',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('x_fc_job_sort_id', '=', self.id)],
|
||||
'context': {'default_x_fc_job_sort_id': self.id},
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
# -*- 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, models
|
||||
|
||||
|
||||
class ProductPricelist(models.Model):
|
||||
"""Express Orders currency-picker enhancement (C1 / 2026-05-26).
|
||||
|
||||
When rendered with `fp_express_currency_picker=True` in context (set
|
||||
by the Express Orders form's pricelist_id field), prefix the display
|
||||
name with the currency code so the dropdown reads:
|
||||
|
||||
CAD - Public Pricelist (CAD)
|
||||
USD - Westin USA Pricelist
|
||||
EUR - Public Pricelist (EUR)
|
||||
|
||||
Elsewhere in Odoo (partner form, sale.order, settings), the standard
|
||||
pricelist display name is unchanged.
|
||||
"""
|
||||
_inherit = 'product.pricelist'
|
||||
|
||||
@api.depends('name', 'currency_id')
|
||||
@api.depends_context('fp_express_currency_picker')
|
||||
def _compute_display_name(self):
|
||||
super()._compute_display_name()
|
||||
if self.env.context.get('fp_express_currency_picker'):
|
||||
for pl in self:
|
||||
if pl.currency_id and pl.currency_id.name not in (pl.display_name or ''):
|
||||
pl.display_name = f"{pl.currency_id.name} - {pl.name}"
|
||||
@@ -1,76 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
|
||||
x_fc_part_catalog_ids = fields.One2many(
|
||||
'fp.part.catalog', 'partner_id',
|
||||
string='Part Catalog',
|
||||
)
|
||||
x_fc_part_count = fields.Integer(
|
||||
string='Parts',
|
||||
compute='_compute_part_count',
|
||||
)
|
||||
# Default lead-time band for new Express Orders. Set once per
|
||||
# customer in their Plating profile; auto-copies onto every new
|
||||
# Express Order via the partner-onchange on fp.direct.order.wizard.
|
||||
x_fc_default_lead_time_min_days = fields.Integer(
|
||||
string='Default Lead Time Min (days)',
|
||||
default=0,
|
||||
help='Pre-fills the Lead Time Min field on new Express Orders '
|
||||
'for this customer. Operator can override per-order.',
|
||||
)
|
||||
x_fc_default_lead_time_max_days = fields.Integer(
|
||||
string='Default Lead Time Max (days)',
|
||||
default=0,
|
||||
help='Pre-fills the Lead Time Max field on new Express Orders '
|
||||
'for this customer. Operator can override per-order.',
|
||||
)
|
||||
|
||||
def _compute_part_count(self):
|
||||
for partner in self:
|
||||
partner.x_fc_part_count = self.env['fp.part.catalog'].search_count([
|
||||
('partner_id', '=', partner.id),
|
||||
('is_latest_revision', '=', True),
|
||||
])
|
||||
|
||||
def action_view_parts(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': f'Parts - {self.name}',
|
||||
'res_model': 'fp.part.catalog',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('partner_id', '=', self.id), ('is_latest_revision', '=', True)],
|
||||
'context': {'default_partner_id': self.id},
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_fp_import_parts(self):
|
||||
"""Open the CSV import wizard with this partner pre-selected."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Import Parts from CSV'),
|
||||
'res_model': 'fp.part.catalog.import.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_partner_id': self.id},
|
||||
}
|
||||
|
||||
def action_fp_new_direct_order(self):
|
||||
"""Open the Direct Order wizard with this partner pre-selected."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('New Direct Order'),
|
||||
'res_model': 'fp.direct.order.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {'default_partner_id': self.id},
|
||||
}
|
||||
@@ -1,904 +0,0 @@
|
||||
# -*- 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
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
x_fc_configurator_id = fields.Many2one('fp.quote.configurator', string='Configurator', copy=False)
|
||||
x_fc_part_catalog_id = fields.Many2one('fp.part.catalog', string='Part')
|
||||
# x_fc_coating_config_id removed; specs live on customer.spec via
|
||||
# the line-level x_fc_customer_spec_id (added by quality inherit).
|
||||
x_fc_po_number = fields.Char(string='Customer PO #', tracking=True)
|
||||
x_fc_po_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='PO Document', tracking=True,
|
||||
)
|
||||
x_fc_po_received = fields.Boolean(string='PO Received', tracking=True)
|
||||
x_fc_rfq_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='RFQ Document', tracking=True,
|
||||
help="Customer's original Request for Quote document.",
|
||||
)
|
||||
upload_rfq_file = fields.Binary(string='Upload RFQ', attachment=False)
|
||||
upload_rfq_filename = fields.Char(string='RFQ Filename')
|
||||
upload_po_file = fields.Binary(string='Upload PO', attachment=False)
|
||||
upload_po_filename = fields.Char(string='PO Filename')
|
||||
x_fc_po_override = fields.Boolean(string='PO Override',
|
||||
help='Manager override - proceed without formal PO (handshake deal).')
|
||||
x_fc_po_override_reason = fields.Text(string='Override Reason')
|
||||
# Estimator-level "PO is coming later" flag. Unlike PO Override
|
||||
# (permanent, manager-only), this one is time-boxed: the order
|
||||
# confirms with no PO yet, but a chase activity is scheduled for
|
||||
# po_expected_date so sales chases the customer for the paperwork.
|
||||
x_fc_po_pending = fields.Boolean(
|
||||
string='PO Pending',
|
||||
tracking=True,
|
||||
help='Customer will provide the PO later. Confirms the order '
|
||||
'without a PO number, schedules a chase activity, and '
|
||||
'shows a "PO Pending" ribbon on the form. Toggle off once '
|
||||
'the real PO arrives and you\'ve entered it below.',
|
||||
)
|
||||
x_fc_po_expected_date = fields.Date(
|
||||
string='PO Expected By',
|
||||
tracking=True,
|
||||
help='Date the customer promised to send the PO. A follow-up '
|
||||
'activity is scheduled for this date when the order is '
|
||||
'confirmed with PO Pending set.',
|
||||
)
|
||||
x_fc_invoice_strategy = fields.Selection(
|
||||
[('deposit', 'Deposit'), ('progress', 'Progress Billing'),
|
||||
('net_terms', 'Net Terms'), ('cod_prepay', 'COD / Prepay')],
|
||||
string='Invoice Strategy', tracking=True,
|
||||
)
|
||||
x_fc_deposit_percent = fields.Float(string='Deposit %',
|
||||
help='Deposit percentage if strategy is Deposit.')
|
||||
x_fc_progress_initial_percent = fields.Float(
|
||||
string='Progress - Initial %',
|
||||
default=50.0,
|
||||
help='First-phase percentage for Progress Billing strategy. '
|
||||
'Billed on SO confirmation; remainder billed on delivery.',
|
||||
)
|
||||
x_fc_final_invoice_id = fields.Many2one(
|
||||
'account.move', string='Final Invoice', copy=False, readonly=True,
|
||||
help='Final invoice auto-created on delivery for Progress Billing / '
|
||||
'Net Terms strategies.',
|
||||
)
|
||||
x_fc_rush_order = fields.Boolean(string='Rush Order', tracking=True)
|
||||
|
||||
# Lead Time (Phase D11) - promised production window in business
|
||||
# days. Operators enter a min/max range (e.g. 3-5 days or 7-10 days)
|
||||
# so we render a proper expectation on the SO confirmation instead
|
||||
# of the binary Standard/Rush we had before. Both fields default to
|
||||
# 0 - `x_fc_lead_time_display` computes the right human-readable
|
||||
# string (range / single value / Rush / Standard) for the PDF.
|
||||
x_fc_lead_time_min_days = fields.Integer(
|
||||
string='Lead Time Min (days)', tracking=True,
|
||||
help='Lower bound of the promised production lead time, in '
|
||||
'business days. Leave 0 if not committed.',
|
||||
)
|
||||
x_fc_lead_time_max_days = fields.Integer(
|
||||
string='Lead Time Max (days)', tracking=True,
|
||||
help='Upper bound of the promised production lead time, in '
|
||||
'business days. Leave 0 if not committed.',
|
||||
)
|
||||
x_fc_lead_time_display = fields.Char(
|
||||
string='Lead Time',
|
||||
compute='_compute_lead_time_display',
|
||||
help='Human-readable lead time string for the SO confirmation PDF.',
|
||||
)
|
||||
x_fc_delivery_method = fields.Selection(
|
||||
[('local_delivery', 'Local Delivery'), ('shipping_partner', 'Shipping Partner'),
|
||||
('customer_pickup', 'Customer Pickup')],
|
||||
string='Delivery Method', tracking=True,
|
||||
)
|
||||
x_fc_receiving_status = fields.Selection(
|
||||
[('not_received', 'Not Received'), ('partial', 'Partial'),
|
||||
('received', 'Received')],
|
||||
string='Receiving Status', default='not_received', tracking=True,
|
||||
help='State of the linked fp.receiving record(s). Inspection is '
|
||||
"no longer a receiving state - Sub 8 moved part inspection "
|
||||
'into the recipe (racking step), so receiving stops at '
|
||||
'"received" (boxes counted, staged, closed).',
|
||||
)
|
||||
|
||||
# ---- Direct Order rewrite (Phase A) ----
|
||||
x_fc_customer_job_number = fields.Char(
|
||||
string='Customer Job #',
|
||||
help="Customer's internal job number for cross-referencing.",
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_job_sort_id = fields.Many2one(
|
||||
'fp.so.job.sort',
|
||||
string='Job Sorting',
|
||||
ondelete='set null',
|
||||
tracking=True,
|
||||
help='Free-form bucket that groups this SO in the "Sale Orders '
|
||||
'by Sorting" list view. Quick-create from the dropdown - '
|
||||
'each shop slices its backlog differently (customer programme, '
|
||||
'priority, week, etc.).',
|
||||
)
|
||||
|
||||
# ---- Express Orders header-level (2026-05-26) ----
|
||||
# 2026-05-27: changed from Char to Many2One - Material/Process Tag
|
||||
# IS the order's recipe. Auto-applies to every line at confirm time.
|
||||
x_fc_material_process = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Material / Process Tag',
|
||||
domain="[('node_type', '=', 'recipe')]",
|
||||
help='Order-level recipe - auto-applies to every line. Individual '
|
||||
'lines can still override via x_fc_process_variant_id.',
|
||||
)
|
||||
x_fc_internal_notes = fields.Text(
|
||||
string='Order-Level Internal Notes',
|
||||
help='Notes visible only to the estimator / planner / shop. Never '
|
||||
'prints on customer-facing PDFs. Distinct from sale.order.note '
|
||||
'which IS customer-facing (Terms & Conditions).',
|
||||
)
|
||||
x_fc_print_terms = fields.Boolean(
|
||||
string='Print Terms on Customer Documents',
|
||||
default=True,
|
||||
help='When False, the Terms & Conditions (sale.order.note) is '
|
||||
'suppressed on quote / SO / invoice / packing slip PDFs.',
|
||||
)
|
||||
x_fc_tooling_charge = fields.Monetary(
|
||||
string='Tooling Charge',
|
||||
currency_field='currency_id',
|
||||
help='Optional one-time tooling fee from the Express Orders form. '
|
||||
'Surfaced on the invoice as a separate line.',
|
||||
)
|
||||
x_fc_planned_start_date = fields.Date(
|
||||
string='Planned Start Date', tracking=True,
|
||||
)
|
||||
x_fc_internal_deadline = fields.Date(
|
||||
string='Internal Deadline', tracking=True,
|
||||
)
|
||||
x_fc_is_blanket_order = fields.Boolean(
|
||||
string='Is Blanket Sales Order',
|
||||
help='Blanket orders release parts in quantities over time, '
|
||||
'often with a negotiated price and a fixed expiry.',
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_block_partial_shipments = fields.Boolean(
|
||||
string='Block Partial Shipments',
|
||||
help='If set, the order must ship all-or-nothing. '
|
||||
'Partial pickings are blocked.',
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- Phase D: SO detail view polish ----
|
||||
x_fc_external_note = fields.Html(
|
||||
string='External Notes',
|
||||
help='Customer-visible notes. Appear on the SO acknowledgement '
|
||||
'and customer portal.',
|
||||
)
|
||||
x_fc_internal_note = fields.Html(
|
||||
string='Internal Notes',
|
||||
help='Internal-only notes for the estimator / planner / shop floor.',
|
||||
)
|
||||
x_fc_ship_via = fields.Char(
|
||||
string='Ship Via',
|
||||
help='Carrier or delivery method name (UPS, FedEx, customer pickup, etc.).',
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_contact_phone = fields.Char(
|
||||
related='partner_id.phone', string='Contact Phone', readonly=True,
|
||||
)
|
||||
x_fc_deadline_countdown = fields.Char(
|
||||
string='Deadline',
|
||||
compute='_compute_deadline_countdown',
|
||||
)
|
||||
# Drives the colour of the Deadline column. Computed in the same pass
|
||||
# as x_fc_deadline_countdown so the buckets always agree with the
|
||||
# human-readable countdown string.
|
||||
x_fc_deadline_urgency = fields.Selection(
|
||||
[('overdue', 'Overdue'),
|
||||
('urgent', 'Due within 2 days'),
|
||||
('safe', 'More than 2 days')],
|
||||
string='Deadline Urgency',
|
||||
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',
|
||||
)
|
||||
x_fc_margin_percent = fields.Float(
|
||||
string='Margin %',
|
||||
compute='_compute_margin',
|
||||
)
|
||||
x_fc_margin_available = fields.Boolean(
|
||||
string='Margin Available',
|
||||
compute='_compute_margin',
|
||||
help='False when no order line has a costed coating - the '
|
||||
'margin fields should render "n/a" in the UI.',
|
||||
)
|
||||
|
||||
# NB. The compute lives in fusion_plating_bridge_mrp. We keep a
|
||||
# stub field here so configurator's SO view (loaded before
|
||||
# bridge_mrp on `-u`) can reference the field by name. bridge_mrp's
|
||||
# `fields.Integer(compute=…)` redeclaration fills in the compute on
|
||||
# top of this stub during its own load pass.
|
||||
x_fc_workorder_count = fields.Integer(string='Work Orders')
|
||||
|
||||
# Smart-button visibility helpers (post-Sub 11). The BOM Items kanban
|
||||
# is only useful when the SO carries 2+ distinct parts; the By Job
|
||||
# Group kanban is only useful when at least one line is tagged with
|
||||
# x_fc_wo_group_tag. Default-hidden otherwise so the smart-button
|
||||
# row stays clean for the typical single-part SO.
|
||||
x_fc_distinct_part_count = fields.Integer(
|
||||
string='# Distinct Parts',
|
||||
compute='_compute_smart_button_visibility',
|
||||
)
|
||||
x_fc_has_wo_group_tag = fields.Boolean(
|
||||
string='Has Job Group Tag',
|
||||
compute='_compute_smart_button_visibility',
|
||||
)
|
||||
x_fc_wo_group_count = fields.Integer(
|
||||
string='# Job Groups',
|
||||
compute='_compute_smart_button_visibility',
|
||||
help='Distinct x_fc_wo_group_tag values across this SO\'s lines.',
|
||||
)
|
||||
|
||||
@api.depends('order_line.x_fc_part_catalog_id',
|
||||
'order_line.x_fc_wo_group_tag')
|
||||
def _compute_smart_button_visibility(self):
|
||||
for rec in self:
|
||||
parts = rec.order_line.mapped('x_fc_part_catalog_id')
|
||||
rec.x_fc_distinct_part_count = len(parts)
|
||||
tags = {
|
||||
t for t in rec.order_line.mapped('x_fc_wo_group_tag') if t
|
||||
}
|
||||
rec.x_fc_has_wo_group_tag = bool(tags)
|
||||
rec.x_fc_wo_group_count = len(tags)
|
||||
|
||||
# Sub 9 - process variant summary across order lines. Renders one
|
||||
# variant label when all lines share one, otherwise "Mixed (N)".
|
||||
x_fc_process_summary = fields.Char(
|
||||
string='Process',
|
||||
compute='_compute_process_summary',
|
||||
help='Process variant(s) used by this order. Drives WO generation.',
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'order_line.x_fc_process_variant_id',
|
||||
'order_line.x_fc_part_catalog_id.default_process_id',
|
||||
)
|
||||
def _compute_process_summary(self):
|
||||
for so in self:
|
||||
variants = []
|
||||
for line in so.order_line:
|
||||
if not line.x_fc_part_catalog_id:
|
||||
continue # non-plating line
|
||||
variant = (line.x_fc_process_variant_id
|
||||
or line.x_fc_part_catalog_id.default_process_id)
|
||||
if variant and variant not in variants:
|
||||
variants.append(variant)
|
||||
if not variants:
|
||||
so.x_fc_process_summary = False
|
||||
elif len(variants) == 1:
|
||||
v = variants[0]
|
||||
so.x_fc_process_summary = (
|
||||
v.variant_label or v.name or 'Default'
|
||||
)
|
||||
else:
|
||||
so.x_fc_process_summary = 'Mixed (%d variants)' % len(variants)
|
||||
|
||||
# ---- Phase E: list view helpers ----
|
||||
x_fc_wo_completion = fields.Char(
|
||||
string='WO Progress',
|
||||
compute='_compute_wo_completion',
|
||||
help='Ratio of completed work orders, shown as "3/5 done".',
|
||||
)
|
||||
x_fc_invoiced_amount = fields.Monetary(
|
||||
string='Invoiced',
|
||||
compute='_compute_invoiced_amount',
|
||||
currency_field='currency_id',
|
||||
)
|
||||
# Single "Job Status" pill rendered in the SO list. Pipeline order:
|
||||
# Draft → Awaiting Parts → Parts Partial → Ready to Start →
|
||||
# <Step Name> → Ready to Ship → Ship Booked → In Transit →
|
||||
# Delivered → Invoiced → Paid → Cancelled.
|
||||
# Rendered as an Html field so each kind can carry its own tint via
|
||||
# an .fp-kind-* class - Bootstrap's 5 decoration-* slots aren't
|
||||
# enough to give every phase a distinct colour. SCSS bundle at
|
||||
# static/src/scss/fp_job_status_pill.scss owns the colour map.
|
||||
x_fc_fp_job_status = fields.Html(
|
||||
string='Job Status',
|
||||
compute='_compute_fp_job_status',
|
||||
sanitize=False,
|
||||
help='Single at-a-glance pill that advances through the order '
|
||||
'lifecycle: receiving → WO progress → shipping → invoicing.',
|
||||
)
|
||||
x_fc_fp_job_status_kind = fields.Selection(
|
||||
[('muted', 'Draft (grey)'),
|
||||
('warning', 'Awaiting / Partial (amber)'),
|
||||
('primary', 'Ready / Milestone (purple)'),
|
||||
('info', 'Active Work (blue)'),
|
||||
('shipping', 'Shipping (cyan)'),
|
||||
('delivered', 'Delivered (teal)'),
|
||||
('invoiced', 'Invoiced (lime)'),
|
||||
('paid', 'Paid (green bold)'),
|
||||
('danger', 'Cancelled (red)')],
|
||||
string='Job Status Kind',
|
||||
compute='_compute_fp_job_status',
|
||||
help='Colour category that backs the Job Status pill - also '
|
||||
'usable for filtering / grouping in the list search panel.',
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'state',
|
||||
'x_fc_receiving_status',
|
||||
'x_fc_wo_completion',
|
||||
'invoice_ids.state',
|
||||
'invoice_ids.payment_state',
|
||||
'invoice_ids.move_type',
|
||||
)
|
||||
def _compute_fp_job_status(self):
|
||||
from markupsafe import Markup as _Markup
|
||||
from markupsafe import escape as _escape
|
||||
for so in self:
|
||||
label, kind = self._fp_resolve_job_status(so)
|
||||
so.x_fc_fp_job_status_kind = kind
|
||||
so.x_fc_fp_job_status = _Markup(
|
||||
'<span class="fp-job-status fp-kind-%s">%s</span>'
|
||||
) % (_Markup(kind), _escape(label))
|
||||
|
||||
@staticmethod
|
||||
def _fp_resolve_job_status(so):
|
||||
# Terminal SO states first.
|
||||
if so.state == 'cancel':
|
||||
return ('Cancelled', 'danger')
|
||||
if so.state in ('draft', 'sent'):
|
||||
return ('Draft', 'muted')
|
||||
|
||||
# Invoice phase (terminal positive states).
|
||||
posted = so.invoice_ids.filtered(
|
||||
lambda m: m.state == 'posted'
|
||||
and m.move_type in ('out_invoice', 'out_refund')
|
||||
)
|
||||
if posted and all(
|
||||
m.payment_state in ('paid', 'in_payment') for m in posted
|
||||
):
|
||||
return ('Paid', 'paid')
|
||||
|
||||
# Shipping phase signals - read once.
|
||||
ship_status = None
|
||||
if 'x_fc_receiving_ids' in so._fields:
|
||||
for r in so.x_fc_receiving_ids:
|
||||
ship = (
|
||||
r.x_fc_outbound_shipment_id
|
||||
if 'x_fc_outbound_shipment_id' in r._fields else False
|
||||
)
|
||||
if not ship:
|
||||
continue
|
||||
# Latch the most-advanced status across all receivings.
|
||||
rank = {None: 0, 'booked': 1, 'in_transit': 2, 'delivered': 3}
|
||||
cur = (
|
||||
'delivered' if ship.status == 'delivered'
|
||||
else 'in_transit' if ship.status == 'shipped'
|
||||
else 'booked' if ship.status in ('confirmed', 'draft')
|
||||
else None
|
||||
)
|
||||
if rank[cur] > rank[ship_status]:
|
||||
ship_status = cur
|
||||
|
||||
if posted and ship_status == 'delivered':
|
||||
return ('Invoiced', 'invoiced')
|
||||
if ship_status == 'delivered':
|
||||
return ('Delivered', 'delivered')
|
||||
if ship_status == 'in_transit':
|
||||
return ('In Transit', 'shipping')
|
||||
|
||||
# WO phase - figure out total steps and the current step name.
|
||||
tot = 0
|
||||
current_step_name = None
|
||||
Job = so.env.get('fp.job')
|
||||
if Job is not None and so.name:
|
||||
jobs = Job.sudo().search([('origin', '=', so.name)])
|
||||
if jobs:
|
||||
steps = jobs.mapped('step_ids').sorted(
|
||||
lambda s: (s.job_id.id, s.sequence)
|
||||
)
|
||||
tot = len(steps)
|
||||
# Priority: in_progress → paused → next ready/pending.
|
||||
current = (
|
||||
steps.filtered(lambda s: s.state == 'in_progress')[:1]
|
||||
or steps.filtered(lambda s: s.state == 'paused')[:1]
|
||||
or steps.filtered(lambda s: s.state in ('ready', 'pending'))[:1]
|
||||
)
|
||||
current_step_name = current.name if current else None
|
||||
|
||||
all_steps_done = tot > 0 and current_step_name is None
|
||||
|
||||
if all_steps_done:
|
||||
if ship_status == 'booked':
|
||||
return ('Ship Booked', 'shipping')
|
||||
return ('Ready to Ship', 'primary')
|
||||
if current_step_name:
|
||||
return (current_step_name, 'info')
|
||||
|
||||
# Receiving phase (no WO yet).
|
||||
recv = so.x_fc_receiving_status or 'not_received'
|
||||
if recv == 'received':
|
||||
return ('Ready to Start', 'primary')
|
||||
if recv == 'partial':
|
||||
return ('Parts Partial', 'warning')
|
||||
return ('Awaiting Parts', 'warning')
|
||||
|
||||
@api.depends('x_fc_lead_time_min_days', 'x_fc_lead_time_max_days', 'x_fc_rush_order')
|
||||
def _compute_lead_time_display(self):
|
||||
"""Render the lead time as a human-readable string for reports.
|
||||
|
||||
Priority order:
|
||||
- Real min/max range set → "X-Y days" or "X days"
|
||||
- Range not set, rush_order on → "Rush"
|
||||
- Otherwise → "Standard"
|
||||
"""
|
||||
for so in self:
|
||||
mn = so.x_fc_lead_time_min_days or 0
|
||||
mx = so.x_fc_lead_time_max_days or 0
|
||||
if mn and mx and mn != mx:
|
||||
so.x_fc_lead_time_display = '%d-%d days' % (min(mn, mx), max(mn, mx))
|
||||
elif mx or mn:
|
||||
so.x_fc_lead_time_display = '%d days' % (mx or mn)
|
||||
elif so.x_fc_rush_order:
|
||||
so.x_fc_lead_time_display = 'Rush'
|
||||
else:
|
||||
so.x_fc_lead_time_display = 'Standard'
|
||||
|
||||
@api.depends('name')
|
||||
def _compute_wo_completion(self):
|
||||
"""Batched: one grouped query across all records in self.
|
||||
|
||||
Sub 11 - MRP is gone; we count fp.job.step completion instead of
|
||||
mrp.workorder. The selection is the same shape: completed steps
|
||||
out of total steps across every fp.job for this SO.
|
||||
"""
|
||||
for rec in self:
|
||||
rec.x_fc_wo_completion = '0/0'
|
||||
names = [so.name for so in self if so.name]
|
||||
if not names:
|
||||
return
|
||||
if 'fp.job.step' not in self.env or 'fp.job' not in self.env:
|
||||
return
|
||||
Job = self.env['fp.job'].sudo()
|
||||
Step = self.env['fp.job.step'].sudo()
|
||||
jobs = Job.search([('origin', 'in', names)])
|
||||
if not jobs:
|
||||
return
|
||||
job_to_origin = {j.id: j.origin for j in jobs}
|
||||
# Odoo 19 - use _read_group with aggregates=['__count'].
|
||||
rows = Step._read_group(
|
||||
domain=[('job_id', 'in', jobs.ids)],
|
||||
groupby=['job_id', 'state'],
|
||||
aggregates=['__count'],
|
||||
)
|
||||
totals = {} # {origin: [total, done]}
|
||||
for job_rec, state_val, count in rows:
|
||||
origin = job_to_origin.get(job_rec.id)
|
||||
if not origin:
|
||||
continue
|
||||
bucket = totals.setdefault(origin, [0, 0])
|
||||
bucket[0] += count
|
||||
if state_val == 'done':
|
||||
bucket[1] += count
|
||||
for rec in self:
|
||||
if not rec.name:
|
||||
continue
|
||||
tot, done = totals.get(rec.name, [0, 0])
|
||||
rec.x_fc_wo_completion = f'{done}/{tot}' if tot else '0/0'
|
||||
|
||||
# ---- Phase F: quotes list view polish ----
|
||||
x_fc_follow_up_date = fields.Date(
|
||||
string='Follow-Up Date',
|
||||
help='Date to chase the customer for a decision on this quote.',
|
||||
tracking=True,
|
||||
)
|
||||
x_fc_follow_up_user_id = fields.Many2one(
|
||||
'res.users', string='Follow-Up Owner',
|
||||
help='Who should chase the customer on the follow-up date.',
|
||||
)
|
||||
x_fc_email_status = fields.Selection(
|
||||
[('draft', 'Draft'),
|
||||
('sent', 'Sent'),
|
||||
('opened', 'Opened'),
|
||||
('won', 'Order Received')],
|
||||
string='Email Status',
|
||||
compute='_compute_email_status',
|
||||
store=True,
|
||||
)
|
||||
x_fc_part_numbers_summary = fields.Char(
|
||||
string='Part Numbers',
|
||||
compute='_compute_part_numbers_summary',
|
||||
)
|
||||
x_fc_signed_at = fields.Datetime(
|
||||
string='Signed On', tracking=True,
|
||||
help='When the customer signed / accepted this quote.',
|
||||
)
|
||||
x_fc_signed_by = fields.Char(
|
||||
string='Signed By', tracking=True,
|
||||
help='Name of the customer signatory.',
|
||||
)
|
||||
x_fc_is_signed = fields.Boolean(
|
||||
string='Signed', compute='_compute_is_signed', store=True,
|
||||
)
|
||||
|
||||
@api.depends('x_fc_signed_at')
|
||||
def _compute_is_signed(self):
|
||||
for rec in self:
|
||||
rec.x_fc_is_signed = bool(rec.x_fc_signed_at)
|
||||
|
||||
def action_mark_signed(self):
|
||||
self.ensure_one()
|
||||
self.write({
|
||||
'x_fc_signed_at': fields.Datetime.now(),
|
||||
'x_fc_signed_by': self.partner_id.name,
|
||||
})
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_email_status(self):
|
||||
"""Map state + mail tracking to a single visible pill.
|
||||
|
||||
- state draft => draft
|
||||
- state sent => sent (or 'opened' if the customer partner has
|
||||
a read notification for any email message on this SO)
|
||||
- state sale / done => won
|
||||
|
||||
'Opened' is scoped to the CUSTOMER partner's notifications -
|
||||
not internal CCs - to avoid false positives from sales-ops
|
||||
viewing the thread.
|
||||
"""
|
||||
for rec in self:
|
||||
if rec.state in ('sale', 'done'):
|
||||
rec.x_fc_email_status = 'won'
|
||||
continue
|
||||
if rec.state == 'draft':
|
||||
rec.x_fc_email_status = 'draft'
|
||||
continue
|
||||
# state == 'sent'
|
||||
opened = False
|
||||
if rec.id and rec.partner_id:
|
||||
# Look for any read notification on any email message
|
||||
# of this SO that targeted the customer.
|
||||
notif_count = self.env['mail.notification'].sudo().search_count([
|
||||
('mail_message_id.model', '=', 'sale.order'),
|
||||
('mail_message_id.res_id', '=', rec.id),
|
||||
('mail_message_id.message_type', '=', 'email'),
|
||||
('res_partner_id', '=', rec.partner_id.id),
|
||||
('is_read', '=', True),
|
||||
])
|
||||
opened = notif_count > 0
|
||||
rec.x_fc_email_status = 'opened' if opened else 'sent'
|
||||
|
||||
@api.depends('order_line.x_fc_part_catalog_id.part_number')
|
||||
def _compute_part_numbers_summary(self):
|
||||
for rec in self:
|
||||
parts = rec.order_line.mapped('x_fc_part_catalog_id.part_number')
|
||||
parts = [p for p in parts if p]
|
||||
if not parts:
|
||||
rec.x_fc_part_numbers_summary = False
|
||||
continue
|
||||
if len(parts) <= 2:
|
||||
rec.x_fc_part_numbers_summary = ', '.join(parts)
|
||||
else:
|
||||
rec.x_fc_part_numbers_summary = '%s, %s (+%d more)' % (
|
||||
parts[0], parts[1], len(parts) - 2,
|
||||
)
|
||||
|
||||
@api.depends('invoice_ids.amount_total', 'invoice_ids.state',
|
||||
'invoice_ids.move_type')
|
||||
def _compute_invoiced_amount(self):
|
||||
for rec in self:
|
||||
posted = rec.invoice_ids.filtered(
|
||||
lambda m: m.state == 'posted' and m.move_type == 'out_invoice'
|
||||
)
|
||||
refunds = rec.invoice_ids.filtered(
|
||||
lambda m: m.state == 'posted' and m.move_type == 'out_refund'
|
||||
)
|
||||
rec.x_fc_invoiced_amount = (
|
||||
sum(posted.mapped('amount_total'))
|
||||
- sum(refunds.mapped('amount_total'))
|
||||
)
|
||||
|
||||
def action_view_workorders(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Work Orders',
|
||||
'res_model': 'mrp.workorder',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('production_id.origin', '=', self.name)],
|
||||
'context': {'search_default_group_production_id': 1},
|
||||
}
|
||||
|
||||
# ---- Quick-nav counts for smart buttons (Phase D9 / D14) ----
|
||||
x_fc_ncr_count = fields.Integer(
|
||||
string='NCRs', compute='_compute_nav_counts',
|
||||
)
|
||||
x_fc_picking_count = fields.Integer(
|
||||
string='Transfer Count', compute='_compute_nav_counts',
|
||||
)
|
||||
|
||||
@api.depends('picking_ids')
|
||||
def _compute_nav_counts(self):
|
||||
for rec in self:
|
||||
rec.x_fc_picking_count = len(rec.picking_ids)
|
||||
|
||||
# NCR counts - only if the module is installed.
|
||||
ids = self.ids
|
||||
NCR = self.env.get('fusion.plating.ncr')
|
||||
ncr_counts = {}
|
||||
if ids and NCR is not None and 'sale_order_id' in NCR._fields:
|
||||
rows = NCR.sudo().read_group(
|
||||
[('sale_order_id', 'in', ids)],
|
||||
['sale_order_id'], ['sale_order_id'], lazy=False,
|
||||
)
|
||||
ncr_counts = {
|
||||
(r['sale_order_id'][0] if r['sale_order_id'] else False):
|
||||
r['__count']
|
||||
for r in rows
|
||||
}
|
||||
for rec in self:
|
||||
rec.x_fc_ncr_count = ncr_counts.get(rec.id, 0)
|
||||
|
||||
def action_view_pickings(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Transfers',
|
||||
'res_model': 'stock.picking',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('id', 'in', self.picking_ids.ids)],
|
||||
}
|
||||
|
||||
def action_view_ncrs(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'NCRs',
|
||||
'res_model': 'fusion.plating.ncr',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [('sale_order_id', '=', self.id)],
|
||||
}
|
||||
|
||||
|
||||
def action_view_bom_items(self):
|
||||
"""Open SO lines grouped by part catalog (Phase D2)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'BOM Items - %s' % self.name,
|
||||
'res_model': 'sale.order.line',
|
||||
'view_mode': 'kanban,list,form',
|
||||
'views': [
|
||||
(self.env.ref('fusion_plating_configurator.view_sale_order_line_bom_kanban').id, 'kanban'),
|
||||
(False, 'list'),
|
||||
(False, 'form'),
|
||||
],
|
||||
'domain': [('order_id', '=', self.id)],
|
||||
}
|
||||
|
||||
def action_view_wo_perspective(self):
|
||||
"""Open SO lines grouped by WO tag (Phase D10)."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Lines by WO - %s' % self.name,
|
||||
'res_model': 'sale.order.line',
|
||||
'view_mode': 'kanban,list',
|
||||
'views': [
|
||||
(self.env.ref('fusion_plating_configurator.view_sale_order_line_wo_kanban').id, 'kanban'),
|
||||
(False, 'list'),
|
||||
],
|
||||
'domain': [('order_id', '=', self.id)],
|
||||
}
|
||||
|
||||
@api.depends('commitment_date')
|
||||
def _compute_deadline_countdown(self):
|
||||
from datetime import datetime
|
||||
now = fields.Datetime.now()
|
||||
TWO_DAYS = 2 * 86400 # seconds threshold for "urgent"
|
||||
for rec in self:
|
||||
if not rec.commitment_date:
|
||||
rec.x_fc_deadline_countdown = False
|
||||
rec.x_fc_deadline_urgency = False
|
||||
continue
|
||||
target = rec.commitment_date
|
||||
if isinstance(target, datetime):
|
||||
delta = target - now
|
||||
else:
|
||||
from datetime import datetime as _dt
|
||||
delta = _dt.combine(target, _dt.min.time()) - now
|
||||
secs = int(delta.total_seconds())
|
||||
if secs == 0:
|
||||
rec.x_fc_deadline_countdown = 'due now'
|
||||
rec.x_fc_deadline_urgency = 'overdue'
|
||||
continue
|
||||
past = secs < 0
|
||||
abs_secs = abs(secs)
|
||||
days = abs_secs // 86400
|
||||
hours = (abs_secs % 86400) // 3600
|
||||
mins = (abs_secs % 3600) // 60
|
||||
bits = []
|
||||
if days:
|
||||
bits.append('%dd' % days)
|
||||
if hours:
|
||||
bits.append('%dh' % hours)
|
||||
if mins and not days:
|
||||
bits.append('%dm' % mins)
|
||||
phrase = ' '.join(bits) or '<1m'
|
||||
rec.x_fc_deadline_countdown = (
|
||||
'overdue %s' % phrase if past else 'in %s' % phrase
|
||||
)
|
||||
if past:
|
||||
rec.x_fc_deadline_urgency = 'overdue'
|
||||
elif secs <= TWO_DAYS:
|
||||
rec.x_fc_deadline_urgency = 'urgent'
|
||||
else:
|
||||
rec.x_fc_deadline_urgency = 'safe'
|
||||
|
||||
@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 computation - stub.
|
||||
|
||||
Pre-promote-customer-spec, this rolled up cost from
|
||||
fp.coating.config.unit_cost. Coating Config is retired; cost
|
||||
data on the recipe is a future enhancement (backlog). Until
|
||||
then, margin is "not available" and the UI hides the fields.
|
||||
"""
|
||||
for rec in self:
|
||||
rec.x_fc_margin_available = False
|
||||
rec.x_fc_margin_amount = 0.0
|
||||
rec.x_fc_margin_percent = 0.0
|
||||
|
||||
@api.onchange('upload_rfq_file')
|
||||
def _onchange_upload_rfq_file(self):
|
||||
"""Create attachment from uploaded binary and link it."""
|
||||
if not self.upload_rfq_file:
|
||||
return
|
||||
fname = self.upload_rfq_filename or 'rfq.pdf'
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_rfq_file,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.x_fc_rfq_attachment_id = att.id
|
||||
self.upload_rfq_file = False
|
||||
self.upload_rfq_filename = False
|
||||
|
||||
@api.onchange('upload_po_file')
|
||||
def _onchange_upload_po_file(self):
|
||||
"""Create attachment from uploaded binary, link it, and mark PO received."""
|
||||
if not self.upload_po_file:
|
||||
return
|
||||
fname = self.upload_po_filename or 'po.pdf'
|
||||
att = self.env['ir.attachment'].create({
|
||||
'name': fname,
|
||||
'datas': self.upload_po_file,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.x_fc_po_attachment_id = att.id
|
||||
if not self.x_fc_po_received:
|
||||
self.x_fc_po_received = True
|
||||
self.upload_po_file = False
|
||||
self.upload_po_filename = False
|
||||
|
||||
def action_view_rfq(self):
|
||||
self.ensure_one()
|
||||
if not self.x_fc_rfq_attachment_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.x_fc_rfq_attachment_id.id,
|
||||
'title': 'RFQ - %s' % (self.x_fc_rfq_attachment_id.name or ''),
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_po(self):
|
||||
self.ensure_one()
|
||||
if not self.x_fc_po_attachment_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_pdf_preview_open',
|
||||
'params': {
|
||||
'attachment_id': self.x_fc_po_attachment_id.id,
|
||||
'title': 'PO - %s' % (self.x_fc_po_attachment_id.name or ''),
|
||||
},
|
||||
}
|
||||
|
||||
# ---- Sub 5 - auto-assign Job # on confirm -------------------------------
|
||||
# Job # is the shop-floor reference that prints on travellers and WOs.
|
||||
# Auto-assigned once at confirm so every confirmed line has one; still
|
||||
# editable afterwards (clearable, overridable to match a customer scheme).
|
||||
def action_confirm(self):
|
||||
# Phase G of permissions overhaul: only Sales Manager+ can confirm
|
||||
# Sale Orders. Sales Rep can save drafts but cannot move them to
|
||||
# 'sale' state. The has_group() check resolves True for Sales Manager,
|
||||
# Manager (implies Sales Manager via diamond), Quality Manager
|
||||
# (implies Manager), and Owner (implies Quality Manager) - see
|
||||
# spec Section 2.B.
|
||||
if not self.env.user.has_group('fusion_plating.group_fp_sales_manager'):
|
||||
raise UserError(_(
|
||||
'Only Sales Manager or higher can confirm Sale Orders. '
|
||||
'Please ask a Sales Manager to confirm this quote.'
|
||||
))
|
||||
res = super().action_confirm()
|
||||
Sequence = self.env['ir.sequence']
|
||||
for so in self:
|
||||
for line in so.order_line:
|
||||
if line.display_type:
|
||||
continue
|
||||
if not line.x_fc_job_number:
|
||||
line.x_fc_job_number = Sequence.next_by_code(
|
||||
'fp.job.number'
|
||||
) or False
|
||||
# Per-part description history (spec 2026-05-29). After super() +
|
||||
# the parent-number rename, so.name is the final order number.
|
||||
for so in self:
|
||||
for line in so.order_line:
|
||||
if line.display_type:
|
||||
continue
|
||||
part = (line.x_fc_part_catalog_id
|
||||
if 'x_fc_part_catalog_id' in line._fields else False)
|
||||
if not part:
|
||||
continue
|
||||
part._fp_save_description_version(
|
||||
internal_desc=line.x_fc_internal_description or '',
|
||||
customer_desc=line.name or '',
|
||||
order=so, line=line,
|
||||
)
|
||||
return res
|
||||
@@ -1,957 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# 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, ValidationError
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
def fp_customer_description(self):
|
||||
"""Return line.name with the leading "[code] product_name" stripped.
|
||||
|
||||
Odoo's _compute_name re-prepends the product code + name on save,
|
||||
polluting customer-facing PDFs with internal-product noise like
|
||||
"[FP-SERVICE] Plating Service". This helper peels that prefix
|
||||
off so the QWeb macros print only what the estimator actually
|
||||
typed for the customer to see. Same logic mirrored on
|
||||
account.move.line for invoice rendering.
|
||||
"""
|
||||
self.ensure_one()
|
||||
name = (self.name or '').strip()
|
||||
if not self.product_id or not name:
|
||||
return name
|
||||
code = self.product_id.default_code or ''
|
||||
pname = self.product_id.name or ''
|
||||
# Try the bracketed form first ("[CODE] Name"), then bare name.
|
||||
# Whichever matches gets stripped along with any trailing
|
||||
# newline / dash / em-dash separator.
|
||||
prefixes = []
|
||||
if code and pname:
|
||||
prefixes.append(f'[{code}] {pname}')
|
||||
if pname:
|
||||
prefixes.append(pname)
|
||||
for prefix in prefixes:
|
||||
if name.startswith(prefix):
|
||||
tail = name[len(prefix):]
|
||||
return tail.lstrip(' \t\r\n---:').strip()
|
||||
return name
|
||||
|
||||
@api.onchange('x_fc_part_catalog_id')
|
||||
def _fp_onchange_part_load_description(self):
|
||||
"""Pre-fill name (customer-facing) + x_fc_internal_description from
|
||||
the part's latest description version, when empty (spec 2026-05-29)."""
|
||||
for line in self:
|
||||
part = line.x_fc_part_catalog_id
|
||||
if not part:
|
||||
continue
|
||||
descs = part._fp_resolve_line_descriptions()
|
||||
if not line.name and descs['customer_facing']:
|
||||
line.name = descs['customer_facing']
|
||||
if not line.x_fc_internal_description and descs['internal']:
|
||||
line.x_fc_internal_description = descs['internal']
|
||||
|
||||
x_fc_part_catalog_id = fields.Many2one(
|
||||
'fp.part.catalog', string='Part',
|
||||
)
|
||||
# Sub 2 - dual descriptions captured from a template row at order
|
||||
# entry. `name` remains Odoo's standard customer-facing line
|
||||
# description; x_fc_internal_description is ops-only (prints on WO).
|
||||
# Nullable during Phase A; flipped to required in Phase C.
|
||||
x_fc_internal_description = fields.Text(
|
||||
string='Internal Description',
|
||||
required=True,
|
||||
help='Shop-floor instructions. Prints on WO / traveler. Never on customer docs.',
|
||||
)
|
||||
x_fc_description_template_id = fields.Many2one(
|
||||
'fp.sale.description.template',
|
||||
string='Description Template',
|
||||
help='Which template row populated this line. Informational.',
|
||||
)
|
||||
# Specification picker (x_fc_customer_spec_id) is added by
|
||||
# fusion_plating_quality. Legacy x_fc_coating_config_id +
|
||||
# x_fc_treatment_ids removed.
|
||||
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',
|
||||
help='Lines sharing a tag (e.g. "WO#1") will be batched into one '
|
||||
'manufacturing order when bridge_mrp generates MOs.',
|
||||
)
|
||||
x_fc_part_wo_description = fields.Text(
|
||||
string='On Work Order',
|
||||
help='Extra detail printed on the work order travelling sheet. '
|
||||
'Separate from the customer-facing line description.',
|
||||
)
|
||||
x_fc_start_at_node_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Start at Node',
|
||||
help='For re-work jobs: pick the recipe step where this job '
|
||||
'should begin. bridge_mrp skips ancestor steps.',
|
||||
)
|
||||
x_fc_is_one_off = fields.Boolean(
|
||||
string='One-off Part',
|
||||
help='Flag for prototype / non-catalog parts that should not be '
|
||||
'reused after this order.',
|
||||
)
|
||||
x_fc_quote_id = fields.Many2one(
|
||||
'fp.quote.configurator',
|
||||
string='Linked Quote',
|
||||
help='Quote that seeded this line. Links back for audit trail.',
|
||||
)
|
||||
# Sub 9 (polished 2026-04-28) - process variant per line. The picker
|
||||
# now lets the estimator pick ANY root recipe in the system: the
|
||||
# part's own variants, another customer's variants, or a template
|
||||
# marked is_template. Cross-part picks auto-clone onto this part on
|
||||
# save (see _onchange_process_variant_clone) so per-line edits never
|
||||
# bleed across customers.
|
||||
x_fc_process_variant_id = fields.Many2one(
|
||||
'fusion.plating.process.node',
|
||||
string='Process Variant',
|
||||
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
|
||||
ondelete='set null',
|
||||
help='Pick any recipe - the part\'s own variant, another part\'s '
|
||||
'recipe, or a template from the library. If the chosen recipe '
|
||||
'doesn\'t belong to this part, it will be cloned onto the part '
|
||||
'when the order saves so per-line edits stay scoped. Use the '
|
||||
'Customize button on the line to open the Process Composer.',
|
||||
)
|
||||
x_fc_save_as_default_process = fields.Boolean(
|
||||
string='Save as Default for Part',
|
||||
default=False,
|
||||
help='When ticked, the chosen process variant becomes this part\'s '
|
||||
'default on order save - future orders for the same part '
|
||||
'pre-fill with this variant.',
|
||||
)
|
||||
x_fc_archived = fields.Boolean(
|
||||
string='Archived',
|
||||
default=False,
|
||||
help='Archived lines are hidden from the default list view but '
|
||||
'preserved for audit. Useful when a part is cancelled mid-order.',
|
||||
)
|
||||
|
||||
# ---- Sub 5 - Order-line fields (serial / job# / thickness / revision) ---
|
||||
# NB: sale.order.line in Odoo 19 does not support `tracking=True` on
|
||||
# inherited fields - Odoo emits a warning and ignores it. Audit trail
|
||||
# for these values lives on fp.serial.mail.thread instead.
|
||||
#
|
||||
# 2026-04-28 Phase 1 - multi-serial support. Customer can ship 30 parts
|
||||
# with 30 distinct serials on a single line. The M2M is the source of
|
||||
# truth; `x_fc_serial_id` (M2O) becomes a computed alias of the first
|
||||
# serial so existing reports / smart buttons / downstream code that
|
||||
# still reads the singular keep working unchanged.
|
||||
x_fc_serial_ids = fields.Many2many(
|
||||
'fp.serial',
|
||||
relation='fp_sale_order_line_serial_rel',
|
||||
column1='line_id',
|
||||
column2='serial_id',
|
||||
string='Serial Numbers',
|
||||
copy=False,
|
||||
help='Customer-supplied serial numbers for the parts on this line. '
|
||||
'Use the Bulk Add Serials button to paste a list, range-fill '
|
||||
'(SN-001..SN-030), or scan barcodes. Count must not exceed '
|
||||
'the line quantity.',
|
||||
)
|
||||
x_fc_serial_id = fields.Many2one(
|
||||
'fp.serial',
|
||||
string='Primary Serial',
|
||||
compute='_compute_primary_serial',
|
||||
inverse='_inverse_primary_serial',
|
||||
search='_search_primary_serial',
|
||||
store=False,
|
||||
copy=False,
|
||||
help='First of the line\'s serials - back-compat alias kept so '
|
||||
'pre-Phase-1 code (reports, smart buttons, downstream M2M '
|
||||
'reverse links) keeps working. Setting this prepends the '
|
||||
'serial to the M2M.',
|
||||
)
|
||||
x_fc_serial_count = fields.Integer(
|
||||
string='# Serials',
|
||||
compute='_compute_serial_count',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_serial_ids')
|
||||
def _compute_primary_serial(self):
|
||||
for line in self:
|
||||
line.x_fc_serial_id = line.x_fc_serial_ids[:1]
|
||||
|
||||
def _inverse_primary_serial(self):
|
||||
for line in self:
|
||||
if not line.x_fc_serial_id:
|
||||
continue
|
||||
if line.x_fc_serial_id not in line.x_fc_serial_ids:
|
||||
line.x_fc_serial_ids = [(4, line.x_fc_serial_id.id)]
|
||||
|
||||
def _search_primary_serial(self, operator, value):
|
||||
return [('x_fc_serial_ids', operator, value)]
|
||||
|
||||
@api.depends('x_fc_serial_ids')
|
||||
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,
|
||||
index=True,
|
||||
help='Shop-floor reference for this line. Auto-sequenced on sale '
|
||||
'order confirmation; editable. Blank is allowed.',
|
||||
)
|
||||
x_fc_thickness_range = fields.Char(
|
||||
string='Thickness',
|
||||
help='Target thickness range as the operator types it, e.g. '
|
||||
'"0.0005-0.0008 mils" or "5-10 mils". Free-form text - '
|
||||
'auto-fills from the last order for this (part, customer) '
|
||||
'pair, falling back to the part\'s default range. Prints '
|
||||
'verbatim on the cert, packing slip, and invoice.',
|
||||
)
|
||||
x_fc_is_lot_priced = fields.Boolean(string='Lot Priced')
|
||||
x_fc_lot_total = fields.Monetary(
|
||||
string='Lot Total', currency_field='currency_id')
|
||||
|
||||
# ---- Express Orders per-line flags (2026-05-26) ----
|
||||
# Mirror fp.direct.order.line.{customer_line_ref, masking_enabled, bake_instructions}
|
||||
# and persist past wizard confirm so _fp_apply_express_overrides_to_job can read them.
|
||||
x_fc_customer_line_ref = fields.Char(
|
||||
string='Customer Line Job #',
|
||||
help='Per-line customer sub-reference (e.g. ABC, DEF). '
|
||||
'Prints on customer docs (quote, SO, invoice, packing slip).',
|
||||
)
|
||||
x_fc_masking_enabled = fields.Boolean(
|
||||
string='Masking Enabled',
|
||||
default=True,
|
||||
help='When False, the job-creation hook spawns fp.job.node.override '
|
||||
'(included=False) for every masking + de_masking node on the recipe.',
|
||||
)
|
||||
x_fc_bake_instructions = fields.Text(
|
||||
string='Bake Instructions',
|
||||
help='Empty = bake steps are opted out of the job. Non-empty = bake '
|
||||
'steps run, with this text shown on the operator tablet under '
|
||||
'fp.job.step.instructions.',
|
||||
)
|
||||
x_fc_masking_attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
'sale_order_line_masking_att_rel', 'line_id', 'attachment_id',
|
||||
string='Masking Reference(s)',
|
||||
help='Masking reference image(s)/PDF(s) captured at Express order '
|
||||
'entry; applied to the job\'s masking step at job creation so '
|
||||
'the operator sees what to mask.',
|
||||
)
|
||||
x_fc_revision_snapshot = fields.Char(
|
||||
string='Revision (snapshot)',
|
||||
copy=False,
|
||||
readonly=True,
|
||||
help='Revision letter / number at the moment the line was saved. '
|
||||
'Preserved even if the part catalog revision is later edited '
|
||||
'or the catalog row is removed.',
|
||||
)
|
||||
|
||||
# Revision picker - non-stored compute that re-points x_fc_part_catalog_id
|
||||
# to any revision of the same part number. The Part M2O itself is domain-
|
||||
# filtered to latest revisions only, so the picker is what surfaces
|
||||
# earlier revisions when the estimator needs one.
|
||||
x_fc_revision_pick_id = fields.Many2one(
|
||||
'fp.part.catalog',
|
||||
string='Revision',
|
||||
compute='_compute_revision_pick_id',
|
||||
inverse='_inverse_revision_pick_id',
|
||||
store=False,
|
||||
help='Switch to a different revision of the same part number.',
|
||||
)
|
||||
|
||||
@api.depends('x_fc_part_catalog_id')
|
||||
def _compute_revision_pick_id(self):
|
||||
for line in self:
|
||||
line.x_fc_revision_pick_id = line.x_fc_part_catalog_id
|
||||
|
||||
def _inverse_revision_pick_id(self):
|
||||
for line in self:
|
||||
if line.x_fc_revision_pick_id:
|
||||
line.x_fc_part_catalog_id = line.x_fc_revision_pick_id
|
||||
|
||||
def _fp_apply_recipe_polish(self):
|
||||
"""Post-write step: auto-clone any cross-part recipe pick and
|
||||
honour the Save-as-Default toggle.
|
||||
|
||||
Called from create() and write() so the polish runs on every
|
||||
save path - onchange alone doesn't cover programmatic creates
|
||||
(the direct-order wizard, imports, the sale_mrp bridge, etc.).
|
||||
"""
|
||||
for line in self:
|
||||
if not line.x_fc_part_catalog_id or not line.x_fc_process_variant_id:
|
||||
continue
|
||||
recipe = line.x_fc_process_variant_id
|
||||
if (not recipe.part_catalog_id
|
||||
or recipe.part_catalog_id.id != line.x_fc_part_catalog_id.id):
|
||||
clone = line._fp_clone_recipe_to_part()
|
||||
if clone and clone.id != recipe.id:
|
||||
line.x_fc_process_variant_id = clone.id
|
||||
recipe = clone
|
||||
if line.x_fc_save_as_default_process and recipe.part_catalog_id:
|
||||
line.x_fc_part_catalog_id.action_set_default_variant(recipe.id)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""Default `x_fc_internal_description` from `name` when a caller
|
||||
creates a line programmatically without supplying the internal
|
||||
description.
|
||||
|
||||
Sub 2 made `x_fc_internal_description` required. The UI-side
|
||||
onchange fills it when the user picks a description template,
|
||||
but programmatic creators (sale_mrp bridge, migration scripts,
|
||||
external integrations, demo seeders) may not know about this
|
||||
field. Instead of forcing every call site to update, fall back
|
||||
to `name` - same rule the upgrade migration used when it
|
||||
back-filled historical lines.
|
||||
"""
|
||||
Product = self.env['product.product']
|
||||
Part = self.env['fp.part.catalog']
|
||||
for vals in vals_list:
|
||||
if not vals.get('x_fc_internal_description'):
|
||||
# Try the explicit `name` first. If the caller didn't pass
|
||||
# one (sale_mrp + some Odoo internals don't - they let the
|
||||
# name compute from product_id later), fall back to the
|
||||
# product's display_name so we have SOMETHING non-empty.
|
||||
fallback = vals.get('name')
|
||||
if not fallback and vals.get('product_id'):
|
||||
prod = Product.browse(vals['product_id']).exists()
|
||||
if prod:
|
||||
fallback = prod.display_name or prod.name
|
||||
vals['x_fc_internal_description'] = fallback or '-'
|
||||
|
||||
# Sub 5 - freeze the revision letter on the line at save time.
|
||||
# Protects historical SOs from later edits to the catalog row.
|
||||
if not vals.get('x_fc_revision_snapshot') and vals.get('x_fc_part_catalog_id'):
|
||||
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
|
||||
if part and part.revision:
|
||||
vals['x_fc_revision_snapshot'] = part.revision
|
||||
|
||||
# Auto-fill thickness range - same logic as the onchange but
|
||||
# for programmatic creators (wizard, sale_mrp, imports).
|
||||
# Resolution: explicit > last-used (part, partner) > part default.
|
||||
if (not vals.get('x_fc_thickness_range')
|
||||
and vals.get('x_fc_part_catalog_id')):
|
||||
part = Part.browse(vals['x_fc_part_catalog_id']).exists()
|
||||
if part:
|
||||
# Need partner_id from the parent order
|
||||
partner_id = False
|
||||
if vals.get('order_id'):
|
||||
order = self.env['sale.order'].browse(vals['order_id']).exists()
|
||||
if order:
|
||||
partner_id = order.partner_id.id
|
||||
if partner_id:
|
||||
recent = self.search([
|
||||
('x_fc_part_catalog_id', '=', part.id),
|
||||
('order_id.partner_id', '=', partner_id),
|
||||
('x_fc_thickness_range', '!=', False),
|
||||
('x_fc_thickness_range', '!=', ''),
|
||||
], order='create_date desc', limit=1)
|
||||
if recent:
|
||||
vals['x_fc_thickness_range'] = recent.x_fc_thickness_range
|
||||
if (not vals.get('x_fc_thickness_range')
|
||||
and getattr(part, 'x_fc_default_thickness_range', None)):
|
||||
vals['x_fc_thickness_range'] = part.x_fc_default_thickness_range
|
||||
lines = super().create(vals_list)
|
||||
lines._fp_apply_recipe_polish()
|
||||
return lines
|
||||
|
||||
def write(self, vals):
|
||||
# Sub 5 - keep the revision snapshot in lockstep with the line's
|
||||
# part catalog pointer. Only refresh when the part changes; never
|
||||
# overwrite a snapshot that's already been set on a historical line.
|
||||
if 'x_fc_part_catalog_id' in vals:
|
||||
Part = self.env['fp.part.catalog']
|
||||
new_part = Part.browse(vals['x_fc_part_catalog_id']).exists()
|
||||
if new_part and new_part.revision:
|
||||
vals.setdefault('x_fc_revision_snapshot', new_part.revision)
|
||||
# Blank overrides keep their snapshot; explicit changes do too.
|
||||
for line in self:
|
||||
if line.x_fc_part_catalog_id.id != new_part.id:
|
||||
line.x_fc_revision_snapshot = new_part.revision
|
||||
result = super().write(vals)
|
||||
# Only run the polish when something relevant actually changed -
|
||||
# avoids re-running on every unrelated write (e.g. price updates).
|
||||
if any(k in vals for k in (
|
||||
'x_fc_process_variant_id',
|
||||
'x_fc_part_catalog_id',
|
||||
'x_fc_save_as_default_process',
|
||||
)):
|
||||
self._fp_apply_recipe_polish()
|
||||
return result
|
||||
|
||||
@api.onchange('x_fc_description_template_id')
|
||||
def _onchange_description_template(self):
|
||||
"""When estimator picks a template, auto-fill both descriptions.
|
||||
|
||||
The customer-facing text goes into `name` (Odoo's line description,
|
||||
prints on customer docs). The internal text goes into
|
||||
x_fc_internal_description (prints on WO / traveler only).
|
||||
Estimator can edit either field after the template is applied.
|
||||
"""
|
||||
if self.x_fc_description_template_id:
|
||||
tpl = self.x_fc_description_template_id
|
||||
if tpl.customer_facing_description:
|
||||
self.name = tpl.customer_facing_description
|
||||
if tpl.internal_description:
|
||||
self.x_fc_internal_description = tpl.internal_description
|
||||
|
||||
def action_archive_line(self):
|
||||
self.write({'x_fc_archived': True})
|
||||
return True
|
||||
|
||||
def action_unarchive_line(self):
|
||||
self.write({'x_fc_archived': False})
|
||||
return True
|
||||
|
||||
def _prepare_invoice_line(self, **optional_values):
|
||||
"""Carry x_fc_part_catalog_id + Sub 5 fields from SO line to invoice line.
|
||||
|
||||
Sub 2 Task 19 - lets the customer-facing invoice PDF render the
|
||||
customer's part number via the shared customer_line_header macro
|
||||
instead of the internal service SKU.
|
||||
|
||||
Sub 5 - also carry serial / job# / thickness / revision snapshot so
|
||||
the same macro can print them unchanged on invoices.
|
||||
"""
|
||||
vals = super()._prepare_invoice_line(**optional_values)
|
||||
if self.x_fc_part_catalog_id:
|
||||
vals['x_fc_part_catalog_id'] = self.x_fc_part_catalog_id.id
|
||||
if self.x_fc_serial_ids:
|
||||
# Carry the full M2M to the invoice line. Back-compat alias
|
||||
# x_fc_serial_id will still resolve to the first one if any
|
||||
# downstream code only reads the singular.
|
||||
vals['x_fc_serial_ids'] = [(6, 0, self.x_fc_serial_ids.ids)]
|
||||
elif self.x_fc_serial_id:
|
||||
vals['x_fc_serial_id'] = self.x_fc_serial_id.id
|
||||
if self.x_fc_job_number:
|
||||
vals['x_fc_job_number'] = self.x_fc_job_number
|
||||
if self.x_fc_thickness_range:
|
||||
vals['x_fc_thickness_range'] = self.x_fc_thickness_range
|
||||
if self.x_fc_revision_snapshot:
|
||||
vals['x_fc_revision_snapshot'] = self.x_fc_revision_snapshot
|
||||
# x_fc_customer_spec_id carry-over is handled by an
|
||||
# extension in fusion_plating_quality (the field lives there).
|
||||
return vals
|
||||
|
||||
@api.onchange('x_fc_part_catalog_id')
|
||||
def _onchange_part_default_variant(self):
|
||||
"""When the part changes, pre-fill the variant from the part's
|
||||
default_process_id (if set) so the line carries a sensible
|
||||
starting point. The estimator can override after.
|
||||
|
||||
Previously cleared the variant entirely when the part changed
|
||||
(because the variant picker was scoped to the part). Now that
|
||||
the picker is system-wide, we instead pre-fill from the part's
|
||||
default - much more useful.
|
||||
"""
|
||||
for line in self:
|
||||
if line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.default_process_id:
|
||||
line.x_fc_process_variant_id = line.x_fc_part_catalog_id.default_process_id
|
||||
|
||||
# Spec auto-fill onchange lives in fusion_plating_quality
|
||||
# (the customer.spec model lives there, so the inherit must too).
|
||||
|
||||
def _fp_clone_recipe_to_part(self):
|
||||
"""Deep-copy the picked recipe onto this line's part if it isn't
|
||||
already scoped there. Returns the cloned (or unchanged) variant.
|
||||
|
||||
Edge cases handled:
|
||||
* No recipe picked → no-op, return False.
|
||||
* No part on the line → no-op (we need a part to scope the clone).
|
||||
* Recipe already belongs to this part → no-op, return as-is.
|
||||
* Recipe belongs to a different part / is a template / is unscoped
|
||||
→ deep-copy via Odoo's standard recursive copy(), reparent the
|
||||
clone onto this part, name-stamp it for traceability.
|
||||
"""
|
||||
self.ensure_one()
|
||||
recipe = self.x_fc_process_variant_id
|
||||
part = self.x_fc_part_catalog_id
|
||||
if not recipe or not part:
|
||||
return recipe
|
||||
if recipe.part_catalog_id and recipe.part_catalog_id.id == part.id:
|
||||
return recipe # already scoped - nothing to do
|
||||
# Clone - Odoo's default copy() recurses through child_ids when the
|
||||
# field has copy=True. fp.process.node sets that on its tree, so
|
||||
# one call gets us a full sub-tree clone.
|
||||
clone_name = recipe.name or _('Untitled Recipe')
|
||||
# If the source carried a part scope, preface the clone name with
|
||||
# the customer's part number for quick identification on the
|
||||
# variant dropdown later.
|
||||
if not clone_name.lower().endswith(part.part_number.lower() if part.part_number else ''):
|
||||
clone_name = '%s - %s' % (clone_name, part.part_number or part.display_name)
|
||||
clone = recipe.copy({
|
||||
'name': clone_name,
|
||||
'part_catalog_id': part.id,
|
||||
'is_template': False, # never propagate template flag
|
||||
'is_default_variant': False, # estimator opts in via toggle
|
||||
})
|
||||
return clone
|
||||
|
||||
def action_customize_process(self):
|
||||
"""Open the Process Composer for this line's process variant.
|
||||
|
||||
Auto-clones first if the variant isn't yet scoped to this part -
|
||||
the operator should never edit a recipe that's shared across
|
||||
customers (their edits would bleed). After cloning, the line
|
||||
ends up pointing at the fresh per-part copy.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_part_catalog_id:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Pick a part on this line before customizing the process - '
|
||||
'the recipe needs a part to scope the variant.'
|
||||
))
|
||||
if not self.x_fc_process_variant_id:
|
||||
from odoo.exceptions import UserError
|
||||
raise UserError(_(
|
||||
'Pick a process variant on this line first. To start from '
|
||||
'scratch, use the part\'s Compose button instead.'
|
||||
))
|
||||
clone_or_existing = self._fp_clone_recipe_to_part()
|
||||
if clone_or_existing.id != self.x_fc_process_variant_id.id:
|
||||
self.x_fc_process_variant_id = clone_or_existing.id
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fp_part_process_composer',
|
||||
'name': _('Customize Process - %s') % (
|
||||
self.x_fc_part_catalog_id.display_name
|
||||
or self.x_fc_part_catalog_id.part_number
|
||||
or '?'
|
||||
),
|
||||
'params': {
|
||||
'part_id': self.x_fc_part_catalog_id.id,
|
||||
'part_display': self.x_fc_part_catalog_id.display_name
|
||||
or self.x_fc_part_catalog_id.part_number,
|
||||
'focus_variant_id': clone_or_existing.id,
|
||||
},
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
@api.onchange('x_fc_part_catalog_id')
|
||||
def _onchange_part_default_thickness(self):
|
||||
"""Auto-fill thickness range from last-used or part default.
|
||||
|
||||
Resolution order (first match wins):
|
||||
1. Operator already typed a value → keep
|
||||
2. Most recent SO line for (this part, this customer) with a
|
||||
non-empty thickness_range → copy that
|
||||
3. Part's x_fc_default_thickness_range → copy
|
||||
4. Blank - operator types
|
||||
"""
|
||||
for line in self:
|
||||
if line.x_fc_thickness_range:
|
||||
continue
|
||||
if not line.x_fc_part_catalog_id:
|
||||
continue
|
||||
partner = line.order_id.partner_id
|
||||
# 2. Last-used for (part, customer)
|
||||
if partner:
|
||||
recent = self.env['sale.order.line'].search([
|
||||
('x_fc_part_catalog_id', '=', line.x_fc_part_catalog_id.id),
|
||||
('order_id.partner_id', '=', partner.id),
|
||||
('x_fc_thickness_range', '!=', False),
|
||||
('x_fc_thickness_range', '!=', ''),
|
||||
('id', '!=', line.id or 0),
|
||||
], order='create_date desc', limit=1)
|
||||
if recent:
|
||||
line.x_fc_thickness_range = recent.x_fc_thickness_range
|
||||
continue
|
||||
# 3. Part default
|
||||
part_default = getattr(
|
||||
line.x_fc_part_catalog_id, 'x_fc_default_thickness_range', None,
|
||||
)
|
||||
if part_default:
|
||||
line.x_fc_thickness_range = part_default
|
||||
|
||||
def action_generate_serial(self):
|
||||
"""Generate one new auto-sequenced serial and append it to the M2M.
|
||||
|
||||
Phase 1 polish: the legacy single-serial behaviour was "create one
|
||||
serial and pin it to x_fc_serial_id". Now we append to the M2M so
|
||||
repeated clicks add more serials (handy when the customer didn't
|
||||
send any and the shop wants to assign N).
|
||||
"""
|
||||
self.ensure_one()
|
||||
seq = self.env['ir.sequence'].next_by_code('fp.serial') or 'FP-SN-0000'
|
||||
serial = self.env['fp.serial'].create({
|
||||
'name': seq,
|
||||
'sale_order_line_id': self.id,
|
||||
})
|
||||
self.x_fc_serial_ids = [(4, serial.id)]
|
||||
return False
|
||||
|
||||
def action_open_serial_bulk_add(self):
|
||||
"""Open the Bulk Add Serials wizard for this line."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fp.serial.bulk.add.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'name': _('Bulk Add Serials'),
|
||||
'context': {
|
||||
'default_target_model': 'sale.order.line',
|
||||
'default_target_id': self.id,
|
||||
'default_qty_expected': int(self.product_uom_qty or 0),
|
||||
},
|
||||
}
|
||||
|
||||
@api.constrains('x_fc_serial_ids', 'product_uom_qty')
|
||||
def _check_serial_count_against_qty(self):
|
||||
"""Block save when the operator has attached more serials than
|
||||
the line quantity. Under-count is allowed (some customers ship
|
||||
with serials only on a subset of parts).
|
||||
"""
|
||||
for line in self:
|
||||
if line.x_fc_serial_ids and line.product_uom_qty:
|
||||
n = len(line.x_fc_serial_ids)
|
||||
if n > int(line.product_uom_qty):
|
||||
raise ValidationError(_(
|
||||
'Line "%(part)s": %(n)s serials attached but only '
|
||||
'%(qty)s parts ordered. Either reduce the serial '
|
||||
'list, increase the quantity, or split the line.'
|
||||
) % {
|
||||
'part': (line.x_fc_part_catalog_id.display_name
|
||||
or line.product_id.display_name or ''),
|
||||
'n': n,
|
||||
'qty': int(line.product_uom_qty),
|
||||
})
|
||||
|
||||
|
||||
# ---- Customer references mirrored from parent sale.order ----------
|
||||
# Related (not stored) - display-only on the line list so shipping /
|
||||
# invoicing operators see the customer's job/PO ref per-line without
|
||||
# navigating up to the order header.
|
||||
x_fc_customer_job_number = fields.Char(
|
||||
related='order_id.x_fc_customer_job_number',
|
||||
string='Customer Job #',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
x_fc_po_number = fields.Char(
|
||||
related='order_id.x_fc_po_number',
|
||||
string='Customer PO #',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
|
||||
x_fc_internal_deadline = fields.Date(
|
||||
related='order_id.x_fc_internal_deadline',
|
||||
string='Internal Deadline',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
x_fc_planned_start_date = fields.Date(
|
||||
related='order_id.x_fc_planned_start_date',
|
||||
string='Planned Start',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
x_fc_internal_note = fields.Html(
|
||||
related='order_id.x_fc_internal_note',
|
||||
string='Internal Note',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
x_fc_external_note = fields.Html(
|
||||
related='order_id.x_fc_external_note',
|
||||
string='External Note',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
|
||||
x_fc_delivery_method = fields.Selection(
|
||||
related='order_id.x_fc_delivery_method',
|
||||
string='Delivery Method',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
x_fc_ship_via = fields.Char(
|
||||
related='order_id.x_fc_ship_via',
|
||||
string='Ship Via',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
x_fc_invoice_strategy = fields.Selection(
|
||||
related='order_id.x_fc_invoice_strategy',
|
||||
string='Invoice Strategy',
|
||||
readonly=True,
|
||||
store=True,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Express Orders backend helpers (Phase B - 2026-05-26)
|
||||
# ============================================================
|
||||
|
||||
def _fp_apply_express_overrides_to_job(self, job):
|
||||
"""Convert Express per-line flags into fp.job.node.override + step instructions.
|
||||
|
||||
Called from sale_order._fp_auto_create_job() immediately after Job.create
|
||||
(creates override rows; step instructions skipped if no steps yet), and
|
||||
again from fp.job.action_confirm() after _generate_steps_from_recipe()
|
||||
(override rows recreate identically; step instructions land this time).
|
||||
|
||||
Idempotent: pre-deletes prior masking/bake override rows on each call.
|
||||
|
||||
Algorithm:
|
||||
- x_fc_masking_enabled=False → opt out of masking + de_masking nodes
|
||||
- x_fc_bake_instructions empty → opt out of baking nodes
|
||||
- x_fc_bake_instructions non-empty → keep baking + write text to step.instructions
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not job or not job.recipe_id:
|
||||
return
|
||||
|
||||
recipe = job.recipe_id
|
||||
Override = self.env['fp.job.node.override'].sudo()
|
||||
|
||||
# Idempotency: clear prior masking/bake override rows on this job
|
||||
prior = Override.search([
|
||||
('job_id', '=', job.id),
|
||||
('node_id.default_kind', 'in', ('mask', 'demask', 'bake')),
|
||||
])
|
||||
if prior:
|
||||
prior.unlink()
|
||||
|
||||
msgs = []
|
||||
|
||||
# 1. Masking - opt out of masking + de_masking AS A PAIR
|
||||
if not self.x_fc_masking_enabled:
|
||||
nodes = recipe._fp_all_nodes_with_kind(('mask', 'demask'))
|
||||
for node in nodes:
|
||||
Override.create({
|
||||
'job_id': job.id,
|
||||
'node_id': node.id,
|
||||
'included': False,
|
||||
})
|
||||
if nodes:
|
||||
msgs.append(_('Masking + de-masking steps opted out (per SO line)'))
|
||||
elif self.x_fc_masking_attachment_ids:
|
||||
# Masking ON + Express reference file(s) attached → surface them on
|
||||
# the mask step so the operator sees what to mask. Lands on the
|
||||
# second call (after steps exist), same as bake below.
|
||||
mask_steps = job.step_ids.filtered(
|
||||
lambda s: s.recipe_node_id.default_kind == 'mask'
|
||||
)
|
||||
if mask_steps:
|
||||
mask_steps.sudo().write({
|
||||
'x_fc_masking_attachment_ids': [(6, 0, self.x_fc_masking_attachment_ids.ids)],
|
||||
})
|
||||
msgs.append(_('Masking reference(s) attached to the mask step: %d file(s)')
|
||||
% len(self.x_fc_masking_attachment_ids))
|
||||
|
||||
# 2. Bake - empty = opt out; non-empty = keep + write step.instructions
|
||||
bake_text = (self.x_fc_bake_instructions or '').strip()
|
||||
bake_nodes = recipe._fp_all_nodes_with_kind(('bake',))
|
||||
if not bake_text:
|
||||
for node in bake_nodes:
|
||||
Override.create({
|
||||
'job_id': job.id,
|
||||
'node_id': node.id,
|
||||
'included': False,
|
||||
})
|
||||
if bake_nodes:
|
||||
msgs.append(_('Baking steps opted out (per SO line)'))
|
||||
else:
|
||||
# Step instructions write only succeeds if steps exist. The
|
||||
# helper is called twice - first call (before action_confirm)
|
||||
# finds no steps and skips; second call (after step gen) lands.
|
||||
bake_steps = job.step_ids.filtered(
|
||||
lambda s: s.recipe_node_id.default_kind == 'bake'
|
||||
)
|
||||
if bake_steps:
|
||||
bake_steps.sudo().write({'instructions': bake_text})
|
||||
msgs.append(_('Bake step instructions set to: %s') % bake_text)
|
||||
|
||||
# 3. Audit chatter post on the job (only on the call that actually wrote)
|
||||
if msgs:
|
||||
job.sudo().message_post(body='\n'.join('• ' + m for m in msgs))
|
||||
|
||||
def action_open_serial_bulk_add(self):
|
||||
"""Open the existing fp.serial.bulk.add.wizard targeting this SO line.
|
||||
|
||||
Express Orders surfaces this as the inline '+ bulk' button on the
|
||||
Part cell's serial row (post-confirm). The wizard model and its
|
||||
action already handle both sale.order.line and fp.direct.order.line.
|
||||
"""
|
||||
self.ensure_one()
|
||||
action = self.env.ref(
|
||||
'fusion_plating_configurator.action_fp_serial_bulk_add_wizard'
|
||||
).read()[0]
|
||||
action['context'] = {
|
||||
'default_target_model': 'sale.order.line',
|
||||
'default_target_id': self.id,
|
||||
'default_qty_expected': int(self.product_uom_qty or 0),
|
||||
}
|
||||
return action
|
||||
|
||||
def action_open_part(self):
|
||||
"""Open the linked fp.part.catalog form in a modal."""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_part_catalog_id:
|
||||
return False
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': self.x_fc_part_catalog_id.display_name,
|
||||
'res_model': 'fp.part.catalog',
|
||||
'views': [[False, 'form']],
|
||||
'view_mode': 'form',
|
||||
'res_id': self.x_fc_part_catalog_id.id,
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_upload_drawing(self):
|
||||
"""Attach a file (via context) to the line's part as a drawing.
|
||||
|
||||
Frontend calling pattern: read file picker → base64-encode →
|
||||
set context['fp_drawing_file'] + context['fp_drawing_filename'] →
|
||||
call this method. The drawing lives on the PART (not the line)
|
||||
so future orders for the same part reuse it.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.x_fc_part_catalog_id:
|
||||
raise UserError(_('Pick or create a part on this line first.'))
|
||||
file_data = self.env.context.get('fp_drawing_file')
|
||||
filename = self.env.context.get('fp_drawing_filename', 'drawing.pdf')
|
||||
if not file_data:
|
||||
raise UserError(_('No file data received.'))
|
||||
att = self.env['ir.attachment'].sudo().create({
|
||||
'name': filename,
|
||||
'datas': file_data,
|
||||
'res_model': 'fp.part.catalog',
|
||||
'res_id': self.x_fc_part_catalog_id.id,
|
||||
})
|
||||
self.x_fc_part_catalog_id.sudo().write({
|
||||
'drawing_attachment_ids': [(4, att.id)],
|
||||
})
|
||||
self.x_fc_part_catalog_id.sudo().message_post(body=_(
|
||||
'Drawing "%(name)s" uploaded by %(user)s from line %(seq)s on SO %(so)s.'
|
||||
) % {
|
||||
'name': filename,
|
||||
'user': self.env.user.display_name,
|
||||
'seq': self.sequence or self.id,
|
||||
'so': self.order_id.name,
|
||||
})
|
||||
return {'type': 'ir.actions.client', 'tag': 'reload'}
|
||||
Reference in New Issue
Block a user