# -*- 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)