This commit is contained in:
gsinghpal
2026-04-26 15:05:17 -04:00
parent 160198edb1
commit d9f58b9851
110 changed files with 6210 additions and 1182 deletions

View File

@@ -5,7 +5,7 @@
{
"name": "Fusion Plating — MRP Bridge",
'version': '19.0.12.2.0',
'version': '19.0.13.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """
@@ -59,33 +59,30 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
],
'data': [
'security/ir.model.access.csv',
'data/fp_work_role_data.xml',
# Phase 1 (Sub 11) — fp_work_role_data + fp_qc_data relocated
# to fusion_plating_jobs.
'data/fp_cron_data.xml',
'data/fp_qc_data.xml',
'wizard/fp_recipe_config_wizard_views.xml',
'views/mrp_workcenter_views.xml',
'views/mrp_workorder_views.xml',
'views/fp_qc_template_views.xml',
'views/fp_quality_check_views.xml',
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs / fusion_plating_quality.
# 'views/fp_qc_template_views.xml',
# 'views/fp_quality_check_views.xml',
# 'views/fp_job_consumption_views.xml',
# 'views/fp_work_role_views.xml',
'views/mrp_production_views.xml',
'views/sale_order_views.xml',
'views/fp_quality_hold_views.xml',
'views/fp_batch_views.xml',
'views/fp_workorder_priority_views.xml',
'views/fp_job_consumption_views.xml',
'views/fp_work_role_views.xml',
'views/res_partner_views.xml',
# Phase 3 (Sub 11) — replaced by native fp.job.step priority kanban
# in fusion_plating_jobs/views/fp_step_priority_views.xml.
# 'views/fp_workorder_priority_views.xml',
# Phase 4 (Sub 11) — relocated to fusion_plating_quality.
# 'views/res_partner_views.xml',
'views/fp_serial_views.xml',
],
'assets': {
'web.assets_backend': [
# Depends on _fp_shopfloor_tokens.scss being loaded first —
# shopfloor is in depends, so its tokens bundle-concatenate
# before this file and define $fp-card / $fp-accent / etc.
'fusion_plating_bridge_mrp/static/src/scss/fp_qc_checklist.scss',
'fusion_plating_bridge_mrp/static/src/xml/fp_qc_checklist.xml',
'fusion_plating_bridge_mrp/static/src/js/fp_qc_checklist.js',
],
# Phase 2 (Sub 11) — QC tablet OWL relocated to fusion_plating_quality.
},
'installable': True,
'application': False,

View File

@@ -2,4 +2,5 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_qc_controller
# Phase 2 (Sub 11) — QC controller relocated to fusion_plating_quality.
# from . import fp_qc_controller

View File

@@ -11,16 +11,35 @@ from . import fp_portal_job
from . import fp_quality_hold
from . import fp_delivery
from . import fp_batch
# fusion.plating.job.node.override (mrp.production-bound) — kept here
# until Phase 5 deletes the bridge module. The native fp.job-bound
# override is `fp.job.node.override` in fusion_plating_jobs (different
# model, different table).
from . import fp_job_node_override
from . import fp_job_consumption
# Phase 1 (Sub 11) — fp.job.consumption is now in fusion_plating_jobs.
# bridge_mrp can't depend on jobs (would create a cycle through
# notifications/reports), so the legacy production_id/workorder_id
# fields are gone for good. mrp.production has 0 rows in native mode
# so the loss of the back-link is data-safe.
# from . import fp_job_consumption
from . import account_move
from . import sale_order
from . import fp_work_role
from . import hr_employee
from . import fp_proficiency
from . import fp_process_node
from . import fp_qc_template
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
# from . import fp_work_role
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
# from . import hr_employee
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
# from . import fp_proficiency
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs (fp.work.role lives there).
# from . import fp_process_node
# Phase 1 (Sub 11) — relocated to fusion_plating_jobs.
# from . import fp_qc_template
# Phase 1 (Sub 11) — model relocated to fusion_plating_quality.
# This file now contains only a thin inherit that restores the
# legacy production_id back-link until Phase 5 retires the bridge.
from . import fp_quality_check
from . import fp_thickness_reading
from . import res_partner
# Phase 1 (Sub 11) — relocated to fusion_plating_quality.
# from . import fp_thickness_reading
# Phase 4 (Sub 11) — relocated to fusion_plating_quality.
# from . import res_partner
from . import fp_serial

View File

@@ -2,85 +2,24 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
#
# Phase 1 (Sub 11) — the model proper now lives in
# fusion_plating_jobs. This file restores the legacy production_id +
# workorder_id back-links so bridge_mrp's mrp.production O2M
# (x_fc_consumption_ids) keeps resolving until Phase 5 deletes the
# bridge module.
from odoo import api, fields, models, _
from odoo import fields, models
class FpJobConsumption(models.Model):
"""A single consumable drawdown charged to a manufacturing order.
Sources include bath replenishment applied against a job, masking tape
rolls, PPE, nickel salts — anything that has a cost and should roll
into job costing.
Kept deliberately lightweight: one row per event, cost derived from
`product.standard_price` at log time (snapshot, not reactive).
"""
_name = 'fp.job.consumption'
_description = 'Fusion Plating — Job Consumption'
_order = 'logged_date desc, id desc'
_inherit = 'fp.job.consumption'
production_id = fields.Many2one(
'mrp.production', string='Manufacturing Order',
required=True, ondelete='cascade',
ondelete='cascade', index=True,
)
workorder_id = fields.Many2one(
'mrp.workorder', string='Work Order',
domain="[('production_id', '=', production_id)]",
)
product_id = fields.Many2one(
'product.product', string='Product', required=True,
domain="[('sale_ok', '=', False)]",
)
product_name = fields.Char(
string='Product Name (snapshot)',
help='Free-text product label if no inventory product is linked.',
)
quantity = fields.Float(string='Quantity', required=True, digits=(12, 3))
uom_id = fields.Many2one(
'uom.uom', string='UoM',
)
currency_id = fields.Many2one(
'res.currency', required=True,
default=lambda self: self.env.company.currency_id,
)
unit_cost = fields.Monetary(
string='Unit Cost (snapshot)', currency_field='currency_id',
help='Taken from product.standard_price at log time.',
)
total_cost = fields.Monetary(
string='Total Cost', currency_field='currency_id',
compute='_compute_total_cost', store=True,
)
logged_date = fields.Datetime(
string='Logged', default=fields.Datetime.now,
)
logged_by_id = fields.Many2one(
'res.users', string='Logged By', default=lambda self: self.env.user,
)
source = fields.Selection(
[('replenishment', 'Bath Replenishment'),
('masking', 'Masking Material'),
('ppe', 'PPE / Consumables'),
('chemistry', 'Process Chemistry'),
('other', 'Other')],
string='Source', default='other', required=True,
)
replenishment_id = fields.Many2one(
'fusion.plating.bath.replenishment.suggestion',
string='Replenishment Suggestion',
ondelete='set null',
)
notes = fields.Char(string='Notes')
@api.depends('quantity', 'unit_cost')
def _compute_total_cost(self):
for rec in self:
rec.total_cost = round((rec.quantity or 0) * (rec.unit_cost or 0), 2)
@api.onchange('product_id')
def _onchange_product(self):
if self.product_id:
self.product_name = self.product_id.display_name
self.unit_cost = self.product_id.standard_price or 0.0
self.uom_id = self.product_id.uom_id or False

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import fields, models
from odoo import api, fields, models
class FpJobNodeOverride(models.Model):
@@ -58,6 +58,14 @@ class FpJobNodeOverride(models.Model):
help='Whether this optional step is active for this job.',
)
@api.depends('production_id', 'node_id', 'included')
def _compute_display_name(self):
for rec in self:
mo = rec.production_id.name or '(no MO)'
node = rec.node_id.display_name or '(no node)'
tag = 'included' if rec.included else 'excluded'
rec.display_name = '%s · %s [%s]' % (mo, node, tag)
_sql_constraints = [
('unique_production_node',
'unique(production_id, node_id)',

View File

@@ -2,621 +2,22 @@
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Per-MO QC instance.
#
# Phase 1 (Sub 11) — the QC model proper now lives in
# fusion_plating_quality. This file restores the legacy production_id
# back-link on fusion.plating.quality.check so bridge_mrp's
# mrp.production O2M (x_fc_qc_check_ids) keeps resolving until Phase 5
# deletes the bridge module entirely.
When an MO confirms and the customer requires QC, we clone the active
checklist template into a `fusion.plating.quality.check` with one line
per template line. The inspector picks it up on the tablet, walks the
checks, and signs off — which unblocks `mrp.production.button_mark_done`.
The QC also owns the Fischerscope / XDAL 600 thickness report PDF.
When the operator uploads one, we extract per-reading data server-side
and auto-create `fp.thickness.reading` rows so the CoC PDF picks them up.
"""
import base64
import logging
import re
import subprocess
import tempfile
from markupsafe import Markup
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
from odoo import fields, models
class FpQualityCheck(models.Model):
_name = 'fusion.plating.quality.check'
_description = 'Fusion Plating — Quality Check'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'create_date desc'
_inherit = 'fusion.plating.quality.check'
name = fields.Char(
string='Reference', required=True, copy=False, readonly=True,
default=lambda self: self._default_name(), tracking=True,
)
production_id = fields.Many2one(
'mrp.production', string='Manufacturing Order',
required=True, ondelete='cascade', tracking=True,
index=True,
ondelete='cascade', index=True,
help='Legacy MRP back-link. Native flow uses job_id; this stays '
'for bridge_mrp until Phase 5 cuts the module.',
)
partner_id = fields.Many2one(
'res.partner', string='Customer',
compute='_compute_partner_id', store=True,
)
template_id = fields.Many2one(
'fp.qc.checklist.template', string='Template',
help='The checklist template these lines were cloned from.',
)
state = fields.Selection(
[
('draft', 'Draft'),
('in_progress', 'In Progress'),
('passed', 'Passed'),
('failed', 'Failed'),
('rework', 'Rework Required'),
],
string='Status', default='draft', required=True, tracking=True,
)
overall_result = fields.Selection(
[('pass', 'Pass'), ('fail', 'Fail'), ('partial', 'Partial Pass')],
string='Result', tracking=True,
help='Summary outcome — set when inspector signs off.',
)
line_ids = fields.One2many(
'fusion.plating.quality.check.line', 'check_id',
string='Check Items',
)
line_count = fields.Integer(compute='_compute_line_stats', store=True)
lines_passed = fields.Integer(compute='_compute_line_stats', store=True)
lines_failed = fields.Integer(compute='_compute_line_stats', store=True)
lines_pending = fields.Integer(compute='_compute_line_stats', store=True)
inspector_id = fields.Many2one(
'res.users', string='Inspector',
help='Whoever signed the QC off. Filled when state moves to '
'passed/failed.',
tracking=True,
)
started_at = fields.Datetime(
string='Started', help='First time inspector opened this check.',
)
completed_at = fields.Datetime(
string='Completed', help='When the check was signed off.',
tracking=True,
)
notes = fields.Html(string='Inspector Notes')
# Fischerscope / XDAL 600 PDF + auto-extracted readings
thickness_report_pdf_id = fields.Many2one(
'ir.attachment', string='Thickness Report PDF',
help='Upload the Fischerscope / XDAL 600 export. On upload we '
'parse the PDF and auto-create fp.thickness.reading rows.',
)
thickness_reading_ids = fields.One2many(
'fp.thickness.reading', 'quality_check_id',
string='Thickness Readings',
)
thickness_reading_count = fields.Integer(
compute='_compute_thickness_count',
)
# Cached gate-policy flags from the template (denormalized so
# button_mark_done doesn't have to reach through a potentially-null
# template).
require_thickness_readings = fields.Boolean(
related='template_id.require_thickness_readings',
store=True, readonly=True,
)
require_thickness_report_pdf = fields.Boolean(
related='template_id.require_thickness_report_pdf',
store=True, readonly=True,
)
require_inspector_signoff = fields.Boolean(
related='template_id.require_inspector_signoff',
store=True, readonly=True,
)
company_id = fields.Many2one(
'res.company', related='production_id.company_id',
store=True, readonly=True,
)
# ------------------------------------------------------------------
# Computed
# ------------------------------------------------------------------
@api.depends('production_id.origin')
def _compute_partner_id(self):
SO = self.env['sale.order']
for rec in self:
partner = False
mo = rec.production_id
if mo and mo.origin:
so = SO.search([('name', '=', mo.origin)], limit=1)
if so:
partner = so.partner_id
rec.partner_id = partner
@api.depends('line_ids.result')
def _compute_line_stats(self):
for rec in self:
rec.line_count = len(rec.line_ids)
rec.lines_passed = len(rec.line_ids.filtered(
lambda l: l.result == 'pass'
))
rec.lines_failed = len(rec.line_ids.filtered(
lambda l: l.result == 'fail'
))
rec.lines_pending = len(rec.line_ids.filtered(
lambda l: l.result in (False, 'pending')
))
@api.depends('thickness_reading_ids')
def _compute_thickness_count(self):
for rec in self:
rec.thickness_reading_count = len(rec.thickness_reading_ids)
# ------------------------------------------------------------------
# Create + sequence
# ------------------------------------------------------------------
@api.model
def _default_name(self):
seq = self.env['ir.sequence'].next_by_code(
'fusion.plating.quality.check',
)
return seq or 'QC/NEW'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals.get('name') == '/':
vals['name'] = self._default_name()
return super().create(vals_list)
# ------------------------------------------------------------------
# Factory — spawn a QC from a template
# ------------------------------------------------------------------
@api.model
def create_for_production(self, production, template=None):
"""Spin up a QC record for an MO, cloning lines from the template.
If no template is passed, we try to resolve one from the MO's
customer. Returns the created check, or an empty recordset if
no template matches (=> no QC required for this customer).
"""
self = self.sudo()
if template is None:
partner = False
if production.origin:
so = self.env['sale.order'].search(
[('name', '=', production.origin)], limit=1,
)
if so:
partner = so.partner_id
template = self.env['fp.qc.checklist.template'].resolve_for_partner(
partner,
)
if not template:
return self.browse() # empty — no QC required
# Avoid duplicates — one active (non-failed) check per MO
existing = self.search([
('production_id', '=', production.id),
('state', '!=', 'failed'),
], limit=1)
if existing:
return existing
check = self.create({
'production_id': production.id,
'template_id': template.id,
'state': 'draft',
})
Line = self.env['fusion.plating.quality.check.line']
for tline in template.line_ids.sorted('sequence'):
Line.create({
'check_id': check.id,
'sequence': tline.sequence,
'name': tline.name,
'description': tline.description,
'check_type': tline.check_type,
'required': tline.required,
'requires_value': tline.requires_value,
'value_min': tline.value_min,
'value_max': tline.value_max,
'value_uom': tline.value_uom,
'requires_photo': tline.requires_photo,
'result': 'pending',
})
production.message_post(
body=_('QC checklist "%s" created — %d items to inspect.') % (
template.name, len(template.line_ids),
),
)
return check
# ------------------------------------------------------------------
# State actions
# ------------------------------------------------------------------
def action_start(self):
for rec in self:
if rec.state == 'draft':
rec.write({
'state': 'in_progress',
'started_at': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
rec.message_post(body=_('QC started.'))
def action_pass(self):
for rec in self:
rec._ensure_all_required_complete()
rec.write({
'state': 'passed',
'overall_result': 'pass',
'completed_at': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
rec.message_post(body=Markup(
'<b>QC PASSED</b> — inspector %s.'
) % self.env.user.name)
def action_fail(self):
for rec in self:
rec.write({
'state': 'failed',
'overall_result': 'fail',
'completed_at': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
rec.message_post(body=Markup(
'<b>QC FAILED</b> — inspector %s.'
) % self.env.user.name)
def action_rework(self):
for rec in self:
rec.write({
'state': 'rework',
'overall_result': 'partial',
'completed_at': fields.Datetime.now(),
'inspector_id': self.env.user.id,
})
rec.message_post(body=_('QC flagged for rework.'))
def action_reset_to_draft(self):
for rec in self:
rec.write({
'state': 'draft',
'overall_result': False,
'completed_at': False,
})
def action_spawn_retry(self):
"""Spin up a fresh QC instance for the same MO.
Used after a failed QC — the original stays in history, the
new one gets the same template applied to a clean slate.
Manager-only via ACL.
"""
self.ensure_one()
if self.state != 'failed':
return # no-op; user can just finish the existing one
new_check = self.sudo().create_for_production(
self.production_id, template=self.template_id,
)
if not new_check:
return False
self.message_post(body=_(
'Retry QC created: %s'
) % new_check.name)
new_check.message_post(body=_(
'Retry of failed QC %s'
) % self.name)
return new_check.action_open_tablet()
def _ensure_all_required_complete(self):
"""Guard for action_pass — every required line must be resolved
to pass or n/a (fail would be handled by action_fail) and any
numeric-value / photo requirements must be honoured."""
for rec in self:
pending = rec.line_ids.filtered(
lambda l: l.required and l.result in (False, 'pending')
)
if pending:
raise UserError(_(
'Cannot pass QC "%(name)s"%(n)d required check '
'item(s) still pending:\n%(items)s'
) % {
'name': rec.name,
'n': len(pending),
'items': '\n'.join(pending.mapped('name')),
})
failed = rec.line_ids.filtered(lambda l: l.result == 'fail')
if failed:
raise UserError(_(
'Cannot pass QC "%(name)s"%(n)d check item(s) '
'failed. Fail the QC instead, or reset those '
'items to pass.'
) % {'name': rec.name, 'n': len(failed)})
# ------------------------------------------------------------------
# Fischerscope PDF upload → auto-extract readings
# ------------------------------------------------------------------
def _on_thickness_pdf_uploaded(self):
"""Parse the attached PDF with `pdftotext` and create
fp.thickness.reading rows.
Fischerscope XDAL 600 / WinFTM reports vary a bit in layout
but consistently print one line per reading with a column for
NiP thickness in mils and another for Ni / P percentages. The
parser is conservative: if a column isn't confidently found,
we skip that reading rather than write garbage.
"""
ThicknessReading = self.env['fp.thickness.reading']
for rec in self:
if not rec.thickness_report_pdf_id:
continue
try:
text = rec._extract_pdf_text(rec.thickness_report_pdf_id)
except Exception:
_logger.exception(
'QC %s: pdftotext extraction failed', rec.name,
)
continue
readings = rec._parse_fischerscope_text(text)
if not readings:
rec.message_post(body=_(
'Thickness report PDF attached but no readings '
'could be extracted automatically. Please enter '
'readings manually.'
))
continue
# Replace any prior auto-extracted readings so re-uploads
# don't stack duplicates.
auto = rec.thickness_reading_ids.filtered(
lambda r: r.auto_extracted
)
auto.unlink()
for idx, row in enumerate(readings, start=1):
ThicknessReading.create({
'quality_check_id': rec.id,
'production_id': rec.production_id.id,
'reading_number': idx,
'nip_mils': row.get('nip_mils', 0.0),
'ni_percent': row.get('ni_percent', 0.0),
'p_percent': row.get('p_percent', 0.0),
'position_label': row.get('position', ''),
'auto_extracted': True,
})
rec.message_post(body=_(
'Extracted %d thickness reading(s) from "%s".'
) % (len(readings), rec.thickness_report_pdf_id.name))
@staticmethod
def _extract_pdf_text(attachment):
"""Run pdftotext on an ir.attachment and return the text."""
raw = base64.b64decode(attachment.datas or b'')
if not raw:
return ''
with tempfile.NamedTemporaryFile(
suffix='.pdf', delete=True,
) as tmp:
tmp.write(raw)
tmp.flush()
try:
result = subprocess.run(
['pdftotext', '-layout', tmp.name, '-'],
capture_output=True, text=True, timeout=30,
)
return result.stdout or ''
except FileNotFoundError:
_logger.warning(
'pdftotext not installed — cannot auto-extract '
'Fischerscope PDF data. Install poppler-utils on '
'the Odoo host.',
)
return ''
@staticmethod
def _parse_fischerscope_text(text):
"""Best-effort Fischerscope WinFTM table parser.
WinFTM single-reading export lines look like:
n=1 0.000843 mils 91.5% Ni 8.5% P 120s
or (with labels bleeding together from the PDF layout):
1 0.000843 91.5 8.5 Pos 1
We match any row that has 14 floating-point numbers after a
reading index. The heuristic stays narrow enough that it won't
eat header rows like "Measuring time 120s" or junk lines.
"""
readings = []
# Row: <index> <nip-mils-or-microns> <ni%> <p%>
# Indices may appear as "n=1", "1.", "1", "N1"
row_re = re.compile(
r'^\s*(?:n\s*=\s*|N\s*)?(\d{1,3})[\s.:]+'
r'([0-9]*\.[0-9]+|\d+)' # nip
r'(?:\s*(?:mils|microns|µm|um))?'
r'[\s|]+'
r'([0-9]*\.?[0-9]+)' # ni%
r'[\s|%]+'
r'([0-9]*\.?[0-9]+)' # p%
r'[\s|%]*'
r'(.*)$',
re.IGNORECASE,
)
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
m = row_re.match(line)
if not m:
continue
try:
idx = int(m.group(1))
nip = float(m.group(2))
ni = float(m.group(3))
p = float(m.group(4))
except (TypeError, ValueError):
continue
# Sanity guards — NiP > 1 mil is unheard of on plating;
# Ni% and P% should sum to ~100.
if not (0 < nip < 1) and not (0 < nip < 30): # 30µm envelope
continue
if not (0 < ni < 100):
continue
if not (0 < p < 30):
continue
# Throw out rows where index is obviously wrong
if idx < 1 or idx > 500:
continue
position = (m.group(5) or '').strip()[:60]
readings.append({
'index': idx,
'nip_mils': nip,
'ni_percent': ni,
'p_percent': p,
'position': position,
})
# Keep only one reading per index (first wins)
seen = set()
dedup = []
for r in readings:
if r['index'] in seen:
continue
seen.add(r['index'])
dedup.append(r)
return dedup
def write(self, vals):
trigger = 'thickness_report_pdf_id' in vals and vals.get(
'thickness_report_pdf_id'
)
res = super().write(vals)
if trigger:
self._on_thickness_pdf_uploaded()
return res
# ------------------------------------------------------------------
# Navigation helpers
# ------------------------------------------------------------------
def action_open_tablet(self):
"""Launch the mobile QC checklist OWL client action."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_qc_checklist',
'name': _('QC — %s') % (self.production_id.name or ''),
'params': {'check_id': self.id},
'target': 'current',
}
class FpQualityCheckLine(models.Model):
_name = 'fusion.plating.quality.check.line'
_description = 'Fusion Plating — Quality Check Line'
_order = 'sequence, id'
check_id = fields.Many2one(
'fusion.plating.quality.check', string='Check',
required=True, ondelete='cascade', index=True,
)
sequence = fields.Integer(default=10)
name = fields.Char(string='Check Item', required=True)
description = fields.Text(string='Guidance')
check_type = fields.Selection(
selection=lambda self: self.env[
'fp.qc.checklist.template.line'
]._fields['check_type'].selection,
string='Type', default='visual',
)
required = fields.Boolean(default=True)
requires_value = fields.Boolean()
value = fields.Float(digits=(12, 4))
value_min = fields.Float(digits=(12, 4))
value_max = fields.Float(digits=(12, 4))
value_uom = fields.Char(string='Unit')
requires_photo = fields.Boolean()
photo_attachment_id = fields.Many2one(
'ir.attachment', string='Photo',
)
result = fields.Selection(
[
('pending', 'Pending'),
('pass', 'Pass'),
('fail', 'Fail'),
('na', 'N/A'),
],
string='Result', default='pending', required=True,
)
notes = fields.Text(string='Note')
inspector_id = fields.Many2one('res.users', string='Inspector')
completed_at = fields.Datetime(string='Completed At')
value_in_range = fields.Boolean(
compute='_compute_value_in_range', store=True,
)
@api.depends('value', 'value_min', 'value_max', 'requires_value')
def _compute_value_in_range(self):
for rec in self:
if not rec.requires_value:
rec.value_in_range = True
continue
vmin = rec.value_min
vmax = rec.value_max
if vmin and rec.value < vmin:
rec.value_in_range = False
elif vmax and rec.value > vmax:
rec.value_in_range = False
else:
rec.value_in_range = True
def action_mark_pass(self):
for rec in self:
if rec.requires_value and not rec.value_in_range:
raise UserError(_(
'Cannot pass "%(item)s" — value %(val)s is outside '
'the acceptance range (%(min)s %(max)s %(uom)s).'
) % {
'item': rec.name,
'val': rec.value,
'min': rec.value_min,
'max': rec.value_max,
'uom': rec.value_uom or '',
})
if rec.requires_photo and not rec.photo_attachment_id:
raise UserError(_(
'Cannot pass "%(item)s" — a photo is required.'
) % {'item': rec.name})
rec.write({
'result': 'pass',
'inspector_id': self.env.user.id,
'completed_at': fields.Datetime.now(),
})
def action_mark_fail(self):
for rec in self:
rec.write({
'result': 'fail',
'inspector_id': self.env.user.id,
'completed_at': fields.Datetime.now(),
})
def action_mark_na(self):
for rec in self:
if rec.required:
raise UserError(_(
'"%(item)s" is a required check and cannot be '
'marked N/A.'
) % {'item': rec.name})
rec.write({
'result': 'na',
'inspector_id': self.env.user.id,
'completed_at': fields.Datetime.now(),
})

View File

@@ -170,10 +170,12 @@ class MrpProduction(models.Model):
# T3.3 — Actuals vs quoted margin
# T3.4 — Consumables tied to jobs
# ------------------------------------------------------------------
x_fc_consumption_ids = fields.One2many(
'fp.job.consumption', 'production_id',
string='Consumables Log',
)
# Phase 1 (Sub 11) — fp.job.consumption relocated to
# fusion_plating_jobs. The MO-side O2M would create a circular
# dependency (bridge_mrp → jobs → notifications → bridge_mrp), and
# mrp.production has 0 rows in native mode, so the field is gone.
# The native fp.job analogue carries consumption via
# fp.job.consumption.job_id.
x_fc_consumables_cost = fields.Monetary(
string='Consumables Cost', compute='_compute_job_costs',
store=True, currency_field='x_fc_currency_id',
@@ -228,7 +230,7 @@ class MrpProduction(models.Model):
def _compute_consumption_count(self):
for mo in self:
mo.x_fc_consumption_count = len(mo.x_fc_consumption_ids)
mo.x_fc_consumption_count = 0
@api.depends('origin')
def _compute_sale_order_id(self):
@@ -305,7 +307,6 @@ class MrpProduction(models.Model):
}
@api.depends(
'x_fc_consumption_ids.total_cost',
'workorder_ids.duration',
'workorder_ids.workcenter_id.costs_hour',
'origin',
@@ -314,7 +315,8 @@ class MrpProduction(models.Model):
SO = self.env['sale.order']
for mo in self:
currency = mo.company_id.currency_id
consumables = sum(mo.x_fc_consumption_ids.mapped('total_cost'))
# Phase 1 (Sub 11) — consumption now lives on fp.job, not MO.
consumables = 0.0
labour = 0.0
for wo in mo.workorder_ids:
rate = wo.workcenter_id.costs_hour or 0.0
@@ -1218,29 +1220,35 @@ class MrpProduction(models.Model):
def _resolve_mo_process_tree(self):
"""Resolve which process-tree root to walk for this MO.
Sub 3 — prefers the linked part's cloned tree
(SO line's x_fc_part_catalog_id.default_process_id); falls back
to the legacy x_fc_recipe_id for MOs without a linked part or
without a composed part tree.
Resolution priority (Sub 9 — process variants):
1. SO line's `x_fc_process_variant_id` (per-order variant pick)
2. Linked part's `default_process_id` (the part's default variant)
3. Legacy `x_fc_recipe_id` (coating config / product match)
Single entry point so Sub 4 / Sub 5 updates touch one method.
Multi-line MOs: first line wins. Variants are part-scoped, and a
single MO is bound to a single part group via x_fc_wo_group_tag,
so first-line semantics match how the WO walker batches.
"""
self.ensure_one()
# Resolve part via SO lines (MO's origin → sale.order → first
# line's part). mrp.production has no direct part link; the
# relationship lives on sale.order.line.
part = False
if self.origin:
line = False
if 'x_fc_sale_order_line_ids' in self._fields and self.x_fc_sale_order_line_ids:
line = self.x_fc_sale_order_line_ids[0]
elif self.origin:
so = self.env['sale.order'].search(
[('name', '=', self.origin)], limit=1,
)
if so and so.order_line:
first_line = so.order_line[0]
if 'x_fc_part_catalog_id' in first_line._fields:
part = first_line.x_fc_part_catalog_id
if part and part.default_process_id:
return part.default_process_id
# Fallback — legacy recipe lookup (coating config / product match)
line = so.order_line[0]
if line:
if ('x_fc_process_variant_id' in line._fields
and line.x_fc_process_variant_id):
return line.x_fc_process_variant_id
if ('x_fc_part_catalog_id' in line._fields
and line.x_fc_part_catalog_id
and line.x_fc_part_catalog_id.default_process_id):
return line.x_fc_part_catalog_id.default_process_id
return self.x_fc_recipe_id
# ------------------------------------------------------------------

View File

@@ -159,11 +159,10 @@ class MrpWorkorder(models.Model):
'manager; the Tablet Station shows only WOs assigned to the '
'logged-in user.',
)
x_fc_work_role_id = fields.Many2one(
'fp.work.role', string='Role',
help='Shop role required to perform this step (copied from the '
'recipe operation on WO generation).',
)
# Phase 1 (Sub 11) — fp.work.role relocated to fusion_plating_jobs.
# bridge_mrp can't depend on jobs (cycle through notifications →
# bridge_mrp), so the legacy WO field is gone. mrp.workorder has 0
# rows in native mode, so nothing breaks.
# ------------------------------------------------------------------
# Timer audit — surface the who / when of the timer on the WO header.

View File

@@ -45,17 +45,17 @@ class SaleOrder(models.Model):
# ------------------------------------------------------------------
x_fc_workflow_stage = fields.Selection(
[
('draft', 'Quotation — awaiting confirmation'),
('awaiting_parts', 'Parts en route'),
('inspecting', 'Inspecting received parts'),
('accept_parts', 'Ready to accept parts'),
('assign_work', 'Ready to assign manager'),
('in_production', 'In production'),
('ready_to_ship', 'Production complete — ready to ship'),
('shipped', 'Shipped — awaiting invoice'),
('invoicing', 'Awaiting invoice / payment'),
('draft', 'Quote'),
('awaiting_parts', 'Parts'),
('inspecting', 'Inspecting'),
('accept_parts', 'Accept'),
('assign_work', 'Assign'),
('in_production', 'Production'),
('ready_to_ship', 'Ready'),
('shipped', 'Shipped'),
('invoicing', 'Invoicing'),
('paid', 'Paid'),
('complete', 'Complete'),
('complete', 'Done'),
('cancelled', 'Cancelled'),
],
compute='_compute_workflow_stage',
@@ -199,13 +199,30 @@ class SaleOrder(models.Model):
) % (tag or 'single-line'))
continue
# Recipe: first line's coating -> recipe_id.
# Recipe priority (Sub 9):
# 1. Line's explicit process variant
# 2. Line's part default variant
# 3. Line's coating recipe_id
# 4. Any recipe-type process node (last-ditch fallback)
recipe = False
for ln in lines:
cc = ln.x_fc_coating_config_id
if cc and 'recipe_id' in cc._fields and cc.recipe_id:
recipe = cc.recipe_id
if ('x_fc_process_variant_id' in ln._fields
and ln.x_fc_process_variant_id):
recipe = ln.x_fc_process_variant_id
break
if not recipe:
for ln in lines:
pc = ln.x_fc_part_catalog_id
if (pc and 'default_process_id' in pc._fields
and pc.default_process_id):
recipe = pc.default_process_id
break
if not recipe:
for ln in lines:
cc = ln.x_fc_coating_config_id
if cc and 'recipe_id' in cc._fields and cc.recipe_id:
recipe = cc.recipe_id
break
if not recipe:
recipe = self.env['fusion.plating.process.node'].search(
[('node_type', '=', 'recipe')], limit=1,

View File

@@ -5,30 +5,10 @@ access_fp_bridge_mrp_workorder_manager,fp.bridge.mrp.workorder.manager,mrp_worko
access_fp_bridge_mrp_workorder_supervisor,fp.bridge.mrp.workorder.supervisor,mrp_workorder.model_mrp_workorder,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
access_fp_bridge_mrp_production_manager,fp.bridge.mrp.production.manager,mrp.model_mrp_production,fusion_plating.group_fusion_plating_manager,1,1,1,0
access_fp_bridge_mrp_production_supervisor,fp.bridge.mrp.production.supervisor,mrp.model_mrp_production,fusion_plating.group_fusion_plating_supervisor,1,0,0,0
access_fp_job_node_override_operator,fp.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_job_node_override_supervisor,fp.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_node_override_manager,fp.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_recipe_config_wizard_supervisor,fp.recipe.config.wizard.supervisor,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_recipe_config_wizard_manager,fp.recipe.config.wizard.manager,model_fp_recipe_config_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_recipe_config_wizard_line_supervisor,fp.recipe.config.wizard.line.supervisor,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_recipe_config_wizard_line_manager,fp.recipe.config.wizard.line.manager,model_fp_recipe_config_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_consumption_operator,fp.job.consumption.operator,model_fp_job_consumption,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_job_consumption_supervisor,fp.job.consumption.supervisor,model_fp_job_consumption,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_consumption_manager,fp.job.consumption.manager,model_fp_job_consumption,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_work_role_operator,fp.work.role.operator,model_fp_work_role,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_work_role_manager,fp.work.role.manager,model_fp_work_role,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_proficiency_operator,fp.operator.proficiency.operator,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_proficiency_supervisor,fp.operator.proficiency.supervisor,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_proficiency_manager,fp.operator.proficiency.manager,model_fp_operator_proficiency,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_template_operator,fp.qc.checklist.template.operator,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_qc_template_supervisor,fp.qc.checklist.template.supervisor,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_template_manager,fp.qc.checklist.template.manager,model_fp_qc_checklist_template,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_template_line_operator,fp.qc.checklist.template.line.operator,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_qc_template_line_supervisor,fp.qc.checklist.template.line.supervisor,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_template_line_manager,fp.qc.checklist.template.line.manager,model_fp_qc_checklist_template_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_check_operator,fusion.plating.quality.check.operator,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_qc_check_supervisor,fusion.plating.quality.check.supervisor,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_check_manager,fusion.plating.quality.check.manager,model_fusion_plating_quality_check,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_qc_check_line_operator,fusion.plating.quality.check.line.operator,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_qc_check_line_supervisor,fusion.plating.quality.check.line.supervisor,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_qc_check_line_manager,fusion.plating.quality.check.line.manager,model_fusion_plating_quality_check_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_node_override_legacy_operator,fusion.plating.job.node.override.operator,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_job_node_override_legacy_supervisor,fusion.plating.job.node.override.supervisor,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_job_node_override_legacy_manager,fusion.plating.job.node.override.manager,model_fusion_plating_job_node_override,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_fp_bridge_mrp_workorder_supervisor fp.bridge.mrp.workorder.supervisor mrp_workorder.model_mrp_workorder fusion_plating.group_fusion_plating_supervisor 1 0 0 0
6 access_fp_bridge_mrp_production_manager fp.bridge.mrp.production.manager mrp.model_mrp_production fusion_plating.group_fusion_plating_manager 1 1 1 0
7 access_fp_bridge_mrp_production_supervisor fp.bridge.mrp.production.supervisor mrp.model_mrp_production fusion_plating.group_fusion_plating_supervisor 1 0 0 0
access_fp_job_node_override_operator fp.job.node.override.operator model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_job_node_override_supervisor fp.job.node.override.supervisor model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_supervisor 1 1 1 0
access_fp_job_node_override_manager fp.job.node.override.manager model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_recipe_config_wizard_supervisor fp.recipe.config.wizard.supervisor model_fp_recipe_config_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 0
9 access_fp_recipe_config_wizard_manager fp.recipe.config.wizard.manager model_fp_recipe_config_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
10 access_fp_recipe_config_wizard_line_supervisor fp.recipe.config.wizard.line.supervisor model_fp_recipe_config_wizard_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
11 access_fp_recipe_config_wizard_line_manager fp.recipe.config.wizard.line.manager model_fp_recipe_config_wizard_line fusion_plating.group_fusion_plating_manager 1 1 1 1
12 access_fp_job_consumption_operator access_fp_job_node_override_legacy_operator fp.job.consumption.operator fusion.plating.job.node.override.operator model_fp_job_consumption model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_operator 1 1 0 1 0 0
13 access_fp_job_consumption_supervisor access_fp_job_node_override_legacy_supervisor fp.job.consumption.supervisor fusion.plating.job.node.override.supervisor model_fp_job_consumption model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_supervisor 1 1 1 0
14 access_fp_job_consumption_manager access_fp_job_node_override_legacy_manager fp.job.consumption.manager fusion.plating.job.node.override.manager model_fp_job_consumption model_fusion_plating_job_node_override fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_work_role_operator fp.work.role.operator model_fp_work_role fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_work_role_manager fp.work.role.manager model_fp_work_role fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_proficiency_operator fp.operator.proficiency.operator model_fp_operator_proficiency fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_proficiency_supervisor fp.operator.proficiency.supervisor model_fp_operator_proficiency fusion_plating.group_fusion_plating_supervisor 1 1 1 0
access_fp_proficiency_manager fp.operator.proficiency.manager model_fp_operator_proficiency fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_qc_template_operator fp.qc.checklist.template.operator model_fp_qc_checklist_template fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_qc_template_supervisor fp.qc.checklist.template.supervisor model_fp_qc_checklist_template fusion_plating.group_fusion_plating_supervisor 1 1 1 0
access_fp_qc_template_manager fp.qc.checklist.template.manager model_fp_qc_checklist_template fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_qc_template_line_operator fp.qc.checklist.template.line.operator model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_operator 1 0 0 0
access_fp_qc_template_line_supervisor fp.qc.checklist.template.line.supervisor model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
access_fp_qc_template_line_manager fp.qc.checklist.template.line.manager model_fp_qc_checklist_template_line fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_qc_check_operator fusion.plating.quality.check.operator model_fusion_plating_quality_check fusion_plating.group_fusion_plating_operator 1 1 1 0
access_fp_qc_check_supervisor fusion.plating.quality.check.supervisor model_fusion_plating_quality_check fusion_plating.group_fusion_plating_supervisor 1 1 1 0
access_fp_qc_check_manager fusion.plating.quality.check.manager model_fusion_plating_quality_check fusion_plating.group_fusion_plating_manager 1 1 1 1
access_fp_qc_check_line_operator fusion.plating.quality.check.line.operator model_fusion_plating_quality_check_line fusion_plating.group_fusion_plating_operator 1 1 1 0
access_fp_qc_check_line_supervisor fusion.plating.quality.check.line.supervisor model_fusion_plating_quality_check_line fusion_plating.group_fusion_plating_supervisor 1 1 1 0
access_fp_qc_check_line_manager fusion.plating.quality.check.line.manager model_fusion_plating_quality_check_line fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -95,7 +95,6 @@
string="Assigned To"
required="1"
options="{'no_create': True}"/>
<field name="x_fc_work_role_id" readonly="1"/>
<field name="x_fc_wo_kind" widget="badge" readonly="1"
decoration-info="x_fc_wo_kind == 'wet'"
decoration-warning="x_fc_wo_kind == 'bake'"

View File

@@ -14,11 +14,12 @@
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- Manufacturing: right after Transfers (from configurator). -->
<!-- Manufacturing: right after Transfers (from configurator).
Always visible (no invisible-on-zero) so users have a
navigation entry point even when the SO has no MO yet. -->
<xpath expr="//button[@name='action_view_pickings']" position="after">
<button name="action_view_productions" type="object"
class="oe_stat_button" icon="fa-industry"
invisible="x_fc_production_count == 0">
class="oe_stat_button" icon="fa-industry">
<field name="x_fc_production_count" widget="statinfo"
string="Manufacturing"/>
</button>
@@ -53,12 +54,20 @@
</button>
</xpath>
<!-- Hide Odoo's default state statusbar — replaced below by
the custom plating workflow statusbar that reflects the
real lifecycle (awaiting parts → in production → shipped → ...). -->
<xpath expr="//header//field[@name='state']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<!-- ===== Contextual workflow buttons on the header =====
One (sometimes two) visible at a time. Pattern mirrors
fusion_claims ADP handling — invisible bindings key off
the computed x_fc_workflow_stage selector. -->
<xpath expr="//header" position="inside">
<field name="x_fc_workflow_stage" invisible="1"/>
<field name="x_fc_workflow_stage" widget="statusbar"
statusbar_visible="draft,awaiting_parts,inspecting,in_production,ready_to_ship,shipped,invoicing,complete"/>
<field name="x_fc_assigned_manager_id" invisible="1"/>
<button name="action_fp_mark_inspected"