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