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:
145
backend/app/api/v1/endpoints/materials.py
Normal file
145
backend/app/api/v1/endpoints/materials.py
Normal 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()
|
||||
92
backend/app/api/v1/endpoints/print.py
Normal file
92
backend/app/api/v1/endpoints/print.py
Normal 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"],
|
||||
}
|
||||
@ -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)
|
||||
|
||||
48
backend/app/api/v1/endpoints/records.py
Normal file
48
backend/app/api/v1/endpoints/records.py
Normal 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": "记录已删除"}
|
||||
@ -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)
|
||||
|
||||
51
backend/app/api/v1/endpoints/upload.py
Normal file
51
backend/app/api/v1/endpoints/upload.py
Normal 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)
|
||||
64
backend/app/api/v1/endpoints/users.py
Normal file
64
backend/app/api/v1/endpoints/users.py
Normal 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()
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user