Perubahan SCADA dan KPI Staging CRM

This commit is contained in:
2026-04-14 19:59:19 +07:00
parent e0cceeae5a
commit f8ba29b2ff
12 changed files with 525 additions and 148 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
'name': 'SCADA for Odoo - Manufacturing Integration',
'version': '14.0.7.2.1',
'version': '14.0.7.2.2',
'category': 'manufacturing',
'license': 'LGPL-3',
'author': 'PT. Gagak Rimang Teknologi',
+270 -21
View File
@@ -198,6 +198,56 @@ class ScadaJsonRpcController(http.Controller):
return row.get(key)
return default
def _calc_deviation_percent(self, actual_qty, target_qty):
if not target_qty:
return 0.0 if not actual_qty else 100.0
return ((actual_qty - target_qty) / target_qty) * 100.0
def _calc_silo_oee_percent(self, actual_qty, target_qty):
deviation_percent = self._calc_deviation_percent(actual_qty, target_qty)
return max(0.0, 100.0 - abs(deviation_percent))
def _build_material_stats(self, row):
actual_qty = self._read_group_metric(row, 'consumption_actual', 'sum', 0.0)
bom_qty = self._read_group_metric(row, 'consumption_bom', 'sum', 0.0)
variance_qty = actual_qty - bom_qty
return {
'qty_actual_consumption': actual_qty,
'qty_bom_consumption': bom_qty,
'variance_consumption': variance_qty,
'consumption_ratio': ((actual_qty / bom_qty) * 100.0) if bom_qty else 0.0,
'avg_oee_silo_percent': self._calc_silo_oee_percent(actual_qty, bom_qty),
'max_abs_deviation_percent': abs(self._calc_deviation_percent(actual_qty, bom_qty)),
'oee_records_count': self._read_group_metric(row, 'id', 'count', 0),
'last_date': self._read_group_metric(row, 'timestamp', 'max', None),
}
def _get_equipment_material_group_map(self, date_from=None, date_to=None, equipment_ids=None):
domain = []
if equipment_ids:
domain.append(('equipment_id', 'in', equipment_ids))
if date_from:
domain.append(('timestamp', '>=', date_from))
if date_to:
domain.append(('timestamp', '<=', date_to))
groups = request.env['scada.equipment.material'].read_group(
domain,
[
'equipment_id',
'consumption_actual:sum',
'consumption_bom:sum',
'timestamp:max',
'id:count',
],
['equipment_id'],
lazy=False,
)
return {
row['equipment_id'][0]: row
for row in groups if row.get('equipment_id')
}
def _authenticate_session(self, login, password, dbname=None):
db = dbname or request.session.db or request.env.cr.dbname
uid = request.session.authenticate(db, login, password)
@@ -851,6 +901,104 @@ class ScadaJsonRpcController(http.Controller):
'lines': lines,
})
if not result_data:
material_domain = []
if mo_value:
material_domain.append(('manufacturing_order_id', '=', mo_id))
if equipment_code:
material_domain.append(('equipment_id', '=', equipment.id))
if normalized_from:
material_domain.append(('timestamp', '>=', normalized_from))
if normalized_to:
material_domain.append(('timestamp', '<=', normalized_to))
material_records = request.env['scada.equipment.material'].search(
material_domain,
order='timestamp desc, id desc',
limit=limit,
offset=offset,
)
grouped_material = defaultdict(list)
for material_record in material_records:
key = (
material_record.manufacturing_order_id.id or 0,
material_record.equipment_id.id or 0,
)
grouped_material[key].append(material_record)
for records_by_key in grouped_material.values():
first_record = records_by_key[0]
mo_record = first_record.manufacturing_order_id
detail_lines = []
line_map = defaultdict(lambda: {
'to_consume': 0.0,
'actual_consumed': 0.0,
})
for material_record in records_by_key:
product = material_record.product_id
line_key = product.id or 0
line_map[line_key]['product_id'] = product.id if product else None
line_map[line_key]['product_name'] = product.display_name if product else None
line_map[line_key]['to_consume'] += material_record.consumption_bom or 0.0
line_map[line_key]['actual_consumed'] += material_record.consumption_actual or 0.0
qty_actual_consumption = 0.0
qty_bom_consumption = 0.0
for line_data in line_map.values():
to_consume = line_data['to_consume']
actual_consumed = line_data['actual_consumed']
qty_actual_consumption += actual_consumed
qty_bom_consumption += to_consume
detail_lines.append({
'equipment_id': first_record.equipment_id.id if first_record.equipment_id else None,
'equipment_code': first_record.equipment_id.equipment_code if first_record.equipment_id else None,
'equipment_name': first_record.equipment_id.name if first_record.equipment_id else None,
'product_id': line_data.get('product_id'),
'product_name': line_data.get('product_name'),
'to_consume': to_consume,
'actual_consumed': actual_consumed,
'variance': actual_consumed - to_consume,
'consumption_ratio': ((actual_consumed / to_consume) * 100.0) if to_consume else 0.0,
'material_count': 1,
})
qty_finished = 0.0
qty_planned = 0.0
yield_percent = 0.0
product_id = first_record.product_id.id if first_record.product_id else None
product_name = first_record.product_id.display_name if first_record.product_id else None
if mo_record:
qty_planned = mo_record.product_qty or 0.0
qty_finished = sum(
move.quantity_done for move in mo_record.move_finished_ids if move.state != 'cancel'
)
yield_percent = ((qty_finished / qty_planned) * 100.0) if qty_planned else 0.0
product_id = mo_record.product_id.id if mo_record.product_id else product_id
product_name = mo_record.product_id.display_name if mo_record.product_id else product_name
result_data.append({
'oee_id': None,
'date_done': first_record.timestamp.isoformat() if first_record.timestamp else None,
'mo_id': mo_record.name if mo_record else None,
'mo_record_id': mo_record.id if mo_record else None,
'equipment': self._get_equipment_details(first_record.equipment_id),
'product_id': product_id,
'product_name': product_name,
'qty_planned': qty_planned,
'qty_finished': qty_finished,
'variance_finished': qty_finished - qty_planned,
'yield_percent': yield_percent,
'qty_bom_consumption': qty_bom_consumption,
'qty_actual_consumption': qty_actual_consumption,
'variance_consumption': qty_actual_consumption - qty_bom_consumption,
'consumption_ratio': (
(qty_actual_consumption / qty_bom_consumption) * 100.0
if qty_bom_consumption else 0.0
),
'lines': detail_lines,
})
return {
'status': 'success',
'count': len(result_data),
@@ -982,11 +1130,18 @@ class ScadaJsonRpcController(http.Controller):
group['equipment_id'][0]: group
for group in line_groups if group.get('equipment_id')
}
material_map = self._get_equipment_material_group_map(
date_from=normalized_from,
date_to=normalized_to,
equipment_ids=equipment_ids,
)
result_data = []
for equipment in equipments:
oee_stat = oee_map.get(equipment.id, {})
line_stat = line_map.get(equipment.id, {})
material_stat = material_map.get(equipment.id, {})
material_summary = self._build_material_stats(material_stat) if material_stat else {}
# For SILO equipment: if no header OEE but has line OEE, count line records
# For Main PLC: use header count
@@ -994,20 +1149,30 @@ class ScadaJsonRpcController(http.Controller):
if oee_records_count == 0 and line_stat:
# SILO case: count number of OEE line records for this equipment
oee_records_count = self._read_group_metric(line_stat, 'id', 'count', 0)
if oee_records_count == 0 and material_summary:
oee_records_count = material_summary.get('oee_records_count', 0)
has_header_stats = bool(oee_stat)
if has_header_stats:
yield_percent = self._read_group_metric(oee_stat, 'yield_percent', 'avg', 0.0)
consumption_ratio = self._read_group_metric(oee_stat, 'consumption_ratio', 'avg', 0.0)
else:
avg_oee_silo_percent = self._read_group_metric(oee_stat, 'avg_silo_oee_percent', 'avg', 0.0)
elif line_stat:
# SILO/LQ level stats are stored in line model
yield_percent = self._read_group_metric(line_stat, 'oee_silo_percent', 'avg', 0.0)
consumption_ratio = self._read_group_metric(line_stat, 'consumption_ratio', 'avg', 0.0)
avg_oee_silo_percent = yield_percent
else:
yield_percent = material_summary.get('avg_oee_silo_percent', 0.0)
consumption_ratio = material_summary.get('consumption_ratio', 0.0)
avg_oee_silo_percent = material_summary.get('avg_oee_silo_percent', 0.0)
last_oee_date = self._read_group_metric(oee_stat, 'date_done', 'max', None)
if not last_oee_date:
last_oee_date = self._read_group_metric(line_stat, 'oee_id.date_done', 'max', None)
if not last_oee_date:
last_oee_date = material_summary.get('last_date')
# Return FLAT structure matching frontend mapper expectations (same format as today-reports by_equipment)
result_data.append({
@@ -1016,26 +1181,65 @@ class ScadaJsonRpcController(http.Controller):
'oee_records_count': oee_records_count,
'avg_yield_percent': yield_percent,
'avg_consumption_ratio': consumption_ratio,
'avg_oee_silo_percent': yield_percent, # For compatibility
'avg_oee_silo_percent': avg_oee_silo_percent,
'last_oee_date': last_oee_date,
# Keep nested structure for backward compatibility with API docs
'equipment': self._get_equipment_details(equipment),
'avg_summary': {
'qty_planned': self._read_group_metric(oee_stat, 'qty_planned', 'avg', 0.0) or self._read_group_metric(line_stat, 'qty_to_consume', 'avg', 0.0),
'qty_finished': self._read_group_metric(oee_stat, 'qty_finished', 'avg', 0.0) or self._read_group_metric(line_stat, 'qty_consumed', 'avg', 0.0),
'variance_finished': self._read_group_metric(oee_stat, 'variance_finished', 'avg', 0.0) or self._read_group_metric(line_stat, 'variance_qty', 'avg', 0.0),
'qty_planned': (
self._read_group_metric(oee_stat, 'qty_planned', 'avg', 0.0)
or self._read_group_metric(line_stat, 'qty_to_consume', 'avg', 0.0)
or material_summary.get('qty_bom_consumption', 0.0)
),
'qty_finished': (
self._read_group_metric(oee_stat, 'qty_finished', 'avg', 0.0)
or self._read_group_metric(line_stat, 'qty_consumed', 'avg', 0.0)
or material_summary.get('qty_actual_consumption', 0.0)
),
'variance_finished': (
self._read_group_metric(oee_stat, 'variance_finished', 'avg', 0.0)
or self._read_group_metric(line_stat, 'variance_qty', 'avg', 0.0)
or material_summary.get('variance_consumption', 0.0)
),
'yield_percent': yield_percent,
'qty_bom_consumption': self._read_group_metric(oee_stat, 'qty_bom_consumption', 'avg', 0.0) or self._read_group_metric(line_stat, 'qty_to_consume', 'avg', 0.0),
'qty_actual_consumption': self._read_group_metric(oee_stat, 'qty_actual_consumption', 'avg', 0.0) or self._read_group_metric(line_stat, 'qty_consumed', 'avg', 0.0),
'variance_consumption': self._read_group_metric(oee_stat, 'variance_consumption', 'avg', 0.0) or self._read_group_metric(line_stat, 'variance_qty', 'avg', 0.0),
'qty_bom_consumption': (
self._read_group_metric(oee_stat, 'qty_bom_consumption', 'avg', 0.0)
or self._read_group_metric(line_stat, 'qty_to_consume', 'avg', 0.0)
or material_summary.get('qty_bom_consumption', 0.0)
),
'qty_actual_consumption': (
self._read_group_metric(oee_stat, 'qty_actual_consumption', 'avg', 0.0)
or self._read_group_metric(line_stat, 'qty_consumed', 'avg', 0.0)
or material_summary.get('qty_actual_consumption', 0.0)
),
'variance_consumption': (
self._read_group_metric(oee_stat, 'variance_consumption', 'avg', 0.0)
or self._read_group_metric(line_stat, 'variance_qty', 'avg', 0.0)
or material_summary.get('variance_consumption', 0.0)
),
'consumption_ratio': consumption_ratio,
},
'avg_consumption_detail': {
'to_consume': self._read_group_metric(line_stat, 'qty_to_consume', 'avg', 0.0),
'actual_consumed': self._read_group_metric(line_stat, 'qty_consumed', 'avg', 0.0),
'variance': self._read_group_metric(line_stat, 'variance_qty', 'avg', 0.0),
'consumption_ratio': self._read_group_metric(line_stat, 'consumption_ratio', 'avg', 0.0),
'material_count': self._read_group_metric(line_stat, 'material_count', 'avg', 0.0),
'to_consume': (
self._read_group_metric(line_stat, 'qty_to_consume', 'avg', 0.0)
or material_summary.get('qty_bom_consumption', 0.0)
),
'actual_consumed': (
self._read_group_metric(line_stat, 'qty_consumed', 'avg', 0.0)
or material_summary.get('qty_actual_consumption', 0.0)
),
'variance': (
self._read_group_metric(line_stat, 'variance_qty', 'avg', 0.0)
or material_summary.get('variance_consumption', 0.0)
),
'consumption_ratio': (
self._read_group_metric(line_stat, 'consumption_ratio', 'avg', 0.0)
or material_summary.get('consumption_ratio', 0.0)
),
'material_count': (
self._read_group_metric(line_stat, 'material_count', 'avg', 0.0)
or material_summary.get('oee_records_count', 0)
),
},
})
@@ -1282,12 +1486,21 @@ class ScadaJsonRpcController(http.Controller):
row['equipment_id'][0]: row
for row in line_equipment_avg_rows if row.get('equipment_id')
}
combined_equipment_ids = sorted(set(equipment_avg_map.keys()) | set(line_equipment_avg_map.keys()))
material_equipment_map = self._get_equipment_material_group_map(
date_from=date_from,
date_to=date_to,
)
combined_equipment_ids = sorted(
set(equipment_avg_map.keys()) | set(line_equipment_avg_map.keys()) | set(material_equipment_map.keys())
)
equipment_avg = []
for equipment_id in combined_equipment_ids:
row = equipment_avg_map.get(equipment_id, {})
line_row = line_equipment_avg_map.get(equipment_id, {})
material_row = material_equipment_map.get(equipment_id, {})
equipment = row.get('equipment_id') or line_row.get('equipment_id')
if not equipment and material_row:
equipment = material_row.get('equipment_id')
if not equipment:
continue
line_kpis = line_kpi_map.get(equipment_id, {})
@@ -1296,28 +1509,34 @@ class ScadaJsonRpcController(http.Controller):
(line_kpis.get('abs_deviation_sum', 0.0) / line_count)
if line_count else 0.0
)
material_summary = self._build_material_stats(material_row) if material_row else {}
equipment_avg.append({
'equipment_id': equipment[0],
'equipment_name': equipment[1],
'oee_records_count': (
self._read_group_metric(row, 'id', 'count', 0)
or self._read_group_metric(line_row, 'id', 'count', 0)
or material_summary.get('oee_records_count', 0)
),
'avg_yield_percent': (
self._read_group_metric(row, 'yield_percent', 'avg', 0.0)
or self._read_group_metric(line_row, 'oee_silo_percent', 'avg', 0.0)
or material_summary.get('avg_oee_silo_percent', 0.0)
),
'avg_consumption_ratio': (
self._read_group_metric(row, 'consumption_ratio', 'avg', 0.0)
or self._read_group_metric(line_row, 'consumption_ratio', 'avg', 0.0)
or material_summary.get('consumption_ratio', 0.0)
),
'avg_oee_silo_percent': (
self._read_group_metric(row, 'avg_silo_oee_percent', 'avg', 0.0)
or self._read_group_metric(line_row, 'oee_silo_percent', 'avg', 0.0)
or material_summary.get('avg_oee_silo_percent', 0.0)
),
'avg_max_abs_deviation_percent': (
self._read_group_metric(row, 'max_abs_deviation_percent', 'avg', 0.0)
or avg_abs_deviation
or material_summary.get('max_abs_deviation_percent', 0.0)
),
'total_deviation_alerts': (
self._read_group_metric(row, 'deviation_alert_count', 'sum', 0)
@@ -1553,19 +1772,49 @@ class ScadaJsonRpcController(http.Controller):
['equipment_id'],
lazy=False,
)
material_quality_by_equipment_map = self._get_equipment_material_group_map(
date_from=date_from,
date_to=date_to,
equipment_ids=equipment_ids or None,
)
oee_quality_by_equipment_map = {
row['equipment_id'][0]: row
for row in oee_quality_by_equipment_group if row.get('equipment_id')
}
oee_quality_by_equipment = []
for row in oee_quality_by_equipment_group:
equipment = row.get('equipment_id')
combined_quality_equipment_ids = sorted(
set(oee_quality_by_equipment_map.keys()) | set(material_quality_by_equipment_map.keys())
)
for equipment_id in combined_quality_equipment_ids:
row = oee_quality_by_equipment_map.get(equipment_id, {})
material_row = material_quality_by_equipment_map.get(equipment_id, {})
equipment = row.get('equipment_id') or material_row.get('equipment_id')
if not equipment:
continue
material_summary = self._build_material_stats(material_row) if material_row else {}
oee_quality_by_equipment.append({
'equipment_id': equipment[0],
'equipment_name': equipment[1],
'oee_records_count': self._read_group_metric(row, 'id', 'count', 0),
'avg_yield_percent': self._read_group_metric(row, 'yield_percent', 'avg', 0.0),
'avg_consumption_ratio': self._read_group_metric(row, 'consumption_ratio', 'avg', 0.0),
'avg_oee_silo_percent': self._read_group_metric(row, 'avg_silo_oee_percent', 'avg', 0.0),
'avg_max_abs_deviation_percent': self._read_group_metric(row, 'max_abs_deviation_percent', 'avg', 0.0),
'oee_records_count': (
self._read_group_metric(row, 'id', 'count', 0)
or material_summary.get('oee_records_count', 0)
),
'avg_yield_percent': (
self._read_group_metric(row, 'yield_percent', 'avg', 0.0)
or material_summary.get('avg_oee_silo_percent', 0.0)
),
'avg_consumption_ratio': (
self._read_group_metric(row, 'consumption_ratio', 'avg', 0.0)
or material_summary.get('consumption_ratio', 0.0)
),
'avg_oee_silo_percent': (
self._read_group_metric(row, 'avg_silo_oee_percent', 'avg', 0.0)
or material_summary.get('avg_oee_silo_percent', 0.0)
),
'avg_max_abs_deviation_percent': (
self._read_group_metric(row, 'max_abs_deviation_percent', 'avg', 0.0)
or material_summary.get('max_abs_deviation_percent', 0.0)
),
'total_deviation_alerts': self._read_group_metric(row, 'deviation_alert_count', 'sum', 0),
})
+5
View File
@@ -106,6 +106,7 @@ class MrpProduction(models.Model):
res = super().action_confirm()
# After confirmation, ensure all moves have SCADA equipment from BoM
self._sync_scada_equipment_to_moves()
(self.mapped('move_raw_ids') | self.mapped('move_finished_ids'))._scada_ensure_initial_plan_snapshot()
return res
def button_mark_done(self):
@@ -152,6 +153,10 @@ class MrpProduction(models.Model):
keep_move.product_uom_qty = (keep_move.product_uom_qty or 0.0) + sum(
extra_moves.mapped('product_uom_qty')
)
keep_move.scada_initial_planned_qty = keep_move._scada_get_initial_planned_qty() + sum(
extra._scada_get_initial_planned_qty() for extra in extra_moves
)
keep_move.scada_initial_planned_qty_set = True
_logger.warning(
'MO %s has %s unfinished main finished moves. Collapsing into move %s.',
+96 -91
View File
@@ -85,11 +85,11 @@ class ScadaEquipmentOee(models.Model):
line_ids = fields.One2many(
'scada.equipment.oee.line',
'oee_id',
string='Consumption Details Per Silo',
string='Consumption Details Per Equipment',
readonly=True
)
avg_silo_oee_percent = fields.Float(
string='Avg Silo OEE %',
string='Avg Equipment OEE %',
digits=(16, 3),
compute='_compute_deviation_kpis',
store=True
@@ -115,6 +115,77 @@ class ScadaEquipmentOee(models.Model):
def _safe_ratio(numerator, denominator):
return (numerator / denominator * 100.0) if denominator else 0.0
@staticmethod
def _get_reference_production_qty(mo):
main_finished_moves = mo.move_finished_ids.filtered(
lambda move: move.state != 'cancel' and move.product_id == mo.product_id
)
snapshot_moves = main_finished_moves.filtered('scada_initial_planned_qty_set')
if snapshot_moves:
return sum(snapshot_moves.mapped('scada_initial_planned_qty'))
main_finished_move = main_finished_moves[:1]
return (main_finished_move.product_uom_qty if main_finished_move else False) or mo.product_qty or 0.0
@classmethod
def _get_finished_output_qty(cls, mo):
return sum(
move.quantity_done for move in mo.move_finished_ids
if move.state != 'cancel' and move.product_id == mo.product_id
)
@classmethod
def _get_bom_target_total(cls, mo):
return sum(
move._scada_get_initial_planned_qty() for move in mo.move_raw_ids
if move.state != 'cancel'
)
@classmethod
def _get_bom_target_by_equipment(cls, mo, use_filtered_types=True):
line_map = {}
def _is_summary_detail_equipment(equipment):
return bool(equipment and equipment.equipment_type in ('silo', 'sensor'))
def _is_allowed(equipment):
if not equipment:
return False
if use_filtered_types:
return _is_summary_detail_equipment(equipment)
return True
def _get_bucket(equipment):
key = equipment.id if equipment else 0
if key not in line_map:
line_map[key] = {
'equipment_id': equipment.id if equipment else False,
'equipment_code': equipment.equipment_code if equipment else 'UNMAPPED',
'equipment_name': equipment.name if equipment else 'Unmapped',
'qty_to_consume': 0.0,
'qty_consumed': 0.0,
'material_ids': set(),
}
return line_map[key]
for move in mo.move_raw_ids.filtered(lambda m: m.state != 'cancel'):
equipment = move.scada_equipment_id
if not _is_allowed(equipment):
continue
bucket = _get_bucket(equipment)
bucket['qty_to_consume'] += move._scada_get_initial_planned_qty()
bucket['material_ids'].add(move.product_id.id)
for move in mo.move_raw_ids.filtered(lambda m: m.state != 'cancel'):
equipment = move.scada_equipment_id
if not _is_allowed(equipment):
continue
bucket = _get_bucket(equipment)
bucket['qty_consumed'] += move.quantity_done or 0.0
for bucket in line_map.values():
bucket['material_count'] = len(bucket.pop('material_ids', set()))
return line_map
@staticmethod
def _calc_deviation_percent(actual_qty, target_qty):
if not target_qty:
@@ -156,23 +227,13 @@ class ScadaEquipmentOee(models.Model):
@classmethod
def prepare_from_mo(cls, mo, equipment):
finished_qty = sum(
move.quantity_done for move in mo.move_finished_ids
if move.state != 'cancel'
)
actual_consumed = sum(
move.quantity_done for move in mo.move_raw_ids
if move.state != 'cancel'
)
reference_qty = cls._get_reference_production_qty(mo)
finished_qty = cls._get_finished_output_qty(mo)
raw_moves = mo.move_raw_ids.filtered(lambda move: move.state != 'cancel')
actual_consumed = sum(raw_moves.mapped('quantity_done'))
bom_consumption = cls._get_bom_target_total(mo)
bom_consumption = 0.0
if mo.bom_id and mo.bom_id.product_qty:
for line in mo.bom_id.bom_line_ids:
bom_consumption += (
(line.product_qty / mo.bom_id.product_qty) * mo.product_qty
)
planned_qty = mo.product_qty or 0.0
planned_qty = reference_qty
variance_finished = finished_qty - planned_qty
variance_consumption = actual_consumed - bom_consumption
@@ -199,10 +260,11 @@ class ScadaEquipmentOee(models.Model):
return self._prepare_consumption_lines_from_mo(self.manufacturing_order_id)
def action_rebuild_consumption_lines(self):
"""Rebuild detail per silo from current MO raw moves."""
"""Recompute OEE totals and detail per equipment from the MO + BoM."""
for record in self:
commands = record._build_consumption_line_commands()
record.write({'line_ids': [(5, 0, 0)] + commands})
vals = self.prepare_from_mo(record.manufacturing_order_id, record.equipment_id)
vals['line_ids'] = [(5, 0, 0)] + vals.get('line_ids', [])
record.write(vals)
return True
@api.model_create_multi
@@ -217,77 +279,20 @@ class ScadaEquipmentOee(models.Model):
@classmethod
def _prepare_consumption_lines_from_mo(cls, mo):
line_map = {}
def _ensure_equipment_bucket(equipment):
key = equipment.id if equipment else 0
if key not in line_map:
line_map[key] = {
'equipment_id': equipment.id if equipment else False,
'equipment_code': equipment.equipment_code if equipment else 'UNMAPPED',
'equipment_name': equipment.name if equipment else 'Unmapped',
'qty_to_consume': 0.0,
'qty_consumed': 0.0,
'material_count': 0,
}
return line_map[key]
def _is_silo_equipment(equipment):
return bool(equipment and equipment.equipment_type == 'silo')
def _collect_lines(use_only_silo=True):
local_map = {}
def _local_bucket(equipment):
key = equipment.id if equipment else 0
if key not in local_map:
local_map[key] = {
'equipment_id': equipment.id if equipment else False,
'equipment_code': equipment.equipment_code if equipment else 'UNMAPPED',
'equipment_name': equipment.name if equipment else 'Unmapped',
'qty_to_consume': 0.0,
'qty_consumed': 0.0,
'material_count': 0,
}
return local_map[key]
def _is_allowed(equipment):
if not equipment:
return False
if use_only_silo:
return _is_silo_equipment(equipment)
return True
# 1) Target from BoM.
if mo.bom_id and mo.bom_id.product_qty:
for bom_line in mo.bom_id.bom_line_ids:
equipment = bom_line.scada_equipment_id
if not _is_allowed(equipment):
continue
bucket = _local_bucket(equipment)
planned_qty = (
(bom_line.product_qty / mo.bom_id.product_qty) * (mo.product_qty or 0.0)
)
bucket['qty_to_consume'] += planned_qty
bucket['material_count'] += 1
# 2) Actual from raw move done qty.
for move in mo.move_raw_ids.filtered(lambda m: m.state != 'cancel'):
equipment = move.scada_equipment_id
if not _is_allowed(equipment):
continue
bucket = _local_bucket(equipment)
bucket['qty_consumed'] += move.quantity_done or 0.0
return local_map
# Prefer silo-only details.
line_map = _collect_lines(use_only_silo=True)
line_map = cls._get_bom_target_by_equipment(mo, use_filtered_types=True)
# If none found, fallback to all mapped equipment.
if not line_map:
line_map = _collect_lines(use_only_silo=False)
line_map = cls._get_bom_target_by_equipment(mo, use_filtered_types=False)
line_commands = []
for data in line_map.values():
for equipment_id in sorted(
line_map.keys(),
key=lambda key: (
line_map[key].get('equipment_code') or '',
line_map[key].get('equipment_name') or '',
key or 0,
),
):
data = line_map[equipment_id]
qty_to_consume = data['qty_to_consume']
qty_consumed = data['qty_consumed']
deviation_percent = cls._calc_deviation_percent(qty_consumed, qty_to_consume)
@@ -321,7 +326,7 @@ class ScadaEquipmentOeeLine(models.Model):
)
equipment_id = fields.Many2one(
'scada.equipment',
string='Silo / Equipment',
string='Equipment',
ondelete='set null'
)
equipment_code = fields.Char(
@@ -353,7 +358,7 @@ class ScadaEquipmentOeeLine(models.Model):
digits=(16, 3)
)
oee_silo_percent = fields.Float(
string='OEE Silo %',
string='OEE Equipment %',
digits=(16, 3)
)
deviation_level = fields.Selection(
+23 -8
View File
@@ -385,14 +385,29 @@ class ScadaMaterialConsumption(models.Model):
def _log_equipment_material_consumption(self, equipment, material, mo_record, quantity, timestamp):
"""Create equipment-material consumption record for analytics."""
consumption_bom = 0.0
if mo_record and mo_record.bom_id and mo_record.bom_id.product_qty:
bom_line = mo_record.bom_id.bom_line_ids.filtered(
lambda line: line.product_id.id == material.id
)[:1]
if bom_line:
consumption_bom = (
bom_line.product_qty / mo_record.bom_id.product_qty
) * mo_record.product_qty
if mo_record:
if mo_record.move_raw_ids:
consumption_bom = sum(
move._scada_get_initial_planned_qty()
for move in mo_record.move_raw_ids.filtered(
lambda move: move.product_id.id == material.id and move.state != 'cancel'
)
)
elif mo_record.bom_id and mo_record.bom_id.product_qty:
reference_qty = mo_record.product_qty or 0.0
main_finished_move = mo_record.move_finished_ids.filtered(
lambda move: move.state != 'cancel' and move.product_id == mo_record.product_id
)[:1]
if main_finished_move and main_finished_move.product_uom_qty:
reference_qty = main_finished_move.product_uom_qty
bom_lines = mo_record.bom_id.bom_line_ids.filtered(
lambda line: line.product_id.id == material.id
)
if bom_lines:
consumption_bom = sum(
(bom_line.product_qty / mo_record.bom_id.product_qty) * reference_qty
for bom_line in bom_lines
)
self.env['scada.equipment.material'].create({
'equipment_id': equipment.id,
+23 -8
View File
@@ -375,14 +375,29 @@ class ScadaMoData(models.Model):
return
consumption_bom = 0.0
if mo_record and mo_record.bom_id and mo_record.bom_id.product_qty:
bom_line = mo_record.bom_id.bom_line_ids.filtered(
lambda line: line.product_id.id == material.id
)[:1]
if bom_line:
consumption_bom = (
bom_line.product_qty / mo_record.bom_id.product_qty
) * mo_record.product_qty
if mo_record:
if mo_record.move_raw_ids:
consumption_bom = sum(
move._scada_get_initial_planned_qty()
for move in mo_record.move_raw_ids.filtered(
lambda move: move.product_id.id == material.id and move.state != 'cancel'
)
)
elif mo_record.bom_id and mo_record.bom_id.product_qty:
reference_qty = mo_record.product_qty or 0.0
main_finished_move = mo_record.move_finished_ids.filtered(
lambda move: move.state != 'cancel' and move.product_id == mo_record.product_id
)[:1]
if main_finished_move and main_finished_move.product_uom_qty:
reference_qty = main_finished_move.product_uom_qty
bom_lines = mo_record.bom_id.bom_line_ids.filtered(
lambda line: line.product_id.id == material.id
)
if bom_lines:
consumption_bom = sum(
(bom_line.product_qty / mo_record.bom_id.product_qty) * reference_qty
for bom_line in bom_lines
)
self.env['scada.equipment.material'].create({
'equipment_id': equipment.id,
+58
View File
@@ -9,12 +9,45 @@ from odoo.exceptions import ValidationError
class StockMove(models.Model):
_inherit = 'stock.move'
scada_initial_planned_qty = fields.Float(
string='SCADA Initial Planned Qty',
digits=(12, 3),
copy=False,
help='Snapshot qty plan awal saat manufacturing move dibuat dari BoM.'
)
scada_initial_planned_qty_set = fields.Boolean(
string='SCADA Initial Planned Qty Set',
copy=False,
default=False
)
scada_equipment_id = fields.Many2one(
'scada.equipment',
string='SCADA Equipment (Optional)',
help='Optional equipment mapping for this component move.'
)
def init(self):
self._cr.execute(
"""
SELECT 1
FROM information_schema.columns
WHERE table_name = 'stock_move'
AND column_name = 'scada_initial_planned_qty'
"""
)
if not self._cr.fetchone():
return
self._cr.execute(
"""
UPDATE stock_move
SET scada_initial_planned_qty = COALESCE(product_uom_qty, 0.0),
scada_initial_planned_qty_set = TRUE
WHERE COALESCE(scada_initial_planned_qty_set, FALSE) = FALSE
AND (raw_material_production_id IS NOT NULL OR production_id IS NOT NULL)
"""
)
@api.onchange('bom_line_id')
def _onchange_bom_line_id_scada_equipment(self):
for record in self:
@@ -24,6 +57,13 @@ class StockMove(models.Model):
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if (
not vals.get('scada_initial_planned_qty_set')
and (vals.get('raw_material_production_id') or vals.get('production_id'))
):
vals['scada_initial_planned_qty'] = vals.get('product_uom_qty') or 0.0
vals['scada_initial_planned_qty_set'] = True
if vals.get('scada_equipment_id'):
continue
@@ -51,6 +91,24 @@ class StockMove(models.Model):
return super().create(vals_list)
def _scada_get_initial_planned_qty(self):
self.ensure_one()
if self.scada_initial_planned_qty_set:
return self.scada_initial_planned_qty or 0.0
return self.product_uom_qty or 0.0
def _scada_ensure_initial_plan_snapshot(self):
for move in self:
if move.scada_initial_planned_qty_set:
continue
if not (move.raw_material_production_id or move.production_id):
continue
move.write({
'scada_initial_planned_qty': move.product_uom_qty or 0.0,
'scada_initial_planned_qty_set': True,
})
return True
@api.constrains('raw_material_production_id', 'product_id', 'scada_equipment_id', 'state')
def _check_unique_silo_equipment_per_mo(self):
"""
@@ -65,12 +65,12 @@
</tbody>
</table>
<h3>Consumption Detail Per Silo</h3>
<h3>Consumption Detail Per Equipment</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Silo Code</th>
<th>Silo Name</th>
<th>Equipment Code</th>
<th>Equipment Name</th>
<th class="text-right">To Consume</th>
<th class="text-right">Actual Consumed</th>
<th class="text-right">Variance</th>
@@ -79,7 +79,7 @@
</thead>
<tbody>
<tr t-if="not o.line_ids">
<td colspan="6">No silo consumption detail.</td>
<td colspan="6">No equipment consumption detail.</td>
</tr>
<tr t-foreach="o.line_ids" t-as="line">
<td><span t-esc="line.equipment_code"/></td>
+23 -8
View File
@@ -551,14 +551,29 @@ class MiddlewareService:
return
consumption_bom = 0.0
if mo_record and mo_record.bom_id and mo_record.bom_id.product_qty:
bom_line = mo_record.bom_id.bom_line_ids.filtered(
lambda line: line.product_id.id == material.id
)[:1]
if bom_line:
consumption_bom = (
bom_line.product_qty / mo_record.bom_id.product_qty
) * mo_record.product_qty
if mo_record:
if mo_record.move_raw_ids:
consumption_bom = sum(
move._scada_get_initial_planned_qty()
for move in mo_record.move_raw_ids.filtered(
lambda move: move.product_id.id == material.id and move.state != 'cancel'
)
)
elif mo_record.bom_id and mo_record.bom_id.product_qty:
reference_qty = mo_record.product_qty or 0.0
main_finished_move = mo_record.move_finished_ids.filtered(
lambda move: move.state != 'cancel' and move.product_id == mo_record.product_id
)[:1]
if main_finished_move and main_finished_move.product_uom_qty:
reference_qty = main_finished_move.product_uom_qty
bom_lines = mo_record.bom_id.bom_line_ids.filtered(
lambda line: line.product_id.id == material.id
)
if bom_lines:
consumption_bom = sum(
(bom_line.product_qty / mo_record.bom_id.product_qty) * reference_qty
for bom_line in bom_lines
)
self.env['scada.equipment.material'].create({
'equipment_id': equipment.id,
+3 -3
View File
@@ -36,7 +36,7 @@
<header>
<button
name="action_rebuild_consumption_lines"
string="Rebuild Detail Per Silo"
string="Rebuild Detail Per Equipment"
type="object"
class="oe_highlight"
/>
@@ -72,9 +72,9 @@
<field name="deviation_alert_count" readonly="1"/>
</group>
</group>
<separator string="Detail Per Silo"/>
<separator string="Detail Per Equipment"/>
<field name="line_ids" nolabel="1" readonly="1">
<tree string="Consumption Detail Per Silo"
<tree string="Consumption Detail Per Equipment"
decoration-success="deviation_level == 'normal'"
decoration-warning="deviation_level == 'warning'"
decoration-danger="deviation_level == 'critical'">
@@ -13,6 +13,17 @@ class KpiCrmTriggerRule(models.Model):
business_category_id = fields.Many2one("crm.business.category", required=True, ondelete="cascade", index=True)
line_ids = fields.One2many("kpi.crm.trigger.rule.line", "rule_id", string="Trigger Lines")
@api.onchange("business_category_id")
def _onchange_business_category_id(self):
for rule in self:
for line in rule.line_ids:
if (
line.stage_id
and line.stage_id.business_category_id
and line.stage_id.business_category_id != rule.business_category_id
):
line.stage_id = False
def _find_employee_from_user(self, user):
if not user:
return self.env["hr.employee"]
@@ -31,8 +31,10 @@
<tree editable="bottom">
<field name="sequence"/>
<field name="active"/>
<field name="stage_id"/>
<field name="activity_type_id"/>
<field name="stage_id"
domain="[('business_category_id', '=', parent.business_category_id)]"/>
<field name="activity_type_id"
domain="['|', ('res_model_id', '=', False), ('res_model_id.model', '=', 'crm.lead')]"/>
<field name="assignment_id"/>
<field name="employee_id" readonly="1"/>
<field name="value"/>
@@ -43,8 +45,10 @@
<group>
<field name="sequence"/>
<field name="active"/>
<field name="stage_id"/>
<field name="activity_type_id"/>
<field name="stage_id"
domain="[('business_category_id', '=', parent.business_category_id)]"/>
<field name="activity_type_id"
domain="['|', ('res_model_id', '=', False), ('res_model_id.model', '=', 'crm.lead')]"/>
<field name="assignment_id"/>
<field name="employee_id" readonly="1"/>
</group>