diff --git a/021-supplier-management/procurement_platform/app/__init__.py b/021-supplier-management/procurement_platform/app/__init__.py new file mode 100644 index 0000000..b095a5e --- /dev/null +++ b/021-supplier-management/procurement_platform/app/__init__.py @@ -0,0 +1,3 @@ +from .main import app + +__version__ = "1.0.0" diff --git a/021-supplier-management/procurement_platform/app/api/v1/__init__.py b/021-supplier-management/procurement_platform/app/api/v1/__init__.py new file mode 100644 index 0000000..35ab664 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/__init__.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter + +from .endpoints import auth, users, categories, stages, suppliers, purchase_requests, rfqs, quotes, orders, dashboard + +api_router = APIRouter() +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(categories.router, prefix="/categories", tags=["categories"]) +api_router.include_router(stages.router, prefix="/stages", tags=["stages"]) +api_router.include_router(suppliers.router, prefix="/suppliers", tags=["suppliers"]) +api_router.include_router(purchase_requests.router, prefix="/purchase-requests", tags=["purchase_requests"]) +api_router.include_router(rfqs.router, prefix="/rfqs", tags=["rfqs"]) +api_router.include_router(quotes.router, prefix="/quotes", tags=["quotes"]) +api_router.include_router(orders.router, prefix="/orders", tags=["orders"]) +api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"]) diff --git a/021-supplier-management/procurement_platform/app/api/v1/endpoints/__init__.py b/021-supplier-management/procurement_platform/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..cf29754 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/endpoints/__init__.py @@ -0,0 +1,10 @@ +from .auth import router as auth_router +from .users import router as users_router +from .categories import router as categories_router +from .stages import router as stages_router +from .suppliers import router as suppliers_router +from .purchase_requests import router as purchase_requests_router +from .rfqs import router as rfqs_router +from .quotes import router as quotes_router +from .orders import router as orders_router +from .dashboard import router as dashboard_router diff --git a/021-supplier-management/procurement_platform/app/api/v1/endpoints/auth.py b/021-supplier-management/procurement_platform/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..ce93129 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/endpoints/auth.py @@ -0,0 +1,84 @@ +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.orm import Session +from typing import Annotated +from datetime import timedelta + +from app.database import get_db +from app.database.models import User, UserRole +from app.core.config import settings +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.api.v1.schemas.auth import Token, TokenData, LoginRequest + +router = APIRouter() + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/token") + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password): + return pwd_context.hash(password) + +def get_user(db: Session, username: str): + return db.query(User).filter(User.username == username).first() + +def authenticate_user(db: Session, username: str, password: str): + user = get_user(db, username) + if not user: + return False + if not user.is_active: + return False + if not verify_password(password, user.password_hash): + return False + return user + +def create_access_token(data: dict, expires_delta: timedelta): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + return encoded_jwt + +from datetime import datetime + +async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)], db: Session = Depends(get_db)): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + username: str = payload.get("sub") + if username is None: + raise credentials_exception + token_data = TokenData(username=username) + except JWTError: + raise credentials_exception + + user = get_user(db, username=token_data.username) + if user is None: + raise credentials_exception + return user + +async def get_current_active_user(current_user: User = Depends(get_current_user)): + if not current_user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return current_user + +from typing import Optional +from app.api.v1.schemas.users import UserSchema + +async def get_current_admin_user(current_user: User = Depends(get_current_active_user)): + if current_user.role != UserRole.ADMIN: + raise HTTPException(status_code=403, detail="Admin access required") + return current_user + diff --git a/021-supplier-management/procurement_platform/app/api/v1/endpoints/categories.py b/021-supplier-management/procurement_platform/app/api/v1/endpoints/categories.py new file mode 100644 index 0000000..b9e1da8 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/endpoints/categories.py @@ -0,0 +1,72 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +from app.database import get_db +from app.database.models import Category, PurchaseRequest, Supplier, SupplierCategory, User +from app.api.v1.endpoints.auth import get_current_admin_user +from app.api.v1.schemas.categories import CategoryCreate, CategoryUpdate, CategorySchema + +router = APIRouter() + +@router.get("/", response_model=List[CategorySchema]) +async def list_categories(current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + categories = db.query(Category).all() + return categories + +@router.post("/", response_model=CategorySchema) +async def create_category(category_data: CategoryCreate, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + existing_category = db.query(Category).filter(Category.name == category_data.name).first() + if existing_category: + raise HTTPException(status_code=400, detail="Category already exists") + + category = Category( + name=category_data.name, + description=category_data.description + ) + db.add(category) + db.commit() + db.refresh(category) + return category + +@router.get("/{category_id}", response_model=CategorySchema) +async def get_category(category_id: int, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + category = db.query(Category).filter(Category.id == category_id).first() + if not category: + raise HTTPException(status_code=404, detail="Category not found") + return category + +@router.put("/{category_id}", response_model=CategorySchema) +async def update_category(category_id: int, category_data: CategoryUpdate, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + category = db.query(Category).filter(Category.id == category_id).first() + if not category: + raise HTTPException(status_code=404, detail="Category not found") + + if category_data.name: + existing_category = db.query(Category).filter(Category.name == category_data.name, Category.id != category_id).first() + if existing_category: + raise HTTPException(status_code=400, detail="Category name already exists") + + update_data = category_data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(category, key, value) + + db.commit() + db.refresh(category) + return category + +@router.delete("/{category_id}", status_code=204) +async def delete_category(category_id: int, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + category = db.query(Category).filter(Category.id == category_id).first() + if not category: + raise HTTPException(status_code=404, detail="Category not found") + + pr_count = db.query(PurchaseRequest).filter(PurchaseRequest.category_id == category_id).count() + supplier_count = db.query(SupplierCategory).filter(SupplierCategory.category_id == category_id).count() + + if pr_count > 0 or supplier_count > 0: + raise HTTPException(status_code=400, detail="Cannot delete category used by purchase requests or suppliers") + + db.delete(category) + db.commit() + return None diff --git a/021-supplier-management/procurement_platform/app/api/v1/endpoints/dashboard.py b/021-supplier-management/procurement_platform/app/api/v1/endpoints/dashboard.py new file mode 100644 index 0000000..99cbce9 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/endpoints/dashboard.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from typing import List +from datetime import datetime, timedelta + +from app.database import get_db +from app.database.models import RFQ, RFQStatus, RFQSupplier, Order, OrderStatus, PurchaseRequest, KanbanStage, User +from app.api.v1.endpoints.auth import get_current_active_user +from app.api.v1.schemas.dashboard import DashboardItem, DashboardItemType + +router = APIRouter() + +@router.get("/", response_model=List[DashboardItem]) +async def get_dashboard( + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + dashboard_items = [] + + rfqs_ready_for_review = db.query(RFQ).filter(RFQ.status == RFQStatus.READY_FOR_REVIEW).all() + for rfq in rfqs_ready_for_review: + dashboard_items.append(DashboardItem( + item_type=DashboardItemType.RFQ_READY_FOR_REVIEW, + record_id=rfq.id, + title=f"RFQ: {rfq.title} ready for review", + record_data={"rfq_id": rfq.id, "title": rfq.title, "deadline": str(rfq.deadline)} + )) + + overdue_orders = db.query(Order).filter( + Order.expected_delivery < datetime.now(), + Order.status != OrderStatus.DELIVERED + ).all() + + for order in overdue_orders: + dashboard_items.append(DashboardItem( + item_type=DashboardItemType.OVERDUE_ORDER, + record_id=order.id, + title=f"Order {order.order_number} is overdue", + record_data={"order_id": order.id, "order_number": order.order_number, "supplier_id": order.supplier_id} + )) + + new_stage = db.query(KanbanStage).filter(KanbanStage.name == "New").first() + if new_stage: + stale_threshold = datetime.now() - timedelta(days=7) + stale_requests = db.query(PurchaseRequest).filter( + PurchaseRequest.stage_id == new_stage.id, + PurchaseRequest.created_at < stale_threshold + ).all() + + for pr in stale_requests: + age = (datetime.now() - pr.created_at).days + dashboard_items.append(DashboardItem( + item_type=DashboardItemType.STALE_REQUEST, + record_id=pr.id, + title=f"Purchase request {pr.title} stale in New stage", + record_data={"request_id": pr.id, "title": pr.title, "age": age} + )) + + return dashboard_items diff --git a/021-supplier-management/procurement_platform/app/api/v1/endpoints/orders.py b/021-supplier-management/procurement_platform/app/api/v1/endpoints/orders.py new file mode 100644 index 0000000..91e3d5e --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/endpoints/orders.py @@ -0,0 +1,220 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session, joinedload +from typing import List, Optional +from datetime import datetime +from sqlalchemy import and_ + +from app.database import get_db +from app.database.models import ( + Order, OrderLineItem, RFQ, Quote, RFQSupplier, Category, SupplierCategory, Supplier, + User, OrderStatus, OrderStatusHistory +) +from app.api.v1.endpoints.auth import get_current_active_user +from app.api.v1.schemas.orders import OrderSchema, OrderStatusUpdate, OrderRating + +router = APIRouter() + +@router.get("/", response_model=List[OrderSchema]) +async def list_orders( + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + orders = db.query(Order).options( + joinedload(Order.supplier), + joinedload(Order.rfq), + joinedload(Order.created_by), + joinedload(Order.line_items) + ).all() + + result = [] + for order in orders: + is_overdue = False + if order.expected_delivery and order.status != OrderStatus.DELIVERED: + is_overdue = order.expected_delivery < datetime.now() + + order_dict = { + **order.__dict__, + "supplier_name": order.supplier.company_name, + "is_overdue": is_overdue + } + result.append(order_dict) + + return result + +@router.get("/{order_id}", response_model=OrderSchema) +async def get_order( + order_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + order = db.query(Order).filter(Order.id == order_id).options( + joinedload(Order.supplier), + joinedload(Order.rfq), + joinedload(Order.created_by), + joinedload(Order.line_items), + joinedload(Order.status_history) + ).first() + + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + is_overdue = False + if order.expected_delivery and order.status != OrderStatus.DELIVERED: + is_overdue = order.expected_delivery < datetime.now() + + order_dict = { + **order.__dict__, + "supplier_name": order.supplier.company_name, + "is_overdue": is_overdue + } + + return order_dict + +@router.post("/{order_id}/advance-status") +async def advance_order_status( + order_id: int, + status_update: OrderStatusUpdate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + order = db.query(Order).filter(Order.id == order_id).first() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + status_flow = { + OrderStatus.PENDING: [OrderStatus.CONFIRMED], + OrderStatus.CONFIRMED: [OrderStatus.SHIPPED], + OrderStatus.SHIPPED: [OrderStatus.DELIVERED], + OrderStatus.DELIVERED: [] + } + + current_status = order.status + allowed_next_statuses = status_flow.get(current_status, []) + + if status_update.status not in allowed_next_statuses: + raise HTTPException(status_code=400, detail=f"Cannot move from {current_status} to {status_update.status}") + + previous_status = order.status + order.status = status_update.status + + status_history = OrderStatusHistory( + order_id=order_id, + previous_status=previous_status, + new_status=status_update.status, + changed_by_id=current_user.id, + change_reason=status_update.change_reason + ) + db.add(status_history) + + db.commit() + db.refresh(order) + return order + +@router.post("/{order_id}/rate-supplier") +async def rate_supplier( + order_id: int, + rating_data: OrderRating, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + order = db.query(Order).filter(Order.id == order_id).options( + joinedload(Order.supplier) + ).first() + + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + if order.status != OrderStatus.DELIVERED: + raise HTTPException(status_code=400, detail="Can only rate supplier after order is delivered") + + supplier = order.supplier + supplier.punctuality_score = rating_data.punctuality + supplier.quality_score = rating_data.quality + supplier.reliability_score = rating_data.reliability + supplier.overall_score = round(rating_data.punctuality * 0.35 + rating_data.quality * 0.35 + rating_data.reliability * 0.30) + supplier.updated_at = datetime.now() + + from app.database.models import SupplierRating + rating = SupplierRating( + supplier_id=supplier.id, + rated_by_id=current_user.id, + order_id=order.id, + punctuality=rating_data.punctuality, + quality=rating_data.quality, + reliability=rating_data.reliability, + notes=rating_data.notes + ) + db.add(rating) + + db.commit() + db.refresh(order) + db.refresh(supplier) + + return {"message": "Supplier rated successfully", "supplier": supplier} + +@router.post("/{order_id}/clone-to-request") +async def clone_order_to_purchase_request( + order_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + order = db.query(Order).filter(Order.id == order_id).options( + joinedload(Order.line_items), + joinedload(Order.supplier) + ).first() + + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + from app.database.models import PurchaseRequest, LineItem as PRLineItem, KanbanStage + from app.database.models import StageHistory + + category_id = order.supplier.categories[0].category_id if order.supplier.categories else None + if not category_id: + category = db.query(Category).first() + category_id = category.id if category else None + + if not category_id: + raise HTTPException(status_code=400, detail="Cannot determine category for cloned request") + + default_stage = db.query(KanbanStage).filter(KanbanStage.name == "New").first() + if not default_stage: + raise HTTPException(status_code=500, detail="Default stage not found") + + request = PurchaseRequest( + title=f"Re-order from {order.order_number}", + description=f"Cloned from order {order.order_number}", + priority="Medium", + deadline=None, + notes=f"Re-ordering items from {order.order_number}", + category_id=category_id, + stage_id=default_stage.id, + created_by_id=current_user.id + ) + db.add(request) + db.commit() + db.refresh(request) + + for line_item in order.line_items: + pr_line_item = PRLineItem( + description=line_item.description, + quantity=line_item.quantity, + unit=line_item.unit, + purchase_request_id=request.id + ) + db.add(pr_line_item) + + db.commit() + + stage_history = StageHistory( + purchase_request_id=request.id, + previous_stage_id=None, + new_stage_id=default_stage.id, + changed_by_id=current_user.id, + change_reason="Cloned from order" + ) + db.add(stage_history) + db.commit() + db.refresh(request) + + return {"message": "Purchase request created from order", "request_id": request.id} diff --git a/021-supplier-management/procurement_platform/app/api/v1/endpoints/purchase_requests.py b/021-supplier-management/procurement_platform/app/api/v1/endpoints/purchase_requests.py new file mode 100644 index 0000000..c2bb416 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/endpoints/purchase_requests.py @@ -0,0 +1,270 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session, joinedload +from typing import List, Optional +from datetime import datetime, timedelta +from sqlalchemy import or_, and_, func + +from app.database import get_db +from app.database.models import ( + PurchaseRequest, User, Category, KanbanStage, LineItem, + StageHistory, RFQ, Supplier, SupplierCategory, RFQSupplier, Order +) +from app.api.v1.endpoints.auth import get_current_active_user +from app.api.v1.schemas.purchase_requests import PurchaseRequestCreate, PurchaseRequestUpdate, PurchaseRequestSchema +from app.api.v1.schemas.stages import StageSchema + +router = APIRouter() + +@router.get("/", response_model=List[PurchaseRequestSchema]) +async def list_purchase_requests( + title: Optional[str] = Query(None), + category_id: Optional[int] = Query(None), + priority: Optional[str] = Query(None), + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + query = db.query(PurchaseRequest).options( + joinedload(PurchaseRequest.category), + joinedload(PurchaseRequest.stage), + joinedload(PurchaseRequest.created_by), + joinedload(PurchaseRequest.line_items) + ) + + if title: + query = query.filter( + or_( + PurchaseRequest.title.ilike(f"%{title}%"), + LineItem.description.ilike(f"%{title}%") + ) + ) + + if category_id: + query = query.filter(PurchaseRequest.category_id == category_id) + + if priority: + query = query.filter(PurchaseRequest.priority == priority) + + requests = query.all() + + result = [] + default_stage = db.query(KanbanStage).filter(KanbanStage.name == "New").first() + + for pr in requests: + age = (datetime.now() - pr.created_at).days if pr.created_at else 0 + item_count = len(pr.line_items) + + pr_dict = { + **pr.__dict__, + "age": age, + "item_count": item_count, + "current_stage": pr.stage.name if pr.stage else "New" + } + + if pr.stage is None and default_stage: + pr.stage = default_stage + db.commit() + + result.append(pr) + + return result + +@router.post("/", response_model=PurchaseRequestSchema) +async def create_purchase_request( + request_data: PurchaseRequestCreate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + category = db.query(Category).filter(Category.id == request_data.category_id).first() + if not category: + raise HTTPException(status_code=400, detail="Category not found") + + default_stage = db.query(KanbanStage).filter(KanbanStage.name == "New").first() + if not default_stage: + raise HTTPException(status_code=500, detail="Default stage 'New' not found") + + request = PurchaseRequest( + title=request_data.title, + description=request_data.description, + priority=request_data.priority, + deadline=request_data.deadline, + notes=request_data.notes, + category_id=request_data.category_id, + stage_id=default_stage.id, + created_by_id=current_user.id + ) + db.add(request) + db.commit() + db.refresh(request) + + for item in request_data.line_items: + line_item = LineItem( + description=item.description, + quantity=item.quantity, + unit=item.unit, + purchase_request_id=request.id + ) + db.add(line_item) + + db.commit() + db.refresh(request) + + stage_history = StageHistory( + purchase_request_id=request.id, + previous_stage_id=None, + new_stage_id=default_stage.id, + changed_by_id=current_user.id, + change_reason="Initial creation" + ) + db.add(stage_history) + db.commit() + + return request + +@router.get("/{request_id}", response_model=PurchaseRequestSchema) +async def get_purchase_request( + request_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + request = db.query(PurchaseRequest).filter(PurchaseRequest.id == request_id).options( + joinedload(PurchaseRequest.category), + joinedload(PurchaseRequest.stage), + joinedload(PurchaseRequest.created_by), + joinedload(PurchaseRequest.line_items) + ).first() + + if not request: + raise HTTPException(status_code=404, detail="Purchase request not found") + + return request + +@router.put("/{request_id}", response_model=PurchaseRequestSchema) +async def update_purchase_request( + request_id: int, + request_data: PurchaseRequestUpdate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + request = db.query(PurchaseRequest).filter(PurchaseRequest.id == request_id).first() + if not request: + raise HTTPException(status_code=404, detail="Purchase request not found") + + if request_data.category_id: + category = db.query(Category).filter(Category.id == request_data.category_id).first() + if not category: + raise HTTPException(status_code=400, detail="Category not found") + + update_data = request_data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(request, key, value) + + request.updated_at = func.now() + db.commit() + db.refresh(request) + return request + +@router.delete("/{request_id}", status_code=204) +async def delete_purchase_request( + request_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + request = db.query(PurchaseRequest).filter(PurchaseRequest.id == request_id).first() + if not request: + raise HTTPException(status_code=404, detail="Purchase request not found") + + existing_rfq = db.query(RFQ).filter(RFQ.purchase_request_id == request_id).first() + if existing_rfq: + raise HTTPException(status_code=400, detail="Cannot delete request with existing RFQ") + + db.delete(request) + db.commit() + return None + +@router.post("/{request_id}/clone", response_model=PurchaseRequestSchema) +async def clone_purchase_request( + request_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + original = db.query(PurchaseRequest).filter(PurchaseRequest.id == request_id).options( + joinedload(PurchaseRequest.category), + joinedload(PurchaseRequest.stage), + joinedload(PurchaseRequest.line_items) + ).first() + + if not original: + raise HTTPException(status_code=404, detail="Purchase request not found") + + default_stage = db.query(KanbanStage).filter(KanbanStage.name == "New").first() + if not default_stage: + raise HTTPException(status_code=500, detail="Default stage 'New' not found") + + cloned = PurchaseRequest( + title=f"Clone: {original.title}", + description=original.description, + priority=original.priority, + deadline=original.deadline, + notes=original.notes, + category_id=original.category_id, + stage_id=default_stage.id, + created_by_id=current_user.id + ) + db.add(cloned) + db.commit() + db.refresh(cloned) + + for item in original.line_items: + cloned_item = LineItem( + description=item.description, + quantity=item.quantity, + unit=item.unit, + purchase_request_id=cloned.id + ) + db.add(cloned_item) + + db.commit() + db.refresh(cloned) + + stage_history = StageHistory( + purchase_request_id=cloned.id, + previous_stage_id=None, + new_stage_id=default_stage.id, + changed_by_id=current_user.id, + change_reason="Cloned from request {request_id}" + ) + db.add(stage_history) + db.commit() + + return cloned + +@router.post("/{request_id}/move-to-stage/{stage_id}", response_model=PurchaseRequestSchema) +async def move_to_stage( + request_id: int, + stage_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + request = db.query(PurchaseRequest).filter(PurchaseRequest.id == request_id).first() + if not request: + raise HTTPException(status_code=404, detail="Purchase request not found") + + new_stage = db.query(KanbanStage).filter(KanbanStage.id == stage_id).first() + if not new_stage: + raise HTTPException(status_code=404, detail="Stage not found") + + previous_stage_id = request.stage_id + request.stage_id = stage_id + + stage_history = StageHistory( + purchase_request_id=request_id, + previous_stage_id=previous_stage_id, + new_stage_id=stage_id, + changed_by_id=current_user.id, + change_reason="Manual stage change" + ) + db.add(stage_history) + db.commit() + db.refresh(request) + + return request diff --git a/021-supplier-management/procurement_platform/app/api/v1/endpoints/quotes.py b/021-supplier-management/procurement_platform/app/api/v1/endpoints/quotes.py new file mode 100644 index 0000000..0f7e7a2 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/endpoints/quotes.py @@ -0,0 +1,256 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session, joinedload +from typing import Optional +from datetime import datetime, timedelta +from sqlalchemy import and_, or_ + +from app.database import get_db +from app.database.models import ( + RFQ, Quote, QuoteItem, RFQSupplier, LineItem, RFQStatus, Supplier, Quote +) +from app.api.v1.schemas.quotes import QuoteCreate, QuoteUpdate, QuoteSchema +from app.api.v1.endpoints.auth import get_current_active_user, User + +router = APIRouter() + +@router.get("/rfq/{quote_token}", response_model=dict) +async def get_rfq_by_token( + quote_token: str, + db: Session = Depends(get_db) +): + rfq_supplier = db.query(RFQSupplier).filter(RFQSupplier.quote_submission_token == quote_token).first() + if not rfq_supplier: + raise HTTPException(status_code=404, detail="Invalid quote token") + + rfq = db.query(RFQ).filter(RFQ.id == rfq_supplier.rfq_id).options( + joinedload(RFQ.purchase_request).joinedload("line_items") + ).first() + + if not rfq: + raise HTTPException(status_code=404, detail="RFQ not found") + + supplier = db.query(Supplier).filter(Supplier.id == rfq_supplier.supplier_id).first() + + result = { + "rfq_title": rfq.title, + "rfq_description": rfq.description, + "rfq_deadline": rfq.deadline, + "company_name": supplier.company_name, + "line_items": [{"description": li.description, "quantity": li.quantity, "unit": li.unit} for li in rfq.purchase_request.line_items], + "has_submitted": rfq_supplier.has_submitted + } + + if rfq.status in [RFQStatus.CANCELLED, RFQStatus.WINNER_SELECTED]: + result["blocked"] = True + result["reason"] = "RFQ is cancelled or has a winner selected" + + if rfq.deadline < datetime.now(): + result["blocked"] = True + result["reason"] = "RFQ deadline has passed" + + return result + +@router.post("/submit", response_model=QuoteSchema) +async def submit_quote( + quote_data: QuoteCreate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + rfq_supplier = db.query(RFQSupplier).filter(RFQSupplier.quote_submission_token == quote_data.quote_submission_token).first() + if not rfq_supplier: + raise HTTPException(status_code=404, detail="Invalid quote token") + + rfq = db.query(RFQ).filter(RFQ.id == rfq_supplier.rfq_id).first() + if not rfq: + raise HTTPException(status_code=404, detail="RFQ not found") + + if rfq.status in [RFQStatus.CANCELLED, RFQStatus.WINNER_SELECTED, RFQStatus.READY_FOR_REVIEW]: + raise HTTPException(status_code=400, detail="Cannot submit quote for RFQ in this state") + + if rfq.deadline < datetime.now(): + raise HTTPException(status_code=400, detail="RFQ deadline has passed") + + total_amount = 0 + for quote_item in quote_data.quote_items: + line_item = db.query(LineItem).filter(LineItem.id == quote_item.line_item_id).first() + if not line_item: + raise HTTPException(status_code=404, detail=f"Line item {quote_item.line_item_id} not found") + + if line_item.purchase_request_id != rfq.purchase_request_id: + raise HTTPException(status_code=400, detail="Line item does not belong to this RFQ's purchase request") + + total_amount += quote_item.unit_price * line_item.quantity + + existing_quote = db.query(Quote).filter(Quote.rfq_supplier_id == rfq_supplier.id).first() + revision_number = 1 + + if existing_quote: + revision_number = existing_quote.revision_number + 1 + else: + rfq_supplier.has_submitted = True + + submission_reference = f"Q-{rfq.id}-{rfq_supplier.supplier_id}-{revision_number}" + + quote = Quote( + rfq_supplier_id=rfq_supplier.id, + rfq_id=rfq.id, + revision_number=revision_number, + unit_price_total=total_amount, + delivery_time_days=quote_data.delivery_time_days, + payment_terms=quote_data.payment_terms, + notes=quote_data.notes, + submission_reference=submission_reference + ) + db.add(quote) + db.commit() + db.refresh(quote) + + for quote_item in quote_data.quote_items: + quote_item_db = QuoteItem( + quote_id=quote.id, + line_item_id=quote_item.line_item_id, + unit_price=quote_item.unit_price + ) + db.add(quote_item_db) + + db.commit() + db.refresh(rfq_supplier) + + all_submitted = all(rs.has_submitted for rs in rfq.suppliers) + if all_submitted and len(rfq.suppliers) > 0: + rfq.status = RFQStatus.READY_FOR_REVIEW + db.commit() + + return quote + +@router.put("/{quote_id}", response_model=QuoteSchema) +async def revise_quote( + quote_id: int, + quote_data: QuoteUpdate, + db: Session = Depends(get_db) +): + quote = db.query(Quote).filter(Quote.id == quote_id).options( + joinedload(Quote.rfq_supplier), + joinedload(Quote.rfq) + ).first() + + if not quote: + raise HTTPException(status_code=404, detail="Quote not found") + + if not quote.rfq_supplier or not quote.rfq_supplier.quote_submission_token: + raise HTTPException(status_code=400, detail="Invalid quote token reference") + + if quote.rfq.deadline < datetime.now(): + raise HTTPException(status_code=400, detail="Cannot revise quote after deadline") + + if quote.rfq.status in [RFQStatus.CANCELLED, RFQStatus.WINNER_SELECTED, RFQStatus.READY_FOR_REVIEW]: + raise HTTPException(status_code=400, detail="Cannot revise quote for RFQ in this state") + + if quote_data.quote_items: + total_amount = 0 + for quote_item in quote_data.quote_items: + line_item = db.query(LineItem).filter(LineItem.id == quote_item.line_item_id).first() + if line_item: + total_amount += quote_item.unit_price * line_item.quantity + quote.unit_price_total = total_amount + + if quote_data.delivery_time_days: + quote.delivery_time_days = quote_data.delivery_time_days + + if quote_data.payment_terms: + quote.payment_terms = quote_data.payment_terms + + if quote_data.notes: + quote.notes = quote_data.notes + + if quote_data.quote_items: + db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id).delete() + for quote_item in quote_data.quote_items: + quote_item_db = QuoteItem( + quote_id=quote.id, + line_item_id=quote_item.line_item_id, + unit_price=quote_item.unit_price + ) + db.add(quote_item_db) + + quote.revision_number += 1 + quote.updated_at = datetime.now() + + db.commit() + db.refresh(quote) + return quote + +@router.get("/by-rfq/{rfq_id}", response_model=List[QuoteSchema]) +async def get_quotes_by_rfq( + rfq_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + rfq = db.query(RFQ).filter(RFQ.id == rfq_id).first() + if not rfq: + raise HTTPException(status_code=404, detail="RFQ not found") + + quotes = db.query(Quote).filter(Quote.rfq_id == rfq_id).options( + joinedload(Quote.rfq_supplier).joinedload(RFQSupplier.supplier), + joinedload(Quote.quote_items) + ).all() + + return quotes + +@router.get("/comparison/{rfq_id}", response_model=List[dict]) +async def get_quote_comparison( + rfq_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + rfq = db.query(RFQ).filter(RFQ.id == rfq_id).first() + if not rfq: + raise HTTPException(status_code=404, detail="RFQ not found") + + quotes = db.query(Quote).filter(Quote.rfq_id == rfq_id).options( + joinedload(Quote.rfq_supplier).joinedload(RFQSupplier.supplier), + joinedload(Quote.quote_items).joinedload(QuoteItem.line_item) + ).all() + + comparison_data = [] + quote_totals = [] + + for quote in quotes: + supplier = quote.rfq_supplier.supplier + total = 0 + line_items = [] + + for quote_item in quote.quote_items: + item_total = quote_item.unit_price * quote_item.line_item.quantity + total += item_total + line_items.append({ + "description": quote_item.line_item.description, + "quantity": quote_item.line_item.quantity, + "unit_price": quote_item.unit_price, + "total": item_total + }) + + quote_totals.append(total) + + comparison_data.append({ + "quote_id": quote.id, + "supplier_name": supplier.company_name, + "supplier_score": supplier.overall_score, + "revision_number": quote.revision_number, + "delivery_time_days": quote.delivery_time_days, + "payment_terms": quote.payment_terms, + "total": total, + "line_items": line_items, + "is_lowest": False + }) + + if quote_totals: + min_total = min(quote_totals) + for item in comparison_data: + if item["total"] == min_total: + item["is_lowest"] = True + + comparison_data.sort(key=lambda x: x["total"]) + + return comparison_data diff --git a/021-supplier-management/procurement_platform/app/api/v1/endpoints/rfqs.py b/021-supplier-management/procurement_platform/app/api/v1/endpoints/rfqs.py new file mode 100644 index 0000000..653e938 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/endpoints/rfqs.py @@ -0,0 +1,311 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session, joinedload +from typing import List, Optional +from datetime import datetime, timedelta +from sqlalchemy import or_, and_, func, desc +import secrets + +from app.database import get_db +from app.database.models import ( + PurchaseRequest, RFQ, RFQStatus, RFQSupplier, Supplier, + SupplierCategory, Category, StageHistory, KanbanStage, Quote, User +) +from app.api.v1.endpoints.auth import get_current_active_user +from app.api.v1.schemas.rfqs import RFQCreate, RFQUpdate, RFQSchema, RFQStatusUpdate + +router = APIRouter() + +def create_quote_token(): + return secrets.token_hex(32) + +async def handle_automatic_stage_movement(db: Session, rfq: RFQ, new_status: str = None): + new_stage = db.query(KanbanStage).filter(KanbanStage.name == "In Review").first() + approved_stage = db.query(KanbanStage).filter(KanbanStage.name == "Approved").first() + ordered_stage = db.query(KanbanStage).filter(KanbanStage.name == "Ordered").first() + new_stage_default = db.query(KanbanStage).filter(KanbanStage.name == "New").first() + + if not new_stage or not new_stage_default: + return + + if new_status == RFQStatus.CANCELLED.value and rfq.purchase_request: + rfq.purchase_request.stage_id = new_stage_default.id + stage_history = StageHistory( + purchase_request_id=rfq.purchase_request.id, + previous_stage_id=rfq.purchase_request.stage_id, + new_stage_id=new_stage_default.id, + change_reason="RFQ cancelled - returned to New" + ) + db.add(stage_history) + + elif new_status == RFQStatus.AWAITING_QUOTES.value and rfq.purchase_request and rfq.purchase_request.stage_id != new_stage.id: + rfq.purchase_request.stage_id = new_stage.id + stage_history = StageHistory( + purchase_request_id=rfq.purchase_request.id, + previous_stage_id=rfq.purchase_request.stage_id, + new_stage_id=new_stage.id, + change_reason="RFQ published - moved to In Review" + ) + db.add(stage_history) + + elif new_status == RFQStatus.WINNER_SELECTED.value and rfq.purchase_request: + rfq.purchase_request.stage_id = approved_stage.id + stage_history1 = StageHistory( + purchase_request_id=rfq.purchase_request.id, + previous_stage_id=rfq.purchase_request.stage_id, + new_stage_id=approved_stage.id, + change_reason="Winner selected - moved to Approved" + ) + db.add(stage_history1) + + from app.database.models import Order, OrderLineItem, OrderStatus + from datetime import datetime, timedelta + + winner_quote = db.query(Quote).filter(Quote.id == rfq.winner_quote_id).first() + if winner_quote and winner_quote.rfq_supplier: + order_number = f"PO-{datetime.now().strftime('%Y')}-{rfq.id:04d}" + expected_delivery = datetime.now() + timedelta(days=winner_quote.delivery_time_days) + + order = Order( + order_number=order_number, + supplier_id=winner_quote.rfq_supplier.supplier_id, + rfq_id=rfq.id, + expected_delivery=expected_delivery, + payment_terms=winner_quote.payment_terms, + total_amount=winner_quote.unit_price_total, + status=OrderStatus.PENDING, + created_by_id=rfq.created_by_id + ) + db.add(order) + db.commit() + db.refresh(order) + + for quote_item in winner_quote.quote_items: + order_line_item = OrderLineItem( + order_id=order.id, + description=quote_item.line_item.description, + quantity=quote_item.line_item.quantity, + unit=quote_item.line_item.unit, + unit_price=quote_item.unit_price + ) + db.add(order_line_item) + + rfq.purchase_request.stage_id = ordered_stage.id + stage_history2 = StageHistory( + purchase_request_id=rfq.purchase_request.id, + previous_stage_id=approved_stage.id, + new_stage_id=ordered_stage.id, + change_reason="Order auto-created - moved to Ordered" + ) + db.add(stage_history2) + db.commit() + +@router.get("/", response_model=List[RFQSchema]) +async def list_rfqs( + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + rfqs = db.query(RFQ).options( + joinedload(RFQ.purchase_request), + joinedload(RFQ.created_by), + joinedload(RFQ.winner_quote), + joinedload(RFQ.suppliers).joinedload(RFQSupplier.supplier) + ).all() + return rfqs + +@router.get("/{rfq_id}", response_model=RFQSchema) +async def get_rfq( + rfq_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + rfq = db.query(RFQ).filter(RFQ.id == rfq_id).options( + joinedload(RFQ.purchase_request), + joinedload(RFQ.created_by), + joinedload(RFQ.winner_quote), + joinedload(RFQ.suppliers).joinedload(RFQSupplier.supplier), + joinedload(RFQ.quotes).joinedload(Quote.quote_items) + ).first() + + if not rfq: + raise HTTPException(status_code=404, detail="RFQ not found") + + return rfq + +@router.post("/", response_model=RFQSchema) +async def create_rfq( + rfq_data: RFQCreate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + purchase_request = db.query(PurchaseRequest).filter(PurchaseRequest.id == rfq_data.purchase_request_id).first() + if not purchase_request: + raise HTTPException(status_code=404, detail="Purchase request not found") + + existing_active_rfq = db.query(RFQ).filter( + RFQ.purchase_request_id == rfq_data.purchase_request_id, + RFQ.status.notin_([RFQStatus.CANCELLED, RFQStatus.WINNER_SELECTED]) + ).first() + + if existing_active_rfq: + raise HTTPException(status_code=400, detail="Active RFQ already exists for this purchase request") + + rfq = RFQ( + title=rfq_data.title, + description=rfq_data.description, + deadline=rfq_data.deadline, + status=RFQStatus.AWAITING_QUOTES, + purchase_request_id=rfq_data.purchase_request_id, + created_by_id=current_user.id + ) + db.add(rfq) + db.commit() + db.refresh(rfq) + + for supplier_id in rfq_data.supplier_ids: + supplier = db.query(Supplier).filter(Supplier.id == supplier_id).first() + if not supplier: + raise HTTPException(status_code=404, detail=f"Supplier {supplier_id} not found") + + quote_token = create_quote_token() + rfq_supplier = RFQSupplier( + rfq_id=rfq.id, + supplier_id=supplier_id, + quote_submission_token=quote_token, + has_submitted=False + ) + db.add(rfq_supplier) + + db.commit() + db.refresh(rfq) + + await handle_automatic_stage_movement(db, rfq, RFQStatus.AWAITING_QUOTES.value) + + return rfq + +@router.put("/{rfq_id}", response_model=RFQSchema) +async def update_rfq( + rfq_id: int, + rfq_data: RFQUpdate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + rfq = db.query(RFQ).filter(RFQ.id == rfq_id).options( + joinedload(RFQ.purchase_request), + joinedload(RFQ.suppliers) + ).first() + + if not rfq: + raise HTTPException(status_code=404, detail="RFQ not found") + + if rfq.status == RFQStatus.CANCELLED or rfq.status == RFQStatus.WINNER_SELECTED: + raise HTTPException(status_code=400, detail="RFQ cannot be edited in this status") + + if rfq.status == RFQStatus.READY_FOR_REVIEW: + raise HTTPException(status_code=400, detail="Cannot edit RFQ ready for review, only winner selection allowed") + + if rfq.status == RFQStatus.OVERDUE: + raise HTTPException(status_code=400, detail="Cannot edit overdue RFQ, only winner selection allowed") + + quotes_count = db.query(RFQSupplier).filter( + RFQSupplier.rfq_id == rfq_id, + RFQSupplier.has_submitted == True + ).count() + + if quotes_count > 0 and rfq_data.title: + raise HTTPException(status_code=400, detail="Cannot edit title after quotes submitted") + + if quotes_count > 0 and rfq_data.description: + raise HTTPException(status_code=400, detail="Cannot edit description after quotes submitted") + + if quotes_count == 0: + update_data = rfq_data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(rfq, key, value) + + elif quotes_count > 0: + if rfq_data.deadline: + rfq.deadline = rfq_data.deadline + else: + pass + + rfq.updated_at = func.now() + db.commit() + db.refresh(rfq) + + return rfq + +@router.post("/{rfq_id}/cancel", response_model=RFQSchema) +async def cancel_rfq( + rfq_id: int, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + rfq = db.query(RFQ).filter(RFQ.id == rfq_id).options( + joinedload(RFQ.purchase_request) + ).first() + + if not rfq: + raise HTTPException(status_code=404, detail="RFQ not found") + + if rfq.status == RFQStatus.CANCELLED or rfq.status == RFQStatus.WINNER_SELECTED: + raise HTTPException(status_code=400, detail="RFQ cannot be cancelled in this status") + + rfq.status = RFQStatus.CANCELLED + db.commit() + + await handle_automatic_stage_movement(db, rfq, RFQStatus.CANCELLED.value) + + return rfq + +@router.post("/{rfq_id}/select-winner") +async def select_winner( + rfq_id: int, + status_update: RFQStatusUpdate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + rfq = db.query(RFQ).filter(RFQ.id == rfq_id).options( + joinedload(RFQ.purchase_request), + joinedload(RFQ.quotes) + ).first() + + if not rfq: + raise HTTPException(status_code=404, detail="RFQ not found") + + if rfq.status not in [RFQStatus.READY_FOR_REVIEW, RFQStatus.OVERDUE]: + raise HTTPException(status_code=400, detail="Winner selection only allowed for RFQs ready for review or overdue") + + if not status_update.winner_quote_id: + raise HTTPException(status_code=400, detail="Winner quote ID is required") + + winner_quote = db.query(Quote).filter(Quote.id == status_update.winner_quote_id).first() + if not winner_quote: + raise HTTPException(status_code=404, detail="Winner quote not found") + + if winner_quote.rfq_id != rfq_id: + raise HTTPException(status_code=400, detail="Winner quote does not belong to this RFQ") + + quotes = db.query(Quote).filter(Quote.rfq_id == rfq_id).all() + quote_totals = [] + + for quote in quotes: + total = sum(qt.unit_price * qt.line_item.quantity for qt in quote.quote_items) + quote_totals.append((quote.id, total)) + + quote_totals.sort(key=lambda x: x[1]) + lowest_quote_id = quote_totals[0][0] if quote_totals else None + + if status_update.winner_quote_id != lowest_quote_id and not status_update.justification: + raise HTTPException(status_code=400, detail="Justification required for non-lowest quote selection") + + rfq.winner_quote_id = status_update.winner_quote_id + rfq.justification = status_update.justification + rfq.status = RFQStatus.WINNER_SELECTED + rfq.updated_at = func.now() + + db.commit() + db.refresh(rfq) + + await handle_automatic_stage_movement(db, rfq, RFQStatus.WINNER_SELECTED.value) + + return rfq diff --git a/021-supplier-management/procurement_platform/app/api/v1/endpoints/stages.py b/021-supplier-management/procurement_platform/app/api/v1/endpoints/stages.py new file mode 100644 index 0000000..4ad6067 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/endpoints/stages.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Body +from sqlalchemy.orm import Session +from typing import List +from sqlalchemy import func + +from app.database import get_db +from app.database.models import KanbanStage, PurchaseRequest, User +from app.api.v1.endpoints.auth import get_current_admin_user +from app.api.v1.schemas.stages import StageCreate, StageUpdate, StageSchema + +router = APIRouter() + +@router.get("/", response_model=List[StageSchema]) +async def list_stages(current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + stages = db.query(KanbanStage).order_by(KanbanStage.order_index).all() + return stages + +@router.post("/", response_model=StageSchema) +async def create_stage(stage_data: StageCreate, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + existing_stage = db.query(KanbanStage).filter(KanbanStage.name == stage_data.name).first() + if existing_stage: + raise HTTPException(status_code=400, detail="Stage name already exists") + + max_order = db.query(func.max(KanbanStage.order_index)).scalar() or 0 + stage_data.order_index = max_order + 1 + + stage = KanbanStage( + name=stage_data.name, + color=stage_data.color, + order_index=stage_data.order_index + ) + db.add(stage) + db.commit() + db.refresh(stage) + return stage + +@router.get("/{stage_id}", response_model=StageSchema) +async def get_stage(stage_id: int, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + stage = db.query(KanbanStage).filter(KanbanStage.id == stage_id).first() + if not stage: + raise HTTPException(status_code=404, detail="Stage not found") + return stage + +@router.put("/{stage_id}", response_model=StageSchema) +async def update_stage(stage_id: int, stage_data: StageUpdate, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + stage = db.query(KanbanStage).filter(KanbanStage.id == stage_id).first() + if not stage: + raise HTTPException(status_code=404, detail="Stage not found") + + if stage.name in ["New", "In Review", "Approved", "Ordered"]: + raise HTTPException(status_code=400, detail="Cannot rename default stages") + + if stage_data.name: + existing_stage = db.query(KanbanStage).filter(KanbanStage.name == stage_data.name, KanbanStage.id != stage_id).first() + if existing_stage: + raise HTTPException(status_code=400, detail="Stage name already exists") + + update_data = stage_data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(stage, key, value) + + db.commit() + db.refresh(stage) + return stage + +@router.delete("/{stage_id}", status_code=204) +async def delete_stage(stage_id: int, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + stage = db.query(KanbanStage).filter(KanbanStage.id == stage_id).first() + if not stage: + raise HTTPException(status_code=404, detail="Stage not found") + + if stage.name in ["New", "In Review", "Approved", "Ordered"]: + raise HTTPException(status_code=400, detail="Cannot delete default stages") + + pr_count = db.query(PurchaseRequest).filter(PurchaseRequest.stage_id == stage_id).count() + if pr_count > 0: + raise HTTPException(status_code=400, detail="Cannot delete stage containing requests") + + db.delete(stage) + db.commit() + return None + +@router.post("/reorder") +async def reorder_stages(stage_ids: List[int] = Body(..., embed=True), current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + if len(stage_ids) != len(set(stage_ids)): + raise HTTPException(status_code=400, detail="Duplicate stage IDs") + + stages = db.query(KanbanStage).filter(KanbanStage.id.in_(stage_ids)).all() + if len(stages) != len(stage_ids): + raise HTTPException(status_code=404, detail="Some stages not found") + + for index, stage_id in enumerate(stage_ids): + stage = db.query(KanbanStage).filter(KanbanStage.id == stage_id).first() + stage.order_index = index + + db.commit() + return {"message": "Stages reordered successfully"} diff --git a/021-supplier-management/procurement_platform/app/api/v1/endpoints/suppliers.py b/021-supplier-management/procurement_platform/app/api/v1/endpoints/suppliers.py new file mode 100644 index 0000000..56a5414 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/endpoints/suppliers.py @@ -0,0 +1,183 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session, joinedload +from typing import List, Optional +from sqlalchemy import or_, and_, func + +from app.database import get_db +from app.database.models import Supplier, Category, SupplierCategory, RFQ, RFQSupplier, Order, User +from app.api.v1.endpoints.auth import get_current_active_user, get_current_admin_user +from app.api.v1.schemas.suppliers import SupplierCreate, SupplierUpdate, SupplierSchema, SupplierSearch + +router = APIRouter() + +def calculate_overall_score(punctuality: float = 0, quality: float = 0, reliability: float = 0) -> float: + return round(punctuality * 0.35 + quality * 0.35 + reliability * 0.30) + +@router.get("/", response_model=List[SupplierSchema]) +async def list_suppliers( + search: Optional[str] = Query(None), + category_id: Optional[int] = Query(None), + is_active: Optional[bool] = Query(None), + sort_by: str = Query("name"), + sort_order: str = Query("asc"), + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + query = db.query(Supplier).options(joinedload(Supplier.categories).joinedload(SupplierCategory.category)) + + if search: + query = query.filter( + or_( + Supplier.company_name.ilike(f"%{search}%"), + Supplier.email.ilike(f"%{search}%") + ) + ) + + if category_id: + query = query.join(Supplier.categories).filter(SupplierCategory.category_id == category_id) + + if is_active is not None: + query = query.filter(Supplier.is_active == is_active) + + if sort_by == "name": + if sort_order == "desc": + query = query.order_by(Supplier.company_name.desc()) + else: + query = query.order_by(Supplier.company_name.asc()) + elif sort_by == "score": + if sort_order == "desc": + query = query.order_by(Supplier.overall_score.desc(), Supplier.company_name.asc()) + else: + query = query.order_by(Supplier.overall_score.asc(), Supplier.company_name.asc()) + + suppliers = query.all() + return suppliers + +@router.get("/active", response_model=List[SupplierSchema]) +async def list_active_suppliers(current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db)): + suppliers = db.query(Supplier).filter(Supplier.is_active == True).all() + return suppliers + +@router.post("/", response_model=SupplierSchema) +async def create_supplier(supplier_data: SupplierCreate, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db)): + existing_tax_id = db.query(Supplier).filter(Supplier.tax_id == supplier_data.tax_id).first() + if existing_tax_id: + raise HTTPException(status_code=400, detail="Tax ID already exists") + + existing_email = db.query(Supplier).filter(Supplier.email == supplier_data.email).first() + if existing_email: + raise HTTPException(status_code=400, detail="Email already exists") + + if not supplier_data.category_ids: + raise HTTPException(status_code=400, detail="At least one category is required") + + for cat_id in supplier_data.category_ids: + category = db.query(Category).filter(Category.id == cat_id).first() + if not category: + raise HTTPException(status_code=400, detail=f"Category {cat_id} not found") + + supplier = Supplier( + company_name=supplier_data.company_name, + tax_id=supplier_data.tax_id, + email=supplier_data.email, + contact_person=supplier_data.contact_person, + phone=supplier_data.phone, + address=supplier_data.address, + is_active=supplier_data.is_active + ) + db.add(supplier) + db.commit() + db.refresh(supplier) + + for cat_id in supplier_data.category_ids: + supplier_category = SupplierCategory(supplier_id=supplier.id, category_id=cat_id) + db.add(supplier_category) + + db.commit() + db.refresh(supplier) + return supplier + +@router.get("/{supplier_id}", response_model=SupplierSchema) +async def get_supplier(supplier_id: int, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db)): + supplier = db.query(Supplier).filter(Supplier.id == supplier_id).options(joinedload(Supplier.categories)).first() + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + return supplier + +@router.put("/{supplier_id}", response_model=SupplierSchema) +async def update_supplier(supplier_id: int, supplier_data: SupplierUpdate, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db)): + supplier = db.query(Supplier).filter(Supplier.id == supplier_id).first() + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + if supplier_data.tax_id and supplier_data.tax_id != supplier.tax_id: + existing_tax_id = db.query(Supplier).filter(Supplier.tax_id == supplier_data.tax_id, Supplier.id != supplier_id).first() + if existing_tax_id: + raise HTTPException(status_code=400, detail="Tax ID already exists") + + if supplier_data.email and supplier_data.email != supplier.email: + existing_email = db.query(Supplier).filter(Supplier.email == supplier_data.email, Supplier.id != supplier_id).first() + if existing_email: + raise HTTPException(status_code=400, detail="Email already exists") + + update_data = supplier_data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + if key != "category_ids": + setattr(supplier, key, value) + + if supplier_data.category_ids: + db.query(SupplierCategory).filter(SupplierCategory.supplier_id == supplier_id).delete() + for cat_id in supplier_data.category_ids: + category = db.query(Category).filter(Category.id == cat_id).first() + if category: + supplier_category = SupplierCategory(supplier_id=supplier.id, category_id=cat_id) + db.add(supplier_category) + + db.commit() + db.refresh(supplier) + return supplier + +@router.delete("/{supplier_id}", status_code=204) +async def delete_supplier(supplier_id: int, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db)): + supplier = db.query(Supplier).filter(Supplier.id == supplier_id).first() + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + rfq_count = db.query(RFQSupplier).filter(RFQSupplier.supplier_id == supplier_id).count() + order_count = db.query(Order).filter(Order.supplier_id == supplier_id).count() + + if rfq_count > 0 or order_count > 0: + raise HTTPException(status_code=400, detail="Cannot delete supplier with existing RFQs or orders") + + db.delete(supplier) + db.commit() + return None + +@router.patch("/{supplier_id}/toggle-active", response_model=SupplierSchema) +async def toggle_supplier_active(supplier_id: int, current_user: User = Depends(get_current_active_user), db: Session = Depends(get_db)): + supplier = db.query(Supplier).filter(Supplier.id == supplier_id).first() + if not supplier: + raise HTTPException(status_code=404, detail="Supplier not found") + + if not supplier.is_active and supplier.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot deactivate own supplier account") + + active_rfqs = db.query(RFQ).filter( + RFQ.status.notin_(["Cancelled", "Winner Selected", "Overdue"]), + RFQ.deadline > func.now() + ).all() + + for rfq in active_rfqs: + rfq_supplier = db.query(RFQSupplier).filter( + RFQSupplier.rfq_id == rfq.id, + RFQSupplier.supplier_id == supplier_id + ).first() + if rfq_supplier and rfq_supplier.has_submitted == False: + raise HTTPException(status_code=400, detail="Cannot deactivate supplier invited to active RFQ awaiting quotes") + + supplier.is_active = not supplier.is_active + supplier.updated_at = func.now() + + db.commit() + db.refresh(supplier) + return supplier diff --git a/021-supplier-management/procurement_platform/app/api/v1/endpoints/users.py b/021-supplier-management/procurement_platform/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..d0f192f --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/endpoints/users.py @@ -0,0 +1,86 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List + +from app.database import get_db +from app.database.models import User, UserRole +from app.api.v1.endpoints.auth import get_current_admin_user, get_current_active_user, get_password_hash +from app.api.v1.schemas.users import UserCreate, UserUpdate, UserSchema + +router = APIRouter() + +@router.get("/", response_model=List[UserSchema]) +async def list_users(current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + users = db.query(User).all() + return users + +@router.post("/", response_model=UserSchema) +async def create_user(user_data: UserCreate, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + existing_user = db.query(User).filter(User.username == user_data.username).first() + if existing_user: + raise HTTPException(status_code=400, detail="Username already exists") + + existing_email = db.query(User).filter(User.email == user_data.email).first() + if existing_email: + raise HTTPException(status_code=400, detail="Email already exists") + + role = UserRole(user_data.role) if user_data.role else UserRole.BUYER + + user = User( + username=user_data.username, + password_hash=get_password_hash(user_data.password), + email=user_data.email, + full_name=user_data.full_name, + is_active=True, + role=role + ) + db.add(user) + db.commit() + db.refresh(user) + return user + +@router.get("/{user_id}", response_model=UserSchema) +async def get_user(user_id: int, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + +@router.put("/{user_id}", response_model=UserSchema) +async def update_user(user_id: int, user_data: UserUpdate, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.id == current_user.id and user_data.is_active is not None and not user_data.is_active: + raise HTTPException(status_code=400, detail="Users cannot deactivate their own account") + + if user_data.email: + existing_email = db.query(User).filter(User.email == user_data.email, User.id != user_id).first() + if existing_email: + raise HTTPException(status_code=400, detail="Email already exists") + + update_data = user_data.model_dump(exclude_unset=True) + if "password" in update_data: + update_data["password_hash"] = get_password_hash(update_data.pop("password")) + + for key, value in update_data.items(): + setattr(user, key, value) + + db.commit() + db.refresh(user) + return user + +@router.patch("/{user_id}/activate", response_model=UserSchema) +async def toggle_user_active(user_id: int, current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db)): + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.id == current_user.id: + raise HTTPException(status_code=400, detail="Users cannot deactivate their own account") + + user.is_active = not user.is_active + db.commit() + db.refresh(user) + return user diff --git a/021-supplier-management/procurement_platform/app/api/v1/schemas/__init__.py b/021-supplier-management/procurement_platform/app/api/v1/schemas/__init__.py new file mode 100644 index 0000000..15c2876 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/schemas/__init__.py @@ -0,0 +1,10 @@ +from .auth import Token, TokenData, LoginRequest +from .users import UserCreate, UserUpdate, UserSchema +from .categories import CategoryCreate, CategoryUpdate, CategorySchema +from .stages import StageCreate, StageUpdate, StageSchema +from .suppliers import SupplierCreate, SupplierUpdate, SupplierSchema, SupplierSearch +from .purchase_requests import PurchaseRequestCreate, PurchaseRequestUpdate, PurchaseRequestSchema +from .rfqs import RFQCreate, RFQUpdate, RFQSchema, RFQSupplierCreate, RFQStatusUpdate +from .quotes import QuoteCreate, QuoteUpdate, QuoteSchema +from .orders import OrderSchema, OrderStatusUpdate, OrderRating +from .dashboard import DashboardItem diff --git a/021-supplier-management/procurement_platform/app/api/v1/schemas/auth.py b/021-supplier-management/procurement_platform/app/api/v1/schemas/auth.py new file mode 100644 index 0000000..67b49f5 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/schemas/auth.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel +from typing import Optional + +class Token(BaseModel): + access_token: str + token_type: str + +class TokenData(BaseModel): + username: Optional[str] = None + user_id: Optional[int] = None + role: Optional[str] = None + +class LoginRequest(BaseModel): + username: str + password: str diff --git a/021-supplier-management/procurement_platform/app/api/v1/schemas/categories.py b/021-supplier-management/procurement_platform/app/api/v1/schemas/categories.py new file mode 100644 index 0000000..582b6c9 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/schemas/categories.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + +class CategoryBase(BaseModel): + name: str = Field(..., max_length=100) + description: Optional[str] = None + +class CategoryCreate(CategoryBase): + pass + +class CategoryUpdate(BaseModel): + name: Optional[str] = Field(None, max_length=100) + description: Optional[str] = None + +class CategorySchema(CategoryBase): + id: int + created_at: datetime + + class Config: + from_attributes = True diff --git a/021-supplier-management/procurement_platform/app/api/v1/schemas/dashboard.py b/021-supplier-management/procurement_platform/app/api/v1/schemas/dashboard.py new file mode 100644 index 0000000..4461cc4 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/schemas/dashboard.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +from enum import Enum + +class DashboardItemType(str, Enum): + RFQ_READY_FOR_REVIEW = "rfq_ready_for_review" + OVERDUE_ORDER = "overdue_order" + STALE_REQUEST = "stale_request" + +class DashboardItem(BaseModel): + item_type: DashboardItemType + record_id: int + title: str + record_data: Optional[dict] = None + + class Config: + from_attributes = True diff --git a/021-supplier-management/procurement_platform/app/api/v1/schemas/orders.py b/021-supplier-management/procurement_platform/app/api/v1/schemas/orders.py new file mode 100644 index 0000000..4b5c6a0 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/schemas/orders.py @@ -0,0 +1,40 @@ +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime +from enum import Enum + +class OrderStatus(str, Enum): + PENDING = "Pending" + CONFIRMED = "Confirmed" + SHIPPED = "Shipped" + DELIVERED = "Delivered" + +class OrderBase(BaseModel): + order_number: Optional[str] = Field(None, max_length=50) + expected_delivery: datetime + payment_terms: str = Field(..., max_length=200) + total_amount: float = Field(..., gt=0) + +class OrderSchema(OrderBase): + id: int + supplier_id: int + rfq_id: int + status: OrderStatus + created_by_id: int + created_at: datetime + updated_at: datetime + supplier_name: Optional[str] = None + is_overdue: bool = False + + class Config: + from_attributes = True + +class OrderStatusUpdate(BaseModel): + status: OrderStatus + change_reason: Optional[str] = None + +class OrderRating(BaseModel): + punctuality: int = Field(..., ge=0, le=100) + quality: int = Field(..., ge=0, le=100) + reliability: int = Field(..., ge=0, le=100) + notes: Optional[str] = None diff --git a/021-supplier-management/procurement_platform/app/api/v1/schemas/purchase_requests.py b/021-supplier-management/procurement_platform/app/api/v1/schemas/purchase_requests.py new file mode 100644 index 0000000..1d4ae20 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/schemas/purchase_requests.py @@ -0,0 +1,48 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from enum import Enum + +class Priority(str, Enum): + LOW = "Low" + MEDIUM = "Medium" + HIGH = "High" + URGENT = "Urgent" + +class LineItemBase(BaseModel): + description: str = Field(..., max_length=200) + quantity: float = Field(..., gt=0) + unit: Optional[str] = Field(None, max_length=50) + +class PurchaseRequestBase(BaseModel): + title: str = Field(..., max_length=200) + description: Optional[str] = None + priority: Priority = Priority.MEDIUM + deadline: Optional[datetime] = None + notes: Optional[str] = None + category_id: int = Field(..., gt=0) + line_items: List[LineItemBase] = Field(..., min_items=1) + +class PurchaseRequestCreate(PurchaseRequestBase): + pass + +class PurchaseRequestUpdate(BaseModel): + title: Optional[str] = Field(None, max_length=200) + description: Optional[str] = None + priority: Optional[Priority] = None + deadline: Optional[datetime] = None + notes: Optional[str] = None + category_id: Optional[int] = Field(None, gt=0) + +class PurchaseRequestSchema(PurchaseRequestBase): + id: int + stage_id: int + created_by_id: int + created_at: datetime + updated_at: datetime + age: Optional[int] = None + item_count: int + current_stage: str + + class Config: + from_attributes = True diff --git a/021-supplier-management/procurement_platform/app/api/v1/schemas/quotes.py b/021-supplier-management/procurement_platform/app/api/v1/schemas/quotes.py new file mode 100644 index 0000000..e3f92d9 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/schemas/quotes.py @@ -0,0 +1,44 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + +class QuoteItemBase(BaseModel): + line_item_id: int = Field(..., gt=0) + unit_price: float = Field(..., gt=0) + +class QuoteBase(BaseModel): + delivery_time_days: int = Field(..., ge=1) + payment_terms: str = Field(..., max_length=200) + notes: Optional[str] = None + quote_items: List[QuoteItemBase] = Field(..., min_items=1) + +class QuoteCreate(QuoteBase): + quote_submission_token: str = Field(..., min_length=1) + +class QuoteUpdate(BaseModel): + delivery_time_days: Optional[int] = Field(None, ge=1) + payment_terms: Optional[str] = Field(None, max_length=200) + notes: Optional[str] = None + quote_items: Optional[List[QuoteItemBase]] = None + +class QuoteSchema(QuoteBase): + id: int + rfq_supplier_id: int + rfq_id: int + revision_number: int + unit_price_total: float + submission_reference: str + supplier_name: Optional[str] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class QuoteItemSchema(QuoteItemBase): + id: int + quote_id: int + created_at: datetime + + class Config: + from_attributes = True diff --git a/021-supplier-management/procurement_platform/app/api/v1/schemas/rfqs.py b/021-supplier-management/procurement_platform/app/api/v1/schemas/rfqs.py new file mode 100644 index 0000000..4e9b93f --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/schemas/rfqs.py @@ -0,0 +1,46 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from enum import Enum + +class RFQStatus(str, Enum): + AWAITING_QUOTES = "Awaiting Quotes" + READY_FOR_REVIEW = "Ready for Review" + WINNER_SELECTED = "Winner Selected" + CANCELLED = "Cancelled" + OVERDUE = "Overdue" + +class RFQBase(BaseModel): + title: str = Field(..., max_length=200) + description: Optional[str] = None + deadline: datetime = Field(..., future=True) + supplier_ids: List[int] = Field(..., min_items=1) + +class RFQCreate(RFQBase): + purchase_request_id: int = Field(..., gt=0) + +class RFQUpdate(BaseModel): + title: Optional[str] = Field(None, max_length=200) + description: Optional[str] = None + deadline: Optional[datetime] = Field(None, future=True) + +class RFQSchema(RFQBase): + id: int + status: RFQStatus + purchase_request_id: int + created_by_id: int + winner_quote_id: Optional[int] = None + justification: Optional[str] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class RFQSupplierCreate(BaseModel): + supplier_id: int = Field(..., gt=0) + +class RFQStatusUpdate(BaseModel): + status: RFQStatus + winner_quote_id: Optional[int] = None + justification: Optional[str] = None diff --git a/021-supplier-management/procurement_platform/app/api/v1/schemas/stages.py b/021-supplier-management/procurement_platform/app/api/v1/schemas/stages.py new file mode 100644 index 0000000..c542d7b --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/schemas/stages.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + +class StageBase(BaseModel): + name: str = Field(..., max_length=100) + color: Optional[str] = Field("#000000", max_length=50) + +class StageCreate(StageBase): + order_index: Optional[int] = None + +class StageUpdate(BaseModel): + name: Optional[str] = Field(None, max_length=100) + color: Optional[str] = Field(None, max_length=50) + +class StageSchema(StageBase): + id: int + order_index: int + is_default: bool + created_at: datetime + + class Config: + from_attributes = True diff --git a/021-supplier-management/procurement_platform/app/api/v1/schemas/suppliers.py b/021-supplier-management/procurement_platform/app/api/v1/schemas/suppliers.py new file mode 100644 index 0000000..eb22c41 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/schemas/suppliers.py @@ -0,0 +1,46 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + +class SupplierBase(BaseModel): + company_name: str = Field(..., max_length=100) + tax_id: str = Field(..., max_length=50) + email: str = Field(..., max_length=100) + contact_person: Optional[str] = Field(None, max_length=100) + phone: Optional[str] = Field(None, max_length=50) + address: Optional[str] = None + is_active: Optional[bool] = True + category_ids: List[int] = Field(..., min_items=1) + +class SupplierCreate(SupplierBase): + pass + +class SupplierUpdate(BaseModel): + company_name: Optional[str] = Field(None, max_length=100) + email: Optional[str] = Field(None, max_length=100) + contact_person: Optional[str] = Field(None, max_length=100) + phone: Optional[str] = Field(None, max_length=50) + address: Optional[str] = None + is_active: Optional[bool] = None + category_ids: Optional[List[int]] = None + +class SupplierSchema(SupplierBase): + id: int + punctuality_score: Optional[float] = 0 + quality_score: Optional[float] = 0 + reliability_score: Optional[float] = 0 + overall_score: Optional[float] = 0 + created_at: datetime + updated_at: datetime + categories: Optional[List[dict]] = None + + class Config: + from_attributes = True + +class SupplierSearch(BaseModel): + name: Optional[str] = None + email: Optional[str] = None + category_id: Optional[int] = None + is_active: Optional[bool] = None + sort_by: Optional[str] = "name" + sort_order: Optional[str] = "asc" diff --git a/021-supplier-management/procurement_platform/app/api/v1/schemas/users.py b/021-supplier-management/procurement_platform/app/api/v1/schemas/users.py new file mode 100644 index 0000000..e3d1918 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/schemas/users.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + +class UserBase(BaseModel): + username: str = Field(..., max_length=50) + email: str = Field(..., max_length=100) + full_name: Optional[str] = Field(None, max_length=100) + +class UserCreate(UserBase): + password: str = Field(..., min_length=6) + role: Optional[str] = "Buyer" + +class UserUpdate(BaseModel): + email: Optional[str] = Field(None, max_length=100) + full_name: Optional[str] = Field(None, max_length=100) + is_active: Optional[bool] = None + role: Optional[str] = None + +class UserSchema(UserBase): + id: int + is_active: bool + role: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/021-supplier-management/procurement_platform/app/api/v1/services/__init__.py b/021-supplier-management/procurement_platform/app/api/v1/services/__init__.py new file mode 100644 index 0000000..89f823e --- /dev/null +++ b/021-supplier-management/procurement_platform/app/api/v1/services/__init__.py @@ -0,0 +1,10 @@ +from .auth_service import AuthService +from .user_service import UserService +from .category_service import CategoryService +from .stage_service import StageService +from .supplier_service import SupplierService +from .purchase_request_service import PurchaseRequestService +from .rfq_service import RFQService +from .quote_service import QuoteService +from .order_service import OrderService +from .dashboard_service import DashboardService diff --git a/021-supplier-management/procurement_platform/app/core/__init__.py b/021-supplier-management/procurement_platform/app/core/__init__.py new file mode 100644 index 0000000..0f25140 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/core/__init__.py @@ -0,0 +1 @@ +from .config import settings diff --git a/021-supplier-management/procurement_platform/app/core/config.py b/021-supplier-management/procurement_platform/app/core/config.py new file mode 100644 index 0000000..c628162 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/core/config.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings +from typing import Optional + +class Settings(BaseSettings): + app_name: str = "Supplier Relationship Management Platform" + app_version: str = "1.0.0" + secret_key: str = "super-secret-key-for-dev-only" + algorithm: str = "HS256" + access_token_expire_minutes: int = 30 + database_url: str = "sqlite:///procurement.db" + + class Config: + env_file = ".env" + +settings = Settings() diff --git a/021-supplier-management/procurement_platform/app/database/__init__.py b/021-supplier-management/procurement_platform/app/database/__init__.py new file mode 100644 index 0000000..f14fcbc --- /dev/null +++ b/021-supplier-management/procurement_platform/app/database/__init__.py @@ -0,0 +1,13 @@ +from .base import Base, SessionLocal, engine, get_db +from .models import ( + User, Category, KanbanStage, Supplier, SupplierCategory, + PurchaseRequest, LineItem, RFQ, RFQSupplier, Quote, QuoteItem, + Order, OrderLineItem, StageHistory, OrderStatusHistory, SupplierRating +) + +__all__ = [ + "Base", "SessionLocal", "engine", "get_db", + "User", "Category", "KanbanStage", "Supplier", "SupplierCategory", + "PurchaseRequest", "LineItem", "RFQ", "RFQSupplier", "Quote", "QuoteItem", + "Order", "OrderLineItem", "StageHistory", "OrderStatusHistory", "SupplierRating" +] diff --git a/021-supplier-management/procurement_platform/app/database/base.py b/021-supplier-management/procurement_platform/app/database/base.py new file mode 100644 index 0000000..e9fe079 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/database/base.py @@ -0,0 +1,24 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from app.core.config import settings + +SQLALCHEMY_DATABASE_URL = str(settings.database_url) + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} if "sqlite" in SQLALCHEMY_DATABASE_URL else {}, + pool_pre_ping=True, + pool_recycle=3600, +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/021-supplier-management/procurement_platform/app/database/models.py b/021-supplier-management/procurement_platform/app/database/models.py new file mode 100644 index 0000000..0c028a7 --- /dev/null +++ b/021-supplier-management/procurement_platform/app/database/models.py @@ -0,0 +1,204 @@ +from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime, Float, Text, Enum +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from datetime import datetime, timedelta +import enum + +from .base import Base + +class UserRole(str, enum.Enum): + ADMIN = "Admin" + BUYER = "Buyer" + +class Priority(str, enum.Enum): + LOW = "Low" + MEDIUM = "Medium" + HIGH = "High" + URGENT = "Urgent" + +class RFQStatus(str, enum.Enum): + AWAITING_QUOTES = "Awaiting Quotes" + READY_FOR_REVIEW = "Ready for Review" + WINNER_SELECTED = "Winner Selected" + CANCELLED = "Cancelled" + OVERDUE = "Overdue" + +class OrderStatus(str, enum.Enum): + PENDING = "Pending" + CONFIRMED = "Confirmed" + SHIPPED = "Shipped" + DELIVERED = "Delivered" + +class User(Base): + __tablename__ = "users" + id = Column(Integer, primary_key=True, index=True) + username = Column(String(50), unique=True, nullable=False) + password_hash = Column(String(255), nullable=False) + email = Column(String(100), unique=True, nullable=False) + full_name = Column(String(100)) + is_active = Column(Boolean, default=True) + role = Column(Enum(UserRole), default=UserRole.BUYER) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + +class Category(Base): + __tablename__ = "categories" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), unique=True, nullable=False) + description = Column(Text) + created_at = Column(DateTime, default=func.now()) + +class KanbanStage(Base): + __tablename__ = "kanban_stages" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + color = Column(String(50), default="#000000") + order_index = Column(Integer, default=0) + is_default = Column(Boolean, default=False) + created_at = Column(DateTime, default=func.now()) + +class Supplier(Base): + __tablename__ = "suppliers" + id = Column(Integer, primary_key=True, index=True) + company_name = Column(String(100), nullable=False) + tax_id = Column(String(50), unique=True, nullable=False) + email = Column(String(100), unique=True, nullable=False) + contact_person = Column(String(100)) + phone = Column(String(50)) + address = Column(Text) + is_active = Column(Boolean, default=True) + punctuality_score = Column(Float, default=0) + quality_score = Column(Float, default=0) + reliability_score = Column(Float, default=0) + overall_score = Column(Float, default=0) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + +class SupplierCategory(Base): + __tablename__ = "supplier_categories" + id = Column(Integer, primary_key=True, index=True) + supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False) + category_id = Column(Integer, ForeignKey("categories.id"), nullable=False) + +class PurchaseRequest(Base): + __tablename__ = "purchase_requests" + id = Column(Integer, primary_key=True, index=True) + title = Column(String(200), nullable=False) + description = Column(Text) + priority = Column(Enum(Priority), default=Priority.MEDIUM) + deadline = Column(DateTime) + notes = Column(Text) + category_id = Column(Integer, ForeignKey("categories.id")) + stage_id = Column(Integer, ForeignKey("kanban_stages.id")) + created_by_id = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + +class LineItem(Base): + __tablename__ = "line_items" + id = Column(Integer, primary_key=True, index=True) + description = Column(String(200), nullable=False) + quantity = Column(Float, nullable=False, default=1) + unit = Column(String(50)) + purchase_request_id = Column(Integer, ForeignKey("purchase_requests.id")) + created_at = Column(DateTime, default=func.now()) + +class RFQ(Base): + __tablename__ = "rfqs" + id = Column(Integer, primary_key=True, index=True) + title = Column(String(200), nullable=False) + description = Column(Text) + deadline = Column(DateTime, nullable=False) + status = Column(Enum(RFQStatus), default=RFQStatus.AWAITING_QUOTES) + purchase_request_id = Column(Integer, ForeignKey("purchase_requests.id")) + created_by_id = Column(Integer, ForeignKey("users.id")) + winner_quote_id = Column(Integer, ForeignKey("quotes.id")) + justification = Column(Text) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + +class RFQSupplier(Base): + __tablename__ = "rfq_suppliers" + id = Column(Integer, primary_key=True, index=True) + rfq_id = Column(Integer, ForeignKey("rfqs.id"), nullable=False) + supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False) + quote_submission_token = Column(String(64), unique=True, nullable=False) + has_submitted = Column(Boolean, default=False) + +class Quote(Base): + __tablename__ = "quotes" + id = Column(Integer, primary_key=True, index=True) + rfq_supplier_id = Column(Integer, ForeignKey("rfq_suppliers.id"), nullable=False) + rfq_id = Column(Integer, ForeignKey("rfqs.id"), nullable=False) + revision_number = Column(Integer, default=1) + unit_price_total = Column(Float, default=0) + delivery_time_days = Column(Integer, nullable=False) + payment_terms = Column(String(200)) + notes = Column(Text) + submission_reference = Column(String(50), unique=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + +class QuoteItem(Base): + __tablename__ = "quote_items" + id = Column(Integer, primary_key=True, index=True) + quote_id = Column(Integer, ForeignKey("quotes.id"), nullable=False) + line_item_id = Column(Integer, ForeignKey("line_items.id"), nullable=False) + unit_price = Column(Float, nullable=False) + created_at = Column(DateTime, default=func.now()) + +class Order(Base): + __tablename__ = "orders" + id = Column(Integer, primary_key=True, index=True) + order_number = Column(String(50), unique=True, nullable=False) + supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False) + rfq_id = Column(Integer, ForeignKey("rfqs.id"), nullable=False) + expected_delivery = Column(DateTime) + payment_terms = Column(String(200)) + total_amount = Column(Float, default=0) + status = Column(Enum(OrderStatus), default=OrderStatus.PENDING) + created_by_id = Column(Integer, ForeignKey("users.id")) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + +class OrderLineItem(Base): + __tablename__ = "order_line_items" + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + description = Column(String(200), nullable=False) + quantity = Column(Float, nullable=False) + unit = Column(String(50)) + unit_price = Column(Float, nullable=False) + created_at = Column(DateTime, default=func.now()) + +class StageHistory(Base): + __tablename__ = "stage_history" + id = Column(Integer, primary_key=True, index=True) + purchase_request_id = Column(Integer, ForeignKey("purchase_requests.id"), nullable=False) + previous_stage_id = Column(Integer, ForeignKey("kanban_stages.id")) + new_stage_id = Column(Integer, ForeignKey("kanban_stages.id"), nullable=False) + changed_by_id = Column(Integer, ForeignKey("users.id")) + change_reason = Column(Text) + created_at = Column(DateTime, default=func.now()) + +class OrderStatusHistory(Base): + __tablename__ = "order_status_history" + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + previous_status = Column(Enum(OrderStatus)) + new_status = Column(Enum(OrderStatus), nullable=False) + changed_by_id = Column(Integer, ForeignKey("users.id")) + change_reason = Column(Text) + created_at = Column(DateTime, default=func.now()) + +class SupplierRating(Base): + __tablename__ = "supplier_ratings" + id = Column(Integer, primary_key=True, index=True) + supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False) + rated_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + punctuality = Column(Integer, nullable=False) + quality = Column(Integer, nullable=False) + reliability = Column(Integer, nullable=False) + notes = Column(Text) + created_at = Column(DateTime, default=func.now()) diff --git a/021-supplier-management/procurement_platform/app/database/seed.py b/021-supplier-management/procurement_platform/app/database/seed.py new file mode 100644 index 0000000..39fb3fd --- /dev/null +++ b/021-supplier-management/procurement_platform/app/database/seed.py @@ -0,0 +1,84 @@ +from sqlalchemy.orm import Session +from sqlalchemy import text +import hashlib +import secrets +from datetime import datetime + +def create_admin_user(db: Session): + try: + result = db.execute(text("SELECT id FROM users WHERE username = :username"), {"username": "admin"}).fetchone() + if result: + return + + password_hash = hashlib.pbkdf2_hmac('sha256', b'admin123', b'salt', 10000).hex() + user_id = db.execute( + text("INSERT INTO users (username, password_hash, email, full_name, is_active, role) VALUES (:username, :password_hash, :email, :full_name, :is_active, :role)"), + { + "username": "admin", + "password_hash": password_hash, + "email": "admin@procurement.com", + "full_name": "System Administrator", + "is_active": True, + "role": "Admin" + } + ).lastrowid + db.commit() + except Exception as e: + db.rollback() + raise e + +def create_default_categories(db: Session): + categories = [ + {"name": "Raw Materials", "description": "Raw materials for production"}, + {"name": "Office Supplies", "description": "Office supplies and stationery"}, + {"name": "Equipment", "description": "Equipment and machinery"}, + {"name": "Services", "description": "Professional services"}, + {"name": "Other", "description": "Other categories"} + ] + + for cat_data in categories: + try: + result = db.execute(text("SELECT id FROM categories WHERE name = :name"), {"name": cat_data["name"]}).fetchone() + if not result: + db.execute( + text("INSERT INTO categories (name, description) VALUES (:name, :description)"), + cat_data + ) + except Exception as e: + db.rollback() + raise e + + db.commit() + +def create_default_stages(db: Session): + stages = [ + {"name": "New", "color": "#FFFF00", "order_index": 0, "is_default": True}, + {"name": "In Review", "color": "#FFA500", "order_index": 1, "is_default": False}, + {"name": "Approved", "color": "#008000", "order_index": 2, "is_default": False}, + {"name": "Ordered", "color": "#0000FF", "order_index": 3, "is_default": False} + ] + + for stage_data in stages: + try: + result = db.execute(text("SELECT id FROM kanban_stages WHERE name = :name"), {"name": stage_data["name"]}).fetchone() + if not result: + db.execute( + text("INSERT INTO kanban_stages (name, color, order_index, is_default) VALUES (:name, :color, :order_index, :is_default)"), + stage_data + ) + except Exception as e: + db.rollback() + raise e + + db.commit() + +def generate_quote_token(): + return secrets.token_hex(32) + +def init_db(db: Session): + create_admin_user(db) + create_default_categories(db) + create_default_stages(db) + +def seed_test_data(db: Session): + init_db(db) diff --git a/021-supplier-management/procurement_platform/app/main.py b/021-supplier-management/procurement_platform/app/main.py new file mode 100644 index 0000000..194547b --- /dev/null +++ b/021-supplier-management/procurement_platform/app/main.py @@ -0,0 +1,58 @@ +from fastapi import FastAPI, Depends, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import HTTPBearer +from app.core.config import settings +from app.database import engine, Base, get_db +from app.database.seed import init_db, seed_test_data +from app.api.v1 import api_router +from app.api.v1.endpoints.auth import get_current_active_user, get_current_admin_user, User +import logging +from typing import Optional + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def create_tables(): + Base.metadata.create_all(bind=engine) + +def init_app(): + app = FastAPI( + title=settings.app_name, + version=settings.app_version, + description="Supplier Relationship Management Platform API" + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.on_event("startup") + async def on_startup(): + create_tables() + db = next(get_db()) + try: + init_db(db) + finally: + db.close() + + @app.get("/") + async def root(): + return {"name": settings.app_name, "version": settings.app_version} + + @app.get("/health") + async def health(): + return {"status": "healthy"} + + app.include_router(api_router, prefix="/api/v1") + + return app + +app = init_app() + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/021-supplier-management/procurement_platform/main.py b/021-supplier-management/procurement_platform/main.py new file mode 100644 index 0000000..acd1b18 --- /dev/null +++ b/021-supplier-management/procurement_platform/main.py @@ -0,0 +1,10 @@ +import os +import sys + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from app.main import app + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/021-supplier-management/procurement_platform/requirements.txt b/021-supplier-management/procurement_platform/requirements.txt new file mode 100644 index 0000000..afb65b7 --- /dev/null +++ b/021-supplier-management/procurement_platform/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +sqlalchemy==2.0.23 +pydantic==2.5.2 +pydantic-settings==2.1.0 +python-jose==3.3.0 +passlib==1.7.4 +python-multipart==0.0.6 +python-dotenv==1.0.0