Compare commits
17 Commits
b958ca493b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0026ac2274 | |||
| b093e1a90c | |||
| 25c3f1fa64 | |||
| 3f230bb0d7 | |||
| 0fea301af1 | |||
| 562494a967 | |||
| 05a093afd3 | |||
| 9ec27e245a | |||
| 36634dc2bf | |||
| 6be571a48c | |||
| cfb08bd288 | |||
| 2aeba2f563 | |||
| d4753c0754 | |||
| 225202aacb | |||
| 4673de4ae5 | |||
| cdbf613e4b | |||
| 36f0a5b07e |
@@ -20,3 +20,4 @@ node_modules/
|
|||||||
.cache/
|
.cache/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
drugs.db
|
drugs.db
|
||||||
|
data/backups/
|
||||||
|
|||||||
@@ -77,9 +77,38 @@ docker-compose up --build
|
|||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
SQLite database is stored as `drugs.db` in the project root. It's a single file that persists between container restarts. You can:
|
SQLite database is stored as `./data/drugs.db`. It's a single file that persists between container restarts.
|
||||||
- Backup by copying the file
|
|
||||||
- Share with team members
|
### 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
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
+590
-40
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import FastAPI, Depends, HTTPException, APIRouter, status, Response
|
from fastapi import FastAPI, Depends, HTTPException, APIRouter, status, Response
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
import math
|
import math
|
||||||
@@ -18,11 +19,13 @@ from .models import (
|
|||||||
Batch,
|
Batch,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
User,
|
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 .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 .mqtt_service import publish_label_print_with_response
|
||||||
from .migrate_to_roles import migrate_users_table
|
from .migrate_to_roles import migrate_users_table
|
||||||
from .migrate_compliance import migrate_compliance_schema
|
from .migrate_compliance import migrate_compliance_schema
|
||||||
|
from .migrate_gtin import migrate_gtin_schema
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Run migration to convert is_admin to role
|
# Run migration to convert is_admin to role
|
||||||
@@ -39,6 +42,11 @@ except Exception as e:
|
|||||||
# Create tables
|
# Create tables
|
||||||
Base.metadata.create_all(bind=engine)
|
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.
|
# Seed default locations after table creation.
|
||||||
try:
|
try:
|
||||||
migrate_compliance_schema()
|
migrate_compliance_schema()
|
||||||
@@ -65,6 +73,9 @@ class UserCreate(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
role: Optional[str] = "user" # admin, user, readonly
|
role: Optional[str] = "user" # admin, user, readonly
|
||||||
|
|
||||||
|
class UserRoleUpdate(BaseModel):
|
||||||
|
role: str
|
||||||
|
|
||||||
class PasswordChange(BaseModel):
|
class PasswordChange(BaseModel):
|
||||||
current_password: str
|
current_password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
@@ -138,14 +149,37 @@ class BatchDisposeRequest(BaseModel):
|
|||||||
notes: Optional[str] = None
|
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):
|
class BatchResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
drug_variant_id: int
|
drug_variant_id: int
|
||||||
batch_number: str
|
batch_number: str
|
||||||
quantity: float
|
quantity: float
|
||||||
received_pack_id: Optional[int] = None
|
received_pack_id: Optional[int] = None
|
||||||
received_pack_label: Optional[str] = None
|
received_pack_unit_name: Optional[str] = None
|
||||||
received_pack_count: Optional[float] = None
|
|
||||||
received_pack_size_snapshot: Optional[float] = None
|
received_pack_size_snapshot: Optional[float] = None
|
||||||
current_full_pack_count: Optional[float] = None
|
current_full_pack_count: Optional[float] = None
|
||||||
current_loose_base_units: Optional[float] = None
|
current_loose_base_units: Optional[float] = None
|
||||||
@@ -187,14 +221,12 @@ class DrugVariantUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class VariantPackCreate(BaseModel):
|
class VariantPackCreate(BaseModel):
|
||||||
label: str
|
|
||||||
pack_unit_name: str
|
pack_unit_name: str
|
||||||
pack_size_in_base_units: float
|
pack_size_in_base_units: float
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
class VariantPackUpdate(BaseModel):
|
class VariantPackUpdate(BaseModel):
|
||||||
label: Optional[str] = None
|
|
||||||
pack_unit_name: Optional[str] = None
|
pack_unit_name: Optional[str] = None
|
||||||
pack_size_in_base_units: Optional[float] = None
|
pack_size_in_base_units: Optional[float] = None
|
||||||
is_active: Optional[bool] = None
|
is_active: Optional[bool] = None
|
||||||
@@ -203,7 +235,6 @@ class VariantPackUpdate(BaseModel):
|
|||||||
class VariantPackResponse(BaseModel):
|
class VariantPackResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
drug_variant_id: int
|
drug_variant_id: int
|
||||||
label: str
|
|
||||||
pack_unit_name: str
|
pack_unit_name: str
|
||||||
pack_size_in_base_units: float
|
pack_size_in_base_units: float
|
||||||
is_active: bool
|
is_active: bool
|
||||||
@@ -236,18 +267,59 @@ class DrugWithVariantsResponse(BaseModel):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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):
|
class DispensingAllocationCreate(BaseModel):
|
||||||
batch_id: int
|
batch_id: int
|
||||||
quantity: float
|
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):
|
class DispensingCreate(BaseModel):
|
||||||
drug_variant_id: int
|
drug_variant_id: int
|
||||||
quantity: Optional[float] = None
|
quantity: Optional[float] = None
|
||||||
dispense_mode: str = "subunit"
|
dispense_mode: str = "subunit"
|
||||||
|
dispense_source: str = "batch"
|
||||||
requested_pack_id: Optional[int] = None
|
requested_pack_id: Optional[int] = None
|
||||||
requested_pack_count: Optional[float] = None
|
requested_pack_count: Optional[float] = None
|
||||||
animal_name: Optional[str] = None
|
animal_name: Optional[str] = None
|
||||||
|
prescribing_vet: Optional[str] = None
|
||||||
user_name: Optional[str] = None
|
user_name: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
allocations: List[DispensingAllocationCreate] = []
|
allocations: List[DispensingAllocationCreate] = []
|
||||||
@@ -270,6 +342,7 @@ class DispensingResponse(BaseModel):
|
|||||||
requested_pack_id: Optional[int] = None
|
requested_pack_id: Optional[int] = None
|
||||||
requested_pack_count: Optional[float] = None
|
requested_pack_count: Optional[float] = None
|
||||||
animal_name: Optional[str] = None
|
animal_name: Optional[str] = None
|
||||||
|
prescribing_vet: Optional[str] = None
|
||||||
user_name: str
|
user_name: str
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
dispensed_at: datetime
|
dispensed_at: datetime
|
||||||
@@ -371,6 +444,40 @@ def enrich_variant_with_batches(db: Session, variant: DrugVariant) -> Dict[str,
|
|||||||
return variant_dict
|
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]:
|
def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
|
||||||
location = db.query(Location).filter(Location.id == batch.location_id).first()
|
location = db.query(Location).filter(Location.id == batch.location_id).first()
|
||||||
pack = None
|
pack = None
|
||||||
@@ -382,7 +489,7 @@ def serialize_batch_response(db: Session, batch: Batch) -> Dict[str, Any]:
|
|||||||
"batch_number": batch.batch_number,
|
"batch_number": batch.batch_number,
|
||||||
"quantity": batch.quantity,
|
"quantity": batch.quantity,
|
||||||
"received_pack_id": batch.received_pack_id,
|
"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_count": batch.received_pack_count,
|
||||||
"received_pack_size_snapshot": batch.received_pack_size_snapshot,
|
"received_pack_size_snapshot": batch.received_pack_size_snapshot,
|
||||||
"current_full_pack_count": batch.current_full_pack_count,
|
"current_full_pack_count": batch.current_full_pack_count,
|
||||||
@@ -428,7 +535,6 @@ def serialize_variant_pack(pack: VariantPack) -> Dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"id": pack.id,
|
"id": pack.id,
|
||||||
"drug_variant_id": pack.drug_variant_id,
|
"drug_variant_id": pack.drug_variant_id,
|
||||||
"label": pack.label,
|
|
||||||
"pack_unit_name": pack.pack_unit_name,
|
"pack_unit_name": pack.pack_unit_name,
|
||||||
"pack_size_in_base_units": pack.pack_size_in_base_units,
|
"pack_size_in_base_units": pack.pack_size_in_base_units,
|
||||||
"is_active": pack.is_active,
|
"is_active": pack.is_active,
|
||||||
@@ -496,12 +602,16 @@ def resolve_requested_allocations(
|
|||||||
requested_quantity: float,
|
requested_quantity: float,
|
||||||
requested_allocations: List[DispensingAllocationCreate],
|
requested_allocations: List[DispensingAllocationCreate],
|
||||||
dispense_mode: str,
|
dispense_mode: str,
|
||||||
|
dispense_source: str,
|
||||||
requested_pack_id: Optional[int],
|
requested_pack_id: Optional[int],
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Validate explicit batch allocations against in-date stock for the variant."""
|
"""Validate explicit batch allocations against in-date stock for the variant."""
|
||||||
today = date.today()
|
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())
|
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)
|
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 = (
|
eligible_batches = (
|
||||||
db.query(Batch)
|
db.query(Batch)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -513,6 +623,20 @@ def resolve_requested_allocations(
|
|||||||
.all()
|
.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 not eligible_batches:
|
||||||
if dispense_mode == "pack":
|
if dispense_mode == "pack":
|
||||||
raise HTTPException(status_code=400, detail="Whole-pack dispensing requires batched stock with pack information")
|
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()
|
db.commit()
|
||||||
return {"message": "User deleted successfully"}
|
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")
|
@router.post("/auth/change-password")
|
||||||
def change_own_password(password_data: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
def change_own_password(password_data: PasswordChange, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||||
"""Change current user's password"""
|
"""Change current user's password"""
|
||||||
@@ -821,21 +973,112 @@ def admin_change_password(user_id: int, password_data: AdminPasswordChange, db:
|
|||||||
def read_root():
|
def read_root():
|
||||||
return {"message": "Drug Inventory API"}
|
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)):
|
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()
|
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 = []
|
result = []
|
||||||
for drug in drugs:
|
for drug in drugs:
|
||||||
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all()
|
drug_variants = variants_by_drug.get(drug.id, [])
|
||||||
drug_dict = {
|
variant_summaries = [
|
||||||
"id": drug.id,
|
{
|
||||||
"name": drug.name,
|
"id": v.id,
|
||||||
"description": drug.description,
|
"drug_id": v.drug_id,
|
||||||
"is_controlled": bool(drug.is_controlled),
|
"strength": v.strength,
|
||||||
"variants": [enrich_variant_with_batches(db, v) for v in variants],
|
"quantity": v.quantity,
|
||||||
}
|
"unit": v.unit,
|
||||||
result.append(drug_dict)
|
"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
|
return result
|
||||||
|
|
||||||
@router.get("/drugs/low-stock", response_model=List[DrugWithVariantsResponse])
|
@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)
|
@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)):
|
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()
|
drug = db.query(Drug).filter(Drug.id == drug_id).first()
|
||||||
if not drug:
|
if not drug:
|
||||||
raise HTTPException(status_code=404, detail="Drug not found")
|
raise HTTPException(status_code=404, detail="Drug not found")
|
||||||
|
|
||||||
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all()
|
variants = db.query(DrugVariant).filter(DrugVariant.drug_id == drug.id).all()
|
||||||
drug_dict = {
|
drug_dict = {
|
||||||
"id": drug.id,
|
"id": drug.id,
|
||||||
"name": drug.name,
|
"name": drug.name,
|
||||||
"description": drug.description,
|
"description": drug.description,
|
||||||
"is_controlled": bool(drug.is_controlled),
|
"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
|
return drug_dict
|
||||||
|
|
||||||
@@ -1031,15 +1274,12 @@ def create_drug_variant(drug_id: int, variant: DrugVariantCreate, db: Session =
|
|||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Ensure each variant has at least one active default 1:1 pack representation.
|
# Ensure each variant has at least one active default 1:1 pack representation.
|
||||||
db.add(
|
db.add(VariantPack(
|
||||||
VariantPack(
|
|
||||||
drug_variant_id=db_variant.id,
|
drug_variant_id=db_variant.id,
|
||||||
label=f"1 {base_unit}",
|
|
||||||
pack_unit_name=base_unit,
|
pack_unit_name=base_unit,
|
||||||
pack_size_in_base_units=1,
|
pack_size_in_base_units=1,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
))
|
||||||
)
|
|
||||||
|
|
||||||
write_audit_log(
|
write_audit_log(
|
||||||
db,
|
db,
|
||||||
@@ -1213,10 +1453,7 @@ def create_variant_pack(
|
|||||||
if not variant:
|
if not variant:
|
||||||
raise HTTPException(status_code=404, detail="Drug variant not found")
|
raise HTTPException(status_code=404, detail="Drug variant not found")
|
||||||
|
|
||||||
label = payload.label.strip()
|
|
||||||
pack_unit_name = payload.pack_unit_name.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:
|
if not pack_unit_name:
|
||||||
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
|
raise HTTPException(status_code=400, detail="Pack unit name cannot be empty")
|
||||||
if payload.pack_size_in_base_units <= 0:
|
if payload.pack_size_in_base_units <= 0:
|
||||||
@@ -1224,7 +1461,6 @@ def create_variant_pack(
|
|||||||
|
|
||||||
row = VariantPack(
|
row = VariantPack(
|
||||||
drug_variant_id=variant_id,
|
drug_variant_id=variant_id,
|
||||||
label=label,
|
|
||||||
pack_unit_name=pack_unit_name,
|
pack_unit_name=pack_unit_name,
|
||||||
pack_size_in_base_units=payload.pack_size_in_base_units,
|
pack_size_in_base_units=payload.pack_size_in_base_units,
|
||||||
is_active=payload.is_active,
|
is_active=payload.is_active,
|
||||||
@@ -1238,7 +1474,6 @@ def create_variant_pack(
|
|||||||
actor=current_user,
|
actor=current_user,
|
||||||
details={
|
details={
|
||||||
"variant_id": variant_id,
|
"variant_id": variant_id,
|
||||||
"label": label,
|
|
||||||
"pack_unit_name": pack_unit_name,
|
"pack_unit_name": pack_unit_name,
|
||||||
"pack_size_in_base_units": payload.pack_size_in_base_units,
|
"pack_size_in_base_units": payload.pack_size_in_base_units,
|
||||||
"is_active": payload.is_active,
|
"is_active": payload.is_active,
|
||||||
@@ -1262,12 +1497,6 @@ def update_variant_pack(
|
|||||||
|
|
||||||
before = serialize_variant_pack(row)
|
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:
|
if payload.pack_unit_name is not None:
|
||||||
cleaned = payload.pack_unit_name.strip()
|
cleaned = payload.pack_unit_name.strip()
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
@@ -1359,9 +1588,12 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
|
|||||||
requested_quantity=dispense_qty,
|
requested_quantity=dispense_qty,
|
||||||
requested_allocations=dispensing.allocations,
|
requested_allocations=dispensing.allocations,
|
||||||
dispense_mode=dispense_mode,
|
dispense_mode=dispense_mode,
|
||||||
|
dispense_source=dispensing.dispense_source,
|
||||||
requested_pack_id=resolved["pack_id"],
|
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
|
user_name = dispensing.user_name or current_user.username
|
||||||
primary_batch_id = allocations[0]["batch"].id if allocations else None
|
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_id=resolved["pack_id"],
|
||||||
requested_pack_count=resolved["pack_count"],
|
requested_pack_count=resolved["pack_count"],
|
||||||
animal_name=dispensing.animal_name,
|
animal_name=dispensing.animal_name,
|
||||||
|
prescribing_vet=dispensing.prescribing_vet,
|
||||||
user_name=user_name,
|
user_name=user_name,
|
||||||
notes=dispensing.notes,
|
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,
|
"drug_variant_id": dispensing.drug_variant_id,
|
||||||
"requested_quantity": dispense_qty,
|
"requested_quantity": dispense_qty,
|
||||||
"dispense_mode": dispense_mode,
|
"dispense_mode": dispense_mode,
|
||||||
|
"dispense_source": selected_source,
|
||||||
"requested_pack_id": resolved["pack_id"],
|
"requested_pack_id": resolved["pack_id"],
|
||||||
"requested_pack_count": resolved["pack_count"],
|
"requested_pack_count": resolved["pack_count"],
|
||||||
"allocations": allocation_payload,
|
"allocations": allocation_payload,
|
||||||
@@ -1428,6 +1662,110 @@ def dispense_drug(dispensing: DispensingCreate, db: Session = Depends(get_db), c
|
|||||||
"allocations": allocation_payload,
|
"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])
|
@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)):
|
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)"""
|
"""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_id": item.requested_pack_id,
|
||||||
"requested_pack_count": item.requested_pack_count,
|
"requested_pack_count": item.requested_pack_count,
|
||||||
"animal_name": item.animal_name,
|
"animal_name": item.animal_name,
|
||||||
|
"prescribing_vet": item.prescribing_vet,
|
||||||
"user_name": item.user_name,
|
"user_name": item.user_name,
|
||||||
"notes": item.notes,
|
"notes": item.notes,
|
||||||
"dispensed_at": item.dispensed_at,
|
"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_id": item.requested_pack_id,
|
||||||
"requested_pack_count": item.requested_pack_count,
|
"requested_pack_count": item.requested_pack_count,
|
||||||
"animal_name": item.animal_name,
|
"animal_name": item.animal_name,
|
||||||
|
"prescribing_vet": item.prescribing_vet,
|
||||||
"user_name": item.user_name,
|
"user_name": item.user_name,
|
||||||
"notes": item.notes,
|
"notes": item.notes,
|
||||||
"dispensed_at": item.dispensed_at,
|
"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_id": item.requested_pack_id,
|
||||||
"requested_pack_count": item.requested_pack_count,
|
"requested_pack_count": item.requested_pack_count,
|
||||||
"animal_name": item.animal_name,
|
"animal_name": item.animal_name,
|
||||||
|
"prescribing_vet": item.prescribing_vet,
|
||||||
"user_name": item.user_name,
|
"user_name": item.user_name,
|
||||||
"notes": item.notes,
|
"notes": item.notes,
|
||||||
"dispensed_at": item.dispensed_at,
|
"dispensed_at": item.dispensed_at,
|
||||||
@@ -1635,7 +1976,41 @@ def create_variant_batch(variant_id: int, payload: BatchCreate, db: Session = De
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if existing:
|
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(
|
row = Batch(
|
||||||
drug_variant_id=variant_id,
|
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)
|
@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()
|
batch = db.query(Batch).filter(Batch.id == batch_id).first()
|
||||||
if not batch:
|
if not batch:
|
||||||
raise HTTPException(status_code=404, detail="Batch not found")
|
raise HTTPException(status_code=404, detail="Batch not found")
|
||||||
@@ -2054,6 +2429,98 @@ def report_stock_by_location(
|
|||||||
return result
|
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")
|
@router.get("/reports/batch-attention")
|
||||||
def report_batch_attention(
|
def report_batch_attention(
|
||||||
format: str = "json",
|
format: str = "json",
|
||||||
@@ -2085,7 +2552,7 @@ def report_batch_attention(
|
|||||||
"location": location.name,
|
"location": location.name,
|
||||||
"expiry_date": batch.expiry_date,
|
"expiry_date": batch.expiry_date,
|
||||||
"status": "expired",
|
"status": "expired",
|
||||||
"received_pack_label": None,
|
"received_pack_unit_name": None,
|
||||||
"current_full_pack_count": batch.current_full_pack_count,
|
"current_full_pack_count": batch.current_full_pack_count,
|
||||||
"current_loose_base_units": batch.current_loose_base_units,
|
"current_loose_base_units": batch.current_loose_base_units,
|
||||||
"is_controlled": bool(drug.is_controlled),
|
"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)}"
|
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
|
# Include router with /api prefix
|
||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ def migrate_compliance_schema() -> None:
|
|||||||
CREATE TABLE variant_packs (
|
CREATE TABLE variant_packs (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
drug_variant_id INTEGER NOT NULL,
|
drug_variant_id INTEGER NOT NULL,
|
||||||
label VARCHAR NOT NULL,
|
|
||||||
pack_unit_name VARCHAR NOT NULL DEFAULT 'pack',
|
pack_unit_name VARCHAR NOT NULL DEFAULT 'pack',
|
||||||
pack_size_in_base_units FLOAT NOT NULL DEFAULT 1,
|
pack_size_in_base_units FLOAT NOT NULL DEFAULT 1,
|
||||||
is_active BOOLEAN 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"):
|
if _table_exists(cursor, "variant_packs") and _table_exists(cursor, "drug_variants"):
|
||||||
cursor.execute(
|
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,
|
SELECT v.id,
|
||||||
'1 ' || COALESCE(NULLIF(v.unit, ''), 'unit'),
|
|
||||||
COALESCE(NULLIF(v.unit, ''), 'unit'),
|
COALESCE(NULLIF(v.unit, ''), 'unit'),
|
||||||
1,
|
1,
|
||||||
1
|
1
|
||||||
@@ -230,6 +228,10 @@ def migrate_compliance_schema() -> None:
|
|||||||
)
|
)
|
||||||
print("Backfilled dispensing mode where missing")
|
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).
|
# Seed default locations once table exists (created via SQLAlchemy create_all).
|
||||||
if _table_exists(cursor, "locations"):
|
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 ('Cupboard', 1)")
|
||||||
|
|||||||
@@ -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
@@ -41,7 +41,6 @@ class VariantPack(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
drug_variant_id = Column(Integer, ForeignKey("drug_variants.id"), nullable=False, 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_unit_name = Column(String, nullable=False, default="pack")
|
||||||
pack_size_in_base_units = Column(Float, nullable=False, default=1)
|
pack_size_in_base_units = Column(Float, nullable=False, default=1)
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
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_id = Column(Integer, ForeignKey("variant_packs.id"), nullable=True)
|
||||||
requested_pack_count = Column(Float, nullable=True)
|
requested_pack_count = Column(Float, nullable=True)
|
||||||
animal_name = Column(String, nullable=True) # Name/ID of the animal (optional)
|
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
|
user_name = Column(String, nullable=False) # User who dispensed
|
||||||
dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
dispensed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
notes = Column(String, nullable=True)
|
notes = Column(String, nullable=True)
|
||||||
@@ -109,6 +109,17 @@ class DispensingAllocation(Base):
|
|||||||
quantity = Column(Float, nullable=False)
|
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):
|
class AuditLog(Base):
|
||||||
__tablename__ = "audit_logs"
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
fastapi==0.104.1
|
fastapi==0.137.2
|
||||||
uvicorn==0.24.0
|
uvicorn==0.49.0
|
||||||
sqlalchemy==2.0.23
|
sqlalchemy==2.0.51
|
||||||
pydantic==2.5.0
|
pydantic==2.13.4
|
||||||
python-multipart==0.0.6
|
python-multipart==0.0.32
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.5.0
|
||||||
passlib[argon2]==1.7.4
|
passlib[argon2]==1.7.4
|
||||||
paho-mqtt==1.6.1
|
paho-mqtt==1.6.1
|
||||||
|
|||||||
+18
-5
@@ -10,8 +10,8 @@ services:
|
|||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite:///./data/drugs.db
|
- DATABASE_URL=sqlite:///./data/drugs.db
|
||||||
- PUID=1001
|
- PUID=${PUID:-1000}
|
||||||
- PGID=1001
|
- PGID=${PGID:-1000}
|
||||||
- MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-mosquitto}
|
- MQTT_BROKER_HOST=${MQTT_BROKER_HOST:-mosquitto}
|
||||||
- MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883}
|
- MQTT_BROKER_PORT=${MQTT_BROKER_PORT:-1883}
|
||||||
- MQTT_USERNAME=${MQTT_USERNAME:-}
|
- MQTT_USERNAME=${MQTT_USERNAME:-}
|
||||||
@@ -23,6 +23,20 @@ services:
|
|||||||
- LABEL_SIZE=${LABEL_SIZE:-29x90}
|
- LABEL_SIZE=${LABEL_SIZE:-29x90}
|
||||||
- LABEL_TEST=${LABEL_TEST:-false}
|
- 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:
|
mosquitto:
|
||||||
image: eclipse-mosquitto:latest
|
image: eclipse-mosquitto:latest
|
||||||
volumes:
|
volumes:
|
||||||
@@ -31,12 +45,11 @@ services:
|
|||||||
- mosquitto_data:/mosquitto/data
|
- mosquitto_data:/mosquitto/data
|
||||||
- mosquitto_logs:/mosquitto/log
|
- mosquitto_logs:/mosquitto/log
|
||||||
environment:
|
environment:
|
||||||
- PUID=1001
|
- PUID=${PUID:-1000}
|
||||||
- PGID=1001
|
- PGID=${PGID:-1000}
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: drugsdev
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/usr/share/nginx/html:ro
|
- ./frontend:/usr/share/nginx/html:ro
|
||||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
|||||||
+2157
-266
File diff suppressed because it is too large
Load Diff
+187
-71
@@ -59,6 +59,7 @@
|
|||||||
<div class="inventory-toolbar">
|
<div class="inventory-toolbar">
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="printNotesBtn" class="btn btn-primary btn-small">📝 Print Notes</button>
|
<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>
|
<button id="addDrugBtn" class="btn btn-primary btn-small">➕ Add Drug</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-search">
|
<div class="toolbar-search">
|
||||||
@@ -137,36 +138,6 @@
|
|||||||
This is a Controlled Substance
|
This is a Controlled Substance
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">Add Drug</button>
|
<button type="submit" class="btn btn-primary">Add Drug</button>
|
||||||
@@ -205,7 +176,7 @@
|
|||||||
|
|
||||||
<div class="form-group" id="dispenseQuantityGroup">
|
<div class="form-group" id="dispenseQuantityGroup">
|
||||||
<label for="dispenseQuantity">Quantity *</label>
|
<label for="dispenseQuantity">Quantity *</label>
|
||||||
<input type="number" id="dispenseQuantity" step="0.1">
|
<input type="number" id="dispenseQuantity" step="1.0">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row" id="dispensePackRow" style="display: none;">
|
<div class="form-row" id="dispensePackRow" style="display: none;">
|
||||||
@@ -222,6 +193,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</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;">
|
<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>
|
<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>
|
<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">
|
<input type="text" id="dispenseAnimal">
|
||||||
</div>
|
</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;">
|
<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;">
|
<label style="display: inline-flex; align-items: center; gap: 8px; margin-bottom: 0; font-weight: 600;">
|
||||||
<input type="checkbox" id="dispensePrintEnabled">
|
<input type="checkbox" id="dispensePrintEnabled">
|
||||||
@@ -273,50 +264,90 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Prescribe Drug Modal -->
|
<!-- Dispose Inventory Modal -->
|
||||||
<div id="prescribeModal" class="modal">
|
<div id="disposeInventoryModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content modal-large dispense-modal-content">
|
||||||
<span class="close">×</span>
|
<span class="close">×</span>
|
||||||
<h2>Prescribe Drug & Print Label</h2>
|
<h2>Dispose Inventory</h2>
|
||||||
<form id="prescribeForm" novalidate>
|
<form id="disposeInventoryForm" novalidate>
|
||||||
<input type="hidden" id="prescribeVariantId">
|
|
||||||
<input type="hidden" id="prescribeDrugName">
|
|
||||||
<input type="hidden" id="prescribeVariantStrength">
|
|
||||||
<input type="hidden" id="prescribeUnit">
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="prescribeQuantity">Quantity *</label>
|
<label for="disposeDrugSelect">Drug Variant *</label>
|
||||||
<input type="number" id="prescribeQuantity" step="0.1" required>
|
<select id="disposeDrugSelect" onchange="updateDisposeBatchInfo()">
|
||||||
|
<option value="">-- Select a drug variant --</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="prescribeAnimal">Animal Name/ID *</label>
|
<label>Disposal Mode *</label>
|
||||||
<input type="text" id="prescribeAnimal" required>
|
<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>
|
||||||
|
|
||||||
|
<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">
|
<div class="form-group">
|
||||||
<label for="prescribeDosage">Dosage Instructions *</label>
|
<label for="disposeNotes">Disposal Note</label>
|
||||||
<input type="text" id="prescribeDosage" placeholder="e.g., 1 tablet twice daily with food" required>
|
<textarea id="disposeNotes" rows="4" placeholder="Optional note for the audit log"></textarea>
|
||||||
</div>
|
</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">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">Prescribe & Print Label</button>
|
<button type="submit" class="btn btn-danger">Dispose</button>
|
||||||
<button type="button" class="btn btn-secondary" id="cancelPrescribeBtn">Cancel</button>
|
<button type="button" class="btn btn-secondary" id="cancelDisposeInventoryBtn">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,14 +362,14 @@
|
|||||||
<input type="hidden" id="variantDrugId">
|
<input type="hidden" id="variantDrugId">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="variantStrength">Strength *</label>
|
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="variantUnit">Base Unit *</label>
|
<label for="variantUnit">Base Unit *</label>
|
||||||
<select id="variantUnit">
|
<select id="variantUnit">
|
||||||
<option value="ml">ml</option>
|
<option value="ml">ml</option>
|
||||||
<option value="tablets">tablets</option>
|
<option value="tablets" selected>tablets</option>
|
||||||
<option value="capsules">capsules</option>
|
<option value="capsules">capsules</option>
|
||||||
<option value="units">units</option>
|
<option value="units">units</option>
|
||||||
<option value="vials">vials</option>
|
<option value="vials">vials</option>
|
||||||
@@ -490,7 +521,7 @@
|
|||||||
<span class="close">×</span>
|
<span class="close">×</span>
|
||||||
<h2>User Management</h2>
|
<h2>User Management</h2>
|
||||||
<div class="user-management-content">
|
<div class="user-management-content">
|
||||||
<div class="form-group">
|
<section class="user-create-panel">
|
||||||
<h3>Create New User</h3>
|
<h3>Create New User</h3>
|
||||||
<form id="createUserForm">
|
<form id="createUserForm">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -505,7 +536,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-small">Create User</button>
|
<button type="submit" class="btn btn-primary btn-small">Create User</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</section>
|
||||||
<div id="usersList" class="users-list">
|
<div id="usersList" class="users-list">
|
||||||
<h3>Users</h3>
|
<h3>Users</h3>
|
||||||
<p class="loading">Loading users...</p>
|
<p class="loading">Loading users...</p>
|
||||||
@@ -580,6 +611,11 @@
|
|||||||
<input type="text" id="disposeBatchName" disabled>
|
<input type="text" id="disposeBatchName" disabled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="disposeBatchStockSummary">Stock to Dispose</label>
|
||||||
|
<input type="text" id="disposeBatchStockSummary" disabled>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<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>
|
<p style="margin: 0; color: #666;">This will mark the expired batch as disposed and remove its remaining stock from inventory.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -640,6 +676,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Pack Size Modal -->
|
||||||
|
<div id="addPackSizeModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</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 -->
|
<!-- Receive Delivery Modal -->
|
||||||
<div id="receiveDeliveryModal" class="modal">
|
<div id="receiveDeliveryModal" class="modal">
|
||||||
<div class="modal-content modal-large receive-delivery-modal-content">
|
<div class="modal-content modal-large receive-delivery-modal-content">
|
||||||
@@ -651,6 +722,7 @@
|
|||||||
<div class="delivery-toolbar">
|
<div class="delivery-toolbar">
|
||||||
<button type="button" id="addDeliveryLineBtn" class="btn btn-secondary">+ Add Delivery Line</button>
|
<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="addVariantFromDeliveryBtn" class="btn btn-info">+ Add Variant</button>
|
||||||
|
<button type="button" id="addPackSizeFromDeliveryBtn" class="btn btn-info">+ Add Pack Size</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">Receive Delivery</button>
|
<button type="submit" class="btn btn-primary">Receive Delivery</button>
|
||||||
@@ -660,6 +732,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- GTIN Mapping Modal -->
|
||||||
|
<div id="gtinMappingModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close">×</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>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<label for="reportTypeSelect">Report</label>
|
<label for="reportTypeSelect">Report</label>
|
||||||
<select id="reportTypeSelect">
|
<select id="reportTypeSelect">
|
||||||
<option value="dispensing" selected>Dispensing History</option>
|
<option value="dispensing" selected>Dispensing History</option>
|
||||||
|
<option value="global_inventory">Stock Check</option>
|
||||||
<option value="batch_attention">Expired Batches</option>
|
<option value="batch_attention">Expired Batches</option>
|
||||||
<option value="audit">Audit Trail (Raw)</option>
|
<option value="audit">Audit Trail (Raw)</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
<button id="applyReportFiltersBtn" type="button" class="btn btn-primary btn-small">Apply Filters</button>
|
<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="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="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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,4 +121,4 @@
|
|||||||
|
|
||||||
<script src="reports.js"></script>
|
<script src="reports.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+340
-2
@@ -5,6 +5,7 @@ let currentUser = null;
|
|||||||
let allDrugs = [];
|
let allDrugs = [];
|
||||||
let auditTrailRows = [];
|
let auditTrailRows = [];
|
||||||
let dispensingRows = [];
|
let dispensingRows = [];
|
||||||
|
let globalInventoryRows = [];
|
||||||
let batchAttentionRows = [];
|
let batchAttentionRows = [];
|
||||||
let activeReportType = 'dispensing';
|
let activeReportType = 'dispensing';
|
||||||
const batchLookupById = new Map();
|
const batchLookupById = new Map();
|
||||||
@@ -200,24 +201,28 @@ function detailsContainsText(details, searchText) {
|
|||||||
|
|
||||||
function getActiveRows() {
|
function getActiveRows() {
|
||||||
if (activeReportType === 'dispensing') return dispensingRows;
|
if (activeReportType === 'dispensing') return dispensingRows;
|
||||||
|
if (activeReportType === 'global_inventory') return globalInventoryRows;
|
||||||
if (activeReportType === 'batch_attention') return batchAttentionRows;
|
if (activeReportType === 'batch_attention') return batchAttentionRows;
|
||||||
return auditTrailRows;
|
return auditTrailRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRowUser(row) {
|
function getRowUser(row) {
|
||||||
if (activeReportType === 'dispensing') return row.user_name || 'unknown';
|
if (activeReportType === 'dispensing') return row.user_name || 'unknown';
|
||||||
|
if (activeReportType === 'global_inventory') return '';
|
||||||
if (activeReportType === 'batch_attention') return '';
|
if (activeReportType === 'batch_attention') return '';
|
||||||
return row.actor_username || 'system';
|
return row.actor_username || 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRowDrug(row) {
|
function getRowDrug(row) {
|
||||||
if (activeReportType === 'dispensing') return extractDrugLabelFromDispenseRow(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}` : ''}`;
|
if (activeReportType === 'batch_attention') return `${row.drug_name || 'Unknown Drug'}${row.strength ? ` ${row.strength}` : ''}`;
|
||||||
return extractDrugLabelFromAuditRow(row);
|
return extractDrugLabelFromAuditRow(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRowDate(row) {
|
function getRowDate(row) {
|
||||||
if (activeReportType === 'dispensing') return new Date(row.dispensed_at);
|
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);
|
if (activeReportType === 'batch_attention') return new Date(row.expiry_date);
|
||||||
return new Date(row.created_at);
|
return new Date(row.created_at);
|
||||||
}
|
}
|
||||||
@@ -312,6 +317,7 @@ function renderDispensingTable(rows) {
|
|||||||
const info = getVariantInfoById(row.drug_variant_id);
|
const info = getVariantInfoById(row.drug_variant_id);
|
||||||
const quantityText = `${row.quantity} ${info.unit || 'units'}`;
|
const quantityText = `${row.quantity} ${info.unit || 'units'}`;
|
||||||
const animal = row.animal_name || '-';
|
const animal = row.animal_name || '-';
|
||||||
|
const vet = row.prescribing_vet || '-';
|
||||||
const notes = row.notes || '-';
|
const notes = row.notes || '-';
|
||||||
const allocations = formatDispenseAllocation(row);
|
const allocations = formatDispenseAllocation(row);
|
||||||
|
|
||||||
@@ -323,6 +329,7 @@ function renderDispensingTable(rows) {
|
|||||||
<td>${escapeHtml(info.strength || '-')}</td>
|
<td>${escapeHtml(info.strength || '-')}</td>
|
||||||
<td>${escapeHtml(quantityText)}</td>
|
<td>${escapeHtml(quantityText)}</td>
|
||||||
<td>${escapeHtml(animal)}</td>
|
<td>${escapeHtml(animal)}</td>
|
||||||
|
<td>${escapeHtml(vet)}</td>
|
||||||
<td>${escapeHtml(allocations)}</td>
|
<td>${escapeHtml(allocations)}</td>
|
||||||
<td>${escapeHtml(notes)}</td>
|
<td>${escapeHtml(notes)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -339,6 +346,7 @@ function renderDispensingTable(rows) {
|
|||||||
<th>Strength</th>
|
<th>Strength</th>
|
||||||
<th>Quantity</th>
|
<th>Quantity</th>
|
||||||
<th>Animal</th>
|
<th>Animal</th>
|
||||||
|
<th>Prescribing Vet</th>
|
||||||
<th>Batch Allocation</th>
|
<th>Batch Allocation</th>
|
||||||
<th>Notes</th>
|
<th>Notes</th>
|
||||||
</tr>
|
</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) {
|
function renderBatchAttentionTable(rows) {
|
||||||
const container = document.getElementById('reportsTableContainer');
|
const container = document.getElementById('reportsTableContainer');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -469,8 +521,8 @@ function applyCurrentFilters() {
|
|||||||
const userMatch = !selectedUser || getRowUser(row) === selectedUser;
|
const userMatch = !selectedUser || getRowUser(row) === selectedUser;
|
||||||
const drugMatch = !selectedDrug || getRowDrug(row) === selectedDrug;
|
const drugMatch = !selectedDrug || getRowDrug(row) === selectedDrug;
|
||||||
const rowDate = getRowDate(row);
|
const rowDate = getRowDate(row);
|
||||||
const fromMatch = !fromDate || rowDate >= fromDate;
|
const fromMatch = !fromDate || !rowDate || rowDate >= fromDate;
|
||||||
const toMatch = !toDate || rowDate <= toDate;
|
const toMatch = !toDate || !rowDate || rowDate <= toDate;
|
||||||
|
|
||||||
let textMatch = true;
|
let textMatch = true;
|
||||||
if (searchText) {
|
if (searchText) {
|
||||||
@@ -481,10 +533,21 @@ function applyCurrentFilters() {
|
|||||||
info.drugName || '',
|
info.drugName || '',
|
||||||
info.strength || '',
|
info.strength || '',
|
||||||
row.animal_name || '',
|
row.animal_name || '',
|
||||||
|
row.prescribing_vet || '',
|
||||||
row.notes || '',
|
row.notes || '',
|
||||||
formatDispenseAllocation(row)
|
formatDispenseAllocation(row)
|
||||||
].join(' ').toLowerCase();
|
].join(' ').toLowerCase();
|
||||||
textMatch = haystack.includes(searchText);
|
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') {
|
} else if (activeReportType === 'batch_attention') {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
row.drug_name || '',
|
row.drug_name || '',
|
||||||
@@ -512,6 +575,8 @@ function applyCurrentFilters() {
|
|||||||
if (reportsSummary) {
|
if (reportsSummary) {
|
||||||
const reportName = activeReportType === 'dispensing'
|
const reportName = activeReportType === 'dispensing'
|
||||||
? 'dispensing records'
|
? 'dispensing records'
|
||||||
|
: activeReportType === 'global_inventory'
|
||||||
|
? 'inventory lines'
|
||||||
: activeReportType === 'batch_attention'
|
: activeReportType === 'batch_attention'
|
||||||
? 'expired batches'
|
? 'expired batches'
|
||||||
: 'audit events';
|
: 'audit events';
|
||||||
@@ -520,34 +585,295 @@ function applyCurrentFilters() {
|
|||||||
|
|
||||||
if (activeReportType === 'dispensing') {
|
if (activeReportType === 'dispensing') {
|
||||||
renderDispensingTable(filteredRows);
|
renderDispensingTable(filteredRows);
|
||||||
|
} else if (activeReportType === 'global_inventory') {
|
||||||
|
renderGlobalInventoryTable(filteredRows);
|
||||||
} else if (activeReportType === 'batch_attention') {
|
} else if (activeReportType === 'batch_attention') {
|
||||||
renderBatchAttentionTable(filteredRows);
|
renderBatchAttentionTable(filteredRows);
|
||||||
} else {
|
} else {
|
||||||
renderAuditTable(filteredRows);
|
renderAuditTable(filteredRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return filteredRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateReportHeading() {
|
function updateReportHeading() {
|
||||||
const heading = document.getElementById('reportsHeading');
|
const heading = document.getElementById('reportsHeading');
|
||||||
const searchInput = document.getElementById('reportActionSearch');
|
const searchInput = document.getElementById('reportActionSearch');
|
||||||
const userFilter = document.getElementById('reportUserFilter')?.closest('.report-control');
|
const userFilter = document.getElementById('reportUserFilter')?.closest('.report-control');
|
||||||
|
const stockCheckPdfBtn = document.getElementById('stockCheckPdfBtn');
|
||||||
if (!heading || !searchInput) return;
|
if (!heading || !searchInput) return;
|
||||||
|
|
||||||
if (activeReportType === 'dispensing') {
|
if (activeReportType === 'dispensing') {
|
||||||
heading.textContent = 'Dispensing History';
|
heading.textContent = 'Dispensing History';
|
||||||
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
|
searchInput.placeholder = 'Search user, drug, animal, notes, batch allocation...';
|
||||||
if (userFilter) userFilter.style.display = '';
|
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') {
|
} else if (activeReportType === 'batch_attention') {
|
||||||
heading.textContent = 'Expired Batches';
|
heading.textContent = 'Expired Batches';
|
||||||
searchInput.placeholder = 'Search drug, batch, location...';
|
searchInput.placeholder = 'Search drug, batch, location...';
|
||||||
if (userFilter) userFilter.style.display = 'none';
|
if (userFilter) userFilter.style.display = 'none';
|
||||||
|
if (stockCheckPdfBtn) stockCheckPdfBtn.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
heading.textContent = 'Audit Trail (Raw)';
|
heading.textContent = 'Audit Trail (Raw)';
|
||||||
searchInput.placeholder = 'Search action, entity, details...';
|
searchInput.placeholder = 'Search action, entity, details...';
|
||||||
if (userFilter) userFilter.style.display = '';
|
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 = {}) {
|
async function apiCall(endpoint, options = {}) {
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -587,6 +913,8 @@ async function loadActiveReport() {
|
|||||||
if (container) {
|
if (container) {
|
||||||
const loadingText = activeReportType === 'dispensing'
|
const loadingText = activeReportType === 'dispensing'
|
||||||
? 'Loading dispensing history...'
|
? 'Loading dispensing history...'
|
||||||
|
: activeReportType === 'global_inventory'
|
||||||
|
? 'Loading global inventory...'
|
||||||
: activeReportType === 'batch_attention'
|
: activeReportType === 'batch_attention'
|
||||||
? 'Loading expired batches...'
|
? 'Loading expired batches...'
|
||||||
: 'Loading audit trail...';
|
: 'Loading audit trail...';
|
||||||
@@ -604,6 +932,14 @@ async function loadActiveReport() {
|
|||||||
dispensingRows = await response.json();
|
dispensingRows = await response.json();
|
||||||
await ensureBatchLookupForDispensing(dispensingRows);
|
await ensureBatchLookupForDispensing(dispensingRows);
|
||||||
populateCommonFilters(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') {
|
} else if (activeReportType === 'batch_attention') {
|
||||||
const response = await apiCall('/reports/batch-attention');
|
const response = await apiCall('/reports/batch-attention');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -655,6 +991,7 @@ function setupEventListeners() {
|
|||||||
const applyBtn = document.getElementById('applyReportFiltersBtn');
|
const applyBtn = document.getElementById('applyReportFiltersBtn');
|
||||||
const clearBtn = document.getElementById('clearReportFiltersBtn');
|
const clearBtn = document.getElementById('clearReportFiltersBtn');
|
||||||
const refreshBtn = document.getElementById('refreshReportsBtn');
|
const refreshBtn = document.getElementById('refreshReportsBtn');
|
||||||
|
const stockCheckPdfBtn = document.getElementById('stockCheckPdfBtn');
|
||||||
const backBtn = document.getElementById('backToInventoryBtn');
|
const backBtn = document.getElementById('backToInventoryBtn');
|
||||||
const logoutBtn = document.getElementById('reportsLogoutBtn');
|
const logoutBtn = document.getElementById('reportsLogoutBtn');
|
||||||
const goToLoginBtn = document.getElementById('goToLoginBtn');
|
const goToLoginBtn = document.getElementById('goToLoginBtn');
|
||||||
@@ -678,6 +1015,7 @@ function setupEventListeners() {
|
|||||||
|
|
||||||
if (applyBtn) applyBtn.addEventListener('click', applyCurrentFilters);
|
if (applyBtn) applyBtn.addEventListener('click', applyCurrentFilters);
|
||||||
if (refreshBtn) refreshBtn.addEventListener('click', loadActiveReport);
|
if (refreshBtn) refreshBtn.addEventListener('click', loadActiveReport);
|
||||||
|
if (stockCheckPdfBtn) stockCheckPdfBtn.addEventListener('click', generateStockCheckPdf);
|
||||||
|
|
||||||
if (clearBtn) {
|
if (clearBtn) {
|
||||||
clearBtn.addEventListener('click', () => {
|
clearBtn.addEventListener('click', () => {
|
||||||
|
|||||||
+117
-25
@@ -685,7 +685,8 @@ footer {
|
|||||||
max-height: calc(100vh - 48px) !important;
|
max-height: calc(100vh - 48px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#dispenseModal.show {
|
#dispenseModal.show,
|
||||||
|
#disposeInventoryModal.show {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
@@ -697,21 +698,22 @@ footer {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#dispenseForm {
|
#dispenseForm,
|
||||||
|
#disposeInventoryForm {
|
||||||
display: block;
|
display: block;
|
||||||
padding-right: 6px;
|
padding-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#batchInfoSection,
|
#batchInfoSection,
|
||||||
|
#disposeBatchInfoSection,
|
||||||
#allocationPreviewSection {
|
#allocationPreviewSection {
|
||||||
max-height: 220px;
|
max-height: 220px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: fit-content;
|
min-height: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
#dispenseModal .form-actions {
|
#dispenseModal .form-actions,
|
||||||
position: sticky;
|
#disposeInventoryModal .form-actions {
|
||||||
bottom: 0;
|
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
padding-top: 14px;
|
padding-top: 14px;
|
||||||
background: var(--white);
|
background: var(--white);
|
||||||
@@ -844,6 +846,8 @@ footer {
|
|||||||
.report-actions {
|
.report-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reports-summary {
|
.reports-summary {
|
||||||
@@ -913,7 +917,7 @@ footer {
|
|||||||
|
|
||||||
.delivery-line-grid {
|
.delivery-line-grid {
|
||||||
display: 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;
|
gap: 12px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
@@ -1069,11 +1073,13 @@ footer {
|
|||||||
max-height: calc(100vh - 24px);
|
max-height: calc(100vh - 24px);
|
||||||
}
|
}
|
||||||
|
|
||||||
#dispenseModal.show {
|
#dispenseModal.show,
|
||||||
|
#disposeInventoryModal.show {
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#batchInfoSection,
|
#batchInfoSection,
|
||||||
|
#disposeBatchInfoSection,
|
||||||
#allocationPreviewSection {
|
#allocationPreviewSection {
|
||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
}
|
}
|
||||||
@@ -1195,22 +1201,29 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-management-content h3 {
|
.user-management-content h3 {
|
||||||
margin-top: 20px;
|
margin-top: 0;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
color: var(--primary-color);
|
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 {
|
.user-management-content .form-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(150px, 1fr) minmax(150px, 1fr) minmax(150px, 0.8fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-management-content input,
|
.user-management-content input,
|
||||||
.user-management-content select {
|
.user-management-content select {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
min-width: 150px;
|
min-width: 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -1222,33 +1235,103 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.users-list {
|
.users-list {
|
||||||
margin-top: 20px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.users-table {
|
.users-table {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-item {
|
.user-item {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: space-between;
|
grid-template-columns: minmax(140px, 1fr) minmax(110px, auto) minmax(170px, 0.8fr) minmax(190px, auto);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px;
|
padding: 12px 14px;
|
||||||
background: #f8f9fa;
|
background: var(--white);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
gap: 15px;
|
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 {
|
.admin-badge {
|
||||||
padding: 4px 12px;
|
justify-self: start;
|
||||||
background: var(--warning-color);
|
padding: 5px 10px;
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
border-radius: 20px;
|
border-radius: 999px;
|
||||||
font-size: 0.9em;
|
font-size: 0.85em;
|
||||||
font-weight: 500;
|
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 */
|
/* Responsive Styles */
|
||||||
@@ -1278,6 +1361,15 @@ footer {
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-management-content .form-row,
|
||||||
|
.user-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.drug-item {
|
.drug-item {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -1386,4 +1478,4 @@ footer {
|
|||||||
min-width: auto;
|
min-width: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user