Perbaikan Bulk MO
This commit is contained in:
@@ -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
@@ -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,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',
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
"""
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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']:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
Reference in New Issue
Block a user