首次提交by MimoCode
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class VisionResult(BaseModel):
|
||||
generic_name: Optional[str] = None
|
||||
brand_name: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
specification: Optional[str] = None
|
||||
|
||||
|
||||
class DateResult(BaseModel):
|
||||
production_date: Optional[str] = None
|
||||
expiry_date: Optional[str] = None
|
||||
|
||||
|
||||
class LeafletResult(BaseModel):
|
||||
indications: str
|
||||
adult_dose: str
|
||||
child_dose: Optional[str] = None
|
||||
contraindications: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class VisionProvider(ABC):
|
||||
@abstractmethod
|
||||
async def recognize_medicine(self, image_bytes: bytes) -> VisionResult:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def recognize_dates(self, image_bytes: bytes) -> DateResult:
|
||||
pass
|
||||
|
||||
|
||||
class TextProvider(ABC):
|
||||
@abstractmethod
|
||||
async def summarize_leaflet(self, text: str) -> LeafletResult:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def natural_language_search(self, query: str, medicines: list) -> list:
|
||||
pass
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Optional
|
||||
from app.ai.base import VisionProvider, TextProvider
|
||||
|
||||
|
||||
class AIManager:
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self.vision_providers: dict[str, VisionProvider] = {}
|
||||
self.text_providers: dict[str, TextProvider] = {}
|
||||
self._initialized = True
|
||||
|
||||
def register_vision_provider(self, name: str, provider: VisionProvider):
|
||||
self.vision_providers[name] = provider
|
||||
|
||||
def register_text_provider(self, name: str, provider: TextProvider):
|
||||
self.text_providers[name] = provider
|
||||
|
||||
def get_vision_provider(self, name: str) -> Optional[VisionProvider]:
|
||||
return self.vision_providers.get(name)
|
||||
|
||||
def get_text_provider(self, name: str) -> Optional[TextProvider]:
|
||||
return self.text_providers.get(name)
|
||||
|
||||
|
||||
ai_manager = AIManager()
|
||||
@@ -0,0 +1,153 @@
|
||||
import base64
|
||||
import json
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.ai.base import VisionProvider, TextProvider, VisionResult, DateResult, LeafletResult
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class OpenAIVisionProvider(VisionProvider):
|
||||
def __init__(self):
|
||||
self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
self.model = settings.OPENAI_MODEL
|
||||
|
||||
async def recognize_medicine(self, image_bytes: bytes) -> VisionResult:
|
||||
base64_image = base64.b64encode(image_bytes).decode('utf-8')
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": """请识别这张药品包装图片中的信息,返回JSON格式:
|
||||
{
|
||||
"generic_name": "通用名称",
|
||||
"brand_name": "商品名称",
|
||||
"manufacturer": "生产厂家",
|
||||
"specification": "规格"
|
||||
}
|
||||
只提取图片中真实出现的内容,不要猜测。"""
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return VisionResult(**result)
|
||||
|
||||
async def recognize_dates(self, image_bytes: bytes) -> DateResult:
|
||||
base64_image = base64.b64encode(image_bytes).decode('utf-8')
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": """请识别这张图片中的日期信息,返回JSON格式:
|
||||
{
|
||||
"production_date": "生产日期(YYYY-MM-DD格式,如果无法识别则为null)",
|
||||
"expiry_date": "有效期/过期日期(YYYY-MM-DD格式,如果无法识别则为null)"
|
||||
}
|
||||
只提取图片中真实出现的日期,不要猜测或推理。"""
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return DateResult(**result)
|
||||
|
||||
|
||||
class OpenAITextProvider(TextProvider):
|
||||
def __init__(self):
|
||||
self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
self.model = "gpt-4"
|
||||
|
||||
async def summarize_leaflet(self, text: str) -> LeafletResult:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个医疗信息提取助手。请从药品说明书中提取关键信息。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"""请从以下药品说明书中提取关键信息,返回JSON格式:
|
||||
{{
|
||||
"indications": "适应症",
|
||||
"adult_dose": "成人用法用量",
|
||||
"child_dose": "儿童用法用量(如果没有则为null)",
|
||||
"contraindications": "禁忌",
|
||||
"notes": "注意事项(如果有)"
|
||||
}}
|
||||
|
||||
说明书内容:
|
||||
{text}"""
|
||||
}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return LeafletResult(**result)
|
||||
|
||||
async def natural_language_search(self, query: str, medicines: list) -> list:
|
||||
medicines_text = "\n".join([
|
||||
f"- {m['name']}: {m.get('indications', '')}"
|
||||
for m in medicines
|
||||
])
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个药品搜索助手。根据用户描述的症状,从药品列表中找出可能适用的药品。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"""用户描述:{query}
|
||||
|
||||
可用药品列表:
|
||||
{medicines_text}
|
||||
|
||||
请返回JSON格式的搜索结果:
|
||||
{{
|
||||
"results": [
|
||||
{{
|
||||
"medicine_id": 药品ID,
|
||||
"name": "药品名称",
|
||||
"reason": "匹配原因"
|
||||
}}
|
||||
]
|
||||
}}"""
|
||||
}
|
||||
],
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
|
||||
result = json.loads(response.choices[0].message.content)
|
||||
return result.get('results', [])
|
||||
@@ -0,0 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1 import auth, medicines, batches, categories, search, notifications, ai, users, settings
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(auth.router, prefix="/v1/auth", tags=["认证"])
|
||||
api_router.include_router(medicines.router, prefix="/v1/medicines", tags=["药品管理"])
|
||||
api_router.include_router(batches.router, prefix="/v1/batches", tags=["批次管理"])
|
||||
api_router.include_router(categories.router, prefix="/v1/categories", tags=["分类管理"])
|
||||
api_router.include_router(search.router, prefix="/v1/search", tags=["搜索"])
|
||||
api_router.include_router(notifications.router, prefix="/v1/notifications", tags=["通知"])
|
||||
api_router.include_router(ai.router, prefix="/v1/ai", tags=["AI 识别"])
|
||||
api_router.include_router(users.router, prefix="/v1/users", tags=["用户管理"])
|
||||
api_router.include_router(settings.router, prefix="/v1/settings", tags=["系统设置"])
|
||||
@@ -0,0 +1,115 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user, require_role
|
||||
from app.models.user import User
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/recognize-medicine")
|
||||
async def recognize_medicine(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
from app.ai.manager import ai_manager
|
||||
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="请上传图片文件")
|
||||
|
||||
image_bytes = await file.read()
|
||||
if len(image_bytes) > settings.MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=413, detail="文件过大")
|
||||
|
||||
vision_provider = ai_manager.get_vision_provider(settings.AI_PROVIDER)
|
||||
if not vision_provider:
|
||||
raise HTTPException(status_code=500, detail="AI 服务未配置")
|
||||
|
||||
try:
|
||||
result = await vision_provider.recognize_medicine(image_bytes)
|
||||
return result.model_dump()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/recognize-dates")
|
||||
async def recognize_dates(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
from app.ai.manager import ai_manager
|
||||
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="请上传图片文件")
|
||||
|
||||
image_bytes = await file.read()
|
||||
if len(image_bytes) > settings.MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=413, detail="文件过大")
|
||||
|
||||
vision_provider = ai_manager.get_vision_provider(settings.AI_PROVIDER)
|
||||
if not vision_provider:
|
||||
raise HTTPException(status_code=500, detail="AI 服务未配置")
|
||||
|
||||
try:
|
||||
result = await vision_provider.recognize_dates(image_bytes)
|
||||
return result.model_dump()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/recognize-leaflet")
|
||||
async def recognize_leaflet(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
from app.ai.manager import ai_manager
|
||||
|
||||
if not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="请上传图片文件")
|
||||
|
||||
image_bytes = await file.read()
|
||||
if len(image_bytes) > settings.MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=413, detail="文件过大")
|
||||
|
||||
text_provider = ai_manager.get_text_provider(settings.AI_PROVIDER)
|
||||
if not text_provider:
|
||||
raise HTTPException(status_code=500, detail="AI 服务未配置")
|
||||
|
||||
try:
|
||||
import base64
|
||||
base64_image = base64.b64encode(image_bytes).decode('utf-8')
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
|
||||
response = await client.chat.completions.create(
|
||||
model=settings.OPENAI_MODEL,
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "请识别这张说明书图片中的文字内容,返回纯文本。"
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
ocr_text = response.choices[0].message.content
|
||||
result = await text_provider.summarize_leaflet(ocr_text)
|
||||
return result.model_dump()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
|
||||
@@ -0,0 +1,42 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.services.auth import AuthService
|
||||
from app.schemas.user import UserLogin, Token, UserResponse
|
||||
from app.schemas.auth import PasswordChangeRequest
|
||||
from app.services.user import UserService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(data: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||
service = AuthService(db)
|
||||
result = await service.login(data.username, data.password)
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
return Token(
|
||||
access_token=result["access_token"],
|
||||
token_type=result["token_type"],
|
||||
user=UserResponse.model_validate(result["user"])
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_info(current_user=Depends(get_current_user)):
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@router.put("/password")
|
||||
async def change_password(
|
||||
data: PasswordChangeRequest,
|
||||
current_user=Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
service = UserService(db)
|
||||
success = await service.change_password(current_user.id, data.old_password, data.new_password)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
return {"message": "密码修改成功"}
|
||||
@@ -0,0 +1,104 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user, require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.batch import BatchCreate, BatchUpdate, BatchResponse, BatchDispense, BatchAddStock
|
||||
from app.services.batch import BatchService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/medicine/{medicine_id}", response_model=List[BatchResponse])
|
||||
async def list_batches_by_medicine(
|
||||
medicine_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = BatchService(db)
|
||||
batches = await service.get_batches_by_medicine(medicine_id)
|
||||
return [BatchResponse.model_validate(b) for b in batches]
|
||||
|
||||
|
||||
@router.get("/{batch_id}", response_model=BatchResponse)
|
||||
async def get_batch(
|
||||
batch_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = BatchService(db)
|
||||
batch = await service.get_batch(batch_id)
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="批次不存在")
|
||||
return BatchResponse.model_validate(batch)
|
||||
|
||||
|
||||
@router.post("/medicine/{medicine_id}", response_model=BatchResponse)
|
||||
async def create_batch(
|
||||
medicine_id: int,
|
||||
data: BatchCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
service = BatchService(db)
|
||||
batch = await service.create_batch(medicine_id, data)
|
||||
return BatchResponse.model_validate(batch)
|
||||
|
||||
|
||||
@router.put("/{batch_id}", response_model=BatchResponse)
|
||||
async def update_batch(
|
||||
batch_id: int,
|
||||
data: BatchUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
service = BatchService(db)
|
||||
batch = await service.update_batch(batch_id, data)
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="批次不存在")
|
||||
return BatchResponse.model_validate(batch)
|
||||
|
||||
|
||||
@router.delete("/{batch_id}")
|
||||
async def delete_batch(
|
||||
batch_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = BatchService(db)
|
||||
success = await service.delete_batch(batch_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="批次不存在")
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/{batch_id}/dispense", response_model=BatchResponse)
|
||||
async def dispense_batch(
|
||||
batch_id: int,
|
||||
data: BatchDispense,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
service = BatchService(db)
|
||||
try:
|
||||
batch = await service.dispense(batch_id, data.quantity, current_user.id)
|
||||
return BatchResponse.model_validate(batch)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{batch_id}/add-stock", response_model=BatchResponse)
|
||||
async def add_stock_batch(
|
||||
batch_id: int,
|
||||
data: BatchAddStock,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
service = BatchService(db)
|
||||
try:
|
||||
batch = await service.add_stock(batch_id, data.quantity, current_user.id)
|
||||
return BatchResponse.model_validate(batch)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -0,0 +1,83 @@
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user, require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.category import CategoryCreate, CategoryUpdate, CategoryResponse
|
||||
from app.services.category import CategoryService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_categories(
|
||||
level: Optional[int] = Query(None),
|
||||
parent_id: Optional[int] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = CategoryService(db)
|
||||
categories = await service.get_categories(level=level, parent_id=parent_id)
|
||||
return [CategoryResponse.model_validate(c) for c in categories]
|
||||
|
||||
|
||||
@router.get("/tree")
|
||||
async def get_category_tree(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = CategoryService(db)
|
||||
return await service.get_category_tree()
|
||||
|
||||
|
||||
@router.get("/{category_id}", response_model=CategoryResponse)
|
||||
async def get_category(
|
||||
category_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = CategoryService(db)
|
||||
category = await service.get_category(category_id)
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="分类不存在")
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.post("/", response_model=CategoryResponse)
|
||||
async def create_category(
|
||||
data: CategoryCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = CategoryService(db)
|
||||
category = await service.create_category(data.model_dump())
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.put("/{category_id}", response_model=CategoryResponse)
|
||||
async def update_category(
|
||||
category_id: int,
|
||||
data: CategoryUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = CategoryService(db)
|
||||
category = await service.update_category(category_id, data.model_dump(exclude_unset=True))
|
||||
if not category:
|
||||
raise HTTPException(status_code=404, detail="分类不存在")
|
||||
return CategoryResponse.model_validate(category)
|
||||
|
||||
|
||||
@router.delete("/{category_id}")
|
||||
async def delete_category(
|
||||
category_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = CategoryService(db)
|
||||
success = await service.delete_category(category_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="分类不存在")
|
||||
return {"message": "删除成功"}
|
||||
@@ -0,0 +1,86 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user, require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.medicine import MedicineCreate, MedicineUpdate, MedicineResponse, MedicineWithStock
|
||||
from app.services.medicine import MedicineService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_medicines(
|
||||
category_id: Optional[int] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = MedicineService(db)
|
||||
medicines, total = await service.get_medicines(
|
||||
category_id=category_id,
|
||||
search=search,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
return {
|
||||
"data": [MedicineWithStock.model_validate(m) for m in medicines],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{medicine_id}", response_model=MedicineResponse)
|
||||
async def get_medicine(
|
||||
medicine_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = MedicineService(db)
|
||||
medicine = await service.get_medicine(medicine_id)
|
||||
if not medicine:
|
||||
raise HTTPException(status_code=404, detail="药品不存在")
|
||||
return MedicineResponse.model_validate(medicine)
|
||||
|
||||
|
||||
@router.post("/", response_model=MedicineResponse)
|
||||
async def create_medicine(
|
||||
data: MedicineCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
service = MedicineService(db)
|
||||
medicine = await service.create_medicine(data, current_user.id)
|
||||
return MedicineResponse.model_validate(medicine)
|
||||
|
||||
|
||||
@router.put("/{medicine_id}", response_model=MedicineResponse)
|
||||
async def update_medicine(
|
||||
medicine_id: int,
|
||||
data: MedicineUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin", "user"]))
|
||||
):
|
||||
service = MedicineService(db)
|
||||
medicine = await service.update_medicine(medicine_id, data)
|
||||
if not medicine:
|
||||
raise HTTPException(status_code=404, detail="药品不存在")
|
||||
return MedicineResponse.model_validate(medicine)
|
||||
|
||||
|
||||
@router.delete("/{medicine_id}")
|
||||
async def delete_medicine(
|
||||
medicine_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = MedicineService(db)
|
||||
success = await service.delete_medicine(medicine_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="药品不存在")
|
||||
return {"message": "删除成功"}
|
||||
@@ -0,0 +1,77 @@
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.notification import NotificationService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_notifications(
|
||||
is_read: Optional[bool] = Query(None),
|
||||
type: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = NotificationService(db)
|
||||
notifications = await service.get_notifications(
|
||||
user_id=current_user.id,
|
||||
is_read=is_read,
|
||||
type=type,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": n.id,
|
||||
"type": n.type,
|
||||
"title": n.title,
|
||||
"content": n.content,
|
||||
"is_read": n.is_read,
|
||||
"related_id": n.related_id,
|
||||
"created_at": n.created_at
|
||||
}
|
||||
for n in notifications
|
||||
]
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read")
|
||||
async def mark_notification_read(
|
||||
notification_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = NotificationService(db)
|
||||
success = await service.mark_as_read(notification_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="通知不存在")
|
||||
return {"message": "success"}
|
||||
|
||||
|
||||
@router.put("/read-all")
|
||||
async def mark_all_notifications_read(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = NotificationService(db)
|
||||
count = await service.mark_all_as_read(current_user.id)
|
||||
return {"message": "success", "count": count}
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
async def delete_notification(
|
||||
notification_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = NotificationService(db)
|
||||
success = await service.delete_notification(notification_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="通知不存在")
|
||||
return {"message": "删除成功"}
|
||||
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.services.medicine import MedicineService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NaturalSearchRequest(BaseModel):
|
||||
query: str
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def search_medicines(
|
||||
q: str = Query(...),
|
||||
type: str = Query("name"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
service = MedicineService(db)
|
||||
medicines = await service.search_medicines(q)
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"generic_name": m.generic_name,
|
||||
"indications": m.indications,
|
||||
"total_quantity": sum(b.quantity for b in m.batches if not b.is_expired)
|
||||
}
|
||||
for m in medicines
|
||||
]
|
||||
|
||||
|
||||
@router.post("/natural")
|
||||
async def natural_language_search(
|
||||
data: NaturalSearchRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
from app.ai.manager import ai_manager
|
||||
from app.config import settings
|
||||
|
||||
service = MedicineService(db)
|
||||
medicines = await service.search_medicines(data.query)
|
||||
|
||||
if not medicines:
|
||||
return {"results": [], "ai_response": "未找到相关药品"}
|
||||
|
||||
text_provider = ai_manager.get_text_provider(settings.AI_PROVIDER)
|
||||
if not text_provider:
|
||||
return {"results": [], "ai_response": "AI 服务未配置"}
|
||||
|
||||
medicines_data = [
|
||||
{"name": m.name, "indications": m.indications or ""}
|
||||
for m in medicines
|
||||
]
|
||||
|
||||
try:
|
||||
results = await text_provider.natural_language_search(data.query, medicines_data)
|
||||
return {"results": results, "ai_response": "搜索完成"}
|
||||
except Exception as e:
|
||||
return {"results": [], "ai_response": f"搜索失败: {str(e)}"}
|
||||
@@ -0,0 +1,82 @@
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import require_role
|
||||
from app.models.user import User
|
||||
from app.models.setting import Setting
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SettingResponse(BaseModel):
|
||||
key: str
|
||||
value: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class SettingUpdate(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class BulkSettingUpdate(BaseModel):
|
||||
settings: List[SettingUpdate]
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
result = await db.execute(select(Setting))
|
||||
settings = list(result.scalars().all())
|
||||
return [
|
||||
{
|
||||
"key": s.key,
|
||||
"value": s.value,
|
||||
"description": s.description
|
||||
}
|
||||
for s in settings
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=SettingResponse)
|
||||
async def get_setting(
|
||||
key: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
result = await db.execute(select(Setting).where(Setting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if not setting:
|
||||
raise HTTPException(status_code=404, detail="设置不存在")
|
||||
return SettingResponse(
|
||||
key=setting.key,
|
||||
value=setting.value,
|
||||
description=setting.description
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=SettingResponse)
|
||||
async def update_setting(
|
||||
key: str,
|
||||
data: SettingUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
result = await db.execute(select(Setting).where(Setting.key == key))
|
||||
setting = result.scalar_one_or_none()
|
||||
if not setting:
|
||||
setting = Setting(key=key, value=data.value)
|
||||
db.add(setting)
|
||||
else:
|
||||
setting.value = data.value
|
||||
await db.commit()
|
||||
return SettingResponse(
|
||||
key=setting.key,
|
||||
value=setting.value,
|
||||
description=setting.description
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.deps import require_role
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserUpdate, UserResponse
|
||||
from app.services.user import UserService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.get("/", response_model=List[UserResponse])
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = UserService(db)
|
||||
users = await service.get_all_users()
|
||||
return [UserResponse.model_validate(u) for u in users]
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = UserService(db)
|
||||
user = await service.get_user(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.post("/", response_model=UserResponse)
|
||||
async def create_user(
|
||||
data: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = UserService(db)
|
||||
existing_user = await service.get_user_by_username(data.username)
|
||||
if existing_user:
|
||||
raise HTTPException(status_code=409, detail="用户名已存在")
|
||||
user = await service.create_user(data.model_dump())
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
data: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = UserService(db)
|
||||
user = await service.update_user(user_id, data.model_dump(exclude_unset=True))
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = UserService(db)
|
||||
success = await service.delete_user(user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/{user_id}/reset-password")
|
||||
async def reset_password(
|
||||
user_id: int,
|
||||
data: ResetPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(require_role(["admin"]))
|
||||
):
|
||||
service = UserService(db)
|
||||
success = await service.reset_password(user_id, data.new_password)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
return {"message": "success"}
|
||||
@@ -0,0 +1,61 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "药箱"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./data/yaoxiang.db"
|
||||
|
||||
JWT_SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRATION_HOURS: int = 24
|
||||
|
||||
AI_PROVIDER: str = "openai"
|
||||
OPENAI_API_KEY: str = ""
|
||||
OPENAI_MODEL: str = "gpt-4o"
|
||||
GEMINI_API_KEY: str = ""
|
||||
GEMINI_MODEL: str = "gemini-pro-vision"
|
||||
ANTHROPIC_API_KEY: str = ""
|
||||
ANTHROPIC_MODEL: str = "claude-3-opus-20240229"
|
||||
DEEPSEEK_API_KEY: str = ""
|
||||
DEEPSEEK_MODEL: str = "deepseek-chat"
|
||||
OLLAMA_BASE_URL: str = "http://localhost:11434"
|
||||
OLLAMA_MODEL: str = "llava"
|
||||
|
||||
NOTIFICATION_PROVIDERS: List[str] = []
|
||||
SERVERCHAN_KEY: str = ""
|
||||
PUSHPLUS_TOKEN: str = ""
|
||||
BARK_URL: str = ""
|
||||
WECHAT_WEBHOOK_URL: str = ""
|
||||
TELEGRAM_BOT_TOKEN: str = ""
|
||||
TELEGRAM_CHAT_ID: str = ""
|
||||
SMTP_HOST: str = ""
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM: str = ""
|
||||
|
||||
UPLOAD_DIR: str = "./data/uploads"
|
||||
MAX_UPLOAD_SIZE: int = 10485760
|
||||
|
||||
EXPIRY_WARNING_DAYS: List[int] = [90, 30, 7]
|
||||
LOW_STOCK_THRESHOLD: int = 5
|
||||
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
EXPIRY_GRACE_DAYS_MAX: int = 60
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,60 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.core.security import decode_access_token
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
from app.services.user import UserService
|
||||
from app.models.user import User
|
||||
|
||||
token = credentials.credentials
|
||||
payload = decode_access_token(token)
|
||||
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证令牌"
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证令牌"
|
||||
)
|
||||
|
||||
user_service = UserService(db)
|
||||
user = await user_service.get_user(int(user_id))
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="用户已被禁用"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def require_role(roles: list[str]):
|
||||
async def role_checker(current_user=Depends(get_current_user)):
|
||||
if current_user.role not in roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足"
|
||||
)
|
||||
return current_user
|
||||
return role_checker
|
||||
@@ -0,0 +1,47 @@
|
||||
from fastapi import Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
def __init__(self, code: int, message: str, detail: any = None):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.detail = detail
|
||||
|
||||
|
||||
async def app_exception_handler(request: Request, exc: AppException):
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"code": exc.code,
|
||||
"message": exc.message,
|
||||
"detail": exc.detail
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"code": exc.status_code,
|
||||
"message": exc.detail
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"code": 500,
|
||||
"message": "服务器内部错误"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app):
|
||||
from fastapi import FastAPI
|
||||
app.add_exception_handler(AppException, app_exception_handler)
|
||||
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
@@ -0,0 +1,35 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(hours=settings.JWT_EXPIRATION_HOURS)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
@@ -0,0 +1,37 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,51 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from app.config import settings
|
||||
from app.database import init_db
|
||||
from app.api.router import api_router
|
||||
from app.core.exceptions import register_exception_handlers
|
||||
from app.notifications.manager import notification_manager
|
||||
from app.ai.manager import ai_manager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
|
||||
notification_manager.load_providers()
|
||||
|
||||
from app.ai.openai_provider import OpenAIVisionProvider, OpenAITextProvider
|
||||
if settings.AI_PROVIDER == "openai":
|
||||
ai_manager.register_vision_provider("openai", OpenAIVisionProvider())
|
||||
ai_manager.register_text_provider("openai", OpenAITextProvider())
|
||||
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="药箱 API",
|
||||
description="家庭药品与应急物资管理系统 API",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs" if settings.DEBUG else None,
|
||||
redoc_url="/redoc" if settings.DEBUG else None,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix="/api")
|
||||
|
||||
register_exception_handlers(app)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "version": "1.0.0"}
|
||||
@@ -0,0 +1,17 @@
|
||||
from app.models.user import User
|
||||
from app.models.medicine import Medicine
|
||||
from app.models.batch import Batch
|
||||
from app.models.category import Category
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.setting import Setting
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"Medicine",
|
||||
"Batch",
|
||||
"Category",
|
||||
"AuditLog",
|
||||
"Notification",
|
||||
"Setting"
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
medicine_id = Column(Integer, ForeignKey("medicines.id"), nullable=False, index=True)
|
||||
batch_id = Column(Integer, ForeignKey("batches.id"))
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
action = Column(String(50), nullable=False)
|
||||
quantity_change = Column(Integer, nullable=False)
|
||||
quantity_after = Column(Integer, nullable=False)
|
||||
remark = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
medicine = relationship("Medicine", back_populates="audit_logs")
|
||||
batch = relationship("Batch", back_populates="audit_logs")
|
||||
user = relationship("User", back_populates="audit_logs")
|
||||
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import Column, Integer, String, Date, Boolean, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Batch(Base):
|
||||
__tablename__ = "batches"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
medicine_id = Column(Integer, ForeignKey("medicines.id"), nullable=False, index=True)
|
||||
batch_no = Column(String(100))
|
||||
production_date = Column(Date)
|
||||
expiry_date = Column(Date, nullable=False)
|
||||
quantity = Column(Integer, nullable=False, default=0)
|
||||
location = Column(String(200))
|
||||
is_expired = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
medicine = relationship("Medicine", back_populates="batches")
|
||||
audit_logs = relationship("AuditLog", back_populates="batch")
|
||||
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
parent_id = Column(Integer, ForeignKey("categories.id"))
|
||||
level = Column(Integer, nullable=False, default=1)
|
||||
icon = Column(String(50))
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
parent = relationship("Category", remote_side=[id])
|
||||
children = relationship("Category", back_populates="parent")
|
||||
medicines = relationship("Medicine", back_populates="category")
|
||||
@@ -0,0 +1,35 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Medicine(Base):
|
||||
__tablename__ = "medicines"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False, index=True)
|
||||
generic_name = Column(String(200), index=True)
|
||||
brand_name = Column(String(200))
|
||||
manufacturer = Column(String(200))
|
||||
specification = Column(String(200))
|
||||
category_id = Column(Integer, ForeignKey("categories.id"))
|
||||
description = Column(Text)
|
||||
indications = Column(Text)
|
||||
adult_dose = Column(Text)
|
||||
child_dose = Column(Text)
|
||||
contraindications = Column(Text)
|
||||
notes = Column(Text)
|
||||
image_front_path = Column(String(500))
|
||||
image_expiry_path = Column(String(500))
|
||||
image_leaflet_paths = Column(JSON)
|
||||
expiry_grace_days = Column(Integer, default=0)
|
||||
created_by = Column(Integer, ForeignKey("users.id"))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
category = relationship("Category", back_populates="medicines")
|
||||
creator = relationship("User", back_populates="medicines")
|
||||
batches = relationship("Batch", back_populates="medicine", cascade="all, delete-orphan")
|
||||
audit_logs = relationship("AuditLog", back_populates="medicine")
|
||||
@@ -0,0 +1,20 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Text, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
type = Column(String(50), nullable=False)
|
||||
title = Column(String(200), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
is_read = Column(Boolean, default=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"))
|
||||
related_id = Column(Integer)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
user = relationship("User", back_populates="notifications")
|
||||
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(100), unique=True, index=True, nullable=False)
|
||||
value = Column(Text)
|
||||
description = Column(String(500))
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
role = Column(String(20), nullable=False, default="user")
|
||||
display_name = Column(String(100))
|
||||
email = Column(String(100))
|
||||
notification_level = Column(String(20), default="normal")
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
medicines = relationship("Medicine", back_populates="creator")
|
||||
audit_logs = relationship("AuditLog", back_populates="user")
|
||||
notifications = relationship("Notification", back_populates="user")
|
||||
@@ -0,0 +1,11 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class NotificationProvider(ABC):
|
||||
@abstractmethod
|
||||
async def send(self, title: str, content: str) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def validate_config(self) -> bool:
|
||||
pass
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import List, Optional
|
||||
from app.notifications.base import NotificationProvider
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class NotificationManager:
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self.providers: List[NotificationProvider] = []
|
||||
self._initialized = True
|
||||
|
||||
def add_provider(self, provider: NotificationProvider):
|
||||
self.providers.append(provider)
|
||||
|
||||
async def send_notification(self, title: str, content: str):
|
||||
for provider in self.providers:
|
||||
try:
|
||||
await provider.send(title, content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def load_providers(self):
|
||||
from app.notifications.serverchan import ServerChanProvider
|
||||
from app.notifications.pushplus import PushPlusProvider
|
||||
|
||||
if "serverchan" in settings.NOTIFICATION_PROVIDERS:
|
||||
provider = ServerChanProvider()
|
||||
if provider.validate_config():
|
||||
self.add_provider(provider)
|
||||
|
||||
if "pushplus" in settings.NOTIFICATION_PROVIDERS:
|
||||
provider = PushPlusProvider()
|
||||
if provider.validate_config():
|
||||
self.add_provider(provider)
|
||||
|
||||
|
||||
notification_manager = NotificationManager()
|
||||
@@ -0,0 +1,27 @@
|
||||
import httpx
|
||||
from app.notifications.base import NotificationProvider
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class PushPlusProvider(NotificationProvider):
|
||||
def __init__(self):
|
||||
self.token = settings.PUSHPLUS_TOKEN
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
return bool(self.token)
|
||||
|
||||
async def send(self, title: str, content: str) -> bool:
|
||||
if not self.validate_config():
|
||||
return False
|
||||
|
||||
url = "https://www.pushplus.plus/send"
|
||||
data = {
|
||||
"token": self.token,
|
||||
"title": title,
|
||||
"content": content
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=data)
|
||||
result = response.json()
|
||||
return result.get("code") == 200
|
||||
@@ -0,0 +1,25 @@
|
||||
import httpx
|
||||
from app.notifications.base import NotificationProvider
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class ServerChanProvider(NotificationProvider):
|
||||
def __init__(self):
|
||||
self.key = settings.SERVERCHAN_KEY
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
return bool(self.key)
|
||||
|
||||
async def send(self, title: str, content: str) -> bool:
|
||||
if not self.validate_config():
|
||||
return False
|
||||
|
||||
url = f"https://sctapi.ftqq.com/{self.key}.send"
|
||||
data = {
|
||||
"title": title,
|
||||
"desp": content
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, data=data)
|
||||
return response.status_code == 200
|
||||
@@ -0,0 +1,51 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
|
||||
class AuditLogRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, log_id: int) -> Optional[AuditLog]:
|
||||
result = await self.db.execute(
|
||||
select(AuditLog)
|
||||
.options(selectinload(AuditLog.medicine), selectinload(AuditLog.user))
|
||||
.where(AuditLog.id == log_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
medicine_id: Optional[int] = None,
|
||||
user_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> List[AuditLog]:
|
||||
query = select(AuditLog).options(
|
||||
selectinload(AuditLog.medicine),
|
||||
selectinload(AuditLog.user)
|
||||
)
|
||||
|
||||
if medicine_id:
|
||||
query = query.where(AuditLog.medicine_id == medicine_id)
|
||||
if user_id:
|
||||
query = query.where(AuditLog.user_id == user_id)
|
||||
if action:
|
||||
query = query.where(AuditLog.action == action)
|
||||
|
||||
query = query.order_by(AuditLog.created_at.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict) -> AuditLog:
|
||||
log = AuditLog(**data)
|
||||
self.db.add(log)
|
||||
await self.db.flush()
|
||||
return log
|
||||
@@ -0,0 +1,56 @@
|
||||
from typing import Optional, List
|
||||
from datetime import date
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.batch import Batch
|
||||
|
||||
|
||||
class BatchRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, batch_id: int) -> Optional[Batch]:
|
||||
result = await self.db.execute(select(Batch).where(Batch.id == batch_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_medicine_id(self, medicine_id: int) -> List[Batch]:
|
||||
result = await self.db.execute(
|
||||
select(Batch)
|
||||
.where(Batch.medicine_id == medicine_id)
|
||||
.order_by(Batch.expiry_date)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_expiring_before(self, target_date: date) -> List[Batch]:
|
||||
result = await self.db.execute(
|
||||
select(Batch)
|
||||
.where(
|
||||
Batch.expiry_date <= target_date,
|
||||
Batch.is_expired == False,
|
||||
Batch.quantity > 0
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict) -> Batch:
|
||||
batch = Batch(**data)
|
||||
self.db.add(batch)
|
||||
await self.db.flush()
|
||||
return batch
|
||||
|
||||
async def update(self, batch_id: int, data: dict) -> Optional[Batch]:
|
||||
batch = await self.get_by_id(batch_id)
|
||||
if not batch:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
setattr(batch, key, value)
|
||||
await self.db.flush()
|
||||
return batch
|
||||
|
||||
async def delete(self, batch_id: int) -> bool:
|
||||
batch = await self.get_by_id(batch_id)
|
||||
if not batch:
|
||||
return False
|
||||
await self.db.delete(batch)
|
||||
return True
|
||||
@@ -0,0 +1,54 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.category import Category
|
||||
|
||||
|
||||
class CategoryRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, category_id: int) -> Optional[Category]:
|
||||
result = await self.db.execute(select(Category).where(Category.id == category_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all(self, level: Optional[int] = None, parent_id: Optional[int] = None) -> List[Category]:
|
||||
query = select(Category)
|
||||
if level:
|
||||
query = query.where(Category.level == level)
|
||||
if parent_id:
|
||||
query = query.where(Category.parent_id == parent_id)
|
||||
query = query.order_by(Category.sort_order)
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_children(self, parent_id: int) -> List[Category]:
|
||||
result = await self.db.execute(
|
||||
select(Category)
|
||||
.where(Category.parent_id == parent_id)
|
||||
.order_by(Category.sort_order)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict) -> Category:
|
||||
category = Category(**data)
|
||||
self.db.add(category)
|
||||
await self.db.flush()
|
||||
return category
|
||||
|
||||
async def update(self, category_id: int, data: dict) -> Optional[Category]:
|
||||
category = await self.get_by_id(category_id)
|
||||
if not category:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
setattr(category, key, value)
|
||||
await self.db.flush()
|
||||
return category
|
||||
|
||||
async def delete(self, category_id: int) -> bool:
|
||||
category = await self.get_by_id(category_id)
|
||||
if not category:
|
||||
return False
|
||||
await self.db.delete(category)
|
||||
return True
|
||||
@@ -0,0 +1,92 @@
|
||||
from typing import Optional, List, Tuple
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.medicine import Medicine
|
||||
|
||||
|
||||
class MedicineRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, medicine_id: int) -> Optional[Medicine]:
|
||||
result = await self.db.execute(
|
||||
select(Medicine)
|
||||
.options(selectinload(Medicine.batches))
|
||||
.where(Medicine.id == medicine_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
category_id: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> Tuple[List[Medicine], int]:
|
||||
query = select(Medicine).options(selectinload(Medicine.batches))
|
||||
|
||||
if category_id:
|
||||
query = query.where(Medicine.category_id == category_id)
|
||||
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
query = query.where(
|
||||
Medicine.name.ilike(search_filter) |
|
||||
Medicine.generic_name.ilike(search_filter) |
|
||||
Medicine.brand_name.ilike(search_filter)
|
||||
)
|
||||
|
||||
count_query = select(func.count()).select_from(Medicine)
|
||||
if category_id:
|
||||
count_query = count_query.where(Medicine.category_id == category_id)
|
||||
if search:
|
||||
search_filter = f"%{search}%"
|
||||
count_query = count_query.where(
|
||||
Medicine.name.ilike(search_filter) |
|
||||
Medicine.generic_name.ilike(search_filter) |
|
||||
Medicine.brand_name.ilike(search_filter)
|
||||
)
|
||||
|
||||
total_result = await self.db.execute(count_query)
|
||||
total = total_result.scalar()
|
||||
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await self.db.execute(query)
|
||||
medicines = list(result.scalars().all())
|
||||
|
||||
return medicines, total
|
||||
|
||||
async def create(self, data: dict) -> Medicine:
|
||||
medicine = Medicine(**data)
|
||||
self.db.add(medicine)
|
||||
await self.db.flush()
|
||||
return medicine
|
||||
|
||||
async def update(self, medicine_id: int, data: dict) -> Optional[Medicine]:
|
||||
medicine = await self.get_by_id(medicine_id)
|
||||
if not medicine:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
setattr(medicine, key, value)
|
||||
await self.db.flush()
|
||||
return medicine
|
||||
|
||||
async def delete(self, medicine_id: int) -> bool:
|
||||
medicine = await self.get_by_id(medicine_id)
|
||||
if not medicine:
|
||||
return False
|
||||
await self.db.delete(medicine)
|
||||
return True
|
||||
|
||||
async def search(self, query: str) -> List[Medicine]:
|
||||
search_filter = f"%{query}%"
|
||||
result = await self.db.execute(
|
||||
select(Medicine).options(selectinload(Medicine.batches)).where(
|
||||
Medicine.name.ilike(search_filter) |
|
||||
Medicine.generic_name.ilike(search_filter) |
|
||||
Medicine.indications.ilike(search_filter)
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,71 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification import Notification
|
||||
|
||||
|
||||
class NotificationRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, notification_id: int) -> Optional[Notification]:
|
||||
result = await self.db.execute(
|
||||
select(Notification).where(Notification.id == notification_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_list(
|
||||
self,
|
||||
user_id: Optional[int] = None,
|
||||
is_read: Optional[bool] = None,
|
||||
type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> List[Notification]:
|
||||
query = select(Notification)
|
||||
|
||||
if user_id:
|
||||
query = query.where(Notification.user_id == user_id)
|
||||
if is_read is not None:
|
||||
query = query.where(Notification.is_read == is_read)
|
||||
if type:
|
||||
query = query.where(Notification.type == type)
|
||||
|
||||
query = query.order_by(Notification.created_at.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict) -> Notification:
|
||||
notification = Notification(**data)
|
||||
self.db.add(notification)
|
||||
await self.db.flush()
|
||||
return notification
|
||||
|
||||
async def mark_as_read(self, notification_id: int) -> bool:
|
||||
notification = await self.get_by_id(notification_id)
|
||||
if not notification:
|
||||
return False
|
||||
notification.is_read = True
|
||||
return True
|
||||
|
||||
async def mark_all_as_read(self, user_id: int) -> int:
|
||||
result = await self.db.execute(
|
||||
select(Notification).where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.is_read == False
|
||||
)
|
||||
)
|
||||
notifications = list(result.scalars().all())
|
||||
for notification in notifications:
|
||||
notification.is_read = True
|
||||
return len(notifications)
|
||||
|
||||
async def delete(self, notification_id: int) -> bool:
|
||||
notification = await self.get_by_id(notification_id)
|
||||
if not notification:
|
||||
return False
|
||||
await self.db.delete(notification)
|
||||
return True
|
||||
@@ -0,0 +1,44 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class UserRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, user_id: int) -> Optional[User]:
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_username(self, username: str) -> Optional[User]:
|
||||
result = await self.db.execute(select(User).where(User.username == username))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all(self) -> List[User]:
|
||||
result = await self.db.execute(select(User))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, data: dict) -> User:
|
||||
user = User(**data)
|
||||
self.db.add(user)
|
||||
await self.db.flush()
|
||||
return user
|
||||
|
||||
async def update(self, user_id: int, data: dict) -> Optional[User]:
|
||||
user = await self.get_by_id(user_id)
|
||||
if not user:
|
||||
return None
|
||||
for key, value in data.items():
|
||||
setattr(user, key, value)
|
||||
await self.db.flush()
|
||||
return user
|
||||
|
||||
async def delete(self, user_id: int) -> bool:
|
||||
user = await self.get_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
await self.db.delete(user)
|
||||
return True
|
||||
@@ -0,0 +1,33 @@
|
||||
from app.schemas.user import (
|
||||
UserBase,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
UserResponse,
|
||||
UserLogin,
|
||||
Token
|
||||
)
|
||||
from app.schemas.medicine import (
|
||||
MedicineBase,
|
||||
MedicineCreate,
|
||||
MedicineUpdate,
|
||||
MedicineResponse,
|
||||
MedicineWithStock
|
||||
)
|
||||
from app.schemas.batch import (
|
||||
BatchBase,
|
||||
BatchCreate,
|
||||
BatchUpdate,
|
||||
BatchResponse,
|
||||
BatchDispense,
|
||||
BatchAddStock
|
||||
)
|
||||
from app.schemas.category import (
|
||||
CategoryBase,
|
||||
CategoryCreate,
|
||||
CategoryUpdate,
|
||||
CategoryResponse
|
||||
)
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
PasswordChangeRequest
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
@@ -0,0 +1,42 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
class BatchBase(BaseModel):
|
||||
batch_no: Optional[str] = None
|
||||
production_date: Optional[date] = None
|
||||
expiry_date: date
|
||||
quantity: int = Field(default=0, ge=0)
|
||||
location: Optional[str] = None
|
||||
|
||||
|
||||
class BatchCreate(BatchBase):
|
||||
pass
|
||||
|
||||
|
||||
class BatchUpdate(BaseModel):
|
||||
batch_no: Optional[str] = None
|
||||
production_date: Optional[date] = None
|
||||
expiry_date: Optional[date] = None
|
||||
quantity: Optional[int] = None
|
||||
location: Optional[str] = None
|
||||
|
||||
|
||||
class BatchResponse(BatchBase):
|
||||
id: int
|
||||
medicine_id: int
|
||||
is_expired: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BatchDispense(BaseModel):
|
||||
quantity: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class BatchAddStock(BaseModel):
|
||||
quantity: int = Field(..., gt=0)
|
||||
@@ -0,0 +1,36 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
parent_id: Optional[int] = None
|
||||
level: int = Field(default=1, ge=1, le=2)
|
||||
icon: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
pass
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
level: Optional[int] = None
|
||||
icon: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class CategoryResponse(CategoryBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CategoryWithChildren(CategoryResponse):
|
||||
children: List["CategoryResponse"] = []
|
||||
@@ -0,0 +1,58 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
|
||||
|
||||
class MedicineBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
generic_name: Optional[str] = None
|
||||
brand_name: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
specification: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
indications: Optional[str] = None
|
||||
adult_dose: Optional[str] = None
|
||||
child_dose: Optional[str] = None
|
||||
contraindications: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
expiry_grace_days: int = Field(default=0, ge=0, le=60)
|
||||
|
||||
|
||||
class MedicineCreate(MedicineBase):
|
||||
pass
|
||||
|
||||
|
||||
class MedicineUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
generic_name: Optional[str] = None
|
||||
brand_name: Optional[str] = None
|
||||
manufacturer: Optional[str] = None
|
||||
specification: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
description: Optional[str] = None
|
||||
indications: Optional[str] = None
|
||||
adult_dose: Optional[str] = None
|
||||
child_dose: Optional[str] = None
|
||||
contraindications: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
expiry_grace_days: Optional[int] = None
|
||||
|
||||
|
||||
class MedicineResponse(MedicineBase):
|
||||
id: int
|
||||
image_front_path: Optional[str] = None
|
||||
image_expiry_path: Optional[str] = None
|
||||
image_leaflet_paths: Optional[List[str]] = None
|
||||
created_by: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MedicineWithStock(MedicineResponse):
|
||||
total_quantity: int = 0
|
||||
nearest_expiry_date: Optional[date] = None
|
||||
batch_count: int = 0
|
||||
@@ -0,0 +1,44 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
display_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
role: str = Field(default="user", pattern="^(admin|user|readonly)$")
|
||||
notification_level: str = Field(default="normal", pattern="^(none|low|normal|high)$")
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
notification_level: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserResponse
|
||||
@@ -0,0 +1,47 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.repositories.audit_log import AuditLogRepository
|
||||
|
||||
|
||||
class AuditService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = AuditLogRepository(db)
|
||||
|
||||
async def log_action(
|
||||
self,
|
||||
medicine_id: int,
|
||||
batch_id: Optional[int],
|
||||
user_id: Optional[int],
|
||||
action: str,
|
||||
quantity_change: int,
|
||||
quantity_after: int,
|
||||
remark: Optional[str] = None
|
||||
):
|
||||
data = {
|
||||
"medicine_id": medicine_id,
|
||||
"batch_id": batch_id,
|
||||
"user_id": user_id,
|
||||
"action": action,
|
||||
"quantity_change": quantity_change,
|
||||
"quantity_after": quantity_after,
|
||||
"remark": remark
|
||||
}
|
||||
return await self.repo.create(data)
|
||||
|
||||
async def get_audit_logs(
|
||||
self,
|
||||
medicine_id: Optional[int] = None,
|
||||
user_id: Optional[int] = None,
|
||||
action: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> List:
|
||||
return await self.repo.get_list(
|
||||
medicine_id=medicine_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
from app.services.user import UserService
|
||||
from app.core.security import create_access_token
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.user_service = UserService(db)
|
||||
|
||||
async def login(self, username: str, password: str) -> Optional[dict]:
|
||||
user = await self.user_service.authenticate(username, password)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": user
|
||||
}
|
||||
|
||||
async def register(self, data: dict) -> Optional[dict]:
|
||||
existing_user = await self.user_service.get_user_by_username(data["username"])
|
||||
if existing_user:
|
||||
return None
|
||||
|
||||
user = await self.user_service.create_user(data)
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"user": user
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
from typing import List, Optional
|
||||
from datetime import date, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.batch import Batch
|
||||
from app.schemas.batch import BatchCreate, BatchUpdate
|
||||
from app.repositories.batch import BatchRepository
|
||||
from app.services.audit import AuditService
|
||||
|
||||
|
||||
class BatchService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = BatchRepository(db)
|
||||
self.audit_service = AuditService(db)
|
||||
|
||||
async def get_batches_by_medicine(self, medicine_id: int) -> List[Batch]:
|
||||
return await self.repo.get_by_medicine_id(medicine_id)
|
||||
|
||||
async def get_batch(self, batch_id: int) -> Optional[Batch]:
|
||||
return await self.repo.get_by_id(batch_id)
|
||||
|
||||
async def create_batch(self, medicine_id: int, data: BatchCreate) -> Batch:
|
||||
batch_data = data.model_dump()
|
||||
batch_data['medicine_id'] = medicine_id
|
||||
return await self.repo.create(batch_data)
|
||||
|
||||
async def update_batch(self, batch_id: int, data: BatchUpdate) -> Optional[Batch]:
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
return await self.repo.update(batch_id, update_data)
|
||||
|
||||
async def delete_batch(self, batch_id: int) -> bool:
|
||||
return await self.repo.delete(batch_id)
|
||||
|
||||
async def dispense(self, batch_id: int, quantity: int, user_id: int) -> Optional[Batch]:
|
||||
batch = await self.repo.get_by_id(batch_id)
|
||||
if not batch:
|
||||
raise ValueError("批次不存在")
|
||||
|
||||
if batch.quantity < quantity:
|
||||
raise ValueError("库存不足")
|
||||
|
||||
await self.audit_service.log_action(
|
||||
medicine_id=batch.medicine_id,
|
||||
batch_id=batch_id,
|
||||
user_id=user_id,
|
||||
action="dispense",
|
||||
quantity_change=-quantity,
|
||||
quantity_after=batch.quantity - quantity
|
||||
)
|
||||
|
||||
batch.quantity -= quantity
|
||||
await self.db.commit()
|
||||
|
||||
return batch
|
||||
|
||||
async def add_stock(self, batch_id: int, quantity: int, user_id: int) -> Optional[Batch]:
|
||||
batch = await self.repo.get_by_id(batch_id)
|
||||
if not batch:
|
||||
raise ValueError("批次不存在")
|
||||
|
||||
await self.audit_service.log_action(
|
||||
medicine_id=batch.medicine_id,
|
||||
batch_id=batch_id,
|
||||
user_id=user_id,
|
||||
action="add_stock",
|
||||
quantity_change=quantity,
|
||||
quantity_after=batch.quantity + quantity
|
||||
)
|
||||
|
||||
batch.quantity += quantity
|
||||
await self.db.commit()
|
||||
|
||||
return batch
|
||||
|
||||
async def check_expiring_batches(self, warning_days: List[int]) -> List[dict]:
|
||||
expiring = []
|
||||
today = date.today()
|
||||
|
||||
for days in warning_days:
|
||||
target_date = today + timedelta(days=days)
|
||||
batches = await self.repo.get_expiring_before(target_date)
|
||||
for batch in batches:
|
||||
expiring.append({
|
||||
'batch': batch,
|
||||
'days_until_expiry': days
|
||||
})
|
||||
|
||||
return expiring
|
||||
@@ -0,0 +1,56 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.category import Category
|
||||
from app.repositories.category import CategoryRepository
|
||||
|
||||
|
||||
class CategoryService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = CategoryRepository(db)
|
||||
|
||||
async def get_categories(self, level: Optional[int] = None, parent_id: Optional[int] = None) -> List[Category]:
|
||||
return await self.repo.get_all(level=level, parent_id=parent_id)
|
||||
|
||||
async def get_category(self, category_id: int) -> Optional[Category]:
|
||||
return await self.repo.get_by_id(category_id)
|
||||
|
||||
async def get_category_with_children(self, category_id: int) -> Optional[Category]:
|
||||
category = await self.repo.get_by_id(category_id)
|
||||
if category:
|
||||
category.children = await self.repo.get_children(category_id)
|
||||
return category
|
||||
|
||||
async def create_category(self, data: dict) -> Category:
|
||||
return await self.repo.create(data)
|
||||
|
||||
async def update_category(self, category_id: int, data: dict) -> Optional[Category]:
|
||||
return await self.repo.update(category_id, data)
|
||||
|
||||
async def delete_category(self, category_id: int) -> bool:
|
||||
return await self.repo.delete(category_id)
|
||||
|
||||
async def get_category_tree(self) -> List[dict]:
|
||||
root_categories = await self.repo.get_all(level=1)
|
||||
tree = []
|
||||
for category in root_categories:
|
||||
children = await self.repo.get_children(category.id)
|
||||
tree.append({
|
||||
"id": category.id,
|
||||
"name": category.name,
|
||||
"level": category.level,
|
||||
"icon": category.icon,
|
||||
"sort_order": category.sort_order,
|
||||
"children": [
|
||||
{
|
||||
"id": child.id,
|
||||
"name": child.name,
|
||||
"level": child.level,
|
||||
"icon": child.icon,
|
||||
"sort_order": child.sort_order
|
||||
}
|
||||
for child in children
|
||||
]
|
||||
})
|
||||
return tree
|
||||
@@ -0,0 +1,64 @@
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.medicine import Medicine
|
||||
from app.schemas.medicine import MedicineCreate, MedicineUpdate, MedicineWithStock
|
||||
from app.repositories.medicine import MedicineRepository
|
||||
|
||||
|
||||
class MedicineService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = MedicineRepository(db)
|
||||
|
||||
async def get_medicines(
|
||||
self,
|
||||
category_id: Optional[int] = None,
|
||||
search: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> Tuple[List[MedicineWithStock], int]:
|
||||
medicines, total = await self.repo.get_list(
|
||||
category_id=category_id,
|
||||
search=search,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
|
||||
result = []
|
||||
for medicine in medicines:
|
||||
total_quantity = sum(b.quantity for b in medicine.batches if not b.is_expired)
|
||||
|
||||
nearest_expiry = None
|
||||
for batch in medicine.batches:
|
||||
if not batch.is_expired:
|
||||
if nearest_expiry is None or batch.expiry_date < nearest_expiry:
|
||||
nearest_expiry = batch.expiry_date
|
||||
|
||||
medicine_with_stock = MedicineWithStock(
|
||||
**medicine.__dict__,
|
||||
total_quantity=total_quantity,
|
||||
nearest_expiry_date=nearest_expiry,
|
||||
batch_count=len([b for b in medicine.batches if not b.is_expired])
|
||||
)
|
||||
result.append(medicine_with_stock)
|
||||
|
||||
return result, total
|
||||
|
||||
async def get_medicine(self, medicine_id: int) -> Optional[Medicine]:
|
||||
return await self.repo.get_by_id(medicine_id)
|
||||
|
||||
async def create_medicine(self, data: MedicineCreate, user_id: int) -> Medicine:
|
||||
medicine_data = data.model_dump()
|
||||
medicine_data['created_by'] = user_id
|
||||
return await self.repo.create(medicine_data)
|
||||
|
||||
async def update_medicine(self, medicine_id: int, data: MedicineUpdate) -> Optional[Medicine]:
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
return await self.repo.update(medicine_id, update_data)
|
||||
|
||||
async def delete_medicine(self, medicine_id: int) -> bool:
|
||||
return await self.repo.delete(medicine_id)
|
||||
|
||||
async def search_medicines(self, query: str) -> List[Medicine]:
|
||||
return await self.repo.search(query)
|
||||
@@ -0,0 +1,39 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification import Notification
|
||||
from app.repositories.notification import NotificationRepository
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = NotificationRepository(db)
|
||||
|
||||
async def get_notifications(
|
||||
self,
|
||||
user_id: Optional[int] = None,
|
||||
is_read: Optional[bool] = None,
|
||||
type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> List[Notification]:
|
||||
return await self.repo.get_list(
|
||||
user_id=user_id,
|
||||
is_read=is_read,
|
||||
type=type,
|
||||
page=page,
|
||||
page_size=page_size
|
||||
)
|
||||
|
||||
async def create_notification(self, data: dict) -> Notification:
|
||||
return await self.repo.create(data)
|
||||
|
||||
async def mark_as_read(self, notification_id: int) -> bool:
|
||||
return await self.repo.mark_as_read(notification_id)
|
||||
|
||||
async def mark_all_as_read(self, user_id: int) -> int:
|
||||
return await self.repo.mark_all_as_read(user_id)
|
||||
|
||||
async def delete_notification(self, notification_id: int) -> bool:
|
||||
return await self.repo.delete(notification_id)
|
||||
@@ -0,0 +1,60 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
from app.repositories.user import UserRepository
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = UserRepository(db)
|
||||
|
||||
async def get_user(self, user_id: int) -> Optional[User]:
|
||||
return await self.repo.get_by_id(user_id)
|
||||
|
||||
async def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
return await self.repo.get_by_username(username)
|
||||
|
||||
async def get_all_users(self) -> List[User]:
|
||||
return await self.repo.get_all()
|
||||
|
||||
async def create_user(self, data: dict) -> User:
|
||||
if "password" in data:
|
||||
data["password_hash"] = get_password_hash(data.pop("password"))
|
||||
return await self.repo.create(data)
|
||||
|
||||
async def update_user(self, user_id: int, data: dict) -> Optional[User]:
|
||||
if "password" in data:
|
||||
data["password_hash"] = get_password_hash(data.pop("password"))
|
||||
return await self.repo.update(user_id, data)
|
||||
|
||||
async def delete_user(self, user_id: int) -> bool:
|
||||
return await self.repo.delete(user_id)
|
||||
|
||||
async def authenticate(self, username: str, password: str) -> Optional[User]:
|
||||
user = await self.repo.get_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
if not user.is_active:
|
||||
return None
|
||||
return user
|
||||
|
||||
async def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
||||
user = await self.repo.get_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
if not verify_password(old_password, user.password_hash):
|
||||
return False
|
||||
await self.repo.update(user_id, {"password_hash": get_password_hash(new_password)})
|
||||
return True
|
||||
|
||||
async def reset_password(self, user_id: int, new_password: str) -> bool:
|
||||
user = await self.repo.get_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
await self.repo.update(user_id, {"password_hash": get_password_hash(new_password)})
|
||||
return True
|
||||
@@ -0,0 +1,15 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class StorageProvider(ABC):
|
||||
@abstractmethod
|
||||
async def save(self, file_path: str, data: bytes) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, file_path: str) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_url(self, file_path: str) -> str:
|
||||
pass
|
||||
@@ -0,0 +1,29 @@
|
||||
import os
|
||||
import aiofiles
|
||||
from app.storage.base import StorageProvider
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class LocalStorageProvider(StorageProvider):
|
||||
def __init__(self):
|
||||
self.upload_dir = settings.UPLOAD_DIR
|
||||
os.makedirs(self.upload_dir, exist_ok=True)
|
||||
|
||||
async def save(self, file_path: str, data: bytes) -> str:
|
||||
full_path = os.path.join(self.upload_dir, file_path)
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
|
||||
async with aiofiles.open(full_path, 'wb') as f:
|
||||
await f.write(data)
|
||||
|
||||
return full_path
|
||||
|
||||
async def delete(self, file_path: str) -> bool:
|
||||
full_path = os.path.join(self.upload_dir, file_path)
|
||||
if os.path.exists(full_path):
|
||||
os.remove(full_path)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_url(self, file_path: str) -> str:
|
||||
return f"/uploads/{file_path}"
|
||||
@@ -0,0 +1,33 @@
|
||||
from app.storage.base import StorageProvider
|
||||
from app.storage.local import LocalStorageProvider
|
||||
|
||||
|
||||
class StorageManager:
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self.provider: StorageProvider = LocalStorageProvider()
|
||||
self._initialized = True
|
||||
|
||||
def set_provider(self, provider: StorageProvider):
|
||||
self.provider = provider
|
||||
|
||||
async def save(self, file_path: str, data: bytes) -> str:
|
||||
return await self.provider.save(file_path, data)
|
||||
|
||||
async def delete(self, file_path: str) -> bool:
|
||||
return await self.provider.delete(file_path)
|
||||
|
||||
async def get_url(self, file_path: str) -> str:
|
||||
return await self.provider.get_url(file_path)
|
||||
|
||||
|
||||
storage_manager = StorageManager()
|
||||
@@ -0,0 +1,37 @@
|
||||
from datetime import date, timedelta
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.models.batch import Batch
|
||||
from app.models.medicine import Medicine
|
||||
from app.config import settings
|
||||
|
||||
|
||||
async def check_expiring_medicines():
|
||||
async with async_session_factory() as db:
|
||||
today = date.today()
|
||||
|
||||
for days in settings.EXPIRY_WARNING_DAYS:
|
||||
target_date = today + timedelta(days=days)
|
||||
|
||||
query = select(Batch, Medicine).join(
|
||||
Medicine, Batch.medicine_id == Medicine.id
|
||||
).where(
|
||||
Batch.expiry_date <= target_date,
|
||||
Batch.is_expired == False,
|
||||
Batch.quantity > 0
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
batches = result.all()
|
||||
|
||||
if batches:
|
||||
from app.notifications.manager import notification_manager
|
||||
title = f"药品过期提醒 ({days}天内)"
|
||||
content = "以下药品即将过期,请及时处理:\n\n"
|
||||
|
||||
for batch, medicine in batches:
|
||||
content += f"- {medicine.name}: {batch.batch_no or '默认批次'} "
|
||||
content += f"(过期日期: {batch.expiry_date})\n"
|
||||
|
||||
await notification_manager.send_notification(title, content)
|
||||
Reference in New Issue
Block a user