Perubahan SCADA dan KPI Staging CRM
This commit is contained in:
@@ -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
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user