Perbaikan Bulk MO

This commit is contained in:
2026-02-28 09:28:32 +07:00
parent 923b9f5be0
commit 8eb039679f
31 changed files with 10238 additions and 51 deletions
+147
View File
@@ -0,0 +1,147 @@
# Equipment Failure Duration Field Addition
## Overview
Added `duration` field to SCADA equipment failure report untuk mencatat durasi failure dalam format hh:mm (manual input).
## Changes Made
### 1. Model Changes
**File**: `models/scada_equipment_failure.py`
- **Field `duration`**: Char field untuk menyimpan durasi dalam format hh:mm
- Format: `HH:MM` (e.g., 02:30 untuk 2 jam 30 menit)
- Optional field - durasi bisa diisi manual sesuai kebutuhan
- Validation dilakukan via `@api.constrains('duration')` - memastikan format yang valid
- **Computed Field `duration_minutes`**: Integer field yang computed dari `duration`
- Otomatis convert format hh:mm ke total menit (e.g., 02:30 = 150 menit)
- Useful untuk reporting dan analisis
- Store=True untuk quick access di report
### 2. View Changes
**File**: `views/scada_equipment_failure_view.xml`
**Tree View (List)**: Tambahan kolom `duration` untuk menampilkan durasi failure
**Form View**:
- Tambahan field `duration` dengan placeholder "HH:MM (e.g., 02:30)"
- Tambahan field `duration_minutes` (readonly) - untuk reference
### 3. API Controller Changes
**File**: `controllers/main.py`
Update 3 endpoints:
#### POST /api/scada/equipment-failure (Create)
- Accept parameter `duration` dari request body
- Return `duration` dan `duration_minutes` di response
#### GET /api/scada/equipment-failure (Get List)
- Adding `duration` dan `duration_minutes` fields ke response data
#### POST /api/scada/equipment-failure-report (Get Report)
- Adding `duration` dan `duration_minutes` fields ke response data
### 4. Database Migration
**File**: `migrations/7.0.59/pre-migration.py`
SQL migration untuk menambahkan kolom `duration` ke table `scada_equipment_failure`.
### 5. API Documentation
**File**: `API_SPEC.md`
Update dokumentasi untuk 3 endpoint equipment failure:
- **Section 19A**: Create Equipment Failure Report
- **Section 19B**: Get Equipment Failure Reports
- **Section 19E**: Get Equipment Failure Report (Frontend)
- **Section 24**: Create Failure Report (Extension Module)
Menambahkan contoh request/response dengan field `duration` dan `duration_minutes`.
## Input Format
Duration field mengikuti format hh:mm:
- Valid: `00:00`, `01:30`, `12:45`, `23:59`
- Invalid: `24:00`, `12`, `12:60`, `invalid`
## Validation Rules
1. Format harus `HH:MM` (00-23 untuk jam, 00-59 untuk menit)
2. Validation dilakukan via `@api.constrains` decorator
3. Jika format invalid, sistem akan raise ValueError dengan pesan yang jelas
## Usage Examples
### Via UI Form
1. Buka Equipment Failure Report form
2. Isi field "Duration (hh:mm)" dengan format e.g., `02:30`
3. Field "Duration (minutes)" akan otomatis ter-compute dan ter-display (readonly)
### Via API (REST)
```bash
curl -X POST http://localhost:8069/api/scada/equipment-failure \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"equipment_code": "PLC01",
"description": "Motor overload",
"date": "2026-02-15 08:30:00",
"duration": "02:30"
}'
```
### Via API (JSON-RPC)
```bash
curl -X POST http://localhost:8069/api/scada/equipment-failure \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"jsonrpc": "2.0",
"method": "call",
"params": {
"equipment_code": "PLC01",
"description": "Motor overload",
"date": "2026-02-15 08:30:00",
"duration": "02:30"
}
}'
```
## Response Format
```json
{
"status": "success",
"message": "Equipment failure report created",
"data": {
"id": 1,
"equipment_code": "PLC01",
"equipment_name": "Main PLC - Injection Machine 01",
"description": "Motor overload",
"date": "2026-02-15T08:30:00",
"duration": "02:30",
"duration_minutes": 150
}
}
```
## Field Descriptions
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `duration` | Char | No | Duration dalam format hh:mm (e.g., 02:30) |
| `duration_minutes` | Integer | No (Computed) | Duration converted to total minutes untuk reporting |
## Benefits
1. **Manual Input**: Durasi bisa diisi manual oleh operator, tidak hanya dari PLC
2. **Easy Tracking**: Mempermudah tracking berapa lama equipment down
3. **Auto Calculation**: `duration_minutes` otomatis ter-compute untuk analytics
4. **Format Validation**: Sistem validasi otomatis format hh:mm
5. **Report Ready**: Data siap untuk reporting dan analytics tanpa conversion
## Backward Compatibility
- Field adalah optional, tidak mandatory
- Data lama yang tidak punya duration akan tetap valid
- Existing API calls yang tidak mengirim `duration` tetap berfungsi normal
+34 -11
View File
@@ -1063,9 +1063,9 @@ Content-Type: application/json
```
Note: `mo_id` and `finished_qty` are required to update finished goods quantity.
Note: `finished_qty` must be > 0. The system sets `qty_producing` to `finished_qty` before marking done.
Note: `finished_qty` must be > 0. The system updates finished move `quantity_done` directly and does not force `qty_producing`.
Note: `mo_id` in payload refers to MO name (e.g. `MO/2025/001`).
Note: If `auto_consume` is enabled, `equipment_id` should be provided so material consumption can be applied to MO moves.
Note: `auto_consume` parameter is ignored to preserve manual/middleware actual consumption values.
**Response**:
@@ -1197,7 +1197,7 @@ Content-Type: application/json
2. Jika quantity (atau alias quantity) diberikan, system akan:
- re-scale MO menggunakan wizard standar Odoo `change.production.qty`
- update `product_qty` dan raw/finished moves agar konsisten
- sinkronkan `qty_producing` ke target terbaru
- tidak menyetel `qty_producing` secara paksa (untuk menghindari re-hitungan konsumsi ke nilai BoM)
3. Untuk setiap equipment code yang dikirim (kecuali `mo_id` dan key quantity):
- System mencari equipment berdasarkan code (e.g., "silo101")
- Mencari raw material moves yang berelasi dengan equipment tersebut
@@ -1581,7 +1581,8 @@ Content-Type: application/json
{
"equipment_code": "PLC01",
"description": "Motor overload saat proses mixing",
"date": "2026-02-15 08:30:00"
"date": "2026-02-15 08:30:00",
"duration": "02:30"
}
```
@@ -1594,11 +1595,18 @@ Content-Type: application/json
"params": {
"equipment_code": "PLC01",
"description": "Motor overload saat proses mixing",
"date": "2026-02-15 08:30:00"
"date": "2026-02-15 08:30:00",
"duration": "02:30"
}
}
```
**Field Descriptions**:
- `equipment_code` (required): Equipment code di SCADA module
- `description` (required): Deskripsi failure
- `date` (optional): Format `YYYY-MM-DD HH:MM:SS` atau `YYYY-MM-DDTHH:MM` (default: waktu server saat create)
- `duration` (optional): Durasi failure dalam format hh:mm (e.g., `02:30` untuk 2 jam 30 menit)
**Response**:
```json
@@ -1611,7 +1619,9 @@ Content-Type: application/json
"equipment_code": "PLC01",
"equipment_name": "Main PLC - Injection Machine 01",
"description": "Motor overload saat proses mixing",
"date": "2026-02-15T08:30:00"
"date": "2026-02-15T08:30:00",
"duration": "02:30",
"duration_minutes": 150
}
}
```
@@ -1624,7 +1634,8 @@ curl -X POST http://localhost:8069/api/scada/equipment-failure \
-d '{
"equipment_code": "PLC01",
"description": "Motor overload saat proses mixing",
"date": "2026-02-15 08:30:00"
"date": "2026-02-15 08:30:00",
"duration": "02:30"
}'
```
@@ -1639,7 +1650,8 @@ curl -X POST http://localhost:8069/api/scada/equipment-failure \
"params": {
"equipment_code": "PLC01",
"description": "Motor overload saat proses mixing",
"date": "2026-02-15 08:30:00"
"date": "2026-02-15 08:30:00",
"duration": "02:30"
}
}'
```
@@ -1675,6 +1687,8 @@ Untuk report frontend berbasis JSON-RPC gunakan endpoint baru `POST /api/scada/e
"equipment_name": "Main PLC - Injection Machine 01",
"description": "Motor overload saat proses mixing",
"date": "2026-02-15T08:30:00",
"duration": "02:30",
"duration_minutes": 150,
"reported_by": "Administrator"
}
]
@@ -1761,6 +1775,8 @@ Catatan:
"equipment_name": "Main PLC - Injection Machine 01",
"description": "Motor overload",
"date": "2026-02-15T08:30:00",
"duration": "02:30",
"duration_minutes": 150,
"reported_by": "Administrator"
},
{
@@ -1770,6 +1786,8 @@ Catatan:
"equipment_name": "Main PLC - Injection Machine 01",
"description": "Trip breaker",
"date": "2026-02-12T11:12:00",
"duration": "01:15",
"duration_minutes": 75,
"reported_by": "Administrator"
}
],
@@ -2327,7 +2345,8 @@ Content-Type: application/json
{
"equipment_code": "PLC01",
"description": "Motor overload saat proses mixing",
"date": "2026-02-15 08:30:00"
"date": "2026-02-15 08:30:00",
"duration": "02:30"
}
```
@@ -2335,6 +2354,7 @@ Content-Type: application/json
- `equipment_code` (required): nilai `equipment_code` pada model `scada.equipment`
- `description` (required): deskripsi failure
- `date` (optional): format `YYYY-MM-DD HH:MM:SS` atau `YYYY-MM-DDTHH:MM`; jika kosong akan pakai waktu server saat create
- `duration` (optional): durasi failure dalam format hh:mm (e.g., `02:30` untuk 2 jam 30 menit)
**Response (Success)**:
@@ -2346,7 +2366,9 @@ Content-Type: application/json
"id": 1,
"equipment_code": "PLC01",
"description": "Motor overload saat proses mixing",
"date": "2026-02-15 08:30:00"
"date": "2026-02-15 08:30:00",
"duration": "02:30",
"duration_minutes": 150
}
}
```
@@ -2368,7 +2390,8 @@ curl -X POST http://localhost:8069/api/scada/failure-report \
-d '{
"equipment_code": "PLC01",
"description": "Motor overload saat proses mixing",
"date": "2026-02-15 08:30:00"
"date": "2026-02-15 08:30:00",
"duration": "02:30"
}'
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
'name': 'SCADA for Odoo - Manufacturing Integration',
'version': '7.0.57',
'version': '7.0.69',
'category': 'manufacturing',
'license': 'LGPL-3',
'author': 'PT. Gagak Rimang Teknologi',
+8
View File
@@ -1621,6 +1621,7 @@ class ScadaJsonRpcController(http.Controller):
equipment_code = data.get('equipment_code')
description = data.get('description')
date_value = data.get('date')
duration = data.get('duration') # hh:mm format
if not equipment_code:
return {'status': 'error', 'message': 'equipment_code is required'}
@@ -1650,6 +1651,7 @@ class ScadaJsonRpcController(http.Controller):
'equipment_id': equipment.id,
'description': description,
'date': failure_date,
'duration': duration,
})
return {
'status': 'success',
@@ -1661,6 +1663,8 @@ class ScadaJsonRpcController(http.Controller):
'equipment_name': equipment.name,
'description': failure.description,
'date': failure.date.isoformat() if failure.date else None,
'duration': failure.duration,
'duration_minutes': failure.duration_minutes,
},
}
except Exception as e:
@@ -1695,6 +1699,8 @@ class ScadaJsonRpcController(http.Controller):
'equipment_name': failure.equipment_id.name if failure.equipment_id else None,
'description': failure.description,
'date': failure.date.isoformat() if failure.date else None,
'duration': failure.duration,
'duration_minutes': failure.duration_minutes,
'reported_by': failure.reported_by.name if failure.reported_by else None,
})
@@ -1798,6 +1804,8 @@ class ScadaJsonRpcController(http.Controller):
'equipment_name': failure.equipment_id.name if failure.equipment_id else None,
'description': failure.description,
'date': failure.date.isoformat() if failure.date else None,
'duration': failure.duration,
'duration_minutes': failure.duration_minutes,
'reported_by': failure.reported_by.name if failure.reported_by else None,
})
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Migration: Add duration field to scada.equipment.failure model
# Version: 7.0.59
def migrate(cr, version):
"""
Add duration field (Char) to scada.equipment.failure table
This field stores failure duration in hh:mm format
"""
cr.execute("""
ALTER TABLE scada_equipment_failure
ADD COLUMN IF NOT EXISTS duration VARCHAR;
""")
# Optional: Add computed field duration_minutes for analysis
# This will be computed automatically via ORM
@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# Migration: Add duration field to scada.equipment.failure
# Version: 7.0.60
def migrate(cr, version):
"""
Add duration Char field to scada.equipment.failure table
This allows manual input of failure duration in hh:mm format
"""
# Add duration column if not exists
cr.execute("""
ALTER TABLE scada_equipment_failure
ADD COLUMN duration VARCHAR;
""")
# Add duration_minutes computed column (will be auto-managed by ORM)
# Note: duration_minutes is a computed field, ORM handles it
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Migration: ensure duration fields exist in scada.equipment.failure
# Version: 7.0.67
def migrate(cr, version):
"""Create missing columns required by stored duration fields."""
cr.execute(
"""
ALTER TABLE scada_equipment_failure
ADD COLUMN IF NOT EXISTS duration VARCHAR;
"""
)
cr.execute(
"""
ALTER TABLE scada_equipment_failure
ADD COLUMN IF NOT EXISTS duration_minutes INTEGER;
"""
)
+27 -1
View File
@@ -2,7 +2,8 @@
MRP BoM line extensions for optional SCADA equipment mapping.
"""
from odoo import models, fields
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class MrpBomLine(models.Model):
@@ -13,3 +14,28 @@ class MrpBomLine(models.Model):
string='SCADA Equipment (Optional)',
help='Optional equipment mapping for this component.'
)
@api.constrains('bom_id', 'product_id', 'scada_equipment_id')
def _check_unique_silo_equipment_per_bom(self):
"""
In one BoM, one equipment must map to only one material/product.
Prevent accidental assignment of the same equipment to multiple materials.
"""
for line in self:
equipment = line.scada_equipment_id
if not line.bom_id or not line.product_id or not equipment:
continue
conflict = line.bom_id.bom_line_ids.filtered(
lambda other: (
other.id != line.id
and other.scada_equipment_id.id == equipment.id
and other.product_id.id != line.product_id.id
)
)[:1]
if conflict:
raise ValidationError(
"Equipment '%s' sudah dipakai oleh material '%s' pada BoM ini. "
"Satu equipment hanya boleh untuk satu bahan."
% (equipment.display_name, conflict.product_id.display_name)
)
+91
View File
@@ -2,7 +2,12 @@
MRP Production extensions for SCADA equipment defaults.
"""
import logging
from odoo import models, fields, api
from odoo.tools import float_round
_logger = logging.getLogger(__name__)
class MrpProduction(models.Model):
@@ -33,6 +38,7 @@ class MrpProduction(models.Model):
return super().create(vals_list)
def button_mark_done(self):
self._scada_normalize_main_finished_moves()
res = super().button_mark_done()
oee_model = self.env['scada.equipment.oee']
@@ -48,3 +54,88 @@ class MrpProduction(models.Model):
oee_model.create(vals)
return res
def _scada_normalize_main_finished_moves(self):
"""
Guard against duplicate unfinished finished-product moves that can
trigger singleton errors in Odoo core `_post_inventory`.
"""
for mo in self:
main_moves = mo.move_finished_ids.filtered(
lambda m: m.product_id == mo.product_id and m.state not in ('done', 'cancel')
)
if len(main_moves) <= 1:
continue
keep_move = main_moves.sorted(lambda m: (-(m.product_uom_qty or 0.0), m.id))[0]
extra_moves = main_moves - keep_move
# Re-link open move lines to the kept move so existing done/lot data is preserved.
extra_open_lines = extra_moves.mapped('move_line_ids').filtered(
lambda ml: ml.state not in ('done', 'cancel')
)
if extra_open_lines:
extra_open_lines.write({'move_id': keep_move.id})
# Keep total demand consistent after collapsing duplicates.
keep_move.product_uom_qty = (keep_move.product_uom_qty or 0.0) + sum(
extra_moves.mapped('product_uom_qty')
)
_logger.warning(
'MO %s has %s unfinished main finished moves. Collapsing into move %s.',
mo.name, len(main_moves), keep_move.id
)
extra_moves._action_cancel()
def _check_immediate(self):
"""
Disable core immediate production wizard.
The wizard auto-fills component consumption from should_consume_qty
(BoM-theoretical), while SCADA flow must keep manual/middleware actuals.
"""
return self.browse()
def _set_qty_producing(self):
"""
Hybrid behavior:
- Raw material with manual consumption (> 0) is preserved.
- Raw material without manual consumption (== 0) is auto-filled by BoM.
- By-product behavior follows core sync.
"""
for production in self:
if production.product_id.tracking == 'serial':
qty_producing_uom = production.product_uom_id._compute_quantity(
production.qty_producing,
production.product_id.uom_id,
rounding_method='HALF-UP'
)
if qty_producing_uom != 1:
production.qty_producing = production.product_id.uom_id._compute_quantity(
1,
production.product_uom_id,
rounding_method='HALF-UP'
)
moves_to_sync = (
production.move_raw_ids |
production.move_finished_ids.filtered(lambda move: move.product_id != production.product_id)
)
for move in moves_to_sync:
if move._should_bypass_set_qty_producing() or not move.product_uom:
continue
# Preserve manually entered raw material consumption.
if move.raw_material_production_id and (move.quantity_done or 0.0) > 0.0:
continue
new_qty = float_round(
(production.qty_producing - production.qty_produced) * move.unit_factor,
precision_rounding=move.product_uom.rounding
)
if not move.is_quantity_done_editable:
move.move_line_ids.filtered(
lambda ml: ml.state not in ('done', 'cancel')
).qty_done = 0
move.move_line_ids = move._set_quantity_done_prepare_vals(new_qty)
else:
move.quantity_done = new_qty
return True
+39 -1
View File
@@ -1,4 +1,4 @@
from odoo import fields, models
from odoo import fields, models, api
class ScadaEquipmentFailure(models.Model):
@@ -30,3 +30,41 @@ class ScadaEquipmentFailure(models.Model):
default=lambda self: self.env.user,
readonly=True,
)
duration = fields.Char(
string='Duration (hh:mm)',
help='Durasi failure dalam format hh:mm (contoh: 02:30 untuk 2 jam 30 menit)',
)
duration_minutes = fields.Integer(
string='Duration (minutes)',
compute='_compute_duration_minutes',
store=True,
help='Durasi dalam menit untuk reporting',
)
@api.constrains('duration')
def _check_duration_format(self):
"""Validasi format duration hh:mm"""
for record in self:
if record.duration:
import re
# Pattern: hh:mm format (00:00 to 23:59)
pattern = r'^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'
if not re.match(pattern, record.duration.strip()):
raise ValueError(
f"Format durasi '{record.duration}' tidak valid. "
"Gunakan format hh:mm (contoh: 02:30)"
)
@api.depends('duration')
def _compute_duration_minutes(self):
"""Konversi durasi hh:mm ke total menit"""
for record in self:
if record.duration:
try:
hours, minutes = map(int, record.duration.strip().split(':'))
record.duration_minutes = hours * 60 + minutes
except (ValueError, AttributeError):
record.duration_minutes = 0
else:
record.duration_minutes = 0
+48 -18
View File
@@ -235,27 +235,57 @@ class ScadaEquipmentOee(models.Model):
def _is_silo_equipment(equipment):
return bool(equipment and equipment.equipment_type == 'silo')
# 1) Target per silo from BoM (so deviation compares against formula target, not split moves).
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_silo_equipment(equipment):
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 = _ensure_equipment_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
bucket = _local_bucket(equipment)
bucket['qty_consumed'] += move.quantity_done or 0.0
# 2) Actual per silo from stock move done qty (includes overconsumption/additional lines).
for move in mo.move_raw_ids.filtered(lambda m: m.state != 'cancel'):
equipment = move.scada_equipment_id
if not _is_silo_equipment(equipment):
continue
bucket = _ensure_equipment_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)
# If none found, fallback to all mapped equipment.
if not line_map:
line_map = _collect_lines(use_only_silo=False)
line_commands = []
for data in line_map.values():
qty_to_consume = data['qty_to_consume']
+7 -8
View File
@@ -629,9 +629,6 @@ class ScadaMoData(models.Model):
'message': 'No finished product move found for this MO',
}
if hasattr(mo, 'qty_producing'):
mo.qty_producing = finished_qty
finished_moves[0].quantity_done = finished_qty
# Update actual end date if provided
@@ -639,12 +636,14 @@ class ScadaMoData(models.Model):
mo_data.write({'date_end_actual': payload_data['date_end_actual']})
# Auto-consume materials from BoM if enabled (default: False)
# Only auto-consume if consumption not already present from middleware/manual
auto_consume = payload_data.get('auto_consume', False)
consumed_materials = []
if auto_consume and mo.bom_id:
consumed_materials = self._auto_consume_from_bom_smart(mo, equipment)
if payload_data.get('auto_consume'):
import logging
_logger = logging.getLogger(__name__)
_logger.info(
'Ignoring auto_consume for MO %s to preserve manual/middleware actual consumption.',
mo.name
)
# Mark MO as done
if mo.state not in ['done', 'cancel']:
+29
View File
@@ -3,6 +3,7 @@ Stock move extensions for optional SCADA equipment mapping.
"""
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class StockMove(models.Model):
@@ -31,3 +32,31 @@ class StockMove(models.Model):
if bom_line and bom_line.scada_equipment_id:
vals['scada_equipment_id'] = bom_line.scada_equipment_id.id
return super().create(vals_list)
@api.constrains('raw_material_production_id', 'product_id', 'scada_equipment_id', 'state')
def _check_unique_silo_equipment_per_mo(self):
"""
In one MO raw moves, one equipment must map to only one material/product.
"""
for move in self:
mo = move.raw_material_production_id
equipment = move.scada_equipment_id
if not mo or not move.product_id or not equipment:
continue
if move.state == 'cancel':
continue
conflict = mo.move_raw_ids.filtered(
lambda other: (
other.id != move.id
and other.state != 'cancel'
and other.scada_equipment_id.id == equipment.id
and other.product_id.id != move.product_id.id
)
)[:1]
if conflict:
raise ValidationError(
"Equipment '%s' pada MO '%s' sudah dipakai oleh material '%s'. "
"Satu equipment hanya boleh untuk satu bahan."
% (equipment.display_name, mo.name, conflict.product_id.display_name)
)
+5 -11
View File
@@ -339,19 +339,17 @@ class MiddlewareService:
'message': 'No finished product move found for this MO',
}
if hasattr(mo_record, 'qty_producing'):
mo_record.qty_producing = finished_qty
finished_moves[0].quantity_done = finished_qty
if payload_data.get('date_end_actual'):
mo_record.write({'date_finished': payload_data['date_end_actual']})
auto_consume = payload_data.get('auto_consume', False)
consumed_materials = []
if auto_consume and mo_record.bom_id and equipment:
# Smart auto-consume: hanya jika consumption belum ada dari middleware atau manual input
consumed_materials = self._auto_consume_from_bom_smart(mo_record, equipment)
if payload_data.get('auto_consume'):
_logger.info(
'Ignoring auto_consume for MO %s to preserve manual/middleware actual consumption.',
mo_record.name
)
if mo_record.state not in ['done', 'cancel']:
mo_record.sudo().button_mark_done()
@@ -757,10 +755,6 @@ class MiddlewareService:
})
change_wizard.change_prod_qty()
# Selaraskan qty_producing dengan target terbaru untuk menghindari mismatch.
if hasattr(mo_record, 'qty_producing'):
mo_record.qty_producing = quantity
_logger.info(
f'Updated MO {mo_name} quantity from "{quantity_key_used}" to {quantity} '
f'(rescaled with change.production.qty)'
@@ -8,6 +8,8 @@
<field name="date"/>
<field name="equipment_id"/>
<field name="equipment_code"/>
<field name="duration"/>
<field name="duration_minutes"/>
<field name="description"/>
<field name="reported_by"/>
</tree>
@@ -26,6 +28,10 @@
<field name="equipment_code" readonly="1"/>
<field name="reported_by" readonly="1"/>
</group>
<group>
<field name="duration" placeholder="HH:MM (contoh: 02:30)"/>
<field name="duration_minutes" readonly="1"/>
</group>
<group>
<field name="description"/>
</group>
+43
View File
@@ -104,6 +104,49 @@ class ScadaMoBulkWizard(models.TransientModel):
mo_records = self.env['mrp.production'].create(vals_list)
# Trigger BoM explosion to populate components (move_raw_ids)
# The batch create() doesn't automatically explode BoMs, so we force it
for mo in mo_records:
if not mo.move_raw_ids and mo.bom_id:
# Explode the BoM to get component lines
bom_lines, _ = mo.bom_id.explode(mo.product_id, mo.product_qty)
# Create raw material moves from BoM lines
raw_moves_vals = []
for bom_line, line_data in bom_lines:
raw_moves_vals.append({
'name': mo.name,
'product_id': bom_line.product_id.id,
'product_uom': bom_line.product_uom_id.id,
'product_uom_qty': line_data['qty'],
'location_id': mo.location_src_id.id,
'location_dest_id': mo.product_id.property_stock_production.id,
'raw_material_production_id': mo.id,
'company_id': mo.company_id.id,
'origin': mo.name,
'group_id': mo.procurement_group_id.id,
'bom_line_id': bom_line.id,
})
if raw_moves_vals:
self.env['stock.move'].create(raw_moves_vals)
# Ensure finished product move exists
if not mo.move_finished_ids.filtered(lambda m: m.product_id == mo.product_id):
finished_move_vals = {
'name': mo.name,
'product_id': mo.product_id.id,
'product_uom': mo.product_uom_id.id,
'product_uom_qty': mo.product_qty,
'location_id': mo.product_id.property_stock_production.id,
'location_dest_id': mo.location_dest_id.id,
'production_id': mo.id,
'company_id': mo.company_id.id,
'origin': mo.name,
'group_id': mo.procurement_group_id.id,
}
self.env['stock.move'].create(finished_move_vals)
return {
'type': 'ir.actions.act_window',
'name': _('Generated Manufacturing Orders'),
+9451
View File
File diff suppressed because it is too large Load Diff
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Test script for Equipment Failure Duration Field
Contoh testing equipment failure API dengan field duration
"""
import requests
import json
from datetime import datetime
# Configuration
BASE_URL = "http://localhost:8069"
API_BASE = f"{BASE_URL}/api/scada"
# Credentials
DB = "your_database"
LOGIN = "admin"
PASSWORD = "admin"
# Session untuk menyimpan cookies
session = requests.Session()
def authenticate():
"""Login ke Odoo dan simpan session cookie"""
print("=" * 60)
print("Authenticating to Odoo...")
print("=" * 60)
url = f"{API_BASE}/authenticate"
payload = {
"db": DB,
"login": LOGIN,
"password": PASSWORD
}
response = session.post(url, json=payload)
result = response.json()
if result.get('status') == 'error':
print(f"❌ Authentication failed: {result.get('message')}")
exit(1)
print(f"✓ Successfully authenticated")
print(f" UID: {result.get('uid')}")
print(f" Db: {result.get('db')}")
print()
def create_equipment_failure_with_duration():
"""Create equipment failure dengan duration field"""
print("=" * 60)
print("Creating Equipment Failure with Duration Field...")
print("=" * 60)
url = f"{API_BASE}/equipment-failure"
# Example 1: With duration
payload = {
"equipment_code": "PLC01",
"description": "Motor overload saat proses mixing",
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"duration": "02:30" # 2 jam 30 menit
}
print("\nPayload:")
print(json.dumps(payload, indent=2))
response = session.post(url, json=payload)
result = response.json()
print("\nResponse:")
print(json.dumps(result, indent=2))
if result.get('status') == 'success':
print(f"\n✓ Equipment failure created successfully")
print(f" ID: {result['data']['id']}")
print(f" Equipment: {result['data']['equipment_code']} ({result['data']['equipment_name']})")
print(f" Duration: {result['data']['duration']} ({result['data']['duration_minutes']} minutes)")
return result['data']['id']
else:
print(f"\n❌ Failed to create equipment failure: {result.get('message')}")
return None
def create_equipment_failure_without_duration():
"""Create equipment failure tanpa duration (optional field)"""
print("\n" + "=" * 60)
print("Creating Equipment Failure WITHOUT Duration Field...")
print("=" * 60)
url = f"{API_BASE}/equipment-failure"
# Example 2: Without duration (optional field)
payload = {
"equipment_code": "PLC01",
"description": "Electrical short circuit issue",
"date": "2026-02-15 10:00:00"
# No duration field
}
print("\nPayload:")
print(json.dumps(payload, indent=2))
response = session.post(url, json=payload)
result = response.json()
print("\nResponse:")
print(json.dumps(result, indent=2))
if result.get('status') == 'success':
print(f"\n✓ Equipment failure created successfully (without duration)")
print(f" ID: {result['data']['id']}")
else:
print(f"\n❌ Failed: {result.get('message')}")
def get_equipment_failures():
"""Get list of equipment failures dengan duration field"""
print("\n" + "=" * 60)
print("Getting Equipment Failure List...")
print("=" * 60)
url = f"{API_BASE}/equipment-failure"
params = {
"equipment_code": "PLC01",
"limit": 50
}
response = session.get(url, params=params)
result = response.json()
print("\nResponse:")
print(json.dumps(result, indent=2))
if result.get('status') == 'success':
print(f"\n✓ Retrieved {result.get('count')} equipment failures")
for failure in result.get('data', []):
print(f"\n - ID: {failure['id']}")
print(f" Equipment: {failure['equipment_code']}")
print(f" Description: {failure['description']}")
print(f" Date: {failure['date']}")
print(f" Duration: {failure.get('duration', 'N/A')}")
print(f" Duration (minutes): {failure.get('duration_minutes', 'N/A')}")
else:
print(f"\n❌ Failed to get failures: {result.get('message')}")
def get_equipment_failure_report():
"""Get equipment failure report dengan duration field"""
print("\n" + "=" * 60)
print("Getting Equipment Failure Report (Frontend)...")
print("=" * 60)
url = f"{API_BASE}/equipment-failure-report"
payload = {
"equipment_code": "PLC01",
"period": "this_month",
"limit": 100
}
print("\nPayload:")
print(json.dumps(payload, indent=2))
response = session.post(url, json=payload)
result = response.json()
print("\nResponse (partial):")
data = result.copy()
if 'data' in data and len(data['data']) > 0:
data['data'] = data['data'][:2] # Show only first 2 for brevity
print(json.dumps(data, indent=2))
if result.get('status') == 'success':
print(f"\n✓ Retrieved equipment failure report")
print(f" Total failures: {result['summary']['total_failures']}")
print(f" Total equipment: {result['summary']['equipment_count']}")
print("\n Failures by equipment:")
for eq in result['summary']['by_equipment']:
print(f" - {eq['equipment_name']}: {eq['failure_count']} failures")
else:
print(f"\n❌ Failed to get report: {result.get('message')}")
def test_invalid_duration():
"""Test invalid duration format"""
print("\n" + "=" * 60)
print("Testing Invalid Duration Format...")
print("=" * 60)
url = f"{API_BASE}/equipment-failure"
# Invalid duration format
payload = {
"equipment_code": "PLC01",
"description": "Test invalid duration",
"duration": "25:00" # Invalid: hour > 23
}
print("\nPayload with invalid duration:")
print(json.dumps(payload, indent=2))
response = session.post(url, json=payload)
result = response.json()
print("\nResponse:")
print(json.dumps(result, indent=2))
if result.get('status') == 'error':
print(f"\n✓ Validation working correctly: {result.get('message')}")
else:
print(f"\n❌ Validation should have caught this error")
def main():
"""Main test execution"""
try:
# Authenticate
authenticate()
# Test 1: Create with duration
create_equipment_failure_with_duration()
# Test 2: Create without duration
create_equipment_failure_without_duration()
# Test 3: Get failures list
get_equipment_failures()
# Test 4: Get failure report
get_equipment_failure_report()
# Test 5: Test invalid duration
test_invalid_duration()
print("\n" + "=" * 60)
print("✓ All tests completed!")
print("=" * 60)
except Exception as e:
print(f"\n❌ Error: {str(e)}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()