From 79583cf8f497132fae80d63b93719c4ff0d631b2 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Wed, 5 Aug 2026 14:00:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=A0=B8=E5=BF=83=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=20=E2=80=94=20=E6=8E=A5=E6=94=B6/=E9=A9=B3=E5=9B=9E/?= =?UTF-8?q?=E8=BD=AC=E4=BA=A4/=E6=89=93=E5=8D=B0/=E4=B8=8A=E4=BC=A0/?= =?UTF-8?q?=E7=94=A8=E6=88=B7/=E7=89=A9=E6=96=99/=E8=AE=B0=E5=BD=95CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增端点: - 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 过滤 --- backend/app/api/v1/endpoints/materials.py | 145 ++++++++++++++++++++++ backend/app/api/v1/endpoints/print.py | 92 ++++++++++++++ backend/app/api/v1/endpoints/products.py | 23 ++++ backend/app/api/v1/endpoints/records.py | 48 +++++++ backend/app/api/v1/endpoints/tasks.py | 20 ++- backend/app/api/v1/endpoints/upload.py | 51 ++++++++ backend/app/api/v1/endpoints/users.py | 64 ++++++++++ backend/app/api/v1/router.py | 10 ++ 8 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/v1/endpoints/materials.py create mode 100644 backend/app/api/v1/endpoints/print.py create mode 100644 backend/app/api/v1/endpoints/records.py create mode 100644 backend/app/api/v1/endpoints/upload.py create mode 100644 backend/app/api/v1/endpoints/users.py diff --git a/backend/app/api/v1/endpoints/materials.py b/backend/app/api/v1/endpoints/materials.py new file mode 100644 index 0000000..66b7b32 --- /dev/null +++ b/backend/app/api/v1/endpoints/materials.py @@ -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() diff --git a/backend/app/api/v1/endpoints/print.py b/backend/app/api/v1/endpoints/print.py new file mode 100644 index 0000000..c93e069 --- /dev/null +++ b/backend/app/api/v1/endpoints/print.py @@ -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"], + } diff --git a/backend/app/api/v1/endpoints/products.py b/backend/app/api/v1/endpoints/products.py index 2c3f749..cd46ab0 100644 --- a/backend/app/api/v1/endpoints/products.py +++ b/backend/app/api/v1/endpoints/products.py @@ -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) diff --git a/backend/app/api/v1/endpoints/records.py b/backend/app/api/v1/endpoints/records.py new file mode 100644 index 0000000..38eb511 --- /dev/null +++ b/backend/app/api/v1/endpoints/records.py @@ -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": "记录已删除"} diff --git a/backend/app/api/v1/endpoints/tasks.py b/backend/app/api/v1/endpoints/tasks.py index 5066f03..99b73d0 100644 --- a/backend/app/api/v1/endpoints/tasks.py +++ b/backend/app/api/v1/endpoints/tasks.py @@ -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) diff --git a/backend/app/api/v1/endpoints/upload.py b/backend/app/api/v1/endpoints/upload.py new file mode 100644 index 0000000..e8595af --- /dev/null +++ b/backend/app/api/v1/endpoints/upload.py @@ -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) diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..f44f01c --- /dev/null +++ b/backend/app/api/v1/endpoints/users.py @@ -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() diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 17e9cc1..bd35844 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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)