import hashlib import ipaddress import re import zipfile from datetime import datetime from pathlib import Path from urllib.parse import quote, unquote, urlparse from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy import select from sqlalchemy.orm import Session from app.config import settings from app.database import get_db from app.deps import current_user, require_roles from app.models import DeviceQrBatchTask, DeviceQRCode, Personnel, Product, Role from app.schemas import DeviceQrBatchCreate, DeviceQrBatchOut, DeviceQrBatchTaskOut, DeviceQrCreate, DeviceQrOut, PageResponse from app.services.attendance_points import DEFAULT_ATTENDANCE_POINT_NAME, accessible_point_names, require_attendance_point_access from app.services.cleaning import is_cleaning_product from app.services.continuous_die import is_continuous_die_product from app.services.display_names import mold_process_display_name, point_mold_display_name from app.services.misc_work import is_misc_product from app.services.qrcode_batch_tasks import ( QRCODE_TASK_STATUS_COMPLETED, QRCODE_TASK_STATUS_FAILED, QRCODE_TASK_STATUS_PAUSED, QRCODE_TASK_STATUS_PENDING, QRCODE_TASK_STATUS_RUNNING, create_qrcode_batch_task, qrcode_batch_task_out, ) from app.services.wechat import WechatConfigError, create_device_qrcode router = APIRouter(prefix="/api/devices", tags=["devices"]) def _clean_text(value: str | None) -> str: return str(value or "").strip() def _safe_file_stem(value: str) -> str: normalized = re.sub(r'[\\/:*?"<>|\s]+', "_", value).strip("._") return normalized[:120] or "模具二维码" def _unique_zip_name(used_names: set[str], stem: str, suffix: str) -> str: suffix = suffix if suffix else ".jpg" name = f"{stem}{suffix}" index = 2 while name in used_names: name = f"{stem}_{index}{suffix}" index += 1 used_names.add(name) return name def _qrcode_path_from_url(qr_url: str | None) -> Path | None: if not qr_url: return None filename = unquote(Path(urlparse(qr_url).path).name) if not filename: return None path = settings.upload_path / "qrcodes" / filename return path if path.is_file() else None def _is_local_request_base(base_url: str) -> bool: host = urlparse(base_url).hostname or "" if host in {"localhost", "127.0.0.1", "0.0.0.0"}: return True try: address = ipaddress.ip_address(host) except ValueError: return False return address.is_private or address.is_loopback def _public_base_url(request: Request) -> str: request_base_url = str(request.base_url).rstrip("/") configured_base_url = settings.public_base_url.rstrip("/") if settings.app_env != "production" and _is_local_request_base(request_base_url): return request_base_url return configured_base_url or request_base_url def _product_for_mold(db: Session, point_name: str, mold_name: str, process_name: str) -> Product | None: return db.query(Product).filter( Product.attendance_point_name == point_name, Product.product_name == mold_name, Product.process_name == process_name, Product.device_no == "", ).order_by(Product.project_no.asc()).first() def _scene_token(point_name: str, mold_name: str, process_name: str) -> str: mold_key = f"{point_name}\0{mold_name}\0{process_name}" return hashlib.sha1(mold_key.encode("utf-8")).hexdigest()[:16] def _legacy_process_names(process_name: str) -> list[str]: process = _clean_text(process_name) names: list[str] = [] if process.isdigit(): names.append(f"{int(process)}序") names.append(f"{process}序") return list(dict.fromkeys(name for name in names if name and name != process)) def _find_legacy_qrcode_scene(db: Session, token: str) -> DeviceQRCode | None: rows = db.query(DeviceQRCode).all() for row in rows: for legacy_process in _legacy_process_names(row.process_name): if _scene_token(row.attendance_point_name, row.device_no, legacy_process) == token: return row return None async def _generate_mold_qrcode( attendance_point_name: str, mold_name: str, process_name: str, request: Request, user: Personnel, db: Session, reuse_existing: bool = False, allow_missing_config: bool = True, commit: bool = True, ) -> DeviceQrOut: try: point_name = require_attendance_point_access(db, user, attendance_point_name) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except PermissionError as exc: raise HTTPException(status_code=403, detail=str(exc)) from exc original = _clean_text(mold_name) process = _clean_text(process_name) if not original: raise HTTPException(status_code=400, detail="请选择模具名称") if not process: raise HTTPException(status_code=400, detail="请选择工序") product = _product_for_mold(db, point_name, original, process) if product is None: raise HTTPException(status_code=400, detail="该考勤点下没有该产品、工序和冲压方式对应的模具") page = "pages/clock/clock" display_name = point_mold_display_name(point_name, original, process, product.stamping_method) scene_token = _scene_token(point_name, original, process) scene = f"mold={scene_token}" record = db.get( DeviceQRCode, {"attendance_point_name": point_name, "device_no": original, "process_name": process}, ) if reuse_existing and record is not None and _qrcode_path_from_url(record.qr_url) is not None: return DeviceQrOut( device_no=original, attendance_point_name=point_name, mold_name=original, process_name=process, stamping_method=product.stamping_method if product else None, is_cleaning=is_cleaning_product(product), is_misc=is_misc_product(product), is_continuous_die=is_continuous_die_product(product), display_name=display_name, scene=record.qr_scene or scene, page=page, qr_url=record.qr_url, ) qr_url = None try: public_base_url = _public_base_url(request) qr_url = await create_device_qrcode( display_name, page=page, scene=scene, public_base_url=public_base_url, label=f"模具 {display_name}", ) except WechatConfigError as exc: if not allow_missing_config: raise HTTPException(status_code=400, detail=str(exc)) from exc qr_url = None except RuntimeError as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc if record is None: record = DeviceQRCode( attendance_point_name=point_name, device_no=original, process_name=process, created_by=user.phone, ) db.add(record) record.qr_scene = scene record.qr_url = qr_url if commit: db.commit() else: db.flush() return DeviceQrOut( device_no=original, attendance_point_name=point_name, mold_name=original, process_name=process, stamping_method=product.stamping_method if product else None, is_cleaning=is_cleaning_product(product), is_misc=is_misc_product(product), is_continuous_die=is_continuous_die_product(product), display_name=display_name, scene=scene, page=page, qr_url=qr_url, ) @router.get("/resolve-scene", response_model=DeviceQrOut) def resolve_qrcode_scene( scene: str = Query(...), user: Personnel = Depends(current_user), db: Session = Depends(get_db), ) -> DeviceQrOut: raw = str(scene or "").strip() token = raw.split("=", 1)[1] if raw.startswith("mold=") else raw record = db.query(DeviceQRCode).filter(DeviceQRCode.qr_scene == f"mold={token}").first() if record is None: record = _find_legacy_qrcode_scene(db, token) if record is None: raise HTTPException(status_code=404, detail="未找到模具二维码") if user.role != Role.manager and record.attendance_point_name not in accessible_point_names(db, user): raise HTTPException(status_code=403, detail="无该考勤点模具二维码权限") product = _product_for_mold(db, record.attendance_point_name, record.device_no, record.process_name) display_name = point_mold_display_name( record.attendance_point_name, record.device_no, record.process_name, product.stamping_method if product else None, ) return DeviceQrOut( device_no=record.device_no, attendance_point_name=record.attendance_point_name, mold_name=record.device_no, process_name=record.process_name, stamping_method=product.stamping_method if product else None, is_cleaning=is_cleaning_product(product), is_misc=is_misc_product(product), is_continuous_die=is_continuous_die_product(product), display_name=display_name, scene=record.qr_scene, page="pages/clock/clock", qr_url=None, ) @router.post("/molds/qrcode", response_model=DeviceQrOut) async def generate_mold_process_qrcode( payload: DeviceQrCreate, request: Request, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> DeviceQrOut: return await _generate_mold_qrcode( payload.attendance_point_name, payload.product_name, payload.process_name, request, user, db, ) def _validated_batch_items(db: Session, user: Personnel, payload: DeviceQrBatchCreate) -> list[dict]: user_point_names = set(accessible_point_names(db, user)) seen_keys: set[tuple[str, str, str]] = set() normalized_items: list[dict] = [] for item in payload.items: point_name = _clean_text(item.attendance_point_name) or DEFAULT_ATTENDANCE_POINT_NAME if point_name not in user_point_names: raise HTTPException(status_code=403, detail=f"无该考勤点权限:{point_name}") mold_name = _clean_text(item.product_name) process_name = _clean_text(item.process_name) key = (point_name, mold_name, process_name) if not mold_name or not process_name: raise HTTPException(status_code=400, detail="批量生成二维码必须包含产品名称、工序和冲压方式") if key in seen_keys: raise HTTPException(status_code=400, detail=f"重复选择模具二维码:{point_name} / {mold_process_display_name(mold_name, process_name)}") seen_keys.add(key) normalized_items.append( { "attendance_point_name": point_name, "product_name": mold_name, "process_name": process_name, } ) if not normalized_items: return [] requested_point_names = sorted({item["attendance_point_name"] for item in normalized_items}) requested_mold_names = sorted({item["product_name"] for item in normalized_items}) requested_process_names = sorted({item["process_name"] for item in normalized_items}) products = db.scalars( select(Product) .where( Product.attendance_point_name.in_(requested_point_names), Product.product_name.in_(requested_mold_names), Product.process_name.in_(requested_process_names), Product.device_no == "", ) .order_by(Product.attendance_point_name.asc(), Product.product_name.asc(), Product.process_name.asc(), Product.project_no.asc()) ).all() product_by_key: dict[tuple[str, str, str], Product] = {} for product in products: product_by_key.setdefault( (product.attendance_point_name, product.product_name, product.process_name or ""), product, ) for item in normalized_items: key = (item["attendance_point_name"], item["product_name"], item["process_name"]) product = product_by_key.get(key) if product is None: raise HTTPException( status_code=400, detail=f"该考勤点下没有该模具:{key[0]} / {mold_process_display_name(key[1], key[2])}", ) return normalized_items @router.post("/molds/qrcode/batch/tasks", response_model=DeviceQrBatchTaskOut) def create_mold_process_qrcode_batch_task( payload: DeviceQrBatchCreate, request: Request, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> DeviceQrBatchTaskOut: task = create_qrcode_batch_task( db, created_by=user.phone, items=_validated_batch_items(db, user, payload), ) return qrcode_batch_task_out(task, _public_base_url(request)) @router.get("/molds/qrcode/batch/tasks", response_model=PageResponse) def list_mold_process_qrcode_batch_tasks( request: Request, page: int = Query(1, ge=1), page_size: int = Query(5, ge=1, le=20), user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> PageResponse: query = ( db.query(DeviceQrBatchTask) .filter(DeviceQrBatchTask.created_by == user.phone) .order_by(DeviceQrBatchTask.created_at.desc(), DeviceQrBatchTask.id.desc()) ) total = query.count() safe_page_size = max(1, min(20, page_size)) total_pages = max(1, (total + safe_page_size - 1) // safe_page_size) safe_page = max(1, min(page, total_pages)) rows = query.offset((safe_page - 1) * safe_page_size).limit(safe_page_size).all() return PageResponse( page=safe_page, page_size=safe_page_size, total=total, total_pages=total_pages, rows=[qrcode_batch_task_out(row, _public_base_url(request)) for row in rows], ) def _get_own_batch_task(db: Session, user: Personnel, task_id: int) -> DeviceQrBatchTask: task = db.get(DeviceQrBatchTask, task_id) if task is None or task.created_by != user.phone: raise HTTPException(status_code=404, detail="二维码ZIP任务不存在") return task @router.post("/molds/qrcode/batch/tasks/{task_id}/stop", response_model=DeviceQrBatchTaskOut) def stop_mold_process_qrcode_batch_task( task_id: int, request: Request, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> DeviceQrBatchTaskOut: task = _get_own_batch_task(db, user, task_id) if task.status in {QRCODE_TASK_STATUS_COMPLETED, QRCODE_TASK_STATUS_FAILED}: return qrcode_batch_task_out(task, _public_base_url(request)) task.status = QRCODE_TASK_STATUS_PAUSED task.error_message = None task.finished_at = None db.commit() db.refresh(task) return qrcode_batch_task_out(task, _public_base_url(request)) @router.post("/molds/qrcode/batch/tasks/{task_id}/resume", response_model=DeviceQrBatchTaskOut) def resume_mold_process_qrcode_batch_task( task_id: int, request: Request, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> DeviceQrBatchTaskOut: task = _get_own_batch_task(db, user, task_id) if task.status == QRCODE_TASK_STATUS_COMPLETED: return qrcode_batch_task_out(task, _public_base_url(request)) if task.status not in {QRCODE_TASK_STATUS_PAUSED, QRCODE_TASK_STATUS_FAILED}: return qrcode_batch_task_out(task, _public_base_url(request)) if int(task.completed_count or 0) >= int(task.item_count or 0): task.status = QRCODE_TASK_STATUS_COMPLETED task.finished_at = task.finished_at or datetime.now() else: task.status = QRCODE_TASK_STATUS_PENDING task.failed_count = 0 task.error_message = None task.finished_at = None db.commit() db.refresh(task) return qrcode_batch_task_out(task, _public_base_url(request)) @router.delete("/molds/qrcode/batch/tasks/{task_id}") def delete_mold_process_qrcode_batch_task( task_id: int, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> dict[str, bool]: task = _get_own_batch_task(db, user, task_id) file_path = settings.upload_path / "qrcode_zips" / task.file_name task.status = QRCODE_TASK_STATUS_PAUSED db.flush() db.delete(task) db.commit() if file_path.exists(): file_path.unlink() return {"ok": True} @router.post("/molds/qrcode/batch", response_model=DeviceQrBatchOut) async def generate_mold_process_qrcode_batch( payload: DeviceQrBatchCreate, request: Request, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> DeviceQrBatchOut: _validated_batch_items(db, user, payload) generated: list[tuple[DeviceQrOut, Path]] = [] for item in payload.items: qr = await _generate_mold_qrcode( item.attendance_point_name, item.product_name, item.process_name, request, user, db, reuse_existing=False, allow_missing_config=False, commit=False, ) qr_path = _qrcode_path_from_url(qr.qr_url) if qr_path is None: raise HTTPException(status_code=400, detail=f"{qr.display_name} 二维码图片未生成,无法打包") generated.append((qr, qr_path)) if not generated: raise HTTPException(status_code=400, detail="请选择需要生成的模具二维码") zip_dir = settings.upload_path / "qrcode_zips" zip_dir.mkdir(parents=True, exist_ok=True) fingerprint = hashlib.sha1( "|".join( f"{qr.attendance_point_name}\0{qr.mold_name}\0{qr.process_name}" for qr, _ in generated ).encode("utf-8") ).hexdigest()[:10] file_name = f"mold-qrcodes-{datetime.now().strftime('%Y%m%d%H%M%S')}-{fingerprint}.zip" zip_path = zip_dir / file_name used_names: set[str] = set() with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive: for index, (qr, qr_path) in enumerate(generated, start=1): stem = _safe_file_stem(f"{index:03d}_{qr.display_name or qr.mold_name}") archive.write(qr_path, _unique_zip_name(used_names, stem, qr_path.suffix)) db.commit() zip_url = f"{_public_base_url(request)}/uploads/qrcode_zips/{quote(file_name, safe='')}" return DeviceQrBatchOut(count=len(generated), file_name=file_name, zip_url=zip_url) @router.post("/molds/{mold_name}/qrcode", response_model=DeviceQrOut) async def generate_mold_qrcode( mold_name: str, request: Request, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> DeviceQrOut: return await _generate_mold_qrcode(accessible_point_names(db, user)[0], mold_name, "", request, user, db) @router.post("/{device_no}/qrcode", response_model=DeviceQrOut) async def generate_qrcode( device_no: str, request: Request, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> DeviceQrOut: return await _generate_mold_qrcode(accessible_point_names(db, user)[0], device_no, "", request, user, db)