diff --git a/run.py b/run.py index e572f23..ad78104 100644 --- a/run.py +++ b/run.py @@ -1,7 +1,5 @@ import uvicorn - from src.config import HOST, PORT, ENV - -if __name__ == "__main__": - is_dev = str(ENV).lower() == "development" - uvicorn.run("src.main:app", host=HOST, port=PORT, reload=is_dev) +if __name__ == '__main__': + is_dev = str(ENV).lower() == 'development' + uvicorn.run('src.main:app', host=HOST, port=PORT, reload=is_dev) diff --git a/src/api.py b/src/api.py index 170c2d9..606560b 100644 --- a/src/api.py +++ b/src/api.py @@ -1,18 +1,11 @@ from typing import List, Optional - from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse from pydantic import BaseModel - - from src.auth.service import JWTBearer -from src.calculation_budget_constrains.router import \ - router as calculation_budget_constraint -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.calculation_budget_constrains.router import router as calculation_budget_constraint +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.overhaul.router import router as overhaul_router from src.standard_scope.router import router as standard_scope_router from src.overhaul_activity.router import router as overhaul_activity_router @@ -21,136 +14,32 @@ from src.equipment_workscope_group.router import router as equipment_workscope_g from src.overhaul_gantt.router import router as gantt_router from src.sparepart.router import router as sparepart_router from src.equipment_sparepart.router import router as equipment_sparepart_router - from src.overhaul_scope.router import router as ovehaul_schedule_router -# - class ErrorMessage(BaseModel): msg: str - class ErrorResponse(BaseModel): 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 = 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.get('/healthcheck', include_in_schema=False) def healthcheck(): - return {"status": "ok"} - - -authenticated_api_router = APIRouter( - dependencies=[Depends(JWTBearer())], -) -# overhaul data -authenticated_api_router.include_router( - overhaul_router, prefix="/overhauls", tags=["overhaul"] -) - - - -# # # 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, - -# authenticated_api_router.include_router( -# overhaul_schedule_router, - -# 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"], -) - + 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.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(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']) +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']) 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) diff --git a/src/app_logging.py b/src/app_logging.py index a3ea1da..c45e857 100644 --- a/src/app_logging.py +++ b/src/app_logging.py @@ -4,129 +4,61 @@ import json import datetime import os import sys - try: from zoneinfo import ZoneInfo except ImportError: - from backports.zoneinfo import ZoneInfo # type: ignore[no-redef] - + from backports.zoneinfo import ZoneInfo from src.config import LOG_LEVEL, SERVICE_NAME from src.enums import OptimumOHEnum - - -_TZ_LOCAL = ZoneInfo("Asia/Jakarta") - -# 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", -}) - +_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'} +_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): - info = "INFO" - warn = "WARN" - error = "ERROR" - debug = "DEBUG" - + info = 'INFO' + warn = 'WARN' + error = 'ERROR' + debug = 'DEBUG' 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: dt = datetime.datetime.fromtimestamp(record.created, tz=_TZ_LOCAL) - timestamp = dt.isoformat(timespec="microseconds") - - status_code = getattr(record, "status_code", None) - result = getattr(record, "result", None) + timestamp = dt.isoformat(timespec='microseconds') + status_code = getattr(record, 'status_code', None) + result = getattr(record, 'result', None) if result is None and status_code is not None: - 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), - } - + 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_json = json.dumps(log_record, default=str) - if sys.stdout.isatty(): - color = COLORS.get(record.levelname, "") - return f"{color}{log_json}{RESET}" - + color = COLORS.get(record.levelname, '') + return f'{color}{log_json}{RESET}' return log_json - def configure_logging() -> None: log_level = str(LOG_LEVEL).upper() if log_level not in list(LogLevels): log_level = LogLevels.error - root_logger = logging.getLogger() root_logger.setLevel(log_level) - if root_logger.hasHandlers(): root_logger.handlers.clear() - formatter = JSONFormatter() - - # stdout handler stream_handler = logging.StreamHandler(sys.stdout) stream_handler.setFormatter(formatter) root_logger.addHandler(stream_handler) - - # 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", - ) + 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) root_logger.addHandler(file_handler) - - # Reconfigure uvicorn loggers to propagate through our formatter - for logger_name in ["uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"]: + for logger_name in ['uvicorn', 'uvicorn.access', 'uvicorn.error', 'fastapi']: uvicorn_logger = logging.getLogger(logger_name) uvicorn_logger.handlers = [] uvicorn_logger.propagate = True - - # Silence chatty loggers — we emit one structured log per request ourselves - logging.getLogger("uvicorn.access").setLevel(logging.WARNING) - - slowapi_logger = logging.getLogger("slowapi") + logging.getLogger('uvicorn.access').setLevel(logging.WARNING) + slowapi_logger = logging.getLogger('slowapi') slowapi_logger.setLevel(logging.ERROR) slowapi_logger.propagate = False - - logging.getLogger("slack_sdk.web.base_client").setLevel(logging.CRITICAL) \ No newline at end of file + logging.getLogger('slack_sdk.web.base_client').setLevel(logging.CRITICAL) diff --git a/src/auth/__init__.py b/src/auth/__init__.py index e69de29..8b13789 100644 --- a/src/auth/__init__.py +++ b/src/auth/__init__.py @@ -0,0 +1 @@ + diff --git a/src/auth/access_control.py b/src/auth/access_control.py index 4609818..fc2f09a 100644 --- a/src/auth/access_control.py +++ b/src/auth/access_control.py @@ -2,9 +2,8 @@ from dataclasses import dataclass from enum import Enum from typing import Any, List, Union from fastapi import Request, HTTPException, status - -Allow: str = "allow" -Deny: str = "deny" +Allow: str = 'allow' +Deny: str = 'deny' @dataclass(frozen=True) class Principal: @@ -12,147 +11,95 @@ class Principal: value: str def __repr__(self) -> str: - return f"{self.key}:{self.value}" + return f'{self.key}:{self.value}' def __str__(self) -> str: return self.__repr__() @dataclass(frozen=True) class SystemPrincipal(Principal): + def __init__(self, value: str): - super().__init__(key="system", value=value) + super().__init__(key='system', value=value) @dataclass(frozen=True) class RolePrincipal(Principal): - def __init__(self, value: str): - super().__init__(key="role", value=value) - -Everyone = SystemPrincipal(value="everyone") -Authenticated = SystemPrincipal(value="authenticated") + def __init__(self, value: str): + super().__init__(key='role', value=value) +Everyone = SystemPrincipal(value='everyone') +Authenticated = SystemPrincipal(value='authenticated') class OHPermission(Enum): - CREATE = "create" - READ = "read" - EDIT = "edit" - DELETE = "delete" - + CREATE = 'create' + READ = 'read' + EDIT = 'edit' + DELETE = 'delete' class AccessControl: - def __init__(self, permission_exception: Any = None): - self.permission_exception = permission_exception or HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied" - ) + + def __init__(self, permission_exception: Any=None): + self.permission_exception = permission_exception or HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Permission denied') def _acl(self, resource): - acl = getattr(resource, "__acl__", []) + acl = getattr(resource, '__acl__', []) if callable(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): resource = [resource] - permits = [] for res in resource: granted = False acl = self._acl(res) - for action, principal, permissions in acl: - # Check if any of the required permissions are in the allowed permissions for this principal - is_permitted = any(p in permissions for p in required_permissions) - - 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 break - elif (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 break - permits.append(granted) - 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): raise self.permission_exception - - -# Roles allowed to access the backend (whitelist). -# Management is intentionally excluded. -ALLOWED_ROLES = {"Admin", "Super Admin", "Engineer", "Application Administrator"} - +ALLOWED_ROLES = {'Admin', 'Super Admin', 'Engineer', 'Application Administrator'} 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} async def dependency(request: Request): - user = getattr(request.state, "user", None) + user = getattr(request.state, 'user', None) if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated" - ) - - user_role = user.get("role") if isinstance(user, dict) else getattr(user, "role", None) - + raise HTTPException(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: - 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 dependency - def required_permission(permissions: Union[OHPermission, List[OHPermission]], resources: Any): - """ - FastAPI dependency for role-based access control. - """ if isinstance(permissions, OHPermission): permissions = [permissions] async def dependency(request: Request): - user = getattr(request.state, "user", None) + user = getattr(request.state, 'user', None) 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 if isinstance(user, dict): - user_role = user.get("role") + user_role = user.get('role') else: - user_role = getattr(user, "role", None) - + user_role = getattr(user, 'role', None) 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)] - ac = AccessControl() ac.assert_access(user_principals, permissions, resources) - return True - return dependency diff --git a/src/auth/model.py b/src/auth/model.py index 492117f..69d33d6 100644 --- a/src/auth/model.py +++ b/src/auth/model.py @@ -1,10 +1,7 @@ from typing import Optional - from pydantic import BaseModel - class UserBase(BaseModel): - # Some users from auth service may not have a display name. name: Optional[str] = None role: str user_id: str diff --git a/src/auth/service.py b/src/auth/service.py index cf3b114..f7f9f9b 100644 --- a/src/auth/service.py +++ b/src/auth/service.py @@ -1,253 +1,137 @@ -# app/auth/auth_bearer.py - import json from typing import Annotated - import requests from fastapi import Depends, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer - import src.config as config - from .model import UserBase from .util import extract_template class JWTBearer(HTTPBearer): - 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. + + def __init__(self, auto_error: bool=True): super(JWTBearer, self).__init__(auto_error=False) 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 not credentials.scheme == "Bearer": - raise HTTPException( - status_code=401, detail="Invalid authentication scheme." - ) + if not credentials.scheme == 'Bearer': + raise HTTPException(status_code=401, detail='Invalid authentication scheme.') method = request.method - - if method == "OPTIONS": + if method == 'OPTIONS': return - 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) - if not user_info: 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: - message = str(message) if message else "Invalid token or expired token." - raise HTTPException( - status_code=401, detail=message - ) - + message = str(message) if message else 'Invalid token or expired token.' + raise HTTPException(status_code=401, detail=message) request.state.user = message - 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)) - username = getattr(message, "username", None) or getattr(message, "name", None) + username = getattr(message, 'username', None) or getattr(message, 'name', None) if username: set_username(username) - if hasattr(message, "role"): + if hasattr(message, 'role'): set_role(message.role) - return message else: - raise HTTPException(status_code=401, detail="Invalid authorization code.") + raise HTTPException(status_code=401, detail='Invalid authorization code.') def verify_jwt(self, jwtoken: str, method: str, endpoint: str): try: - url_to_verify = f"{config.AUTH_SERVICE_API}/verify-token" - response = requests.get( - url_to_verify, - headers={"Authorization": f"Bearer {jwtoken}"}, - ) - + url_to_verify = f'{config.AUTH_SERVICE_API}/verify-token' + response = requests.get(url_to_verify, headers={'Authorization': f'Bearer {jwtoken}'}) if not response.ok: - return False, response.json() - + return (False, response.json()) user_data = response.json() - return True, UserBase(**user_data["data"]) - + return (True, UserBase(**user_data['data'])) except Exception as e: - print(f"Token verification error: {str(e)}") - return False, str(e) + print(f'Token verification error: {str(e)}') + return (False, str(e)) - -# Create dependency to get current user from request state async def get_current_user(request: Request) -> UserBase: return request.state.user async def get_token(request: Request): - token = request.headers.get("Authorization") + token = request.headers.get('Authorization') if token: - return token.replace("Bearer ", "") # Menghapus prefix "Bearer " + return token.replace('Bearer ', '') else: - return request.cookies.get("access_token") # Fallback ke cookie - - return "" # Mengembalikan token atau None jika tidak ada - + return request.cookies.get('access_token') + return '' async def internal_key(request: Request): - token = request.headers.get("Authorization") - - if not token: - api_key = request.headers.get("X-Internal-Key") - + token = request.headers.get('Authorization') + if not token: + api_key = request.headers.get('X-Internal-Key') if api_key != config.API_KEY: - raise HTTPException( - status_code=401, detail="Invalid Key." - ) - + raise HTTPException(status_code=401, detail='Invalid Key.') try: - headers = { - 'Content-Type': 'application/json' - } - - response = requests.post( - f"{config.AUTH_SERVICE_API}/sign-in", - headers=headers, - data=json.dumps({ - "username": "ohuser", - "password": "123456789" - }) - ) - + headers = {'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: print(str(response.json())) - raise Exception("error auth") - + raise Exception('error auth') user_data = response.json() return user_data['data']['access_token'] - except Exception as e: raise Exception(str(e)) else: try: - verify_url = f"{config.AUTH_SERVICE_API}/verify-token" - response = requests.get( - verify_url, - headers={"Authorization": f"{token}"}, - ) - + verify_url = f'{config.AUTH_SERVICE_API}/verify-token' + response = requests.get(verify_url, headers={'Authorization': f'{token}'}) if not response.ok: - raise HTTPException( - status_code=401, detail="Invalid token." - ) - - return token.split(" ")[1] - + raise HTTPException(status_code=401, detail='Invalid token.') + return token.split(' ')[1] except Exception as e: - print(f"Token verification error: {str(e)}") - return False, str(e) - - - - - + print(f'Token verification error: {str(e)}') + return (False, str(e)) import asyncio import logging from typing import Dict, Any import src.config as config - - log = logging.getLogger(__name__) +AUTH_NOTIFY_ENDPOINT = f'{config.AUTH_SERVICE_API}/admin/notify-limit' - -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]: - """ - 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 {} - +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} + token = request.headers.get('Authorization') + headers = {'Authorization': token} if token else {} 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() result = response.json() - log.info(f"Notifikasi admin sent | Endpoint: {endpoint_name}") + log.info(f'Notifikasi admin sent | Endpoint: {endpoint_name}') return result - except Exception as e: - log.error(f"Error notifying admin: {str(e)}") - 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 {} + log.error(f'Error notifying admin: {str(e)}') + 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]: + 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: response = requests.post(AUTH_NOTIFY_ENDPOINT, json=payload, headers=headers, timeout=timeout) response.raise_for_status() result = response.json() - log.info(f"Notifikasi admin sent | Endpoint: {endpoint_name}") + log.info(f'Notifikasi admin sent | Endpoint: {endpoint_name}') return result - except Exception as e: - log.error(f"Error notifying admin: {str(e)}") - return {"status": False, "message": str(e), "data": payload} + log.error(f'Error notifying admin: {str(e)}') + 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." - ) + 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)] Token = Annotated[str, Depends(get_token)] -InternalKey = Annotated[str, Depends(internal_key)] \ No newline at end of file +InternalKey = Annotated[str, Depends(internal_key)] diff --git a/src/auth/util.py b/src/auth/util.py index 1ceece4..aa760ed 100644 --- a/src/auth/util.py +++ b/src/auth/util.py @@ -1,9 +1,6 @@ def extract_template(path_string, value_dict): template = path_string - - # Replace each value in the dict with its corresponding key placeholder for key, value in value_dict.items(): if str(value) in template: template = template.replace(str(value), f'<{key}>') - return template diff --git a/src/calculation_budget_constrains/__init__.py b/src/calculation_budget_constrains/__init__.py index e69de29..8b13789 100644 --- a/src/calculation_budget_constrains/__init__.py +++ b/src/calculation_budget_constrains/__init__.py @@ -0,0 +1 @@ + diff --git a/src/calculation_budget_constrains/router.py b/src/calculation_budget_constrains/router.py index cf0441b..bf0118e 100644 --- a/src/calculation_budget_constrains/router.py +++ b/src/calculation_budget_constrains/router.py @@ -1,46 +1,21 @@ from typing import Annotated, Dict - from fastapi import APIRouter from fastapi.params import Query - from src.auth.service import Token from src.calculation_budget_constrains.schema import BudgetContraintQuery from src.calculation_target_reliability.service import get_simulation_results from src.config import TC_RBD_ID from src.database.core import CollectorDbSession, DbSession from src.models import StandardResponse - from .service import get_all_budget_constrains from src.auth.access_control import required_permission, require_any_role, OHPermission, ALLOWED_ROLES from src.overhaul_scope.model import OverhaulScope from fastapi import Depends - 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()], -): - """Get all scope pagination.""" +@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()]): cost_threshold = params.cost_threshold - results = await get_simulation_results( - simulation_id = TC_RBD_ID, - 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", - ) + results = await get_simulation_results(simulation_id=TC_RBD_ID, 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') diff --git a/src/calculation_budget_constrains/schema.py b/src/calculation_budget_constrains/schema.py index f60e220..c3cbfbc 100644 --- a/src/calculation_budget_constrains/schema.py +++ b/src/calculation_budget_constrains/schema.py @@ -1,27 +1,18 @@ from typing import Any, Dict, List - from pydantic import BaseModel, Field - from src.models import DefultBase - class OverhaulBase(BaseModel): pass - class OverhaulCriticalParts(OverhaulBase): - criticalParts: List[str] = Field(..., description="List of critical parts") - + criticalParts: List[str] = Field(..., description='List of critical parts') 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): - systemComponents: Dict[str, Any] = Field( - ..., description="List of system components" - ) - + systemComponents: Dict[str, Any] = Field(..., description='List of system components') class OverhaulRead(OverhaulBase): overview: Dict[str, Any] @@ -29,23 +20,5 @@ class OverhaulRead(OverhaulBase): schedules: List[Dict[str, Any]] systemComponents: Dict[str, Any] - class BudgetContraintQuery(DefultBase): cost_threshold: float = Field(100, ge=0, le=1000000000000000) - -# "equipmentCount": 30 -# }, -# "criticalParts": [ -# "Boiler feed pump", -# "Boiler reheater system", -# "BCP A Discharge Valve", -# "BFPT A EXH Press HI Root VLV" -# ], -# "schedules": [ -# "status": "upcoming" -# // ... other scheduled overhauls -# ], -# "lastOverhaul": "2024-06-15" -# }, -# "lpt": { "status": "operational" } -# // ... other major components diff --git a/src/calculation_budget_constrains/service.py b/src/calculation_budget_constrains/service.py index b10fbaf..7f7d7af 100644 --- a/src/calculation_budget_constrains/service.py +++ b/src/calculation_budget_constrains/service.py @@ -2,172 +2,90 @@ from collections import defaultdict import math from typing import Optional, List, Dict, Tuple from uuid import UUID - - from src.database.core import CollectorDbSession, DbSession from src.overhaul_activity.service import 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 uuid import UUID from typing import List, Dict, Tuple - from src.database.core import CollectorDbSession, DbSession from src.overhaul_activity.service import get_standard_scope_by_session_id - -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"] - - equipments = await get_standard_scope_by_session_id( - db_session=db_session, - overhaul_session_id=session_id, - collector_db=collector_db, - ) +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]]: + calc_result = simulation_result['calc_result'] + plant_result = simulation_result['plant_result'] + equipments = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=session_id, collector_db=collector_db) if not equipments: - return [], [] - - # Flatten results + return ([], []) eq_results = calc_result if isinstance(calc_result, list) else [calc_result] - - # 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 + equipments_eaf_contribution = calculate_asset_eaf_contributions(plant_result=plant_result, eq_results=eq_results) result = [] for equipment in equipments: contribution = equipments_eaf_contribution.get(equipment.location_tag, 0.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, - } - ) - - # Normalize contribution so sum = 1.0 - 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}) + total_contrib = sum((item['eaf_contribution'] for item in result)) or 1.0 for item in result: - item["contribution_norm"] = item["eaf_contribution"] / total_contrib - - # Calculate efficiency and composite score + item['contribution_norm'] = item['eaf_contribution'] / total_contrib for item in result: - cost = item["total_cost"] or 1.0 - item["contribution_norm"] / cost - item["priority_score"] = item["contribution_norm"] - - # Choose method + cost = item['total_cost'] or 1.0 + item['contribution_norm'] / cost + item['priority_score'] = item['contribution_norm'] if use_optimal: priority_list, consequence_list = knapsack_selection(result, cost_threshold) else: 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): - """ - 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) 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: - results[node_name] = asset.get("contribution_factor", 0.0) + results[node_name] = asset.get('contribution_factor', 0.0) return results - def greedy_selection(equipments: List[dict], budget: float) -> Tuple[List[dict], List[dict]]: - """Greedy fallback: select items by score until budget is used.""" - # Sort by priority_score descending - equipments_sorted = sorted(equipments, key=lambda x: x["priority_score"], reverse=True) + equipments_sorted = sorted(equipments, key=lambda x: x['priority_score'], reverse=True) current_cost = 0.0 - selected, excluded = [], [] - + selected, excluded = ([], []) for eq in equipments_sorted: - cost = eq.get("total_cost", 0.0) + cost = eq.get('total_cost', 0.0) if current_cost + cost <= budget: selected.append(eq) current_cost += cost else: excluded.append(eq) - - return selected, excluded + return (selected, excluded) - -def knapsack_selection(equipments: List[dict], budget: float, scale: Optional[float] = None) -> Tuple[List[dict], List[dict]]: - """ - Select equipment optimally within budget using 0/1 knapsack DP. - Uses dynamic scaling + 1D DP optimization to ensure accuracy and avoid MemoryError. - """ +def knapsack_selection(equipments: List[dict], budget: float, scale: Optional[float]=None) -> Tuple[List[dict], List[dict]]: if not equipments: - return [], [] - - # Filter out items that are strictly above budget right away - # This helps significantly when budget is very low (e.g. 1) + return ([], []) eligible_items = [] strictly_excluded = [] for eq in equipments: - if eq["total_cost"] > budget: + if eq['total_cost'] > budget: strictly_excluded.append(eq) else: eligible_items.append(eq) - if not eligible_items: - return [], strictly_excluded - + return ([], strictly_excluded) n = len(eligible_items) - - # Dynamic scaling: target W around 2000 for good precision/performance balance if scale is None: 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] + 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) - - # Fallback if W is still too large (memory safety) - if W > 1_000_000: + if W > 1000000: return greedy_selection(equipments, budget) - - # 1D DP array dp = [0.0] * (W + 1) keep = [[False] * (W + 1) for _ 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): if dp[w - cost] + value >= dp[w]: dp[w] = dp[w - cost] + value keep[i][w] = True - - # Backtrack selected = [] backtrack_excluded = [] w = W @@ -177,23 +95,14 @@ def knapsack_selection(equipments: List[dict], budget: float, scale: Optional[fl w -= costs[i] else: backtrack_excluded.append(eligible_items[i]) - excluded = backtrack_excluded + strictly_excluded - - # Optional: greedy fill leftover budget with actual values - # This compensates for any conservative rounding from math.ceil - current_total_cost = sum(eq["total_cost"] for eq in selected) + current_total_cost = sum((eq['total_cost'] for eq in selected)) remaining_budget = budget - current_total_cost - if remaining_budget > 0: - # Sort excluded items by priority score for better greedy fill - excluded.sort(key=lambda x: x["priority_score"], reverse=True) + excluded.sort(key=lambda x: x['priority_score'], reverse=True) for eq in excluded[:]: - if eq["total_cost"] <= remaining_budget: + if eq['total_cost'] <= remaining_budget: selected.append(eq) excluded.remove(eq) - remaining_budget -= eq["total_cost"] - - return selected, excluded - - \ No newline at end of file + remaining_budget -= eq['total_cost'] + return (selected, excluded) diff --git a/src/calculation_target_reliability/__init__.py b/src/calculation_target_reliability/__init__.py index e69de29..8b13789 100644 --- a/src/calculation_target_reliability/__init__.py +++ b/src/calculation_target_reliability/__init__.py @@ -0,0 +1 @@ + diff --git a/src/calculation_target_reliability/router.py b/src/calculation_target_reliability/router.py index 9a2cc36..4f6caea 100644 --- a/src/calculation_target_reliability/router.py +++ b/src/calculation_target_reliability/router.py @@ -2,104 +2,45 @@ from typing_extensions import Annotated from temporalio.client import Client from fastapi import APIRouter, HTTPException, status from fastapi.params import Query - from src.config import TEMPORAL_URL, TR_RBD_ID from src.database.core import DbSession, CollectorDbSession from src.auth.service import Token from src.models import StandardResponse - -from .service import get_simulation_results, identify_worst_eaf_contributors +from .service import get_simulation_results, identify_worst_eaf_contributors from .schema import OptimizationResult, TargetReliabiltiyQuery from src.auth.access_control import required_permission, OHPermission from src.overhaul_scope.model import OverhaulScope from fastapi import Depends router = APIRouter() - -# @router.get("", response_model=StandardResponse[List[Dict]]) -# async def get_target_reliability( -# db_session: DbSession, -# ): -# """Get all scope pagination.""" - -# return StandardResponse( - - -@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()], -): - """Get all scope pagination.""" +@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 = params.oh_session_id eaf_input = params.eaf_input duration = params.duration simulation_id = params.simulation_id cut_hours = params.cut_hours - 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') if duration != 17520: 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: 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() 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.", - ) - elif status_name != "COMPLETED": - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Simulation failed with status: {status_name}", - ) + if status_name in ['RUNNING', 'CONTINUED_AS_NEW']: + raise HTTPException(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: raise except Exception as 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)}", - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f'Simulation not found or error checking status: {str(e)}') else: 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') diff --git a/src/calculation_target_reliability/schema.py b/src/calculation_target_reliability/schema.py index cfb47c0..5a66e6b 100644 --- a/src/calculation_target_reliability/schema.py +++ b/src/calculation_target_reliability/schema.py @@ -1,27 +1,18 @@ from typing import Any, Dict, List, Optional - from pydantic import BaseModel, Field - from src.models import DefultBase - class OverhaulBase(BaseModel): pass - class OverhaulCriticalParts(OverhaulBase): - criticalParts: List[str] = Field(..., description="List of critical parts") - + criticalParts: List[str] = Field(..., description='List of critical parts') 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): - systemComponents: Dict[str, Any] = Field( - ..., description="List of system components" - ) - + systemComponents: Dict[str, Any] = Field(..., description='List of system components') class OverhaulRead(OverhaulBase): overview: Dict[str, Any] @@ -31,13 +22,13 @@ class OverhaulRead(OverhaulBase): class AssetWeight(OverhaulBase): node: dict - availability:float + availability: float contribution: float required_improvement: float num_of_failures: int down_time: float efficiency: float - improvement_impact:float + improvement_impact: float birbaum: float class MaintenanceScenario(OverhaulBase): @@ -46,42 +37,23 @@ class MaintenanceScenario(OverhaulBase): projected_eaf_improvement: float priority_score: float plant_level_benefit: float = 0.0 - capacity_weight : float - + capacity_weight: float class OptimizationResult(OverhaulBase): current_plant_eaf: float target_plant_eaf: float - possible_plant_eaf:float + possible_plant_eaf: float eaf_gap: float - eaf_improvement_text:str - recommended_reduced_outage:Optional[float] = 0 - warning_message:Optional[str] + eaf_improvement_text: str + recommended_reduced_outage: Optional[float] = 0 + warning_message: Optional[str] asset_contributions: List[dict] optimization_success: bool = False simulation_id: Optional[str] = None - class TargetReliabiltiyQuery(DefultBase): oh_session_id: Optional[str] = Field(None) eaf_input: float = Field(99.8) duration: int = Field(17520) simulation_id: Optional[str] = Field(None) - cut_hours:int = Field(0) - -# "equipmentCount": 30 -# }, -# "criticalParts": [ -# "Boiler feed pump", -# "Boiler reheater system", -# "BCP A Discharge Valve", -# "BFPT A EXH Press HI Root VLV" -# ], -# "schedules": [ -# "status": "upcoming" -# // ... other scheduled overhauls -# ], -# "lastOverhaul": "2024-06-15" -# }, -# "lpt": { "status": "operational" } -# // ... other major components + cut_hours: int = Field(0) diff --git a/src/calculation_target_reliability/service.py b/src/calculation_target_reliability/service.py index c37dcfc..f72e137 100644 --- a/src/calculation_target_reliability/service.py +++ b/src/calculation_target_reliability/service.py @@ -3,337 +3,140 @@ import httpx from src.config import RBD_SERVICE_API from src.database.core import DbSession, CollectorDbSession import asyncio -from .schema import AssetWeight,OptimizationResult - +from .schema import AssetWeight, OptimizationResult from src.overhaul_activity.service import get_standard_scope_by_session_id - client = httpx.AsyncClient(timeout=300.0) - 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 - } - - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - } - - rbd_simulation_url = f"{RBD_SERVICE_API}/aeros/simulation/run" - + 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} + 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: response = await client.post(rbd_simulation_url, json=sim_data, headers=headers) response.raise_for_status() return response.json() async def get_simulation_results(*, simulation_id: str, token: str): - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - } - - calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode" - calc_plant_result = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant" - + headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} + calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{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: calc_task = client.get(calc_result_url, 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.raise_for_status() plant_response.raise_for_status() - - calc_data = calc_response.json()["data"] - plant_data = plant_response.json()["data"] - - return { - "calc_result": calc_data, - "plant_result": plant_data - } + calc_data = calc_response.json()['data'] + plant_data = plant_response.json()['data'] + return {'calc_result': calc_data, 'plant_result': plant_data} 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 - - 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 - REALISTIC_MAX_TECHNICAL = 0.995 REALISTIC_MAX_AVAILABILITY = REALISTIC_MAX_TECHNICAL * plant_operating_fraction MIN_IMPROVEMENT_PERCENT = 0.0001 min_improvement_fraction = MIN_IMPROVEMENT_PERCENT / 100.0 - - results = [] weighted_assets = [] - - # Step 1: Collect eligible assets and their weights for asset in eq_results: - node = asset.get("aeros_node") + node = asset.get('aeros_node') if not node: 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: continue - - contribution_factor = asset.get("contribution_factor", 0.0) - birbaum = asset.get("contribution", 0.0) - current_availability = asset.get("availability", 0.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 + contribution_factor = asset.get('contribution_factor', 0.0) + birbaum = asset.get('contribution', 0.0) + current_availability = asset.get('availability', 0.0) + downtime = asset.get('total_downtime', 0.0) + if num_of_events < 2 or contribution_factor <= 0: continue - - - # --- Compute realistic possible improvement --- if REALISTIC_MAX_AVAILABILITY > current_availability: max_possible_improvement = REALISTIC_MAX_AVAILABILITY - current_availability else: - max_possible_improvement = 0.0 # No improvement possible - - - - # Compute weighted importance (Birnbaum × FV) + max_possible_improvement = 0.0 raw_weight = birbaum weight = math.sqrt(max(raw_weight, 0.0)) - weighted_assets.append((asset, weight, 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 + total_weight = sum((w for _, w, _ in weighted_assets)) or 1.0 for asset, weight, max_possible_improvement in weighted_assets: - node = asset.get("aeros_node") - contribution_factor = asset.get("contribution_factor", 0.0) - birbaum = asset.get("contribution", 0.0) - current_availability = asset.get("availability", 0.0) - downtime = asset.get("total_downtime", 0.0) - - required_improvement = eaf_gap_fraction * (weight/total_weight) + node = asset.get('aeros_node') + contribution_factor = asset.get('contribution_factor', 0.0) + birbaum = asset.get('contribution', 0.0) + current_availability = asset.get('availability', 0.0) + downtime = asset.get('total_downtime', 0.0) + required_improvement = eaf_gap_fraction * (weight / total_weight) required_improvement = min(required_improvement, max_possible_improvement) required_improvement = max(required_improvement, min_improvement_fraction) - improvement_impact = required_improvement * contribution_factor 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) - - # Step 4: Sort by Birnbaum importance results.sort(key=lambda x: x.birbaum, reverse=True) return results - -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 - """ +def project_eaf_improvement(asset: AssetWeight, improvement_factor: float=0.3) -> float: current_downtime_pct = 100 - asset.eaf improved_downtime_pct = current_downtime_pct * (1 - improvement_factor) projected_eaf = 100 - improved_downtime_pct - 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"] + return min(projected_eaf, 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): + calc_result = simulation_result['calc_result'] + plant_result = simulation_result['plant_result'] eq_results = calc_result if isinstance(calc_result, list) else [calc_result] - - # Base parameters - current_plant_eaf = plant_result.get("eaf", 0) + current_plant_eaf = plant_result.get('eaf', 0) total_hours = duration scheduled_outage = int(po_duration) reduced_outage = max(scheduled_outage - cut_hours, 0) max_eaf_possible = (total_hours - reduced_outage) / total_hours * 100 - - # Improvement purely from outage reduction (global) - scheduled_eaf_gain = (cut_hours / total_hours) * 100 if cut_hours > 0 else 0.0 - - # Target feasibility check + scheduled_eaf_gain = cut_hours / total_hours * 100 if cut_hours > 0 else 0.0 warning_message = None if target_eaf > max_eaf_possible: target_eaf - max_eaf_possible required_scheduled_hours = total_hours * (1 - target_eaf / 100) required_reduction = reduced_outage - required_scheduled_hours - - # Build dynamic phrase for clarity 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: - reduction_phrase = "" - - 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 + 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).' target_eaf = max_eaf_possible - eaf_gap = (target_eaf - current_plant_eaf) / 100.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="" - ) - - # 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, - ) + 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='') + 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] - - # 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 + asset_contributions = calculate_asset_eaf_contributions(plant_result, eq_results, standard_scope_location_tags, eaf_gap, reduced_outage) project_eaf_improvement_total = 0.0 selected_eq = [] - for asset in asset_contributions: if project_eaf_improvement_total >= eaf_gap: 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) project_eaf_improvement_total += asset.improvement_impact else: 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 = min(possible_eaf_plant, max_eaf_possible) - selected_eq.sort(key=lambda x: x.birbaum, reverse=True) required_cut_hours = 0 - - # --- 2. Optimization feasible but cannot reach target (underperformance case) --- if possible_eaf_plant < target_eaf: - # Calculate shortfall performance_gap = target_eaf - possible_eaf_plant - - # Estimate how many scheduled outage hours must be reduced to close the remaining gap - # 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)." - ) - + 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}%).' + 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).' 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: - warning_message = reliability_limit_msg + "\n" + recommendation_msg - - # --- EAF improvement reporting --- - eaf_improvement_points = (possible_eaf_plant - current_plant_eaf) - - # Express as text for user readability + warning_message = reliability_limit_msg + '\n' + recommendation_msg + eaf_improvement_points = possible_eaf_plant - current_plant_eaf 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: - improvement_text = "No measurable improvement achieved" - - # 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, - ) + 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) diff --git a/src/calculation_target_reliability/utils.py b/src/calculation_target_reliability/utils.py index 2328eaa..cc41cad 100644 --- a/src/calculation_target_reliability/utils.py +++ b/src/calculation_target_reliability/utils.py @@ -3,89 +3,48 @@ from datetime import datetime, timedelta import random from typing import Optional 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]]: - """ - 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 - """ +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]]: if num_periods is None: num_periods = random.randint(1, 3) - total_days = (end_date - start_date).days down_periods = [] - - # Generate random down periods for _ in range(num_periods): - # Random duration for this period duration = random.randint(min_duration, max_duration) - - # Ensure we don't exceed the total date range latest_possible_start = total_days - duration - if latest_possible_start < 0: continue - - # Random start day within available range start_day = random.randint(0, latest_possible_start) period_start = start_date + timedelta(days=start_day) period_end = period_start + timedelta(days=duration) - - # 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 - ) - + overlaps = any((p_start <= period_end and period_start <= p_end for p_start, p_end in down_periods)) if not overlaps: down_periods.append((period_start, period_end)) - return sorted(down_periods) - async def wait_for_workflow(simulation_id, max_retries=3): - workflow_id = f"simulation-{simulation_id}" # use returned ID + workflow_id = f'simulation-{simulation_id}' retries = 0 temporal_client = await Client.connect(TEMPORAL_URL) - while True: try: handle = temporal_client.get_workflow_handle(workflow_id=workflow_id) desc = await handle.describe() 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 - - 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: retries += 1 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 else: - print(f"⚠️ Workflow {workflow_id} not found (retry {retries}/{max_retries}), waiting 10s before retry...") + print(f'⚠️ Workflow {workflow_id} not found (retry {retries}/{max_retries}), waiting 10s before retry...') await asyncio.sleep(10) continue - - retries = 0 # reset retries if describe() worked + retries = 0 await asyncio.sleep(30) - - return simulation_id \ No newline at end of file + return simulation_id diff --git a/src/calculation_time_constrains/__init__.py b/src/calculation_time_constrains/__init__.py index e69de29..8b13789 100644 --- a/src/calculation_time_constrains/__init__.py +++ b/src/calculation_time_constrains/__init__.py @@ -0,0 +1 @@ + diff --git a/src/calculation_time_constrains/class.py b/src/calculation_time_constrains/class.py index f2a1477..da4c32b 100644 --- a/src/calculation_time_constrains/class.py +++ b/src/calculation_time_constrains/class.py @@ -6,226 +6,103 @@ from collections import defaultdict from src.config import BASE_URL 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.token = token self.last_oh_date = last_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): - """ - 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') - - # 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 - } - + url = f'{self.api_base_url}/reliability/calculate/reliability/{location_tag}/{date_str}' + header = {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + self.token} try: response = requests.get(url, headers=header) response.raise_for_status() - data = response.json() reliability = data['data']['value'] - return reliability - except requests.RequestException as e: - print(f"API Error: {e}") + print(f'API Error: {e}') return None except KeyError as e: - print(f"Data parsing error: {e}") + print(f'Data parsing error: {e}') return None - def get_reliability_equipment(self, location_tag): current_date = self.last_oh_date results = defaultdict() - - print("Fetching reliability data from API...") - + print('Fetching reliability data from API...') while current_date <= self.next_oh_date: reliability = self.get_reliability_from_api(current_date, location_tag) results[current_date] = reliability - if reliability is not None: print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: {reliability:.6f}") else: print(f"Date: {current_date.strftime('%Y-%m-%d')}, Reliability: Failed to fetch") - current_date += timedelta(days=self.interval) - return results - - def calculate_costs_each_point(self, reliabilities, preventive_cost: float, failure_replacement_cost: float): - # Calculate costs for each time point + def calculate_costs_each_point(self, reliabilities, preventive_cost: float, failure_replacement_cost: float): results = [] dates = list(reliabilities.keys()) - for i, (date, reliability) in enumerate(reliabilities.items()): if reliability is None: continue - - # Calculate time from last OH in days time_from_last_oh = (date - self.last_oh_date).days - - # Calculate failure replacement cost 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: - expected_operating_time = time_from_last_oh # First point + expected_operating_time = time_from_last_oh else: - # Approximate integral using available reliability values - 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] - + 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] if len(rel_values) > 1: expected_operating_time = np.trapezoid(rel_values, time_points) else: expected_operating_time = time_from_last_oh * reliability - - # Calculate costs if expected_operating_time > 0: - failure_cost = (failure_prob * failure_replacement_cost) / expected_operating_time - preventive_cost = (reliability * preventive_cost) / expected_operating_time + failure_cost = failure_prob * failure_replacement_cost / expected_operating_time + preventive_cost = reliability * preventive_cost / expected_operating_time else: - continue - 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 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: return None - - # Find minimum total cost min_cost_idx = results_df['total_cost'].idxmin() 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} - 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 - """ + async def calculate_cost_all_equipment(self, db_session: DbSession, equipments: list, preventive_cost: float, calculation): max_interval = self._get_months_between(start_date, end_date) preventive_cost / len(equipments) - fleet_results = [] total_corrective_costs = np.zeros(max_interval) total_preventive_costs = np.zeros(max_interval) total_procurement_costs = np.zeros(max_interval) np.zeros(max_interval) - for equipment in equipments: - # Get reliability data 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) - optimum_cost = self.find_optimal_timing(pd.DataFrame(predicted_cost)) - - # Aggregate costs - corrective_costs = [r["failure_cost"] for r in predicted_cost] - preventive_costs = [r["preventive_cost"] for r in predicted_cost] - procurement_costs = [r["procurement_cost"] for r in predicted_cost] - 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 - ) - ) - + corrective_costs = [r['failure_cost'] for r in predicted_cost] + preventive_costs = [r['preventive_cost'] for r in predicted_cost] + procurement_costs = [r['procurement_cost'] for r in predicted_cost] + 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_preventive_costs += np.array(preventive_costs) total_procurement_costs += np.array(procurement_costs) - - # Calculate fleet optimal interval total_costs = total_corrective_costs + total_preventive_costs + total_procurement_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) 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()} diff --git a/src/calculation_time_constrains/flows.py b/src/calculation_time_constrains/flows.py index 1ccde99..39a9254 100644 --- a/src/calculation_time_constrains/flows.py +++ b/src/calculation_time_constrains/flows.py @@ -1,112 +1,37 @@ from typing import Optional - from fastapi import HTTPException, status from sqlalchemy import func, select - from src.config import TC_RBD_ID from src.database.core import DbSession from src.overhaul_scope.service import get_all from src.standard_scope.model import StandardScope 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 +from .service import create_param_and_data, get_calculation_data_by_id, run_simulation_with_spareparts 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: - 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: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - 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) - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 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) costFailure = results.all() scopes = await get_all(db_session=db_session) avaiableScopes = {scope.id: scope.scope_name for scope in scopes} - costFailurePerScope = { - avaiableScopes.get(costPerFailure[0]): costPerFailure[1] - for costPerFailure in costFailure - } - - return CalculationTimeConstrainsParametersRetrive( - costPerFailure=costFailurePerScope, - availableScopes=avaiableScopes.values(), - recommendedScope="A", - # }, - # }, - ) - - -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 - - 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 - ) + costFailurePerScope = {avaiableScopes.get(costPerFailure[0]): costPerFailure[1] for costPerFailure in costFailure} + return CalculationTimeConstrainsParametersRetrive(costPerFailure=costFailurePerScope, availableScopes=avaiableScopes.values(), recommendedScope='A') +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 + 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 - ) - +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: - 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 - - # Check if calculation already exist - # return CalculationTimeConstrainsRead( diff --git a/src/calculation_time_constrains/model.py b/src/calculation_time_constrains/model.py index fa237a4..ae9b6d5 100644 --- a/src/calculation_time_constrains/model.py +++ b/src/calculation_time_constrains/model.py @@ -1,133 +1,60 @@ from enum import Enum from typing import Optional - -from sqlalchemy import (JSON, UUID, Boolean, Column, Float, ForeignKey, - Integer, String) +from sqlalchemy import JSON, UUID, Boolean, Column, Float, ForeignKey, Integer, String from sqlalchemy.orm import relationship - from src.database.core import Base, DbSession from src.models import DefaultMixin, IdentityMixin - class OverhaulReferenceType(str, Enum): - SCOPE = "SCOPE" - ASSET = "ASSET" - + SCOPE = 'SCOPE' + ASSET = 'ASSET' class CalculationParam(Base, DefaultMixin, IdentityMixin): - __tablename__ = "oh_ms_calculation_param" - + __tablename__ = 'oh_ms_calculation_param' avg_failure_cost = Column(Float, nullable=False) overhaul_cost = Column(Float, nullable=False) - - # 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} - # ): - # # Create parameter - - # # Create reference links - # for ref in references: - - + calculation_data = relationship('CalculationData', back_populates='parameter') + results = relationship('CalculationResult', back_populates='parameter') class CalculationData(Base, DefaultMixin, IdentityMixin): - __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") - ) + __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')) optimum_oh_day = Column(Integer, nullable=True) - max_interval = Column(Integer, nullable=True) - rbd_simulation_id = Column(UUID(as_uuid=True), nullable=True) - optimum_analysis = Column(JSON, nullable=True) - - session = relationship("OverhaulScope", lazy="raise") - - parameter = relationship("CalculationParam", back_populates="calculation_data") - - equipment_results = relationship( - "CalculationEquipmentResult", lazy="raise", viewonly=True - ) - - - results = relationship("CalculationResult", lazy="raise", viewonly=True) - + session = relationship('OverhaulScope', lazy='raise') + parameter = relationship('CalculationParam', back_populates='calculation_data') + equipment_results = relationship('CalculationEquipmentResult', lazy='raise', viewonly=True) + results = relationship('CalculationResult', lazy='raise', viewonly=True) @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: - # Create Params - params = CalculationParam( - avg_failure_cost=avg_failure_cost, - overhaul_cost=overhaul_cost, - created_by=created_by, - ) - + params = CalculationParam(avg_failure_cost=avg_failure_cost, overhaul_cost=overhaul_cost, created_by=created_by) db.add(params) await db.flush() 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) - await db.commit() await db.refresh(calculation_data) - return calculation_data - class CalculationResult(Base, DefaultMixin): - - __tablename__ = "oh_tr_calculation_result" - - 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 - ) + __tablename__ = 'oh_tr_calculation_result' + 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) corrective_cost = Column(Float, nullable=False) overhaul_cost = Column(Float, 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): - - __tablename__ = "oh_tr_calculation_equipment_result" - + __tablename__ = 'oh_tr_calculation_equipment_result' corrective_costs = Column(JSON, nullable=False) overhaul_costs = Column(JSON, nullable=False) daily_failures = Column(JSON, nullable=False) @@ -135,17 +62,9 @@ class CalculationEquipmentResult(Base, DefaultMixin): location_tag = Column(String(255), nullable=False) material_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) is_included = Column(Boolean, default=True) procurement_details = Column(JSON, nullable=True) is_initial = Column(Boolean, default=True) - - 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 - ) + master_equipment = relationship('MasterEquipment', lazy='joined', primaryjoin='and_(CalculationEquipmentResult.location_tag == foreign(MasterEquipment.location_tag))', uselist=False) diff --git a/src/calculation_time_constrains/router.py b/src/calculation_time_constrains/router.py index bb3ea01..f96d790 100644 --- a/src/calculation_time_constrains/router.py +++ b/src/calculation_time_constrains/router.py @@ -1,191 +1,61 @@ from typing import Annotated, List, Optional, Union - from fastapi import APIRouter from fastapi.params import Query - from src.auth.service import CurrentUser, InternalKey, Token from src.config import DEFAULT_TC_ID from src.database.core import DbSession 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 .service import (bulk_update_equipment, get_calculation_result, - get_calculation_result_by_day, get_calculation_by_assetnum, get_all_calculations) +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 .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.auth.access_control import required_permission, OHPermission from src.overhaul_scope.model import OverhaulScope from fastapi import Depends from src.csrf_protect import csrf_protect - 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()], -): - """Save calculation time constrains Here""" +@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 = params.scope_calculation_id simulation_id = params.simulation_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: - 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 - ) - - 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", - ) - - + 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) + 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): + calculations = await get_all_calculations(db_session=db_session) + return StandardResponse(data=calculations, message='Data retrieved successfully') -@router.get( - "/parameters", - response_model=StandardResponse[ - 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))]) +async def get_calculation_parameters(db_session: DbSession, calculation_id: Optional[str]=Query(default=None)): + parameters = await get_create_calculation_parameters(db_session=db_session, calculation_id=calculation_id) + return StandardResponse(data=parameters, message='Data retrieved successfully') - 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))] -) -async def get_calculation_results(db_session: DbSession, calculation_id, token:InternalKey, include_risk_cost:int = Query(1, alias="risk_cost")): +@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': 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 - ) - - # requests.post(f"{config.AUTH_SERVICE_API}/sign-out", headers={ - # "Authorization": f"Bearer {token}" + 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') - return StandardResponse( - data=results, - message="Data retrieved successfully", - ) - -@router.get( - "/{calculation_id}/{assetnum}", response_model=StandardResponse[EquipmentResult], - dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))] -) +@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): + results = await get_calculation_by_assetnum(db_session=db_session, assetnum=assetnum, calculation_id=calculation_id) + return StandardResponse(data=results, message='Data retrieved successfully') - 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))]) +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( - "/{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], -): +@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': - 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", - ) \ No newline at end of file + 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') diff --git a/src/calculation_time_constrains/schema.py b/src/calculation_time_constrains/schema.py index 8eedbd7..6543d9c 100644 --- a/src/calculation_time_constrains/schema.py +++ b/src/calculation_time_constrains/schema.py @@ -2,43 +2,31 @@ from src.overhaul_scope.schema import ScopeRead from datetime import datetime from typing import Dict, List, Optional, Union from uuid import UUID - from pydantic import Field - from src.models import DefultBase from src.standard_scope.schema import MasterEquipmentBase - class CalculationTimeConstrainsBase(DefultBase): pass - class ReferenceLinkBase(DefultBase): - reference_id: str = Field(..., description="Reference ID") - overhaul_reference_type: str = Field(..., description="Overhaul reference type") - + reference_id: str = Field(..., description='Reference ID') + overhaul_reference_type: str = Field(..., description='Overhaul reference type') class CalculationTimeConstrainsParametersRetrive(CalculationTimeConstrainsBase): - # type: ignore - costPerFailure: Union[dict, float] = Field(..., description="Cost per failure") - availableScopes: List[str] = Field(..., description="Available scopes") - recommendedScope: str = Field(..., description="Recommended scope") - + costPerFailure: Union[dict, float] = Field(..., description='Cost per failure') + availableScopes: List[str] = Field(..., description='Available scopes') + recommendedScope: str = Field(..., description='Recommended scope') class CalculationTimeConstrainsParametersRead(CalculationTimeConstrainsBase): - costPerFailure: Union[dict, float] = Field(..., description="Cost per failure") - overhaulCost: Optional[float] = Field(None, description="Overhaul cost") - reference: Optional[List[ReferenceLinkBase]] = Field(None, description="Reference") - + costPerFailure: Union[dict, float] = Field(..., description='Cost per failure') + overhaulCost: Optional[float] = Field(None, description='Overhaul cost') + reference: Optional[List[ReferenceLinkBase]] = Field(None, description='Reference') class CalculationTimeConstrainsParametersCreate(CalculationTimeConstrainsBase): - overhaulCost: Optional[float] = Field(0, description="Overhaul cost") - ohSessionId: Optional[UUID] = Field(None, description="Scope OH") - costPerFailure: Optional[float] = Field(0, description="Cost per failure") - - -# class CalculationTimeConstrainsCreate(CalculationTimeConstrainsBase): - + overhaulCost: Optional[float] = Field(0, description='Overhaul cost') + ohSessionId: Optional[UUID] = Field(None, description='Scope OH') + costPerFailure: Optional[float] = Field(0, description='Cost per failure') class CalculationResultsRead(CalculationTimeConstrainsBase): overhaul_cost: float @@ -51,14 +39,12 @@ class CalculationResultsRead(CalculationTimeConstrainsBase): procurement_details: Dict sparepart_summary: dict - class OptimumResult(CalculationTimeConstrainsBase): overhaul_cost: float corrective_cost: float num_failures: int days: int - class EquipmentResult(CalculationTimeConstrainsBase): id: UUID corrective_costs: List[float] @@ -68,7 +54,7 @@ class EquipmentResult(CalculationTimeConstrainsBase): location_tag: str material_cost: float service_cost: float - optimum_day: int # Added optimum result for each equipment + optimum_day: int is_included: bool master_equipment: Optional[MasterEquipmentBase] = Field(None) @@ -119,21 +105,18 @@ class CalculationTimeConstrainsRead(CalculationTimeConstrainsBase): optimal_analysis: dict analysis_metadata: dict - class CalculationTimeConstrainsCreate(CalculationTimeConstrainsBase): intervalDays: int - class CalculationTimeConstrainsSimulationRead(CalculationTimeConstrainsBase): simulation: CalculationResultsRead - class CalculationSelectedEquipmentUpdate(CalculationTimeConstrainsBase): is_included: bool location_tag: str - name:str + name: str class CreateCalculationQuery(DefultBase): scope_calculation_id: Optional[str] = Field(None) with_results: Optional[int] = Field(0) - simulation_id: Optional[UUID] = Field(None) \ No newline at end of file + simulation_id: Optional[UUID] = Field(None) diff --git a/src/calculation_time_constrains/service.py b/src/calculation_time_constrains/service.py index 9a8a134..8ccaf5c 100644 --- a/src/calculation_time_constrains/service.py +++ b/src/calculation_time_constrains/service.py @@ -1,8 +1,7 @@ import datetime -from typing import List, Optional, Tuple,Dict +from typing import List, Optional, Tuple, Dict from uuid import UUID import calendar - import httpx from src.config import REALIBILITY_SERVICE_API, RBD_SERVICE_API import numpy as np @@ -10,19 +9,13 @@ import requests from fastapi import HTTPException, status from sqlalchemy import and_, case, func, select, update from sqlalchemy.orm import joinedload, selectinload - from src.database.core import DbSession from src.overhaul_activity.service import get_all as get_all_by_session_id from src.overhaul_scope.service import get as get_scope, get_prev_oh from src.sparepart.service import load_sparepart_data_from_db from src.workorder.model import MasterWorkOrder -from .model import (CalculationData, CalculationEquipmentResult, - CalculationResult) -from .schema import (CalculationResultsRead, - CalculationSelectedEquipmentUpdate, - CalculationTimeConstrainsParametersCreate, - CalculationTimeConstrainsRead, OptimumResult) - +from .model import CalculationData, CalculationEquipmentResult, CalculationResult +from .schema import CalculationResultsRead, CalculationSelectedEquipmentUpdate, CalculationTimeConstrainsParametersCreate, CalculationTimeConstrainsRead, OptimumResult from .utils import analyze_monthly_metrics, create_time_series_data, get_months_between, plant_simulation_metrics import math from src.overhaul_activity.service import get_standard_scope_by_session_id @@ -33,1429 +26,583 @@ from datetime import datetime, date import asyncio import json from src.config import BASE_URL, REALIBILITY_SERVICE_API - client = httpx.AsyncClient(timeout=300.0) - log = logging.getLogger(__name__) class OptimumCostModelWithSpareparts: - def __init__(self, token: str, last_oh_date: date, next_oh_date: date, - sparepart_manager, - time_window_months: Optional[int] = None, - base_url: str = f"{BASE_URL}"): - """ - Initialize the Optimum Cost Model with sparepart management - """ + + def __init__(self, token: str, last_oh_date: date, next_oh_date: date, sparepart_manager, time_window_months: Optional[int]=None, base_url: str=f'{BASE_URL}'): self.api_base_url = base_url self.token = token self.last_oh_date = last_oh_date self.next_oh_date = next_oh_date self.session = None self.sparepart_manager = sparepart_manager - - # Calculate planned overhaul interval in months self.planned_oh_months = self._get_months_between(last_oh_date, next_oh_date) - - # Set analysis time window (default: 1.5x planned interval) self.time_window_months = time_window_months or int(self.planned_oh_months * 1.2) - - # Pre-calculate date range for API calls self.date_range = self._generate_date_range() - self.logger = log def _get_months_between(self, start_date: date, end_date: date) -> int: - """Calculate number of months between two dates""" return (end_date.year - start_date.year) * 12 + (end_date.month - start_date.month) def _generate_date_range(self) -> List[datetime]: - """Generate date range for analysis based on time window""" dates = [] current_date = datetime.combine(self.last_oh_date, datetime.min.time()) end_date = current_date + timedelta(days=self.time_window_months * 30) - while current_date <= end_date: dates.append(current_date) current_date += timedelta(days=31) - return dates async def _create_session(self): - """Create aiohttp session with connection pooling""" if self.session is None: timeout = aiohttp.ClientTimeout(total=300) - connector = aiohttp.TCPConnector( - limit=500, - limit_per_host=200, - ttl_dns_cache=300, - use_dns_cache=True, - force_close=False, - enable_cleanup_closed=True - ) - self.session = aiohttp.ClientSession( - timeout=timeout, - connector=connector, - headers={'Authorization': f'Bearer {self.token}'} - ) + connector = aiohttp.TCPConnector(limit=500, limit_per_host=200, ttl_dns_cache=300, use_dns_cache=True, force_close=False, enable_cleanup_closed=True) + self.session = aiohttp.ClientSession(timeout=timeout, connector=connector, headers={'Authorization': f'Bearer {self.token}'}) async def _close_session(self): - """Close aiohttp session""" if self.session: await self.session.close() self.session = None - async def get_failures_prediction(self, simulation_id: str, location_tag: str, birnbaum_importance: float, use_location_tag: int = 1): - """Get failure predictions for equipment from simulation service""" - plot_result_url = f"{self.api_base_url}/aeros/simulation/result/plot/{simulation_id}/{location_tag}?use_location_tag={use_location_tag}" - + async def get_failures_prediction(self, simulation_id: str, location_tag: str, birnbaum_importance: float, use_location_tag: int=1): + plot_result_url = f'{self.api_base_url}/aeros/simulation/result/plot/{simulation_id}/{location_tag}?use_location_tag={use_location_tag}' try: - response = requests.get( - plot_result_url, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {self.token}", - }, - timeout=30 - ) + response = requests.get(plot_result_url, headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {self.token}'}, timeout=30) response.raise_for_status() prediction_data = response.json() except (requests.RequestException, ValueError) as e: - self.logger.error(f"Failed to fetch prediction data for {location_tag}: {e}") + self.logger.error(f'Failed to fetch prediction data for {location_tag}: {e}') return None - - plot_data = prediction_data.get('data', {}).get('timestamp_outs') if prediction_data.get("data") else None - + plot_data = prediction_data.get('data', {}).get('timestamp_outs') if prediction_data.get('data') else None if not plot_data: - self.logger.warning(f"No plot data available for {location_tag}") + self.logger.warning(f'No plot data available for {location_tag}') return None - - time_series = create_time_series_data(plot_data, (self.time_window_months * 24 * 31)) + time_series = create_time_series_data(plot_data, self.time_window_months * 24 * 31) monthly_data = analyze_monthly_metrics(time_series, self.last_oh_date) - return monthly_data - async def get_simulation_results(self, simulation_id: str = "default"): - """Get simulation results for Birnbaum importance calculations""" - headers = { - "Authorization": f"Bearer {self.token}", - "Content-Type": "application/json" - } - - calc_result_url = f"{self.api_base_url}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode" - plant_result_url = f"{self.api_base_url}/aeros/simulation/result/calc/{simulation_id}/plant" - + async def get_simulation_results(self, simulation_id: str='default'): + headers = {'Authorization': f'Bearer {self.token}', 'Content-Type': 'application/json'} + calc_result_url = f'{self.api_base_url}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode' + plant_result_url = f'{self.api_base_url}/aeros/simulation/result/calc/{simulation_id}/plant' async with httpx.AsyncClient(timeout=300.0) as client: calc_task = client.get(calc_result_url, headers=headers) plant_task = client.get(plant_result_url, headers=headers) - calc_response, plant_response = await asyncio.gather(calc_task, plant_task) - calc_response.raise_for_status() plant_response.raise_for_status() + calc_data = calc_response.json()['data'] + plant_data = plant_response.json()['data'] + return {'calc_result': calc_data, 'plant_result': plant_data} - calc_data = calc_response.json()["data"] - plant_data = plant_response.json()["data"] - - return { - "calc_result": calc_data, - "plant_result": plant_data - } - - def _calculate_equipment_costs_with_spareparts(self, failures_prediction: Dict, birnbaum_importance: float, - preventive_cost: float, failure_replacement_cost: float, ecs, - location_tag: str, planned_overhauls: List = None, loss_production_permonth=0) -> List[Dict]: - """Calculate costs for each month including sparepart costs and availability""" - + def _calculate_equipment_costs_with_spareparts(self, failures_prediction: Dict, birnbaum_importance: float, preventive_cost: float, failure_replacement_cost: float, ecs, location_tag: str, planned_overhauls: List=None, loss_production_permonth=0) -> List[Dict]: if not failures_prediction: - self.logger.warning(f"No failure prediction data for {location_tag}") + self.logger.warning(f'No failure prediction data for {location_tag}') return [] - months = list(failures_prediction.keys()) num_months = len(months) - failure_counts = [] - monthly_risk_cost_per_failure = 0 - if ecs: - is_trip = 1 if ecs.get("Diskripsi Operasional Akibat Equip. Failure") == "Trip" else 0 + is_trip = 1 if ecs.get('Diskripsi Operasional Akibat Equip. Failure') == 'Trip' else 0 is_series = 0 if not birnbaum_importance else math.floor(birnbaum_importance) if is_trip: - downtime = ecs.get("Estimasi Waktu Maint. / Downtime / Gangguan (Jam)") + downtime = ecs.get('Estimasi Waktu Maint. / Downtime / Gangguan (Jam)') monthly_risk_cost_per_failure = 660 * 1000000 * is_trip * downtime * is_series - for month_key in months: data = failures_prediction[month_key] failure_counts.append(data['cumulative_failures']) - - # Calculate costs for each month including sparepart considerations results = [] - for i in range(num_months): month_index = i + 1 - - - # Basic failure and preventive costs - failure_cost = (failure_counts[i] * (failure_replacement_cost + monthly_risk_cost_per_failure)) + failure_cost = failure_counts[i] * (failure_replacement_cost + monthly_risk_cost_per_failure) preventive_cost_month = preventive_cost / month_index - - # Check sparepart availability for this month - sparepart_analysis = self._analyze_sparepart_availability( - location_tag, month_index - 1, planned_overhauls or [] - ) - - - # Calculate procurement costs if spareparts are needed + sparepart_analysis = self._analyze_sparepart_availability(location_tag, month_index - 1, planned_overhauls or []) procurement_cost = sparepart_analysis['total_procurement_cost'] procurement_details = sparepart_analysis - - # Adjust total cost based on sparepart availability if not sparepart_analysis['available']: - total_cost = failure_cost + preventive_cost_month + procurement_cost + total_cost = failure_cost + preventive_cost_month + procurement_cost else: - # All spareparts available total_cost = failure_cost + preventive_cost_month + procurement_cost - - results.append({ - 'month': month_index, - 'number_of_failures': failure_counts[i], - 'failure_cost': failure_cost, - 'preventive_cost': preventive_cost_month, - 'procurement_cost': procurement_cost, - 'total_cost': total_cost, - 'is_after_planned_oh': month_index > self.planned_oh_months, - 'delay_months': max(0, month_index - self.planned_oh_months), - 'procurement_details': procurement_details, - 'sparepart_available': sparepart_analysis['available'], - 'sparepart_status': sparepart_analysis['message'], - 'can_proceed': sparepart_analysis['can_proceed_with_delays'] - }) - + results.append({'month': month_index, 'number_of_failures': failure_counts[i], 'failure_cost': failure_cost, 'preventive_cost': preventive_cost_month, 'procurement_cost': procurement_cost, 'total_cost': total_cost, 'is_after_planned_oh': month_index > self.planned_oh_months, 'delay_months': max(0, month_index - self.planned_oh_months), 'procurement_details': procurement_details, 'sparepart_available': sparepart_analysis['available'], 'sparepart_status': sparepart_analysis['message'], 'can_proceed': sparepart_analysis['can_proceed_with_delays']}) return results - def _analyze_sparepart_availability(self, equipment_tag: str, target_month: int, - planned_overhauls: List) -> Dict: - """Analyze sparepart availability for equipment at target month""" + def _analyze_sparepart_availability(self, equipment_tag: str, target_month: int, planned_overhauls: List) -> Dict: if not self.sparepart_manager: - return { - 'available': True, - 'message': 'Sparepart manager not initialized', - 'total_procurement_cost': 0, - 'procurement_needed': [], - 'can_proceed_with_delays': True - } - - # Convert planned overhauls to format expected by sparepart manager - other_overhauls = [(eq_tag, month) for eq_tag, month in planned_overhauls - if eq_tag != equipment_tag and month <= target_month] - - return self.sparepart_manager.check_sparepart_availability( - equipment_tag, target_month, other_overhauls - ) + return {'available': True, 'message': 'Sparepart manager not initialized', 'total_procurement_cost': 0, 'procurement_needed': [], 'can_proceed_with_delays': True} + other_overhauls = [(eq_tag, month) for eq_tag, month in planned_overhauls if eq_tag != equipment_tag and month <= target_month] + return self.sparepart_manager.check_sparepart_availability(equipment_tag, target_month, other_overhauls) def _find_optimal_timing_with_spareparts(self, cost_results: List[Dict], location_tag: str) -> Optional[Dict]: - """Find optimal timing considering sparepart constraints""" if not cost_results: return None - - # Filter out months where overhaul cannot proceed due to critical sparepart shortages feasible_results = [r for r in cost_results if r['can_proceed']] - - # if not feasible_results: - # # Return the earliest month with the least critical parts missing - # for result in cost_results: - # if result['missing_critical_parts'] == min_critical_missing: - - # Find month with minimum total cost among feasible options min_cost = float('inf') optimal_result = None - for i, result in enumerate(cost_results): if result in feasible_results and result['total_cost'] < min_cost: min_cost = result['total_cost'] optimal_result = result - if optimal_result is None: return None - - return self._create_optimal_result(optimal_result, location_tag, "OPTIMAL") + return self._create_optimal_result(optimal_result, location_tag, 'OPTIMAL') def _create_optimal_result(self, optimal_result: Dict, location_tag: str, status: str) -> Dict: - """Create standardized optimal result dictionary""" - # Calculate cost comparison with planned timing planned_cost = None cost_vs_planned = None - if self.planned_oh_months <= len(optimal_result.get('all_monthly_costs', [])): - # This would need the full cost results array - pass # Will be calculated in the calling function - - return { - 'location_tag': location_tag, - 'optimal_month': optimal_result['month'], - 'optimal_index': optimal_result['month'] - 1, - 'optimal_cost': optimal_result['total_cost'], - 'failure_cost': optimal_result['failure_cost'], - 'preventive_cost': optimal_result['preventive_cost'], - 'procurement_cost': optimal_result['procurement_cost'], - 'number_of_failures': optimal_result['number_of_failures'], - 'is_delayed': optimal_result['is_after_planned_oh'], - 'delay_months': optimal_result['delay_months'], - 'planned_oh_month': self.planned_oh_months, - 'planned_cost': planned_cost, - 'cost_vs_planned': cost_vs_planned, - 'savings_from_delay': 0, # Will be calculated later - 'cost_of_delay': 0, # Will be calculated later - 'sparepart_available': optimal_result['sparepart_available'], - 'sparepart_status': optimal_result['sparepart_status'], - 'procurement_details': optimal_result['procurement_details'], - 'optimization_status': status, - 'all_monthly_costs': [] # Will be filled by calling function - } + pass + return {'location_tag': location_tag, 'optimal_month': optimal_result['month'], 'optimal_index': optimal_result['month'] - 1, 'optimal_cost': optimal_result['total_cost'], 'failure_cost': optimal_result['failure_cost'], 'preventive_cost': optimal_result['preventive_cost'], 'procurement_cost': optimal_result['procurement_cost'], 'number_of_failures': optimal_result['number_of_failures'], 'is_delayed': optimal_result['is_after_planned_oh'], 'delay_months': optimal_result['delay_months'], 'planned_oh_month': self.planned_oh_months, 'planned_cost': planned_cost, 'cost_vs_planned': cost_vs_planned, 'savings_from_delay': 0, 'cost_of_delay': 0, 'sparepart_available': optimal_result['sparepart_available'], 'sparepart_status': optimal_result['sparepart_status'], 'procurement_details': optimal_result['procurement_details'], 'optimization_status': status, 'all_monthly_costs': []} - async def calculate_cost_all_equipment_with_spareparts(self, db_session,collector_db_session ,equipments: List, - calculation, preventive_cost: float, - simulation_id: str = "default"): - """ - Calculate optimal overhaul timing for entire fleet considering sparepart constraints - """ - - self.logger.info(f"Starting fleet optimization with sparepart management for {len(equipments)} equipment") + async def calculate_cost_all_equipment_with_spareparts(self, db_session, collector_db_session, equipments: List, calculation, preventive_cost: float, simulation_id: str='default'): + self.logger.info(f'Starting fleet optimization with sparepart management for {len(equipments)} equipment') max_interval = self.time_window_months - - # Get Birnbaum importance values try: importance_results = await self.get_simulation_results(simulation_id) - equipment_birnbaum = { - imp['aeros_node']['node_name']: imp['contribution_factor'] - for imp in importance_results["calc_result"] - } - - loss_production = importance_results["plant_result"]['total_downtime'] * 660 - + equipment_birnbaum = {imp['aeros_node']['node_name']: imp['contribution_factor'] for imp in importance_results['calc_result']} + loss_production = importance_results['plant_result']['total_downtime'] * 660 except Exception as e: - self.logger.error(f"Failed to get simulation results: {e}") + self.logger.error(f'Failed to get simulation results: {e}') equipment_birnbaum = {} - - # Phase 1: Calculate individual optimal timings without considering interactions individual_results = {} - with open('src/calculation_time_constrains/full_equipment_with_downtime_opdesc.json', 'r') as f: data = json.load(f) - - ecs_tags = { - eq["Location"]: eq - for eq in data - } - await self.get_failures_prediction(simulation_id=simulation_id, location_tag="plant", use_location_tag=0, birnbaum_importance=0) - - - - total_simulation_period = int((importance_results["plant_result"]['total_downtime'] + importance_results["plant_result"]['total_uptime'])/720) - + ecs_tags = {eq['Location']: eq for eq in data} + await self.get_failures_prediction(simulation_id=simulation_id, location_tag='plant', use_location_tag=0, birnbaum_importance=0) + total_simulation_period = int((importance_results['plant_result']['total_downtime'] + importance_results['plant_result']['total_uptime']) / 720) loss_production_per_month = np.arange(0, total_simulation_period) k = 5 - - loss_exp = (importance_results["plant_result"]['total_downtime'] * 660 * 500_000) * (np.exp(k * (loss_production_per_month / total_simulation_period)) - 1) / (np.exp(k) - 1) - - + loss_exp = importance_results['plant_result']['total_downtime'] * 660 * 500000 * (np.exp(k * (loss_production_per_month / total_simulation_period)) - 1) / (np.exp(k) - 1) for equipment in equipments: location_tag = equipment.location_tag contribution_factor = equipment_birnbaum.get(location_tag, 0.0) ecs = ecs_tags.get(location_tag, None) - - # # Get failure predictions monthly_data = await self.get_failures_prediction(simulation_id, location_tag, contribution_factor) - if not monthly_data: continue - - # Calculate costs without considering other equipment (first pass) equipment_preventive_cost = equipment.overhaul_cost + equipment.service_cost - failure_replacement_cost = equipment.material_cost + (3 * 111000 * 3) - - cost_results = self._calculate_equipment_costs_with_spareparts( - failures_prediction=monthly_data, - birnbaum_importance=contribution_factor, - preventive_cost=equipment_preventive_cost, - failure_replacement_cost=failure_replacement_cost, - location_tag=location_tag, - planned_overhauls=[] , # Empty in first pass - ecs=ecs, - loss_production_permonth=loss_production - ) - + failure_replacement_cost = equipment.material_cost + 3 * 111000 * 3 + cost_results = self._calculate_equipment_costs_with_spareparts(failures_prediction=monthly_data, birnbaum_importance=contribution_factor, preventive_cost=equipment_preventive_cost, failure_replacement_cost=failure_replacement_cost, location_tag=location_tag, planned_overhauls=[], ecs=ecs, loss_production_permonth=loss_production) if not cost_results: continue - - # Find individual optimal timing optimal_timing = self._find_optimal_timing_with_spareparts(cost_results, location_tag) - if optimal_timing: optimal_timing['all_monthly_costs'] = cost_results individual_results[location_tag] = optimal_timing - self.logger.info(f"Individual optimal for {location_tag}: Month {optimal_timing['optimal_month']}") - - - # Phase 2: Optimize considering sparepart interactions - self.logger.info("Phase 2: Optimizing with sparepart interactions...") - - # Start with individual optimal timings + self.logger.info('Phase 2: Optimizing with sparepart interactions...') [(tag, result['optimal_month']) for tag, result in individual_results.items()] - - # Iteratively improve the plan considering sparepart constraints - improved_plan = self._optimize_fleet_with_sparepart_constraints( - individual_results, equipments, equipment_birnbaum, simulation_id - ) - - - - # Phase 3: Generate final results and database objects + improved_plan = self._optimize_fleet_with_sparepart_constraints(individual_results, equipments, equipment_birnbaum, simulation_id) fleet_results = [] total_corrective_costs = np.zeros(max_interval) + loss_exp[0:max_interval] total_preventive_costs = np.zeros(max_interval) total_procurement_costs = np.zeros(max_interval) total_costs = np.zeros(max_interval) - total_fleet_procurement_cost = 0 - - for equipment in equipments: location_tag = equipment.location_tag - if location_tag not in individual_results: continue - - # Get the optimized timing from improved plan - equipment_timing = next((month for tag, month in improved_plan if tag == location_tag), - individual_results[location_tag]['optimal_month']) - - # Get the cost data for this timing + equipment_timing = next((month for tag, month in improved_plan if tag == location_tag), individual_results[location_tag]['optimal_month']) cost_data = individual_results[location_tag]['all_monthly_costs'][equipment_timing - 1] - - # Prepare arrays for database all_costs = individual_results[location_tag]['all_monthly_costs'] - - corrective_costs = [r["failure_cost"] for r in all_costs] - preventive_costs = [r["preventive_cost"] for r in all_costs] - procurement_costs = [r["procurement_cost"] for r in all_costs] - failures = [r["number_of_failures"] for r in all_costs] + corrective_costs = [r['failure_cost'] for r in all_costs] + preventive_costs = [r['preventive_cost'] for r in all_costs] + procurement_costs = [r['procurement_cost'] for r in all_costs] + failures = [r['number_of_failures'] for r in all_costs] total_costs_equipment = [r['total_cost'] for r in all_costs] - procurement_details = [r["procurement_details"] for r in all_costs] - - # Pad arrays to max_interval length + procurement_details = [r['procurement_details'] for r in all_costs] + def pad_array(arr, target_length): if len(arr) < target_length: return arr + [arr[-1]] * (target_length - len(arr)) return arr[:target_length] - corrective_costs = pad_array(corrective_costs, max_interval) preventive_costs = pad_array(preventive_costs, max_interval) procurement_costs = pad_array(procurement_costs, max_interval) failures = pad_array(failures, max_interval) total_costs_equipment = pad_array(total_costs_equipment, max_interval) procurement_details = pad_array(procurement_details, max_interval) - - # Create database result object - equipment_result = CalculationEquipmentResult( - corrective_costs=corrective_costs, - overhaul_costs=preventive_costs, - procurement_costs=procurement_costs, - daily_failures=failures, - location_tag=equipment.location_tag, - material_cost=equipment.material_cost, - service_cost=equipment.service_cost, - optimum_day=equipment_timing - 1, # Convert to 0-based index - calculation_data_id=calculation.id, - procurement_details=procurement_details - ) - + equipment_result = CalculationEquipmentResult(corrective_costs=corrective_costs, overhaul_costs=preventive_costs, procurement_costs=procurement_costs, daily_failures=failures, location_tag=equipment.location_tag, material_cost=equipment.material_cost, service_cost=equipment.service_cost, optimum_day=equipment_timing - 1, calculation_data_id=calculation.id, procurement_details=procurement_details) fleet_results.append(equipment_result) - - # Aggregate costs for fleet analysis total_corrective_costs += np.array(corrective_costs) total_preventive_costs += np.array(preventive_costs) total_procurement_costs += np.array(procurement_costs) total_costs += np.array(total_costs_equipment) - - # Add to total fleet procurement cost total_fleet_procurement_cost += cost_data['procurement_cost'] - - self.logger.info(f"Final timing for {location_tag}: Month {equipment_timing} " - f"(Procurement cost: ${cost_data['procurement_cost']:,.2f})") - - # Calculate fleet optimal interval + self.logger.info(f"Final timing for {location_tag}: Month {equipment_timing} (Procurement cost: ${cost_data['procurement_cost']:,.2f})") fleet_optimal_index = np.argmin(total_corrective_costs + total_preventive_costs) fleet_optimal_cost = total_costs[fleet_optimal_index] - - # Generate procurement optimization report self.sparepart_manager.optimize_procurement_timing(improved_plan) - - # Update calculation with results calculation.optimum_oh_day = fleet_optimal_index calculation.max_interval = max_interval calculation.rbd_simulation_id = simulation_id - - # Save all results to database db_session.add_all(fleet_results) await db_session.commit() - - self.logger.info(f"Fleet optimization with spareparts completed:") - self.logger.info(f" - Fleet optimal month: {fleet_optimal_index + 1}") - self.logger.info(f" - Fleet optimal cost: ${fleet_optimal_cost:,.2f}") - self.logger.info(f" - Total procurement cost: ${total_fleet_procurement_cost:,.2f}") + self.logger.info(f'Fleet optimization with spareparts completed:') + self.logger.info(f' - Fleet optimal month: {fleet_optimal_index + 1}') + self.logger.info(f' - Fleet optimal cost: ${fleet_optimal_cost:,.2f}') + self.logger.info(f' - Total procurement cost: ${total_fleet_procurement_cost:,.2f}') self.logger.info(f" - Equipment with sparepart constraints: {len([r for r in individual_results.values() if not r['sparepart_available']])}") - return fleet_optimal_index - def _optimize_fleet_with_sparepart_constraints(self, individual_results: Dict, equipments: List, - equipment_birnbaum: Dict, simulation_id: str) -> List[Tuple[str, int]]: - """ - Optimize fleet overhaul timing considering sparepart sharing constraints - """ - # Start with individual optimal timings + def _optimize_fleet_with_sparepart_constraints(self, individual_results: Dict, equipments: List, equipment_birnbaum: Dict, simulation_id: str) -> List[Tuple[str, int]]: current_plan = [(tag, result['optimal_month']) for tag, result in individual_results.items()] - - # Sort by optimal month to process in chronological order current_plan.sort(key=lambda x: x[1]) - improved_plan = [] processed_equipment = [] - for equipment_tag, optimal_month in current_plan: - # Check sparepart availability considering already processed equipment - sparepart_analysis = self.sparepart_manager.check_sparepart_availability( - equipment_tag, optimal_month - 1, processed_equipment - ) - + sparepart_analysis = self.sparepart_manager.check_sparepart_availability(equipment_tag, optimal_month - 1, processed_equipment) if sparepart_analysis['available'] or sparepart_analysis['can_proceed_with_delays']: - # Can proceed with optimal timing improved_plan.append((equipment_tag, optimal_month)) processed_equipment.append((equipment_tag, optimal_month)) - self.logger.info(f"Confirmed optimal timing for {equipment_tag}: Month {optimal_month}") + self.logger.info(f'Confirmed optimal timing for {equipment_tag}: Month {optimal_month}') else: - # Need to find alternative timing - alternative_month = self._find_alternative_timing( - equipment_tag, optimal_month, individual_results[equipment_tag]['all_monthly_costs'], - processed_equipment - ) - + alternative_month = self._find_alternative_timing(equipment_tag, optimal_month, individual_results[equipment_tag]['all_monthly_costs'], processed_equipment) if alternative_month: improved_plan.append((equipment_tag, alternative_month)) processed_equipment.append((equipment_tag, alternative_month)) - self.logger.info(f"Alternative timing for {equipment_tag}: Month {alternative_month} " - f"(was {optimal_month})") + self.logger.info(f'Alternative timing for {equipment_tag}: Month {alternative_month} (was {optimal_month})') else: - # Force original timing with procurement improved_plan.append((equipment_tag, optimal_month)) processed_equipment.append((equipment_tag, optimal_month)) - self.logger.warning(f"Forced timing for {equipment_tag}: Month {optimal_month} " - f"(requires emergency procurement)") - + self.logger.warning(f'Forced timing for {equipment_tag}: Month {optimal_month} (requires emergency procurement)') return improved_plan - def _find_alternative_timing(self, equipment_tag: str, preferred_month: int, - cost_results: List[Dict], processed_equipment: List[Tuple[str, int]]) -> Optional[int]: - """ - Find alternative timing when preferred month has sparepart constraints - """ - # Try months around the preferred timing - search_range = 6 # Look 3 months before and after - + def _find_alternative_timing(self, equipment_tag: str, preferred_month: int, cost_results: List[Dict], processed_equipment: List[Tuple[str, int]]) -> Optional[int]: + search_range = 6 candidates = [] - - for offset in range(-search_range//2, search_range//2 + 1): + for offset in range(-search_range // 2, search_range // 2 + 1): candidate_month = preferred_month + offset - if candidate_month <= 0 or candidate_month > len(cost_results): continue - if candidate_month == preferred_month: - continue # Already know this doesn't work - - # Check sparepart availability for this month - sparepart_analysis = self.sparepart_manager.check_sparepart_availability( - equipment_tag, candidate_month - 1, processed_equipment - ) - + continue + sparepart_analysis = self.sparepart_manager.check_sparepart_availability(equipment_tag, candidate_month - 1, processed_equipment) if sparepart_analysis['available'] or sparepart_analysis['can_proceed_with_delays']: cost_data = cost_results[candidate_month - 1] candidates.append((candidate_month, cost_data['total_cost'])) - if not candidates: return None - - # Return the month with lowest cost among feasible alternatives candidates.sort(key=lambda x: x[1]) return candidates[0][0] def generate_sparepart_report(self, results: Dict) -> str: - """Generate comprehensive sparepart analysis report""" individual_results = results['individual_results'] procurement_plan = results['procurement_plan'] - - report = f""" - SPAREPART ANALYSIS REPORT - {'='*50} - - FLEET SUMMARY: - - Total equipment analyzed: {len(individual_results)} - - Total procurement cost: ${results['total_fleet_procurement_cost']:,.2f} - - Equipment requiring procurement: {len([r for r in individual_results.values() if r['procurement_cost'] > 0])} - - PROCUREMENT SUMMARY: - - Total procurement items: {procurement_plan['summary']['total_items'] if 'summary' in procurement_plan else 0} - - Critical items: {procurement_plan['summary']['critical_items'] if 'summary' in procurement_plan else 0} - - Unique spareparts: {procurement_plan['summary']['unique_spareparts'] if 'summary' in procurement_plan else 0} - - Suppliers involved: {procurement_plan['summary']['suppliers_involved'] if 'summary' in procurement_plan else 0} - - EQUIPMENT DETAILS: - """ - + report = f"\n SPAREPART ANALYSIS REPORT\n {'=' * 50}\n\n FLEET SUMMARY:\n - Total equipment analyzed: {len(individual_results)}\n - Total procurement cost: ${results['total_fleet_procurement_cost']:,.2f}\n - Equipment requiring procurement: {len([r for r in individual_results.values() if r['procurement_cost'] > 0])}\n\n PROCUREMENT SUMMARY:\n - Total procurement items: {(procurement_plan['summary']['total_items'] if 'summary' in procurement_plan else 0)}\n - Critical items: {(procurement_plan['summary']['critical_items'] if 'summary' in procurement_plan else 0)}\n - Unique spareparts: {(procurement_plan['summary']['unique_spareparts'] if 'summary' in procurement_plan else 0)}\n - Suppliers involved: {(procurement_plan['summary']['suppliers_involved'] if 'summary' in procurement_plan else 0)}\n\n EQUIPMENT DETAILS:\n " for tag, result in individual_results.items(): - status = "✓ Available" if result['sparepart_available'] else "⚠ Procurement needed" + status = '✓ Available' if result['sparepart_available'] else '⚠ Procurement needed' report += f"- {tag}: Month {result['optimal_month']} - {status}" if result['procurement_cost'] > 0: report += f" (${result['procurement_cost']:,.2f})" - report += "\n" - + report += '\n' if procurement_plan.get('procurement_by_month'): - report += "\nPROCUREMENT SCHEDULE:\n" + report += '\nPROCUREMENT SCHEDULE:\n' for month, items in procurement_plan['procurement_by_month'].items(): - month_cost = sum(item['total_cost'] for item in items) - report += f"Month {month + 1}: {len(items)} items - ${month_cost:,.2f}\n" - + month_cost = sum((item['total_cost'] for item in items)) + report += f'Month {month + 1}: {len(items)} items - ${month_cost:,.2f}\n' return report async def __aenter__(self): - """Async context manager entry""" await self._create_session() return self async def __aexit__(self, exc_type, exc_val, exc_tb): - """Async context manager exit""" await self._close_session() - -# Updated run_simulation function with sparepart management -async def run_simulation_with_spareparts(*, db_session, calculation, token: str, collector_db_session, - time_window_months: Optional[int] = None, - simulation_id: str = "default") -> Dict: - """ - Run complete overhaul optimization simulation with sparepart management - """ - - # Get equipment and scope data - equipments = await get_standard_scope_by_session_id( - db_session=db_session, - overhaul_session_id=calculation.overhaul_session_id, - collector_db=collector_db_session - ) - +async def run_simulation_with_spareparts(*, db_session, calculation, token: str, collector_db_session, time_window_months: Optional[int]=None, simulation_id: str='default') -> Dict: + equipments = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=calculation.overhaul_session_id, collector_db=collector_db_session) scope = await get_scope(db_session=db_session, overhaul_session_id=calculation.overhaul_session_id) prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope) - - calculation_data = await get_calculation_data_by_id( - db_session=db_session, calculation_id=calculation.id - ) - + calculation_data = await get_calculation_data_by_id(db_session=db_session, calculation_id=calculation.id) time_window_months = 60 - - sparepart_manager = await load_sparepart_data_from_db(scope=scope, prev_oh_scope=prev_oh_scope, db_session=collector_db_session,app_db_session=db_session ,analysis_window_months=time_window_months) - - - # Initialize optimization model with sparepart management - optimum_oh_model = OptimumCostModelWithSpareparts( - token=token, - last_oh_date=prev_oh_scope.end_date, - next_oh_date=scope.start_date, - base_url=RBD_SERVICE_API, - sparepart_manager=sparepart_manager - ) - - simulation_ids = [ - simulation_id - # "146fe891-cf36-44c0-a18a-7e0b24c7b574", - # "4f650a1d-7928-4f3b-a026-aeac1de73c41", - # "fdeba8cd-4e97-4f61-a7f3-a12d20d8f792", - # "6d2f78b2-fac3-4a6d-aa22-cfe064277ca0", - # "d22bcbe6-44ff-4f3a-9b78-02c0e7bacfc5", - # "abdddd5b-cf05-4981-ac96-3b45e6e7d174", - # "a0c43140-f3ec-4829-9687-9206c645d660", - # "d84d9cb7-8dfe-440a-9427-ea5d15c769c7", - # "a51c97bc-fc14-4c9c-aba5-f6d427815cec", - # "d279535e-a852-4161-aaf5-a862b1760055" -] - - + sparepart_manager = await load_sparepart_data_from_db(scope=scope, prev_oh_scope=prev_oh_scope, db_session=collector_db_session, app_db_session=db_session, analysis_window_months=time_window_months) + optimum_oh_model = OptimumCostModelWithSpareparts(token=token, last_oh_date=prev_oh_scope.end_date, next_oh_date=scope.start_date, base_url=RBD_SERVICE_API, sparepart_manager=sparepart_manager) + simulation_ids = [simulation_id] try: optimum_collection = [] - for sim_id in simulation_ids: - # Run fleet optimization with sparepart management - results = await optimum_oh_model.calculate_cost_all_equipment_with_spareparts( - db_session=db_session, - collector_db_session=collector_db_session, - equipments=equipments, - calculation=calculation_data, - preventive_cost=calculation_data.parameter.overhaul_cost, - simulation_id=sim_id - ) - + results = await optimum_oh_model.calculate_cost_all_equipment_with_spareparts(db_session=db_session, collector_db_session=collector_db_session, equipments=equipments, calculation=calculation_data, preventive_cost=calculation_data.parameter.overhaul_cost, simulation_id=sim_id) optimum_collection.append(results) finally: await optimum_oh_model._close_session() - np_optimum = np.array(optimum_collection) - - stats = { - 'min': float(np.min(np_optimum)), - 'max': float(np.max(np_optimum)), - 'mean': float(np.mean(np_optimum)), - 'median': float(np.median(np_optimum)), - 'std': float(np.std(np_optimum)), - 'variance': float(np.var(np_optimum)), - 'range': float(np.ptp(np_optimum)), # max - min - } - + stats = {'min': float(np.min(np_optimum)), 'max': float(np.max(np_optimum)), 'mean': float(np.mean(np_optimum)), 'median': float(np.median(np_optimum)), 'std': float(np.std(np_optimum)), 'variance': float(np.var(np_optimum)), 'range': float(np.ptp(np_optimum))} calculation_data.optimum_analysis = stats - await db_session.commit() - - return { - "id": calculation_data.id, - "optimum": stats - } - - + return {'id': calculation_data.id, 'optimum': stats} -async def create_param_and_data( - *, - db_session: DbSession, - calculation_param_in: CalculationTimeConstrainsParametersCreate, - created_by: str, - parameter_id: Optional[UUID] = None, -): - """Creates a new document.""" +async def create_param_and_data(*, db_session: DbSession, calculation_param_in: CalculationTimeConstrainsParametersCreate, created_by: str, parameter_id: Optional[UUID]=None): if calculation_param_in.ohSessionId is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="overhaul_session_id is required", - ) - - calculationData = await CalculationData.create_with_param( - db=db_session, - overhaul_session_id=calculation_param_in.ohSessionId, - avg_failure_cost=calculation_param_in.costPerFailure, - overhaul_cost=calculation_param_in.overhaulCost, - created_by=created_by, - params_id=parameter_id, - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='overhaul_session_id is required') + calculationData = await CalculationData.create_with_param(db=db_session, overhaul_session_id=calculation_param_in.ohSessionId, avg_failure_cost=calculation_param_in.costPerFailure, overhaul_cost=calculation_param_in.overhaulCost, created_by=created_by, params_id=parameter_id) return calculationData - async def get_calculation_result(db_session: DbSession, calculation_id: str, token, include_risk_cost): - """ - Get calculation results with improved error handling, performance, and sparepart details - """ try: - # Get calculation data with equipment results in single query - calculation_query = await db_session.execute( - select(CalculationData) - .options(selectinload(CalculationData.equipment_results)) - .where(CalculationData.id == calculation_id) - ) + calculation_query = await db_session.execute(select(CalculationData).options(selectinload(CalculationData.equipment_results)).where(CalculationData.id == calculation_id)) scope_calculation = calculation_query.scalar_one_or_none() - if not scope_calculation: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Calculation with id {calculation_id} does not exist.", - ) - - # Get scope information - scope_overhaul = await get_scope( - db_session=db_session, - overhaul_session_id=scope_calculation.overhaul_session_id - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f'Calculation with id {calculation_id} does not exist.') + scope_overhaul = await get_scope(db_session=db_session, overhaul_session_id=scope_calculation.overhaul_session_id) if not scope_overhaul: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Overhaul scope for session {scope_calculation.overhaul_session_id} does not exist.", - ) - - # Get previous overhaul scope for analysis context + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f'Overhaul scope for session {scope_calculation.overhaul_session_id} does not exist.') prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope_overhaul) - - # Validate data integrity data_num = scope_calculation.max_interval if data_num <= 0: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid max_interval in calculation data.", - ) - - # Filter included equipment for performance + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid max_interval in calculation data.') included_equipment = [eq for eq in scope_calculation.equipment_results if eq.is_included] all_equipment = scope_calculation.equipment_results - - # Pre-calculate aggregated statistics calculation_results = [] - fleet_statistics = { - 'total_equipment': len(all_equipment), - 'included_equipment': len(included_equipment), - 'excluded_equipment': len(all_equipment) - len(included_equipment), - 'equipment_with_sparepart_constraints': 0, - 'total_procurement_items': 0, - 'critical_procurement_items': 0, - 'total_spareparts': 745 - } - - # The code `plant_monthly_metrics` is likely a function or a variable in Python. Without - # seeing the implementation of the code, it is not possible to determine exactly what it is - # doing. It could be used to store monthly metrics for a plant, calculate metrics, or perform - # some other operation related to plant data. - plant_monthly_metrics = await plant_simulation_metrics(simulation_id=scope_calculation.rbd_simulation_id, location_tag="plant", use_location_tag=0, token=token, last_oh_date=prev_oh_scope.end_date, max_interval=scope_calculation.max_interval) - total_simulation_period = int((plant_monthly_metrics['total_downtime'] + plant_monthly_metrics['total_uptime'])/720) - + fleet_statistics = {'total_equipment': len(all_equipment), 'included_equipment': len(included_equipment), 'excluded_equipment': len(all_equipment) - len(included_equipment), 'equipment_with_sparepart_constraints': 0, 'total_procurement_items': 0, 'critical_procurement_items': 0, 'total_spareparts': 745} + plant_monthly_metrics = await plant_simulation_metrics(simulation_id=scope_calculation.rbd_simulation_id, location_tag='plant', use_location_tag=0, token=token, last_oh_date=prev_oh_scope.end_date, max_interval=scope_calculation.max_interval) + total_simulation_period = int((plant_monthly_metrics['total_downtime'] + plant_monthly_metrics['total_uptime']) / 720) loss_production_per_month = np.arange(0, total_simulation_period) k = 5 - loss_exp = (plant_monthly_metrics['total_downtime'] * 660 * 500_000 * include_risk_cost) * (np.exp(k * (loss_production_per_month / total_simulation_period)) - 1) / (np.exp(k) - 1) - - - - - # Process each monthself + loss_exp = plant_monthly_metrics['total_downtime'] * 660 * 500000 * include_risk_cost * (np.exp(k * (loss_production_per_month / total_simulation_period)) - 1) / (np.exp(k) - 1) for month_index in range(data_num): - month_result = { - "overhaul_cost": 0.0, - "corrective_cost": float(loss_exp[month_index]), - "procurement_cost": 0.0, - "num_failures": 0.0, - "day": month_index + 1, - "month": month_index + 1, # More intuitive naming - "procurement_details": {}, - "sparepart_summary": { - "total_procurement_cost": 0.0, - "equipment_requiring_procurement": 0, - "critical_shortages": 0, - "existing_orders_value": 0.0, - "new_orders_required": 0, - "urgent_procurements": 0 - } - } - + month_result = {'overhaul_cost': 0.0, 'corrective_cost': float(loss_exp[month_index]), 'procurement_cost': 0.0, 'num_failures': 0.0, 'day': month_index + 1, 'month': month_index + 1, 'procurement_details': {}, 'sparepart_summary': {'total_procurement_cost': 0.0, 'equipment_requiring_procurement': 0, 'critical_shortages': 0, 'existing_orders_value': 0.0, 'new_orders_required': 0, 'urgent_procurements': 0}} equipment_requiring_procurement = 0 total_existing_orders_value = 0.0 total_new_orders_value = 0.0 critical_shortages = 0 urgent_procurements = 0 - - # Process all equipment (included and excluded) for complete procurement picture for eq in all_equipment: - # Validate array bounds if month_index >= len(eq.procurement_details): continue - procurement_detail = eq.procurement_details[month_index] - - # Handle equipment with procurement needs - if (procurement_detail and - isinstance(procurement_detail, dict) and - procurement_detail.get("procurement_needed")): - + if procurement_detail and isinstance(procurement_detail, dict) and procurement_detail.get('procurement_needed'): equipment_requiring_procurement += 1 - - # Extract PR/PO summary if available - pr_po_summary = procurement_detail.get("pr_po_summary", {}) - - # Aggregate existing orders value - existing_orders_value = pr_po_summary.get("total_existing_value", 0) + pr_po_summary = procurement_detail.get('pr_po_summary', {}) + existing_orders_value = pr_po_summary.get('total_existing_value', 0) total_existing_orders_value += existing_orders_value - - # Aggregate new orders value - new_orders_value = pr_po_summary.get("total_new_orders_value", 0) + new_orders_value = pr_po_summary.get('total_new_orders_value', 0) total_new_orders_value += new_orders_value - - # Count critical shortages - critical_missing = procurement_detail.get("critical_missing_parts", 0) + critical_missing = procurement_detail.get('critical_missing_parts', 0) if critical_missing > 0: critical_shortages += 1 - - # Count urgent procurements - recommendations = procurement_detail.get("recommendations", []) - urgent_items = [r for r in recommendations if r.get("priority") == "CRITICAL"] + recommendations = procurement_detail.get('recommendations', []) + urgent_items = [r for r in recommendations if r.get('priority') == 'CRITICAL'] if urgent_items: urgent_procurements += 1 - is_included_eq = False if eq.is_initial else eq.is_included - - # Add detailed procurement info for this equipment - month_result["procurement_details"][eq.location_tag] = { - "is_included": is_included_eq, - "location_tag": eq.location_tag, - "details": procurement_detail.get("procurement_needed", []), - "detailed_message": procurement_detail.get("detailed_message", ""), - "pr_po_summary": pr_po_summary, - "recommendations": recommendations, - "sparepart_available": procurement_detail.get("sparepart_available", True), - "can_proceed": procurement_detail.get("can_proceed_with_delays", True), - "critical_missing_parts": critical_missing, - "existing_orders_value": existing_orders_value, - "new_orders_value": new_orders_value - } - - # Only include costs from equipment marked as included + month_result['procurement_details'][eq.location_tag] = {'is_included': is_included_eq, 'location_tag': eq.location_tag, 'details': procurement_detail.get('procurement_needed', []), 'detailed_message': procurement_detail.get('detailed_message', ''), 'pr_po_summary': pr_po_summary, 'recommendations': recommendations, 'sparepart_available': procurement_detail.get('sparepart_available', True), 'can_proceed': procurement_detail.get('can_proceed_with_delays', True), 'critical_missing_parts': critical_missing, 'existing_orders_value': existing_orders_value, 'new_orders_value': new_orders_value} if eq.is_included: - # Validate array bounds before accessing - if (month_index < len(eq.corrective_costs) and - month_index < len(eq.overhaul_costs) and - month_index < len(eq.procurement_costs) and - month_index < len(eq.daily_failures)): - - month_result["corrective_cost"] += float(eq.corrective_costs[month_index]) - month_result["overhaul_cost"] += float(eq.overhaul_costs[month_index]) - month_result["procurement_cost"] += float(eq.procurement_costs[month_index]) - month_result["num_failures"] += float(eq.daily_failures[month_index]) - - # Update month sparepart summary - month_result["sparepart_summary"].update({ - "total_procurement_cost": month_result["procurement_cost"], - "equipment_requiring_procurement": equipment_requiring_procurement, - "critical_shortages": critical_shortages, - "existing_orders_value": total_existing_orders_value, - "new_orders_required": len([eq for eq in all_equipment - if month_index < len(eq.procurement_details) and - eq.procurement_details[month_index] and - eq.procurement_details[month_index].get("procurement_needed")]), - "urgent_procurements": urgent_procurements - }) - - # Calculate total cost for this month - month_result["total_cost"] = (month_result["corrective_cost"] + - month_result["overhaul_cost"] ) - + if month_index < len(eq.corrective_costs) and month_index < len(eq.overhaul_costs) and (month_index < len(eq.procurement_costs)) and (month_index < len(eq.daily_failures)): + month_result['corrective_cost'] += float(eq.corrective_costs[month_index]) + month_result['overhaul_cost'] += float(eq.overhaul_costs[month_index]) + month_result['procurement_cost'] += float(eq.procurement_costs[month_index]) + month_result['num_failures'] += float(eq.daily_failures[month_index]) + month_result['sparepart_summary'].update({'total_procurement_cost': month_result['procurement_cost'], 'equipment_requiring_procurement': equipment_requiring_procurement, 'critical_shortages': critical_shortages, 'existing_orders_value': total_existing_orders_value, 'new_orders_required': len([eq for eq in all_equipment if month_index < len(eq.procurement_details) and eq.procurement_details[month_index] and eq.procurement_details[month_index].get('procurement_needed')]), 'urgent_procurements': urgent_procurements}) + month_result['total_cost'] = month_result['corrective_cost'] + month_result['overhaul_cost'] calculation_results.append(CalculationResultsRead(**month_result)) - optimum_day = np.argmin([month.total_cost for month in calculation_results]) - scope_calculation.optimum_oh_day = int(optimum_day) await db_session.commit() - - # Update fleet statistics - fleet_statistics['equipment_with_sparepart_constraints'] = len([ - eq for eq in all_equipment - if any(detail and detail.get("procurement_needed") - for detail in eq.procurement_details if detail) - ]) - - fleet_statistics['total_procurement_items'] = sum([ - len(detail.get("procurement_needed", [])) - for eq in all_equipment - for detail in eq.procurement_details - if detail and isinstance(detail, dict) - ]) - - # Calculate optimal timing analysis - optimal_analysis = _analyze_optimal_timing( - calculation_results, scope_calculation.optimum_oh_day, prev_oh_scope, scope_overhaul - ) - - # Return comprehensive result - return CalculationTimeConstrainsRead( - id=scope_calculation.id, - reference=scope_calculation.overhaul_session_id, - scope=scope_overhaul.maintenance_type.name, - results=calculation_results, - optimum_oh=scope_calculation.optimum_oh_day, - optimum_oh_month=scope_calculation.optimum_oh_day + 1, # 1-based month - equipment_results=scope_calculation.equipment_results, - fleet_statistics=fleet_statistics, - optimal_analysis=optimal_analysis, - analysis_metadata={ - "max_interval_months": data_num, - "last_overhaul_date": prev_oh_scope.end_date.isoformat() if prev_oh_scope else None, - "next_planned_overhaul": scope_overhaul.start_date.isoformat(), - "calculation_type": "sparepart_optimized" if fleet_statistics['equipment_with_sparepart_constraints'] > 0 else "standard", - "total_equipment_analyzed": len(all_equipment), - "included_in_optimization": len(included_equipment), - "optimal_stat": scope_calculation.optimum_analysis - } - ) - + fleet_statistics['equipment_with_sparepart_constraints'] = len([eq for eq in all_equipment if any((detail and detail.get('procurement_needed') for detail in eq.procurement_details if detail))]) + fleet_statistics['total_procurement_items'] = sum([len(detail.get('procurement_needed', [])) for eq in all_equipment for detail in eq.procurement_details if detail and isinstance(detail, dict)]) + optimal_analysis = _analyze_optimal_timing(calculation_results, scope_calculation.optimum_oh_day, prev_oh_scope, scope_overhaul) + return CalculationTimeConstrainsRead(id=scope_calculation.id, reference=scope_calculation.overhaul_session_id, scope=scope_overhaul.maintenance_type.name, results=calculation_results, optimum_oh=scope_calculation.optimum_oh_day, optimum_oh_month=scope_calculation.optimum_oh_day + 1, equipment_results=scope_calculation.equipment_results, fleet_statistics=fleet_statistics, optimal_analysis=optimal_analysis, analysis_metadata={'max_interval_months': data_num, 'last_overhaul_date': prev_oh_scope.end_date.isoformat() if prev_oh_scope else None, 'next_planned_overhaul': scope_overhaul.start_date.isoformat(), 'calculation_type': 'sparepart_optimized' if fleet_statistics['equipment_with_sparepart_constraints'] > 0 else 'standard', 'total_equipment_analyzed': len(all_equipment), 'included_in_optimization': len(included_equipment), 'optimal_stat': scope_calculation.optimum_analysis}) except HTTPException: - # Re-raise HTTP exceptions as-is raise except Exception as e: - # Log the error for debugging import logging logger = logging.getLogger(__name__) - logger.error(f"Error in get_calculation_result for calculation_id {calculation_id}: {str(e)}") - - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Internal error processing calculation results: {str(e)}", - ) + logger.error(f'Error in get_calculation_result for calculation_id {calculation_id}: {str(e)}') + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Internal error processing calculation results: {str(e)}') - -def _analyze_optimal_timing(calculation_results: List, optimum_oh_day: int, - prev_oh_scope, scope_overhaul) -> Dict: - """Analyze optimal timing and provide recommendations""" - +def _analyze_optimal_timing(calculation_results: List, optimum_oh_day: int, prev_oh_scope, scope_overhaul) -> Dict: if not calculation_results: return {} - - # Find the result for optimal day optimal_result = None if 0 <= optimum_oh_day < len(calculation_results): optimal_result = calculation_results[optimum_oh_day] - - # Calculate planned overhaul timing planned_oh_months = None if prev_oh_scope and scope_overhaul: - planned_oh_months = (scope_overhaul.start_date.year - prev_oh_scope.end_date.year) * 12 + \ - (scope_overhaul.start_date.month - prev_oh_scope.end_date.month) - - # Find minimum cost point + planned_oh_months = (scope_overhaul.start_date.year - prev_oh_scope.end_date.year) * 12 + (scope_overhaul.start_date.month - prev_oh_scope.end_date.month) min(calculation_results, key=lambda x: x.total_cost) - - # Calculate timing recommendation - timing_recommendation = "OPTIMAL" + timing_recommendation = 'OPTIMAL' if planned_oh_months: if optimum_oh_day + 1 < planned_oh_months: - timing_recommendation = "EARLY" + timing_recommendation = 'EARLY' elif optimum_oh_day + 1 > planned_oh_months: - timing_recommendation = "DELAYED" + timing_recommendation = 'DELAYED' else: - timing_recommendation = "ON_SCHEDULE" - - # Analyze cost trends - cost_trend = "STABLE" + timing_recommendation = 'ON_SCHEDULE' + cost_trend = 'STABLE' if len(calculation_results) > 1: - early_costs = [r.total_cost for r in calculation_results[:len(calculation_results)//3]] - late_costs = [r.total_cost for r in calculation_results[-len(calculation_results)//3:]] - + early_costs = [r.total_cost for r in calculation_results[:len(calculation_results) // 3]] + late_costs = [r.total_cost for r in calculation_results[-len(calculation_results) // 3:]] avg_early = sum(early_costs) / len(early_costs) if early_costs else 0 avg_late = sum(late_costs) / len(late_costs) if late_costs else 0 - if avg_late > avg_early * 1.2: - cost_trend = "INCREASING" + cost_trend = 'INCREASING' elif avg_late < avg_early * 0.8: - cost_trend = "DECREASING" - - return { - "optimal_month": optimum_oh_day + 1, - "planned_month": planned_oh_months, - "timing_recommendation": timing_recommendation, - "optimal_total_cost": optimal_result.total_cost if optimal_result else 0, - "optimal_breakdown": { - "corrective_cost": optimal_result.corrective_cost if optimal_result else 0, - "overhaul_cost": optimal_result.overhaul_cost if optimal_result else 0, - "procurement_cost": optimal_result.procurement_cost if optimal_result else 0, - "num_failures": optimal_result.num_failures if optimal_result else 0 - }, - "cost_trend": cost_trend, - "months_from_planned": (optimum_oh_day + 1 - planned_oh_months) if planned_oh_months else None, - "cost_savings_vs_planned": None, # Would need planned month cost to calculate - "sparepart_impact": { - "equipment_with_constraints": optimal_result.sparepart_summary["equipment_requiring_procurement"] if optimal_result else 0, - "critical_shortages": optimal_result.sparepart_summary["critical_shortages"] if optimal_result else 0, - "procurement_investment": optimal_result.sparepart_summary["total_procurement_cost"] if optimal_result else 0 - } - } - - - - -async def get_calculation_data_by_id( - db_session: DbSession, calculation_id -) -> CalculationData: - stmt = ( - select(CalculationData) - .filter(CalculationData.id == calculation_id) - .options( - joinedload(CalculationData.equipment_results), - joinedload(CalculationData.parameter), - ) - ) + cost_trend = 'DECREASING' + return {'optimal_month': optimum_oh_day + 1, 'planned_month': planned_oh_months, 'timing_recommendation': timing_recommendation, 'optimal_total_cost': optimal_result.total_cost if optimal_result else 0, 'optimal_breakdown': {'corrective_cost': optimal_result.corrective_cost if optimal_result else 0, 'overhaul_cost': optimal_result.overhaul_cost if optimal_result else 0, 'procurement_cost': optimal_result.procurement_cost if optimal_result else 0, 'num_failures': optimal_result.num_failures if optimal_result else 0}, 'cost_trend': cost_trend, 'months_from_planned': optimum_oh_day + 1 - planned_oh_months if planned_oh_months else None, 'cost_savings_vs_planned': None, 'sparepart_impact': {'equipment_with_constraints': optimal_result.sparepart_summary['equipment_requiring_procurement'] if optimal_result else 0, 'critical_shortages': optimal_result.sparepart_summary['critical_shortages'] if optimal_result else 0, 'procurement_investment': optimal_result.sparepart_summary['total_procurement_cost'] if optimal_result else 0}} +async def get_calculation_data_by_id(db_session: DbSession, calculation_id) -> CalculationData: + stmt = select(CalculationData).filter(CalculationData.id == calculation_id).options(joinedload(CalculationData.equipment_results), joinedload(CalculationData.parameter)) result = await db_session.execute(stmt) return result.unique().scalar() - -async def get_all_calculations( - db_session: DbSession, -) -> List[CalculationData]: - stmt = ( - select(CalculationData) - .options( - selectinload(CalculationData.session) - ) - .where( - CalculationData.optimum_oh_day.isnot(None), - CalculationData.max_interval.isnot(None), - CalculationData.optimum_analysis.isnot(None), - ) - .order_by(CalculationData.created_at.desc()) - ) +async def get_all_calculations(db_session: DbSession) -> List[CalculationData]: + stmt = select(CalculationData).options(selectinload(CalculationData.session)).where(CalculationData.optimum_oh_day.isnot(None), CalculationData.max_interval.isnot(None), CalculationData.optimum_analysis.isnot(None)).order_by(CalculationData.created_at.desc()) result = await db_session.execute(stmt) - return result.scalars().all() - -async def get_calculation_by_assetnum( - *, db_session: DbSession, assetnum: str, calculation_id: str -): - stmt = ( - select(CalculationEquipmentResult) - .where(CalculationEquipmentResult.assetnum == assetnum) - .where(CalculationEquipmentResult.calculation_data_id == calculation_id) - ) +async def get_calculation_by_assetnum(*, db_session: DbSession, assetnum: str, calculation_id: str): + stmt = select(CalculationEquipmentResult).where(CalculationEquipmentResult.assetnum == assetnum).where(CalculationEquipmentResult.calculation_data_id == calculation_id) result = await db_session.execute(stmt) - return result.scalar() - - async def get_number_of_failures(location_tag, start_date, end_date, token, max_interval=24): - url_prediction = ( - f"{REALIBILITY_SERVICE_API}/main/number-of-failures/" - f"{location_tag}/{start_date.strftime('%Y-%m-%d')}/{end_date.strftime('%Y-%m-%d')}" - ) - + url_prediction = f"{REALIBILITY_SERVICE_API}/main/number-of-failures/{location_tag}/{start_date.strftime('%Y-%m-%d')}/{end_date.strftime('%Y-%m-%d')}" results = {} - try: - response = requests.get( - url_prediction, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {token}", - }, - timeout=10 - ) + response = requests.get(url_prediction, headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'}, timeout=10) response.raise_for_status() prediction_data = response.json() except (requests.RequestException, ValueError) as e: - raise Exception(f"Failed to fetch or parse prediction data: {e}") - - if not prediction_data or "data" not in prediction_data or not isinstance(prediction_data["data"], list): - raise Exception("Invalid or empty prediction data format.") - - last_data = prediction_data["data"][-1] - last_data_date = datetime.strptime(last_data["date"], "%d %b %Y") - results[datetime.date(last_data_date.year, last_data_date.month, last_data_date.day)] = round(last_data["num_fail"]) if last_data["num_fail"] is not None else 0 - - - # Parse prediction data - for item in prediction_data["data"]: + raise Exception(f'Failed to fetch or parse prediction data: {e}') + if not prediction_data or 'data' not in prediction_data or (not isinstance(prediction_data['data'], list)): + raise Exception('Invalid or empty prediction data format.') + last_data = prediction_data['data'][-1] + last_data_date = datetime.strptime(last_data['date'], '%d %b %Y') + results[datetime.date(last_data_date.year, last_data_date.month, last_data_date.day)] = round(last_data['num_fail']) if last_data['num_fail'] is not None else 0 + for item in prediction_data['data']: try: - date = datetime.strptime(item["date"], "%d %b %Y") + date = datetime.strptime(item['date'], '%d %b %Y') last_day = calendar.monthrange(date.year, date.month)[1] - value = item.get("num_fail", 0) + value = item.get('num_fail', 0) if date.day == last_day: if date.month == start_date.month and date.year == start_date.year: results[date.date()] = 0 else: results[date.date()] = 0 if value <= 0 else int(value) - except (KeyError, ValueError): - continue # skip invalid items - - # Fill missing months with 0 + continue current = start_date.replace(day=1) for _ in range(max_interval): last_day = calendar.monthrange(current.year, current.month)[1] last_day_date = datetime.date(current.year, current.month, last_day) if last_day_date not in results: results[last_day_date] = 0 - # move to next month if current.month == 12: current = current.replace(year=current.year + 1, month=1) else: current = current.replace(month=current.month + 1) - - # Sort results by date results = dict(sorted(results.items())) - - return results -async def get_equipment_foh( - location_tag: str, - token: str -): - url_mdt = ( - f"{REALIBILITY_SERVICE_API}/asset/mdt/{location_tag}" - ) - +async def get_equipment_foh(location_tag: str, token: str): + url_mdt = f'{REALIBILITY_SERVICE_API}/asset/mdt/{location_tag}' try: - response = requests.get( - url_mdt, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {token}", - }, - timeout=10 - ) + response = requests.get(url_mdt, headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'}, timeout=10) response.raise_for_status() result = response.json() except (requests.RequestException, ValueError) as e: - raise Exception(f"Failed to fetch or parse mdt data: {e}") - - mdt_data = result["data"]["hours"] - + raise Exception(f'Failed to fetch or parse mdt data: {e}') + mdt_data = result['data']['hours'] return mdt_data -# Function to simulate overhaul strategy for a single equipment -def simulate_equipment_overhaul(equipment, preventive_cost,predicted_num_failures, interval_months, forced_outage_hours_value ,total_months=24): - """ - Simulates overhaul strategy for a specific piece of equipment - and returns the associated costs. - """ +def simulate_equipment_overhaul(equipment, preventive_cost, predicted_num_failures, interval_months, forced_outage_hours_value, total_months=24): total_preventive_cost = 0 total_corrective_cost = 0 months_since_overhaul = 0 - failures_by_month = {i: val for i, (date, val) in enumerate(sorted(predicted_num_failures.items()))} - cost_per_failure = equipment.material_cost - - # Simulate for the total period for month in range(total_months): - # If it's time for overhaul if months_since_overhaul >= interval_months: - # Perform preventive overhaul total_preventive_cost += preventive_cost months_since_overhaul = 0 - if months_since_overhaul == 0: - # Calculate failures for this month based on time since last overhaul expected_failures = 0 equivalent_force_derated_hours = 0 - failure_cost = (expected_failures * cost_per_failure) + ((forced_outage_hours_value + equivalent_force_derated_hours) * equipment.service_cost) + failure_cost = expected_failures * cost_per_failure + (forced_outage_hours_value + equivalent_force_derated_hours) * equipment.service_cost total_corrective_cost += failure_cost - else: - # Calculate failures for this month based on time since last overhaul expected_failures = failures_by_month.get(months_since_overhaul, 0) equivalent_force_derated_hours = 0 - failure_cost = (expected_failures * cost_per_failure) + ((forced_outage_hours_value + equivalent_force_derated_hours) * equipment.service_cost) + failure_cost = expected_failures * cost_per_failure + (forced_outage_hours_value + equivalent_force_derated_hours) * equipment.service_cost total_corrective_cost += failure_cost - - # Increment time since overhaul months_since_overhaul += 1 - - # Calculate costs per month (to normalize for comparison) monthly_preventive_cost = total_preventive_cost / total_months monthly_corrective_cost = total_corrective_cost / total_months monthly_total_cost = monthly_preventive_cost + monthly_corrective_cost + return {'interval': interval_months, 'preventive_cost': monthly_preventive_cost, 'corrective_cost': monthly_corrective_cost, 'total_cost': monthly_total_cost} - return { - 'interval': interval_months, - 'preventive_cost': monthly_preventive_cost, - 'corrective_cost': monthly_corrective_cost, - 'total_cost': monthly_total_cost - } - - -async def create_calculation_result_service( - db_session: DbSession, calculation: CalculationData, token: str -) -> CalculationTimeConstrainsRead: - - # Get all equipment for this calculation session - equipments = await get_all_by_session_id( - db_session=db_session, overhaul_session_id=calculation.overhaul_session_id - ) - +async def create_calculation_result_service(db_session: DbSession, calculation: CalculationData, token: str) -> CalculationTimeConstrainsRead: + equipments = await get_all_by_session_id(db_session=db_session, overhaul_session_id=calculation.overhaul_session_id) scope = await get_scope(db_session=db_session, overhaul_session_id=calculation.overhaul_session_id) - prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=scope) - - - calculation_data = await get_calculation_data_by_id( - db_session=db_session, calculation_id=calculation.id - ) - - # Set the date range for the calculation + calculation_data = await get_calculation_data_by_id(db_session=db_session, calculation_id=calculation.id) if prev_oh_scope: - # Start date is the day after the previous scope's end date start_date = datetime.combine(prev_oh_scope.end_date + datetime.timedelta(days=1), datetime.time.min) - # End date is the start date of the current scope end_date = datetime.combine(scope.start_date, datetime.time.min) else: - # If there's no previous scope, use the start and end dates from the current scope start_date = datetime.combine(scope.start_date, datetime.time.min) end_date = datetime.combine(scope.end_date, datetime.time.min) - max_interval = get_months_between(start_date, end_date) overhaul_cost = calculation_data.parameter.overhaul_cost / len(equipments) - - # Store results for each equipment results = [] - total_corrective_costs = np.zeros(max_interval) total_overhaul_costs = np.zeros(max_interval) total_daily_failures = np.zeros(max_interval) total_costs = np.zeros(max_interval) - - # Calculate for each equipment for eq in equipments: equipment_results = [] corrective_costs = [] overhaul_costs = [] total = [] - - predicted_num_failures = await get_number_of_failures( - location_tag=eq.location_tag, - start_date=start_date, - end_date=end_date, - token=token - ) - - foh_value = await get_equipment_foh( - location_tag=eq.location_tag, - token=token - ) - - for interval in range(1, max_interval+1): + predicted_num_failures = await get_number_of_failures(location_tag=eq.location_tag, start_date=start_date, end_date=end_date, token=token) + foh_value = await get_equipment_foh(location_tag=eq.location_tag, token=token) + for interval in range(1, max_interval + 1): result = simulate_equipment_overhaul(eq, overhaul_cost, predicted_num_failures, interval, foh_value, total_months=max_interval) corrective_costs.append(result['corrective_cost']) overhaul_costs.append(result['preventive_cost']) total.append(result['total_cost']) equipment_results.append(result) - optimal_result = min(equipment_results, key=lambda x: x['total_cost']) - - results.append( - CalculationEquipmentResult( - corrective_costs=corrective_costs, - overhaul_costs=overhaul_costs, - daily_failures=[failure for _, failure in predicted_num_failures.items()], - assetnum=eq.assetnum, - material_cost=eq.material_cost, - service_cost=eq.service_cost, - optimum_day=optimal_result['interval'], - calculation_data_id=calculation.id, - master_equipment=eq.master_equipment, - ) - ) - + results.append(CalculationEquipmentResult(corrective_costs=corrective_costs, overhaul_costs=overhaul_costs, daily_failures=[failure for _, failure in predicted_num_failures.items()], assetnum=eq.assetnum, material_cost=eq.material_cost, service_cost=eq.service_cost, optimum_day=optimal_result['interval'], calculation_data_id=calculation.id, master_equipment=eq.master_equipment)) if len(predicted_num_failures.values()) < max_interval: raise Exception(eq.equipment.assetnum) - - total_corrective_costs += np.array(corrective_costs) total_overhaul_costs += np.array(overhaul_costs) total_daily_failures += np.array([failure for _, failure in predicted_num_failures.items()]) total_costs += np.array(total_costs) - - db_session.add_all(results) - total_costs_point = total_corrective_costs + total_overhaul_costs - - # Calculate optimum points using total costs optimum_oh_index = np.argmin(total_costs_point) - numbers_of_failure = sum(total_daily_failures[:optimum_oh_index]) - - optimum = OptimumResult( - overhaul_cost=float(total_overhaul_costs[optimum_oh_index]), - corrective_cost=float(total_corrective_costs[optimum_oh_index]), - num_failures=int(numbers_of_failure), - days=int(optimum_oh_index + 1), - ) + optimum = OptimumResult(overhaul_cost=float(total_overhaul_costs[optimum_oh_index]), corrective_cost=float(total_corrective_costs[optimum_oh_index]), num_failures=int(numbers_of_failure), days=int(optimum_oh_index + 1)) calculation.optimum_oh_day = optimum.days - await db_session.commit() + return CalculationTimeConstrainsRead(id=calculation.id, reference=calculation.overhaul_session_id, scope=scope.type, results=[], optimum_oh=optimum, equipment_results=results) - # Return results including individual equipment data - return CalculationTimeConstrainsRead( - id=calculation.id, - reference=calculation.overhaul_session_id, - scope=scope.type, - results=[], - optimum_oh=optimum, - equipment_results=results, - ) - - -async def get_calculation_by_reference_and_parameter( - *, db_session: DbSession, calculation_reference_id, parameter_id -): - stmt = select(CalculationData).filter( - and_( - CalculationData.reference_id == calculation_reference_id, - CalculationData.parameter_id == parameter_id, - ) - ) - +async def get_calculation_by_reference_and_parameter(*, db_session: DbSession, calculation_reference_id, parameter_id): + stmt = select(CalculationData).filter(and_(CalculationData.reference_id == calculation_reference_id, CalculationData.parameter_id == parameter_id)) result = await db_session.execute(stmt) - return result.scalar() - -async def get_calculation_result_by_day( - *, db_session: DbSession, calculation_id, simulation_day -): - stmt = select(CalculationResult).filter( - and_( - CalculationResult.day == simulation_day, - CalculationResult.calculation_data_id == calculation_id, - ) - ) - +async def get_calculation_result_by_day(*, db_session: DbSession, calculation_id, simulation_day): + stmt = select(CalculationResult).filter(and_(CalculationResult.day == simulation_day, CalculationResult.calculation_data_id == calculation_id)) result = await db_session.execute(stmt) - return result.scalar() - async def get_avg_cost_by_asset(*, db_session: DbSession, assetnum: str): - stmt = select(func.avg(MasterWorkOrder.total_cost_max).label("average_cost")).where( - MasterWorkOrder.assetnum == assetnum - ) - + stmt = select(func.avg(MasterWorkOrder.total_cost_max).label('average_cost')).where(MasterWorkOrder.assetnum == assetnum) result = await db_session.execute(stmt) return result.scalar_one_or_none() - -async def bulk_update_equipment( - *, - db: DbSession, - selected_equipments: List[CalculationSelectedEquipmentUpdate], - calculation_data_id: UUID, -): - # Create a dictionary mapping assetnum to is_included status +async def bulk_update_equipment(*, db: DbSession, selected_equipments: List[CalculationSelectedEquipmentUpdate], calculation_data_id: UUID): case_mappings = {asset.location_tag: asset.is_included for asset in selected_equipments} - - # Get all assetnums that need to be updated location_tags = list(case_mappings.keys()) - - # Create a list of when clauses for the case statement - when_clauses = [ - (CalculationEquipmentResult.location_tag == location_tag, is_included) - for location_tag, is_included in case_mappings.items() - ] - - # Build the update statement - stmt = ( - update(CalculationEquipmentResult) - .where(CalculationEquipmentResult.calculation_data_id == calculation_data_id) - .where(CalculationEquipmentResult.location_tag.in_(location_tags)) - .values( - { - "is_included": case( - *when_clauses - ) , - "is_initial" : False - } - ) - ) - + when_clauses = [(CalculationEquipmentResult.location_tag == location_tag, is_included) for location_tag, is_included in case_mappings.items()] + stmt = update(CalculationEquipmentResult).where(CalculationEquipmentResult.calculation_data_id == calculation_data_id).where(CalculationEquipmentResult.location_tag.in_(location_tags)).values({'is_included': case(*when_clauses), 'is_initial': False}) await db.execute(stmt) await db.commit() - return location_tags diff --git a/src/calculation_time_constrains/utils.py b/src/calculation_time_constrains/utils.py index 043e62d..a0321f3 100644 --- a/src/calculation_time_constrains/utils.py +++ b/src/calculation_time_constrains/utils.py @@ -1,295 +1,135 @@ import datetime - import pandas as pd import requests - from src.config import RBD_SERVICE_API def get_months_between(start_date: datetime.datetime, end_date: datetime.datetime) -> int: - """ - 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): - # Filter out ON_OH - filtered_data = [d for d in chart_data if d["currentEvent"] != "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: return [] - hourly_data = [] current_state_index = 0 - current_flow_rate = sorted_data[0]["flowRate"] - current_eq_status = sorted_data[0]["currentEQStatus"] - - # Determine maximum bound (either given or from data) - last_time = int(sorted_data[-1]["cumulativeTime"]) + current_flow_rate = sorted_data[0]['flowRate'] + current_eq_status = sorted_data[0]['currentEQStatus'] + last_time = int(sorted_data[-1]['cumulativeTime']) if max_hours is None: max_hours = last_time - - 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"]): + 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']: current_state_index += 1 - current_flow_rate = sorted_data[current_state_index]["flowRate"] - current_eq_status = sorted_data[current_state_index]["currentEQStatus"] - - hourly_data.append({ - "cumulativeTime": hour, - "flowRate": current_flow_rate, - "currentEQStatus": current_eq_status - }) - + current_flow_rate = sorted_data[current_state_index]['flowRate'] + current_eq_status = sorted_data[current_state_index]['currentEQStatus'] + hourly_data.append({'cumulativeTime': hour, 'flowRate': current_flow_rate, 'currentEQStatus': current_eq_status}) return 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 previous_eq_status = None monthly_data = {} - for data_point in hourly_data: hour = data_point['hour'] current_eq_status = data_point['currentEQStatus'] - - # 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": + month = (hour - 1) // 720 + 1 + if current_eq_status == 'OoS' and previous_eq_status is not None and (previous_eq_status != 'OoS'): total_failures += 1 - # Special case: if the very first data point is a failure - elif current_eq_status == "OoS" and previous_eq_status is None: + elif current_eq_status == 'OoS' and previous_eq_status is None: total_failures += 1 - - # Store the current cumulative count for this month monthly_data[month] = total_failures previous_eq_status = current_eq_status - - # Convert to list format result = [] if monthly_data: max_month = max(monthly_data.keys()) 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 - - import pandas as pd import datetime - import datetime 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): - """Get failure predictions for equipment from simulation service""" - calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/{location_tag}" - - try: - response = requests.get( - calc_result_url, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {token}", - }, - timeout=30 - ) - response.raise_for_status() - prediction_data = response.json()['data'] - except (requests.RequestException, ValueError) as e: - raise Exception(str(e)) - - - return prediction_data - -def analyze_monthly_metrics(timestamp_outs, start_date, max_flow_rate: float = 550): +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}' + try: + response = requests.get(calc_result_url, headers={'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'}, timeout=30) + response.raise_for_status() + prediction_data = response.json()['data'] + except (requests.RequestException, ValueError) as e: + raise Exception(str(e)) + return prediction_data + +def analyze_monthly_metrics(timestamp_outs, start_date, max_flow_rate: float=550): if not timestamp_outs: return {} - df = pd.DataFrame(timestamp_outs) 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 {} - 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['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['duration_hours'].fillna(0) - - # Failure detection df['status_change'] = df['currentEQStatus'].shift() != df['currentEQStatus'] df['failure'] = (df['currentEQStatus'] == 'OoS') & df['status_change'] - - # Cumulative tracking df['cumulative_failures'] = df['failure'].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['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_hours_equivalent'] = df['derated_mwh'] / max_flow_rate - monthly_results = {} - for month_period, group in df.groupby('month_year', sort=True): month_str = str(month_period) monthly_results[month_str] = {} - - # Failures monthly_results[month_str]['failures_count'] = int(group['failure'].sum()) monthly_results[month_str]['cumulative_failures'] = int(group['cumulative_failures'].max()) - - # OOS hours oos_time = group.loc[group['currentEQStatus'] == 'OoS', 'duration_hours'].sum() monthly_results[month_str]['total_oos_hours'] = float(oos_time) 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_time = group['duration_hours'].sum() 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) - - # Extra metrics monthly_results[month_str]['total_hours'] = float(total_time) service_hours = group.loc[group['currentEQStatus'] == 'Svc', 'duration_hours'].sum() 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 - ) - - # Derating metrics + monthly_results[month_str]['availability_percentage'] = float(service_hours / total_time * 100 if total_time > 0 else 0) derating_hours = group.loc[group['is_derated'], 'duration_hours'].sum() derated_mwh = group['derated_mwh'].sum() equivalent_derated_hours = group['derated_hours_equivalent'].sum() - monthly_results[month_str]['derating_hours'] = float(derating_hours) monthly_results[month_str]['derated_mwh'] = float(derated_mwh) monthly_results[month_str]['equivalent_derated_hours'] = float(equivalent_derated_hours) - return monthly_results - 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_cost_array = [] - for month, data in monthly_results.items(): - # Extract monthly data avg_flow_rate = data['avg_flow_rate'] - availability = data['availability_percentage'] / 100 # Convert to decimal + availability = data['availability_percentage'] / 100 total_oos_hours = data['total_oos_hours'] failures_count = data['failures_count'] - - # 1. Calculate equipment capacity contribution to system 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 - - # 3. Calculate total risk cost for the month - # Risk cost = capacity_lost * energy_price monthly_risk_cost = capacity_lost_to_downtime * energy_price - - # 4. Calculate risk cost per failure for this month if failures_count > 0: risk_cost_per_failure = monthly_risk_cost / failures_count else: - # If no failures, set to 0 or use alternative approach risk_cost_per_failure = 0 - - # 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_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} 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): - """ - Complete analysis combining monthly metrics with risk cost calculation - """ - # Get monthly metrics monthly_metrics = analyze_monthly_metrics(timestamp_outs) - - # Calculate risk costs - risk_analysis = calculate_risk_cost_per_failure( - monthly_metrics, - birnbaum_importance, - energy_price - ) - - # Combine results for comprehensive view + risk_analysis = calculate_risk_cost_per_failure(monthly_metrics, birnbaum_importance, energy_price) combined_results = {} for month in monthly_metrics.keys(): - combined_results[month] = { - **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: -# + combined_results[month] = {**monthly_metrics[month], **risk_analysis['monthly_details'][month]} + return {'monthly_data': combined_results, 'risk_cost_array': risk_analysis['risk_cost_per_failure_array']} diff --git a/src/config.py b/src/config.py index 1f8c729..05f96fe 100644 --- a/src/config.py +++ b/src/config.py @@ -2,97 +2,63 @@ import logging import os from typing import List from urllib import parse - from pydantic import BaseModel from starlette.config import Config - log = logging.getLogger(__name__) - class BaseConfigurationModel(BaseModel): - """Base configuration model used by all config options.""" - pass - def get_env_tags(tag_list: List[str]) -> dict: - """Create dictionary of available env tags.""" tags = {} for t in tag_list: - tag_key, env_key = t.split(":") - + tag_key, env_key = t.split(':') env_value = os.environ.get(env_key) - if env_value: tags.update({tag_key: env_value}) - return tags - def get_config(): try: - # Try to load from .env file first - config = Config(".env") + config = Config('.env') except FileNotFoundError: - # If .env doesn't exist, use environment variables config = Config(environ=os.environ) - return config - - config = get_config() - - -LOG_LEVEL = config("LOG_LEVEL", default="INFO") -SERVICE_NAME = config("SERVICE_NAME", default="Be-OptimumOH") -ENV = config("ENV") -PORT = config("PORT", cast=int) -HOST = config("HOST") - - -# database -DATABASE_HOSTNAME = config("DATABASE_HOSTNAME") -_DATABASE_CREDENTIAL_USER = config("DATABASE_CREDENTIAL_USER") -_DATABASE_CREDENTIAL_PASSWORD = config("DATABASE_CREDENTIAL_PASSWORD") +LOG_LEVEL = config('LOG_LEVEL', default='INFO') +SERVICE_NAME = config('SERVICE_NAME', default='Be-OptimumOH') +ENV = config('ENV') +PORT = config('PORT', cast=int) +HOST = config('HOST') +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)) -DATABASE_NAME = config("DATABASE_NAME") -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 -) - -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") +DATABASE_NAME = config('DATABASE_NAME') +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) +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)) -COLLECTOR_NAME = config("COLLECTOR_NAME") - -# Deal w -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}" - - -TIMEZONE = config("TIMEZONE") - -MAXIMO_BASE_URL = config("MAXIMO_BASE_URL") -MAXIMO_API_KEY = config("MAXIMO_API_KEY") - -AUTH_SERVICE_API = config("AUTH_SERVICE_API") -REALIBILITY_SERVICE_API = config("REALIBILITY_SERVICE_API") -RBD_SERVICE_API = config("RBD_SERVICE_API") -TEMPORAL_URL = config("TEMPORAL_URL") -BASE_URL = config("BASE_URL") - - -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) \ No newline at end of file +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}' +TIMEZONE = config('TIMEZONE') +MAXIMO_BASE_URL = config('MAXIMO_BASE_URL') +MAXIMO_API_KEY = config('MAXIMO_API_KEY') +AUTH_SERVICE_API = config('AUTH_SERVICE_API') +REALIBILITY_SERVICE_API = config('REALIBILITY_SERVICE_API') +RBD_SERVICE_API = config('RBD_SERVICE_API') +TEMPORAL_URL = config('TEMPORAL_URL') +BASE_URL = config('BASE_URL') +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) diff --git a/src/context.py b/src/context.py index 779a3e4..a7a8718 100644 --- a/src/context.py +++ b/src/context.py @@ -1,25 +1,20 @@ from contextvars import ContextVar from typing import Optional, Final - -REQUEST_ID_CTX_KEY: Final[str] = "request_id" -USER_ID_CTX_KEY: Final[str] = "user_id" -USERNAME_CTX_KEY: Final[str] = "username" -ROLE_CTX_KEY: Final[str] = "role" - +REQUEST_ID_CTX_KEY: Final[str] = 'request_id' +USER_ID_CTX_KEY: Final[str] = 'user_id' +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) _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) _role_ctx_var: ContextVar[Optional[str]] = ContextVar(ROLE_CTX_KEY, default=None) - def get_request_id() -> Optional[str]: return _request_id_ctx_var.get() - def set_request_id(request_id: str): return _request_id_ctx_var.set(request_id) - def reset_request_id(token): _request_id_ctx_var.reset(token) diff --git a/src/contribution_util.py b/src/contribution_util.py index fda38b9..b85f465 100644 --- a/src/contribution_util.py +++ b/src/contribution_util.py @@ -2,16 +2,11 @@ import json import logging from typing import Dict, Union from decimal import Decimal, getcontext - -# Set high precision for decimal calculations getcontext().prec = 50 - Structure = Union[str, Dict[str, list]] - log = logging.getLogger(__name__) def prod(iterable): - """Compute product of all elements in iterable with high precision.""" result = Decimal('1.0') for x in iterable: if isinstance(x, (int, float)): @@ -19,56 +14,43 @@ def prod(iterable): result *= x return float(result) - def system_availability(structure: Structure, availabilities: Dict[str, float]) -> float: - """Recursively compute system availability with precise calculations.""" - if isinstance(structure, str): # base case - component + if isinstance(structure, str): if structure not in availabilities: raise ValueError(f"Component '{structure}' not found in availabilities") return float(Decimal(str(availabilities[structure]))) - if isinstance(structure, dict): - if "series" in structure: - components = structure["series"] - if not components: # Handle empty series + if 'series' in structure: + components = structure['series'] + if not components: return 1.0 - product = Decimal('1.0') for s in components: availability = system_availability(s, availabilities) product *= Decimal(str(availability)) return float(product) - - elif "parallel" in structure: - components = structure["parallel"] - if not components: # Handle empty parallel + elif 'parallel' in structure: + components = structure['parallel'] + if not components: return 0.0 - product = Decimal('1.0') for s in components: availability = system_availability(s, availabilities) unavailability = Decimal('1.0') - Decimal(str(availability)) product *= unavailability - result = Decimal('1.0') - product return float(result) - - elif "parallel_no_redundancy" in structure: - # Load sharing - system availability is minimum of components - components = structure["parallel_no_redundancy"] + elif 'parallel_no_redundancy' in structure: + components = structure['parallel_no_redundancy'] if not components: return 0.0 - availabilities_list = [system_availability(s, availabilities) for s in components] 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: - """Extract all component names from a structure.""" components = set() - + def extract_components(substructure): if isinstance(substructure, str): components.add(substructure) @@ -76,131 +58,61 @@ def get_all_components(structure: Structure) -> set: for component_list in substructure.values(): for component in component_list: extract_components(component) - extract_components(structure) return components - 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_down = availabilities.copy() - - # Set component availability to 1 (perfect) avail_up[component] = 1.0 - - # Set component availability to 0 (failed) avail_down[component] = 0.0 - - # Calculate system availability in both cases system_up = system_availability(structure, avail_up) system_down = system_availability(structure, avail_down) - - # Birnbaum importance is the difference return system_up - system_down - 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) system_avail = system_availability(structure, availabilities) component_avail = availabilities[component] - - if system_avail >= 1.0: # Perfect system + if system_avail >= 1.0: return 0.0 - 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: - """ - 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) - if system_avail <= 0.0: return 0.0 - - # Calculate system availability with component failed avail_down = availabilities.copy() avail_down[component] = 0.0 system_down = system_availability(structure, avail_down) - 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]]: - """ - 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 = {} for k, v in availabilities.items(): if v > 1.0: normalized_availabilities[k] = v / 100.0 else: normalized_availabilities[k] = v - # Clamp to valid range [0, 1] normalized_availabilities[k] = max(0.0, min(1.0, normalized_availabilities[k])) - - # Get all components in the system all_components = get_all_components(structure) - - # Check for missing components missing_components = all_components - set(normalized_availabilities.keys()) 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: normalized_availabilities[comp] = 1.0 - - # Calculate system baseline availability system_avail = system_availability(structure, normalized_availabilities) - - # Calculate importance measures for each component results = {} total_birnbaum = 0.0 - for component in all_components: if component in normalized_availabilities: birnbaum = birnbaum_importance(structure, normalized_availabilities, component) criticality = criticality_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 - - # Calculate contribution percentages based on Birnbaum importance if total_birnbaum > 0: for component in results: contribution_pct = results[component]['birnbaum_importance'] / total_birnbaum @@ -208,69 +120,29 @@ def compute_all_importance_measures(structure: Structure, availabilities: Dict[s else: for component in results: results[component]['contribution_percentage'] = 0.0 - - # Add system-level information - results['_system_info'] = { - 'system_availability': system_avail, - 'system_unavailability': 1.0 - system_avail, - 'total_birnbaum_importance': total_birnbaum - } - + results['_system_info'] = {'system_availability': system_avail, 'system_unavailability': 1.0 - system_avail, 'total_birnbaum_importance': total_birnbaum} return results - -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 - """ +def calculate_contribution_accurate(availabilities: Dict[str, float], structure_file: str='src/overhaul/rbd_structure.json') -> Dict[str, Dict[str, float]]: try: with open(structure_file, 'r') as model_file: structure = json.load(model_file) except FileNotFoundError: - raise FileNotFoundError(f"Structure file not found: {structure_file}") + raise FileNotFoundError(f'Structure file not found: {structure_file}') except json.JSONDecodeError: - raise ValueError(f"Invalid JSON in structure file: {structure_file}") - - # Compute all importance measures + raise ValueError(f'Invalid JSON in structure file: {structure_file}') results = compute_all_importance_measures(structure, availabilities) - - # Extract system information system_info = results.pop('_system_info') - - # Log results log.info(f"System Availability: {system_info['system_availability']:.6f}") log.info(f"System Unavailability: {system_info['system_unavailability']:.6f}") - - # Sort components by Birnbaum importance (most critical first) - sorted_components = sorted(results.items(), - key=lambda x: x[1]['birnbaum_importance'], - reverse=True) - - + sorted_components = sorted(results.items(), key=lambda x: x[1]['birnbaum_importance'], reverse=True) for component, measures in sorted_components: - 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 - + 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}") return results - -# Legacy function for backwards compatibility def calculate_contribution(availabilities): - """Legacy function - redirects to improved version.""" try: return calculate_contribution_accurate(availabilities) except Exception as e: - log.error(f"Error in contribution calculation: {e}") + log.error(f'Error in contribution calculation: {e}') raise - - diff --git a/src/csrf_protect.py b/src/csrf_protect.py index 6cae4ae..8d121c4 100644 --- a/src/csrf_protect.py +++ b/src/csrf_protect.py @@ -1,125 +1,36 @@ -""" -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 logging import secrets - import redis from fastapi import HTTPException, Request - import src.config as config from src.context import get_user_id - log = logging.getLogger(__name__) - -# 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, -) - +_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: - """ - 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 = getattr(request.state, 'user', None) user_id = str(user.user_id) if user else get_user_id() - if not user_id: - raise HTTPException(status_code=401, detail="Authentication required") - - # ── 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") - ) - + 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') 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", - ) - raise HTTPException(status_code=403, detail="CSRF token missing in header") - + log.warning('[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() - - # ── Step 3: bangun Redis key (harus sama dengan format di be-auth) ───────── current_path = request.url.path path_hash = hashlib.sha256(current_path.encode()).hexdigest()[:16] - redis_key = f"csrf:{user_id}:{path_hash}" - - # ── Step 4: ambil stored token hash dari Redis (DB 0) ──────────────────── + redis_key = f'csrf:{user_id}:{path_hash}' stored_token_hash = _redis_auth.get(redis_key) 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", - ) - raise HTTPException(status_code=403, detail="CSRF token expired or invalid") - + 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') + raise HTTPException(status_code=403, detail='CSRF token expired or invalid') if isinstance(stored_token_hash, bytes): - stored_token_hash = stored_token_hash.decode("utf-8") - - # ── Step 5: bandingkan hash dengan constant-time digest ─────────────────── + stored_token_hash = stored_token_hash.decode('utf-8') incoming_hash = hashlib.sha256(csrf_token_header.encode()).hexdigest() 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) - raise HTTPException(status_code=403, detail="CSRF token verification failed") - - # ── Step 6: hapus token (one-time use) dan lanjutkan ───────────────────── + raise HTTPException(status_code=403, detail='CSRF token verification failed') _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) diff --git a/src/database/__init__.py b/src/database/__init__.py index e69de29..8b13789 100644 --- a/src/database/__init__.py +++ b/src/database/__init__.py @@ -0,0 +1 @@ + diff --git a/src/database/core.py b/src/database/core.py index 514e016..13e09f1 100644 --- a/src/database/core.py +++ b/src/database/core.py @@ -1,66 +1,44 @@ -# src/database.py import re import operator from contextlib import asynccontextmanager from typing import Annotated, Any - from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.orm import DeclarativeBase from starlette.requests import Request - from src.config import SQLALCHEMY_DATABASE_URI, SQLALCHEMY_COLLECTOR_URI - engine = create_async_engine(SQLALCHEMY_DATABASE_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): return request.state.collector_db - def get_db(request: Request): return request.state.db - - DbSession = Annotated[AsyncSession, Depends(get_db)] CollectorDbSession = Annotated[AsyncSession, Depends(get_collector_db)] - class Base(DeclarativeBase): + @declared_attr.directive def __tablename__(cls) -> str: return resolve_table_name(cls.__name__) def dict(self): - """Returns a dict representation of a model.""" if hasattr(self, '__table__'): return {c.name: operator.attrgetter(c.name)(self) for c in self.__table__.columns} return {} class CollectorBase(DeclarativeBase): + @declared_attr.directive def __tablename__(cls) -> str: return resolve_table_name(cls.__name__) def dict(self): - """Returns a dict representation of a model.""" if hasattr(self, '__table__'): return {c.name: operator.attrgetter(c.name)(self) for c in self.__table__.columns} return {} @@ -90,21 +68,16 @@ async def get_collector_session(): await session.close() def resolve_table_name(name): - """Resolves table names to their mapped names.""" - names = re.split("(?=[A-Z])", name) # noqa - return "_".join([x.lower() for x in names if x]) - + names = re.split('(?=[A-Z])', name) + return '_'.join([x.lower() for x in names if x]) def get_class_by_tablename(table_fullname: str) -> Any: - """Return class reference mapped to table.""" def _find_class(name): for c in Base.registry._class_registry.values(): - if hasattr(c, "__table__"): + if hasattr(c, '__table__'): if c.__table__.fullname.lower() == name.lower(): return c - mapped_name = resolve_table_name(table_fullname) mapped_class = _find_class(mapped_name) - return mapped_class diff --git a/src/database/schema.py b/src/database/schema.py index 58734f9..4e4bc2f 100644 --- a/src/database/schema.py +++ b/src/database/schema.py @@ -1,22 +1,18 @@ from typing import List, Optional - from pydantic import Field from src.models import DefultBase - class CommonParams(DefultBase): - # This ensures no extra query params are allowed - current_user: Optional[str] = Field(None, alias="currentUser") + current_user: Optional[str] = Field(None, alias='currentUser') page: int = Field(1, gt=0, le=1000000) - items_per_page: int = Field(5, gt=0, le=50, multiple_of=5, alias="itemsPerPage") - query_str: Optional[str] = Field(None, alias="q") - filter_spec: Optional[str] = Field(None, alias="filter") - sort_by: List[str] = Field(default_factory=list, alias="sortBy[]") - descending: List[bool] = Field(default_factory=list, alias="descending[]") - exclude: List[str] = Field(default_factory=list, alias="exclude[]") - all_params: int = Field(0, alias="all", ge=0, le=1000000) + items_per_page: int = Field(5, gt=0, le=50, multiple_of=5, alias='itemsPerPage') + query_str: Optional[str] = Field(None, alias='q') + filter_spec: Optional[str] = Field(None, alias='filter') + sort_by: List[str] = Field(default_factory=list, alias='sortBy[]') + descending: List[bool] = Field(default_factory=list, alias='descending[]') + exclude: List[str] = Field(default_factory=list, alias='exclude[]') + all_params: int = Field(0, alias='all', ge=0, le=1000000) - # Property to mirror your original return dict's bool conversion @property def is_all(self) -> bool: - return bool(self.all_params) \ No newline at end of file + return bool(self.all_params) diff --git a/src/database/service.py b/src/database/service.py index efe7dcc..28d0b73 100644 --- a/src/database/service.py +++ b/src/database/service.py @@ -1,144 +1,58 @@ import logging from typing import Annotated, List, Type, TypeVar - from fastapi import Depends, Query from pydantic.types import Json, constr from sqlalchemy import Select, desc, func, or_ - from src.database.schema import CommonParams - from .core import DbSession - log = logging.getLogger(__name__) +QueryStr = constr(pattern='^[ -~]+$', min_length=1) -# allows only printable characters -QueryStr = constr(pattern=r"^[ -~]+$", min_length=1) - - -def common_parameters( - db_session: DbSession, # type: ignore - current_user: QueryStr = Query(None, alias="currentUser"), # type: ignore - 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"), # 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, ge=0, le=1000000), -): - 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), - } - - -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, 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)): + 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)} +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 - ): + + async def wrapper(db_session: DbSession, params: Annotated[model_type, Query()]): 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 - def search(*, query_str: str, query: Query, model, sort=False): - """Perform a search based on the query.""" search_model = model - if not query_str.strip(): return query - search = [] - if hasattr(search_model, "search_vector"): + if hasattr(search_model, 'search_vector'): vector = search_model.search_vector - search.append(vector.op("@@")(func.tsq_parse(query_str))) - - if hasattr(search_model, "name"): - search.append( - search_model.name.ilike(f"%{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}%')) search.append(search_model.name == query_str) - 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)) - if sort: query = query.order_by(desc(func.ts_rank_cd(vector, func.tsq_parse(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, -): - """Common functionality for searching, filtering, sorting, and pagination.""" - # Check if model is Select +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): if not isinstance(model, Select): query = Select(model) else: query = model - if query_str: sort = False if sort_by else True query = search(query_str=query_str, query=query, model=model, sort=sort) - - # Get total count count_query = Select(func.count()).select_from(query.subquery()) total = await db_session.scalar(count_query) - if all: result = await db_session.execute(query) 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) - result = await db_session.execute(query) 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} diff --git a/src/enums.py b/src/enums.py index acd5551..7648c0b 100644 --- a/src/enums.py +++ b/src/enums.py @@ -1,29 +1,12 @@ import sys - if sys.version_info >= (3, 11): from enum import StrEnum else: from strenum import StrEnum - class OptimumOHEnum(StrEnum): - """ - 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 - + pass class ResponseStatus(OptimumOHEnum): - SUCCESS = "success" - ERROR = "error" + SUCCESS = 'success' + ERROR = 'error' diff --git a/src/equipment_sparepart/__init__.py b/src/equipment_sparepart/__init__.py index e69de29..8b13789 100644 --- a/src/equipment_sparepart/__init__.py +++ b/src/equipment_sparepart/__init__.py @@ -0,0 +1 @@ + diff --git a/src/equipment_sparepart/model.py b/src/equipment_sparepart/model.py index 215b46b..f11561f 100644 --- a/src/equipment_sparepart/model.py +++ b/src/equipment_sparepart/model.py @@ -1,19 +1,12 @@ from sqlalchemy import UUID, Column, Float, ForeignKey, String from sqlalchemy.orm import relationship - from src.database.core import Base from src.models import 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) - 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) - - part = relationship( - "MasterSparePart", - lazy="selectin", - ) + part = relationship('MasterSparePart', lazy='selectin') diff --git a/src/equipment_sparepart/router.py b/src/equipment_sparepart/router.py index 9486f59..4b4fd3a 100644 --- a/src/equipment_sparepart/router.py +++ b/src/equipment_sparepart/router.py @@ -1,22 +1,12 @@ from typing import Dict, List - from fastapi import APIRouter, Depends - from src.database.core import CollectorDbSession from src.models import StandardResponse from src.auth.access_control import require_any_role, ALLOWED_ROLES - from .service import get_all - 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): - """Get all scope activity pagination.""" 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') diff --git a/src/equipment_sparepart/schema.py b/src/equipment_sparepart/schema.py index 50ff670..19bcae0 100644 --- a/src/equipment_sparepart/schema.py +++ b/src/equipment_sparepart/schema.py @@ -1,46 +1,21 @@ from typing import List, Optional - from pydantic import Field - from src.models import DefultBase, Pagination - class ScopeEquipmentActivityBase(DefultBase): - assetnum: str = Field(..., description="Assetnum is required") - + assetnum: str = Field(..., description='Assetnum is required') class ScopeEquipmentActivityCreate(ScopeEquipmentActivityBase): name: str cost: Optional[float] = Field(0, ge=0, le=1000000000000000) - class ScopeEquipmentActivityUpdate(ScopeEquipmentActivityBase): name: Optional[str] = Field(None) cost: Optional[str] = Field(0) - class ScopeEquipmentActivityRead(ScopeEquipmentActivityBase): name: str cost: float = Field(..., ge=0, le=1000000000000000) - class ScopeEquipmentActivityPagination(Pagination): items: List[ScopeEquipmentActivityRead] = [] - - -# "equipmentCount": 30 -# }, -# "criticalParts": [ -# "Boiler feed pump", -# "Boiler reheater system", -# "BCP A Discharge Valve", -# "BFPT A EXH Press HI Root VLV" -# ], -# "schedules": [ -# "status": "upcoming" -# // ... other scheduled overhauls -# ], -# "lastOverhaul": "2024-06-15" -# }, -# "lpt": { "status": "operational" } -# // ... other major components diff --git a/src/equipment_sparepart/service.py b/src/equipment_sparepart/service.py index b028f71..dca2e4b 100644 --- a/src/equipment_sparepart/service.py +++ b/src/equipment_sparepart/service.py @@ -1,174 +1,29 @@ from typing import Optional - from sqlalchemy import text - - - -# async def get(*, db_session: DbSession, scope_equipment_activity_id: str) -> Optional[ScopeEquipmentActivity]: -# """Returns a document based on the given document id.""" - from typing import Optional, List, Dict, Any from sqlalchemy.sql import text import logging - logger = logging.getLogger(__name__) - -# async def get_all( -# db_session: CollectorDbSession, -# ) -> 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: - -# if start_year < 1900 or (end_year and end_year < start_year): - -# if end_year is None: - -# # --- 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 -# """ - - -# if 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 --- - -# # Handle "no data found" -# if not rows: - -# # --- 4. Map results cleanly --- -# for row in rows: -# equipment_parts.append({ -# "inv_curbaltotal": float(row.inv_curbaltotal or 0) - - - from typing import List, Dict, Any, Optional from sqlalchemy.ext.asyncio import AsyncSession 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]]: - """ - 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 +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]]: if end_year is None: end_year = start_year + 1 - - # 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') - """ - + 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 " params = {} - - # Optional filter for location if location_tag: - query_str += " AND asset_location = :location_tag" - params["location_tag"] = location_tag - - 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; - """ - + query_str += ' AND asset_location = :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 " try: result = await db_session.execute(text(query_str), params) rows = result.fetchall() - equipment_parts = [] 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 - except Exception as e: - print(f"[get_all] Database query error: {e}") + print(f'[get_all] Database query error: {e}') raise diff --git a/src/equipment_workscope_group/__init__.py b/src/equipment_workscope_group/__init__.py index e69de29..8b13789 100644 --- a/src/equipment_workscope_group/__init__.py +++ b/src/equipment_workscope_group/__init__.py @@ -0,0 +1 @@ + diff --git a/src/equipment_workscope_group/model.py b/src/equipment_workscope_group/model.py index 6433d11..fb76e09 100644 --- a/src/equipment_workscope_group/model.py +++ b/src/equipment_workscope_group/model.py @@ -1,20 +1,11 @@ from sqlalchemy import UUID, Column, ForeignKey, String from sqlalchemy.orm import relationship - from src.database.core import Base from src.models import 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')) 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, # Add this if it's a one-to-one relationship - ) + 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) diff --git a/src/equipment_workscope_group/router.py b/src/equipment_workscope_group/router.py index 02dfbe3..6efa794 100644 --- a/src/equipment_workscope_group/router.py +++ b/src/equipment_workscope_group/router.py @@ -1,48 +1,22 @@ from typing import Optional - from fastapi import APIRouter, Query - -from src.database.service import (CommonParameters, DbSession) +from src.database.service import CommonParameters, DbSession from src.models import StandardResponse - from .schema import ScopeEquipmentJobCreate, ScopeEquipmentJobPagination from .service import create, delete, get_all - router = APIRouter() - -@router.get( - "/{location_tag}", response_model=StandardResponse[ScopeEquipmentJobPagination] -) -async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str] = Query(None)): - """Get all scope pagination.""" +@router.get('/{location_tag}', response_model=StandardResponse[ScopeEquipmentJobPagination]) +async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str]=Query(None)): results = await get_all(common=common, location_tag=location_tag, scope=scope) + return StandardResponse(data=results, message='Data retrieved successfully') - return StandardResponse( - 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.""" +@router.post('/{assetnum}', response_model=StandardResponse[None]) +async def create_scope_equipment_jobs(db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate): await create(db_session=db_session, assetnum=assetnum, scope_job_in=scope_job_in) + return StandardResponse(data=None, message='Data created successfully') - return StandardResponse( - data=None, - message="Data created successfully", - ) - - -@router.post("/delete/{scope_job_id}", response_model=StandardResponse[None]) +@router.post('/delete/{scope_job_id}', response_model=StandardResponse[None]) async def delete_scope_equipment_job(db_session: DbSession, 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') diff --git a/src/equipment_workscope_group/schema.py b/src/equipment_workscope_group/schema.py index 07d962d..01f4543 100644 --- a/src/equipment_workscope_group/schema.py +++ b/src/equipment_workscope_group/schema.py @@ -1,58 +1,32 @@ from typing import List, Optional from uuid import UUID - from pydantic import Field - from src.workscope_group.schema import ActivityMasterRead from src.models import DefultBase, Pagination from src.overhaul_scope.schema import ScopeRead - class ScopeEquipmentJobBase(DefultBase): pass class ScopeEquipmentJobCreate(ScopeEquipmentJobBase): job_ids: Optional[List[UUID]] = [] - class ScopeEquipmentJobUpdate(ScopeEquipmentJobBase): name: Optional[str] = Field(None) cost: Optional[str] = Field(0) - class OverhaulActivity(DefultBase): id: UUID overhaul_scope: ScopeRead - class OverhaulJob(DefultBase): id: UUID overhaul_activity: OverhaulActivity - class ScopeEquipmentJobRead(ScopeEquipmentJobBase): id: UUID workscope_group: Optional[ActivityMasterRead] location_tag: str - class ScopeEquipmentJobPagination(Pagination): items: List[ScopeEquipmentJobRead] = [] - - -# "equipmentCount": 30 -# }, -# "criticalParts": [ -# "Boiler feed pump", -# "Boiler reheater system", -# "BCP A Discharge Valve", -# "BFPT A EXH Press HI Root VLV" -# ], -# "schedules": [ -# "status": "upcoming" -# // ... other scheduled overhauls -# ], -# "lastOverhaul": "2024-06-15" -# }, -# "lpt": { "status": "operational" } -# // ... other major components diff --git a/src/equipment_workscope_group/service.py b/src/equipment_workscope_group/service.py index b08ca3d..3eb4eb8 100644 --- a/src/equipment_workscope_group/service.py +++ b/src/equipment_workscope_group/service.py @@ -1,129 +1,47 @@ from typing import Optional - from fastapi import HTTPException, status from sqlalchemy import Select - from src.database.core import DbSession from src.database.service import search_filter_sort_paginate from src.standard_scope.model import MasterEquipment from src.workscope_group.model import MasterActivity from src.workscope_group_maintenance_type.model import WorkscopeOHType from src.overhaul_scope.model import MaintenanceType - from .model import EquipmentWorkscopeGroup from .schema import ScopeEquipmentJobCreate 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.""" - - -# async def get_all(db_session: DbSession, assetnum: Optional[str], common): -# # Example usage -# if not assetnum: - -# # First get the parent equipment - -# if not equipment: - -# # Build query for parts -# .where(ScopeEquipmentJob.assetnum == assetnum) -# .options( -# .selectinload(OverhaulJob.overhaul_activity) -# .selectinload(OverhaulActivity.overhaul_scope), - - - -async def get_all(*, common, location_tag: str, scope: Optional[str] = None): - """Returns all documents.""" - query = ( - Select(EquipmentWorkscopeGroup) - .where(EquipmentWorkscopeGroup.location_tag == location_tag).filter(EquipmentWorkscopeGroup.workscope_group_id.is_not(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)) query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup) - if scope: - query = ( - query - .join(EquipmentWorkscopeGroup.workscope_group) - .join(MasterActivity.oh_types) - .join(WorkscopeOHType.oh_type) - .filter(MaintenanceType.name == scope) - ) - + query = 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, assetnum, scope_job_in: ScopeEquipmentJobCreate -): +async def create(*, db_session: DbSession, assetnum, scope_job_in: ScopeEquipmentJobCreate): scope_jobs = [] - if not assetnum: - raise ValueError("assetnum parameter is required") - - # First get the parent equipment + raise ValueError('assetnum parameter is required') 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}") - + raise ValueError(f'No equipment found with assetnum: {assetnum}') for job_id in scope_job_in.job_ids: scope_equipment_job = ScopeEquipmentJob(assetnum=assetnum, job_id=job_id) scope_jobs.append(scope_equipment_job) - db_session.add_all(scope_jobs) await db_session.commit() return - -# async def update(*, db_session: DbSession, activity: ScopeEquipmentActivity, scope_equipment_activty_in: ScopeEquipmentActivityUpdate): -# """Updates a document.""" - - -# for field in data: -# if field in update_data: - - - - -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 - """ +async def delete(*, db_session: DbSession, scope_job_id: int) -> bool: try: - # Check if job exists scope_job = await db_session.get(ScopeEquipmentJob, scope_job_id) if not scope_job: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="A data with this id does not exist.", - ) - - # Perform deletion + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') await db_session.delete(scope_job) await db_session.commit() - return True - except Exception: await db_session.rollback() raise diff --git a/src/exceptions.py b/src/exceptions.py index 8ac9e5a..45ee53d 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -1,9 +1,7 @@ -# Define base error model and exception hierarchy import logging import re import uuid from typing import Any, Dict, List, Optional - from asyncpg.exceptions import DataError as AsyncPGDataError from fastapi import Request from fastapi.exceptions import RequestValidationError @@ -12,82 +10,43 @@ from pydantic import BaseModel from slowapi.errors import RateLimitExceeded from sqlalchemy.exc import DataError, DBAPIError, IntegrityError, SQLAlchemyError, NoResultFound from starlette.exceptions import HTTPException as StarletteHTTPException - from src.enums import ResponseStatus - log = logging.getLogger(__name__) - -# ── 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.data = data super().__init__(message) +class BadRequestError(OptimumOHBaseException): + pass -class BadRequestError(OptimumOHBaseException): 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 UnauthorizedError(OptimumOHBaseException): + pass +class ForbiddenError(OptimumOHBaseException): + pass -# ── Fixed client response messages (generic, not for logging) ───────────────── +class NotFoundError(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", -} +class RequestTimeoutError(OptimumOHBaseException): + pass -# Human-readable error types for logging (no underscores) -_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 UnprocessableEntityError(OptimumOHBaseException): + pass -_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 TooManyRequestsError(OptimumOHBaseException): + pass +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): field: Optional[str] = None @@ -95,283 +54,193 @@ class ErrorDetail(BaseModel): code: Optional[str] = None params: Optional[Dict[str, Any]] = None - class ErrorResponse(BaseModel): data: Optional[Any] = None message: str status: ResponseStatus = ResponseStatus.ERROR errors: Optional[List[ErrorDetail]] = None - -# ── Helpers ─────────────────────────────────────────────────────────────────── - def get_request_context(request: Request) -> dict: - """Get detailed request context for logging.""" def get_client_ip() -> str: - if "X-Real-IP" in request.headers: - return request.headers["X-Real-IP"] - if "X-Forwarded-For" in request.headers: - return request.headers["X-Forwarded-For"].split(",")[0].strip() - 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(), - } - + if 'X-Real-IP' in request.headers: + return request.headers['X-Real-IP'] + if 'X-Forwarded-For' in request.headers: + return request.headers['X-Forwarded-For'].split(',')[0].strip() + 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()} -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}, - ) +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}) - -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.""" +def _store_log_state(request: Request, status_code: int, error_id: str, error_type: str, error_message: str) -> None: request.state.log_status_code = status_code request.state.log_error_id = error_id request.state.log_error_type = error_type request.state.log_error_message = error_message - 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() - - if "uuid" in s: - if "length must be between" in s or "got " in s: - return "Invalid UUID length" - if "badly formed" in s or "invalid hexadecimal" in s or "invalid uuid" in s: - return "Invalid UUID format" - - if "invalid input syntax for type" in s: - m = re.search(r"invalid input syntax for type (\w+)", s) - type_name = m.group(1) if m else "value" - return f"Invalid format for type {type_name}" - - if "invalid input for query argument" in s: - m = re.search(r"\(([^)]+)\)\s*$", orig_str) + if 'uuid' in s: + if 'length must be between' in s or 'got ' in s: + return 'Invalid UUID length' + if 'badly formed' in s or 'invalid hexadecimal' in s or 'invalid uuid' in s: + return 'Invalid UUID format' + if 'invalid input syntax for type' in s: + m = re.search('invalid input syntax for type (\\w+)', s) + type_name = m.group(1) if m else 'value' + return f'Invalid format for type {type_name}' + if 'invalid input for query argument' in s: + m = re.search('\\(([^)]+)\\)\\s*$', orig_str) if m: return _short_db_error(m.group(1)) - return "Invalid query argument value" - - if "unique constraint" in s: - return "Unique constraint violation" - if "foreign key constraint" in s: - return "Foreign key constraint violation" - if "null value in column" in s: - m = re.search(r'null value in column "([^"]+)"', orig_str) - col = f' "{m.group(1)}"' if m else "" - return f"Required field{col} is missing" - if "check constraint" in s: - return "Check constraint violation" - if "out of range" in s: - return "Value out of allowed range" - if "numeric field overflow" in s: - return "Numeric field overflow" - - first_line = orig_str.split("\n")[0].split(". ")[0].strip() - first_line = re.sub(r"^:\s*", "", first_line) - return first_line if first_line else "Database error" - - -# ── Core error response builder ─────────────────────────────────────────────── + return 'Invalid query argument value' + if 'unique constraint' in s: + return 'Unique constraint violation' + if 'foreign key constraint' in s: + return 'Foreign key constraint violation' + if 'null value in column' in s: + m = re.search('null value in column "([^"]+)"', orig_str) + col = f' "{m.group(1)}"' if m else '' + return f'Required field{col} is missing' + if 'check constraint' in s: + return 'Check constraint violation' + if 'out of range' in s: + return 'Value out of allowed range' + if 'numeric field overflow' in s: + return 'Numeric field overflow' + first_line = orig_str.split('\n')[0].split('. ')[0].strip() + first_line = re.sub("^:\\s*", '', first_line) + return first_line if first_line else 'Database error' 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(): if isinstance(exc, exc_class): error_type = _ERROR_TYPE_MAP[exc_class] client_msg = _FIXED_MESSAGES.get(status_code, 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}") - - # 2. SlowAPI RateLimitExceeded + return _json_response(status_code, f'{client_msg}. Error ID: {error_id}') if isinstance(exc, RateLimitExceeded): try: from src.auth.service import notify_admin_on_rate_limit_sync 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: pass - _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}") - - # 3. Pydantic / FastAPI RequestValidationError + _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}') if isinstance(exc, RequestValidationError): errors = exc.errors() detail_parts = [] for err in errors: - loc = " -> ".join(str(part) for part in err.get("loc", [])) - msg = err.get("msg", "") - detail_parts.append(f"{loc}: {msg}" if loc else msg) - detail_str = "; ".join(detail_parts) if detail_parts else "Input validation failed" - _store_log_state(request, 422, error_id, "Unprocessable Entity", detail_str) - return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}") - - # 4. Starlette/FastAPI HTTPException + loc = ' -> '.join((str(part) for part in err.get('loc', []))) + msg = err.get('msg', '') + detail_parts.append(f'{loc}: {msg}' if loc else msg) + detail_str = '; '.join(detail_parts) if detail_parts else 'Input validation failed' + _store_log_state(request, 422, error_id, 'Unprocessable Entity', detail_str) + return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') if isinstance(exc, StarletteHTTPException): 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: - log_msg = str(exc.detail) if exc.detail else "Invalid authorization token" - _store_log_state(request, 401, error_id, "Unauthorized", log_msg) - return _json_response(401, f"{_FIXED_MESSAGES[401]}. Error ID: {error_id}") - + log_msg = str(exc.detail) if exc.detail else 'Invalid authorization token' + _store_log_state(request, 401, error_id, 'Unauthorized', log_msg) + return _json_response(401, f'{_FIXED_MESSAGES[401]}. Error ID: {error_id}') if status_code == 422: log_msg = str(exc.detail) if exc.detail else _FIXED_MESSAGES[422] - _store_log_state(request, 422, error_id, "Unprocessable Entity", log_msg) - return _json_response(422, f"{_FIXED_MESSAGES[422]}. Error ID: {error_id}") - + _store_log_state(request, 422, error_id, 'Unprocessable Entity', log_msg) + return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') if isinstance(exc.detail, dict): - log_msg = exc.detail.get("error", error_type) + log_msg = exc.detail.get('error', error_type) else: log_msg = str(exc.detail) if exc.detail else error_type - client_msg = _FIXED_MESSAGES.get(status_code, 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}") - - # 5. SQLAlchemy errors + return _json_response(status_code, f'{client_msg}. Error ID: {error_id}') if isinstance(exc, SQLAlchemyError): - orig = getattr(exc, "orig", None) + orig = getattr(exc, 'orig', None) orig_str = str(orig) if orig else str(exc) short = _short_db_error(orig_str) - - # NoResultFound: .one() / .scalar_one() found no row → 404 if isinstance(exc, NoResultFound): - _store_log_state(request, 404, error_id, "Not Found", "Resource not found") - return _json_response(404, f"Resource not found. Error ID: {error_id}") - + _store_log_state(request, 404, error_id, 'Not Found', 'Resource not found') + return _json_response(404, f'Resource not found. Error ID: {error_id}') if isinstance(exc, IntegrityError): orig_lower = orig_str.lower() - if "unique constraint" in orig_lower: - _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}") - elif "foreign key constraint" in orig_lower: - _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}") + if 'unique constraint' in orig_lower: + _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}') + elif 'foreign key constraint' in orig_lower: + _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}') else: - _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}") - + _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): - _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}") - + _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}') if isinstance(exc, DBAPIError): 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: - _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}") - elif "unique constraint" in orig_lower: - _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}") - elif "foreign key constraint" in orig_lower: - _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}") + 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}') + return _json_response(422, f'{_FIXED_MESSAGES[422]}. Error ID: {error_id}') + elif 'unique constraint' in orig_lower: + _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}') + elif 'foreign key constraint' in orig_lower: + _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}') else: - _store_log_state(request, 500, error_id, "Internal Server Error", f"Database query error: {short}") - 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) + _store_log_state(request, 500, error_id, 'Internal Server Error', f'Database query error: {short}') + 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}') try: import httpx as _httpx if isinstance(exc, _httpx.HTTPStatusError): upstream_status = exc.response.status_code - error_type = _HTTP_STATUS_NAMES.get(upstream_status, f"HTTP {upstream_status}") - client_msg = _FIXED_MESSAGES.get(upstream_status, "Upstream service error") - log_msg = f"Upstream service returned {upstream_status}: {exc.request.url}" + error_type = _HTTP_STATUS_NAMES.get(upstream_status, f'HTTP {upstream_status}') + client_msg = _FIXED_MESSAGES.get(upstream_status, 'Upstream service error') + log_msg = f'Upstream service returned {upstream_status}: {exc.request.url}' _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)): - log_msg = f"Upstream service unreachable: {type(exc).__name__}: {exc}" - _store_log_state(request, 502, error_id, "Bad Gateway", log_msg) - return _json_response(502, f"Upstream service unavailable. Error ID: {error_id}") + log_msg = f'Upstream service unreachable: {type(exc).__name__}: {exc}' + _store_log_state(request, 502, error_id, 'Bad Gateway', log_msg) + return _json_response(502, f'Upstream service unavailable. Error ID: {error_id}') except ImportError: pass - - # 6. Catch-all detail = type(exc).__name__ - _store_log_state(request, 500, error_id, "Internal Server Error", detail) - return _json_response(500, f"An unexpected error occurred. Error ID: {error_id}") - - -# ── Single exception handler registered with FastAPI ───────────────────────── + _store_log_state(request, 500, error_id, 'Internal Server Error', detail) + return _json_response(500, f'An unexpected error occurred. Error ID: {error_id}') 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()) - - if not hasattr(request.state, "request_id"): + if not hasattr(request.state, 'request_id'): request.state.request_id = error_id - return _build_error_response(request, exc, error_id) - -# ── Legacy helper kept for backward compatibility ───────────────────────────── - def handle_sqlalchemy_error(error: SQLAlchemyError): - """Return (user_message, status_code) for a SQLAlchemy error.""" - original_error = error.orig if hasattr(error, "orig") else None - + original_error = error.orig if hasattr(error, 'orig') else None if isinstance(error, IntegrityError): - if "unique constraint" in str(error).lower(): - return "This record already exists.", 409 - elif "foreign key constraint" in str(error).lower(): - return "Related record not found.", 400 + if 'unique constraint' in str(error).lower(): + return ('This record already exists.', 409) + elif 'foreign key constraint' in str(error).lower(): + return ('Related record not found.', 400) else: - return "Data integrity error.", 400 + return ('Data integrity error.', 400) elif isinstance(error, DataError) or isinstance(original_error, AsyncPGDataError): - return "Invalid data provided.", 400 + return ('Invalid data provided.', 400) elif isinstance(error, DBAPIError): - if "unique constraint" in str(error).lower(): - return "This record already exists.", 409 - elif "foreign key constraint" in str(error).lower(): - return "Related record not found.", 400 - elif "null value in column" in str(error).lower(): - return "Required data missing.", 400 - elif "invalid input for query argument" in str(error).lower(): - return "Invalid data provided.", 400 + if 'unique constraint' in str(error).lower(): + return ('This record already exists.', 409) + elif 'foreign key constraint' in str(error).lower(): + return ('Related record not found.', 400) + elif 'null value in column' in str(error).lower(): + return ('Required data missing.', 400) + elif 'invalid input for query argument' in str(error).lower(): + return ('Invalid data provided.', 400) else: - return "Database error.", 500 + return ('Database error.', 500) else: - log.error(f"Unexpected database error: {str(error)}") - return "An unexpected database error occurred.", 500 - + log.error(f'Unexpected database error: {str(error)}') + return ('An unexpected database error occurred.', 500) diff --git a/src/main.py b/src/main.py index d1aaa07..4c490b2 100644 --- a/src/main.py +++ b/src/main.py @@ -3,19 +3,16 @@ import asyncio import time from src.context import set_request_id, reset_request_id, get_request_id from uuid import uuid1 - from fastapi import Depends, FastAPI, HTTPException from src.auth.service import JWTBearer from fastapi.responses import JSONResponse from slowapi.errors import RateLimitExceeded from sqlalchemy.ext.asyncio import async_scoped_session -from starlette.middleware.base import (BaseHTTPMiddleware) +from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.gzip import GZipMiddleware from starlette.requests import Request from starlette.responses import Response - import uuid as _uuid_module - from src.api import api_router from src.database.core import async_session, async_collector_session from src.enums import ResponseStatus @@ -25,268 +22,132 @@ from src.middleware import RequestValidationMiddleware, RequestTimeoutMiddleware from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException as StarletteHTTPException from src.csrf_protect import csrf_protect - log = logging.getLogger(__name__) - -# we configure the logging level and format configure_logging() - 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: error_id = str(_uuid_module.uuid4()) request.state.log_status_code = 429 request.state.log_error_id = error_id - request.state.log_error_type = "Too Many Requests" - 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}, - ) - -# we define the exception handlers + request.state.log_error_type = 'Too Many Requests' + 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}) app.add_exception_handler(Exception, handle_exception) app.add_exception_handler(HTTPException, handle_exception) app.add_exception_handler(StarletteHTTPException, handle_exception) app.add_exception_handler(RequestValidationError, handle_exception) app.add_exception_handler(RateLimitExceeded, _sync_rate_limit_handler) - app.add_middleware(GZipMiddleware, minimum_size=1000) app.add_middleware(RequestTimeoutMiddleware, timeout=REQUEST_TIMEOUT_SECONDS) -# credentials: "include", - - -@app.route('/slow', methods=["GET"]) +@app.route('/slow', methods=['GET']) async def slow_endpoint(request: Request): 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)]) 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): 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())) - # 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): response: Response = await call_next(request) - if is_production: - response.headers["Strict-Transport-Security"] = "max-age=15724800; includeSubDomains; preload" - response.headers["X-Frame-Options"] = "DENY" - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["X-XSS-Protection"] = "1; mode=block" - response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - response.headers["Content-Security-Policy"] = csp_header_value - response.headers["Permissions-Policy"] = feature_header_value + response.headers['Strict-Transport-Security'] = 'max-age=15724800; includeSubDomains; preload' + response.headers['X-Frame-Options'] = 'DENY' + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['X-XSS-Protection'] = '1; mode=block' + response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' + response.headers['Content-Security-Policy'] = csp_header_value + response.headers['Permissions-Policy'] = feature_header_value else: - # 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 - + response.headers['Content-Security-Policy'] = "default-src 'self' 'unsafe-inline' 'unsafe-eval' *" return response - app.add_middleware(SecurityHeadersMiddleware) - security_headers_middleware(app) - - app.add_middleware(RequestValidationMiddleware) -# app.add_middleware( -# CSRFProtectMiddleware, - - - -@app.middleware("http") +@app.middleware('http') async def db_session_middleware(request: Request, call_next): 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) request.state.request_id = request_id - try: session = async_scoped_session(async_session, scopefunc=get_request_id) request.state.db = session() - collector_session = async_scoped_session(async_collector_session, scopefunc=get_request_id) request.state.collector_db = collector_session() - start_time = time.time() try: response = await call_next(request) 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) duration_ms = round((time.time() - start_time) * 1000, 2) - - # ── Determine status_code ───────────────────────────────────────────── - # RequestTimeoutMiddleware flags the scope when a 408 bypasses exception handlers - timed_out = request.scope.get("x_timed_out", False) + timed_out = request.scope.get('x_timed_out', False) if timed_out: status_code = 408 - error_id = request.scope.get("x_timeout_error_id", None) - error_type = "Request Timeout" - error_message = "Request timeout over 20 seconds" + error_id = request.scope.get('x_timeout_error_id', None) + error_type = 'Request Timeout' + error_message = 'Request timeout over 20 seconds' else: - status_code = getattr(request.state, "log_status_code", None) or response.status_code - error_id = getattr(request.state, "log_error_id", None) - error_type = getattr(request.state, "log_error_type", None) - error_message = getattr(request.state, "log_error_message", None) - - # ── Fallback error labels for responses that skip exception handler ─── - _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", - } - 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 ───────────────────────────────────────────────────────── + status_code = getattr(request.state, 'log_status_code', None) or response.status_code + error_id = getattr(request.state, 'log_error_id', None) + error_type = getattr(request.state, 'log_error_type', 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'} + 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') from src.context import get_user_id, set_user_id user_id = get_user_id() - user_obj = getattr(request.state, "user", None) - if user_obj and not user_id: + user_obj = getattr(request.state, 'user', None) + if user_obj and (not user_id): if isinstance(user_obj, dict): - u_id = user_obj.get("user_id") + u_id = user_obj.get('user_id') else: - u_id = getattr(user_obj, "user_id", None) + u_id = getattr(user_obj, 'user_id', None) if u_id: user_id = str(u_id) set_user_id(user_id) - - # ── Client IP ───────────────────────────────────────────────────────── headers = request.headers - if "X-Real-IP" in headers: - user_ip = headers["X-Real-IP"] - elif "X-Forwarded-For" in headers: - user_ip = headers["X-Forwarded-For"].split(",")[0].strip() + if 'X-Real-IP' in headers: + user_ip = headers['X-Real-IP'] + elif 'X-Forwarded-For' in headers: + user_ip = headers['X-Forwarded-For'].split(',')[0].strip() else: user_ip = request.client.host if request.client else None - - # ── Full path with query string ─────────────────────────────────────── path = request.url.path if request.url.query: - path = f"{path}?{request.url.query}" - - # ── Emit single structured log ──────────────────────────────────────── - result = "Request completed" if status_code < 400 else "Request failed" + path = f'{path}?{request.url.query}' + result = 'Request completed' if status_code < 400 else 'Request failed' if status_code >= 500: log_level = logging.ERROR elif status_code >= 400: log_level = logging.WARNING else: 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: await request.state.db.close() await request.state.collector_db.close() - reset_request_id(ctx_token) return response - -@app.middleware("http") +@app.middleware('http') async def add_security_headers(request: Request, call_next): 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 - - -# class MetricsMiddleware(BaseHTTPMiddleware): -# async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: - -# raise e from None - - - app.include_router(api_router) diff --git a/src/maximo/__init__.py b/src/maximo/__init__.py index e69de29..8b13789 100644 --- a/src/maximo/__init__.py +++ b/src/maximo/__init__.py @@ -0,0 +1 @@ + diff --git a/src/maximo/model.py b/src/maximo/model.py index 87c158c..960edd0 100644 --- a/src/maximo/model.py +++ b/src/maximo/model.py @@ -1,10 +1,8 @@ from sqlalchemy import Column, BigInteger, Integer, Float, String, Text, DateTime from src.database.core import CollectorBase - class WorkOrderData(CollectorBase): - __tablename__ = "wo_staging_3" - + __tablename__ = 'wo_staging_3' id = Column(BigInteger, primary_key=True, autoincrement=True) assetnum = Column(Text, nullable=True) description1 = Column(Text, nullable=True) diff --git a/src/maximo/service.py b/src/maximo/service.py index fe35fdf..b802131 100644 --- a/src/maximo/service.py +++ b/src/maximo/service.py @@ -4,313 +4,34 @@ from sqlalchemy import select, text from src.database.core import CollectorDbSession, DbSession from src.overhaul_scope.model import OverhaulScope -async def get_cm_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime): - 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; - """) +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 ") results = await collector_db.execute(query) data = [] - for row in results: - data.append({ - "location_tag": row.asset_location, - "avg_cost": row.avg_cost - }) - + data.append({'location_tag': row.asset_location, 'avg_cost': row.avg_cost}) + return {item['location_tag']: item['avg_cost'] for item in data} - 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 * -# ) 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; -# """) - - - -# for row in result: -# data.append({ - - - -async def get_oh_cost_summary(collector_db: CollectorDbSession, last_oh_date:datetime, upcoming_oh_date:datetime): -# 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 -# 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; - """) - +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 ") result = await collector_db.execute(query) data = [] - for row in result: - data.append({ - "location_tag": row.asset_location, - "avg_cost": row.avg_cost - }) - - return { - item["location_tag"]: item["avg_cost"] for item in data - } - - + data.append({'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 -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 +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): if not parent_wo_num: query = select(OverhaulScope.wo_parent).where(OverhaulScope.id == oh_session_id) result = await db_session.execute(query) parent_wo_num = result.scalar() - if not parent_wo_num: return [] - - # Ensure parent_wo_num is a list and removed duplicates if any if isinstance(parent_wo_num, str): parent_wo_num = [parent_wo_num] else: parent_wo_num = list(set(parent_wo_num)) - - sql_query = text(""" - 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 - ] - - - - \ No newline at end of file + 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}) + 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] diff --git a/src/metrics.py b/src/metrics.py index 19c0194..8b13789 100644 --- a/src/metrics.py +++ b/src/metrics.py @@ -1,25 +1 @@ - - - - -# class Metrics(object): - -# def __init__(self): -# if not METRIC_PROVIDERS: -# log.info( -# "No metric providers defined via METRIC_PROVIDERS env var. Metrics will not be sent." - -# def gauge(self, name, value, tags=None): -# for provider in self._providers: -# log.debug( - -# def counter(self, name, value=None, tags=None): -# for provider in self._providers: -# log.debug( - -# def timer(self, name, value, tags=None): -# for provider in self._providers: -# log.debug( - - diff --git a/src/middleware.py b/src/middleware.py index e4c6fb7..dc900cb 100644 --- a/src/middleware.py +++ b/src/middleware.py @@ -4,261 +4,55 @@ import logging from collections import Counter from fastapi import Request, HTTPException from starlette.middleware.base import BaseHTTPMiddleware - -# ========================= -# 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" -} - +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_LENGTH = 2000 -MAX_JSON_BODY_SIZE = 1024 * 500 # 500 KB - -XSS_PATTERN = re.compile( - r"(" - r"<(script|iframe|embed|object|svg|img|video|audio|base|link|meta|form|button|details|animate)\b|" - r"javascript\s*:|vbscript\s*:|data\s*:[^,]*base64[^,]*|data\s*:text/html|" - r"\bon[a-z]+\s*=|" # Catch-all for any 'on' event (onerror, onclick, etc.) - 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"(? 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): - if not isinstance(value, str) or value == "*/*": + if not isinstance(value, str) or value == '*/*': return - 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): - 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): - 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): - 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): - 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): for key, value in obj.items(): 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: - raise HTTPException( - status_code=422, - 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) + raise HTTPException(status_code=422, detail=f'Unknown JSON key detected: {path}.{key}') + 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): 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): inspect_value(obj, path) - -# ========================= -# Middleware -# ========================= - class RequestValidationMiddleware(BaseHTTPMiddleware): + 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: return await self._validate_and_forward(request, call_next) except HTTPException as exc: @@ -266,202 +60,86 @@ class RequestValidationMiddleware(BaseHTTPMiddleware): return await handle_exception(request, exc) async def _validate_and_forward(self, request: Request, call_next): - # # ------------------------- - # # 0. Header validation - # # ------------------------- - - # # Check for duplicate headers - - # if real_duplicates: - # raise HTTPException( - - # # Whitelist headers - # if unknown_headers: - # if filtered_unknown: - # raise HTTPException( - - # # Inspect header values - # for key, value in request.headers.items(): - # if value: - - # ------------------------- - # 1. Query string limits - # ------------------------- 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() - if len(params) > MAX_QUERY_PARAMS: - raise HTTPException( - status_code=422, - detail="Too many query parameters", - ) - - # Check for unknown query parameters + raise HTTPException(status_code=422, detail='Too many query parameters') unknown_params = [key for key, _ in params if key not in ALLOWED_DATA_PARAMS] if unknown_params: - raise HTTPException( - status_code=422, - 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 - ] - + raise HTTPException(status_code=422, detail=f'Unknown query parameters detected: {unknown_params}') + 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: - raise HTTPException( - 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"} + raise HTTPException(status_code=422, detail=f'Duplicate query parameters: {duplicates}') + pagination_size_keys = {'size', 'itemsPerPage', 'per_page', 'limit', 'items_per_page'} for key, value in params: if value: inspect_value(value, f"query param '{key}'") - if key in pagination_size_keys and value: try: size_val = int(value) 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: - 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: - raise HTTPException( - status_code=422, - detail=f"Pagination size invalid value for '{key}'", - ) - - # ------------------------- - # 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) - # ------------------------- + raise HTTPException(status_code=422, detail=f"Pagination size invalid value for '{key}'") + 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}') has_query = len(params) > 0 has_body = False - - 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_type.startswith('application/json'): + content_length = request.headers.get('content-length') if content_length and int(content_length) > 0: has_body = True - if has_query and has_body: - raise HTTPException( - 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"): + raise HTTPException(status_code=422, detail='Mixed parameters: both query string and JSON body are not allowed') + if content_type.startswith('application/json'): body = await request.body() - # if len(body) > MAX_JSON_BODY_SIZE: - if body: try: payload = json.loads(body) except json.JSONDecodeError: - # 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") - + raise HTTPException(status_code=400, detail='Invalid JSON body: malformed JSON') inspect_json(payload) - # Re-inject body for downstream handlers async def receive(): - return {"type": "http.request", "body": body} + return {'type': 'http.request', 'body': body} request._receive = receive - return await call_next(request) - import asyncio import json import logging - REQUEST_TIMEOUT_SECONDS = 20 - -log = logging.getLogger("security_logger") - +log = logging.getLogger('security_logger') 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.timeout = timeout async def __call__(self, scope, receive, send): - if scope["type"] != "http": + if scope['type'] != 'http': await self.app(scope, receive, send) return - response_started = False async def send_wrapper(message): nonlocal response_started - if message["type"] == "http.response.start": + if message['type'] == 'http.response.start': response_started = True await send(message) - 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: if not response_started: import uuid as _uuid error_id = str(_uuid.uuid4()) - # Flag the scope so db_session_middleware can log the 408 with correct fields - scope["x_timed_out"] = True - scope["x_timeout_error_id"] = error_id - body = json.dumps({ - "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, - }) \ No newline at end of file + scope['x_timed_out'] = True + scope['x_timeout_error_id'] = error_id + body = json.dumps({'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}) diff --git a/src/models.py b/src/models.py index 3b0febf..ac17b56 100644 --- a/src/models.py +++ b/src/models.py @@ -1,29 +1,17 @@ -# src/common/models.py import uuid from datetime import datetime from typing import Generic, Optional, TypeVar - import pytz from pydantic import BaseModel, SecretStr from sqlalchemy import Column, DateTime, String, event from sqlalchemy.dialects.postgresql import UUID - from src.config import TIMEZONE from src.enums import ResponseStatus -# SQLAlchemy Mixins - - class TimeStampMixin(object): - """Timestamping mixin""" - - created_at = Column( - DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE)) - ) + created_at = Column(DateTime(timezone=True), default=datetime.now(pytz.timezone(TIMEZONE))) 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 @staticmethod @@ -32,57 +20,32 @@ class TimeStampMixin(object): @classmethod def __declare_last__(cls): - event.listen(cls, "before_update", cls._updated_at) - + event.listen(cls, 'before_update', cls._updated_at) class UUIDMixin: - """UUID mixin""" - - id = Column( - UUID(as_uuid=True), - primary_key=True, - default=uuid.uuid4, - unique=True, - nullable=False, - ) - + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, unique=True, nullable=False) 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._creation_order = 9999 - class DefaultMixin(SoftDeleteMixin, TimeStampMixin, UUIDMixin): - """Default mixin""" - pass - class IdentityMixin: - """Identity mixin""" - created_by = Column(String(100), nullable=True) updated_by = Column(String(100), nullable=True) - -# Pydantic Models class DefultBase(BaseModel): + class Config: from_attributes = True validate_assignment = True arbitrary_types_allowed = True str_strip_whitespace = True populate_by_name = True - extra="forbid" - - 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, - } - + 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} class Pagination(DefultBase): itemsPerPage: int @@ -90,16 +53,11 @@ class Pagination(DefultBase): page: int total: int - class PrimaryKeyModel(BaseModel): id: uuid.UUID - - -# Define data type variable for generic response -T = TypeVar("T") - +T = TypeVar('T') class StandardResponse(BaseModel, Generic[T]): data: Optional[T] = None - message: str = "Success" + message: str = 'Success' status: ResponseStatus = ResponseStatus.SUCCESS diff --git a/src/overhaul/__init__.py b/src/overhaul/__init__.py index e69de29..8b13789 100644 --- a/src/overhaul/__init__.py +++ b/src/overhaul/__init__.py @@ -0,0 +1 @@ + diff --git a/src/overhaul/router.py b/src/overhaul/router.py index a184cbc..f6759f4 100644 --- a/src/overhaul/router.py +++ b/src/overhaul/router.py @@ -1,72 +1,35 @@ from typing import List - from fastapi import APIRouter - from src.auth.service import Token from src.database.core import CollectorDbSession, DbSession 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 .schema import (OverhaulCriticalParts, OverhaulRead, - OverhaulSystemComponents) +from .schema import OverhaulCriticalParts, OverhaulRead, OverhaulSystemComponents from src.auth.access_control import required_permission, OHPermission from src.overhaul_scope.model import OverhaulScope from fastapi import Depends - router = APIRouter() - -@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): - """Get all scope pagination.""" +@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): overview = await get_overhaul_overview(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() + return StandardResponse(data=OverhaulRead(overview=overview, schedules=schedules, criticalParts=criticalParts, systemComponents=systemComponents), message='Data retrieved successfully') - 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))]) +@router.get('/schedules', response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) async def get_schedules(): - """Get all 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(): - """Get all 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(): - """Get all 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') diff --git a/src/overhaul/schema.py b/src/overhaul/schema.py index 0854691..eebcb71 100644 --- a/src/overhaul/schema.py +++ b/src/overhaul/schema.py @@ -1,48 +1,21 @@ from typing import Any, Dict, List - from pydantic import BaseModel, Field - from src.overhaul_scope.schema import ScopeRead - class OverhaulBase(BaseModel): pass - class OverhaulCriticalParts(OverhaulBase): - criticalParts: List[str] = Field(..., description="List of critical parts") - + criticalParts: List[str] = Field(..., description='List of critical parts') 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): - systemComponents: Dict[str, Any] = Field( - ..., description="List of system components" - ) - + systemComponents: Dict[str, Any] = Field(..., description='List of system components') class OverhaulRead(OverhaulBase): overview: Dict[str, Any] criticalParts: dict schedules: List[ScopeRead] systemComponents: Dict[str, Any] - - -# "equipmentCount": 30 -# }, -# "criticalParts": [ -# "Boiler feed pump", -# "Boiler reheater system", -# "BCP A Discharge Valve", -# "BFPT A EXH Press HI Root VLV" -# ], -# "schedules": [ -# "status": "upcoming" -# // ... other scheduled overhauls -# ], -# "lastOverhaul": "2024-06-15" -# }, -# "lpt": { "status": "operational" } -# // ... other major components diff --git a/src/overhaul/service.py b/src/overhaul/service.py index c51b3cc..7e3262d 100644 --- a/src/overhaul/service.py +++ b/src/overhaul/service.py @@ -1,8 +1,6 @@ import asyncio - import httpx from sqlalchemy import Select - from src.calculation_target_reliability.service import RBD_SERVICE_API from src.config import TC_RBD_ID from src.database.core import DbSession @@ -11,318 +9,45 @@ from src.overhaul_activity.service import get_standard_scope_by_session_id from src.overhaul_scope.model import OverhaulScope from src.overhaul_scope.service import get_overview_overhaul - async def get_overhaul_overview(db_session: DbSession): - """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): - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - } - - calc_result_url = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}?nodetype=RegularNode" - calc_plant_result = f"{RBD_SERVICE_API}/aeros/simulation/result/calc/{simulation_id}/plant" - + headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} + calc_result_url = f'{RBD_SERVICE_API}/aeros/simulation/result/calc/{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: calc_task = client.get(calc_result_url, 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.raise_for_status() plant_response.raise_for_status() - - calc_data = calc_response.json()["data"] - plant_data = plant_response.json()["data"] - - return { - "calc_result": calc_data, - "plant_result": plant_data - } + calc_data = calc_response.json()['data'] + plant_data = plant_response.json()['data'] + return {'calc_result': calc_data, 'plant_result': plant_data} async def get_overhaul_critical_parts(db_session, session_id, token, collector_db_session): - """Get all overhaul critical parts.""" - equipments = await get_standard_scope_by_session_id( - db_session=db_session, - overhaul_session_id=session_id, - collector_db=collector_db_session - ) - - - 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 - } - + equipments = await get_standard_scope_by_session_id(db_session=db_session, overhaul_session_id=session_id, collector_db=collector_db_session) + 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']} + 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] + filtered_result = [item for item in base_result if item['matrix'] is not None] + 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} async def get_overhaul_schedules(*, db_session: DbSession): - """Get all overhaul schedules.""" query = Select(OverhaulScope) - results = await db_session.execute(query) - return results.scalars().all() - def get_overhaul_system_components(): - """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() } - - + 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()} percentages = calculate_contribution(availabilities) - for schema, contribution in percentages.items(): - powerplant_reliability[schema]["critical_contribution"] = contribution['criticality_importance'] - - # 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 - )) - + powerplant_reliability[schema]['critical_contribution'] = contribution['criticality_importance'] + sorted_powerplant_reliability = dict(sorted(powerplant_reliability.items(), key=lambda x: x[1]['critical_contribution'], reverse=True)) 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.""" - - -# async def get_all(*, db_session: DbSession): -# """Returns all documents.""" - - -# async def create(*, db_session: DbSession, scope_id: ScopeCreate): -# """Creates a new document.""" - - -# async def update(*, db_session: DbSession, scope: Scope, scope_id: ScopeUpdate): -# """Updates a document.""" - - -# for field in data: -# if field in update_data: - - - - -# async def delete(*, db_session: DbSession, scope_id: str): -# """Deletes a document.""" + 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%'}} diff --git a/src/overhaul_activity/__init__.py b/src/overhaul_activity/__init__.py index e69de29..8b13789 100644 --- a/src/overhaul_activity/__init__.py +++ b/src/overhaul_activity/__init__.py @@ -0,0 +1 @@ + diff --git a/src/overhaul_activity/model.py b/src/overhaul_activity/model.py index 39493dd..be6af18 100644 --- a/src/overhaul_activity/model.py +++ b/src/overhaul_activity/model.py @@ -1,25 +1,11 @@ from sqlalchemy import UUID, Column, Float, ForeignKey, String - from src.database.core import Base from src.models import DefaultMixin - class OverhaulActivity(Base, DefaultMixin): - __tablename__ = "oh_tr_overhaul_activity" - + __tablename__ = 'oh_tr_overhaul_activity' 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) service_cost = Column(Float, nullable=False, default=0) - status = Column(String, nullable=False, default="pending") - -# "MasterEquipment", - -# # "ScopeEquipmentPart", - - -# "OverhaulScope", - -# "OverhaulJob", back_populates="overhaul_activity", lazy="raise" + status = Column(String, nullable=False, default='pending') diff --git a/src/overhaul_activity/router.py b/src/overhaul_activity/router.py index 8689e65..c12e9c6 100644 --- a/src/overhaul_activity/router.py +++ b/src/overhaul_activity/router.py @@ -1,103 +1,33 @@ from typing import Optional from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException, Query, status - from src.csrf_protect import csrf_protect from src.database.core import CollectorDbSession -from src.database.service import (CommonParameters, DbSession) +from src.database.service import CommonParameters, DbSession from src.models import StandardResponse from src.auth.access_control import require_any_role, ALLOWED_ROLES - -from .schema import (OverhaulActivityCreate, OverhaulActivityRead) +from .schema import OverhaulActivityCreate, OverhaulActivityRead from .service import add_multiple_equipment_to_session, get, get_all, remove_equipment_from_session - 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.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), -): - """Get all scope activity pagination.""" - 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, -): - - 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.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): + 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') - return StandardResponse(data=None, message="Data created successfully") - - -@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 - ) +@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: - raise HTTPException( - 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( -# async def update_scope( -# db_session: DbSession, -# scope_equipment_activity_in: OverhaulActivityUpdate, -# assetnum: str, -# ): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='A data with this id does not exist.') + return StandardResponse(data=equipment, message='Data retrieved successfully') -# if not activity: -# raise HTTPException( - -# return StandardResponse( -# ), - - -@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): +@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): 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) diff --git a/src/overhaul_activity/schema.py b/src/overhaul_activity/schema.py index 46147e4..4290852 100644 --- a/src/overhaul_activity/schema.py +++ b/src/overhaul_activity/schema.py @@ -1,20 +1,16 @@ 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.workscope_group.schema import ActivityMasterRead class OverhaulActivityBase(DefultBase): pass - class OverhaulActivityCreate(OverhaulActivityBase): location_tags: List[str] - class OverhaulActivityUpdate(OverhaulActivityBase): material_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) service_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) @@ -25,7 +21,6 @@ class OverhaulScope(DefultBase): end_date: datetime duration_oh: int = Field(..., ge=0, le=1000000) - class ScopeEquipmentJob(DefultBase): job: ActivityMasterRead @@ -41,6 +36,5 @@ class OverhaulActivityRead(OverhaulActivityBase): oh_scope: str overhaul_cost: Optional[float] = Field(0, ge=0, le=1000000000000000) - class OverhaulActivityPagination(Pagination): items: List[OverhaulActivityRead] = [] diff --git a/src/overhaul_activity/service.py b/src/overhaul_activity/service.py index d924cac..4953344 100644 --- a/src/overhaul_activity/service.py +++ b/src/overhaul_activity/service.py @@ -1,11 +1,9 @@ import datetime from typing import List, Optional from uuid import UUID, uuid4 - from fastapi import HTTPException, status from sqlalchemy import Select, and_, select from sqlalchemy.orm import joinedload, selectinload - from src.database.core import DbSession from src.database.service import CommonParameters, search_filter_sort_paginate from src.utils import update_model @@ -18,532 +16,183 @@ from src.workscope_group_maintenance_type.model import WorkscopeOHType from src.overhaul_scope.service import get as get_overhaul from src.standard_scope.model import EquipmentOHHistory from .model import OverhaulActivity -from .schema import (OverhaulActivityRead, - OverhaulActivityUpdate) - +from .schema import OverhaulActivityRead, OverhaulActivityUpdate import json from src.database.core import CollectorDbSession from src.maximo.service import get_cm_cost_summary, get_oh_cost_summary 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]: - """Returns a document based on the given document id.""" - query = ( - Select(OverhaulActivity) - .where(OverhaulActivity.assetnum == assetnum) - .options(joinedload(OverhaulActivity.equipment)) - ) +async def get(*, db_session: DbSession, assetnum: str, overhaul_session_id: Optional[UUID]=None) -> Optional[OverhaulActivityRead]: + query = Select(OverhaulActivity).where(OverhaulActivity.assetnum == assetnum).options(joinedload(OverhaulActivity.equipment)) query = apply_not_deleted_filter(query, OverhaulActivity) - if overhaul_session_id: query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id) - result = await db_session.execute(query) return result.scalar() def get_cost_per_failute(): - with open('src/overhaul_activity/cost_failure.json', 'r') as f: - data = json.load(f) - - 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 -): - # .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: - - # if scope_name: - - - + with open('src/overhaul_activity/cost_failure.json', 'r') as f: + data = json.load(f) + 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): 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) - - 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 = 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 = apply_not_deleted_filter(query, StandardScope) - if location_tag: query = query.filter(StandardScope.location_tag == location_tag) - - # Use search_filter_sort_paginate for server-side pagination - # 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"] - + 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) 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 = [] - for equipment in equipments: if not equipment.master_equipment: continue - cost = material_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) - - # Return paginated structure with transformed items - return { - **paginated_results, - "items": results - } - + return {**paginated_results, 'items': results} 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) prev_oh_scope = await get_prev_oh(db_session=db_session, overhaul_session=overhaul) - - 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() - ) - + 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() data = await db_session.execute(query) eqs = data.scalars().all() - 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) 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: - cost = material_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, - ) - + cost = material_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) results.append(res) return results - - -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 - """ +async def add_equipment_to_session(*, db_session: DbSession, collector_db: CollectorDbSession, overhaul_session_id: UUID, location_tag: str) -> Optional[StandardScope]: try: - # Get the overhaul session - 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 - ) + overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id) + master_eq_query = select(MasterEquipment).filter(MasterEquipment.location_tag == location_tag) master_eq_result = await db_session.execute(master_eq_query) master_equipment = master_eq_result.scalar_one_or_none() - if not master_equipment: - print("Equipment not found in master") + print('Equipment not found in master') return None - - # Check if equipment already exists in StandardScope - existing_query = select(StandardScope).filter( - StandardScope.location_tag == location_tag - ) + existing_query = select(StandardScope).filter(StandardScope.location_tag == location_tag) existing_result = await db_session.execute(existing_query) existing_equipment = existing_result.scalar_one_or_none() - - # --- 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_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() - - # --- 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 - ) - + 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: - # Already belongs to this overhaul naturally → skip return None - - # --- Step 3: Handle existing equipment --- if existing_equipment: - # 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, - ) - + 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: dummy_workscope.location_tag = location_tag await db_session.commit() await db_session.refresh(existing_equipment) - return existing_equipment - - # --- 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 = 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 - db_session.add(new_equipment) await db_session.commit() await db_session.refresh(new_equipment) - return new_equipment - 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() return None - -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 --- +async def get_or_create_dummy_workscope(*, db_session: DbSession, maintenance_type_name: str) -> EquipmentWorkscopeGroup: + dummy_name = f'Included Equipment Workscope - {maintenance_type_name}' query = select(MasterActivity).filter(MasterActivity.workscope == dummy_name) result = await db_session.execute(query) master_activity = result.scalar_one_or_none() - if not master_activity: master_activity = MasterActivity(workscope=dummy_name) db_session.add(master_activity) await db_session.commit() await db_session.refresh(master_activity) - - # --- Step 2: Ensure WorkscopeOHType link exists --- mt_query = select(MaintenanceType).filter(MaintenanceType.name == maintenance_type_name) mt_result = await db_session.execute(mt_query) 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() - 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) await db_session.commit() await db_session.refresh(workscope_oh_type) - - # --- Step 3: Create new EquipmentWorkscopeGroup --- - equipment_workscope_group = EquipmentWorkscopeGroup( - workscope_group=master_activity, - ) + equipment_workscope_group = EquipmentWorkscopeGroup(workscope_group=master_activity) db_session.add(equipment_workscope_group) await db_session.commit() await db_session.refresh(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]: - """ - 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 - """ +async def add_multiple_equipment_to_session(*, db_session: DbSession, collector_db: CollectorDbSession, overhaul_session_id: UUID, location_tags: List[str]) -> List[StandardScope]: results = [] - 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: results.append(equipment) - return results 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) - results = await db_session.execute(query) - return results.scalars().all() - - -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() - ) +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() query = apply_not_deleted_filter(query, OverhaulActivity) - if overhaul_session_id: query = query.filter(OverhaulActivity.overhaul_scope_id == overhaul_session_id) - result = await db_session.execute(query) activity = result.scalar() - 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) - update_data = overhaul_activity_in.model_dump(exclude_defaults=True) update_model(activity, update_data) - await db_session.commit() - return activity - -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) - """ +async def remove_equipment_from_session(*, db_session: DbSession, overhaul_session_id: UUID, location_tag: str) -> bool: try: - # Get the overhaul session - 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 - ) + overhaul = await get_session(db_session=db_session, overhaul_session_id=overhaul_session_id) + existing_query = select(StandardScope).filter(StandardScope.location_tag == location_tag) existing_result = await db_session.execute(existing_query) equipment = existing_result.scalar_one_or_none() - if not equipment: - print(f"Equipment {location_tag} not found in StandardScope") + print(f'Equipment {location_tag} not found in StandardScope') return False - - # --- 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_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%')) 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: - 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 - - # --- 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, - ) - + 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: equipment.workscope_groups.remove(dummy_group) await db_session.commit() await db_session.refresh(equipment) - - # --- Step 3 (optional): Delete StandardScope if no groups left --- if not equipment.workscope_groups: await db_session.delete(equipment) await db_session.commit() - print(f"Equipment {location_tag} completely removed from StandardScope") - + print(f'Equipment {location_tag} completely removed from StandardScope') 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 - 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() return False diff --git a/src/overhaul_activity/utils.py b/src/overhaul_activity/utils.py index 87b5402..8a9d4e1 100644 --- a/src/overhaul_activity/utils.py +++ b/src/overhaul_activity/utils.py @@ -1,37 +1,26 @@ from decimal import Decimal, getcontext - def get_material_cost(scope, total_equipment): - # Set precision to 28 digits (maximum precision for Decimal) getcontext().prec = 28 - - if not total_equipment: # Guard against division by zero + if not total_equipment: return float(0) - 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) else: - result = Decimal("8565468127") / Decimal(str(total_equipment)) + result = Decimal('8565468127') / Decimal(str(total_equipment)) return float(result) - return float(0) - def get_service_cost(scope, total_equipment): - # Set precision to 28 digits (maximum precision for Decimal) getcontext().prec = 28 - - if not total_equipment: # Guard against division by zero + if not total_equipment: 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) else: - result = Decimal("36000000000") / Decimal(str(total_equipment)) + result = Decimal('36000000000') / Decimal(str(total_equipment)) return float(result) - return float(0) diff --git a/src/overhaul_gantt/__init__.py b/src/overhaul_gantt/__init__.py index e69de29..8b13789 100644 --- a/src/overhaul_gantt/__init__.py +++ b/src/overhaul_gantt/__init__.py @@ -0,0 +1 @@ + diff --git a/src/overhaul_gantt/model.py b/src/overhaul_gantt/model.py index 77fc2ed..e7890ef 100644 --- a/src/overhaul_gantt/model.py +++ b/src/overhaul_gantt/model.py @@ -1,17 +1,8 @@ - - - - - - from sqlalchemy import Column, String from src.database.core import Base from src.models import DefaultMixin - class OverhaulGantt(Base, DefaultMixin): - __tablename__ = "oh_ms_monitoring_spreadsheet" - + __tablename__ = 'oh_ms_monitoring_spreadsheet' spreadsheet_id = Column(String, nullable=True) spreadsheet_link = Column(String, nullable=True) - \ No newline at end of file diff --git a/src/overhaul_gantt/router.py b/src/overhaul_gantt/router.py index 6c95807..7ec9ad3 100644 --- a/src/overhaul_gantt/router.py +++ b/src/overhaul_gantt/router.py @@ -1,123 +1,46 @@ import re - from fastapi import APIRouter, Depends from sqlalchemy import select - from src.auth.service import Admin from src.csrf_protect import csrf_protect from src.database.core import DbSession from src.models import StandardResponse from src.overhaul_gantt.model import OverhaulGantt from src.overhaul_gantt.schema import OverhaulGanttIn - from .service import get_gantt_performance_chart - router = APIRouter() - -@router.get( - "", response_model=StandardResponse[dict] -) +@router.get('', response_model=StandardResponse[dict]) async def get_gantt_performance(db_session: DbSession): - """Get all scope pagination.""" query = select(OverhaulGantt).limit(1) - data = (await db_session.execute(query)).scalar_one_or_none() - 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') - return StandardResponse( - data={ - "chart_data": results, - "gantt_data": gantt_data - }, - message="Data retrieved successfully", - ) - -@router.get( - "/spreadsheet", response_model=StandardResponse[dict] -) +@router.get('/spreadsheet', response_model=StandardResponse[dict]) async def get_gantt_spreadsheet(db_session: DbSession): - """Get all scope pagination.""" query = select(OverhaulGantt).limit(1) - 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: - result = { - "spreadsheet_id": data.spreadsheet_id, - "spreadsheet_link": data.spreadsheet_link - } - + result = {'spreadsheet_id': data.spreadsheet_id, 'spreadsheet_link': data.spreadsheet_link} + return StandardResponse(data=result, message='Data retrieved successfully') - return StandardResponse( - data=result, - message="Data retrieved successfully", - ) - -@router.post( - "/spreadsheet", response_model=StandardResponse[dict], dependencies=[Depends(csrf_protect)] -) +@router.post('/spreadsheet', response_model=StandardResponse[dict], dependencies=[Depends(csrf_protect)]) async def update_gantt_spreadsheet(db_session: DbSession, spreadsheet_in: OverhaulGanttIn, admin: Admin): - """Get all scope pagination.""" - - match = re.search(r"/d/([a-zA-Z0-9-_]+)", spreadsheet_in.spreadsheet_link) + match = re.search('/d/([a-zA-Z0-9-_]+)', spreadsheet_in.spreadsheet_link) if not match: - raise ValueError("Invalid Google Sheets URL") - + raise ValueError('Invalid Google Sheets URL') spreadsheet_id = match.group(1) - query = select(OverhaulGantt).limit(1) - data = (await db_session.execute(query)).scalar_one_or_none() if data: data.spreadsheet_link = spreadsheet_in.spreadsheet_link data.spreadsheet_id = spreadsheet_id 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) - - await db_session.commit() - if data: - result = { - "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( - -# return StandardResponse( - -# @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 -# ): - -# return StandardResponse( - -# @router.delete("/{overhaul_job_id}", response_model=StandardResponse[None]) -# async def delete_overhaul_equipment_job(db_session: DbSession, overhaul_job_id): - -# return StandardResponse( + result = {'spreadsheet_id': spreadsheet_id} + return StandardResponse(data=result, message='Data retrieved successfully') diff --git a/src/overhaul_gantt/schema.py b/src/overhaul_gantt/schema.py index 1dfa5db..9d4df5c 100644 --- a/src/overhaul_gantt/schema.py +++ b/src/overhaul_gantt/schema.py @@ -1,22 +1,5 @@ - - - from pydantic import Field from src.models import DefultBase - class OverhaulGanttIn(DefultBase): spreadsheet_link: str = Field(...) - - -# class OverhaulScheduleCreate(OverhaulScheduleBase): - - -# class OverhaulScheduleUpdate(OverhaulScheduleBase): - - -# class OverhaulScheduleRead(OverhaulScheduleBase): - - - -# class OverhaulSchedulePagination(Pagination): diff --git a/src/overhaul_gantt/service.py b/src/overhaul_gantt/service.py index 3a0b293..18b5bba 100644 --- a/src/overhaul_gantt/service.py +++ b/src/overhaul_gantt/service.py @@ -1,91 +1,34 @@ - from fastapi import HTTPException, status - from .utils import fetch_all_sections, get_google_creds, get_spreatsheed_service, process_spreadsheet_data -# async def get_all(*, common): -# """Returns all documents.""" - - - -# async def create( -# *, db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate -# ): - - - - - -# async def update(*, db_session: DbSession, overhaul_schedule_id: str, overhaul_job_in: OverhaulScheduleUpdate): -# """Updates a document.""" - - -# for field in data: -# if field in update_data: - - - - -# async def delete(*, db_session: DbSession, overhaul_schedule_id: str): -# """Deletes a document.""" - - -async def get_gantt_performance_chart(*, spreadsheet_id = "1gZXuwA97zU1v4QBv56wKeiqadc6skHUucGKYG8qVFRk"): +async def get_gantt_performance_chart(*, spreadsheet_id='1gZXuwA97zU1v4QBv56wKeiqadc6skHUucGKYG8qVFRk'): creds = get_google_creds() - RANGE_NAME = "'SUMMARY'!K34:AZ38" # Or just "2024 schedule" - GANTT_DATA_NAME = "ACTUAL PROGRESS" - + RANGE_NAME = "'SUMMARY'!K34:AZ38" + GANTT_DATA_NAME = 'ACTUAL PROGRESS' try: service = get_spreatsheed_service(creds) 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: - raise Exception("Spreadsheet format invalid: need 4 rows (DAY, DATE, PLAN, ACTUAL).") - - # Extract rows - day_row = values[0][1:] - date_row = values[1][1:] - plan_row = values[3][1:] + raise Exception('Spreadsheet format invalid: need 4 rows (DAY, DATE, PLAN, ACTUAL).') + day_row = values[0][1:] + date_row = values[1][1:] + plan_row = values[3][1:] actual_row = values[4][1:] - - - total_days = len(day_row) - - # PAD rows so lengths match day count - date_row += [""] * (total_days - len(date_row)) - plan_row += [""] * (total_days - len(plan_row)) - 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 = [] - for i in range(total_days): - day = day_row[i] date = date_row[i] plan = plan_row[i] - actual = actual_row[i] if actual_row[i] else "0%" # <-- FIX HERE - - results.append({ - "day": day, - "date": date, - "plan": plan, - "actual": actual - }) - + actual = actual_row[i] if actual_row[i] else '0%' + results.append({'day': day, 'date': date, 'plan': plan, 'actual': actual}) except Exception as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - - processed_data = process_spreadsheet_data(results) 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) diff --git a/src/overhaul_gantt/utils.py b/src/overhaul_gantt/utils.py index 9bead2d..0d1d9bc 100644 --- a/src/overhaul_gantt/utils.py +++ b/src/overhaul_gantt/utils.py @@ -1,127 +1,76 @@ from google.oauth2.service_account import Credentials from googleapiclient.discovery import build - - -SCOPES = ["https://www.googleapis.com/auth/spreadsheets.readonly"] - +SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly'] 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(): creds = None - creds = Credentials.from_service_account_file("credentials.json", scopes=SCOPES) + creds = Credentials.from_service_account_file('credentials.json', scopes=SCOPES) return creds - def process_spreadsheet_data(rows): processed_data = [] for row in rows: processed_row = convert_spreadsheet_data(row) processed_data.append(processed_row) if processed_row else None return processed_data - - from datetime import datetime - from datetime import datetime def convert_spreadsheet_data(data, default_year=None): - """ - 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(): + if not data.get('day') or not data['day'].isdigit(): return None - result = {} - - # Convert day - result["day"] = int(data["day"]) - - # Determine default year + result['day'] = int(data['day']) if default_year is None: default_year = datetime.now().year - - date_str = data.get("date", "").strip() - - # ---------- DATE HANDLING ---------- - # Accept formats like: "Nov 20", "Dec 3", "Jan 1" + date_str = data.get('date', '').strip() parsed_date = None - if date_str: 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: 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: parsed_date = None - - # YEAR ROLLOVER (Dec → Jan next year) - if parsed_date and parsed_date.month == 1 and "Dec" in data.get("date", ""): + if parsed_date and parsed_date.month == 1 and ('Dec' in data.get('date', '')): parsed_date = parsed_date.replace(year=default_year + 1) + result['date'] = parsed_date - result["date"] = parsed_date - - # ---------- PERCENT HANDLING ---------- def parse_percent(value): if not value: return 0.0 - v = value.strip().replace(",", ".").replace("%", "") + v = value.strip().replace(',', '.').replace('%', '') try: return float(v) / 100.0 except: return 0.0 - - result["plan"] = parse_percent(data.get("plan", "0")) - result["actual"] = parse_percent(data.get("actual", "0")) - - # Gap calculation - result["gap"] = result["actual"] - result["plan"] - + result['plan'] = parse_percent(data.get('plan', '0')) + result['actual'] = parse_percent(data.get('actual', '0')) + result['gap'] = result['actual'] - result['plan'] return result - - def fetch_all_sections(service, spreadsheet_id, sheet_name): - # Fetch a wide range including columns A–L - result = service.spreadsheets().values().get( - spreadsheetId=spreadsheet_id, - range=f"{sheet_name}!A5:M5000" - ).execute() - - 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: - raise ValueError("No data found in sheet") - + raise ValueError('No data found in sheet') data = [] current_section = None current_subsystem = None - - for row in values: - # Pad missing columns to avoid index errors - row += [""] * (13 - len(row)) - + row += [''] * (13 - len(row)) colA, colB, colC, colD, colE, colF, colG, colH, colI, colJ, colK, colL, colM = row - - - # Detect a SECTION — bold blue rows in Column C - if colC and not colD and not colE: + if colC and (not colD) and (not colE): current_section = colC.strip() current_subsystem = None continue - - # Detect a SUBSYSTEM — indented header in Column D - if colD and not colE: + if colD and (not colE): current_subsystem = colD.strip() continue - - # Detect a TASK — Column E populated if colE: task = colE.strip() pic = colF.strip() @@ -131,64 +80,21 @@ def fetch_all_sections(service, spreadsheet_id, sheet_name): plan = colK.strip() actual = colL.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 - def indo_formatted_date(date_str, base_year=2025): - """ - Convert short date like 'Nov 20', '30-Dec', 'Jan 1' - into: 'Rabu, November 20, 2025' - If month is January, year becomes 2026. - """ - - # 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 + 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'} + if '-' in date_str: + d, m = date_str.split('-') + date_str = f'{m} {d}' try: - dt = datetime.strptime(f"{date_str} {base_year}", "%b %d %Y") + dt = datetime.strptime(f'{date_str} {base_year}', '%b %d %Y') except: return None - - # Handle year rollover (Jan -> next year) if dt.month == 1: dt = dt.replace(year=base_year + 1) - - # Convert to Indonesian components day_name = indo_days[dt.weekday()] - month_name = eng_to_indo_month[dt.strftime("%b")] - - return f"{day_name}, {month_name} {dt.day}, {dt.year}" \ No newline at end of file + month_name = eng_to_indo_month[dt.strftime('%b')] + return f'{day_name}, {month_name} {dt.day}, {dt.year}' diff --git a/src/overhaul_job/__init__.py b/src/overhaul_job/__init__.py index e69de29..8b13789 100644 --- a/src/overhaul_job/__init__.py +++ b/src/overhaul_job/__init__.py @@ -0,0 +1 @@ + diff --git a/src/overhaul_job/model.py b/src/overhaul_job/model.py index 1386a8c..8b13789 100644 --- a/src/overhaul_job/model.py +++ b/src/overhaul_job/model.py @@ -1,16 +1 @@ - - -# class EquipmentWorkscopeGroup(Base, DefaultMixin): - -# # UUID(as_uuid=True), ForeignKey("oh_tr_overhaul_activity.id"), nullable=False - - -# UUID(as_uuid=True), ForeignKey("oh_ms_workscope_group.id"), nullable=False - - -# "StandardScope", - - -# # "ScopeEquipmentJob", lazy="raise", back_populates="overhaul_jobs" - diff --git a/src/overhaul_job/router.py b/src/overhaul_job/router.py index 957c5bc..2201f13 100644 --- a/src/overhaul_job/router.py +++ b/src/overhaul_job/router.py @@ -1,74 +1,23 @@ from typing import Optional - from fastapi import APIRouter, Query - from src.database.core import DbSession from src.database.service import CommonParameters from src.models import StandardResponse - -from .schema import (OverhaulJobCreate, OverhaulJobPagination) +from .schema import OverhaulJobCreate, OverhaulJobPagination from .service import create, delete, get_all - router = APIRouter() - -@router.get( - "/{location_tag}", response_model=StandardResponse[OverhaulJobPagination] -) -async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str] = Query(None)): - """Get all scope pagination.""" +@router.get('/{location_tag}', response_model=StandardResponse[OverhaulJobPagination]) +async def get_jobs(common: CommonParameters, location_tag: str, scope: Optional[str]=Query(None)): results = await get_all(common=common, location_tag=location_tag, scope=scope) + return StandardResponse(data=results, message='Data retrieved successfully') - return StandardResponse( - data=results, - message="Data retrieved successfully", - ) - +@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): + 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("/{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.""" - 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]) +@router.post('/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_job_id=overhaul_job_id) - - 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): - - - -# @router.put("/{scope_id}", response_model=StandardResponse[ScopeRead]) -# async def update_scope(db_session: DbSession, scope_id: str, scope_in: ScopeUpdate, current_user: CurrentUser): - -# if not scope: -# raise HTTPException( - - - -# @router.delete("/{scope_id}", response_model=StandardResponse[ScopeRead]) -# async def delete_scope(db_session: DbSession, scope_id: str): - -# if not scope: -# raise HTTPException( - - + return StandardResponse(data=None, message='Data deleted successfully') diff --git a/src/overhaul_job/schema.py b/src/overhaul_job/schema.py index c8ba9b5..6e6574a 100644 --- a/src/overhaul_job/schema.py +++ b/src/overhaul_job/schema.py @@ -1,7 +1,5 @@ from typing import List, Optional from uuid import UUID - - from src.models import DefultBase, Pagination from src.overhaul_scope.schema import ScopeRead from src.job.schema import ActivityMasterRead @@ -9,15 +7,12 @@ from src.job.schema import ActivityMasterRead class OverhaulJobBase(DefultBase): pass - class OverhaulJobCreate(OverhaulJobBase): job_ids: Optional[List[UUID]] = [] - class OverhaulJobUpdate(OverhaulJobBase): pass - class OverhaulActivity(DefultBase): id: UUID overhaul_scope_id: UUID @@ -31,7 +26,5 @@ class OverhaulJobRead(OverhaulJobBase): scope_equipment_job: ScopeEquipment overhaul_activity: OverhaulActivity - - class OverhaulJobPagination(Pagination): items: List[OverhaulJobRead] = [] diff --git a/src/overhaul_job/service.py b/src/overhaul_job/service.py index df63f5f..c6e50a9 100644 --- a/src/overhaul_job/service.py +++ b/src/overhaul_job/service.py @@ -1,11 +1,8 @@ from typing import Optional - from fastapi import HTTPException, status from sqlalchemy import Select - from src.database.core import DbSession from src.database.service import search_filter_sort_paginate - from src.workscope_group.model import MasterActivity from src.workscope_group_maintenance_type.model import WorkscopeOHType from src.overhaul_scope.model import MaintenanceType @@ -13,99 +10,33 @@ from src.equipment_workscope_group.model import EquipmentWorkscopeGroup from .schema import OverhaulJobCreate from src.soft_delete import apply_not_deleted_filter, soft_delete_record - -async def get_all(*, common, location_tag: str, scope: Optional[str] = None): - """Returns all documents.""" - query = ( - Select(EquipmentWorkscopeGroup) - .where(EquipmentWorkscopeGroup.location_tag == location_tag) - ) +async def get_all(*, common, location_tag: str, scope: Optional[str]=None): + query = Select(EquipmentWorkscopeGroup).where(EquipmentWorkscopeGroup.location_tag == location_tag) query = apply_not_deleted_filter(query, EquipmentWorkscopeGroup) - if scope: - query = ( - query - .join(EquipmentWorkscopeGroup.workscope_group) - .join(MasterActivity.oh_types) - .join(WorkscopeOHType.oh_type) - .filter(MaintenanceType.name == scope) - ) - + query = 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 = [] - if not overhaul_equipment_id: - raise ValueError("assetnum parameter is required") - - equipment_stmt = Select(OverhaulJob).where( - OverhaulJob.overhaul_activity_id == overhaul_equipment_id - ) - + raise ValueError('assetnum parameter is required') + equipment_stmt = Select(OverhaulJob).where(OverhaulJob.overhaul_activity_id == overhaul_equipment_id) await db_session.scalar(equipment_stmt) - 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) - db_session.add_all(overhaul_jobs) await db_session.commit() return overhaul_job_in.job_ids - -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 - """ +async def delete(*, db_session: DbSession, overhaul_job_id: str) -> bool: try: - # Soft-delete the record deleted = await soft_delete_record(db_session, EquipmentWorkscopeGroup, overhaul_job_id) 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 - except Exception: await db_session.rollback() raise - - -# async def update(*, db_session: DbSession, scope: OverhaulScope, scope_in: ScopeUpdate): -# """Updates a document.""" - - -# for field in data: -# if field in update_data: - - - - -# async def delete(*, db_session: DbSession, scope_id: str): -# """Deletes a document.""" diff --git a/src/overhaul_schedule/__init__.py b/src/overhaul_schedule/__init__.py index e69de29..8b13789 100644 --- a/src/overhaul_schedule/__init__.py +++ b/src/overhaul_schedule/__init__.py @@ -0,0 +1 @@ + diff --git a/src/overhaul_schedule/model.py b/src/overhaul_schedule/model.py index e2d71ab..2b9a864 100644 --- a/src/overhaul_schedule/model.py +++ b/src/overhaul_schedule/model.py @@ -1,17 +1,13 @@ -from sqlalchemy import (Column, DateTime, Integer, - String) - +from sqlalchemy import Column, DateTime, Integer, String from src.database.core import Base from src.models import DefaultMixin - class OverhaulSchedule(Base, DefaultMixin): - __tablename__ = "rp_oh_schedule" - + __tablename__ = 'rp_oh_schedule' year = Column(Integer, nullable=False) plan_duration = Column(Integer, nullable=True) planned_outage = Column(Integer, nullable=True) actual_shutdown = Column(Integer, nullable=True) - start = Column(DateTime(timezone=True)) # This will be TIMESTAMP WITH TIME ZONE + start = Column(DateTime(timezone=True)) finish = Column(DateTime(timezone=True)) remark = Column(String, nullable=True) diff --git a/src/overhaul_schedule/router.py b/src/overhaul_schedule/router.py index e3c6ef5..d6cdf35 100644 --- a/src/overhaul_schedule/router.py +++ b/src/overhaul_schedule/router.py @@ -1,60 +1,27 @@ - from fastapi import APIRouter - from src.database.core import DbSession from src.database.service import CommonParameters 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 - router = APIRouter() - -@router.get( - "", response_model=StandardResponse[OverhaulSchedulePagination] -) +@router.get('', response_model=StandardResponse[OverhaulSchedulePagination]) async def get_schedules(common: CommonParameters): - """Get all scope pagination.""" 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') - 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]) -async def update_overhaul_schedule( - db_session: DbSession, overhaul_job_id: str, overhaul_job_in: OverhaulScheduleUpdate -): +@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) + return StandardResponse(data=None, message='Data updated successfully') - return StandardResponse( - data=None, - message="Data updated successfully", - ) - -@router.post("/delete/{overhaul_job_id}", response_model=StandardResponse[None]) +@router.post('/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", - ) + return StandardResponse(data=None, message='Data deleted successfully') diff --git a/src/overhaul_schedule/schema.py b/src/overhaul_schedule/schema.py index 71f9160..790d6bf 100644 --- a/src/overhaul_schedule/schema.py +++ b/src/overhaul_schedule/schema.py @@ -1,15 +1,12 @@ from datetime import datetime from typing import List, Optional from uuid import UUID - from pydantic import Field - from src.models import DefultBase, Pagination class OverhaulScheduleBase(DefultBase): pass - class OverhaulScheduleCreate(OverhaulScheduleBase): year: int plan_duration: Optional[int] = Field(None) @@ -19,12 +16,10 @@ class OverhaulScheduleCreate(OverhaulScheduleBase): finish: datetime remark: Optional[str] = Field(None) - class OverhaulScheduleUpdate(OverhaulScheduleBase): start: datetime finish: datetime - class OverhaulScheduleRead(OverhaulScheduleBase): id: UUID year: int @@ -35,7 +30,5 @@ class OverhaulScheduleRead(OverhaulScheduleBase): finish: datetime remark: Optional[str] - - class OverhaulSchedulePagination(Pagination): items: List[OverhaulScheduleRead] = [] diff --git a/src/overhaul_schedule/service.py b/src/overhaul_schedule/service.py index 1aec55f..0c1befb 100644 --- a/src/overhaul_schedule/service.py +++ b/src/overhaul_schedule/service.py @@ -1,54 +1,34 @@ - from sqlalchemy import Select - from src.database.core import DbSession from src.database.service import search_filter_sort_paginate from src.utils import update_model from src.soft_delete import apply_not_deleted_filter, soft_delete_record - from .model import OverhaulSchedule from .schema import OverhaulScheduleCreate, OverhaulScheduleUpdate - async def get_all(*, common): - """Returns all documents.""" query = Select(OverhaulSchedule).order_by(OverhaulSchedule.start.desc()) query = apply_not_deleted_filter(query, OverhaulSchedule) - results = await search_filter_sort_paginate(model=query, **common) return results - -async def create( - *, db_session: DbSession, overhaul_job_in: OverhaulScheduleCreate -): - - +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.""" overhaul_job_in.model_dump() query = Select(OverhaulSchedule).where(OverhaulSchedule.id == overhaul_schedule_id) query = apply_not_deleted_filter(query, OverhaulSchedule) query = query.with_for_update() result = await db_session.execute(query) overhaul_schedule = result.scalars().one_or_none() - update_data = overhaul_job_in.model_dump(exclude_defaults=True) - update_model(overhaul_schedule, update_data) - await db_session.commit() - return overhaul_schedule - 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) diff --git a/src/overhaul_scope/__init__.py b/src/overhaul_scope/__init__.py index e69de29..8b13789 100644 --- a/src/overhaul_scope/__init__.py +++ b/src/overhaul_scope/__init__.py @@ -0,0 +1 @@ + diff --git a/src/overhaul_scope/model.py b/src/overhaul_scope/model.py index 59ae3a8..cd63457 100644 --- a/src/overhaul_scope/model.py +++ b/src/overhaul_scope/model.py @@ -1,47 +1,29 @@ 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 src.database.core import Base from src.models import DefaultMixin - - from src.auth.access_control import Allow, RolePrincipal, OHPermission class OverhaulScope(Base, DefaultMixin): - __tablename__ = "oh_ms_overhaul" - start_date = Column(DateTime(timezone=True), nullable=False) # Made non-nullable to match model - end_date = Column(DateTime(timezone=True), nullable=True) # Already nullable + __tablename__ = 'oh_ms_overhaul' + start_date = Column(DateTime(timezone=True), nullable=False) + end_date = Column(DateTime(timezone=True), nullable=True) duration_oh = Column(Integer, nullable=True) crew_number = Column(Integer, nullable=True, default=1) - status = Column(String, nullable=False, default="Upcoming") - maintenance_type_id = Column( - UUID(as_uuid=True), ForeignKey("oh_ms_maintenance_type.id"), nullable=False) + status = Column(String, nullable=False, default='Upcoming') + maintenance_type_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_maintenance_type.id'), nullable=False) wo_parent = Column(JSON, nullable=True) @classmethod def __acl__(self): 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) - - 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') class MaintenanceType(Base, DefaultMixin): - __tablename__ = "oh_ms_maintenance_type" - code = Column(String, nullable=False, default="OH") + __tablename__ = 'oh_ms_maintenance_type' + code = Column(String, nullable=False, default='OH') name = Column(String, nullable=False) diff --git a/src/overhaul_scope/router.py b/src/overhaul_scope/router.py index 70fef11..e598888 100644 --- a/src/overhaul_scope/router.py +++ b/src/overhaul_scope/router.py @@ -1,81 +1,47 @@ from typing import List from typing import Optional - from fastapi import APIRouter, HTTPException, status - from src.auth.service import CurrentUser from src.database.core import DbSession from src.database.service import CommonParameters from src.models import StandardResponse - from .model import OverhaulScope 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.csrf_protect import csrf_protect from fastapi import Depends - router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) - -@router.get("", response_model=StandardResponse[ScopePagination], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) -async def get_scopes(common: CommonParameters, scope_name: Optional[str] = None): - """Get all scope pagination.""" +@router.get('', response_model=StandardResponse[ScopePagination], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) +async def get_scopes(common: CommonParameters, scope_name: Optional[str]=None): results = await get_all(common=common, scope_name=scope_name) + return StandardResponse(data=results, message='Data retrieved successfully') - return StandardResponse( - data=results, - message="Data retrieved successfully", - ) - -@router.get("/history", response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) +@router.get('/history', response_model=StandardResponse[List[ScopeRead]], dependencies=[Depends(required_permission(OHPermission.READ, OverhaulScope))]) 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): scope = await get(db_session=db_session, overhaul_session_id=overhaul_session_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=scope, message="Data retrieved successfully") - + raise HTTPException(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): scope = await create(db_session=db_session, scope_in=scope_in) + return StandardResponse(data=scope, message='Data created successfully') - return StandardResponse(data=scope, message="Data created successfully") +@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("/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)]) +@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): scope = await get(db_session=db_session, overhaul_session_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."}], - ) - + 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) - - + return StandardResponse(message='Data deleted successfully', data=scope) diff --git a/src/overhaul_scope/schema.py b/src/overhaul_scope/schema.py index 605aa78..fcba70c 100644 --- a/src/overhaul_scope/schema.py +++ b/src/overhaul_scope/schema.py @@ -1,17 +1,13 @@ from datetime import datetime from typing import List, Optional from uuid import UUID - from pydantic import Field - from src.models import DefultBase, Pagination - class ScopeBase(DefultBase): - duration_oh: Optional[int] = Field(720, title="Duration OH", ge=0, le=1000000) - crew_number: Optional[int] = Field(10, title="Crew", ge=0, le=1000000) - status: Optional[str] = Field("Upcoming") - + duration_oh: Optional[int] = Field(720, title='Duration OH', ge=0, le=1000000) + crew_number: Optional[int] = Field(10, title='Crew', ge=0, le=1000000) + status: Optional[str] = Field('Upcoming') class MaintenanceType(DefultBase): name: str @@ -20,17 +16,14 @@ class ScopeCreate(ScopeBase): start_date: datetime end_date: Optional[datetime] = Field(None) - class ScopeUpdate(ScopeBase): pass - class ScopeRead(ScopeBase): id: UUID start_date: datetime end_date: Optional[datetime] maintenance_type: MaintenanceType - class ScopePagination(Pagination): items: List[ScopeRead] = [] diff --git a/src/overhaul_scope/service.py b/src/overhaul_scope/service.py index 8fcf7c6..612a6cf 100644 --- a/src/overhaul_scope/service.py +++ b/src/overhaul_scope/service.py @@ -1,8 +1,6 @@ from typing import Optional - from sqlalchemy import Select from sqlalchemy.orm import selectinload - from src.database.core import DbSession from src.database.service import search_filter_sort_paginate from src.overhaul_activity.model import OverhaulActivity @@ -11,7 +9,6 @@ from src.standard_scope.model import MasterEquipment, StandardScope, EquipmentOH from src.workscope_group.model import MasterActivity from src.workscope_group_maintenance_type.model import WorkscopeOHType from src.equipment_workscope_group.model import EquipmentWorkscopeGroup - from .model import OverhaulScope, MaintenanceType from .schema import ScopeCreate, ScopeUpdate from .utils import get_material_cost, get_service_cost @@ -19,233 +16,115 @@ from datetime import datetime from uuid import UUID from src.soft_delete import apply_not_deleted_filter, soft_delete_record -async def get( - *, db_session: DbSession, overhaul_session_id: UUID -) -> Optional[OverhaulScope]: - """Returns a document based on the given document id.""" +async def get(*, db_session: DbSession, overhaul_session_id: UUID) -> Optional[OverhaulScope]: query = Select(OverhaulScope).filter(OverhaulScope.id == overhaul_session_id).options(selectinload(OverhaulScope.maintenance_type)) query = apply_not_deleted_filter(query, OverhaulScope) result = await db_session.execute(query) return result.scalars().one_or_none() 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 = apply_not_deleted_filter(result, OverhaulScope) result = await db_session.execute(result) return result.scalars().one_or_none() - -async def get_all(*, common, scope_name: Optional[str] = None): - """Returns all documents.""" +async def get_all(*, common, scope_name: Optional[str]=None): query = Select(OverhaulScope) query = apply_not_deleted_filter(query, OverhaulScope) - if scope_name: query = query.filter(OverhaulScope.maintenance_type.name == scope_name) - results = await search_filter_sort_paginate(model=query, **common) return results - async def create(*, db_session: DbSession, scope_in: ScopeCreate): - # Ensure dates are datetime objects if isinstance(scope_in.start_date, str): try: start_date = datetime.fromisoformat(scope_in.start_date.replace('Z', '+00:00')) 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: start_date = scope_in.start_date - - # Handle end_date (which could be None) end_date = None if scope_in.end_date: if isinstance(scope_in.end_date, str): try: end_date = datetime.fromisoformat(scope_in.end_date.replace('Z', '+00:00')) 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: end_date = scope_in.end_date - - # Calculate duration in days if both dates are available duration_days = None if start_date and end_date: 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_exec = await db_session.execute(maintenance_type) maintenance_type = maintenance_type_exec.scalars().one_or_none() - if maintenance_type is None: try: - # Need to use nested transaction for safe concurrent creation async with db_session.begin_nested(): maintenance_type = MaintenanceType(name=scope_in.type) db_session.add(maintenance_type) await db_session.flush() 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 = maintenance_type_exec.scalars().one_or_none() - - # 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 - ) - - + 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) db_session.add(overhaul_session) - # Need to flush to get the id await db_session.flush() - - 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) - - 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) - ) - - 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 + 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)) + 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: db_session.add_all(scope_equipments) - await db_session.commit() return overhaul_session - async def update(*, db_session: DbSession, scope_id: str, scope_in: ScopeUpdate): - """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 = Select(OverhaulScope).filter(OverhaulScope.id == scope_id).options(selectinload(OverhaulScope.maintenance_type)).with_for_update() query = apply_not_deleted_filter(query, OverhaulScope) result = await db_session.execute(query) scope = result.scalars().one_or_none() - 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) - update_data = scope_in.model_dump(exclude_defaults=True) update_model(scope, update_data) - await db_session.commit() - return scope - 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) - async def get_overview_overhaul(*, db_session: DbSession): - current_date = time_now().date() - - # 1. Check for ongoing overhaul - ongoing_query = ( - Select(OverhaulScope) - .where( - OverhaulScope.start_date <= current_date, - OverhaulScope.end_date >= current_date, - ) - ) + 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_result = await db_session.execute(ongoing_query.options(selectinload(OverhaulScope.maintenance_type))) ongoing_overhaul = ongoing_result.scalar_one_or_none() - - # 2. If no ongoing overhaul, get the closest scheduled overhaul if ongoing_overhaul is None: - # 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 = 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_result = await db_session.execute(next_overhaul_query.options(selectinload(OverhaulScope.maintenance_type))) next_overhaul = next_result.scalar_one_or_none() - 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 else: - print("No upcoming overhauls scheduled") + print('No upcoming overhauls scheduled') selected_overhaul = None 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 - - - 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 = 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 = apply_not_deleted_filter(equipments, StandardScope) - results = await db_session.execute(equipments) - - #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()), - }, - } + 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): query = Select(OverhaulScope).options(selectinload(OverhaulScope.maintenance_type)).where(OverhaulScope.wo_parent.isnot(None)) query = apply_not_deleted_filter(query, OverhaulScope) results = await db_session.execute(query) - return results.scalars().all() \ No newline at end of file + return results.scalars().all() diff --git a/src/overhaul_scope/utils.py b/src/overhaul_scope/utils.py index af17d97..cf31852 100644 --- a/src/overhaul_scope/utils.py +++ b/src/overhaul_scope/utils.py @@ -1,35 +1,25 @@ from decimal import Decimal, getcontext - def get_material_cost(scope, total_equipment): - # Set precision to 28 digits (maximum precision for Decimal) getcontext().prec = 28 - - if not total_equipment: # Guard against division by zero + if not total_equipment: 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) else: - result = Decimal("8565468127") / Decimal(str(total_equipment)) + result = Decimal('8565468127') / Decimal(str(total_equipment)) return float(result) - return float(0) - def get_service_cost(scope, total_equipment): - # Set precision to 28 digits (maximum precision for Decimal) getcontext().prec = 28 - - if not total_equipment: # Guard against division by zero + if not total_equipment: 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) else: - result = Decimal("36000000000") / Decimal(str(total_equipment)) + result = Decimal('36000000000') / Decimal(str(total_equipment)) return float(result) - return float(0) diff --git a/src/soft_delete.py b/src/soft_delete.py index 6bf9ab8..79ec40a 100644 --- a/src/soft_delete.py +++ b/src/soft_delete.py @@ -1,77 +1,22 @@ -""" -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 from datetime import datetime from typing import TypeVar, Type - from sqlalchemy import Select, Update from sqlalchemy.ext.asyncio import AsyncSession - import pytz from src.config import TIMEZONE - log = logging.getLogger(__name__) - -T = TypeVar("T") - +T = TypeVar('T') def apply_not_deleted_filter(query: Select, model) -> Select: - """ - 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"): + if hasattr(model, 'deleted_at'): return query.filter(model.deleted_at.is_(None)) return query - -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"). - """ +async def soft_delete_record(*, db_session: AsyncSession, model: Type[T], record_id: str, id_column: str='id') -> None: now = datetime.now(pytz.timezone(TIMEZONE)) 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.commit() - - log.info(f"Soft-deleted {model.__tablename__} record: {record_id}") + log.info(f'Soft-deleted {model.__tablename__} record: {record_id}') diff --git a/src/sparepart/__init__.py b/src/sparepart/__init__.py index e69de29..8b13789 100644 --- a/src/sparepart/__init__.py +++ b/src/sparepart/__init__.py @@ -0,0 +1 @@ + diff --git a/src/sparepart/model.py b/src/sparepart/model.py index ab1c3e8..7133e41 100644 --- a/src/sparepart/model.py +++ b/src/sparepart/model.py @@ -1,41 +1,28 @@ from sqlalchemy import UUID, Column, Float, ForeignKey, Integer, String, Date from sqlalchemy.orm import relationship - from src.database.core import Base from src.models import DefaultMixin - class MasterSparePart(Base, DefaultMixin): - __tablename__ = "oh_ms_sparepart" - + __tablename__ = 'oh_ms_sparepart' assetnum = Column(String, nullable=False) location_tag = Column(String, nullable=False) stock = Column(Integer, nullable=False, default=0) name = Column(String, nullable=False) cost_per_stock = Column(Float, nullable=False) unit = Column(String, nullable=False) - - sparepart_procurements = relationship("MasterSparepartProcurement", lazy="selectin") - + sparepart_procurements = relationship('MasterSparepartProcurement', lazy='selectin') class MasterSparepartProcurement(Base, DefaultMixin): - __tablename__ = "oh_ms_sparepart_procurement" - - sparepart_id = Column( - UUID(as_uuid=True), - ForeignKey("oh_ms_sparepart.id", ondelete="cascade"), - nullable=False, - ) + __tablename__ = 'oh_ms_sparepart_procurement' + sparepart_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_sparepart.id', ondelete='cascade'), nullable=False) quantity = Column(Integer, nullable=False) status = Column(String, nullable=False) eta_requisition = Column(Date, nullable=False) eta_ordered = Column(Date, nullable=True) eta_received = Column(Date, nullable=True) - - class SparepartRemark(Base, DefaultMixin): - __tablename__ = "oh_ms_sparepart_remark" - + __tablename__ = 'oh_ms_sparepart_remark' itemnum = Column(String, nullable=False) - remark = Column(String, nullable=False) \ No newline at end of file + remark = Column(String, nullable=False) diff --git a/src/sparepart/router.py b/src/sparepart/router.py index 01111e7..e2a752c 100644 --- a/src/sparepart/router.py +++ b/src/sparepart/router.py @@ -1,69 +1,19 @@ from fastapi import APIRouter, Depends from src.csrf_protect import csrf_protect from src.database.core import CollectorDbSession -from src.database.service import (DbSession) +from src.database.service import DbSession from src.models import StandardResponse from src.sparepart.schema import SparepartRemark from src.auth.access_control import require_any_role, ALLOWED_ROLES from .service import create_remark, get_spareparts_paginated - router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) - -@router.get("", response_model=StandardResponse[list]) -async def get_sparepart(collector_db_session:CollectorDbSession, db_session: DbSession): - """Get all scope activity pagination.""" +@router.get('', response_model=StandardResponse[list]) +async def get_sparepart(collector_db_session: CollectorDbSession, db_session: DbSession): data = await get_spareparts_paginated(db_session=db_session, collector_db_session=collector_db_session) + return StandardResponse(data=data, message='Data retrieved successfully') - 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): +@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): 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" - ) - - - -# @router.post("", response_model=StandardResponse[ActivityMasterCreate]) -# async def create_activity(db_session: DbSession, activity_in: ActivityMasterCreate): - - - - -# @router.get( -# "/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster] -# async def get_activity(db_session: DbSession, activity_id: str): -# if not activity: -# raise HTTPException( - - - -# @router.put( -# "/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster] -# async def update_scope( -# db_session: DbSession, activity_in: ActivityMasterCreate, activity_id -# ): - -# if not activity: -# raise HTTPException( - -# return StandardResponse( -# ), - - -# @router.delete( -# "/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster] -# async def delete_scope(db_session: DbSession, activity_id: str): - -# if not activity: -# raise HTTPException( - - + return StandardResponse(data=sparepart_remark, message='Remark Created successfully') diff --git a/src/sparepart/schema.py b/src/sparepart/schema.py index 7ab0aa6..b18df46 100644 --- a/src/sparepart/schema.py +++ b/src/sparepart/schema.py @@ -3,28 +3,21 @@ from datetime import date from enum import Enum from typing import List from uuid import UUID - - from src.models import DefultBase, Pagination - class ActivityMaster(DefultBase): pass - class ActivityMasterDetail(DefultBase): name: str - class ActivityMasterCreate(ActivityMaster): description: str - class ActivityMasterTasks(DefultBase): description: str oh_type: str - class ActivityMasterRead(ActivityMaster): id: UUID workscope: str @@ -32,40 +25,36 @@ class ActivityMasterRead(ActivityMaster): subsystem: str tasks: List[ActivityMasterTasks] - class ActivityMasterPagination(Pagination): items: List[ActivityMasterRead] = [] class ProcurementStatus(Enum): - PLANNED = "planned" - ORDERED = "ordered" - RECEIVED = "received" - CANCELLED = "cancelled" + PLANNED = 'planned' + ORDERED = 'ordered' + RECEIVED = 'received' + CANCELLED = 'cancelled' @dataclass class SparepartRequirement: - """Sparepart requirement for equipment overhaul""" sparepart_id: str quantity_required: int lead_time: int sparepart_name: str unit_cost: float avg_cost: float - remark:str + remark: str @dataclass class SparepartStock: - """Current sparepart stock information""" sparepart_id: str sparepart_name: str current_stock: int unit_cost: float location: str - remark:str + remark: str @dataclass class ProcurementRecord: - """Purchase Order/Purchase Request record""" po_pr_id: str sparepart_id: str sparepart_name: str @@ -76,8 +65,7 @@ class ProcurementRecord: expected_delivery_date: date status: ProcurementStatus po_vendor_delivery_date: date - - + class SparepartRemark(DefultBase): itemnum: str - remark:str \ No newline at end of file + remark: str diff --git a/src/sparepart/service.py b/src/sparepart/service.py index 2dbe826..ef49091 100644 --- a/src/sparepart/service.py +++ b/src/sparepart/service.py @@ -2,1375 +2,292 @@ import logging from datetime import datetime, timedelta, date from typing import List, Dict, Tuple from collections import defaultdict - from sqlalchemy import select, text - from src.overhaul_activity.service import get_standard_scope_by_session_id from src.overhaul_scope.service import get_overview_overhaul from src.sparepart.model import SparepartRemark from src.sparepart.schema import ProcurementRecord, ProcurementStatus, SparepartRequirement, SparepartStock from src.soft_delete import apply_not_deleted_filter - - log = logging.getLogger(__name__) - from sqlalchemy import text - from sqlalchemy import text -# async def get_spareparts_paginated( -# *, -# db_session, -# collector_db_session, -# ): -# """ -# Get spare parts for work orders under specific parent WO(s), -# including inventory and PR/PO data. -# """ - -# # Normalize parent_num to array for SQL ANY() - -# data_query = text(""" -# WITH selected_wo AS ( -# SELECT -# wonum, -# xx_parent, -# location_tag, -# assetnum, -# siteid, -# reportdate -# FROM public.wo_maxim -# WHERE xx_parent = ANY(:parent_nums) -# ), -# wo_materials AS ( -# SELECT -# wm.wonum, -# wm.itemnum, -# wm.itemqty, -# wm.inv_itemnum, -# wm.inv_location, -# wm.inv_curbaltotal, -# wm.inv_avgcost, -# sw.location_tag -# FROM public.wo_maxim_material wm -# JOIN selected_wo sw ON wm.wonum = sw.wonum -# ), -# -- PR Lines -# pr_lines AS ( -# SELECT -# pl.item_num, -# h.num AS pr_number, -# h.issue_date AS pr_issue_date, -# h.status AS pr_status, -# pl.qty_ordered AS pr_qty_ordered, -# pl.qty_requested AS pr_qty_requested -# FROM public.maximo_sparepart_pr_po h -# JOIN public.maximo_sparepart_pr_po_line pl -# ON h.num = pl.num -# WHERE h.type = 'PR' -# AND EXTRACT(YEAR FROM h.issue_date) >= 2019 -# ), -# -- PO Lines -# po_lines AS ( -# SELECT -# pl.item_num, -# h.num AS po_number, -# h.estimated_arrival_date AS po_estimated_arrival_date, -# h.vendeliverydate AS po_vendeliverydate, -# h.receipts AS po_receipt, -# h.status AS po_status, -# pl.qty_ordered AS po_qty_ordered, -# pl.qty_received AS po_qty_received -# FROM public.maximo_sparepart_pr_po h -# JOIN public.maximo_sparepart_pr_po_line pl -# ON h.num = pl.num -# WHERE h.type = 'PO' -# AND (h.receipts = 'NONE') -# AND (h.status IS NOT NULL) -# ), -# -- Item Descriptions -# item_descriptions AS ( -# SELECT DISTINCT -# item_num, -# FIRST_VALUE(description) OVER ( -# PARTITION BY item_num -# ORDER BY created_at DESC NULLS LAST -# ) AS description -# FROM public.maximo_sparepart_pr_po_line -# WHERE description IS NOT NULL -# ), -# -- Unified PR/PO data -# pr_po_unified AS ( -# SELECT -# pr.item_num, -# pr.pr_number, -# pr.pr_issue_date, -# pr.pr_qty_ordered, -# pr.pr_status, -# po.po_number, -# COALESCE(po.po_qty_ordered, 0) AS po_qty_ordered, -# COALESCE(po.po_qty_received, 0) AS po_qty_received, -# po.po_estimated_arrival_date, -# po.po_vendeliverydate, -# po.po_receipt, -# po.po_status, -# CASE WHEN po.po_number IS NOT NULL THEN 'YES' ELSE 'NO' END AS po_exists -# FROM pr_lines pr -# LEFT JOIN po_lines po -# ON pr.item_num = po.item_num -# AND pr.pr_number = po.po_number -# ), -# -- Aggregate PR/PO info -# pr_po_agg AS ( -# SELECT -# item_num, -# SUM(COALESCE(pr_qty_ordered, 0)) AS total_pr_qty, -# SUM(COALESCE(po_qty_ordered, 0)) AS total_po_qty, -# SUM(COALESCE(po_qty_received, 0)) AS total_po_received, -# JSON_AGG( -# JSON_BUILD_OBJECT( -# 'pr_number', pr_number, -# 'pr_issue_date', pr_issue_date, -# 'pr_qty_requested', pr_qty_ordered, -# 'pr_status', pr_status, -# 'po_exists', po_exists, -# 'po_qty_ordered', po_qty_ordered, -# 'po_qty_received', po_qty_received, -# 'po_estimated_arrival_date', po_estimated_arrival_date, -# 'po_vendeliverydate', po_vendeliverydate, -# 'po_receipt', po_receipt, -# 'po_status', po_status -# ORDER BY pr_issue_date DESC -# ) AS pr_po_details -# FROM pr_po_unified -# GROUP BY item_num - -# SELECT -# wm.itemnum, -# COALESCE(id.description, 'No description available') AS item_description, -# SUM(wm.itemqty) AS total_required_for_oh, -# COALESCE(MAX(wm.inv_curbaltotal), 0) AS current_balance_total, -# COALESCE(ap.total_pr_qty, 0) AS total_pr_qty, -# COALESCE(ap.total_po_qty, 0) AS total_po_qty, -# COALESCE(ap.total_po_received, 0) AS total_po_received, -# ap.pr_po_details -# FROM wo_materials wm -# LEFT JOIN item_descriptions id -# ON wm.itemnum = id.item_num -# LEFT JOIN pr_po_agg ap -# ON wm.itemnum = ap.item_num -# GROUP BY -# wm.itemnum, id.description, -# ap.total_pr_qty, ap.total_po_qty, ap.total_po_received, ap.pr_po_details -# ORDER BY wm.itemnum; -# """) - - -# for row in rows: -# spare_parts.append({ - - - async def get_spareparts_paginated(*, db_session, collector_db_session): - """ - Get paginated spare parts with usage, inventory, and PR/PO information. - Uses two queries: one for data, one for total count. - - Args: - db_session: SQLAlchemy database session - page (int): Page number (1-based) - items_per_page (int): Number of items per page - """ - # calculate limit/offset - - # wo_materials AS ( - # SELECT - # wm.wonum, - # wm.itemnum, - # wm.itemqty, - # wm.inv_itemnum, - # wm.inv_location, - # wm.inv_curbaltotal, - # wm.inv_avgcost, - # sw.asset_location as location_tag - # FROM public.wo_maxim_material wm - # JOIN oh_workorders sw ON wm.wonum = sw.wonum - # ), - - # ----------------------------- - # Query #1: Fetch paginated rows - # ----------------------------- - data_query = text(""" - WITH oh_workorders AS ( - SELECT DISTINCT wonum, asset_location, asset_unit - FROM public.wo_maximo ma - WHERE ma.xx_parent IN ('155026', '155027', '155029', '155030') - ), - wo_materials AS ( - SELECT - wm.wonum, - wm.itemnum, - wm.itemqty, - wm.inv_location AS inv_location, - wm.inv_curbaltotal AS inv_curbaltotal, - wm.inv_avgcost AS inv_avgcost, - sw.asset_location as location_tag - FROM public.wo_maximo_material wm - JOIN oh_workorders sw ON wm.wonum = sw.wonum - ), - location_sparepart_stats AS ( - SELECT location_tag, itemnum, - COUNT(DISTINCT wonum) as total_wo_count, - SUM(itemqty) as total_qty_used, - AVG(itemqty) as avg_qty_per_wo, - MIN(itemqty) as min_qty_used, - MAX(itemqty) as max_qty_used - FROM wo_materials - GROUP BY location_tag, itemnum - HAVING SUM(itemqty) > 0 - ), - pr_lines AS ( - SELECT - pl.item_num, - h.num as pr_number, - h.issue_date as pr_issue_date, - h.status as pr_status, - pl.qty_ordered as pr_qty_ordered, - pl.qty_requested as pr_qty_requested - FROM public.maximo_sparepart_pr_po h - JOIN public.maximo_sparepart_pr_po_line pl ON h.num = pl.num - WHERE h.type = 'PR' AND EXTRACT(YEAR FROM h.issue_date) >= 2023 - ), - item_descriptions AS ( - SELECT DISTINCT - item_num, - FIRST_VALUE(description) OVER ( - PARTITION BY item_num - ORDER BY created_at DESC NULLS LAST - ) as description - FROM public.maximo_sparepart_pr_po_line - WHERE description IS NOT NULL - ), - po_lines AS ( - SELECT - pl.item_num, - h.num as po_number, - h.estimated_arrival_date as po_estimated_arrival_date, - h.vendeliverydate as po_vendeliverydate, - h.receipts as po_receipt, - h.status as po_status, - pl.qty_ordered as po_qty_ordered, - pl.qty_received as po_qty_received - FROM public.maximo_sparepart_pr_po h - JOIN public.maximo_sparepart_pr_po_line pl ON h.num = pl.num - WHERE h.type = 'PO' - AND (h.receipts = 'NONE') - AND (h.status IS NOT NULL) - ), - pr_po_unified AS ( - SELECT - pr.item_num, - pr.pr_number, - pr.pr_issue_date, - pr.pr_qty_ordered, - pr.pr_status, - po.po_number, - COALESCE(po.po_qty_ordered,0) as po_qty_ordered, - COALESCE(po.po_qty_received,0) as po_qty_received, - po.po_estimated_arrival_date, - po.po_vendeliverydate, - po.po_receipt, - po.po_status, - CASE WHEN po.po_number IS NOT NULL THEN 'YES' ELSE 'NO' END as po_exists - FROM pr_lines pr - LEFT JOIN po_lines po - ON pr.item_num = po.item_num - AND pr.pr_number = po.po_number - ), - pr_po_agg AS ( - SELECT - item_num, - SUM(COALESCE(pr_qty_ordered,0)) as total_pr_qty, - SUM(COALESCE(po_qty_ordered,0)) as total_po_qty, - SUM(COALESCE(po_qty_received,0)) as total_po_received, - JSON_AGG( - JSON_BUILD_OBJECT( - 'pr_number', pr_number, - 'pr_issue_date', pr_issue_date, - 'pr_qty_requested', pr_qty_ordered, - 'pr_status', pr_status, - 'po_exists', po_exists, - 'po_qty_ordered', po_qty_ordered, - 'po_qty_received', po_qty_received, - 'po_estimated_arrival_date', po_estimated_arrival_date, - 'po_vendeliverydate', po_vendeliverydate, - 'po_receipt', po_receipt, - 'po_status', po_status - ) ORDER BY pr_issue_date DESC - ) as pr_po_details - FROM pr_po_unified - GROUP BY item_num - ), - inv_summary AS ( - SELECT - itemnum, - MAX(inv_curbaltotal) AS total_curbaltotal, - AVG(inv_avgcost) AS avg_cost - FROM wo_materials - GROUP BY itemnum - ) - SELECT - lss.itemnum, - COALESCE(id.description, 'No description available') as item_description, - lss.total_wo_count, - lss.total_qty_used, - ROUND(CAST(lss.avg_qty_per_wo AS NUMERIC), 2) as avg_qty_per_wo, - lss.min_qty_used, - lss.max_qty_used, - COALESCE(i.total_curbaltotal,0) as current_balance_total, - COALESCE(ap.total_pr_qty,0) as total_pr_qty, - COALESCE(ap.total_po_qty,0) as total_po_qty, - COALESCE(ap.total_po_received,0) as total_po_received, - ap.pr_po_details - FROM location_sparepart_stats lss - LEFT JOIN item_descriptions id ON lss.itemnum = id.item_num - LEFT JOIN inv_summary i ON lss.itemnum = i.itemnum - LEFT JOIN pr_po_agg ap ON lss.itemnum = ap.item_num - ORDER BY lss.location_tag, lss.itemnum; - """) - + data_query = text("\n WITH oh_workorders 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 ),\n wo_materials AS (\n SELECT \n wm.wonum,\n wm.itemnum,\n wm.itemqty,\n wm.inv_location AS inv_location,\n wm.inv_curbaltotal AS inv_curbaltotal,\n wm.inv_avgcost AS inv_avgcost,\n sw.asset_location as location_tag\n FROM public.wo_maximo_material wm\n JOIN oh_workorders sw ON wm.wonum = sw.wonum\n ),\n location_sparepart_stats AS (\n SELECT location_tag, itemnum,\n COUNT(DISTINCT wonum) as total_wo_count,\n SUM(itemqty) as total_qty_used,\n AVG(itemqty) as avg_qty_per_wo,\n MIN(itemqty) as min_qty_used,\n MAX(itemqty) as max_qty_used\n FROM wo_materials\n GROUP BY location_tag, itemnum\n HAVING SUM(itemqty) > 0\n ),\n pr_lines AS (\n SELECT \n pl.item_num,\n h.num as pr_number,\n h.issue_date as pr_issue_date,\n h.status as pr_status,\n pl.qty_ordered as pr_qty_ordered,\n pl.qty_requested as pr_qty_requested\n FROM public.maximo_sparepart_pr_po h\n JOIN public.maximo_sparepart_pr_po_line pl ON h.num = pl.num\n WHERE h.type = 'PR' AND EXTRACT(YEAR FROM h.issue_date) >= 2023\n ),\n item_descriptions AS (\n SELECT DISTINCT\n item_num,\n FIRST_VALUE(description) OVER (\n PARTITION BY item_num \n ORDER BY created_at DESC NULLS LAST\n ) as description\n FROM public.maximo_sparepart_pr_po_line\n WHERE description IS NOT NULL\n ),\n po_lines AS (\n SELECT \n pl.item_num,\n h.num as po_number,\n h.estimated_arrival_date as po_estimated_arrival_date,\n h.vendeliverydate as po_vendeliverydate,\n h.receipts as po_receipt,\n h.status as po_status,\n pl.qty_ordered as po_qty_ordered,\n pl.qty_received as po_qty_received\n FROM public.maximo_sparepart_pr_po h\n JOIN public.maximo_sparepart_pr_po_line pl ON h.num = pl.num\n WHERE h.type = 'PO'\n AND (h.receipts = 'NONE')\n AND (h.status IS NOT NULL)\n ),\n pr_po_unified AS (\n SELECT \n pr.item_num,\n pr.pr_number,\n pr.pr_issue_date,\n pr.pr_qty_ordered,\n pr.pr_status,\n po.po_number,\n COALESCE(po.po_qty_ordered,0) as po_qty_ordered,\n COALESCE(po.po_qty_received,0) as po_qty_received,\n po.po_estimated_arrival_date,\n po.po_vendeliverydate,\n po.po_receipt,\n po.po_status,\n CASE WHEN po.po_number IS NOT NULL THEN 'YES' ELSE 'NO' END as po_exists\n FROM pr_lines pr\n LEFT JOIN po_lines po \n ON pr.item_num = po.item_num \n AND pr.pr_number = po.po_number\n ),\n pr_po_agg AS (\n SELECT \n item_num,\n SUM(COALESCE(pr_qty_ordered,0)) as total_pr_qty,\n SUM(COALESCE(po_qty_ordered,0)) as total_po_qty,\n SUM(COALESCE(po_qty_received,0)) as total_po_received,\n JSON_AGG(\n JSON_BUILD_OBJECT(\n 'pr_number', pr_number,\n 'pr_issue_date', pr_issue_date,\n 'pr_qty_requested', pr_qty_ordered,\n 'pr_status', pr_status,\n 'po_exists', po_exists,\n 'po_qty_ordered', po_qty_ordered,\n 'po_qty_received', po_qty_received,\n 'po_estimated_arrival_date', po_estimated_arrival_date,\n 'po_vendeliverydate', po_vendeliverydate,\n 'po_receipt', po_receipt,\n 'po_status', po_status\n ) ORDER BY pr_issue_date DESC\n ) as pr_po_details\n FROM pr_po_unified\n GROUP BY item_num\n ),\n inv_summary AS (\n SELECT \n itemnum,\n MAX(inv_curbaltotal) AS total_curbaltotal,\n AVG(inv_avgcost) AS avg_cost\n FROM wo_materials\n GROUP BY itemnum\n )\n SELECT \n lss.itemnum,\n COALESCE(id.description, 'No description available') as item_description,\n lss.total_wo_count,\n lss.total_qty_used,\n ROUND(CAST(lss.avg_qty_per_wo AS NUMERIC), 2) as avg_qty_per_wo,\n lss.min_qty_used,\n lss.max_qty_used,\n COALESCE(i.total_curbaltotal,0) as current_balance_total,\n COALESCE(ap.total_pr_qty,0) as total_pr_qty,\n COALESCE(ap.total_po_qty,0) as total_po_qty,\n COALESCE(ap.total_po_received,0) as total_po_received,\n ap.pr_po_details\n FROM location_sparepart_stats lss\n LEFT JOIN item_descriptions id ON lss.itemnum = id.item_num\n LEFT JOIN inv_summary i ON lss.itemnum = i.itemnum\n LEFT JOIN pr_po_agg ap ON lss.itemnum = ap.item_num\n ORDER BY lss.location_tag, lss.itemnum;\n ") overhaul = await get_overview_overhaul(db_session=db_session) - standard_overhaul = await get_standard_scope_by_session_id(db_session=db_session, collector_db=collector_db_session, overhaul_session_id=overhaul['overhaul']['id']) - asset_locations = [eq.location_tag for eq in standard_overhaul] - - rows = await collector_db_session.execute( - data_query, - {"asset_locations": asset_locations} - ) - - sparepart_remark = (await db_session.execute( - apply_not_deleted_filter(select(SparepartRemark), SparepartRemark) - )).scalars().all() - + rows = await collector_db_session.execute(data_query, {'asset_locations': asset_locations}) + sparepart_remark = (await db_session.execute(apply_not_deleted_filter(select(SparepartRemark), SparepartRemark))).scalars().all() sparepart_remark_dict = {item.itemnum: item.remark for item in sparepart_remark} - - spare_parts = [] for row in rows: - spare_parts.append({ - "item_num": row.itemnum, - "description": row.item_description, - "remark": sparepart_remark_dict.get(row.itemnum, ""), - "current_balance_total": float(row.current_balance_total) if row.current_balance_total else 0.0, - "total_required_for_oh": float(row.avg_qty_per_wo), - "total_pr_qty": row.total_pr_qty, - "total_po_qty": row.total_po_qty, - "total_po_received": row.total_po_received, - "pr_po_details": row.pr_po_details - }) - + spare_parts.append({'item_num': row.itemnum, 'description': row.item_description, 'remark': sparepart_remark_dict.get(row.itemnum, ''), 'current_balance_total': float(row.current_balance_total) if row.current_balance_total else 0.0, 'total_required_for_oh': float(row.avg_qty_per_wo), 'total_pr_qty': row.total_pr_qty, 'total_po_qty': row.total_po_qty, 'total_po_received': row.total_po_received, 'pr_po_details': row.pr_po_details}) return spare_parts - class SparepartManager: - """Manages sparepart availability and procurement for overhaul optimization""" - + def __init__(self, analysis_start_date: date, analysis_window_months: int): self.analysis_start_date = analysis_start_date self.analysis_window_months = analysis_window_months self.logger = log - - # Storage for sparepart data self.sparepart_stocks: Dict[str, SparepartStock] = {} self.equipment_requirements: Dict[str, List[SparepartRequirement]] = {} self.procurement_records: List[ProcurementRecord] = [] - - # Monthly projected stocks self.projected_stocks: Dict[str, List[int]] = {} def add_sparepart_stock(self, stock: SparepartStock): - """Add sparepart stock information""" self.sparepart_stocks[stock.sparepart_id] = stock def add_equipment_requirements(self, equipment_tag: str, requirements: List[SparepartRequirement]): - """Add sparepart requirements for equipment""" self.equipment_requirements[equipment_tag] = requirements def add_procurement_record(self, record: ProcurementRecord): - """Add procurement record (PO/PR)""" self.procurement_records.append(record) def _calculate_monthly_deliveries(self) -> Dict[str, Dict[int, int]]: - """Calculate expected deliveries for each sparepart by month""" deliveries = defaultdict(lambda: defaultdict(int)) - for record in self.procurement_records: if record.status in [ProcurementStatus.ORDERED, ProcurementStatus.PLANNED]: - # Skip records with no expected delivery date (e.g., still PR stage) if not record.expected_delivery_date: continue - - months_from_start = ( - (record.expected_delivery_date.year - self.analysis_start_date.year) * 12 + - (record.expected_delivery_date.month - self.analysis_start_date.month) - ) - + months_from_start = (record.expected_delivery_date.year - self.analysis_start_date.year) * 12 + (record.expected_delivery_date.month - self.analysis_start_date.month) if 0 <= months_from_start < self.analysis_window_months: deliveries[record.sparepart_id][months_from_start] += record.quantity - return deliveries - def _project_monthly_stocks(self) -> Dict[str, List[int]]: - """Project sparepart stock levels for each month""" projected_stocks = {} monthly_deliveries = self._calculate_monthly_deliveries() - for sparepart_id, stock_info in self.sparepart_stocks.items(): monthly_stock = [] current_stock = float(stock_info.current_stock) - for month in range(self.analysis_window_months): - # Add any deliveries for this month if sparepart_id in monthly_deliveries and month in monthly_deliveries[sparepart_id]: current_stock += float(monthly_deliveries[sparepart_id][month]) - monthly_stock.append(current_stock) - - # Note: We don't subtract usage here yet - that will be done during optimization - projected_stocks[sparepart_id] = monthly_stock - self.projected_stocks = projected_stocks return projected_stocks - def check_sparepart_availability(self, equipment_tag: str, target_month: int, - consider_other_overhauls: List[Tuple[str, int]] = None) -> Dict: - """ - Check if spareparts are available for equipment overhaul at target month - - Args: - equipment_tag: Equipment location tag - target_month: Month when overhaul is planned (0-based) - consider_other_overhauls: List of (equipment_tag, month) for other planned overhauls - - Returns: - Dict with availability status and details - """ + def check_sparepart_availability(self, equipment_tag: str, target_month: int, consider_other_overhauls: List[Tuple[str, int]]=None) -> Dict: if equipment_tag not in self.equipment_requirements: - return { - 'available': True, - 'message': f'No sparepart requirements defined for {equipment_tag}', - 'missing_parts': [], - 'procurement_needed': [], - 'total_procurement_cost': 0, - 'can_proceed_with_delays': True, - 'pr_po_summary': { - 'existing_orders': [], - 'required_new_orders': [], - 'total_existing_value': 0, - 'total_new_orders_value': 0 - } - } - + return {'available': True, 'message': f'No sparepart requirements defined for {equipment_tag}', 'missing_parts': [], 'procurement_needed': [], 'total_procurement_cost': 0, 'can_proceed_with_delays': True, 'pr_po_summary': {'existing_orders': [], 'required_new_orders': [], 'total_existing_value': 0, 'total_new_orders_value': 0}} requirements = self.equipment_requirements[equipment_tag] missing_parts = [] procurement_needed = [] total_procurement_cost = 0 - - # Calculate stock after considering other overhauls adjusted_stocks = self._calculate_adjusted_stocks(target_month, consider_other_overhauls or []) - existing_orders = self._get_existing_orders_for_month(target_month) - - pr_po_summary = { - 'existing_orders': [], - 'required_new_orders': [], - 'total_existing_value': 0, - 'total_new_orders_value': 0, - 'orders_by_status': { - 'planned': [], - 'ordered': [], - 'received': [], - 'cancelled': [] - } - } - - + pr_po_summary = {'existing_orders': [], 'required_new_orders': [], 'total_existing_value': 0, 'total_new_orders_value': 0, 'orders_by_status': {'planned': [], 'ordered': [], 'received': [], 'cancelled': []}} for requirement in requirements: sparepart_id = requirement.sparepart_id needed_quantity = requirement.quantity_required sparepart_name = requirement.sparepart_name - sparepart_remark= requirement.remark - unit_cost = requirement.avg_cost if requirement.avg_cost > 0 else requirement.unit_cost - + sparepart_remark = requirement.remark + unit_cost = requirement.avg_cost if requirement.avg_cost > 0 else requirement.unit_cost current_stock = adjusted_stocks.get(sparepart_id, 0) - - existing_sparepart_orders = [order for order in existing_orders - if order.sparepart_id == sparepart_id] - - total_ordered_quantity = sum(order.quantity for order in existing_sparepart_orders) - + existing_sparepart_orders = [order for order in existing_orders if order.sparepart_id == sparepart_id] + total_ordered_quantity = sum((order.quantity for order in existing_sparepart_orders)) effective_stock = current_stock + total_ordered_quantity - if effective_stock >= needed_quantity: - # Sufficient stock available (including from existing orders) if existing_sparepart_orders: - # Add existing order info to summary for order in existing_sparepart_orders: - order_info = { - 'po_pr_id': order.po_pr_id, - 'sparepart_id': sparepart_id, - 'sparepart_name': sparepart_name, - 'quantity': order.quantity, - 'unit_cost': order.unit_cost, - 'total_cost': order.total_cost, - 'order_date': order.order_date.isoformat(), - 'expected_delivery_date': order.expected_delivery_date.isoformat(), - 'status': order.status.value, - 'months_until_delivery': self._calculate_months_until_delivery(order.expected_delivery_date, target_month), - 'is_on_time': self._is_delivery_on_time(order.expected_delivery_date, target_month), - 'usage': 'covers_requirement', - 'remark': sparepart_remark - } + order_info = {'po_pr_id': order.po_pr_id, 'sparepart_id': sparepart_id, 'sparepart_name': sparepart_name, 'quantity': order.quantity, 'unit_cost': order.unit_cost, 'total_cost': order.total_cost, 'order_date': order.order_date.isoformat(), 'expected_delivery_date': order.expected_delivery_date.isoformat(), 'status': order.status.value, 'months_until_delivery': self._calculate_months_until_delivery(order.expected_delivery_date, target_month), 'is_on_time': self._is_delivery_on_time(order.expected_delivery_date, target_month), 'usage': 'covers_requirement', 'remark': sparepart_remark} pr_po_summary['existing_orders'].append(order_info) pr_po_summary['total_existing_value'] += order.total_cost pr_po_summary['orders_by_status'][order.status.value].append(order_info) else: - # Insufficient stock - need additional procurement shortage = needed_quantity - effective_stock - - missing_parts.append({ - 'sparepart_id': sparepart_id, - 'sparepart_name': sparepart_name, - 'remark': sparepart_remark, - 'required': needed_quantity, - 'current_stock': current_stock, - 'ordered_quantity': total_ordered_quantity, - 'effective_available': effective_stock, - 'shortage': shortage, - 'criticality': "warning", - 'existing_orders': len(existing_sparepart_orders), - 'existing_orders_details': [ - { - 'po_pr_id': order.po_pr_id, - 'quantity': order.quantity, - 'status': order.status.value, - 'expected_delivery': order.expected_delivery_date.isoformat(), - 'is_on_time': self._is_delivery_on_time(order.expected_delivery_date, target_month) - } for order in existing_sparepart_orders - ] - }) - - # Calculate additional procurement needed + missing_parts.append({'sparepart_id': sparepart_id, 'sparepart_name': sparepart_name, 'remark': sparepart_remark, 'required': needed_quantity, 'current_stock': current_stock, 'ordered_quantity': total_ordered_quantity, 'effective_available': effective_stock, 'shortage': shortage, 'criticality': 'warning', 'existing_orders': len(existing_sparepart_orders), 'existing_orders_details': [{'po_pr_id': order.po_pr_id, 'quantity': order.quantity, 'status': order.status.value, 'expected_delivery': order.expected_delivery_date.isoformat(), 'is_on_time': self._is_delivery_on_time(order.expected_delivery_date, target_month)} for order in existing_sparepart_orders]}) if sparepart_id in self.sparepart_stocks: procurement_cost = shortage * unit_cost total_procurement_cost += procurement_cost - - # Calculate when to order (considering lead time) order_month = max(0, target_month - requirement.lead_time) order_date = self.analysis_start_date + timedelta(days=order_month * 30) expected_delivery = order_date + timedelta(days=requirement.lead_time * 30) - - new_order = { - 'sparepart_id': sparepart_id, - 'sparepart_name': sparepart_name, - 'remark': sparepart_remark, - 'quantity_needed': shortage, - 'unit_cost': unit_cost, - 'total_cost': procurement_cost, - 'order_by_month': order_month, - 'recommended_order_date': order_date.isoformat(), - 'expected_delivery_date': expected_delivery.isoformat(), - 'lead_time_months': requirement.lead_time, - 'criticality': "warning", - 'urgency': self._calculate_urgency(order_month, target_month), - 'reason': f'Additional {shortage} units needed beyond existing orders' - } - + new_order = {'sparepart_id': sparepart_id, 'sparepart_name': sparepart_name, 'remark': sparepart_remark, 'quantity_needed': shortage, 'unit_cost': unit_cost, 'total_cost': procurement_cost, 'order_by_month': order_month, 'recommended_order_date': order_date.isoformat(), 'expected_delivery_date': expected_delivery.isoformat(), 'lead_time_months': requirement.lead_time, 'criticality': 'warning', 'urgency': self._calculate_urgency(order_month, target_month), 'reason': f'Additional {shortage} units needed beyond existing orders'} procurement_needed.append(new_order) pr_po_summary['required_new_orders'].append(new_order) pr_po_summary['total_new_orders_value'] += procurement_cost - - # Check for critical parts critical_missing = [p for p in missing_parts if p['criticality'] == 'critical'] - - # Generate comprehensive summary - availability_summary = self._generate_comprehensive_availability_message( - missing_parts, critical_missing, pr_po_summary - ) - - return { - 'available': len(critical_missing) == 0, - 'total_missing_parts': len(missing_parts), - 'critical_missing_parts': len(critical_missing), - 'missing_parts': missing_parts, - 'procurement_needed': procurement_needed, - 'total_procurement_cost': total_procurement_cost, - 'can_proceed_with_delays': len(critical_missing) == 0, - 'message': availability_summary['message'], - 'detailed_message': availability_summary['detailed_message'], - 'pr_po_summary': pr_po_summary, - 'recommendations': self._generate_procurement_recommendations(pr_po_summary, target_month) - } - + availability_summary = self._generate_comprehensive_availability_message(missing_parts, critical_missing, pr_po_summary) + return {'available': len(critical_missing) == 0, 'total_missing_parts': len(missing_parts), 'critical_missing_parts': len(critical_missing), 'missing_parts': missing_parts, 'procurement_needed': procurement_needed, 'total_procurement_cost': total_procurement_cost, 'can_proceed_with_delays': len(critical_missing) == 0, 'message': availability_summary['message'], 'detailed_message': availability_summary['detailed_message'], 'pr_po_summary': pr_po_summary, 'recommendations': self._generate_procurement_recommendations(pr_po_summary, target_month)} + def _calculate_months_until_delivery(self, delivery_date: date, target_month: int) -> int: - """Calculate months from target month to delivery date""" target_date = self.analysis_start_date + timedelta(days=target_month * 30) months_diff = (delivery_date.year - target_date.year) * 12 + (delivery_date.month - target_date.month) return months_diff def _calculate_urgency(self, order_month: int, target_month: int) -> str: - """Calculate urgency level for new procurement""" time_gap = target_month - order_month - if time_gap <= 1: - return "URGENT" + return 'URGENT' elif time_gap <= 3: - return "HIGH" + return 'HIGH' elif time_gap <= 6: - return "MEDIUM" + return 'MEDIUM' else: - return "LOW" - + return 'LOW' + def _generate_procurement_recommendations(self, pr_po_summary: Dict, target_month: int) -> List[Dict]: - """Generate procurement recommendations based on analysis""" recommendations = [] - - # Check for late deliveries - late_orders = [order for order in pr_po_summary['existing_orders'] - if not order['is_on_time']] - + late_orders = [order for order in pr_po_summary['existing_orders'] if not order['is_on_time']] if late_orders: - recommendations.append({ - 'type': 'LATE_DELIVERY_WARNING', - 'priority': 'HIGH', - 'message': f"{len(late_orders)} existing orders will deliver late", - 'details': [f"PO/PR {order['po_pr_id']} for {order['sparepart_name']}" - for order in late_orders], - 'action': 'Expedite delivery or find alternative suppliers' - }) - - # Check for urgent new orders - urgent_orders = [order for order in pr_po_summary['required_new_orders'] - if order['urgency'] == 'URGENT'] - + recommendations.append({'type': 'LATE_DELIVERY_WARNING', 'priority': 'HIGH', 'message': f'{len(late_orders)} existing orders will deliver late', 'details': [f"PO/PR {order['po_pr_id']} for {order['sparepart_name']}" for order in late_orders], 'action': 'Expedite delivery or find alternative suppliers'}) + urgent_orders = [order for order in pr_po_summary['required_new_orders'] if order['urgency'] == 'URGENT'] if urgent_orders: - recommendations.append({ - 'type': 'URGENT_PROCUREMENT', - 'priority': 'CRITICAL', - 'message': f"{len(urgent_orders)} spareparts need immediate ordering", - 'details': [f"{order['sparepart_name']}: {order['quantity_needed']} units" - for order in urgent_orders], - 'action': 'Place orders immediately or consider expedited delivery' - }) - - # Check for cancelled orders + recommendations.append({'type': 'URGENT_PROCUREMENT', 'priority': 'CRITICAL', 'message': f'{len(urgent_orders)} spareparts need immediate ordering', 'details': [f"{order['sparepart_name']}: {order['quantity_needed']} units" for order in urgent_orders], 'action': 'Place orders immediately or consider expedited delivery'}) cancelled_orders = pr_po_summary['orders_by_status'].get('cancelled', []) if cancelled_orders: - recommendations.append({ - 'type': 'CANCELLED_ORDER_IMPACT', - 'priority': 'MEDIUM', - 'message': f"{len(cancelled_orders)} cancelled orders may affect availability", - 'details': [f"Cancelled: PO/PR {order['po_pr_id']} for {order['sparepart_name']}" - for order in cancelled_orders], - 'action': 'Review impact and place replacement orders if necessary' - }) - - # Budget recommendations + recommendations.append({'type': 'CANCELLED_ORDER_IMPACT', 'priority': 'MEDIUM', 'message': f'{len(cancelled_orders)} cancelled orders may affect availability', 'details': [f"Cancelled: PO/PR {order['po_pr_id']} for {order['sparepart_name']}" for order in cancelled_orders], 'action': 'Review impact and place replacement orders if necessary'}) total_investment = pr_po_summary['total_existing_value'] + pr_po_summary['total_new_orders_value'] if total_investment > 0: - recommendations.append({ - 'type': 'BUDGET_SUMMARY', - 'priority': 'INFO', - 'message': f'Total sparepart investment: Rp. {total_investment:,.2f}', - 'details': [ - f"Existing orders: Rp. {pr_po_summary['total_existing_value']:,.2f}", - f"Additional orders needed: Rp. {pr_po_summary['total_new_orders_value']:,.2f}" - ], - 'action': 'Ensure budget allocation for sparepart procurement' - }) - + recommendations.append({'type': 'BUDGET_SUMMARY', 'priority': 'INFO', 'message': f'Total sparepart investment: Rp. {total_investment:,.2f}', 'details': [f"Existing orders: Rp. {pr_po_summary['total_existing_value']:,.2f}", f"Additional orders needed: Rp. {pr_po_summary['total_new_orders_value']:,.2f}"], 'action': 'Ensure budget allocation for sparepart procurement'}) return recommendations - + def _is_delivery_on_time(self, delivery_date: datetime, target_month: int) -> bool: - """Check if delivery will arrive on time for target month""" target_date = self.analysis_start_date + timedelta(days=target_month * 30) del_time = delivery_date.date() if delivery_date else None - return del_time <= target_date if del_time else False def _calculate_adjusted_stocks(self, target_month: int, other_overhauls: List[Tuple[str, int]]) -> Dict[str, int]: - """Calculate stock levels after considering consumption from other planned overhauls""" adjusted_stocks = {} - for sparepart_id, monthly_stocks in self.projected_stocks.items(): if target_month < len(monthly_stocks): stock_at_month = monthly_stocks[target_month] - - # Subtract consumption from other overhauls happening at or before target month for other_equipment, other_month in other_overhauls: if other_month <= target_month and other_equipment in self.equipment_requirements: for req in self.equipment_requirements[other_equipment]: if req.sparepart_id == sparepart_id: stock_at_month -= req.quantity_required - adjusted_stocks[sparepart_id] = max(0, stock_at_month) - return adjusted_stocks - + def _get_existing_orders_for_month(self, target_month: int) -> List[ProcurementRecord]: - """Get existing PR/PO orders that could supply spareparts for target month""" target_date = self.analysis_start_date + timedelta(days=target_month * 30) - relevant_orders = [] for record in self.procurement_records: date_expected_delivery = record.expected_delivery_date.date() if record.expected_delivery_date else None - # Include orders that deliver before or around the target month - # and are not cancelled - if (record.status != ProcurementStatus.CANCELLED and date_expected_delivery and - date_expected_delivery <= target_date): # 15 days buffer + if record.status != ProcurementStatus.CANCELLED and date_expected_delivery and (date_expected_delivery <= target_date): relevant_orders.append(record) - return relevant_orders - - def _generate_comprehensive_availability_message(self, missing_parts: List[Dict], - critical_missing: List[Dict], - pr_po_summary: Dict) -> Dict: - """Generate comprehensive availability message with PR/PO details""" - + + def _generate_comprehensive_availability_message(self, missing_parts: List[Dict], critical_missing: List[Dict], pr_po_summary: Dict) -> Dict: if not missing_parts: if pr_po_summary['existing_orders']: message = f"All spareparts available through {len(pr_po_summary['existing_orders'])} existing orders" detailed_message = f"Total existing order value: Rp. {pr_po_summary['total_existing_value']:,.2f}" else: - message = "All spareparts available from current stock" - detailed_message = "No additional procurement required" + message = 'All spareparts available from current stock' + detailed_message = 'No additional procurement required' + elif critical_missing: + message = f'CRITICAL: {len(critical_missing)} critical spareparts missing. Overhaul cannot proceed.' + detailed_message = f"Additional procurement required: Rp. {pr_po_summary['total_new_orders_value']:,.2f}" else: - if critical_missing: - message = f"CRITICAL: {len(critical_missing)} critical spareparts missing. Overhaul cannot proceed." - detailed_message = f"Additional procurement required: Rp. {pr_po_summary['total_new_orders_value']:,.2f}" + message = f'WARNING: {len(missing_parts)} spareparts missing, but no critical parts.' + if pr_po_summary['total_new_orders_value'] > 0: + detailed_message = f"Additional procurement required: Rp. {pr_po_summary['total_new_orders_value']:,.2f}. " else: - message = f"WARNING: {len(missing_parts)} spareparts missing, but no critical parts." - if pr_po_summary['total_new_orders_value'] > 0: - detailed_message = f"Additional procurement required: Rp. {pr_po_summary['total_new_orders_value']:,.2f}. " - else: - detailed_message = "" - - if pr_po_summary['existing_orders']: - detailed_message += f"Existing orders cover some requirements (Rp. {pr_po_summary['total_existing_value']:,.2f})." - - return { - 'message': message, - 'detailed_message': detailed_message - } + detailed_message = '' + if pr_po_summary['existing_orders']: + detailed_message += f"Existing orders cover some requirements (Rp. {pr_po_summary['total_existing_value']:,.2f})." + return {'message': message, 'detailed_message': detailed_message} def _generate_availability_message(self, missing_parts: List[Dict], critical_missing: List[Dict]) -> str: - """Generate human-readable availability message""" if not missing_parts: - return "All spareparts available" - + return 'All spareparts available' if critical_missing: - return f"CRITICAL: {len(critical_missing)} critical spareparts missing. Overhaul cannot proceed." - - return f"WARNING: {len(missing_parts)} spareparts missing, but no critical parts. Overhaul can proceed with procurement." + return f'CRITICAL: {len(critical_missing)} critical spareparts missing. Overhaul cannot proceed.' + return f'WARNING: {len(missing_parts)} spareparts missing, but no critical parts. Overhaul can proceed with procurement.' def optimize_procurement_timing(self, planned_overhauls: List[Tuple[str, int]]) -> Dict: - """ - Optimize procurement timing for multiple equipment overhauls - - Args: - planned_overhauls: List of (equipment_tag, planned_month) tuples - - Returns: - Optimized procurement plan - """ procurement_plan = [] total_procurement_cost = 0 - - # Sort overhauls by month sorted_overhauls = sorted(planned_overhauls, key=lambda x: x[1]) - - # Track cumulative procurement needs processed_overhauls = [] - for equipment_tag, target_month in sorted_overhauls: - availability = self.check_sparepart_availability( - equipment_tag, target_month, processed_overhauls - ) - + availability = self.check_sparepart_availability(equipment_tag, target_month, processed_overhauls) for procurement in availability['procurement_needed']: - procurement_plan.append({ - 'equipment_tag': equipment_tag, - 'target_overhaul_month': target_month, - **procurement - }) + procurement_plan.append({'equipment_tag': equipment_tag, 'target_overhaul_month': target_month, **procurement}) total_procurement_cost += procurement['total_cost'] - processed_overhauls.append((equipment_tag, target_month)) - - # Group by order month for better planning procurement_by_month = defaultdict(list) for item in procurement_plan: procurement_by_month[item['order_by_month']].append(item) - - return { - 'total_procurement_cost': total_procurement_cost, - 'procurement_plan': procurement_plan, - 'procurement_by_month': dict(procurement_by_month), - # 'summary': self._generate_procurement_summary(procurement_plan) - } + return {'total_procurement_cost': total_procurement_cost, 'procurement_plan': procurement_plan, 'procurement_by_month': dict(procurement_by_month)} def _generate_procurement_summary(self, procurement_plan: List[Dict]) -> Dict: - """Generate procurement summary statistics""" if not procurement_plan: return {'message': 'No procurement needed'} - critical_items = [p for p in procurement_plan if p['criticality'] == 'critical'] total_items = len(procurement_plan) - total_cost = sum(p['total_cost'] for p in procurement_plan) - - # Group by supplier + total_cost = sum((p['total_cost'] for p in procurement_plan)) by_supplier = defaultdict(list) for item in procurement_plan: by_supplier[item['supplier']].append(item) - - return { - 'total_items': total_items, - 'critical_items': len(critical_items), - 'total_cost': total_cost, - 'unique_spareparts': len(set(p['sparepart_id'] for p in procurement_plan)), - 'suppliers_involved': len(by_supplier), - 'by_supplier': { - supplier: { - 'item_count': len(items), - 'total_cost': sum(i['total_cost'] for i in items) - } - for supplier, items in by_supplier.items() - } - } + return {'total_items': total_items, 'critical_items': len(critical_items), 'total_cost': total_cost, 'unique_spareparts': len(set((p['sparepart_id'] for p in procurement_plan))), 'suppliers_involved': len(by_supplier), 'by_supplier': {supplier: {'item_count': len(items), 'total_cost': sum((i['total_cost'] for i in items))} for supplier, items in by_supplier.items()}} def get_monthly_procurement_schedule(self) -> Dict[int, List[Dict]]: - """Get procurement schedule by month""" if not hasattr(self, '_monthly_schedule'): self._monthly_schedule = {} return self._monthly_schedule def update_projected_stocks_with_consumption(self, equipment_overhauls: List[Tuple[str, int]]): - """Update projected stocks considering sparepart consumption from overhauls""" - # Create a copy of projected stocks updated_stocks = {} - for sparepart_id, monthly_stocks in self.projected_stocks.items(): updated_stocks[sparepart_id] = monthly_stocks.copy() - - # Apply consumption from overhauls for equipment_tag, overhaul_month in equipment_overhauls: if equipment_tag in self.equipment_requirements: for requirement in self.equipment_requirements[equipment_tag]: sparepart_id = requirement.sparepart_id quantity_needed = requirement.quantity_required - if sparepart_id in updated_stocks: - # Reduce stock from overhaul month onwards for month in range(overhaul_month, len(updated_stocks[sparepart_id])): - updated_stocks[sparepart_id][month] = max( - 0, updated_stocks[sparepart_id][month] - quantity_needed - ) - + updated_stocks[sparepart_id][month] = max(0, updated_stocks[sparepart_id][month] - quantity_needed) return updated_stocks - -# Integration functions for database operations -async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_session, analysis_window_months = None) -> SparepartManager: - """Load sparepart data from database""" - # You'll need to implement these queries based on your database schema - # Get scope dates for analysis window - +async def load_sparepart_data_from_db(scope, prev_oh_scope, db_session, app_db_session, analysis_window_months=None) -> SparepartManager: analysis_start_date = prev_oh_scope.end_date - analysis_window_months = int(((scope.start_date - prev_oh_scope.end_date).days / 30) * 1.2) if not analysis_window_months else analysis_window_months - + analysis_window_months = int((scope.start_date - prev_oh_scope.end_date).days / 30 * 1.2) if not analysis_window_months else analysis_window_months sparepart_manager = SparepartManager(analysis_start_date, analysis_window_months) - - # Load sparepart stocks - # Example query - adjust based on your schema - query = text("""SELECT - wm.inv_itemnum AS itemnum, - wm.inv_itemsetid AS itemsetid, - wm.inv_location AS location, - MAX(wm.inv_curbaltotal) AS curbaltotal, - AVG(wm.inv_avgcost) AS avgcost, - COALESCE(mspl.description, 'No description available') AS description - FROM public.wo_maximo_material wm - LEFT JOIN public.maximo_sparepart_pr_po_line mspl - ON wm.inv_itemnum = mspl.item_num - WHERE wm.inv_itemnum IS NOT NULL - GROUP BY wm.inv_itemnum, wm.inv_itemsetid, wm.inv_location, mspl.description - """) - log.info("Fetch sparepart") + query = text("SELECT \n wm.inv_itemnum AS itemnum,\n wm.inv_itemsetid AS itemsetid,\n wm.inv_location AS location,\n MAX(wm.inv_curbaltotal) AS curbaltotal,\n AVG(wm.inv_avgcost) AS avgcost,\n COALESCE(mspl.description, 'No description available') AS description\n FROM public.wo_maximo_material wm\n LEFT JOIN public.maximo_sparepart_pr_po_line mspl \n ON wm.inv_itemnum = mspl.item_num\n WHERE wm.inv_itemnum IS NOT NULL\n GROUP BY wm.inv_itemnum, wm.inv_itemsetid, wm.inv_location, mspl.description\n ") + log.info('Fetch sparepart') sparepart_stocks_query = await db_session.execute(query) - - sparepart_remark = (await app_db_session.execute( - apply_not_deleted_filter(select(SparepartRemark), SparepartRemark) - )).scalars().all() - + sparepart_remark = (await app_db_session.execute(apply_not_deleted_filter(select(SparepartRemark), SparepartRemark))).scalars().all() sparepart_remark_dict = {item.itemnum: item.remark for item in sparepart_remark} - for stock_record in sparepart_stocks_query: - stock = SparepartStock( - sparepart_id=stock_record.itemnum, - remark=sparepart_remark_dict.get(stock_record.itemnum), - sparepart_name=stock_record.description, - current_stock=stock_record.curbaltotal, - unit_cost=stock_record.avgcost, - location=stock_record.location or "Unknown", - ) + stock = SparepartStock(sparepart_id=stock_record.itemnum, remark=sparepart_remark_dict.get(stock_record.itemnum), sparepart_name=stock_record.description, current_stock=stock_record.curbaltotal, unit_cost=stock_record.avgcost, location=stock_record.location or 'Unknown') sparepart_manager.add_sparepart_stock(stock) - - - # query = text(""" - # WITH target_wo AS ( - # -- Work orders from the given parent(s) - # SELECT - # wonum, - # xx_parent, - # location_tag AS asset_location - # FROM public.wo_maxim - # WHERE xx_parent = ANY(:parent_nums) - # ), - # target_materials AS ( - # -- Materials directly linked to target WOs (new requirement data) - # SELECT - # tw.asset_location, - # wm.itemnum, - # wm.inv_avgcost - # SUM(wm.itemqty) AS total_qty_required - # FROM public.wo_maxim_material wm - # JOIN target_wo tw ON wm.wonum = tw.wonum - # WHERE wm.itemnum IS NOT NULL - # GROUP BY tw.asset_location, wm.itemnum - # ), - - # -- Historical OH work orders (for lead time reference) - # oh_workorders AS ( - # SELECT DISTINCT - # wonum, - # asset_location - # FROM public.wo_staging_maximo_2 - # WHERE worktype = 'OH' - # AND asset_location IS NOT NULL - # AND asset_unit IN ('3', '00') - # ), - # sparepart_usage AS ( - # SELECT - # oh.asset_location, - # mwm.itemnum, - # mwm.itemqty, - # mwm.wonum - # FROM oh_workorders oh - # INNER JOIN public.wo_maxim_material mwm - # ON oh.wonum = mwm.wonum - # ), - # location_sparepart_stats AS ( - # SELECT - # asset_location, - # itemnum, - # COUNT(DISTINCT wonum) as total_wo_count, - # SUM(itemqty) as total_qty_used, - # AVG(itemqty) as avg_qty_per_wo - # FROM sparepart_usage - # GROUP BY asset_location, itemnum - # ), - - # pr_po_combined AS ( - # SELECT - # mspl.item_num, - # mspl.num, - # mspl.unit_cost, - # mspl.qty_ordered, - # MAX(CASE WHEN mspo.type = 'PR' THEN mspo.issue_date END) as issue_date, - # MAX(CASE WHEN mspo.type = 'PO' THEN mspo.vendeliverydate END) as vendeliverydate, - # MAX(CASE WHEN mspo.type = 'PO' THEN mspo.estimated_arrival_date END) as estimated_arrival_date - # FROM public.maximo_sparepart_pr_po_line mspl - # INNER JOIN public.maximo_sparepart_pr_po mspo - # ON mspl.num = mspo.num - # WHERE mspo.type IN ('PR', 'PO') - # GROUP BY mspl.item_num, mspl.num, mspl.unit_cost, mspl.qty_ordered - # ), - # leadtime_stats AS ( - # SELECT - # item_num, - # ROUND(CAST(AVG( - # EXTRACT(EPOCH FROM ( - # )) / 86400 / 30.44 - # ) AS NUMERIC), 1) as avg_leadtime_months, - # ROUND(CAST(MIN( - # EXTRACT(EPOCH FROM ( - # )) / 86400 / 30.44 - # ) AS NUMERIC), 1) as min_leadtime_months, - # ROUND(CAST(MAX( - # EXTRACT(EPOCH FROM ( - # )) / 86400 / 30.44 - # ) AS NUMERIC), 1) as max_leadtime_months, - # COUNT(*) as leadtime_sample_size, - # COUNT(CASE WHEN vendeliverydate IS NOT NULL THEN 1 END) as vendelivery_count, - # COUNT(CASE WHEN vendeliverydate IS NULL AND estimated_arrival_date IS NOT NULL THEN 1 END) as estimated_only_count - # FROM pr_po_combined - # WHERE issue_date IS NOT NULL - # AND COALESCE(vendeliverydate, estimated_arrival_date) IS NOT NULL - # AND COALESCE(vendeliverydate, estimated_arrival_date) > issue_date - # GROUP BY item_num - # ), - # cost_stats AS ( - # SELECT - # item_num, - # ROUND(CAST(AVG(unit_cost) AS NUMERIC), 2) as avg_unit_cost, - # ROUND(CAST(MIN(unit_cost) AS NUMERIC), 2) as min_unit_cost, - # ROUND(CAST(MAX(unit_cost) AS NUMERIC), 2) as max_unit_cost, - # COUNT(*) as cost_sample_size, - # ROUND(CAST(AVG(unit_cost * qty_ordered) AS NUMERIC), 2) as avg_order_value, - # ROUND(CAST(SUM(unit_cost * qty_ordered) AS NUMERIC), 2) as total_value_ordered - # FROM pr_po_combined - # WHERE unit_cost IS NOT NULL AND unit_cost > 0 - # GROUP BY item_num - # ), - # item_descriptions AS ( - # SELECT DISTINCT - # item_num, - # FIRST_VALUE(description) OVER ( - # PARTITION BY item_num - # ORDER BY created_at DESC NULLS LAST - # ) as description - # FROM public.maximo_sparepart_pr_po_line - # WHERE description IS NOT NULL - # SELECT - # tr.asset_location, - # tr.itemnum, - # COALESCE(id.description, 'No description available') as item_description, - # tr.total_qty_required AS total_required_for_oh, - # tr.inv_avgcost, - # COALESCE(lt.avg_leadtime_months, 0) as avg_leadtime_months, - # COALESCE(cs.avg_unit_cost, 0) as avg_unit_cost, - # ROUND(CAST(COALESCE(tr.total_qty_required * cs.avg_unit_cost, 0) AS NUMERIC), 2) as estimated_cost_for_oh - # FROM target_materials tr - # LEFT JOIN item_descriptions id ON tr.itemnum = id.item_num - # LEFT JOIN leadtime_stats lt ON tr.itemnum = lt.item_num - # LEFT JOIN cost_stats cs ON tr.itemnum = cs.item_num - # ORDER BY tr.asset_location, tr.itemnum; - # """) - - - # for req_record in equipment_requirements_query: - - # for equipment_tag, requirements in equipment_requirements.items(): - - # Load equipment sparepart requirements - # You'll need to create this table/relationship - query = text("""WITH oh_workorders AS ( - -- First, get all OH work orders - SELECT DISTINCT - wonum, - asset_location - FROM public.wo_maximo ma - WHERE worktype = 'OH' AND asset_location IS NOT NULL and asset_unit IN ('3', '00') AND EXTRACT(YEAR FROM reportdate) >= 2019 - ), - current_oh as ( - SELECT DISTINCT wonum, asset_location, asset_unit - FROM public.wo_maximo ma - WHERE ma.xx_parent IN ('155026', '155027', '155029', '155030') - ), - sparepart_usage AS ( - SELECT - oh.asset_location, - mwm.itemnum, - mwm.itemqty, - mwm.wonum, - mwm.inv_avgcost - FROM current_oh oh - INNER JOIN public.wo_maximo_material mwm - ON oh.wonum = mwm.wonum - ), -location_sparepart_stats AS ( - -- Calculate average usage per sparepart per location - SELECT - asset_location, - itemnum, - COUNT(DISTINCT wonum) as total_wo_count, - SUM(itemqty) as total_qty_used, - AVG(itemqty) as avg_qty_per_wo, - MIN(itemqty) as min_qty_used, - MAX(itemqty) as max_qty_used - FROM sparepart_usage - GROUP BY asset_location, itemnum -), -pr_po_combined AS ( - -- Combine PR and PO data by num to get issue_date and delivery dates - SELECT - mspl.item_num, - mspl.num, - mspl.unit_cost, - mspl.qty_ordered, - MAX(CASE WHEN mspo.type = 'PR' THEN mspo.issue_date END) as issue_date, - MAX(CASE WHEN mspo.type = 'PO' THEN mspo.vendeliverydate END) as vendeliverydate, - MAX(CASE WHEN mspo.type = 'PO' THEN mspo.estimated_arrival_date END) as estimated_arrival_date - FROM public.maximo_sparepart_pr_po_line mspl - INNER JOIN public.maximo_sparepart_pr_po mspo - ON mspl.num = mspo.num - WHERE mspo.type IN ('PR', 'PO') - GROUP BY mspl.item_num, mspl.num, mspl.unit_cost, mspl.qty_ordered -), -leadtime_stats AS ( - -- Calculate lead time statistics for each item - -- Prioritize vendeliverydate over estimated_arrival_date - SELECT - item_num, - ROUND(CAST(AVG( - EXTRACT(EPOCH FROM ( - COALESCE(vendeliverydate, estimated_arrival_date) - issue_date - )) / 86400 / 30.44 - ) AS NUMERIC), 1) as avg_leadtime_months, - ROUND(CAST(MIN( - EXTRACT(EPOCH FROM ( - COALESCE(vendeliverydate, estimated_arrival_date) - issue_date - )) / 86400 / 30.44 - ) AS NUMERIC), 1) as min_leadtime_months, - ROUND(CAST(MAX( - EXTRACT(EPOCH FROM ( - COALESCE(vendeliverydate, estimated_arrival_date) - issue_date - )) / 86400 / 30.44 - ) AS NUMERIC), 1) as max_leadtime_months, - COUNT(*) as leadtime_sample_size, - -- Additional metrics for transparency - COUNT(CASE WHEN vendeliverydate IS NOT NULL THEN 1 END) as vendelivery_count, - COUNT(CASE WHEN vendeliverydate IS NULL AND estimated_arrival_date IS NOT NULL THEN 1 END) as estimated_only_count - FROM pr_po_combined - WHERE issue_date IS NOT NULL - AND COALESCE(vendeliverydate, estimated_arrival_date) IS NOT NULL - AND COALESCE(vendeliverydate, estimated_arrival_date) > issue_date - GROUP BY item_num -), -cost_stats AS ( - -- Calculate cost statistics for each item - SELECT - item_num, - ROUND(CAST(AVG(unit_cost) AS NUMERIC), 2) as avg_unit_cost, - ROUND(CAST(MIN(unit_cost) AS NUMERIC), 2) as min_unit_cost, - ROUND(CAST(MAX(unit_cost) AS NUMERIC), 2) as max_unit_cost, - COUNT(*) as cost_sample_size, - -- Total value statistics - ROUND(CAST(AVG(unit_cost * qty_ordered) AS NUMERIC), 2) as avg_order_value, - ROUND(CAST(SUM(unit_cost * qty_ordered) AS NUMERIC), 2) as total_value_ordered - FROM pr_po_combined - WHERE unit_cost IS NOT NULL AND unit_cost > 0 - GROUP BY item_num -), -item_descriptions AS ( - -- Get unique descriptions for each item (optimized) - SELECT DISTINCT - item_num, - FIRST_VALUE(description) OVER ( - PARTITION BY item_num - ORDER BY created_at DESC NULLS LAST - ) as description - FROM public.maximo_sparepart_pr_po_line - WHERE description IS NOT NULL -), -item_inventory as ( - SELECT - itemnum, - avgcost - FROM public.maximo_inventory -) -SELECT - lss.asset_location, - lss.itemnum, - COALESCE(id.description, 'No description available') as item_description, - lss.total_wo_count, - lss.total_qty_used, - ROUND(CAST(lss.avg_qty_per_wo AS NUMERIC), 2) as avg_qty_per_wo, - lss.min_qty_used, - lss.max_qty_used, - iin.inv_avgcost, - -- Lead time metrics - COALESCE(lt.avg_leadtime_months, 0) as avg_leadtime_months, - COALESCE(lt.min_leadtime_months, 0) as min_leadtime_months, - COALESCE(lt.max_leadtime_months, 0) as max_leadtime_months, - COALESCE(lt.leadtime_sample_size, 0) as leadtime_sample_size, - COALESCE(lt.vendelivery_count, 0) as vendelivery_count, - COALESCE(lt.estimated_only_count, 0) as estimated_only_count, - -- Cost metrics - COALESCE(cs.avg_unit_cost, 0) as avg_unit_cost, - COALESCE(cs.min_unit_cost, 0) as min_unit_cost, - COALESCE(cs.max_unit_cost, 0) as max_unit_cost, - COALESCE(cs.cost_sample_size, 0) as cost_sample_size, - COALESCE(cs.avg_order_value, 0) as avg_order_value, - COALESCE(cs.total_value_ordered, 0) as total_value_ordered, - -- Estimated total cost for average OH - ROUND(CAST(COALESCE(lss.avg_qty_per_wo * cs.avg_unit_cost, 0) AS NUMERIC), 2) as estimated_cost_per_oh -FROM location_sparepart_stats lss -LEFT JOIN item_descriptions id ON lss.itemnum = id.item_num -LEFT JOIN leadtime_stats lt ON lss.itemnum = lt.item_num -LEFT JOIN cost_stats cs ON lss.itemnum = cs.item_num -LEFT JOIN sparepart_usage iin ON lss.itemnum = iin.itemnum -ORDER BY lss.asset_location, lss.itemnum;""") - + query = text("WITH oh_workorders AS (\n -- First, get all OH work orders\n SELECT DISTINCT\n wonum,\n asset_location\n FROM public.wo_maximo ma\n WHERE worktype = 'OH' AND asset_location IS NOT NULL and asset_unit IN ('3', '00') AND EXTRACT(YEAR FROM reportdate) >= 2019\n ),\n current_oh 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 ),\n sparepart_usage AS (\n SELECT \n oh.asset_location,\n mwm.itemnum,\n mwm.itemqty,\n mwm.wonum,\n mwm.inv_avgcost\n FROM current_oh oh\n INNER JOIN public.wo_maximo_material mwm \n ON oh.wonum = mwm.wonum\n ),\nlocation_sparepart_stats AS (\n -- Calculate average usage per sparepart per location\n SELECT \n asset_location,\n itemnum,\n COUNT(DISTINCT wonum) as total_wo_count,\n SUM(itemqty) as total_qty_used,\n AVG(itemqty) as avg_qty_per_wo,\n MIN(itemqty) as min_qty_used,\n MAX(itemqty) as max_qty_used\n FROM sparepart_usage\n GROUP BY asset_location, itemnum\n),\npr_po_combined AS (\n -- Combine PR and PO data by num to get issue_date and delivery dates\n SELECT \n mspl.item_num,\n mspl.num,\n mspl.unit_cost,\n mspl.qty_ordered,\n MAX(CASE WHEN mspo.type = 'PR' THEN mspo.issue_date END) as issue_date,\n MAX(CASE WHEN mspo.type = 'PO' THEN mspo.vendeliverydate END) as vendeliverydate,\n MAX(CASE WHEN mspo.type = 'PO' THEN mspo.estimated_arrival_date END) as estimated_arrival_date\n FROM public.maximo_sparepart_pr_po_line mspl\n INNER JOIN public.maximo_sparepart_pr_po mspo \n ON mspl.num = mspo.num\n WHERE mspo.type IN ('PR', 'PO')\n GROUP BY mspl.item_num, mspl.num, mspl.unit_cost, mspl.qty_ordered\n),\nleadtime_stats AS (\n -- Calculate lead time statistics for each item\n -- Prioritize vendeliverydate over estimated_arrival_date\n SELECT \n item_num,\n ROUND(CAST(AVG(\n EXTRACT(EPOCH FROM (\n COALESCE(vendeliverydate, estimated_arrival_date) - issue_date\n )) / 86400 / 30.44\n ) AS NUMERIC), 1) as avg_leadtime_months,\n ROUND(CAST(MIN(\n EXTRACT(EPOCH FROM (\n COALESCE(vendeliverydate, estimated_arrival_date) - issue_date\n )) / 86400 / 30.44\n ) AS NUMERIC), 1) as min_leadtime_months,\n ROUND(CAST(MAX(\n EXTRACT(EPOCH FROM (\n COALESCE(vendeliverydate, estimated_arrival_date) - issue_date\n )) / 86400 / 30.44\n ) AS NUMERIC), 1) as max_leadtime_months,\n COUNT(*) as leadtime_sample_size,\n -- Additional metrics for transparency\n COUNT(CASE WHEN vendeliverydate IS NOT NULL THEN 1 END) as vendelivery_count,\n COUNT(CASE WHEN vendeliverydate IS NULL AND estimated_arrival_date IS NOT NULL THEN 1 END) as estimated_only_count\n FROM pr_po_combined\n WHERE issue_date IS NOT NULL \n AND COALESCE(vendeliverydate, estimated_arrival_date) IS NOT NULL\n AND COALESCE(vendeliverydate, estimated_arrival_date) > issue_date\n GROUP BY item_num\n),\ncost_stats AS (\n -- Calculate cost statistics for each item\n SELECT \n item_num,\n ROUND(CAST(AVG(unit_cost) AS NUMERIC), 2) as avg_unit_cost,\n ROUND(CAST(MIN(unit_cost) AS NUMERIC), 2) as min_unit_cost,\n ROUND(CAST(MAX(unit_cost) AS NUMERIC), 2) as max_unit_cost,\n COUNT(*) as cost_sample_size,\n -- Total value statistics\n ROUND(CAST(AVG(unit_cost * qty_ordered) AS NUMERIC), 2) as avg_order_value,\n ROUND(CAST(SUM(unit_cost * qty_ordered) AS NUMERIC), 2) as total_value_ordered\n FROM pr_po_combined\n WHERE unit_cost IS NOT NULL AND unit_cost > 0\n GROUP BY item_num\n),\nitem_descriptions AS (\n -- Get unique descriptions for each item (optimized)\n SELECT DISTINCT\n item_num,\n FIRST_VALUE(description) OVER (\n PARTITION BY item_num \n ORDER BY created_at DESC NULLS LAST\n ) as description\n FROM public.maximo_sparepart_pr_po_line\n WHERE description IS NOT NULL\n),\nitem_inventory as (\n SELECT\n itemnum,\n avgcost\n FROM public.maximo_inventory\n)\nSELECT \n lss.asset_location,\n lss.itemnum,\n COALESCE(id.description, 'No description available') as item_description,\n lss.total_wo_count,\n lss.total_qty_used,\n ROUND(CAST(lss.avg_qty_per_wo AS NUMERIC), 2) as avg_qty_per_wo,\n lss.min_qty_used,\n lss.max_qty_used,\n iin.inv_avgcost,\n -- Lead time metrics\n COALESCE(lt.avg_leadtime_months, 0) as avg_leadtime_months,\n COALESCE(lt.min_leadtime_months, 0) as min_leadtime_months,\n COALESCE(lt.max_leadtime_months, 0) as max_leadtime_months,\n COALESCE(lt.leadtime_sample_size, 0) as leadtime_sample_size,\n COALESCE(lt.vendelivery_count, 0) as vendelivery_count,\n COALESCE(lt.estimated_only_count, 0) as estimated_only_count,\n -- Cost metrics\n COALESCE(cs.avg_unit_cost, 0) as avg_unit_cost,\n COALESCE(cs.min_unit_cost, 0) as min_unit_cost,\n COALESCE(cs.max_unit_cost, 0) as max_unit_cost,\n COALESCE(cs.cost_sample_size, 0) as cost_sample_size,\n COALESCE(cs.avg_order_value, 0) as avg_order_value,\n COALESCE(cs.total_value_ordered, 0) as total_value_ordered,\n -- Estimated total cost for average OH\n ROUND(CAST(COALESCE(lss.avg_qty_per_wo * cs.avg_unit_cost, 0) AS NUMERIC), 2) as estimated_cost_per_oh\nFROM location_sparepart_stats lss\nLEFT JOIN item_descriptions id ON lss.itemnum = id.item_num\nLEFT JOIN leadtime_stats lt ON lss.itemnum = lt.item_num\nLEFT JOIN cost_stats cs ON lss.itemnum = cs.item_num\nLEFT JOIN sparepart_usage iin ON lss.itemnum = iin.itemnum\nORDER BY lss.asset_location, lss.itemnum;") equipment_requirements_query = await db_session.execute(query) - equipment_requirements = defaultdict(list) for req_record in equipment_requirements_query: - requirement = SparepartRequirement( - sparepart_id=req_record.itemnum, - quantity_required=float(req_record.avg_qty_per_wo), - lead_time=float(req_record.avg_leadtime_months), - sparepart_name=req_record.item_description, - unit_cost=float(req_record.avg_unit_cost), - avg_cost=float(req_record.inv_avgcost or 0), - remark=sparepart_remark_dict.get(req_record.itemnum, "") - - ) + requirement = SparepartRequirement(sparepart_id=req_record.itemnum, quantity_required=float(req_record.avg_qty_per_wo), lead_time=float(req_record.avg_leadtime_months), sparepart_name=req_record.item_description, unit_cost=float(req_record.avg_unit_cost), avg_cost=float(req_record.inv_avgcost or 0), remark=sparepart_remark_dict.get(req_record.itemnum, '')) equipment_requirements[req_record.asset_location].append(requirement) - for equipment_tag, requirements in equipment_requirements.items(): sparepart_manager.add_equipment_requirements(equipment_tag, requirements) - - - # Load procurement records (PO/PR) - query = text(""" - WITH active_pos AS ( - -- Get all POs that are NOT complete (not in inventory yet) and NOT closed - SELECT - pl.item_num, - h.num as po_number, - pl.qty_received, - pl.qty_ordered, - h.estimated_arrival_date, - h.vendeliverydate, - h.receipts as po_receipts, - h.status as po_status, - pl.description, - pl.unit_cost, - pl.line_cost - FROM public.maximo_sparepart_pr_po h - JOIN public.maximo_sparepart_pr_po_line pl - ON h.num = pl.num - WHERE h.type = 'PO' - -- Exclude POs where receipts = 'COMPLETE' - AND (h.receipts IS NULL OR h.receipts != 'COMPLETE') - -- Exclude closed POs - AND (h.status IS NULL OR h.status = 'APPR') -), -po_with_pr_date AS ( - -- Force join with PR to ensure every PO has a PR - SELECT - po.*, - pr.issue_date as pr_issue_date - FROM active_pos po - INNER JOIN public.maximo_sparepart_pr_po pr - ON pr.num = po.po_number - AND pr.type = 'PR' -), -item_inventory AS ( - SELECT - itemnum, - MAX(inv_curbaltotal) AS current_balance_total, - AVG(inv_avgcost) AS avg_cost - FROM public.wo_maximo_material - WHERE inv_itemnum IS NOT NULL - GROUP BY itemnum -) -SELECT - po.item_num, - po.description, - po.line_cost, - po.unit_cost, - COALESCE(i.current_balance_total, 0) as current_balance_total, - po.po_number, - po.pr_issue_date, - po.po_status, - po.po_receipts, - COALESCE(po.qty_received, 0) as po_qty_received, - COALESCE(po.qty_ordered, 0) as po_qty_ordered, - po.estimated_arrival_date as po_estimated_arrival_date, - po.vendeliverydate as po_vendor_delivery_date -FROM po_with_pr_date po -LEFT JOIN item_inventory i - ON po.item_num = i.itemnum -ORDER BY po.item_num, po.pr_issue_date DESC; -""") - - # Execute the query + query = text("\n WITH active_pos AS (\n -- Get all POs that are NOT complete (not in inventory yet) and NOT closed\n SELECT \n pl.item_num,\n h.num as po_number,\n pl.qty_received,\n pl.qty_ordered,\n h.estimated_arrival_date,\n h.vendeliverydate,\n h.receipts as po_receipts,\n h.status as po_status,\n pl.description,\n pl.unit_cost,\n pl.line_cost\n FROM public.maximo_sparepart_pr_po h\n JOIN public.maximo_sparepart_pr_po_line pl \n ON h.num = pl.num\n WHERE h.type = 'PO'\n -- Exclude POs where receipts = 'COMPLETE'\n AND (h.receipts IS NULL OR h.receipts != 'COMPLETE')\n -- Exclude closed POs\n AND (h.status IS NULL OR h.status = 'APPR')\n),\npo_with_pr_date AS (\n -- Force join with PR to ensure every PO has a PR\n SELECT \n po.*,\n pr.issue_date as pr_issue_date\n FROM active_pos po\n INNER JOIN public.maximo_sparepart_pr_po pr \n ON pr.num = po.po_number \n AND pr.type = 'PR'\n),\nitem_inventory AS (\n SELECT \n itemnum,\n MAX(inv_curbaltotal) AS current_balance_total,\n AVG(inv_avgcost) AS avg_cost\n FROM public.wo_maximo_material\n WHERE inv_itemnum IS NOT NULL\n GROUP BY itemnum\n)\nSELECT \n po.item_num,\n po.description,\n po.line_cost,\n po.unit_cost,\n COALESCE(i.current_balance_total, 0) as current_balance_total,\n po.po_number,\n po.pr_issue_date,\n po.po_status,\n po.po_receipts,\n COALESCE(po.qty_received, 0) as po_qty_received,\n COALESCE(po.qty_ordered, 0) as po_qty_ordered,\n po.estimated_arrival_date as po_estimated_arrival_date,\n po.vendeliverydate as po_vendor_delivery_date\nFROM po_with_pr_date po\nLEFT JOIN item_inventory i \n ON po.item_num = i.itemnum\nORDER BY po.item_num, po.pr_issue_date DESC;\n") result = await db_session.execute(query) - - # Fetch all results and convert to list of dictionaries procurement_query = [] for row in result: - procurement_query.append({ - "item_num": row.item_num, - "description": row.description, - "line_cost": row.line_cost, - "unit_cost": row.unit_cost, - "current_balance_total": float(row.current_balance_total) if row.current_balance_total is not None else 0.0, - "po_number": row.po_number, - "pr_issue_date": row.pr_issue_date, - "po_status": row.po_status, - "po_receipts": row.po_receipts, - "po_qty_received": float(row.po_qty_received) if row.po_qty_received is not None else 0.0, - "po_qty_ordered": float(row.po_qty_ordered) if row.po_qty_ordered is not None else 0.0, - "po_estimated_arrival_date": row.po_estimated_arrival_date, - "po_vendor_delivery_date": row.po_vendor_delivery_date - }) - - + procurement_query.append({'item_num': row.item_num, 'description': row.description, 'line_cost': row.line_cost, 'unit_cost': row.unit_cost, 'current_balance_total': float(row.current_balance_total) if row.current_balance_total is not None else 0.0, 'po_number': row.po_number, 'pr_issue_date': row.pr_issue_date, 'po_status': row.po_status, 'po_receipts': row.po_receipts, 'po_qty_received': float(row.po_qty_received) if row.po_qty_received is not None else 0.0, 'po_qty_ordered': float(row.po_qty_ordered) if row.po_qty_ordered is not None else 0.0, 'po_estimated_arrival_date': row.po_estimated_arrival_date, 'po_vendor_delivery_date': row.po_vendor_delivery_date}) for proc_record in procurement_query: - procurement = ProcurementRecord( - po_pr_id=proc_record["po_number"], - sparepart_id=proc_record["item_num"], - sparepart_name=proc_record["description"], - quantity=proc_record["po_qty_ordered"], - unit_cost=proc_record["unit_cost"], - total_cost=proc_record["line_cost"], - order_date=proc_record['pr_issue_date'], - expected_delivery_date=proc_record['po_estimated_arrival_date'], - po_vendor_delivery_date=proc_record['po_vendor_delivery_date'], - status=ProcurementStatus("ordered"), - ) + procurement = ProcurementRecord(po_pr_id=proc_record['po_number'], sparepart_id=proc_record['item_num'], sparepart_name=proc_record['description'], quantity=proc_record['po_qty_ordered'], unit_cost=proc_record['unit_cost'], total_cost=proc_record['line_cost'], order_date=proc_record['pr_issue_date'], expected_delivery_date=proc_record['po_estimated_arrival_date'], po_vendor_delivery_date=proc_record['po_vendor_delivery_date'], status=ProcurementStatus('ordered')) sparepart_manager.add_procurement_record(procurement) - - # Calculate projected stocks sparepart_manager._project_monthly_stocks() - return sparepart_manager - - async def create_remark(*, db_session, collector_db_session, remark_in): - # Step 1: Check if remark already exists for this itemnum - result = await db_session.execute( - apply_not_deleted_filter( - select(SparepartRemark).where(SparepartRemark.itemnum == remark_in.itemnum), - SparepartRemark - ) - ) + result = await db_session.execute(apply_not_deleted_filter(select(SparepartRemark).where(SparepartRemark.itemnum == remark_in.itemnum), SparepartRemark)) existing_remark = result.scalar_one_or_none() - - # Step 2: If it already exists, you can decide what to do if existing_remark: - # Option B: Update existing remark (if needed) existing_remark.remark = remark_in.remark await db_session.commit() await db_session.refresh(existing_remark) return existing_remark - - # Step 3: If it doesn’t exist, create new one - new_remark = SparepartRemark( - itemnum=remark_in.itemnum, - remark=remark_in.remark, - ) - + new_remark = SparepartRemark(itemnum=remark_in.itemnum, remark=remark_in.remark) db_session.add(new_remark) await db_session.commit() await db_session.refresh(new_remark) - return new_remark diff --git a/src/standard_scope/__init__.py b/src/standard_scope/__init__.py index e69de29..8b13789 100644 --- a/src/standard_scope/__init__.py +++ b/src/standard_scope/__init__.py @@ -0,0 +1 @@ + diff --git a/src/standard_scope/enum.py b/src/standard_scope/enum.py index aea2ce7..bdca2f7 100644 --- a/src/standard_scope/enum.py +++ b/src/standard_scope/enum.py @@ -1,6 +1,5 @@ from src.enums import OptimumOHEnum - class ScopeEquipmentType(OptimumOHEnum): - TEMP = "Temporary" - PERM = "Permanent" + TEMP = 'Temporary' + PERM = 'Permanent' diff --git a/src/standard_scope/model.py b/src/standard_scope/model.py index 358eb77..9d3a04c 100644 --- a/src/standard_scope/model.py +++ b/src/standard_scope/model.py @@ -1,69 +1,36 @@ from sqlalchemy import UUID, Column, Date, Float, ForeignKey, Integer, String, Boolean from sqlalchemy.orm import relationship - from src.database.core import Base from src.models import DefaultMixin - class StandardScope(Base, DefaultMixin): - __tablename__ = "oh_ms_standard_scope" - + __tablename__ = 'oh_ms_standard_scope' location_tag = Column(String, nullable=False) is_alternating_oh = Column(Boolean, nullable=False, default=False) assigned_date = Column(Date, 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, # 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 - ) + 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) + workscope_groups = relationship('EquipmentWorkscopeGroup', lazy='selectin', primaryjoin='and_(EquipmentWorkscopeGroup.location_tag == foreign(StandardScope.location_tag))', uselist=True) class EquipmentOHHistory(Base, DefaultMixin): - __tablename__ = "oh_ms_equipment_oh_history" + __tablename__ = 'oh_ms_equipment_oh_history' location_tag = Column(String, nullable=False) last_oh_date = Column(Date, nullable=True) last_oh_type = Column(String, nullable=True) - class MasterEquipment(Base, DefaultMixin): - __tablename__ = "ms_equipment_master" - + __tablename__ = 'ms_equipment_master' 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) system_tag = Column(String, nullable=True) location_tag = 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 = relationship("MasterEquipmentTree", backref="master_equipments") - parent = relationship("MasterEquipment", remote_side=[id], lazy="selectin") - + equipment_tree_id = Column(UUID(as_uuid=True), ForeignKey('ms_equipment_tree.id'), nullable=True) + equipment_tree = relationship('MasterEquipmentTree', backref='master_equipments') + parent = relationship('MasterEquipment', remote_side=[id], lazy='selectin') class MasterEquipmentTree(Base, DefaultMixin): - __tablename__ = "ms_equipment_tree" - + __tablename__ = 'ms_equipment_tree' level_no = Column(Integer) diff --git a/src/standard_scope/router.py b/src/standard_scope/router.py index 2cc9a47..cbb0e5b 100644 --- a/src/standard_scope/router.py +++ b/src/standard_scope/router.py @@ -1,73 +1,31 @@ from typing import List - from fastapi import APIRouter, Depends from fastapi.params import Query - from src.database.core import DbSession, CollectorDbSession from src.database.service import CommonParameters from src.models import StandardResponse 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 +from .service import create, get_all, get_all_master_equipment, get_history_standard_scope_wo_service from uuid import UUID router = APIRouter(dependencies=[Depends(require_any_role(*ALLOWED_ROLES))]) - -@router.get("", response_model=StandardResponse[ScopeEquipmentPagination]) -async def get_scope_equipments(common: CommonParameters, scope_name: str = Query(None)): - """Get all scope pagination.""" +@router.get('', response_model=StandardResponse[ScopeEquipmentPagination]) +async def get_scope_equipments(common: CommonParameters, scope_name: str=Query(None)): data = await get_all(common=common, oh_scope=scope_name) + return StandardResponse(data=data, message='Data retrieved successfully') - return StandardResponse( - data=data, - message="Data retrieved successfully", - ) - - -@router.get( - "/available/{scope_name}", - response_model=StandardResponse[MasterEquipmentPagination], -) +@router.get('/available/{scope_name}', response_model=StandardResponse[MasterEquipmentPagination]) async def get_master_equipment(common: CommonParameters, scope_name: str): results = await get_all_master_equipment(common=common, scope_name=scope_name) + return StandardResponse(data=results, message='Data retrieved successfully') - return StandardResponse(data=results, message="Data retrieved successfully") - - -@router.post("", response_model=StandardResponse[List[str]]) -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) + return StandardResponse(data=scope, message='Data created successfully') - return StandardResponse(data=scope, message="Data created successfully") - - -@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): +@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) - - 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 -# ): - -# if not scope_equipment: -# raise HTTPException( - -# return StandardResponse( -# ), - - -# @router.delete("/{assetnum}", response_model=StandardResponse[None]) -# async def delete_scope_equipment(db_session: DbSession, assetnum: str): - -# if not scope_equipment: -# raise HTTPException( - - + return StandardResponse(data=results, message='Data retrieved successfully') diff --git a/src/standard_scope/schema.py b/src/standard_scope/schema.py index 4955958..8aaf55a 100644 --- a/src/standard_scope/schema.py +++ b/src/standard_scope/schema.py @@ -1,22 +1,16 @@ from datetime import datetime from typing import List, Optional, ForwardRef from uuid import UUID - from pydantic import Field - from src.models import DefultBase, Pagination - from .enum import ScopeEquipmentType - class MasterEquipmentBase(DefultBase): - name: Optional[str] = Field(None, title="Name") - location_tag: Optional[str] = Field(None, title="Location Tag") - + name: Optional[str] = Field(None, title='Name') + location_tag: Optional[str] = Field(None, title='Location Tag') class ScopeEquipmentBase(DefultBase): - scope_overhaul: Optional[str] = Field(None, title="Scope ID") - + scope_overhaul: Optional[str] = Field(None, title='Scope ID') class ScopeEquipmentCreate(DefultBase): assetnums: List[str] @@ -24,10 +18,8 @@ class ScopeEquipmentCreate(DefultBase): removal_date: Optional[datetime] = Field(None) type: Optional[str] = Field(ScopeEquipmentType.PERM) - class ScopeEquipmentUpdate(ScopeEquipmentBase): - assetnum: Optional[str] = Field(None, title="Asset Number") - + assetnum: Optional[str] = Field(None, title='Asset Number') class ScopeEquipmentRead(ScopeEquipmentBase): id: UUID @@ -35,21 +27,19 @@ class ScopeEquipmentRead(ScopeEquipmentBase): assigned_date: datetime master_equipment: Optional[MasterEquipmentBase] = Field(None) - class ScopeEquipmentPagination(DefultBase): items: List[ScopeEquipmentRead] = [] total: int class MasterEquipmentRead(DefultBase): - assetnum: Optional[str] = Field(None, title="Asset Number") - location_tag: Optional[str] = Field(None, title="Location Tag") + assetnum: Optional[str] = Field(None, title='Asset Number') + location_tag: Optional[str] = Field(None, title='Location Tag') name: str - -EquipmentMasterTreeRef = ForwardRef("MasterEquipmentTree") +EquipmentMasterTreeRef = ForwardRef('MasterEquipmentTree') class MasterEquipmentTree(MasterEquipmentRead): parent_id: Optional[UUID] - parent: Optional[EquipmentMasterTreeRef] = Field(None) # type: ignore + parent: Optional[EquipmentMasterTreeRef] = Field(None) class MasterEquipmentPagination(Pagination): items: List[MasterEquipmentRead] = [] diff --git a/src/standard_scope/service.py b/src/standard_scope/service.py index 3030eb3..afb903b 100644 --- a/src/standard_scope/service.py +++ b/src/standard_scope/service.py @@ -1,11 +1,9 @@ from datetime import datetime, timedelta from typing import Optional - from fastapi import HTTPException, status from sqlalchemy import Select, desc from sqlalchemy.dialects.postgresql import insert from sqlalchemy.orm import selectinload - from src.database.core import DbSession, CollectorDbSession from src.utils import update_model from src.database.service import CommonParameters, search_filter_sort_paginate @@ -18,243 +16,99 @@ from src.workscope_group.model import MasterActivity from src.workscope_group_maintenance_type.model import WorkscopeOHType from src.overhaul_scope.model import MaintenanceType from src.overhaul_scope.service import get as get_overhaul -from src.maximo.service import get_history_oh_wo +from src.maximo.service import get_history_oh_wo from .model import MasterEquipment, MasterEquipmentTree, StandardScope from .schema import ScopeEquipmentCreate, ScopeEquipmentUpdate from uuid import UUID async def get_by_location_tag(*, db_session: DbSession, location_tag: str): - query = ( - Select(StandardScope) - .filter(StandardScope.location_tag == location_tag) - .options(selectinload(StandardScope.master_equipment)) - ) + query = Select(StandardScope).filter(StandardScope.location_tag == location_tag).options(selectinload(StandardScope.master_equipment)) query = apply_not_deleted_filter(query, StandardScope) - result = await db_session.execute(query) return result.unique().scalars().one_or_none() - -async def get_all(*, common, oh_scope: Optional[str] = None): - """Returns all documents.""" - query = Select(StandardScope).options( - selectinload(StandardScope.master_equipment) - ) +async def get_all(*, common, oh_scope: Optional[str]=None): + query = Select(StandardScope).options(selectinload(StandardScope.master_equipment)) query = apply_not_deleted_filter(query, StandardScope) - query = query.order_by(desc(StandardScope.created_at)).options(selectinload(StandardScope.master_equipment)) - if oh_scope: - query = ( - query - .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(StandardScope.master_equipment) - .filter(MasterEquipment.location_tag.is_not(None)) - .filter(MaintenanceType.name == oh_scope) - - # .filter( - # (StandardScope.is_alternating_oh == False) | - # (StandardScope.oh_history == None) | - # ).distinct() - ) - + query = query.outerjoin(StandardScope.oh_history).join(StandardScope.workscope_groups).join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).join(StandardScope.master_equipment).filter(MasterEquipment.location_tag.is_not(None)).filter(MaintenanceType.name == oh_scope) results = await common['db_session'].execute(query) - items = results.scalars().all() - - - return { - "items": items, - "total": len(items) - } - + return {'items': items, 'total': len(items)} async def get_by_oh_session_id(*, db_session: DbSession, oh_session_id: UUID): overhaul = await get_overhaul(db_session=db_session, overhaul_session_id=oh_session_id) - - 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 = 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 = apply_not_deleted_filter(query, StandardScope) - result = await db_session.execute(query) - return result.scalars().all(), overhaul + return (result.scalars().all(), overhaul) async def create(*, db_session: DbSession, scope_equipment_in: ScopeEquipmentCreate): - """Creates a new document.""" assetnums = scope_equipment_in.assetnums results = [] removal_date = scope_equipment_in.removal_date - if scope_equipment_in.type == ScopeEquipmentType.TEMP: - # Search for the next or ongoing overhaul session for the given scope - stmt = ( - Select(OverhaulScope.end_date) - .where( - OverhaulScope.type == scope_equipment_in.scope_name, - (OverhaulScope.start_date <= datetime.now()) - & (OverhaulScope.end_date >= datetime.now()) # Ongoing - | (OverhaulScope.start_date > datetime.now()), # Upcoming - ) - .order_by(OverhaulScope.start_date.asc()) - .limit(1) - ) - + stmt = Select(OverhaulScope.end_date).where(OverhaulScope.type == scope_equipment_in.scope_name, (OverhaulScope.start_date <= datetime.now()) & (OverhaulScope.end_date >= datetime.now()) | (OverhaulScope.start_date > datetime.now())).order_by(OverhaulScope.start_date.asc()).limit(1) result = await db_session.execute(stmt) removal_date = result.scalar_one_or_none() - - # If no overhaul found, set a default removal date or handle the error if removal_date is None: - # Handle if no overhaul session is found, set default or raise an error - removal_date = datetime.now() + timedelta( - days=30 - ) # Example: 30 days from now - + removal_date = datetime.now() + timedelta(days=30) for assetnum in assetnums: - stmt = insert(StandardScope).values( - assetnum=assetnum, - scope_overhaul=scope_equipment_in.scope_name, - type=scope_equipment_in.type, - removal_date=removal_date, - ) - - stmt = stmt.on_conflict_do_nothing( - index_elements=["assetnum", "scope_overhaul"] - ) - + stmt = insert(StandardScope).values(assetnum=assetnum, scope_overhaul=scope_equipment_in.scope_name, type=scope_equipment_in.type, removal_date=removal_date) + stmt = stmt.on_conflict_do_nothing(index_elements=['assetnum', 'scope_overhaul']) await db_session.execute(stmt) results.append(assetnum) - await db_session.commit() return results - -async def update( - *, - db_session: DbSession, - location_tag: str, - scope_equipment_in: ScopeEquipmentUpdate -): - """Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update.""" - query = ( - Select(StandardScope) - .filter(StandardScope.location_tag == location_tag) - .options(selectinload(StandardScope.master_equipment)) - .with_for_update() - ) +async def update(*, db_session: DbSession, location_tag: str, scope_equipment_in: ScopeEquipmentUpdate): + query = Select(StandardScope).filter(StandardScope.location_tag == location_tag).options(selectinload(StandardScope.master_equipment)).with_for_update() query = apply_not_deleted_filter(query, StandardScope) result = await db_session.execute(query) scope_equipment = result.unique().scalars().one_or_none() - if not scope_equipment: - 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_equipment) - update_data = scope_equipment_in.model_dump(exclude_defaults=True) update_model(scope_equipment, update_data) - await db_session.commit() - return scope_equipment - async def delete(*, db_session: DbSession, assetnum: str): - """Soft-deletes a document.""" result = await get_by_location_tag(db_session=db_session, location_tag=assetnum) if result: from src.soft_delete import soft_delete_record await soft_delete_record(db_session=db_session, model=StandardScope, record_id=str(result.id)) return assetnum -async def get_by_oh_scope( - *, db_session: DbSession, oh_scope: str -): +async def get_by_oh_scope(*, db_session: DbSession, oh_scope: str): pass - - 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) - .filter(MaintenanceType.name == oh_scope) - # .filter( - # (StandardScope.is_alternating_oh == False) | - # (StandardScope.oh_history == None) | - # ).distinct() - ) - + query = Select(StandardScope).outerjoin(StandardScope.oh_history).join(StandardScope.workscope_groups).join(EquipmentWorkscopeGroup.workscope_group).join(MasterActivity.oh_types).join(WorkscopeOHType.oh_type).filter(MaintenanceType.name == oh_scope) results = await db_session.execute(query) return results.scalars().all() - async def get_all_master_equipment(*, common: CommonParameters, scope_name): - equipments_scope = [ - equip.location_tag - for equip in await get_by_oh_scope( - db_session=common.get("db_session"), oh_scope=scope_name - ) - ] - + equipments_scope = [equip.location_tag for equip in await get_by_oh_scope(db_session=common.get('db_session'), oh_scope=scope_name)] query = Select(MasterEquipment).filter(MasterEquipment.location_tag.is_not(None)) query = apply_not_deleted_filter(query, MasterEquipment) - - # Only add not_in filter if there are items in equipments_scope if equipments_scope: query = query.filter(MasterEquipment.location_tag.not_in(equipments_scope)) - results = await search_filter_sort_paginate(model=query, **common) return results - async def get_equipment_level_by_no(*, db_session: DbSession, level: int): - query = ( - Select(MasterEquipment) - .join(MasterEquipment.equipment_tree) - .where(MasterEquipmentTree.level_no == level) - ) + query = Select(MasterEquipment).join(MasterEquipment.equipment_tree).where(MasterEquipmentTree.level_no == level) query = apply_not_deleted_filter(query, MasterEquipment) - result = await db_session.execute(query) return result.scalars().all() - -async def get_history_standard_scope_wo_service(*, db_session: DbSession, collector_db_session:CollectorDbSession, oh_session_id:UUID): +async def get_history_standard_scope_wo_service(*, db_session: DbSession, collector_db_session: CollectorDbSession, oh_session_id: UUID): planning_oh_data = await get_by_oh_session_id(db_session=db_session, oh_session_id=oh_session_id) - planning_scopes = planning_oh_data[0] overhaul = planning_oh_data[1] - - results = await get_history_oh_wo( - db_session=db_session, - collector_db_session=collector_db_session, - oh_session_id=oh_session_id, - parent_wo_num=overhaul.wo_parent - ) - + results = await get_history_oh_wo(db_session=db_session, collector_db_session=collector_db_session, oh_session_id=oh_session_id, parent_wo_num=overhaul.wo_parent) scope_cost_map = {scope.location_tag: scope.service_cost for scope in planning_scopes} - for result in results: - result["planning_service_cost"] = scope_cost_map.get(result["location_tag"], 0) - + result['planning_service_cost'] = scope_cost_map.get(result['location_tag'], 0) return results - \ No newline at end of file diff --git a/src/testing/integration_test.py b/src/testing/integration_test.py index d545425..992b648 100644 --- a/src/testing/integration_test.py +++ b/src/testing/integration_test.py @@ -1,296 +1,31 @@ -""" -Integration tests for Optimum OH backend. -All endpoints are sourced from frontend API calls using OPTIMUM_OH_API_URL. - -Run with: - pytest src/testing/integration_test.py -v -""" - import requests - -BASE_URL = "http://100.125.115.116:8000/optimumoh" -AUTH_BASE_URL = "http://100.125.115.116:8000/auth" - -# Replace with a valid token before running -AUTH_TOKEN = "Bearer " - -HEADERS = { - "Authorization": AUTH_TOKEN, - "Content-Type": "application/json", -} - -# Placeholder IDs — replace with real values from your database before running -OVERHAUL_SESSION_ID = "3704c82c-cfc5-47f7-813a-1661f89b0738" -SCOPE_JOB_ID = "db590764-72cb-4eb1-a30f-014116df0472" -CALCULATION_ID = "44f483f3-bfe4-4094-a59f-b97a10f2fea6" +BASE_URL = 'http://100.125.115.116:8000/optimumoh' +AUTH_BASE_URL = 'http://100.125.115.116:8000/auth' +AUTH_TOKEN = 'Bearer ' +HEADERS = {'Authorization': AUTH_TOKEN, 'Content-Type': 'application/json'} +OVERHAUL_SESSION_ID = '3704c82c-cfc5-47f7-813a-1661f89b0738' +SCOPE_JOB_ID = 'db590764-72cb-4eb1-a30f-014116df0472' +CALCULATION_ID = '44f483f3-bfe4-4094-a59f-b97a10f2fea6' BUDGET_THRESHOLD = 100000 RISK_COST = 1 -LOCATION_TAG = "3AL-F501A" -SCOPE_NAME = "A" -ASSET_NUM = "A19825" +LOCATION_TAG = '3AL-F501A' +SCOPE_NAME = 'A' +ASSET_NUM = 'A19825' EAF_INPUT = 0.85 DURATION = 17520 -SCOPE_EQUIPMENT_ACTIVITY_ID = "4527eb2b-0bc4-46b3-80a1-e1d2f67882f8" -ITEMNUM = "206510" -SPREADSHEET_LINK = "https://docs.google.com/spreadsheets/d/example_spreadsheet_id/edit" -# UUID-format placeholder — replace with a real UUID from your database - -# --------------------------------------------------------------------------- -# Users -# --------------------------------------------------------------------------- -USER_LIST = [ - {"id": 1, "name": "devmustbeadmin", "pass": "", "email": "", "authorization_token": ""}, - {"id": 2, "name": "devmustbeengineer", "pass": "", "email": "", "authorization_token": ""}, - {"id": 3, "name": "devmustbeapplicationadmin", "pass": "", "email": "", "authorization_token": ""}, - {"id": 4, "name": "devmustbemanagement", "pass": "", "email": "", "authorization_token": ""}, -] - -# --------------------------------------------------------------------------- -# Role-based access constants -# --------------------------------------------------------------------------- -# Endpoints guarded by require_any_role(*ALLOWED_ROLES) block Management entirely. -# Endpoints with only required_permission(READ) allow Management (has READ in ACL). -# Endpoints with no guard at all allow every role. -NON_MANAGEMENT = {"devmustbeadmin", "devmustbeengineer", "devmustbeapplicationadmin"} -ALL_ALLOWED = {"devmustbeadmin", "devmustbeengineer", "devmustbeapplicationadmin", "devmustbemanagement"} - +SCOPE_EQUIPMENT_ACTIVITY_ID = '4527eb2b-0bc4-46b3-80a1-e1d2f67882f8' +ITEMNUM = '206510' +SPREADSHEET_LINK = 'https://docs.google.com/spreadsheets/d/example_spreadsheet_id/edit' +USER_LIST = [{'id': 1, 'name': 'devmustbeadmin', 'pass': '', 'email': '', 'authorization_token': ''}, {'id': 2, 'name': 'devmustbeengineer', 'pass': '', 'email': '', 'authorization_token': ''}, {'id': 3, 'name': 'devmustbeapplicationadmin', 'pass': '', 'email': '', 'authorization_token': ''}, {'id': 4, 'name': 'devmustbemanagement', 'pass': '', 'email': '', 'authorization_token': ''}] +NON_MANAGEMENT = {'devmustbeadmin', 'devmustbeengineer', 'devmustbeapplicationadmin'} +ALL_ALLOWED = {'devmustbeadmin', 'devmustbeengineer', 'devmustbeapplicationadmin', 'devmustbemanagement'} def user_auth_headers(token: str) -> dict: - return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} - + return {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} def get_csrf_token(path: str, auth_token: str) -> str: - """Fetch a one-time CSRF token from be-auth for the given endpoint path. - - Args: - path: The endpoint path as seen by be-optimumoh (e.g. "/spareparts"). - Do NOT include the "/optimumoh" Kong prefix. - auth_token: The raw JWT token string (without "Bearer " prefix). - - Returns: - The plain-text CSRF token to send in the X-CSRF-Token header. - """ - res = requests.post( - f"{AUTH_BASE_URL}/csrf-token", - headers={ - "Authorization": f"Bearer {auth_token}", - "Content-Type": "application/json", - "X-Target-Path": path, - }, - json={ - "_csrf_request": True, - } - ) - print(f"Requested CSRF token for {path}, got status {res.status_code}, response: {res.text}") - assert res.status_code == 200, f"Failed to get CSRF token for {path}: {res.status_code} {res.text}" + res = requests.post(f'{AUTH_BASE_URL}/csrf-token', headers={'Authorization': f'Bearer {auth_token}', 'Content-Type': 'application/json', 'X-Target-Path': path}, json={'_csrf_request': True}) + print(f'Requested CSRF token for {path}, got status {res.status_code}, response: {res.text}') + assert res.status_code == 200, f'Failed to get CSRF token for {path}: {res.status_code} {res.text}' data = res.json() - # Response shape: {"data": {"csrf_token": "..."}, ...} - return data["data"]["csrf_token"] - - - - - -# OH GANTT - -# # [[BUG IN THIS ROUTE]] -# def test_get_overhaul_gantt(): -# """GET /overhaul-gantt — no role guard; all roles allowed""" -# for user in USER_LIST: - - - -# ───────────────────────────────────────────── -# Overhaul Session -# ───────────────────────────────────────────── - -# # [[Source code got commented]] -# def test_get_overhaul_session(): -# for user in USER_LIST: - -# # [[Source code got commented]] -# def test_post_overhaul_session(): -# """POST /overhaul-session — require_any_role + CREATE blocks Management""" -# for user in USER_LIST: -# if user["name"] in NON_MANAGEMENT: - -# # ───────────────────────────────────────────── -# # Overhaul Schedules -# # ───────────────────────────────────────────── - -# # [[Attribute error]] -# def test_post_overhaul_schedules(): -# """POST /overhaul-schedules — require_any_role + CREATE blocks Management""" -# "status": "Upcoming" -# for user in USER_LIST: -# if user["name"] in NON_MANAGEMENT: - - -# # [[Source code got commented]] -# def test_delete_overhaul_schedule(): -# for user in USER_LIST: -# if user["name"] in NON_MANAGEMENT: - - - - -# # ───────────────────────────────────────────── -# # Overhaul Jobs -# # ───────────────────────────────────────────── - -# # [[Source code got commented]] -# def test_post_overhaul_jobs(): -# for user in USER_LIST: - - -# ───────────────────────────────────────────── -# Scope Equipments -# ───────────────────────────────────────────── - -# # [[Database error: Unconsumed column names: scope_overhaul, assetnum, type, removal_date]] -# def test_post_scope_equipments(): -# """POST /scope-equipments — require_any_role blocks Management""" -# "scope_name": "A" -# for user in USER_LIST: -# if user["name"] in NON_MANAGEMENT: - -# # [[No endpoint exists]] -# def test_delete_scope_equipment(): -# for user in USER_LIST: -# if user["name"] in NON_MANAGEMENT: - - -# # ───────────────────────────────────────────── -# # Scope Equipment Jobs -# # ───────────────────────────────────────────── - -# # [[Source code got commented]] -# def test_post_scope_equipment_jobs(): -# for user in USER_LIST: - -# # [[Source code got commented]] -# def test_delete_scope_equipment_job(): -# for user in USER_LIST: - -# # ───────────────────────────────────────────── -# # Overhaul Jobs -# # ───────────────────────────────────────────── - -# # [[Source code got commented]] -# def test_get_overhaul_jobs(): -# for user in USER_LIST: - - -# # ───────────────────────────────────────────── -# # Calculation — Budget Constraint -# # ───────────────────────────────────────────── - -# def test_post_calculation_time_constraint(): -# for user in USER_LIST: -# if user["name"] in NON_MANAGEMENT: - - -# # ───────────────────────────────────────────── -# # Equipment Activities / Workscopes -# # ───────────────────────────────────────────── - -# # [[Source code got commented]] -# def test_post_equipment_activities(): -# """POST /equipment-activities — no role guard; all roles allowed -# Note: route commented out in api.py; active equivalent is POST /overhaul-activity/{session_id} -# """ -# for user in USER_LIST: - - -# # [[Source code got commented]] -# def test_delete_equipment_activity(): -# """POST /equipment-activities/delete/{id} — no role guard; all roles allowed -# Note: route commented out in api.py; active equivalent is POST /overhaul-activity/delete/{session}/{location_tag} -# """ -# for user in USER_LIST: - - -# # ───────────────────────────────────────────── -# # Jobs -# # ───────────────────────────────────────────── - -# # [[Source code got commented]] -# def test_get_jobs(): -# """GET /jobs — no role guard; all roles allowed""" -# for user in USER_LIST: - - -# ───────────────────────────────────────────── -# Overhauls sub-routes -# ───────────────────────────────────────────── - -# def test_get_overhauls_schedules(): -# for user in USER_LIST: - -# def test_get_overhauls_critical_parts(): -# for user in USER_LIST: - - -# # ───────────────────────────────────────────── -# # Overhaul Activity -# # ───────────────────────────────────────────── - -# # [[Attribute Error]] -# def test_get_overhaul_activity_by_assetnum(): -# for user in USER_LIST: - - -# # ───────────────────────────────────────────── -# # Workscopes -# # ───────────────────────────────────────────── - -# def test_post_workscopes(): -# """POST /workscopes — no role guard; all roles allowed -# """ -# for user in USER_LIST: - - -# ───────────────────────────────────────────── -# Equipment Workscopes -# ───────────────────────────────────────────── - -# def test_post_equipment_workscopes_delete(): -# """POST /equipment-workscopes/delete/{scope_job_id} — no role guard; all roles allowed -# No body required. -# """ -# for user in USER_LIST: - - -# # ───────────────────────────────────────────── -# # Calculation — Time Constraint -# # ───────────────────────────────────────────── - -# def test_post_calculation_time_constraint(): -# """POST /calculation/time-constraint — required_permission(CREATE); Management has no CREATE -# Payload: overhaulCost (float), ohSessionId (UUID), costPerFailure (float) -# Query params: scope_calculation_id (optional), with_results (optional int), simulation_id (optional) -# """ -# for user in USER_LIST: -# if user["name"] in NON_MANAGEMENT: - -# def test_get_calculation_time_constraint_parameters(): -# """GET /calculation/time-constraint/parameters — required_permission(READ); Management has READ -# Query params: calculation_id (optional) -# """ -# for user in USER_LIST: - -# def test_get_calculation_time_constraint_by_id_and_assetnum(): -# for user in USER_LIST: - - - - -# # ───────────────────────────────────────────── -# # Calculation — Budget Constraint -# # ───────────────────────────────────────────── - -# def test_get_calculation_budget_constraint(): -# """GET /calculation/budget-constraint/{session_id} — require_any_role blocks Management -# Query params: cost_threshold (float, default 100) -# """ -# for user in USER_LIST: + return data['data']['csrf_token'] diff --git a/src/testing/prod_integration_test.py b/src/testing/prod_integration_test.py index fa00bce..bd2e295 100644 --- a/src/testing/prod_integration_test.py +++ b/src/testing/prod_integration_test.py @@ -1,529 +1,255 @@ -""" -Integration tests for Optimum OH backend. -All endpoints are sourced from frontend API calls using OPTIMUM_OH_API_URL. - -Run with: - pytest src/testing/integration_test.py -v -""" - import requests - -BASE_URL = "http://100.125.115.116:8000/optimumoh" -AUTH_BASE_URL = "http://100.125.115.116:8000/auth" - -OVERHAUL_SESSION_ID = "3704c82c-cfc5-47f7-813a-1661f89b0738" -SCOPE_JOB_ID = "db590764-72cb-4eb1-a30f-014116df0472" -CALCULATION_ID = "44f483f3-bfe4-4094-a59f-b97a10f2fea6" +BASE_URL = 'http://100.125.115.116:8000/optimumoh' +AUTH_BASE_URL = 'http://100.125.115.116:8000/auth' +OVERHAUL_SESSION_ID = '3704c82c-cfc5-47f7-813a-1661f89b0738' +SCOPE_JOB_ID = 'db590764-72cb-4eb1-a30f-014116df0472' +CALCULATION_ID = '44f483f3-bfe4-4094-a59f-b97a10f2fea6' BUDGET_THRESHOLD = 100000 RISK_COST = 1 -LOCATION_TAG = "3AL-F501A" -SCOPE_NAME = "A" -ASSET_NUM = "A19825" +LOCATION_TAG = '3AL-F501A' +SCOPE_NAME = 'A' +ASSET_NUM = 'A19825' EAF_INPUT = 0.85 DURATION = 17520 -SCOPE_EQUIPMENT_ACTIVITY_ID = "4527eb2b-0bc4-46b3-80a1-e1d2f67882f8" -ITEMNUM = "206510" -SPREADSHEET_LINK = "https://docs.google.com/spreadsheets/d/example_spreadsheet_id/edit" - -# --------------------------------------------------------------------------- -# Users -# --------------------------------------------------------------------------- -USER_LIST = [ - {"id": 1, "name": "devmustbeadmin", "pass": "", "email": "", "authorization_token": ""}, - {"id": 2, "name": "devmustbeengineer", "pass": "", "email": "", "authorization_token": ""}, - {"id": 3, "name": "devmustbeapplicationadmin", "pass": "", "email": "", "authorization_token": ""}, - {"id": 4, "name": "devmustbemanagement", "pass": "", "email": "", "authorization_token": ""}, -] - -# --------------------------------------------------------------------------- -# Role-based access constants -# --------------------------------------------------------------------------- -# Endpoints guarded by require_any_role(*ALLOWED_ROLES) block Management entirely. -# Endpoints with only required_permission(READ) allow Management (has READ in ACL). -# Endpoints with no guard at all allow every role. -NON_MANAGEMENT = {"devmustbeadmin", "devmustbeengineer", "devmustbeapplicationadmin"} -ALL_ALLOWED = {"devmustbeadmin", "devmustbeengineer", "devmustbeapplicationadmin", "devmustbemanagement"} - +SCOPE_EQUIPMENT_ACTIVITY_ID = '4527eb2b-0bc4-46b3-80a1-e1d2f67882f8' +ITEMNUM = '206510' +SPREADSHEET_LINK = 'https://docs.google.com/spreadsheets/d/example_spreadsheet_id/edit' +USER_LIST = [{'id': 1, 'name': 'devmustbeadmin', 'pass': '', 'email': '', 'authorization_token': ''}, {'id': 2, 'name': 'devmustbeengineer', 'pass': '', 'email': '', 'authorization_token': ''}, {'id': 3, 'name': 'devmustbeapplicationadmin', 'pass': '', 'email': '', 'authorization_token': ''}, {'id': 4, 'name': 'devmustbemanagement', 'pass': '', 'email': '', 'authorization_token': ''}] +NON_MANAGEMENT = {'devmustbeadmin', 'devmustbeengineer', 'devmustbeapplicationadmin'} +ALL_ALLOWED = {'devmustbeadmin', 'devmustbeengineer', 'devmustbeapplicationadmin', 'devmustbemanagement'} def user_auth_headers(token: str) -> dict: - return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} - + return {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} def get_csrf_token(path: str, auth_token: str) -> str: - """Fetch a one-time CSRF token from be-auth for the given endpoint path. - - Args: - path: The endpoint path as seen by be-optimumoh (e.g. "/spareparts"). - Do NOT include the "/optimumoh" Kong prefix. - auth_token: The raw JWT token string (without "Bearer " prefix). - - Returns: - The plain-text CSRF token to send in the X-CSRF-Token header. - """ - res = requests.post( - f"{AUTH_BASE_URL}/csrf-token", - headers={ - "Authorization": f"Bearer {auth_token}", - "Content-Type": "application/json", - "X-Target-Path": path, - }, - json={ - "_csrf_request": True, - } - ) - print(f"Requested CSRF token for {path}, got status {res.status_code}, response: {res.text}") - assert res.status_code == 200, f"Failed to get CSRF token for {path}: {res.status_code} {res.text}" + res = requests.post(f'{AUTH_BASE_URL}/csrf-token', headers={'Authorization': f'Bearer {auth_token}', 'Content-Type': 'application/json', 'X-Target-Path': path}, json={'_csrf_request': True}) + print(f'Requested CSRF token for {path}, got status {res.status_code}, response: {res.text}') + assert res.status_code == 200, f'Failed to get CSRF token for {path}: {res.status_code} {res.text}' data = res.json() - # Response shape: {"data": {"csrf_token": "..."}, ...} - return data["data"]["csrf_token"] - - -# ───────────────────────────────────────────── -# Overhaul Schedules -# ───────────────────────────────────────────── + return data['data']['csrf_token'] def test_get_overhaul_schedules(): - """GET /overhaul-schedules — require_any_role blocks Management""" for user in USER_LIST: - res = requests.get(f"{BASE_URL}/overhaul-schedules", headers=user_auth_headers(user["authorization_token"])) - expected = 200 if user["name"] in NON_MANAGEMENT else 403 + res = requests.get(f'{BASE_URL}/overhaul-schedules', headers=user_auth_headers(user['authorization_token'])) + expected = 200 if user['name'] in NON_MANAGEMENT else 403 assert res.status_code == expected, f"[{user['name']}] expected {expected}, got {res.status_code}" def test_get_overhaul_schedules_by_id(): - """GET /overhaul-schedules/{overhaul_session_id} — require_any_role blocks Management""" for user in USER_LIST: - res = requests.get( - f"{BASE_URL}/overhaul-schedules/{OVERHAUL_SESSION_ID}", - headers=user_auth_headers(user["authorization_token"]), - ) - expected = 200 if user["name"] in NON_MANAGEMENT else 403 + res = requests.get(f'{BASE_URL}/overhaul-schedules/{OVERHAUL_SESSION_ID}', headers=user_auth_headers(user['authorization_token'])) + expected = 200 if user['name'] in NON_MANAGEMENT else 403 assert res.status_code == expected, f"[{user['name']}] expected {expected}, got {res.status_code}: {res.text}" def test_post_overhaul_schedules_update(): - """POST /overhaul-schedules/update/{scope_id} — require_any_role + EDIT blocks Management - Payload: duration_oh (optional int), crew_number (optional int), status (optional str) - """ - payload = { - "duration_oh": 720, - "crew_number": 10, - "status": "Upcoming", - } - for user in USER_LIST: - csrf_token = get_csrf_token("/overhaul-schedules/update", user["authorization_token"]) - headers = {**user_auth_headers(user["authorization_token"]), "X-CSRF-Token": csrf_token} - res = requests.post( - f"{BASE_URL}/overhaul-schedules/update/{OVERHAUL_SESSION_ID}", - json=payload, - headers=headers, - ) + payload = {'duration_oh': 720, 'crew_number': 10, 'status': 'Upcoming'} + for user in USER_LIST: + csrf_token = get_csrf_token('/overhaul-schedules/update', user['authorization_token']) + headers = {**user_auth_headers(user['authorization_token']), 'X-CSRF-Token': csrf_token} + res = requests.post(f'{BASE_URL}/overhaul-schedules/update/{OVERHAUL_SESSION_ID}', json=payload, headers=headers) print(f"[{user['name']}], got {res.status_code}, response: {res.text}") - if user["name"] in NON_MANAGEMENT: + if user['name'] in NON_MANAGEMENT: assert res.status_code in (200, 204, 404), f"[{user['name']}] expected 200/204/404, got {res.status_code}" else: assert res.status_code == 403, f"[{user['name']}] expected 403, got {res.status_code}" def test_get_overhaul_schedules_history(): - """GET /overhaul-schedules/history — require_any_role blocks Management""" for user in USER_LIST: - res = requests.get(f"{BASE_URL}/overhaul-schedules/history", headers=user_auth_headers(user["authorization_token"])) - expected = 200 if user["name"] in NON_MANAGEMENT else 403 + res = requests.get(f'{BASE_URL}/overhaul-schedules/history', headers=user_auth_headers(user['authorization_token'])) + expected = 200 if user['name'] in NON_MANAGEMENT else 403 assert res.status_code == expected, f"[{user['name']}] expected {expected}, got {res.status_code}" def test_post_overhaul_schedules_delete(): - """POST /overhaul-schedules/delete/{scope_id} — require_any_role + DELETE blocks Management - No body required. CSRF token required in production. - """ - for user in USER_LIST: - csrf_token = get_csrf_token(f"/overhaul-schedules/delete/{OVERHAUL_SESSION_ID}", user["authorization_token"]) - headers = {**user_auth_headers(user["authorization_token"]), "X-CSRF-Token": csrf_token} - res = requests.post( - f"{BASE_URL}/overhaul-schedules/delete/{OVERHAUL_SESSION_ID}", - headers=headers, - ) + for user in USER_LIST: + csrf_token = get_csrf_token(f'/overhaul-schedules/delete/{OVERHAUL_SESSION_ID}', user['authorization_token']) + headers = {**user_auth_headers(user['authorization_token']), 'X-CSRF-Token': csrf_token} + res = requests.post(f'{BASE_URL}/overhaul-schedules/delete/{OVERHAUL_SESSION_ID}', headers=headers) print(f"[{user['name']}], got {res.status_code}, response: {res.text}") - if user["name"] in NON_MANAGEMENT: + if user['name'] in NON_MANAGEMENT: assert res.status_code in (200, 204, 404), f"[{user['name']}] expected 200/204/404, got {res.status_code}" else: assert res.status_code == 403, f"[{user['name']}] expected 403, got {res.status_code}" -# ───────────────────────────────────────────── -# Overhauls -# ───────────────────────────────────────────── - def test_get_overhauls(): - """GET /overhauls — required_permission(READ) only; Management has READ in ACL""" for user in USER_LIST: - res = requests.get(f"{BASE_URL}/overhauls", headers=user_auth_headers(user["authorization_token"])) + res = requests.get(f'{BASE_URL}/overhauls', headers=user_auth_headers(user['authorization_token'])) assert res.status_code == 200, f"[{user['name']}] expected 200, got {res.status_code}" -# ───────────────────────────────────────────── -# Overhaul Gantt -# ───────────────────────────────────────────── - def test_get_overhaul_gantt_spreadsheet(): - """GET /overhaul-gantt/spreadsheet — no role guard; all roles allowed""" for user in USER_LIST: - res = requests.get( - f"{BASE_URL}/overhaul-gantt/spreadsheet", - headers=user_auth_headers(user["authorization_token"]), - ) + res = requests.get(f'{BASE_URL}/overhaul-gantt/spreadsheet', headers=user_auth_headers(user['authorization_token'])) assert res.status_code == 200, f"[{user['name']}] expected 200, got {res.status_code}: {res.text}" - - -# ───────────────────────────────────────────── -# Spareparts -# ───────────────────────────────────────────── - def test_get_spareparts(): - """GET /spareparts — require_any_role blocks Management""" for user in USER_LIST: - res = requests.get(f"{BASE_URL}/spareparts", headers=user_auth_headers(user["authorization_token"])) - expected = 200 if user["name"] in NON_MANAGEMENT else 403 + res = requests.get(f'{BASE_URL}/spareparts', headers=user_auth_headers(user['authorization_token'])) + expected = 200 if user['name'] in NON_MANAGEMENT else 403 assert res.status_code == expected, f"[{user['name']}] expected {expected}, got {res.status_code}" def test_get_equipment_spareparts(): - """GET /equipment-spareparts/{location_tag} — require_any_role blocks Management""" for user in USER_LIST: - res = requests.get( - f"{BASE_URL}/equipment-spareparts/{LOCATION_TAG}", - headers=user_auth_headers(user["authorization_token"]), - ) - expected = 200 if user["name"] in NON_MANAGEMENT else 403 + res = requests.get(f'{BASE_URL}/equipment-spareparts/{LOCATION_TAG}', headers=user_auth_headers(user['authorization_token'])) + expected = 200 if user['name'] in NON_MANAGEMENT else 403 assert res.status_code == expected, f"[{user['name']}] expected {expected}, got {res.status_code}" def test_post_spareparts(): - """POST /spareparts — require_any_role blocks Management - Payload: itemnum (str), remark (str). CSRF token required. - """ - payload = { - "itemnum": ITEMNUM, - "remark": "Test remark for integration test", - } - for user in USER_LIST: - csrf_token = get_csrf_token("/spareparts", user["authorization_token"]) - headers = {**user_auth_headers(user["authorization_token"]), "X-CSRF-Token": csrf_token} - res = requests.post(f"{BASE_URL}/spareparts", json=payload, headers=headers) - if user["name"] in NON_MANAGEMENT: + payload = {'itemnum': ITEMNUM, 'remark': 'Test remark for integration test'} + for user in USER_LIST: + csrf_token = get_csrf_token('/spareparts', user['authorization_token']) + headers = {**user_auth_headers(user['authorization_token']), 'X-CSRF-Token': csrf_token} + res = requests.post(f'{BASE_URL}/spareparts', json=payload, headers=headers) + if user['name'] in NON_MANAGEMENT: assert res.status_code in (200, 201), f"[{user['name']}] expected 200/201, got {res.status_code}" else: assert res.status_code == 403, f"[{user['name']}] expected 403, got {res.status_code}" def test_post_overhaul_gantt_spreadsheet(): - """POST /overhaul-gantt/spreadsheet — no role guard; all roles allowed - Payload: spreadsheet_link (str). CSRF token required in production. - """ - payload = { - "spreadsheet_link": SPREADSHEET_LINK, - } - for user in USER_LIST: - csrf_token = get_csrf_token("/overhaul-gantt/spreadsheet", user["authorization_token"]) - headers = {**user_auth_headers(user["authorization_token"]), "X-CSRF-Token": csrf_token} - res = requests.post( - f"{BASE_URL}/overhaul-gantt/spreadsheet", - json=payload, - headers=headers, - ) + payload = {'spreadsheet_link': SPREADSHEET_LINK} + for user in USER_LIST: + csrf_token = get_csrf_token('/overhaul-gantt/spreadsheet', user['authorization_token']) + headers = {**user_auth_headers(user['authorization_token']), 'X-CSRF-Token': csrf_token} + res = requests.post(f'{BASE_URL}/overhaul-gantt/spreadsheet', json=payload, headers=headers) assert res.status_code in (200, 201), f"[{user['name']}] expected 200/201, got {res.status_code}" -# ───────────────────────────────────────────── -# Scope Equipments -# ───────────────────────────────────────────── - def test_get_scope_equipments(): - """GET /scope-equipments?page=1 — require_any_role blocks Management""" for user in USER_LIST: - res = requests.get(f"{BASE_URL}/scope-equipments?page=1", headers=user_auth_headers(user["authorization_token"])) - expected = 200 if user["name"] in NON_MANAGEMENT else 403 + res = requests.get(f'{BASE_URL}/scope-equipments?page=1', headers=user_auth_headers(user['authorization_token'])) + expected = 200 if user['name'] in NON_MANAGEMENT else 403 assert res.status_code == expected, f"[{user['name']}] expected {expected}, got {res.status_code}" - def test_get_scope_equipments_available(): - """GET /scope-equipments/available/{scope_name} — require_any_role blocks Management""" for user in USER_LIST: - res = requests.get( - f"{BASE_URL}/scope-equipments/available/{SCOPE_NAME}", - headers=user_auth_headers(user["authorization_token"]), - ) - expected = 200 if user["name"] in NON_MANAGEMENT else 403 + res = requests.get(f'{BASE_URL}/scope-equipments/available/{SCOPE_NAME}', headers=user_auth_headers(user['authorization_token'])) + expected = 200 if user['name'] in NON_MANAGEMENT else 403 assert res.status_code == expected, f"[{user['name']}] expected {expected}, got {res.status_code}" - -# ───────────────────────────────────────────── -# Overhaul Schedules (additional) -# ───────────────────────────────────────────── - def test_get_overhaul_schedules(): - """GET /overhaul-schedules — require_any_role blocks Management""" for user in USER_LIST: - res = requests.get(f"{BASE_URL}/overhaul-schedules", headers=user_auth_headers(user["authorization_token"])) - expected = 200 if user["name"] in NON_MANAGEMENT else 403 + res = requests.get(f'{BASE_URL}/overhaul-schedules', headers=user_auth_headers(user['authorization_token'])) + expected = 200 if user['name'] in NON_MANAGEMENT else 403 assert res.status_code == expected, f"[{user['name']}] expected {expected}, got {res.status_code}: {res.text}" - def test_get_overhaul_schedules_history(): - """GET /overhaul-schedules/history — require_any_role blocks Management""" for user in USER_LIST: - res = requests.get(f"{BASE_URL}/overhaul-schedules/history", headers=user_auth_headers(user["authorization_token"])) - expected = 200 if user["name"] in NON_MANAGEMENT else 403 + res = requests.get(f'{BASE_URL}/overhaul-schedules/history', headers=user_auth_headers(user['authorization_token'])) + expected = 200 if user['name'] in NON_MANAGEMENT else 403 assert res.status_code == expected, f"[{user['name']}] expected {expected}, got {res.status_code}: {res.text}" -# ───────────────────────────────────────────── -# Overhauls sub-routes -# ───────────────────────────────────────────── - def test_get_overhauls_system_components(): - """GET /overhauls/system-components — required_permission(READ); Management has READ""" for user in USER_LIST: - res = requests.get(f"{BASE_URL}/overhauls/system-components", headers=user_auth_headers(user["authorization_token"])) + res = requests.get(f'{BASE_URL}/overhauls/system-components', headers=user_auth_headers(user['authorization_token'])) assert res.status_code == 200, f"[{user['name']}] expected 200, got {res.status_code}: {res.text}" - -# ───────────────────────────────────────────── -# Workscopes -# ───────────────────────────────────────────── - def test_get_workscopes(): - """GET /workscopes — no role guard; all roles allowed""" for user in USER_LIST: - res = requests.get(f"{BASE_URL}/workscopes", headers=user_auth_headers(user["authorization_token"])) + res = requests.get(f'{BASE_URL}/workscopes', headers=user_auth_headers(user['authorization_token'])) assert res.status_code == 200, f"[{user['name']}] expected 200, got {res.status_code}: {res.text}" def test_get_workscopes_by_id(): - """GET /workscopes/{scope_equipment_activity_id}?activity_id=... — no role guard; all roles allowed - """ - for user in USER_LIST: - res = requests.get( - f"{BASE_URL}/workscopes/{SCOPE_EQUIPMENT_ACTIVITY_ID}", - params={"activity_id": SCOPE_EQUIPMENT_ACTIVITY_ID}, - headers=user_auth_headers(user["authorization_token"]), - ) + for user in USER_LIST: + res = requests.get(f'{BASE_URL}/workscopes/{SCOPE_EQUIPMENT_ACTIVITY_ID}', params={'activity_id': SCOPE_EQUIPMENT_ACTIVITY_ID}, headers=user_auth_headers(user['authorization_token'])) assert res.status_code in (200, 404), f"[{user['name']}] expected 200/404, got {res.status_code}: {res.text}" def test_post_workscopes_update(): - """POST /workscopes/update/{scope_equipment_activity_id} — no role guard; all roles allowed - Payload: description (str) - """ - payload = { - "description": "Updated workscope description", - } - for user in USER_LIST: - csrf_token = get_csrf_token("/workscopes/update", user["authorization_token"]) - headers = {**user_auth_headers(user["authorization_token"]), "X-CSRF-Token": csrf_token} - res = requests.post( - f"{BASE_URL}/workscopes/update/{SCOPE_EQUIPMENT_ACTIVITY_ID}", - json=payload, - params={"activity_id": SCOPE_EQUIPMENT_ACTIVITY_ID}, - headers=headers, - ) + payload = {'description': 'Updated workscope description'} + for user in USER_LIST: + csrf_token = get_csrf_token('/workscopes/update', user['authorization_token']) + headers = {**user_auth_headers(user['authorization_token']), 'X-CSRF-Token': csrf_token} + res = requests.post(f'{BASE_URL}/workscopes/update/{SCOPE_EQUIPMENT_ACTIVITY_ID}', json=payload, params={'activity_id': SCOPE_EQUIPMENT_ACTIVITY_ID}, headers=headers) print(f"[{user['name']}], got {res.status_code}, response: {res.text}") assert res.status_code in (200, 404), f"[{user['name']}] expected 200/404, got {res.status_code}" - def test_post_workscopes_delete(): - """POST /workscopes/delete/{scope_equipment_activity_id} — no role guard; all roles allowed - No body required. - """ - for user in USER_LIST: - csrf_token = get_csrf_token("/workscopes/delete", user["authorization_token"]) - headers = {**user_auth_headers(user["authorization_token"]), "X-CSRF-Token": csrf_token} - res = requests.post( - f"{BASE_URL}/workscopes/delete/{SCOPE_EQUIPMENT_ACTIVITY_ID}", - params={"activity_id": SCOPE_EQUIPMENT_ACTIVITY_ID}, - headers=headers, - ) + for user in USER_LIST: + csrf_token = get_csrf_token('/workscopes/delete', user['authorization_token']) + headers = {**user_auth_headers(user['authorization_token']), 'X-CSRF-Token': csrf_token} + res = requests.post(f'{BASE_URL}/workscopes/delete/{SCOPE_EQUIPMENT_ACTIVITY_ID}', params={'activity_id': SCOPE_EQUIPMENT_ACTIVITY_ID}, headers=headers) print(f"[{user['name']}], got {res.status_code}, response: {res.text}") assert res.status_code in (200, 204, 404), f"[{user['name']}] expected 200/204/404, got {res.status_code}" -# ───────────────────────────────────────────── -# Overhaul Activity -# ───────────────────────────────────────────── - def test_get_overhaul_activity(): - """GET /overhaul-activity/{overhaul_session} — require_any_role blocks Management""" for user in USER_LIST: - res = requests.get( - f"{BASE_URL}/overhaul-activity/{OVERHAUL_SESSION_ID}", - headers=user_auth_headers(user["authorization_token"]), - ) - expected = 200 if user["name"] in NON_MANAGEMENT else 403 + res = requests.get(f'{BASE_URL}/overhaul-activity/{OVERHAUL_SESSION_ID}', headers=user_auth_headers(user['authorization_token'])) + expected = 200 if user['name'] in NON_MANAGEMENT else 403 assert res.status_code == expected, f"[{user['name']}] expected {expected}, got {res.status_code}" def test_post_overhaul_activity_current(): - """POST /overhaul-activity/{session_id} — require_any_role blocks Management - Payload: location_tags (List[str]). CSRF token required. - """ - payload = { - "location_tags": [LOCATION_TAG], - } - for user in USER_LIST: - csrf_token = get_csrf_token("/overhaul-activity", user["authorization_token"]) - headers = {**user_auth_headers(user["authorization_token"]), "X-CSRF-Token": csrf_token} - res = requests.post( - f"{BASE_URL}/overhaul-activity/{OVERHAUL_SESSION_ID}", - json=payload, - headers=headers, - ) - if user["name"] in NON_MANAGEMENT: + payload = {'location_tags': [LOCATION_TAG]} + for user in USER_LIST: + csrf_token = get_csrf_token('/overhaul-activity', user['authorization_token']) + headers = {**user_auth_headers(user['authorization_token']), 'X-CSRF-Token': csrf_token} + res = requests.post(f'{BASE_URL}/overhaul-activity/{OVERHAUL_SESSION_ID}', json=payload, headers=headers) + if user['name'] in NON_MANAGEMENT: assert res.status_code in (200, 201), f"[{user['name']}] expected 200/201, got {res.status_code}" else: assert res.status_code == 403, f"[{user['name']}] expected 403, got {res.status_code}" def test_delete_overhaul_activity(): - """POST /overhaul-activity/delete/{overhaul_session}/{location_tag} — require_any_role blocks Management. CSRF required.""" - for user in USER_LIST: - csrf_token = get_csrf_token(f"/overhaul-activity/delete/{OVERHAUL_SESSION_ID}/{LOCATION_TAG}", user["authorization_token"]) - headers = {**user_auth_headers(user["authorization_token"]), "X-CSRF-Token": csrf_token} - res = requests.post( - f"{BASE_URL}/overhaul-activity/delete/{OVERHAUL_SESSION_ID}/{LOCATION_TAG}", - headers=headers, - ) - if user["name"] in NON_MANAGEMENT: + for user in USER_LIST: + csrf_token = get_csrf_token(f'/overhaul-activity/delete/{OVERHAUL_SESSION_ID}/{LOCATION_TAG}', user['authorization_token']) + headers = {**user_auth_headers(user['authorization_token']), 'X-CSRF-Token': csrf_token} + res = requests.post(f'{BASE_URL}/overhaul-activity/delete/{OVERHAUL_SESSION_ID}/{LOCATION_TAG}', headers=headers) + if user['name'] in NON_MANAGEMENT: assert res.status_code in (200, 204, 404), f"[{user['name']}] expected 200/204/404, got {res.status_code}" else: assert res.status_code == 403, f"[{user['name']}] expected 403, got {res.status_code}" - -# ───────────────────────────────────────────── -# Scope Equipments History -# ───────────────────────────────────────────── - def test_get_scope_equipments_history(): - """GET /scope-equipments/history/{oh_session_id} — require_any_role blocks Management""" for user in USER_LIST: - res = requests.get( - f"{BASE_URL}/scope-equipments/history/{OVERHAUL_SESSION_ID}", - headers=user_auth_headers(user["authorization_token"]), - ) - expected = 200 if user["name"] in NON_MANAGEMENT else 403 + res = requests.get(f'{BASE_URL}/scope-equipments/history/{OVERHAUL_SESSION_ID}', headers=user_auth_headers(user['authorization_token'])) + expected = 200 if user['name'] in NON_MANAGEMENT else 403 assert res.status_code == expected, f"[{user['name']}] expected {expected}, got {res.status_code}: {res.text}" - -# ───────────────────────────────────────────── -# Equipment Workscopes -# ───────────────────────────────────────────── - def test_get_equipment_workscopes(): - """GET /equipment-workscopes/{location_tag} — no role guard; all roles allowed - Query params: scope (optional str) - """ - for user in USER_LIST: - res = requests.get( - f"{BASE_URL}/equipment-workscopes/{LOCATION_TAG}", - headers=user_auth_headers(user["authorization_token"]), - ) + for user in USER_LIST: + res = requests.get(f'{BASE_URL}/equipment-workscopes/{LOCATION_TAG}', headers=user_auth_headers(user['authorization_token'])) assert res.status_code == 200, f"[{user['name']}] expected 200, got {res.status_code}: {res.text}" - def test_post_equipment_workscopes(): - """POST /equipment-workscopes/{assetnum} — no role guard; all roles allowed - Payload: job_ids (List[UUID]) - """ - payload = { - "job_ids": [], - } - for user in USER_LIST: - csrf_token = get_csrf_token("/equipment-workscopes", user["authorization_token"]) - headers = {**user_auth_headers(user["authorization_token"]), "X-CSRF-Token": csrf_token} - res = requests.post( - f"{BASE_URL}/equipment-workscopes/{ASSET_NUM}", - json=payload, - headers=headers, - ) + payload = {'job_ids': []} + for user in USER_LIST: + csrf_token = get_csrf_token('/equipment-workscopes', user['authorization_token']) + headers = {**user_auth_headers(user['authorization_token']), 'X-CSRF-Token': csrf_token} + res = requests.post(f'{BASE_URL}/equipment-workscopes/{ASSET_NUM}', json=payload, headers=headers) print(f"[{user['name']}], got {res.status_code}, response: {res.text}") assert res.status_code in (200, 201), f"[{user['name']}] expected 200/201, got {res.status_code}" - -# ───────────────────────────────────────────── -# Calculation — Target Reliability -# ───────────────────────────────────────────── - def test_get_calculation_target_reliability(): - """GET /calculation/target-reliability — required_permission(READ); Management has READ - Query params: oh_session_id (required), eaf_input (float), duration (int), simulation_id (optional), cut_hours (optional) - """ - for user in USER_LIST: - res = requests.get( - f"{BASE_URL}/calculation/target-reliability" - f"?oh_session_id={OVERHAUL_SESSION_ID}&eaf_input={EAF_INPUT}&duration={DURATION}", - headers=user_auth_headers(user["authorization_token"]), - ) + for user in USER_LIST: + res = requests.get(f'{BASE_URL}/calculation/target-reliability?oh_session_id={OVERHAUL_SESSION_ID}&eaf_input={EAF_INPUT}&duration={DURATION}', headers=user_auth_headers(user['authorization_token'])) assert res.status_code in (200, 400, 404, 425), f"[{user['name']}] expected 200/400/404/425, got {res.status_code}: {res.text}" - -# ───────────────────────────────────────────── -# Calculation — Time Constraint -# ───────────────────────────────────────────── - def test_get_calculation_time_constraint(): - """GET /calculation/time-constraint — required_permission(READ); Management has READ""" for user in USER_LIST: - res = requests.get( - f"{BASE_URL}/calculation/time-constraint", - headers=user_auth_headers(user["authorization_token"]), - ) + res = requests.get(f'{BASE_URL}/calculation/time-constraint', headers=user_auth_headers(user['authorization_token'])) assert res.status_code == 200, f"[{user['name']}] expected 200, got {res.status_code}: {res.text}" def test_get_calculation_time_constraint_by_id(): - """GET /calculation/time-constraint/{calculation_id} — required_permission(READ); Management has READ - Query params: risk_cost (optional int, default 1) - """ - for user in USER_LIST: - res = requests.get( - f"{BASE_URL}/calculation/time-constraint/{CALCULATION_ID}?risk_cost={RISK_COST}", - headers=user_auth_headers(user["authorization_token"]), - ) + for user in USER_LIST: + res = requests.get(f'{BASE_URL}/calculation/time-constraint/{CALCULATION_ID}?risk_cost={RISK_COST}', headers=user_auth_headers(user['authorization_token'])) assert res.status_code in (200, 404), f"[{user['name']}] expected 200/404, got {res.status_code}: {res.text}" def test_post_calculation_time_constraint_simulation(): - """POST /calculation/time-constraint/{calculation_id}/simulation — required_permission(READ); Management has READ - Payload: intervalDays (int) - """ - payload = { - "intervalDays": 365, - } - for user in USER_LIST: - csrf_token = get_csrf_token("/calculation/time-constraint/simulation", user["authorization_token"]) - headers = {**user_auth_headers(user["authorization_token"]), "X-CSRF-Token": csrf_token} - res = requests.post( - f"{BASE_URL}/calculation/time-constraint/{CALCULATION_ID}/simulation", - json=payload, - headers=headers, - ) + payload = {'intervalDays': 365} + for user in USER_LIST: + csrf_token = get_csrf_token('/calculation/time-constraint/simulation', user['authorization_token']) + headers = {**user_auth_headers(user['authorization_token']), 'X-CSRF-Token': csrf_token} + res = requests.post(f'{BASE_URL}/calculation/time-constraint/{CALCULATION_ID}/simulation', json=payload, headers=headers) print(f"[{user['name']}], got {res.status_code}, response: {res.text}") assert res.status_code in (200, 201, 404), f"[{user['name']}] expected 200/201/404, got {res.status_code}" def test_post_calculation_time_constraint_update(): - """POST /calculation/time-constraint/update/{calculation_id} — required_permission(EDIT); Management has no EDIT - Payload: List of { location_tag (str), name (str), is_included (bool) }. CSRF token required in production. - """ - payload = [ - {"location_tag": LOCATION_TAG, "name": "Test Equipment", "is_included": True}, - ] - for user in USER_LIST: - csrf_token = get_csrf_token(f"/calculation/time-constraint/update/{CALCULATION_ID}", user["authorization_token"]) - headers = {**user_auth_headers(user["authorization_token"]), "X-CSRF-Token": csrf_token} - res = requests.post( - f"{BASE_URL}/calculation/time-constraint/update/{CALCULATION_ID}", - json=payload, - headers=headers, - ) + payload = [{'location_tag': LOCATION_TAG, 'name': 'Test Equipment', 'is_included': True}] + for user in USER_LIST: + csrf_token = get_csrf_token(f'/calculation/time-constraint/update/{CALCULATION_ID}', user['authorization_token']) + headers = {**user_auth_headers(user['authorization_token']), 'X-CSRF-Token': csrf_token} + res = requests.post(f'{BASE_URL}/calculation/time-constraint/update/{CALCULATION_ID}', json=payload, headers=headers) print(f"[{user['name']}], got {res.status_code}, response: {res.text}") - if user["name"] in NON_MANAGEMENT: + if user['name'] in NON_MANAGEMENT: assert res.status_code in (200, 204, 404), f"[{user['name']}] expected 200/204/404, got {res.status_code}" else: assert res.status_code == 403, f"[{user['name']}] expected 403, got {res.status_code}" - -# ───────────────────────────────────────────── -# Equipment Activities / Workscopes -# ───────────────────────────────────────────── - def test_get_equipment_workscopes(): - """GET /equipment-workscopes/{location_tag} — require_any_role blocks Management""" for user in USER_LIST: - res = requests.get( - f"{BASE_URL}/equipment-workscopes/{LOCATION_TAG}", - headers=user_auth_headers(user["authorization_token"]), - ) + res = requests.get(f'{BASE_URL}/equipment-workscopes/{LOCATION_TAG}', headers=user_auth_headers(user['authorization_token'])) assert res.status_code == 200, f"[{user['name']}] expected 200, got {res.status_code}" diff --git a/src/utils.py b/src/utils.py index e0010fa..bd6b4ca 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,152 +1,75 @@ import re from datetime import datetime, timedelta from typing import Optional - import pytz from dateutil.relativedelta import relativedelta - from src.config import TIMEZONE, REALIBILITY_SERVICE_API - def parse_relative_expression(date_str: str) -> Optional[datetime]: - """ - Parse relative date expressions using T (days), M (months), and Y (years) - Returns tuple of (datetime, type_description) or None if not a relative date - """ - pattern = r"^([HTMY])([+-]\d+)?$" + pattern = '^([HTMY])([+-]\\d+)?$' match = re.match(pattern, date_str) - if not match: return None - unit, offset = match.groups() offset = int(offset) if offset else 0 - # Use UTC timezone for consistency - jakarta_tz = pytz.timezone("Asia/Jakarta") + jakarta_tz = pytz.timezone('Asia/Jakarta') today = datetime.now(jakarta_tz) - if unit == "H": - # For hours, keep minutes and seconds + if unit == 'H': result_time = today + timedelta(hours=offset) return result_time - elif unit == "T": + elif unit == 'T': return today + timedelta(days=offset) - elif unit == "M": + elif unit == 'M': return today + relativedelta(months=offset) - elif unit == "Y": + elif unit == 'Y': return today + relativedelta(years=offset) - def parse_date_string(date_str: str) -> Optional[datetime]: - """ - Parse date strings in various formats including relative expressions - Returns tuple of (datetime, type) - """ - # Try parsing as relative expression first relative_result = parse_relative_expression(date_str) if relative_result: return relative_result - - # Try different date formats - date_formats = [ - ("%Y-%m-%d", "iso"), # 2024-11-08 - ("%Y/%m/%d", "slash"), # 2024/11/08 - ("%d-%m-%Y", "european"), # 08-11-2024 - ("%d/%m/%Y", "european_slash"), # 08/11/2024 - ("%Y.%m.%d", "dot"), # 2024.11.08 - ("%d.%m.%Y", "european_dot"), # 08.11.2024 - ] - + date_formats = [('%Y-%m-%d', 'iso'), ('%Y/%m/%d', 'slash'), ('%d-%m-%Y', 'european'), ('%d/%m/%Y', 'european_slash'), ('%Y.%m.%d', 'dot'), ('%d.%m.%Y', 'european_dot')] for fmt, type_name in date_formats: try: - # Parse the date and set it to start of day in UTC dt = datetime.strptime(date_str, fmt) - dt = dt.replace( - hour=0, - minute=0, - second=0, - microsecond=0, - tzinfo=pytz.timezone("Asia/Jakarta"), - ) + dt = dt.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=pytz.timezone('Asia/Jakarta')) return dt except ValueError: continue - - raise ValueError( - "Invalid date format. Supported formats:\n" - "Relative formats:\n" - "- T (days): T, T-n, T+n\n" - "- M (months): M, M-1, M+2\n" - "- Y (years): Y, Y-1, Y+1\n" - "Regular formats:\n" - "- YYYY-MM-DD\n" - "- YYYY/MM/DD\n" - "- DD-MM-YYYY\n" - "- DD/MM/YYYY\n" - "- YYYY.MM.DD\n" - "- DD.MM.YYYY" - ) - + raise ValueError('Invalid date format. Supported formats:\nRelative formats:\n- T (days): T, T-n, T+n\n- M (months): M, M-1, M+2\n- Y (years): Y, Y-1, Y+1\nRegular formats:\n- YYYY-MM-DD\n- YYYY/MM/DD\n- DD-MM-YYYY\n- DD/MM/YYYY\n- YYYY.MM.DD\n- DD.MM.YYYY') def time_now(): return datetime.now(pytz.timezone(TIMEZONE)) - - import requests - def get_latest_numOfFail(location_tag, token) -> float: - today = datetime.today().strftime("%Y-%m-%d") - url_today = f"{REALIBILITY_SERVICE_API}/main/number-of-failures/{location_tag}/2016-01-01/{today}" - + today = datetime.today().strftime('%Y-%m-%d') + url_today = f'{REALIBILITY_SERVICE_API}/main/number-of-failures/{location_tag}/2016-01-01/{today}' try: - response = requests.get( - url_today, - { - "Content-Type": "application/json", - "Authorization": f"Bearer {token}", - }, - ) + response = requests.get(url_today, {'Content-Type': 'application/json', 'Authorization': f'Bearer {token}'}) data = response.json() - raise Exception(data) - - latest_num = data["data"][-1]["num_fail"] - + latest_num = data['data'][-1]['num_fail'] raise Exception(latest_num) - if not latest_num: latest_num = 0 - return latest_num except requests.exceptions.RequestException as e: - print(f"Error fetching data: {e}") + print(f'Error fetching data: {e}') return 0 - import requests +PASTEBIN_API_KEY = 'G9uCTzoI1CJWCupt5lcZRpijondt5jwA' -PASTEBIN_API_KEY = 'G9uCTzoI1CJWCupt5lcZRpijondt5jwA' # Replace this with your actual API key - -def save_to_pastebin(data, title="Result Log", expire_date="1H"): - url = "https://pastebin.com/api/api_post.php" - payload = { - 'api_dev_key': PASTEBIN_API_KEY, - 'api_option': 'paste', - 'api_paste_code': data, - 'api_paste_name': title, - 'api_paste_expire_date': expire_date, # Example: 10M, 1H, 1D, N (never) - } +def save_to_pastebin(data, title='Result Log', expire_date='1H'): + url = 'https://pastebin.com/api/api_post.php' + payload = {'api_dev_key': PASTEBIN_API_KEY, 'api_option': 'paste', 'api_paste_code': data, 'api_paste_name': title, 'api_paste_expire_date': expire_date} response = requests.post(url, data=payload) if response.status_code == 200: - return response.text # This will be the paste URL + return response.text else: - return f"Error: {response.status_code} - {response.text}" - + return f'Error: {response.status_code} - {response.text}' def update_model(model, update_data: dict): - """ - Update a SQLAlchemy model with data from a dictionary. - """ for key, value in update_data.items(): if hasattr(model, key): setattr(model, key, value) - diff --git a/src/workorder/__init__.py b/src/workorder/__init__.py index e69de29..8b13789 100644 --- a/src/workorder/__init__.py +++ b/src/workorder/__init__.py @@ -0,0 +1 @@ + diff --git a/src/workorder/model.py b/src/workorder/model.py index 600cad3..c1dba68 100644 --- a/src/workorder/model.py +++ b/src/workorder/model.py @@ -1,15 +1,10 @@ from sqlalchemy import Column, Float, String - from src.database.core import Base from src.models import DefaultMixin - class MasterWorkOrder(Base, DefaultMixin): - __tablename__ = "oh_wo_master" - + __tablename__ = 'oh_wo_master' assetnum = Column(String, nullable=True) worktype = Column(String, nullable=True) workgroup = Column(String, nullable=True) total_cost_max = Column(Float, nullable=True) - - # "ScopeEquipment", diff --git a/src/workscope_group/__init__.py b/src/workscope_group/__init__.py index e69de29..8b13789 100644 --- a/src/workscope_group/__init__.py +++ b/src/workscope_group/__init__.py @@ -0,0 +1 @@ + diff --git a/src/workscope_group/model.py b/src/workscope_group/model.py index f025784..3b3277c 100644 --- a/src/workscope_group/model.py +++ b/src/workscope_group/model.py @@ -1,50 +1,18 @@ from sqlalchemy import UUID, Column, ForeignKey, String from sqlalchemy.orm import relationship - from src.database.core import Base from src.models import DefaultMixin - class MasterActivitytask(Base, DefaultMixin): - __tablename__ = "oh_ms_workscope_task" - + __tablename__ = 'oh_ms_workscope_task' description = Column(String, nullable=False) - - - job_id = Column( - UUID(as_uuid=True), - ForeignKey("oh_ms_workscope_group.id", ondelete="cascade"), - nullable=False, - ) - + job_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_workscope_group.id', ondelete='cascade'), nullable=False) class MasterActivity(Base, DefaultMixin): - __tablename__ = "oh_ms_workscope_group" - + __tablename__ = 'oh_ms_workscope_group' workscope = Column(String, nullable=True) system = Column(String, nullable=True) subsystem = Column(String, nullable=True) - - tasks = relationship( - "MasterActivitytask", - lazy="selectin", - ) - - - equipment_workscope_groups = relationship( - "EquipmentWorkscopeGroup", - lazy="selectin", - back_populates="workscope_group", - ) - - oh_types = relationship( - "WorkscopeOHType", - lazy="selectin", - back_populates="workscope_group", - ) - - # "MasterActivityDetail", - - -# class MasterActivityDetail(Base, DefaultMixin): - + tasks = relationship('MasterActivitytask', lazy='selectin') + equipment_workscope_groups = relationship('EquipmentWorkscopeGroup', lazy='selectin', back_populates='workscope_group') + oh_types = relationship('WorkscopeOHType', lazy='selectin', back_populates='workscope_group') diff --git a/src/workscope_group/router.py b/src/workscope_group/router.py index f291f6c..f445cb9 100644 --- a/src/workscope_group/router.py +++ b/src/workscope_group/router.py @@ -1,76 +1,35 @@ from fastapi import APIRouter, HTTPException, status - -from src.database.service import (CommonParameters, DbSession) +from src.database.service import CommonParameters, DbSession from src.models import StandardResponse - -from .schema import (ActivityMaster, ActivityMasterCreate, - ActivityMasterPagination) +from .schema import ActivityMaster, ActivityMasterCreate, ActivityMasterPagination from .service import create, delete, get, get_all, update - router = APIRouter() - -@router.get("", response_model=StandardResponse[ActivityMasterPagination]) +@router.get('', response_model=StandardResponse[ActivityMasterPagination]) async def get_activities(common: CommonParameters): - """Get all scope activity pagination.""" data = await get_all(common=common) + return StandardResponse(data=data, message='Data retrieved successfully') - - - return StandardResponse( - data=data, - message="Data retrieved successfully", - ) - - -@router.post("", response_model=StandardResponse[ActivityMasterCreate]) +@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') - return StandardResponse(data=activity, message="Data created successfully") - - -@router.get( - "/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster] -) +@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") - + 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.post( - "/update/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster] -) -async def update_scope( - db_session: DbSession, activity_in: ActivityMasterCreate, activity_id -): - return StandardResponse( - data=await update( - db_session=db_session, activity_id=activity_id, activity_in=activity_in - ), - message="Data updated successfully", - ) +@router.post('/update/{scope_equipment_activity_id}', response_model=StandardResponse[ActivityMaster]) +async def update_scope(db_session: DbSession, activity_in: ActivityMasterCreate, activity_id): + return StandardResponse(data=await update(db_session=db_session, activity_id=activity_id, activity_in=activity_in), message='Data updated successfully') - -@router.post( - "/delete/{scope_equipment_activity_id}", response_model=StandardResponse[ActivityMaster] -) +@router.post('/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."}], - ) - + 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) + return StandardResponse(message='Data deleted successfully', data=activity) diff --git a/src/workscope_group/schema.py b/src/workscope_group/schema.py index 9ad8ea0..545e8bc 100644 --- a/src/workscope_group/schema.py +++ b/src/workscope_group/schema.py @@ -1,26 +1,19 @@ from typing import List from uuid import UUID - - from src.models import DefultBase, Pagination - class ActivityMaster(DefultBase): pass - class ActivityMasterDetail(DefultBase): name: str - class ActivityMasterCreate(ActivityMaster): description: str - class ActivityMasterTasks(DefultBase): description: str - class OHType(DefultBase): code: str name: str @@ -28,7 +21,6 @@ class OHType(DefultBase): class ActivityOHType(DefultBase): oh_type: OHType - class ActivityMasterRead(ActivityMaster): id: UUID workscope: str @@ -37,24 +29,5 @@ class ActivityMasterRead(ActivityMaster): tasks: List[ActivityMasterTasks] oh_types: List[ActivityOHType] - class ActivityMasterPagination(Pagination): items: List[ActivityMasterRead] = [] - - -# "equipmentCount": 30 -# }, -# "criticalParts": [ -# "Boiler feed pump", -# "Boiler reheater system", -# "BCP A Discharge Valve", -# "BFPT A EXH Press HI Root VLV" -# ], -# "schedules": [ -# "status": "upcoming" -# // ... other scheduled overhauls -# ], -# "lastOverhaul": "2024-06-15" -# }, -# "lpt": { "status": "operational" } -# // ... other major components diff --git a/src/workscope_group/service.py b/src/workscope_group/service.py index 85f04f0..af33c90 100644 --- a/src/workscope_group/service.py +++ b/src/workscope_group/service.py @@ -1,70 +1,44 @@ from typing import Optional - from fastapi import HTTPException, status from sqlalchemy import Select - from src.database.core import DbSession from src.database.service import CommonParameters, search_filter_sort_paginate from src.utils import update_model from src.soft_delete import apply_not_deleted_filter, soft_delete_record - from .model import MasterActivity from .schema import ActivityMaster, ActivityMasterCreate - async def get(*, db_session: DbSession, activity_id: str) -> Optional[ActivityMaster]: - """Returns a document based on the given document id.""" query = Select(MasterActivity).filter(MasterActivity.id == activity_id) query = apply_not_deleted_filter(query, MasterActivity) result = await db_session.execute(query) return result.scalars().one_or_none() - async def get_all(common: CommonParameters): query = Select(MasterActivity) query = apply_not_deleted_filter(query, MasterActivity) - results = await search_filter_sort_paginate(model=query, **common) - return results - async def create(*, db_session: DbSession, activty_in: ActivityMasterCreate): activity = MasterActivity(**activty_in.model_dump()) db_session.add(activity) await db_session.commit() return activity - -async def update( - *, - db_session: DbSession, - activity_id: str, - activity_in: ActivityMasterCreate -): - """Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update.""" +async def update(*, db_session: DbSession, activity_id: str, activity_in: ActivityMasterCreate): query = Select(MasterActivity).filter(MasterActivity.id == activity_id) query = apply_not_deleted_filter(query, MasterActivity) query = query.with_for_update() result = await db_session.execute(query) activity = result.scalars().one_or_none() - 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) - update_data = activity_in.model_dump(exclude_defaults=True) update_model(activity, update_data) - await db_session.commit() - return activity - async def delete(*, db_session: DbSession, activity_id: str): - """Soft-deletes a document.""" await soft_delete_record(db_session=db_session, model=MasterActivity, record_id=activity_id) diff --git a/src/workscope_group_maintenance_type/__init__.py b/src/workscope_group_maintenance_type/__init__.py index e69de29..8b13789 100644 --- a/src/workscope_group_maintenance_type/__init__.py +++ b/src/workscope_group_maintenance_type/__init__.py @@ -0,0 +1 @@ + diff --git a/src/workscope_group_maintenance_type/model.py b/src/workscope_group_maintenance_type/model.py index 295f117..a660081 100644 --- a/src/workscope_group_maintenance_type/model.py +++ b/src/workscope_group_maintenance_type/model.py @@ -1,15 +1,11 @@ from sqlalchemy import UUID, Column, ForeignKey from sqlalchemy.orm import relationship - from src.database.core import Base - class WorkscopeOHType(Base): __tablename__ = 'oh_tr_workscope_maintenance_type' - id = Column(UUID(as_uuid=True), primary_key=True) workscope_group_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_workscope_group.id')) maintenance_type_id = Column(UUID(as_uuid=True), ForeignKey('oh_ms_maintenance_type.id')) - workscope_group = relationship('MasterActivity', back_populates='oh_types') oh_type = relationship('MaintenanceType', lazy='selectin') diff --git a/src/workscope_group_maintenance_type/service.py b/src/workscope_group_maintenance_type/service.py index 85f04f0..af33c90 100644 --- a/src/workscope_group_maintenance_type/service.py +++ b/src/workscope_group_maintenance_type/service.py @@ -1,70 +1,44 @@ from typing import Optional - from fastapi import HTTPException, status from sqlalchemy import Select - from src.database.core import DbSession from src.database.service import CommonParameters, search_filter_sort_paginate from src.utils import update_model from src.soft_delete import apply_not_deleted_filter, soft_delete_record - from .model import MasterActivity from .schema import ActivityMaster, ActivityMasterCreate - async def get(*, db_session: DbSession, activity_id: str) -> Optional[ActivityMaster]: - """Returns a document based on the given document id.""" query = Select(MasterActivity).filter(MasterActivity.id == activity_id) query = apply_not_deleted_filter(query, MasterActivity) result = await db_session.execute(query) return result.scalars().one_or_none() - async def get_all(common: CommonParameters): query = Select(MasterActivity) query = apply_not_deleted_filter(query, MasterActivity) - results = await search_filter_sort_paginate(model=query, **common) - return results - async def create(*, db_session: DbSession, activty_in: ActivityMasterCreate): activity = MasterActivity(**activty_in.model_dump()) db_session.add(activity) await db_session.commit() return activity - -async def update( - *, - db_session: DbSession, - activity_id: str, - activity_in: ActivityMasterCreate -): - """Fetches the record with a row-level lock (SELECT FOR UPDATE) then applies the update.""" +async def update(*, db_session: DbSession, activity_id: str, activity_in: ActivityMasterCreate): query = Select(MasterActivity).filter(MasterActivity.id == activity_id) query = apply_not_deleted_filter(query, MasterActivity) query = query.with_for_update() result = await db_session.execute(query) activity = result.scalars().one_or_none() - 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) - update_data = activity_in.model_dump(exclude_defaults=True) update_model(activity, update_data) - await db_session.commit() - return activity - async def delete(*, db_session: DbSession, activity_id: str): - """Soft-deletes a document.""" await soft_delete_record(db_session=db_session, model=MasterActivity, record_id=activity_id)