This commit is contained in:
gsinghpal
2026-04-27 00:11:18 -04:00
parent d9f58b9851
commit f08f328688
116 changed files with 9891 additions and 359 deletions

View File

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

View File

@@ -167,23 +167,37 @@ class FpPartCatalog(models.Model):
'Compose button to edit. When an order does not pick a '
'specific variant, this one is used.',
)
process_variant_ids = fields.One2many(
# Computed instead of plain One2many because the One2many `domain=`
# was silently NOT being applied — `part.process_variant_ids` was
# returning every node (root + children) for the part instead of
# only the root recipe variants. Computing explicitly via search
# is bulletproof and survives the Odoo 19 ORM rewrites. The store
# is False because the underlying recipe-tree topology can change
# outside this model (composer, drag/drop in editor, etc.) and we
# want fresh reads.
process_variant_ids = fields.Many2many(
'fusion.plating.process.node',
'part_catalog_id',
compute='_compute_process_variant_ids',
string='Process Variants',
domain="[('parent_id', '=', False), ('node_type', '=', 'recipe')]",
help='All recipe variants composed for this part. Each order line '
'picks one (or falls back to the default).',
help='Root recipe variants composed for this part. Each order '
'line picks one (or falls back to the default).',
)
process_variant_count = fields.Integer(
string='Variants',
compute='_compute_process_variant_count',
compute='_compute_process_variant_ids',
)
@api.depends('process_variant_ids')
def _compute_process_variant_count(self):
@api.depends_context('uid')
def _compute_process_variant_ids(self):
Node = self.env['fusion.plating.process.node']
for rec in self:
rec.process_variant_count = len(rec.process_variant_ids)
variants = Node.search([
('part_catalog_id', '=', rec.id),
('parent_id', '=', False),
('node_type', '=', 'recipe'),
])
rec.process_variant_ids = variants
rec.process_variant_count = len(variants)
# ---- Direct-order defaults (Phase C — C4) ----
x_fc_default_coating_config_id = fields.Many2one(
@@ -360,21 +374,25 @@ class FpPartCatalog(models.Model):
[('part_catalog_id', '=', part.id)])
def _compute_workorder_count(self):
SaleOrder = self.env['sale.order']
Production = self.env['mrp.production']
MrpWO = self.env.get('mrp.workorder')
# Sub 11 — MRP gone; count fp.job.step rows scoped to this part's SOs.
for part in self:
part.workorder_count = 0
if 'fp.job' not in self.env or 'fp.job.step' not in self.env:
return
SaleOrder = self.env['sale.order']
Job = self.env['fp.job'].sudo()
Step = self.env['fp.job.step'].sudo()
for part in self:
if MrpWO is None:
part.workorder_count = 0
continue
so_names = SaleOrder.search(
[('x_fc_part_catalog_id', '=', part.id)]
).mapped('name')
if not so_names:
part.workorder_count = 0
continue
mos = Production.search([('origin', 'in', so_names)])
part.workorder_count = sum(len(m.workorder_ids) for m in mos)
jobs = Job.search([('origin', 'in', so_names)])
if not jobs:
continue
part.workorder_count = Step.search_count(
[('job_id', 'in', jobs.ids)])
def _compute_revision_count(self):
for part in self:
@@ -460,18 +478,20 @@ class FpPartCatalog(models.Model):
}
def action_view_workorders(self):
# Sub 11 — MRP gone; navigate to fp.job.step rows scoped to this part.
self.ensure_one()
so_names = self.env['sale.order'].search(
[('x_fc_part_catalog_id', '=', self.id)]
).mapped('name')
mos = self.env['mrp.production'].search([('origin', 'in', so_names)])
wo_ids = mos.mapped('workorder_ids').ids
if 'fp.job' not in self.env or 'fp.job.step' not in self.env:
return False
jobs = self.env['fp.job'].sudo().search([('origin', 'in', so_names)])
return {
'type': 'ir.actions.act_window',
'name': _('Work Orders — %s') % (self.part_number or self.name),
'res_model': 'mrp.workorder',
'domain': [('id', 'in', wo_ids)],
'view_mode': 'list,form,kanban',
'res_model': 'fp.job.step',
'domain': [('job_id', 'in', jobs.ids)],
'view_mode': 'list,form',
}
def action_view_revisions(self):

View File

@@ -605,6 +605,15 @@ class FpQuoteConfigurator(models.Model):
'name': '%s%s (x%d)' % (coating_name, part_name, self.quantity),
'product_uom_qty': self.quantity,
'price_unit': price / self.quantity if self.quantity else price,
# Sub 11 fix — propagate part + coating to the LINE too.
# fusion_plating_jobs._fp_auto_create_job filters lines
# by x_fc_part_catalog_id; without it, no fp.job spawns.
'x_fc_part_catalog_id': (
self.part_catalog_id.id if self.part_catalog_id else False
),
'x_fc_coating_config_id': (
self.coating_config_id.id if self.coating_config_id else False
),
})],
}
so = self.env['sale.order'].create(so_vals)

View File

@@ -146,6 +146,37 @@ class SaleOrder(models.Model):
# 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(
@@ -192,42 +223,45 @@ class SaleOrder(models.Model):
@api.depends('name')
def _compute_wo_completion(self):
"""Batched: one grouped query across all records in 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
WO = self.env['mrp.workorder'].sudo()
rows = WO.read_group(
[('production_id.origin', 'in', names)],
['production_id.origin', 'state'],
['production_id', 'state'],
lazy=False,
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'],
)
# Build {origin: {'done': n, 'total': n}}
# read_group returns production_id as (id, name) tuples; we need
# to translate back to origin. Do a small lookup.
mos = self.env['mrp.production'].sudo().search(
[('origin', 'in', names)]
)
mo_to_origin = {m.id: m.origin for m in mos}
totals = {} # {origin: [total, done]}
for r in rows:
mo_id = r['production_id'][0] if r['production_id'] else False
origin = mo_to_origin.get(mo_id)
for job_rec, state_val, count in rows:
origin = job_to_origin.get(job_rec.id)
if not origin:
continue
cnt = r['__count']
bucket = totals.setdefault(origin, [0, 0])
bucket[0] += cnt
if r['state'] == 'done':
bucket[1] += cnt
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 = '%d/%d' % (done, tot) if tot else '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(

View File

@@ -56,16 +56,16 @@
</button>
</xpath>
<!-- After standard Manufacturing: Active WOs, NCRs, Files, BOM Items, By WO.
BOM Items and By WO are last so Odoo's button box overflows them into More. -->
<xpath expr="//button[@name='action_view_mrp_production']" position="after">
<button name="action_view_workorders"
type="object"
class="oe_stat_button"
icon="fa-cogs">
<field name="x_fc_workorder_count" widget="statinfo"
string="Work Orders"/>
</button>
<!-- Sub 11 — MRP gone. The "Work Orders" button used to count
mrp.workorder; removed because Plating Jobs (added by
fusion_plating_jobs) now counts the canonical fp.job.step
rows. NCRs surfaces only when there's at least one open;
BOM Items and By Job Group only when the SO is actually
multi-part / tagged (otherwise both render one column with
one card — pure noise). Anchored after Transfers; the two
conditional ones go last so the typical clean SO shows
just the meaningful buttons up front. -->
<xpath expr="//button[@name='action_view_pickings']" position="after">
<button name="action_view_ncrs"
type="object"
class="oe_stat_button"
@@ -74,16 +74,29 @@
<field name="x_fc_ncr_count" widget="statinfo"
string="NCRs"/>
</button>
</xpath>
<!-- Push BOM Items / By Job Group to the end of the button
box (after the Plating Jobs / Holds row added by jobs +
quality). They sit hidden by default and only surface
when the SO actually has multi-part lines or job-group
tags. -->
<xpath expr="//div[hasclass('oe_button_box')]" position="inside">
<button name="action_view_bom_items"
type="object"
class="oe_stat_button"
icon="fa-list-alt"
string="BOM Items"/>
invisible="x_fc_distinct_part_count &lt; 2">
<field name="x_fc_distinct_part_count" widget="statinfo"
string="BOM Items"/>
</button>
<button name="action_view_wo_perspective"
type="object"
class="oe_stat_button"
icon="fa-th-large"
string="By WO"/>
invisible="not x_fc_has_wo_group_tag">
<field name="x_fc_wo_group_count" widget="statinfo"
string="Job Groups"/>
</button>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Plating" name="plating_tab">

View File

@@ -116,11 +116,61 @@ class FpDirectOrderLine(models.Model):
@api.onchange('part_catalog_id')
def _onchange_part_clears_variant(self):
"""Clear variant pick when the part changes (variants are part-scoped)."""
"""Clear variant pick when the part changes (variants are part-scoped).
Pre-fill coating + treatments from the part's saved defaults so
Sarah doesn't re-pick the same coating every repeat customer.
Defaults only apply when the line currently has no coating set
— editing an existing line with a chosen coating doesn't get
clobbered.
For BRAND-NEW parts (no defaults saved yet) auto-tick
`push_to_defaults` so Sarah's first coating pick gets persisted
back to the part. Without this Sarah has to remember to tick the
toggle herself, and the second order doesn't pre-fill.
Returns a warning popup explaining what's happening.
"""
warning = None
for rec in self:
# Variant clear (original behaviour).
if (rec.process_variant_id
and rec.process_variant_id.part_catalog_id != rec.part_catalog_id):
rec.process_variant_id = False
if not rec.part_catalog_id:
continue
part = rec.part_catalog_id
has_default_coating = bool(getattr(
part, 'x_fc_default_coating_config_id', False))
has_default_treatments = bool(getattr(
part, 'x_fc_default_treatment_ids', False))
# Pre-fill default coating if the line is empty.
if not rec.coating_config_id and has_default_coating:
rec.coating_config_id = part.x_fc_default_coating_config_id
# Pre-fill default treatments if any are configured.
if not rec.treatment_ids and has_default_treatments:
rec.treatment_ids = [(6, 0, part.x_fc_default_treatment_ids.ids)]
# New-part auto-suggest: if neither default exists, this is
# likely a first-time use of the part. Auto-tick the
# push_to_defaults toggle so whatever Sarah picks becomes
# the saved default — surface a warning popup so she knows.
# `is_one_off` always wins (operator opted out of catalog
# persistence), so don't auto-tick in that case.
if (not has_default_coating
and not has_default_treatments
and not rec.is_one_off
and not rec.push_to_defaults):
rec.push_to_defaults = True
warning = {
'title': _('First-Time Part — Defaults Will Be Saved'),
'message': _(
'%(part)s has no saved coating / treatments. '
'The coating + treatments you pick on this line '
'will be saved as the part\'s defaults so the '
'next order auto-fills them. Untick "Save as '
'Default" on the line if you don\'t want this.'
) % {'part': part.display_name or part.part_number or '(part)'},
}
return {'warning': warning} if warning else None
# ---- Qty / price ----
quantity = fields.Integer(string='Qty', default=1, required=True)

View File

@@ -209,37 +209,63 @@ class FpDirectOrderWizard(models.Model):
# ---- Onchange ----
@api.onchange('partner_id')
def _onchange_partner_id(self):
"""Seed invoice defaults + addresses + payment terms when customer changes."""
if self.partner_id and '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
if self.partner_id:
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
# Seed payment terms: customer's invoice-strategy default wins;
# fallback to partner.property_payment_term_id.
term = False
isd = self.env['fp.invoice.strategy.default'].search(
[('partner_id', '=', self.partner_id.id)], limit=1,
)
if isd and isd.payment_term_id:
term = isd.payment_term_id
# Also seed strategy from the same record if not already set.
if not self.invoice_strategy:
self.invoice_strategy = isd.default_strategy
if not self.deposit_percent:
self.deposit_percent = isd.default_deposit_percent or 0.0
if not term and self.partner_id.property_payment_term_id:
term = self.partner_id.property_payment_term_id
self.payment_term_id = term or False
else:
"""Seed invoice defaults + addresses + payment terms when customer
changes. Also surface an account-hold warning so Sarah doesn't
build a full quote for a customer she can't ship to.
"""
if not self.partner_id:
self.partner_invoice_id = False
self.partner_shipping_id = False
self.payment_term_id = False
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
# 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
# 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.
isd = self.env['fp.invoice.strategy.default'].search(
[('partner_id', '=', self.partner_id.id)], limit=1,
)
term = False
if isd:
if not self.invoice_strategy:
self.invoice_strategy = isd.default_strategy
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
self.payment_term_id = term or False
# Re-apply strategy → terms mapping after partner switch.
self._apply_strategy_payment_term()
# Account-hold early warning. Hard block lives in action_confirm
# but Sarah deserves to know NOW before she builds 5 lines.
if getattr(self.partner_id, 'x_fc_account_hold', False):
return {
'warning': {
'title': _('Customer on Account Hold'),
'message': _(
'%s is currently on account hold. You can still '
'build the quotation, but it cannot be confirmed '
'until the hold is cleared by accounting.'
) % self.partner_id.display_name,
}
}
@api.onchange('invoice_strategy')
def _onchange_invoice_strategy(self):
"""Map the strategy onto sensible payment terms."""
@@ -247,12 +273,15 @@ class FpDirectOrderWizard(models.Model):
def _apply_strategy_payment_term(self):
"""Mapping rule:
- cod_prepay → Immediate Payment (Odoo's stock term)
- deposit / progress / net_terms → keep what the partner default
already gave us; if blank, leave it blank so the user can pick.
Never overwrites an explicit user choice for non-COD strategies —
only fills in when payment_term_id is empty.
- cod_prepay → Immediate Payment
- net_terms / deposit / progress → fall back to a 30-day
term when nothing is set. Without ANY payment term Odoo
blocks invoice posting, which silently strands SOs at the
invoicing step. Better to default to net-30 and let the
estimator override if the customer's terms are different.
Never overwrites an explicit user choice — only fills the gap.
"""
Pt = self.env['account.payment.term']
for rec in self:
if rec.invoice_strategy == 'cod_prepay':
immediate = rec.env.ref(
@@ -261,6 +290,20 @@ class FpDirectOrderWizard(models.Model):
)
if immediate:
rec.payment_term_id = immediate.id
elif rec.invoice_strategy in ('net_terms', 'deposit', 'progress') \
and not rec.payment_term_id:
# Try canonical Net-30, then any term named "30 Days",
# then any term at all as last-ditch.
term = rec.env.ref(
'account.account_payment_term_30days',
raise_if_not_found=False,
)
if not term:
term = Pt.search([('name', 'ilike', '30 Days')], limit=1)
if not term:
term = Pt.search([], limit=1)
if term:
rec.payment_term_id = term.id
# ---- Actions ----
@api.model
@@ -351,6 +394,17 @@ class FpDirectOrderWizard(models.Model):
raise UserError(_('Pick a customer before confirming.'))
if not self.line_ids:
raise UserError(_('Add at least one part line before confirming.'))
# Account-hold hard block — same policy as sale.order.action_confirm
# but enforced earlier so the wizard doesn't waste Sarah's time.
# Manager override allowed via context key fp_skip_account_hold=True.
if (getattr(self.partner_id, 'x_fc_account_hold', False)
and not self.env.context.get('fp_skip_account_hold')
and not self.env.user.has_group(
'fusion_plating.group_fusion_plating_manager')):
raise UserError(_(
'Customer %s is on account hold. Have a manager clear the '
'hold (or override) before creating the order.'
) % self.partner_id.display_name)
# Accept EITHER a PO (document + number) OR the PO Pending
# flag. Customers who haven't sent paperwork yet use Pending;

View File

@@ -141,9 +141,9 @@
<field name="is_missing_info" column_invisible="1"/>
<field name="sequence" widget="handle"/>
<field name="part_catalog_id"
context="{'default_partner_id': parent.partner_id}"
context="{'default_partner_id': parent.partner_id, 'default_revision': 'A'}"
domain="[('partner_id', '=', parent.partner_id), ('is_latest_revision', '=', True)]"
options="{'no_create_edit': True}"/>
options="{'no_quick_create': True}"/>
<field name="description_template_id"
domain="[('part_catalog_id', '=', part_catalog_id)]"
context="{'default_part_catalog_id': part_catalog_id}"