JhHardwareWRS_BackPoint/app/routers/clock.py
2026-06-24 15:19:14 +08:00

517 lines
20 KiB
Python
Raw Permalink 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.

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)