527 lines
20 KiB
Python
527 lines
20 KiB
Python
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)
|