diff --git a/backend/app/api/v1/endpoints/__init__.py b/backend/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..5d6dc85 --- /dev/null +++ b/backend/app/api/v1/endpoints/__init__.py @@ -0,0 +1 @@ +"""API v1 端点""" diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..04e68a5 --- /dev/null +++ b/backend/app/api/v1/endpoints/auth.py @@ -0,0 +1,23 @@ +"""认证 API — 对接 MOM sys_user""" +from fastapi import APIRouter, Depends +from app.schemas.user import LoginRequest, LoginResponse, UserResponse +from app.services.auth_service import login, get_current_user + +router = APIRouter(prefix="/auth", tags=["认证"]) + + +@router.post("/login", response_model=LoginResponse) +def login_endpoint(data: LoginRequest): + """登录 — 验证 MOM sys_user 表,返回 JWT""" + return login(data.username, data.password) + + +@router.get("/me", response_model=UserResponse) +def get_me(current_user: dict = Depends(get_current_user)): + """获取当前用户信息(从 JWT 解析)""" + return UserResponse( + id=current_user["sub"], + username=current_user.get("username", ""), + display_name=current_user.get("display_name", ""), + role=current_user.get("role", "operator"), + ) diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py new file mode 100644 index 0000000..cdcfbee --- /dev/null +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -0,0 +1,12 @@ +"""Dashboard API""" +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.database import get_db +from app.services.dashboard_service import get_dashboard_stats, DashboardStats + +router = APIRouter(prefix="/dashboard", tags=["管理看板"]) + + +@router.get("/stats", response_model=DashboardStats) +async def dashboard_stats(db: AsyncSession = Depends(get_db)): + return await get_dashboard_stats(db) diff --git a/backend/app/api/v1/endpoints/orders.py b/backend/app/api/v1/endpoints/orders.py new file mode 100644 index 0000000..afc4b5b --- /dev/null +++ b/backend/app/api/v1/endpoints/orders.py @@ -0,0 +1,35 @@ +"""生产订单 API 端点""" +from __future__ import annotations +import uuid +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.database import get_db +from app.models.production_order import ProductionOrder +from app.schemas.order import OrderCreate, OrderResponse + +router = APIRouter(prefix="/orders", tags=["订单管理"]) + + +@router.get("/", response_model=list[OrderResponse]) +async def list_orders( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + db: AsyncSession = Depends(get_db), +): + result = await db.execute( + select(ProductionOrder).offset(skip).limit(limit).order_by(ProductionOrder.created_at.desc()) + ) + orders = result.scalars().all() + return [OrderResponse.model_validate(o) for o in orders] + + +@router.post("/", response_model=OrderResponse, status_code=201) +async def create_order(data: OrderCreate, db: AsyncSession = Depends(get_db)): + order = ProductionOrder(**data.model_dump()) + db.add(order) + await db.commit() + await db.refresh(order) + return OrderResponse.model_validate(order) diff --git a/backend/app/api/v1/endpoints/products.py b/backend/app/api/v1/endpoints/products.py new file mode 100644 index 0000000..2c3f749 --- /dev/null +++ b/backend/app/api/v1/endpoints/products.py @@ -0,0 +1,98 @@ +"""产品 API 端点 — 扫码查询、CRUD、二维码生成""" +from __future__ import annotations +from fastapi import APIRouter, Depends, Query +from fastapi.responses import Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.schemas.product import ( + ProductCreate, + ProductUpdate, + ProductResponse, + ProductScanResponse, +) +from app.services import product_service +from app.services.qrcode_service import generate_qrcode_png + +router = APIRouter(prefix="/products", tags=["产品管理"]) + + +# ============================================================ +# 二维码生成 — 根据序列号生成二维码 PNG 图片 +# ============================================================ + +@router.get("/qrcode/{serial_number}") +async def get_product_qrcode(serial_number: str): + """ + 生成产品二维码(PNG 图片)。 + 内容为 16 位序列号,扫描后可调用 /scan/{serial_number} 查询产品。 + 尺寸:300×300 px,用于 PC 端打印或嵌入标签。 + """ + if len(serial_number) != 16: + from fastapi import HTTPException, status + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="序列号必须为 16 位", + ) + buf = generate_qrcode_png(serial_number, size_px=300) + return Response(content=buf.getvalue(), media_type="image/png") + + +# ============================================================ +# 扫码查询 — 根据 16 位序列号查产品 + 顶层任务 +# ============================================================ + +@router.get("/scan/{serial_number}", response_model=ProductScanResponse) +async def scan_product( + serial_number: str, + db: AsyncSession = Depends(get_db), +): + """ + 扫码接口:根据 16 位序列号查询产品及其当前进度。 + 返回产品信息、所属订单、以及顶层任务列表。 + """ + return await product_service.get_product_by_serial(db, serial_number) + + +# ============================================================ +# 产品 CRUD +# ============================================================ + +@router.get("/", response_model=list[ProductResponse]) +async def list_products( + skip: int = Query(0, ge=0, description="跳过条数"), + limit: int = Query(50, ge=1, le=200, description="返回条数"), + db: AsyncSession = Depends(get_db), +): + """获取产品列表""" + return await product_service.get_all_products(db, skip=skip, limit=limit) + + +@router.get("/{product_id}", response_model=ProductResponse) +async def get_product( + product_id: str, + db: AsyncSession = Depends(get_db), +): + """获取单个产品详情""" + import uuid + return await product_service.get_product(db, uuid.UUID(product_id)) + + +@router.post("/", response_model=ProductResponse, status_code=201) +async def create_product_endpoint( + data: ProductCreate, + db: AsyncSession = Depends(get_db), +): + """创建产品""" + return await product_service.create_product(db, data) + + +@router.patch("/{product_id}", response_model=ProductResponse) +async def update_product_endpoint( + product_id: str, + data: ProductUpdate, + db: AsyncSession = Depends(get_db), +): + """更新产品""" + import uuid + return await product_service.update_product(db, uuid.UUID(product_id), data) diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py new file mode 100644 index 0000000..a27a69c --- /dev/null +++ b/backend/app/schemas/order.py @@ -0,0 +1,24 @@ +"""生产订单 Pydantic Schemas""" +from __future__ import annotations +import uuid +from datetime import datetime +from pydantic import BaseModel, Field + + +class OrderCreate(BaseModel): + """创建订单""" + order_no: str = Field(..., max_length=64, description="订单编号") + customer_info: str | None = Field(None, max_length=500, description="客户信息") + + model_config = {"from_attributes": True} + + +class OrderResponse(BaseModel): + """订单响应""" + id: uuid.UUID + order_no: str + customer_info: str | None + status: str + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/task_log.py b/backend/app/schemas/task_log.py new file mode 100644 index 0000000..beada64 --- /dev/null +++ b/backend/app/schemas/task_log.py @@ -0,0 +1,17 @@ +"""任务操作日志 Pydantic Schemas""" +from __future__ import annotations +import uuid +from datetime import datetime +from pydantic import BaseModel, Field + + +class TaskLogResponse(BaseModel): + """任务日志响应""" + id: uuid.UUID + task_id: uuid.UUID + operator_id: str | None + action_type: str + remark: str | None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..c12d041 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,24 @@ +"""用户 Schemas — 对接 MOM sys_user 表""" +from pydantic import BaseModel, Field + + +class LoginRequest(BaseModel): + username: str = Field(..., max_length=64) + password: str = Field(..., max_length=128) + + +class UserResponse(BaseModel): + id: str + username: str + display_name: str + role: str + is_active: bool = True + created_at: str | None = None + + model_config = {"from_attributes": True} + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserResponse diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..94820cc --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,98 @@ +"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT""" +from fastapi import HTTPException, status, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt +from werkzeug.security import check_password_hash + +from app.core.config import settings +from app.core.security import create_access_token, ALGORITHM +from app.core.mom_database import MomSessionLocal +from app.schemas.user import LoginResponse, UserResponse + +security = HTTPBearer() + + +def login(username: str, password: str) -> LoginResponse: + """登录 — 查询 MOM 数据库 sys_user 表验证""" + db = MomSessionLocal() + try: + # 1. 超级管理员硬编码(和 MOM 系统一致) + if username == "IRIS" and password == "123321": + return LoginResponse( + access_token=create_access_token( + data={"sub": "0", "role": "SUPER_ADMIN"} + ), + user=UserResponse( + id="0", + username="IRIS", + display_name="超级管理员", + role="SUPER_ADMIN", + ), + ) + + # 2. 普通用户:LIKE '%/username' 模糊匹配 MOM sys_user 表 + from sqlalchemy import text + result = db.execute( + text( + "SELECT id, username, department, role, password_hash " + "FROM sys_user " + "WHERE username LIKE :pattern" + ), + {"pattern": f"%/{username}"}, + ) + row = result.fetchone() + + if not row: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + user_id, full_username, department, role, password_hash = row + + # 3. Werkzeug scrypt 密码验证 + if not check_password_hash(password_hash, password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + # 4. 解析 display_name("张三/zhangsan01" → "张三") + display_name = full_username.split("/")[0] if "/" in full_username else full_username + + token = create_access_token( + data={ + "sub": str(user_id), + "role": role or "operator", + "username": username, + "display_name": display_name, + } + ) + + return LoginResponse( + access_token=token, + user=UserResponse( + id=str(user_id), + username=username, + display_name=display_name, + role=role or "operator", + ), + ) + + finally: + db.close() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> dict: + """从 Bearer Token 解析当前用户(不查数据库,直接解 JWT)""" + token = credentials.credentials + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=401, detail="无效的 Token") + return payload + except JWTError: + raise HTTPException(status_code=401, detail="无效的 Token") diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py new file mode 100644 index 0000000..6f24ebe --- /dev/null +++ b/backend/app/services/dashboard_service.py @@ -0,0 +1,41 @@ +"""Dashboard 统计服务""" +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + + +class DashboardStats(BaseModel): + products_total: int + products_pending: int + products_in_progress: int + products_completed: int + tasks_total: int + tasks_pending: int + tasks_in_progress: int + tasks_completed: int + + +async def get_dashboard_stats(db: AsyncSession) -> DashboardStats: + from app.models.product import Product + from app.models.task import Task + + p_total = await db.scalar(select(func.count(Product.id))) + p_pending = await db.scalar(select(func.count(Product.id)).where(Product.status == "pending")) + p_progress = await db.scalar(select(func.count(Product.id)).where(Product.status == "in_progress")) + p_done = await db.scalar(select(func.count(Product.id)).where(Product.status == "completed")) + + t_total = await db.scalar(select(func.count(Task.id))) + t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == "pending")) + t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == "in_progress")) + t_done = await db.scalar(select(func.count(Task.id)).where(Task.status == "completed")) + + return DashboardStats( + products_total=p_total or 0, + products_pending=p_pending or 0, + products_in_progress=p_progress or 0, + products_completed=p_done or 0, + tasks_total=t_total or 0, + tasks_pending=t_pending or 0, + tasks_in_progress=t_progress or 0, + tasks_completed=t_done or 0, + ) diff --git a/backend/app/services/qrcode_service.py b/backend/app/services/qrcode_service.py new file mode 100644 index 0000000..edc80ba --- /dev/null +++ b/backend/app/services/qrcode_service.py @@ -0,0 +1,34 @@ +"""二维码生成服务 — 参考 MOM 系统 label_service.py 的 QR 生成逻辑""" +import io +import qrcode +from qrcode.image.pil import PilImage + + +def generate_qrcode_png(content: str, size_px: int = 300) -> io.BytesIO: + """ + 生成二维码 PNG 图片,返回 BytesIO 流。 + + 参数: + content: 二维码内容(如 16 位序列号) + size_px: 输出图片尺寸(像素),默认 300×300 + + 返回: + io.BytesIO: PNG 格式的图片字节流 + """ + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=2, + ) + qr.add_data(content) + qr.make(fit=True) + + img: PilImage = qr.make_image(fill_color="black", back_color="white") + img = img.convert("RGB") + img = img.resize((size_px, size_px)) + + buf = io.BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + return buf