from contextlib import nullcontext from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from app.database import get_db from app.deps import require_roles from app.models import Personnel, Product, Role, SessionStatus, WorkSession, WorkSessionDevice from app.schemas import ClockRequest, ClockSessionOut, ClockState, ContinueWorkRequest from app.services.attendance_points import require_attendance_point_access, validate_attendance_point_location from app.services.auto_submit import auto_submit_overdue_sessions from app.services.cleaning import is_cleaning_product from app.services.display_names import mold_process_display_name from app.services.mold_locks import ( current_unreleased_device, ensure_mold_available, is_lockable_product, mold_lock_mutex, ) from app.services.misc_work import is_misc_product from app.services.reporting_window import is_reporting_expired from app.services.serializers import session_out from app.services.work_schedule import get_work_schedule from app.timezone import now router = APIRouter(prefix="/api/clock", tags=["clock"]) def _mold_lock_context( db: Session, point_name: str, mold_name: str, process_name: str, product: Product | None, ): if is_lockable_product(product): return mold_lock_mutex(db, point_name, mold_name, process_name) return nullcontext() def _mold_name(value: str | None) -> str: return str(value or "").strip() def _process_name(value: str | None) -> str: return str(value or "").strip() def _request_mold_name(payload: ClockRequest) -> str: return _mold_name(payload.mold_name or payload.device_no) def _request_process_name(payload: ClockRequest) -> str: return _process_name(payload.process_name) def _mold_key(mold_name: str, process_name: str) -> tuple[str, str]: return (_mold_name(mold_name), _process_name(process_name)) def _mold_display(mold_name: str, process_name: str, stamping_method: str | None = None) -> str: return mold_process_display_name(mold_name, process_name, stamping_method) def _require_clock_location( db: Session, point_name: str, latitude: float | None, longitude: float | None, ) -> None: try: validate_attendance_point_location(db, point_name, latitude, longitude, action_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 def _product_for_mold(db: Session, point_name: str, mold_name: str, process_name: str) -> Product | None: return db.scalar( select(Product) .where( 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()) ) def _resolve_product_for_mold( db: Session, point_name: str, mold_name: str, process_name: str, ) -> tuple[Product | None, str]: process = _process_name(process_name) product = _product_for_mold(db, point_name, mold_name, process) if product is not None or process: return product, process candidates = db.scalars( select(Product) .where( Product.attendance_point_name == point_name, Product.product_name == mold_name, Product.device_no == "", ) .order_by(Product.project_no.asc(), Product.process_name.asc()) ).all() if len(candidates) == 1: product = candidates[0] return product, _process_name(product.process_name) return None, process def _active_session(db: Session, user: Personnel) -> WorkSession | None: auto_submit_overdue_sessions(db, employee_phone=user.phone) return db.scalar( select(WorkSession) .options(selectinload(WorkSession.devices)) .where( WorkSession.employee_phone == user.phone, WorkSession.status.in_([SessionStatus.active, SessionStatus.reporting]), ) .order_by(WorkSession.created_at.desc()) ) def _ordered_devices(session: WorkSession) -> list[WorkSessionDevice]: return sorted( session.devices, key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)), ) def _next_sort_order(session: WorkSession) -> int: orders = [int(device.sort_order or 0) for device in session.devices] return (max(orders) + 1) if orders else 0 def _unique_molds(db: Session, devices: list[WorkSessionDevice]) -> list[str]: molds: list[str] = [] seen: set[tuple[str, str]] = set() for device in devices: key = _mold_key(device.device_no, device.process_name) if key in seen: continue product = _product_for_mold(db, device.attendance_point_name, device.device_no, device.process_name) molds.append(_mold_display(device.device_no, device.process_name, product.stamping_method if product else None)) seen.add(key) return molds def _unfinished_report_mismatch_detail(session: WorkSession, unique_molds: list[str], expired: bool) -> dict: mold_text = "、".join(unique_molds) if unique_molds else "原模具" return { "code": "unfinished_report_mold_mismatch", "message": f"报工已进入填写阶段,请扫描本次记录中的模具({mold_text})继续报工", "session_id": session.id, "devices": unique_molds, "reporting_expired": expired, } @router.get("/state", response_model=ClockState) def get_clock_state( attendance_point_name: str = Query(""), device_no: str = Query(""), mold_name: str = Query(""), process_name: str = Query(""), latitude: float | None = Query(None), longitude: float | None = Query(None), user: Personnel = Depends(require_roles(Role.worker)), db: Session = Depends(get_db), ) -> ClockState: mold = _mold_name(mold_name or device_no) process = _process_name(process_name) 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 _require_clock_location(db, point_name, latitude, longitude) if not mold: raise HTTPException(status_code=400, detail="缺少模具名称") product, process = _resolve_product_for_mold(db, point_name, mold, process) if product is None: raise HTTPException(status_code=403, detail=f"无该考勤点模具报工权限:{point_name}") ensure_mold_available(db, user, point_name, mold, process, product) stamping_method = product.stamping_method if product else None display_name = _mold_display(mold, process, stamping_method) is_cleaning = is_cleaning_product(product) is_misc = is_misc_product(product) auto_submit_hours = float(get_work_schedule(db).auto_submit_hours or 15) session = _active_session(db, user) if session is None: return ClockState( action="start", action_text="开始上班", attendance_point_name=point_name, device_no=mold, mold_name=mold, process_name=process, stamping_method=stamping_method, display_name=display_name, is_cleaning=is_cleaning, is_misc=is_misc, auto_submit_hours=auto_submit_hours, ) if session.attendance_point_name != point_name: raise HTTPException(status_code=400, detail=f"当前上班记录属于{session.attendance_point_name}考勤点,请扫描该考勤点模具") ordered_devices = _ordered_devices(session) molds = [_mold_key(device.device_no, device.process_name) for device in ordered_devices] unique_molds = _unique_molds(db, ordered_devices) current_device = ordered_devices[-1] if ordered_devices else None active_device = current_unreleased_device(session) current_mold = active_device.device_no if active_device else (current_device.device_no if current_device else "") current_process = active_device.process_name if active_device else (current_device.process_name if current_device else "") scanned_key = _mold_key(mold, process) if session.status == SessionStatus.reporting: if scanned_key not in set(molds): raise HTTPException( status_code=400, detail=_unfinished_report_mismatch_detail(session, unique_molds, is_reporting_expired(session)), ) if is_reporting_expired(session): return ClockState( action="refinish", action_text="重新报工", attendance_point_name=point_name, device_no=mold, mold_name=mold, process_name=process, stamping_method=stamping_method, display_name=display_name, is_cleaning=is_cleaning, is_misc=is_misc, current_mold_name=current_mold, current_process_name=current_process, session_id=session.id, start_at=session.start_at, end_at=session.end_at, devices=unique_molds, auto_submit_hours=auto_submit_hours, ) return ClockState( action="report", action_text="继续报工", attendance_point_name=point_name, device_no=mold, mold_name=mold, process_name=process, stamping_method=stamping_method, display_name=display_name, is_cleaning=is_cleaning, is_misc=is_misc, current_mold_name=current_mold, current_process_name=current_process, session_id=session.id, start_at=session.start_at, end_at=session.end_at, devices=unique_molds, auto_submit_hours=auto_submit_hours, ) if active_device is None: return ClockState( action="switch", action_text="继续上工", attendance_point_name=point_name, device_no=mold, mold_name=mold, process_name=process, stamping_method=stamping_method, display_name=display_name, is_cleaning=is_cleaning, is_misc=is_misc, current_mold_name=current_mold, current_process_name=current_process, session_id=session.id, start_at=session.start_at, end_at=session.end_at, devices=unique_molds, auto_submit_hours=auto_submit_hours, ) action = "finish" if (is_misc or scanned_key == _mold_key(current_mold, current_process)) else "switch_or_finish" return ClockState( action=action, action_text="下班打卡" if action == "finish" else "换模具或下班打卡", attendance_point_name=point_name, device_no=mold, mold_name=mold, process_name=process, stamping_method=stamping_method, display_name=display_name, is_cleaning=is_cleaning, is_misc=is_misc, current_mold_name=current_mold, current_process_name=current_process, session_id=session.id, start_at=session.start_at, end_at=session.end_at, devices=unique_molds, auto_submit_hours=auto_submit_hours, ) @router.post("/start", response_model=ClockSessionOut) def start_work( payload: ClockRequest, user: Personnel = Depends(require_roles(Role.worker)), db: Session = Depends(get_db), ) -> ClockSessionOut: existing = _active_session(db, user) if existing is not None: raise HTTPException(status_code=409, detail="已有进行中的上班记录") timestamp = now() try: point_name = require_attendance_point_access(db, user, payload.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 mold = _request_mold_name(payload) process = _request_process_name(payload) if not mold: raise HTTPException(status_code=400, detail="缺少模具名称") product, process = _resolve_product_for_mold(db, point_name, mold, process) if product is None: raise HTTPException(status_code=403, detail=f"无该考勤点模具报工权限:{point_name}") _require_clock_location(db, point_name, payload.latitude, payload.longitude) with _mold_lock_context(db, point_name, mold, process, product): ensure_mold_available(db, user, point_name, mold, process, product) session = WorkSession( attendance_point_name=point_name, employee_phone=user.phone, start_at=timestamp, status=SessionStatus.active, ) session.devices.append( WorkSessionDevice( device_no=mold, attendance_point_name=point_name, process_name=process, scanned_at=timestamp, sort_order=0, ) ) db.add(session) db.commit() db.refresh(session) return session_out(session, db) @router.post("/switch-device", response_model=ClockSessionOut) def switch_device( payload: ClockRequest, user: Personnel = Depends(require_roles(Role.worker)), db: Session = Depends(get_db), ) -> ClockSessionOut: session = _active_session(db, user) if session is None or session.status != SessionStatus.active: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有进行中的上班记录") mold = _request_mold_name(payload) process = _request_process_name(payload) try: point_name = require_attendance_point_access(db, user, payload.attendance_point_name or session.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 if session.attendance_point_name != point_name: raise HTTPException(status_code=400, detail="当前上班记录不属于该考勤点") _require_clock_location(db, point_name, payload.latitude, payload.longitude) if not mold: raise HTTPException(status_code=400, detail="缺少模具名称") product, process = _resolve_product_for_mold(db, point_name, mold, process) if product is None: raise HTTPException(status_code=403, detail=f"无该考勤点模具报工权限:{point_name}") with _mold_lock_context(db, point_name, mold, process, product): ensure_mold_available(db, user, point_name, mold, process, product) ordered_devices = _ordered_devices(session) current_device = ordered_devices[-1] if ordered_devices else None current_key = _mold_key(current_device.device_no, current_device.process_name) if current_device else ("", "") active_device = current_unreleased_device(session) if active_device is None or _mold_key(mold, process) != current_key: timestamp = now() if active_device is not None: active_device.released_at = timestamp active_device.released_by = user.phone active_device.release_reason = "换模具释放占用" session.devices.append( WorkSessionDevice( attendance_point_name=point_name, device_no=mold, process_name=process, scanned_at=timestamp, sort_order=_next_sort_order(session), ) ) db.commit() db.refresh(session) return session_out(session, db) @router.post("/finish", response_model=ClockSessionOut) def finish_work( payload: ClockRequest, user: Personnel = Depends(require_roles(Role.worker)), db: Session = Depends(get_db), ) -> ClockSessionOut: session = _active_session(db, user) if session is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有进行中的上班记录") mold = _request_mold_name(payload) try: point_name = require_attendance_point_access(db, user, payload.attendance_point_name or session.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 if session.attendance_point_name != point_name: raise HTTPException(status_code=400, detail="当前上班记录不属于该考勤点") _require_clock_location(db, point_name, payload.latitude, payload.longitude) if not mold: raise HTTPException(status_code=400, detail="缺少模具名称") process = _request_process_name(payload) timestamp = now() if session.status == SessionStatus.reporting and not is_reporting_expired(session, timestamp): raise HTTPException(status_code=409, detail="当前报工还在15分钟填写时间内,请继续报工") if session.status not in [SessionStatus.active, SessionStatus.reporting]: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有进行中的上班记录") product, process = _resolve_product_for_mold(db, point_name, mold, process) if product is None: raise HTTPException(status_code=403, detail=f"无该考勤点模具报工权限:{point_name}") if is_misc_product(product) and session.status == SessionStatus.active: ordered_devices = _ordered_devices(session) current_device = ordered_devices[-1] if ordered_devices else None if current_device is not None: current_device.device_no = mold current_device.process_name = process session.end_at = timestamp session.status = SessionStatus.reporting db.commit() db.refresh(session) return session_out(session, db) @router.post("/continue-work", response_model=ClockSessionOut) def continue_work( payload: ContinueWorkRequest, user: Personnel = Depends(require_roles(Role.worker)), db: Session = Depends(get_db), ) -> ClockSessionOut: session = db.scalar( select(WorkSession) .options(selectinload(WorkSession.devices)) .where( WorkSession.id == payload.session_id, WorkSession.employee_phone == user.phone, ) ) if session is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工流程不存在") if session.status != SessionStatus.reporting: raise HTTPException(status_code=400, detail="当前流程不在下班报工填报中") ordered_devices = _ordered_devices(session) current_device = ordered_devices[-1] if ordered_devices else None if current_device is not None: product = _product_for_mold( db, session.attendance_point_name, current_device.device_no, current_device.process_name, ) with _mold_lock_context( db, session.attendance_point_name, current_device.device_no, current_device.process_name, product, ): ensure_mold_available( db, user, session.attendance_point_name, current_device.device_no, current_device.process_name, product, ) if current_device.released_at is not None: session.devices.append( WorkSessionDevice( attendance_point_name=session.attendance_point_name, device_no=current_device.device_no, process_name=current_device.process_name, scanned_at=now(), sort_order=_next_sort_order(session), ) ) session.end_at = None session.status = SessionStatus.active db.commit() db.refresh(session) return session_out(session, db)