Compare commits

...

17 Commits

Author SHA1 Message Date
jamesp 0026ac2274 Backup script 2026-06-19 17:42:28 +01:00
jamesp b093e1a90c Disposal 2026-06-19 17:21:07 +01:00
jamesp 25c3f1fa64 Scroll wheel fix again 2026-06-18 20:20:03 +01:00
jamesp 3f230bb0d7 Stock Take PDF 2026-06-18 20:15:24 +01:00
jamesp 0fea301af1 Scroll wheel fix 2026-05-19 15:58:56 -04:00
jamesp 562494a967 Couple of prod fixes 2026-04-26 16:19:13 -04:00
jamesp 05a093afd3 Dispensing Vet 2026-04-26 16:02:42 -04:00
jamesp 9ec27e245a Scan shortcut on main screen 2026-04-20 14:28:21 -04:00
jamesp 36634dc2bf Refactor - API lazy loading 2026-04-20 14:12:11 -04:00
jamesp 6be571a48c GS1 scanning and workflow improvements 2026-04-20 12:43:29 -04:00
jamesp cfb08bd288 Barcode scanning and GTIN mapping 2026-04-16 15:32:36 -04:00
jamesp 2aeba2f563 Add new pack size modal 2026-04-15 06:39:54 -04:00
jamesp d4753c0754 Ship it 2026-04-12 06:31:03 -04:00
jamesp 225202aacb Minor UI tweaks 2026-04-12 05:37:57 -04:00
jamesp 4673de4ae5 Add drug 2026-04-06 11:26:51 -04:00
jamesp cdbf613e4b Remove old Print button 2026-04-06 11:18:35 -04:00
jamesp 36f0a5b07e Reporting and batch management 2026-04-06 11:04:06 -04:00
14 changed files with 3674 additions and 423 deletions
+1
View File
@@ -20,3 +20,4 @@ node_modules/
.cache/
.pytest_cache/
drugs.db
data/backups/
+32 -3
View File
@@ -77,9 +77,38 @@ docker-compose up --build
## Database
SQLite database is stored as `drugs.db` in the project root. It's a single file that persists between container restarts. You can:
- Backup by copying the file
- Share with team members
SQLite database is stored as `./data/drugs.db`. It's a single file that persists between container restarts.
### Backups
The Docker Compose stack includes a `backup` sidecar that creates a SQLite-safe compressed backup every hour and keeps backups for 7 days.
Backups are stored in:
```bash
./data/backups/
```
The latest backup is also copied to:
```bash
./data/backups/latest.db.gz
```
To restore a backup:
```bash
docker compose stop backend backup
gunzip -c data/backups/drugs-YYYY-MM-DDTHH-MM-SSZ.db.gz > data/drugs.db
docker compose up -d backend backup
```
Backup interval and retention can be changed with:
```bash
BACKUP_INTERVAL_SECONDS=3600
BACKUP_RETENTION_DAYS=7
```
## Configuration
+590 -40
View File
@@ -1,6 +1,7 @@
from fastapi import FastAPI, Depends, HTTPException, APIRouter, status, Response
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta, date
import math
@@ -18,11 +19,13 @@ from .models import (
Batch,
AuditLog,
User,
GtinMapping,
)
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 .migrate_gtin import migrate_gtin_schema
from pydantic import BaseModel
# Run migration to convert is_admin to role
@@ -39,6 +42,11 @@ except Exception as e:
# Create tables
Base.metadata.create_all(bind=engine)
try:
migrate_gtin_schema()
except Exception as e:
print(f"Warning: GTIN migration failed: {e}. Continuing anyway...")
# Seed default locations after table creation.
try:
migrate_compliance_schema()
@@ -65,6 +73,9 @@ class UserCreate(BaseModel):
password: str
role: Optional[str] = "user" # admin, user, readonly
class UserRoleUpdate(BaseModel):
role: str
class PasswordChange(BaseModel):
current_password: str
new_password: str
@@ -138,14 +149,37 @@ class BatchDisposeRequest(BaseModel):
notes: Optional[str] = None
class GtinMappingCreate(BaseModel):
gtin: str
drug_variant_id: int
variant_pack_id: int
class GtinMappingResponse(BaseModel):
id: int
gtin: str
drug_variant_id: int
variant_pack_id: int
drug_id: int
drug_name: str
variant_strength: str
variant_unit: str
pack_label: str
pack_size_in_base_units: float
pack_unit_name: str
class Config:
from_attributes = True
class BatchResponse(BaseModel):
id: int
drug_variant_id: int
batch_number: str
quantity: float
received_pack_id: Optional[int] = None
received_pack_label: Optional[str] = None
received_pack_count: Optional[float] = None
received_pack_unit_name: Optional[str] = None
received_pack_size_snapshot: Optional[float] = None
current_full_pack_count: Optional[float] = None
current_loose_base_units: Optional[float] = None
@@ -187,14 +221,12 @@ class DrugVariantUpdate(BaseModel):
class VariantPackCreate(BaseModel):
label: str
pack_unit_name: str
pack_size_in_base_units: float
is_active: bool = True
class VariantPackUpdate(BaseModel):
label: Optional[str] = None
pack_unit_name: Optional[str] = None
pack_size_in_base_units: Optional[float] = None
is_active: Optional[bool] = None
@@ -203,7 +235,6 @@ class VariantPackUpdate(BaseModel):
class VariantPackResponse(BaseModel):
id: int
drug_variant_id: int
label: str
pack_unit_name: str
pack_size_in_base_units: float
is_active: bool
@@ -236,18 +267,59 @@ class DrugWithVariantsResponse(BaseModel):
class Config:
from_attributes = True
class VariantSummaryResponse(BaseModel):
"""Lightweight variant summary returned by GET /drugs list — no packs or batches."""
id: int
drug_id: int
strength: str
quantity: float
unit: str
low_stock_threshold: float
has_inventory_history: bool = False
expired_quantity: float = 0.0
class Config:
from_attributes = True
class DrugSummaryResponse(BaseModel):
"""Lightweight drug summary for the main list — variants without packs or batches."""
id: int
name: str
description: Optional[str] = None
is_controlled: bool = False
locations: List[str] = []
variants: List[VariantSummaryResponse] = []
class Config:
from_attributes = True
class DispensingAllocationCreate(BaseModel):
batch_id: int
quantity: float
class InventoryDisposeRequest(BaseModel):
drug_variant_id: int
quantity: Optional[float] = None
dispense_mode: str = "subunit"
dispense_source: str = "batch"
requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None
notes: Optional[str] = None
allocations: List[DispensingAllocationCreate] = []
class DispensingCreate(BaseModel):
drug_variant_id: int
quantity: Optional[float] = None
dispense_mode: str = "subunit"
dispense_source: str = "batch"
requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None
animal_name: Optional[str] = None
prescribing_vet: Optional[str] = None
user_name: Optional[str] = None
notes: Optional[str] = None
allocations: List[DispensingAllocationCreate] = []
@@ -270,6 +342,7 @@ class DispensingResponse(BaseModel):
requested_pack_id: Optional[int] = None
requested_pack_count: Optional[float] = None
animal_name: Optional[str] = None
prescribing_vet: Optional[str] = None
user_name: str
notes: Optional[str] = None
dispensed_at: datetime
@@ -371,6 +444,40 @@ def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str,
return variant_dict
def serialize_variant_with_packs(db: Session, variant: DrugVariant) -> Dict[str, Any]:
"""Return variant data with packs but without batch details (level-2 detail)."""
has_batch_history = (
db.query(Batch.id)
.filter(Batch.drug_variant_id == variant.id)
.first()
is not None
)
has_dispense_history = (
db.query(Dispensing.id)
.filter(Dispensing.drug_variant_id == variant.id)
.first()
is not None
)
packs = (
db.query(VariantPack)
.filter(VariantPack.drug_variant_id == variant.id)
.order_by(VariantPack.is_active.desc(), VariantPack.id.asc())
.all()
)
return {
"id": variant.id,
"drug_id": variant.drug_id,
"strength": variant.strength,
"quantity": variant.quantity,
"unit": variant.unit,
"base_unit": variant.unit,
"low_stock_threshold": variant.low_stock_threshold,
"has_inventory_history": has_batch_history or has_dispense_history,
"packs": [serialize_variant_pack(pack) for pack in packs],
"batches": [],
}
def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
location = db.query(Location).filter(Location.id == batch.location_id).first()
pack = None
@@ -382,7 +489,7 @@ def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
"batch_number": batch.batch_number,
"quantity": batch.quantity,
"received_pack_id": batch.received_pack_id,
"received_pack_label": pack.label if pack else None,
"received_pack_unit_name": pack.pack_unit_name if pack else None,
"received_pack_count": batch.received_pack_count,
"received_pack_size_snapshot": batch.received_pack_size_snapshot,
"current_full_pack_count": batch.current_full_pack_count,
@@ -428,7 +535,6 @@ def serialize_variant_pack(pack: VariantPack) -> Dict[str, Any]:
return {
"id": pack.id,
"drug_variant_id": pack.drug_variant_id,
"label": pack.label,
"pack_unit_name": pack.pack_unit_name,
"pack_size_in_base_units": pack.pack_size_in_base_units,
"is_active": pack.is_active,
@@ -496,12 +602,16 @@ def resolve_requested_allocations(
requested_quantity: float,
requested_allocations: List[DispensingAllocationCreate],
dispense_mode: str,
dispense_source: str,
requested_pack_id: Optional[int],
) -> List[Dict[str, Any]]:
"""Validate explicit batch allocations against in-date stock for the variant."""
today = date.today()
total_batched_quantity = sum(float(batch.quantity or 0) for batch in db.query(Batch).filter(Batch.drug_variant_id == variant_id).all())
legacy_unbatched_quantity = max(0.0, float(variant_quantity or 0) - total_batched_quantity)
selected_source = (dispense_source or "batch").strip().lower()
if selected_source not in {"batch", "legacy"}:
raise HTTPException(status_code=400, detail="dispense_source must be either 'batch' or 'legacy'")
eligible_batches = (
db.query(Batch)
.filter(
@@ -513,6 +623,20 @@ def resolve_requested_allocations(
.all()
)
if selected_source == "legacy":
if dispense_mode == "pack":
raise HTTPException(status_code=400, detail="Whole-pack dispensing requires batched stock with pack information")
if requested_allocations:
raise HTTPException(status_code=400, detail="Batch allocations cannot be supplied when dispensing legacy stock")
if legacy_unbatched_quantity <= 0:
raise HTTPException(status_code=400, detail="No legacy loose stock is available for this variant")
if requested_quantity - legacy_unbatched_quantity > 1e-6:
raise HTTPException(
status_code=400,
detail=f"Insufficient unbatched stock. Available: {legacy_unbatched_quantity}, Requested: {requested_quantity}",
)
return []
if not eligible_batches:
if dispense_mode == "pack":
raise HTTPException(status_code=400, detail="Whole-pack dispensing requires batched stock with pack information")
@@ -766,6 +890,34 @@ def delete_user(user_id: int, db: Session = Depends(get_db), current_user: User
db.commit()
return {"message": "User deleted successfully"}
@router.patch("/users/{user_id}/role", response_model=UserResponse)
def update_user_role(user_id: int, role_data: UserRoleUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
"""Update a user's role (admin only)"""
if current_user.id == user_id:
raise HTTPException(status_code=400, detail="Cannot change your own role")
valid_roles = ["admin", "user", "readonly"]
if role_data.role not in valid_roles:
raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {', '.join(valid_roles)}")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
old_role = user.role
user.role = role_data.role
write_audit_log(
db,
action="user.role.update",
entity_type="user",
entity_id=user.id,
actor=current_user,
details={"username": user.username, "old_role": old_role, "new_role": user.role},
)
db.commit()
db.refresh(user)
return user
@router.post("/auth/change-password")
def change_own_password(password_data: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Change current user's password"""
@@ -821,21 +973,112 @@ def admin_change_password(user_id: int, password_data: AdminPasswordChange, db:
def read_root():
return {"message": "Drug Inventory API"}
@router.get("/drugs", response_model=List[DrugWithVariantsResponse])
@router.get("/drugs", response_model=List[DrugSummaryResponse])
def list_drugs(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Get all drugs with their variants"""
"""Get all drugs with lightweight variant summaries (no packs or batches)."""
drugs = db.query(Drug).all()
if not drugs:
return []
drug_ids = [d.id for d in drugs]
all_variants = (
db.query(DrugVariant)
.filter(DrugVariant.drug_id.in_(drug_ids))
.all()
)
variants_by_drug: Dict[int, list] = {}
for v in all_variants:
variants_by_drug.setdefault(v.drug_id, []).append(v)
variant_ids = [v.id for v in all_variants]
if not variant_ids:
return [
{
"id": d.id,
"name": d.name,
"description": d.description,
"is_controlled": bool(d.is_controlled),
"locations": [],
"variants": [],
}
for d in drugs
]
# Variant IDs that have ever had a batch received
batch_history_ids = set(
row[0]
for row in db.query(Batch.drug_variant_id)
.filter(Batch.drug_variant_id.in_(variant_ids))
.distinct()
.all()
)
# Variant IDs that have ever had a dispense
dispense_history_ids = set(
row[0]
for row in db.query(Dispensing.drug_variant_id)
.filter(Dispensing.drug_variant_id.in_(variant_ids))
.distinct()
.all()
)
# Sum of expired active batch stock per variant
today = date.today()
expired_rows = (
db.query(Batch.drug_variant_id, func.sum(Batch.quantity))
.filter(
Batch.drug_variant_id.in_(variant_ids),
Batch.quantity > 0,
Batch.expiry_date < today,
)
.group_by(Batch.drug_variant_id)
.all()
)
expired_by_variant: Dict[int, float] = {row[0]: float(row[1]) for row in expired_rows}
# Distinct location names per drug (from active batch stock)
location_rows = (
db.query(DrugVariant.drug_id, Location.name)
.join(Batch, Batch.drug_variant_id == DrugVariant.id)
.join(Location, Location.id == Batch.location_id)
.filter(
DrugVariant.drug_id.in_(drug_ids),
Batch.quantity > 0,
)
.distinct()
.all()
)
locations_by_drug: Dict[int, list] = {}
for drug_id, loc_name in location_rows:
locations_by_drug.setdefault(drug_id, []).append(loc_name)
result = []
for drug in drugs:
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all()
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)
drug_variants = variants_by_drug.get(drug.id, [])
variant_summaries = [
{
"id": v.id,
"drug_id": v.drug_id,
"strength": v.strength,
"quantity": v.quantity,
"unit": v.unit,
"low_stock_threshold": v.low_stock_threshold,
"has_inventory_history": v.id in batch_history_ids or v.id in dispense_history_ids,
"expired_quantity": expired_by_variant.get(v.id, 0.0),
}
for v in drug_variants
]
result.append(
{
"id": drug.id,
"name": drug.name,
"description": drug.description,
"is_controlled": bool(drug.is_controlled),
"locations": locations_by_drug.get(drug.id, []),
"variants": variant_summaries,
}
)
return result
@router.get("/drugs/low-stock", response_model=List[DrugWithVariantsResponse])
@@ -865,18 +1108,18 @@ def low_stock_drugs(db: Session = Depends(get_db), current_user: User = Depends(
@router.get("/drugs/{drug_id}", response_model=DrugWithVariantsResponse)
def get_drug(drug_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""Get a specific drug with its variants"""
"""Get a specific drug with its variants and packs (no batch detail — level-2 detail)."""
drug = db.query(Drug).filter(Drug.id == drug_id).first()
if not drug:
raise HTTPException(status_code=404, detail="Drug not found")
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all()
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],
"variants": [serialize_variant_with_packs(db, v) for v in variants],
}
return drug_dict
@@ -1031,15 +1274,12 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
db.flush()
# Ensure each variant has at least one active default 1:1 pack representation.
db.add(
VariantPack(
db.add(VariantPack(
drug_variant_id=db_variant.id,
label=f"1 {base_unit}",
pack_unit_name=base_unit,
pack_size_in_base_units=1,
is_active=True,
)
)
))
write_audit_log(
db,
@@ -1213,10 +1453,7 @@ def create_variant_pack(
if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found")
label = payload.label.strip()
pack_unit_name = payload.pack_unit_name.strip()
if not label:
raise HTTPException(status_code=400, detail="Pack label cannot be empty")
if not pack_unit_name:
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
if payload.pack_size_in_base_units <= 0:
@@ -1224,7 +1461,6 @@ def create_variant_pack(
row = VariantPack(
drug_variant_id=variant_id,
label=label,
pack_unit_name=pack_unit_name,
pack_size_in_base_units=payload.pack_size_in_base_units,
is_active=payload.is_active,
@@ -1238,7 +1474,6 @@ def create_variant_pack(
actor=current_user,
details={
"variant_id": variant_id,
"label": label,
"pack_unit_name": pack_unit_name,
"pack_size_in_base_units": payload.pack_size_in_base_units,
"is_active": payload.is_active,
@@ -1262,12 +1497,6 @@ def update_variant_pack(
before = serialize_variant_pack(row)
if payload.label is not None:
cleaned = payload.label.strip()
if not cleaned:
raise HTTPException(status_code=400, detail="Pack label cannot be empty")
row.label = cleaned
if payload.pack_unit_name is not None:
cleaned = payload.pack_unit_name.strip()
if not cleaned:
@@ -1359,9 +1588,12 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
requested_quantity=dispense_qty,
requested_allocations=dispensing.allocations,
dispense_mode=dispense_mode,
dispense_source=dispensing.dispense_source,
requested_pack_id=resolved["pack_id"],
)
selected_source = (dispensing.dispense_source or ("legacy" if not allocations else "batch")).strip().lower()
user_name = dispensing.user_name or current_user.username
primary_batch_id = allocations[0]["batch"].id if allocations else None
@@ -1374,6 +1606,7 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
requested_pack_id=resolved["pack_id"],
requested_pack_count=resolved["pack_count"],
animal_name=dispensing.animal_name,
prescribing_vet=dispensing.prescribing_vet,
user_name=user_name,
notes=dispensing.notes,
)
@@ -1402,6 +1635,7 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
"drug_variant_id": dispensing.drug_variant_id,
"requested_quantity": dispense_qty,
"dispense_mode": dispense_mode,
"dispense_source": selected_source,
"requested_pack_id": resolved["pack_id"],
"requested_pack_count": resolved["pack_count"],
"allocations": allocation_payload,
@@ -1428,6 +1662,110 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
"allocations": allocation_payload,
}
@router.post("/dispose")
def dispose_inventory(payload: InventoryDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
"""Dispose selected inventory and reduce stock (admin only)"""
variant = db.query(DrugVariant).filter(DrugVariant.id == payload.drug_variant_id).first()
if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found")
dispose_mode = (payload.dispense_mode or "subunit").strip().lower()
if dispose_mode not in {"subunit", "pack"}:
raise HTTPException(status_code=400, detail="dispose_mode must be either 'subunit' or 'pack'")
if dispose_mode == "pack":
if payload.requested_pack_id is None or payload.requested_pack_count is None:
raise HTTPException(status_code=400, detail="Pack disposal requires requested_pack_id and requested_pack_count")
if payload.requested_pack_count <= 0:
raise HTTPException(status_code=400, detail="Pack count must be greater than zero")
if abs(payload.requested_pack_count - round(payload.requested_pack_count)) > 1e-6:
raise HTTPException(status_code=400, detail="Whole-pack disposal requires an integer pack count")
resolved = resolve_pack_quantity(
db,
variant_id=variant.id,
quantity=None,
pack_id=payload.requested_pack_id,
pack_count=payload.requested_pack_count,
)
else:
if payload.quantity is None or payload.quantity <= 0:
raise HTTPException(status_code=400, detail="Quantity disposal requires quantity > 0")
resolved = resolve_pack_quantity(
db,
variant_id=variant.id,
quantity=payload.quantity,
pack_id=None,
pack_count=None,
)
dispose_qty = resolved["quantity"]
if variant.quantity < dispose_qty:
raise HTTPException(
status_code=400,
detail=f"Insufficient quantity. Available: {variant.quantity}, Requested: {dispose_qty}",
)
allocations = resolve_requested_allocations(
db,
variant_id=variant.id,
variant_quantity=variant.quantity,
requested_quantity=dispose_qty,
requested_allocations=payload.allocations,
dispense_mode=dispose_mode,
dispense_source=payload.dispense_source,
requested_pack_id=resolved["pack_id"],
)
selected_source = (payload.dispense_source or ("legacy" if not allocations else "batch")).strip().lower()
allocation_payload = []
disposed_at = datetime.utcnow()
for allocation in allocations:
batch = allocation["batch"]
qty = allocation["quantity"]
remaining_before = batch.quantity
batch.quantity -= qty
if batch.quantity <= 1e-6:
batch.quantity = 0
batch.disposed_at = disposed_at
batch.disposed_by_user_id = current_user.id
batch.disposed_quantity = remaining_before
batch.disposal_notes = payload.notes
recompute_batch_pack_state(batch)
allocation_payload.append({"batch_id": batch.id, "quantity": qty})
variant.quantity = max(0, variant.quantity - dispose_qty)
write_audit_log(
db,
action="inventory.dispose",
entity_type="drug_variant",
entity_id=variant.id,
actor=current_user,
details={
"drug_variant_id": variant.id,
"disposed_quantity": dispose_qty,
"dispose_mode": dispose_mode,
"dispose_source": selected_source,
"requested_pack_id": resolved["pack_id"],
"requested_pack_count": resolved["pack_count"],
"allocations": allocation_payload,
"notes": payload.notes,
},
)
db.commit()
db.refresh(variant)
return {
"message": "Inventory disposed successfully",
"drug_variant_id": variant.id,
"disposed_quantity": dispose_qty,
"dispose_mode": dispose_mode,
"dispose_source": selected_source,
"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)"""
@@ -1446,6 +1784,7 @@ def list_dispensings(skip: int = 0, limit: int = 100, db: Session = Depends(get_
"requested_pack_id": item.requested_pack_id,
"requested_pack_count": item.requested_pack_count,
"animal_name": item.animal_name,
"prescribing_vet": item.prescribing_vet,
"user_name": item.user_name,
"notes": item.notes,
"dispensed_at": item.dispensed_at,
@@ -1480,6 +1819,7 @@ def get_drug_dispensings(drug_id: int, db: Session = Depends(get_db), current_us
"requested_pack_id": item.requested_pack_id,
"requested_pack_count": item.requested_pack_count,
"animal_name": item.animal_name,
"prescribing_vet": item.prescribing_vet,
"user_name": item.user_name,
"notes": item.notes,
"dispensed_at": item.dispensed_at,
@@ -1511,6 +1851,7 @@ def get_variant_dispensings(variant_id: int, db: Session = Depends(get_db), curr
"requested_pack_id": item.requested_pack_id,
"requested_pack_count": item.requested_pack_count,
"animal_name": item.animal_name,
"prescribing_vet": item.prescribing_vet,
"user_name": item.user_name,
"notes": item.notes,
"dispensed_at": item.dispensed_at,
@@ -1635,7 +1976,41 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
.first()
)
if existing:
raise HTTPException(status_code=400, detail="Batch number already exists for this variant")
if existing.expiry_date != payload.expiry_date:
raise HTTPException(
status_code=400,
detail="Batch number already exists for this variant with a different expiry date",
)
# Same batch number and expiry — restock the existing batch
existing.quantity += batch_quantity
if existing.received_pack_id == resolved["pack_id"]:
existing.received_pack_count = (existing.received_pack_count or 0) + resolved["pack_count"]
if payload.notes:
existing.notes = (existing.notes + "\n" + payload.notes) if existing.notes else payload.notes
recompute_batch_pack_state(existing)
variant.quantity += batch_quantity
write_audit_log(
db,
action="batch.restock",
entity_type="batch",
entity_id=existing.id,
actor=current_user,
details={
"variant_id": variant_id,
"batch_number": batch_number,
"quantity_added": batch_quantity,
"new_total_quantity": existing.quantity,
"received_pack_id": resolved["pack_id"],
"received_pack_count": resolved["pack_count"],
"expiry_date": str(payload.expiry_date),
"location_id": existing.location_id,
},
)
db.commit()
db.refresh(existing)
return serialize_batch_response(db, existing)
row = Batch(
drug_variant_id=variant_id,
@@ -1783,7 +2158,7 @@ def update_batch(batch_id: int, payload: BatchUpdate, db: Session = Depends(get_
@router.post("/batches/{batch_id}/dispose", response_model=BatchResponse)
def dispose_batch(batch_id: int, payload: BatchDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_non_readonly_user)):
def dispose_batch(batch_id: int, payload: BatchDisposeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_admin_user)):
batch = db.query(Batch).filter(Batch.id == batch_id).first()
if not batch:
raise HTTPException(status_code=404, detail="Batch not found")
@@ -2054,6 +2429,98 @@ def report_stock_by_location(
return result
@router.get("/reports/global-inventory")
def report_global_inventory(
format: str = "json",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
variant_rows = (
db.query(DrugVariant, Drug)
.join(Drug, DrugVariant.drug_id == Drug.id)
.order_by(Drug.name.asc(), DrugVariant.strength.asc())
.all()
)
result: List[Dict[str, Any]] = []
for variant, drug in variant_rows:
batch_rows = (
db.query(Batch, Location)
.join(Location, Batch.location_id == Location.id)
.filter(Batch.drug_variant_id == variant.id, Batch.quantity > 0)
.order_by(Batch.expiry_date.asc(), Location.name.asc(), Batch.batch_number.asc())
.all()
)
total_batch_quantity = 0.0
for batch, location in batch_rows:
total_batch_quantity += float(batch.quantity or 0)
result.append(
{
"batch_id": batch.id,
"batch_number": batch.batch_number,
"drug_name": drug.name,
"strength": variant.strength,
"quantity": batch.quantity,
"unit": variant.unit,
"location_name": location.name,
"expiry_date": batch.expiry_date,
"inventory_source": "batch",
"is_controlled": bool(drug.is_controlled),
}
)
legacy_quantity = max(0.0, float(variant.quantity or 0) - total_batch_quantity)
if legacy_quantity > 1e-6:
result.append(
{
"batch_id": None,
"batch_number": "Legacy stock",
"drug_name": drug.name,
"strength": variant.strength,
"quantity": legacy_quantity,
"unit": variant.unit,
"location_name": None,
"expiry_date": None,
"inventory_source": "legacy",
"is_controlled": bool(drug.is_controlled),
}
)
if format.lower() == "csv":
csv_rows = [
[
item["drug_name"],
item["strength"],
item["batch_number"],
item["quantity"],
item["unit"],
item["location_name"],
item["expiry_date"],
item["inventory_source"],
item["is_controlled"],
]
for item in result
]
return _csv_response(
"global_inventory.csv",
[
"drug_name",
"strength",
"batch_number",
"quantity",
"unit",
"location_name",
"expiry_date",
"inventory_source",
"is_controlled",
],
csv_rows,
)
return result
@router.get("/reports/batch-attention")
def report_batch_attention(
format: str = "json",
@@ -2085,7 +2552,7 @@ def report_batch_attention(
"location": location.name,
"expiry_date": batch.expiry_date,
"status": "expired",
"received_pack_label": None,
"received_pack_unit_name": None,
"current_full_pack_count": batch.current_full_pack_count,
"current_loose_base_units": batch.current_loose_base_units,
"is_controlled": bool(drug.is_controlled),
@@ -2335,5 +2802,88 @@ def print_notes(notes_request: NotesPrintRequest, current_user: User = Depends(g
detail=f"Error sending notes print request: {str(e)}"
)
@router.get("/gtin/{gtin}", response_model=GtinMappingResponse)
def get_gtin_mapping(
gtin: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
mapping = db.query(GtinMapping).filter(GtinMapping.gtin == gtin).first()
if not mapping:
raise HTTPException(status_code=404, detail="GTIN not found")
variant = db.query(DrugVariant).filter(DrugVariant.id == mapping.drug_variant_id).first()
drug = db.query(Drug).filter(Drug.id == variant.drug_id).first() if variant else None
pack = db.query(VariantPack).filter(VariantPack.id == mapping.variant_pack_id).first()
if not variant or not drug or not pack:
# Referenced records no longer exist — delete the stale mapping and treat as unknown
db.delete(mapping)
db.commit()
raise HTTPException(status_code=404, detail="GTIN not found")
return GtinMappingResponse(
id=mapping.id,
gtin=mapping.gtin,
drug_variant_id=mapping.drug_variant_id,
variant_pack_id=mapping.variant_pack_id,
drug_id=drug.id,
drug_name=drug.name,
variant_strength=variant.strength,
variant_unit=variant.unit,
pack_label=f"{pack.pack_unit_name} of {int(pack.pack_size_in_base_units) if pack.pack_size_in_base_units == int(pack.pack_size_in_base_units) else pack.pack_size_in_base_units}",
pack_size_in_base_units=pack.pack_size_in_base_units,
pack_unit_name=pack.pack_unit_name,
)
@router.post("/gtin", response_model=GtinMappingResponse, status_code=201)
def create_gtin_mapping(
body: GtinMappingCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_non_readonly_user),
):
existing = db.query(GtinMapping).filter(GtinMapping.gtin == body.gtin).first()
if existing:
raise HTTPException(status_code=409, detail="GTIN mapping already exists")
variant = db.query(DrugVariant).filter(DrugVariant.id == body.drug_variant_id).first()
if not variant:
raise HTTPException(status_code=404, detail="Drug variant not found")
pack = db.query(VariantPack).filter(
VariantPack.id == body.variant_pack_id,
VariantPack.drug_variant_id == body.drug_variant_id,
).first()
if not pack:
raise HTTPException(status_code=404, detail="Pack not found for this variant")
drug = db.query(Drug).filter(Drug.id == variant.drug_id).first()
mapping = GtinMapping(
gtin=body.gtin,
drug_variant_id=body.drug_variant_id,
variant_pack_id=body.variant_pack_id,
created_by_user_id=current_user.id,
)
db.add(mapping)
db.commit()
db.refresh(mapping)
return GtinMappingResponse(
id=mapping.id,
gtin=mapping.gtin,
drug_variant_id=mapping.drug_variant_id,
variant_pack_id=mapping.variant_pack_id,
drug_id=drug.id,
drug_name=drug.name,
variant_strength=variant.strength,
variant_unit=variant.unit,
pack_label=f"{pack.pack_unit_name} of {int(pack.pack_size_in_base_units) if pack.pack_size_in_base_units == int(pack.pack_size_in_base_units) else pack.pack_size_in_base_units}",
pack_size_in_base_units=pack.pack_size_in_base_units,
pack_unit_name=pack.pack_unit_name,
)
# Include router with /api prefix
app.include_router(router)
+5 -3
View File
@@ -62,7 +62,6 @@ def migrate_compliance_schema() -> None:
CREATE TABLE variant_packs (
id INTEGER PRIMARY KEY,
drug_variant_id INTEGER NOT NULL,
label VARCHAR NOT NULL,
pack_unit_name VARCHAR NOT NULL DEFAULT 'pack',
pack_size_in_base_units FLOAT NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT 1,
@@ -138,9 +137,8 @@ def migrate_compliance_schema() -> None:
if _table_exists(cursor, "variant_packs") and _table_exists(cursor, "drug_variants"):
cursor.execute(
"""
INSERT INTO variant_packs (drug_variant_id, label, pack_unit_name, pack_size_in_base_units, is_active)
INSERT INTO variant_packs (drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active)
SELECT v.id,
'1 ' || COALESCE(NULLIF(v.unit, ''), 'unit'),
COALESCE(NULLIF(v.unit, ''), 'unit'),
1,
1
@@ -230,6 +228,10 @@ def migrate_compliance_schema() -> None:
)
print("Backfilled dispensing mode where missing")
if _table_exists(cursor, "dispensings") and not _column_exists(cursor, "dispensings", "prescribing_vet"):
cursor.execute("ALTER TABLE dispensings ADD COLUMN prescribing_vet VARCHAR")
print("Added dispensings.prescribing_vet")
# 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)")
+103
View File
@@ -0,0 +1,103 @@
"""
GTIN mapping table migration.
Creates the gtin_mappings table if it does not already exist.
Idempotent and safe to run on every startup.
"""
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 GTIN migration: {db_url}")
return None
raw_path = db_url.replace("sqlite:///", "")
if raw_path.startswith("/"):
return Path(raw_path)
return Path(raw_path)
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 _column_exists(cursor: sqlite3.Cursor, table_name: str, column_name: str) -> bool:
cursor.execute(f"PRAGMA table_info({table_name})")
return any(row[1] == column_name for row in cursor.fetchall())
def migrate_gtin_schema() -> None:
"""Create gtin_mappings table if it does not exist, and drop label from variant_packs."""
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 GTIN migration")
return
print(f"Running GTIN migration on {db_path}")
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
try:
if not _table_exists(cursor, "gtin_mappings"):
cursor.execute("""
CREATE TABLE gtin_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
gtin VARCHAR(14) NOT NULL,
drug_variant_id INTEGER NOT NULL REFERENCES drug_variants(id),
variant_pack_id INTEGER NOT NULL REFERENCES variant_packs(id),
created_by_user_id INTEGER REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("CREATE UNIQUE INDEX ix_gtin_mappings_gtin ON gtin_mappings (gtin)")
cursor.execute("CREATE INDEX ix_gtin_mappings_drug_variant_id ON gtin_mappings (drug_variant_id)")
cursor.execute("CREATE INDEX ix_gtin_mappings_variant_pack_id ON gtin_mappings (variant_pack_id)")
print("Created gtin_mappings table")
else:
print("gtin_mappings table already exists, skipping")
# Drop label column from variant_packs if it still exists
if _table_exists(cursor, "variant_packs") and _column_exists(cursor, "variant_packs", "label"):
print("Dropping label column from variant_packs")
cursor.execute("""
CREATE TABLE variant_packs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
drug_variant_id INTEGER NOT NULL REFERENCES drug_variants(id),
pack_unit_name VARCHAR NOT NULL DEFAULT 'pack',
pack_size_in_base_units FLOAT NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
INSERT INTO variant_packs_new (id, drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active, created_at, updated_at)
SELECT id, drug_variant_id, pack_unit_name, pack_size_in_base_units, is_active, created_at, updated_at
FROM variant_packs
""")
# Re-create indexes
cursor.execute("DROP INDEX IF EXISTS ix_variant_packs_drug_variant_id")
cursor.execute("DROP TABLE variant_packs")
cursor.execute("ALTER TABLE variant_packs_new RENAME TO variant_packs")
cursor.execute("CREATE INDEX ix_variant_packs_drug_variant_id ON variant_packs (drug_variant_id)")
print("Dropped label column from variant_packs")
else:
print("variant_packs.label already absent, skipping")
conn.commit()
finally:
conn.close()
+12 -1
View File
@@ -41,7 +41,6 @@ class VariantPack(Base):
id = Column(Integer, primary_key=True, index=True)
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
label = Column(String, nullable=False)
pack_unit_name = Column(String, nullable=False, default="pack")
pack_size_in_base_units = Column(Float, nullable=False, default=1)
is_active = Column(Boolean, nullable=False, default=True)
@@ -61,6 +60,7 @@ class Dispensing(Base):
requested_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=True)
requested_pack_count = Column(Float, nullable=True)
animal_name = Column(String, nullable=True) # Name/ID of the animal (optional)
prescribing_vet = Column(String, nullable=True) # Prescribing vet's name (required for controlled drugs)
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)
@@ -109,6 +109,17 @@ class DispensingAllocation(Base):
quantity = Column(Float, nullable=False)
class GtinMapping(Base):
__tablename__ = "gtin_mappings"
id = Column(Integer, primary_key=True, index=True)
gtin = Column(String(14), unique=True, index=True, nullable=False)
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, index=True)
variant_pack_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=False, index=True)
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class AuditLog(Base):
__tablename__ = "audit_logs"
+6 -6
View File
@@ -1,8 +1,8 @@
fastapi==0.104.1
uvicorn==0.24.0
sqlalchemy==2.0.23
pydantic==2.5.0
python-multipart==0.0.6
python-jose[cryptography]==3.3.0
fastapi==0.137.2
uvicorn==0.49.0
sqlalchemy==2.0.51
pydantic==2.13.4
python-multipart==0.0.32
python-jose[cryptography]==3.5.0
passlib[argon2]==1.7.4
paho-mqtt==1.6.1
+18 -5
View File
@@ -10,8 +10,8 @@ services:
- ./data:/app/data
environment:
- DATABASE_URL=sqlite:///./data/drugs.db
- PUID=1001
- PGID=1001
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
- MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-mosquitto}
- MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883}
- MQTT_USERNAME=${MQTT_USERNAME:-}
@@ -23,6 +23,20 @@ services:
- LABEL_SIZE=${LABEL_SIZE:-29x90}
- LABEL_TEST=${LABEL_TEST:-false}
backup:
image: python:3.11-slim
user: "${PUID:-1000}:${PGID:-1000}"
volumes:
- ./data:/data
- ./scripts/backup_sqlite.py:/usr/local/bin/backup_sqlite.py:ro
environment:
- SQLITE_DB_PATH=/data/drugs.db
- BACKUP_DIR=/data/backups
- BACKUP_INTERVAL_SECONDS=${BACKUP_INTERVAL_SECONDS:-3600}
- BACKUP_RETENTION_DAYS=${BACKUP_RETENTION_DAYS:-7}
command: ["python", "/usr/local/bin/backup_sqlite.py"]
restart: unless-stopped
mosquitto:
image: eclipse-mosquitto:latest
volumes:
@@ -31,12 +45,11 @@ services:
- mosquitto_data:/mosquitto/data
- mosquitto_logs:/mosquitto/log
environment:
- PUID=1001
- PGID=1001
- PUID=${PUID:-1000}
- PGID=${PGID:-1000}
frontend:
image: nginx:alpine
container_name: drugsdev
volumes:
- ./frontend:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+2157 -266
View File
File diff suppressed because it is too large Load Diff
+187 -71
View File
@@ -59,6 +59,7 @@
<div class="inventory-toolbar">
<div class="header-actions">
<button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button>
<button id="receiveDeliveryBtn" class="btn btn-success btn-small">📦 Receive Delivery</button>
<button id="addDrugBtn" class="btn btn-primary btn-small"> Add Drug</button>
</div>
<div class="toolbar-search">
@@ -137,36 +138,6 @@
This is a Controlled Substance
</label>
</div>
<hr style="margin: 20px 0; border: none; border-top: 1px solid #ddd;">
<h3 style="margin-top: 0;">Initial Variant (Optional)</h3>
<div class="form-group">
<label for="initialVariantStrength">Strength</label>
<input type="text" id="initialVariantStrength" placeholder="e.g., 10mg, 5.4mg">
</div>
<div class="form-group">
<label for="initialVariantQuantity">Quantity</label>
<input type="number" id="initialVariantQuantity" placeholder="0" min="0" step="0.1">
</div>
<div class="form-group">
<label for="initialVariantUnit">Unit</label>
<select id="initialVariantUnit">
<option value="tablets">Tablets</option>
<option value="bottles">Bottles</option>
<option value="boxes">Boxes</option>
<option value="vials">Vials</option>
<option value="units">Units</option>
<option value="packets">Packets</option>
</select>
</div>
<div class="form-group">
<label for="initialVariantThreshold">Low Stock Threshold</label>
<input type="number" id="initialVariantThreshold" placeholder="0" min="0" step="0.1" value="10">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Add Drug</button>
@@ -205,7 +176,7 @@
<div class="form-group" id="dispenseQuantityGroup">
<label for="dispenseQuantity">Quantity *</label>
<input type="number" id="dispenseQuantity" step="0.1">
<input type="number" id="dispenseQuantity" step="1.0">
</div>
<div class="form-row" id="dispensePackRow" style="display: none;">
@@ -222,6 +193,21 @@
</div>
</div>
<div class="form-group" id="dispenseSourceGroup" style="display: none;">
<label>Stock Source *</label>
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="dispenseSource" id="dispenseSourceBatch" value="batch" checked>
Batch stock
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="dispenseSource" id="dispenseSourceLegacy" value="legacy">
Legacy loose stock
</label>
</div>
<small id="dispenseSourceHelp" style="display: block; margin-top: 6px; color: #666;"></small>
</div>
<div id="batchInfoSection" style="display: none; margin: 15px 0; padding: 12px; background: #f5f5f5; border-radius: 4px;">
<h4 style="margin-top: 0; margin-bottom: 4px;">Batch Allocation</h4>
<p style="margin: 0 0 10px; color: #666;">Batches are shown in FEFO order. Adjust the allocation against each batch so the total matches the requested dispense amount.</p>
@@ -242,6 +228,11 @@
<input type="text" id="dispenseAnimal">
</div>
<div class="form-group">
<label for="dispenseVet" id="dispenseVetLabel">Prescribing Vet</label>
<input type="text" id="dispenseVet" placeholder="Vet's name">
</div>
<div class="form-group" style="margin-top: 18px; padding: 12px; background: #f9fafb; border: 1px solid #d9e2ec; border-radius: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin-bottom: 0; font-weight: 600;">
<input type="checkbox" id="dispensePrintEnabled">
@@ -273,50 +264,90 @@
</div>
</div>
<!-- Prescribe Drug Modal -->
<div id="prescribeModal" class="modal">
<div class="modal-content">
<!-- Dispose Inventory Modal -->
<div id="disposeInventoryModal" class="modal">
<div class="modal-content modal-large dispense-modal-content">
<span class="close">&times;</span>
<h2>Prescribe Drug & Print Label</h2>
<form id="prescribeForm" novalidate>
<input type="hidden" id="prescribeVariantId">
<input type="hidden" id="prescribeDrugName">
<input type="hidden" id="prescribeVariantStrength">
<input type="hidden" id="prescribeUnit">
<h2>Dispose Inventory</h2>
<form id="disposeInventoryForm" novalidate>
<div class="form-group">
<label for="prescribeQuantity">Quantity *</label>
<input type="number" id="prescribeQuantity" step="0.1" required>
<label for="disposeDrugSelect">Drug Variant *</label>
<select id="disposeDrugSelect" onchange="updateDisposeBatchInfo()">
<option value="">-- Select a drug variant --</option>
</select>
</div>
<div class="form-group">
<label for="prescribeAnimal">Animal Name/ID *</label>
<input type="text" id="prescribeAnimal" required>
<label>Disposal Mode *</label>
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeMode" id="disposeModeQuantity" value="subunit" checked>
Quantity
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeMode" id="disposeModePack" value="pack">
Whole Pack
</label>
</div>
</div>
<div class="form-group" id="disposeQuantityGroup">
<label for="disposeQuantity">Quantity *</label>
<input type="number" id="disposeQuantity" step="1.0">
</div>
<div class="form-row" id="disposePackRow" style="display: none;">
<div class="form-group">
<label for="disposePackSelect">Pack Type *</label>
<select id="disposePackSelect" onchange="updateDisposeQuantityFromPack()">
<option value="">-- Select pack --</option>
</select>
</div>
<div class="form-group">
<label for="disposePackCount">Pack Count *</label>
<input type="number" id="disposePackCount" min="1" step="1" onchange="updateDisposeQuantityFromPack()">
<small id="disposePackPreview" style="display: block; margin-top: 6px; color: #666;">Select a pack and whole-number count.</small>
</div>
</div>
<div class="form-group" id="disposeSourceGroup" style="display: none;">
<label>Stock Source *</label>
<div style="display: flex; gap: 18px; align-items: center; flex-wrap: wrap; margin-top: 6px;">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeSource" id="disposeSourceBatch" value="batch" checked>
Batch stock
</label>
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-weight: 500;">
<input type="radio" name="disposeSource" id="disposeSourceLegacy" value="legacy">
Legacy loose stock
</label>
</div>
<small id="disposeSourceHelp" style="display: block; margin-top: 6px; color: #666;"></small>
</div>
<div id="disposeBatchInfoSection" style="display: none; margin: 15px 0; padding: 12px; background: #f5f5f5; border-radius: 4px;">
<h4 style="margin-top: 0; margin-bottom: 4px;">Batch Allocation</h4>
<p style="margin: 0 0 10px; color: #666;">Batches are shown in FEFO order. Adjust the allocation against each batch so the total matches the requested disposal amount.</p>
<details id="disposeExpiredBatchDetails" style="display: none; margin-bottom: 10px; background: #fffaf0; border: 1px solid #f5d08a; border-radius: 4px; padding: 8px 10px;">
<summary style="cursor: pointer; font-weight: 600; color: #7a4f01;">Show expired batches</summary>
<div id="disposeExpiredBatchContent" style="margin-top: 10px;"></div>
</details>
<div id="disposeAllocationSummary" style="display: none; margin-bottom: 10px; padding: 8px 10px; background: #f0f8ff; border-left: 3px solid #2196F3; border-radius: 4px;">
<div id="disposeAllocationSummaryContent"></div>
</div>
<div id="disposeBatchInfoContent">
<p class="loading">Loading batches...</p>
</div>
</div>
<div class="form-group">
<label for="prescribeDosage">Dosage Instructions *</label>
<input type="text" id="prescribeDosage" placeholder="e.g., 1 tablet twice daily with food" required>
<label for="disposeNotes">Disposal Note</label>
<textarea id="disposeNotes" rows="4" placeholder="Optional note for the audit log"></textarea>
</div>
<div class="form-group">
<label for="prescribeExpiry">Expiry Date *</label>
<input type="date" id="prescribeExpiry" required>
</div>
<div class="form-group">
<label for="prescribeUser">Prescribed by *</label>
<input type="text" id="prescribeUser" required>
</div>
<div class="form-group">
<label for="prescribeNotes">Notes</label>
<input type="text" id="prescribeNotes" placeholder="Optional">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Prescribe & Print Label</button>
<button type="button" class="btn btn-secondary" id="cancelPrescribeBtn">Cancel</button>
<button type="submit" class="btn btn-danger">Dispose</button>
<button type="button" class="btn btn-secondary" id="cancelDisposeInventoryBtn">Cancel</button>
</div>
</form>
</div>
@@ -331,14 +362,14 @@
<input type="hidden" id="variantDrugId">
<div class="form-group">
<label for="variantStrength">Strength *</label>
<input type="text" id="variantStrength" placeholder="e.g., 5.4mg, 10.8mg, 100ml" required>
<input type="text" id="variantStrength" placeholder="e.g., 5.4mg, 0.5mg/ml" required>
</div>
<div class="form-group">
<label for="variantUnit">Base Unit *</label>
<select id="variantUnit">
<option value="ml">ml</option>
<option value="tablets">tablets</option>
<option value="tablets" selected>tablets</option>
<option value="capsules">capsules</option>
<option value="units">units</option>
<option value="vials">vials</option>
@@ -490,7 +521,7 @@
<span class="close">&times;</span>
<h2>User Management</h2>
<div class="user-management-content">
<div class="form-group">
<section class="user-create-panel">
<h3>Create New User</h3>
<form id="createUserForm">
<div class="form-row">
@@ -505,7 +536,7 @@
</div>
<button type="submit" class="btn btn-primary btn-small">Create User</button>
</form>
</div>
</section>
<div id="usersList" class="users-list">
<h3>Users</h3>
<p class="loading">Loading users...</p>
@@ -580,6 +611,11 @@
<input type="text" id="disposeBatchName" disabled>
</div>
<div class="form-group">
<label for="disposeBatchStockSummary">Stock to Dispose</label>
<input type="text" id="disposeBatchStockSummary" disabled>
</div>
<div class="form-group">
<p style="margin: 0; color: #666;">This will mark the expired batch as disposed and remove its remaining stock from inventory.</p>
</div>
@@ -640,6 +676,41 @@
</div>
</div>
<!-- Add Pack Size Modal -->
<div id="addPackSizeModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Add Pack Size</h2>
<p id="addPackSizeDrugLabel" style="margin: 4px 0 14px; color: #666; font-weight: 600;"></p>
<form id="addPackSizeForm" novalidate>
<div class="form-group">
<label for="addPackSizeVariantSelect">Variant *</label>
<select id="addPackSizeVariantSelect" required>
<option value="">-- Select variant --</option>
</select>
</div>
<div class="form-group">
<label for="addPackSizeType">Pack Type *</label>
<select id="addPackSizeType" required>
<option value="box" selected>Box</option>
<option value="bottle">Bottle</option>
<option value="vial">Vial</option>
<option value="packet">Packet</option>
</select>
</div>
<div class="form-group">
<label for="addPackSizeCount">Pack Size (units) *</label>
<input type="number" id="addPackSizeCount" min="0.0001" step="0.0001" placeholder="e.g., 28" required>
<small id="addPackSizeHint" style="display: block; margin-top: 4px; color: #666;"></small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Add Pack Size</button>
<button type="button" class="btn btn-secondary" id="cancelAddPackSizeBtn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Receive Delivery Modal -->
<div id="receiveDeliveryModal" class="modal">
<div class="modal-content modal-large receive-delivery-modal-content">
@@ -651,6 +722,7 @@
<div class="delivery-toolbar">
<button type="button" id="addDeliveryLineBtn" class="btn btn-secondary">+ Add Delivery Line</button>
<button type="button" id="addVariantFromDeliveryBtn" class="btn btn-info">+ Add Variant</button>
<button type="button" id="addPackSizeFromDeliveryBtn" class="btn btn-info">+ Add Pack Size</button>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Receive Delivery</button>
@@ -660,6 +732,50 @@
</div>
</div>
<!-- GTIN Mapping Modal -->
<div id="gtinMappingModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Unknown Barcode — Map GTIN</h2>
<p style="color:#666; margin-bottom:16px;">This barcode hasn't been seen before. Please map it to the correct drug, variant and pack.</p>
<div class="form-group">
<label>GTIN</label>
<input type="text" id="gtinMappingGtin" readonly style="background:#f5f5f5;">
</div>
<div class="form-group">
<label>Drug</label>
<div style="display:flex; gap:8px; align-items:center;">
<select id="gtinMappingDrugSelect" onchange="onGtinMappingDrugChange()" style="flex:1">
<option value="">-- Select drug --</option>
</select>
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddDrug()">+ New Drug</button>
</div>
</div>
<div class="form-group">
<label>Variant</label>
<div style="display:flex; gap:8px; align-items:center;">
<select id="gtinMappingVariantSelect" onchange="onGtinMappingVariantChange()" style="flex:1">
<option value="">-- Select variant --</option>
</select>
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddVariant()">+ New Variant</button>
</div>
</div>
<div class="form-group">
<label>Pack</label>
<div style="display:flex; gap:8px; align-items:center;">
<select id="gtinMappingPackSelect" style="flex:1">
<option value="">-- Select pack --</option>
</select>
<button type="button" class="btn btn-info btn-small" onclick="gtinMappingAddPack()">+ New Pack</button>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" onclick="handleSaveGtinMapping()">Save Mapping</button>
<button type="button" class="btn btn-secondary" id="cancelGtinMappingBtn">Cancel</button>
</div>
</div>
</div>
</div>
<script src="app.js"></script>
+3 -1
View File
@@ -31,6 +31,7 @@
<label for="reportTypeSelect">Report</label>
<select id="reportTypeSelect">
<option value="dispensing" selected>Dispensing History</option>
<option value="global_inventory">Stock Check</option>
<option value="batch_attention">Expired Batches</option>
<option value="audit">Audit Trail (Raw)</option>
</select>
@@ -63,6 +64,7 @@
<button id="applyReportFiltersBtn" type="button" class="btn btn-primary btn-small">Apply Filters</button>
<button id="clearReportFiltersBtn" type="button" class="btn btn-secondary btn-small">Clear</button>
<button id="refreshReportsBtn" type="button" class="btn btn-secondary btn-small">Refresh</button>
<button id="stockCheckPdfBtn" type="button" class="btn btn-secondary btn-small" style="display: none;">Print Stock Check</button>
</div>
</div>
</div>
@@ -119,4 +121,4 @@
<script src="reports.js"></script>
</body>
</html>
</html>
+340 -2
View File
@@ -5,6 +5,7 @@ let currentUser = null;
let allDrugs = [];
let auditTrailRows = [];
let dispensingRows = [];
let globalInventoryRows = [];
let batchAttentionRows = [];
let activeReportType = 'dispensing';
const batchLookupById = new Map();
@@ -200,24 +201,28 @@ function detailsContainsText(details, searchText) {
function getActiveRows() {
if (activeReportType === 'dispensing') return dispensingRows;
if (activeReportType === 'global_inventory') return globalInventoryRows;
if (activeReportType === 'batch_attention') return batchAttentionRows;
return auditTrailRows;
}
function getRowUser(row) {
if (activeReportType === 'dispensing') return row.user_name || 'unknown';
if (activeReportType === 'global_inventory') return '';
if (activeReportType === 'batch_attention') return '';
return row.actor_username || 'system';
}
function getRowDrug(row) {
if (activeReportType === 'dispensing') return extractDrugLabelFromDispenseRow(row);
if (activeReportType === 'global_inventory') return `${row.drug_name || 'Unknown Drug'}${row.strength ? ` ${row.strength}` : ''}`;
if (activeReportType === 'batch_attention') return `${row.drug_name || 'Unknown Drug'}${row.strength ? ` ${row.strength}` : ''}`;
return extractDrugLabelFromAuditRow(row);
}
function getRowDate(row) {
if (activeReportType === 'dispensing') return new Date(row.dispensed_at);
if (activeReportType === 'global_inventory') return row.expiry_date ? new Date(row.expiry_date) : null;
if (activeReportType === 'batch_attention') return new Date(row.expiry_date);
return new Date(row.created_at);
}
@@ -312,6 +317,7 @@ function renderDispensingTable(rows) {
const info = getVariantInfoById(row.drug_variant_id);
const quantityText = `${row.quantity} ${info.unit || 'units'}`;
const animal = row.animal_name || '-';
const vet = row.prescribing_vet || '-';
const notes = row.notes || '-';
const allocations = formatDispenseAllocation(row);
@@ -323,6 +329,7 @@ function renderDispensingTable(rows) {
<td>${escapeHtml(info.strength || '-')}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(animal)}</td>
<td>${escapeHtml(vet)}</td>
<td>${escapeHtml(allocations)}</td>
<td>${escapeHtml(notes)}</td>
</tr>
@@ -339,6 +346,7 @@ function renderDispensingTable(rows) {
<th>Strength</th>
<th>Quantity</th>
<th>Animal</th>
<th>Prescribing Vet</th>
<th>Batch Allocation</th>
<th>Notes</th>
</tr>
@@ -348,6 +356,50 @@ function renderDispensingTable(rows) {
`;
}
function renderGlobalInventoryTable(rows) {
const container = document.getElementById('reportsTableContainer');
if (!container) return;
if (!rows.length) {
container.innerHTML = '<p class="empty" style="padding: 14px;">No inventory lines match the selected filters.</p>';
return;
}
const rowsHtml = rows.map(row => {
const expiryText = row.expiry_date ? new Date(row.expiry_date).toLocaleDateString() : '-';
const quantityText = `${row.quantity} ${row.unit || 'units'}`;
const batchText = row.inventory_source === 'legacy' ? 'Legacy stock' : (row.batch_number || '');
const locationText = row.location_name || '-';
return `
<tr>
<td>${escapeHtml(row.drug_name || '')}</td>
<td>${escapeHtml(row.strength || '-')}</td>
<td>${escapeHtml(batchText)}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(locationText)}</td>
<td>${escapeHtml(expiryText)}</td>
</tr>
`;
}).join('');
container.innerHTML = `
<table class="reports-table">
<thead>
<tr>
<th>Drug</th>
<th>Variant</th>
<th>Batch</th>
<th>Quantity</th>
<th>Location</th>
<th>Expiry</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
`;
}
function renderBatchAttentionTable(rows) {
const container = document.getElementById('reportsTableContainer');
if (!container) return;
@@ -469,8 +521,8 @@ function applyCurrentFilters() {
const userMatch = !selectedUser || getRowUser(row) === selectedUser;
const drugMatch = !selectedDrug || getRowDrug(row) === selectedDrug;
const rowDate = getRowDate(row);
const fromMatch = !fromDate || rowDate >= fromDate;
const toMatch = !toDate || rowDate <= toDate;
const fromMatch = !fromDate || !rowDate || rowDate >= fromDate;
const toMatch = !toDate || !rowDate || rowDate <= toDate;
let textMatch = true;
if (searchText) {
@@ -481,10 +533,21 @@ function applyCurrentFilters() {
info.drugName || '',
info.strength || '',
row.animal_name || '',
row.prescribing_vet || '',
row.notes || '',
formatDispenseAllocation(row)
].join(' ').toLowerCase();
textMatch = haystack.includes(searchText);
} else if (activeReportType === 'global_inventory') {
const haystack = [
row.drug_name || '',
row.strength || '',
row.batch_number || '',
row.inventory_source || '',
row.location_name || '',
row.unit || ''
].join(' ').toLowerCase();
textMatch = haystack.includes(searchText);
} else if (activeReportType === 'batch_attention') {
const haystack = [
row.drug_name || '',
@@ -512,6 +575,8 @@ function applyCurrentFilters() {
if (reportsSummary) {
const reportName = activeReportType === 'dispensing'
? 'dispensing records'
: activeReportType === 'global_inventory'
? 'inventory lines'
: activeReportType === 'batch_attention'
? 'expired batches'
: 'audit events';
@@ -520,34 +585,295 @@ function applyCurrentFilters() {
if (activeReportType === 'dispensing') {
renderDispensingTable(filteredRows);
} else if (activeReportType === 'global_inventory') {
renderGlobalInventoryTable(filteredRows);
} else if (activeReportType === 'batch_attention') {
renderBatchAttentionTable(filteredRows);
} else {
renderAuditTable(filteredRows);
}
return filteredRows;
}
function updateReportHeading() {
const heading = document.getElementById('reportsHeading');
const searchInput = document.getElementById('reportActionSearch');
const userFilter = document.getElementById('reportUserFilter')?.closest('.report-control');
const stockCheckPdfBtn = document.getElementById('stockCheckPdfBtn');
if (!heading || !searchInput) return;
if (activeReportType === 'dispensing') {
heading.textContent = 'Dispensing History';
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
if (userFilter) userFilter.style.display = '';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
} else if (activeReportType === 'global_inventory') {
heading.textContent = 'Global Inventory';
searchInput.placeholder = 'Search drug, variant, batch, location...';
if (userFilter) userFilter.style.display = 'none';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = '';
} else if (activeReportType === 'batch_attention') {
heading.textContent = 'Expired Batches';
searchInput.placeholder = 'Search drug, batch, location...';
if (userFilter) userFilter.style.display = 'none';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
} else {
heading.textContent = 'Audit Trail (Raw)';
searchInput.placeholder = 'Search action, entity, details...';
if (userFilter) userFilter.style.display = '';
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
}
}
function formatDisplayDate(value) {
if (!value) return '-';
const date = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
? new Date(`${value}T00:00:00`)
: new Date(value);
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleDateString();
}
function getReportFilterSummary() {
const drugFilter = document.getElementById('reportDrugFilter')?.value || '';
const fromDate = document.getElementById('reportFromDate')?.value || '';
const toDate = document.getElementById('reportToDate')?.value || '';
const searchText = document.getElementById('reportActionSearch')?.value.trim() || '';
const parts = [];
if (drugFilter) parts.push(`Drug: ${drugFilter}`);
if (fromDate) parts.push(`From: ${formatDisplayDate(fromDate)}`);
if (toDate) parts.push(`To: ${formatDisplayDate(toDate)}`);
if (searchText) parts.push(`Search: ${searchText}`);
return parts.length ? parts.join(' | ') : 'All inventory lines';
}
function getStockCheckRows() {
const previousReportType = activeReportType;
activeReportType = 'global_inventory';
const rows = applyCurrentFilters() || [];
activeReportType = previousReportType;
return rows;
}
function buildStockCheckPdfHtml(rows) {
const groupedRows = new Map();
const sortedRows = [...rows].sort((a, b) => {
const drugCompare = String(a.drug_name || '').localeCompare(String(b.drug_name || ''), undefined, { sensitivity: 'base' });
if (drugCompare !== 0) return drugCompare;
const strengthCompare = String(a.strength || '').localeCompare(String(b.strength || ''), undefined, { numeric: true, sensitivity: 'base' });
if (strengthCompare !== 0) return strengthCompare;
const expiryCompare = String(a.expiry_date || '9999-12-31').localeCompare(String(b.expiry_date || '9999-12-31'));
if (expiryCompare !== 0) return expiryCompare;
return String(a.batch_number || '').localeCompare(String(b.batch_number || ''), undefined, { numeric: true, sensitivity: 'base' });
});
sortedRows.forEach(row => {
const drugName = row.drug_name || 'Unknown Drug';
if (!groupedRows.has(drugName)) groupedRows.set(drugName, []);
groupedRows.get(drugName).push(row);
});
const generatedAt = new Date().toLocaleString();
const filterSummary = getReportFilterSummary();
const totalLines = rows.length;
const sectionsHtml = Array.from(groupedRows.entries()).map(([drugName, drugRows]) => {
const bodyHtml = drugRows.map(row => {
const batchText = row.inventory_source === 'legacy' ? 'Legacy stock' : (row.batch_number || '-');
const quantityText = `${row.quantity ?? 0} ${row.unit || 'units'}`;
const controlledText = row.is_controlled ? 'Yes' : 'No';
return `
<tr>
<td>${escapeHtml(row.strength || '-')}</td>
<td>${escapeHtml(batchText)}</td>
<td>${escapeHtml(quantityText)}</td>
<td>${escapeHtml(row.location_name || '-')}</td>
<td>${escapeHtml(formatDisplayDate(row.expiry_date))}</td>
<td>${escapeHtml(controlledText)}</td>
<td class="manual-entry"></td>
<td class="manual-entry notes-cell"></td>
</tr>
`;
}).join('');
return `
<section class="drug-section">
<h2>${escapeHtml(drugName)}</h2>
<table>
<thead>
<tr>
<th>Variant</th>
<th>Batch</th>
<th>System Qty</th>
<th>Location</th>
<th>Expiry</th>
<th>CD</th>
<th>Actual</th>
<th>Notes</th>
</tr>
</thead>
<tbody>${bodyHtml}</tbody>
</table>
</section>
`;
}).join('');
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Manual Stock Check</title>
<style>
@page { size: A4 portrait; margin: 8mm; }
* { box-sizing: border-box; }
body {
margin: 0;
color: #111827;
font-family: Arial, Helvetica, sans-serif;
font-size: 8.5pt;
}
header {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
margin-bottom: 5mm;
border-bottom: 1.5px solid #111827;
padding-bottom: 3mm;
}
h1 {
margin: 0 0 1.5mm;
font-size: 14pt;
line-height: 1.1;
}
.meta {
color: #374151;
font-size: 7.5pt;
line-height: 1.35;
}
.signoff {
min-width: 52mm;
display: grid;
gap: 2mm;
font-size: 7.5pt;
}
.signoff-line {
border-bottom: 1px solid #6b7280;
height: 5mm;
}
.empty {
border: 1px solid #d1d5db;
padding: 8mm;
color: #4b5563;
}
.drug-section {
break-inside: avoid;
page-break-inside: avoid;
margin-bottom: 4mm;
}
h2 {
margin: 0;
padding: 1mm 1.5mm;
background: #e5e7eb;
border: 1px solid #9ca3af;
border-bottom: 0;
font-size: 9.5pt;
line-height: 1.15;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
border: 1px solid #9ca3af;
padding: 1.2mm 1.4mm;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
line-height: 1.2;
}
th {
background: #f3f4f6;
font-size: 6.8pt;
text-transform: uppercase;
letter-spacing: 0;
padding-top: 1mm;
padding-bottom: 1mm;
}
th:nth-child(1) { width: 13%; }
th:nth-child(2) { width: 15%; }
th:nth-child(3) { width: 12%; }
th:nth-child(4) { width: 13%; }
th:nth-child(5) { width: 10%; }
th:nth-child(6) { width: 5%; }
th:nth-child(7) { width: 12%; }
th:nth-child(8) { width: 20%; }
td.manual-entry {
height: 7mm;
background: #ffffff;
}
.notes-cell {
min-height: 7mm;
}
@media print {
body { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
}
</style>
</head>
<body>
<header>
<div>
<h1>Manual Stock Check</h1>
<div class="meta">Generated: ${escapeHtml(generatedAt)}</div>
<div class="meta">Filters: ${escapeHtml(filterSummary)}</div>
<div class="meta">Inventory lines: ${escapeHtml(String(totalLines))}</div>
</div>
<div class="signoff">
<div>Checked by<div class="signoff-line"></div></div>
<div>Date<div class="signoff-line"></div></div>
</div>
</header>
${sectionsHtml || '<div class="empty">No inventory lines match the selected filters.</div>'}
<script>
window.addEventListener('load', () => {
window.focus();
window.print();
});
</script>
</body>
</html>
`;
}
function generateStockCheckPdf() {
if (activeReportType !== 'global_inventory') {
showToast('Select Global Inventory to generate a stock check PDF.', 'warning');
return;
}
const rows = getStockCheckRows();
if (!rows.length) {
showToast('No inventory lines match the selected filters.', 'warning');
return;
}
const printWindow = window.open('', '_blank');
if (!printWindow) {
showToast('Allow pop-ups to generate the stock check PDF.', 'warning');
return;
}
printWindow.document.open();
printWindow.document.write(buildStockCheckPdfHtml(rows));
printWindow.document.close();
}
async function apiCall(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
@@ -587,6 +913,8 @@ async function loadActiveReport() {
if (container) {
const loadingText = activeReportType === 'dispensing'
? 'Loading dispensing history...'
: activeReportType === 'global_inventory'
? 'Loading global inventory...'
: activeReportType === 'batch_attention'
? 'Loading expired batches...'
: 'Loading audit trail...';
@@ -604,6 +932,14 @@ async function loadActiveReport() {
dispensingRows = await response.json();
await ensureBatchLookupForDispensing(dispensingRows);
populateCommonFilters(dispensingRows);
} else if (activeReportType === 'global_inventory') {
const response = await apiCall('/reports/global-inventory');
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to load global inventory');
}
globalInventoryRows = await response.json();
populateCommonFilters(globalInventoryRows);
} else if (activeReportType === 'batch_attention') {
const response = await apiCall('/reports/batch-attention');
if (!response.ok) {
@@ -655,6 +991,7 @@ function setupEventListeners() {
const applyBtn = document.getElementById('applyReportFiltersBtn');
const clearBtn = document.getElementById('clearReportFiltersBtn');
const refreshBtn = document.getElementById('refreshReportsBtn');
const stockCheckPdfBtn = document.getElementById('stockCheckPdfBtn');
const backBtn = document.getElementById('backToInventoryBtn');
const logoutBtn = document.getElementById('reportsLogoutBtn');
const goToLoginBtn = document.getElementById('goToLoginBtn');
@@ -678,6 +1015,7 @@ function setupEventListeners() {
if (applyBtn) applyBtn.addEventListener('click', applyCurrentFilters);
if (refreshBtn) refreshBtn.addEventListener('click', loadActiveReport);
if (stockCheckPdfBtn) stockCheckPdfBtn.addEventListener('click', generateStockCheckPdf);
if (clearBtn) {
clearBtn.addEventListener('click', () => {
+117 -25
View File
@@ -685,7 +685,8 @@ footer {
max-height: calc(100vh - 48px) !important;
}
#dispenseModal.show {
#dispenseModal.show,
#disposeInventoryModal.show {
align-items: flex-start;
overflow-y: auto;
padding: 24px 0;
@@ -697,21 +698,22 @@ footer {
overflow-y: auto;
}
#dispenseForm {
#dispenseForm,
#disposeInventoryForm {
display: block;
padding-right: 6px;
}
#batchInfoSection,
#disposeBatchInfoSection,
#allocationPreviewSection {
max-height: 220px;
overflow-y: auto;
min-height: fit-content;
}
#dispenseModal .form-actions {
position: sticky;
bottom: 0;
#dispenseModal .form-actions,
#disposeInventoryModal .form-actions {
margin-top: 16px;
padding-top: 14px;
background: var(--white);
@@ -844,6 +846,8 @@ footer {
.report-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.reports-summary {
@@ -913,7 +917,7 @@ footer {
.delivery-line-grid {
display: grid;
grid-template-columns: 1.9fr 1.8fr 0.9fr 1.4fr 1.2fr 1.3fr auto;
grid-template-columns: 1.9fr 1.8fr 1.5fr 0.8fr 1.2fr 1.3fr auto;
gap: 12px;
align-items: end;
}
@@ -1069,11 +1073,13 @@ footer {
max-height: calc(100vh - 24px);
}
#dispenseModal.show {
#dispenseModal.show,
#disposeInventoryModal.show {
padding: 12px 0;
}
#batchInfoSection,
#disposeBatchInfoSection,
#allocationPreviewSection {
max-height: 160px;
}
@@ -1195,22 +1201,29 @@ footer {
}
.user-management-content h3 {
margin-top: 20px;
margin-top: 0;
margin-bottom: 15px;
color: var(--primary-color);
}
.user-create-panel {
padding: 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: #f8fafc;
}
.user-management-content .form-row {
display: flex;
display: grid;
grid-template-columns: minmax(150px, 1fr) minmax(150px, 1fr) minmax(150px, 0.8fr);
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.user-management-content input,
.user-management-content select {
flex: 1;
min-width: 150px;
width: 100%;
min-width: 0;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
@@ -1222,33 +1235,103 @@ footer {
}
.users-list {
margin-top: 20px;
margin-top: 24px;
}
.users-table {
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
}
.user-item {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(140px, 1fr) minmax(110px, auto) minmax(170px, 0.8fr) minmax(190px, auto);
align-items: center;
padding: 12px;
background: #f8f9fa;
padding: 12px 14px;
background: var(--white);
border: 1px solid var(--border-color);
border-radius: 6px;
gap: 15px;
border-radius: 8px;
gap: 12px;
}
.user-identity {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.user-identity strong {
overflow-wrap: anywhere;
}
.current-user-label,
.self-note {
color: #64748b;
font-size: 0.85em;
}
.current-user-label {
padding: 2px 8px;
border-radius: 999px;
background: #e2e8f0;
color: #334155;
}
.admin-badge {
padding: 4px 12px;
background: var(--warning-color);
justify-self: start;
padding: 5px 10px;
color: var(--white);
border-radius: 20px;
font-size: 0.9em;
border-radius: 999px;
font-size: 0.85em;
font-weight: 500;
white-space: nowrap;
}
.admin-badge.role-admin {
background: var(--warning-color);
}
.admin-badge.role-user {
background: var(--secondary-color);
}
.admin-badge.role-readonly {
background: #64748b;
}
.role-control {
display: flex;
align-items: center;
gap: 8px;
}
.role-control span {
color: #64748b;
font-size: 0.85em;
}
.role-control select {
margin: 0;
}
.role-control select:disabled {
color: #64748b;
background: #f1f5f9;
cursor: not-allowed;
}
.user-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.user-actions .btn {
margin-bottom: 0;
}
/* Responsive Styles */
@@ -1278,6 +1361,15 @@ footer {
max-width: none;
}
.user-management-content .form-row,
.user-item {
grid-template-columns: 1fr;
}
.user-actions {
justify-content: flex-start;
}
.drug-item {
flex-direction: column;
}
@@ -1386,4 +1478,4 @@ footer {
min-width: auto;
width: 100%;
}
}
}
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
import gzip
import os
import shutil
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
DB_PATH = Path(os.getenv("SQLITE_DB_PATH", "/data/drugs.db"))
BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "/data/backups"))
INTERVAL_SECONDS = int(os.getenv("BACKUP_INTERVAL_SECONDS", "3600"))
RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", "7"))
LATEST_BACKUP = BACKUP_DIR / "latest.db.gz"
def utc_stamp() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
def log(message: str) -> None:
print(f"[{utc_stamp()}] {message}", flush=True)
def run_integrity_check(db_path: Path) -> None:
with sqlite3.connect(str(db_path)) as conn:
result = conn.execute("PRAGMA integrity_check").fetchone()
if not result or result[0] != "ok":
detail = result[0] if result else "no result"
raise RuntimeError(f"SQLite integrity check failed: {detail}")
def create_backup() -> None:
if not DB_PATH.exists():
log(f"database not found at {DB_PATH}; skipping backup")
return
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
stamp = utc_stamp()
temp_db = BACKUP_DIR / f".{stamp}.db.tmp"
temp_gz = BACKUP_DIR / f".{stamp}.db.gz.tmp"
final_gz = BACKUP_DIR / f"drugs-{stamp}.db.gz"
for path in (temp_db, temp_gz):
if path.exists():
path.unlink()
source_uri = f"file:{DB_PATH}?mode=ro"
with sqlite3.connect(source_uri, uri=True) as source:
with sqlite3.connect(str(temp_db)) as target:
source.backup(target)
run_integrity_check(temp_db)
with temp_db.open("rb") as raw, gzip.open(temp_gz, "wb", compresslevel=6) as compressed:
shutil.copyfileobj(raw, compressed)
temp_gz.replace(final_gz)
shutil.copy2(final_gz, LATEST_BACKUP)
temp_db.unlink(missing_ok=True)
size_kb = final_gz.stat().st_size / 1024
log(f"created {final_gz.name} ({size_kb:.1f} KiB)")
def prune_old_backups() -> None:
if RETENTION_DAYS <= 0 or not BACKUP_DIR.exists():
return
cutoff = time.time() - (RETENTION_DAYS * 24 * 60 * 60)
deleted = 0
for backup in BACKUP_DIR.glob("drugs-*.db.gz"):
if backup.stat().st_mtime < cutoff:
backup.unlink()
deleted += 1
if deleted:
log(f"deleted {deleted} backup(s) older than {RETENTION_DAYS} day(s)")
def main() -> None:
log(
"starting SQLite backup loop: "
f"db={DB_PATH}, dir={BACKUP_DIR}, interval={INTERVAL_SECONDS}s, "
f"retention={RETENTION_DAYS}d"
)
while True:
try:
create_backup()
prune_old_backups()
except Exception as exc:
log(f"backup failed: {exc}")
time.sleep(INTERVAL_SECONDS)
if __name__ == "__main__":
main()