377 lines
13 KiB
Python
377 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
# Part of the Fusion Plating product family.
|
|
|
|
import logging
|
|
from datetime import timedelta
|
|
|
|
from odoo import api, fields, models
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
KPI_TYPE_SELECTION = [
|
|
('otd', 'On-Time Delivery'),
|
|
('dock_to_dock', 'Dock-to-Dock Lead Time'),
|
|
('throughput', 'Daily Throughput'),
|
|
('quality_yield', 'Quality Yield'),
|
|
('bath_uptime', 'Bath Uptime'),
|
|
('rework_count', 'Rework Events by Month'),
|
|
('failed_spec_count', 'Failed Spec Count by Month'),
|
|
('processed_parts', 'Processed Parts by Month'),
|
|
('shipped_parts', 'Shipped Parts by Month'),
|
|
('cost_per_part', 'Average Cost Per Part'),
|
|
('custom', 'Custom'),
|
|
]
|
|
|
|
TREND_SELECTION = [
|
|
('up', 'Up'),
|
|
('down', 'Down'),
|
|
('flat', 'Flat'),
|
|
]
|
|
|
|
|
|
class FpKpi(models.Model):
|
|
"""Plating KPI Definition.
|
|
|
|
Each record represents a single measurable KPI. Auto-type KPIs have
|
|
their daily value computed by the scheduled action; manual KPIs are
|
|
updated by operators or supervisors through the value history.
|
|
"""
|
|
_name = 'fusion.plating.kpi'
|
|
_description = 'Plating KPI Definition'
|
|
_order = 'kpi_type, name'
|
|
|
|
name = fields.Char(
|
|
string='Name',
|
|
required=True,
|
|
help='Human-readable KPI label, e.g. "On-Time Delivery %".',
|
|
)
|
|
code = fields.Char(
|
|
string='Code',
|
|
required=True,
|
|
help='Unique machine identifier, e.g. "otd_pct".',
|
|
)
|
|
facility_id = fields.Many2one(
|
|
'fusion.plating.facility',
|
|
string='Facility',
|
|
help='Leave blank for company-wide.',
|
|
)
|
|
kpi_type = fields.Selection(
|
|
KPI_TYPE_SELECTION,
|
|
string='Type',
|
|
required=True,
|
|
default='custom',
|
|
)
|
|
target_value = fields.Float(
|
|
string='Target',
|
|
)
|
|
uom = fields.Char(
|
|
string='Unit',
|
|
help='Display unit, e.g. "%", "days", "parts".',
|
|
)
|
|
compute_method = fields.Selection(
|
|
[
|
|
('auto', 'Automatic'),
|
|
('manual', 'Manual'),
|
|
],
|
|
string='Compute Method',
|
|
default='manual',
|
|
required=True,
|
|
)
|
|
active = fields.Boolean(
|
|
string='Active',
|
|
default=True,
|
|
)
|
|
company_id = fields.Many2one(
|
|
'res.company',
|
|
string='Company',
|
|
default=lambda self: self.env.company,
|
|
)
|
|
value_ids = fields.One2many(
|
|
'fusion.plating.kpi.value',
|
|
'kpi_id',
|
|
string='Value History',
|
|
)
|
|
current_value = fields.Float(
|
|
string='Current Value',
|
|
compute='_compute_current_value',
|
|
store=False,
|
|
)
|
|
trend = fields.Selection(
|
|
TREND_SELECTION,
|
|
string='Trend',
|
|
compute='_compute_current_value',
|
|
store=False,
|
|
)
|
|
|
|
_sql_constraints = [
|
|
('code_uniq', 'unique(code, company_id)',
|
|
'The KPI code must be unique per company.'),
|
|
]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Computed fields
|
|
# ------------------------------------------------------------------
|
|
@api.depends('value_ids', 'value_ids.value', 'value_ids.date')
|
|
def _compute_current_value(self):
|
|
for kpi in self:
|
|
values = kpi.value_ids.sorted('date', reverse=True)
|
|
if not values:
|
|
kpi.current_value = 0.0
|
|
kpi.trend = 'flat'
|
|
continue
|
|
kpi.current_value = values[0].value
|
|
if len(values) >= 2:
|
|
diff = values[0].value - values[1].value
|
|
if diff > 0.001:
|
|
kpi.trend = 'up'
|
|
elif diff < -0.001:
|
|
kpi.trend = 'down'
|
|
else:
|
|
kpi.trend = 'flat'
|
|
else:
|
|
kpi.trend = 'flat'
|
|
|
|
# ------------------------------------------------------------------
|
|
# Cron entry point
|
|
# ------------------------------------------------------------------
|
|
@api.model
|
|
def _cron_compute_daily(self):
|
|
"""Compute today's value for every active auto-KPI."""
|
|
today = fields.Date.context_today(self)
|
|
kpis = self.search([
|
|
('compute_method', '=', 'auto'),
|
|
('active', '=', True),
|
|
])
|
|
for kpi in kpis:
|
|
try:
|
|
value = kpi._compute_kpi_value(today)
|
|
if value is not None:
|
|
# Upsert: replace today's value if already present
|
|
existing = self.env['fusion.plating.kpi.value'].search([
|
|
('kpi_id', '=', kpi.id),
|
|
('date', '=', today),
|
|
], limit=1)
|
|
if existing:
|
|
existing.value = value
|
|
else:
|
|
self.env['fusion.plating.kpi.value'].create({
|
|
'kpi_id': kpi.id,
|
|
'date': today,
|
|
'value': value,
|
|
})
|
|
except Exception:
|
|
_logger.exception(
|
|
'Failed to compute KPI %s (%s)', kpi.name, kpi.code)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Per-type computation (raw SQL for speed)
|
|
# ------------------------------------------------------------------
|
|
def _compute_kpi_value(self, today):
|
|
"""Dispatch to the correct SQL-based calculator."""
|
|
self.ensure_one()
|
|
method = getattr(self, '_kpi_%s' % self.kpi_type, None)
|
|
if method:
|
|
return method(today)
|
|
return None
|
|
|
|
def _company_clause(self, alias='j'):
|
|
"""Return a SQL WHERE fragment restricting to company."""
|
|
return " AND %s.company_id = %s" % (alias, self.company_id.id)
|
|
|
|
# -- On-Time Delivery --------------------------------------------------
|
|
def _kpi_otd(self, today):
|
|
"""Portal jobs shipped on or before target_ship_date (last 30 d).
|
|
|
|
NOTE: fusion.plating.portal.job has no facility_id — the
|
|
facility filter on this KPI type is therefore ignored.
|
|
"""
|
|
cutoff = today - timedelta(days=30)
|
|
extra = self._company_clause('j')
|
|
self.env.cr.execute("""
|
|
SELECT
|
|
COUNT(*) FILTER (
|
|
WHERE j.actual_ship_date <= j.target_ship_date
|
|
) AS on_time,
|
|
COUNT(*) AS total
|
|
FROM fusion_plating_portal_job j
|
|
WHERE j.state IN ('shipped', 'complete')
|
|
AND j.actual_ship_date IS NOT NULL
|
|
AND j.target_ship_date IS NOT NULL
|
|
AND j.actual_ship_date >= %s
|
|
""" + extra, (cutoff,))
|
|
row = self.env.cr.fetchone()
|
|
if row and row[1]:
|
|
return round(row[0] / row[1] * 100, 2)
|
|
return None
|
|
|
|
# -- Dock-to-Dock Lead Time --------------------------------------------
|
|
def _kpi_dock_to_dock(self, today):
|
|
cutoff = today - timedelta(days=30)
|
|
extra = self._company_clause('j')
|
|
self.env.cr.execute("""
|
|
SELECT AVG(j.actual_ship_date - j.received_date)
|
|
FROM fusion_plating_portal_job j
|
|
WHERE j.state IN ('shipped', 'complete')
|
|
AND j.actual_ship_date IS NOT NULL
|
|
AND j.received_date IS NOT NULL
|
|
AND j.actual_ship_date >= %s
|
|
""" + extra, (cutoff,))
|
|
row = self.env.cr.fetchone()
|
|
if row and row[0] is not None:
|
|
return round(float(row[0]), 2)
|
|
return None
|
|
|
|
# -- Daily Throughput ---------------------------------------------------
|
|
def _kpi_throughput(self, today):
|
|
extra = self._company_clause('j')
|
|
self.env.cr.execute("""
|
|
SELECT COALESCE(SUM(j.quantity), 0)
|
|
FROM fusion_plating_portal_job j
|
|
WHERE j.received_date = %s
|
|
""" + extra, (today,))
|
|
row = self.env.cr.fetchone()
|
|
if row:
|
|
return float(row[0])
|
|
return None
|
|
|
|
# -- Quality Yield % ---------------------------------------------------
|
|
def _kpi_quality_yield(self, today):
|
|
cutoff = today - timedelta(days=30)
|
|
extra_j = self._company_clause('j')
|
|
# Total parts shipped in period
|
|
self.env.cr.execute("""
|
|
SELECT COALESCE(SUM(j.quantity), 0)
|
|
FROM fusion_plating_portal_job j
|
|
WHERE j.state IN ('shipped', 'complete')
|
|
AND j.actual_ship_date >= %s
|
|
""" + extra_j, (cutoff,))
|
|
total_parts = self.env.cr.fetchone()[0]
|
|
if not total_parts:
|
|
return None
|
|
|
|
# Sum of NCR quantity_affected in same window
|
|
# NCR model *does* have facility_id
|
|
extra_n = self._company_clause('n')
|
|
if self.facility_id:
|
|
extra_n += " AND n.facility_id = %s" % self.facility_id.id
|
|
self.env.cr.execute("""
|
|
SELECT COALESCE(SUM(n.quantity_affected), 0)
|
|
FROM fusion_plating_ncr n
|
|
WHERE n.reported_date >= %s
|
|
""" + extra_n, (cutoff,))
|
|
ncr_qty = self.env.cr.fetchone()[0]
|
|
|
|
if total_parts > 0:
|
|
return round((total_parts - ncr_qty) / total_parts * 100, 2)
|
|
return None
|
|
|
|
# -- Bath Uptime % -----------------------------------------------------
|
|
def _kpi_bath_uptime(self, today):
|
|
"""Operational baths / active baths * 100.
|
|
|
|
Bath has facility via tank_id.facility_id — filter is supported.
|
|
"""
|
|
extra = self._company_clause('b')
|
|
if self.facility_id:
|
|
extra += """
|
|
AND b.tank_id IN (
|
|
SELECT t.id FROM fusion_plating_tank t
|
|
WHERE t.facility_id = %s
|
|
)""" % self.facility_id.id
|
|
self.env.cr.execute("""
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE b.state = 'operational') AS up,
|
|
COUNT(*) AS total
|
|
FROM fusion_plating_bath b
|
|
WHERE b.active = TRUE
|
|
""" + extra)
|
|
row = self.env.cr.fetchone()
|
|
if row and row[1]:
|
|
return round(row[0] / row[1] * 100, 2)
|
|
return None
|
|
|
|
# -- Rework Events (current month) -------------------------------------
|
|
def _kpi_rework_count(self, today):
|
|
"""Count NCRs flagged as rework in the current month."""
|
|
month_start = today.replace(day=1)
|
|
extra = self._company_clause('n')
|
|
if self.facility_id:
|
|
extra += " AND n.facility_id = %s" % self.facility_id.id
|
|
self.env.cr.execute("""
|
|
SELECT COUNT(*)
|
|
FROM fusion_plating_ncr n
|
|
WHERE n.reported_date >= %s
|
|
AND n.reported_date <= %s
|
|
AND n.disposition = 'rework'
|
|
""" + extra, (month_start, today))
|
|
row = self.env.cr.fetchone()
|
|
return float(row[0]) if row else None
|
|
|
|
# -- Failed Spec Count (current month) ---------------------------------
|
|
def _kpi_failed_spec_count(self, today):
|
|
"""Count NCRs in the current month (any disposition)."""
|
|
month_start = today.replace(day=1)
|
|
extra = self._company_clause('n')
|
|
if self.facility_id:
|
|
extra += " AND n.facility_id = %s" % self.facility_id.id
|
|
self.env.cr.execute("""
|
|
SELECT COUNT(*)
|
|
FROM fusion_plating_ncr n
|
|
WHERE n.reported_date >= %s
|
|
AND n.reported_date <= %s
|
|
""" + extra, (month_start, today))
|
|
row = self.env.cr.fetchone()
|
|
return float(row[0]) if row else None
|
|
|
|
# -- Processed Parts (current month) -----------------------------------
|
|
def _kpi_processed_parts(self, today):
|
|
"""Sum of quantities received this month."""
|
|
month_start = today.replace(day=1)
|
|
extra = self._company_clause('j')
|
|
self.env.cr.execute("""
|
|
SELECT COALESCE(SUM(j.quantity), 0)
|
|
FROM fusion_plating_portal_job j
|
|
WHERE j.received_date >= %s
|
|
AND j.received_date <= %s
|
|
""" + extra, (month_start, today))
|
|
row = self.env.cr.fetchone()
|
|
return float(row[0]) if row else None
|
|
|
|
# -- Shipped Parts (current month) -------------------------------------
|
|
def _kpi_shipped_parts(self, today):
|
|
"""Sum of quantities shipped this month."""
|
|
month_start = today.replace(day=1)
|
|
extra = self._company_clause('j')
|
|
self.env.cr.execute("""
|
|
SELECT COALESCE(SUM(j.quantity), 0)
|
|
FROM fusion_plating_portal_job j
|
|
WHERE j.state IN ('shipped', 'complete')
|
|
AND j.actual_ship_date >= %s
|
|
AND j.actual_ship_date <= %s
|
|
""" + extra, (month_start, today))
|
|
row = self.env.cr.fetchone()
|
|
return float(row[0]) if row else None
|
|
|
|
# -- Average Cost Per Part (last 30 days) ------------------------------
|
|
def _kpi_cost_per_part(self, today):
|
|
"""Average standard_price for products on shipped jobs (30 d)."""
|
|
cutoff = today - timedelta(days=30)
|
|
extra = self._company_clause('j')
|
|
self.env.cr.execute("""
|
|
SELECT
|
|
COALESCE(SUM(j.quantity), 0),
|
|
COUNT(DISTINCT j.id)
|
|
FROM fusion_plating_portal_job j
|
|
WHERE j.state IN ('shipped', 'complete')
|
|
AND j.actual_ship_date >= %s
|
|
""" + extra, (cutoff,))
|
|
row = self.env.cr.fetchone()
|
|
total_qty = row[0] if row else 0
|
|
if not total_qty:
|
|
return None
|
|
# Use product standard prices from related MO raw materials
|
|
# Fallback: return total shipped qty for now (will refine)
|
|
return round(float(total_qty), 2)
|