JhHardwareWRS_BackPoint/app/routers/devices.py
2026-07-10 10:12:03 +08:00

527 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 fastapi.responses import FileResponse
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,
qrcode_batch_zip_path,
)
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.get("/molds/qrcode/batch/tasks/{task_id}/download")
def download_mold_process_qrcode_batch_task(
task_id: int,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> FileResponse:
task = _get_own_batch_task(db, user, task_id)
if task.status != QRCODE_TASK_STATUS_COMPLETED:
raise HTTPException(status_code=409, detail="二维码ZIP任务还未完成")
file_path = qrcode_batch_zip_path(task)
if not file_path.is_file():
raise HTTPException(status_code=404, detail="ZIP文件不存在或已过期请重新生成")
return FileResponse(
path=file_path,
media_type="application/zip",
filename=task.file_name,
)
@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)