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

@@ -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(