changes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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 1–4 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(),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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'"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user