This commit is contained in:
gsinghpal
2026-04-29 03:35:33 -04:00
parent 6ac6d24da6
commit a2fe1fcbcc
61 changed files with 4655 additions and 667 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.18.2.0',
'version': '19.0.18.3.2',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """

View File

@@ -13,6 +13,30 @@ 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',

View File

@@ -25,9 +25,26 @@ class FpCoatingThickness(models.Model):
ondelete='cascade',
)
value = fields.Float(
string='Nominal',
digits=(10, 4),
required=True,
help='Target thickness value (magnitude only; UoM in the next field).',
help='Target / nominal thickness value (the number printed on the cert). '
'Magnitude only — UoM lives in the next field.',
)
# Hitting an exact thickness on plated parts is impossible — the spec
# is always "X mils ± tolerance" or a min/max range. These fields
# capture the acceptance band so QC can mark a reading pass/fail
# against real customer specs (e.g. AMS-2404 Class 4 = 0.001"0.0015").
# Both optional: leave blank for legacy single-value entries.
value_min = fields.Float(
string='Min',
digits=(10, 4),
help='Lower acceptance bound. Readings below this fail QC.',
)
value_max = fields.Float(
string='Max',
digits=(10, 4),
help='Upper acceptance bound. Readings above this fail QC.',
)
uom = fields.Selection(
[('mils', 'mils (0.001 in)'),
@@ -44,7 +61,7 @@ class FpCoatingThickness(models.Model):
store=True,
)
@api.depends('value', 'uom')
@api.depends('value', 'value_min', 'value_max', 'uom')
def _compute_display_name(self):
uom_labels = dict(self._fields['uom'].selection)
for rec in self:
@@ -52,7 +69,22 @@ class FpCoatingThickness(models.Model):
# Strip the bracketed clarification for a tighter dropdown row.
if ' (' in label:
label = label.split(' (')[0]
if rec.value:
# Range overrides single value when both bounds are set —
# operators see the real spec, not a phantom-precise nominal.
if rec.value_min and rec.value_max:
rec.display_name = (
f'{rec.value_min:g}{rec.value_max:g} {label}'.strip()
)
elif rec.value:
rec.display_name = f'{rec.value:g} {label}'.strip()
else:
rec.display_name = label
@api.constrains('value_min', 'value_max')
def _check_range(self):
for rec in self:
if rec.value_min and rec.value_max and rec.value_min > rec.value_max:
from odoo.exceptions import ValidationError
raise ValidationError(_(
'Thickness Min (%(mn)s) cannot exceed Max (%(mx)s).'
) % {'mn': rec.value_min, 'mx': rec.value_max})

View File

@@ -10,6 +10,36 @@ from odoo.exceptions import 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
x_fc_part_catalog_id = fields.Many2one(
'fp.part.catalog', string='Part',
)

View File

@@ -77,7 +77,9 @@
<field name="thickness_option_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/>
<field name="value"/>
<field name="value" string="Nominal"/>
<field name="value_min" string="Min"/>
<field name="value_max" string="Max"/>
<field name="uom"/>
<field name="display_name" string="Display" readonly="1"/>
<field name="active" widget="boolean_toggle"/>

View File

@@ -100,11 +100,39 @@
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Plating" name="plating_tab">
<group>
<group string="Part &amp; Coating">
<field name="x_fc_configurator_id" readonly="1"/>
<!-- Multi-part summary: read-only list of every order line
showing part / coating / process. The Order Lines tab
is the editable surface; this is the at-a-glance view
so you can confirm an order has the right parts/coatings
without scrolling pricing columns. The pre-Sub-12 SO-
header singletons (x_fc_part_catalog_id /
x_fc_coating_config_id) only ever populated when the
order was built via the quote configurator — they're
silent on direct orders, which is why they appeared
empty after confirm. They still exist on the model
(used by configurator/portal) but are no longer the
primary display. -->
<separator string="Parts on this order"/>
<field name="order_line" nolabel="1"
context="{'tree_view_ref': 'fusion_plating_configurator.view_sale_order_line_plating_summary'}"
readonly="1">
<list create="false" delete="false" edit="false">
<field name="x_fc_part_catalog_id"/>
<field name="x_fc_coating_config_id"/>
<field name="x_fc_thickness_id" optional="show"/>
<field name="x_fc_process_variant_id" optional="show"
string="Process"/>
<field name="product_uom_qty" string="Qty"/>
<field name="x_fc_part_deadline" optional="show"
string="Part Deadline"/>
<field name="x_fc_rush_order" optional="hide"/>
<field name="x_fc_job_number" optional="show"
string="Job #"/>
</list>
</field>
<group>
<group string="Configurator (legacy)" invisible="not x_fc_configurator_id">
<field name="x_fc_configurator_id" readonly="1"/>
<field name="x_fc_process_summary" readonly="1"/>
</group>
<group string="RFQ / PO">

View File

@@ -3,6 +3,8 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
@@ -220,23 +222,39 @@ class FpDirectOrderWizard(models.Model):
self._apply_strategy_payment_term()
return
# Legacy partner-field defaults (pre-Sub-5).
if 'x_fc_default_invoice_strategy' in self.partner_id._fields:
self.invoice_strategy = self.partner_id.x_fc_default_invoice_strategy or False
self.deposit_percent = self.partner_id.x_fc_default_deposit_percent or 0.0
# Partner-level plating defaults — primary cascade. Customers
# migrated to the new partner fields skip the legacy lookup below.
partner = self.partner_id
if partner.x_fc_default_invoice_strategy:
self.invoice_strategy = partner.x_fc_default_invoice_strategy
if partner.x_fc_default_deposit_percent:
self.deposit_percent = partner.x_fc_default_deposit_percent
if partner.x_fc_default_delivery_method:
self.delivery_method = partner.x_fc_default_delivery_method
# Deadline auto-fill — anchored to planned_start_date with today
# as fallback. Honours explicit deadlines the user already typed.
anchor = self.planned_start_date or fields.Date.context_today(self)
if (partner.x_fc_default_internal_deadline_days
and not self.internal_deadline):
self.internal_deadline = (
anchor + timedelta(days=partner.x_fc_default_internal_deadline_days)
)
if (partner.x_fc_default_customer_deadline_days
and not self.customer_deadline):
self.customer_deadline = (
anchor + timedelta(days=partner.x_fc_default_customer_deadline_days)
)
# Addresses.
addrs = self.partner_id.address_get(['invoice', 'delivery'])
self.partner_invoice_id = addrs.get('invoice') or self.partner_id.id
self.partner_shipping_id = addrs.get('delivery') or self.partner_id.id
addrs = partner.address_get(['invoice', 'delivery'])
self.partner_invoice_id = addrs.get('invoice') or partner.id
self.partner_shipping_id = addrs.get('delivery') or partner.id
# Per-customer invoice strategy default (fp.invoice.strategy.default).
# Pull strategy + deposit even when payment_term_id is empty — the
# previous condition `if isd and isd.payment_term_id` silently
# skipped the strategy fill for net-terms customers without
# explicit terms configured.
# Legacy fallback: fp.invoice.strategy.default (kept for sites
# mid-migration). Only fills gaps the partner fields didn't cover.
isd = self.env['fp.invoice.strategy.default'].search(
[('partner_id', '=', self.partner_id.id)], limit=1,
[('partner_id', '=', partner.id)], limit=1,
)
term = False
if isd:
@@ -245,8 +263,8 @@ class FpDirectOrderWizard(models.Model):
if not self.deposit_percent:
self.deposit_percent = isd.default_deposit_percent or 0.0
term = isd.payment_term_id
if not term and self.partner_id.property_payment_term_id:
term = self.partner_id.property_payment_term_id
if not term and partner.property_payment_term_id:
term = partner.property_payment_term_id
self.payment_term_id = term or False
# Re-apply strategy → terms mapping after partner switch.
@@ -271,6 +289,29 @@ class FpDirectOrderWizard(models.Model):
"""Map the strategy onto sensible payment terms."""
self._apply_strategy_payment_term()
@api.onchange('planned_start_date')
def _onchange_planned_start_date(self):
"""Recompute deadlines from partner offsets when start moves.
Runs only if the partner has offsets configured AND deadlines
are still blank — typing a manual deadline locks it.
"""
if not self.partner_id or not self.planned_start_date:
return
partner = self.partner_id
if (partner.x_fc_default_internal_deadline_days
and not self.internal_deadline):
self.internal_deadline = (
self.planned_start_date
+ timedelta(days=partner.x_fc_default_internal_deadline_days)
)
if (partner.x_fc_default_customer_deadline_days
and not self.customer_deadline):
self.customer_deadline = (
self.planned_start_date
+ timedelta(days=partner.x_fc_default_customer_deadline_days)
)
def _apply_strategy_payment_term(self):
"""Mapping rule:
- cod_prepay → Immediate Payment
@@ -435,6 +476,12 @@ class FpDirectOrderWizard(models.Model):
[('default_code', '=', 'FP-SERVICE')], limit=1,
)
if not product:
# Seed the product with the company's default sale tax so the
# customer's fiscal position has something to RE-MAP. Without
# this, lines come out tax-free regardless of how the customer's
# fiscal position is configured (fiscal positions only re-map
# existing taxes; they don't manufacture them).
default_sale_tax = self.env.company.account_sale_tax_id
product = self.env['product.product'].create({
'name': 'Plating Service',
'default_code': 'FP-SERVICE',
@@ -442,7 +489,14 @@ class FpDirectOrderWizard(models.Model):
'list_price': 0,
'sale_ok': True,
'purchase_ok': False,
'taxes_id': [(6, 0, default_sale_tax.ids)] if default_sale_tax else False,
})
elif not product.taxes_id and self.env.company.account_sale_tax_id:
# Self-heal: pre-existing FP-SERVICE without taxes (created in
# an earlier version) silently produced tax-free lines. Top up
# with the company default sale tax so customer fiscal positions
# can re-map correctly.
product.taxes_id = [(6, 0, self.env.company.account_sale_tax_id.ids)]
# 3. Build SO header
so_vals = {