diff --git a/backend/app/database.py b/backend/app/database.py index 2e7ce6f..8f9fb33 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -2,7 +2,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base import os -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./drugs.db") +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/drugs.db") # For SQLite, ensure the directory exists if "sqlite" in DATABASE_URL: @@ -18,10 +18,6 @@ engine = create_engine( SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() -# Drop and recreate all tables (for development only) -Base.metadata.drop_all(bind=engine) -Base.metadata.create_all(bind=engine) - def get_db(): db = SessionLocal() try: diff --git a/backend/app/main.py b/backend/app/main.py index f787dd5..574329a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,24 +1,48 @@ -from fastapi import FastAPI, Depends, HTTPException, APIRouter, status +from fastapi import FastAPI, Depends, HTTPException, APIRouter, status, Response from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session from typing import List, Optional, Dict, Any -from datetime import datetime, timedelta +from datetime import datetime, timedelta, date +import json +import csv +import io from .database import engine, get_db, Base -from .models import Drug, DrugVariant, Dispensing, User +from .models import ( + Drug, + DrugVariant, + Dispensing, + DispensingAllocation, + Location, + Batch, + AuditLog, + User, +) from .auth import hash_password, verify_password, create_access_token, get_current_user, get_current_admin_user, get_current_non_readonly_user, ACCESS_TOKEN_EXPIRE_MINUTES from .mqtt_service import publish_label_print_with_response from .migrate_to_roles import migrate_users_table +from .migrate_compliance import migrate_compliance_schema from pydantic import BaseModel -# Create tables -Base.metadata.create_all(bind=engine) - # Run migration to convert is_admin to role try: migrate_users_table() except Exception as e: print(f"Warning: Migration failed: {e}. Continuing anyway...") +try: + migrate_compliance_schema() +except Exception as e: + print(f"Warning: Compliance migration failed: {e}. Continuing anyway...") + +# Create tables +Base.metadata.create_all(bind=engine) + +# Seed default locations after table creation. +try: + migrate_compliance_schema() +except Exception as e: + print(f"Warning: Compliance seed pass failed: {e}. Continuing anyway...") + app = FastAPI(title="Drug Inventory API") # CORS middleware for frontend @@ -62,15 +86,67 @@ class TokenResponse(BaseModel): class DrugCreate(BaseModel): name: str description: Optional[str] = None + is_controlled: bool = False class DrugUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None + is_controlled: Optional[bool] = None + + +class LocationCreate(BaseModel): + name: str + + +class LocationUpdate(BaseModel): + name: Optional[str] = None + is_active: Optional[bool] = None + + +class LocationResponse(BaseModel): + id: int + name: str + is_active: bool + + class Config: + from_attributes = True + + +class BatchCreate(BaseModel): + batch_number: str + quantity: float + expiry_date: date + location_id: int + notes: Optional[str] = None + + +class BatchUpdate(BaseModel): + batch_number: Optional[str] = None + quantity: Optional[float] = None + expiry_date: Optional[date] = None + location_id: Optional[int] = None + notes: Optional[str] = None + + +class BatchResponse(BaseModel): + id: int + drug_variant_id: int + batch_number: str + quantity: float + expiry_date: date + location_id: int + location_name: Optional[str] = None + notes: Optional[str] = None + received_at: datetime + + class Config: + from_attributes = True class DrugResponse(BaseModel): id: int name: str description: Optional[str] = None + is_controlled: bool class Config: from_attributes = True @@ -94,6 +170,7 @@ class DrugVariantResponse(BaseModel): quantity: float unit: str low_stock_threshold: float + batches: List[BatchResponse] = [] class Config: from_attributes = True @@ -102,6 +179,7 @@ class DrugWithVariantsResponse(BaseModel): id: int name: str description: Optional[str] = None + is_controlled: bool = False variants: List[DrugVariantResponse] = [] class Config: @@ -110,18 +188,31 @@ class DrugWithVariantsResponse(BaseModel): class DispensingCreate(BaseModel): drug_variant_id: int quantity: float + batch_id: Optional[int] = None + allow_split: bool = True animal_name: Optional[str] = None - user_name: str + user_name: Optional[str] = None notes: Optional[str] = None + +class DispensingAllocationResponse(BaseModel): + batch_id: int + quantity: float + + class Config: + from_attributes = True + class DispensingResponse(BaseModel): id: int drug_variant_id: int + batch_id: Optional[int] = None + actor_user_id: Optional[int] = None quantity: float animal_name: Optional[str] = None user_name: str notes: Optional[str] = None dispensed_at: datetime + allocations: List[DispensingAllocationResponse] = [] class Config: from_attributes = True @@ -152,6 +243,133 @@ class NotesPrintResponse(BaseModel): success: bool message: str + +def write_audit_log( + db: Session, + action: str, + entity_type: str, + entity_id: Optional[int], + actor: Optional[User], + details: Optional[Dict[str, Any]] = None, +) -> None: + """Persist a best-effort audit event in the current transaction.""" + payload = None + if details is not None: + payload = json.dumps(details, default=str) + + log = AuditLog( + action=action, + entity_type=entity_type, + entity_id=entity_id, + actor_user_id=actor.id if actor else None, + actor_username=actor.username if actor else "system", + details=payload, + ) + db.add(log) + + +def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str, Any]: + """Return variant data with active batch details for API responses.""" + variant_dict = { + "id": variant.id, + "drug_id": variant.drug_id, + "strength": variant.strength, + "quantity": variant.quantity, + "unit": variant.unit, + "low_stock_threshold": variant.low_stock_threshold, + } + batches = ( + db.query(Batch) + .filter(Batch.drug_variant_id == variant.id, Batch.quantity > 0) + .order_by(Batch.expiry_date.asc(), Batch.received_at.asc()) + .all() + ) + variant_dict["batches"] = [serialize_batch_response(db, batch) for batch in batches] + return variant_dict + + +def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]: + location = db.query(Location).filter(Location.id == batch.location_id).first() + return { + "id": batch.id, + "drug_variant_id": batch.drug_variant_id, + "batch_number": batch.batch_number, + "quantity": batch.quantity, + "expiry_date": batch.expiry_date, + "location_id": batch.location_id, + "location_name": location.name if location else None, + "notes": batch.notes, + "received_at": batch.received_at, + } + + +def select_batches_for_dispense( + db: Session, + variant_id: int, + requested_quantity: float, + preferred_batch_id: Optional[int], + allow_split: bool, +) -> List[Dict[str, Any]]: + """Select one or more batch allocations using FEFO with optional preferred batch override.""" + today = date.today() + eligible_batches = ( + db.query(Batch) + .filter( + Batch.drug_variant_id == variant_id, + Batch.quantity > 0, + Batch.expiry_date >= today, + ) + .order_by(Batch.expiry_date.asc(), Batch.received_at.asc()) + .all() + ) + + if not eligible_batches: + raise HTTPException(status_code=400, detail="No in-date stock batches available for this variant") + + remaining = requested_quantity + allocations: List[Dict[str, Any]] = [] + + # If a preferred batch is supplied, consume from that batch first. + if preferred_batch_id is not None: + preferred = next((b for b in eligible_batches if b.id == preferred_batch_id), None) + if preferred is None: + raise HTTPException(status_code=400, detail="Preferred batch is unavailable or expired") + + used = min(preferred.quantity, remaining) + if used > 0: + allocations.append({"batch": preferred, "quantity": used}) + remaining -= used + eligible_batches = [b for b in eligible_batches if b.id != preferred_batch_id] + + if remaining > 0 and not allow_split: + raise HTTPException( + status_code=400, + detail=f"Preferred batch cannot fully satisfy request. Remaining required: {remaining}", + ) + + if remaining > 0 and not allow_split: + # In non-split mode, only a single batch may satisfy the request. + first = eligible_batches[0] + if first.quantity >= remaining: + allocations.append({"batch": first, "quantity": remaining}) + remaining = 0 + else: + raise HTTPException(status_code=400, detail="Single-batch fulfillment is not possible for requested quantity") + + if remaining > 0: + for batch in eligible_batches: + if remaining <= 0: + break + used = min(batch.quantity, remaining) + if used > 0: + allocations.append({"batch": batch, "quantity": used}) + remaining -= used + + if remaining > 0: + raise HTTPException(status_code=400, detail="Insufficient in-date stock across available batches") + + return allocations + # Authentication Routes @router.post("/auth/register", response_model=TokenResponse) def register(user_data: UserCreate, db: Session = Depends(get_db)): @@ -177,6 +395,14 @@ def register(user_data: UserCreate, db: Session = Depends(get_db)): role="admin" ) db.add(db_user) + write_audit_log( + db, + action="auth.register", + entity_type="user", + entity_id=None, + actor=None, + details={"username": user_data.username, "assigned_role": "admin"}, + ) db.commit() db.refresh(db_user) @@ -199,6 +425,16 @@ def login(user_data: UserCreate, db: Session = Depends(get_db)): user = db.query(User).filter(User.username == user_data.username).first() if not user or not verify_password(user_data.password, user.hashed_password): raise HTTPException(status_code=401, detail="Invalid credentials") + + write_audit_log( + db, + action="auth.login", + entity_type="user", + entity_id=user.id, + actor=user, + details={"username": user.username}, + ) + db.commit() # Create access token access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) @@ -245,6 +481,14 @@ def create_user(user_data: UserCreate, db: Session = Depends(get_db), current_us role=role ) db.add(db_user) + write_audit_log( + db, + action="user.create", + entity_type="user", + entity_id=None, + actor=current_user, + details={"username": user_data.username, "role": role}, + ) db.commit() db.refresh(db_user) return db_user @@ -260,6 +504,14 @@ def delete_user(user_id: int, db: Session = Depends(get_db), current_user: User if not user: raise HTTPException(status_code=404, detail="User not found") + write_audit_log( + db, + action="user.delete", + entity_type="user", + entity_id=user_id, + actor=current_user, + details={"username": user.username}, + ) db.delete(user) db.commit() return {"message": "User deleted successfully"} @@ -277,6 +529,14 @@ def change_own_password(password_data: PasswordChange, db: Session = Depends(get # Update password user.hashed_password = hash_password(password_data.new_password) + write_audit_log( + db, + action="auth.password.change.self", + entity_type="user", + entity_id=user.id, + actor=current_user, + details={"username": user.username}, + ) db.commit() return {"message": "Password changed successfully"} @@ -294,6 +554,14 @@ def admin_change_password(user_id: int, password_data: AdminPasswordChange, db: # Update password user.hashed_password = hash_password(password_data.new_password) + write_audit_log( + db, + action="auth.password.change.admin", + entity_type="user", + entity_id=user.id, + actor=current_user, + details={"username": user.username}, + ) db.commit() return {"message": "Password changed successfully"} @@ -310,8 +578,13 @@ def list_drugs(db: Session = Depends(get_db), current_user: User = Depends(get_c result = [] for drug in drugs: variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all() - drug_dict = drug.__dict__.copy() - drug_dict['variants'] = variants + drug_dict = { + "id": drug.id, + "name": drug.name, + "description": drug.description, + "is_controlled": bool(drug.is_controlled), + "variants": [enrich_variant_with_batches(db, v) for v in variants], + } result.append(drug_dict) return result @@ -330,8 +603,13 @@ def low_stock_drugs(db: Session = Depends(get_db), current_user: User = Depends( result = [] for drug in drugs: variants = [v for v in low_stock_variants if v.drug_id == drug.id] - drug_dict = drug.__dict__.copy() - drug_dict['variants'] = variants + drug_dict = { + "id": drug.id, + "name": drug.name, + "description": drug.description, + "is_controlled": bool(drug.is_controlled), + "variants": [enrich_variant_with_batches(db, v) for v in variants], + } result.append(drug_dict) return result @@ -343,8 +621,13 @@ def get_drug(drug_id: int, db: Session = Depends(get_db), current_user: User = D raise HTTPException(status_code=404, detail="Drug not found") variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all() - drug_dict = drug.__dict__.copy() - drug_dict['variants'] = variants + drug_dict = { + "id": drug.id, + "name": drug.name, + "description": drug.description, + "is_controlled": bool(drug.is_controlled), + "variants": [enrich_variant_with_batches(db, v) for v in variants], + } return drug_dict @router.post("/drugs", response_model=DrugWithVariantsResponse) @@ -355,14 +638,27 @@ def create_drug(drug: DrugCreate, db: Session = Depends(get_db), current_user: U if existing: raise HTTPException(status_code=400, detail="Drug with this name already exists") - db_drug = Drug(name=drug.name, description=drug.description) + db_drug = Drug(name=drug.name, description=drug.description, is_controlled=drug.is_controlled) db.add(db_drug) + write_audit_log( + db, + action="drug.create", + entity_type="drug", + entity_id=None, + actor=current_user, + details={"name": drug.name, "is_controlled": drug.is_controlled}, + ) db.commit() db.refresh(db_drug) # Return drug with empty variants list - drug_dict = db_drug.__dict__.copy() - drug_dict['variants'] = [] + drug_dict = { + "id": db_drug.id, + "name": db_drug.name, + "description": db_drug.description, + "is_controlled": bool(db_drug.is_controlled), + "variants": [], + } return drug_dict @router.put("/drugs/{drug_id}", response_model=DrugWithVariantsResponse) @@ -372,15 +668,34 @@ def update_drug(drug_id: int, drug_update: DrugUpdate, db: Session = Depends(get if not drug: raise HTTPException(status_code=404, detail="Drug not found") + before = { + "name": drug.name, + "description": drug.description, + "is_controlled": bool(drug.is_controlled), + } for field, value in drug_update.dict(exclude_unset=True).items(): setattr(drug, field, value) + + write_audit_log( + db, + action="drug.update", + entity_type="drug", + entity_id=drug.id, + actor=current_user, + details={"before": before, "after": drug_update.dict(exclude_unset=True)}, + ) db.commit() db.refresh(drug) variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all() - drug_dict = drug.__dict__.copy() - drug_dict['variants'] = variants + drug_dict = { + "id": drug.id, + "name": drug.name, + "description": drug.description, + "is_controlled": bool(drug.is_controlled), + "variants": [enrich_variant_with_batches(db, v) for v in variants], + } return drug_dict @router.delete("/drugs/{drug_id}") @@ -390,8 +705,23 @@ def delete_drug(drug_id: int, db: Session = Depends(get_db), current_user: User if not drug: raise HTTPException(status_code=404, detail="Drug not found") - # Delete all variants first - db.query(DrugVariant).filter(DrugVariant.drug_id == drug_id).delete() + variant_ids = [row[0] for row in db.query(DrugVariant.id).filter(DrugVariant.drug_id == drug_id).all()] + if variant_ids: + batch_ids = [row[0] for row in db.query(Batch.id).filter(Batch.drug_variant_id.in_(variant_ids)).all()] + if batch_ids: + db.query(DispensingAllocation).filter(DispensingAllocation.batch_id.in_(batch_ids)).delete(synchronize_session=False) + db.query(Batch).filter(Batch.id.in_(batch_ids)).delete(synchronize_session=False) + db.query(Dispensing).filter(Dispensing.drug_variant_id.in_(variant_ids)).delete(synchronize_session=False) + db.query(DrugVariant).filter(DrugVariant.id.in_(variant_ids)).delete(synchronize_session=False) + + write_audit_log( + db, + action="drug.delete", + entity_type="drug", + entity_id=drug_id, + actor=current_user, + details={"name": drug.name}, + ) # Delete the drug db.delete(drug) db.commit() @@ -423,9 +753,22 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session = low_stock_threshold=variant.low_stock_threshold ) db.add(db_variant) + write_audit_log( + db, + action="variant.create", + entity_type="drug_variant", + entity_id=None, + actor=current_user, + details={ + "drug_id": drug_id, + "strength": variant.strength, + "quantity": variant.quantity, + "unit": variant.unit, + }, + ) db.commit() db.refresh(db_variant) - return db_variant + return enrich_variant_with_batches(db, db_variant) @router.get("/variants/{variant_id}", response_model=DrugVariantResponse) def get_drug_variant(variant_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): @@ -433,7 +776,7 @@ def get_drug_variant(variant_id: int, db: Session = Depends(get_db), current_use variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first() if not variant: raise HTTPException(status_code=404, detail="Drug variant not found") - return variant + return enrich_variant_with_batches(db, variant) @router.put("/variants/{variant_id}", response_model=DrugVariantResponse) def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)): @@ -442,12 +785,27 @@ def update_drug_variant(variant_id: int, variant_update: DrugVariantUpdate, db: if not variant: raise HTTPException(status_code=404, detail="Drug variant not found") + before = { + "strength": variant.strength, + "quantity": variant.quantity, + "unit": variant.unit, + "low_stock_threshold": variant.low_stock_threshold, + } for field, value in variant_update.dict(exclude_unset=True).items(): setattr(variant, field, value) + + write_audit_log( + db, + action="variant.update", + entity_type="drug_variant", + entity_id=variant.id, + actor=current_user, + details={"before": before, "after": variant_update.dict(exclude_unset=True)}, + ) db.commit() db.refresh(variant) - return variant + return enrich_variant_with_batches(db, variant) @router.delete("/variants/{variant_id}") def delete_drug_variant(variant_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)): @@ -456,6 +814,20 @@ def delete_drug_variant(variant_id: int, db: Session = Depends(get_db), current_ if not variant: raise HTTPException(status_code=404, detail="Drug variant not found") + batch_ids = [row[0] for row in db.query(Batch.id).filter(Batch.drug_variant_id == variant_id).all()] + if batch_ids: + db.query(DispensingAllocation).filter(DispensingAllocation.batch_id.in_(batch_ids)).delete(synchronize_session=False) + db.query(Batch).filter(Batch.id.in_(batch_ids)).delete(synchronize_session=False) + + db.query(Dispensing).filter(Dispensing.drug_variant_id == variant_id).delete(synchronize_session=False) + write_audit_log( + db, + action="variant.delete", + entity_type="drug_variant", + entity_id=variant_id, + actor=current_user, + details={"drug_id": variant.drug_id, "strength": variant.strength}, + ) db.delete(variant) db.commit() @@ -465,33 +837,107 @@ def delete_drug_variant(variant_id: int, db: Session = Depends(get_db), current_ @router.post("/dispense", response_model=DispensingResponse) def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)): """Record a drug dispensing and reduce inventory""" + if dispensing.quantity <= 0: + raise HTTPException(status_code=400, detail="Quantity must be greater than zero") + # Check if drug variant exists variant = db.query(DrugVariant).filter(DrugVariant.id == dispensing.drug_variant_id).first() if not variant: raise HTTPException(status_code=404, detail="Drug variant not found") - # Check if enough quantity available + # Check if enough total quantity available from active stock (legacy and batch-based remain in sync). if variant.quantity < dispensing.quantity: raise HTTPException( - status_code=400, - detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispensing.quantity}" + status_code=400, + detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispensing.quantity}", ) - - # Reduce variant quantity - variant.quantity -= dispensing.quantity - db.commit() - - # Create dispensing record - db_dispensing = Dispensing(**dispensing.dict()) + + allocations = select_batches_for_dispense( + db, + variant_id=variant.id, + requested_quantity=dispensing.quantity, + preferred_batch_id=dispensing.batch_id, + allow_split=dispensing.allow_split, + ) + + user_name = dispensing.user_name or current_user.username + primary_batch_id = dispensing.batch_id if dispensing.batch_id is not None else allocations[0]["batch"].id + + db_dispensing = Dispensing( + drug_variant_id=dispensing.drug_variant_id, + batch_id=primary_batch_id, + actor_user_id=current_user.id, + quantity=dispensing.quantity, + animal_name=dispensing.animal_name, + user_name=user_name, + notes=dispensing.notes, + ) db.add(db_dispensing) + db.flush() + + allocation_payload = [] + for allocation in allocations: + batch = allocation["batch"] + qty = allocation["quantity"] + batch.quantity -= qty + allocation_payload.append({"batch_id": batch.id, "quantity": qty}) + db.add(DispensingAllocation(dispensing_id=db_dispensing.id, batch_id=batch.id, quantity=qty)) + + # Keep legacy variant quantity field in sync for existing frontend flows. + variant.quantity -= dispensing.quantity + + write_audit_log( + db, + action="dispense.create", + entity_type="dispensing", + entity_id=db_dispensing.id, + actor=current_user, + details={ + "drug_variant_id": dispensing.drug_variant_id, + "requested_quantity": dispensing.quantity, + "allocations": allocation_payload, + "animal_name": dispensing.animal_name, + "notes": dispensing.notes, + }, + ) + db.commit() db.refresh(db_dispensing) - return db_dispensing + return { + "id": db_dispensing.id, + "drug_variant_id": db_dispensing.drug_variant_id, + "batch_id": db_dispensing.batch_id, + "actor_user_id": db_dispensing.actor_user_id, + "quantity": db_dispensing.quantity, + "animal_name": db_dispensing.animal_name, + "user_name": db_dispensing.user_name, + "notes": db_dispensing.notes, + "dispensed_at": db_dispensing.dispensed_at, + "allocations": allocation_payload, + } @router.get("/dispense/history", response_model=List[DispensingResponse]) def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): """Get dispensing records (audit log)""" - return db.query(Dispensing).order_by(Dispensing.dispensed_at.desc()).offset(skip).limit(limit).all() + entries = db.query(Dispensing).order_by(Dispensing.dispensed_at.desc()).offset(skip).limit(limit).all() + result = [] + for item in entries: + allocations = db.query(DispensingAllocation).filter(DispensingAllocation.dispensing_id == item.id).all() + result.append( + { + "id": item.id, + "drug_variant_id": item.drug_variant_id, + "batch_id": item.batch_id, + "actor_user_id": item.actor_user_id, + "quantity": item.quantity, + "animal_name": item.animal_name, + "user_name": item.user_name, + "notes": item.notes, + "dispensed_at": item.dispensed_at, + "allocations": [{"batch_id": a.batch_id, "quantity": a.quantity} for a in allocations], + } + ) + return result @router.get("/drugs/{drug_id}/dispense/history", response_model=List[DispensingResponse]) def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): @@ -504,7 +950,25 @@ def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db), current_us # Get all variant IDs for this drug variant_ids = db.query(DrugVariant.id).filter(DrugVariant.drug_id == drug_id).subquery() - return db.query(Dispensing).filter(Dispensing.drug_variant_id.in_(variant_ids)).order_by(Dispensing.dispensed_at.desc()).all() + entries = db.query(Dispensing).filter(Dispensing.drug_variant_id.in_(variant_ids)).order_by(Dispensing.dispensed_at.desc()).all() + result = [] + for item in entries: + allocations = db.query(DispensingAllocation).filter(DispensingAllocation.dispensing_id == item.id).all() + result.append( + { + "id": item.id, + "drug_variant_id": item.drug_variant_id, + "batch_id": item.batch_id, + "actor_user_id": item.actor_user_id, + "quantity": item.quantity, + "animal_name": item.animal_name, + "user_name": item.user_name, + "notes": item.notes, + "dispensed_at": item.dispensed_at, + "allocations": [{"batch_id": a.batch_id, "quantity": a.quantity} for a in allocations], + } + ) + return result @router.get("/variants/{variant_id}/dispense/history", response_model=List[DispensingResponse]) def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): @@ -514,7 +978,512 @@ def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db), curr if not variant: raise HTTPException(status_code=404, detail="Drug variant not found") - return db.query(Dispensing).filter(Dispensing.drug_variant_id == variant_id).order_by(Dispensing.dispensed_at.desc()).all() + entries = db.query(Dispensing).filter(Dispensing.drug_variant_id == variant_id).order_by(Dispensing.dispensed_at.desc()).all() + result = [] + for item in entries: + allocations = db.query(DispensingAllocation).filter(DispensingAllocation.dispensing_id == item.id).all() + result.append( + { + "id": item.id, + "drug_variant_id": item.drug_variant_id, + "batch_id": item.batch_id, + "actor_user_id": item.actor_user_id, + "quantity": item.quantity, + "animal_name": item.animal_name, + "user_name": item.user_name, + "notes": item.notes, + "dispensed_at": item.dispensed_at, + "allocations": [{"batch_id": a.batch_id, "quantity": a.quantity} for a in allocations], + } + ) + return result + + +@router.get("/locations", response_model=List[LocationResponse]) +def list_locations(active_only: bool = True, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + query = db.query(Location) + if active_only: + query = query.filter(Location.is_active.is_(True)) + return query.order_by(Location.name.asc()).all() + + +@router.post("/locations", response_model=LocationResponse) +def create_location(location: LocationCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)): + cleaned_name = location.name.strip() + if not cleaned_name: + raise HTTPException(status_code=400, detail="Location name cannot be empty") + + existing = db.query(Location).filter(Location.name == cleaned_name).first() + if existing: + raise HTTPException(status_code=400, detail="Location with this name already exists") + + row = Location(name=cleaned_name, is_active=True) + db.add(row) + write_audit_log( + db, + action="location.create", + entity_type="location", + entity_id=None, + actor=current_user, + details={"name": row.name}, + ) + db.commit() + db.refresh(row) + return row + + +@router.put("/locations/{location_id}", response_model=LocationResponse) +def update_location(location_id: int, payload: LocationUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)): + location = db.query(Location).filter(Location.id == location_id).first() + if not location: + raise HTTPException(status_code=404, detail="Location not found") + + before = {"name": location.name, "is_active": location.is_active} + + if payload.name is not None: + cleaned_name = payload.name.strip() + if not cleaned_name: + raise HTTPException(status_code=400, detail="Location name cannot be empty") + dup = db.query(Location).filter(Location.name == cleaned_name, Location.id != location_id).first() + if dup: + raise HTTPException(status_code=400, detail="Another location already uses that name") + location.name = cleaned_name + + if payload.is_active is not None and payload.is_active is False: + stock_count = db.query(Batch).filter(Batch.location_id == location_id, Batch.quantity > 0).count() + if stock_count > 0: + raise HTTPException(status_code=400, detail="Cannot archive location while active stock remains") + location.is_active = False + elif payload.is_active is not None and payload.is_active is True: + location.is_active = True + + write_audit_log( + db, + action="location.update", + entity_type="location", + entity_id=location_id, + actor=current_user, + details={"before": before, "after": payload.dict(exclude_unset=True)}, + ) + db.commit() + db.refresh(location) + return location + + +@router.get("/variants/{variant_id}/batches", response_model=List[BatchResponse]) +def list_variant_batches(variant_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): + variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first() + if not variant: + raise HTTPException(status_code=404, detail="Drug variant not found") + + batches = ( + db.query(Batch) + .filter(Batch.drug_variant_id == variant_id) + .order_by(Batch.expiry_date.asc(), Batch.received_at.asc()) + .all() + ) + return [serialize_batch_response(db, batch) for batch in batches] + + +@router.post("/variants/{variant_id}/batches", response_model=BatchResponse) +def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)): + variant = db.query(DrugVariant).filter(DrugVariant.id == variant_id).first() + if not variant: + raise HTTPException(status_code=404, detail="Drug variant not found") + if payload.quantity <= 0: + raise HTTPException(status_code=400, detail="Batch quantity must be greater than zero") + + location = db.query(Location).filter(Location.id == payload.location_id, Location.is_active.is_(True)).first() + if not location: + raise HTTPException(status_code=400, detail="Location not found or inactive") + + batch_number = payload.batch_number.strip() + if not batch_number: + raise HTTPException(status_code=400, detail="Batch number cannot be empty") + + existing = ( + db.query(Batch) + .filter(Batch.drug_variant_id == variant_id, Batch.batch_number == batch_number) + .first() + ) + if existing: + raise HTTPException(status_code=400, detail="Batch number already exists for this variant") + + row = Batch( + drug_variant_id=variant_id, + batch_number=batch_number, + quantity=payload.quantity, + expiry_date=payload.expiry_date, + location_id=payload.location_id, + notes=payload.notes, + ) + db.add(row) + + variant.quantity += payload.quantity + + write_audit_log( + db, + action="batch.create", + entity_type="batch", + entity_id=None, + actor=current_user, + details={ + "variant_id": variant_id, + "batch_number": batch_number, + "quantity": payload.quantity, + "expiry_date": payload.expiry_date, + "location_id": payload.location_id, + }, + ) + db.commit() + db.refresh(row) + return serialize_batch_response(db, row) + + +@router.put("/batches/{batch_id}", response_model=BatchResponse) +def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)): + batch = db.query(Batch).filter(Batch.id == batch_id).first() + if not batch: + raise HTTPException(status_code=404, detail="Batch not found") + + variant = db.query(DrugVariant).filter(DrugVariant.id == batch.drug_variant_id).first() + if not variant: + raise HTTPException(status_code=404, detail="Parent variant not found") + + before = { + "batch_number": batch.batch_number, + "quantity": batch.quantity, + "expiry_date": batch.expiry_date, + "location_id": batch.location_id, + "notes": batch.notes, + } + + if payload.batch_number is not None: + cleaned_batch_number = payload.batch_number.strip() + if not cleaned_batch_number: + raise HTTPException(status_code=400, detail="Batch number cannot be empty") + duplicate = ( + db.query(Batch) + .filter( + Batch.drug_variant_id == batch.drug_variant_id, + Batch.batch_number == cleaned_batch_number, + Batch.id != batch.id, + ) + .first() + ) + if duplicate: + raise HTTPException(status_code=400, detail="Batch number already exists for this variant") + batch.batch_number = cleaned_batch_number + + if payload.location_id is not None: + location = db.query(Location).filter(Location.id == payload.location_id, Location.is_active.is_(True)).first() + if not location: + raise HTTPException(status_code=400, detail="Location not found or inactive") + batch.location_id = payload.location_id + + if payload.expiry_date is not None: + batch.expiry_date = payload.expiry_date + + if payload.notes is not None: + batch.notes = payload.notes + + if payload.quantity is not None: + if payload.quantity < 0: + raise HTTPException(status_code=400, detail="Batch quantity cannot be negative") + delta = payload.quantity - batch.quantity + projected_variant_qty = variant.quantity + delta + if projected_variant_qty < 0: + raise HTTPException(status_code=400, detail="Variant quantity cannot become negative") + batch.quantity = payload.quantity + variant.quantity = projected_variant_qty + + write_audit_log( + db, + action="batch.update", + entity_type="batch", + entity_id=batch_id, + actor=current_user, + details={"before": before, "after": payload.dict(exclude_unset=True)}, + ) + db.commit() + db.refresh(batch) + return serialize_batch_response(db, batch) + + +@router.get("/audit", response_model=List[Dict[str, Any]]) +def list_audit_events(skip: int = 0, limit: int = 200, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)): + events = db.query(AuditLog).order_by(AuditLog.created_at.desc()).offset(skip).limit(limit).all() + response = [] + for evt in events: + details = None + if evt.details: + try: + details = json.loads(evt.details) + except json.JSONDecodeError: + details = {"raw": evt.details} + response.append( + { + "id": evt.id, + "action": evt.action, + "entity_type": evt.entity_type, + "entity_id": evt.entity_id, + "actor_user_id": evt.actor_user_id, + "actor_username": evt.actor_username, + "details": details, + "created_at": evt.created_at, + } + ) + return response + + +def _csv_response(filename: str, headers: List[str], rows: List[List[Any]]) -> Response: + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(headers) + writer.writerows(rows) + return Response( + content=output.getvalue(), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@router.get("/reports/controlled-movement") +def report_controlled_movement( + from_date: Optional[date] = None, + to_date: Optional[date] = None, + format: str = "json", + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + query = ( + db.query(Dispensing, Drug, DrugVariant) + .join(DrugVariant, Dispensing.drug_variant_id == DrugVariant.id) + .join(Drug, DrugVariant.drug_id == Drug.id) + .filter(Drug.is_controlled.is_(True)) + ) + + if from_date is not None: + query = query.filter(Dispensing.dispensed_at >= datetime.combine(from_date, datetime.min.time())) + if to_date is not None: + query = query.filter(Dispensing.dispensed_at < datetime.combine(to_date + timedelta(days=1), datetime.min.time())) + + rows = query.order_by(Dispensing.dispensed_at.desc()).all() + result = [ + { + "dispensing_id": d.id, + "dispensed_at": d.dispensed_at, + "drug_name": drug.name, + "strength": variant.strength, + "quantity": d.quantity, + "user_name": d.user_name, + "animal_name": d.animal_name, + "batch_id": d.batch_id, + } + for d, drug, variant in rows + ] + + if format.lower() == "csv": + csv_rows = [ + [ + item["dispensing_id"], + item["dispensed_at"], + item["drug_name"], + item["strength"], + item["quantity"], + item["user_name"], + item["animal_name"], + item["batch_id"], + ] + for item in result + ] + return _csv_response( + "controlled_movement.csv", + ["dispensing_id", "dispensed_at", "drug_name", "strength", "quantity", "user_name", "animal_name", "batch_id"], + csv_rows, + ) + + return result + + +@router.get("/reports/batch-expiry") +def report_batch_expiry( + days: int = 30, + format: str = "json", + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if days < 0: + raise HTTPException(status_code=400, detail="Days must be non-negative") + + today = date.today() + cutoff = today + timedelta(days=days) + + rows = ( + db.query(Batch, DrugVariant, Drug, Location) + .join(DrugVariant, Batch.drug_variant_id == DrugVariant.id) + .join(Drug, DrugVariant.drug_id == Drug.id) + .join(Location, Batch.location_id == Location.id) + .filter(Batch.quantity > 0, Batch.expiry_date <= cutoff) + .order_by(Batch.expiry_date.asc()) + .all() + ) + + result = [] + for batch, variant, drug, location in rows: + status_label = "expired" if batch.expiry_date < today else "expiring" + result.append( + { + "batch_id": batch.id, + "batch_number": batch.batch_number, + "drug_name": drug.name, + "strength": variant.strength, + "quantity": batch.quantity, + "location": location.name, + "expiry_date": batch.expiry_date, + "status": status_label, + "is_controlled": bool(drug.is_controlled), + } + ) + + if format.lower() == "csv": + csv_rows = [ + [ + item["batch_id"], + item["batch_number"], + item["drug_name"], + item["strength"], + item["quantity"], + item["location"], + item["expiry_date"], + item["status"], + item["is_controlled"], + ] + for item in result + ] + return _csv_response( + "batch_expiry.csv", + ["batch_id", "batch_number", "drug_name", "strength", "quantity", "location", "expiry_date", "status", "is_controlled"], + csv_rows, + ) + + return result + + +@router.get("/reports/stock-by-location") +def report_stock_by_location( + format: str = "json", + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + rows = ( + db.query(Location, Batch, DrugVariant, Drug) + .join(Batch, Batch.location_id == Location.id) + .join(DrugVariant, Batch.drug_variant_id == DrugVariant.id) + .join(Drug, DrugVariant.drug_id == Drug.id) + .filter(Batch.quantity > 0) + .order_by(Location.name.asc(), Drug.name.asc(), DrugVariant.strength.asc()) + .all() + ) + + result = [ + { + "location_id": location.id, + "location_name": location.name, + "batch_id": batch.id, + "batch_number": batch.batch_number, + "drug_name": drug.name, + "strength": variant.strength, + "quantity": batch.quantity, + "unit": variant.unit, + "expiry_date": batch.expiry_date, + "is_controlled": bool(drug.is_controlled), + } + for location, batch, variant, drug in rows + ] + + if format.lower() == "csv": + csv_rows = [ + [ + item["location_id"], + item["location_name"], + item["batch_id"], + item["batch_number"], + item["drug_name"], + item["strength"], + item["quantity"], + item["unit"], + item["expiry_date"], + item["is_controlled"], + ] + for item in result + ] + return _csv_response( + "stock_by_location.csv", + ["location_id", "location_name", "batch_id", "batch_number", "drug_name", "strength", "quantity", "unit", "expiry_date", "is_controlled"], + csv_rows, + ) + + return result + + +@router.get("/reports/audit-trail") +def report_audit_trail( + from_date: Optional[date] = None, + to_date: Optional[date] = None, + format: str = "json", + db: Session = Depends(get_db), + current_user: User = Depends(get_current_admin_user), +): + query = db.query(AuditLog) + if from_date is not None: + query = query.filter(AuditLog.created_at >= datetime.combine(from_date, datetime.min.time())) + if to_date is not None: + query = query.filter(AuditLog.created_at < datetime.combine(to_date + timedelta(days=1), datetime.min.time())) + + rows = query.order_by(AuditLog.created_at.desc()).all() + result = [] + for evt in rows: + details = None + if evt.details: + try: + details = json.loads(evt.details) + except json.JSONDecodeError: + details = {"raw": evt.details} + result.append( + { + "id": evt.id, + "created_at": evt.created_at, + "action": evt.action, + "entity_type": evt.entity_type, + "entity_id": evt.entity_id, + "actor_user_id": evt.actor_user_id, + "actor_username": evt.actor_username, + "details": details, + } + ) + + if format.lower() == "csv": + csv_rows = [ + [ + item["id"], + item["created_at"], + item["action"], + item["entity_type"], + item["entity_id"], + item["actor_user_id"], + item["actor_username"], + json.dumps(item["details"], default=str) if item["details"] is not None else "", + ] + for item in result + ] + return _csv_response( + "audit_trail.csv", + ["id", "created_at", "action", "entity_type", "entity_id", "actor_user_id", "actor_username", "details"], + csv_rows, + ) + + return result # Helper function to capitalize text for labels def capitalize_label_text(text: str) -> str: diff --git a/backend/app/migrate_compliance.py b/backend/app/migrate_compliance.py new file mode 100644 index 0000000..4c85467 --- /dev/null +++ b/backend/app/migrate_compliance.py @@ -0,0 +1,83 @@ +""" +Compliance schema migration helpers. + +This module applies additive migrations for SQLite databases used by this project. +It is intentionally lightweight and idempotent because the project does not yet +use Alembic-style versioned migrations. +""" + +import os +import sqlite3 +from pathlib import Path + + +DEFAULT_DB_URL = "sqlite:///./data/drugs.db" + + +def _resolve_sqlite_path(db_url: str) -> Path | None: + if not db_url.startswith("sqlite:///"): + print(f"Unsupported database URL for compliance migration: {db_url}") + return None + + raw_path = db_url.replace("sqlite:///", "") + if raw_path.startswith("/"): + return Path(raw_path) + return Path(raw_path) + + +def _column_exists(cursor: sqlite3.Cursor, table_name: str, column_name: str) -> bool: + cursor.execute(f"PRAGMA table_info({table_name})") + columns = [row[1] for row in cursor.fetchall()] + return column_name in columns + + +def _table_exists(cursor: sqlite3.Cursor, table_name: str) -> bool: + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + (table_name,), + ) + return cursor.fetchone() is not None + + +def migrate_compliance_schema() -> None: + """Apply additive schema changes needed for compliance features.""" + db_url = os.getenv("DATABASE_URL", DEFAULT_DB_URL) + db_path = _resolve_sqlite_path(db_url) + if db_path is None: + return + + if not db_path.exists(): + print(f"Database does not exist at {db_path}, skipping compliance migration") + return + + print(f"Running compliance migration on {db_path}") + + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + try: + if _table_exists(cursor, "drugs") and not _column_exists(cursor, "drugs", "is_controlled"): + cursor.execute("ALTER TABLE drugs ADD COLUMN is_controlled BOOLEAN NOT NULL DEFAULT 0") + print("Added drugs.is_controlled") + + if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "batch_id"): + cursor.execute("ALTER TABLE dispensings ADD COLUMN batch_id INTEGER") + print("Added dispensings.batch_id") + + if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "actor_user_id"): + cursor.execute("ALTER TABLE dispensings ADD COLUMN actor_user_id INTEGER") + print("Added dispensings.actor_user_id") + + # Seed default locations once table exists (created via SQLAlchemy create_all). + if _table_exists(cursor, "locations"): + cursor.execute("INSERT OR IGNORE INTO locations(name, is_active) VALUES ('Cupboard', 1)") + cursor.execute("INSERT OR IGNORE INTO locations(name, is_active) VALUES ('Fridge', 1)") + print("Ensured default locations exist") + + conn.commit() + print("Compliance migration completed") + except sqlite3.Error as exc: + conn.rollback() + raise RuntimeError(f"Compliance migration failed: {exc}") from exc + finally: + conn.close() diff --git a/backend/app/models.py b/backend/app/models.py index dbff482..5c37fb5 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean +from sqlalchemy import Column, Integer, String, Float, DateTime, Date, ForeignKey, Boolean, Text from sqlalchemy.sql import func from .database import Base @@ -18,6 +18,7 @@ class Drug(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String, unique=True, index=True, nullable=False) description = Column(String, nullable=True) + is_controlled = Column(Boolean, nullable=False, default=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now()) @@ -40,8 +41,57 @@ class Dispensing(Base): id = Column(Integer, primary_key=True, index=True) drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False) + batch_id = Column(Integer, ForeignKey("batches.id"), nullable=True) + actor_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) quantity = Column(Float, nullable=False) animal_name = Column(String, nullable=True) # Name/ID of the animal (optional) user_name = Column(String, nullable=False) # User who dispensed dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) notes = Column(String, nullable=True) + + +class Location(Base): + __tablename__ = "locations" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, unique=True, index=True, nullable=False) + is_active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now()) + + +class Batch(Base): + __tablename__ = "batches" + + id = Column(Integer, primary_key=True, index=True) + drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True) + batch_number = Column(String, nullable=False, index=True) + quantity = Column(Float, nullable=False, default=0) + expiry_date = Column(Date, nullable=False, index=True) + location_id = Column(Integer, ForeignKey("locations.id"), nullable=False, index=True) + received_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now(), index=True) + notes = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), server_default=func.now()) + + +class DispensingAllocation(Base): + __tablename__ = "dispensing_allocations" + + id = Column(Integer, primary_key=True, index=True) + dispensing_id = Column(Integer, ForeignKey("dispensings.id"), nullable=False, index=True) + batch_id = Column(Integer, ForeignKey("batches.id"), nullable=False, index=True) + quantity = Column(Float, nullable=False) + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, index=True) + action = Column(String, nullable=False, index=True) + entity_type = Column(String, nullable=False, index=True) + entity_id = Column(Integer, nullable=True, index=True) + actor_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) + actor_username = Column(String, nullable=False, index=True) + details = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) diff --git a/frontend/app.js b/frontend/app.js index ae085ec..3d6d977 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -2,6 +2,7 @@ const API_URL = '/api'; let allDrugs = []; let currentDrug = null; let showLowStockOnly = false; +let selectedLocationFilter = ''; let searchTerm = ''; let expandedDrugs = new Set(); let currentUser = null; @@ -91,6 +92,11 @@ function showMainApp() { adminBtn.style.display = currentUser.role === 'admin' ? 'block' : 'none'; } + const locationsBtn = document.getElementById('locationsBtn'); + if (locationsBtn) { + locationsBtn.style.display = currentUser.role === 'admin' ? 'block' : 'none'; + } + // Hide action buttons for read-only users const isReadOnly = currentUser.role === 'readonly'; const addDrugBtn = document.getElementById('addDrugBtn'); @@ -200,6 +206,7 @@ function setupEventListeners() { const prescribeModal = document.getElementById('prescribeModal'); const editModal = document.getElementById('editModal'); const printNotesModal = document.getElementById('printNotesModal'); + const batchReceiveModal = document.getElementById('batchReceiveModal'); const addDrugBtn = document.getElementById('addDrugBtn'); const dispenseBtn = document.getElementById('dispenseBtn'); const printNotesBtn = document.getElementById('printNotesBtn'); @@ -209,10 +216,13 @@ function setupEventListeners() { const cancelDispenseBtn = document.getElementById('cancelDispenseBtn'); const cancelPrescribeBtn = document.getElementById('cancelPrescribeBtn'); const cancelEditBtn = document.getElementById('cancelEditBtn'); + const cancelBatchReceiveBtn = document.getElementById('cancelBatchReceiveBtn'); const showAllBtn = document.getElementById('showAllBtn'); const showLowStockBtn = document.getElementById('showLowStockBtn'); + const locationFilterSelect = document.getElementById('locationFilterSelect'); const userMenuBtn = document.getElementById('userMenuBtn'); const adminBtn = document.getElementById('adminBtn'); + const locationsBtn = document.getElementById('locationsBtn'); const logoutBtn = document.getElementById('logoutBtn'); const changePasswordBtn = document.getElementById('changePasswordBtn'); @@ -227,6 +237,10 @@ function setupEventListeners() { if (editForm) editForm.addEventListener('submit', handleEditDrug); if (printNotesForm) printNotesForm.addEventListener('submit', handlePrintNotes); + const batchReceiveForm = document.getElementById('batchReceiveForm'); + if (batchReceiveForm) batchReceiveForm.addEventListener('submit', handleBatchReceive); + if (cancelBatchReceiveBtn) cancelBatchReceiveBtn.addEventListener('click', () => closeModal(batchReceiveModal)); + if (addDrugBtn) addDrugBtn.addEventListener('click', () => openModal(addModal)); if (printNotesBtn) printNotesBtn.addEventListener('click', () => openModal(printNotesModal)); if (dispenseBtn) dispenseBtn.addEventListener('click', () => { @@ -250,6 +264,12 @@ function setupEventListeners() { const closeUserManagementBtn = document.getElementById('closeUserManagementBtn'); if (closeUserManagementBtn) closeUserManagementBtn.addEventListener('click', () => closeModal(document.getElementById('userManagementModal'))); + const closeLocationManagementBtn = document.getElementById('closeLocationManagementBtn'); + if (closeLocationManagementBtn) closeLocationManagementBtn.addEventListener('click', () => closeModal(document.getElementById('locationManagementModal'))); + + const createLocationForm = document.getElementById('createLocationForm'); + if (createLocationForm) createLocationForm.addEventListener('submit', createLocation); + const changePasswordForm = document.getElementById('changePasswordForm'); if (changePasswordForm) changePasswordForm.addEventListener('submit', handleChangePassword); @@ -277,6 +297,10 @@ function setupEventListeners() { updateFilterButtons(); renderDrugs(); }); + if (locationFilterSelect) locationFilterSelect.addEventListener('change', (e) => { + selectedLocationFilter = e.target.value; + renderDrugs(); + }); // User menu if (userMenuBtn) userMenuBtn.addEventListener('click', () => { @@ -286,6 +310,7 @@ function setupEventListeners() { if (changePasswordBtn) changePasswordBtn.addEventListener('click', openChangePasswordModal); if (adminBtn) adminBtn.addEventListener('click', openUserManagement); + if (locationsBtn) locationsBtn.addEventListener('click', openLocationManagement); if (logoutBtn) logoutBtn.addEventListener('click', handleLogout); // Search functionality @@ -315,7 +340,8 @@ async function loadDrugs() { const response = await apiCall('/drugs'); if (!response.ok) throw new Error('Failed to load drugs'); allDrugs = await response.json(); - + + updateLocationFilterOptions(); renderDrugs(); updateDispenseDrugSelect(); } catch (error) { @@ -367,6 +393,262 @@ function updateDispenseDrugSelect() { }); } +function formatDisplayDate(value) { + if (!value) { + return 'Unknown'; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return value; + } + + return parsed.toLocaleDateString(); +} + +function getBatchLocationLabel(batch) { + return batch.location_name || batch.location?.name || `Location #${batch.location_id}`; +} + +function updateLocationFilterOptions() { + const locationFilterSelect = document.getElementById('locationFilterSelect'); + if (!locationFilterSelect) return; + + const previousValue = selectedLocationFilter; + const locations = new Set(); + + allDrugs.forEach(drug => { + drug.variants.forEach(variant => { + (variant.batches || []).forEach(batch => { + if (batch.quantity > 0) { + locations.add(getBatchLocationLabel(batch)); + } + }); + }); + }); + + locationFilterSelect.innerHTML = ''; + Array.from(locations) + .sort((a, b) => a.localeCompare(b)) + .forEach(location => { + const option = document.createElement('option'); + option.value = location; + option.textContent = location; + locationFilterSelect.appendChild(option); + }); + + if (previousValue && locations.has(previousValue)) { + selectedLocationFilter = previousValue; + locationFilterSelect.value = previousValue; + } else { + selectedLocationFilter = ''; + locationFilterSelect.value = ''; + } +} + +function populateDispenseBatchSelect(activeBatches) { + const batchSelect = document.getElementById('dispenseBatchSelect'); + const previousValue = batchSelect.value; + + batchSelect.innerHTML = ''; + + activeBatches.forEach((batch, index) => { + const option = document.createElement('option'); + const expiryLabel = formatDisplayDate(batch.expiry_date); + const locationLabel = getBatchLocationLabel(batch); + const fefoLabel = index === 0 ? ' [FEFO default]' : ''; + option.value = batch.id; + option.textContent = `${batch.batch_number} | ${batch.quantity} units | ${locationLabel} | Expires ${expiryLabel}${fefoLabel}`; + batchSelect.appendChild(option); + }); + + if (previousValue && activeBatches.some(batch => String(batch.id) === previousValue)) { + batchSelect.value = previousValue; + } +} + +// Update batch info display when variant is selected +async function updateBatchInfo() { + const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); + const batchInfoSection = document.getElementById('batchInfoSection'); + const batchInfoContent = document.getElementById('batchInfoContent'); + const batchSelect = document.getElementById('dispenseBatchSelect'); + + if (!variantId) { + batchInfoSection.style.display = 'none'; + batchSelect.innerHTML = ''; + return; + } + + batchInfoSection.style.display = 'block'; + batchInfoContent.innerHTML = '
Loading batches...
'; + + try { + const response = await apiCall(`/variants/${variantId}/batches`); + if (!response.ok) throw new Error('Failed to load batches'); + + const batches = await response.json(); + + // Filter out empty batches + const activeBatches = batches.filter(b => b.quantity > 0); + + if (activeBatches.length === 0) { + populateDispenseBatchSelect([]); + batchInfoContent.innerHTML = '⚠️ No active batches available for this variant
'; + return; + } + + // Sort by expiry date (FEFO order) + activeBatches.sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date)); + populateDispenseBatchSelect(activeBatches); + + const batchHtml = activeBatches.map((batch, index) => { + const expiryDate = new Date(batch.expiry_date); + const locationLabel = getBatchLocationLabel(batch); + const expiryLabel = formatDisplayDate(batch.expiry_date); + const today = new Date(); + const isExpired = expiryDate < today; + const daysToExpiry = Math.ceil((expiryDate - today) / (1000 * 60 * 60 * 24)); + + let expiryStatus = '✓ OK'; + let statusColor = '#4caf50'; + if (isExpired) { + expiryStatus = '✕ EXPIRED'; + statusColor = '#d32f2f'; + } else if (daysToExpiry <= 7) { + expiryStatus = `⚠️ ${daysToExpiry}d left`; + statusColor = '#ff9800'; + } + + const isFEFO = index === 0; + + return ` +Error loading batches
'; + } + + // Update allocation preview when batches load + updateAllocationPreview(); +} + +// Update allocation preview based on quantity and allow_split flag +async function updateAllocationPreview() { + const variantId = parseInt(document.getElementById('dispenseDrugSelect').value); + const quantity = parseFloat(document.getElementById('dispenseQuantity').value); + const allowSplit = document.getElementById('dispenseAllowSplit').checked; + const preferredBatchId = parseInt(document.getElementById('dispenseBatchSelect').value); + const allocationPreviewSection = document.getElementById('allocationPreviewSection'); + const allocationPreviewContent = document.getElementById('allocationPreviewContent'); + + if (!variantId || isNaN(quantity) || quantity <= 0) { + allocationPreviewSection.style.display = 'none'; + return; + } + + allocationPreviewSection.style.display = 'block'; + allocationPreviewContent.innerHTML = 'Calculating allocation...
'; + + try { + const response = await apiCall(`/variants/${variantId}/batches`); + if (!response.ok) throw new Error('Failed to load batches'); + + const batches = await response.json(); + let activeBatches = batches.filter(b => b.quantity > 0) + .sort((a, b) => new Date(a.expiry_date) - new Date(b.expiry_date)); + + if (activeBatches.length === 0) { + allocationPreviewContent.innerHTML = '⚠️ No active batches available
'; + return; + } + + if (!Number.isNaN(preferredBatchId)) { + const preferredBatch = activeBatches.find(batch => batch.id === preferredBatchId); + if (!preferredBatch) { + allocationPreviewContent.innerHTML = '✕ Selected preferred batch is no longer available.
'; + return; + } + + activeBatches = [preferredBatch, ...activeBatches.filter(batch => batch.id !== preferredBatchId)]; + } + + // Simulate FEFO allocation + const allocations = []; + let remainingQty = quantity; + + for (const batch of activeBatches) { + if (remainingQty <= 0) break; + + const allocQty = Math.min(remainingQty, batch.quantity); + allocations.push({ + batchNumber: batch.batch_number, + batchId: batch.id, + quantity: allocQty, + location: getBatchLocationLabel(batch), + expiryDate: batch.expiry_date, + preferred: !Number.isNaN(preferredBatchId) && batch.id === preferredBatchId + }); + remainingQty -= allocQty; + + if (!allowSplit) break; + } + + if (remainingQty > 0 && !allowSplit) { + const failureContext = !Number.isNaN(preferredBatchId) + ? 'Preferred batch cannot fully satisfy this request. Enable split to fall through to FEFO batches.' + : 'Insufficient stock in first batch. Check "Allow Split" to use multiple batches.'; + allocationPreviewContent.innerHTML = `✕ ${failureContext}
`; + return; + } + + if (remainingQty > 0 && allowSplit) { + allocationPreviewContent.innerHTML = ` +✕ Warning: Only ${quantity - remainingQty} units available across all batches (${remainingQty} short)
+${introText}
+Error calculating allocation
'; + } +} + // Render drugs list function renderDrugs() { const drugsList = document.getElementById('drugsList'); @@ -388,6 +670,17 @@ function renderDrugs() { ); } + // Apply location filter + if (selectedLocationFilter) { + drugsToShow = drugsToShow.filter(drug => + drug.variants.some(variant => + (variant.batches || []).some(batch => + batch.quantity > 0 && getBatchLocationLabel(batch) === selectedLocationFilter + ) + ) + ); + } + // Sort alphabetically by drug name drugsToShow = drugsToShow.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()) @@ -405,6 +698,7 @@ function renderDrugs() { const isLowStock = lowStockVariants > 0; const isExpanded = expandedDrugs.has(drug.id); const isReadOnly = currentUser.role === 'readonly'; + const isControlled = drug.is_controlled; const variantsHtml = isExpanded ? ` ${drug.variants.map(variant => { @@ -424,6 +718,7 @@ function renderDrugs() {