Step 9:药品管理(IMP)

This commit is contained in:
Cheng Zhou
2025-12-16 19:26:45 +08:00
parent 566ebdf749
commit 882fcb0963
107 changed files with 855 additions and 1 deletions
+10
View File
@@ -0,0 +1,10 @@
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session
from app.schemas.imp import TxCreate
router = APIRouter()
+95
View File
@@ -0,0 +1,95 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
from app.crud import audit as audit_crud
from app.crud import imp_batch as batch_crud
from app.crud import study as study_crud
from app.schemas.imp import BatchCreate, BatchRead, BatchUpdate
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
return study
@router.post(
"/",
response_model=BatchRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
)
async def create_batch(
study_id: uuid.UUID,
batch_in: BatchCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> BatchRead:
await _ensure_study_exists(db, study_id)
try:
batch = await batch_crud.create_batch(db, study_id, batch_in)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="imp_batch",
entity_id=batch.id,
action="CREATE_IMP_BATCH",
detail=f"IMP batch {batch.batch_no} created",
operator_id=current_user.id,
operator_role=current_user.role,
)
return BatchRead.model_validate(batch)
@router.get(
"/",
response_model=list[BatchRead],
dependencies=[Depends(require_study_member())],
)
async def list_batches(
study_id: uuid.UUID,
product_id: uuid.UUID | None = None,
status_filter: str | None = None,
db: AsyncSession = Depends(get_db_session),
) -> list[BatchRead]:
await _ensure_study_exists(db, study_id)
batches = await batch_crud.list_batches(db, study_id, product_id=product_id, status=status_filter)
return [BatchRead.model_validate(b) for b in batches]
@router.patch(
"/{batch_id}",
response_model=BatchRead,
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
)
async def update_batch(
study_id: uuid.UUID,
batch_id: uuid.UUID,
batch_in: BatchUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> BatchRead:
await _ensure_study_exists(db, study_id)
batch = await batch_crud.get_batch(db, batch_id)
if not batch or batch.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Batch not found")
updated = await batch_crud.update_batch(db, batch, batch_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="imp_batch",
entity_id=batch_id,
action="UPDATE_IMP_BATCH",
detail=f"IMP batch {batch_id} updated",
operator_id=current_user.id,
operator_role=current_user.role,
)
return BatchRead.model_validate(updated)
+32
View File
@@ -0,0 +1,32 @@
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member
from app.crud import imp_inventory as inventory_crud
from app.crud import study as study_crud
from app.schemas.imp import InventoryRead
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
return study
@router.get(
"/",
response_model=list[InventoryRead],
dependencies=[Depends(require_study_member())],
)
async def list_inventory(
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
product_id: uuid.UUID | None = None,
db: AsyncSession = Depends(get_db_session),
) -> list[InventoryRead]:
await _ensure_study_exists(db, study_id)
inv = await inventory_crud.list_inventory(db, study_id, site_id=site_id, product_id=product_id)
return [InventoryRead.model_validate(i) for i in inv]
+91
View File
@@ -0,0 +1,91 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
from app.crud import audit as audit_crud
from app.crud import imp_product as product_crud
from app.crud import study as study_crud
from app.schemas.imp import ProductCreate, ProductRead, ProductUpdate
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
return study
@router.post(
"/",
response_model=ProductRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
)
async def create_product(
study_id: uuid.UUID,
product_in: ProductCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> ProductRead:
await _ensure_study_exists(db, study_id)
product = await product_crud.create_product(db, study_id, product_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="imp_product",
entity_id=product.id,
action="CREATE_IMP_PRODUCT",
detail=f"IMP product {product.name} created",
operator_id=current_user.id,
operator_role=current_user.role,
)
return ProductRead.model_validate(product)
@router.get(
"/",
response_model=list[ProductRead],
dependencies=[Depends(require_study_member())],
)
async def list_products(
study_id: uuid.UUID,
is_active: bool | None = None,
db: AsyncSession = Depends(get_db_session),
) -> list[ProductRead]:
await _ensure_study_exists(db, study_id)
products = await product_crud.list_products(db, study_id, is_active=is_active)
return [ProductRead.model_validate(p) for p in products]
@router.patch(
"/{product_id}",
response_model=ProductRead,
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
)
async def update_product(
study_id: uuid.UUID,
product_id: uuid.UUID,
product_in: ProductUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> ProductRead:
await _ensure_study_exists(db, study_id)
product = await product_crud.get_product(db, product_id)
if not product or product.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found")
updated = await product_crud.update_product(db, product, product_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="imp_product",
entity_id=product_id,
action="UPDATE_IMP_PRODUCT",
detail=f"IMP product {product_id} updated",
operator_id=current_user.id,
operator_role=current_user.role,
)
return ProductRead.model_validate(updated)
+112
View File
@@ -0,0 +1,112 @@
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
from app.crud import audit as audit_crud
from app.crud import imp_batch as batch_crud
from app.crud import imp_tx as tx_crud
from app.crud import study as study_crud
from app.crud import subject as subject_crud
from app.crud import member as member_crud
from app.schemas.imp import TxCreate, TxRead
from app.services.imp import create_transaction_and_apply_inventory, NEGATIVE_TYPES
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
return study
async def _validate_entities(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID, subject_id: uuid.UUID | None):
batch = await batch_crud.get_batch(db, batch_id)
if not batch or batch.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Batch not found")
from app.models.site import Site
site = await db.get(Site, site_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
if subject_id:
subj = await subject_crud.get_subject(db, subject_id)
if not subj or subj.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found")
if batch.expiry_date and date.today() > batch.expiry_date and batch_id:
# forbid outflow when expired
pass
@router.post(
"/",
response_model=TxRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
)
async def create_transaction(
study_id: uuid.UUID,
tx_in: TxCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> TxRead:
await _ensure_study_exists(db, study_id)
await _validate_entities(db, study_id, tx_in.site_id, tx_in.batch_id, tx_in.subject_id)
if tx_in.tx_type in NEGATIVE_TYPES:
batch = await batch_crud.get_batch(db, tx_in.batch_id)
if batch and batch.status == "EXPIRED":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot dispense from expired batch")
try:
tx = await create_transaction_and_apply_inventory(
db,
study_id=study_id,
site_id=tx_in.site_id,
batch_id=tx_in.batch_id,
subject_id=tx_in.subject_id,
tx_type=tx_in.tx_type,
quantity=tx_in.quantity,
tx_date=tx_in.tx_date,
reference=tx_in.reference,
notes=tx_in.notes,
operator_id=current_user.id,
operator_role=current_user.role,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return TxRead.model_validate(tx)
@router.get(
"/",
response_model=list[TxRead],
dependencies=[Depends(require_study_member())],
)
async def list_transactions(
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
batch_id: uuid.UUID | None = None,
subject_id: uuid.UUID | None = None,
tx_type: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
) -> list[TxRead]:
await _ensure_study_exists(db, study_id)
txs = await tx_crud.list_txs(
db,
study_id,
site_id=site_id,
batch_id=batch_id,
subject_id=subject_id,
tx_type=tx_type,
date_from=date_from,
date_to=date_to,
skip=skip,
limit=limit,
)
return [TxRead.model_validate(t) for t in txs]
+5 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard, subjects, visits, aes, issues, data_queries, verifications from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -20,3 +20,7 @@ api_router.include_router(aes.router, prefix="/studies/{study_id}/aes", tags=["a
api_router.include_router(issues.router, prefix="/studies/{study_id}/issues", tags=["issues"]) api_router.include_router(issues.router, prefix="/studies/{study_id}/issues", tags=["issues"])
api_router.include_router(data_queries.router, prefix="/studies/{study_id}/data-queries", tags=["data-queries"]) api_router.include_router(data_queries.router, prefix="/studies/{study_id}/data-queries", tags=["data-queries"])
api_router.include_router(verifications.router, prefix="/studies/{study_id}/verifications", tags=["verifications"]) api_router.include_router(verifications.router, prefix="/studies/{study_id}/verifications", tags=["verifications"])
api_router.include_router(imp_products.router, prefix="/studies/{study_id}/imp/products", tags=["imp-products"])
api_router.include_router(imp_batches.router, prefix="/studies/{study_id}/imp/batches", tags=["imp-batches"])
api_router.include_router(imp_inventory.router, prefix="/studies/{study_id}/imp/inventory", tags=["imp-inventory"])
api_router.include_router(imp_transactions.router, prefix="/studies/{study_id}/imp/transactions", tags=["imp-transactions"])
+57
View File
@@ -0,0 +1,57 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imp_batch import ImpBatch
from app.models.imp_product import ImpProduct
from app.schemas.imp import BatchCreate, BatchUpdate
async def create_batch(db: AsyncSession, study_id: uuid.UUID, batch_in: BatchCreate) -> ImpBatch:
result = await db.execute(select(ImpProduct).where(ImpProduct.id == batch_in.product_id))
product = result.scalar_one_or_none()
if not product or product.study_id != study_id:
raise ValueError("Product not found in study")
batch = ImpBatch(
study_id=study_id,
product_id=batch_in.product_id,
batch_no=batch_in.batch_no,
expiry_date=batch_in.expiry_date,
manufacture_date=batch_in.manufacture_date,
status="ACTIVE",
)
db.add(batch)
await db.commit()
await db.refresh(batch)
return batch
async def get_batch(db: AsyncSession, batch_id: uuid.UUID) -> ImpBatch | None:
result = await db.execute(select(ImpBatch).where(ImpBatch.id == batch_id))
return result.scalar_one_or_none()
async def list_batches(db: AsyncSession, study_id: uuid.UUID, product_id: uuid.UUID | None = None, status: str | None = None) -> Sequence[ImpBatch]:
stmt = select(ImpBatch).where(ImpBatch.study_id == study_id)
if product_id:
stmt = stmt.where(ImpBatch.product_id == product_id)
if status:
stmt = stmt.where(ImpBatch.status == status)
result = await db.execute(stmt)
return result.scalars().all()
async def update_batch(db: AsyncSession, batch: ImpBatch, batch_in: BatchUpdate) -> ImpBatch:
update_data = batch_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
sa_update(ImpBatch)
.where(ImpBatch.id == batch.id)
.values(**update_data)
)
await db.commit()
await db.refresh(batch)
return batch
+46
View File
@@ -0,0 +1,46 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imp_inventory import ImpInventory
from app.models.imp_batch import ImpBatch
async def get_inventory_for_update(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID) -> ImpInventory | None:
result = await db.execute(
select(ImpInventory)
.where(
ImpInventory.study_id == study_id,
ImpInventory.site_id == site_id,
ImpInventory.batch_id == batch_id,
)
.with_for_update()
)
return result.scalar_one_or_none()
async def get_or_create_inventory(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID) -> ImpInventory:
inv = await get_inventory_for_update(db, study_id, site_id, batch_id)
if inv:
return inv
inv = ImpInventory(study_id=study_id, site_id=site_id, batch_id=batch_id, quantity_on_hand=0)
db.add(inv)
await db.flush()
return inv
async def list_inventory(
db: AsyncSession,
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
product_id: uuid.UUID | None = None,
) -> Sequence[ImpInventory]:
stmt = select(ImpInventory).where(ImpInventory.study_id == study_id)
if site_id:
stmt = stmt.where(ImpInventory.site_id == site_id)
if product_id:
stmt = stmt.join(ImpBatch, ImpBatch.id == ImpInventory.batch_id).where(ImpBatch.product_id == product_id)
result = await db.execute(stmt)
return result.scalars().all()
+50
View File
@@ -0,0 +1,50 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imp_product import ImpProduct
from app.schemas.imp import ProductCreate, ProductUpdate
async def create_product(db: AsyncSession, study_id: uuid.UUID, product_in: ProductCreate) -> ImpProduct:
product = ImpProduct(
study_id=study_id,
name=product_in.name,
form=product_in.form,
strength=product_in.strength,
unit=product_in.unit,
description=product_in.description,
is_active=product_in.is_active,
)
db.add(product)
await db.commit()
await db.refresh(product)
return product
async def get_product(db: AsyncSession, product_id: uuid.UUID) -> ImpProduct | None:
result = await db.execute(select(ImpProduct).where(ImpProduct.id == product_id))
return result.scalar_one_or_none()
async def list_products(db: AsyncSession, study_id: uuid.UUID, is_active: bool | None = None) -> Sequence[ImpProduct]:
stmt = select(ImpProduct).where(ImpProduct.study_id == study_id)
if is_active is not None:
stmt = stmt.where(ImpProduct.is_active == is_active)
result = await db.execute(stmt)
return result.scalars().all()
async def update_product(db: AsyncSession, product: ImpProduct, product_in: ProductUpdate) -> ImpProduct:
update_data = product_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
sa_update(ImpProduct)
.where(ImpProduct.id == product.id)
.values(**update_data)
)
await db.commit()
await db.refresh(product)
return product
+70
View File
@@ -0,0 +1,70 @@
import uuid
from datetime import date
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imp_transaction import ImpTransaction
async def create_tx(
db: AsyncSession,
*,
study_id: uuid.UUID,
site_id: uuid.UUID,
batch_id: uuid.UUID,
subject_id: uuid.UUID | None,
tx_type: str,
quantity: int,
tx_date: date,
reference: str | None,
notes: str | None,
created_by: uuid.UUID,
) -> ImpTransaction:
tx = ImpTransaction(
study_id=study_id,
site_id=site_id,
batch_id=batch_id,
subject_id=subject_id,
tx_type=tx_type,
quantity=quantity,
tx_date=tx_date,
reference=reference,
notes=notes,
created_by=created_by,
)
db.add(tx)
await db.flush()
return tx
async def list_txs(
db: AsyncSession,
study_id: uuid.UUID,
*,
site_id: uuid.UUID | None = None,
batch_id: uuid.UUID | None = None,
subject_id: uuid.UUID | None = None,
tx_type: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[ImpTransaction]:
stmt = select(ImpTransaction).where(ImpTransaction.study_id == study_id)
if site_id:
stmt = stmt.where(ImpTransaction.site_id == site_id)
if batch_id:
stmt = stmt.where(ImpTransaction.batch_id == batch_id)
if subject_id:
stmt = stmt.where(ImpTransaction.subject_id == subject_id)
if tx_type:
stmt = stmt.where(ImpTransaction.tx_type == tx_type)
if date_from:
stmt = stmt.where(ImpTransaction.tx_date >= date_from)
if date_to:
stmt = stmt.where(ImpTransaction.tx_date <= date_to)
stmt = stmt.offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
+4
View File
@@ -16,3 +16,7 @@ from app.models.ae import AdverseEvent # noqa: F401
from app.models.issue import Issue # noqa: F401 from app.models.issue import Issue # noqa: F401
from app.models.data_query import DataQuery # noqa: F401 from app.models.data_query import DataQuery # noqa: F401
from app.models.verification import VerificationProgress # noqa: F401 from app.models.verification import VerificationProgress # noqa: F401
from app.models.imp_product import ImpProduct # noqa: F401
from app.models.imp_batch import ImpBatch # noqa: F401
from app.models.imp_inventory import ImpInventory # noqa: F401
from app.models.imp_transaction import ImpTransaction # noqa: F401
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class ImpBatch(Base):
__tablename__ = "imp_batches"
__table_args__ = (UniqueConstraint("study_id", "batch_no", "product_id", name="uq_imp_batch_no"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
product_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("imp_products.id"), index=True, nullable=False)
batch_no: Mapped[str] = mapped_column(String(100), nullable=False)
expiry_date: Mapped[date | None] = mapped_column(Date, nullable=True)
manufacture_date: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+20
View File
@@ -0,0 +1,20 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class ImpInventory(Base):
__tablename__ = "imp_inventory"
__table_args__ = (UniqueConstraint("study_id", "site_id", "batch_id", name="uq_imp_inventory_site_batch"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
batch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("imp_batches.id"), index=True, nullable=False)
quantity_on_hand: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
+26
View File
@@ -0,0 +1,26 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class ImpProduct(Base):
__tablename__ = "imp_products"
__table_args__ = (UniqueConstraint("study_id", "name", name="uq_imp_product_name"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
form: Mapped[str | None] = mapped_column(String(100), nullable=True)
strength: Mapped[str | None] = mapped_column(String(100), nullable=True)
unit: Mapped[str] = mapped_column(String(50), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class ImpTransaction(Base):
__tablename__ = "imp_transactions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
batch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("imp_batches.id"), index=True, nullable=False)
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), nullable=True)
tx_type: Mapped[str] = mapped_column(String(20), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False)
tx_date: Mapped[date] = mapped_column(Date, nullable=False)
reference: Mapped[str | None] = mapped_column(String(255), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+102
View File
@@ -0,0 +1,102 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class ProductCreate(BaseModel):
name: str
form: Optional[str] = None
strength: Optional[str] = None
unit: str
description: Optional[str] = None
is_active: bool = True
class ProductUpdate(BaseModel):
name: Optional[str] = None
form: Optional[str] = None
strength: Optional[str] = None
unit: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class ProductRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
name: str
form: Optional[str]
strength: Optional[str]
unit: str
description: Optional[str]
is_active: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class BatchCreate(BaseModel):
product_id: uuid.UUID
batch_no: str
expiry_date: Optional[date] = None
manufacture_date: Optional[date] = None
class BatchUpdate(BaseModel):
status: Optional[str] = None
expiry_date: Optional[date] = None
manufacture_date: Optional[date] = None
class BatchRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
product_id: uuid.UUID
batch_no: str
expiry_date: Optional[date]
manufacture_date: Optional[date]
status: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class InventoryRead(BaseModel):
site_id: uuid.UUID
batch_id: uuid.UUID
quantity_on_hand: int
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class TxCreate(BaseModel):
site_id: uuid.UUID
batch_id: uuid.UUID
subject_id: Optional[uuid.UUID] = None
tx_type: str
quantity: int = Field(gt=0)
tx_date: date
reference: Optional[str] = None
notes: Optional[str] = None
class TxRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_id: uuid.UUID
batch_id: uuid.UUID
subject_id: Optional[uuid.UUID]
tx_type: str
quantity: int
tx_date: date
reference: Optional[str]
notes: Optional[str]
created_by: uuid.UUID
created_at: datetime
model_config = ConfigDict(from_attributes=True)
+85
View File
@@ -0,0 +1,85 @@
import uuid
from typing import Literal
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import imp_inventory as inventory_crud
from app.crud import imp_tx as tx_crud
from app.crud import audit as audit_crud
from app.models.imp_batch import ImpBatch
from app.models.site import Site
from app.models.subject import Subject
TxType = Literal["RECEIPT", "DISPENSE", "RETURN", "RECONCILE", "DESTROY", "TRANSFER_IN", "TRANSFER_OUT"]
POSITIVE_TYPES = {"RECEIPT", "RETURN", "TRANSFER_IN"}
NEGATIVE_TYPES = {"DISPENSE", "DESTROY", "TRANSFER_OUT"}
async def _validate_entities(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID, subject_id: uuid.UUID | None):
site = await db.get(Site, site_id)
if not site or site.study_id != study_id:
raise ValueError("Site not found in study")
batch = await db.get(ImpBatch, batch_id)
if not batch or batch.study_id != study_id:
raise ValueError("Batch not found in study")
# batch status check could be extended
if subject_id:
subj = await db.get(Subject, subject_id)
if not subj or subj.study_id != study_id:
raise ValueError("Subject not found in study")
async def create_transaction_and_apply_inventory(
db: AsyncSession,
*,
study_id: uuid.UUID,
site_id: uuid.UUID,
batch_id: uuid.UUID,
subject_id: uuid.UUID | None,
tx_type: TxType,
quantity: int,
tx_date,
reference: str | None,
notes: str | None,
operator_id: uuid.UUID,
operator_role: str,
) -> tuple:
if quantity <= 0:
raise ValueError("Quantity must be positive")
await _validate_entities(db, study_id, site_id, batch_id, subject_id)
async with db.begin_nested():
inv = await inventory_crud.get_or_create_inventory(db, study_id, site_id, batch_id)
delta = quantity if tx_type in POSITIVE_TYPES else -quantity
new_qoh = inv.quantity_on_hand + delta
if new_qoh < 0:
raise ValueError("Insufficient inventory")
inv.quantity_on_hand = new_qoh
tx = await tx_crud.create_tx(
db,
study_id=study_id,
site_id=site_id,
batch_id=batch_id,
subject_id=subject_id,
tx_type=tx_type,
quantity=quantity,
tx_date=tx_date,
reference=reference,
notes=notes,
created_by=operator_id,
)
await db.flush()
detail = f"{tx_type} {quantity} units batch {batch_id} at site {site_id}, QOH -> {new_qoh}"
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="imp_tx",
entity_id=tx.id,
action="CREATE_IMP_TX",
detail=detail,
operator_id=operator_id,
operator_role=operator_role,
)
return tx
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More