feat(api): 核心端点 — 接收/驳回/转交/打印/上传/用户/物料/记录CRUD

新增端点:
- POST /tasks/{id}/receive — 确认接收 (PENDING→WIP)
- POST /tasks/{id}/reject  — 品质驳回 + 自动返工
- POST /tasks/{id}/transfer — 完工裂变转交 (多路分支+入库)
- PATCH /tasks/{id}/records — 追加图文记录
- PATCH /products/scan/{sn}/status — 宏观状态定调
- POST /upload/ + GET /upload/files/{name} — 文件上传
- GET /users/ — MOM用户列表
- GET /materials/groups + /materials/items — BOM物料手风琴
- POST /print/preview + /execute — 标签预览/打印
- PUT/DELETE /records/{id} — 记录编辑/删除
- GET /tasks/ 新增 assignee_id 过滤
This commit is contained in:
2026-08-05 14:00:43 +08:00
parent f9e10a7dd1
commit 79583cf8f4
8 changed files with 451 additions and 2 deletions

View File

@ -0,0 +1,145 @@
"""物料选择器 — 读 MOM material_base按成品/半成品 category 手风琴分组"""
from fastapi import APIRouter, Query, HTTPException, status
from pydantic import BaseModel
from app.core.mom_database import MomSessionLocal
from sqlalchemy import text
router = APIRouter(prefix="/materials", tags=["物料选择"])
# 只展示成品 / 半成品category 字段区分,如 IRIS/成品/… / IRIS/半成品/…)
TYPE_FILTER = "category ILIKE '%成品%' OR category ILIKE '%半成品%'"
# ============================================================
# 响应模型
# ============================================================
class MaterialGroup(BaseModel):
category: str
count: int
class MaterialItem(BaseModel):
id: int
name: str
spec: str
category: str
type: str
unit: str
is_enabled: bool
# ============================================================
# 端点
# ============================================================
@router.get("/groups", response_model=list[MaterialGroup])
def get_material_groups(
keyword: str = Query("", description="搜索(按名称/规格)"),
):
"""
按 category 分组汇总,前端渲染手风琴外层。
只返回成品/半成品分类。
"""
db = MomSessionLocal()
try:
if keyword.strip():
sql = text(
f"""
SELECT category, COUNT(*) AS count
FROM material_base
WHERE is_enabled = TRUE
AND ({TYPE_FILTER})
AND (name ILIKE :kw OR spec_model ILIKE :kw)
GROUP BY category
ORDER BY category
"""
)
result = db.execute(sql, {"kw": f"%{keyword.strip()}%"})
else:
sql = text(
f"""
SELECT category, COUNT(*) AS count
FROM material_base
WHERE is_enabled = TRUE
AND ({TYPE_FILTER})
GROUP BY category
ORDER BY category
"""
)
result = db.execute(sql)
rows = result.fetchall()
return [MaterialGroup(category=row.category, count=row.count) for row in rows]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"MOM material_base 查询失败: {str(e)}",
)
finally:
db.close()
@router.get("/items", response_model=list[MaterialItem])
def get_material_items(
category: str = Query(..., description="物料分类"),
keyword: str = Query("", description="分组内搜索"),
limit: int = Query(500, ge=1, le=9999),
):
"""
获取指定 category 下的物料条目,前端展开手风琴时懒加载。
"""
db = MomSessionLocal()
try:
if keyword.strip():
sql = text(
f"""
SELECT id, name, spec_model AS spec, category, material_type AS type,
COALESCE(unit, '') AS unit, is_enabled
FROM material_base
WHERE is_enabled = TRUE
AND ({TYPE_FILTER})
AND category = :cat
AND (name ILIKE :kw OR spec_model ILIKE :kw)
ORDER BY name
LIMIT :lim
"""
)
result = db.execute(
sql, {"cat": category, "kw": f"%{keyword.strip()}%", "lim": limit}
)
else:
sql = text(
f"""
SELECT id, name, spec_model AS spec, category, material_type AS type,
COALESCE(unit, '') AS unit, is_enabled
FROM material_base
WHERE is_enabled = TRUE
AND ({TYPE_FILTER})
AND category = :cat
ORDER BY name
LIMIT :lim
"""
)
result = db.execute(sql, {"cat": category, "lim": limit})
rows = result.fetchall()
return [
MaterialItem(
id=row.id,
name=row.name,
spec=row.spec,
category=row.category,
type=row.type,
unit=row.unit,
is_enabled=row.is_enabled,
)
for row in rows
]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"MOM material_base 查询失败: {str(e)}",
)
finally:
db.close()

View File

@ -0,0 +1,92 @@
"""标签打印 API — 预览 / 执行 / 打印机配置"""
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from app.services.label_service import generate_preview_image, send_to_printer
from app.services.print_config import PrintConfigManager
router = APIRouter(prefix="/print", tags=["标签打印"])
# ============================================================
# 请求体
# ============================================================
class LabelPreviewRequest(BaseModel):
serial_number: str = Field(..., min_length=1, description="16位HEX系统ID二维码内容")
material_name: str = Field("", description="物料名称")
spec_model: str = Field("", description="规格型号")
order_no: str = Field("", description="订单号(条件渲染)")
class PrintExecuteRequest(LabelPreviewRequest):
copies: int = Field(1, ge=1, le=100, description="打印份数")
printer_ip: str | None = Field(None, description="覆盖配置的打印机 IP")
printer_port: int | None = Field(None, description="覆盖配置的打印机端口")
class PrinterConfigUpdate(BaseModel):
ip: str = Field(..., description="打印机 IP 地址")
port: int = Field(9100, ge=1, le=65535, description="打印机端口")
enabled: bool = Field(True, description="是否启用")
# ============================================================
# 端点
# ============================================================
@router.post("/preview")
def print_preview(data: LabelPreviewRequest) -> dict:
"""生成标签预览图Base64 JPEG"""
try:
data_url = generate_preview_image(**data.model_dump())
return {"data_url": data_url}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"生成预览失败: {str(e)}",
)
@router.post("/execute")
def print_execute(data: PrintExecuteRequest) -> dict:
"""发送打印指令到物理打标机"""
payload = data.model_dump()
copies = payload.pop("copies", 1)
printer_ip = payload.pop("printer_ip", None)
printer_port = payload.pop("printer_port", None)
result = send_to_printer(
copies=copies,
printer_ip=printer_ip,
printer_port=printer_port,
**payload,
)
if not result["success"]:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=result["message"],
)
return result
@router.get("/config")
def get_printer_config() -> dict:
"""获取打印机当前配置"""
return PrintConfigManager.get_config()
@router.post("/config")
def update_printer_config(data: PrinterConfigUpdate) -> dict:
"""更新打印机配置IP/端口)"""
current = PrintConfigManager.get_config()
current["label_printer"] = {
"ip": data.ip,
"port": data.port,
"enabled": data.enabled,
}
PrintConfigManager.save_config(current)
return {
"message": "打印机配置已更新",
"config": current["label_printer"],
}

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
@ -96,3 +97,25 @@ async def update_product_endpoint(
"""更新产品"""
import uuid
return await product_service.update_product(db, uuid.UUID(product_id), data)
# ============================================================
# 宏观状态更新 — 扫码定调
# ============================================================
class OverallStatusUpdate(BaseModel):
status: str = Field(..., min_length=1, max_length=20, description="宏观状态: 备货/生产/测试/维修/在库")
@router.patch("/scan/{serial_number}/status", response_model=ProductScanResponse)
async def update_product_overall_status(
serial_number: str,
data: OverallStatusUpdate,
db: AsyncSession = Depends(get_db),
):
"""
更新产品宏观流转状态。
移动端首次扫码或手动切换时调用。
合法值: 备货 | 生产 | 测试 | 维修 | 在库
"""
return await product_service.update_overall_status(db, serial_number, data.status)

View File

@ -0,0 +1,48 @@
"""任务记录 CRUD — 编辑 / 删除"""
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.task import TaskRecord
from app.schemas.task import TaskRecordCreate, TaskRecordResponse
router = APIRouter(prefix="/records", tags=["任务记录"])
async def _get_record_or_404(db: AsyncSession, record_id: int) -> TaskRecord:
result = await db.execute(select(TaskRecord).where(TaskRecord.id == record_id))
record = result.scalar_one_or_none()
if not record:
raise HTTPException(status_code=404, detail=f"记录不存在: {record_id}")
return record
@router.put("/{record_id}", response_model=TaskRecordResponse)
async def update_record(
record_id: int,
data: TaskRecordCreate,
db: AsyncSession = Depends(get_db),
):
"""更新任务记录(备注+图片)"""
record = await _get_record_or_404(db, record_id)
record.remark = data.remark or None
record.images = json.dumps(data.images) if data.images else None
await db.commit()
await db.refresh(record)
# 手动反序列化 images
return TaskRecordResponse.model_validate(record)
@router.delete("/{record_id}")
async def delete_record(
record_id: int,
db: AsyncSession = Depends(get_db),
):
"""删除任务记录"""
record = await _get_record_or_404(db, record_id)
await db.delete(record)
await db.commit()
return {"message": "记录已删除"}

View File

@ -12,6 +12,7 @@ from app.schemas.task import (
TaskRejectRequest,
TaskTransferRequest,
SubtaskCreate,
TaskRecordCreate,
TaskResponse,
TaskCompleteResponse,
TaskTransferResponse,
@ -30,13 +31,14 @@ router = APIRouter(prefix="/tasks", tags=["任务管理"])
@router.get("/", response_model=TaskListResponse)
async def list_tasks(
product_id: str | None = Query(None, description="按产品ID筛选"),
assignee_id: str | None = Query(None, description="按负责人ID筛选逻辑外键→老系统"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
"""获取任务列表,可按产品筛选(只返回顶层任务)"""
"""获取任务列表,可按产品/负责人筛选(只返回顶层任务)"""
pid = uuid.UUID(product_id) if product_id else None
return await task_service.get_all_tasks(db, product_id=pid, skip=skip, limit=limit)
return await task_service.get_all_tasks(db, product_id=pid, assignee_id=assignee_id, skip=skip, limit=limit)
@router.get("/{task_id}", response_model=TaskResponse)
@ -208,3 +210,17 @@ async def get_tasks_by_product(
):
"""获取指定产品的顶层任务列表(不含子任务嵌套)"""
return await task_service.get_top_level_tasks(db, uuid.UUID(product_id))
# ============================================================
# 任务进度记录 — 备注/传图
# ============================================================
@router.patch("/{task_id}/records", response_model=TaskResponse)
async def add_task_record_endpoint(
task_id: str,
data: TaskRecordCreate,
db: AsyncSession = Depends(get_db),
):
"""追加进度记录(备注+图片),不改变任务状态"""
return await task_service.add_task_record(db, uuid.UUID(task_id), data)

View File

@ -0,0 +1,51 @@
"""文件上传 & 静态文件访问"""
import os
import uuid
from fastapi import APIRouter, UploadFile, File, HTTPException, status
from fastapi.responses import FileResponse
router = APIRouter(prefix="/upload", tags=["文件上传"])
# Docker: os.getcwd() = /app → /app/uploads → host:backend/uploads
BASE_DIR = os.environ.get("PROJECT_ROOT", os.getcwd())
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True)
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp", "pdf", "doc", "docx", "xls", "xlsx", "zip", "rar", "7z"}
MAX_SIZE = 50 * 1024 * 1024 # 50MB
@router.post("/")
async def upload_file(file: UploadFile = File(...)) -> dict:
"""上传文件 → 保存到 uploads/uuid.ext → 返回可访问 URL"""
if not file.filename:
raise HTTPException(status_code=400, detail="文件名为空")
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"不支持的文件类型: .{ext}")
# 检查大小
content = await file.read()
if len(content) > MAX_SIZE:
raise HTTPException(status_code=400, detail="文件超过 50MB 限制")
await file.seek(0)
os.makedirs(UPLOAD_DIR, exist_ok=True)
filename = f"{uuid.uuid4().hex}.{ext}"
filepath = os.path.join(UPLOAD_DIR, filename)
with open(filepath, "wb") as f:
f.write(await file.read())
return {"url": f"/api/v1/upload/files/{filename}"}
@router.get("/files/{filename}")
async def serve_file(filename: str):
"""直接返回物理文件"""
filepath = os.path.join(UPLOAD_DIR, filename)
if not os.path.exists(filepath):
raise HTTPException(status_code=404, detail="文件不存在")
return FileResponse(filepath)

View File

@ -0,0 +1,64 @@
"""用户列表 — 对接 MOM sys_user"""
from fastapi import APIRouter, Query, HTTPException, status
from pydantic import BaseModel
from app.core.mom_database import MomSessionLocal
from sqlalchemy import text
router = APIRouter(prefix="/users", tags=["用户"])
class UserOption(BaseModel):
id: str
username: str
full_name: str
model_config = {"from_attributes": True}
@router.get("/", response_model=list[UserOption])
def list_users(
keyword: str = Query("", description="按用户名/姓名模糊搜索"),
limit: int = Query(100, ge=1, le=500),
):
"""获取 MOM 系统用户列表,供前端选人使用"""
db = MomSessionLocal()
try:
if keyword.strip():
sql = text(
"""
SELECT id, username,
SPLIT_PART(username, '/', 1) AS full_name
FROM sys_user
WHERE username ILIKE :kw
ORDER BY username
LIMIT :lim
"""
)
rows = db.execute(sql, {"kw": f"%{keyword.strip()}%", "lim": limit}).fetchall()
else:
sql = text(
"""
SELECT id, username,
SPLIT_PART(username, '/', 1) AS full_name
FROM sys_user
ORDER BY username
LIMIT :lim
"""
)
rows = db.execute(sql, {"lim": limit}).fetchall()
return [
UserOption(
id=str(row.id),
username=row.username.split("/")[-1] if "/" in row.username else row.username,
full_name=row.full_name,
)
for row in rows
]
except Exception as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"MOM 用户查询失败: {str(e)}",
)
finally:
db.close()

View File

@ -5,6 +5,11 @@ from app.api.v1.endpoints.tasks import router as tasks_router
from app.api.v1.endpoints.orders import router as orders_router
from app.api.v1.endpoints.dashboard import router as dashboard_router
from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.print import router as print_router
from app.api.v1.endpoints.materials import router as materials_router
from app.api.v1.endpoints.users import router as users_router
from app.api.v1.endpoints.upload import router as upload_router
from app.api.v1.endpoints.records import router as records_router
api_router = APIRouter()
@ -13,3 +18,8 @@ api_router.include_router(dashboard_router)
api_router.include_router(orders_router)
api_router.include_router(products_router)
api_router.include_router(tasks_router)
api_router.include_router(print_router)
api_router.include_router(materials_router)
api_router.include_router(users_router)
api_router.include_router(upload_router)
api_router.include_router(records_router)