changes
This commit is contained in:
@@ -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': """
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -100,11 +100,39 @@
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Plating" name="plating_tab">
|
||||
<group>
|
||||
<group string="Part & 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">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user