506 lines
19 KiB
Python
506 lines
19 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 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)
|