Compare commits

..

No commits in common. '9322a898226aab45ec61227d948fea9ad5abd632' and 'd2ecf0849ef50f41ee6935de67a52d33c4ab0a51' have entirely different histories.

@ -1,2 +0,0 @@
tests/
.venv/

1
.gitignore vendored

@ -1,4 +1,3 @@
env/ env/
__pycache__/ __pycache__/
venv/ venv/
tests/

87
Jenkinsfile vendored

@ -57,93 +57,6 @@ pipeline {
} }
} }
// stage('Security: Verify Dependencies') {
// steps {
// script {
// sh """#!/bin/bash
// set -e
// WORKSPACE_DIR="\$(pwd)"
// echo "Working directory: \${WORKSPACE_DIR}"
// echo "1. Checking Python dependencies..."
// if [ -f "\${WORKSPACE_DIR}/requirements.txt" ]; then
// echo " - Found requirements.txt"
// echo ""
// echo "2. Running security checks inside Python container..."
// # Buat container, copy file, jalankan check
// CONTAINER_ID=\$(docker create python:3.11-slim bash -c '
// set -e
// echo "Installing security tools..."
// pip install --upgrade pip setuptools wheel > /dev/null 2>&1
// pip install pip-audit safety > /dev/null 2>&1
// echo ""
// echo "Installing dependencies from requirements.txt..."
// pip install -r /tmp/requirements.txt
// echo ""
// echo "Running pip-audit for known vulnerabilities..."
// pip-audit --desc || {
// echo "Security vulnerabilities detected"
// exit 1
// }
// echo ""
// echo "Running safety check..."
// safety check || {
// echo "Safety check found issues"
// exit 1
// }
// echo ""
// echo "Checking for outdated packages..."
// pip list --outdated || echo "All packages are up to date"
// echo ""
// echo "Verifying package integrity..."
// pip check || {
// echo "Dependency conflicts detected"
// exit 1
// }
// echo "All Python dependencies verified successfully"
// ')
// echo " - Container ID: \${CONTAINER_ID}"
// # Copy requirements.txt langsung ke container (tanpa volume mount)
// docker cp "\${WORKSPACE_DIR}/requirements.txt" "\${CONTAINER_ID}:/tmp/requirements.txt"
// # Copy Pipfile jika ada
// if [ -f "\${WORKSPACE_DIR}/Pipfile.lock" ]; then
// docker cp "\${WORKSPACE_DIR}/Pipfile.lock" "\${CONTAINER_ID}:/tmp/Pipfile.lock"
// fi
// # Jalankan container dan pastikan cleanup
// docker start --attach "\${CONTAINER_ID}" || {
// EXIT_CODE=\$?
// docker rm "\${CONTAINER_ID}" > /dev/null 2>&1 || true
// exit \${EXIT_CODE}
// }
// docker rm "\${CONTAINER_ID}" > /dev/null 2>&1 || true
// echo "✓ All Python dependencies are verified and up to date"
// else
// echo " - requirements.txt not found, skipping dependency check"
// echo " Directory contents:"
// ls -la "\${WORKSPACE_DIR}/"
// fi
// """
// }
// }
// }
stage('Setup Vault SSH Tunnel') { stage('Setup Vault SSH Tunnel') {
steps { steps {
sshagent(credentials: ['vault-server-ssh-key']) { sshagent(credentials: ['vault-server-ssh-key']) {

@ -1,8 +0,0 @@
{"timestamp": "2026-05-26T15:34:23.850085+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:23.850303+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:23.850685+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:26.442755+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:26.442988+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:26.443255+07:00", "service_name": "Be-OptimumOH", "level": "INFO", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:43.799353+07:00", "service_name": "Be-OptimumOH", "level": "WARNING", "method": null, "path": null, "status_code": null, "duration_ms": null, "request_id": null, "user_id": null, "user_ip": null, "error_id": null, "result": null, "error_type": null, "error_message": null}
{"timestamp": "2026-05-26T15:34:43.799965+07:00", "service_name": "Be-OptimumOH", "level": "WARNING", "method": "POST", "path": "/overhaul-gantt/spreadsheet", "status_code": 403, "duration_ms": 79.18, "request_id": "becb4296-58dd-11f1-b957-00155de829e0", "user_id": "2d26235f-a61c-4541-b817-be65b780e3eb", "user_ip": "127.0.0.1", "error_id": "0dbb51ac-0e3b-43a7-80ce-a1d8edf1945c", "result": "Request failed", "error_type": "Forbidden", "error_message": "CSRF token missing in header"}

@ -1,5 +1,7 @@
import uvicorn import uvicorn
from src.config import HOST, PORT, ENV from src.config import HOST, PORT, ENV
if __name__ == '__main__':
is_dev = str(ENV).lower() == 'development' if __name__ == "__main__":
uvicorn.run('src.main:app', host=HOST, port=PORT, reload=is_dev) is_dev = str(ENV).lower() == "development"
uvicorn.run("src.main:app", host=HOST, port=PORT, reload=is_dev)

@ -1,45 +1,183 @@
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from src.auth.service import JWTBearer from src.auth.service import JWTBearer
from src.calculation_budget_constrains.router import router as calculation_budget_constraint from src.calculation_budget_constrains.router import \
from src.calculation_target_reliability.router import router as calculation_target_reliability router as calculation_budget_constraint
from src.calculation_time_constrains.router import router as calculation_time_constrains_router, get_calculation from src.calculation_target_reliability.router import \
router as calculation_target_reliability
from src.calculation_time_constrains.router import \
router as calculation_time_constrains_router, get_calculation
# from src.job.router import router as job_router
from src.overhaul.router import router as overhaul_router from src.overhaul.router import router as overhaul_router
from src.standard_scope.router import router as standard_scope_router from src.standard_scope.router import router as standard_scope_router
from src.overhaul_activity.router import router as overhaul_activity_router from src.overhaul_activity.router import router as overhaul_activity_router
from src.workscope_group.router import router as workscope_group_router from src.workscope_group.router import router as workscope_group_router
from src.equipment_workscope_group.router import router as equipment_workscope_group_router from src.equipment_workscope_group.router import router as equipment_workscope_group_router
# from src.overhaul_job.router import router as job_overhaul_router
# from src.overhaul_scope.router import router as scope_router
# from src.scope_equipment.router import router as scope_equipment_router
# from src.scope_equipment_job.router import router as scope_equipment_job_router
# from src.overhaul_schedule.router import router as overhaul_schedule_router
from src.overhaul_gantt.router import router as gantt_router from src.overhaul_gantt.router import router as gantt_router
from src.sparepart.router import router as sparepart_router from src.sparepart.router import router as sparepart_router
from src.equipment_sparepart.router import router as equipment_sparepart_router from src.equipment_sparepart.router import router as equipment_sparepart_router
# from src.overhaul_scope.router import router as scope_router
# from src.scope_equipment.router import router as scope_equipment_router
# from src.overhaul.router import router as overhaul_router
# from src.overhaul_history.router import router as overhaul_history_router
# from src.overhaul_activity.router import router as scope_equipment_activity_router
from src.overhaul_scope.router import router as ovehaul_schedule_router from src.overhaul_scope.router import router as ovehaul_schedule_router
# from src.scope_equipment_part.router import router as scope_equipment_part_router
# from src.calculation_target_reliability.router import router as calculation_target_reliability
#
# from src.master_activity.router import router as activity_router
class ErrorMessage(BaseModel): class ErrorMessage(BaseModel):
msg: str msg: str
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
detail: Optional[List[ErrorMessage]] detail: Optional[List[ErrorMessage]]
api_router = APIRouter(default_response_class=JSONResponse, responses={400: {'model': ErrorResponse}, 401: {'model': ErrorResponse}, 403: {'model': ErrorResponse}, 404: {'model': ErrorResponse}, 500: {'model': ErrorResponse}})
@api_router.get('/healthcheck', include_in_schema=False)
api_router = APIRouter(
default_response_class=JSONResponse,
responses={
400: {"model": ErrorResponse},
401: {"model": ErrorResponse},
403: {"model": ErrorResponse},
404: {"model": ErrorResponse},
500: {"model": ErrorResponse},
},
)
@api_router.get("/healthcheck", include_in_schema=False)
def healthcheck(): def healthcheck():
return {'status': 'ok'} return {"status": "ok"}
authenticated_api_router = APIRouter(dependencies=[Depends(JWTBearer())])
authenticated_api_router.include_router(overhaul_router, prefix='/overhauls', tags=['overhaul'])
authenticated_api_router.include_router(standard_scope_router, prefix='/scope-equipments', tags=['scope_equipment']) authenticated_api_router = APIRouter(
authenticated_api_router.include_router(overhaul_activity_router, prefix='/overhaul-activity', tags=['activity']) dependencies=[Depends(JWTBearer())],
authenticated_api_router.include_router(workscope_group_router, prefix='/workscopes', tags=['workscope_groups']) )
authenticated_api_router.include_router(sparepart_router, prefix='/spareparts', tags=['sparepart']) # overhaul data
authenticated_api_router.include_router(equipment_workscope_group_router, prefix='/equipment-workscopes', tags=['equipment_workscope_groups']) authenticated_api_router.include_router(
authenticated_api_router.include_router(equipment_sparepart_router, prefix='/equipment-spareparts', tags=['equipment_workscope_sparepart_router']) overhaul_router, prefix="/overhauls", tags=["overhaul"]
authenticated_api_router.include_router(gantt_router, prefix='/overhaul-gantt', tags=['gantt']) )
authenticated_api_router.include_router(ovehaul_schedule_router, prefix='/overhaul-schedules', tags=['overhaul_schedules'])
calculation_router = APIRouter(prefix='/calculation', tags=['calculations'])
calculation_router.include_router(calculation_time_constrains_router, prefix='/time-constraint', tags=['calculation', 'time_constraint']) # authenticated_api_router.include_router(job_router, prefix="/jobs", tags=["job"])
calculation_router.include_router(calculation_target_reliability, prefix='/target-reliability', tags=['calculation', 'target_reliability'])
calculation_router.include_router(calculation_budget_constraint, prefix='/budget-constraint', tags=['calculation', 'budget_constraint']) # # # Overhaul session data
# authenticated_api_router.include_router(
# scope_router, prefix="/overhaul-session", tags=["overhaul-session"]
# )
authenticated_api_router.include_router(
standard_scope_router, prefix="/scope-equipments", tags=["scope_equipment"]
)
authenticated_api_router.include_router(
overhaul_activity_router, prefix="/overhaul-activity", tags=["activity"]
)
authenticated_api_router.include_router(
workscope_group_router, prefix="/workscopes", tags=["workscope_groups"]
)
authenticated_api_router.include_router(
sparepart_router, prefix="/spareparts", tags=["sparepart"]
)
authenticated_api_router.include_router(
equipment_workscope_group_router,
prefix="/equipment-workscopes",
tags=["equipment_workscope_groups"],
)
authenticated_api_router.include_router(
equipment_sparepart_router,
prefix="/equipment-spareparts",
tags=["equipment_workscope_sparepart_router"]
)
# authenticated_api_router.include_router(
# scope_equipment_job_router,
# prefix="/scope-equipment-jobs",
# tags=["scope_equipment", "job"],
# )
# authenticated_api_router.include_router(
# overhaul_schedule_router,
# prefix="/overhaul-schedules",
# tags=["overhaul_schedule"],
# )
# authenticated_api_router.include_router(
# job_overhaul_router, prefix="/overhaul-jobs", tags=["job", "overhaul"]
# )
authenticated_api_router.include_router(
gantt_router, prefix="/overhaul-gantt", tags=["gantt"]
)
# authenticated_api_router.include_router(
# overhaul_history_router, prefix="/overhaul-history", tags=["overhaul_history"]
# )
# authenticated_api_router.include_router(
# scope_equipment_activity_router, prefix="/equipment-activities", tags=["scope_equipment_activities"]
# )
# authenticated_api_router.include_router(
# activity_router, prefix="/activities", tags=["activities"]
# )
# authenticated_api_router.include_router(
# scope_equipment_part_router, prefix="/equipment-parts", tags=["scope_equipment_parts"]
# )
authenticated_api_router.include_router(
ovehaul_schedule_router, prefix="/overhaul-schedules", tags=["overhaul_schedules"]
)
# calculation
calculation_router = APIRouter(prefix="/calculation", tags=["calculations"])
# Time constrains
calculation_router.include_router(
calculation_time_constrains_router,
prefix="/time-constraint",
tags=["calculation", "time_constraint"],
)
# Target reliability
calculation_router.include_router(
calculation_target_reliability,
prefix="/target-reliability",
tags=["calculation", "target_reliability"],
)
# # Budget Constrain
calculation_router.include_router(
calculation_budget_constraint,
prefix="/budget-constraint",
tags=["calculation", "budget_constraint"],
)
authenticated_api_router.include_router(calculation_router) authenticated_api_router.include_router(calculation_router)
authenticated_api_router.include_router(get_calculation, prefix='/calculation/time-constraint', tags=['calculation', 'time_constraint'])
authenticated_api_router.include_router(
get_calculation,
prefix="/calculation/time-constraint",
tags=["calculation", "time_constraint"],
)
api_router.include_router(authenticated_api_router) api_router.include_router(authenticated_api_router)

@ -4,61 +4,129 @@ import json
import datetime import datetime
import os import os
import sys import sys
try: try:
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
except ImportError: except ImportError:
from backports.zoneinfo import ZoneInfo from backports.zoneinfo import ZoneInfo # type: ignore[no-redef]
from src.config import LOG_LEVEL, SERVICE_NAME from src.config import LOG_LEVEL, SERVICE_NAME
from src.enums import OptimumOHEnum from src.enums import OptimumOHEnum
_TZ_LOCAL = ZoneInfo('Asia/Jakarta')
RESET = '\x1b[0m'
COLORS = {'DEBUG': '\x1b[36m', 'INFO': '\x1b[32m', 'WARNING': '\x1b[33m', 'WARN': '\x1b[33m', 'ERROR': '\x1b[31m', 'CRITICAL': '\x1b[1;31m'} _TZ_LOCAL = ZoneInfo("Asia/Jakarta")
_REQUEST_FIELDS = frozenset({'method', 'path', 'status_code', 'duration_ms', 'request_id', 'user_id', 'user_ip', 'error_id', 'result', 'error_type', 'error_message'})
# ANSI Color Codes
RESET = "\033[0m"
COLORS = {
"DEBUG": "\033[36m", # Cyan
"INFO": "\033[32m", # Green
"WARNING": "\033[33m", # Yellow
"WARN": "\033[33m", # Yellow
"ERROR": "\033[31m", # Red
"CRITICAL": "\033[1;31m", # Bold Red
}
# Fields in the log record injected by the logging middleware or extra= kwargs
_REQUEST_FIELDS = frozenset({
"method", "path", "status_code", "duration_ms",
"request_id", "user_id", "user_ip",
"error_id", "result", "error_type", "error_message",
})
class LogLevels(OptimumOHEnum): class LogLevels(OptimumOHEnum):
info = 'INFO' info = "INFO"
warn = 'WARN' warn = "WARN"
error = 'ERROR' error = "ERROR"
debug = 'DEBUG' debug = "DEBUG"
class JSONFormatter(logging.Formatter): class JSONFormatter(logging.Formatter):
"""
Outputs exactly 14 fixed fields per log line in JSON format.
Fields (always present, null when not applicable):
timestamp, service_name, level, method, path, status_code,
duration_ms, request_id, user_id, user_ip, error_id,
result, error_type, error_message
"""
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
dt = datetime.datetime.fromtimestamp(record.created, tz=_TZ_LOCAL) dt = datetime.datetime.fromtimestamp(record.created, tz=_TZ_LOCAL)
timestamp = dt.isoformat(timespec='microseconds') timestamp = dt.isoformat(timespec="microseconds")
status_code = getattr(record, 'status_code', None)
result = getattr(record, 'result', None) status_code = getattr(record, "status_code", None)
result = getattr(record, "result", None)
if result is None and status_code is not None: if result is None and status_code is not None:
result = 'Request completed' if int(status_code) < 400 else 'Request failed' result = "Request completed" if int(status_code) < 400 else "Request failed"
log_record = {'timestamp': timestamp, 'service_name': SERVICE_NAME, 'level': record.levelname, 'method': getattr(record, 'method', None), 'path': getattr(record, 'path', None), 'status_code': status_code, 'duration_ms': getattr(record, 'duration_ms', None), 'request_id': getattr(record, 'request_id', None), 'user_id': getattr(record, 'user_id', None), 'user_ip': getattr(record, 'user_ip', None), 'error_id': getattr(record, 'error_id', None), 'result': result, 'error_type': getattr(record, 'error_type', None), 'error_message': getattr(record, 'error_message', None)}
log_record = {
"timestamp": timestamp,
"service_name": SERVICE_NAME,
"level": record.levelname,
"method": getattr(record, "method", None),
"path": getattr(record, "path", None),
"status_code": status_code,
"duration_ms": getattr(record, "duration_ms", None),
"request_id": getattr(record, "request_id", None),
"user_id": getattr(record, "user_id", None),
"user_ip": getattr(record, "user_ip", None),
"error_id": getattr(record, "error_id", None),
"result": result,
"error_type": getattr(record, "error_type", None),
"error_message": getattr(record, "error_message", None),
}
log_json = json.dumps(log_record, default=str) log_json = json.dumps(log_record, default=str)
if sys.stdout.isatty(): if sys.stdout.isatty():
color = COLORS.get(record.levelname, '') color = COLORS.get(record.levelname, "")
return f'{color}{log_json}{RESET}' return f"{color}{log_json}{RESET}"
return log_json return log_json
def configure_logging() -> None: def configure_logging() -> None:
log_level = str(LOG_LEVEL).upper() log_level = str(LOG_LEVEL).upper()
if log_level not in list(LogLevels): if log_level not in list(LogLevels):
log_level = LogLevels.error log_level = LogLevels.error
root_logger = logging.getLogger() root_logger = logging.getLogger()
root_logger.setLevel(log_level) root_logger.setLevel(log_level)
if root_logger.hasHandlers(): if root_logger.hasHandlers():
root_logger.handlers.clear() root_logger.handlers.clear()
formatter = JSONFormatter() formatter = JSONFormatter()
# stdout handler
stream_handler = logging.StreamHandler(sys.stdout) stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(formatter) stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler) root_logger.addHandler(stream_handler)
os.makedirs('logs', exist_ok=True)
file_handler = logging.handlers.RotatingFileHandler('logs/app.log', maxBytes=10 * 1024 * 1024, backupCount=5, encoding='utf-8') # rotating file handler — logs/app.log 10 MB × 5 backups
os.makedirs("logs", exist_ok=True)
file_handler = logging.handlers.RotatingFileHandler(
"logs/app.log",
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler) root_logger.addHandler(file_handler)
for logger_name in ['uvicorn', 'uvicorn.access', 'uvicorn.error', 'fastapi']:
# Reconfigure uvicorn loggers to propagate through our formatter
for logger_name in ["uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"]:
uvicorn_logger = logging.getLogger(logger_name) uvicorn_logger = logging.getLogger(logger_name)
uvicorn_logger.handlers = [] uvicorn_logger.handlers = []
uvicorn_logger.propagate = True uvicorn_logger.propagate = True
logging.getLogger('uvicorn.access').setLevel(logging.WARNING)
slowapi_logger = logging.getLogger('slowapi') # Silence chatty loggers — we emit one structured log per request ourselves
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
slowapi_logger = logging.getLogger("slowapi")
slowapi_logger.setLevel(logging.ERROR) slowapi_logger.setLevel(logging.ERROR)
slowapi_logger.propagate = False slowapi_logger.propagate = False
logging.getLogger('slack_sdk.web.base_client').setLevel(logging.CRITICAL)
logging.getLogger("slack_sdk.web.base_client").setLevel(logging.CRITICAL)

@ -2,8 +2,9 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Any, List, Union from typing import Any, List, Union
from fastapi import Request, HTTPException, status from fastapi import Request, HTTPException, status
Allow: str = 'allow'
Deny: str = 'deny' Allow: str = "allow"
Deny: str = "deny"
@dataclass(frozen=True) @dataclass(frozen=True)
class Principal: class Principal:
@ -11,95 +12,147 @@ class Principal:
value: str value: str
def __repr__(self) -> str: def __repr__(self) -> str:
return f'{self.key}:{self.value}' return f"{self.key}:{self.value}"
def __str__(self) -> str: def __str__(self) -> str:
return self.__repr__() return self.__repr__()
@dataclass(frozen=True) @dataclass(frozen=True)
class SystemPrincipal(Principal): class SystemPrincipal(Principal):
def __init__(self, value: str): def __init__(self, value: str):
super().__init__(key='system', value=value) super().__init__(key="system", value=value)
@dataclass(frozen=True) @dataclass(frozen=True)
class RolePrincipal(Principal): class RolePrincipal(Principal):
def __init__(self, value: str): def __init__(self, value: str):
super().__init__(key='role', value=value) super().__init__(key="role", value=value)
Everyone = SystemPrincipal(value='everyone')
Authenticated = SystemPrincipal(value='authenticated') Everyone = SystemPrincipal(value="everyone")
Authenticated = SystemPrincipal(value="authenticated")
class OHPermission(Enum): class OHPermission(Enum):
CREATE = 'create' CREATE = "create"
READ = 'read' READ = "read"
EDIT = 'edit' EDIT = "edit"
DELETE = 'delete' DELETE = "delete"
class AccessControl:
class AccessControl:
def __init__(self, permission_exception: Any = None): def __init__(self, permission_exception: Any = None):
self.permission_exception = permission_exception or HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Permission denied') self.permission_exception = permission_exception or HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied"
)
def _acl(self, resource): def _acl(self, resource):
acl = getattr(resource, '__acl__', []) acl = getattr(resource, "__acl__", [])
if callable(acl): if callable(acl):
return acl() return acl()
return acl return acl
def has_permission(self, principals: List[Principal], required_permissions: List[OHPermission], resource: Any) -> bool: def has_permission(
self, principals: List[Principal], required_permissions: List[OHPermission], resource: Any
) -> bool:
if not isinstance(resource, list): if not isinstance(resource, list):
resource = [resource] resource = [resource]
permits = [] permits = []
for res in resource: for res in resource:
granted = False granted = False
acl = self._acl(res) acl = self._acl(res)
for action, principal, permissions in acl: for action, principal, permissions in acl:
is_permitted = any((p in permissions for p in required_permissions)) # Check if any of the required permissions are in the allowed permissions for this principal
if (action == Allow and is_permitted) and (principal in principals or principal == Everyone): is_permitted = any(p in permissions for p in required_permissions)
if (action == Allow and is_permitted) and (
principal in principals or principal == Everyone
):
granted = True granted = True
break break
if (action == Deny and is_permitted) and (principal in principals or principal == Everyone): elif (action == Deny and is_permitted) and (
principal in principals or principal == Everyone
):
granted = False granted = False
break break
permits.append(granted) permits.append(granted)
return all(permits) return all(permits)
def assert_access(self, principals: List[Principal], required_permissions: List[OHPermission], resource: Any): def assert_access(
self, principals: List[Principal], required_permissions: List[OHPermission], resource: Any
):
if not self.has_permission(principals, required_permissions, resource): if not self.has_permission(principals, required_permissions, resource):
raise self.permission_exception raise self.permission_exception
ALLOWED_ROLES = {'Admin', 'Super Admin', 'Engineer', 'Application Administrator'}
# Roles allowed to access the backend (whitelist).
# Management is intentionally excluded.
ALLOWED_ROLES = {"Admin", "Super Admin", "Engineer", "Application Administrator"}
def require_any_role(*allowed_roles: str): def require_any_role(*allowed_roles: str):
"""
FastAPI dependency whitelist-based role check.
Raises 403 if the authenticated user's role is not in the allowed set.
Comparison is case-insensitive.
Usage (router-level):
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
"""
normalized = {r.lower() for r in allowed_roles} normalized = {r.lower() for r in allowed_roles}
async def dependency(request: Request): async def dependency(request: Request):
user = getattr(request.state, 'user', None) user = getattr(request.state, "user", None)
if not user: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Not authenticated') raise HTTPException(
user_role = user.get('role') if isinstance(user, dict) else getattr(user, 'role', None) status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
)
user_role = user.get("role") if isinstance(user, dict) else getattr(user, "role", None)
if not user_role or user_role.lower() not in normalized: if not user_role or user_role.lower() not in normalized:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Your role does not have permission to access this resource.') raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your role does not have permission to access this resource.",
)
return True return True
return dependency return dependency
def required_permission(permissions: Union[OHPermission, List[OHPermission]], resources: Any): def required_permission(permissions: Union[OHPermission, List[OHPermission]], resources: Any):
"""
FastAPI dependency for role-based access control.
"""
if isinstance(permissions, OHPermission): if isinstance(permissions, OHPermission):
permissions = [permissions] permissions = [permissions]
async def dependency(request: Request): async def dependency(request: Request):
user = getattr(request.state, 'user', None) user = getattr(request.state, "user", None)
if not user: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Not authenticated') raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated"
)
user_role = None user_role = None
if isinstance(user, dict): if isinstance(user, dict):
user_role = user.get('role') user_role = user.get("role")
else: else:
user_role = getattr(user, 'role', None) user_role = getattr(user, "role", None)
if not user_role: if not user_role:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='No user role found') raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="No user role found"
)
user_principals = [Authenticated, RolePrincipal(user_role)] user_principals = [Authenticated, RolePrincipal(user_role)]
ac = AccessControl() ac = AccessControl()
ac.assert_access(user_principals, permissions, resources) ac.assert_access(user_principals, permissions, resources)
return True return True
return dependency return dependency

@ -1,7 +1,10 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
class UserBase(BaseModel): class UserBase(BaseModel):
# Some users from auth service may not have a display name.
name: Optional[str] = None name: Optional[str] = None
role: str role: str
user_id: str user_id: str

@ -1,135 +1,247 @@
# app/auth/auth_bearer.py
import json import json
from typing import Annotated from typing import Annotated, Optional
import requests import requests
from fastapi import Depends, HTTPException, Request from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.sql.expression import false
import src.config as config import src.config as config
from .model import UserBase from .model import UserBase
from .util import extract_template from .util import extract_template
class JWTBearer(HTTPBearer): class JWTBearer(HTTPBearer):
def __init__(self, auto_error: bool = True): def __init__(self, auto_error: bool = True):
# Always pass auto_error=False so that HTTPBearer returns None (instead of
# raising HTTP 403) when the Authorization header is absent. We raise 401
# ourselves in the else-branch below, which is the correct status for a
# missing / unauthenticated credential.
super(JWTBearer, self).__init__(auto_error=False) super(JWTBearer, self).__init__(auto_error=False)
async def __call__(self, request: Request): async def __call__(self, request: Request):
credentials: HTTPAuthorizationCredentials = await super(JWTBearer, self).__call__(request) credentials: HTTPAuthorizationCredentials = await super(
JWTBearer, self
).__call__(request)
if credentials: if credentials:
if not credentials.scheme == 'Bearer': if not credentials.scheme == "Bearer":
raise HTTPException(status_code=401, detail='Invalid authentication scheme.') raise HTTPException(
status_code=401, detail="Invalid authentication scheme."
)
method = request.method method = request.method
if method == 'OPTIONS':
return None if method == "OPTIONS":
return
path = extract_template(request.url.path, request.path_params) path = extract_template(request.url.path, request.path_params)
endpoint = f'/optimumoh{path}'
endpoint = f"/optimumoh{path}"
user_info, message = self.verify_jwt(credentials.credentials, method, endpoint) user_info, message = self.verify_jwt(credentials.credentials, method, endpoint)
if not user_info: if not user_info:
if isinstance(message, dict): if isinstance(message, dict):
message = message.get('message') or message.get('detail') or 'Invalid token or expired token.' message = message.get("message") or message.get("detail") or "Invalid token or expired token."
else: else:
message = str(message) if message else 'Invalid token or expired token.' message = str(message) if message else "Invalid token or expired token."
raise HTTPException(status_code=401, detail=message) raise HTTPException(
status_code=401, detail=message
)
request.state.user = message request.state.user = message
from src.context import set_user_id, set_username, set_role from src.context import set_user_id, set_username, set_role
if hasattr(message, 'user_id'): if hasattr(message, "user_id"):
set_user_id(str(message.user_id)) set_user_id(str(message.user_id))
username = getattr(message, 'username', None) or getattr(message, 'name', None) username = getattr(message, "username", None) or getattr(message, "name", None)
if username: if username:
set_username(username) set_username(username)
if hasattr(message, 'role'): if hasattr(message, "role"):
set_role(message.role) set_role(message.role)
return message return message
raise HTTPException(status_code=401, detail='Invalid authorization code.') else:
raise HTTPException(status_code=401, detail="Invalid authorization code.")
def verify_jwt(self, jwtoken: str, method: str, endpoint: str): def verify_jwt(self, jwtoken: str, method: str, endpoint: str):
try: try:
url_to_verify = f'{config.AUTH_SERVICE_API}/verify-token' url_to_verify = f"{config.AUTH_SERVICE_API}/verify-token"
response = requests.get(url_to_verify, headers={'Authorization': f'Bearer {jwtoken}'}) response = requests.get(
url_to_verify,
headers={"Authorization": f"Bearer {jwtoken}"},
)
if not response.ok: if not response.ok:
return (False, response.json()) return False, response.json()
user_data = response.json() user_data = response.json()
return (True, UserBase(**user_data['data'])) return True, UserBase(**user_data["data"])
except Exception as e: except Exception as e:
print(f'Token verification error: {str(e)}') print(f"Token verification error: {str(e)}")
return (False, str(e)) return False, str(e)
# Create dependency to get current user from request state
async def get_current_user(request: Request) -> UserBase: async def get_current_user(request: Request) -> UserBase:
return request.state.user return request.state.user
async def get_token(request: Request): async def get_token(request: Request):
token = request.headers.get('Authorization') token = request.headers.get("Authorization")
if token: if token:
return token.replace('Bearer ', '') return token.replace("Bearer ", "") # Menghapus prefix "Bearer "
return request.cookies.get('access_token') else:
return '' return request.cookies.get("access_token") # Fallback ke cookie
return "" # Mengembalikan token atau None jika tidak ada
async def internal_key(request: Request): async def internal_key(request: Request):
token = request.headers.get('Authorization') token = request.headers.get("Authorization")
if not token: if not token:
api_key = request.headers.get('X-Internal-Key') api_key = request.headers.get("X-Internal-Key")
if api_key != config.API_KEY: if api_key != config.API_KEY:
raise HTTPException(status_code=401, detail='Invalid Key.') raise HTTPException(
status_code=401, detail="Invalid Key."
)
try: try:
headers = {'Content-Type': 'application/json'} headers = {
response = requests.post(f'{config.AUTH_SERVICE_API}/sign-in', headers=headers, data=json.dumps({'username': 'ohuser', 'password': '123456789'})) 'Content-Type': 'application/json'
}
response = requests.post(
f"{config.AUTH_SERVICE_API}/sign-in",
headers=headers,
data=json.dumps({
"username": "ohuser",
"password": "123456789"
})
)
if not response.ok: if not response.ok:
print(str(response.json())) print(str(response.json()))
raise Exception('error auth') raise Exception("error auth")
user_data = response.json() user_data = response.json()
return user_data['data']['access_token'] return user_data['data']['access_token']
except Exception as e: except Exception as e:
raise Exception(str(e)) raise Exception(str(e))
else: else:
try: try:
verify_url = f'{config.AUTH_SERVICE_API}/verify-token' verify_url = f"{config.AUTH_SERVICE_API}/verify-token"
response = requests.get(verify_url, headers={'Authorization': f'{token}'}) response = requests.get(
verify_url,
headers={"Authorization": f"{token}"},
)
if not response.ok: if not response.ok:
raise HTTPException(status_code=401, detail='Invalid token.') raise HTTPException(
return token.split(' ')[1] status_code=401, detail="Invalid token."
)
return token.split(" ")[1]
except Exception as e: except Exception as e:
print(f'Token verification error: {str(e)}') print(f"Token verification error: {str(e)}")
return (False, str(e)) return False, str(e)
import asyncio import asyncio
import logging import logging
from typing import Dict, Any from typing import Dict, Any
import src.config as config import src.config as config
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
AUTH_NOTIFY_ENDPOINT = f'{config.AUTH_SERVICE_API}/admin/notify-limit'
async def notify_admin_on_rate_limit(endpoint_name: str, ip_address: str, request: Request, method: str='POST', cooldown: int=900, timeout: int=5) -> Dict[str, Any]:
payload = {'endpoint_name': f'oh/{endpoint_name}'.replace('//', ''), 'ip_address': ip_address, 'method': method, 'cooldown': cooldown} AUTH_NOTIFY_ENDPOINT = f"{config.AUTH_SERVICE_API}/admin/notify-limit"
token = request.headers.get('Authorization')
headers = {'Authorization': token} if token else {}
async def notify_admin_on_rate_limit(
endpoint_name: str,
ip_address: str,
request: Request,
method: str = "POST",
cooldown: int = 900,
timeout: int = 5
) -> Dict[str, Any]:
"""
Kirim notifikasi ke admin via be-auth service ketika rate limit terlampaui.
Async version - gunakan di async context.
"""
payload = {
"endpoint_name": f"oh/{endpoint_name}".replace("//", ""),
"ip_address": ip_address,
"method": method,
"cooldown": cooldown,
}
token = request.headers.get("Authorization")
headers = {"Authorization": token} if token else {}
try: try:
response = await asyncio.to_thread(requests.post, AUTH_NOTIFY_ENDPOINT, json=payload, headers=headers, timeout=timeout) response = await asyncio.to_thread(
requests.post, AUTH_NOTIFY_ENDPOINT,
json=payload, headers=headers, timeout=timeout
)
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
log.info(f'Notifikasi admin sent | Endpoint: {endpoint_name}') log.info(f"Notifikasi admin sent | Endpoint: {endpoint_name}")
return result return result
except Exception as e: except Exception as e:
log.error(f'Error notifying admin: {str(e)}') log.error(f"Error notifying admin: {str(e)}")
return {'status': False, 'message': str(e), 'data': payload} return {"status": False, "message": str(e), "data": payload}
def notify_admin_on_rate_limit_sync(
endpoint_name: str,
ip_address: str,
request: Request,
method: str = "POST",
cooldown: int = 900,
timeout: int = 5
) -> Dict[str, Any]:
"""
Kirim notifikasi ke admin via be-auth service.
Sync version - gunakan di exception handler atau sync context.
RECOMMENDED untuk use case ini.
"""
payload = {
"endpoint_name": f"oh/{endpoint_name}".replace("//", "/"),
"ip_address": ip_address,
"method": method,
"cooldown": cooldown,
}
token = request.headers.get("Authorization")
headers = {"Authorization": token} if token else {}
def notify_admin_on_rate_limit_sync(endpoint_name: str, ip_address: str, request: Request, method: str='POST', cooldown: int=900, timeout: int=5) -> Dict[str, Any]:
payload = {'endpoint_name': f'oh/{endpoint_name}'.replace('//', '/'), 'ip_address': ip_address, 'method': method, 'cooldown': cooldown}
token = request.headers.get('Authorization')
headers = {'Authorization': token} if token else {}
try: try:
response = requests.post(AUTH_NOTIFY_ENDPOINT, json=payload, headers=headers, timeout=timeout) response = requests.post(AUTH_NOTIFY_ENDPOINT, json=payload, headers=headers, timeout=timeout)
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()
log.info(f'Notifikasi admin sent | Endpoint: {endpoint_name}') log.info(f"Notifikasi admin sent | Endpoint: {endpoint_name}")
return result return result
except Exception as e: except Exception as e:
log.error(f'Error notifying admin: {str(e)}') log.error(f"Error notifying admin: {str(e)}")
return {'status': False, 'message': str(e), 'data': payload} return {"status": False, "message": str(e), "data": payload}
async def admin_required(request: Request):
user = await get_current_user(request)
if user.role != 'Admin' or user.role != 'Superadmin':
raise HTTPException(status_code=403, detail='Invalid authorization code.')
return user
Admin = Annotated[UserBase, Depends(admin_required)]
CurrentUser = Annotated[UserBase, Depends(get_current_user)] CurrentUser = Annotated[UserBase, Depends(get_current_user)]
Token = Annotated[str, Depends(get_token)] Token = Annotated[str, Depends(get_token)]
InternalKey = Annotated[str, Depends(internal_key)] InternalKey = Annotated[str, Depends(internal_key)]

@ -1,6 +1,9 @@
def extract_template(path_string, value_dict): def extract_template(path_string, value_dict):
template = path_string template = path_string
# Replace each value in the dict with its corresponding key placeholder
for key, value in value_dict.items(): for key, value in value_dict.items():
if str(value) in template: if str(value) in template:
template = template.replace(str(value), f'<{key}>') template = template.replace(str(value), f'<{key}>')
return template return template

@ -1,21 +1,46 @@
from typing import Annotated, Dict from typing import Annotated, Dict, List, Optional
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, status
from fastapi.params import Query from fastapi.params import Query
from src.auth.service import Token from src.auth.service import Token
from src.calculation_budget_constrains.schema import BudgetContraintQuery from src.calculation_budget_constrains.schema import BudgetContraintQuery
from src.calculation_target_reliability.service import get_simulation_results from src.calculation_target_reliability.service import get_simulation_results
from src.config import TC_RBD_ID from src.config import TC_RBD_ID
from src.database.core import CollectorDbSession, DbSession from src.database.core import CollectorDbSession, DbSession
from src.models import StandardResponse from src.models import StandardResponse
from .service import get_all_budget_constrains from .service import get_all_budget_constrains
from src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES from src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES
from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends from fastapi import Depends
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get('/{session_id}', response_model=StandardResponse[Dict], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_target_reliability(db_session: DbSession, token: Token, session_id: str, collector_db: CollectorDbSession, params: Annotated[BudgetContraintQuery, Query()]): @router.get("/{session_id}", response_model=StandardResponse[Dict], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_target_reliability(
db_session: DbSession,
token: Token,
session_id: str,
collector_db: CollectorDbSession,
params: Annotated[BudgetContraintQuery, Query()],
):
"""Get all scope pagination."""
cost_threshold = params.cost_threshold cost_threshold = params.cost_threshold
results = await get_simulation_results(simulation_id=TC_RBD_ID, token=token) results = await get_simulation_results(
results, consequence = await get_all_budget_constrains(db_session=db_session, session_id=session_id, cost_threshold=cost_threshold, simulation_result=results, collector_db=collector_db) simulation_id = TC_RBD_ID,
return StandardResponse(data={'results': results, 'consequence': consequence}, message='Data retrieved successfully') token=token
)
results, consequence = await get_all_budget_constrains(
db_session=db_session, session_id=session_id, cost_threshold=cost_threshold, simulation_result=results, collector_db=collector_db
)
return StandardResponse(
data={
"results": results,
"consequence": consequence
},
message="Data retrieved successfully",
)

@ -1,18 +1,29 @@
from typing import Any, Dict, List from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.models import DefultBase
from src.models import DefultBase, Pagination
class OverhaulBase(BaseModel): class OverhaulBase(BaseModel):
pass pass
class OverhaulCriticalParts(OverhaulBase): class OverhaulCriticalParts(OverhaulBase):
criticalParts: List[str] = Field(..., description='List of critical parts') criticalParts: List[str] = Field(..., description="List of critical parts")
class OverhaulSchedules(OverhaulBase): class OverhaulSchedules(OverhaulBase):
schedules: List[Dict[str, Any]] = Field(..., description='List of schedules') schedules: List[Dict[str, Any]] = Field(..., description="List of schedules")
class OverhaulSystemComponents(OverhaulBase): class OverhaulSystemComponents(OverhaulBase):
systemComponents: Dict[str, Any] = Field(..., description='List of system components') systemComponents: Dict[str, Any] = Field(
..., description="List of system components"
)
class OverhaulRead(OverhaulBase): class OverhaulRead(OverhaulBase):
overview: Dict[str, Any] overview: Dict[str, Any]
@ -20,5 +31,44 @@ class OverhaulRead(OverhaulBase):
schedules: List[Dict[str, Any]] schedules: List[Dict[str, Any]]
systemComponents: Dict[str, Any] systemComponents: Dict[str, Any]
class BudgetContraintQuery(DefultBase): class BudgetContraintQuery(DefultBase):
cost_threshold: float = Field(100, ge=0, le=1000000000000000) cost_threshold: float = 100
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,108 +1,178 @@
from collections import defaultdict from collections import defaultdict
import math import random
from typing import Optional, List, Dict, Tuple from typing import Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import Delete, Select
from src.auth.service import CurrentUser
from src.contribution_util import calculate_contribution_accurate
from src.database.core import CollectorDbSession, DbSession from src.database.core import CollectorDbSession, DbSession
from src.overhaul_activity.service import get_standard_scope_by_session_id # from src.scope_equipment.model import ScopeEquipment
# from src.scope_equipment.service import get_by_scope_name
from src.overhaul_activity.service import get_all_by_session_id, get_standard_scope_by_session_id
# async def get_all_budget_constrains(
# *, db_session: DbSession, session_id: str, cost_threshold: float = 100000000
# ):
# At the module level, add this dictionary to store persistent EAF values
from collections import defaultdict from collections import defaultdict
from uuid import UUID from uuid import UUID
from typing import List, Dict, Tuple from typing import List, Dict, Tuple
from src.database.core import CollectorDbSession, DbSession from src.database.core import CollectorDbSession, DbSession
from src.overhaul_activity.service import get_standard_scope_by_session_id from src.overhaul_activity.service import get_standard_scope_by_session_id
from src.contribution_util import calculate_contribution_accurate
async def get_all_budget_constrains(
*,
db_session: DbSession,
collector_db: CollectorDbSession,
session_id: UUID,
simulation_result: Dict,
cost_threshold: float = 100_000_000,
use_optimal: bool = True, # default = optimal (knapsack)
) -> Tuple[List[Dict], List[Dict]]:
"""
Select equipment under budget constraint using contribution + cost efficiency.
Returns (priority_list, consequence_list).
"""
calc_result = simulation_result["calc_result"]
plant_result = simulation_result["plant_result"]
async def get_all_budget_constrains(*, db_session: DbSession, collector_db: CollectorDbSession, session_id: UUID, simulation_result: Dict, cost_threshold: float=100000000, use_optimal: bool=True) -> Tuple[List[Dict], List[Dict]]: equipments = await get_standard_scope_by_session_id(
calc_result = simulation_result['calc_result'] db_session=db_session,
plant_result = simulation_result['plant_result'] overhaul_session_id=session_id,
equipments = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=session_id, collector_db=collector_db) collector_db=collector_db,
)
if not equipments: if not equipments:
return ([], []) return [], []
# Flatten results
eq_results = calc_result if isinstance(calc_result, list) else [calc_result] eq_results = calc_result if isinstance(calc_result, list) else [calc_result]
equipments_eaf_contribution = calculate_asset_eaf_contributions(plant_result=plant_result, eq_results=eq_results)
# Calculate contribution map (node_name → contribution)
equipments_eaf_contribution = calculate_asset_eaf_contributions(
plant_result=plant_result, eq_results=eq_results
)
# Build base result list
result = [] result = []
for equipment in equipments: for equipment in equipments:
contribution = equipments_eaf_contribution.get(equipment.location_tag, 0.0) contribution = equipments_eaf_contribution.get(equipment.location_tag, 0.0)
total_cost = (equipment.overhaul_cost or 0) + (equipment.service_cost or 0) total_cost = (equipment.overhaul_cost or 0) + (equipment.service_cost or 0)
result.append({'id': equipment.id, 'location_tag': equipment.location_tag, 'name': equipment.equipment_name, 'total_cost': total_cost, 'eaf_contribution': contribution})
total_contrib = sum((item['eaf_contribution'] for item in result)) or 1.0 result.append(
{
"id": equipment.id,
"location_tag": equipment.location_tag,
"name": equipment.equipment_name,
"total_cost": total_cost,
"eaf_contribution": contribution,
}
)
# Normalize contribution so sum = 1.0
total_contrib = sum(item["eaf_contribution"] for item in result) or 1.0
for item in result: for item in result:
item['contribution_norm'] = item['eaf_contribution'] / total_contrib item["contribution_norm"] = item["eaf_contribution"] / total_contrib
# Calculate efficiency and composite score
for item in result: for item in result:
cost = item['total_cost'] or 1.0 cost = item["total_cost"] or 1.0
item['contribution_norm'] / cost efficiency = item["contribution_norm"] / cost
item['priority_score'] = item['contribution_norm'] item["priority_score"] = item["contribution_norm"]
# Choose method
if use_optimal: if use_optimal:
priority_list, consequence_list = knapsack_selection(result, cost_threshold) priority_list, consequence_list = knapsack_selection(result, cost_threshold)
else: else:
priority_list, consequence_list = greedy_selection(result, cost_threshold) priority_list, consequence_list = greedy_selection(result, cost_threshold)
return (priority_list, consequence_list)
return priority_list, consequence_list
def calculate_asset_eaf_contributions(plant_result, eq_results): def calculate_asset_eaf_contributions(plant_result, eq_results):
"""
Calculate each asset's negative contribution to plant EAF.
Key assumption: eq_results have aeros_node.node_name matching equipment.location_tag.
"""
results = defaultdict(float) results = defaultdict(float)
for asset in eq_results: for asset in eq_results:
node_name = asset.get('aeros_node', {}).get('node_name') node_name = asset.get("aeros_node", {}).get("node_name")
if node_name: if node_name:
results[node_name] = asset.get('contribution_factor', 0.0) results[node_name] = asset.get("contribution_factor", 0.0)
return results return results
def greedy_selection(equipments: List[dict], budget: float) -> Tuple[List[dict], List[dict]]: def greedy_selection(equipments: List[dict], budget: float) -> Tuple[List[dict], List[dict]]:
equipments_sorted = sorted(equipments, key=lambda x: x['priority_score'], reverse=True) """Greedy fallback: select items by score until budget is used."""
current_cost = 0.0 # Sort by priority_score descending
selected, excluded = ([], []) equipments_sorted = sorted(equipments, key=lambda x: x["priority_score"], reverse=True)
total_cost = 0
selected, excluded = [], []
for eq in equipments_sorted: for eq in equipments_sorted:
cost = eq.get('total_cost', 0.0) if total_cost + eq["cost"] <= budget:
if current_cost + cost <= budget:
selected.append(eq) selected.append(eq)
current_cost += cost total_cost += eq["cost"]
else: else:
excluded.append(eq) excluded.append(eq)
return (selected, excluded)
def knapsack_selection(equipments: List[dict], budget: float, scale: Optional[float]=None) -> Tuple[List[dict], List[dict]]: return selected, excluded
if not equipments:
return ([], [])
eligible_items = [] def knapsack_selection(equipments: List[dict], budget: float, scale: int = 10_000_000) -> Tuple[List[dict], List[dict]]:
strictly_excluded = [] """
for eq in equipments: Select equipment optimally within budget using 0/1 knapsack DP.
if eq['total_cost'] > budget: Uses scaling + 1D DP optimization to avoid MemoryError.
strictly_excluded.append(eq) Falls back to greedy if W is too large.
else: """
eligible_items.append(eq) n = len(equipments)
if not eligible_items:
return ([], strictly_excluded) # Scale costs + budget
n = len(eligible_items) costs = [int(eq["total_cost"] // scale) for eq in equipments]
if scale is None: values = [eq["priority_score"] for eq in equipments]
target_W = 2000
scale = max(1.0, budget / target_W)
costs = [int(math.ceil(eq['total_cost'] / scale)) for eq in eligible_items]
values = [eq['priority_score'] for eq in eligible_items]
W = int(budget // scale) W = int(budget // scale)
if W > 1000000:
# Fallback if W is still too large
if W > 1_000_000:
print("too big")
return greedy_selection(equipments, budget) return greedy_selection(equipments, budget)
# 1D DP array
dp = [0.0] * (W + 1) dp = [0.0] * (W + 1)
keep = [[False] * (W + 1) for _ in range(n)] keep = [[False] * (W + 1) for _ in range(n)] # track selection choices
for i in range(n): for i in range(n):
cost, value = (costs[i], values[i]) cost, value = costs[i], values[i]
for w in range(W, cost - 1, -1): for w in range(W, cost - 1, -1):
if dp[w - cost] + value >= dp[w]: if dp[w - cost] + value >= dp[w]: # <= FIXED HERE
dp[w] = dp[w - cost] + value dp[w] = dp[w - cost] + value
keep[i][w] = True keep[i][w] = True
selected = []
backtrack_excluded = [] # Backtrack to find selected items
selected, excluded = [], []
w = W w = W
for i in range(n - 1, -1, -1): for i in range(n - 1, -1, -1):
if keep[i][w]: if keep[i][w]:
selected.append(eligible_items[i]) selected.append(equipments[i])
w -= costs[i] w -= costs[i]
else: else:
backtrack_excluded.append(eligible_items[i]) excluded.append(equipments[i])
excluded = backtrack_excluded + strictly_excluded
current_total_cost = sum((eq['total_cost'] for eq in selected)) # Optional: fill leftover budget with zero-priority items
remaining_budget = budget - current_total_cost remaining_budget = budget - sum(eq["total_cost"] for eq in selected)
if remaining_budget > 0: if remaining_budget > 0:
excluded.sort(key=lambda x: x['priority_score'], reverse=True)
for eq in excluded[:]: for eq in excluded[:]:
if eq['total_cost'] <= remaining_budget: if eq["total_cost"] <= remaining_budget:
selected.append(eq) selected.append(eq)
excluded.remove(eq) excluded.remove(eq)
remaining_budget -= eq['total_cost'] remaining_budget -= eq["total_cost"]
return (selected, excluded)
return selected, excluded

@ -1,45 +1,127 @@
import asyncio
from typing import Dict, List, Optional
from typing_extensions import Annotated from typing_extensions import Annotated
from temporalio.client import Client from temporalio.client import Client
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from fastapi.params import Query from fastapi.params import Query
from src.calculation_target_reliability.utils import wait_for_workflow
from src.config import TEMPORAL_URL, TR_RBD_ID from src.config import TEMPORAL_URL, TR_RBD_ID
from src.database.core import DbSession, CollectorDbSession from src.database.core import DbSession, CollectorDbSession
from src.auth.service import Token from src.auth.service import Token
from src.models import StandardResponse from src.models import StandardResponse
from .service import get_simulation_results, identify_worst_eaf_contributors
from .service import run_rbd_simulation, get_simulation_results, identify_worst_eaf_contributors
from .schema import OptimizationResult, TargetReliabiltiyQuery from .schema import OptimizationResult, TargetReliabiltiyQuery
from src.auth.access_control import required_permission, OHPermission from src.auth.access_control import required_permission, OHPermission
from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends from fastapi import Depends
router = APIRouter() router = APIRouter()
@router.get('', response_model=StandardResponse[OptimizationResult], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_target_reliability(db_session: DbSession, token: Token, collector_db: CollectorDbSession, params: Annotated[TargetReliabiltiyQuery, Query()]): # @router.get("", response_model=StandardResponse[List[Dict]])
# async def get_target_reliability(
# db_session: DbSession,
# scope_name: Optional[str] = Query(None),
# eaf_threshold: float = Query(100),
# ):
# """Get all scope pagination."""
# results = await get_all_target_reliability(
# db_session=db_session, scope_name=scope_name, eaf_threshold=eaf_threshold
# )
# return StandardResponse(
# data=results,
# message="Data retrieved successfully",
# )
@router.get("", response_model=StandardResponse[OptimizationResult], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_target_reliability(
db_session: DbSession,
token: Token,
collector_db: CollectorDbSession,
params: Annotated[TargetReliabiltiyQuery, Query()],
# oh_session_id: Optional[str] = Query(None),
# eaf_input: float = Query(99.8),
# duration: int = Query(17520),
# simulation_id: Optional[str] = Query(None),
# cut_hours = Query(0)
):
"""Get all scope pagination."""
oh_session_id = params.oh_session_id oh_session_id = params.oh_session_id
eaf_input = params.eaf_input eaf_input = params.eaf_input
duration = params.duration duration = params.duration
simulation_id = params.simulation_id simulation_id = params.simulation_id
cut_hours = params.cut_hours cut_hours = params.cut_hours
if not oh_session_id: if not oh_session_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='oh_session_id is required') raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="oh_session_id is required",
)
# results = await get_eaf_timeline(
# db_session=db_session,
# oh_session_id=oh_session_id,
# eaf_input=eaf_input,
# oh_duration=duration
# )
if duration != 17520: if duration != 17520:
if not simulation_id: if not simulation_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Simulation ID is required for non-default duration. Please run simulation first.') raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Simulation ID is required for non-default duration. Please run simulation first.",
)
else:
try: try:
temporal_client = await Client.connect(TEMPORAL_URL) temporal_client = await Client.connect(TEMPORAL_URL)
handle = temporal_client.get_workflow_handle(f'simulation-{simulation_id}') handle = temporal_client.get_workflow_handle(f"simulation-{simulation_id}")
desc = await handle.describe() desc = await handle.describe()
status_name = desc.status.name status_name = desc.status.name
if status_name in ['RUNNING', 'CONTINUED_AS_NEW']:
raise HTTPException(status_code=status.HTTP_425_TOO_EARLY, detail='Simulation is still running.') if status_name in ["RUNNING", "CONTINUED_AS_NEW"]:
if status_name != 'COMPLETED': raise HTTPException(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Simulation failed with status: {status_name}') status_code=status.HTTP_425_TOO_EARLY,
detail="Simulation is still running.",
)
elif status_name != "COMPLETED":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Simulation failed with status: {status_name}",
)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f'Simulation not found or error checking status: {str(e)}') # Handle connection errors or invalid workflow IDs
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Simulation not found or error checking status: {str(e)}",
)
else: else:
simulation_id = TR_RBD_ID simulation_id = TR_RBD_ID
results = await get_simulation_results(simulation_id=simulation_id, token=token)
optimize_result = await identify_worst_eaf_contributors(simulation_result=results, target_eaf=eaf_input, db_session=db_session, oh_session_id=oh_session_id, collector_db=collector_db, simulation_id=simulation_id, duration=duration, po_duration=1200, cut_hours=float(cut_hours))
return StandardResponse(data=optimize_result, message='Data retrieved successfully') results = await get_simulation_results(
simulation_id=simulation_id,
token=token
)
optimize_result = await identify_worst_eaf_contributors(
simulation_result=results,
target_eaf=eaf_input,
db_session=db_session,
oh_session_id=oh_session_id,
collector_db=collector_db,
simulation_id=simulation_id,
duration=duration,
po_duration=1200,
cut_hours=float(cut_hours)
)
return StandardResponse(
data=optimize_result,
message="Data retrieved successfully",
)

@ -1,18 +1,29 @@
from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.models import DefultBase
from src.models import DefultBase, Pagination
class OverhaulBase(BaseModel): class OverhaulBase(BaseModel):
pass pass
class OverhaulCriticalParts(OverhaulBase): class OverhaulCriticalParts(OverhaulBase):
criticalParts: List[str] = Field(..., description='List of critical parts') criticalParts: List[str] = Field(..., description="List of critical parts")
class OverhaulSchedules(OverhaulBase): class OverhaulSchedules(OverhaulBase):
schedules: List[Dict[str, Any]] = Field(..., description='List of schedules') schedules: List[Dict[str, Any]] = Field(..., description="List of schedules")
class OverhaulSystemComponents(OverhaulBase): class OverhaulSystemComponents(OverhaulBase):
systemComponents: Dict[str, Any] = Field(..., description='List of system components') systemComponents: Dict[str, Any] = Field(
..., description="List of system components"
)
class OverhaulRead(OverhaulBase): class OverhaulRead(OverhaulBase):
overview: Dict[str, Any] overview: Dict[str, Any]
@ -39,6 +50,7 @@ class MaintenanceScenario(OverhaulBase):
plant_level_benefit: float = 0.0 plant_level_benefit: float = 0.0
capacity_weight : float capacity_weight : float
class OptimizationResult(OverhaulBase): class OptimizationResult(OverhaulBase):
current_plant_eaf: float current_plant_eaf: float
target_plant_eaf: float target_plant_eaf: float
@ -51,9 +63,48 @@ class OptimizationResult(OverhaulBase):
optimization_success: bool = False optimization_success: bool = False
simulation_id: Optional[str] = None simulation_id: Optional[str] = None
class TargetReliabiltiyQuery(DefultBase): class TargetReliabiltiyQuery(DefultBase):
oh_session_id: Optional[str] = Field(None) oh_session_id: Optional[str] = Field(None)
eaf_input: float = Field(99.8) eaf_input: float = Field(99.8)
duration: int = Field(17520) duration: int = Field(17520)
simulation_id: Optional[str] = Field(None) simulation_id: Optional[str] = Field(None)
cut_hours:int = Field(0) cut_hours:int = Field(0)
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,142 +1,356 @@
import math import math
from typing import Optional, List
from dataclasses import dataclass
from sqlalchemy import Delete, Select
import httpx import httpx
from src.auth.service import CurrentUser
from src.config import RBD_SERVICE_API from src.config import RBD_SERVICE_API
from src.contribution_util import calculate_contribution, calculate_contribution_accurate
from src.database.core import DbSession, CollectorDbSession from src.database.core import DbSession, CollectorDbSession
from datetime import datetime, timedelta
import random
from .utils import generate_down_periods
from src.overhaul_scope.service import get as get_overhaul
from bisect import bisect_left
from collections import defaultdict
import asyncio import asyncio
from .schema import AssetWeight, OptimizationResult from .schema import AssetWeight,MaintenanceScenario,OptimizationResult
from src.overhaul_activity.service import get_standard_scope_by_session_id from src.overhaul_activity.service import get_standard_scope_by_session_id
client = httpx.AsyncClient(timeout=300.0) client = httpx.AsyncClient(timeout=300.0)
async def run_rbd_simulation(*, sim_hours: int, token): async def run_rbd_simulation(*, sim_hours: int, token):
sim_data = {'SimulationName': f'Simulasi TR OH {sim_hours}', 'SchematicName': '- TJB - Unit 3 -', 'SimSeed': 1, 'SimDuration': sim_hours, 'OverhaulInterval': sim_hours - 1201, 'DurationUnit': 'UHour', 'SimNumRun': 1, 'IsDefault': False, 'OverhaulDuration': 1200} sim_data = {
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} "SimulationName": f"Simulasi TR OH {sim_hours}",
rbd_simulation_url = f'{RBD_SERVICE_API}/aeros/simulation/run' "SchematicName": "- TJB - Unit 3 -",
"SimSeed": 1,
"SimDuration": sim_hours,
"OverhaulInterval": sim_hours - 1201,
"DurationUnit": "UHour",
"SimNumRun": 1,
"IsDefault": False,
"OverhaulDuration": 1200
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
rbd_simulation_url = f"{RBD_SERVICE_API}/aeros/simulation/run"
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
response = await client.post(rbd_simulation_url, json=sim_data, headers=headers) response = await client.post(rbd_simulation_url, json=sim_data, headers=headers)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
async def get_simulation_results(*, simulation_id: str, token: str): async def get_simulation_results(*, simulation_id: str, token: str):
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} headers = {
calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode' "Authorization": f"Bearer {token}",
calc_plant_result = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant' "Content-Type": "application/json"
}
calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode"
# plot_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/plot/{simulation_id}?nodetype=RegularNode"
calc_plant_result = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant"
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
calc_task = client.get(calc_result_url, headers=headers) calc_task = client.get(calc_result_url, headers=headers)
# plot_task = client.get(plot_result_url, headers=headers)
plant_task = client.get(calc_plant_result, headers=headers) plant_task = client.get(calc_plant_result, headers=headers)
# Run all three requests concurrently
calc_response, plant_response = await asyncio.gather(calc_task, plant_task) calc_response, plant_response = await asyncio.gather(calc_task, plant_task)
calc_response.raise_for_status() calc_response.raise_for_status()
# plot_response.raise_for_status()
plant_response.raise_for_status() plant_response.raise_for_status()
calc_data = calc_response.json()['data']
plant_data = plant_response.json()['data'] calc_data = calc_response.json()["data"]
return {'calc_result': calc_data, 'plant_result': plant_data} # plot_data = plot_response.json()["data"]
plant_data = plant_response.json()["data"]
return {
"calc_result": calc_data,
# "plot_result": plot_data,
"plant_result": plant_data
}
def calculate_asset_eaf_contributions(plant_result, eq_results, standard_scope, eaf_gap, scheduled_outage): def calculate_asset_eaf_contributions(plant_result, eq_results, standard_scope, eaf_gap, scheduled_outage):
"""
Calculate each asset's contribution to plant EAF with realistic, fair improvement allocation.
The total EAF gap is distributed among assets proportionally to their contribution potential.
Automatically skips equipment with no unplanned downtime (only scheduled outages).
"""
eaf_gap_fraction = eaf_gap / 100.0 if eaf_gap > 1.0 else eaf_gap eaf_gap_fraction = eaf_gap / 100.0 if eaf_gap > 1.0 else eaf_gap
total_hours = plant_result.get('total_uptime') + plant_result.get('total_downtime')
total_hours = plant_result.get("total_uptime") + plant_result.get("total_downtime")
plant_operating_fraction = (total_hours - scheduled_outage) / total_hours plant_operating_fraction = (total_hours - scheduled_outage) / total_hours
REALISTIC_MAX_TECHNICAL = 0.995 REALISTIC_MAX_TECHNICAL = 0.995
REALISTIC_MAX_AVAILABILITY = REALISTIC_MAX_TECHNICAL * plant_operating_fraction REALISTIC_MAX_AVAILABILITY = REALISTIC_MAX_TECHNICAL * plant_operating_fraction
MIN_IMPROVEMENT_PERCENT = 0.0001 MIN_IMPROVEMENT_PERCENT = 0.0001
min_improvement_fraction = MIN_IMPROVEMENT_PERCENT / 100.0 min_improvement_fraction = MIN_IMPROVEMENT_PERCENT / 100.0
EPSILON = 0.001 # 1 ms or a fraction of an hour for comparison tolerance
results = [] results = []
weighted_assets = [] weighted_assets = []
# Step 1: Collect eligible assets and their weights
for asset in eq_results: for asset in eq_results:
node = asset.get('aeros_node') node = asset.get("aeros_node")
if not node: if not node:
continue continue
asset_name = node.get('node_name')
num_of_events = asset.get('num_events', 0) asset_name = node.get("node_name")
num_of_events = asset.get("num_events", 0)
if asset_name not in standard_scope: if asset_name not in standard_scope:
continue continue
contribution_factor = asset.get('contribution_factor', 0.0)
birbaum = asset.get('contribution', 0.0) contribution_factor = asset.get("contribution_factor", 0.0)
current_availability = asset.get('availability', 0.0) birbaum = asset.get("contribution", 0.0)
downtime = asset.get('total_downtime', 0.0) current_availability = asset.get("availability", 0.0)
if num_of_events < 2 or contribution_factor <= 0: downtime = asset.get("total_downtime", 0.0)
# --- NEW: Skip equipment with no failures and near-maximum availability ---
if (
num_of_events < 2 # no unplanned events
or contribution_factor <= 0
):
# This equipment has nothing to improve realistically
continue continue
# --- Compute realistic possible improvement ---
if REALISTIC_MAX_AVAILABILITY > current_availability: if REALISTIC_MAX_AVAILABILITY > current_availability:
max_possible_improvement = REALISTIC_MAX_AVAILABILITY - current_availability max_possible_improvement = REALISTIC_MAX_AVAILABILITY - current_availability
else: else:
max_possible_improvement = 0.0 max_possible_improvement = 0.0 # No improvement possible
# Compute weighted importance (Birnbaum × FV)
raw_weight = birbaum raw_weight = birbaum
weight = math.sqrt(max(raw_weight, 0.0)) weight = math.sqrt(max(raw_weight, 0.0))
weighted_assets.append((asset, weight, 0)) weighted_assets.append((asset, weight, 0))
total_weight = sum((w for _, w, _ in weighted_assets)) or 1.0
# Step 2: Compute total weight
total_weight = sum(w for _, w, _ in weighted_assets) or 1.0
# Step 3: Distribute improvement proportionally to weight
for asset, weight, max_possible_improvement in weighted_assets: for asset, weight, max_possible_improvement in weighted_assets:
node = asset.get('aeros_node') node = asset.get("aeros_node")
contribution_factor = asset.get('contribution_factor', 0.0) contribution_factor = asset.get("contribution_factor", 0.0)
birbaum = asset.get('contribution', 0.0) birbaum = asset.get("contribution", 0.0)
current_availability = asset.get('availability', 0.0) current_availability = asset.get("availability", 0.0)
downtime = asset.get('total_downtime', 0.0) downtime = asset.get("total_downtime", 0.0)
required_improvement = eaf_gap_fraction * (weight/total_weight) required_improvement = eaf_gap_fraction * (weight/total_weight)
required_improvement = min(required_improvement, max_possible_improvement) required_improvement = min(required_improvement, max_possible_improvement)
required_improvement = max(required_improvement, min_improvement_fraction) required_improvement = max(required_improvement, min_improvement_fraction)
improvement_impact = required_improvement * contribution_factor improvement_impact = required_improvement * contribution_factor
efficiency = birbaum / downtime if downtime > 0 else birbaum efficiency = birbaum / downtime if downtime > 0 else birbaum
contribution = AssetWeight(node=node, availability=current_availability, contribution=contribution_factor, required_improvement=required_improvement, improvement_impact=improvement_impact, num_of_failures=asset.get('num_events', 0), down_time=downtime, efficiency=efficiency, birbaum=birbaum)
contribution = AssetWeight(
node=node,
availability=current_availability,
contribution=contribution_factor,
required_improvement=required_improvement,
improvement_impact=improvement_impact,
num_of_failures=asset.get("num_events", 0),
down_time=downtime,
efficiency=efficiency,
birbaum=birbaum,
)
results.append(contribution) results.append(contribution)
# Step 4: Sort by Birnbaum importance
results.sort(key=lambda x: x.birbaum, reverse=True) results.sort(key=lambda x: x.birbaum, reverse=True)
return results return results
def project_eaf_improvement(asset: AssetWeight, improvement_factor: float = 0.3) -> float: def project_eaf_improvement(asset: AssetWeight, improvement_factor: float = 0.3) -> float:
"""
Project EAF improvement after maintenance
This is a simplified model - you should replace with your actual prediction logic
"""
current_downtime_pct = 100 - asset.eaf current_downtime_pct = 100 - asset.eaf
improved_downtime_pct = current_downtime_pct * (1 - improvement_factor) improved_downtime_pct = current_downtime_pct * (1 - improvement_factor)
projected_eaf = 100 - improved_downtime_pct projected_eaf = 100 - improved_downtime_pct
return min(projected_eaf, 99.9) return min(projected_eaf, 99.9) # Cap at 99.9%
async def identify_worst_eaf_contributors(
*,
simulation_result,
target_eaf: float,
db_session: DbSession,
oh_session_id: str,
collector_db: CollectorDbSession,
simulation_id: str,
duration: int,
po_duration: int,
cut_hours: float = 0, # new optional parameter: how many hours of planned outage user wants to cut
):
"""
Identify equipment that contributes most to plant EAF reduction,
evaluate if target EAF is physically achievable, and optionally
calculate the additional improvement if user cuts scheduled outage.
"""
calc_result = simulation_result["calc_result"]
plant_result = simulation_result["plant_result"]
async def identify_worst_eaf_contributors(*, simulation_result, target_eaf: float, db_session: DbSession, oh_session_id: str, collector_db: CollectorDbSession, simulation_id: str, duration: int, po_duration: int, cut_hours: float=0):
calc_result = simulation_result['calc_result']
plant_result = simulation_result['plant_result']
eq_results = calc_result if isinstance(calc_result, list) else [calc_result] eq_results = calc_result if isinstance(calc_result, list) else [calc_result]
current_plant_eaf = plant_result.get('eaf', 0)
# Base parameters
current_plant_eaf = plant_result.get("eaf", 0)
total_hours = duration total_hours = duration
scheduled_outage = int(po_duration) scheduled_outage = int(po_duration)
reduced_outage = max(scheduled_outage - cut_hours, 0) reduced_outage = max(scheduled_outage - cut_hours, 0)
max_eaf_possible = (total_hours - reduced_outage) / total_hours * 100 max_eaf_possible = (total_hours - reduced_outage) / total_hours * 100
scheduled_eaf_gain = cut_hours / total_hours * 100 if cut_hours > 0 else 0.0
# Improvement purely from outage reduction (global)
scheduled_eaf_gain = (cut_hours / total_hours) * 100 if cut_hours > 0 else 0.0
# Target feasibility check
warning_message = None warning_message = None
if target_eaf > max_eaf_possible: if target_eaf > max_eaf_possible:
target_eaf - max_eaf_possible impossible_gap = target_eaf - max_eaf_possible
required_scheduled_hours = total_hours * (1 - target_eaf / 100) required_scheduled_hours = total_hours * (1 - target_eaf / 100)
required_reduction = reduced_outage - required_scheduled_hours required_reduction = reduced_outage - required_scheduled_hours
# Build dynamic phrase for clarity
if cut_hours > 0: if cut_hours > 0:
reduction_phrase = f' even after reducing planned outage by {cut_hours}h' reduction_phrase = f" even after reducing planned outage by {cut_hours}h"
else: else:
reduction_phrase = '' reduction_phrase = ""
warning_message = f'⚠️ Target EAF {target_eaf:.2f}% exceeds theoretical maximum {max_eaf_possible:.2f}%{reduction_phrase}.\nTo achieve it, planned outage must be further reduced by approximately {required_reduction:.1f} hours (from {reduced_outage:.0f}h → {required_scheduled_hours:.0f}h).'
warning_message = (
f"⚠️ Target EAF {target_eaf:.2f}% exceeds theoretical maximum {max_eaf_possible:.2f}%"
f"{reduction_phrase}.\n"
f"To achieve it, planned outage must be further reduced by approximately "
f"{required_reduction:.1f} hours (from {reduced_outage:.0f}h → {required_scheduled_hours:.0f}h)."
)
# Cap target EAF to max achievable for calculation
target_eaf = max_eaf_possible target_eaf = max_eaf_possible
eaf_gap = (target_eaf - current_plant_eaf) / 100.0 eaf_gap = (target_eaf - current_plant_eaf) / 100.0
if eaf_gap <= 0: if eaf_gap <= 0:
return OptimizationResult(current_plant_eaf=current_plant_eaf, target_plant_eaf=target_eaf, possible_plant_eaf=current_plant_eaf, eaf_gap=0, warning_message=warning_message or 'Target already achieved or exceeded.', asset_contributions=[], optimization_success=True, simulation_id=simulation_id, eaf_improvement_text='') return OptimizationResult(
standard_scope = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=oh_session_id, collector_db=collector_db) current_plant_eaf=current_plant_eaf,
target_plant_eaf=target_eaf,
possible_plant_eaf=current_plant_eaf,
eaf_gap=0,
warning_message=warning_message or "Target already achieved or exceeded.",
asset_contributions=[],
optimization_success=True,
simulation_id=simulation_id,
eaf_improvement_text=""
)
# Get standard scope (equipment in OH)
standard_scope = await get_standard_scope_by_session_id(
db_session=db_session,
overhaul_session_id=oh_session_id,
collector_db=collector_db,
)
standard_scope_location_tags = [tag.location_tag for tag in standard_scope] standard_scope_location_tags = [tag.location_tag for tag in standard_scope]
asset_contributions = calculate_asset_eaf_contributions(plant_result, eq_results, standard_scope_location_tags, eaf_gap, reduced_outage)
# Compute contributions for reliability improvements
asset_contributions = calculate_asset_eaf_contributions(
plant_result, eq_results, standard_scope_location_tags, eaf_gap, reduced_outage
)
# Greedy improvement allocation
project_eaf_improvement_total = 0.0 project_eaf_improvement_total = 0.0
selected_eq = [] selected_eq = []
for asset in asset_contributions: for asset in asset_contributions:
if project_eaf_improvement_total >= eaf_gap: if project_eaf_improvement_total >= eaf_gap:
break break
if project_eaf_improvement_total + asset.improvement_impact <= eaf_gap:
if (project_eaf_improvement_total + asset.improvement_impact) <= eaf_gap:
selected_eq.append(asset) selected_eq.append(asset)
project_eaf_improvement_total += asset.improvement_impact project_eaf_improvement_total += asset.improvement_impact
else: else:
continue continue
# Total EAF after improvements + optional outage cut
possible_eaf_plant = current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain possible_eaf_plant = current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain
possible_eaf_plant = min(possible_eaf_plant, max_eaf_possible) possible_eaf_plant = min(possible_eaf_plant, max_eaf_possible)
selected_eq.sort(key=lambda x: x.birbaum, reverse=True) selected_eq.sort(key=lambda x: x.birbaum, reverse=True)
required_cut_hours = 0 required_cut_hours = 0
# --- 2. Optimization feasible but cannot reach target (underperformance case) ---
if possible_eaf_plant < target_eaf: if possible_eaf_plant < target_eaf:
# Calculate shortfall
performance_gap = target_eaf - possible_eaf_plant performance_gap = target_eaf - possible_eaf_plant
required_cut_hours = performance_gap / 100 * total_hours
reliability_limit_msg = f'⚠️ Optimization was unable to reach target EAF {target_eaf:.2f}%.\nThe best achievable EAF based on current reliability is {possible_eaf_plant:.2f}% (short by {performance_gap:.2f}%).' # Estimate how many scheduled outage hours must be reduced to close the remaining gap
recommendation_msg = f'To achieve the target EAF, consider reducing planned outage by approximately {required_cut_hours:.1f} hours or {int(required_cut_hours / 24)} days (from {reduced_outage:.0f}h → {reduced_outage - required_cut_hours:.0f}h).' # Each hour reduced adds (1 / total_hours) * 100 % to plant EAF
required_cut_hours = (performance_gap / 100) * total_hours
reliability_limit_msg = (
f"⚠️ Optimization was unable to reach target EAF {target_eaf:.2f}%.\n"
f"The best achievable EAF based on current reliability is "
f"{possible_eaf_plant:.2f}% (short by {performance_gap:.2f}%)."
)
# Add actionable recommendation
recommendation_msg = (
f"To achieve the target EAF, consider reducing planned outage by approximately "
f"{required_cut_hours:.1f} hours or {int(required_cut_hours/24)} days (from {reduced_outage:.0f}h → {reduced_outage - required_cut_hours:.0f}h)."
)
if warning_message: if warning_message:
warning_message = warning_message + '\n\n' + reliability_limit_msg + '\n' + recommendation_msg warning_message = warning_message + "\n\n" + reliability_limit_msg + "\n" + recommendation_msg
else: else:
warning_message = reliability_limit_msg + '\n' + recommendation_msg warning_message = reliability_limit_msg + "\n" + recommendation_msg
eaf_improvement_points = possible_eaf_plant - current_plant_eaf
# --- EAF improvement reporting ---
eaf_improvement_points = (possible_eaf_plant - current_plant_eaf)
# Express as text for user readability
if eaf_improvement_points > 0: if eaf_improvement_points > 0:
improvement_text = f'{eaf_improvement_points:.6f} percentage points increase' improvement_text = f"{eaf_improvement_points:.6f} percentage points increase"
else: else:
improvement_text = 'No measurable improvement achieved' improvement_text = "No measurable improvement achieved"
return OptimizationResult(current_plant_eaf=current_plant_eaf, target_plant_eaf=target_eaf, possible_plant_eaf=possible_eaf_plant, eaf_gap=eaf_gap, warning_message=warning_message, eaf_improvement_text=improvement_text, recommended_reduced_outage=required_cut_hours, asset_contributions=[{'node': asset.node, 'availability': asset.availability, 'contribution': asset.contribution, 'sensitivy': asset.birbaum, 'required_improvement': asset.required_improvement, 'system_impact': asset.improvement_impact, 'num_of_failures': asset.num_of_failures, 'down_time': asset.down_time, 'efficiency': asset.efficiency} for asset in selected_eq], outage_reduction_hours=cut_hours, optimization_success=current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain >= target_eaf, simulation_id=simulation_id)
# Build result
return OptimizationResult(
current_plant_eaf=current_plant_eaf,
target_plant_eaf=target_eaf,
possible_plant_eaf=possible_eaf_plant,
eaf_gap=eaf_gap,
warning_message=warning_message, # numeric
eaf_improvement_text=improvement_text,
recommended_reduced_outage=required_cut_hours,
asset_contributions=[
{
"node": asset.node,
"availability": asset.availability,
"contribution": asset.contribution,
"sensitivy": asset.birbaum,
"required_improvement": asset.required_improvement,
"system_impact": asset.improvement_impact,
"num_of_failures": asset.num_of_failures,
"down_time": asset.down_time,
"efficiency": asset.efficiency,
}
for asset in selected_eq
],
outage_reduction_hours=cut_hours,
optimization_success=(current_plant_eaf + project_eaf_improvement_total * 100 + scheduled_eaf_gain)
>= target_eaf,
simulation_id=simulation_id,
)

@ -1,49 +1,91 @@
import asyncio import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
import random import random
from typing import Optional from typing import List, Optional
from temporalio.client import Client from temporalio.client import Client
from src.config import TEMPORAL_URL
def generate_down_periods(start_date: datetime, end_date: datetime, num_periods: Optional[int]=None, min_duration: int=3, max_duration: int=7) -> list[tuple[datetime, datetime]]: from src.config import TEMPORAL_URL, TR_RBD_ID
def generate_down_periods(start_date: datetime, end_date: datetime,
num_periods: Optional[int] = None, min_duration: int = 3,
max_duration: int = 7) -> list[tuple[datetime, datetime]]:
"""
Generate random system down periods within a date range.
Args:
start_date (datetime): Start date of the overall period
end_date (datetime): End date of the overall period
num_periods (int, optional): Number of down periods to generate.
If None, generates 1-3 periods randomly
min_duration (int): Minimum duration of each down period in days
max_duration (int): Maximum duration of each down period in days
Returns:
list[tuple[datetime, datetime]]: List of (start_date, end_date) tuples
for each down period
"""
if num_periods is None: if num_periods is None:
num_periods = random.randint(1, 3) num_periods = random.randint(1, 3)
total_days = (end_date - start_date).days total_days = (end_date - start_date).days
down_periods = [] down_periods = []
# Generate random down periods
for _ in range(num_periods): for _ in range(num_periods):
# Random duration for this period
duration = random.randint(min_duration, max_duration) duration = random.randint(min_duration, max_duration)
# Ensure we don't exceed the total date range
latest_possible_start = total_days - duration latest_possible_start = total_days - duration
if latest_possible_start < 0: if latest_possible_start < 0:
continue continue
# Random start day within available range
start_day = random.randint(0, latest_possible_start) start_day = random.randint(0, latest_possible_start)
period_start = start_date + timedelta(days=start_day) period_start = start_date + timedelta(days=start_day)
period_end = period_start + timedelta(days=duration) period_end = period_start + timedelta(days=duration)
overlaps = any((p_start <= period_end and period_start <= p_end for p_start, p_end in down_periods))
# Check for overlaps with existing periods
overlaps = any(
(p_start <= period_end and period_start <= p_end)
for p_start, p_end in down_periods
)
if not overlaps: if not overlaps:
down_periods.append((period_start, period_end)) down_periods.append((period_start, period_end))
return sorted(down_periods) return sorted(down_periods)
async def wait_for_workflow(simulation_id, max_retries=3): async def wait_for_workflow(simulation_id, max_retries=3):
workflow_id = f'simulation-{simulation_id}' workflow_id = f"simulation-{simulation_id}" # use returned ID
retries = 0 retries = 0
temporal_client = await Client.connect(TEMPORAL_URL) temporal_client = await Client.connect(TEMPORAL_URL)
while True: while True:
try: try:
handle = temporal_client.get_workflow_handle(workflow_id=workflow_id) handle = temporal_client.get_workflow_handle(workflow_id=workflow_id)
desc = await handle.describe() desc = await handle.describe()
status = desc.status.name status = desc.status.name
if status not in ['RUNNING', 'CONTINUED_AS_NEW']:
print(f'✅ Workflow {workflow_id} finished with status: {status}') if status not in ["RUNNING", "CONTINUED_AS_NEW"]:
print(f"✅ Workflow {workflow_id} finished with status: {status}")
break break
print(f'⏳ Workflow {workflow_id} still {status}, checking again in 10s...')
print(f"⏳ Workflow {workflow_id} still {status}, checking again in 10s...")
except Exception as e: except Exception as e:
retries += 1 retries += 1
if retries > max_retries: if retries > max_retries:
print(f'⚠️ Workflow {workflow_id} not found after {max_retries} retries, treating as done. Error: {e}') print(f"⚠️ Workflow {workflow_id} not found after {max_retries} retries, treating as done. Error: {e}")
break break
print(f'⚠️ Workflow {workflow_id} not found (retry {retries}/{max_retries}), waiting 10s before retry...') else:
print(f"⚠️ Workflow {workflow_id} not found (retry {retries}/{max_retries}), waiting 10s before retry...")
await asyncio.sleep(10) await asyncio.sleep(10)
continue continue
retries = 0
retries = 0 # reset retries if describe() worked
await asyncio.sleep(30) await asyncio.sleep(30)
return simulation_id return simulation_id

@ -6,102 +6,228 @@ from collections import defaultdict
from src.config import BASE_URL from src.config import BASE_URL
class OptimumCostModel: class OptimumCostModel:
def __init__(self, token, last_oh_date, next_oh_date,interval =30, base_url: str = f"{BASE_URL}"):
def __init__(self, token, last_oh_date, next_oh_date, interval=30, base_url: str=f'{BASE_URL}'):
self.api_base_url = base_url self.api_base_url = base_url
self.token = token self.token = token
self.last_oh_date = last_oh_date self.last_oh_date = last_oh_date
self.next_oh_date = next_oh_date self.next_oh_date = next_oh_date
self.interval=30 self.interval=30
def get_reliability_from_api(self, target_date, location_tag): def get_reliability_from_api(self, target_date, location_tag):
"""
Get reliability value from API for a specific date.
Parameters:
target_date: datetime object for the target date
Returns:
reliability value (float)
"""
# Format date for API call
date_str = target_date.strftime('%Y-%m-%d %H:%M:%S.%f') date_str = target_date.strftime('%Y-%m-%d %H:%M:%S.%f')
url = f'{self.api_base_url}/reliability/calculate/reliability/{location_tag}/{date_str}'
header = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + self.token} # Construct API URL
url = f"{self.api_base_url}/reliability/calculate/reliability/{location_tag}/{date_str}"
header = {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + self.token
}
try: try:
response = requests.get(url, headers=header) response = requests.get(url, headers=header)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
return data['data']['value'] reliability = data['data']['value']
return reliability
except requests.RequestException as e: except requests.RequestException as e:
print(f'API Error: {e}') print(f"API Error: {e}")
return None return None
except KeyError as e: except KeyError as e:
print(f'Data parsing error: {e}') print(f"Data parsing error: {e}")
return None return None
def get_reliability_equipment(self, location_tag): def get_reliability_equipment(self, location_tag):
current_date = self.last_oh_date current_date = self.last_oh_date
results = defaultdict() results = defaultdict()
print('Fetching reliability data from API...')
print("Fetching reliability data from API...")
while current_date <= self.next_oh_date: while current_date <= self.next_oh_date:
reliability = self.get_reliability_from_api(current_date, location_tag) reliability = self.get_reliability_from_api(current_date, location_tag)
results[current_date] = reliability results[current_date] = reliability
if reliability is not None: if reliability is not None:
print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: {reliability:.6f}") print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: {reliability:.6f}")
else: else:
print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: Failed to fetch") print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: Failed to fetch")
current_date += timedelta(days=self.interval) current_date += timedelta(days=self.interval)
return results return results
def calculate_costs_each_point(self, reliabilities, preventive_cost: float, failure_replacement_cost: float): def calculate_costs_each_point(self, reliabilities, preventive_cost: float, failure_replacement_cost: float):
# Calculate costs for each time point
results = [] results = []
dates = list(reliabilities.keys()) dates = list(reliabilities.keys())
for i, (date, reliability) in enumerate(reliabilities.items()): for i, (date, reliability) in enumerate(reliabilities.items()):
if reliability is None: if reliability is None:
continue continue
# Calculate time from last OH in days
time_from_last_oh = (date - self.last_oh_date).days time_from_last_oh = (date - self.last_oh_date).days
# Calculate failure replacement cost
failure_prob = 1 - reliability failure_prob = 1 - reliability
# For expected operating time, we need to integrate R(t) from 0 to T
# Since we have discrete points, we'll approximate using trapezoidal rule
if i == 0: if i == 0:
expected_operating_time = time_from_last_oh expected_operating_time = time_from_last_oh # First point
else: else:
# Approximate integral using available reliability values
time_points = [(dates[j] - self.last_oh_date).days for j in range(i+1)] time_points = [(dates[j] - self.last_oh_date).days for j in range(i+1)]
rel_values = [rel for rel in reliabilities[:i+1] if rel is not None] rel_values = [rel for rel in reliabilities[:i+1] if rel is not None]
if len(rel_values) > 1: if len(rel_values) > 1:
expected_operating_time = np.trapezoid(rel_values, time_points) expected_operating_time = np.trapezoid(rel_values, time_points)
else: else:
expected_operating_time = time_from_last_oh * reliability expected_operating_time = time_from_last_oh * reliability
# Calculate costs
if expected_operating_time > 0: if expected_operating_time > 0:
failure_cost = failure_prob * failure_replacement_cost / expected_operating_time failure_cost = (failure_prob * failure_replacement_cost) / expected_operating_time
preventive_cost = reliability * preventive_cost / expected_operating_time preventive_cost = (reliability * preventive_cost) / expected_operating_time
else: else:
# failure_cost = failure_prob * self.IDRu
# preventive_cost = reliability * self.IDRp
continue continue
total_cost = failure_cost + preventive_cost total_cost = failure_cost + preventive_cost
results.append({'date': date, 'days_from_last_oh': time_from_last_oh, 'reliability': reliability, 'failure_probability': failure_prob, 'expected_operating_time': expected_operating_time, 'failure_replacement_cost': failure_cost, 'preventive_replacement_cost': preventive_cost, 'total_cost': total_cost, 'procurement_cost': 0})
results.append({
'date': date,
'days_from_last_oh': time_from_last_oh,
'reliability': reliability,
'failure_probability': failure_prob,
'expected_operating_time': expected_operating_time,
'failure_replacement_cost': failure_cost,
'preventive_replacement_cost': preventive_cost,
'total_cost': total_cost,
'procurement_cost': 0
})
return results return results
def find_optimal_timing(self, results_df): def find_optimal_timing(self, results_df):
"""
Find the timing that gives the lowest total cost.
Parameters:
results_df: DataFrame from calculate_costs_over_period
Returns:
Dictionary with optimal timing information
"""
if results_df.empty: if results_df.empty:
return None return None
# Find minimum total cost
min_cost_idx = results_df['total_cost'].idxmin() min_cost_idx = results_df['total_cost'].idxmin()
optimal_row = results_df.loc[min_cost_idx] optimal_row = results_df.loc[min_cost_idx]
return {'optimal_index': min_cost_idx, 'optimal_date': optimal_row['date'], 'days_from_last_oh': optimal_row['days_from_last_oh'], 'reliability': optimal_row['reliability'], 'failure_cost': optimal_row['failure_replacement_cost'], 'preventive_cost': optimal_row['preventive_replacement_cost'], 'total_cost': optimal_row['total_cost'], 'procurement_cost': 0}
async def calculate_cost_all_equipment(self, db_session: DbSession, equipments: list, preventive_cost: float, calculation): return {
'optimal_index': min_cost_idx,
'optimal_date': optimal_row['date'],
'days_from_last_oh': optimal_row['days_from_last_oh'],
'reliability': optimal_row['reliability'],
'failure_cost': optimal_row['failure_replacement_cost'],
'preventive_cost': optimal_row['preventive_replacement_cost'],
'total_cost': optimal_row['total_cost'],
'procurement_cost': 0
}
async def calculate_cost_all_equipment(
self,
db_session: DbSession,
equipments: list,
preventive_cost: float,
calculation,
) :
"""
Calculate optimization for entire fleet of equipment
"""
max_interval = self._get_months_between(start_date, end_date) max_interval = self._get_months_between(start_date, end_date)
preventive_cost / len(equipments) preventive_cost_per_equipment = preventive_cost / len(equipments)
fleet_results = [] fleet_results = []
total_corrective_costs = np.zeros(max_interval) total_corrective_costs = np.zeros(max_interval)
total_preventive_costs = np.zeros(max_interval) total_preventive_costs = np.zeros(max_interval)
total_procurement_costs = np.zeros(max_interval) total_procurement_costs = np.zeros(max_interval)
np.zeros(max_interval) total_failures = np.zeros(max_interval)
for equipment in equipments: for equipment in equipments:
# Get reliability data
cost_per_failure = equipment.material_cost cost_per_failure = equipment.material_cost
reliabilities = await self.get_reliability_equipment(location_tag=equipment.equipment.location_tag) reliabilities = await self.get_reliability_equipment(
location_tag=equipment.equipment.location_tag,
)
predicted_cost = self.calculate_costs_each_point(reliabilities=reliabilities, preventive_cost=preventive_cost, failure_replacement_cost=cost_per_failure) predicted_cost = self.calculate_costs_each_point(reliabilities=reliabilities, preventive_cost=preventive_cost, failure_replacement_cost=cost_per_failure)
optimum_cost = self.find_optimal_timing(pd.DataFrame(predicted_cost)) optimum_cost = self.find_optimal_timing(pd.DataFrame(predicted_cost))
corrective_costs = [r['failure_cost'] for r in predicted_cost]
preventive_costs = [r['preventive_cost'] for r in predicted_cost] # Aggregate costs
procurement_costs = [r['procurement_cost'] for r in predicted_cost] corrective_costs = [r["failure_cost"] for r in predicted_cost]
procurement_details = [r['procurement_details'] for r in predicted_cost] preventive_costs = [r["preventive_cost"] for r in predicted_cost]
failures = [1 - r['reliability'] for r in predicted_cost] procurement_costs = [r["procurement_cost"] for r in predicted_cost]
fleet_results.append(CalculationEquipmentResult(corrective_costs=corrective_costs, overhaul_costs=preventive_costs, procurement_costs=procurement_costs, daily_failures=failures, assetnum=equipment.assetnum, material_cost=equipment.material_cost, service_cost=equipment.service_cost, optimum_day=optimum_cost['optimal_index'], calculation_data_id=calculation.id, master_equipment=equipment.equipment, procurement_details=procurement_details)) procurement_details = [r["procurement_details"] for r in predicted_cost]
failures = [(1-r["reliability"]) for r in predicted_cost]
fleet_results.append(
CalculationEquipmentResult(
corrective_costs=corrective_costs,
overhaul_costs=preventive_costs,
procurement_costs=procurement_costs,
daily_failures=failures,
assetnum=equipment.assetnum,
material_cost=equipment.material_cost,
service_cost=equipment.service_cost,
optimum_day=optimum_cost['optimal_index'],
calculation_data_id=calculation.id,
master_equipment=equipment.equipment,
procurement_details=procurement_details
)
)
total_corrective_costs += np.array(corrective_costs) total_corrective_costs += np.array(corrective_costs)
total_preventive_costs += np.array(preventive_costs) total_preventive_costs += np.array(preventive_costs)
total_procurement_costs += np.array(procurement_costs) total_procurement_costs += np.array(procurement_costs)
# Calculate fleet optimal interval
total_costs = total_corrective_costs + total_preventive_costs + total_procurement_costs total_costs = total_corrective_costs + total_preventive_costs + total_procurement_costs
fleet_optimal_index = np.argmin(total_costs) fleet_optimal_index = np.argmin(total_costs)
calculation.optimum_oh_day =fleet_optimal_index + 1 calculation.optimum_oh_day =fleet_optimal_index + 1
db_session.add_all(fleet_results) db_session.add_all(fleet_results)
await db_session.commit() await db_session.commit()
return {'id': calculation.id, 'fleet_results': fleet_results, 'fleet_optimal_interval': fleet_optimal_index + 1, 'fleet_optimal_cost': total_costs[fleet_optimal_index], 'total_corrective_costs': total_corrective_costs.tolist(), 'total_preventive_costs': total_preventive_costs.tolist(), 'total_procurement_costs': total_procurement_costs.tolist()}
return {
'id': calculation.id,
'fleet_results': fleet_results,
'fleet_optimal_interval': fleet_optimal_index + 1,
'fleet_optimal_cost': total_costs[fleet_optimal_index],
'total_corrective_costs': total_corrective_costs.tolist(),
'total_preventive_costs': total_preventive_costs.tolist(),
'total_procurement_costs': total_procurement_costs.tolist(),
}

@ -1,36 +1,135 @@
from typing import Optional from typing import Optional
from uuid import UUID
import numpy as np
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import func, select from sqlalchemy import Select, func, select
from sqlalchemy.orm import joinedload
from src.auth.service import Token
from src.config import TC_RBD_ID from src.config import TC_RBD_ID
from src.database.core import DbSession from src.database.core import DbSession
from src.overhaul_scope.service import get_all from src.overhaul_scope.service import get_all
from src.standard_scope.model import StandardScope from src.standard_scope.model import StandardScope
from src.workorder.model import MasterWorkOrder from src.workorder.model import MasterWorkOrder
from .schema import CalculationTimeConstrainsParametersCreate, CalculationTimeConstrainsParametersRead, CalculationTimeConstrainsParametersRetrive
from .service import create_param_and_data, get_calculation_data_by_id, run_simulation_with_spareparts from .schema import (CalculationTimeConstrainsParametersCreate,
CalculationTimeConstrainsParametersRead,
CalculationTimeConstrainsParametersRetrive,
CalculationTimeConstrainsRead)
from .service import (create_calculation_result_service, create_param_and_data,
get_avg_cost_by_asset,
get_calculation_by_reference_and_parameter,
get_calculation_data_by_id, get_calculation_result,
run_simulation_with_spareparts)
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
async def get_create_calculation_parameters(*, db_session: DbSession, calculation_id: Optional[str]=None):
async def get_create_calculation_parameters(
*, db_session: DbSession, calculation_id: Optional[str] = None
):
if calculation_id is not None: if calculation_id is not None:
calculation = await get_calculation_data_by_id(calculation_id=calculation_id, db_session=db_session) calculation = await get_calculation_data_by_id(
calculation_id=calculation_id, db_session=db_session
)
if not calculation: if not calculation:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') raise HTTPException(
return CalculationTimeConstrainsParametersRead(costPerFailure=calculation.parameter.avg_failure_cost, overhaulCost=calculation.parameter.overhaul_cost, reference=calculation) status_code=status.HTTP_404_NOT_FOUND,
stmt = select(StandardScope, func.avg(MasterWorkOrder.total_cost_max).label('average_cost')).outerjoin(MasterWorkOrder, StandardScope.location_tag == MasterWorkOrder.location_tag).group_by(StandardScope.id) detail="A data with this id does not exist.",
)
return CalculationTimeConstrainsParametersRead(
costPerFailure=calculation.parameter.avg_failure_cost,
overhaulCost=calculation.parameter.overhaul_cost,
reference=calculation,
)
stmt = (
select(
StandardScope,
func.avg(MasterWorkOrder.total_cost_max).label("average_cost"),
)
.outerjoin(MasterWorkOrder, StandardScope.location_tag == MasterWorkOrder.location_tag)
.group_by(StandardScope.id)
)
results = await db_session.execute(stmt) results = await db_session.execute(stmt)
costFailure = results.all() costFailure = results.all()
scopes = await get_all(db_session=db_session) scopes = await get_all(db_session=db_session)
avaiableScopes = {scope.id: scope.scope_name for scope in scopes} avaiableScopes = {scope.id: scope.scope_name for scope in scopes}
costFailurePerScope = {avaiableScopes.get(costPerFailure[0]): costPerFailure[1] for costPerFailure in costFailure} costFailurePerScope = {
return CalculationTimeConstrainsParametersRetrive(costPerFailure=costFailurePerScope, availableScopes=avaiableScopes.values(), recommendedScope='A') avaiableScopes.get(costPerFailure[0]): costPerFailure[1]
for costPerFailure in costFailure
}
return CalculationTimeConstrainsParametersRetrive(
costPerFailure=costFailurePerScope,
availableScopes=avaiableScopes.values(),
recommendedScope="A",
# historicalData={
# "averageOverhaulCost": 10000000,
# "lastCalculation": {
# "id": "calc_122",
# "date": "2024-10-15",
# "scope": "B",
# },
# },
)
async def create_calculation(
*,
token: str,
db_session: DbSession,
collector_db_session: CollectorDbSession,
calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate,
created_by: str,
simulation_id
):
calculation_data = await create_param_and_data(
db_session=db_session,
calculation_param_in=calculation_time_constrains_in,
created_by=created_by,
)
async def create_calculation(*, token: str, db_session: DbSession, collector_db_session: CollectorDbSession, calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate, created_by: str, simulation_id):
calculation_data = await create_param_and_data(db_session=db_session, calculation_param_in=calculation_time_constrains_in, created_by=created_by)
rbd_simulation_id = simulation_id or TC_RBD_ID rbd_simulation_id = simulation_id or TC_RBD_ID
return await run_simulation_with_spareparts(db_session=db_session, calculation=calculation_data, token=token, collector_db_session=collector_db_session, simulation_id=rbd_simulation_id)
async def get_or_create_scope_equipment_calculation(*, db_session: DbSession, scope_calculation_id, calculation_time_constrains_in: Optional[CalculationTimeConstrainsParametersCreate]): # results = await create_calculation_result_service(
scope_calculation = await get_calculation_data_by_id(db_session=db_session, calculation_id=scope_calculation_id) # db_session=db_session, calculation=calculation_data, token=token
# )
results = await run_simulation_with_spareparts(
db_session=db_session, calculation=calculation_data, token=token, collector_db_session=collector_db_session, simulation_id=rbd_simulation_id
)
return results
async def get_or_create_scope_equipment_calculation(
*,
db_session: DbSession,
scope_calculation_id,
calculation_time_constrains_in: Optional[CalculationTimeConstrainsParametersCreate]
):
scope_calculation = await get_calculation_data_by_id(
db_session=db_session, calculation_id=scope_calculation_id
)
if not scope_calculation: if not scope_calculation:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
return scope_calculation.id return scope_calculation.id
# Check if calculation already exist
# return CalculationTimeConstrainsRead(
# id=scope_calculation.id,
# reference=scope_calculation.overhaul_session_id,
# results=scope_calculation.results,
# optimum_oh=scope_calculation.optimum_oh_day,
# equipment_results=scope_calculation.equipment_results,
# )

@ -1,60 +1,150 @@
from enum import Enum from enum import Enum
from typing import Optional from typing import List, Optional, Union
from sqlalchemy import JSON, UUID, Boolean, Column, Float, ForeignKey, Integer, String
from sqlalchemy import (JSON, UUID, Boolean, Column, Float, ForeignKey,
Integer, Numeric, String)
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.database.core import Base, DbSession from src.database.core import Base, DbSession
from src.models import DefaultMixin, IdentityMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin, UUIDMixin
class OverhaulReferenceType(str, Enum): class OverhaulReferenceType(str, Enum):
SCOPE = 'SCOPE' SCOPE = "SCOPE"
ASSET = 'ASSET' ASSET = "ASSET"
class CalculationParam(Base, DefaultMixin, IdentityMixin): class CalculationParam(Base, DefaultMixin, IdentityMixin):
__tablename__ = 'oh_ms_calculation_param' __tablename__ = "oh_ms_calculation_param"
avg_failure_cost = Column(Float, nullable=False) avg_failure_cost = Column(Float, nullable=False)
overhaul_cost = Column(Float, nullable=False) overhaul_cost = Column(Float, nullable=False)
calculation_data = relationship('CalculationData', back_populates='parameter')
results = relationship('CalculationResult', back_populates='parameter') # Relationships
calculation_data = relationship("CalculationData", back_populates="parameter")
results = relationship("CalculationResult", back_populates="parameter")
# @classmethod
# async def create_with_references(
# cls,
# db: DbSession,
# avg_failure_cost: float,
# overhaul_cost: float,
# created_by: str,
# # list of {"reference_type": OverhaulReferenceType, "reference_id": str}
# references: List[dict]
# ):
# # Create parameter
# param = cls(
# avg_failure_cost=avg_failure_cost,
# overhaul_cost=overhaul_cost,
# created_by=created_by
# )
# db.add(param)
# await db.flush() # Flush to get the param.id
# # Create reference links
# for ref in references:
# reference_link = ReferenceLink(
# parameter_id=param.id,
# overhaul_reference_type=ref["reference_type"],
# reference_id=ref["reference_id"]
# )
# db.add(reference_link)
# await db.commit()
# await db.refresh(param)
# return param
class CalculationData(Base, DefaultMixin, IdentityMixin): class CalculationData(Base, DefaultMixin, IdentityMixin):
__tablename__ = 'oh_tr_calculation_data' __tablename__ = "oh_tr_calculation_data"
parameter_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_calculation_param.id'), nullable=True)
overhaul_session_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_overhaul.id')) parameter_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_calculation_param.id"), nullable=True
)
overhaul_session_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_overhaul.id")
)
optimum_oh_day = Column(Integer, nullable=True) optimum_oh_day = Column(Integer, nullable=True)
max_interval = Column(Integer, nullable=True) max_interval = Column(Integer, nullable=True)
rbd_simulation_id = Column(UUID(as_uuid=True), nullable=True) rbd_simulation_id = Column(UUID(as_uuid=True), nullable=True)
optimum_analysis = Column(JSON, nullable=True) optimum_analysis = Column(JSON, nullable=True)
session = relationship('OverhaulScope', lazy='raise')
parameter = relationship('CalculationParam', back_populates='calculation_data') session = relationship("OverhaulScope", lazy="raise")
equipment_results = relationship('CalculationEquipmentResult', lazy='raise', viewonly=True)
results = relationship('CalculationResult', lazy='raise', viewonly=True) parameter = relationship("CalculationParam", back_populates="calculation_data")
equipment_results = relationship(
"CalculationEquipmentResult", lazy="raise", viewonly=True
)
results = relationship("CalculationResult", lazy="raise", viewonly=True)
@classmethod @classmethod
async def create_with_param(cls, overhaul_session_id: str, db: DbSession, avg_failure_cost: Optional[float], overhaul_cost: Optional[float], created_by: str, params_id: Optional[UUID]): async def create_with_param(
cls,
overhaul_session_id: str,
db: DbSession,
avg_failure_cost: Optional[float],
overhaul_cost: Optional[float],
created_by: str,
params_id: Optional[UUID],
):
if not params_id: if not params_id:
params = CalculationParam(avg_failure_cost=avg_failure_cost, overhaul_cost=overhaul_cost, created_by=created_by) # Create Params
params = CalculationParam(
avg_failure_cost=avg_failure_cost,
overhaul_cost=overhaul_cost,
created_by=created_by,
)
db.add(params) db.add(params)
await db.flush() await db.flush()
params_id = params.id params_id = params.id
calculation_data = cls(overhaul_session_id=overhaul_session_id, created_by=created_by, parameter_id=params_id)
calculation_data = cls(
overhaul_session_id=overhaul_session_id,
created_by=created_by,
parameter_id=params_id,
)
db.add(calculation_data) db.add(calculation_data)
await db.commit() await db.commit()
await db.refresh(calculation_data) await db.refresh(calculation_data)
return calculation_data return calculation_data
class CalculationResult(Base, DefaultMixin): class CalculationResult(Base, DefaultMixin):
__tablename__ = 'oh_tr_calculation_result'
parameter_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_calculation_param.id'), nullable=False) __tablename__ = "oh_tr_calculation_result"
calculation_data_id = Column(UUID(as_uuid=True), ForeignKey('oh_tr_calculation_data.id'), nullable=False)
parameter_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_calculation_param.id"), nullable=False
)
calculation_data_id = Column(
UUID(as_uuid=True), ForeignKey("oh_tr_calculation_data.id"), nullable=False
)
day = Column(Integer, nullable=False) day = Column(Integer, nullable=False)
corrective_cost = Column(Float, nullable=False) corrective_cost = Column(Float, nullable=False)
overhaul_cost = Column(Float, nullable=False) overhaul_cost = Column(Float, nullable=False)
num_failures = Column(Integer, nullable=False) num_failures = Column(Integer, nullable=False)
parameter = relationship('CalculationParam', back_populates='results')
reference_link = relationship('CalculationData') parameter = relationship("CalculationParam", back_populates="results")
reference_link = relationship("CalculationData")
class CalculationEquipmentResult(Base, DefaultMixin): class CalculationEquipmentResult(Base, DefaultMixin):
__tablename__ = 'oh_tr_calculation_equipment_result'
__tablename__ = "oh_tr_calculation_equipment_result"
corrective_costs = Column(JSON, nullable=False) corrective_costs = Column(JSON, nullable=False)
overhaul_costs = Column(JSON, nullable=False) overhaul_costs = Column(JSON, nullable=False)
daily_failures = Column(JSON, nullable=False) daily_failures = Column(JSON, nullable=False)
@ -62,9 +152,17 @@ class CalculationEquipmentResult(Base, DefaultMixin):
location_tag = Column(String(255), nullable=False) location_tag = Column(String(255), nullable=False)
material_cost = Column(Float, nullable=False) material_cost = Column(Float, nullable=False)
service_cost = Column(Float, nullable=False) service_cost = Column(Float, nullable=False)
calculation_data_id = Column(UUID(as_uuid=True), ForeignKey('oh_tr_calculation_data.id'), nullable=True) calculation_data_id = Column(
UUID(as_uuid=True), ForeignKey("oh_tr_calculation_data.id"), nullable=True
)
optimum_day = Column(Integer, default=1) optimum_day = Column(Integer, default=1)
is_included = Column(Boolean, default=True) is_included = Column(Boolean, default=True)
procurement_details = Column(JSON, nullable=True) procurement_details = Column(JSON, nullable=True)
is_initial = Column(Boolean, default=True) is_initial = Column(Boolean, default=True)
master_equipment = relationship('MasterEquipment', lazy='joined', primaryjoin='and_(CalculationEquipmentResult.location_tag == foreign(MasterEquipment.location_tag))', uselist=False)
master_equipment = relationship(
"MasterEquipment",
lazy="joined",
primaryjoin="and_(CalculationEquipmentResult.location_tag == foreign(MasterEquipment.location_tag))",
uselist=False, # Add this if it's a one-to-one relationship
)

@ -1,61 +1,198 @@
from typing import Annotated, List, Optional, Union from typing import Annotated, List, Optional, Union
from fastapi import APIRouter from fastapi import APIRouter
from fastapi.params import Query from fastapi.params import Query
import requests
from src import config
from src.auth.service import CurrentUser, InternalKey, Token from src.auth.service import CurrentUser, InternalKey, Token
from src.config import DEFAULT_TC_ID from src.config import DEFAULT_TC_ID
from src.database.core import DbSession from src.database.core import DbSession
from src.models import StandardResponse from src.models import StandardResponse
from .flows import create_calculation, get_create_calculation_parameters, get_or_create_scope_equipment_calculation
from .schema import CalculationResultsRead, CalculationSelectedEquipmentUpdate, CalculationTimeConstrainsCreate, CalculationTimeConstrainsParametersCreate, CalculationTimeConstrainsParametersRead, CalculationTimeConstrainsParametersRetrive, CalculationTimeConstrainsRead, CreateCalculationQuery, EquipmentResult, CalculationTimeConstrainsReadNoResult from .flows import (create_calculation, get_create_calculation_parameters,
from .service import bulk_update_equipment, get_calculation_result, get_calculation_result_by_day, get_calculation_by_assetnum, get_all_calculations get_or_create_scope_equipment_calculation)
from .schema import (CalculationResultsRead,
CalculationSelectedEquipmentUpdate,
CalculationTimeConstrainsCreate,
CalculationTimeConstrainsParametersCreate,
CalculationTimeConstrainsParametersRead,
CalculationTimeConstrainsParametersRetrive,
CalculationTimeConstrainsRead, CreateCalculationQuery, EquipmentResult, CalculationTimeConstrainsReadNoResult)
from .service import (bulk_update_equipment, get_calculation_result,
get_calculation_result_by_day, get_calculation_by_assetnum, get_all_calculations)
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.auth.access_control import required_permission, OHPermission from src.auth.access_control import required_permission, OHPermission
from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends from fastapi import Depends
from src.csrf_protect import csrf_protect from src.csrf_protect import csrf_protect
router = APIRouter() router = APIRouter()
get_calculation = APIRouter() get_calculation = APIRouter()
@router.post('', response_model=StandardResponse[Union[dict, CalculationTimeConstrainsRead]], dependencies=[Depends(required_permission(OHPermission.CREATE, OverhaulScope))])
async def create_calculation_time_constrains(token: Token, db_session: DbSession, collector_db_session: CollectorDbSession, current_user: CurrentUser, calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate, params: Annotated[CreateCalculationQuery, Query()]): @router.post(
"", response_model=StandardResponse[Union[dict, CalculationTimeConstrainsRead]],
dependencies=[Depends(required_permission(OHPermission.CREATE, OverhaulScope))]
)
async def create_calculation_time_constrains(
token: Token,
db_session: DbSession,
collector_db_session: CollectorDbSession,
current_user: CurrentUser,
calculation_time_constrains_in: CalculationTimeConstrainsParametersCreate,
params: Annotated[CreateCalculationQuery, Query()],
# scope_calculation_id: Optional[str] = Query(None),
# with_results: Optional[int] = Query(0),
# simulation_id = Query(None)
):
"""Save calculation time constrains Here"""
scope_calculation_id = params.scope_calculation_id scope_calculation_id = params.scope_calculation_id
with_results = params.with_results
simulation_id = params.simulation_id simulation_id = params.simulation_id
if scope_calculation_id: if scope_calculation_id:
results = await get_or_create_scope_equipment_calculation(db_session=db_session, scope_calculation_id=scope_calculation_id, calculation_time_constrains_in=calculation_time_constrains_in) results = await get_or_create_scope_equipment_calculation(
db_session=db_session,
scope_calculation_id=scope_calculation_id,
calculation_time_constrains_in=calculation_time_constrains_in,
)
else: else:
results = await create_calculation(token=token, db_session=db_session, collector_db_session=collector_db_session, calculation_time_constrains_in=calculation_time_constrains_in, created_by=current_user.name, simulation_id=simulation_id) results = await create_calculation(
return StandardResponse(data=results, message='Data created successfully') token=token,
db_session=db_session,
collector_db_session=collector_db_session,
calculation_time_constrains_in=calculation_time_constrains_in,
created_by=current_user.name,
simulation_id=simulation_id
)
return StandardResponse(data=results, message="Data created successfully")
@router.get(
"", response_model=StandardResponse[List[CalculationTimeConstrainsReadNoResult]],
dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]
)
async def get_all_simulation_calculations(
db_session: DbSession,
token: Token,
current_user: CurrentUser,
):
"""Get all calculation time constrains Here"""
calculations = await get_all_calculations(
db_session=db_session,
)
return StandardResponse(
data=calculations,
message="Data retrieved successfully",
)
@router.get('', response_model=StandardResponse[List[CalculationTimeConstrainsReadNoResult]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) @router.get(
async def get_all_simulation_calculations(db_session: DbSession, token: Token, current_user: CurrentUser): "/parameters",
calculations = await get_all_calculations(db_session=db_session) response_model=StandardResponse[
return StandardResponse(data=calculations, message='Data retrieved successfully') Union[
CalculationTimeConstrainsParametersRetrive,
CalculationTimeConstrainsParametersRead,
]
],
dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]
)
async def get_calculation_parameters(
db_session: DbSession, calculation_id: Optional[str] = Query(default=None)
):
"""Get all calculation parameter."""
@router.get('/parameters', response_model=StandardResponse[Union[CalculationTimeConstrainsParametersRetrive, CalculationTimeConstrainsParametersRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) parameters = await get_create_calculation_parameters(
async def get_calculation_parameters(db_session: DbSession, calculation_id: Optional[str]=Query(default=None)): db_session=db_session, calculation_id=calculation_id
parameters = await get_create_calculation_parameters(db_session=db_session, calculation_id=calculation_id) )
return StandardResponse(data=parameters, message='Data retrieved successfully')
@get_calculation.get('/{calculation_id}', response_model=StandardResponse[CalculationTimeConstrainsRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) return StandardResponse(
async def get_calculation_results(db_session: DbSession, calculation_id, token: InternalKey, include_risk_cost: int=Query(1, alias='risk_cost')): data=parameters,
message="Data retrieved successfully",
)
@get_calculation.get(
"/{calculation_id}", response_model=StandardResponse[CalculationTimeConstrainsRead],
dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]
)
async def get_calculation_results(db_session: DbSession, calculation_id, token:InternalKey, include_risk_cost:int = Query(1, alias="risk_cost")):
if calculation_id == 'default': if calculation_id == 'default':
calculation_id = DEFAULT_TC_ID calculation_id = DEFAULT_TC_ID
results = await get_calculation_result(db_session=db_session, calculation_id=calculation_id, token=token, include_risk_cost=include_risk_cost)
return StandardResponse(data=results, message='Data retrieved successfully')
@router.get('/{calculation_id}/{assetnum}', response_model=StandardResponse[EquipmentResult], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) results = await get_calculation_result(
db_session=db_session, calculation_id=calculation_id, token=token, include_risk_cost=include_risk_cost
)
# requests.post(f"{config.AUTH_SERVICE_API}/sign-out", headers={
# "Authorization": f"Bearer {token}"
# })
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
@router.get(
"/{calculation_id}/{assetnum}", response_model=StandardResponse[EquipmentResult],
dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]
)
async def get_calculation_per_equipment(db_session: DbSession, calculation_id, assetnum): async def get_calculation_per_equipment(db_session: DbSession, calculation_id, assetnum):
results = await get_calculation_by_assetnum(db_session=db_session, assetnum=assetnum, calculation_id=calculation_id)
return StandardResponse(data=results, message='Data retrieved successfully')
@router.post('/{calculation_id}/simulation', response_model=StandardResponse[CalculationResultsRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) results = await get_calculation_by_assetnum(
async def get_simulation_result(db_session: DbSession, calculation_id, calculation_simuation_in: CalculationTimeConstrainsCreate): db_session=db_session, assetnum=assetnum, calculation_id=calculation_id
simulation_result = await get_calculation_result_by_day(db_session=db_session, calculation_id=calculation_id, simulation_day=calculation_simuation_in.intervalDays) )
return StandardResponse(data=simulation_result, message='Data retrieved successfully')
return StandardResponse(
data=results,
message="Data retrieved successfully",
)
@router.post('/update/{calculation_id}', response_model=StandardResponse[List[str]], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope)), Depends(csrf_protect)])
async def update_selected_equipment(db_session: DbSession, calculation_id, calculation_time_constrains_in: List[CalculationSelectedEquipmentUpdate]): @router.post(
"/{calculation_id}/simulation",
response_model=StandardResponse[CalculationResultsRead],
dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]
)
async def get_simulation_result(
db_session: DbSession,
calculation_id,
calculation_simuation_in: CalculationTimeConstrainsCreate,
):
simulation_result = await get_calculation_result_by_day(
db_session=db_session,
calculation_id=calculation_id,
simulation_day=calculation_simuation_in.intervalDays,
)
return StandardResponse(
data=simulation_result, message="Data retrieved successfully"
)
@router.post("/update/{calculation_id}", response_model=StandardResponse[List[str]], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope)), Depends(csrf_protect)])
async def update_selected_equipment(
db_session: DbSession,
calculation_id,
calculation_time_constrains_in: List[CalculationSelectedEquipmentUpdate],
):
if calculation_id == 'default': if calculation_id == 'default':
calculation_id = '3b9a73a2-bde6-418c-9e2f-19046f501a05' calculation_id = "3b9a73a2-bde6-418c-9e2f-19046f501a05"
results = await bulk_update_equipment(db=db_session, selected_equipments=calculation_time_constrains_in, calculation_data_id=calculation_id)
return StandardResponse(data=results, message='Data retrieved successfully') results = await bulk_update_equipment(
db=db_session,
selected_equipments=calculation_time_constrains_in,
calculation_data_id=calculation_id,
)
return StandardResponse(
data=results,
message="Data retrieved successfully",
)

@ -1,32 +1,50 @@
from src.overhaul_scope.schema import ScopeRead from src.overhaul_scope.schema import ScopeRead
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Union from typing import Any, Dict, List, Optional, Union
from uuid import UUID from uuid import UUID
from pydantic import Field from pydantic import Field
from src.models import DefultBase from src.models import DefultBase
from src.standard_scope.schema import MasterEquipmentBase from src.standard_scope.schema import MasterEquipmentBase
class CalculationTimeConstrainsBase(DefultBase): class CalculationTimeConstrainsBase(DefultBase):
pass pass
class ReferenceLinkBase(DefultBase): class ReferenceLinkBase(DefultBase):
reference_id: str = Field(..., description='Reference ID') reference_id: str = Field(..., description="Reference ID")
overhaul_reference_type: str = Field(..., description='Overhaul reference type') overhaul_reference_type: str = Field(..., description="Overhaul reference type")
class CalculationTimeConstrainsParametersRetrive(CalculationTimeConstrainsBase): class CalculationTimeConstrainsParametersRetrive(CalculationTimeConstrainsBase):
costPerFailure: Union[dict, float] = Field(..., description='Cost per failure') # type: ignore
availableScopes: List[str] = Field(..., description='Available scopes') costPerFailure: Union[dict, float] = Field(..., description="Cost per failure")
recommendedScope: str = Field(..., description='Recommended scope') availableScopes: List[str] = Field(..., description="Available scopes")
recommendedScope: str = Field(..., description="Recommended scope")
# historicalData: Dict[str, Any] = Field(..., description="Historical data")
class CalculationTimeConstrainsParametersRead(CalculationTimeConstrainsBase): class CalculationTimeConstrainsParametersRead(CalculationTimeConstrainsBase):
costPerFailure: Union[dict, float] = Field(..., description='Cost per failure') costPerFailure: Union[dict, float] = Field(..., description="Cost per failure")
overhaulCost: Optional[float] = Field(None, description='Overhaul cost') overhaulCost: Optional[float] = Field(None, description="Overhaul cost")
reference: Optional[List[ReferenceLinkBase]] = Field(None, description='Reference') reference: Optional[List[ReferenceLinkBase]] = Field(None, description="Reference")
class CalculationTimeConstrainsParametersCreate(CalculationTimeConstrainsBase): class CalculationTimeConstrainsParametersCreate(CalculationTimeConstrainsBase):
overhaulCost: Optional[float] = Field(0, description='Overhaul cost') overhaulCost: Optional[float] = Field(0, description="Overhaul cost")
ohSessionId: Optional[UUID] = Field(None, description='Scope OH') ohSessionId: Optional[UUID] = Field(None, description="Scope OH")
costPerFailure: Optional[float] = Field(0, description='Cost per failure') costPerFailure: Optional[float] = Field(0, description="Cost per failure")
# class CalculationTimeConstrainsCreate(CalculationTimeConstrainsBase):
# overhaulCost: float = Field(..., description="Overhaul cost")
# scopeOH: str = Field(..., description="Scope OH")
# costPerFailure: float = Field(..., description="Cost per failure")
# metadata: Dict[str, Any] = Field(..., description="Metadata")
class CalculationResultsRead(CalculationTimeConstrainsBase): class CalculationResultsRead(CalculationTimeConstrainsBase):
overhaul_cost: float overhaul_cost: float
@ -39,12 +57,14 @@ class CalculationResultsRead(CalculationTimeConstrainsBase):
procurement_details: Dict procurement_details: Dict
sparepart_summary: dict sparepart_summary: dict
class OptimumResult(CalculationTimeConstrainsBase): class OptimumResult(CalculationTimeConstrainsBase):
overhaul_cost: float overhaul_cost: float
corrective_cost: float corrective_cost: float
num_failures: int num_failures: int
days: int days: int
class EquipmentResult(CalculationTimeConstrainsBase): class EquipmentResult(CalculationTimeConstrainsBase):
id: UUID id: UUID
corrective_costs: List[float] corrective_costs: List[float]
@ -54,7 +74,7 @@ class EquipmentResult(CalculationTimeConstrainsBase):
location_tag: str location_tag: str
material_cost: float material_cost: float
service_cost: float service_cost: float
optimum_day: int optimum_day: int # Added optimum result for each equipment
is_included: bool is_included: bool
master_equipment: Optional[MasterEquipmentBase] = Field(None) master_equipment: Optional[MasterEquipmentBase] = Field(None)
@ -92,6 +112,10 @@ class CalculationTimeConstrainsReadNoResult(CalculationTimeConstrainsBase):
max_interval: Optional[int] max_interval: Optional[int]
optimum_analysis: Optional[dict] optimum_analysis: Optional[dict]
session: ScopeRead session: ScopeRead
# optimum_oh_day: int
# max_interval: int
# optimal_analysis: dict
# analysis_metadata: dict
class CalculationTimeConstrainsRead(CalculationTimeConstrainsBase): class CalculationTimeConstrainsRead(CalculationTimeConstrainsBase):
id: UUID id: UUID
@ -105,12 +129,15 @@ class CalculationTimeConstrainsRead(CalculationTimeConstrainsBase):
optimal_analysis: dict optimal_analysis: dict
analysis_metadata: dict analysis_metadata: dict
class CalculationTimeConstrainsCreate(CalculationTimeConstrainsBase): class CalculationTimeConstrainsCreate(CalculationTimeConstrainsBase):
intervalDays: int intervalDays: int
class CalculationTimeConstrainsSimulationRead(CalculationTimeConstrainsBase): class CalculationTimeConstrainsSimulationRead(CalculationTimeConstrainsBase):
simulation: CalculationResultsRead simulation: CalculationResultsRead
class CalculationSelectedEquipmentUpdate(CalculationTimeConstrainsBase): class CalculationSelectedEquipmentUpdate(CalculationTimeConstrainsBase):
is_included: bool is_included: bool
location_tag: str location_tag: str

File diff suppressed because it is too large Load Diff

@ -1,134 +1,302 @@
import datetime import datetime
import json
import pandas as pd import pandas as pd
import requests import requests
from src.config import RBD_SERVICE_API from src.config import RBD_SERVICE_API
def get_months_between(start_date: datetime.datetime, end_date: datetime.datetime) -> int: def get_months_between(start_date: datetime.datetime, end_date: datetime.datetime) -> int:
return (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month) """
Calculate number of months between two dates.
"""
months = (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month)
# Add 1 to include both start and end months
return months
def create_time_series_data(chart_data, max_hours=None): def create_time_series_data(chart_data, max_hours=None):
filtered_data = [d for d in chart_data if d['currentEvent'] != 'ON_OH'] # Filter out ON_OH
sorted_data = sorted(filtered_data, key=lambda x: x['cumulativeTime']) filtered_data = [d for d in chart_data if d["currentEvent"] != "ON_OH"]
sorted_data = sorted(filtered_data, key=lambda x: x["cumulativeTime"])
if not sorted_data: if not sorted_data:
return [] return []
hourly_data = [] hourly_data = []
current_state_index = 0 current_state_index = 0
current_flow_rate = sorted_data[0]['flowRate'] current_flow_rate = sorted_data[0]["flowRate"]
current_eq_status = sorted_data[0]['currentEQStatus'] current_eq_status = sorted_data[0]["currentEQStatus"]
last_time = int(sorted_data[-1]['cumulativeTime'])
# Determine maximum bound (either given or from data)
last_time = int(sorted_data[-1]["cumulativeTime"])
if max_hours is None: if max_hours is None:
max_hours = last_time max_hours = last_time
for hour in range(0, max_hours + 1):
while current_state_index < len(sorted_data) - 1 and hour >= sorted_data[current_state_index + 1]['cumulativeTime']: for hour in range(0, max_hours + 1): # start from 0
# Advance state if needed
while (current_state_index < len(sorted_data) - 1 and
hour >= sorted_data[current_state_index + 1]["cumulativeTime"]):
current_state_index += 1 current_state_index += 1
current_flow_rate = sorted_data[current_state_index]['flowRate'] current_flow_rate = sorted_data[current_state_index]["flowRate"]
current_eq_status = sorted_data[current_state_index]['currentEQStatus'] current_eq_status = sorted_data[current_state_index]["currentEQStatus"]
hourly_data.append({'cumulativeTime': hour, 'flowRate': current_flow_rate, 'currentEQStatus': current_eq_status})
hourly_data.append({
"cumulativeTime": hour,
"flowRate": current_flow_rate,
"currentEQStatus": current_eq_status
})
return hourly_data return hourly_data
def calculate_failures_per_month(hourly_data): def calculate_failures_per_month(hourly_data):
"""
Calculate the cumulative number of failures up to each month from hourly data.
A failure is defined as when currentEQStatus = "OoS".
Only counts the start of each failure period (transition from "Svc" to "OoS").
Args:
hourly_data: List of dicts with 'hour', 'flowrate', and 'currentEQStatus' keys
Returns:
List of dicts with 'month' and 'failures' keys (cumulative count)
"""
total_failures = 0 total_failures = 0
previous_eq_status = None previous_eq_status = None
monthly_data = {} monthly_data = {}
for data_point in hourly_data: for data_point in hourly_data:
hour = data_point['hour'] hour = data_point['hour']
current_eq_status = data_point['currentEQStatus'] current_eq_status = data_point['currentEQStatus']
month = (hour - 1) // 720 + 1
if current_eq_status == 'OoS' and previous_eq_status is not None and (previous_eq_status != 'OoS'): # Calculate which month this hour belongs to (1-based)
# Assuming 30 days per month = 720 hours per month
month = ((hour - 1) // 720) + 1
# Check if this is the start of a failure (transition to "OoS")
if current_eq_status == "OoS" and previous_eq_status is not None and previous_eq_status != "OoS":
total_failures += 1 total_failures += 1
elif current_eq_status == 'OoS' and previous_eq_status is None: # Special case: if the very first data point is a failure
elif current_eq_status == "OoS" and previous_eq_status is None:
total_failures += 1 total_failures += 1
# Store the current cumulative count for this month
monthly_data[month] = total_failures monthly_data[month] = total_failures
previous_eq_status = current_eq_status previous_eq_status = current_eq_status
# Convert to list format
result = [] result = []
if monthly_data: if monthly_data:
max_month = max(monthly_data.keys()) max_month = max(monthly_data.keys())
for month in range(1, max_month + 1): for month in range(1, max_month + 1):
result.append({'month': month, 'failures': monthly_data.get(month, monthly_data.get(month - 1, 0))}) result.append({
'month': month,
'failures': monthly_data.get(month, monthly_data.get(month-1, 0))
})
return result return result
import pandas as pd import pandas as pd
import datetime import datetime
import datetime import datetime
import pandas as pd import pandas as pd
async def plant_simulation_metrics(simulation_id: str, location_tag: str, max_interval, token, last_oh_date, use_location_tag: int = 1): async def plant_simulation_metrics(simulation_id: str, location_tag: str, max_interval, token, last_oh_date, use_location_tag: int = 1):
calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/{location_tag}' """Get failure predictions for equipment from simulation service"""
calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/{location_tag}"
try: try:
response = requests.get(calc_result_url, headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'}, timeout=30) response = requests.get(
calc_result_url,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
timeout=30
)
response.raise_for_status() response.raise_for_status()
prediction_data = response.json()['data'] prediction_data = response.json()['data']
except (requests.RequestException, ValueError) as e: except (requests.RequestException, ValueError) as e:
raise Exception(str(e)) raise Exception(str(e))
return prediction_data return prediction_data
def analyze_monthly_metrics(timestamp_outs, start_date, max_flow_rate: float = 550): def analyze_monthly_metrics(timestamp_outs, start_date, max_flow_rate: float = 550):
if not timestamp_outs: if not timestamp_outs:
return {} return {}
df = pd.DataFrame(timestamp_outs) df = pd.DataFrame(timestamp_outs)
required_columns = ['cumulativeTime', 'currentEQStatus', 'flowRate'] required_columns = ['cumulativeTime', 'currentEQStatus', 'flowRate']
if not all((col in df.columns for col in required_columns)): if not all(col in df.columns for col in required_columns):
return {} return {}
start_oh = datetime.datetime(start_date.year, start_date.month, start_date.day) start_oh = datetime.datetime(start_date.year, start_date.month, start_date.day)
# Actual datetime from cumulative hours
df['datetime'] = df['cumulativeTime'].apply(lambda x: start_oh + datetime.timedelta(hours=x)) df['datetime'] = df['cumulativeTime'].apply(lambda x: start_oh + datetime.timedelta(hours=x))
df['month_year'] = df['datetime'].dt.to_period('M') df['month_year'] = df['datetime'].dt.to_period('M')
# Duration until next timestamp
df['duration_hours'] = df['cumulativeTime'].shift(-1) - df['cumulativeTime'] df['duration_hours'] = df['cumulativeTime'].shift(-1) - df['cumulativeTime']
df['duration_hours'] = df['duration_hours'].fillna(0) df['duration_hours'] = df['duration_hours'].fillna(0)
# Failure detection
df['status_change'] = df['currentEQStatus'].shift() != df['currentEQStatus'] df['status_change'] = df['currentEQStatus'].shift() != df['currentEQStatus']
df['failure'] = (df['currentEQStatus'] == 'OoS') & df['status_change'] df['failure'] = (df['currentEQStatus'] == 'OoS') & df['status_change']
# Cumulative tracking
df['cumulative_failures'] = df['failure'].cumsum() df['cumulative_failures'] = df['failure'].cumsum()
df['cumulative_oos'] = (df['duration_hours'] * (df['currentEQStatus'] == 'OoS')).cumsum() df['cumulative_oos'] = (df['duration_hours'] * (df['currentEQStatus'] == 'OoS')).cumsum()
# Derating calculation
# Derating = capacity reduction below max but not outage
df['derating'] = (max_flow_rate - df['flowRate']).clip(lower=0) df['derating'] = (max_flow_rate - df['flowRate']).clip(lower=0)
df['is_derated'] = (df['currentEQStatus'] == 'Svc') & (df['derating'] > 0) df['is_derated'] = (df['currentEQStatus'] == 'Svc') & (df['derating'] > 0)
# Equivalent Derated Hours (EFDH) → sum of derating * hours, then normalized by max capacity
df['derated_mwh'] = df['derating'] * df['duration_hours'] df['derated_mwh'] = df['derating'] * df['duration_hours']
df['derated_hours_equivalent'] = df['derated_mwh'] / max_flow_rate df['derated_hours_equivalent'] = df['derated_mwh'] / max_flow_rate
monthly_results = {} monthly_results = {}
for month_period, group in df.groupby('month_year', sort=True): for month_period, group in df.groupby('month_year', sort=True):
month_str = str(month_period) month_str = str(month_period)
monthly_results[month_str] = {} monthly_results[month_str] = {}
# Failures
monthly_results[month_str]['failures_count'] = int(group['failure'].sum()) monthly_results[month_str]['failures_count'] = int(group['failure'].sum())
monthly_results[month_str]['cumulative_failures'] = int(group['cumulative_failures'].max()) monthly_results[month_str]['cumulative_failures'] = int(group['cumulative_failures'].max())
# OOS hours
oos_time = group.loc[group['currentEQStatus'] == 'OoS', 'duration_hours'].sum() oos_time = group.loc[group['currentEQStatus'] == 'OoS', 'duration_hours'].sum()
monthly_results[month_str]['total_oos_hours'] = float(oos_time) monthly_results[month_str]['total_oos_hours'] = float(oos_time)
monthly_results[month_str]['cummulative_oos'] = float(group['cumulative_oos'].max()) monthly_results[month_str]['cummulative_oos'] = float(group['cumulative_oos'].max())
# Flow rate (weighted average)
total_flow_time = (group['flowRate'] * group['duration_hours']).sum() total_flow_time = (group['flowRate'] * group['duration_hours']).sum()
total_time = group['duration_hours'].sum() total_time = group['duration_hours'].sum()
avg_flow_rate = total_flow_time / total_time if total_time > 0 else 0 avg_flow_rate = total_flow_time / total_time if total_time > 0 else 0
monthly_results[month_str]['avg_flow_rate'] = float(avg_flow_rate) monthly_results[month_str]['avg_flow_rate'] = float(avg_flow_rate)
# Extra metrics
monthly_results[month_str]['total_hours'] = float(total_time) monthly_results[month_str]['total_hours'] = float(total_time)
service_hours = group.loc[group['currentEQStatus'] == 'Svc', 'duration_hours'].sum() service_hours = group.loc[group['currentEQStatus'] == 'Svc', 'duration_hours'].sum()
monthly_results[month_str]['service_hours'] = float(service_hours) monthly_results[month_str]['service_hours'] = float(service_hours)
monthly_results[month_str]['availability_percentage'] = float(service_hours / total_time * 100 if total_time > 0 else 0) monthly_results[month_str]['availability_percentage'] = float(
(service_hours / total_time * 100) if total_time > 0 else 0
)
# Derating metrics
derating_hours = group.loc[group['is_derated'], 'duration_hours'].sum() derating_hours = group.loc[group['is_derated'], 'duration_hours'].sum()
derated_mwh = group['derated_mwh'].sum() derated_mwh = group['derated_mwh'].sum()
equivalent_derated_hours = group['derated_hours_equivalent'].sum() equivalent_derated_hours = group['derated_hours_equivalent'].sum()
monthly_results[month_str]['derating_hours'] = float(derating_hours) monthly_results[month_str]['derating_hours'] = float(derating_hours)
monthly_results[month_str]['derated_mwh'] = float(derated_mwh) monthly_results[month_str]['derated_mwh'] = float(derated_mwh)
monthly_results[month_str]['equivalent_derated_hours'] = float(equivalent_derated_hours) monthly_results[month_str]['equivalent_derated_hours'] = float(equivalent_derated_hours)
return monthly_results return monthly_results
def calculate_risk_cost_per_failure(monthly_results, birnbaum_importance, energy_price): def calculate_risk_cost_per_failure(monthly_results, birnbaum_importance, energy_price):
"""
Calculate risk cost per failure for each month based on:
1. Equipment capacity contribution to system (flowrate * birnbaum_importance * availability)
2. Capacity lost to downtime per month
3. Energy price
Parameters:
- monthly_results: Output from analyze_monthly_metrics()
- birnbaum_importance: Birnbaum importance factor for this equipment
- energy_price: Price per unit of energy/flow
Returns:
- Dictionary with monthly risk costs and array of risk costs per failure
"""
risk_costs = {} risk_costs = {}
risk_cost_array = [] risk_cost_array = []
for month, data in monthly_results.items(): for month, data in monthly_results.items():
# Extract monthly data
avg_flow_rate = data['avg_flow_rate'] avg_flow_rate = data['avg_flow_rate']
availability = data['availability_percentage'] / 100 availability = data['availability_percentage'] / 100 # Convert to decimal
total_oos_hours = data['total_oos_hours'] total_oos_hours = data['total_oos_hours']
failures_count = data['failures_count'] failures_count = data['failures_count']
# 1. Calculate equipment capacity contribution to system
# Capacity = avg_flowrate * birnbaum_importance * availability
equipment_capacity = avg_flow_rate * birnbaum_importance * availability equipment_capacity = avg_flow_rate * birnbaum_importance * availability
# 2. Calculate capacity lost to downtime per month
# Lost capacity = avg_flowrate * birnbaum_importance * downtime_hours
capacity_lost_to_downtime = avg_flow_rate * birnbaum_importance * total_oos_hours capacity_lost_to_downtime = avg_flow_rate * birnbaum_importance * total_oos_hours
# 3. Calculate total risk cost for the month
# Risk cost = capacity_lost * energy_price
monthly_risk_cost = capacity_lost_to_downtime * energy_price monthly_risk_cost = capacity_lost_to_downtime * energy_price
# 4. Calculate risk cost per failure for this month
if failures_count > 0: if failures_count > 0:
risk_cost_per_failure = monthly_risk_cost / failures_count risk_cost_per_failure = monthly_risk_cost / failures_count
else: else:
# If no failures, set to 0 or use alternative approach
risk_cost_per_failure = 0 risk_cost_per_failure = 0
risk_costs[month] = {'equipment_capacity': equipment_capacity, 'capacity_lost_to_downtime': capacity_lost_to_downtime, 'monthly_risk_cost': monthly_risk_cost, 'failures_count': failures_count, 'risk_cost_per_failure': risk_cost_per_failure}
# Store results
risk_costs[month] = {
'equipment_capacity': equipment_capacity,
'capacity_lost_to_downtime': capacity_lost_to_downtime,
'monthly_risk_cost': monthly_risk_cost,
'failures_count': failures_count,
'risk_cost_per_failure': risk_cost_per_failure
}
# Add to array
risk_cost_array.append(risk_cost_per_failure) risk_cost_array.append(risk_cost_per_failure)
return {'monthly_details': risk_costs, 'risk_cost_per_failure_array': risk_cost_array}
return {
'monthly_details': risk_costs,
'risk_cost_per_failure_array': risk_cost_array
}
# Example usage:
def get_monthly_risk_analysis(timestamp_outs, birnbaum_importance, energy_price): def get_monthly_risk_analysis(timestamp_outs, birnbaum_importance, energy_price):
"""
Complete analysis combining monthly metrics with risk cost calculation
"""
# Get monthly metrics
monthly_metrics = analyze_monthly_metrics(timestamp_outs) monthly_metrics = analyze_monthly_metrics(timestamp_outs)
risk_analysis = calculate_risk_cost_per_failure(monthly_metrics, birnbaum_importance, energy_price)
# Calculate risk costs
risk_analysis = calculate_risk_cost_per_failure(
monthly_metrics,
birnbaum_importance,
energy_price
)
# Combine results for comprehensive view
combined_results = {} combined_results = {}
for month in monthly_metrics.keys(): for month in monthly_metrics.keys():
combined_results[month] = {**monthly_metrics[month], **risk_analysis['monthly_details'][month]} combined_results[month] = {
return {'monthly_data': combined_results, 'risk_cost_array': risk_analysis['risk_cost_per_failure_array']} **monthly_metrics[month],
**risk_analysis['monthly_details'][month]
}
return {
'monthly_data': combined_results,
'risk_cost_array': risk_analysis['risk_cost_per_failure_array']
}
# Usage example:
# birnbaum_importance = 0.85 # Example value
# energy_price = 100 # Example: $100 per unit
#
# results = get_monthly_risk_analysis(timestamp_outs, birnbaum_importance, energy_price)
# risk_cost_array = results['risk_cost_array']
# print("Risk cost per failure each month:", risk_cost_array)

@ -1,64 +1,100 @@
import base64
import logging import logging
import os import os
from typing import List from typing import List
from urllib import parse from urllib import parse
from pydantic import BaseModel from pydantic import BaseModel
from starlette.config import Config from starlette.config import Config
from starlette.datastructures import CommaSeparatedStrings
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class BaseConfigurationModel(BaseModel): class BaseConfigurationModel(BaseModel):
"""Base configuration model used by all config options."""
pass pass
def get_env_tags(tag_list: List[str]) -> dict: def get_env_tags(tag_list: List[str]) -> dict:
"""Create dictionary of available env tags."""
tags = {} tags = {}
for t in tag_list: for t in tag_list:
tag_key, env_key = t.split(':') tag_key, env_key = t.split(":")
env_value = os.environ.get(env_key) env_value = os.environ.get(env_key)
if env_value: if env_value:
tags.update({tag_key: env_value}) tags.update({tag_key: env_value})
return tags return tags
def get_config(): def get_config():
try: try:
config = Config('.env') # Try to load from .env file first
config = Config(".env")
except FileNotFoundError: except FileNotFoundError:
# If .env doesn't exist, use environment variables
config = Config(environ=os.environ) config = Config(environ=os.environ)
return config return config
config = get_config() config = get_config()
LOG_LEVEL = config('LOG_LEVEL', default='INFO')
SERVICE_NAME = config('SERVICE_NAME', default='Be-OptimumOH')
ENV = config('ENV') LOG_LEVEL = config("LOG_LEVEL", default="INFO")
PORT = config('PORT', cast=int) SERVICE_NAME = config("SERVICE_NAME", default="Be-OptimumOH")
HOST = config('HOST') ENV = config("ENV")
DATABASE_HOSTNAME = config('DATABASE_HOSTNAME') PORT = config("PORT", cast=int)
_DATABASE_CREDENTIAL_USER = config('DATABASE_CREDENTIAL_USER') HOST = config("HOST")
_DATABASE_CREDENTIAL_PASSWORD = config('DATABASE_CREDENTIAL_PASSWORD')
# database
DATABASE_HOSTNAME = config("DATABASE_HOSTNAME")
_DATABASE_CREDENTIAL_USER = config("DATABASE_CREDENTIAL_USER")
_DATABASE_CREDENTIAL_PASSWORD = config("DATABASE_CREDENTIAL_PASSWORD")
_QUOTED_DATABASE_PASSWORD = parse.quote(str(_DATABASE_CREDENTIAL_PASSWORD)) _QUOTED_DATABASE_PASSWORD = parse.quote(str(_DATABASE_CREDENTIAL_PASSWORD))
DATABASE_NAME = config('DATABASE_NAME') DATABASE_NAME = config("DATABASE_NAME")
DATABASE_PORT = config('DATABASE_PORT') DATABASE_PORT = config("DATABASE_PORT")
DATABASE_ENGINE_POOL_SIZE = config('DATABASE_ENGINE_POOL_SIZE', cast=int, default=20)
DATABASE_ENGINE_MAX_OVERFLOW = config('DATABASE_ENGINE_MAX_OVERFLOW', cast=int, default=0) DATABASE_ENGINE_POOL_SIZE = config("DATABASE_ENGINE_POOL_SIZE", cast=int, default=20)
COLLECTOR_HOSTNAME = config('COLLECTOR_HOSTNAME') DATABASE_ENGINE_MAX_OVERFLOW = config(
COLLECTOR_PORT = config('COLLECTOR_PORT', cast=int) "DATABASE_ENGINE_MAX_OVERFLOW", cast=int, default=0
COLLECTOR_CREDENTIAL_USER = config('COLLECTOR_CREDENTIAL_USER') )
COLLECTOR_CREDENTIAL_PASSWORD = config('COLLECTOR_CREDENTIAL_PASSWORD')
COLLECTOR_HOSTNAME = config("COLLECTOR_HOSTNAME")
COLLECTOR_PORT = config("COLLECTOR_PORT", cast=int)
COLLECTOR_CREDENTIAL_USER = config("COLLECTOR_CREDENTIAL_USER")
COLLECTOR_CREDENTIAL_PASSWORD = config("COLLECTOR_CREDENTIAL_PASSWORD")
QUOTED_COLLECTOR_CREDENTIAL_PASSWORD = parse.quote(str(COLLECTOR_CREDENTIAL_PASSWORD)) QUOTED_COLLECTOR_CREDENTIAL_PASSWORD = parse.quote(str(COLLECTOR_CREDENTIAL_PASSWORD))
COLLECTOR_NAME = config('COLLECTOR_NAME') COLLECTOR_NAME = config("COLLECTOR_NAME")
SQLALCHEMY_DATABASE_URI = f'postgresql+asyncpg://{_DATABASE_CREDENTIAL_USER}:{_QUOTED_DATABASE_PASSWORD}@{DATABASE_HOSTNAME}:{DATABASE_PORT}/{DATABASE_NAME}'
SQLALCHEMY_COLLECTOR_URI = f'postgresql+asyncpg://{COLLECTOR_CREDENTIAL_USER}:{QUOTED_COLLECTOR_CREDENTIAL_PASSWORD}@{COLLECTOR_HOSTNAME}:{COLLECTOR_PORT}/{COLLECTOR_NAME}' # Deal w
TIMEZONE = config('TIMEZONE') SQLALCHEMY_DATABASE_URI = f"postgresql+asyncpg://{_DATABASE_CREDENTIAL_USER}:{_QUOTED_DATABASE_PASSWORD}@{DATABASE_HOSTNAME}:{DATABASE_PORT}/{DATABASE_NAME}"
MAXIMO_BASE_URL = config('MAXIMO_BASE_URL') SQLALCHEMY_COLLECTOR_URI = f"postgresql+asyncpg://{COLLECTOR_CREDENTIAL_USER}:{QUOTED_COLLECTOR_CREDENTIAL_PASSWORD}@{COLLECTOR_HOSTNAME}:{COLLECTOR_PORT}/{COLLECTOR_NAME}"
MAXIMO_API_KEY = config('MAXIMO_API_KEY')
AUTH_SERVICE_API = config('AUTH_SERVICE_API')
REALIBILITY_SERVICE_API = config('REALIBILITY_SERVICE_API') TIMEZONE = config("TIMEZONE")
RBD_SERVICE_API = config('RBD_SERVICE_API')
TEMPORAL_URL = config('TEMPORAL_URL') MAXIMO_BASE_URL = config("MAXIMO_BASE_URL")
BASE_URL = config('BASE_URL') MAXIMO_API_KEY = config("MAXIMO_API_KEY")
API_KEY = config('API_KEY')
TR_RBD_ID = config('TR_RBD_ID') AUTH_SERVICE_API = config("AUTH_SERVICE_API")
TC_RBD_ID = config('TC_RBD_ID') REALIBILITY_SERVICE_API = config("REALIBILITY_SERVICE_API")
DEFAULT_TC_ID = config('DEFAULT_TC_ID') RBD_SERVICE_API = config("RBD_SERVICE_API")
REDIS_HOST = config('REDIS_HOST') TEMPORAL_URL = config("TEMPORAL_URL")
REDIS_PORT = config('REDIS_PORT', cast=int) BASE_URL = config("BASE_URL")
RATELIMIT_STORAGE_URI = f'redis://{REDIS_HOST}:{REDIS_PORT}'
REDIS_AUTH_DB = config('REDIS_AUTH_DB', cast=int)
API_KEY = config("API_KEY")
TR_RBD_ID = config("TR_RBD_ID")
TC_RBD_ID = config("TC_RBD_ID")
DEFAULT_TC_ID = config("DEFAULT_TC_ID")
REDIS_HOST = config("REDIS_HOST")
REDIS_PORT = config("REDIS_PORT", cast=int)
RATELIMIT_STORAGE_URI = f"redis://{REDIS_HOST}:{REDIS_PORT}"
REDIS_AUTH_DB = config("REDIS_AUTH_DB", cast=int)

@ -1,20 +1,25 @@
from contextvars import ContextVar from contextvars import ContextVar
from typing import Optional, Final from typing import Optional, Final
REQUEST_ID_CTX_KEY: Final[str] = 'request_id'
USER_ID_CTX_KEY: Final[str] = 'user_id' REQUEST_ID_CTX_KEY: Final[str] = "request_id"
USERNAME_CTX_KEY: Final[str] = 'username' USER_ID_CTX_KEY: Final[str] = "user_id"
ROLE_CTX_KEY: Final[str] = 'role' USERNAME_CTX_KEY: Final[str] = "username"
ROLE_CTX_KEY: Final[str] = "role"
_request_id_ctx_var: ContextVar[Optional[str]] = ContextVar(REQUEST_ID_CTX_KEY, default=None) _request_id_ctx_var: ContextVar[Optional[str]] = ContextVar(REQUEST_ID_CTX_KEY, default=None)
_user_id_ctx_var: ContextVar[Optional[str]] = ContextVar(USER_ID_CTX_KEY, default=None) _user_id_ctx_var: ContextVar[Optional[str]] = ContextVar(USER_ID_CTX_KEY, default=None)
_username_ctx_var: ContextVar[Optional[str]] = ContextVar(USERNAME_CTX_KEY, default=None) _username_ctx_var: ContextVar[Optional[str]] = ContextVar(USERNAME_CTX_KEY, default=None)
_role_ctx_var: ContextVar[Optional[str]] = ContextVar(ROLE_CTX_KEY, default=None) _role_ctx_var: ContextVar[Optional[str]] = ContextVar(ROLE_CTX_KEY, default=None)
def get_request_id() -> Optional[str]: def get_request_id() -> Optional[str]:
return _request_id_ctx_var.get() return _request_id_ctx_var.get()
def set_request_id(request_id: str): def set_request_id(request_id: str):
return _request_id_ctx_var.set(request_id) return _request_id_ctx_var.set(request_id)
def reset_request_id(token): def reset_request_id(token):
_request_id_ctx_var.reset(token) _request_id_ctx_var.reset(token)

@ -1,12 +1,18 @@
import json import json
import logging import logging
from typing import Dict, Union from typing import Dict, Union, Tuple
from decimal import Decimal, getcontext from decimal import Decimal, getcontext
import math
# Set high precision for decimal calculations
getcontext().prec = 50 getcontext().prec = 50
Structure = Union[str, Dict[str, list]] Structure = Union[str, Dict[str, list]]
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def prod(iterable): def prod(iterable):
"""Compute product of all elements in iterable with high precision."""
result = Decimal('1.0') result = Decimal('1.0')
for x in iterable: for x in iterable:
if isinstance(x, (int, float)): if isinstance(x, (int, float)):
@ -14,41 +20,56 @@ def prod(iterable):
result *= x result *= x
return float(result) return float(result)
def system_availability(structure: Structure, availabilities: Dict[str, float]) -> float: def system_availability(structure: Structure, availabilities: Dict[str, float]) -> float:
if isinstance(structure, str): """Recursively compute system availability with precise calculations."""
if isinstance(structure, str): # base case - component
if structure not in availabilities: if structure not in availabilities:
raise ValueError(f"Component '{structure}' not found in availabilities") raise ValueError(f"Component '{structure}' not found in availabilities")
return float(Decimal(str(availabilities[structure]))) return float(Decimal(str(availabilities[structure])))
if isinstance(structure, dict): if isinstance(structure, dict):
if 'series' in structure: if "series" in structure:
components = structure['series'] components = structure["series"]
if not components: if not components: # Handle empty series
return 1.0 return 1.0
# Series: A_system = A1 * A2 * ... * An
product = Decimal('1.0') product = Decimal('1.0')
for s in components: for s in components:
availability = system_availability(s, availabilities) availability = system_availability(s, availabilities)
product *= Decimal(str(availability)) product *= Decimal(str(availability))
return float(product) return float(product)
if 'parallel' in structure:
components = structure['parallel'] elif "parallel" in structure:
if not components: components = structure["parallel"]
if not components: # Handle empty parallel
return 0.0 return 0.0
# Parallel: A_system = 1 - (1-A1) * (1-A2) * ... * (1-An)
product = Decimal('1.0') product = Decimal('1.0')
for s in components: for s in components:
availability = system_availability(s, availabilities) availability = system_availability(s, availabilities)
unavailability = Decimal('1.0') - Decimal(str(availability)) unavailability = Decimal('1.0') - Decimal(str(availability))
product *= unavailability product *= unavailability
result = Decimal('1.0') - product result = Decimal('1.0') - product
return float(result) return float(result)
if 'parallel_no_redundancy' in structure:
components = structure['parallel_no_redundancy'] elif "parallel_no_redundancy" in structure:
# Load sharing - system availability is minimum of components
components = structure["parallel_no_redundancy"]
if not components: if not components:
return 0.0 return 0.0
availabilities_list = [system_availability(s, availabilities) for s in components] availabilities_list = [system_availability(s, availabilities) for s in components]
return min(availabilities_list) return min(availabilities_list)
raise ValueError(f'Invalid structure definition: {structure}')
raise ValueError(f"Invalid structure definition: {structure}")
def get_all_components(structure: Structure) -> set: def get_all_components(structure: Structure) -> set:
"""Extract all component names from a structure."""
components = set() components = set()
def extract_components(substructure): def extract_components(substructure):
@ -58,59 +79,131 @@ def get_all_components(structure: Structure) -> set:
for component_list in substructure.values(): for component_list in substructure.values():
for component in component_list: for component in component_list:
extract_components(component) extract_components(component)
extract_components(structure) extract_components(structure)
return components return components
def birnbaum_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float: def birnbaum_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float:
"""
Calculate Birnbaum importance for a component.
Birnbaum importance = A_system/A_component
This is approximated as:
I_B = A_system(A_i=1) - A_system(A_i=0)
Where A_i is the availability of component i.
"""
# Create copies for calculations
avail_up = availabilities.copy() avail_up = availabilities.copy()
avail_down = availabilities.copy() avail_down = availabilities.copy()
# Set component availability to 1 (perfect)
avail_up[component] = 1.0 avail_up[component] = 1.0
# Set component availability to 0 (failed)
avail_down[component] = 0.0 avail_down[component] = 0.0
# Calculate system availability in both cases
system_up = system_availability(structure, avail_up) system_up = system_availability(structure, avail_up)
system_down = system_availability(structure, avail_down) system_down = system_availability(structure, avail_down)
# Birnbaum importance is the difference
return system_up - system_down return system_up - system_down
def criticality_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float: def criticality_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float:
"""
Calculate Criticality importance for a component.
Criticality importance = Birnbaum importance * (1 - A_component) / (1 - A_system)
This represents the probability that component i is critical to system failure.
"""
birnbaum = birnbaum_importance(structure, availabilities, component) birnbaum = birnbaum_importance(structure, availabilities, component)
system_avail = system_availability(structure, availabilities) system_avail = system_availability(structure, availabilities)
component_avail = availabilities[component] component_avail = availabilities[component]
if system_avail >= 1.0:
if system_avail >= 1.0: # Perfect system
return 0.0 return 0.0
return birnbaum * (1.0 - component_avail) / (1.0 - system_avail)
criticality = birnbaum * (1.0 - component_avail) / (1.0 - system_avail)
return criticality
def fussell_vesely_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float: def fussell_vesely_importance(structure: Structure, availabilities: Dict[str, float], component: str) -> float:
"""
Calculate Fussell-Vesely importance for a component.
FV importance = (A_system - A_system(A_i=0)) / A_system
This represents the fractional decrease in system availability when component i fails.
"""
system_avail = system_availability(structure, availabilities) system_avail = system_availability(structure, availabilities)
if system_avail <= 0.0: if system_avail <= 0.0:
return 0.0 return 0.0
# Calculate system availability with component failed
avail_down = availabilities.copy() avail_down = availabilities.copy()
avail_down[component] = 0.0 avail_down[component] = 0.0
system_down = system_availability(structure, avail_down) system_down = system_availability(structure, avail_down)
return (system_avail - system_down) / system_avail
fv = (system_avail - system_down) / system_avail
return fv
def compute_all_importance_measures(structure: Structure, availabilities: Dict[str, float]) -> Dict[str, Dict[str, float]]: def compute_all_importance_measures(structure: Structure, availabilities: Dict[str, float]) -> Dict[str, Dict[str, float]]:
"""
Compute all importance measures for each component.
Returns:
Dictionary with component names as keys and importance measures as values
"""
# Normalize availabilities to 0-1 range if needed
normalized_availabilities = {} normalized_availabilities = {}
for k, v in availabilities.items(): for k, v in availabilities.items():
if v > 1.0: if v > 1.0:
normalized_availabilities[k] = v / 100.0 normalized_availabilities[k] = v / 100.0
else: else:
normalized_availabilities[k] = v normalized_availabilities[k] = v
# Clamp to valid range [0, 1]
normalized_availabilities[k] = max(0.0, min(1.0, normalized_availabilities[k])) normalized_availabilities[k] = max(0.0, min(1.0, normalized_availabilities[k]))
# Get all components in the system
all_components = get_all_components(structure) all_components = get_all_components(structure)
# Check for missing components
missing_components = all_components - set(normalized_availabilities.keys()) missing_components = all_components - set(normalized_availabilities.keys())
if missing_components: if missing_components:
log.warning(f'Missing components (assuming 100% availability): {missing_components}') log.warning(f"Missing components (assuming 100% availability): {missing_components}")
for comp in missing_components: for comp in missing_components:
normalized_availabilities[comp] = 1.0 normalized_availabilities[comp] = 1.0
# Calculate system baseline availability
system_avail = system_availability(structure, normalized_availabilities) system_avail = system_availability(structure, normalized_availabilities)
# Calculate importance measures for each component
results = {} results = {}
total_birnbaum = 0.0 total_birnbaum = 0.0
for component in all_components: for component in all_components:
if component in normalized_availabilities: if component in normalized_availabilities:
birnbaum = birnbaum_importance(structure, normalized_availabilities, component) birnbaum = birnbaum_importance(structure, normalized_availabilities, component)
criticality = criticality_importance(structure, normalized_availabilities, component) criticality = criticality_importance(structure, normalized_availabilities, component)
fv = fussell_vesely_importance(structure, normalized_availabilities, component) fv = fussell_vesely_importance(structure, normalized_availabilities, component)
results[component] = {'birnbaum_importance': birnbaum, 'criticality_importance': criticality, 'fussell_vesely_importance': fv, 'component_availability': normalized_availabilities[component]}
results[component] = {
'birnbaum_importance': birnbaum,
'criticality_importance': criticality,
'fussell_vesely_importance': fv,
'component_availability': normalized_availabilities[component]
}
total_birnbaum += birnbaum total_birnbaum += birnbaum
# Calculate contribution percentages based on Birnbaum importance
if total_birnbaum > 0: if total_birnbaum > 0:
for component in results: for component in results:
contribution_pct = results[component]['birnbaum_importance'] / total_birnbaum contribution_pct = results[component]['birnbaum_importance'] / total_birnbaum
@ -118,29 +211,76 @@ def compute_all_importance_measures(structure: Structure, availabilities: Dict[s
else: else:
for component in results: for component in results:
results[component]['contribution_percentage'] = 0.0 results[component]['contribution_percentage'] = 0.0
results['_system_info'] = {'system_availability': system_avail, 'system_unavailability': 1.0 - system_avail, 'total_birnbaum_importance': total_birnbaum}
# Add system-level information
results['_system_info'] = {
'system_availability': system_avail,
'system_unavailability': 1.0 - system_avail,
'total_birnbaum_importance': total_birnbaum
}
return results return results
def calculate_contribution_accurate(availabilities: Dict[str, float], structure_file: str = 'src/overhaul/rbd_structure.json') -> Dict[str, Dict[str, float]]: def calculate_contribution_accurate(availabilities: Dict[str, float], structure_file: str = 'src/overhaul/rbd_structure.json') -> Dict[str, Dict[str, float]]:
"""
Calculate component contributions using proper importance measures.
Args:
availabilities: Dictionary of component availabilities
structure_file: Path to RBD structure JSON file
Returns:
Dictionary containing all importance measures and contributions
"""
try: try:
with open(structure_file, 'r') as model_file: with open(structure_file, 'r') as model_file:
structure = json.load(model_file) structure = json.load(model_file)
except FileNotFoundError: except FileNotFoundError:
raise FileNotFoundError(f'Structure file not found: {structure_file}') raise FileNotFoundError(f"Structure file not found: {structure_file}")
except json.JSONDecodeError: except json.JSONDecodeError:
raise ValueError(f'Invalid JSON in structure file: {structure_file}') raise ValueError(f"Invalid JSON in structure file: {structure_file}")
# Compute all importance measures
results = compute_all_importance_measures(structure, availabilities) results = compute_all_importance_measures(structure, availabilities)
# Extract system information
system_info = results.pop('_system_info') system_info = results.pop('_system_info')
# Log results
log.info(f"System Availability: {system_info['system_availability']:.6f}") log.info(f"System Availability: {system_info['system_availability']:.6f}")
log.info(f"System Unavailability: {system_info['system_unavailability']:.6f}") log.info(f"System Unavailability: {system_info['system_unavailability']:.6f}")
sorted_components = sorted(results.items(), key=lambda x: x[1]['birnbaum_importance'], reverse=True)
# Sort components by Birnbaum importance (most critical first)
sorted_components = sorted(results.items(),
key=lambda x: x[1]['birnbaum_importance'],
reverse=True)
# print("\n=== COMPONENT IMPORTANCE ANALYSIS ===")
# print(f"System Availability: {system_info['system_availability']:.6f} ({system_info['system_availability']*100:.4f}%)")
# print(f"System Unavailability: {system_info['system_unavailability']:.6f}")
# print("\nComponent Rankings (by Birnbaum Importance):")
# print(f"{'Component':<20} {'Availability':<12} {'Birnbaum':<12} {'Criticality':<12} {'F-V':<12} {'Contribution%':<12}")
# print("-" * 92)
for component, measures in sorted_components: for component, measures in sorted_components:
print(f"{component:<20} {measures['component_availability']:<12.6f} {measures['birnbaum_importance']:<12.6f} {measures['criticality_importance']:<12.6f} {measures['fussell_vesely_importance']:<12.6f} {measures['contribution_percentage'] * 100:<12.4f}") print(f"{component:<20} {measures['component_availability']:<12.6f} "
f"{measures['birnbaum_importance']:<12.6f} {measures['criticality_importance']:<12.6f} "
f"{measures['fussell_vesely_importance']:<12.6f} {measures['contribution_percentage']*100:<12.4f}")
# Return results with system info included
# results['_system_info'] = system_info
return results return results
# Legacy function for backwards compatibility
def calculate_contribution(availabilities): def calculate_contribution(availabilities):
"""Legacy function - redirects to improved version."""
try: try:
return calculate_contribution_accurate(availabilities) return calculate_contribution_accurate(availabilities)
except Exception as e: except Exception as e:
log.error(f'Error in contribution calculation: {e}') log.error(f"Error in contribution calculation: {e}")
raise raise

@ -1,36 +1,125 @@
"""
CSRF Token Verification Dependency for be-optimumoh (FastAPI).
Menggantikan CSRFProtectMiddleware (Double Submit Cookie) dengan verifikasi
berbasis Redis. Token CSRF di-generate oleh be-auth (CSRFTokenResource) dan
disimpan di Redis dalam bentuk SHA256 hash.
Token Flow (generation di be-auth, verification di sini):
GENERATION (be-auth / CSRFTokenResource):
1. token = secrets.token_urlsafe(32)
2. token_hash = SHA256(token)
3. redis[csrf:{user_id}:{path_hash}] = token_hash (dengan TTL)
4. return plain token ke client
VERIFICATION (dependency ini):
1. Baca X-CSRF-Token header dari request
2. Ambil user_id dari request.state.user (di-set oleh JWTBearer)
3. incoming_hash = SHA256(nilai X-CSRF-Token)
4. stored_hash = redis_auth.get("csrf:{user_id}:{path_hash}")
5. secrets.compare_digest(incoming_hash, stored_hash)
6. redis_auth.delete(key) one-time use
NOTE: Dependency ini harus selalu dikombinasikan dengan JWTBearer() agar
request.state.user sudah tersedia saat dijalankan.
Usage:
from fastapi import Depends
from src.csrf_protect import csrf_protect
@router.post("/some-endpoint", dependencies=[Depends(csrf_protect)])
async def some_endpoint(...):
...
"""
import hashlib import hashlib
import logging import logging
import secrets import secrets
import redis import redis
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
import src.config as config import src.config as config
from src.context import get_user_id from src.context import get_user_id
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_redis_auth = redis.Redis(host=config.REDIS_HOST, port=int(config.REDIS_PORT), db=config.REDIS_AUTH_DB)
# Koneksi Redis untuk CSRF — Redis DB 0 tempat be-auth menyimpan CSRF token
_redis_auth = redis.Redis(
host=config.REDIS_HOST,
port=int(config.REDIS_PORT),
db=config.REDIS_AUTH_DB,
)
async def csrf_protect(request: Request) -> None: async def csrf_protect(request: Request) -> None:
user = getattr(request.state, 'user', None) """
FastAPI dependency untuk verifikasi CSRF token berbasis Redis.
Harus dikombinasikan dengan JWTBearer() dependency.
"""
# ── Step 1: ambil user_id dari request.state (di-set oleh JWTBearer) ────
user = getattr(request.state, "user", None)
user_id = str(user.user_id) if user else get_user_id() user_id = str(user.user_id) if user else get_user_id()
if not user_id: if not user_id:
raise HTTPException(status_code=401, detail='Authentication required') raise HTTPException(status_code=401, detail="Authentication required")
csrf_token_header = request.headers.get('x-csrf-token') or request.headers.get('x-xsrf-token') or request.headers.get('x-csrf-token')
# ── Step 2: baca CSRF token dari request header ───────────────────────────
# Starlette lowercases all header names
csrf_token_header = (
request.headers.get("x-csrf-token")
or request.headers.get("x-xsrf-token")
or request.headers.get("x-csrf-token")
)
if not csrf_token_header: if not csrf_token_header:
log.warning('[CSRF] Missing X-CSRF-Token header | user_id=%s | IP: %s', user_id, request.client.host if request.client else 'unknown') log.warning(
raise HTTPException(status_code=403, detail='CSRF token missing in header') "[CSRF] Missing X-CSRF-Token header | user_id=%s | IP: %s",
user_id,
request.client.host if request.client else "unknown",
)
raise HTTPException(status_code=403, detail="CSRF token missing in header")
csrf_token_header = csrf_token_header.strip() csrf_token_header = csrf_token_header.strip()
# ── Step 3: bangun Redis key (harus sama dengan format di be-auth) ─────────
current_path = request.url.path current_path = request.url.path
path_hash = hashlib.sha256(current_path.encode()).hexdigest()[:16] path_hash = hashlib.sha256(current_path.encode()).hexdigest()[:16]
redis_key = f'csrf:{user_id}:{path_hash}' redis_key = f"csrf:{user_id}:{path_hash}"
# ── Step 4: ambil stored token hash dari Redis (DB 0) ────────────────────
stored_token_hash = _redis_auth.get(redis_key) stored_token_hash = _redis_auth.get(redis_key)
if not stored_token_hash: if not stored_token_hash:
log.warning('[CSRF] Token not found in Redis | user_id=%s | path=%s | IP: %s', user_id, current_path, request.client.host if request.client else 'unknown') log.warning(
raise HTTPException(status_code=403, detail='CSRF token expired or invalid') "[CSRF] Token not found in Redis | user_id=%s | path=%s | IP: %s",
user_id,
current_path,
request.client.host if request.client else "unknown",
)
raise HTTPException(status_code=403, detail="CSRF token expired or invalid")
if isinstance(stored_token_hash, bytes): if isinstance(stored_token_hash, bytes):
stored_token_hash = stored_token_hash.decode('utf-8') stored_token_hash = stored_token_hash.decode("utf-8")
# ── Step 5: bandingkan hash dengan constant-time digest ───────────────────
incoming_hash = hashlib.sha256(csrf_token_header.encode()).hexdigest() incoming_hash = hashlib.sha256(csrf_token_header.encode()).hexdigest()
if not secrets.compare_digest(incoming_hash, stored_token_hash): if not secrets.compare_digest(incoming_hash, stored_token_hash):
log.warning('[CSRF] Token mismatch | user_id=%s | path=%s | IP: %s', user_id, current_path, request.client.host if request.client else 'unknown') log.warning(
"[CSRF] Token mismatch | user_id=%s | path=%s | IP: %s",
user_id,
current_path,
request.client.host if request.client else "unknown",
)
_redis_auth.delete(redis_key) _redis_auth.delete(redis_key)
raise HTTPException(status_code=403, detail='CSRF token verification failed') raise HTTPException(status_code=403, detail="CSRF token verification failed")
# ── Step 6: hapus token (one-time use) dan lanjutkan ─────────────────────
_redis_auth.delete(redis_key) _redis_auth.delete(redis_key)
log.info('[CSRF] Verified & deleted | user_id=%s | path=%s', user_id, current_path) log.info(
"[CSRF] Verified & deleted | user_id=%s | path=%s",
user_id,
current_path,
)

@ -1,44 +1,66 @@
# src/database.py
import re import re
import operator import operator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import Depends from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.declarative import declarative_base, declared_attr
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from starlette.requests import Request from starlette.requests import Request
from src.config import SQLALCHEMY_DATABASE_URI, SQLALCHEMY_COLLECTOR_URI from src.config import SQLALCHEMY_DATABASE_URI, SQLALCHEMY_COLLECTOR_URI
engine = create_async_engine(SQLALCHEMY_DATABASE_URI, echo=False, future=True) engine = create_async_engine(SQLALCHEMY_DATABASE_URI, echo=False, future=True)
collector_engine = create_async_engine(SQLALCHEMY_COLLECTOR_URI, echo=False, future=True) collector_engine = create_async_engine(SQLALCHEMY_COLLECTOR_URI, echo=False, future=True)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False, autocommit=False, autoflush=False)
async_collector_session = async_sessionmaker(collector_engine, class_=AsyncSession, expire_on_commit=False, autocommit=False, autoflush=False) async_session = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async_collector_session = async_sessionmaker(
collector_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
async def get_collector_db(request: Request): async def get_collector_db(request: Request):
return request.state.collector_db return request.state.collector_db
def get_db(request: Request): def get_db(request: Request):
return request.state.db return request.state.db
DbSession = Annotated[AsyncSession, Depends(get_db)] DbSession = Annotated[AsyncSession, Depends(get_db)]
CollectorDbSession = Annotated[AsyncSession, Depends(get_collector_db)] CollectorDbSession = Annotated[AsyncSession, Depends(get_collector_db)]
class Base(DeclarativeBase):
class Base(DeclarativeBase):
@declared_attr.directive @declared_attr.directive
def __tablename__(cls) -> str: def __tablename__(cls) -> str:
return resolve_table_name(cls.__name__) return resolve_table_name(cls.__name__)
def dict(self): def dict(self):
"""Returns a dict representation of a model."""
if hasattr(self, '__table__'): if hasattr(self, '__table__'):
return {c.name: operator.attrgetter(c.name)(self) for c in self.__table__.columns} return {c.name: operator.attrgetter(c.name)(self) for c in self.__table__.columns}
return {} return {}
class CollectorBase(DeclarativeBase): class CollectorBase(DeclarativeBase):
@declared_attr.directive @declared_attr.directive
def __tablename__(cls) -> str: def __tablename__(cls) -> str:
return resolve_table_name(cls.__name__) return resolve_table_name(cls.__name__)
def dict(self): def dict(self):
"""Returns a dict representation of a model."""
if hasattr(self, '__table__'): if hasattr(self, '__table__'):
return {c.name: operator.attrgetter(c.name)(self) for c in self.__table__.columns} return {c.name: operator.attrgetter(c.name)(self) for c in self.__table__.columns}
return {} return {}
@ -68,16 +90,21 @@ async def get_collector_session():
await session.close() await session.close()
def resolve_table_name(name): def resolve_table_name(name):
names = re.split('(?=[A-Z])', name) """Resolves table names to their mapped names."""
return '_'.join([x.lower() for x in names if x]) names = re.split("(?=[A-Z])", name) # noqa
return "_".join([x.lower() for x in names if x])
def get_class_by_tablename(table_fullname: str) -> Any: def get_class_by_tablename(table_fullname: str) -> Any:
"""Return class reference mapped to table."""
def _find_class(name): def _find_class(name):
for c in Base.registry._class_registry.values(): for c in Base.registry._class_registry.values():
if hasattr(c, '__table__'): if hasattr(c, "__table__"):
if c.__table__.fullname.lower() == name.lower(): if c.__table__.fullname.lower() == name.lower():
return c return c
return None
mapped_name = resolve_table_name(table_fullname) mapped_name = resolve_table_name(table_fullname)
return _find_class(mapped_name) mapped_class = _find_class(mapped_name)
return mapped_class

@ -1,18 +1,22 @@
from typing import List, Optional from typing import List, Optional
from pydantic import Field from pydantic import Field
from src.models import DefultBase from src.models import DefultBase
class CommonParams(DefultBase): class CommonParams(DefultBase):
current_user: Optional[str] = Field(None, alias='currentUser') # This ensures no extra query params are allowed
page: int = Field(1, gt=0, le=1000000) current_user: Optional[str] = Field(None, alias="currentUser")
items_per_page: int = Field(5, gt=0, le=50, multiple_of=5, alias='itemsPerPage') page: int = Field(1, gt=0, lt=2147483647)
query_str: Optional[str] = Field(None, alias='q') items_per_page: int = Field(5, gt=0, le=50, multiple_of=5, alias="itemsPerPage")
filter_spec: Optional[str] = Field(None, alias='filter') query_str: Optional[str] = Field(None, alias="q")
sort_by: List[str] = Field(default_factory=list, alias='sortBy[]') filter_spec: Optional[str] = Field(None, alias="filter")
descending: List[bool] = Field(default_factory=list, alias='descending[]') sort_by: List[str] = Field(default_factory=list, alias="sortBy[]")
exclude: List[str] = Field(default_factory=list, alias='exclude[]') descending: List[bool] = Field(default_factory=list, alias="descending[]")
all_params: int = Field(0, alias='all', ge=0, le=1000000) exclude: List[str] = Field(default_factory=list, alias="exclude[]")
all_params: int = Field(0, alias="all")
# Property to mirror your original return dict's bool conversion
@property @property
def is_all(self) -> bool: def is_all(self) -> bool:
return bool(self.all_params) return bool(self.all_params)

@ -1,58 +1,149 @@
import logging import logging
from typing import Annotated, List, Type, TypeVar from typing import Annotated, List, Type, TypeVar
from fastapi import Depends, Query from fastapi import Depends, Query
from pydantic.types import Json, constr from pydantic.types import Json, constr
from sqlalchemy import Select, desc, func, or_ from sqlalchemy import Select, desc, func, or_
from sqlalchemy.exc import ProgrammingError
from sqlalchemy_filters import apply_pagination
from src.database.schema import CommonParams from src.database.schema import CommonParams
from .core import DbSession from .core import DbSession
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
QueryStr = constr(pattern='^[ -~]+$', min_length=1)
def common_parameters(db_session: DbSession, current_user: QueryStr=Query(None, alias='currentUser'), page: int=Query(1, gt=0, le=1000000), items_per_page: int=Query(5, alias='itemsPerPage', gt=-2, le=1000000), query_str: QueryStr=Query(None, alias='q'), filter_spec: QueryStr=Query(None, alias='filter'), sort_by: List[str]=Query([], alias='sortBy[]'), descending: List[bool]=Query([], alias='descending[]'), exclude: List[str]=Query([], alias='exclude[]'), all: int=Query(0, ge=0, le=1000000)): # allows only printable characters
return {'db_session': db_session, 'page': page, 'items_per_page': items_per_page, 'query_str': query_str, 'filter_spec': filter_spec, 'sort_by': sort_by, 'descending': descending, 'current_user': current_user, 'all': bool(all)} QueryStr = constr(pattern=r"^[ -~]+$", min_length=1)
CommonParameters = Annotated[dict[str, int | str | DbSession | QueryStr | Json | List[str] | List[bool]] | bool, Depends(common_parameters)]
T = TypeVar('T', bound=CommonParams)
def common_parameters(
db_session: DbSession, # type: ignore
current_user: QueryStr = Query(None, alias="currentUser"), # type: ignore
page: int = Query(1, gt=0, lt=2147483647),
items_per_page: int = Query(5, alias="itemsPerPage", gt=-2, lt=2147483647),
query_str: QueryStr = Query(None, alias="q"), # type: ignore
filter_spec: QueryStr = Query(None, alias="filter"), # type: ignore
sort_by: List[str] = Query([], alias="sortBy[]"),
descending: List[bool] = Query([], alias="descending[]"),
exclude: List[str] = Query([], alias="exclude[]"),
all: int = Query(0),
# role: QueryStr = Depends(get_current_role),
):
return {
"db_session": db_session,
"page": page,
"items_per_page": items_per_page,
"query_str": query_str,
"filter_spec": filter_spec,
"sort_by": sort_by,
"descending": descending,
"current_user": current_user,
"all": bool(all),
# "role": role,
}
def get_params_factory(model_type: Type[T]):
async def wrapper(db_session: DbSession, params: Annotated[model_type, Query()]): CommonParameters = Annotated[
dict[str, int | str | DbSession | QueryStr | Json | List[str] | List[bool]] | bool,
Depends(common_parameters),
]
T = TypeVar("T", bound=CommonParams)
def get_params_factory(model_type: Type[T]):
async def wrapper(
db_session: DbSession,
params: Annotated[model_type, Query()] # type: ignore
):
res = params.model_dump() res = params.model_dump()
return {'db_session': db_session, 'all': params.is_all, **res} return {
"db_session": db_session,
"all": params.is_all,
**res
}
return wrapper return wrapper
def search(*, query_str: str, query: Query, model, sort=False): def search(*, query_str: str, query: Query, model, sort=False):
"""Perform a search based on the query."""
search_model = model search_model = model
if not query_str.strip(): if not query_str.strip():
return query return query
search = [] search = []
if hasattr(search_model, 'search_vector'): if hasattr(search_model, "search_vector"):
vector = search_model.search_vector vector = search_model.search_vector
search.append(vector.op('@@')(func.tsq_parse(query_str))) search.append(vector.op("@@")(func.tsq_parse(query_str)))
if hasattr(search_model, 'name'):
search.append(search_model.name.ilike(f'%{query_str}%')) if hasattr(search_model, "name"):
search.append(
search_model.name.ilike(f"%{query_str}%"),
)
search.append(search_model.name == query_str) search.append(search_model.name == query_str)
if not search: if not search:
raise Exception(f'Search not supported for model: {model}') raise Exception(f"Search not supported for model: {model}")
query = query.filter(or_(*search)) query = query.filter(or_(*search))
if sort: if sort:
query = query.order_by(desc(func.ts_rank_cd(vector, func.tsq_parse(query_str)))) query = query.order_by(desc(func.ts_rank_cd(vector, func.tsq_parse(query_str))))
return query.params(term=query_str) return query.params(term=query_str)
async def search_filter_sort_paginate(db_session: DbSession, model, query_str: str=None, filter_spec: str | dict | None=None, page: int=1, items_per_page: int=5, sort_by: List[str]=None, descending: List[bool]=None, current_user: str=None, exclude: List[str]=None, all: bool=False):
async def search_filter_sort_paginate(
db_session: DbSession,
model,
query_str: str = None,
filter_spec: str | dict | None = None,
page: int = 1,
items_per_page: int = 5,
sort_by: List[str] = None,
descending: List[bool] = None,
current_user: str = None,
exclude: List[str] = None,
all: bool = False,
):
"""Common functionality for searching, filtering, sorting, and pagination."""
# try:
# Check if model is Select
if not isinstance(model, Select): if not isinstance(model, Select):
query = Select(model) query = Select(model)
else: else:
query = model query = model
if query_str: if query_str:
sort = False if sort_by else True sort = False if sort_by else True
query = search(query_str=query_str, query=query, model=model, sort=sort) query = search(query_str=query_str, query=query, model=model, sort=sort)
# Get total count
count_query = Select(func.count()).select_from(query.subquery()) count_query = Select(func.count()).select_from(query.subquery())
total = await db_session.scalar(count_query) total = await db_session.scalar(count_query)
if all: if all:
result = await db_session.execute(query) result = await db_session.execute(query)
items = result.scalars().all() items = result.scalars().all()
return {'items': items, 'itemsPerPage': total, 'page': 1, 'total': total, 'totalPages': 1}
return {
"items": items,
"itemsPerPage": total,
"page": 1,
"total": total,
"totalPages": 1,
}
query = query.offset((page - 1) * items_per_page).limit(items_per_page) query = query.offset((page - 1) * items_per_page).limit(items_per_page)
result = await db_session.execute(query) result = await db_session.execute(query)
items = result.scalars().all() items = result.scalars().all()
return {'items': items, 'itemsPerPage': items_per_page, 'page': page, 'total': total, 'totalPages': (total + items_per_page - 1) // items_per_page}
return {
"items": items,
"itemsPerPage": items_per_page,
"page": page,
"total": total,
"totalPages": (total + items_per_page - 1) // items_per_page,
}

@ -1,12 +1,29 @@
import sys import sys
if sys.version_info >= (3, 11): if sys.version_info >= (3, 11):
from enum import StrEnum from enum import StrEnum
else: else:
from strenum import StrEnum from strenum import StrEnum
class OptimumOHEnum(StrEnum): class OptimumOHEnum(StrEnum):
pass """
A custom Enum class that extends StrEnum.
This class inherits all functionality from StrEnum, including
string representation and automatic value conversion to strings.
Example:
class Visibility(DispatchEnum):
OPEN = "Open"
RESTRICTED = "Restricted"
assert str(Visibility.OPEN) == "Open"
"""
pass # No additional implementation needed
class ResponseStatus(OptimumOHEnum): class ResponseStatus(OptimumOHEnum):
SUCCESS = 'success' SUCCESS = "success"
ERROR = 'error' ERROR = "error"

@ -1,12 +1,21 @@
from sqlalchemy import UUID, Column, Float, ForeignKey, String from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.database.core import Base from src.database.core import Base
from src.models import DefaultMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
class ScopeEquipmentPart(Base, DefaultMixin): class ScopeEquipmentPart(Base, DefaultMixin):
__tablename__ = 'oh_ms_scope_equipment_part' __tablename__ = "oh_ms_scope_equipment_part"
required_stock = Column(Float, nullable=False, default=0) required_stock = Column(Float, nullable=False, default=0)
sparepart_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_sparepart.id'), nullable=False) sparepart_id = Column(UUID(as_uuid=True), ForeignKey("oh_ms_sparepart.id"), nullable=False)
location_tag = Column(String, nullable=False) location_tag = Column(String, nullable=False)
location_tag = Column(String, nullable=False) location_tag = Column(String, nullable=False)
part = relationship('MasterSparePart', lazy='selectin')
part = relationship(
"MasterSparePart",
lazy="selectin",
)

@ -1,12 +1,28 @@
from typing import Dict, List from typing import Dict, List
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, Query, status
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.models import StandardResponse from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import (ScopeEquipmentActivityCreate,
ScopeEquipmentActivityPagination,
ScopeEquipmentActivityRead, ScopeEquipmentActivityUpdate)
from .service import get_all from .service import get_all
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get('/{location_tag}', response_model=StandardResponse[List[Dict]])
@router.get("/{location_tag}", response_model=StandardResponse[List[Dict]])
async def get_scope_equipment_parts(collector_db_session: CollectorDbSession, location_tag): async def get_scope_equipment_parts(collector_db_session: CollectorDbSession, location_tag):
"""Get all scope activity pagination."""
# return
data = await get_all(db_session=collector_db_session, location_tag=location_tag) data = await get_all(db_session=collector_db_session, location_tag=location_tag)
return StandardResponse(data=data, message='Data retrieved successfully')
return StandardResponse(
data=data,
message="Data retrieved successfully",
)

@ -1,21 +1,69 @@
from typing import List, Optional from datetime import datetime
from pydantic import Field from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
from src.models import DefultBase, Pagination from src.models import DefultBase, Pagination
class ScopeEquipmentActivityBase(DefultBase): class ScopeEquipmentActivityBase(DefultBase):
assetnum: str = Field(..., description='Assetnum is required') assetnum: str = Field(..., description="Assetnum is required")
class ScopeEquipmentActivityCreate(ScopeEquipmentActivityBase): class ScopeEquipmentActivityCreate(ScopeEquipmentActivityBase):
name: str name: str
cost: Optional[float] = Field(0, ge=0, le=1000000000000000) cost: Optional[float] = Field(0)
class ScopeEquipmentActivityUpdate(ScopeEquipmentActivityBase): class ScopeEquipmentActivityUpdate(ScopeEquipmentActivityBase):
name: Optional[str] = Field(None) name: Optional[str] = Field(None)
cost: Optional[str] = Field(0) cost: Optional[str] = Field(0)
class ScopeEquipmentActivityRead(ScopeEquipmentActivityBase): class ScopeEquipmentActivityRead(ScopeEquipmentActivityBase):
name: str name: str
cost: float = Field(..., ge=0, le=1000000000000000) cost: float
class ScopeEquipmentActivityPagination(Pagination): class ScopeEquipmentActivityPagination(Pagination):
items: List[ScopeEquipmentActivityRead] = [] items: List[ScopeEquipmentActivityRead] = []
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,29 +1,218 @@
import random
from typing import Optional from typing import Optional
from sqlalchemy import text
from sqlalchemy import Delete, Select, and_, text
from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser
from src.database.core import CollectorDbSession, DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate
from .model import ScopeEquipmentPart
from .schema import ScopeEquipmentActivityCreate, ScopeEquipmentActivityUpdate
# async def get(*, db_session: DbSession, scope_equipment_activity_id: str) -> Optional[ScopeEquipmentActivity]:
# """Returns a document based on the given document id."""
# result = await db_session.get(ScopeEquipmentActivity, scope_equipment_activity_id)
# return result
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession as DbSession
from sqlalchemy.sql import text from sqlalchemy.sql import text
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# async def get_all(
# db_session: CollectorDbSession,
# location_tag: Optional[str] = None,
# start_year: int = 2023,
# end_year: Optional[int] = None,
# parent_wonum: Optional[str] = None
# ) -> List[Dict[str, Any]]:
# """
# Retrieve overhaul spare parts consumption data.
# Handles missing data, null parent WO, and query safety.
# Args:
# db_session: Async SQLAlchemy session
# location_tag: Optional location filter
# start_year: Year to start analysis (default 2023)
# end_year: Optional year to end analysis (default start_year + 1)
# parent_wonum: Parent work order number (required for context)
# Returns:
# List of dictionaries with spare part usage per overhaul WO.
# """
# # --- 1. Basic validation ---
# if not parent_wonum:
# logger.warning("Parent WO number not provided. Returning empty result.")
# return []
# if start_year < 1900 or (end_year and end_year < start_year):
# raise ValueError("Invalid year range provided.")
# if end_year is None:
# end_year = start_year + 1
# # --- 2. Build SQL safely ---
# base_query = """
# WITH filtered_wo AS (
# SELECT wonum, location_tag
# FROM public.wo_max
# WHERE worktype = 'OH'
# AND xx_parent = :parent_wonum
# """
# params = {
# "parent_wonum": parent_wonum,
# }
# if location_tag:
# base_query += " AND location_tag = :location_tag"
# params["location_tag"] = location_tag
# base_query += """
# ),
# filtered_materials AS (
# SELECT wonum, itemnum, itemqty, inv_curbaltotal, inv_avgcost
# FROM public.wo_max_material
# WHERE wonum IN (SELECT wonum FROM filtered_wo)
# )
# SELECT
# fwo.location_tag AS location_tag,
# fm.itemnum,
# spl.description AS sparepart_name,
# COALESCE(SUM(fm.itemqty), 0) AS parts_consumed_in_oh,
# COALESCE(AVG(fm.inv_avgcost), 0) AS avgcost,
# COALESCE(AVG(fm.inv_curbaltotal), 0) AS inv_curbaltotal
# FROM filtered_wo fwo
# INNER JOIN filtered_materials fm ON fwo.wonum = fm.wonum
# LEFT JOIN public.maximo_sparepart_pr_po_line spl ON fm.itemnum = spl.item_num
# GROUP BY fwo.location_tag, fm.itemnum, spl.description
# ORDER BY fwo.location_tag, fm.itemnum;
# """
# # --- 3. Execute query ---
# try:
# result = await db_session.execute(text(base_query), params)
# rows = result.fetchall()
# # Handle "no data found"
# if not rows:
# logger.info(f"No spare part data found for parent WO {parent_wonum}.")
# return []
# # --- 4. Map results cleanly ---
# equipment_parts = []
# for row in rows:
# try:
# equipment_parts.append({
# "location_tag": row.location_tag,
# "itemnum": row.itemnum,
# "sparepart_name": row.sparepart_name or "-",
# "parts_consumed_in_oh": float(row.parts_consumed_in_oh or 0),
# "avgcost": float(row.avgcost or 0),
# "inv_curbaltotal": float(row.inv_curbaltotal or 0)
# })
# except Exception as parse_err:
# logger.error(f"Failed to parse row {row}: {parse_err}")
# continue # Skip malformed rows
# return equipment_parts
# except Exception as e:
# logger.exception(f"Database query failed: {e}")
# raise RuntimeError("Failed to fetch overhaul spare parts data.") from e
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import text from sqlalchemy.sql import text
async def get_all(db_session: AsyncSession, location_tag: Optional[str]=None, start_year: int=2023, end_year: Optional[int]=None) -> List[Dict[str, Any]]:
async def get_all(
db_session: AsyncSession,
location_tag: Optional[str] = None,
start_year: int = 2023,
end_year: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Get overhaul spare parts consumption data with optimized query.
Args:
db_session: SQLAlchemy async database session
location_tag: Optional filter for location (asset_location)
start_year: Starting year (default: 2023)
end_year: Ending year (default: start_year + 1)
Returns:
List of dictionaries with spare parts consumption data
"""
# Set default end year
if end_year is None: if end_year is None:
end_year = start_year + 1 end_year = start_year + 1
query_str = "\n WITH filtered_wo AS (\n SELECT DISTINCT wonum, asset_location, asset_unit\n FROM public.wo_maximo ma\n WHERE ma.xx_parent IN ('155026', '155027', '155029', '155030')\n "
# Build query dynamically
query_str = """
WITH filtered_wo AS (
SELECT DISTINCT wonum, asset_location, asset_unit
FROM public.wo_maximo ma
WHERE ma.xx_parent IN ('155026', '155027', '155029', '155030')
"""
params = {} params = {}
# Optional filter for location
if location_tag: if location_tag:
query_str += ' AND asset_location = :location_tag' query_str += " AND asset_location = :location_tag"
params['location_tag'] = location_tag params["location_tag"] = location_tag
query_str += "\n ),\n filtered_materials AS (\n SELECT \n mat.wonum,\n mat.itemnum,\n mat.itemqty,\n mat.inv_curbaltotal AS inv_curbaltotal,\n mat.inv_avgcost AS inv_avgcost\n FROM public.wo_maximo_material AS mat\n WHERE mat.wonum IN (SELECT wonum FROM filtered_wo)\n )\n SELECT \n fwo.asset_location AS location_tag,\n ft.itemnum,\n COALESCE(spl.description, 'Unknown') AS sparepart_name,\n AVG(ft.itemqty) AS total_parts_used,\n COALESCE(AVG(ft.inv_avgcost), 0) AS avg_cost,\n COALESCE(AVG(ft.inv_curbaltotal), 0) AS avg_inventory_balance\n FROM filtered_wo AS fwo\n INNER JOIN filtered_materials AS ft \n ON fwo.wonum = ft.wonum\n LEFT JOIN public.maximo_sparepart_pr_po_line AS spl \n ON ft.itemnum = spl.item_num\n GROUP BY fwo.asset_location, ft.itemnum, spl.description\n ORDER BY fwo.asset_location, ft.itemnum;\n "
query_str += """
),
filtered_materials AS (
SELECT
mat.wonum,
mat.itemnum,
mat.itemqty,
mat.inv_curbaltotal AS inv_curbaltotal,
mat.inv_avgcost AS inv_avgcost
FROM public.wo_maximo_material AS mat
WHERE mat.wonum IN (SELECT wonum FROM filtered_wo)
)
SELECT
fwo.asset_location AS location_tag,
ft.itemnum,
COALESCE(spl.description, 'Unknown') AS sparepart_name,
AVG(ft.itemqty) AS total_parts_used,
COALESCE(AVG(ft.inv_avgcost), 0) AS avg_cost,
COALESCE(AVG(ft.inv_curbaltotal), 0) AS avg_inventory_balance
FROM filtered_wo AS fwo
INNER JOIN filtered_materials AS ft
ON fwo.wonum = ft.wonum
LEFT JOIN public.maximo_sparepart_pr_po_line AS spl
ON ft.itemnum = spl.item_num
GROUP BY fwo.asset_location, ft.itemnum, spl.description
ORDER BY fwo.asset_location, ft.itemnum;
"""
try: try:
result = await db_session.execute(text(query_str), params) result = await db_session.execute(text(query_str), params)
rows = result.fetchall() rows = result.fetchall()
equipment_parts = [] equipment_parts = []
for row in rows: for row in rows:
equipment_parts.append({'location_tag': row.location_tag, 'itemnum': row.itemnum, 'sparepart_name': row.sparepart_name, 'parts_consumed_in_oh': float(row.total_parts_used or 0), 'avg_cost': float(row.avg_cost or 0), 'inv_curbaltotal': float(row.avg_inventory_balance or 0)}) equipment_parts.append({
"location_tag": row.location_tag,
"itemnum": row.itemnum,
"sparepart_name": row.sparepart_name,
"parts_consumed_in_oh": float(row.total_parts_used or 0),
"avg_cost": float(row.avg_cost or 0),
"inv_curbaltotal": float(row.avg_inventory_balance or 0),
})
return equipment_parts return equipment_parts
except Exception as e: except Exception as e:
print(f'[get_all] Database query error: {e}') print(f"[get_all] Database query error: {e}")
raise raise

@ -1,11 +1,22 @@
from sqlalchemy import UUID, Column, ForeignKey, String from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.database.core import Base from src.database.core import Base
from src.models import DefaultMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
class EquipmentWorkscopeGroup(Base, DefaultMixin): class EquipmentWorkscopeGroup(Base, DefaultMixin):
__tablename__ = 'oh_tr_equipment_workscope_group' __tablename__ = "oh_tr_equipment_workscope_group"
workscope_group_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_workscope_group.id')) workscope_group_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_workscope_group.id'))
location_tag = Column(String, nullable=False) location_tag = Column(String, nullable=False)
workscope_group = relationship('MasterActivity', lazy='selectin', back_populates='equipment_workscope_groups')
equipment = relationship('StandardScope', lazy='raise', primaryjoin='and_(EquipmentWorkscopeGroup.location_tag == foreign(StandardScope.location_tag))', uselist=False) workscope_group = relationship("MasterActivity", lazy="selectin", back_populates="equipment_workscope_groups")
equipment = relationship(
"StandardScope",
lazy="raise",
primaryjoin="and_(EquipmentWorkscopeGroup.location_tag == foreign(StandardScope.location_tag))",
uselist=False, # Add this if it's a one-to-one relationship
)

@ -1,22 +1,51 @@
from typing import Optional from typing import Dict, List, Optional
from fastapi import APIRouter, Query
from src.database.service import CommonParameters, DbSession from fastapi import APIRouter, HTTPException, Query, status
from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.models import StandardResponse from src.models import StandardResponse
from .schema import ScopeEquipmentJobCreate, ScopeEquipmentJobPagination from .schema import ScopeEquipmentJobCreate, ScopeEquipmentJobPagination
from .service import create, delete, get_all from .service import create, delete, get_all
router = APIRouter() router = APIRouter()
@router.get('/{location_tag}', response_model=StandardResponse[ScopeEquipmentJobPagination])
@router.get(
"/{location_tag}", response_model=StandardResponse[ScopeEquipmentJobPagination]
)
async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str] = Query(None)): async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str] = Query(None)):
"""Get all scope pagination."""
# return
results = await get_all(common=common, location_tag=location_tag, scope=scope) results = await get_all(common=common, location_tag=location_tag, scope=scope)
return StandardResponse(data=results, message='Data retrieved successfully')
@router.post('/{assetnum}', response_model=StandardResponse[None]) return StandardResponse(
async def create_scope_equipment_jobs(db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate): data=results,
message="Data retrieved successfully",
)
@router.post("/{assetnum}", response_model=StandardResponse[None])
async def create_scope_equipment_jobs(
db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate
):
"""Get all scope activity pagination."""
# return
await create(db_session=db_session, assetnum=assetnum, scope_job_in=scope_job_in) await create(db_session=db_session, assetnum=assetnum, scope_job_in=scope_job_in)
return StandardResponse(data=None, message='Data created successfully')
@router.post('/delete/{scope_job_id}', response_model=StandardResponse[None]) return StandardResponse(
data=None,
message="Data created successfully",
)
@router.post("/delete/{scope_job_id}", response_model=StandardResponse[None])
async def delete_scope_equipment_job(db_session: DbSession, scope_job_id): async def delete_scope_equipment_job(db_session: DbSession, scope_job_id):
await delete(db_session=db_session, scope_job_id=scope_job_id) await delete(db_session=db_session, scope_job_id=scope_job_id)
return StandardResponse(data=None, message='Data deleted successfully')
return StandardResponse(
data=None,
message="Data deleted successfully",
)

@ -1,32 +1,80 @@
from typing import List, Optional from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
from pydantic import Field
from pydantic import BaseModel, Field
from src.workscope_group.schema import ActivityMasterRead from src.workscope_group.schema import ActivityMasterRead
from src.models import DefultBase, Pagination from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead from src.overhaul_scope.schema import ScopeRead
class ScopeEquipmentJobBase(DefultBase): class ScopeEquipmentJobBase(DefultBase):
pass pass
class ScopeEquipmentJobCreate(ScopeEquipmentJobBase): class ScopeEquipmentJobCreate(ScopeEquipmentJobBase):
job_ids: Optional[List[UUID]] = [] job_ids: Optional[List[UUID]] = []
class ScopeEquipmentJobUpdate(ScopeEquipmentJobBase): class ScopeEquipmentJobUpdate(ScopeEquipmentJobBase):
name: Optional[str] = Field(None) name: Optional[str] = Field(None)
cost: Optional[str] = Field(0) cost: Optional[str] = Field(0)
class OverhaulActivity(DefultBase): class OverhaulActivity(DefultBase):
id: UUID id: UUID
overhaul_scope: ScopeRead overhaul_scope: ScopeRead
class OverhaulJob(DefultBase): class OverhaulJob(DefultBase):
id: UUID id: UUID
overhaul_activity: OverhaulActivity overhaul_activity: OverhaulActivity
class ScopeEquipmentJobRead(ScopeEquipmentJobBase): class ScopeEquipmentJobRead(ScopeEquipmentJobBase):
id: UUID id: UUID
workscope_group: Optional[ActivityMasterRead] workscope_group: Optional[ActivityMasterRead]
location_tag: str location_tag: str
class ScopeEquipmentJobPagination(Pagination): class ScopeEquipmentJobPagination(Pagination):
items: List[ScopeEquipmentJobRead] = [] items: List[ScopeEquipmentJobRead] = []
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,46 +1,153 @@
import random
from typing import Optional from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import Select from sqlalchemy import Delete, Select, and_
from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import search_filter_sort_paginate from src.database.service import CommonParameters, search_filter_sort_paginate
from src.overhaul_activity.model import OverhaulActivity
from src.standard_scope.model import MasterEquipment from src.standard_scope.model import MasterEquipment
from src.standard_scope.service import get_equipment_level_by_no
from src.workscope_group.model import MasterActivity from src.workscope_group.model import MasterActivity
from src.workscope_group_maintenance_type.model import WorkscopeOHType from src.workscope_group_maintenance_type.model import WorkscopeOHType
from src.overhaul_scope.model import MaintenanceType from src.overhaul_scope.model import MaintenanceType
from .model import EquipmentWorkscopeGroup from .model import EquipmentWorkscopeGroup
from .schema import ScopeEquipmentJobCreate from .schema import ScopeEquipmentJobCreate
from src.soft_delete import apply_not_deleted_filter from src.soft_delete import apply_not_deleted_filter
# async def get(*, db_session: DbSession, scope_equipment_activity_id: str) -> Optional[ScopeEquipmentActivity]:
# """Returns a document based on the given document id."""
# result = await db_session.get(ScopeEquipmentActivity, scope_equipment_activity_id)
# return result
# async def get_all(db_session: DbSession, assetnum: Optional[str], common):
# # Example usage
# if not assetnum:
# raise ValueError("assetnum parameter is required")
# # First get the parent equipment
# equipment_stmt = Select(MasterEquipment).where(MasterEquipment.assetnum == assetnum)
# equipment: MasterEquipment = await db_session.scalar(equipment_stmt)
# if not equipment:
# raise ValueError(f"No equipment found with assetnum: {assetnum}")
# # Build query for parts
# stmt = (
# Select(ScopeEquipmentJob)
# .where(ScopeEquipmentJob.assetnum == assetnum)
# .options(
# selectinload(ScopeEquipmentJob.job),
# selectinload(ScopeEquipmentJob.overhaul_jobs)
# .selectinload(OverhaulJob.overhaul_activity)
# .selectinload(OverhaulActivity.overhaul_scope),
# )
# )
# results = await search_filter_sort_paginate(model=stmt, **common)
# return results
async def get_all(*, common, location_tag: str, scope: Optional[str] = None): async def get_all(*, common, location_tag: str, scope: Optional[str] = None):
query = Select(EquipmentWorkscopeGroup).where(EquipmentWorkscopeGroup.location_tag == location_tag).filter(EquipmentWorkscopeGroup.workscope_group_id.is_not(None)) """Returns all documents."""
query = (
Select(EquipmentWorkscopeGroup)
.where(EquipmentWorkscopeGroup.location_tag == location_tag).filter(EquipmentWorkscopeGroup.workscope_group_id.is_not(None))
)
query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup) query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup)
if scope: if scope:
query = query.join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).filter(MaintenanceType.name == scope) query = (
return await search_filter_sort_paginate(model=query, **common) query
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(WorkscopeOHType.oh_type)
.filter(MaintenanceType.name == scope)
)
async def create(*, db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate): results = await search_filter_sort_paginate(model=query, **common)
return results
async def create(
*, db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate
):
scope_jobs = [] scope_jobs = []
if not assetnum: if not assetnum:
raise ValueError('assetnum parameter is required') raise ValueError("assetnum parameter is required")
# First get the parent equipment
equipment_stmt = Select(MasterEquipment).where(MasterEquipment.assetnum == assetnum) equipment_stmt = Select(MasterEquipment).where(MasterEquipment.assetnum == assetnum)
equipment: MasterEquipment = await db_session.scalar(equipment_stmt) equipment: MasterEquipment = await db_session.scalar(equipment_stmt)
if not equipment: if not equipment:
raise ValueError(f'No equipment found with assetnum: {assetnum}') raise ValueError(f"No equipment found with assetnum: {assetnum}")
for job_id in scope_job_in.job_ids: for job_id in scope_job_in.job_ids:
scope_equipment_job = ScopeEquipmentJob(assetnum=assetnum, job_id=job_id) scope_equipment_job = ScopeEquipmentJob(assetnum=assetnum, job_id=job_id)
scope_jobs.append(scope_equipment_job) scope_jobs.append(scope_equipment_job)
db_session.add_all(scope_jobs) db_session.add_all(scope_jobs)
await db_session.commit() await db_session.commit()
return return
async def delete(*, db_session: DbSession, scope_job_id: int) -> bool:
# async def update(*, db_session: DbSession, activity: ScopeEquipmentActivity, scope_equipment_activty_in: ScopeEquipmentActivityUpdate):
# """Updates a document."""
# data = scope_equipment_activty_in.model_dump()
# update_data = scope_equipment_activty_in.model_dump(exclude_defaults=True)
# for field in data:
# if field in update_data:
# setattr(activity, field, update_data[field])
# await db_session.commit()
# return activity
async def delete(
*,
db_session: DbSession,
scope_job_id: int,
) -> bool:
"""
Deletes a scope job and returns success status.
Args:
db_session: Database session
scope_job_id: ID of the scope job to delete
user_id: ID of user performing the deletion
Returns:
bool: True if deletion was successful, False otherwise
Raises:
NotFoundException: If scope job doesn't exist
AuthorizationError: If user lacks delete permission
"""
try: try:
# Check if job exists
scope_job = await db_session.get(ScopeEquipmentJob, scope_job_id) scope_job = await db_session.get(ScopeEquipmentJob, scope_job_id)
if not scope_job: if not scope_job:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
# Perform deletion
await db_session.delete(scope_job) await db_session.delete(scope_job)
await db_session.commit() await db_session.commit()
return True return True
except Exception:
except Exception as e:
await db_session.rollback() await db_session.rollback()
raise raise

@ -1,7 +1,9 @@
# Define base error model and exception hierarchy
import logging import logging
import re import re
import uuid import uuid
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from asyncpg.exceptions import DataError as AsyncPGDataError from asyncpg.exceptions import DataError as AsyncPGDataError
from fastapi import Request from fastapi import Request
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
@ -10,43 +12,82 @@ from pydantic import BaseModel
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, SQLAlchemyError, NoResultFound from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, SQLAlchemyError, NoResultFound
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from src.enums import ResponseStatus from src.enums import ResponseStatus
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class OptimumOHBaseException(Exception):
# ── Custom exception classes ──────────────────────────────────────────────────
class OptimumOHBaseException(Exception):
def __init__(self, message: str, data: Any = None): def __init__(self, message: str, data: Any = None):
self.message = message self.message = message
self.data = data self.data = data
super().__init__(message) super().__init__(message)
class BadRequestError(OptimumOHBaseException):
pass
class UnauthorizedError(OptimumOHBaseException): class BadRequestError(OptimumOHBaseException): pass
pass class UnauthorizedError(OptimumOHBaseException): pass
class ForbiddenError(OptimumOHBaseException): pass
class NotFoundError(OptimumOHBaseException): pass
class RequestTimeoutError(OptimumOHBaseException): pass
class UnprocessableEntityError(OptimumOHBaseException): pass
class TooManyRequestsError(OptimumOHBaseException): pass
class InternalServerError(OptimumOHBaseException): pass
class ForbiddenError(OptimumOHBaseException):
pass
class NotFoundError(OptimumOHBaseException): # ── Fixed client response messages (generic, not for logging) ─────────────────
pass
class RequestTimeoutError(OptimumOHBaseException): _FIXED_MESSAGES = {
pass 400: "Malformed JSON body input",
401: "Invalid authorization token",
403: "Forbidden Access",
404: "Resource not found",
408: "Request timeout",
422: "Invalid request parameter",
429: "Please try again later",
}
class UnprocessableEntityError(OptimumOHBaseException): # Human-readable error types for logging (no underscores)
pass _ERROR_TYPE_MAP = {
BadRequestError: "Bad Request",
UnauthorizedError: "Unauthorized",
ForbiddenError: "Forbidden",
NotFoundError: "Not Found",
RequestTimeoutError: "Request Timeout",
UnprocessableEntityError: "Unprocessable Entity",
TooManyRequestsError: "Too Many Requests",
InternalServerError: "Internal Server Error",
}
class TooManyRequestsError(OptimumOHBaseException): _EXCEPTION_STATUS_MAP = {
pass BadRequestError: 400,
UnauthorizedError: 401,
ForbiddenError: 403,
NotFoundError: 404,
RequestTimeoutError: 408,
UnprocessableEntityError: 422,
TooManyRequestsError: 429,
InternalServerError: 500,
}
_HTTP_STATUS_NAMES = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
408: "Request Timeout",
409: "Conflict",
413: "Payload Too Large",
414: "URI Too Long",
415: "Unsupported Media Type",
422: "Unprocessable Entity",
429: "Too Many Requests",
500: "Internal Server Error",
}
class InternalServerError(OptimumOHBaseException):
pass
_FIXED_MESSAGES = {400: 'Malformed JSON body input', 401: 'Invalid authorization token', 403: 'Forbidden Access', 404: 'Resource not found', 408: 'Request timeout', 422: 'Invalid request parameter', 429: 'Please try again later'}
_ERROR_TYPE_MAP = {BadRequestError: 'Bad Request', UnauthorizedError: 'Unauthorized', ForbiddenError: 'Forbidden', NotFoundError: 'Not Found', RequestTimeoutError: 'Request Timeout', UnprocessableEntityError: 'Unprocessable Entity', TooManyRequestsError: 'Too Many Requests', InternalServerError: 'Internal Server Error'}
_EXCEPTION_STATUS_MAP = {BadRequestError: 400, UnauthorizedError: 401, ForbiddenError: 403, NotFoundError: 404, RequestTimeoutError: 408, UnprocessableEntityError: 422, TooManyRequestsError: 429, InternalServerError: 500}
_HTTP_STATUS_NAMES = {400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed', 408: 'Request Timeout', 409: 'Conflict', 413: 'Payload Too Large', 414: 'URI Too Long', 415: 'Unsupported Media Type', 422: 'Unprocessable Entity', 429: 'Too Many Requests', 500: 'Internal Server Error'}
class ErrorDetail(BaseModel): class ErrorDetail(BaseModel):
field: Optional[str] = None field: Optional[str] = None
@ -54,188 +95,283 @@ class ErrorDetail(BaseModel):
code: Optional[str] = None code: Optional[str] = None
params: Optional[Dict[str, Any]] = None params: Optional[Dict[str, Any]] = None
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
data: Optional[Any] = None data: Optional[Any] = None
message: str message: str
status: ResponseStatus = ResponseStatus.ERROR status: ResponseStatus = ResponseStatus.ERROR
errors: Optional[List[ErrorDetail]] = None errors: Optional[List[ErrorDetail]] = None
# ── Helpers ───────────────────────────────────────────────────────────────────
def get_request_context(request: Request) -> dict: def get_request_context(request: Request) -> dict:
"""Get detailed request context for logging."""
def get_client_ip() -> str: def get_client_ip() -> str:
if 'X-Real-IP' in request.headers: if "X-Real-IP" in request.headers:
return request.headers['X-Real-IP'] return request.headers["X-Real-IP"]
if 'X-Forwarded-For' in request.headers: if "X-Forwarded-For" in request.headers:
return request.headers['X-Forwarded-For'].split(',')[0].strip() return request.headers["X-Forwarded-For"].split(",")[0].strip()
return request.client.host if request.client else 'unknown' return request.client.host if request.client else "unknown"
return {'endpoint': request.url.path, 'url': str(request.url), 'method': request.method, 'remote_addr': get_client_ip()}
return {
"endpoint": request.url.path,
"url": str(request.url),
"method": request.method,
"remote_addr": get_client_ip(),
}
def _json_response(status_code: int, message: str, data: Any = None) -> JSONResponse: def _json_response(status_code: int, message: str, data: Any = None) -> JSONResponse:
return JSONResponse(status_code=status_code, content={'data': data, 'message': message, 'status': ResponseStatus.ERROR}) return JSONResponse(
status_code=status_code,
content={"data": data, "message": message, "status": ResponseStatus.ERROR},
)
def _store_log_state(request: Request, status_code: int, error_id: str, error_type: str, error_message: str) -> None: def _store_log_state(
request: Request,
status_code: int,
error_id: str,
error_type: str,
error_message: str,
) -> None:
"""Store logging metadata on request.state so db_session_middleware emits a single structured log."""
request.state.log_status_code = status_code request.state.log_status_code = status_code
request.state.log_error_id = error_id request.state.log_error_id = error_id
request.state.log_error_type = error_type request.state.log_error_type = error_type
request.state.log_error_message = error_message request.state.log_error_message = error_message
def _short_db_error(orig_str: str) -> str: def _short_db_error(orig_str: str) -> str:
"""Extract a concise, human-readable reason from a raw DB/asyncpg error string."""
s = orig_str.lower() s = orig_str.lower()
if 'uuid' in s:
if 'length must be between' in s or 'got ' in s: if "uuid" in s:
return 'Invalid UUID length' if "length must be between" in s or "got " in s:
if 'badly formed' in s or 'invalid hexadecimal' in s or 'invalid uuid' in s: return "Invalid UUID length"
return 'Invalid UUID format' if "badly formed" in s or "invalid hexadecimal" in s or "invalid uuid" in s:
if 'invalid input syntax for type' in s: return "Invalid UUID format"
m = re.search('invalid input syntax for type (\\w+)', s)
type_name = m.group(1) if m else 'value' if "invalid input syntax for type" in s:
return f'Invalid format for type {type_name}' m = re.search(r"invalid input syntax for type (\w+)", s)
if 'invalid input for query argument' in s: type_name = m.group(1) if m else "value"
m = re.search('\\(([^)]+)\\)\\s*$', orig_str) return f"Invalid format for type {type_name}"
if "invalid input for query argument" in s:
m = re.search(r"\(([^)]+)\)\s*$", orig_str)
if m: if m:
return _short_db_error(m.group(1)) return _short_db_error(m.group(1))
return 'Invalid query argument value' return "Invalid query argument value"
if 'unique constraint' in s:
return 'Unique constraint violation' if "unique constraint" in s:
if 'foreign key constraint' in s: return "Unique constraint violation"
return 'Foreign key constraint violation' if "foreign key constraint" in s:
if 'null value in column' in s: return "Foreign key constraint violation"
m = re.search('null value in column "([^"]+)"', orig_str) if "null value in column" in s:
col = f' "{m.group(1)}"' if m else '' m = re.search(r'null value in column "([^"]+)"', orig_str)
return f'Required field{col} is missing' col = f' "{m.group(1)}"' if m else ""
if 'check constraint' in s: return f"Required field{col} is missing"
return 'Check constraint violation' if "check constraint" in s:
if 'out of range' in s: return "Check constraint violation"
return 'Value out of allowed range' if "out of range" in s:
if 'numeric field overflow' in s: return "Value out of allowed range"
return 'Numeric field overflow' if "numeric field overflow" in s:
first_line = orig_str.split('\n')[0].split('. ')[0].strip() return "Numeric field overflow"
first_line = re.sub("^<class '[^']+'>:\\s*", '', first_line)
return first_line if first_line else 'Database error' first_line = orig_str.split("\n")[0].split(". ")[0].strip()
first_line = re.sub(r"^<class '[^']+'>:\s*", "", first_line)
return first_line if first_line else "Database error"
# ── Core error response builder ───────────────────────────────────────────────
def _build_error_response(request: Request, exc: Exception, error_id: str) -> JSONResponse: def _build_error_response(request: Request, exc: Exception, error_id: str) -> JSONResponse:
"""
Build JSON error response and store log metadata on request.state.
Status codes handled:
400 BadRequestError
401 UnauthorizedError (missing/expired token)
403 ForbiddenError (valid token, access denied)
404 NotFoundError
408 RequestTimeoutError
422 UnprocessableEntityError / validation errors / security payloads
429 TooManyRequestsError / RateLimitExceeded
500 InternalServerError / SQLAlchemy errors / unexpected
"""
# 1. Custom OptimumOH exceptions
for exc_class, status_code in _EXCEPTION_STATUS_MAP.items(): for exc_class, status_code in _EXCEPTION_STATUS_MAP.items():
if isinstance(exc, exc_class): if isinstance(exc, exc_class):
error_type = _ERROR_TYPE_MAP[exc_class] error_type = _ERROR_TYPE_MAP[exc_class]
client_msg = _FIXED_MESSAGES.get(status_code, exc.message) client_msg = _FIXED_MESSAGES.get(status_code, exc.message)
_store_log_state(request, status_code, error_id, error_type, exc.message) _store_log_state(request, status_code, error_id, error_type, exc.message)
return _json_response(status_code, f'{client_msg}. Error ID: {error_id}') return _json_response(status_code, f"{client_msg}. Error ID: {error_id}")
# 2. SlowAPI RateLimitExceeded
if isinstance(exc, RateLimitExceeded): if isinstance(exc, RateLimitExceeded):
try: try:
from src.auth.service import notify_admin_on_rate_limit_sync from src.auth.service import notify_admin_on_rate_limit_sync
request_info = get_request_context(request) request_info = get_request_context(request)
notify_admin_on_rate_limit_sync(endpoint_name=request_info['endpoint'], ip_address=request_info['remote_addr'], method=request_info['method'], request=request) notify_admin_on_rate_limit_sync(
endpoint_name=request_info["endpoint"],
ip_address=request_info["remote_addr"],
method=request_info["method"],
request=request,
)
except Exception: except Exception:
pass pass
_store_log_state(request, 429, error_id, 'Too Many Requests', 'Please try again later') _store_log_state(request, 429, error_id, "Too Many Requests", "Please try again later")
return _json_response(429, f'Please try again later. Error ID: {error_id}') return _json_response(429, f"Please try again later. Error ID: {error_id}")
# 3. Pydantic / FastAPI RequestValidationError
if isinstance(exc, RequestValidationError): if isinstance(exc, RequestValidationError):
errors = exc.errors() errors = exc.errors()
detail_parts = [] detail_parts = []
for err in errors: for err in errors:
loc = ' -> '.join((str(part) for part in err.get('loc', []))) loc = " -> ".join(str(part) for part in err.get("loc", []))
msg = err.get('msg', '') msg = err.get("msg", "")
detail_parts.append(f'{loc}: {msg}' if loc else msg) detail_parts.append(f"{loc}: {msg}" if loc else msg)
detail_str = '; '.join(detail_parts) if detail_parts else 'Input validation failed' detail_str = "; ".join(detail_parts) if detail_parts else "Input validation failed"
_store_log_state(request, 422, error_id, 'Unprocessable Entity', detail_str) _store_log_state(request, 422, error_id, "Unprocessable Entity", detail_str)
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
# 4. Starlette/FastAPI HTTPException
if isinstance(exc, StarletteHTTPException): if isinstance(exc, StarletteHTTPException):
status_code = exc.status_code status_code = exc.status_code
error_type = _HTTP_STATUS_NAMES.get(status_code, f'HTTP {status_code}') error_type = _HTTP_STATUS_NAMES.get(status_code, f"HTTP {status_code}")
if status_code == 401: if status_code == 401:
log_msg = str(exc.detail) if exc.detail else 'Invalid authorization token' log_msg = str(exc.detail) if exc.detail else "Invalid authorization token"
_store_log_state(request, 401, error_id, 'Unauthorized', log_msg) _store_log_state(request, 401, error_id, "Unauthorized", log_msg)
return _json_response(401, f'{_FIXED_MESSAGES[401]}. Error ID: {error_id}') return _json_response(401, f"{_FIXED_MESSAGES[401]}. Error ID: {error_id}")
if status_code == 422: if status_code == 422:
log_msg = str(exc.detail) if exc.detail else _FIXED_MESSAGES[422] log_msg = str(exc.detail) if exc.detail else _FIXED_MESSAGES[422]
_store_log_state(request, 422, error_id, 'Unprocessable Entity', log_msg) _store_log_state(request, 422, error_id, "Unprocessable Entity", log_msg)
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
if isinstance(exc.detail, dict): if isinstance(exc.detail, dict):
log_msg = exc.detail.get('error', error_type) log_msg = exc.detail.get("error", error_type)
else: else:
log_msg = str(exc.detail) if exc.detail else error_type log_msg = str(exc.detail) if exc.detail else error_type
client_msg = _FIXED_MESSAGES.get(status_code, log_msg) client_msg = _FIXED_MESSAGES.get(status_code, log_msg)
_store_log_state(request, status_code, error_id, error_type, log_msg) _store_log_state(request, status_code, error_id, error_type, log_msg)
return _json_response(status_code, f'{client_msg}. Error ID: {error_id}') return _json_response(status_code, f"{client_msg}. Error ID: {error_id}")
# 5. SQLAlchemy errors
if isinstance(exc, SQLAlchemyError): if isinstance(exc, SQLAlchemyError):
orig = getattr(exc, 'orig', None) orig = getattr(exc, "orig", None)
orig_str = str(orig) if orig else str(exc) orig_str = str(orig) if orig else str(exc)
short = _short_db_error(orig_str) short = _short_db_error(orig_str)
# NoResultFound: .one() / .scalar_one() found no row → 404
if isinstance(exc, NoResultFound): if isinstance(exc, NoResultFound):
_store_log_state(request, 404, error_id, 'Not Found', 'Resource not found') _store_log_state(request, 404, error_id, "Not Found", "Resource not found")
return _json_response(404, f'Resource not found. Error ID: {error_id}') return _json_response(404, f"Resource not found. Error ID: {error_id}")
if isinstance(exc, IntegrityError): if isinstance(exc, IntegrityError):
orig_lower = orig_str.lower() orig_lower = orig_str.lower()
if 'unique constraint' in orig_lower: if "unique constraint" in orig_lower:
_store_log_state(request, 409, error_id, 'Conflict', f'Unique constraint violation: {short}') _store_log_state(request, 409, error_id, "Conflict", f"Unique constraint violation: {short}")
return _json_response(409, f'This record already exists. Error ID: {error_id}') return _json_response(409, f"This record already exists. Error ID: {error_id}")
if 'foreign key constraint' in orig_lower: elif "foreign key constraint" in orig_lower:
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Foreign key constraint violation: {short}') _store_log_state(request, 422, error_id, "Unprocessable Entity", f"Foreign key constraint violation: {short}")
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Database integrity error: {short}') else:
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') _store_log_state(request, 422, error_id, "Unprocessable Entity", f"Database integrity error: {short}")
return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
if isinstance(exc, DataError): if isinstance(exc, DataError):
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Invalid data format: {short}') _store_log_state(request, 422, error_id, "Unprocessable Entity", f"Invalid data format: {short}")
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
if isinstance(exc, DBAPIError): if isinstance(exc, DBAPIError):
orig_lower = orig_str.lower() orig_lower = orig_str.lower()
if 'invalid input syntax' in orig_lower or 'invalid uuid' in orig_lower or 'invalid input for query argument' in orig_lower: if "invalid input syntax" in orig_lower or "invalid uuid" in orig_lower or "invalid input for query argument" in orig_lower:
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Invalid input format: {short}') _store_log_state(request, 422, error_id, "Unprocessable Entity", f"Invalid input format: {short}")
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
if 'unique constraint' in orig_lower: elif "unique constraint" in orig_lower:
_store_log_state(request, 409, error_id, 'Conflict', f'Unique constraint violation: {short}') _store_log_state(request, 409, error_id, "Conflict", f"Unique constraint violation: {short}")
return _json_response(409, f'This record already exists. Error ID: {error_id}') return _json_response(409, f"This record already exists. Error ID: {error_id}")
if 'foreign key constraint' in orig_lower: elif "foreign key constraint" in orig_lower:
_store_log_state(request, 422, error_id, 'Unprocessable Entity', f'Foreign key constraint violation: {short}') _store_log_state(request, 422, error_id, "Unprocessable Entity", f"Foreign key constraint violation: {short}")
return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}")
_store_log_state(request, 500, error_id, 'Internal Server Error', f'Database query error: {short}') else:
return _json_response(500, f'An unexpected error occurred. Error ID: {error_id}') _store_log_state(request, 500, error_id, "Internal Server Error", f"Database query error: {short}")
_store_log_state(request, 500, error_id, 'Internal Server Error', f'Database error: {short}') return _json_response(500, f"An unexpected error occurred. Error ID: {error_id}")
return _json_response(500, f'An unexpected error occurred. Error ID: {error_id}')
_store_log_state(request, 500, error_id, "Internal Server Error", f"Database error: {short}")
return _json_response(500, f"An unexpected error occurred. Error ID: {error_id}")
# 5b. httpx upstream errors (raise_for_status() from external service calls)
try: try:
import httpx as _httpx import httpx as _httpx
if isinstance(exc, _httpx.HTTPStatusError): if isinstance(exc, _httpx.HTTPStatusError):
upstream_status = exc.response.status_code upstream_status = exc.response.status_code
error_type = _HTTP_STATUS_NAMES.get(upstream_status, f'HTTP {upstream_status}') error_type = _HTTP_STATUS_NAMES.get(upstream_status, f"HTTP {upstream_status}")
client_msg = _FIXED_MESSAGES.get(upstream_status, 'Upstream service error') client_msg = _FIXED_MESSAGES.get(upstream_status, "Upstream service error")
log_msg = f'Upstream service returned {upstream_status}: {exc.request.url}' log_msg = f"Upstream service returned {upstream_status}: {exc.request.url}"
_store_log_state(request, upstream_status, error_id, error_type, log_msg) _store_log_state(request, upstream_status, error_id, error_type, log_msg)
return _json_response(upstream_status, f'{client_msg}. Error ID: {error_id}') return _json_response(upstream_status, f"{client_msg}. Error ID: {error_id}")
if isinstance(exc, (_httpx.ConnectError, _httpx.TimeoutException, _httpx.RequestError)): if isinstance(exc, (_httpx.ConnectError, _httpx.TimeoutException, _httpx.RequestError)):
log_msg = f'Upstream service unreachable: {type(exc).__name__}: {exc}' log_msg = f"Upstream service unreachable: {type(exc).__name__}: {exc}"
_store_log_state(request, 502, error_id, 'Bad Gateway', log_msg) _store_log_state(request, 502, error_id, "Bad Gateway", log_msg)
return _json_response(502, f'Upstream service unavailable. Error ID: {error_id}') return _json_response(502, f"Upstream service unavailable. Error ID: {error_id}")
except ImportError: except ImportError:
pass pass
# 6. Catch-all
detail = type(exc).__name__ detail = type(exc).__name__
_store_log_state(request, 500, error_id, 'Internal Server Error', detail) _store_log_state(request, 500, error_id, "Internal Server Error", detail)
return _json_response(500, f'An unexpected error occurred. Error ID: {error_id}') return _json_response(500, f"An unexpected error occurred. Error ID: {error_id}")
# ── Single exception handler registered with FastAPI ─────────────────────────
async def handle_exception(request: Request, exc: Exception) -> JSONResponse: async def handle_exception(request: Request, exc: Exception) -> JSONResponse:
"""
Global exception handler.
Sets request.state.log_* so that db_session_middleware emits a single structured log.
"""
error_id = str(uuid.uuid4()) error_id = str(uuid.uuid4())
if not hasattr(request.state, 'request_id'):
if not hasattr(request.state, "request_id"):
request.state.request_id = error_id request.state.request_id = error_id
return _build_error_response(request, exc, error_id) return _build_error_response(request, exc, error_id)
# ── Legacy helper kept for backward compatibility ─────────────────────────────
def handle_sqlalchemy_error(error: SQLAlchemyError): def handle_sqlalchemy_error(error: SQLAlchemyError):
original_error = error.orig if hasattr(error, 'orig') else None """Return (user_message, status_code) for a SQLAlchemy error."""
original_error = error.orig if hasattr(error, "orig") else None
if isinstance(error, IntegrityError): if isinstance(error, IntegrityError):
if 'unique constraint' in str(error).lower(): if "unique constraint" in str(error).lower():
return ('This record already exists.', 409) return "This record already exists.", 409
if 'foreign key constraint' in str(error).lower(): elif "foreign key constraint" in str(error).lower():
return ('Related record not found.', 400) return "Related record not found.", 400
return ('Data integrity error.', 400) else:
if isinstance(error, DataError) or isinstance(original_error, AsyncPGDataError): return "Data integrity error.", 400
return ('Invalid data provided.', 400) elif isinstance(error, DataError) or isinstance(original_error, AsyncPGDataError):
if isinstance(error, DBAPIError): return "Invalid data provided.", 400
if 'unique constraint' in str(error).lower(): elif isinstance(error, DBAPIError):
return ('This record already exists.', 409) if "unique constraint" in str(error).lower():
if 'foreign key constraint' in str(error).lower(): return "This record already exists.", 409
return ('Related record not found.', 400) elif "foreign key constraint" in str(error).lower():
if 'null value in column' in str(error).lower(): return "Related record not found.", 400
return ('Required data missing.', 400) elif "null value in column" in str(error).lower():
if 'invalid input for query argument' in str(error).lower(): return "Required data missing.", 400
return ('Invalid data provided.', 400) elif "invalid input for query argument" in str(error).lower():
return ('Database error.', 500) return "Invalid data provided.", 400
log.error(f'Unexpected database error: {str(error)}') else:
return ('An unexpected database error occurred.', 500) return "Database error.", 500
else:
log.error(f"Unexpected database error: {str(error)}")
return "An unexpected database error occurred.", 500

@ -2,152 +2,323 @@ import logging
import asyncio import asyncio
import time import time
from src.context import set_request_id, reset_request_id, get_request_id from src.context import set_request_id, reset_request_id, get_request_id
from contextvars import ContextVar
from os import path
from typing import Final, Optional
from uuid import uuid1 from uuid import uuid1
from fastapi import Depends, FastAPI, HTTPException
from fastapi import Depends, FastAPI, HTTPException, status
from src.auth.service import JWTBearer from src.auth.service import JWTBearer
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import ValidationError
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from sqlalchemy import inspect
from sqlalchemy.ext.asyncio import async_scoped_session from sqlalchemy.ext.asyncio import async_scoped_session
from starlette.middleware.base import BaseHTTPMiddleware from sqlalchemy.orm import scoped_session
from starlette.middleware.base import (BaseHTTPMiddleware,
RequestResponseEndpoint)
from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.gzip import GZipMiddleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import Response from starlette.responses import FileResponse, Response, StreamingResponse
from starlette.routing import compile_path
from starlette.staticfiles import StaticFiles
import uuid as _uuid_module import uuid as _uuid_module
from src.api import api_router from src.api import api_router
from src.database.core import async_session, async_collector_session from src.database.core import async_session, engine, async_collector_session
from src.enums import ResponseStatus from src.enums import ResponseStatus
from src.exceptions import handle_exception from src.exceptions import handle_exception
from src.app_logging import configure_logging from src.app_logging import configure_logging
from src.middleware import RequestValidationMiddleware, RequestTimeoutMiddleware from src.middleware import RequestValidationMiddleware, RequestTimeoutMiddleware
# from src.rate_limiter import limiter
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.exceptions import HTTPException as StarletteHTTPException
from src.csrf_protect import csrf_protect from src.csrf_protect import csrf_protect
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# we configure the logging level and format
configure_logging() configure_logging()
REQUEST_TIMEOUT_SECONDS = 20 REQUEST_TIMEOUT_SECONDS = 20
app = FastAPI(openapi_url='', title='Optimum OH API', description="Welcome to Optimum OH's API documentation!", version='0.1.0')
# we create the ASGI for the app
app = FastAPI(
openapi_url="",
title="Optimum OH API",
description="Welcome to Optimum OH's API documentation!",
version="0.1.0",
)
# Sync handler for RateLimitExceeded — SlowAPIMiddleware falls back to default
# when the registered handler is async, so this must be a plain sync function.
def _sync_rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse: def _sync_rate_limit_handler(request: Request, exc: RateLimitExceeded) -> JSONResponse:
error_id = str(_uuid_module.uuid4()) error_id = str(_uuid_module.uuid4())
request.state.log_status_code = 429 request.state.log_status_code = 429
request.state.log_error_id = error_id request.state.log_error_id = error_id
request.state.log_error_type = 'Too Many Requests' request.state.log_error_type = "Too Many Requests"
request.state.log_error_message = 'Please try again later' request.state.log_error_message = "Please try again later"
return JSONResponse(status_code=429, content={'data': None, 'message': f'Please try again later. Error ID: {error_id}', 'status': ResponseStatus.ERROR}) return JSONResponse(
status_code=429,
content={"data": None, "message": f"Please try again later. Error ID: {error_id}", "status": ResponseStatus.ERROR},
)
# we define the exception handlers
app.add_exception_handler(Exception, handle_exception) app.add_exception_handler(Exception, handle_exception)
app.add_exception_handler(HTTPException, handle_exception) app.add_exception_handler(HTTPException, handle_exception)
app.add_exception_handler(StarletteHTTPException, handle_exception) app.add_exception_handler(StarletteHTTPException, handle_exception)
app.add_exception_handler(RequestValidationError, handle_exception) app.add_exception_handler(RequestValidationError, handle_exception)
app.add_exception_handler(RateLimitExceeded, _sync_rate_limit_handler) app.add_exception_handler(RateLimitExceeded, _sync_rate_limit_handler)
# app.state.limiter = limiter
app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(GZipMiddleware, minimum_size=1000)
# app.add_middleware(SlowAPIMiddleware)
app.add_middleware(RequestTimeoutMiddleware, timeout=REQUEST_TIMEOUT_SECONDS) app.add_middleware(RequestTimeoutMiddleware, timeout=REQUEST_TIMEOUT_SECONDS)
@app.route('/slow', methods=['GET']) # credentials: "include",
@app.route('/slow', methods=["GET"])
async def slow_endpoint(request: Request): async def slow_endpoint(request: Request):
await asyncio.sleep(REQUEST_TIMEOUT_SECONDS + 10) await asyncio.sleep(REQUEST_TIMEOUT_SECONDS + 10)
return JSONResponse(content={'message': 'This should timeout'}, status_code=200) return JSONResponse(content={"message": "This should timeout"}, status_code=200)
@app.post('/testcsrf', dependencies=[Depends(JWTBearer()), Depends(csrf_protect)]) @app.post('/testcsrf', dependencies=[Depends(JWTBearer()), Depends(csrf_protect)])
async def test_csrf(request: Request): async def test_csrf(request: Request):
return JSONResponse(content={'data': None, 'message': 'CSRF token is valid', 'status': ResponseStatus.SUCCESS}, status_code=200) return JSONResponse(
content={"data": None, "message": "CSRF token is valid", "status": ResponseStatus.SUCCESS},
status_code=200,
)
def security_headers_middleware(app: FastAPI): def security_headers_middleware(app: FastAPI):
is_production = False is_production = False
csp_policy = {'default-src': "'self'", 'script-src': "'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net", 'style-src': "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net", 'img-src': "'self' data: https: blob:", 'font-src': "'self' https://fonts.gstatic.com data:", 'connect-src': "'self' https://api.your-domain.com wss://ws.your-domain.com", 'frame-src': "'none'", 'object-src': "'none'", 'base-uri': "'self'", 'form-action': "'self'"}
feature_policy = {'geolocation': "'none'", 'midi': "'none'", 'notifications': "'none'", 'push': "'none'", 'sync-xhr': "'none'", 'microphone': "'none'", 'camera': "'none'", 'magnetometer': "'none'", 'gyroscope': "'none'", 'speaker': "'none'", 'vibrate': "'none'", 'fullscreen': "'self'", 'payment': "'none'"}
csp_header_value = '; '.join((f'{k} {v}' for k, v in csp_policy.items()))
feature_header_value = '; '.join((f'{k}={v}' for k, v in feature_policy.items()))
class SecurityHeadersMiddleware(BaseHTTPMiddleware): # CSP rules
csp_policy = {
"default-src": "'self'",
"script-src": "'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net",
"img-src": "'self' data: https: blob:",
"font-src": "'self' https://fonts.gstatic.com data:",
"connect-src": "'self' https://api.your-domain.com wss://ws.your-domain.com",
"frame-src": "'none'",
"object-src": "'none'",
"base-uri": "'self'",
"form-action": "'self'",
}
# Feature / Permissions Policy
feature_policy = {
"geolocation": "'none'",
"midi": "'none'",
"notifications": "'none'",
"push": "'none'",
"sync-xhr": "'none'",
"microphone": "'none'",
"camera": "'none'",
"magnetometer": "'none'",
"gyroscope": "'none'",
"speaker": "'none'",
"vibrate": "'none'",
"fullscreen": "'self'",
"payment": "'none'",
}
csp_header_value = "; ".join(f"{k} {v}" for k, v in csp_policy.items())
feature_header_value = "; ".join(f"{k}={v}" for k, v in feature_policy.items())
# Middleware definition
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
response: Response = await call_next(request) response: Response = await call_next(request)
if is_production: if is_production:
response.headers['Strict-Transport-Security'] = 'max-age=15724800; includeSubDomains; preload' response.headers["Strict-Transport-Security"] = "max-age=15724800; includeSubDomains; preload"
response.headers['X-Frame-Options'] = 'DENY' response.headers["X-Frame-Options"] = "DENY"
response.headers['X-Content-Type-Options'] = 'nosniff' response.headers["X-Content-Type-Options"] = "nosniff"
response.headers['X-XSS-Protection'] = '1; mode=block' response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers['Content-Security-Policy'] = csp_header_value response.headers["Content-Security-Policy"] = csp_header_value
response.headers['Permissions-Policy'] = feature_header_value response.headers["Permissions-Policy"] = feature_header_value
else: else:
response.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' *" # Relaxed settings for development
response.headers["Content-Security-Policy"] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' *"
# You can skip some headers here for local testing
return response return response
app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(SecurityHeadersMiddleware)
security_headers_middleware(app) security_headers_middleware(app)
app.add_middleware(RequestValidationMiddleware) app.add_middleware(RequestValidationMiddleware)
# app.add_middleware(
# CSRFProtectMiddleware,
# excluded_paths=["/healthcheck"]
# )
@app.middleware('http')
@app.middleware("http")
async def db_session_middleware(request: Request, call_next): async def db_session_middleware(request: Request, call_next):
request_id = str(uuid1()) request_id = str(uuid1())
# Per-request ID scopes the SQLAlchemy session.
# see: https://github.com/tiangolo/fastapi/issues/726
ctx_token = set_request_id(request_id) ctx_token = set_request_id(request_id)
request.state.request_id = request_id request.state.request_id = request_id
try: try:
session = async_scoped_session(async_session, scopefunc=get_request_id) session = async_scoped_session(async_session, scopefunc=get_request_id)
request.state.db = session() request.state.db = session()
collector_session = async_scoped_session(async_collector_session, scopefunc=get_request_id) collector_session = async_scoped_session(async_collector_session, scopefunc=get_request_id)
request.state.collector_db = collector_session() request.state.collector_db = collector_session()
start_time = time.time() start_time = time.time()
try: try:
response = await call_next(request) response = await call_next(request)
except Exception as exc: except Exception as exc:
# Non-HTTP exceptions (SQLAlchemy, httpx, ValueError, etc.) are NOT caught by
# ExceptionMiddleware because app.add_exception_handler(Exception, ...) registers
# with ServerErrorMiddleware, not ExceptionMiddleware. If we let such exceptions
# escape here, db_session_middleware exits via finally WITHOUT logging, then
# ServerErrorMiddleware calls handle_exception (giving the 500 response) and
# re-raises, causing uvicorn to emit an all-null JSON log entry.
# Catching here ensures: proper error response, structured log, no re-raise.
response = await handle_exception(request, exc) response = await handle_exception(request, exc)
duration_ms = round((time.time() - start_time) * 1000, 2) duration_ms = round((time.time() - start_time) * 1000, 2)
timed_out = request.scope.get('x_timed_out', False)
# ── Determine status_code ─────────────────────────────────────────────
# RequestTimeoutMiddleware flags the scope when a 408 bypasses exception handlers
timed_out = request.scope.get("x_timed_out", False)
if timed_out: if timed_out:
status_code = 408 status_code = 408
error_id = request.scope.get('x_timeout_error_id', None) error_id = request.scope.get("x_timeout_error_id", None)
error_type = 'Request Timeout' error_type = "Request Timeout"
error_message = 'Request timeout over 20 seconds' error_message = "Request timeout over 20 seconds"
else: else:
status_code = getattr(request.state, 'log_status_code', None) or response.status_code status_code = getattr(request.state, "log_status_code", None) or response.status_code
error_id = getattr(request.state, 'log_error_id', None) error_id = getattr(request.state, "log_error_id", None)
error_type = getattr(request.state, 'log_error_type', None) error_type = getattr(request.state, "log_error_type", None)
error_message = getattr(request.state, 'log_error_message', None) error_message = getattr(request.state, "log_error_message", None)
_FALLBACK_ERROR_TYPES = {400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 408: 'Request Timeout', 409: 'Conflict', 422: 'Unprocessable Entity', 429: 'Too Many Requests', 500: 'Internal Server Error'}
_FALLBACK_ERROR_MESSAGES = {400: 'Bad request', 401: 'Invalid authorization token', 403: 'Forbidden Access', 404: 'Resource not found', 408: 'Request timeout', 409: 'This record already exists', 422: 'Input validation failed', 429: 'Please try again later', 500: 'An unexpected error occurred'} # ── Fallback error labels for responses that skip exception handler ───
if status_code >= 400 and (not error_type): _FALLBACK_ERROR_TYPES = {
error_type = _FALLBACK_ERROR_TYPES.get(status_code, f'HTTP {status_code}') 400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
if status_code >= 400 and (not error_message): 404: "Not Found", 408: "Request Timeout", 409: "Conflict",
error_message = _FALLBACK_ERROR_MESSAGES.get(status_code, 'Request failed') 422: "Unprocessable Entity", 429: "Too Many Requests",
500: "Internal Server Error",
}
_FALLBACK_ERROR_MESSAGES = {
400: "Bad request", 401: "Invalid authorization token",
403: "Forbidden Access", 404: "Resource not found",
408: "Request timeout", 409: "This record already exists",
422: "Input validation failed", 429: "Please try again later",
500: "An unexpected error occurred",
}
if status_code >= 400 and not error_type:
error_type = _FALLBACK_ERROR_TYPES.get(status_code, f"HTTP {status_code}")
if status_code >= 400 and not error_message:
error_message = _FALLBACK_ERROR_MESSAGES.get(status_code, "Request failed")
# ── User info ─────────────────────────────────────────────────────────
from src.context import get_user_id, set_user_id from src.context import get_user_id, set_user_id
user_id = get_user_id() user_id = get_user_id()
user_obj = getattr(request.state, 'user', None) user_obj = getattr(request.state, "user", None)
if user_obj and (not user_id): if user_obj and not user_id:
if isinstance(user_obj, dict): if isinstance(user_obj, dict):
u_id = user_obj.get('user_id') u_id = user_obj.get("user_id")
else: else:
u_id = getattr(user_obj, 'user_id', None) u_id = getattr(user_obj, "user_id", None)
if u_id: if u_id:
user_id = str(u_id) user_id = str(u_id)
set_user_id(user_id) set_user_id(user_id)
# ── Client IP ─────────────────────────────────────────────────────────
headers = request.headers headers = request.headers
if 'X-Real-IP' in headers: if "X-Real-IP" in headers:
user_ip = headers['X-Real-IP'] user_ip = headers["X-Real-IP"]
elif 'X-Forwarded-For' in headers: elif "X-Forwarded-For" in headers:
user_ip = headers['X-Forwarded-For'].split(',')[0].strip() user_ip = headers["X-Forwarded-For"].split(",")[0].strip()
else: else:
user_ip = request.client.host if request.client else None user_ip = request.client.host if request.client else None
# ── Full path with query string ───────────────────────────────────────
path = request.url.path path = request.url.path
if request.url.query: if request.url.query:
path = f'{path}?{request.url.query}' path = f"{path}?{request.url.query}"
result = 'Request completed' if status_code < 400 else 'Request failed'
# ── Emit single structured log ────────────────────────────────────────
result = "Request completed" if status_code < 400 else "Request failed"
if status_code >= 500: if status_code >= 500:
log_level = logging.ERROR log_level = logging.ERROR
elif status_code >= 400: elif status_code >= 400:
log_level = logging.WARNING log_level = logging.WARNING
else: else:
log_level = logging.INFO log_level = logging.INFO
log.log(log_level, f'{request.method} {path} {status_code}', extra={'method': request.method, 'path': path, 'status_code': status_code, 'duration_ms': duration_ms, 'request_id': request_id, 'user_id': user_id, 'user_ip': user_ip, 'error_id': error_id, 'result': result, 'error_type': error_type if status_code >= 400 else None, 'error_message': error_message if status_code >= 400 else None})
log.log(
log_level,
f"{request.method} {path} {status_code}",
extra={
"method": request.method,
"path": path,
"status_code": status_code,
"duration_ms": duration_ms,
"request_id": request_id,
"user_id": user_id,
"user_ip": user_ip,
"error_id": error_id,
"result": result,
"error_type": error_type if status_code >= 400 else None,
"error_message": error_message if status_code >= 400 else None,
},
)
finally: finally:
await request.state.db.close() await request.state.db.close()
await request.state.collector_db.close() await request.state.collector_db.close()
reset_request_id(ctx_token) reset_request_id(ctx_token)
return response return response
@app.middleware('http')
@app.middleware("http")
async def add_security_headers(request: Request, call_next): async def add_security_headers(request: Request, call_next):
response = await call_next(request) response = await call_next(request)
response.headers['Strict-Transport-Security'] = 'max-age=31536000 ; includeSubDomains' response.headers["Strict-Transport-Security"] = (
"max-age=31536000 ; includeSubDomains"
)
return response return response
# class MetricsMiddleware(BaseHTTPMiddleware):
# async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
# method = request.method
# endpoint = request.url.path
# tags = {"method": method, "endpoint": endpoint}
# try:
# start = time.perf_counter()
# response = await call_next(request)
# elapsed_time = time.perf_counter() - start
# tags.update({"status_code": response.status_code})
# metric_provider.counter("server.call.counter", tags=tags)
# metric_provider.timer("server.call.elapsed", value=elapsed_time, tags=tags)
# log.debug(f"server.call.elapsed.{endpoint}: {elapsed_time}")
# except Exception as e:
# metric_provider.counter("server.call.exception.counter", tags=tags)
# raise e from None
# return response
# app.add_middleware(ExceptionMiddleware)
app.include_router(api_router) app.include_router(api_router)

@ -1,8 +1,10 @@
from sqlalchemy import Column, BigInteger, Integer, Float, String, Text, DateTime from sqlalchemy import Column, BigInteger, Integer, Float, String, Text, DateTime
from src.database.core import CollectorBase from src.database.core import CollectorBase
class WorkOrderData(CollectorBase): class WorkOrderData(CollectorBase):
__tablename__ = 'wo_staging_3' __tablename__ = "wo_staging_3"
id = Column(BigInteger, primary_key=True, autoincrement=True) id = Column(BigInteger, primary_key=True, autoincrement=True)
assetnum = Column(Text, nullable=True) assetnum = Column(Text, nullable=True)
description1 = Column(Text, nullable=True) description1 = Column(Text, nullable=True)

@ -1,37 +1,332 @@
from datetime import datetime from datetime import datetime
from typing import Optional, Union from typing import Optional, Union
from sqlalchemy import select, text from sqlalchemy import select, func, cast, Numeric, text
from sqlalchemy.orm import Session
from sqlalchemy import and_
from sqlalchemy.sql import not_
from src.maximo.model import WorkOrderData # Assuming this is where your model is
from src.database.core import CollectorDbSession, DbSession from src.database.core import CollectorDbSession, DbSession
from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.model import OverhaulScope
async def get_cm_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime): async def get_cm_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime):
query = text("WITH part_costs AS (\n SELECT \n mu.wonum,\n SUM(mu.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost\n FROM maximo_workorder_materials mu\n LEFT JOIN maximo_inventory inv\n ON mu.itemnum = inv.itemnum\n LEFT JOIN (\n SELECT item_num, AVG(unit_cost) AS unit_cost\n FROM maximo_sparepart_pr_po_line\n GROUP BY item_num\n ) po\n ON mu.itemnum = po.item_num\n GROUP BY mu.wonum\n),\nwo_costs AS (\n SELECT \n w.wonum,\n w.asset_location,\n (COALESCE(w.mat_cost_max, 0) + COALESCE(pc.parts_total_cost, 0)) AS total_wo_cost\n FROM wo_staging_maximo_2 w\n LEFT JOIN part_costs pc\n ON w.wonum = pc.wonum\n WHERE \n w.worktype IN ('CM', 'EM', 'PROACTIVE')\n AND w.asset_system IN (\n 'HPB','AH','APC','SCR','CL','DM','CRH','ASH','BAD','DS','WTP',\n 'MT','SUP','DCS','FF','EG','AI','SPS','EVM','SCW','KLH','CH',\n 'TUR','LOT','HRH','ESP','CAE','GMC','BFT','LSH','CHB','BSS',\n 'LOS','LPB','SAC','CP','EHS','RO','GG','MS','CW','SO','ATT',\n 'AFG','EHB','RP','FO','PC','APE','AF','DMW','BRS','GEN','ABS',\n 'CHA','TR','H2','BDW','LOM','ACR','AL','FW','COND','CCCW','IA',\n 'GSS','BOL','SSB','CO','OA','CTH-UPD','AS','DP'\n )\n AND w.reportdate IS NOT NULL\n AND w.actstart IS NOT NULL\n AND w.actfinish IS NOT NULL\n AND w.asset_unit IN ('3','00')\n AND w.reportdate >= '2015-01-01'\n AND w.wonum NOT LIKE 'T%'\n),\n-- find max cost per location\nlocation_max AS (\n SELECT asset_location, MAX(total_wo_cost) AS max_cost\n FROM wo_costs\n WHERE total_wo_cost > 0\n GROUP BY asset_location\n),\n-- filter WO costs to only reasonable range (e.g. >0 and >=10% of max)\nfiltered_wo AS (\n SELECT w.*\n FROM wo_costs w\n JOIN location_max lm ON w.asset_location = lm.asset_location\n WHERE w.total_wo_cost > 0\n)\nSELECT \n asset_location,\n SUM(total_wo_cost)::numeric / COUNT(wonum) AS avg_cost\nFROM filtered_wo\nGROUP BY asset_location\nORDER BY avg_cost DESC;\n ") query = text("""WITH part_costs AS (
SELECT
mu.wonum,
SUM(mu.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost
FROM maximo_workorder_materials mu
LEFT JOIN maximo_inventory inv
ON mu.itemnum = inv.itemnum
LEFT JOIN (
SELECT item_num, AVG(unit_cost) AS unit_cost
FROM maximo_sparepart_pr_po_line
GROUP BY item_num
) po
ON mu.itemnum = po.item_num
GROUP BY mu.wonum
),
wo_costs AS (
SELECT
w.wonum,
w.asset_location,
(COALESCE(w.mat_cost_max, 0) + COALESCE(pc.parts_total_cost, 0)) AS total_wo_cost
FROM wo_staging_maximo_2 w
LEFT JOIN part_costs pc
ON w.wonum = pc.wonum
WHERE
w.worktype IN ('CM', 'EM', 'PROACTIVE')
AND w.asset_system IN (
'HPB','AH','APC','SCR','CL','DM','CRH','ASH','BAD','DS','WTP',
'MT','SUP','DCS','FF','EG','AI','SPS','EVM','SCW','KLH','CH',
'TUR','LOT','HRH','ESP','CAE','GMC','BFT','LSH','CHB','BSS',
'LOS','LPB','SAC','CP','EHS','RO','GG','MS','CW','SO','ATT',
'AFG','EHB','RP','FO','PC','APE','AF','DMW','BRS','GEN','ABS',
'CHA','TR','H2','BDW','LOM','ACR','AL','FW','COND','CCCW','IA',
'GSS','BOL','SSB','CO','OA','CTH-UPD','AS','DP'
)
AND w.reportdate IS NOT NULL
AND w.actstart IS NOT NULL
AND w.actfinish IS NOT NULL
AND w.asset_unit IN ('3','00')
AND w.reportdate >= '2015-01-01'
AND w.wonum NOT LIKE 'T%'
),
-- find max cost per location
location_max AS (
SELECT asset_location, MAX(total_wo_cost) AS max_cost
FROM wo_costs
WHERE total_wo_cost > 0
GROUP BY asset_location
),
-- filter WO costs to only reasonable range (e.g. >0 and >=10% of max)
filtered_wo AS (
SELECT w.*
FROM wo_costs w
JOIN location_max lm ON w.asset_location = lm.asset_location
WHERE w.total_wo_cost > 0
)
SELECT
asset_location,
SUM(total_wo_cost)::numeric / COUNT(wonum) AS avg_cost
FROM filtered_wo
GROUP BY asset_location
ORDER BY avg_cost DESC;
""")
results = await collector_db.execute(query) results = await collector_db.execute(query)
data = [] data = []
for row in results: for row in results:
data.append({'location_tag': row.asset_location, 'avg_cost': row.avg_cost}) data.append({
return {item['location_tag']: item['avg_cost'] for item in data} "location_tag": row.asset_location,
"avg_cost": row.avg_cost
})
return {
item["location_tag"]: item["avg_cost"] for item in data
}
# async def get_oh_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime):
# query = text("""
# WITH target_wo AS (
# -- Get work orders under a specific parent(s)
# SELECT
# wonum,
# xx_parent,
# assetnum,
# location_tag AS asset_location,
# actmatcost,
# actservcost,
# reportdate
# FROM public.wo_maxim
# WHERE xx_parent = ANY(:parent_nums)
# ),
# part_costs AS (
# -- Calculate parts cost per WO if actmatcost = 0
# SELECT
# wm.wonum,
# SUM(
# wm.itemqty *
# COALESCE(wm.inv_avgcost, po.unit_cost, 0)
# ) AS parts_total_cost
# FROM public.wo_maxim_material wm
# LEFT JOIN (
# SELECT item_num, AVG(unit_cost) AS unit_cost
# FROM public.maximo_sparepart_pr_po_line
# GROUP BY item_num
# ) po ON wm.itemnum = po.item_num
# WHERE wm.itemnum IS NOT NULL
# GROUP BY wm.wonum
# ),
# wo_costs AS (
# SELECT
# w.wonum,
# w.asset_location,
# CASE
# WHEN COALESCE(w.actmatcost, 0) > 0 THEN COALESCE(w.actmatcost, 0)
# ELSE COALESCE(pc.parts_total_cost, 0)
# END AS material_cost,
# COALESCE(w.actservcost, 0) AS service_cost
# FROM target_wo w
# LEFT JOIN part_costs pc ON w.wonum = pc.wonum
# )
# SELECT
# asset_location,
# ROUND(SUM(material_cost + service_cost)::numeric / COUNT(wonum), 2) AS avg_cost,
# COUNT(wonum) AS total_wo_count
# FROM wo_costs
# GROUP BY asset_location
# ORDER BY total_wo_count DESC;
# """)
# parent_nums = []
# result = await collector_db.execute(query, {"parent_nums": parent_nums})
# data = []
# for row in result:
# data.append({
# "location_tag": row.asset_location,
# "avg_cost": float(row.avg_cost or 0.0),
# "total_wo_count": row.total_wo_count,
# })
# return {item["location_tag"]: item["avg_cost"] for item in data}
async def get_oh_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime): async def get_oh_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime):
query = text("\n WITH part_costs AS (\n SELECT \n wm.wonum,\n SUM(wm.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost\n FROM public.maximo_workorder_materials wm\n JOIN public.maximo_inventory AS inv on inv.itemnum = wm.itemnum\n LEFT JOIN (\n SELECT item_num, AVG(unit_cost) AS unit_cost\n FROM public.maximo_sparepart_pr_po_line\n GROUP BY item_num\n ) po ON wm.itemnum = po.item_num\n WHERE wm.itemnum IS NOT NULL\n GROUP BY wm.wonum\n ),\n wo_costs AS (\n SELECT \n w.wonum,\n w.asset_location,\n -- Use mat_cost_max if parts_total_cost = 0\n CASE \n WHEN COALESCE(pc.parts_total_cost, 0) = 0 THEN COALESCE(w.mat_cost_max , 0)\n ELSE COALESCE(pc.parts_total_cost, 0)\n END AS total_wo_cost\n FROM wo_staging_maximo_2 w\n LEFT JOIN part_costs pc\n ON w.wonum = pc.wonum\n WHERE \n w.worktype = 'OH'\n AND w.reportdate IS NOT NULL\n AND w.actstart IS NOT NULL\n AND w.actfinish IS NOT NULL\n AND w.asset_unit IN ('3', '00')\n AND w.wonum NOT LIKE 'T%'\n )\n SELECT \n asset_location,\n AVG(total_wo_cost) AS avg_cost\n FROM wo_costs\n GROUP BY asset_location\n ORDER BY COUNT(wonum) DESC;\n ") # query = text("""
# WITH part_costs AS (
# SELECT
# wm.wonum,
# SUM(wm.itemqty * COALESCE(wm.inv_avgcost, po.unit_cost, 0)) AS parts_total_cost
# FROM public.wo_maxim_material wm
# LEFT JOIN (
# SELECT item_num, AVG(unit_cost) AS unit_cost
# FROM public.maximo_sparepart_pr_po_line
# GROUP BY item_num
# ) po ON wm.itemnum = po.item_num
# WHERE wm.itemnum IS NOT NULL
# GROUP BY wm.wonum
# ),
# wo_costs AS (
# SELECT
# w.wonum,
# w.asset_location,
# -- Use mat_cost_max if parts_total_cost = 0
# CASE
# WHEN COALESCE(pc.parts_total_cost, 0) = 0 THEN COALESCE(w.mat_cost_max , 0)
# ELSE COALESCE(pc.parts_total_cost, 0)
# END AS total_wo_cost
# FROM wo_staging_maximo_2 w
# LEFT JOIN part_costs pc
# ON w.wonum = pc.wonum
# WHERE
# w.worktype = 'OH'
# AND w.reportdate IS NOT NULL
# AND w.actstart IS NOT NULL
# AND w.actfinish IS NOT NULL
# AND w.asset_unit IN ('3', '00')
# AND w.wonum NOT LIKE 'T%'
# )
# SELECT
# asset_location,
# AVG(total_wo_cost) AS avg_cost
# FROM wo_costs
# GROUP BY asset_location
# ORDER BY COUNT(wonum) DESC;
# """)
query = text("""
WITH part_costs AS (
SELECT
wm.wonum,
SUM(wm.itemqty * COALESCE(inv.avgcost, po.unit_cost, 0)) AS parts_total_cost
FROM public.maximo_workorder_materials wm
JOIN public.maximo_inventory AS inv on inv.itemnum = wm.itemnum
LEFT JOIN (
SELECT item_num, AVG(unit_cost) AS unit_cost
FROM public.maximo_sparepart_pr_po_line
GROUP BY item_num
) po ON wm.itemnum = po.item_num
WHERE wm.itemnum IS NOT NULL
GROUP BY wm.wonum
),
wo_costs AS (
SELECT
w.wonum,
w.asset_location,
-- Use mat_cost_max if parts_total_cost = 0
CASE
WHEN COALESCE(pc.parts_total_cost, 0) = 0 THEN COALESCE(w.mat_cost_max , 0)
ELSE COALESCE(pc.parts_total_cost, 0)
END AS total_wo_cost
FROM wo_staging_maximo_2 w
LEFT JOIN part_costs pc
ON w.wonum = pc.wonum
WHERE
w.worktype = 'OH'
AND w.reportdate IS NOT NULL
AND w.actstart IS NOT NULL
AND w.actfinish IS NOT NULL
AND w.asset_unit IN ('3', '00')
AND w.wonum NOT LIKE 'T%'
)
SELECT
asset_location,
AVG(total_wo_cost) AS avg_cost
FROM wo_costs
GROUP BY asset_location
ORDER BY COUNT(wonum) DESC;
""")
result = await collector_db.execute(query) result = await collector_db.execute(query)
data = [] data = []
for row in result: for row in result:
data.append({'location_tag': row.asset_location, 'avg_cost': row.avg_cost}) data.append({
return {item['location_tag']: item['avg_cost'] for item in data} "location_tag": row.asset_location,
"avg_cost": row.avg_cost
})
return {
item["location_tag"]: item["avg_cost"] for item in data
}
from uuid import UUID from uuid import UUID
async def get_history_oh_wo(*, db_session: DbSession, collector_db_session: CollectorDbSession, oh_session_id: UUID, parent_wo_num: Optional[Union[str, list]] = None): async def get_history_oh_wo(*, db_session: DbSession, collector_db_session: CollectorDbSession, oh_session_id: UUID, parent_wo_num: Optional[Union[str, list]] = None):
## Get Parent wo num from oh session table
if not parent_wo_num: if not parent_wo_num:
query = select(OverhaulScope.wo_parent).where(OverhaulScope.id == oh_session_id) query = select(OverhaulScope.wo_parent).where(OverhaulScope.id == oh_session_id)
result = await db_session.execute(query) result = await db_session.execute(query)
parent_wo_num = result.scalar() parent_wo_num = result.scalar()
if not parent_wo_num: if not parent_wo_num:
return [] return []
# Ensure parent_wo_num is a list and removed duplicates if any
if isinstance(parent_wo_num, str): if isinstance(parent_wo_num, str):
parent_wo_num = [parent_wo_num] parent_wo_num = [parent_wo_num]
else: else:
parent_wo_num = list(set(parent_wo_num)) parent_wo_num = list(set(parent_wo_num))
sql_query = text("\n WITH target_wos AS (\n SELECT \n w.wonum,\n w.assetnum,\n COALESCE(w.actmatcost, 0) as actmatcost,\n COALESCE(w.actservcost, 0) as actservcost\n FROM public.wo_maximo w\n WHERE w.xx_parent = ANY(:parent_wo_num)\n ),\n wo_tasks AS (\n SELECT \n t.xx_parent AS parent_wonum,\n JSON_AGG(t.description) AS task_list\n FROM public.wo_maximo t\n JOIN target_wos tw ON t.xx_parent = tw.wonum\n GROUP BY t.xx_parent\n )\n SELECT \n w.assetnum,\n e.name AS equipment_name,\n e.location_tag,\n JSON_OBJECT_AGG(w.wonum, COALESCE(wt.task_list, '[]'::json)) AS wonum_list,\n COUNT(w.wonum) AS total_wo_count,\n COALESCE(SUM(w.actmatcost), 0) AS total_material_cost,\n COALESCE(SUM(w.actservcost), 0) AS total_service_cost,\n COALESCE(SUM(w.actmatcost + w.actservcost), 0) AS total_actual_cost\n FROM target_wos w\n INNER JOIN public.ms_equipment_master e \n ON w.assetnum = e.assetnum\n LEFT JOIN wo_tasks wt \n ON w.wonum = wt.parent_wonum\n GROUP BY \n w.assetnum, \n e.name, \n e.location_tag\n ORDER BY total_actual_cost DESC;\n ")
results = await collector_db_session.execute(sql_query, {'parent_wo_num': parent_wo_num}) sql_query = text("""
return [{'assetnum': row.assetnum, 'equipment_name': row.equipment_name, 'location_tag': row.location_tag, 'wonum_list': row.wonum_list, 'total_wo_count': row.total_wo_count, 'total_material_cost': float(row.total_material_cost), 'total_service_cost': float(row.total_service_cost), 'total_actual_cost': float(row.total_actual_cost)} for row in results] WITH target_wos AS (
SELECT
w.wonum,
w.assetnum,
COALESCE(w.actmatcost, 0) as actmatcost,
COALESCE(w.actservcost, 0) as actservcost
FROM public.wo_maximo w
WHERE w.xx_parent = ANY(:parent_wo_num)
),
wo_tasks AS (
SELECT
t.xx_parent AS parent_wonum,
JSON_AGG(t.description) AS task_list
FROM public.wo_maximo t
JOIN target_wos tw ON t.xx_parent = tw.wonum
GROUP BY t.xx_parent
)
SELECT
w.assetnum,
e.name AS equipment_name,
e.location_tag,
JSON_OBJECT_AGG(w.wonum, COALESCE(wt.task_list, '[]'::json)) AS wonum_list,
COUNT(w.wonum) AS total_wo_count,
COALESCE(SUM(w.actmatcost), 0) AS total_material_cost,
COALESCE(SUM(w.actservcost), 0) AS total_service_cost,
COALESCE(SUM(w.actmatcost + w.actservcost), 0) AS total_actual_cost
FROM target_wos w
INNER JOIN public.ms_equipment_master e
ON w.assetnum = e.assetnum
LEFT JOIN wo_tasks wt
ON w.wonum = wt.parent_wonum
GROUP BY
w.assetnum,
e.name,
e.location_tag
ORDER BY total_actual_cost DESC;
""")
results = await collector_db_session.execute(sql_query, {"parent_wo_num": parent_wo_num})
return [
{
"assetnum": row.assetnum,
"equipment_name": row.equipment_name,
"location_tag": row.location_tag,
"wonum_list": row.wonum_list,
"total_wo_count": row.total_wo_count,
"total_material_cost": float(row.total_material_cost),
"total_service_cost": float(row.total_service_cost),
"total_actual_cost": float(row.total_actual_cost)
}
for row in results
]

@ -1 +1,46 @@
# import logging
# from dispatch.plugins.base import plugins
# from .config import METRIC_PROVIDERS
# log = logging.getLogger(__file__)
# class Metrics(object):
# _providers = []
# def __init__(self):
# if not METRIC_PROVIDERS:
# log.info(
# "No metric providers defined via METRIC_PROVIDERS env var. Metrics will not be sent."
# )
# else:
# self._providers = METRIC_PROVIDERS
# def gauge(self, name, value, tags=None):
# for provider in self._providers:
# log.debug(
# f"Sending gauge metric {name} to provider {provider}. Value: {value} Tags: {tags}"
# )
# p = plugins.get(provider)
# p.gauge(name, value, tags=tags)
# def counter(self, name, value=None, tags=None):
# for provider in self._providers:
# log.debug(
# f"Sending counter metric {name} to provider {provider}. Value: {value} Tags: {tags}"
# )
# p = plugins.get(provider)
# p.counter(name, value=value, tags=tags)
# def timer(self, name, value, tags=None):
# for provider in self._providers:
# log.debug(
# f"Sending timer metric {name} to provider {provider}. Value: {value} Tags: {tags}"
# )
# p = plugins.get(provider)
# p.timer(name, value, tags=tags)
# provider = Metrics()

@ -4,55 +4,261 @@ import logging
from collections import Counter from collections import Counter
from fastapi import Request, HTTPException from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
ALLOWED_MULTI_PARAMS = {'sortBy[]', 'descending[]', 'exclude[]', 'assetnums', 'plant_ids', 'job_ids'}
ALLOWED_DATA_PARAMS = {'actual_shutdown', 'all_params', 'analysis_metadata', 'asset_contributions', 'assetnum', 'assetnums', 'assigned_date', 'availability', 'availableScopes', 'avg_cost', 'birbaum', 'calculation_type', 'capacity_weight', 'code', 'contribution', 'corrective_cost', 'corrective_costs', 'cost', 'costPerFailure', 'cost_savings_vs_planned', 'cost_threshold', 'cost_trend', 'created_at', 'crew_number', 'criticalParts', 'critical_procurement_items', 'current_eaf', 'current_plant_eaf', 'current_stock', 'current_user', 'cut_hours', 'daily_failures', 'data', 'datetime', 'day', 'days', 'descending', 'description', 'down_time', 'duration', 'duration_oh', 'eaf_gap', 'eaf_improvement_text', 'eaf_input', 'efficiency', 'end_date', 'equipment_name', 'equipment_results', 'equipment_with_sparepart_constraints', 'exclude', 'excluded_equipment', 'expected_delivery_date', 'filter_spec', 'finish', 'fleet_statistics', 'id', 'improvement_impact', 'included_equipment', 'included_in_optimization', 'intervalDays', 'is_included', 'itemnum', 'items', 'itemsPerPage', 'items_per_page', 'job', 'job_ids', 'last_overhaul_date', 'lead_time', 'location', 'location_tag', 'location_tags', 'maintenance_type', 'master_equipment', 'material_cost', 'max_interval', 'max_interval_months', 'message', 'month', 'months_from_planned', 'name', 'next_planned_overhaul', 'node', 'num_failures', 'num_of_failures', 'ohSessionId', 'oh_scope', 'oh_session_id', 'oh_type', 'oh_types', 'optimal_analysis', 'optimal_breakdown', 'optimal_month', 'optimal_total_cost', 'optimization_success', 'optimum_analysis', 'optimum_day', 'optimum_oh', 'optimum_oh_day', 'optimum_oh_month', 'order_date', 'overhaulCost', 'overhaul_activity', 'overhaul_cost', 'overhaul_costs', 'overhaul_reference_type', 'overhaul_scope', 'overhaul_scope_id', 'overview', 'page', 'parent', 'parent_id', 'plan_duration', 'planned_month', 'planned_outage', 'plant_level_benefit', 'po_pr_id', 'po_vendor_delivery_date', 'possible_plant_eaf', 'priority_score', 'procurement_cost', 'procurement_costs', 'procurement_details', 'projected_eaf_improvement', 'quantity', 'quantity_required', 'query_str', 'recommendedScope', 'recommended_reduced_outage', 'reference', 'reference_id', 'remark', 'removal_date', 'required_improvement', 'results', 'schedules', 'scope', 'scope_calculation_id', 'scope_equipment_job', 'scope_name', 'scope_overhaul', 'service_cost', 'session', 'simulation', 'simulation_id', 'sort_by', 'sortBy[]', 'descending[]', 'exclude[]', 'sparepart_id', 'sparepart_impact', 'sparepart_name', 'sparepart_summary', 'spreadsheet_link', 'start', 'start_date', 'status', 'subsystem', 'system', 'systemComponents', 'target_plant_eaf', 'tasks', 'timing_recommendation', 'total', 'totalPages', 'total_cost', 'total_equipment', 'total_equipment_analyzed', 'total_procurement_items', 'type', 'unit_cost', 'warning_message', 'with_results', 'workscope', 'workscope_group', 'year', '_', 't', 'timestamp', 'q', 'filter', 'currentUser', 'risk_cost', 'all', 'with_results', 'eaf_threshold', 'simulation_id', 'scope_calculation_id', 'calculation_id'} # =========================
ALLOWED_HEADERS = {'host', 'user-agent', 'accept', 'accept-language', 'accept-encoding', 'connection', 'upgrade-insecure-requests', 'if-modified-since', 'if-none-match', 'cache-control', 'authorization', 'content-type', 'content-length', 'origin', 'referer', 'sec-fetch-dest', 'sec-fetch-mode', 'sec-fetch-site', 'sec-fetch-user', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'pragma', 'dnt', 'priority', 'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-forwarded-port', 'x-real-ip', 'x-request-id', 'x-correlation-id', 'x-requested-with', 'x-csrf-token', 'x-xsrf-token', 'postman-token', 'x-forwarded-path', 'x-forwarded-prefix', 'cookie', 'x-kong-request-id'} # Configuration
# =========================
ALLOWED_MULTI_PARAMS = {
"sortBy[]",
"descending[]",
"exclude[]",
"assetnums",
"plant_ids",
"job_ids",
}
ALLOWED_DATA_PARAMS = {
"actual_shutdown", "all_params", "analysis_metadata", "asset_contributions",
"assetnum", "assetnums", "assigned_date", "availability", "availableScopes",
"avg_cost", "birbaum", "calculation_type", "capacity_weight", "code",
"contribution", "corrective_cost", "corrective_costs", "cost", "costPerFailure",
"cost_savings_vs_planned", "cost_threshold", "cost_trend", "created_at",
"crew_number", "criticalParts", "critical_procurement_items", "current_eaf",
"current_plant_eaf", "current_stock", "current_user", "cut_hours",
"daily_failures", "data", "datetime", "day", "days", "descending",
"description", "down_time", "duration", "duration_oh", "eaf_gap",
"eaf_improvement_text", "eaf_input", "efficiency", "end_date",
"equipment_name", "equipment_results", "equipment_with_sparepart_constraints",
"exclude", "excluded_equipment", "expected_delivery_date", "filter_spec",
"finish", "fleet_statistics", "id", "improvement_impact", "included_equipment",
"included_in_optimization", "intervalDays", "is_included", "itemnum",
"items", "itemsPerPage", "items_per_page", "job", "job_ids",
"last_overhaul_date", "lead_time", "location", "location_tag", "location_tags",
"maintenance_type", "master_equipment", "material_cost", "max_interval",
"max_interval_months", "message", "month", "months_from_planned", "name",
"next_planned_overhaul", "node", "num_failures", "num_of_failures",
"ohSessionId", "oh_scope", "oh_session_id", "oh_type", "oh_types",
"optimal_analysis", "optimal_breakdown", "optimal_month", "optimal_total_cost",
"optimization_success", "optimum_analysis", "optimum_day", "optimum_oh",
"optimum_oh_day", "optimum_oh_month", "order_date", "overhaulCost",
"overhaul_activity", "overhaul_cost", "overhaul_costs",
"overhaul_reference_type", "overhaul_scope", "overhaul_scope_id", "overview",
"page", "parent", "parent_id", "plan_duration", "planned_month",
"planned_outage", "plant_level_benefit", "po_pr_id", "po_vendor_delivery_date",
"possible_plant_eaf", "priority_score", "procurement_cost", "procurement_costs",
"procurement_details", "projected_eaf_improvement", "quantity",
"quantity_required", "query_str", "recommendedScope", "recommended_reduced_outage",
"reference", "reference_id", "remark", "removal_date", "required_improvement",
"results", "schedules", "scope", "scope_calculation_id", "scope_equipment_job",
"scope_name", "scope_overhaul", "service_cost", "session", "simulation",
"simulation_id", "sort_by", "sortBy[]", "descending[]", "exclude[]",
"sparepart_id", "sparepart_impact", "sparepart_name", "sparepart_summary",
"spreadsheet_link", "start", "start_date", "status", "subsystem", "system",
"systemComponents", "target_plant_eaf", "tasks", "timing_recommendation",
"total", "totalPages", "total_cost", "total_equipment", "total_equipment_analyzed",
"total_procurement_items", "type", "unit_cost", "warning_message", "with_results",
"workscope", "workscope_group", "year", "_", "t", "timestamp",
"q", "filter", "currentUser", "risk_cost", "all", "with_results",
"eaf_threshold", "simulation_id", "scope_calculation_id", "calculation_id"
}
ALLOWED_HEADERS = {
"host",
"user-agent",
"accept",
"accept-language",
"accept-encoding",
"connection",
"upgrade-insecure-requests",
"if-modified-since",
"if-none-match",
"cache-control",
"authorization",
"content-type",
"content-length",
"origin",
"referer",
"sec-fetch-dest",
"sec-fetch-mode",
"sec-fetch-site",
"sec-fetch-user",
"sec-ch-ua",
"sec-ch-ua-mobile",
"sec-ch-ua-platform",
"pragma",
"dnt",
"priority",
"x-forwarded-for",
"x-forwarded-proto",
"x-forwarded-host",
"x-forwarded-port",
"x-real-ip",
"x-request-id",
"x-correlation-id",
"x-requested-with",
"x-csrf-token",
"x-xsrf-token",
"postman-token",
"x-forwarded-path",
"x-forwarded-prefix",
"cookie",
"x-kong-request-id"
}
MAX_QUERY_PARAMS = 50 MAX_QUERY_PARAMS = 50
MAX_QUERY_LENGTH = 2000 MAX_QUERY_LENGTH = 2000
MAX_JSON_BODY_SIZE = 1024 * 500 MAX_JSON_BODY_SIZE = 1024 * 500 # 500 KB
XSS_PATTERN = re.compile('(<(script|iframe|embed|object|svg|img|video|audio|base|link|meta|form|button|details|animate)\\b|javascript\\s*:|vbscript\\s*:|data\\s*:[^,]*base64[^,]*|data\\s*:text/html|\\bon[a-z]+\\s*=|style\\s*=.*expression\\s*\\(|\\b(eval|setTimeout|setInterval|Function)\\s*\\()', re.IGNORECASE)
SQLI_PATTERN = re.compile('(\\b(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|EXEC(UTE)?|DECLARE)\\b\\s+[\\w\\*\\(\\\']|\\b(WAITFOR\\b\\s+DELAY|PG_SLEEP|SLEEP\\s*\\()|\\b(INFORMATION_SCHEMA|SYS\\.|SYSOBJECTS|XP_CMDSHELL|LOAD_FILE|INTO\\s+OUTFILE)\\b|\\b(OR|AND)\\b\\s+[\'\\"]?\\d+[\'\\"]?\\s*=\\s*[\'\\"]?\\d+|(?<!\\S)--|(?<!\\*)/\\*|(?<!\\*)\\*/(?!\\*)|(?<!\\S)#|;\\s*\\b(SELECT|DROP|DELETE|UPDATE|INSERT)\\b)', re.IGNORECASE) XSS_PATTERN = re.compile(
RCE_PATTERN = re.compile('(\\$\\(.*\\)|`.*`|[;&|]\\s*(cat|ls|id|whoami|pwd|ifconfig|ip|netstat|nc|netcat|nmap|curl|wget|python|php|perl|ruby|bash|sh|cmd|powershell|pwsh|sc\\s+|tasklist|taskkill|base64|sudo|crontab|ssh|ftp|tftp)|\\b(/etc/passwd|/etc/shadow|/etc/group|/etc/issue|/proc/self/|/windows/system32/|C:\\\\Windows\\\\)\\b)', re.IGNORECASE) r"("
TRAVERSAL_PATTERN = re.compile('(\\.\\.[/\\\\]|%2e%2e%2f|%2e%2e/|\\.\\.%2f|%2e%2e%5c|%252e%252e%252f|\\\\00)', re.IGNORECASE) r"<(script|iframe|embed|object|svg|img|video|audio|base|link|meta|form|button|details|animate)\b|"
FORBIDDEN_JSON_KEYS = {'__proto__', 'constructor', 'prototype'} r"javascript\s*:|vbscript\s*:|data\s*:[^,]*base64[^,]*|data\s*:text/html|"
DYNAMIC_KEYS = {'data', 'results', 'analysis_metadata', 'asset_contributions', 'equipment_results', 'optimal_analysis', 'optimum_analysis', 'schedules', 'tasks', 'all_params', 'parameters', 'program_data'} r"\bon[a-z]+\s*=|" # Catch-all for any 'on' event (onerror, onclick, etc.)
log = logging.getLogger('security_logger') r"style\s*=.*expression\s*\(|" # Old IE specific
r"\b(eval|setTimeout|setInterval|Function)\s*\("
r")",
re.IGNORECASE,
)
SQLI_PATTERN = re.compile(
r"("
# 1. Keywords followed by whitespace and common SQL characters
r"\b(UNION|SELECT|INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|TRUNCATE|EXEC(UTE)?|DECLARE)\b\s+[\w\*\(\']|"
# 2. Time-based attacks (more specific than just 'SLEEP')
r"\b(WAITFOR\b\s+DELAY|PG_SLEEP|SLEEP\s*\()|"
# 3. System tables/functions
r"\b(INFORMATION_SCHEMA|SYS\.|SYSOBJECTS|XP_CMDSHELL|LOAD_FILE|INTO\s+OUTFILE)\b|"
# 4. Logical Tautologies (OR 1=1) - Optimized for boundaries
r"\b(OR|AND)\b\s+['\"]?\d+['\"]?\s*=\s*['\"]?\d+|"
# 5. Comments
# Match '--' if at start or preceded by whitespace
r"(?<!\S)--|"
# Match block comments, ensuring they aren't part of mime patterns like */*
r"(?<!\*)/\*|(?<!\*)\*/(?!\*)|"
# Match '#' if at start or preceded by whitespace
r"(?<!\S)#|"
# 6. Hex / Stacked Queries
r";\s*\b(SELECT|DROP|DELETE|UPDATE|INSERT)\b"
r")",
re.IGNORECASE
)
RCE_PATTERN = re.compile(
r"("
r"\$\(.*\)|`.*`|" # Command substitution $(...) or `...`
r"[;&|]\s*(cat|ls|id|whoami|pwd|ifconfig|ip|netstat|nc|netcat|nmap|curl|wget|python|php|perl|ruby|bash|sh|cmd|powershell|pwsh|sc\s+|tasklist|taskkill|base64|sudo|crontab|ssh|ftp|tftp)|"
# Only flag naked commands if they are clearly standalone or system paths
r"\b(/etc/passwd|/etc/shadow|/etc/group|/etc/issue|/proc/self/|/windows/system32/|C:\\Windows\\)\b"
r")",
re.IGNORECASE,
)
TRAVERSAL_PATTERN = re.compile(
r"(\.\.[/\\]|%2e%2e%2f|%2e%2e/|\.\.%2f|%2e%2e%5c|%252e%252e%252f|\\00)",
re.IGNORECASE,
)
FORBIDDEN_JSON_KEYS = {"__proto__", "constructor", "prototype"}
DYNAMIC_KEYS = {
"data",
"results",
"analysis_metadata",
"asset_contributions",
"equipment_results",
"optimal_analysis",
"optimum_analysis",
"schedules",
"tasks",
"all_params",
"parameters",
"program_data"
}
log = logging.getLogger("security_logger")
def has_control_chars(value: str) -> bool: def has_control_chars(value: str) -> bool:
return any((ord(c) < 32 and c not in ('\n', '\r', '\t') for c in value)) return any(ord(c) < 32 and c not in ("\n", "\r", "\t") for c in value)
def inspect_value(value: str, source: str): def inspect_value(value: str, source: str):
if not isinstance(value, str) or value == '*/*': if not isinstance(value, str) or value == "*/*":
return return
if XSS_PATTERN.search(value): if XSS_PATTERN.search(value):
raise HTTPException(status_code=422, detail=f'XSS payload detected in {source}') raise HTTPException(
status_code=422,
detail=f"XSS payload detected in {source}",
)
if SQLI_PATTERN.search(value): if SQLI_PATTERN.search(value):
raise HTTPException(status_code=422, detail=f'SQL injection payload detected in {source}') raise HTTPException(
status_code=422,
detail=f"SQL injection payload detected in {source}",
)
if RCE_PATTERN.search(value): if RCE_PATTERN.search(value):
raise HTTPException(status_code=422, detail=f'Remote code execution payload detected in {source}') raise HTTPException(
status_code=422,
detail=f"Remote code execution payload detected in {source}",
)
if TRAVERSAL_PATTERN.search(value): if TRAVERSAL_PATTERN.search(value):
raise HTTPException(status_code=422, detail=f'Path traversal payload detected in {source}') raise HTTPException(
status_code=422,
detail=f"Path traversal payload detected in {source}",
)
if has_control_chars(value): if has_control_chars(value):
raise HTTPException(status_code=422, detail=f'Invalid control characters detected in {source}') raise HTTPException(
status_code=422,
detail=f"Invalid control characters detected in {source}",
)
def inspect_json(obj, path='body', check_whitelist=True): def inspect_json(obj, path="body", check_whitelist=True):
if isinstance(obj, dict): if isinstance(obj, dict):
for key, value in obj.items(): for key, value in obj.items():
if key in FORBIDDEN_JSON_KEYS: if key in FORBIDDEN_JSON_KEYS:
raise HTTPException(status_code=422, detail=f'Forbidden JSON key detected: {path}.{key}') raise HTTPException(
status_code=422,
detail=f"Forbidden JSON key detected: {path}.{key}",
)
if check_whitelist and key not in ALLOWED_DATA_PARAMS: if check_whitelist and key not in ALLOWED_DATA_PARAMS:
raise HTTPException(status_code=422, detail=f'Unknown JSON key detected: {path}.{key}') raise HTTPException(
should_check_subkeys = check_whitelist and key not in DYNAMIC_KEYS status_code=422,
inspect_json(value, f'{path}.{key}', check_whitelist=should_check_subkeys) detail=f"Unknown JSON key detected: {path}.{key}",
)
# Recurse. If the key is a dynamic container, we stop whitelist checking for children.
should_check_subkeys = check_whitelist and (key not in DYNAMIC_KEYS)
inspect_json(value, f"{path}.{key}", check_whitelist=should_check_subkeys)
elif isinstance(obj, list): elif isinstance(obj, list):
for i, item in enumerate(obj): for i, item in enumerate(obj):
inspect_json(item, f'{path}[{i}]', check_whitelist=check_whitelist) inspect_json(item, f"{path}[{i}]", check_whitelist=check_whitelist)
elif isinstance(obj, str): elif isinstance(obj, str):
inspect_value(obj, path) inspect_value(obj, path)
class RequestValidationMiddleware(BaseHTTPMiddleware):
# =========================
# Middleware
# =========================
class RequestValidationMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
# Wrap the entire dispatch body so that any HTTPException raised during
# validation is handled by our central handle_exception rather than
# escaping through call_next into the outer middleware chain.
# Without this wrapper, exceptions from dispatch() bypass db_session_middleware's
# logging code and cause an all-nulls ERROR log from uvicorn's internal logger.
try: try:
return await self._validate_and_forward(request, call_next) return await self._validate_and_forward(request, call_next)
except HTTPException as exc: except HTTPException as exc:
@ -60,86 +266,218 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
return await handle_exception(request, exc) return await handle_exception(request, exc)
async def _validate_and_forward(self, request: Request, call_next): async def _validate_and_forward(self, request: Request, call_next):
# # -------------------------
# # 0. Header validation
# # -------------------------
# header_keys = [key.lower() for key, _ in request.headers.items()]
# # Check for duplicate headers
# header_counter = Counter(header_keys)
# duplicate_headers = [key for key, count in header_counter.items() if count > 1]
# ALLOW_DUPLICATE_HEADERS = {'accept', 'accept-encoding', 'accept-language', 'accept-charset', 'cookie'}
# real_duplicates = [h for h in duplicate_headers if h not in ALLOW_DUPLICATE_HEADERS]
# if real_duplicates:
# raise HTTPException(
# status_code=422,
# detail=f"Duplicate headers detected: {real_duplicates}",
# )
# # Whitelist headers
# unknown_headers = [key for key in header_keys if key not in ALLOWED_HEADERS]
# if unknown_headers:
# filtered_unknown = [h for h in unknown_headers if not h.startswith('sec-')]
# if filtered_unknown:
# raise HTTPException(
# status_code=422,
# detail=f"Unknown headers detected: {filtered_unknown}",
# )
# # Inspect header values
# for key, value in request.headers.items():
# if value:
# inspect_value(value, f"header '{key}'")
# -------------------------
# 1. Query string limits
# -------------------------
if len(request.url.query) > MAX_QUERY_LENGTH: if len(request.url.query) > MAX_QUERY_LENGTH:
raise HTTPException(status_code=422, detail='Query string too long') raise HTTPException(
status_code=422,
detail="Query string too long",
)
params = request.query_params.multi_items() params = request.query_params.multi_items()
if len(params) > MAX_QUERY_PARAMS: if len(params) > MAX_QUERY_PARAMS:
raise HTTPException(status_code=422, detail='Too many query parameters') raise HTTPException(
status_code=422,
detail="Too many query parameters",
)
# Check for unknown query parameters
unknown_params = [key for key, _ in params if key not in ALLOWED_DATA_PARAMS] unknown_params = [key for key, _ in params if key not in ALLOWED_DATA_PARAMS]
if unknown_params: if unknown_params:
raise HTTPException(status_code=422, detail=f'Unknown query parameters detected: {unknown_params}') raise HTTPException(
counter = Counter((key for key, _ in params)) status_code=422,
duplicates = [key for key, count in counter.items() if count > 1 and key not in ALLOWED_MULTI_PARAMS] detail=f"Unknown query parameters detected: {unknown_params}",
)
# -------------------------
# 2. Duplicate parameters
# -------------------------
counter = Counter(key for key, _ in params)
duplicates = [
key for key, count in counter.items()
if count > 1 and key not in ALLOWED_MULTI_PARAMS
]
if duplicates: if duplicates:
raise HTTPException(status_code=422, detail=f'Duplicate query parameters: {duplicates}') raise HTTPException(
pagination_size_keys = {'size', 'itemsPerPage', 'per_page', 'limit', 'items_per_page'} status_code=422,
detail=f"Duplicate query parameters: {duplicates}",
)
# -------------------------
# 3. Query param inspection & Pagination
# -------------------------
pagination_size_keys = {"size", "itemsPerPage", "per_page", "limit", "items_per_page"}
for key, value in params: for key, value in params:
if value: if value:
inspect_value(value, f"query param '{key}'") inspect_value(value, f"query param '{key}'")
if key in pagination_size_keys and value: if key in pagination_size_keys and value:
try: try:
size_val = int(value) size_val = int(value)
if size_val > 50: if size_val > 50:
raise HTTPException(status_code=422, detail=f'Pagination size too large: {size_val} (max 50)') raise HTTPException(
status_code=422,
detail=f"Pagination size too large: {size_val} (max 50)",
)
if size_val % 5 != 0: if size_val % 5 != 0:
raise HTTPException(status_code=422, detail=f'Pagination size not a multiple of 5: {size_val}') raise HTTPException(
status_code=422,
detail=f"Pagination size not a multiple of 5: {size_val}",
)
except ValueError: except ValueError:
raise HTTPException(status_code=422, detail=f"Pagination size invalid value for '{key}'") raise HTTPException(
content_type = request.headers.get('content-type', '') status_code=422,
if content_type and (not any((content_type.startswith(t) for t in ('application/json', 'multipart/form-data', 'application/x-www-form-urlencoded')))): detail=f"Pagination size invalid value for '{key}'",
raise HTTPException(status_code=422, detail=f'Unsupported Content-Type: {content_type}') )
# -------------------------
# 4. Content-Type sanity
# -------------------------
content_type = request.headers.get("content-type", "")
if content_type and not any(
content_type.startswith(t)
for t in ("application/json", "multipart/form-data", "application/x-www-form-urlencoded")
):
raise HTTPException(status_code=422, detail=f"Unsupported Content-Type: {content_type}")
# -------------------------
# 5. Single source check (Query vs JSON Body)
# -------------------------
has_query = len(params) > 0 has_query = len(params) > 0
has_body = False has_body = False
if content_type.startswith('application/json'):
content_length = request.headers.get('content-length') if content_type.startswith("application/json"):
# We can't easily check body existence without consuming it,
# so we check if Content-Length > 0
content_length = request.headers.get("content-length")
if content_length and int(content_length) > 0: if content_length and int(content_length) > 0:
has_body = True has_body = True
if has_query and has_body: if has_query and has_body:
raise HTTPException(status_code=422, detail='Mixed parameters: both query string and JSON body are not allowed') raise HTTPException(
if content_type.startswith('application/json'): status_code=422,
detail="Mixed parameters: both query string and JSON body are not allowed",
)
# -------------------------
# 6. JSON body inspection
# -------------------------
if content_type.startswith("application/json"):
body = await request.body() body = await request.body()
# if len(body) > MAX_JSON_BODY_SIZE:
# raise HTTPException(status_code=422, detail="JSON body too large")
if body: if body:
try: try:
payload = json.loads(body) payload = json.loads(body)
except json.JSONDecodeError: except json.JSONDecodeError:
raise HTTPException(status_code=400, detail='Invalid JSON body: malformed JSON') # Malformed JSON is a client-side structural error → 400 Bad Request,
# NOT 422 Unprocessable Entity (which is for semantically invalid input).
raise HTTPException(status_code=400, detail="Invalid JSON body: malformed JSON")
inspect_json(payload) inspect_json(payload)
# Re-inject body for downstream handlers
async def receive(): async def receive():
return {'type': 'http.request', 'body': body} return {"type": "http.request", "body": body}
request._receive = receive request._receive = receive
return await call_next(request) return await call_next(request)
import threading
import asyncio import asyncio
import json import json
import logging import logging
REQUEST_TIMEOUT_SECONDS = 20 REQUEST_TIMEOUT_SECONDS = 20
log = logging.getLogger('security_logger')
log = logging.getLogger("security_logger")
class RequestTimeoutMiddleware: class RequestTimeoutMiddleware:
"""
ASGI middleware that cancels requests exceeding REQUEST_TIMEOUT_SECONDS.
Returns HTTP 408 Request Timeout with a JSON body when threshold is exceeded.
"""
def __init__(self, app, timeout: int = REQUEST_TIMEOUT_SECONDS): def __init__(self, app, timeout: int = REQUEST_TIMEOUT_SECONDS):
self.app = app self.app = app
self.timeout = timeout self.timeout = timeout
async def __call__(self, scope, receive, send): async def __call__(self, scope, receive, send):
if scope['type'] != 'http': if scope["type"] != "http":
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
response_started = False response_started = False
async def send_wrapper(message): async def send_wrapper(message):
nonlocal response_started nonlocal response_started
if message['type'] == 'http.response.start': if message["type"] == "http.response.start":
response_started = True response_started = True
await send(message) await send(message)
try: try:
await asyncio.wait_for(self.app(scope, receive, send_wrapper), timeout=self.timeout) await asyncio.wait_for(
self.app(scope, receive, send_wrapper),
timeout=self.timeout,
)
except asyncio.TimeoutError: except asyncio.TimeoutError:
if not response_started: if not response_started:
import uuid as _uuid import uuid as _uuid
error_id = str(_uuid.uuid4()) error_id = str(_uuid.uuid4())
scope['x_timed_out'] = True # Flag the scope so db_session_middleware can log the 408 with correct fields
scope['x_timeout_error_id'] = error_id scope["x_timed_out"] = True
body = json.dumps({'data': None, 'message': f'Request timeout. Error ID: {error_id}', 'status': False}).encode('utf-8') scope["x_timeout_error_id"] = error_id
await send({'type': 'http.response.start', 'status': 408, 'headers': [[b'content-type', b'application/json; charset=utf-8'], [b'content-length', str(len(body)).encode()]]}) body = json.dumps({
await send({'type': 'http.response.body', 'body': body}) "data": None,
"message": f"Request timeout. Error ID: {error_id}",
"status": False,
}).encode("utf-8")
await send({
"type": "http.response.start",
"status": 408,
"headers": [
[b"content-type", b"application/json; charset=utf-8"],
[b"content-length", str(len(body)).encode()],
],
})
await send({
"type": "http.response.body",
"body": body,
})

@ -1,17 +1,31 @@
# src/common/models.py
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Generic, Optional, TypeVar from typing import Generic, Optional, TypeVar
import pytz import pytz
from pydantic import BaseModel, SecretStr from pydantic import BaseModel, Field, SecretStr
from sqlalchemy import Column, DateTime, String, event from sqlalchemy import Column, DateTime, String, event, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from src.auth.service import CurrentUser
from src.config import TIMEZONE from src.config import TIMEZONE
from src.enums import ResponseStatus from src.enums import ResponseStatus
# SQLAlchemy Mixins
class TimeStampMixin(object): class TimeStampMixin(object):
created_at = Column(DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE))) """Timestamping mixin"""
created_at = Column(
DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE))
)
created_at._creation_order = 9998 created_at._creation_order = 9998
updated_at = Column(DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE))) updated_at = Column(
DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE))
)
updated_at._creation_order = 9998 updated_at._creation_order = 9998
@staticmethod @staticmethod
@ -20,32 +34,57 @@ class TimeStampMixin(object):
@classmethod @classmethod
def __declare_last__(cls): def __declare_last__(cls):
event.listen(cls, 'before_update', cls._updated_at) event.listen(cls, "before_update", cls._updated_at)
class UUIDMixin: class UUIDMixin:
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, unique=True, nullable=False) """UUID mixin"""
id = Column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
unique=True,
nullable=False,
)
class SoftDeleteMixin: class SoftDeleteMixin:
"""Soft delete mixin — adds deleted_at column to mark records as deleted."""
deleted_at = Column(DateTime(timezone=True), nullable=True, default=None) deleted_at = Column(DateTime(timezone=True), nullable=True, default=None)
deleted_at._creation_order = 9999 deleted_at._creation_order = 9999
class DefaultMixin(SoftDeleteMixin, TimeStampMixin, UUIDMixin): class DefaultMixin(SoftDeleteMixin, TimeStampMixin, UUIDMixin):
"""Default mixin"""
pass pass
class IdentityMixin: class IdentityMixin:
"""Identity mixin"""
created_by = Column(String(100), nullable=True) created_by = Column(String(100), nullable=True)
updated_by = Column(String(100), nullable=True) updated_by = Column(String(100), nullable=True)
class DefultBase(BaseModel):
# Pydantic Models
class DefultBase(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
validate_assignment = True validate_assignment = True
arbitrary_types_allowed = True arbitrary_types_allowed = True
str_strip_whitespace = True str_strip_whitespace = True
populate_by_name = True populate_by_name = True
extra = 'forbid' extra="forbid"
json_encoders = {datetime: lambda v: v.strftime('%Y-%m-%dT%H:%M:%S.%fZ') if v else None, SecretStr: lambda v: v.get_secret_value() if v else None}
json_encoders = {
# custom output conversion for datetime
datetime: lambda v: v.strftime("%Y-%m-%dT%H:%M:%S.%fZ") if v else None,
SecretStr: lambda v: v.get_secret_value() if v else None,
}
class Pagination(DefultBase): class Pagination(DefultBase):
itemsPerPage: int itemsPerPage: int
@ -53,11 +92,16 @@ class Pagination(DefultBase):
page: int page: int
total: int total: int
class PrimaryKeyModel(BaseModel): class PrimaryKeyModel(BaseModel):
id: uuid.UUID id: uuid.UUID
T = TypeVar('T')
# Define data type variable for generic response
T = TypeVar("T")
class StandardResponse(BaseModel, Generic[T]): class StandardResponse(BaseModel, Generic[T]):
data: Optional[T] = None data: Optional[T] = None
message: str = 'Success' message: str = "Success"
status: ResponseStatus = ResponseStatus.SUCCESS status: ResponseStatus = ResponseStatus.SUCCESS

@ -1,35 +1,72 @@
from typing import List from typing import List
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException, status
from src.auth.service import Token from src.auth.service import Token
from src.database.core import CollectorDbSession, DbSession from src.database.core import CollectorDbSession, DbSession
from src.models import StandardResponse from src.models import StandardResponse
from src.overhaul.service import get_overhaul_critical_parts, get_overhaul_overview, get_overhaul_schedules, get_overhaul_system_components from src.overhaul.service import (get_overhaul_critical_parts,
get_overhaul_overview,
get_overhaul_schedules,
get_overhaul_system_components)
from src.overhaul_scope.schema import ScopeRead from src.overhaul_scope.schema import ScopeRead
from .schema import OverhaulCriticalParts, OverhaulRead, OverhaulSystemComponents
from .schema import (OverhaulCriticalParts, OverhaulRead,
OverhaulSystemComponents)
from src.auth.access_control import required_permission, OHPermission from src.auth.access_control import required_permission, OHPermission
from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.model import OverhaulScope
from fastapi import Depends from fastapi import Depends
router = APIRouter() router = APIRouter()
@router.get('', response_model=StandardResponse[OverhaulRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@router.get("", response_model=StandardResponse[OverhaulRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_overhaul(db_session: DbSession, token:Token, collector_db_session:CollectorDbSession): async def get_overhaul(db_session: DbSession, token:Token, collector_db_session:CollectorDbSession):
"""Get all scope pagination."""
overview = await get_overhaul_overview(db_session=db_session) overview = await get_overhaul_overview(db_session=db_session)
schedules = await get_overhaul_schedules(db_session=db_session) schedules = await get_overhaul_schedules(db_session=db_session)
criticalParts = await get_overhaul_critical_parts(db_session=db_session, session_id=overview['overhaul']['id'], token=token, collector_db_session=collector_db_session) criticalParts = await get_overhaul_critical_parts(db_session=db_session, session_id=overview["overhaul"]["id"], token=token, collector_db_session=collector_db_session)
systemComponents = get_overhaul_system_components() systemComponents = get_overhaul_system_components()
return StandardResponse(data=OverhaulRead(overview=overview, schedules=schedules, criticalParts=criticalParts, systemComponents=systemComponents), message='Data retrieved successfully')
@router.get('/schedules', response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) return StandardResponse(
data=OverhaulRead(
overview=overview,
schedules=schedules,
criticalParts=criticalParts,
systemComponents=systemComponents,
),
message="Data retrieved successfully",
)
@router.get("/schedules", response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_schedules(): async def get_schedules():
"""Get all overhaul schedules."""
schedules = get_overhaul_schedules() schedules = get_overhaul_schedules()
return StandardResponse(data=schedules, message='Data retrieved successfully') return StandardResponse(
data=schedules,
message="Data retrieved successfully",
)
@router.get('/critical-parts', response_model=StandardResponse[OverhaulCriticalParts], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@router.get("/critical-parts", response_model=StandardResponse[OverhaulCriticalParts], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_critical_parts(): async def get_critical_parts():
"""Get all overhaul critical parts."""
criticalParts = get_overhaul_critical_parts() criticalParts = get_overhaul_critical_parts()
return StandardResponse(data=OverhaulCriticalParts(criticalParts=criticalParts), message='Data retrieved successfully') return StandardResponse(
data=OverhaulCriticalParts(criticalParts=criticalParts),
message="Data retrieved successfully",
)
@router.get('/system-components', response_model=StandardResponse[OverhaulSystemComponents], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) @router.get(
"/system-components", response_model=StandardResponse[OverhaulSystemComponents],
dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]
)
async def get_system_components(): async def get_system_components():
"""Get all overhaul system components."""
systemComponents = get_overhaul_system_components() systemComponents = get_overhaul_system_components()
return StandardResponse(data=OverhaulSystemComponents(systemComponents=systemComponents), message='Data retrieved successfully') return StandardResponse(
data=OverhaulSystemComponents(systemComponents=systemComponents),
message="Data retrieved successfully",
)

@ -1,21 +1,72 @@
from typing import Any, Dict, List from datetime import datetime
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead from src.overhaul_scope.schema import ScopeRead
class OverhaulBase(BaseModel): class OverhaulBase(BaseModel):
pass pass
class OverhaulCriticalParts(OverhaulBase): class OverhaulCriticalParts(OverhaulBase):
criticalParts: List[str] = Field(..., description='List of critical parts') criticalParts: List[str] = Field(..., description="List of critical parts")
class OverhaulSchedules(OverhaulBase): class OverhaulSchedules(OverhaulBase):
schedules: List[Dict[str, Any]] = Field(..., description='List of schedules') schedules: List[Dict[str, Any]] = Field(..., description="List of schedules")
class OverhaulSystemComponents(OverhaulBase): class OverhaulSystemComponents(OverhaulBase):
systemComponents: Dict[str, Any] = Field(..., description='List of system components') systemComponents: Dict[str, Any] = Field(
..., description="List of system components"
)
class OverhaulRead(OverhaulBase): class OverhaulRead(OverhaulBase):
overview: Dict[str, Any] overview: Dict[str, Any]
criticalParts: dict criticalParts: dict
schedules: List[ScopeRead] schedules: List[ScopeRead]
systemComponents: Dict[str, Any] systemComponents: Dict[str, Any]
# {
# "overview": {
# "totalEquipment": 30,
# "nextSchedule": {
# "date": "2025-01-12",
# "Overhaul": "B",
# "equipmentCount": 30
# }
# },
# "criticalParts": [
# "Boiler feed pump",
# "Boiler reheater system",
# "Drum Level (Right) Root Valve A",
# "BCP A Discharge Valve",
# "BFPT A EXH Press HI Root VLV"
# ],
# "schedules": [
# {
# "date": "2025-01-12",
# "Overhaul": "B",
# "status": "upcoming"
# }
# // ... other scheduled overhauls
# ],
# "systemComponents": {
# "boiler": {
# "status": "operational",
# "lastOverhaul": "2024-06-15"
# },
# "turbine": {
# "hpt": { "status": "operational" },
# "ipt": { "status": "operational" },
# "lpt": { "status": "operational" }
# }
# // ... other major components
# }
# }

@ -1,51 +1,355 @@
import asyncio import asyncio
from typing import Optional
import httpx import httpx
from sqlalchemy import Select from sqlalchemy import Delete, Select
from src.auth.service import CurrentUser
from src.calculation_target_reliability.service import RBD_SERVICE_API from src.calculation_target_reliability.service import RBD_SERVICE_API
from src.config import TC_RBD_ID from src.config import TC_RBD_ID
from src.database.core import DbSession from src.database.core import DbSession
from src.contribution_util import calculate_contribution from src.contribution_util import calculate_contribution
from src.overhaul_activity.service import get_standard_scope_by_session_id from src.overhaul_activity.service import get_standard_scope_by_session_id
from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.model import OverhaulScope
from src.overhaul_scope.service import get_all as get_all_session
from src.overhaul_scope.service import get_overview_overhaul from src.overhaul_scope.service import get_overview_overhaul
from src.standard_scope.service import get_by_oh_session_id
async def get_overhaul_overview(db_session: DbSession): async def get_overhaul_overview(db_session: DbSession):
return await get_overview_overhaul(db_session=db_session) """Get all overhaul overview."""
results = await get_overview_overhaul(db_session=db_session)
return results
async def get_simulation_results(*, simulation_id: str, token: str): async def get_simulation_results(*, simulation_id: str, token: str):
headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} headers = {
calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode' "Authorization": f"Bearer {token}",
calc_plant_result = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant' "Content-Type": "application/json"
}
calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode"
# plot_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/plot/{simulation_id}?nodetype=RegularNode"
calc_plant_result = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant"
async with httpx.AsyncClient(timeout=300.0) as client: async with httpx.AsyncClient(timeout=300.0) as client:
calc_task = client.get(calc_result_url, headers=headers) calc_task = client.get(calc_result_url, headers=headers)
# plot_task = client.get(plot_result_url, headers=headers)
plant_task = client.get(calc_plant_result, headers=headers) plant_task = client.get(calc_plant_result, headers=headers)
# Run all three requests concurrently
calc_response, plant_response = await asyncio.gather(calc_task, plant_task) calc_response, plant_response = await asyncio.gather(calc_task, plant_task)
calc_response.raise_for_status() calc_response.raise_for_status()
# plot_response.raise_for_status()
plant_response.raise_for_status() plant_response.raise_for_status()
calc_data = calc_response.json()['data']
plant_data = plant_response.json()['data'] calc_data = calc_response.json()["data"]
return {'calc_result': calc_data, 'plant_result': plant_data} # plot_data = plot_response.json()["data"]
plant_data = plant_response.json()["data"]
return {
"calc_result": calc_data,
# "plot_result": plot_data,
"plant_result": plant_data
}
async def get_overhaul_critical_parts(db_session, session_id, token, collector_db_session): async def get_overhaul_critical_parts(db_session, session_id, token, collector_db_session):
equipments = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=session_id, collector_db=collector_db_session) """Get all overhaul critical parts."""
criticality_simulation = await get_simulation_results(simulation_id=TC_RBD_ID, token=token) equipments = await get_standard_scope_by_session_id(
rbd_simulation = {asset['aeros_node']['node_name']: {'availability': asset['availability'], 'criticality': asset['criticality']} for asset in criticality_simulation['calc_result']} db_session=db_session,
base_result = [{'id': equipment.id, 'location_tag': equipment.location_tag, 'name': equipment.equipment_name, 'matrix': rbd_simulation.get(equipment.location_tag)} for equipment in equipments] overhaul_session_id=session_id,
filtered_result = [item for item in base_result if item['matrix'] is not None] collector_db=collector_db_session
availability_result = sorted(filtered_result, key=lambda x: x['matrix']['availability'])[:10] )
criticality_result = sorted(filtered_result, key=lambda x: x['matrix']['criticality'], reverse=True)[:10]
return {'availability': availability_result, 'criticality': criticality_result}
criticality_simulation = await get_simulation_results(
simulation_id = TC_RBD_ID,
token=token
)
rbd_simulation = {asset['aeros_node']["node_name"]: {
"availability": asset["availability"],
"criticality": asset["criticality"]
} for asset in criticality_simulation["calc_result"]}
# Create the base result list
base_result = [
{
"id": equipment.id,
"location_tag": equipment.location_tag,
"name": equipment.equipment_name,
"matrix": rbd_simulation.get(equipment.location_tag)
} for equipment in equipments
]
# Filter out items without matrix data (where rbd_simulation.get() returned None)
filtered_result = [item for item in base_result if item["matrix"] is not None]
# Sort by availability (lowest to highest) and limit to 10
availability_result = sorted(
filtered_result,
key=lambda x: x["matrix"]["availability"]
)[:10]
# Sort by criticality (highest to lowest) and limit to 10
criticality_result = sorted(
filtered_result,
key=lambda x: x["matrix"]["criticality"],
reverse=True
)[:10]
return {
"availability" :availability_result,
"criticality": criticality_result
}
async def get_overhaul_schedules(*, db_session: DbSession): async def get_overhaul_schedules(*, db_session: DbSession):
"""Get all overhaul schedules."""
query = Select(OverhaulScope) query = Select(OverhaulScope)
results = await db_session.execute(query) results = await db_session.execute(query)
return results.scalars().all() return results.scalars().all()
def get_overhaul_system_components(): def get_overhaul_system_components():
powerplant_reliability = {'Plant Control': {'availability': 0.9994866529774127, 'efficiency': 0.9994956204510826, 'total_uptime': 17523.0}, 'SPS': {'availability': 0.9932694501483038, 'efficiency': 0.9810193821516867, 'total_uptime': 17414.000000000062}, 'Turbine': {'availability': 0.9931553730321733, 'efficiency': 0.9666721976783572, 'total_uptime': 17412.000000000062}, 'Generator': {'availability': 0.9934405658224995, 'efficiency': 0.9625424646119242, 'total_uptime': 17417.000000000062}, 'Condensate Water': {'availability': 0.9934405658224995, 'efficiency': 0.9531653517377116, 'total_uptime': 17417.000000000062}, 'Feedwater System': {'availability': 0.9936116814966953, 'efficiency': 0.9687512254370128, 'total_uptime': 17420.000000000062}, 'Cooling Water': {'availability': 0.99355464293863, 'efficiency': 0.9749999189617731, 'total_uptime': 17419.000000000062}, 'SCR': {'availability': 0.9196577686516085, 'efficiency': 0.9996690612127086, 'total_uptime': 17526.0}, 'Ash Handling': {'availability': 0.9931838923112059, 'efficiency': 0.9933669638506657, 'total_uptime': 17412.500000000062}, 'Air Flue Gas': {'availability': 0.9906456764773022, 'efficiency': 0.8565868045084128, 'total_uptime': 17368.000000000062}, 'Boiler': {'availability': 0.9934976043805648, 'efficiency': 0.9693239775686654, 'total_uptime': 17418.000000000062}, 'SAC-IAC': {'availability': 0.9936116814966953, 'efficiency': 0.9878742473840645, 'total_uptime': 17420.000000000062}, 'KLH': {'availability': 0.992185717545064, 'efficiency': 0.9426658166507496, 'total_uptime': 17395.000000000062}, 'CL': {'availability': 0.9984029203741729, 'efficiency': 0.9984546987034779, 'total_uptime': 17504.0}, 'Desalination': {'total_uptime': 17275.500000000062, 'availability': 0.9853696098562663, 'efficiency': 0.9118366404915063}, 'FGD': {'availability': 0.9933835272644342, 'efficiency': 0.9623105228919693, 'total_uptime': 17416.000000000062}, 'CHS': {'availability': 1.0, 'efficiency': 0.9665857756206829, 'total_uptime': 17532.0}, 'SSB': {'availability': 0.9933264887063691, 'efficiency': 0.993508327346586, 'total_uptime': 17415.000000000062}, 'WTP': {'availability': 0.9925849874515208, 'efficiency': 0.9925849874515206, 'total_uptime': 17402.000000000062}} """Get all overhaul system components with dummy data."""
powerplant_reliability = {
"Plant Control": {
"availability": 0.9994866529774127,
"efficiency": 0.9994956204510826,
"total_uptime": 17523.0,
},
"SPS": {
"availability": 0.9932694501483038,
"efficiency": 0.9810193821516867,
"total_uptime": 17414.000000000062,
},
"Turbine": {
"availability": 0.9931553730321733,
"efficiency": 0.9666721976783572,
"total_uptime": 17412.000000000062,
},
"Generator": {
"availability": 0.9934405658224995,
"efficiency": 0.9625424646119242,
"total_uptime": 17417.000000000062,
},
"Condensate Water": {
"availability": 0.9934405658224995,
"efficiency": 0.9531653517377116,
"total_uptime": 17417.000000000062,
},
"Feedwater System": {
"availability": 0.9936116814966953,
"efficiency": 0.9687512254370128,
"total_uptime": 17420.000000000062,
},
"Cooling Water": {
"availability": 0.99355464293863,
"efficiency": 0.9749999189617731,
"total_uptime": 17419.000000000062,
},
"SCR": {
"availability": 0.9196577686516085,
"efficiency": 0.9996690612127086,
"total_uptime": 17526.0,
},
"Ash Handling": {
"availability": 0.9931838923112059,
"efficiency": 0.9933669638506657,
"total_uptime": 17412.500000000062,
},
"Air Flue Gas": {
"availability": 0.9906456764773022,
"efficiency": 0.8565868045084128,
"total_uptime": 17368.000000000062,
},
"Boiler": {
"availability": 0.9934976043805648,
"efficiency": 0.9693239775686654,
"total_uptime": 17418.000000000062,
},
"SAC-IAC": {
"availability": 0.9936116814966953,
"efficiency": 0.9878742473840645,
"total_uptime": 17420.000000000062,
},
"KLH": {
"availability": 0.992185717545064,
"efficiency": 0.9426658166507496,
"total_uptime": 17395.000000000062,
},
"CL": {
"availability": 0.9984029203741729,
"efficiency": 0.9984546987034779,
"total_uptime": 17504.0,
},
"Desalination": {
"total_uptime": 17275.500000000062,
"availability": 0.9853696098562663,
"efficiency": 0.9118366404915063,
},
"FGD": {
"availability": 0.9933835272644342,
"efficiency": 0.9623105228919693,
"total_uptime": 17416.000000000062,
},
"CHS": {
"availability": 1.0,
"efficiency": 0.9665857756206829,
"total_uptime": 17532.0,
},
"SSB": {
"availability": 0.9933264887063691,
"efficiency": 0.993508327346586,
"total_uptime": 17415.000000000062,
},
"WTP": {
"availability": 0.9925849874515208,
"efficiency": 0.9925849874515206,
"total_uptime": 17402.000000000062,
},
}
availabilities = {schematic: item['availability'] for schematic, item in powerplant_reliability.items() } availabilities = {schematic: item['availability'] for schematic, item in powerplant_reliability.items() }
percentages = calculate_contribution(availabilities) percentages = calculate_contribution(availabilities)
for schema, contribution in percentages.items(): for schema, contribution in percentages.items():
powerplant_reliability[schema]['critical_contribution'] = contribution['criticality_importance'] powerplant_reliability[schema]["critical_contribution"] = contribution['criticality_importance']
return dict(sorted(powerplant_reliability.items(), key=lambda x: x[1]['critical_contribution'], reverse=True))
return {'HPT': {'efficiency': '92%', 'work_hours': '1200', 'reliability': '96%'}, 'IPT': {'efficiency': '91%', 'work_hours': '1100', 'reliability': '95%'}, 'LPT': {'efficiency': '90%', 'work_hours': '1000', 'reliability': '94%'}, 'EG': {'efficiency': '88%', 'work_hours': '950', 'reliability': '93%'}, 'boiler': {'efficiency': '90%', 'work_hours': '1000', 'reliability': '95%'}, 'HPH1': {'efficiency': '89%', 'work_hours': '1050', 'reliability': '94%'}, 'HPH2': {'efficiency': '88%', 'work_hours': '1020', 'reliability': '93%'}, 'HPH3': {'efficiency': '87%', 'work_hours': '1010', 'reliability': '92%'}, 'HPH5': {'efficiency': '86%', 'work_hours': '980', 'reliability': '91%'}, 'HPH6': {'efficiency': '85%', 'work_hours': '970', 'reliability': '90%'}, 'HPH7': {'efficiency': '84%', 'work_hours': '960', 'reliability': '89%'}, 'Condensor': {'efficiency': '83%', 'work_hours': '940', 'reliability': '88%'}, 'Deaerator': {'efficiency': '82%', 'work_hours': '930', 'reliability': '87%'}} # Sort the powerplant_reliability dictionary by critical_contribution in descending order
sorted_powerplant_reliability = dict(sorted(
powerplant_reliability.items(),
key=lambda x: x[1]["critical_contribution"],
reverse=True # Set to True for high to low sorting
))
return sorted_powerplant_reliability
return {
"HPT": {
"efficiency": "92%",
"work_hours": "1200",
"reliability": "96%",
},
"IPT": {
"efficiency": "91%",
"work_hours": "1100",
"reliability": "95%",
},
"LPT": {
"efficiency": "90%",
"work_hours": "1000",
"reliability": "94%",
},
"EG": {
"efficiency": "88%",
"work_hours": "950",
"reliability": "93%",
},
"boiler": {
"efficiency": "90%",
"work_hours": "1000",
"reliability": "95%",
},
"HPH1": {
"efficiency": "89%",
"work_hours": "1050",
"reliability": "94%",
},
"HPH2": {
"efficiency": "88%",
"work_hours": "1020",
"reliability": "93%",
},
"HPH3": {
"efficiency": "87%",
"work_hours": "1010",
"reliability": "92%",
},
"HPH5": {
"efficiency": "86%",
"work_hours": "980",
"reliability": "91%",
},
"HPH6": {
"efficiency": "85%",
"work_hours": "970",
"reliability": "90%",
},
"HPH7": {
"efficiency": "84%",
"work_hours": "960",
"reliability": "89%",
},
"Condensor": {
"efficiency": "83%",
"work_hours": "940",
"reliability": "88%",
},
"Deaerator": {
"efficiency": "82%",
"work_hours": "930",
"reliability": "87%",
},
}
# async def get(*, db_session: DbSession, scope_id: str) -> Optional[Scope]:
# """Returns a document based on the given document id."""
# query = Select(Scope).filter(Scope.id == scope_id)
# result = await db_session.execute(query)
# return result.scalars().one_or_none()
# async def get_all(*, db_session: DbSession):
# """Returns all documents."""
# query = Select(Scope)
# result = await db_session.execute(query)
# return result.scalars().all()
# async def create(*, db_session: DbSession, scope_id: ScopeCreate):
# """Creates a new document."""
# scope = Scope(**scope_id.model_dump())
# db_session.add(scope)
# await db_session.commit()
# return scope
# async def update(*, db_session: DbSession, scope: Scope, scope_id: ScopeUpdate):
# """Updates a document."""
# data = scope_id.model_dump()
# update_data = scope_id.model_dump(exclude_defaults=True)
# for field in data:
# if field in update_data:
# setattr(scope, field, update_data[field])
# await db_session.commit()
# return scope
# async def delete(*, db_session: DbSession, scope_id: str):
# """Deletes a document."""
# query = Delete(Scope).where(Scope.id == scope_id)
# await db_session.execute(query)
# await db_session.commit()

@ -1,11 +1,43 @@
from sqlalchemy import UUID, Column, Float, ForeignKey, String from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship
from src.database.core import Base from src.database.core import Base
from src.models import DefaultMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
class OverhaulActivity(Base, DefaultMixin): class OverhaulActivity(Base, DefaultMixin):
__tablename__ = 'oh_tr_overhaul_activity' __tablename__ = "oh_tr_overhaul_activity"
assetnum = Column(String, nullable=True) assetnum = Column(String, nullable=True)
overhaul_scope_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_overhaul_scope.id'), nullable=False) overhaul_scope_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_overhaul_scope.id"), nullable=False
)
material_cost = Column(Float, nullable=False, default=0) material_cost = Column(Float, nullable=False, default=0)
service_cost = Column(Float, nullable=False, default=0) service_cost = Column(Float, nullable=False, default=0)
status = Column(String, nullable=False, default='pending') status = Column(String, nullable=False, default="pending")
# equipment = relationship(
# "MasterEquipment",
# lazy="raise",
# primaryjoin="and_(OverhaulActivity.assetnum == foreign(MasterEquipment.assetnum))",
# uselist=False, # Add this if it's a one-to-one relationship
# )
# # sparepart_equipments = relationship(
# # "ScopeEquipmentPart",
# # lazy="select", # or "joined", "subquery", "dynamic" depending on your needs
# # primaryjoin="OverhaulActivity.assetnum == foreign(ScopeEquipmentPart.assetnum)",
# # uselist=True
# # )
# overhaul_scope = relationship(
# "OverhaulScope",
# lazy="raise",
# )
# overhaul_jobs = relationship(
# "OverhaulJob", back_populates="overhaul_activity", lazy="raise"
# )

@ -1,33 +1,119 @@
from typing import Optional from typing import List, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from src.csrf_protect import csrf_protect from src.csrf_protect import csrf_protect
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.database.service import CommonParameters, DbSession from src.database.service import (CommonParameters, DbSession,
search_filter_sort_paginate)
from src.models import StandardResponse from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import OverhaulActivityCreate, OverhaulActivityRead
from .service import add_multiple_equipment_to_session, get, get_all, remove_equipment_from_session from .schema import (OverhaulActivityCreate, OverhaulActivityPagination,
OverhaulActivityRead, OverhaulActivityUpdate)
from .service import add_multiple_equipment_to_session, get, get_all, remove_equipment_from_session, update
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get('/{overhaul_session}', response_model=StandardResponse[dict])
async def get_scope_equipments(common: CommonParameters, overhaul_session: str, collector_db: CollectorDbSession, location_tag: Optional[str]=Query(None), scope_name: Optional[str]=Query(None)):
data = await get_all(common=common, location_tag=location_tag, scope_name=scope_name, overhaul_session_id=overhaul_session, collector_db=collector_db)
return StandardResponse(data=data, message='Data retrieved successfully')
@router.post('/{overhaul_session_id}', response_model=StandardResponse[None]) @router.get(
async def create_overhaul_equipment(db_session: DbSession, collector_db_session: CollectorDbSession, overhaul_activty_in: OverhaulActivityCreate, overhaul_session_id: UUID): "/{overhaul_session}", response_model=StandardResponse[dict]
await add_multiple_equipment_to_session(db_session=db_session, collector_db=collector_db_session, overhaul_session_id=overhaul_session_id, location_tags=overhaul_activty_in.location_tags) )
return StandardResponse(data=None, message='Data created successfully') async def get_scope_equipments(
common: CommonParameters,
overhaul_session: str,
collector_db: CollectorDbSession,
location_tag: Optional[str] = Query(None),
scope_name: Optional[str] = Query(None),
):
"""Get all scope activity pagination."""
# return
data = await get_all(
common=common,
location_tag=location_tag,
scope_name=scope_name,
overhaul_session_id=overhaul_session,
collector_db=collector_db,
)
return StandardResponse(
data=data,
message="Data retrieved successfully",
)
@router.post("/{overhaul_session_id}", response_model=StandardResponse[None])
async def create_overhaul_equipment(
db_session: DbSession,
collector_db_session: CollectorDbSession,
overhaul_activty_in: OverhaulActivityCreate,
overhaul_session_id: UUID,
):
activity = await add_multiple_equipment_to_session(
db_session=db_session,
collector_db=collector_db_session,
overhaul_session_id=overhaul_session_id,
location_tags=overhaul_activty_in.location_tags
)
@router.get('/{overhaul_session}/{assetnum}', response_model=StandardResponse[OverhaulActivityRead]) return StandardResponse(data=None, message="Data created successfully")
async def get_overhaul_equipment(db_session: DbSession, assetnum: str, overhaul_session):
equipment = await get(db_session=db_session, assetnum=assetnum, overhaul_session_id=overhaul_session)
@router.get(
"/{overhaul_session}/{assetnum}",
response_model=StandardResponse[OverhaulActivityRead],
)
async def get_overhaul_equipment(
db_session: DbSession, assetnum: str, overhaul_session
):
equipment = await get(
db_session=db_session, assetnum=assetnum, overhaul_session_id=overhaul_session
)
if not equipment: if not equipment:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') raise HTTPException(
return StandardResponse(data=equipment, message='Data retrieved successfully') status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
return StandardResponse(data=equipment, message="Data retrieved successfully")
# @router.put(
# "/{overhaul_session}/{assetnum}",
# response_model=StandardResponse[OverhaulActivityRead],
# )
# async def update_scope(
# db_session: DbSession,
# scope_equipment_activity_in: OverhaulActivityUpdate,
# assetnum: str,
# ):
# activity = await get(db_session=db_session, assetnum=assetnum)
@router.post('/delete/{overhaul_session}/{location_tag}', response_model=StandardResponse[None], dependencies=[Depends(csrf_protect)]) # if not activity:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="A data with this id does not exist.",
# )
# return StandardResponse(
# data=await update(
# db_session=db_session,
# activity=activity,
# scope_equipment_activity_in=scope_equipment_activity_in,
# ),
# message="Data updated successfully",
# )
@router.post(
"/delete/{overhaul_session}/{location_tag}",
response_model=StandardResponse[None],
dependencies=[Depends(csrf_protect)],
)
async def delete_scope(db_session: DbSession, location_tag: str, overhaul_session:UUID): async def delete_scope(db_session: DbSession, location_tag: str, overhaul_session:UUID):
await remove_equipment_from_session(db_session=db_session, overhaul_session_id=overhaul_session, location_tag=location_tag) await remove_equipment_from_session(db_session=db_session, overhaul_session_id=overhaul_session, location_tag=location_tag)
return StandardResponse(message='Data deleted successfully', data=None)
return StandardResponse(message="Data deleted successfully", data=None)

@ -1,25 +1,31 @@
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
from pydantic import Field from pydantic import Field
from src.models import DefultBase, Pagination from src.models import DefultBase, Pagination
from src.standard_scope.schema import MasterEquipmentTree
from src.workscope_group.schema import ActivityMasterRead from src.workscope_group.schema import ActivityMasterRead
class OverhaulActivityBase(DefultBase): class OverhaulActivityBase(DefultBase):
pass pass
class OverhaulActivityCreate(OverhaulActivityBase): class OverhaulActivityCreate(OverhaulActivityBase):
location_tags: List[str] location_tags: List[str]
class OverhaulActivityUpdate(OverhaulActivityBase): class OverhaulActivityUpdate(OverhaulActivityBase):
material_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) material_cost: Optional[float] = Field(0)
service_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) service_cost: Optional[float] = Field(0)
class OverhaulScope(DefultBase): class OverhaulScope(DefultBase):
type: str type: str
start_date: datetime start_date: datetime
end_date: datetime end_date: datetime
duration_oh: int = Field(..., ge=0, le=1000000) duration_oh: int
class ScopeEquipmentJob(DefultBase): class ScopeEquipmentJob(DefultBase):
job: ActivityMasterRead job: ActivityMasterRead
@ -29,12 +35,16 @@ class OverhaulJob(DefultBase):
class OverhaulActivityRead(OverhaulActivityBase): class OverhaulActivityRead(OverhaulActivityBase):
id: UUID id: UUID
material_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) material_cost: Optional[float] = Field(0)
service_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) service_cost: Optional[float] = Field(0)
location_tag: str location_tag: str
equipment_name: Optional[str] equipment_name: Optional[str]
oh_scope: str oh_scope: str
overhaul_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) overhaul_cost: Optional[float] = Field(0)
# equipment: MasterEquipmentTree
# overhaul_scope: OverhaulScope
# overhaul_jobs: Optional[List[OverhaulJob]] = Field([])
class OverhaulActivityPagination(Pagination): class OverhaulActivityPagination(Pagination):
items: List[OverhaulActivityRead] = [] items: List[OverhaulActivityRead] = []

@ -1,14 +1,23 @@
import asyncio
import datetime import datetime
from typing import List, Optional from typing import List, Optional
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import Select, and_, select from sqlalchemy import Delete, Select, and_, func, select
from sqlalchemy import update as sqlUpdate
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.orm import joinedload, selectinload
from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import CommonParameters, search_filter_sort_paginate from src.database.service import CommonParameters, search_filter_sort_paginate
from src.overhaul_activity.utils import get_material_cost, get_service_cost
from src.utils import update_model from src.utils import update_model
from src.overhaul_scope.model import OverhaulScope
from src.overhaul_scope.service import get as get_session, get_prev_oh from src.overhaul_scope.service import get as get_session, get_prev_oh
from src.standard_scope.model import MasterEquipment, StandardScope from src.standard_scope.model import MasterEquipment, StandardScope
from src.standard_scope.service import get_by_oh_session_id
from src.workscope_group.model import MasterActivity from src.workscope_group.model import MasterActivity
from src.equipment_workscope_group.model import EquipmentWorkscopeGroup from src.equipment_workscope_group.model import EquipmentWorkscopeGroup
from src.overhaul_scope.model import MaintenanceType from src.overhaul_scope.model import MaintenanceType
@ -16,183 +25,547 @@ from src.workscope_group_maintenance_type.model import WorkscopeOHType
from src.overhaul_scope.service import get as get_overhaul from src.overhaul_scope.service import get as get_overhaul
from src.standard_scope.model import EquipmentOHHistory from src.standard_scope.model import EquipmentOHHistory
from .model import OverhaulActivity from .model import OverhaulActivity
from .schema import OverhaulActivityRead, OverhaulActivityUpdate from .schema import (OverhaulActivityCreate, OverhaulActivityRead,
OverhaulActivityUpdate)
import json import json
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.maximo.service import get_cm_cost_summary, get_oh_cost_summary from src.maximo.service import get_cm_cost_summary, get_oh_cost_summary
from src.soft_delete import apply_not_deleted_filter from src.soft_delete import apply_not_deleted_filter
async def get(*, db_session: DbSession, assetnum: str, overhaul_session_id: Optional[UUID]=None) -> Optional[OverhaulActivityRead]: async def get(
query = Select(OverhaulActivity).where(OverhaulActivity.assetnum == assetnum).options(joinedload(OverhaulActivity.equipment)) *, db_session: DbSession, assetnum: str, overhaul_session_id: Optional[UUID] = None
) -> Optional[OverhaulActivityRead]:
"""Returns a document based on the given document id."""
query = (
Select(OverhaulActivity)
.where(OverhaulActivity.assetnum == assetnum)
.options(joinedload(OverhaulActivity.equipment))
)
query = apply_not_deleted_filter(query, OverhaulActivity) query = apply_not_deleted_filter(query, OverhaulActivity)
if overhaul_session_id: if overhaul_session_id:
query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id) query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
result = await db_session.execute(query) result = await db_session.execute(query)
return result.scalar() return result.scalar()
def get_cost_per_failute(): def get_cost_per_failute():
with open('src/overhaul_activity/cost_failure.json', 'r') as f: with open('src/overhaul_activity/cost_failure.json', 'r') as f:
data = json.load(f) data = json.load(f)
return data['data'] return data['data']
async def get_all(*, common: CommonParameters, overhaul_session_id: UUID, location_tag: Optional[str]=None, scope_name: Optional[str]=None, all: bool=False, collector_db: CollectorDbSession): async def get_all(
*,
common: CommonParameters,
overhaul_session_id: UUID,
location_tag: Optional[str] = None,
scope_name: Optional[str] = None,
all: bool = False,
collector_db: CollectorDbSession
):
# query = (
# Select(OverhaulActivity)
# .where(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
# .options(joinedload(OverhaulActivity.equipment).options(joinedload(MasterEquipment.parent).options(joinedload(MasterEquipment.parent))))
# .options(selectinload(OverhaulActivity.overhaul_scope))
# .options(selectinload(OverhaulActivity.overhaul_jobs).options(joinedload(OverhaulJob.scope_equipment_job).options(joinedload(ScopeEquipmentJob.job))))
# )
# if assetnum:
# query = query.filter(OverhaulActivity.assetnum == assetnum).options(
# joinedload(OverhaulActivity.overhaul_scope)
# )
# if scope_name:
# query = query.filter(OverhaulActivity.scope_name == scope_name).options(
# joinedload(OverhaulActivity.overhaul_scope)
# )
# results = await search_filter_sort_paginate(model=query, **common)
##raise Exception(results['items'][0].equipment.parent.__dict__)
# equipments, overhaul = await get_by_oh_session_id(
# db_session=db_session, oh_session_id=overhaul_session_id
# )
overhaul = await get_overhaul(db_session=common['db_session'], overhaul_session_id=overhaul_session_id) overhaul = await get_overhaul(db_session=common['db_session'], overhaul_session_id=overhaul_session_id)
prev_oh_scope = await get_prev_oh(db_session=common['db_session'], overhaul_session=overhaul) prev_oh_scope = await get_prev_oh(db_session=common['db_session'], overhaul_session=overhaul)
query = Select(StandardScope).outerjoin(StandardScope.oh_history).join(StandardScope.workscope_groups).join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag).filter(MaintenanceType.name == overhaul.maintenance_type.name).filter((StandardScope.is_alternating_oh == False) | (StandardScope.oh_history == None) | StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name)).distinct()
query = (
Select(StandardScope)
.outerjoin(StandardScope.oh_history) # Use outerjoin to handle None values
.join(StandardScope.workscope_groups)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(WorkscopeOHType.oh_type)
.join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag)
.filter(MaintenanceType.name == overhaul.maintenance_type.name).filter(
(StandardScope.is_alternating_oh == False) |
(StandardScope.oh_history == None) |
(StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name))
).distinct()
)
query = apply_not_deleted_filter(query, StandardScope) query = apply_not_deleted_filter(query, StandardScope)
if location_tag: if location_tag:
query = query.filter(StandardScope.location_tag == location_tag) query = query.filter(StandardScope.location_tag == location_tag)
common_params = {**common, 'all': all or common.get('all', False)}
paginated_results = await search_filter_sort_paginate(model=query, **common_params) # Use search_filter_sort_paginate for server-side pagination
equipments = paginated_results['items'] # Prioritize the 'all' parameter passed to the function
common_params = {**common, "all": all or common.get("all", False)}
paginated_results = await search_filter_sort_paginate(
model=query,
**common_params
)
equipments = paginated_results["items"]
material_cost = await get_cm_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date) material_cost = await get_cm_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date)
overhaul_cost = await get_oh_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date) overhaul_cost = await get_oh_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date)
results = [] results = []
for equipment in equipments: for equipment in equipments:
if not equipment.master_equipment: if not equipment.master_equipment:
continue continue
cost = material_cost.get(equipment.location_tag, 0) cost = material_cost.get(equipment.location_tag, 0)
oh_cost = overhaul_cost.get(equipment.location_tag, 0) oh_cost = overhaul_cost.get(equipment.location_tag, 0)
res = OverhaulActivityRead(id=equipment.id, material_cost=float(cost), service_cost=equipment.service_cost, overhaul_cost=float(oh_cost), location_tag=equipment.location_tag, equipment_name=equipment.master_equipment.name, oh_scope=overhaul.maintenance_type.name)
res = OverhaulActivityRead(
id=equipment.id,
material_cost=float(cost),
service_cost=equipment.service_cost,
overhaul_cost=float(oh_cost),
location_tag=equipment.location_tag,
equipment_name=equipment.master_equipment.name,
oh_scope=overhaul.maintenance_type.name,
)
results.append(res) results.append(res)
return {**paginated_results, 'items': results}
# Return paginated structure with transformed items
return {
**paginated_results,
"items": results
}
async def get_standard_scope_by_session_id(*, db_session: DbSession, overhaul_session_id: UUID, collector_db: CollectorDbSession): async def get_standard_scope_by_session_id(*, db_session: DbSession, overhaul_session_id: UUID, collector_db: CollectorDbSession):
overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id) overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id)
prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=overhaul) prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=overhaul)
query = Select(StandardScope).outerjoin(StandardScope.oh_history).join(StandardScope.workscope_groups).join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag).filter(MaintenanceType.name == overhaul.maintenance_type.name).filter((StandardScope.is_alternating_oh == False) | (StandardScope.oh_history is None) | StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != overhaul.maintenance_type.name)).distinct()
query = (
Select(StandardScope)
.outerjoin(
StandardScope.oh_history
) # Use outerjoin to handle None values
.join(StandardScope.workscope_groups)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(WorkscopeOHType.oh_type)
.join(
MasterEquipment,
StandardScope.location_tag == MasterEquipment.location_tag,
)
.filter(MaintenanceType.name == overhaul.maintenance_type.name)
.filter(
(StandardScope.is_alternating_oh == False)
| (StandardScope.oh_history is None)
| (
StandardScope.oh_history.has(
EquipmentOHHistory.last_oh_type
!= overhaul.maintenance_type.name
)
)
)
.distinct()
)
data = await db_session.execute(query) data = await db_session.execute(query)
eqs = data.scalars().all() eqs = data.scalars().all()
results = [] results = []
material_cost = await get_cm_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date) material_cost = await get_cm_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date)
#service_cost = get_service_cost(scope=overhaul.maintenance_type.name, total_equipment=len(eqs))
overhaul_cost = await get_oh_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date) overhaul_cost = await get_oh_cost_summary(collector_db=collector_db, last_oh_date=prev_oh_scope.end_date, upcoming_oh_date=overhaul.start_date)
for equipment in eqs: for equipment in eqs:
cost = material_cost.get(equipment.location_tag,0) cost = material_cost.get(equipment.location_tag,0)
oh_cost = overhaul_cost.get(equipment.location_tag,0) oh_cost = overhaul_cost.get(equipment.location_tag,0)
res = OverhaulActivityRead(id=equipment.id, material_cost=float(cost), service_cost=equipment.service_cost, overhaul_cost=float(oh_cost), location_tag=equipment.location_tag, equipment_name=equipment.master_equipment.name if equipment.master_equipment else None, oh_scope=overhaul.maintenance_type.name) res = OverhaulActivityRead(
id=equipment.id,
material_cost=float(cost),
service_cost=equipment.service_cost,
overhaul_cost=float(oh_cost),
location_tag=equipment.location_tag,
equipment_name=equipment.master_equipment.name if equipment.master_equipment else None,
oh_scope=overhaul.maintenance_type.name,
)
results.append(res) results.append(res)
return results return results
async def add_equipment_to_session(*, db_session: DbSession, collector_db: CollectorDbSession, overhaul_session_id: UUID, location_tag: str) -> Optional[StandardScope]:
async def add_equipment_to_session(
*,
db_session: DbSession,
collector_db: CollectorDbSession,
overhaul_session_id: UUID,
location_tag: str
) -> Optional[StandardScope]:
"""
Add a new equipment to an existing overhaul session.
If equipment's workscope already maps to the overhaul type, skip.
Otherwise, attach a dummy workscope group for inclusion.
Args:
db_session: Database session
collector_db: Collector database session
overhaul_session_id: The UUID of the overhaul session
location_tag: The location tag of the equipment to add
Returns:
StandardScope: The newly created or updated StandardScope record, or None if skipped
"""
try: try:
overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id) # Get the overhaul session
master_eq_query = select(MasterEquipment).filter(MasterEquipment.location_tag == location_tag) overhaul = await get_session(
db_session=db_session, overhaul_session_id=overhaul_session_id
)
# Check if MasterEquipment exists
master_eq_query = select(MasterEquipment).filter(
MasterEquipment.location_tag == location_tag
)
master_eq_result = await db_session.execute(master_eq_query) master_eq_result = await db_session.execute(master_eq_query)
master_equipment = master_eq_result.scalar_one_or_none() master_equipment = master_eq_result.scalar_one_or_none()
if not master_equipment: if not master_equipment:
print('Equipment not found in master') print("Equipment not found in master")
return None return None
existing_query = select(StandardScope).filter(StandardScope.location_tag == location_tag)
# Check if equipment already exists in StandardScope
existing_query = select(StandardScope).filter(
StandardScope.location_tag == location_tag
)
existing_result = await db_session.execute(existing_query) existing_result = await db_session.execute(existing_query)
existing_equipment = existing_result.scalar_one_or_none() existing_equipment = existing_result.scalar_one_or_none()
eq_workscope_query = select(EquipmentWorkscopeGroup).join(EquipmentWorkscopeGroup.workscope_group).join(WorkscopeOHType, WorkscopeOHType.workscope_group_id == MasterActivity.id).join(MaintenanceType, MaintenanceType.id == WorkscopeOHType.maintenance_type_id).filter(EquipmentWorkscopeGroup.location_tag == location_tag)
# --- Step 1: Fetch equipment's actual workscope mappings ---
eq_workscope_query = (
select(EquipmentWorkscopeGroup)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(
WorkscopeOHType,
WorkscopeOHType.workscope_group_id == MasterActivity.id,
)
.join(
MaintenanceType,
MaintenanceType.id == WorkscopeOHType.maintenance_type_id,
)
.filter(EquipmentWorkscopeGroup.location_tag == location_tag)
)
eq_workscopes = (await db_session.execute(eq_workscope_query)).scalars().all() eq_workscopes = (await db_session.execute(eq_workscope_query)).scalars().all()
is_already_included = any((ws.workscope_group and any((wot.oh_type.name == overhaul.maintenance_type.name for wot in ws.workscope_group.oh_types)) for ws in eq_workscopes))
# --- Step 2: Check if already included via natural workscope ---
is_already_included = any(
ws.workscope_group
and any(
wot.oh_type.name == overhaul.maintenance_type.name
for wot in ws.workscope_group.oh_types
)
for ws in eq_workscopes
)
if is_already_included: if is_already_included:
# Already belongs to this overhaul naturally → skip
return None return None
# --- Step 3: Handle existing equipment ---
if existing_equipment: if existing_equipment:
dummy_workscope = await get_or_create_dummy_workscope(db_session=db_session, maintenance_type_name=overhaul.maintenance_type.name) # Add dummy workscope if not already attached
dummy_workscope = await get_or_create_dummy_workscope(
db_session=db_session,
maintenance_type_name=overhaul.maintenance_type.name,
)
if dummy_workscope not in existing_equipment.workscope_groups: if dummy_workscope not in existing_equipment.workscope_groups:
dummy_workscope.location_tag = location_tag dummy_workscope.location_tag = location_tag
await db_session.commit() await db_session.commit()
await db_session.refresh(existing_equipment) await db_session.refresh(existing_equipment)
return existing_equipment return existing_equipment
dummy_workscope = await get_or_create_dummy_workscope(db_session=db_session, maintenance_type_name=overhaul.maintenance_type.name)
new_equipment = StandardScope(location_tag=location_tag, is_alternating_oh=True, assigned_date=datetime.date.today()) # --- Step 4: Create new StandardScope for fresh equipment ---
dummy_workscope = await get_or_create_dummy_workscope(
db_session=db_session,
maintenance_type_name=overhaul.maintenance_type.name,
)
new_equipment = StandardScope(
location_tag=location_tag,
is_alternating_oh=True,
assigned_date=datetime.date.today(),
)
dummy_workscope.location_tag = location_tag dummy_workscope.location_tag = location_tag
db_session.add(new_equipment) db_session.add(new_equipment)
await db_session.commit() await db_session.commit()
await db_session.refresh(new_equipment) await db_session.refresh(new_equipment)
return new_equipment return new_equipment
except Exception as e: except Exception as e:
print(f'Error adding equipment {location_tag}: {str(e)}') print(f"Error adding equipment {location_tag}: {str(e)}")
await db_session.rollback() await db_session.rollback()
return None return None
async def get_or_create_dummy_workscope(*, db_session: DbSession, maintenance_type_name: str) -> EquipmentWorkscopeGroup:
dummy_name = f'Included Equipment Workscope - {maintenance_type_name}' async def get_or_create_dummy_workscope(
*,
db_session: DbSession,
maintenance_type_name: str,
) -> EquipmentWorkscopeGroup:
"""
Get or create a dummy workscope group for included equipment.
Args:
db_session: Database session
maintenance_type_name: Name of the maintenance type (e.g., "A", "B")
Returns:
EquipmentWorkscopeGroup: The dummy workscope group
"""
dummy_name = f"Included Equipment Workscope - {maintenance_type_name}"
# --- Step 1: Check if dummy MasterActivity exists ---
query = select(MasterActivity).filter(MasterActivity.workscope == dummy_name) query = select(MasterActivity).filter(MasterActivity.workscope == dummy_name)
result = await db_session.execute(query) result = await db_session.execute(query)
master_activity = result.scalar_one_or_none() master_activity = result.scalar_one_or_none()
if not master_activity: if not master_activity:
master_activity = MasterActivity(workscope=dummy_name) master_activity = MasterActivity(workscope=dummy_name)
db_session.add(master_activity) db_session.add(master_activity)
await db_session.commit() await db_session.commit()
await db_session.refresh(master_activity) await db_session.refresh(master_activity)
# --- Step 2: Ensure WorkscopeOHType link exists ---
mt_query = select(MaintenanceType).filter(MaintenanceType.name == maintenance_type_name) mt_query = select(MaintenanceType).filter(MaintenanceType.name == maintenance_type_name)
mt_result = await db_session.execute(mt_query) mt_result = await db_session.execute(mt_query)
maintenance_type = mt_result.scalar_one() maintenance_type = mt_result.scalar_one()
wo_oh_type_query = select(WorkscopeOHType).filter(and_(WorkscopeOHType.workscope_group_id == master_activity.id, WorkscopeOHType.maintenance_type_id == maintenance_type.id))
wo_oh_type_query = select(WorkscopeOHType).filter(
and_(
WorkscopeOHType.workscope_group_id == master_activity.id,
WorkscopeOHType.maintenance_type_id == maintenance_type.id,
)
)
workscope_oh_type = (await db_session.execute(wo_oh_type_query)).scalar_one_or_none() workscope_oh_type = (await db_session.execute(wo_oh_type_query)).scalar_one_or_none()
if not workscope_oh_type: if not workscope_oh_type:
workscope_oh_type = WorkscopeOHType(id=uuid4(), workscope_group=master_activity, oh_type=maintenance_type) workscope_oh_type = WorkscopeOHType(
id=uuid4(),
workscope_group=master_activity,
oh_type=maintenance_type,
)
db_session.add(workscope_oh_type) db_session.add(workscope_oh_type)
await db_session.commit() await db_session.commit()
await db_session.refresh(workscope_oh_type) await db_session.refresh(workscope_oh_type)
equipment_workscope_group = EquipmentWorkscopeGroup(workscope_group=master_activity)
# --- Step 3: Create new EquipmentWorkscopeGroup ---
equipment_workscope_group = EquipmentWorkscopeGroup(
workscope_group=master_activity,
)
db_session.add(equipment_workscope_group) db_session.add(equipment_workscope_group)
await db_session.commit() await db_session.commit()
await db_session.refresh(equipment_workscope_group) await db_session.refresh(equipment_workscope_group)
return equipment_workscope_group return equipment_workscope_group
async def add_multiple_equipment_to_session(*, db_session: DbSession, collector_db: CollectorDbSession, overhaul_session_id: UUID, location_tags: List[str]) -> List[StandardScope]:
async def add_multiple_equipment_to_session(
*,
db_session: DbSession,
collector_db: CollectorDbSession,
overhaul_session_id: UUID,
location_tags: List[str]
) -> List[StandardScope]:
"""
Add multiple equipment to an existing overhaul session.
Silently skips equipment that already exist or have errors.
Args:
db_session: Database session
collector_db: Collector database session
overhaul_session_id: The UUID of the overhaul session
location_tags: List of location tags to add
Returns:
List[StandardScope]: List of newly created/updated StandardScope records
"""
results = [] results = []
for location_tag in location_tags: for location_tag in location_tags:
equipment = await add_equipment_to_session(db_session=db_session, collector_db=collector_db, overhaul_session_id=overhaul_session_id, location_tag=location_tag) equipment = await add_equipment_to_session(
db_session=db_session,
collector_db=collector_db,
overhaul_session_id=overhaul_session_id,
location_tag=location_tag
)
if equipment: if equipment:
results.append(equipment) results.append(equipment)
return results return results
async def get_all_by_session_id(*, db_session: DbSession, overhaul_session_id): async def get_all_by_session_id(*, db_session: DbSession, overhaul_session_id):
query = Select(OverhaulActivity).where(OverhaulActivity.overhaul_scope_id == overhaul_session_id).options(joinedload(OverhaulActivity.equipment).options(joinedload(MasterEquipment.parent).options(joinedload(MasterEquipment.parent)))).options(selectinload(OverhaulActivity.overhaul_scope)) query = (
Select(OverhaulActivity)
.where(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
.options(joinedload(OverhaulActivity.equipment).options(joinedload(MasterEquipment.parent).options(joinedload(MasterEquipment.parent))))
.options(selectinload(OverhaulActivity.overhaul_scope))
)
query = apply_not_deleted_filter(query, OverhaulActivity) query = apply_not_deleted_filter(query, OverhaulActivity)
results = await db_session.execute(query) results = await db_session.execute(query)
return results.scalars().all() return results.scalars().all()
async def update(*, db_session: DbSession, activity_id: str, overhaul_session_id: Optional[UUID]=None, overhaul_activity_in: OverhaulActivityUpdate):
query = Select(OverhaulActivity).where(OverhaulActivity.assetnum == activity_id).options(joinedload(OverhaulActivity.equipment)).with_for_update()
async def update(
*,
db_session: DbSession,
activity_id: str,
overhaul_session_id: Optional[UUID] = None,
overhaul_activity_in: OverhaulActivityUpdate
):
"""Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update."""
query = (
Select(OverhaulActivity)
.where(OverhaulActivity.assetnum == activity_id)
.options(joinedload(OverhaulActivity.equipment))
.with_for_update()
)
query = apply_not_deleted_filter(query, OverhaulActivity) query = apply_not_deleted_filter(query, OverhaulActivity)
if overhaul_session_id: if overhaul_session_id:
query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id) query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id)
result = await db_session.execute(query) result = await db_session.execute(query)
activity = result.scalar() activity = result.scalar()
if not activity: if not activity:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
await db_session.refresh(activity) await db_session.refresh(activity)
update_data = overhaul_activity_in.model_dump(exclude_defaults=True) update_data = overhaul_activity_in.model_dump(exclude_defaults=True)
update_model(activity, update_data) update_model(activity, update_data)
await db_session.commit() await db_session.commit()
return activity return activity
async def remove_equipment_from_session(*, db_session: DbSession, overhaul_session_id: UUID, location_tag: str) -> bool:
async def remove_equipment_from_session(
*,
db_session: DbSession,
overhaul_session_id: UUID,
location_tag: str
) -> bool:
"""
Remove equipment from a given overhaul session.
Only removes dummy workscope group links; natural workscope mappings are preserved.
Args:
db_session: Database session
overhaul_session_id: The UUID of the overhaul session
location_tag: The location tag of the equipment to remove
Returns:
bool: True if removed, False if skipped (not found or naturally included)
"""
try: try:
overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id) # Get the overhaul session
existing_query = select(StandardScope).filter(StandardScope.location_tag == location_tag) overhaul = await get_session(
db_session=db_session, overhaul_session_id=overhaul_session_id
)
# Find the equipment in StandardScope
existing_query = select(StandardScope).filter(
StandardScope.location_tag == location_tag
)
existing_result = await db_session.execute(existing_query) existing_result = await db_session.execute(existing_query)
equipment = existing_result.scalar_one_or_none() equipment = existing_result.scalar_one_or_none()
if not equipment: if not equipment:
print(f'Equipment {location_tag} not found in StandardScope') print(f"Equipment {location_tag} not found in StandardScope")
return False return False
eq_workscope_query = select(EquipmentWorkscopeGroup).join(EquipmentWorkscopeGroup.workscope_group).join(WorkscopeOHType, WorkscopeOHType.workscope_group_id == MasterActivity.id).join(MaintenanceType, MaintenanceType.id == WorkscopeOHType.maintenance_type_id).filter(EquipmentWorkscopeGroup.location_tag == location_tag).filter(~MasterActivity.workscope.like('Included Equipment Workscope%'))
# --- Step 1: Check if equipment belongs naturally (exclude dummy groups) ---
eq_workscope_query = (
select(EquipmentWorkscopeGroup)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(
WorkscopeOHType,
WorkscopeOHType.workscope_group_id == MasterActivity.id,
)
.join(
MaintenanceType,
MaintenanceType.id == WorkscopeOHType.maintenance_type_id,
)
.filter(EquipmentWorkscopeGroup.location_tag == location_tag)
.filter(~MasterActivity.workscope.like("Included Equipment Workscope%")) # 🚨 exclude dummy
)
eq_workscopes = (await db_session.execute(eq_workscope_query)).scalars().all() eq_workscopes = (await db_session.execute(eq_workscope_query)).scalars().all()
is_natural_inclusion = any((ws.workscope_group and any((wot.oh_type.name == overhaul.maintenance_type.name for wot in ws.workscope_group.oh_types)) for ws in eq_workscopes))
is_natural_inclusion = any(
ws.workscope_group
and any(
wot.oh_type.name == overhaul.maintenance_type.name
for wot in ws.workscope_group.oh_types
)
for ws in eq_workscopes
)
if is_natural_inclusion: if is_natural_inclusion:
print(f'Equipment {location_tag} is naturally part of OH {overhaul.maintenance_type.name}, skip removal') print(f"Equipment {location_tag} is naturally part of OH {overhaul.maintenance_type.name}, skip removal")
return False return False
dummy_name = f'Included Equipment Workscope - {overhaul.maintenance_type.name}'
dummy_group = next((wg for wg in equipment.workscope_groups if wg.workscope_group.workscope == dummy_name), None) # --- Step 2: Remove dummy workscope group for this overhaul ---
dummy_name = f"Included Equipment Workscope - {overhaul.maintenance_type.name}"
dummy_group = next(
(wg for wg in equipment.workscope_groups if wg.workscope_group.workscope == dummy_name),
None,
)
if dummy_group: if dummy_group:
equipment.workscope_groups.remove(dummy_group) equipment.workscope_groups.remove(dummy_group)
await db_session.commit() await db_session.commit()
await db_session.refresh(equipment) await db_session.refresh(equipment)
# --- Step 3 (optional): Delete StandardScope if no groups left ---
if not equipment.workscope_groups: if not equipment.workscope_groups:
await db_session.delete(equipment) await db_session.delete(equipment)
await db_session.commit() await db_session.commit()
print(f'Equipment {location_tag} completely removed from StandardScope') print(f"Equipment {location_tag} completely removed from StandardScope")
return True return True
print(f'No dummy workscope group found for {location_tag} in OH {overhaul.maintenance_type.name}')
print(f"No dummy workscope group found for {location_tag} in OH {overhaul.maintenance_type.name}")
return False return False
except Exception as e: except Exception as e:
print(f'Error removing equipment {location_tag}: {str(e)}') print(f"Error removing equipment {location_tag}: {str(e)}")
await db_session.rollback() await db_session.rollback()
return False return False

@ -1,24 +1,37 @@
from decimal import Decimal, getcontext from decimal import Decimal, getcontext
def get_material_cost(scope, total_equipment): def get_material_cost(scope, total_equipment):
# Set precision to 28 digits (maximum precision for Decimal)
getcontext().prec = 28 getcontext().prec = 28
if not total_equipment:
if not total_equipment: # Guard against division by zero
return float(0) return float(0)
cost = 365539731101 / 10 cost = 365539731101 / 10
if scope == 'B':
result = Decimal(f'{cost}') / Decimal(str(total_equipment)) if scope == "B":
result = Decimal(f"{cost}") / Decimal(str(total_equipment))
return float(result) return float(result)
result = Decimal('8565468127') / Decimal(str(total_equipment)) else:
result = Decimal("8565468127") / Decimal(str(total_equipment))
return float(result) return float(result)
return float(0) return float(0)
def get_service_cost(scope, total_equipment): def get_service_cost(scope, total_equipment):
# Set precision to 28 digits (maximum precision for Decimal)
getcontext().prec = 28 getcontext().prec = 28
if not total_equipment:
if not total_equipment: # Guard against division by zero
return float(0) return float(0)
if scope == 'B':
result = Decimal('36405830225') / Decimal(str(total_equipment)) if scope == "B":
result = Decimal("36405830225") / Decimal(str(total_equipment))
return float(result) return float(result)
result = Decimal('36000000000') / Decimal(str(total_equipment)) else:
result = Decimal("36000000000") / Decimal(str(total_equipment))
return float(result) return float(result)
return float(0) return float(0)

@ -1,8 +1,17 @@
from sqlalchemy import Column, String from sqlalchemy import Column, String
from src.database.core import Base from src.database.core import Base
from src.models import DefaultMixin from src.models import DefaultMixin
class OverhaulGantt(Base, DefaultMixin): class OverhaulGantt(Base, DefaultMixin):
__tablename__ = 'oh_ms_monitoring_spreadsheet' __tablename__ = "oh_ms_monitoring_spreadsheet"
spreadsheet_id = Column(String, nullable=True) spreadsheet_id = Column(String, nullable=True)
spreadsheet_link = Column(String, nullable=True) spreadsheet_link = Column(String, nullable=True)

@ -1,46 +1,143 @@
import re import re
from fastapi import APIRouter, Depends from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
from src.auth.service import Admin
from src.auth.service import CurrentUser
from src.csrf_protect import csrf_protect from src.csrf_protect import csrf_protect
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import CommonParameters
from src.models import StandardResponse from src.models import StandardResponse
from src.overhaul_gantt.model import OverhaulGantt from src.overhaul_gantt.model import OverhaulGantt
from src.overhaul_gantt.schema import OverhaulGanttIn from src.overhaul_gantt.schema import OverhaulGanttIn
# from .schema import (OverhaulScheduleCreate, OverhaulSchedulePagination, OverhaulScheduleUpdate)
from .service import get_gantt_performance_chart from .service import get_gantt_performance_chart
router = APIRouter() router = APIRouter()
@router.get('', response_model=StandardResponse[dict])
@router.get(
"", response_model=StandardResponse[dict]
)
async def get_gantt_performance(db_session: DbSession): async def get_gantt_performance(db_session: DbSession):
"""Get all scope pagination."""
# return
query = select(OverhaulGantt).limit(1) query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none() data = (await db_session.execute(query)).scalar_one_or_none()
results, gantt_data = await get_gantt_performance_chart(spreadsheet_id=data.spreadsheet_id) results, gantt_data = await get_gantt_performance_chart(spreadsheet_id=data.spreadsheet_id)
return StandardResponse(data={'chart_data': results, 'gantt_data': gantt_data}, message='Data retrieved successfully')
@router.get('/spreadsheet', response_model=StandardResponse[dict]) return StandardResponse(
data={
"chart_data": results,
"gantt_data": gantt_data
},
message="Data retrieved successfully",
)
@router.get(
"/spreadsheet", response_model=StandardResponse[dict]
)
async def get_gantt_spreadsheet(db_session: DbSession): async def get_gantt_spreadsheet(db_session: DbSession):
"""Get all scope pagination."""
# return
query = select(OverhaulGantt).limit(1) query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none() data = (await db_session.execute(query)).scalar_one_or_none()
result = {'spreadsheet_id': None, 'spreadsheet_link': None} result = {
"spreadsheet_id": None,
"spreadsheet_link": None
}
if data: if data:
result = {'spreadsheet_id': data.spreadsheet_id, 'spreadsheet_link': data.spreadsheet_link} result = {
return StandardResponse(data=result, message='Data retrieved successfully') "spreadsheet_id": data.spreadsheet_id,
"spreadsheet_link": data.spreadsheet_link
}
return StandardResponse(
data=result,
message="Data retrieved successfully",
)
@router.post('/spreadsheet', response_model=StandardResponse[dict], dependencies=[Depends(csrf_protect)]) @router.post(
async def update_gantt_spreadsheet(db_session: DbSession, spreadsheet_in: OverhaulGanttIn, admin: Admin): "/spreadsheet", response_model=StandardResponse[dict], dependencies=[Depends(csrf_protect)]
match = re.search('/d/([a-zA-Z0-9-_]+)', spreadsheet_in.spreadsheet_link) )
async def update_gantt_spreadsheet(db_session: DbSession, spreadsheet_in: OverhaulGanttIn):
"""Get all scope pagination."""
# return
match = re.search(r"/d/([a-zA-Z0-9-_]+)", spreadsheet_in.spreadsheet_link)
if not match: if not match:
raise ValueError('Invalid Google Sheets URL') raise ValueError("Invalid Google Sheets URL")
spreadsheet_id = match.group(1) spreadsheet_id = match.group(1)
query = select(OverhaulGantt).limit(1) query = select(OverhaulGantt).limit(1)
data = (await db_session.execute(query)).scalar_one_or_none() data = (await db_session.execute(query)).scalar_one_or_none()
if data: if data:
data.spreadsheet_link = spreadsheet_in.spreadsheet_link data.spreadsheet_link = spreadsheet_in.spreadsheet_link
data.spreadsheet_id = spreadsheet_id data.spreadsheet_id = spreadsheet_id
else: else:
spreadsheet = OverhaulGantt(spreadsheet_id=spreadsheet_id, spreadsheet_link=spreadsheet_in.spreadsheet_link) spreadsheet = OverhaulGantt(
spreadsheet_id=spreadsheet_id,
spreadsheet_link=spreadsheet_in.spreadsheet_link
)
db_session.add(spreadsheet) db_session.add(spreadsheet)
await db_session.commit() await db_session.commit()
if data: if data:
result = {'spreadsheet_id': spreadsheet_id} result = {
return StandardResponse(data=result, message='Data retrieved successfully') "spreadsheet_id": spreadsheet_id
}
return StandardResponse(
data=result,
message="Data retrieved successfully",
)
# @router.post("", response_model=StandardResponse[None])
# async def create_overhaul_equipment_jobs(
# db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate
# ):
# await create(
# db_session=db_session,
# overhaul_job_in=overhaul_job_in,
# )
# return StandardResponse(
# data=None,
# message="Data created successfully",
# )
# @router.put("/{overhaul_job_id}", response_model=StandardResponse[None])
# async def update_overhaul_schedule(
# db_session: DbSession, overhaul_job_id: str, overhaul_job_in: OverhaulScheduleUpdate
# ):
# await update(db_session=db_session, overhaul_schedule_id=overhaul_job_id, overhaul_job_in=overhaul_job_in)
# return StandardResponse(
# data=None,
# message="Data updated successfully",
# )
# @router.delete("/{overhaul_job_id}", response_model=StandardResponse[None])
# async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id):
# await delete(db_session=db_session, overhaul_schedule_id=overhaul_job_id)
# return StandardResponse(
# data=None,
# message="Data deleted successfully",
# )

@ -1,5 +1,48 @@
# from datetime import datetime
# from typing import List, Optional
# from uuid import UUID
# from pydantic import Field
# from src.models import DefultBase, Pagination
# from src.overhaul_scope.schema import ScopeRead
# from src.scope_equipment_job.schema import ScopeEquipmentJobRead
# from src.job.schema import ActivityMasterRead
from pydantic import Field from pydantic import Field
from src.models import DefultBase from src.models import DefultBase
class OverhaulGanttIn(DefultBase): class OverhaulGanttIn(DefultBase):
spreadsheet_link: str = Field(...) spreadsheet_link: str = Field(...)
# class OverhaulScheduleCreate(OverhaulScheduleBase):
# year: int
# plan_duration: Optional[int] = Field(None)
# planned_outage: Optional[int] = Field(None)
# actual_shutdown: Optional[int] = Field(None)
# start: datetime
# finish: datetime
# remark: Optional[str] = Field(None)
# class OverhaulScheduleUpdate(OverhaulScheduleBase):
# start: datetime
# finish: datetime
# class OverhaulScheduleRead(OverhaulScheduleBase):
# id: UUID
# year: int
# plan_duration: Optional[int]
# planned_outage: Optional[int]
# actual_shutdown: Optional[int]
# start: datetime
# finish: datetime
# remark: Optional[str]
# class OverhaulSchedulePagination(Pagination):
# items: List[OverhaulScheduleRead] = []

@ -1,34 +1,112 @@
from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import Delete, Select, func
from sqlalchemy.orm import selectinload
# from .model import OverhaulSchedule
# from .schema import OverhaulScheduleCreate, OverhaulScheduleUpdate
from .utils import fetch_all_sections, get_google_creds, get_spreatsheed_service, process_spreadsheet_data from .utils import fetch_all_sections, get_google_creds, get_spreatsheed_service, process_spreadsheet_data
async def get_gantt_performance_chart(*, spreadsheet_id='1gZXuwA97zU1v4QBv56wKeiqadc6skHUucGKYG8qVFRk'): # async def get_all(*, common):
# """Returns all documents."""
# query = Select(OverhaulSchedule).order_by(OverhaulSchedule.start.desc())
# results = await search_filter_sort_paginate(model=query, **common)
# return results
# async def create(
# *, db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate
# ):
# schedule = OverhaulSchedule(**overhaul_job_in.model_dump())
# db_session.add(schedule)
# await db_session.commit()
# return schedule
# async def update(*, db_session: DbSession, overhaul_schedule_id: str, overhaul_job_in: OverhaulScheduleUpdate):
# """Updates a document."""
# data = overhaul_job_in.model_dump()
# overhaul_schedule = await db_session.get(OverhaulSchedule, overhaul_schedule_id)
# update_data = overhaul_job_in.model_dump(exclude_defaults=True)
# for field in data:
# if field in update_data:
# setattr(overhaul_schedule, field, update_data[field])
# await db_session.commit()
# return overhaul_schedule
# async def delete(*, db_session: DbSession, overhaul_schedule_id: str):
# """Deletes a document."""
# query = Delete(OverhaulSchedule).where(OverhaulSchedule.id == overhaul_schedule_id)
# await db_session.execute(query)
# await db_session.commit()
async def get_gantt_performance_chart(*, spreadsheet_id = "1gZXuwA97zU1v4QBv56wKeiqadc6skHUucGKYG8qVFRk"):
creds = get_google_creds() creds = get_google_creds()
RANGE_NAME = "'SUMMARY'!K34:AZ38" RANGE_NAME = "'SUMMARY'!K34:AZ38" # Or just "2024 schedule"
GANTT_DATA_NAME = 'ACTUAL PROGRESS' GANTT_DATA_NAME = "ACTUAL PROGRESS"
try: try:
service = get_spreatsheed_service(creds) service = get_spreatsheed_service(creds)
sheet = service.spreadsheets() sheet = service.spreadsheets()
response = sheet.values().get(spreadsheetId=spreadsheet_id, range=RANGE_NAME).execute()
values = response.get('values', []) response = sheet.values().get(
spreadsheetId=spreadsheet_id,
range=RANGE_NAME
).execute()
values = response.get("values", [])
if len(values) < 4: if len(values) < 4:
raise Exception('Spreadsheet format invalid: need 4 rows (DAY, DATE, PLAN, ACTUAL).') raise Exception("Spreadsheet format invalid: need 4 rows (DAY, DATE, PLAN, ACTUAL).")
# Extract rows
day_row = values[0][1:] day_row = values[0][1:]
date_row = values[1][1:] date_row = values[1][1:]
plan_row = values[3][1:] plan_row = values[3][1:]
actual_row = values[4][1:] actual_row = values[4][1:]
total_days = len(day_row) total_days = len(day_row)
date_row += [''] * (total_days - len(date_row))
plan_row += [''] * (total_days - len(plan_row)) # PAD rows so lengths match day count
actual_row += [''] * (total_days - len(actual_row)) date_row += [""] * (total_days - len(date_row))
plan_row += [""] * (total_days - len(plan_row))
actual_row += [""] * (total_days - len(actual_row))
results = [] results = []
for i in range(total_days): for i in range(total_days):
day = day_row[i] day = day_row[i]
date = date_row[i] date = date_row[i]
plan = plan_row[i] plan = plan_row[i]
actual = actual_row[i] if actual_row[i] else '0%' actual = actual_row[i] if actual_row[i] else "0%" # <-- FIX HERE
results.append({'day': day, 'date': date, 'plan': plan, 'actual': actual})
results.append({
"day": day,
"date": date,
"plan": plan,
"actual": actual
})
except Exception as e: except Exception as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
processed_data = process_spreadsheet_data(results) processed_data = process_spreadsheet_data(results)
gantt_data = fetch_all_sections(service=service, spreadsheet_id=spreadsheet_id, sheet_name=GANTT_DATA_NAME) gantt_data = fetch_all_sections(service=service, spreadsheet_id=spreadsheet_id, sheet_name=GANTT_DATA_NAME)
return (processed_data, gantt_data)
return processed_data, gantt_data

@ -1,12 +1,22 @@
import urllib
from google.oauth2.service_account import Credentials from google.oauth2.service_account import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build from googleapiclient.discovery import build
SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly'] from googleapiclient.errors import HttpError
SCOPES = ["https://www.googleapis.com/auth/spreadsheets.readonly"]
def get_spreatsheed_service(credentials): def get_spreatsheed_service(credentials):
return build('sheets', 'v4', credentials=credentials, cache_discovery=False) return build("sheets", "v4", credentials=credentials, cache_discovery=False)
def get_google_creds(): def get_google_creds():
return Credentials.from_service_account_file('credentials.json', scopes=SCOPES) creds = None
creds = Credentials.from_service_account_file("credentials.json", scopes=SCOPES)
return creds
def process_spreadsheet_data(rows): def process_spreadsheet_data(rows):
processed_data = [] processed_data = []
@ -14,61 +24,107 @@ def process_spreadsheet_data(rows):
processed_row = convert_spreadsheet_data(row) processed_row = convert_spreadsheet_data(row)
processed_data.append(processed_row) if processed_row else None processed_data.append(processed_row) if processed_row else None
return processed_data return processed_data
from datetime import datetime from datetime import datetime
from datetime import datetime from datetime import datetime
def convert_spreadsheet_data(data, default_year=None): def convert_spreadsheet_data(data, default_year=None):
if not data.get('day') or not data['day'].isdigit(): """
Convert spreadsheet row into structured data.
Expected keys: day, date, plan, actual
"""
# Skip header or invalid rows
if not data.get("day") or not data["day"].isdigit():
return None return None
result = {} result = {}
result['day'] = int(data['day'])
# Convert day
result["day"] = int(data["day"])
# Determine default year
if default_year is None: if default_year is None:
default_year = datetime.now().year default_year = datetime.now().year
date_str = data.get('date', '').strip()
date_str = data.get("date", "").strip()
# ---------- DATE HANDLING ----------
# Accept formats like: "Nov 20", "Dec 3", "Jan 1"
parsed_date = None parsed_date = None
if date_str: if date_str:
try: try:
parsed_date = datetime.strptime(f'{date_str} {default_year}', '%b %d %Y') parsed_date = datetime.strptime(f"{date_str} {default_year}", "%b %d %Y")
except ValueError: except ValueError:
try: try:
parsed_date = datetime.strptime(f'{date_str} {default_year}', '%B %d %Y') parsed_date = datetime.strptime(f"{date_str} {default_year}", "%B %d %Y")
except: except:
parsed_date = None parsed_date = None
if parsed_date and parsed_date.month == 1 and ('Dec' in data.get('date', '')):
# YEAR ROLLOVER (Dec → Jan next year)
if parsed_date and parsed_date.month == 1 and "Dec" in data.get("date", ""):
parsed_date = parsed_date.replace(year=default_year + 1) parsed_date = parsed_date.replace(year=default_year + 1)
result['date'] = parsed_date
result["date"] = parsed_date
# ---------- PERCENT HANDLING ----------
def parse_percent(value): def parse_percent(value):
if not value: if not value:
return 0.0 return 0.0
v = value.strip().replace(',', '.').replace('%', '') v = value.strip().replace(",", ".").replace("%", "")
try: try:
return float(v) / 100.0 return float(v) / 100.0
except: except:
return 0.0 return 0.0
result['plan'] = parse_percent(data.get('plan', '0'))
result['actual'] = parse_percent(data.get('actual', '0')) result["plan"] = parse_percent(data.get("plan", "0"))
result['gap'] = result['actual'] - result['plan'] result["actual"] = parse_percent(data.get("actual", "0"))
# Gap calculation
result["gap"] = result["actual"] - result["plan"]
return result return result
def fetch_all_sections(service, spreadsheet_id, sheet_name): def fetch_all_sections(service, spreadsheet_id, sheet_name):
result = service.spreadsheets().values().get(spreadsheetId=spreadsheet_id, range=f'{sheet_name}!A5:M5000').execute() # Fetch a wide range including columns AL
values = result.get('values', []) result = service.spreadsheets().values().get(
spreadsheetId=spreadsheet_id,
range=f"{sheet_name}!A5:M5000"
).execute()
values = result.get("values", [])
if not values: if not values:
raise ValueError('No data found in sheet') raise ValueError("No data found in sheet")
data = [] data = []
current_section = None current_section = None
current_subsystem = None current_subsystem = None
for row in values: for row in values:
row += [''] * (13 - len(row)) # Pad missing columns to avoid index errors
row += [""] * (13 - len(row))
colA, colB, colC, colD, colE, colF, colG, colH, colI, colJ, colK, colL, colM = row colA, colB, colC, colD, colE, colF, colG, colH, colI, colJ, colK, colL, colM = row
if colC and (not colD) and (not colE):
# Detect a SECTION — bold blue rows in Column C
if colC and not colD and not colE:
current_section = colC.strip() current_section = colC.strip()
current_subsystem = None current_subsystem = None
continue continue
if colD and (not colE):
# Detect a SUBSYSTEM — indented header in Column D
if colD and not colE:
current_subsystem = colD.strip() current_subsystem = colD.strip()
continue continue
# Detect a TASK — Column E populated
if colE: if colE:
task = colE.strip() task = colE.strip()
pic = colF.strip() pic = colF.strip()
@ -78,21 +134,64 @@ def fetch_all_sections(service, spreadsheet_id, sheet_name):
plan = colK.strip() plan = colK.strip()
actual = colL.strip() actual = colL.strip()
gap = colM.strip() gap = colM.strip()
data.append({'system': current_section, 'subsystem': current_subsystem, 'task': task, 'PIC': pic, 'start_date': start_date, 'end_date': finish_date, 'duration': int(duration), 'plan': plan, 'actual': actual, 'gap': gap})
data.append({
"system": current_section,
"subsystem": current_subsystem,
"task": task,
"PIC": pic,
"start_date": start_date,
"end_date": finish_date,
"duration": int(duration),
"plan": plan,
"actual": actual,
"gap": gap
})
return data return data
def indo_formatted_date(date_str, base_year=2025): def indo_formatted_date(date_str, base_year=2025):
eng_to_indo_month = {'Jan': 'Januari', 'Feb': 'Februari', 'Mar': 'Maret', 'Apr': 'April', 'May': 'Mei', 'Jun': 'Juni', 'Jul': 'Juli', 'Aug': 'Agustus', 'Sep': 'September', 'Oct': 'Oktober', 'Nov': 'November', 'Dec': 'Desember'} """
indo_days = {0: 'Senin', 1: 'Selasa', 2: 'Rabu', 3: 'Kamis', 4: 'Jumat', 5: 'Sabtu', 6: 'Minggu'} Convert short date like 'Nov 20', '30-Dec', 'Jan 1'
if '-' in date_str: into: 'Rabu, November 20, 2025'
d, m = date_str.split('-') If month is January, year becomes 2026.
date_str = f'{m} {d}' """
# Month mappings
eng_to_indo_month = {
"Jan": "Januari", "Feb": "Februari", "Mar": "Maret", "Apr": "April",
"May": "Mei", "Jun": "Juni", "Jul": "Juli", "Aug": "Agustus",
"Sep": "September", "Oct": "Oktober", "Nov": "November", "Dec": "Desember"
}
indo_days = {
0: "Senin",
1: "Selasa",
2: "Rabu",
3: "Kamis",
4: "Jumat",
5: "Sabtu",
6: "Minggu"
}
# Normalize formats ("30-Dec" → "Dec 30")
if "-" in date_str:
d, m = date_str.split("-")
date_str = f"{m} {d}"
# Parse using English abbreviation
try: try:
dt = datetime.strptime(f'{date_str} {base_year}', '%b %d %Y') dt = datetime.strptime(f"{date_str} {base_year}", "%b %d %Y")
except: except:
return None return None
# Handle year rollover (Jan -> next year)
if dt.month == 1: if dt.month == 1:
dt = dt.replace(year=base_year + 1) dt = dt.replace(year=base_year + 1)
# Convert to Indonesian components
day_name = indo_days[dt.weekday()] day_name = indo_days[dt.weekday()]
month_name = eng_to_indo_month[dt.strftime('%b')] month_name = eng_to_indo_month[dt.strftime("%b")]
return f'{day_name}, {month_name} {dt.day}, {dt.year}'
return f"{day_name}, {month_name} {dt.day}, {dt.year}"

@ -1 +1,43 @@
from sqlalchemy import (UUID, Column, DateTime, Float, ForeignKey, Integer,
String)
from sqlalchemy.orm import relationship
from src.database.core import Base
from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
# class EquipmentWorkscopeGroup(Base, DefaultMixin):
# __tablename__ = "oh_tr_equipment_workscope_group"
# # overhaul_activity_id = Column(
# # UUID(as_uuid=True), ForeignKey("oh_tr_overhaul_activity.id"), nullable=False
# # )
# # scope_equipment_job_id = Column(
# # UUID(as_uuid=True),
# # ForeignKey("oh_ms_scope_equipment_job.id", ondelete="cascade"),
# # nullable=False,
# # )
# # notes = Column(String, nullable=True)
# # status = Column(String, nullable=True, default="pending")
# workscope_group_id = Column(
# UUID(as_uuid=True), ForeignKey("oh_ms_workscope_group.id"), nullable=False
# )
# location_tag = Column(String, nullable=False)
# equipment = relationship(
# "StandardScope",
# lazy="selectin",
# primaryjoin="and_(OverhaulJob.location_tag == foreign(StandardScope.location_tag))",
# uselist=False, # Add this if it's a one-to-one relationship
# )
# workscope_group = relationship("MasterActivity", lazy="selectin", back_populates="equipment_workscope_groups")
# # scope_equipment_job = relationship(
# # "ScopeEquipmentJob", lazy="raise", back_populates="overhaul_jobs"
# # )
# # overhaul_activity = relationship("OverhaulActivity", lazy="raise", back_populates="overhaul_jobs")

@ -1,23 +1,91 @@
from typing import Optional from typing import List, Optional
from fastapi import APIRouter, Query
from fastapi import APIRouter, HTTPException, status, Query
from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import CommonParameters from src.database.service import CommonParameters
from src.models import StandardResponse from src.models import StandardResponse
from .schema import OverhaulJobCreate, OverhaulJobPagination
from .schema import (OverhaulJobBase, OverhaulJobCreate, OverhaulJobPagination,
OverhaulJobRead)
from .service import create, delete, get_all from .service import create, delete, get_all
router = APIRouter() router = APIRouter()
@router.get('/{location_tag}', response_model=StandardResponse[OverhaulJobPagination])
@router.get(
"/{location_tag}", response_model=StandardResponse[OverhaulJobPagination]
)
async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str] = Query(None)): async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str] = Query(None)):
"""Get all scope pagination."""
# return
results = await get_all(common=common, location_tag=location_tag, scope=scope) results = await get_all(common=common, location_tag=location_tag, scope=scope)
return StandardResponse(data=results, message='Data retrieved successfully')
@router.post('/{overhaul_equipment_id}', response_model=StandardResponse[None]) return StandardResponse(
async def create_overhaul_equipment_jobs(db_session: DbSession, overhaul_equipment_id, overhaul_job_in: OverhaulJobCreate): data=results,
await create(db_session=db_session, overhaul_equipment_id=overhaul_equipment_id, overhaul_job_in=overhaul_job_in) message="Data retrieved successfully",
return StandardResponse(data=None, message='Data created successfully') )
@router.post('/delete/{overhaul_job_id}', response_model=StandardResponse[None]) @router.post("/{overhaul_equipment_id}", response_model=StandardResponse[None])
async def create_overhaul_equipment_jobs(
db_session: DbSession, overhaul_equipment_id, overhaul_job_in: OverhaulJobCreate
):
"""Get all scope activity pagination."""
# return
await create(
db_session=db_session,
overhaul_equipment_id=overhaul_equipment_id,
overhaul_job_in=overhaul_job_in,
)
return StandardResponse(
data=None,
message="Data created successfully",
)
@router.post("/delete/{overhaul_job_id}", response_model=StandardResponse[None])
async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id): async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id):
await delete(db_session=db_session, overhaul_job_id=overhaul_job_id) await delete(db_session=db_session, overhaul_job_id=overhaul_job_id)
return StandardResponse(data=None, message='Data deleted successfully')
return StandardResponse(
data=None,
message="Data deleted successfully",
)
# @router.post("", response_model=StandardResponse[List[str]])
# async def create_scope(db_session: DbSession, scope_in: OverhaulJobCreate):
# overhaul_job = await create(db_session=db_session, scope_in=scope_in)
# return StandardResponse(data=overhaul_job, message="Data created successfully")
# @router.put("/{scope_id}", response_model=StandardResponse[ScopeRead])
# async def update_scope(db_session: DbSession, scope_id: str, scope_in: ScopeUpdate, current_user: CurrentUser):
# scope = await get(db_session=db_session, scope_id=scope_id)
# if not scope:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="A data with this id does not exist.",
# )
# return StandardResponse(data=await update(db_session=db_session, scope=scope, scope_in=scope_in), message="Data updated successfully")
# @router.delete("/{scope_id}", response_model=StandardResponse[ScopeRead])
# async def delete_scope(db_session: DbSession, scope_id: str):
# scope = await get(db_session=db_session, scope_id=scope_id)
# if not scope:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail=[{"msg": "A data with this id does not exist."}],
# )
# await delete(db_session=db_session, scope_id=scope_id)
# return StandardResponse(message="Data deleted successfully", data=scope)

@ -1,18 +1,26 @@
from datetime import datetime
from typing import List, Optional from typing import List, Optional
from uuid import UUID from uuid import UUID
from pydantic import Field
from src.models import DefultBase, Pagination from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead from src.overhaul_scope.schema import ScopeRead
from src.scope_equipment_job.schema import ScopeEquipmentJobRead
from src.job.schema import ActivityMasterRead from src.job.schema import ActivityMasterRead
class OverhaulJobBase(DefultBase): class OverhaulJobBase(DefultBase):
pass pass
class OverhaulJobCreate(OverhaulJobBase): class OverhaulJobCreate(OverhaulJobBase):
job_ids: Optional[List[UUID]] = [] job_ids: Optional[List[UUID]] = []
class OverhaulJobUpdate(OverhaulJobBase): class OverhaulJobUpdate(OverhaulJobBase):
pass pass
class OverhaulActivity(DefultBase): class OverhaulActivity(DefultBase):
id: UUID id: UUID
overhaul_scope_id: UUID overhaul_scope_id: UUID
@ -26,5 +34,7 @@ class OverhaulJobRead(OverhaulJobBase):
scope_equipment_job: ScopeEquipment scope_equipment_job: ScopeEquipment
overhaul_activity: OverhaulActivity overhaul_activity: OverhaulActivity
class OverhaulJobPagination(Pagination): class OverhaulJobPagination(Pagination):
items: List[OverhaulJobRead] = [] items: List[OverhaulJobRead] = []

@ -1,8 +1,15 @@
from typing import Optional from typing import Optional
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import Select from sqlalchemy import Delete, Select, func
from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import search_filter_sort_paginate from src.database.service import search_filter_sort_paginate
# from src.scope_equipment_job.model import ScopeEquipmentJob
# from src.overhaul_activity.model import OverhaulActivity
from src.workscope_group.model import MasterActivity from src.workscope_group.model import MasterActivity
from src.workscope_group_maintenance_type.model import WorkscopeOHType from src.workscope_group_maintenance_type.model import WorkscopeOHType
from src.overhaul_scope.model import MaintenanceType from src.overhaul_scope.model import MaintenanceType
@ -10,32 +17,107 @@ from src.equipment_workscope_group.model import EquipmentWorkscopeGroup
from .schema import OverhaulJobCreate from .schema import OverhaulJobCreate
from src.soft_delete import apply_not_deleted_filter, soft_delete_record from src.soft_delete import apply_not_deleted_filter, soft_delete_record
async def get_all(*, common, location_tag: str, scope: Optional[str] = None): async def get_all(*, common, location_tag: str, scope: Optional[str] = None):
query = Select(EquipmentWorkscopeGroup).where(EquipmentWorkscopeGroup.location_tag == location_tag) """Returns all documents."""
query = (
Select(EquipmentWorkscopeGroup)
.where(EquipmentWorkscopeGroup.location_tag == location_tag)
)
query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup) query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup)
if scope: if scope:
query = query.join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).filter(MaintenanceType.name == scope) query = (
return await search_filter_sort_paginate(model=query, **common) query
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(WorkscopeOHType.oh_type)
.filter(MaintenanceType.name == scope)
)
results = await search_filter_sort_paginate(model=query, **common)
return results
async def create(*, db_session: DbSession, overhaul_equipment_id, overhaul_job_in: OverhaulJobCreate): async def create(
*, db_session: DbSession, overhaul_equipment_id, overhaul_job_in: OverhaulJobCreate
):
overhaul_jobs = [] overhaul_jobs = []
if not overhaul_equipment_id: if not overhaul_equipment_id:
raise ValueError('assetnum parameter is required') raise ValueError("assetnum parameter is required")
equipment_stmt = Select(OverhaulJob).where(OverhaulJob.overhaul_activity_id == overhaul_equipment_id)
await db_session.scalar(equipment_stmt) equipment_stmt = Select(OverhaulJob).where(
OverhaulJob.overhaul_activity_id == overhaul_equipment_id
)
equipment = await db_session.scalar(equipment_stmt)
for job_id in overhaul_job_in.job_ids: for job_id in overhaul_job_in.job_ids:
overhaul_equipment_job = OverhaulJob(overhaul_activity_id=overhaul_equipment_id, scope_equipment_job_id=job_id) overhaul_equipment_job = OverhaulJob(
overhaul_activity_id=overhaul_equipment_id, scope_equipment_job_id=job_id
)
overhaul_jobs.append(overhaul_equipment_job) overhaul_jobs.append(overhaul_equipment_job)
db_session.add_all(overhaul_jobs) db_session.add_all(overhaul_jobs)
await db_session.commit() await db_session.commit()
return overhaul_job_in.job_ids return overhaul_job_in.job_ids
async def delete(*, db_session: DbSession, overhaul_job_id: str) -> bool:
async def delete(
*,
db_session: DbSession,
overhaul_job_id: str,
) -> bool:
"""
Deletes a scope job and returns success status.
Args:
db_session: Database session
scope_job_id: ID of the scope job to delete
user_id: ID of user performing the deletion
Returns:
bool: True if deletion was successful, False otherwise
Raises:
NotFoundException: If scope job doesn't exist
AuthorizationError: If user lacks delete permission
"""
try: try:
# Soft-delete the record
deleted = await soft_delete_record(db_session, EquipmentWorkscopeGroup, overhaul_job_id) deleted = await soft_delete_record(db_session, EquipmentWorkscopeGroup, overhaul_job_id)
if not deleted: if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
return True return True
except Exception:
except Exception as e:
await db_session.rollback() await db_session.rollback()
raise raise
# async def update(*, db_session: DbSession, scope: OverhaulScope, scope_in: ScopeUpdate):
# """Updates a document."""
# data = scope_in.model_dump()
# update_data = scope_in.model_dump(exclude_defaults=True)
# for field in data:
# if field in update_data:
# setattr(scope, field, update_data[field])
# await db_session.commit()
# return scope
# async def delete(*, db_session: DbSession, scope_id: str):
# """Deletes a document."""
# query = Delete(OverhaulScope).where(OverhaulScope.id == scope_id)
# await db_session.execute(query)
# await db_session.commit()

@ -1,13 +1,18 @@
from sqlalchemy import Column, DateTime, Integer, String from sqlalchemy import (UUID, Column, DateTime, Float, ForeignKey, Integer,
String)
from sqlalchemy.orm import relationship
from src.database.core import Base from src.database.core import Base
from src.models import DefaultMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
class OverhaulSchedule(Base, DefaultMixin): class OverhaulSchedule(Base, DefaultMixin):
__tablename__ = 'rp_oh_schedule' __tablename__ = "rp_oh_schedule"
year = Column(Integer, nullable=False) year = Column(Integer, nullable=False)
plan_duration = Column(Integer, nullable=True) plan_duration = Column(Integer, nullable=True)
planned_outage = Column(Integer, nullable=True) planned_outage = Column(Integer, nullable=True)
actual_shutdown = Column(Integer, nullable=True) actual_shutdown = Column(Integer, nullable=True)
start = Column(DateTime(timezone=True)) start = Column(DateTime(timezone=True)) # This will be TIMESTAMP WITH TIME ZONE
finish = Column(DateTime(timezone=True)) finish = Column(DateTime(timezone=True))
remark = Column(String, nullable=True) remark = Column(String, nullable=True)

@ -1,27 +1,63 @@
from fastapi import APIRouter from typing import List, Optional
from fastapi import APIRouter, HTTPException, status
from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import CommonParameters from src.database.service import CommonParameters
from src.models import StandardResponse from src.models import StandardResponse
from .schema import OverhaulScheduleCreate, OverhaulSchedulePagination, OverhaulScheduleUpdate
from .schema import (OverhaulScheduleCreate, OverhaulSchedulePagination, OverhaulScheduleUpdate)
from .service import create, get_all, delete, update from .service import create, get_all, delete, update
router = APIRouter() router = APIRouter()
@router.get('', response_model=StandardResponse[OverhaulSchedulePagination])
@router.get(
"", response_model=StandardResponse[OverhaulSchedulePagination]
)
async def get_schedules(common: CommonParameters): async def get_schedules(common: CommonParameters):
"""Get all scope pagination."""
# return
results = await get_all(common=common) results = await get_all(common=common)
return StandardResponse(data=results, message='Data retrieved successfully')
@router.post('', response_model=StandardResponse[None])
async def create_overhaul_equipment_jobs(db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate):
await create(db_session=db_session, overhaul_job_in=overhaul_job_in)
return StandardResponse(data=None, message='Data created successfully')
@router.post('/update/{overhaul_job_id}', response_model=StandardResponse[None]) return StandardResponse(
async def update_overhaul_schedule(db_session: DbSession, overhaul_job_id: str, overhaul_job_in: OverhaulScheduleUpdate): data=results,
message="Data retrieved successfully",
)
@router.post("", response_model=StandardResponse[None])
async def create_overhaul_equipment_jobs(
db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate
):
await create(
db_session=db_session,
overhaul_job_in=overhaul_job_in,
)
return StandardResponse(
data=None,
message="Data created successfully",
)
@router.post("/update/{overhaul_job_id}", response_model=StandardResponse[None])
async def update_overhaul_schedule(
db_session: DbSession, overhaul_job_id: str, overhaul_job_in: OverhaulScheduleUpdate
):
await update(db_session=db_session, overhaul_schedule_id=overhaul_job_id, overhaul_job_in=overhaul_job_in) await update(db_session=db_session, overhaul_schedule_id=overhaul_job_id, overhaul_job_in=overhaul_job_in)
return StandardResponse(data=None, message='Data updated successfully')
@router.post('/delete/{overhaul_job_id}', response_model=StandardResponse[None]) return StandardResponse(
data=None,
message="Data updated successfully",
)
@router.post("/delete/{overhaul_job_id}", response_model=StandardResponse[None])
async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id): async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id):
await delete(db_session=db_session, overhaul_schedule_id=overhaul_job_id) await delete(db_session=db_session, overhaul_schedule_id=overhaul_job_id)
return StandardResponse(data=None, message='Data deleted successfully')
return StandardResponse(
data=None,
message="Data deleted successfully",
)

@ -1,12 +1,18 @@
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
from uuid import UUID from uuid import UUID
from pydantic import Field from pydantic import Field
from src.models import DefultBase, Pagination from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead
from src.scope_equipment_job.schema import ScopeEquipmentJobRead
from src.job.schema import ActivityMasterRead
class OverhaulScheduleBase(DefultBase): class OverhaulScheduleBase(DefultBase):
pass pass
class OverhaulScheduleCreate(OverhaulScheduleBase): class OverhaulScheduleCreate(OverhaulScheduleBase):
year: int year: int
plan_duration: Optional[int] = Field(None) plan_duration: Optional[int] = Field(None)
@ -16,10 +22,12 @@ class OverhaulScheduleCreate(OverhaulScheduleBase):
finish: datetime finish: datetime
remark: Optional[str] = Field(None) remark: Optional[str] = Field(None)
class OverhaulScheduleUpdate(OverhaulScheduleBase): class OverhaulScheduleUpdate(OverhaulScheduleBase):
start: datetime start: datetime
finish: datetime finish: datetime
class OverhaulScheduleRead(OverhaulScheduleBase): class OverhaulScheduleRead(OverhaulScheduleBase):
id: UUID id: UUID
year: int year: int
@ -30,5 +38,7 @@ class OverhaulScheduleRead(OverhaulScheduleBase):
finish: datetime finish: datetime
remark: Optional[str] remark: Optional[str]
class OverhaulSchedulePagination(Pagination): class OverhaulSchedulePagination(Pagination):
items: List[OverhaulScheduleRead] = [] items: List[OverhaulScheduleRead] = []

@ -1,33 +1,60 @@
from sqlalchemy import Select from typing import Optional
from fastapi import HTTPException, status
from sqlalchemy import Delete, Select, func
from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import search_filter_sort_paginate from src.database.service import search_filter_sort_paginate
from src.utils import update_model from src.utils import update_model
from src.scope_equipment_job.model import ScopeEquipmentJob
from src.overhaul_activity.model import OverhaulActivity
from src.soft_delete import apply_not_deleted_filter, soft_delete_record from src.soft_delete import apply_not_deleted_filter, soft_delete_record
from .model import OverhaulSchedule from .model import OverhaulSchedule
from .schema import OverhaulScheduleCreate, OverhaulScheduleUpdate from .schema import OverhaulScheduleCreate, OverhaulScheduleUpdate
async def get_all(*, common): async def get_all(*, common):
"""Returns all documents."""
query = Select(OverhaulSchedule).order_by(OverhaulSchedule.start.desc()) query = Select(OverhaulSchedule).order_by(OverhaulSchedule.start.desc())
query = apply_not_deleted_filter(query, OverhaulSchedule) query = apply_not_deleted_filter(query, OverhaulSchedule)
return await search_filter_sort_paginate(model=query, **common)
async def create(*, db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate): results = await search_filter_sort_paginate(model=query, **common)
return results
async def create(
*, db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate
):
schedule = OverhaulSchedule(**overhaul_job_in.model_dump()) schedule = OverhaulSchedule(**overhaul_job_in.model_dump())
db_session.add(schedule) db_session.add(schedule)
await db_session.commit() await db_session.commit()
return schedule return schedule
async def update(*, db_session: DbSession, overhaul_schedule_id: str, overhaul_job_in: OverhaulScheduleUpdate): async def update(*, db_session: DbSession, overhaul_schedule_id: str, overhaul_job_in: OverhaulScheduleUpdate):
overhaul_job_in.model_dump() """Updates a document."""
data = overhaul_job_in.model_dump()
query = Select(OverhaulSchedule).where(OverhaulSchedule.id == overhaul_schedule_id) query = Select(OverhaulSchedule).where(OverhaulSchedule.id == overhaul_schedule_id)
query = apply_not_deleted_filter(query, OverhaulSchedule) query = apply_not_deleted_filter(query, OverhaulSchedule)
query = query.with_for_update() query = query.with_for_update()
result = await db_session.execute(query) result = await db_session.execute(query)
overhaul_schedule = result.scalars().one_or_none() overhaul_schedule = result.scalars().one_or_none()
update_data = overhaul_job_in.model_dump(exclude_defaults=True) update_data = overhaul_job_in.model_dump(exclude_defaults=True)
update_model(overhaul_schedule, update_data) update_model(overhaul_schedule, update_data)
await db_session.commit() await db_session.commit()
return overhaul_schedule return overhaul_schedule
async def delete(*, db_session: DbSession, overhaul_schedule_id: str): async def delete(*, db_session: DbSession, overhaul_schedule_id: str):
"""Soft-deletes a document."""
await soft_delete_record(db_session=db_session, model=OverhaulSchedule, record_id=overhaul_schedule_id) await soft_delete_record(db_session=db_session, model=OverhaulSchedule, record_id=overhaul_schedule_id)

@ -1,29 +1,48 @@
from sqlalchemy import JSON from sqlalchemy import JSON
from sqlalchemy import Column, DateTime , Integer, String, ForeignKey, UUID from sqlalchemy import Column, DateTime , Integer, String, ForeignKey, UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.database.core import Base from src.database.core import Base
from src.models import DefaultMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.auth.access_control import Allow, RolePrincipal, OHPermission from src.auth.access_control import Allow, RolePrincipal, OHPermission
class OverhaulScope(Base, DefaultMixin): class OverhaulScope(Base, DefaultMixin):
__tablename__ = 'oh_ms_overhaul' __tablename__ = "oh_ms_overhaul"
start_date = Column(DateTime(timezone=True), nullable=False) start_date = Column(DateTime(timezone=True), nullable=False) # Made non-nullable to match model
end_date = Column(DateTime(timezone=True), nullable=True) end_date = Column(DateTime(timezone=True), nullable=True) # Already nullable
duration_oh = Column(Integer, nullable=True) duration_oh = Column(Integer, nullable=True)
crew_number = Column(Integer, nullable=True, default=1) crew_number = Column(Integer, nullable=True, default=1)
status = Column(String, nullable=False, default='Upcoming') status = Column(String, nullable=False, default="Upcoming")
maintenance_type_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_maintenance_type.id'), nullable=False) maintenance_type_id = Column(
UUID(as_uuid=True), ForeignKey("oh_ms_maintenance_type.id"), nullable=False)
wo_parent = Column(JSON, nullable=True) wo_parent = Column(JSON, nullable=True)
@classmethod @classmethod
def __acl__(self): def __acl__(self):
basic_permissions = [OHPermission.READ] basic_permissions = [OHPermission.READ]
engineer_permissions = [OHPermission.READ, OHPermission.CREATE, OHPermission.EDIT, OHPermission.DELETE] engineer_permissions = [
OHPermission.READ,
OHPermission.CREATE,
OHPermission.EDIT,
OHPermission.DELETE,
]
all_permissions = list(OHPermission) all_permissions = list(OHPermission)
return [(Allow, RolePrincipal('Management'), basic_permissions), (Allow, RolePrincipal('Engineer'), engineer_permissions), (Allow, RolePrincipal('Admin'), all_permissions), (Allow, RolePrincipal('Application Administrator'), all_permissions)]
maintenance_type = relationship('MaintenanceType', lazy='selectin', backref='overhaul_scopes') return [
(Allow, RolePrincipal("Management"), basic_permissions),
(Allow, RolePrincipal("Engineer"), engineer_permissions),
(Allow, RolePrincipal("Admin"), all_permissions),
(Allow, RolePrincipal("Application Administrator"), all_permissions),
]
maintenance_type = relationship("MaintenanceType", lazy="selectin", backref="overhaul_scopes")
# activity_equipments = relationship("OverhaulActivity", lazy="selectin")
class MaintenanceType(Base, DefaultMixin): class MaintenanceType(Base, DefaultMixin):
__tablename__ = 'oh_ms_maintenance_type' __tablename__ = "oh_ms_maintenance_type"
code = Column(String, nullable=False, default='OH') code = Column(String, nullable=False, default="OH")
name = Column(String, nullable=False) name = Column(String, nullable=False)

@ -1,47 +1,82 @@
from typing import List from typing import List
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from src.auth.service import CurrentUser from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import CommonParameters from src.database.service import CommonParameters, search_filter_sort_paginate
from src.models import StandardResponse from src.models import StandardResponse
from .model import OverhaulScope from .model import OverhaulScope
from .schema import ScopeCreate, ScopePagination, ScopeRead, ScopeUpdate from .schema import ScopeCreate, ScopePagination, ScopeRead, ScopeUpdate
from .service import create, delete, get, get_all, update,get_all_oh_with_history_service from .service import create, delete, get, get_all, update,get_all_oh_with_history_service
from src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES from src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES
from src.csrf_protect import csrf_protect from src.csrf_protect import csrf_protect
from fastapi import Depends from fastapi import Depends
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get('', response_model=StandardResponse[ScopePagination], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
@router.get("", response_model=StandardResponse[ScopePagination], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_scopes(common: CommonParameters, scope_name: Optional[str] = None): async def get_scopes(common: CommonParameters, scope_name: Optional[str] = None):
"""Get all scope pagination."""
# return
results = await get_all(common=common, scope_name=scope_name) results = await get_all(common=common, scope_name=scope_name)
return StandardResponse(data=results, message='Data retrieved successfully')
@router.get('/history', response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) return StandardResponse(
data=results,
message="Data retrieved successfully",
)
@router.get("/history", response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_history(db_session: DbSession): async def get_history(db_session: DbSession):
return StandardResponse(data=await get_all_oh_with_history_service(db_session=db_session), message='Data retrieved successfully') return StandardResponse(data=await get_all_oh_with_history_service(db_session=db_session), message="Data retrieved successfully")
@router.get('/{overhaul_session_id}', response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) @router.get("/{overhaul_session_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))])
async def get_scope(db_session: DbSession, overhaul_session_id: str): async def get_scope(db_session: DbSession, overhaul_session_id: str):
scope = await get(db_session=db_session, overhaul_session_id=overhaul_session_id) scope = await get(db_session=db_session, overhaul_session_id=overhaul_session_id)
if not scope: if not scope:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') raise HTTPException(
return StandardResponse(data=scope, message='Data retrieved successfully') status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
return StandardResponse(data=scope, message="Data retrieved successfully")
@router.post('', response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.CREATE, OverhaulScope))]) @router.post("", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.CREATE, OverhaulScope))])
async def create_scope(db_session: DbSession, scope_in: ScopeCreate): async def create_scope(db_session: DbSession, scope_in: ScopeCreate):
scope = await create(db_session=db_session, scope_in=scope_in) scope = await create(db_session=db_session, scope_in=scope_in)
return StandardResponse(data=scope, message='Data created successfully')
@router.post('/update/{scope_id}', response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope))]) return StandardResponse(data=scope, message="Data created successfully")
async def update_scope(db_session: DbSession, scope_id: str, scope_in: ScopeUpdate, current_user: CurrentUser):
return StandardResponse(data=await update(db_session=db_session, scope_id=scope_id, scope_in=scope_in), message='Data updated successfully')
@router.post('/delete/{scope_id}', response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.DELETE, OverhaulScope)), Depends(csrf_protect)])
@router.post("/update/{scope_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.EDIT, OverhaulScope))])
async def update_scope(
db_session: DbSession,
scope_id: str,
scope_in: ScopeUpdate,
current_user: CurrentUser,
):
return StandardResponse(
data=await update(db_session=db_session, scope_id=scope_id, scope_in=scope_in),
message="Data updated successfully",
)
@router.post("/delete/{scope_id}", response_model=StandardResponse[ScopeRead], dependencies=[Depends(required_permission(OHPermission.DELETE, OverhaulScope)), Depends(csrf_protect)])
async def delete_scope(db_session: DbSession, scope_id: str): async def delete_scope(db_session: DbSession, scope_id: str):
scope = await get(db_session=db_session, overhaul_session_id=scope_id) scope = await get(db_session=db_session, overhaul_session_id=scope_id)
if not scope: if not scope:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=[{'msg': 'A data with this id does not exist.'}]) raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=[{"msg": "A data with this id does not exist."}],
)
await delete(db_session=db_session, scope_id=scope_id) await delete(db_session=db_session, scope_id=scope_id)
return StandardResponse(message='Data deleted successfully', data=scope)
return StandardResponse(message="Data deleted successfully", data=scope)

@ -1,13 +1,17 @@
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
from uuid import UUID from uuid import UUID
from pydantic import Field from pydantic import Field
from src.models import DefultBase, Pagination from src.models import DefultBase, Pagination
class ScopeBase(DefultBase): class ScopeBase(DefultBase):
duration_oh: Optional[int] = Field(720, title='Duration OH', ge=0, le=1000000) duration_oh: Optional[int] = Field(720, title="Duration OH")
crew_number: Optional[int] = Field(10, title='Crew', ge=0, le=1000000) crew_number: Optional[int] = Field(10, title="Crew")
status: Optional[str] = Field('Upcoming') status: Optional[str] = Field("Upcoming")
class MaintenanceType(DefultBase): class MaintenanceType(DefultBase):
name: str name: str
@ -16,14 +20,17 @@ class ScopeCreate(ScopeBase):
start_date: datetime start_date: datetime
end_date: Optional[datetime] = Field(None) end_date: Optional[datetime] = Field(None)
class ScopeUpdate(ScopeBase): class ScopeUpdate(ScopeBase):
pass pass
class ScopeRead(ScopeBase): class ScopeRead(ScopeBase):
id: UUID id: UUID
start_date: datetime start_date: datetime
end_date: Optional[datetime] end_date: Optional[datetime]
maintenance_type: MaintenanceType maintenance_type: MaintenanceType
class ScopePagination(Pagination): class ScopePagination(Pagination):
items: List[ScopeRead] = [] items: List[ScopeRead] = []

@ -1,6 +1,9 @@
from typing import Optional from typing import Optional
from sqlalchemy import Select
from sqlalchemy import Delete, Select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from src.auth.service import CurrentUser
from src.database.core import DbSession from src.database.core import DbSession
from src.database.service import search_filter_sort_paginate from src.database.service import search_filter_sort_paginate
from src.overhaul_activity.model import OverhaulActivity from src.overhaul_activity.model import OverhaulActivity
@ -9,6 +12,7 @@ from src.standard_scope.model import MasterEquipment, StandardScope, EquipmentOH
from src.workscope_group.model import MasterActivity from src.workscope_group.model import MasterActivity
from src.workscope_group_maintenance_type.model import WorkscopeOHType from src.workscope_group_maintenance_type.model import WorkscopeOHType
from src.equipment_workscope_group.model import EquipmentWorkscopeGroup from src.equipment_workscope_group.model import EquipmentWorkscopeGroup
from .model import OverhaulScope, MaintenanceType from .model import OverhaulScope, MaintenanceType
from .schema import ScopeCreate, ScopeUpdate from .schema import ScopeCreate, ScopeUpdate
from .utils import get_material_cost, get_service_cost from .utils import get_material_cost, get_service_cost
@ -16,111 +20,231 @@ from datetime import datetime
from uuid import UUID from uuid import UUID
from src.soft_delete import apply_not_deleted_filter, soft_delete_record from src.soft_delete import apply_not_deleted_filter, soft_delete_record
async def get(*, db_session: DbSession, overhaul_session_id: UUID) -> Optional[OverhaulScope]: async def get(
*, db_session: DbSession, overhaul_session_id: UUID
) -> Optional[OverhaulScope]:
"""Returns a document based on the given document id."""
query = Select(OverhaulScope).filter(OverhaulScope.id == overhaul_session_id).options(selectinload(OverhaulScope.maintenance_type)) query = Select(OverhaulScope).filter(OverhaulScope.id == overhaul_session_id).options(selectinload(OverhaulScope.maintenance_type))
query = apply_not_deleted_filter(query, OverhaulScope) query = apply_not_deleted_filter(query, OverhaulScope)
result = await db_session.execute(query) result = await db_session.execute(query)
return result.scalars().one_or_none() return result.scalars().one_or_none()
async def get_prev_oh(*, db_session: DbSession, overhaul_session: OverhaulScope) -> Optional[OverhaulScope]: async def get_prev_oh(*, db_session: DbSession, overhaul_session: OverhaulScope) -> Optional[OverhaulScope]:
"""Returns a document based on the given document id."""
result = Select(OverhaulScope).where(OverhaulScope.end_date < overhaul_session.end_date).order_by(OverhaulScope.end_date.desc()).limit(1) result = Select(OverhaulScope).where(OverhaulScope.end_date < overhaul_session.end_date).order_by(OverhaulScope.end_date.desc()).limit(1)
result = apply_not_deleted_filter(result, OverhaulScope) result = apply_not_deleted_filter(result, OverhaulScope)
result = await db_session.execute(result) result = await db_session.execute(result)
return result.scalars().one_or_none() return result.scalars().one_or_none()
async def get_all(*, common, scope_name: Optional[str] = None): async def get_all(*, common, scope_name: Optional[str] = None):
"""Returns all documents."""
query = Select(OverhaulScope) query = Select(OverhaulScope)
query = apply_not_deleted_filter(query, OverhaulScope) query = apply_not_deleted_filter(query, OverhaulScope)
if scope_name: if scope_name:
query = query.filter(OverhaulScope.maintenance_type.name == scope_name) query = query.filter(OverhaulScope.maintenance_type.name == scope_name)
return await search_filter_sort_paginate(model=query, **common)
results = await search_filter_sort_paginate(model=query, **common)
return results
async def create(*, db_session: DbSession, scope_in: ScopeCreate): async def create(*, db_session: DbSession, scope_in: ScopeCreate):
# Ensure dates are datetime objects
if isinstance(scope_in.start_date, str): if isinstance(scope_in.start_date, str):
try: try:
start_date = datetime.fromisoformat(scope_in.start_date.replace('Z', '+00:00')) start_date = datetime.fromisoformat(scope_in.start_date.replace('Z', '+00:00'))
except ValueError: except ValueError:
start_date = datetime.strptime(scope_in.start_date, '%Y-%m-%d %H:%M:%S') start_date = datetime.strptime(scope_in.start_date, "%Y-%m-%d %H:%M:%S")
else: else:
start_date = scope_in.start_date start_date = scope_in.start_date
# Handle end_date (which could be None)
end_date = None end_date = None
if scope_in.end_date: if scope_in.end_date:
if isinstance(scope_in.end_date, str): if isinstance(scope_in.end_date, str):
try: try:
end_date = datetime.fromisoformat(scope_in.end_date.replace('Z', '+00:00')) end_date = datetime.fromisoformat(scope_in.end_date.replace('Z', '+00:00'))
except ValueError: except ValueError:
end_date = datetime.strptime(scope_in.end_date, '%Y-%m-%d %H:%M:%S') end_date = datetime.strptime(scope_in.end_date, "%Y-%m-%d %H:%M:%S")
else: else:
end_date = scope_in.end_date end_date = scope_in.end_date
# Calculate duration in days if both dates are available
duration_days = None duration_days = None
if start_date and end_date: if start_date and end_date:
duration_days = (end_date - start_date).days duration_days = (end_date - start_date).days
# Prevent TOCTOU for maintenance type creation
maintenance_type = Select(MaintenanceType).where(MaintenanceType.name == scope_in.type).with_for_update() maintenance_type = Select(MaintenanceType).where(MaintenanceType.name == scope_in.type).with_for_update()
maintenance_type_exec = await db_session.execute(maintenance_type) maintenance_type_exec = await db_session.execute(maintenance_type)
maintenance_type = maintenance_type_exec.scalars().one_or_none() maintenance_type = maintenance_type_exec.scalars().one_or_none()
if maintenance_type is None: if maintenance_type is None:
try: try:
# Need to use nested transaction for safe concurrent creation
async with db_session.begin_nested(): async with db_session.begin_nested():
maintenance_type = MaintenanceType(name=scope_in.type) maintenance_type = MaintenanceType(name=scope_in.type)
db_session.add(maintenance_type) db_session.add(maintenance_type)
await db_session.flush() await db_session.flush()
except BaseException: except BaseException:
# Another concurrent request already created it
maintenance_type_exec = await db_session.execute(Select(MaintenanceType).where(MaintenanceType.name == scope_in.type).with_for_update()) maintenance_type_exec = await db_session.execute(Select(MaintenanceType).where(MaintenanceType.name == scope_in.type).with_for_update())
maintenance_type = maintenance_type_exec.scalars().one_or_none() maintenance_type = maintenance_type_exec.scalars().one_or_none()
overhaul_session = OverhaulScope(start_date=scope_in.start_date, end_date=scope_in.end_date, maintenance_type=maintenance_type, duration_oh=duration_days, crew_number=scope_in.crew_number, status=scope_in.status)
# Create the OverhaulScope object with all hardcoded values
overhaul_session = OverhaulScope(
start_date=scope_in.start_date,
end_date=scope_in.end_date,
maintenance_type=maintenance_type,
duration_oh=duration_days,
crew_number=scope_in.crew_number, # Hardcoded crew number
status=scope_in.status # Hardcoded status
)
# raise Exception(overhaul_session.start_date)
db_session.add(overhaul_session) db_session.add(overhaul_session)
# Need to flush to get the id
await db_session.flush() await db_session.flush()
scope_name = scope_in.type scope_name = scope_in.type
# Fix the function call - parameters were in wrong order
equipments = await get_by_scope_name(db_session=db_session, scope_name=scope_name) equipments = await get_by_scope_name(db_session=db_session, scope_name=scope_name)
material_cost = get_material_cost(scope=overhaul_session.type, total_equipment=len(equipments))
service_cost = get_service_cost(scope=overhaul_session.type, total_equipment=len(equipments)) material_cost = get_material_cost(
scope_equipments = [OverhaulActivity(assetnum=equipment.assetnum, overhaul_scope_id=overhaul_session.id, material_cost=material_cost, service_cost=service_cost) for equipment in equipments] scope=overhaul_session.type, total_equipment=len(equipments)
if scope_equipments: )
service_cost = get_service_cost(
scope=overhaul_session.type, total_equipment=len(equipments)
)
scope_equipments = [
OverhaulActivity(
assetnum=equipment.assetnum,
overhaul_scope_id=overhaul_session.id,
material_cost=material_cost,
service_cost=service_cost,
)
for equipment in equipments
]
if scope_equipments: # Only add if there are items
db_session.add_all(scope_equipments) db_session.add_all(scope_equipments)
await db_session.commit() await db_session.commit()
return overhaul_session return overhaul_session
async def update(*, db_session: DbSession, scope_id: str, scope_in: ScopeUpdate): async def update(*, db_session: DbSession, scope_id: str, scope_in: ScopeUpdate):
query = Select(OverhaulScope).filter(OverhaulScope.id == scope_id).options(selectinload(OverhaulScope.maintenance_type)).with_for_update() """Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update."""
query = (
Select(OverhaulScope)
.filter(OverhaulScope.id == scope_id)
.options(selectinload(OverhaulScope.maintenance_type))
.with_for_update()
)
query = apply_not_deleted_filter(query, OverhaulScope) query = apply_not_deleted_filter(query, OverhaulScope)
result = await db_session.execute(query) result = await db_session.execute(query)
scope = result.scalars().one_or_none() scope = result.scalars().one_or_none()
if not scope: if not scope:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="A data with this id does not exist.",
)
await db_session.refresh(scope) await db_session.refresh(scope)
update_data = scope_in.model_dump(exclude_defaults=True) update_data = scope_in.model_dump(exclude_defaults=True)
update_model(scope, update_data) update_model(scope, update_data)
await db_session.commit() await db_session.commit()
return scope return scope
async def delete(*, db_session: DbSession, scope_id: str): async def delete(*, db_session: DbSession, scope_id: str):
"""Soft-deletes a document."""
await soft_delete_record(db_session=db_session, model=OverhaulScope, record_id=scope_id) await soft_delete_record(db_session=db_session, model=OverhaulScope, record_id=scope_id)
async def get_overview_overhaul(*, db_session: DbSession): async def get_overview_overhaul(*, db_session: DbSession):
current_date = time_now().date() current_date = time_now().date()
ongoing_query = Select(OverhaulScope).where(OverhaulScope.start_date <= current_date, OverhaulScope.end_date >= current_date)
# 1. Check for ongoing overhaul
ongoing_query = (
Select(OverhaulScope)
.where(
OverhaulScope.start_date <= current_date,
OverhaulScope.end_date >= current_date,
)
)
ongoing_query = apply_not_deleted_filter(ongoing_query, OverhaulScope) ongoing_query = apply_not_deleted_filter(ongoing_query, OverhaulScope)
ongoing_result = await db_session.execute(ongoing_query.options(selectinload(OverhaulScope.maintenance_type))) ongoing_result = await db_session.execute(ongoing_query.options(selectinload(OverhaulScope.maintenance_type)))
ongoing_overhaul = ongoing_result.scalar_one_or_none() ongoing_overhaul = ongoing_result.scalar_one_or_none()
# 2. If no ongoing overhaul, get the closest scheduled overhaul
if ongoing_overhaul is None: if ongoing_overhaul is None:
next_overhaul_query = Select(OverhaulScope).where(OverhaulScope.start_date > current_date).order_by(OverhaulScope.start_date.asc()).limit(1) # Get the closest future overhaul (next scheduled)
next_overhaul_query = (
Select(OverhaulScope)
.where(OverhaulScope.start_date > current_date)
.order_by(OverhaulScope.start_date.asc())
.limit(1)
)
next_overhaul_query = apply_not_deleted_filter(next_overhaul_query, OverhaulScope) next_overhaul_query = apply_not_deleted_filter(next_overhaul_query, OverhaulScope)
next_result = await db_session.execute(next_overhaul_query.options(selectinload(OverhaulScope.maintenance_type))) next_result = await db_session.execute(next_overhaul_query.options(selectinload(OverhaulScope.maintenance_type)))
next_overhaul = next_result.scalar_one_or_none() next_overhaul = next_result.scalar_one_or_none()
if next_overhaul: if next_overhaul:
print(f'Next scheduled overhaul starts on: {next_overhaul.start_date}') print(f"Next scheduled overhaul starts on: {next_overhaul.start_date}")
selected_overhaul = next_overhaul selected_overhaul = next_overhaul
else: else:
print('No upcoming overhauls scheduled') print("No upcoming overhauls scheduled")
selected_overhaul = None selected_overhaul = None
else: else:
print(f'Ongoing overhaul found: {ongoing_overhaul.start_date} to {ongoing_overhaul.end_date}') print(f"Ongoing overhaul found: {ongoing_overhaul.start_date} to {ongoing_overhaul.end_date}")
selected_overhaul = ongoing_overhaul selected_overhaul = ongoing_overhaul
equipments = Select(StandardScope).outerjoin(StandardScope.oh_history).join(StandardScope.workscope_groups).join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag).filter(WorkscopeOHType.maintenance_type_id == selected_overhaul.maintenance_type_id).filter((StandardScope.is_alternating_oh == False) | (StandardScope.oh_history == None) | StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != selected_overhaul.maintenance_type.name)).distinct()
equipments = (
Select(StandardScope)
.outerjoin(StandardScope.oh_history) # Use outerjoin to handle None values
.join(StandardScope.workscope_groups)
.join(EquipmentWorkscopeGroup.workscope_group)
.join(MasterActivity.oh_types)
.join(MasterEquipment, StandardScope.location_tag == MasterEquipment.location_tag)
.filter(WorkscopeOHType.maintenance_type_id == selected_overhaul.maintenance_type_id)
.filter(
(StandardScope.is_alternating_oh == False) |
(StandardScope.oh_history == None) |
(StandardScope.oh_history.has(EquipmentOHHistory.last_oh_type != selected_overhaul.maintenance_type.name))
).distinct()
)
equipments = apply_not_deleted_filter(equipments, StandardScope) equipments = apply_not_deleted_filter(equipments, StandardScope)
results = await db_session.execute(equipments) results = await db_session.execute(equipments)
remaining_days = (selected_overhaul.start_date - current_date).days if selected_overhaul.status == 'Upcoming' else (selected_overhaul.end_date - current_date).days
return {'status': selected_overhaul.status, 'overhaul': {'id': selected_overhaul.id, 'type': selected_overhaul.maintenance_type.name, 'start_date': selected_overhaul.start_date, 'end_date': selected_overhaul.end_date, 'duration_oh': selected_overhaul.duration_oh, 'crew_number': selected_overhaul.crew_number, 'remaining_days': remaining_days, 'equipment_count': len(results.scalars().all())}} #Remaining days based on status
remaining_days = (selected_overhaul.start_date - current_date).days if selected_overhaul.status == "Upcoming" else (selected_overhaul.end_date - current_date).days
return {
"status": selected_overhaul.status,
"overhaul": {
"id": selected_overhaul.id,
"type": selected_overhaul.maintenance_type.name,
"start_date": selected_overhaul.start_date,
"end_date": selected_overhaul.end_date,
"duration_oh": selected_overhaul.duration_oh,
"crew_number": selected_overhaul.crew_number,
"remaining_days": remaining_days,
"equipment_count": len(results.scalars().all()),
},
}
async def get_all_oh_with_history_service(*, db_session: DbSession): async def get_all_oh_with_history_service(*, db_session: DbSession):
query = Select(OverhaulScope).options(selectinload(OverhaulScope.maintenance_type)).where(OverhaulScope.wo_parent.isnot(None)) query = Select(OverhaulScope).options(selectinload(OverhaulScope.maintenance_type)).where(OverhaulScope.wo_parent.isnot(None))

@ -1,23 +1,35 @@
from decimal import Decimal, getcontext from decimal import Decimal, getcontext
def get_material_cost(scope, total_equipment): def get_material_cost(scope, total_equipment):
# Set precision to 28 digits (maximum precision for Decimal)
getcontext().prec = 28 getcontext().prec = 28
if not total_equipment:
if not total_equipment: # Guard against division by zero
return float(0) return float(0)
if scope == 'B':
result = Decimal('365539731101') / Decimal(str(total_equipment)) if scope == "B":
result = Decimal("365539731101") / Decimal(str(total_equipment))
return float(result) return float(result)
result = Decimal('8565468127') / Decimal(str(total_equipment)) else:
result = Decimal("8565468127") / Decimal(str(total_equipment))
return float(result) return float(result)
return float(0) return float(0)
def get_service_cost(scope, total_equipment): def get_service_cost(scope, total_equipment):
# Set precision to 28 digits (maximum precision for Decimal)
getcontext().prec = 28 getcontext().prec = 28
if not total_equipment:
if not total_equipment: # Guard against division by zero
return float(0) return float(0)
if scope == 'B':
result = Decimal('36405830225') / Decimal(str(total_equipment)) if scope == "B":
result = Decimal("36405830225") / Decimal(str(total_equipment))
return float(result) return float(result)
result = Decimal('36000000000') / Decimal(str(total_equipment)) else:
result = Decimal("36000000000") / Decimal(str(total_equipment))
return float(result) return float(result)
return float(0) return float(0)

@ -1 +1,10 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
from src.config import RATELIMIT_STORAGE_URI
# limiter = Limiter(
# key_func=get_remote_address,
# default_limits=["120 per hour", "2880 per day"],
# strategy="fixed-window",
# storage_uri=RATELIMIT_STORAGE_URI,
# )

@ -1,22 +1,77 @@
"""
Soft delete utilities.
Provides reusable functions to convert hard-delete operations into soft-deletes.
The `soft_delete_record` function works with any model that inherits `SoftDeleteMixin`
(which is included in `DefaultMixin`).
Usage in service files:
from src.soft_delete import soft_delete_record, apply_not_deleted_filter
async def delete(*, db_session, record_id: str):
await soft_delete_record(db_session=db_session, model=MyModel, record_id=record_id)
async def get_all(*, db_session, ...):
query = Select(MyModel)
query = apply_not_deleted_filter(query, MyModel)
...
"""
import logging import logging
from datetime import datetime from datetime import datetime
from typing import TypeVar, Type from typing import TypeVar, Type
from sqlalchemy import Select, Update from sqlalchemy import Select, Update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import pytz import pytz
from src.config import TIMEZONE from src.config import TIMEZONE
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
T = TypeVar('T')
T = TypeVar("T")
def apply_not_deleted_filter(query: Select, model) -> Select: def apply_not_deleted_filter(query: Select, model) -> Select:
if hasattr(model, 'deleted_at'): """
Appends a WHERE clause to exclude soft-deleted records.
Use this on all "get" queries to hide deleted data.
Example:
query = Select(OverhaulScope)
query = apply_not_deleted_filter(query, OverhaulScope)
"""
if hasattr(model, "deleted_at"):
return query.filter(model.deleted_at.is_(None)) return query.filter(model.deleted_at.is_(None))
return query return query
async def soft_delete_record(*, db_session: AsyncSession, model: Type[T], record_id: str, id_column: str='id') -> None:
async def soft_delete_record(
*,
db_session: AsyncSession,
model: Type[T],
record_id: str,
id_column: str = "id",
) -> None:
"""
Soft-delete a record by setting its `deleted_at` timestamp.
Instead of DELETE, this performs an UPDATE.
Args:
db_session: The async database session.
model: The SQLAlchemy model class.
record_id: The value of the ID to match.
id_column: The name of the ID column (default: "id").
"""
now = datetime.now(pytz.timezone(TIMEZONE)) now = datetime.now(pytz.timezone(TIMEZONE))
id_col = getattr(model, id_column) id_col = getattr(model, id_column)
stmt = Update(model).where(id_col == record_id).values(deleted_at=now)
stmt = (
Update(model)
.where(id_col == record_id)
.values(deleted_at=now)
)
await db_session.execute(stmt) await db_session.execute(stmt)
await db_session.commit() await db_session.commit()
log.info(f'Soft-deleted {model.__tablename__} record: {record_id}')
log.info(f"Soft-deleted {model.__tablename__} record: {record_id}")

@ -1,28 +1,43 @@
from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String, Date from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String, Date
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.database.core import Base from src.database.core import Base
from src.models import DefaultMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
class MasterSparePart(Base, DefaultMixin): class MasterSparePart(Base, DefaultMixin):
__tablename__ = 'oh_ms_sparepart' __tablename__ = "oh_ms_sparepart"
assetnum = Column(String, nullable=False) assetnum = Column(String, nullable=False)
location_tag = Column(String, nullable=False) location_tag = Column(String, nullable=False)
stock = Column(Integer, nullable=False, default=0) stock = Column(Integer, nullable=False, default=0)
name = Column(String, nullable=False) name = Column(String, nullable=False)
cost_per_stock = Column(Float, nullable=False) cost_per_stock = Column(Float, nullable=False)
unit = Column(String, nullable=False) unit = Column(String, nullable=False)
sparepart_procurements = relationship('MasterSparepartProcurement', lazy='selectin')
sparepart_procurements = relationship("MasterSparepartProcurement", lazy="selectin")
class MasterSparepartProcurement(Base, DefaultMixin): class MasterSparepartProcurement(Base, DefaultMixin):
__tablename__ = 'oh_ms_sparepart_procurement' __tablename__ = "oh_ms_sparepart_procurement"
sparepart_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_sparepart.id', ondelete='cascade'), nullable=False)
sparepart_id = Column(
UUID(as_uuid=True),
ForeignKey("oh_ms_sparepart.id", ondelete="cascade"),
nullable=False,
)
quantity = Column(Integer, nullable=False) quantity = Column(Integer, nullable=False)
status = Column(String, nullable=False) status = Column(String, nullable=False)
eta_requisition = Column(Date, nullable=False) eta_requisition = Column(Date, nullable=False)
eta_ordered = Column(Date, nullable=True) eta_ordered = Column(Date, nullable=True)
eta_received = Column(Date, nullable=True) eta_received = Column(Date, nullable=True)
class SparepartRemark(Base, DefaultMixin): class SparepartRemark(Base, DefaultMixin):
__tablename__ = 'oh_ms_sparepart_remark' __tablename__ = "oh_ms_sparepart_remark"
itemnum = Column(String, nullable=False) itemnum = Column(String, nullable=False)
remark = Column(String, nullable=False) remark = Column(String, nullable=False)

@ -1,19 +1,94 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, Query, status
from src.csrf_protect import csrf_protect from src.csrf_protect import csrf_protect
from src.database.core import CollectorDbSession from src.database.core import CollectorDbSession
from src.database.service import DbSession from src.database.service import (CommonParameters, DbSession, search_filter_sort_paginate)
from src.models import StandardResponse from src.models import StandardResponse
from src.sparepart.schema import SparepartRemark from src.sparepart.schema import SparepartRemark
from src.auth.access_control import require_any_role, ALLOWED_ROLES from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .service import create_remark, get_spareparts_paginated from .service import create_remark, get_spareparts_paginated
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get('', response_model=StandardResponse[list])
@router.get("", response_model=StandardResponse[list])
async def get_sparepart(collector_db_session:CollectorDbSession, db_session: DbSession): async def get_sparepart(collector_db_session:CollectorDbSession, db_session: DbSession):
"""Get all scope activity pagination."""
# return
data = await get_spareparts_paginated(db_session=db_session, collector_db_session=collector_db_session) data = await get_spareparts_paginated(db_session=db_session, collector_db_session=collector_db_session)
return StandardResponse(data=data, message='Data retrieved successfully')
@router.post('', response_model=StandardResponse[SparepartRemark], dependencies=[Depends(csrf_protect)]) return StandardResponse(
data=data,
message="Data retrieved successfully",
)
@router.post("", response_model=StandardResponse[SparepartRemark], dependencies=[Depends(csrf_protect)])
async def create_remark_route(collector_db_session:CollectorDbSession, db_session: DbSession, remark_in:SparepartRemark): async def create_remark_route(collector_db_session:CollectorDbSession, db_session: DbSession, remark_in:SparepartRemark):
sparepart_remark = await create_remark(db_session=db_session, collector_db_session=collector_db_session, remark_in=remark_in) sparepart_remark = await create_remark(db_session=db_session, collector_db_session=collector_db_session, remark_in=remark_in)
return StandardResponse(data=sparepart_remark, message='Remark Created successfully')
return StandardResponse(
data=sparepart_remark,
message="Remark Created successfully"
)
# @router.post("", response_model=StandardResponse[ActivityMasterCreate])
# async def create_activity(db_session: DbSession, activity_in: ActivityMasterCreate):
# activity = await create(db_session=db_session, activty_in=activity_in)
# return StandardResponse(data=activity, message="Data created successfully")
# @router.get(
# "/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster]
# )
# async def get_activity(db_session: DbSession, activity_id: str):
# activity = await get(db_session=db_session, activity_id=activity_id)
# if not activity:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="A data with this id does not exist.",
# )
# return StandardResponse(data=activity, message="Data retrieved successfully")
# @router.put(
# "/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster]
# )
# async def update_scope(
# db_session: DbSession, activity_in: ActivityMasterCreate, activity_id
# ):
# activity = await get(db_session=db_session, activity_id=activity_id)
# if not activity:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="A data with this id does not exist.",
# )
# return StandardResponse(
# data=await update(
# db_session=db_session, activity=activity, activity_in=activity_in
# ),
# message="Data updated successfully",
# )
# @router.delete(
# "/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster]
# )
# async def delete_scope(db_session: DbSession, activity_id: str):
# activity = await get(db_session=db_session, activity_id=activity_id)
# if not activity:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail=[{"msg": "A data with this id does not exist."}],
# )
# await delete(db_session=db_session, activity_id=activity_id)
# return StandardResponse(message="Data deleted successfully", data=activity)

@ -1,23 +1,31 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date, datetime
from enum import Enum from enum import Enum
from typing import List from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field
from src.models import DefultBase, Pagination from src.models import DefultBase, Pagination
class ActivityMaster(DefultBase): class ActivityMaster(DefultBase):
pass pass
class ActivityMasterDetail(DefultBase): class ActivityMasterDetail(DefultBase):
name: str name: str
class ActivityMasterCreate(ActivityMaster): class ActivityMasterCreate(ActivityMaster):
description: str description: str
class ActivityMasterTasks(DefultBase): class ActivityMasterTasks(DefultBase):
description: str description: str
oh_type: str oh_type: str
class ActivityMasterRead(ActivityMaster): class ActivityMasterRead(ActivityMaster):
id: UUID id: UUID
workscope: str workscope: str
@ -25,17 +33,19 @@ class ActivityMasterRead(ActivityMaster):
subsystem: str subsystem: str
tasks: List[ActivityMasterTasks] tasks: List[ActivityMasterTasks]
class ActivityMasterPagination(Pagination): class ActivityMasterPagination(Pagination):
items: List[ActivityMasterRead] = [] items: List[ActivityMasterRead] = []
class ProcurementStatus(Enum): class ProcurementStatus(Enum):
PLANNED = 'planned' PLANNED = "planned"
ORDERED = 'ordered' ORDERED = "ordered"
RECEIVED = 'received' RECEIVED = "received"
CANCELLED = 'cancelled' CANCELLED = "cancelled"
@dataclass @dataclass
class SparepartRequirement: class SparepartRequirement:
"""Sparepart requirement for equipment overhaul"""
sparepart_id: str sparepart_id: str
quantity_required: int quantity_required: int
lead_time: int lead_time: int
@ -46,6 +56,7 @@ class SparepartRequirement:
@dataclass @dataclass
class SparepartStock: class SparepartStock:
"""Current sparepart stock information"""
sparepart_id: str sparepart_id: str
sparepart_name: str sparepart_name: str
current_stock: int current_stock: int
@ -55,6 +66,7 @@ class SparepartStock:
@dataclass @dataclass
class ProcurementRecord: class ProcurementRecord:
"""Purchase Order/Purchase Request record"""
po_pr_id: str po_pr_id: str
sparepart_id: str sparepart_id: str
sparepart_name: str sparepart_name: str
@ -66,6 +78,7 @@ class ProcurementRecord:
status: ProcurementStatus status: ProcurementStatus
po_vendor_delivery_date: date po_vendor_delivery_date: date
class SparepartRemark(DefultBase): class SparepartRemark(DefultBase):
itemnum: str itemnum: str
remark:str remark:str

File diff suppressed because one or more lines are too long

@ -1,5 +1,6 @@
from src.enums import OptimumOHEnum from src.enums import OptimumOHEnum
class ScopeEquipmentType(OptimumOHEnum): class ScopeEquipmentType(OptimumOHEnum):
TEMP = 'Temporary' TEMP = "Temporary"
PERM = 'Permanent' PERM = "Permanent"

@ -1,36 +1,71 @@
from sqlalchemy import UUID, Column, Date, Float, ForeignKey, Integer, String, Boolean from sqlalchemy import UUID, Column, Date, Float, ForeignKey, Integer, String, Boolean
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from src.database.core import Base from src.database.core import Base
from src.models import DefaultMixin from src.models import DefaultMixin, IdentityMixin, TimeStampMixin
from src.workorder.model import MasterWorkOrder
class StandardScope(Base, DefaultMixin): class StandardScope(Base, DefaultMixin):
__tablename__ = 'oh_ms_standard_scope' __tablename__ = "oh_ms_standard_scope"
location_tag = Column(String, nullable=False) location_tag = Column(String, nullable=False)
is_alternating_oh = Column(Boolean, nullable=False, default=False) is_alternating_oh = Column(Boolean, nullable=False, default=False)
assigned_date = Column(Date, nullable=True) assigned_date = Column(Date, nullable=True)
service_cost = Column(Float, nullable=True) service_cost = Column(Float, nullable=True)
master_equipment = relationship('MasterEquipment', lazy='selectin', primaryjoin='and_(StandardScope.location_tag == foreign(MasterEquipment.location_tag))', uselist=False)
oh_history = relationship('EquipmentOHHistory', lazy='selectin', primaryjoin='and_(StandardScope.location_tag == foreign(EquipmentOHHistory.location_tag))', uselist=False) master_equipment = relationship(
workscope_groups = relationship('EquipmentWorkscopeGroup', lazy='selectin', primaryjoin='and_(EquipmentWorkscopeGroup.location_tag == foreign(StandardScope.location_tag))', uselist=True) "MasterEquipment",
lazy="selectin",
primaryjoin="and_(StandardScope.location_tag == foreign(MasterEquipment.location_tag))",
uselist=False, # Add this if it's a one-to-one relationship
)
oh_history = relationship(
"EquipmentOHHistory",
lazy="selectin",
primaryjoin="and_(StandardScope.location_tag == foreign(EquipmentOHHistory.location_tag))",
uselist=False, # Add this if it's a one-to-one relationship
)
workscope_groups = relationship(
"EquipmentWorkscopeGroup",
lazy="selectin",
primaryjoin="and_(EquipmentWorkscopeGroup.location_tag == foreign(StandardScope.location_tag))",
uselist=True, # Add this if it's a one-to-one relationship
)
class EquipmentOHHistory(Base, DefaultMixin): class EquipmentOHHistory(Base, DefaultMixin):
__tablename__ = 'oh_ms_equipment_oh_history' __tablename__ = "oh_ms_equipment_oh_history"
location_tag = Column(String, nullable=False) location_tag = Column(String, nullable=False)
last_oh_date = Column(Date, nullable=True) last_oh_date = Column(Date, nullable=True)
last_oh_type = Column(String, nullable=True) last_oh_type = Column(String, nullable=True)
class MasterEquipment(Base, DefaultMixin): class MasterEquipment(Base, DefaultMixin):
__tablename__ = 'ms_equipment_master' __tablename__ = "ms_equipment_master"
id = Column(UUID(as_uuid=True), primary_key=True, index=True) id = Column(UUID(as_uuid=True), primary_key=True, index=True)
parent_id = Column(UUID(as_uuid=True), ForeignKey('ms_equipment_master.id', ondelete='CASCADE'), nullable=True) parent_id = Column(
UUID(as_uuid=True),
ForeignKey("ms_equipment_master.id", ondelete="CASCADE"),
nullable=True,
)
assetnum = Column(String, nullable=True) assetnum = Column(String, nullable=True)
system_tag = Column(String, nullable=True) system_tag = Column(String, nullable=True)
location_tag = Column(String, nullable=True) location_tag = Column(String, nullable=True)
name = Column(String, nullable=True) name = Column(String, nullable=True)
equipment_tree_id = Column(UUID(as_uuid=True), ForeignKey('ms_equipment_tree.id'), nullable=True) equipment_tree_id = Column(
equipment_tree = relationship('MasterEquipmentTree', backref='master_equipments') UUID(as_uuid=True), ForeignKey("ms_equipment_tree.id"), nullable=True
parent = relationship('MasterEquipment', remote_side=[id], lazy='selectin') )
equipment_tree = relationship("MasterEquipmentTree", backref="master_equipments")
parent = relationship("MasterEquipment", remote_side=[id], lazy="selectin")
class MasterEquipmentTree(Base, DefaultMixin): class MasterEquipmentTree(Base, DefaultMixin):
__tablename__ = 'ms_equipment_tree' __tablename__ = "ms_equipment_tree"
level_no = Column(Integer) level_no = Column(Integer)

@ -1,31 +1,92 @@
from typing import List from typing import List, Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.params import Query from fastapi.params import Query
from src.auth.service import CurrentUser
from src.database.core import DbSession, CollectorDbSession from src.database.core import DbSession, CollectorDbSession
from src.database.service import CommonParameters from src.database.service import CommonParameters, search_filter_sort_paginate
from src.models import StandardResponse from src.models import StandardResponse
from src.auth.access_control import require_any_role, ALLOWED_ROLES from src.auth.access_control import require_any_role, ALLOWED_ROLES
from .schema import MasterEquipmentPagination, ScopeEquipmentCreate, ScopeEquipmentPagination
from .service import create, get_all, get_all_master_equipment, get_history_standard_scope_wo_service from .schema import (MasterEquipmentPagination, ScopeEquipmentCreate,
ScopeEquipmentPagination, ScopeEquipmentRead,
ScopeEquipmentUpdate)
from .service import (create, delete, get_all, get_all_master_equipment, update, get_history_standard_scope_wo_service)
from uuid import UUID from uuid import UUID
router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))])
@router.get('', response_model=StandardResponse[ScopeEquipmentPagination])
@router.get("", response_model=StandardResponse[ScopeEquipmentPagination])
async def get_scope_equipments(common: CommonParameters, scope_name: str = Query(None)): async def get_scope_equipments(common: CommonParameters, scope_name: str = Query(None)):
"""Get all scope pagination."""
# return
data = await get_all(common=common, oh_scope=scope_name) data = await get_all(common=common, oh_scope=scope_name)
return StandardResponse(data=data, message='Data retrieved successfully')
@router.get('/available/{scope_name}', response_model=StandardResponse[MasterEquipmentPagination]) return StandardResponse(
data=data,
message="Data retrieved successfully",
)
@router.get(
"/available/{scope_name}",
response_model=StandardResponse[MasterEquipmentPagination],
)
async def get_master_equipment(common: CommonParameters, scope_name: str): async def get_master_equipment(common: CommonParameters, scope_name: str):
results = await get_all_master_equipment(common=common, scope_name=scope_name) results = await get_all_master_equipment(common=common, scope_name=scope_name)
return StandardResponse(data=results, message='Data retrieved successfully')
@router.post('', response_model=StandardResponse[List[str]]) return StandardResponse(data=results, message="Data retrieved successfully")
async def create_scope_equipment(db_session: DbSession, scope_equipment_in: ScopeEquipmentCreate):
@router.post("", response_model=StandardResponse[List[str]])
async def create_scope_equipment(
db_session: DbSession, scope_equipment_in: ScopeEquipmentCreate
):
scope = await create(db_session=db_session, scope_equipment_in=scope_equipment_in) scope = await create(db_session=db_session, scope_equipment_in=scope_equipment_in)
return StandardResponse(data=scope, message='Data created successfully')
@router.get('/history/{oh_session_id}', response_model=StandardResponse[List[dict]]) return StandardResponse(data=scope, message="Data created successfully")
async def get_history_standard_scope_wo(db_session: DbSession, collector_db_session: CollectorDbSession, oh_session_id: UUID):
@router.get("/history/{oh_session_id}", response_model=StandardResponse[List[dict]])
async def get_history_standard_scope_wo(
db_session: DbSession, collector_db_session:CollectorDbSession, oh_session_id:UUID):
results = await get_history_standard_scope_wo_service(db_session=db_session, collector_db_session=collector_db_session, oh_session_id=oh_session_id) results = await get_history_standard_scope_wo_service(db_session=db_session, collector_db_session=collector_db_session, oh_session_id=oh_session_id)
return StandardResponse(data=results, message='Data retrieved successfully')
return StandardResponse(data=results, message="Data retrieved successfully")
# @router.put("/{assetnum}", response_model=StandardResponse[ScopeEquipmentRead])
# async def update_scope_equipment(
# db_session: DbSession, assetnum: str, scope__equipment_in: ScopeEquipmentUpdate
# ):
# scope_equipment = await get_by_assetnum(db_session=db_session, assetnum=assetnum)
# if not scope_equipment:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail="A data with this id does not exist.",
# )
# return StandardResponse(
# data=await update(
# db_session=db_session,
# scope_equipment=scope_equipment,
# scope__equipment_in=scope__equipment_in,
# ),
# message="Data updated successfully",
# )
# @router.delete("/{assetnum}", response_model=StandardResponse[None])
# async def delete_scope_equipment(db_session: DbSession, assetnum: str):
# scope_equipment = await get_by_assetnum(db_session=db_session, assetnum=assetnum)
# if not scope_equipment:
# raise HTTPException(
# status_code=status.HTTP_404_NOT_FOUND,
# detail=[{"msg": "A data with this id does not exist."}],
# )
# await delete(db_session=db_session, assetnum=assetnum)
# return StandardResponse(message="Data deleted successfully", data=None)

@ -1,16 +1,23 @@
from datetime import datetime from datetime import datetime
from typing import List, Optional, ForwardRef from typing import List, Optional, ForwardRef
from uuid import UUID from uuid import UUID
from pydantic import Field
from pydantic import Field, computed_field, field_validator, validator
from src.models import DefultBase, Pagination from src.models import DefultBase, Pagination
from src.overhaul_scope.schema import ScopeRead
from .enum import ScopeEquipmentType from .enum import ScopeEquipmentType
class MasterEquipmentBase(DefultBase): class MasterEquipmentBase(DefultBase):
name: Optional[str] = Field(None, title='Name') name: Optional[str] = Field(None, title="Name")
location_tag: Optional[str] = Field(None, title='Location Tag') location_tag: Optional[str] = Field(None, title="Location Tag")
class ScopeEquipmentBase(DefultBase): class ScopeEquipmentBase(DefultBase):
scope_overhaul: Optional[str] = Field(None, title='Scope ID') scope_overhaul: Optional[str] = Field(None, title="Scope ID")
class ScopeEquipmentCreate(DefultBase): class ScopeEquipmentCreate(DefultBase):
assetnums: List[str] assetnums: List[str]
@ -18,8 +25,10 @@ class ScopeEquipmentCreate(DefultBase):
removal_date: Optional[datetime] = Field(None) removal_date: Optional[datetime] = Field(None)
type: Optional[str] = Field(ScopeEquipmentType.PERM) type: Optional[str] = Field(ScopeEquipmentType.PERM)
class ScopeEquipmentUpdate(ScopeEquipmentBase): class ScopeEquipmentUpdate(ScopeEquipmentBase):
assetnum: Optional[str] = Field(None, title='Asset Number') assetnum: Optional[str] = Field(None, title="Asset Number")
class ScopeEquipmentRead(ScopeEquipmentBase): class ScopeEquipmentRead(ScopeEquipmentBase):
id: UUID id: UUID
@ -27,19 +36,21 @@ class ScopeEquipmentRead(ScopeEquipmentBase):
assigned_date: datetime assigned_date: datetime
master_equipment: Optional[MasterEquipmentBase] = Field(None) master_equipment: Optional[MasterEquipmentBase] = Field(None)
class ScopeEquipmentPagination(DefultBase): class ScopeEquipmentPagination(DefultBase):
items: List[ScopeEquipmentRead] = [] items: List[ScopeEquipmentRead] = []
total: int total: int
class MasterEquipmentRead(DefultBase): class MasterEquipmentRead(DefultBase):
assetnum: Optional[str] = Field(None, title='Asset Number') assetnum: Optional[str] = Field(None, title="Asset Number")
location_tag: Optional[str] = Field(None, title='Location Tag') location_tag: Optional[str] = Field(None, title="Location Tag")
name: str name: str
EquipmentMasterTreeRef = ForwardRef('MasterEquipmentTree')
EquipmentMasterTreeRef = ForwardRef("MasterEquipmentTree")
class MasterEquipmentTree(MasterEquipmentRead): class MasterEquipmentTree(MasterEquipmentRead):
parent_id: Optional[UUID] parent_id: Optional[UUID]
parent: Optional[EquipmentMasterTreeRef] = Field(None) parent: Optional[EquipmentMasterTreeRef] = Field(None) # type: ignore
class MasterEquipmentPagination(Pagination): class MasterEquipmentPagination(Pagination):
items: List[MasterEquipmentRead] = [] items: List[MasterEquipmentRead] = []

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save