chore: initialize mini program backend
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""API routers."""
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import current_user, require_roles
|
||||
from app.models import (
|
||||
AttendancePoint,
|
||||
DeviceQRCode,
|
||||
Equipment,
|
||||
NoticePoint,
|
||||
PersonAttendancePoint,
|
||||
Personnel,
|
||||
Product,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
ReconciliationLedgerEntry,
|
||||
Role,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
)
|
||||
from app.schemas import AttendancePointCreate, AttendancePointOut, PageResponse
|
||||
from app.services.attendance_points import accessible_points, clean_point_name, ensure_default_attendance_point
|
||||
from app.services.common import paginate
|
||||
from app.services.misc_work import ensure_misc_work_product
|
||||
from app.services.serializers import attendance_point_out
|
||||
from app.services.work_schedule import normalize_schedule_payload
|
||||
|
||||
router = APIRouter(prefix="/api/attendance-points", tags=["attendance-points"])
|
||||
|
||||
|
||||
def _validate_payload(payload: AttendancePointCreate) -> str:
|
||||
name = clean_point_name(payload.name)
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="请输入考勤点名称")
|
||||
if (payload.latitude is None) != (payload.longitude is None):
|
||||
raise HTTPException(status_code=400, detail="经纬度必须同时填写")
|
||||
if payload.latitude is not None and not (-90 <= payload.latitude <= 90):
|
||||
raise HTTPException(status_code=400, detail="纬度范围为-90到90")
|
||||
if payload.longitude is not None and not (-180 <= payload.longitude <= 180):
|
||||
raise HTTPException(status_code=400, detail="经度范围为-180到180")
|
||||
if int(payload.radius_meters or 0) <= 0:
|
||||
raise HTTPException(status_code=400, detail="考勤范围半径必须大于0米")
|
||||
try:
|
||||
normalize_schedule_payload(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"{name} {exc}") from exc
|
||||
return name
|
||||
|
||||
|
||||
def _rename_business_point(db: Session, old_name: str, new_name: str) -> None:
|
||||
if old_name == new_name:
|
||||
return
|
||||
for model in (
|
||||
PersonAttendancePoint,
|
||||
Product,
|
||||
Equipment,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
DeviceQRCode,
|
||||
NoticePoint,
|
||||
ReconciliationLedgerEntry,
|
||||
):
|
||||
db.query(model).filter(model.attendance_point_name == old_name).update(
|
||||
{model.attendance_point_name: new_name},
|
||||
synchronize_session=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/accessible", response_model=list[AttendancePointOut])
|
||||
def list_accessible_attendance_points(
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[AttendancePointOut]:
|
||||
return [attendance_point_out(point) for point in accessible_points(db, user)]
|
||||
|
||||
|
||||
@router.get("/public", response_model=list[AttendancePointOut])
|
||||
def list_public_attendance_points(
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[AttendancePointOut]:
|
||||
points = db.scalars(
|
||||
select(AttendancePoint)
|
||||
.where(AttendancePoint.is_active.is_(True))
|
||||
.order_by(AttendancePoint.name.asc())
|
||||
).all()
|
||||
rows = []
|
||||
for point in points:
|
||||
item = attendance_point_out(point)
|
||||
item.remark = None
|
||||
item.created_at = None
|
||||
item.updated_at = None
|
||||
rows.append(item)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("", response_model=PageResponse)
|
||||
def list_attendance_points(
|
||||
keyword: str = "",
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(100, ge=1, le=500),
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
default_point = ensure_default_attendance_point(db)
|
||||
ensure_misc_work_product(db, default_point.name)
|
||||
db.commit()
|
||||
query = select(AttendancePoint).order_by(AttendancePoint.updated_at.desc(), AttendancePoint.name.asc())
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.where(or_(AttendancePoint.name.like(like), AttendancePoint.remark.like(like)))
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{**result, "rows": [attendance_point_out(row) for row in result["rows"]]})
|
||||
|
||||
|
||||
@router.post("", response_model=AttendancePointOut)
|
||||
def save_attendance_point(
|
||||
payload: AttendancePointCreate,
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AttendancePointOut:
|
||||
name = _validate_payload(payload)
|
||||
original_name = clean_point_name(payload.original_name) or name
|
||||
point = db.get(AttendancePoint, original_name)
|
||||
if point is not None and original_name != name:
|
||||
if db.get(AttendancePoint, name) is not None:
|
||||
raise HTTPException(status_code=409, detail="该考勤点名称已存在")
|
||||
next_point = AttendancePoint(name=name)
|
||||
db.add(next_point)
|
||||
db.flush()
|
||||
_rename_business_point(db, original_name, name)
|
||||
db.delete(point)
|
||||
point = next_point
|
||||
elif point is None:
|
||||
point = db.get(AttendancePoint, name)
|
||||
if point is None:
|
||||
point = AttendancePoint(name=name)
|
||||
db.add(point)
|
||||
db.flush()
|
||||
point.latitude = payload.latitude
|
||||
point.longitude = payload.longitude
|
||||
point.radius_meters = int(payload.radius_meters or 500)
|
||||
for field, value in normalize_schedule_payload(payload).items():
|
||||
setattr(point, field, value)
|
||||
point.remark = payload.remark
|
||||
point.is_active = bool(payload.is_active)
|
||||
ensure_misc_work_product(db, name)
|
||||
db.commit()
|
||||
db.refresh(point)
|
||||
return attendance_point_out(point)
|
||||
|
||||
|
||||
@router.delete("/{name}", status_code=204)
|
||||
def delete_attendance_point(
|
||||
name: str,
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
point_name = clean_point_name(name)
|
||||
point = db.get(AttendancePoint, point_name)
|
||||
if point is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="考勤点不存在")
|
||||
if point_name == ensure_default_attendance_point(db).name:
|
||||
raise HTTPException(status_code=400, detail="默认考勤点不能删除")
|
||||
point.is_active = False
|
||||
db.commit()
|
||||
@@ -0,0 +1,309 @@
|
||||
from datetime import timedelta
|
||||
from math import asin, cos, radians, sin, sqrt
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import current_user
|
||||
from app.models import AttendancePoint, PersonRole, Personnel, Role
|
||||
from app.schemas import (
|
||||
LoginSelectionResponse,
|
||||
PersonnelOut,
|
||||
RoleSelectionRequest,
|
||||
SwitchRoleRequest,
|
||||
TokenResponse,
|
||||
WechatLoginRequest,
|
||||
)
|
||||
from app.security import create_token, verify_token
|
||||
from app.services.attendance_points import clean_point_name, set_person_attendance_points
|
||||
from app.services.serializers import personnel_out, temporary_expired
|
||||
from app.services.wechat import WechatConfigError, get_phone_number
|
||||
from app.timezone import now
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
TEMPORARY_WORKER_HOURS = 24
|
||||
EARTH_RADIUS_METERS = 6371000
|
||||
|
||||
|
||||
def _distance_meters(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
||||
delta_lat = radians(lat2 - lat1)
|
||||
delta_lng = radians(lng2 - lng1)
|
||||
first_lat = radians(lat1)
|
||||
second_lat = radians(lat2)
|
||||
value = (
|
||||
sin(delta_lat / 2) * sin(delta_lat / 2)
|
||||
+ cos(first_lat) * cos(second_lat) * sin(delta_lng / 2) * sin(delta_lng / 2)
|
||||
)
|
||||
return 2 * EARTH_RADIUS_METERS * asin(sqrt(min(1, value)))
|
||||
|
||||
|
||||
def _require_temporary_attendance_point(
|
||||
db: Session,
|
||||
point_name: str | None,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
) -> str:
|
||||
name = clean_point_name(point_name)
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="创建临时工前请先进入考勤点范围")
|
||||
if latitude is None or longitude is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="创建临时工需要校验当前位置")
|
||||
point = db.get(AttendancePoint, name)
|
||||
if point is None or not point.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"没有该考勤点:{name}")
|
||||
if point.latitude is None or point.longitude is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"考勤点【{name}】未配置经纬度")
|
||||
distance = _distance_meters(float(latitude), float(longitude), float(point.latitude), float(point.longitude))
|
||||
radius = int(point.radius_meters or 500)
|
||||
if distance > radius:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"当前位置距离考勤点【{name}】约{round(distance)}米,超过{radius}米范围,不能创建临时工",
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _token_for(person: Personnel, role: Role) -> TokenResponse:
|
||||
token = create_token({"sub": person.phone, "name": person.name, "role": str(role)})
|
||||
return TokenResponse(access_token=token, user=personnel_out(person, role))
|
||||
|
||||
|
||||
def _selection_response(person: Personnel) -> LoginSelectionResponse:
|
||||
roles = [item.role for item in person.roles]
|
||||
selection_token = create_token({
|
||||
"type": "role_selection",
|
||||
"sub": person.phone,
|
||||
"roles": [str(role) for role in roles],
|
||||
})
|
||||
return LoginSelectionResponse(
|
||||
needs_selection=True,
|
||||
selection_token=selection_token,
|
||||
matched_people=[personnel_out(person, role) for role in roles],
|
||||
)
|
||||
|
||||
|
||||
def _temporary_worker_token(phone: str) -> str:
|
||||
return create_token({
|
||||
"type": "temporary_worker_confirmation",
|
||||
"sub": phone,
|
||||
})
|
||||
|
||||
|
||||
def _temporary_worker_response(phone: str) -> LoginSelectionResponse:
|
||||
return LoginSelectionResponse(
|
||||
needs_selection=False,
|
||||
needs_temporary_worker=True,
|
||||
temporary_token=_temporary_worker_token(phone),
|
||||
temporary_phone=phone,
|
||||
)
|
||||
|
||||
|
||||
def _phone_from_temporary_token(token: str | None) -> str:
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="缺少临时工确认凭证")
|
||||
try:
|
||||
payload = verify_token(token)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工确认已失效") from exc
|
||||
if payload.get("type") != "temporary_worker_confirmation" or not payload.get("sub"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的临时工确认凭证")
|
||||
return str(payload["sub"])
|
||||
|
||||
|
||||
def _create_or_renew_temporary_worker(
|
||||
db: Session,
|
||||
phone: str,
|
||||
name: str,
|
||||
attendance_point_name: str,
|
||||
) -> Personnel:
|
||||
cleaned_name = str(name or "").strip()
|
||||
if not cleaned_name:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入临时工姓名")
|
||||
person = db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone == phone)
|
||||
)
|
||||
if person is None:
|
||||
person = Personnel(phone=phone, name=cleaned_name)
|
||||
db.add(person)
|
||||
db.flush()
|
||||
person.name = cleaned_name
|
||||
person.is_temporary = True
|
||||
person.temporary_expires_at = now() + timedelta(hours=TEMPORARY_WORKER_HOURS)
|
||||
if db.get(PersonRole, {"phone": phone, "role": Role.worker}) is None:
|
||||
db.add(PersonRole(phone=phone, role=Role.worker))
|
||||
set_person_attendance_points(db, phone, [attendance_point_name])
|
||||
db.commit()
|
||||
refreshed = db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone == phone)
|
||||
)
|
||||
if refreshed is None:
|
||||
raise HTTPException(status_code=500, detail="临时工创建失败")
|
||||
return refreshed
|
||||
|
||||
|
||||
def _login_for_phone(
|
||||
db: Session,
|
||||
phone: str,
|
||||
selected_role: Role | None = None,
|
||||
) -> LoginSelectionResponse:
|
||||
person = db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone == phone)
|
||||
)
|
||||
if person is None or not person.roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在人员清单中")
|
||||
if temporary_expired(person):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="临时工已过期,请微信授权续期")
|
||||
|
||||
roles = [item.role for item in person.roles]
|
||||
if selected_role is not None:
|
||||
if selected_role not in roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="该手机号没有此角色")
|
||||
token = _token_for(person, selected_role)
|
||||
return LoginSelectionResponse(
|
||||
needs_selection=False,
|
||||
access_token=token.access_token,
|
||||
user=token.user,
|
||||
)
|
||||
|
||||
if len(roles) > 1:
|
||||
return _selection_response(person)
|
||||
|
||||
token = _token_for(person, roles[0])
|
||||
return LoginSelectionResponse(
|
||||
needs_selection=False,
|
||||
access_token=token.access_token,
|
||||
user=token.user,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/wechat-login", response_model=LoginSelectionResponse)
|
||||
async def wechat_login(
|
||||
payload: WechatLoginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> LoginSelectionResponse:
|
||||
if payload.create_temporary_worker:
|
||||
phone = _phone_from_temporary_token(payload.temporary_token)
|
||||
attendance_point_name = _require_temporary_attendance_point(
|
||||
db,
|
||||
payload.temporary_attendance_point_name,
|
||||
payload.temporary_latitude,
|
||||
payload.temporary_longitude,
|
||||
)
|
||||
person = _create_or_renew_temporary_worker(db, phone, payload.temporary_name or "", attendance_point_name)
|
||||
token = _token_for(person, Role.worker)
|
||||
return LoginSelectionResponse(
|
||||
needs_selection=False,
|
||||
access_token=token.access_token,
|
||||
user=token.user,
|
||||
temporary_expires_at=person.temporary_expires_at,
|
||||
)
|
||||
|
||||
phone = payload.phone
|
||||
if not phone and payload.phone_code:
|
||||
try:
|
||||
phone = await get_phone_number(payload.phone_code)
|
||||
except WechatConfigError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
candidates = [item.strip() for item in payload.phone_candidates if item.strip()]
|
||||
if phone:
|
||||
candidates.insert(0, phone.strip())
|
||||
candidates = list(dict.fromkeys(candidates))
|
||||
|
||||
if not candidates:
|
||||
raise HTTPException(status_code=400, detail="缺少手机号或手机号授权凭证")
|
||||
|
||||
people = db.scalars(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone.in_(candidates))
|
||||
).all()
|
||||
if not people:
|
||||
return _temporary_worker_response(candidates[0])
|
||||
|
||||
if len(people) > 1 and phone not in [person.phone for person in people]:
|
||||
return LoginSelectionResponse(
|
||||
needs_selection=True,
|
||||
matched_people=[
|
||||
personnel_out(person, role.role)
|
||||
for person in people
|
||||
for role in person.roles
|
||||
],
|
||||
)
|
||||
|
||||
selected = next((person for person in people if person.phone == phone), people[0])
|
||||
if not selected.roles:
|
||||
return _temporary_worker_response(selected.phone)
|
||||
if temporary_expired(selected):
|
||||
return _temporary_worker_response(selected.phone)
|
||||
return _login_for_phone(db, selected.phone, payload.selected_role)
|
||||
|
||||
|
||||
@router.post("/select-role", response_model=TokenResponse)
|
||||
def select_role(payload: RoleSelectionRequest, db: Session = Depends(get_db)) -> TokenResponse:
|
||||
try:
|
||||
token_payload = verify_token(payload.selection_token)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="角色选择已失效") from exc
|
||||
|
||||
if token_payload.get("type") != "role_selection":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的角色选择凭证")
|
||||
if str(payload.role) not in token_payload.get("roles", []):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="该手机号没有此角色")
|
||||
|
||||
person = db.get(Personnel, token_payload.get("sub"))
|
||||
if person is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
|
||||
if temporary_expired(person):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
|
||||
if db.get(PersonRole, {"phone": person.phone, "role": payload.role}) is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="角色不存在")
|
||||
return _token_for(person, payload.role)
|
||||
|
||||
|
||||
@router.get("/me", response_model=PersonnelOut)
|
||||
def get_me(
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PersonnelOut:
|
||||
person = db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone == user.phone)
|
||||
)
|
||||
if person is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
|
||||
if temporary_expired(person):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
|
||||
return personnel_out(person, user.role)
|
||||
|
||||
|
||||
@router.post("/switch-role", response_model=TokenResponse)
|
||||
def switch_role(
|
||||
payload: SwitchRoleRequest,
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
person = db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone == user.phone)
|
||||
)
|
||||
if person is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
|
||||
if temporary_expired(person):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
|
||||
if db.get(PersonRole, {"phone": person.phone, "role": payload.role}) is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="该手机号没有此角色")
|
||||
return _token_for(person, payload.role)
|
||||
@@ -0,0 +1,516 @@
|
||||
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)
|
||||
@@ -0,0 +1,382 @@
|
||||
from datetime import date
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import Response
|
||||
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,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
ReportStatus,
|
||||
Role,
|
||||
WorkSession,
|
||||
)
|
||||
from app.schemas import DashboardRow, PageResponse
|
||||
from app.services.cleaning import is_cleaning_item, is_cleaning_product
|
||||
from app.services.common import as_float, round1, round2
|
||||
from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product
|
||||
from app.services.excel_export import export_dashboard_rows
|
||||
from app.services.metrics import calculate_report_metrics, shift_distribution_text
|
||||
from app.services.misc_work import is_misc_item, is_misc_product
|
||||
from app.services.multi_person import is_multi_person_item
|
||||
from app.services.report_lifecycle import purge_expired_voided_reports
|
||||
from app.services.serializers import report_correction_maps
|
||||
from app.services.work_schedule import get_work_schedule_config
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
def _product_for_item(
|
||||
db: Session,
|
||||
cache: dict[tuple[str, str, str, str, str], Product | None],
|
||||
item: ProductionReportItem,
|
||||
) -> Product | None:
|
||||
key = (
|
||||
item.attendance_point_name,
|
||||
item.project_no,
|
||||
item.product_name,
|
||||
item.process_name or "",
|
||||
"",
|
||||
)
|
||||
if key not in cache:
|
||||
with db.no_autoflush:
|
||||
cache[key] = db.scalar(
|
||||
select(Product).where(
|
||||
Product.project_no == item.project_no,
|
||||
Product.attendance_point_name == item.attendance_point_name,
|
||||
Product.product_name == item.product_name,
|
||||
Product.process_name == (item.process_name or ""),
|
||||
)
|
||||
)
|
||||
return cache[key]
|
||||
|
||||
|
||||
REPORT_CORRECTION_LABELS = {
|
||||
"start_at": "上班时间",
|
||||
"end_at": "下班时间",
|
||||
"review_remark": "备注",
|
||||
}
|
||||
|
||||
ITEM_CORRECTION_LABELS = {
|
||||
"device_no": "设备号",
|
||||
"project_no": "项目号",
|
||||
"product_name": "产品名称",
|
||||
"raw_material_batch_no": "材料库存批次号",
|
||||
"process_name": "工序",
|
||||
"stamping_method": "冲压方式",
|
||||
"process_unit_price_yuan": "工序单价",
|
||||
"changeover_count": "换料次数",
|
||||
"remark": "杂活事项",
|
||||
"good_qty": "成品数量",
|
||||
"defect_qty": "不良数量",
|
||||
"scrap_qty": "报废数量",
|
||||
}
|
||||
|
||||
|
||||
def _correction_value(value) -> str | float:
|
||||
if value is None:
|
||||
return "-"
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def _append_correction_pair(group: dict, key: tuple, label: str, old_value, new_value) -> None:
|
||||
if key in group["correction_pair_keys"]:
|
||||
return
|
||||
group["correction_pair_keys"].add(key)
|
||||
group["correction_pairs"].append(
|
||||
{
|
||||
"key": "__".join(str(part) for part in key),
|
||||
"label": label,
|
||||
"old_value": _correction_value(old_value),
|
||||
"new_value": _correction_value(new_value),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _append_dashboard_corrections(
|
||||
group: dict,
|
||||
report: ProductionReport,
|
||||
item: ProductionReportItem,
|
||||
report_corrections: dict,
|
||||
item_corrections: dict,
|
||||
) -> None:
|
||||
for field, old_value in report_corrections.items():
|
||||
label = REPORT_CORRECTION_LABELS.get(field, field)
|
||||
_append_correction_pair(group, (report.id, field), label, old_value, getattr(report, field))
|
||||
for field, old_value in item_corrections.items():
|
||||
label = ITEM_CORRECTION_LABELS.get(field, field)
|
||||
_append_correction_pair(group, (report.id, item.id, field), label, old_value, getattr(item, field))
|
||||
|
||||
|
||||
def _dashboard_rows(
|
||||
db: Session,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
include_voided: bool = False,
|
||||
) -> list[DashboardRow]:
|
||||
query = (
|
||||
select(ProductionReport)
|
||||
.options(
|
||||
selectinload(ProductionReport.employee),
|
||||
selectinload(ProductionReport.items),
|
||||
selectinload(ProductionReport.audit_logs),
|
||||
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
|
||||
)
|
||||
.where(ProductionReport.status == ReportStatus.approved)
|
||||
)
|
||||
if not include_voided:
|
||||
query = query.where(ProductionReport.is_voided.is_(False))
|
||||
if start_date:
|
||||
query = query.where(ProductionReport.report_date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ProductionReport.report_date <= end_date)
|
||||
reports = db.scalars(query).all()
|
||||
|
||||
groups: dict[tuple, dict] = {}
|
||||
product_cache: dict[tuple[str, str, str, str, str], Product | None] = {}
|
||||
schedule_cache = {}
|
||||
for report in reports:
|
||||
if report.attendance_point_name not in schedule_cache:
|
||||
schedule_cache[report.attendance_point_name] = get_work_schedule_config(db, report.attendance_point_name)
|
||||
schedule = schedule_cache[report.attendance_point_name]
|
||||
report_corrections, item_corrections, _device_corrections = report_correction_maps(report)
|
||||
report_metrics = calculate_report_metrics(
|
||||
report.start_at,
|
||||
report.end_at,
|
||||
list(report.items),
|
||||
devices=report.session.devices if report.session else None,
|
||||
schedule=schedule,
|
||||
)
|
||||
item_count = max(1, len(report.items))
|
||||
fallback_minutes = as_float(report.effective_minutes) / item_count
|
||||
employee_name = report.employee.name if report.employee else ""
|
||||
worker_type = "临时工" if (report.employee and report.employee.is_temporary) else "正式工"
|
||||
for item in report.items:
|
||||
item_misc = is_misc_item(item)
|
||||
process_name = (item.remark or item.process_name or "") if item_misc else (item.process_name or "")
|
||||
product = _product_for_item(db, product_cache, item)
|
||||
is_cleaning = is_cleaning_item(item) or is_cleaning_product(product)
|
||||
is_misc = item_misc or is_misc_product(product)
|
||||
is_continuous_die = is_continuous_die_item(item) or is_continuous_die_product(product)
|
||||
standard_beat = 0 if (is_cleaning or is_misc) else as_float(product.standard_beat if product else item.standard_beat)
|
||||
process_unit_price = as_float(item.process_unit_price_yuan)
|
||||
stamping_method = item.stamping_method or (product.stamping_method if product else None)
|
||||
key = (
|
||||
report.attendance_point_name,
|
||||
report.report_date,
|
||||
employee_name,
|
||||
report.employee_phone,
|
||||
item.project_no,
|
||||
item.product_name,
|
||||
process_name,
|
||||
stamping_method or "",
|
||||
)
|
||||
group = groups.setdefault(
|
||||
key,
|
||||
{
|
||||
"id": "__".join(str(part) for part in key),
|
||||
"attendance_point_name": report.attendance_point_name,
|
||||
"report_date": report.report_date,
|
||||
"employee_phone": report.employee_phone,
|
||||
"employee_name": employee_name,
|
||||
"worker_type": worker_type,
|
||||
"project_no": item.project_no,
|
||||
"product_name": item.product_name,
|
||||
"process_name": process_name,
|
||||
"report_ids": set(),
|
||||
"effective_minutes": 0.0,
|
||||
"shift_day_minutes": 0.0,
|
||||
"shift_overtime_minutes": 0.0,
|
||||
"shift_night_minutes": 0.0,
|
||||
"total_good_qty": 0.0,
|
||||
"total_defect_qty": 0.0,
|
||||
"total_output_qty": 0.0,
|
||||
"standard_beat": standard_beat,
|
||||
"process_unit_price_yuan": process_unit_price,
|
||||
"changeover_count": 0.0,
|
||||
"reference_wage": 0.0,
|
||||
"expected_workload": 0.0,
|
||||
"profile_no": product.profile_no if product else None,
|
||||
"material_code": product.material_code if product else item.material_code,
|
||||
"material_name": product.material_name if product else item.material_name,
|
||||
"raw_material_batch_numbers": set(),
|
||||
"device_no": item.device_no,
|
||||
"stamping_method": stamping_method,
|
||||
"operator_count": as_float(product.operator_count) if product else as_float(item.operator_count),
|
||||
"is_continuous_die": is_continuous_die,
|
||||
"is_multi_person": is_multi_person_item(item),
|
||||
"is_multi_person_assistant": False,
|
||||
"is_misc": is_misc,
|
||||
"is_system_auto_submitted": False,
|
||||
"is_voided": False,
|
||||
"review_remarks": [],
|
||||
"review_remark_keys": set(),
|
||||
"correction_pairs": [],
|
||||
"correction_pair_keys": set(),
|
||||
},
|
||||
)
|
||||
_append_dashboard_corrections(group, report, item, report_corrections, item_corrections.get(item.id, {}))
|
||||
good_qty = as_float(item.good_qty)
|
||||
defect_qty = as_float(item.defect_qty) + as_float(item.scrap_qty)
|
||||
output_qty = good_qty + defect_qty
|
||||
allocated_minutes = 0 if is_cleaning else (as_float(item.allocated_minutes) or fallback_minutes)
|
||||
expected_workload = allocated_minutes * 60 / standard_beat if standard_beat > 0 else 0
|
||||
group["report_ids"].add(report.id)
|
||||
group["effective_minutes"] += allocated_minutes
|
||||
group["expected_workload"] += expected_workload
|
||||
group["shift_day_minutes"] += as_float(getattr(item, "_shift_day_minutes", 0))
|
||||
group["shift_overtime_minutes"] += as_float(getattr(item, "_shift_overtime_minutes", 0))
|
||||
group["shift_night_minutes"] += as_float(getattr(item, "_shift_night_minutes", 0))
|
||||
group["total_good_qty"] += good_qty
|
||||
group["reference_wage"] += good_qty * process_unit_price
|
||||
group["changeover_count"] += as_float(item.changeover_count)
|
||||
group["total_defect_qty"] += defect_qty
|
||||
group["total_output_qty"] += output_qty
|
||||
group["is_system_auto_submitted"] = (
|
||||
group["is_system_auto_submitted"] or bool(report.is_system_auto_submitted)
|
||||
)
|
||||
group["is_voided"] = group["is_voided"] or bool(report.is_voided)
|
||||
group["is_multi_person"] = group["is_multi_person"] or is_multi_person_item(item)
|
||||
group["is_continuous_die"] = group["is_continuous_die"] or is_continuous_die
|
||||
group["is_multi_person_assistant"] = (
|
||||
group["is_multi_person_assistant"] or bool(report.is_multi_person_assistant)
|
||||
)
|
||||
if report.review_remark:
|
||||
review_remark = str(report.review_remark).strip()
|
||||
if review_remark and review_remark not in group["review_remark_keys"]:
|
||||
group["review_remark_keys"].add(review_remark)
|
||||
group["review_remarks"].append(review_remark)
|
||||
if item.raw_material_batch_no:
|
||||
group["raw_material_batch_numbers"].add(item.raw_material_batch_no)
|
||||
|
||||
rows: list[DashboardRow] = []
|
||||
for group in groups.values():
|
||||
actual_beat = (
|
||||
group["effective_minutes"] * 60 / group["total_output_qty"]
|
||||
if group["total_output_qty"] > 0
|
||||
else 0
|
||||
)
|
||||
standard_beat = group["standard_beat"]
|
||||
expected_workload = group["expected_workload"]
|
||||
pace_rate = (actual_beat - standard_beat) / standard_beat * 100 if standard_beat > 0 else 0
|
||||
workload_rate = (
|
||||
(group["total_good_qty"] - expected_workload) / expected_workload * 100
|
||||
if expected_workload > 0
|
||||
else 0
|
||||
)
|
||||
shift_minutes = {
|
||||
"day": group["shift_day_minutes"],
|
||||
"overtime": group["shift_overtime_minutes"],
|
||||
"night": group["shift_night_minutes"],
|
||||
}
|
||||
rows.append(
|
||||
DashboardRow(
|
||||
id=group["id"],
|
||||
attendance_point_name=group["attendance_point_name"],
|
||||
report_date=group["report_date"],
|
||||
employee_phone=group["employee_phone"],
|
||||
employee_name=group["employee_name"],
|
||||
worker_type=group["worker_type"],
|
||||
project_no=group["project_no"],
|
||||
product_name=group["product_name"],
|
||||
process_name=group["process_name"],
|
||||
report_count=len(group["report_ids"]),
|
||||
effective_minutes=round2(group["effective_minutes"]),
|
||||
shift_distribution_text=shift_distribution_text(shift_minutes),
|
||||
shift_day_minutes=round2(group["shift_day_minutes"]),
|
||||
shift_overtime_minutes=round2(group["shift_overtime_minutes"]),
|
||||
shift_night_minutes=round2(group["shift_night_minutes"]),
|
||||
shift_other_minutes=0,
|
||||
total_good_qty=round2(group["total_good_qty"]),
|
||||
total_defect_qty=round2(group["total_defect_qty"]),
|
||||
total_output_qty=round2(group["total_output_qty"]),
|
||||
actual_beat=round2(actual_beat),
|
||||
standard_beat=round2(standard_beat),
|
||||
process_unit_price_yuan=round(as_float(group["process_unit_price_yuan"]), 4),
|
||||
changeover_count=round2(group["changeover_count"]),
|
||||
reference_wage=round2(group["reference_wage"]),
|
||||
expected_workload=round1(expected_workload),
|
||||
pace_rate=round1(pace_rate),
|
||||
workload_rate=round1(workload_rate),
|
||||
pace_text=f"{'慢' if pace_rate > 0 else '快'}{abs(round1(pace_rate))}%",
|
||||
workload_text=f"{'多' if workload_rate >= 0 else '少'}{abs(round1(workload_rate))}%",
|
||||
profile_no=group["profile_no"],
|
||||
material_code=group["material_code"],
|
||||
material_name=group["material_name"],
|
||||
raw_material_batch_no="、".join(sorted(group["raw_material_batch_numbers"])) or None,
|
||||
device_no=group["device_no"],
|
||||
stamping_method=group["stamping_method"],
|
||||
operator_count=group["operator_count"],
|
||||
is_continuous_die=group["is_continuous_die"],
|
||||
is_multi_person=group["is_multi_person"],
|
||||
is_multi_person_assistant=group["is_multi_person_assistant"],
|
||||
is_misc=group["is_misc"],
|
||||
is_system_auto_submitted=group["is_system_auto_submitted"],
|
||||
is_voided=group["is_voided"],
|
||||
review_remark=";".join(group["review_remarks"]) or None,
|
||||
correction_pairs=group["correction_pairs"],
|
||||
)
|
||||
)
|
||||
|
||||
rows.sort(
|
||||
key=lambda item: (
|
||||
item.report_date,
|
||||
item.attendance_point_name,
|
||||
item.employee_phone,
|
||||
item.project_no,
|
||||
item.product_name,
|
||||
item.process_name or "",
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/reports/export")
|
||||
def export_dashboard_report_excel(
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
include_voided: bool = False,
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
purge_expired_voided_reports(db)
|
||||
content = export_dashboard_rows(_dashboard_rows(db, start_date, end_date, include_voided))
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="dashboard.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/reports", response_model=PageResponse)
|
||||
def dashboard_reports(
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
include_voided: bool = False,
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
purge_expired_voided_reports(db)
|
||||
rows = _dashboard_rows(db, start_date, end_date, include_voided)
|
||||
safe_page_size = min(100, max(1, page_size))
|
||||
total = len(rows)
|
||||
total_pages = max(1, ceil(total / safe_page_size))
|
||||
start = (page - 1) * safe_page_size
|
||||
return PageResponse(
|
||||
page=page,
|
||||
page_size=safe_page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
rows=rows[start : start + safe_page_size],
|
||||
)
|
||||
@@ -0,0 +1,505 @@
|
||||
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)
|
||||
@@ -0,0 +1,108 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import Equipment, Personnel, Role
|
||||
from app.schemas import EquipmentCreate, EquipmentOut, PageResponse
|
||||
from app.services.attendance_points import accessible_point_names, require_attendance_point_access
|
||||
from app.services.common import normalize_device_no, paginate
|
||||
from app.services.serializers import equipment_out
|
||||
|
||||
router = APIRouter(prefix="/api/equipment", tags=["equipment"])
|
||||
|
||||
|
||||
EQUIPMENT_TYPES = {"冲压设备", "清洗设备"}
|
||||
|
||||
|
||||
@router.get("", response_model=PageResponse)
|
||||
def list_equipment(
|
||||
attendance_point_name: str = "",
|
||||
keyword: str = "",
|
||||
device_type: str = "",
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
user: Personnel = Depends(require_roles(Role.worker, Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
point_names = accessible_point_names(db, user)
|
||||
query = select(Equipment).where(Equipment.attendance_point_name.in_(point_names)).order_by(Equipment.updated_at.desc(), Equipment.device_no.asc())
|
||||
if attendance_point_name:
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, attendance_point_name)
|
||||
except (ValueError, PermissionError) as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
query = query.where(Equipment.attendance_point_name == point_name)
|
||||
if device_type:
|
||||
query = query.where(Equipment.device_type == device_type)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
Equipment.attendance_point_name.like(like),
|
||||
Equipment.device_no.like(like),
|
||||
Equipment.device_type.like(like),
|
||||
Equipment.remark.like(like),
|
||||
)
|
||||
)
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{**result, "rows": [equipment_out(row) for row in result["rows"]]})
|
||||
|
||||
|
||||
@router.post("", response_model=EquipmentOut)
|
||||
def save_equipment(
|
||||
payload: EquipmentCreate,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> EquipmentOut:
|
||||
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
|
||||
device_no = normalize_device_no(payload.device_no)
|
||||
if not device_no:
|
||||
raise HTTPException(status_code=400, detail="请输入设备号")
|
||||
if payload.device_type not in EQUIPMENT_TYPES:
|
||||
raise HTTPException(status_code=400, detail="设备类型只能是冲压设备或清洗设备")
|
||||
original_point_name = payload.original_attendance_point_name or point_name
|
||||
original_device_no = normalize_device_no(payload.original_device_no) or device_no
|
||||
original_key = {"attendance_point_name": original_point_name, "device_no": original_device_no}
|
||||
next_key = {"attendance_point_name": point_name, "device_no": device_no}
|
||||
equipment = db.get(Equipment, original_key)
|
||||
if equipment is not None and original_key != next_key:
|
||||
existed = db.get(Equipment, next_key)
|
||||
if existed is not None:
|
||||
raise HTTPException(status_code=409, detail="该设备号已存在,不能重复保存")
|
||||
equipment.attendance_point_name = point_name
|
||||
equipment.device_no = device_no
|
||||
elif equipment is None:
|
||||
equipment = db.get(Equipment, next_key)
|
||||
if equipment is None:
|
||||
equipment = Equipment(attendance_point_name=point_name, device_no=device_no)
|
||||
db.add(equipment)
|
||||
equipment.device_type = payload.device_type
|
||||
equipment.remark = payload.remark
|
||||
db.commit()
|
||||
db.refresh(equipment)
|
||||
return equipment_out(equipment)
|
||||
|
||||
|
||||
@router.delete("/{device_no}", status_code=204)
|
||||
def delete_equipment(
|
||||
device_no: str,
|
||||
attendance_point_name: str,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, attendance_point_name)
|
||||
except (ValueError, PermissionError) as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
equipment = db.get(Equipment, {"attendance_point_name": point_name, "device_no": normalize_device_no(device_no)})
|
||||
if equipment is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备不存在")
|
||||
db.delete(equipment)
|
||||
db.commit()
|
||||
@@ -0,0 +1,192 @@
|
||||
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 MoldLockFeedback, Personnel, Role, SessionStatus, WorkSession, WorkSessionDevice
|
||||
from app.schemas import MoldLockFeedbackCreate, MoldLockFeedbackOut, MonitorLockOut, PageResponse
|
||||
from app.services.attendance_points import accessible_point_names
|
||||
from app.services.common import paginate
|
||||
from app.services.mold_locks import (
|
||||
FEEDBACK_STATUS_PENDING,
|
||||
active_locks_for_points,
|
||||
current_unreleased_device,
|
||||
feedback_out,
|
||||
lock_out,
|
||||
release_lock,
|
||||
)
|
||||
from app.timezone import now
|
||||
|
||||
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
|
||||
|
||||
|
||||
def _lock_query():
|
||||
return (
|
||||
select(WorkSessionDevice)
|
||||
.options(
|
||||
selectinload(WorkSessionDevice.session).selectinload(WorkSession.devices),
|
||||
selectinload(WorkSessionDevice.session).selectinload(WorkSession.employee),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _feedback_query():
|
||||
return select(MoldLockFeedback).options(
|
||||
selectinload(MoldLockFeedback.reporter),
|
||||
selectinload(MoldLockFeedback.occupied_person),
|
||||
selectinload(MoldLockFeedback.handler),
|
||||
selectinload(MoldLockFeedback.session_device),
|
||||
)
|
||||
|
||||
|
||||
def _admin_point_names(db: Session, user: Personnel) -> list[str]:
|
||||
return accessible_point_names(db, user)
|
||||
|
||||
|
||||
def _get_admin_lock(db: Session, user: Personnel, lock_id: int) -> WorkSessionDevice:
|
||||
lock = db.scalar(_lock_query().where(WorkSessionDevice.id == lock_id))
|
||||
if lock is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="占用记录不存在")
|
||||
if lock.attendance_point_name not in _admin_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点监控权限")
|
||||
return lock
|
||||
|
||||
|
||||
def _get_admin_feedback(db: Session, user: Personnel, feedback_id: int) -> MoldLockFeedback:
|
||||
feedback = db.scalar(_feedback_query().where(MoldLockFeedback.id == feedback_id))
|
||||
if feedback is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="反馈不存在")
|
||||
if feedback.attendance_point_name not in _admin_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点监控权限")
|
||||
return feedback
|
||||
|
||||
|
||||
def _is_active_current_lock(lock: WorkSessionDevice) -> bool:
|
||||
if (
|
||||
lock.session is None
|
||||
or lock.session.status not in [SessionStatus.active, SessionStatus.reporting]
|
||||
or lock.released_at is not None
|
||||
):
|
||||
return False
|
||||
current = current_unreleased_device(lock.session)
|
||||
return bool(current and current.id == lock.id)
|
||||
|
||||
|
||||
@router.get("/locks", response_model=list[MonitorLockOut])
|
||||
def list_active_locks(
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[MonitorLockOut]:
|
||||
return [lock_out(db, lock) for lock in active_locks_for_points(db, _admin_point_names(db, user))]
|
||||
|
||||
|
||||
@router.post("/locks/{lock_id}/release")
|
||||
def force_release_lock(
|
||||
lock_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, bool]:
|
||||
_get_admin_lock(db, user, lock_id)
|
||||
try:
|
||||
release_lock(db, lock_id, user.phone, "管理员强制解除占用")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/feedbacks", response_model=PageResponse)
|
||||
def list_feedbacks(
|
||||
box: str = Query("unread", pattern="^(unread|read)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=10),
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
point_names = _admin_point_names(db, user)
|
||||
query = _feedback_query().where(MoldLockFeedback.attendance_point_name.in_(point_names))
|
||||
if box == "read":
|
||||
query = query.where(MoldLockFeedback.read_at.is_not(None)).order_by(
|
||||
MoldLockFeedback.read_at.desc(),
|
||||
MoldLockFeedback.created_at.desc(),
|
||||
)
|
||||
else:
|
||||
query = query.where(MoldLockFeedback.read_at.is_(None)).order_by(MoldLockFeedback.created_at.desc())
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{**result, "rows": [feedback_out(db, row) for row in result["rows"]]})
|
||||
|
||||
|
||||
@router.post("/feedbacks", response_model=MoldLockFeedbackOut)
|
||||
def create_feedback(
|
||||
payload: MoldLockFeedbackCreate,
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MoldLockFeedbackOut:
|
||||
lock = db.scalar(_lock_query().where(WorkSessionDevice.id == payload.lock_id))
|
||||
if lock is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="占用记录不存在")
|
||||
if lock.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点反馈权限")
|
||||
if not _is_active_current_lock(lock):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该模具占用已解除")
|
||||
if lock.session.employee_phone == user.phone:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能反馈自己的占用")
|
||||
|
||||
existing = db.scalar(
|
||||
_feedback_query().where(
|
||||
MoldLockFeedback.session_device_id == lock.id,
|
||||
MoldLockFeedback.reporter_phone == user.phone,
|
||||
MoldLockFeedback.status == FEEDBACK_STATUS_PENDING,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
return feedback_out(db, existing)
|
||||
|
||||
feedback = MoldLockFeedback(
|
||||
attendance_point_name=lock.attendance_point_name,
|
||||
mold_name=lock.device_no,
|
||||
process_name=lock.process_name,
|
||||
session_device_id=lock.id,
|
||||
reporter_phone=user.phone,
|
||||
occupied_phone=lock.session.employee_phone,
|
||||
status=FEEDBACK_STATUS_PENDING,
|
||||
)
|
||||
db.add(feedback)
|
||||
db.commit()
|
||||
feedback = db.scalar(_feedback_query().where(MoldLockFeedback.id == feedback.id))
|
||||
if feedback is None:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="反馈保存失败")
|
||||
return feedback_out(db, feedback)
|
||||
|
||||
|
||||
@router.post("/feedbacks/{feedback_id}/read", response_model=MoldLockFeedbackOut)
|
||||
def mark_feedback_read(
|
||||
feedback_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MoldLockFeedbackOut:
|
||||
feedback = _get_admin_feedback(db, user, feedback_id)
|
||||
if feedback.read_at is None:
|
||||
feedback.read_at = now()
|
||||
db.commit()
|
||||
feedback = _get_admin_feedback(db, user, feedback_id)
|
||||
return feedback_out(db, feedback)
|
||||
|
||||
|
||||
@router.post("/feedbacks/{feedback_id}/release", response_model=MoldLockFeedbackOut)
|
||||
def release_feedback_lock(
|
||||
feedback_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MoldLockFeedbackOut:
|
||||
feedback = _get_admin_feedback(db, user, feedback_id)
|
||||
if feedback.read_at is None:
|
||||
feedback.read_at = now()
|
||||
try:
|
||||
release_lock(db, feedback.session_device_id, user.phone, "管理员通过反馈解除占用")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
feedback = _get_admin_feedback(db, user, feedback_id)
|
||||
return feedback_out(db, feedback)
|
||||
@@ -0,0 +1,146 @@
|
||||
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 current_user, require_roles
|
||||
from app.models import Notice, NoticePoint, Personnel, Role
|
||||
from app.schemas import NoticeCreate, NoticeOut, NoticeUpdate, PageResponse
|
||||
from app.services.common import paginate
|
||||
from app.services.attendance_points import accessible_point_names
|
||||
|
||||
router = APIRouter(prefix="/api/notices", tags=["notices"])
|
||||
|
||||
|
||||
def _notice_out(notice: Notice) -> NoticeOut:
|
||||
point_names = sorted([item.attendance_point_name for item in notice.attendance_points])
|
||||
return NoticeOut(
|
||||
id=notice.id,
|
||||
title=notice.title,
|
||||
content=notice.content,
|
||||
attendance_point_names=point_names,
|
||||
sort_order=notice.sort_order,
|
||||
is_active=notice.is_active,
|
||||
created_by=notice.created_by,
|
||||
creator_name=notice.creator.name if notice.creator else "",
|
||||
created_at=notice.created_at,
|
||||
updated_at=notice.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _clean_text(value: str, field_name: str) -> str:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail=f"{field_name}不能为空")
|
||||
return text
|
||||
|
||||
|
||||
@router.get("", response_model=PageResponse)
|
||||
def list_notices(
|
||||
include_inactive: bool = False,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
if include_inactive and user.role not in (Role.admin, Role.manager):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||
|
||||
query = (
|
||||
select(Notice)
|
||||
.options(selectinload(Notice.creator), selectinload(Notice.attendance_points))
|
||||
.order_by(Notice.sort_order.asc(), Notice.updated_at.desc())
|
||||
)
|
||||
if not include_inactive:
|
||||
query = query.where(Notice.is_active.is_(True))
|
||||
point_names = accessible_point_names(db, user)
|
||||
query = query.where(Notice.attendance_points.any(NoticePoint.attendance_point_name.in_(point_names)))
|
||||
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{**result, "rows": [_notice_out(row) for row in result["rows"]]})
|
||||
|
||||
|
||||
@router.post("", response_model=NoticeOut)
|
||||
def create_notice(
|
||||
payload: NoticeCreate,
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> NoticeOut:
|
||||
allowed = set(accessible_point_names(db, user))
|
||||
point_names = payload.attendance_point_names or list(allowed)
|
||||
denied = [name for name in point_names if name not in allowed]
|
||||
if denied:
|
||||
raise HTTPException(status_code=403, detail=f"无该考勤点通知配置权限:{'、'.join(denied)}")
|
||||
notice = Notice(
|
||||
title=_clean_text(payload.title, "标题"),
|
||||
content=_clean_text(payload.content, "内容"),
|
||||
sort_order=payload.sort_order,
|
||||
is_active=payload.is_active,
|
||||
created_by=user.phone,
|
||||
)
|
||||
for name in dict.fromkeys(point_names):
|
||||
notice.attendance_points.append(NoticePoint(attendance_point_name=name))
|
||||
db.add(notice)
|
||||
db.commit()
|
||||
db.refresh(notice)
|
||||
return _notice_out(notice)
|
||||
|
||||
|
||||
@router.put("/{notice_id}", response_model=NoticeOut)
|
||||
def update_notice(
|
||||
notice_id: int,
|
||||
payload: NoticeUpdate,
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> NoticeOut:
|
||||
notice = db.scalar(
|
||||
select(Notice)
|
||||
.options(selectinload(Notice.attendance_points))
|
||||
.where(Notice.id == notice_id)
|
||||
)
|
||||
if notice is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
|
||||
allowed = set(accessible_point_names(db, user))
|
||||
if user.role == Role.admin and not any(item.attendance_point_name in allowed for item in notice.attendance_points):
|
||||
raise HTTPException(status_code=403, detail="无该考勤点通知配置权限")
|
||||
|
||||
if payload.title is not None:
|
||||
notice.title = _clean_text(payload.title, "标题")
|
||||
if payload.content is not None:
|
||||
notice.content = _clean_text(payload.content, "内容")
|
||||
if payload.sort_order is not None:
|
||||
notice.sort_order = payload.sort_order
|
||||
if payload.is_active is not None:
|
||||
notice.is_active = payload.is_active
|
||||
if payload.attendance_point_names is not None:
|
||||
denied = [name for name in payload.attendance_point_names if name not in allowed]
|
||||
if denied:
|
||||
raise HTTPException(status_code=403, detail=f"无该考勤点通知配置权限:{'、'.join(denied)}")
|
||||
notice.attendance_points.clear()
|
||||
db.flush()
|
||||
for name in dict.fromkeys(payload.attendance_point_names or list(allowed)):
|
||||
notice.attendance_points.append(NoticePoint(attendance_point_name=name))
|
||||
|
||||
db.commit()
|
||||
db.refresh(notice)
|
||||
return _notice_out(notice)
|
||||
|
||||
|
||||
@router.delete("/{notice_id}", status_code=204)
|
||||
def delete_notice(
|
||||
notice_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
notice = db.scalar(
|
||||
select(Notice)
|
||||
.options(selectinload(Notice.attendance_points))
|
||||
.where(Notice.id == notice_id)
|
||||
)
|
||||
if notice is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
|
||||
allowed = set(accessible_point_names(db, user))
|
||||
if user.role == Role.admin and not any(item.attendance_point_name in allowed for item in notice.attendance_points):
|
||||
raise HTTPException(status_code=403, detail="无该考勤点通知配置权限")
|
||||
db.delete(notice)
|
||||
db.commit()
|
||||
@@ -0,0 +1,163 @@
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import PersonAttendancePoint, PersonRole, Personnel, Role
|
||||
from app.schemas import ImportResult, PageResponse, PersonnelCreate, PersonnelOut
|
||||
from app.services.common import ROLE_NAMES
|
||||
from app.services.attendance_points import accessible_point_names, set_person_attendance_points
|
||||
from app.services.excel_export import export_people
|
||||
from app.services.excel_import import ProductImportValidationError, import_people
|
||||
from app.services.serializers import personnel_out, personnel_summary_out
|
||||
|
||||
router = APIRouter(prefix="/api/people", tags=["people"])
|
||||
|
||||
|
||||
@router.get("", response_model=PageResponse)
|
||||
def list_people(
|
||||
keyword: str = "",
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
point_names = accessible_point_names(db, user)
|
||||
query = (
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points).selectinload(PersonAttendancePoint.attendance_point))
|
||||
.where(Personnel.roles.any())
|
||||
.order_by(Personnel.created_at.desc())
|
||||
)
|
||||
if user.role == Role.admin:
|
||||
query = query.where(
|
||||
Personnel.roles.any(PersonRole.role == Role.worker),
|
||||
Personnel.attendance_points.any(PersonAttendancePoint.attendance_point_name.in_(point_names)),
|
||||
)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
matched_roles = [role for role, name in ROLE_NAMES.items() if keyword in name or keyword in role]
|
||||
query = query.where(
|
||||
or_(
|
||||
Personnel.phone.like(like),
|
||||
Personnel.name.like(like),
|
||||
Personnel.roles.any(PersonRole.role.in_(matched_roles)),
|
||||
)
|
||||
)
|
||||
safe_page = max(1, page)
|
||||
safe_page_size = min(100, max(1, page_size))
|
||||
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
||||
rows = db.scalars(query.limit(safe_page_size).offset((safe_page - 1) * safe_page_size)).all()
|
||||
return PageResponse(
|
||||
page=safe_page,
|
||||
page_size=safe_page_size,
|
||||
total=total,
|
||||
total_pages=max(1, (total + safe_page_size - 1) // safe_page_size),
|
||||
rows=[personnel_summary_out(row) for row in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=PersonnelOut)
|
||||
def save_person(
|
||||
payload: PersonnelCreate,
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PersonnelOut:
|
||||
if user.role == Role.admin and payload.role != Role.worker:
|
||||
raise HTTPException(status_code=403, detail="管理员只能新增或导入冲压工人")
|
||||
point_names = payload.attendance_point_names or accessible_point_names(db, user)
|
||||
if user.role == Role.admin:
|
||||
allowed = set(accessible_point_names(db, user))
|
||||
denied = [name for name in point_names if name not in allowed]
|
||||
if denied:
|
||||
raise HTTPException(status_code=403, detail=f"无该考勤点人员维护权限:{'、'.join(denied)}")
|
||||
phone = str(payload.phone or "").strip()
|
||||
original_phone = str(payload.original_phone or "").strip()
|
||||
if original_phone and original_phone != phone:
|
||||
raise HTTPException(status_code=400, detail="编辑人员不能修改手机号")
|
||||
|
||||
person = db.get(Personnel, phone)
|
||||
editing_existing = bool(original_phone)
|
||||
active_role_count = db.scalar(select(func.count()).select_from(PersonRole).where(PersonRole.phone == phone)) or 0
|
||||
if person is not None and not editing_existing and active_role_count > 0:
|
||||
raise HTTPException(status_code=409, detail="该手机号已存在,不能重复添加")
|
||||
if person is None:
|
||||
person = Personnel(phone=phone, name=payload.name)
|
||||
db.add(person)
|
||||
db.flush()
|
||||
else:
|
||||
person.name = payload.name
|
||||
person.is_temporary = False
|
||||
person.temporary_expires_at = None
|
||||
role = db.get(PersonRole, {"phone": phone, "role": payload.role})
|
||||
if role is None:
|
||||
role = PersonRole(phone=phone, role=payload.role)
|
||||
db.add(role)
|
||||
try:
|
||||
set_person_attendance_points(db, phone, point_names)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
person = db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points).selectinload(PersonAttendancePoint.attendance_point))
|
||||
.where(Personnel.phone == phone)
|
||||
)
|
||||
if person is None:
|
||||
raise HTTPException(status_code=500, detail="人员保存失败")
|
||||
return personnel_out(person, payload.role)
|
||||
|
||||
|
||||
@router.delete("/{phone}", status_code=204)
|
||||
def delete_person(
|
||||
phone: str,
|
||||
role: Role | None = None,
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
person = db.get(Personnel, phone)
|
||||
if person is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
|
||||
if user.role == Role.admin:
|
||||
point_names = accessible_point_names(db, user)
|
||||
if not any(item.attendance_point_name in point_names for item in person.attendance_points):
|
||||
raise HTTPException(status_code=403, detail="无该考勤点人员维护权限")
|
||||
if role is not None and role != Role.worker:
|
||||
raise HTTPException(status_code=403, detail="管理员只能删除冲压工人")
|
||||
if role is None:
|
||||
db.query(PersonRole).filter(PersonRole.phone == phone).delete()
|
||||
else:
|
||||
person_role = db.get(PersonRole, {"phone": phone, "role": role})
|
||||
if person_role is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="角色不存在")
|
||||
db.delete(person_role)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/import", response_model=ImportResult)
|
||||
async def import_people_excel(
|
||||
file: UploadFile = File(...),
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ImportResult:
|
||||
content = await file.read()
|
||||
try:
|
||||
result = import_people(db, content, user=user)
|
||||
except ProductImportValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return ImportResult(**result.as_dict())
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_people_excel(
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
content = export_people(db, user=user)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="people.xlsx"'},
|
||||
)
|
||||
@@ -0,0 +1,360 @@
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import DeviceQRCode, MoldLockFeedback, Personnel, Product, ProductionReportItem, Role, WorkSessionDevice
|
||||
from app.schemas import ImportResult, MoldNameOut, PageResponse, ProductCreate, ProductOut
|
||||
from app.services.attendance_points import accessible_point_names, require_attendance_point_access
|
||||
from app.services.common import normalize_device_no, paginate
|
||||
from app.services.excel_import import ProductImportValidationError, import_products
|
||||
from app.services.excel_export import export_products
|
||||
from app.services.cleaning import LEGACY_CLEANING_PROCESS_NAME, is_cleaning_stamping_method
|
||||
from app.services.continuous_die import is_continuous_die_product
|
||||
from app.services.display_names import mold_process_display_name
|
||||
from app.services.misc_work import ensure_misc_work_products, is_misc_stamping_method
|
||||
from app.services.multi_person import is_multi_person_product
|
||||
from app.services.process_names import MISC_PROCESS_NAME, normalize_process_name
|
||||
from app.services.serializers import product_out
|
||||
|
||||
router = APIRouter(prefix="/api/products", tags=["products"])
|
||||
|
||||
|
||||
def _cascade_product_key_change(db: Session, original_key: dict, next_key: dict) -> None:
|
||||
if original_key == next_key:
|
||||
return
|
||||
old_point = original_key["attendance_point_name"]
|
||||
old_product = original_key["product_name"]
|
||||
old_process = original_key["process_name"]
|
||||
next_point = next_key["attendance_point_name"]
|
||||
next_product = next_key["product_name"]
|
||||
next_process = next_key["process_name"]
|
||||
|
||||
old_qr = db.get(DeviceQRCode, {
|
||||
"attendance_point_name": old_point,
|
||||
"device_no": old_product,
|
||||
"process_name": old_process,
|
||||
})
|
||||
if old_qr is not None:
|
||||
qr_target = db.get(DeviceQRCode, {
|
||||
"attendance_point_name": next_point,
|
||||
"device_no": next_product,
|
||||
"process_name": next_process,
|
||||
})
|
||||
if qr_target is not None:
|
||||
raise HTTPException(status_code=409, detail="目标产品/工序二维码已存在,不能合并覆盖")
|
||||
old_qr.attendance_point_name = next_point
|
||||
old_qr.device_no = next_product
|
||||
old_qr.process_name = next_process
|
||||
|
||||
for device in db.scalars(select(WorkSessionDevice).where(
|
||||
WorkSessionDevice.attendance_point_name == old_point,
|
||||
WorkSessionDevice.device_no == old_product,
|
||||
WorkSessionDevice.process_name == old_process,
|
||||
)).all():
|
||||
device.attendance_point_name = next_point
|
||||
device.device_no = next_product
|
||||
device.process_name = next_process
|
||||
|
||||
for item in db.scalars(select(ProductionReportItem).where(
|
||||
ProductionReportItem.attendance_point_name == old_point,
|
||||
ProductionReportItem.project_no == original_key["project_no"],
|
||||
ProductionReportItem.product_name == old_product,
|
||||
ProductionReportItem.process_name == old_process,
|
||||
)).all():
|
||||
item.attendance_point_name = next_point
|
||||
item.project_no = next_key["project_no"]
|
||||
item.product_name = next_product
|
||||
item.process_name = next_process
|
||||
|
||||
for feedback in db.scalars(select(MoldLockFeedback).where(
|
||||
MoldLockFeedback.attendance_point_name == old_point,
|
||||
MoldLockFeedback.mold_name == old_product,
|
||||
MoldLockFeedback.process_name == old_process,
|
||||
)).all():
|
||||
feedback.attendance_point_name = next_point
|
||||
feedback.mold_name = next_product
|
||||
feedback.process_name = next_process
|
||||
|
||||
|
||||
@router.get("/molds", response_model=list[MoldNameOut])
|
||||
def list_mold_names(
|
||||
keyword: str = "",
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[MoldNameOut]:
|
||||
point_names = accessible_point_names(db, user)
|
||||
ensure_misc_work_products(db, point_names)
|
||||
db.commit()
|
||||
query = (
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name.in_(point_names),
|
||||
Product.product_name != "",
|
||||
Product.process_name != "",
|
||||
Product.device_no == "",
|
||||
)
|
||||
.distinct()
|
||||
.order_by(Product.attendance_point_name, Product.product_name, Product.process_name)
|
||||
)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.where(or_(Product.product_name.like(like), Product.process_name.like(like), Product.stamping_method.like(like)))
|
||||
rows = db.scalars(query.limit(limit)).all()
|
||||
return [
|
||||
MoldNameOut(
|
||||
attendance_point_name=product.attendance_point_name,
|
||||
name=product.product_name,
|
||||
process_name=product.process_name or "",
|
||||
stamping_method=product.stamping_method,
|
||||
operator_count=product.operator_count,
|
||||
is_cleaning=is_cleaning_stamping_method(product.stamping_method),
|
||||
is_misc=is_misc_stamping_method(product.stamping_method),
|
||||
is_continuous_die=is_continuous_die_product(product),
|
||||
is_multi_person=is_multi_person_product(product),
|
||||
display_name=mold_process_display_name(product.product_name, product.process_name, product.stamping_method),
|
||||
)
|
||||
for product in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=PageResponse)
|
||||
def list_products(
|
||||
attendance_point_name: str = "",
|
||||
keyword: str = "",
|
||||
device_no: str = "",
|
||||
product_name: str = "",
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
user: Personnel = Depends(require_roles(Role.worker, Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
point_names = accessible_point_names(db, user)
|
||||
ensure_misc_work_products(db, point_names)
|
||||
db.commit()
|
||||
query = (
|
||||
select(Product)
|
||||
.where(Product.device_no == "", Product.attendance_point_name.in_(point_names))
|
||||
.order_by(
|
||||
Product.updated_at.desc(),
|
||||
Product.attendance_point_name.asc(),
|
||||
Product.project_no.asc(),
|
||||
Product.product_name.asc(),
|
||||
Product.process_name.asc(),
|
||||
)
|
||||
)
|
||||
if attendance_point_name:
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, attendance_point_name)
|
||||
except (ValueError, PermissionError) as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
query = query.where(Product.attendance_point_name == point_name)
|
||||
if product_name:
|
||||
query = query.where(Product.product_name == product_name)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
Product.project_no.like(like),
|
||||
Product.product_name.like(like),
|
||||
Product.attendance_point_name.like(like),
|
||||
Product.material_code.like(like),
|
||||
Product.material_name.like(like),
|
||||
Product.supplier.like(like),
|
||||
Product.process_name.like(like),
|
||||
Product.stamping_method.like(like),
|
||||
)
|
||||
)
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{**result, "rows": [product_out(row) for row in result["rows"]]})
|
||||
|
||||
|
||||
@router.post("", response_model=ProductOut)
|
||||
def save_product(
|
||||
payload: ProductCreate,
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProductOut:
|
||||
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
|
||||
raw_process_name = str(payload.process_name or "").strip()
|
||||
if raw_process_name == LEGACY_CLEANING_PROCESS_NAME:
|
||||
raise HTTPException(status_code=400, detail="清洗请填写在冲压方式中,工序只能填写数字,处理杂活除外")
|
||||
try:
|
||||
process_name = normalize_process_name(raw_process_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
is_cleaning = is_cleaning_stamping_method(payload.stamping_method)
|
||||
is_misc = is_misc_stamping_method(payload.stamping_method)
|
||||
if is_misc and process_name != MISC_PROCESS_NAME:
|
||||
raise HTTPException(status_code=400, detail="处理杂活的工序必须填写杂活")
|
||||
if not is_misc and process_name == MISC_PROCESS_NAME:
|
||||
raise HTTPException(status_code=400, detail="只有处理杂活的工序可以填写杂活")
|
||||
if not is_cleaning and not is_misc and payload.standard_beat <= 0:
|
||||
raise HTTPException(status_code=400, detail="非清洗工序必须填写标准节拍")
|
||||
|
||||
next_key = {
|
||||
"attendance_point_name": point_name,
|
||||
"project_no": payload.project_no,
|
||||
"product_name": payload.product_name,
|
||||
"device_no": "",
|
||||
"process_name": process_name,
|
||||
}
|
||||
original_key = {
|
||||
"attendance_point_name": payload.original_attendance_point_name or point_name,
|
||||
"project_no": payload.original_project_no or payload.project_no,
|
||||
"product_name": payload.original_product_name or payload.product_name,
|
||||
"device_no": normalize_device_no(payload.original_device_no if payload.original_device_no is not None else ""),
|
||||
"process_name": payload.original_process_name if payload.original_process_name is not None else process_name,
|
||||
}
|
||||
|
||||
product = db.get(Product, original_key)
|
||||
if product is not None and original_key != next_key:
|
||||
existed = db.get(Product, next_key)
|
||||
if existed is not None:
|
||||
raise HTTPException(status_code=409, detail="目标产品/工序已存在,不能合并覆盖")
|
||||
else:
|
||||
_cascade_product_key_change(db, original_key, next_key)
|
||||
product.project_no = next_key["project_no"]
|
||||
product.product_name = next_key["product_name"]
|
||||
product.device_no = next_key["device_no"]
|
||||
product.process_name = next_key["process_name"]
|
||||
elif product is None:
|
||||
product = db.get(Product, next_key)
|
||||
if product is None:
|
||||
product = Product(
|
||||
attendance_point_name=next_key["attendance_point_name"],
|
||||
project_no=next_key["project_no"],
|
||||
product_name=next_key["product_name"],
|
||||
device_no=next_key["device_no"],
|
||||
process_name=next_key["process_name"],
|
||||
)
|
||||
db.add(product)
|
||||
|
||||
aux_values = (
|
||||
payload.product_net_weight_kg,
|
||||
payload.product_gross_weight_kg,
|
||||
payload.scrap_loss_rate,
|
||||
payload.waste_price_yuan_per_kg,
|
||||
)
|
||||
if all(value is None for value in aux_values):
|
||||
sibling = next(
|
||||
(
|
||||
item for item in db.scalars(
|
||||
select(Product).where(Product.product_name == next_key["product_name"], Product.device_no == "")
|
||||
.where(Product.attendance_point_name == point_name)
|
||||
).all()
|
||||
if item.product_net_weight_kg is not None
|
||||
and item.product_gross_weight_kg is not None
|
||||
and item.scrap_loss_rate is not None
|
||||
and item.waste_price_yuan_per_kg is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
if sibling is not None:
|
||||
aux_values = (
|
||||
sibling.product_net_weight_kg,
|
||||
sibling.product_gross_weight_kg,
|
||||
sibling.scrap_loss_rate,
|
||||
sibling.waste_price_yuan_per_kg,
|
||||
)
|
||||
|
||||
product.profile_no = payload.profile_no
|
||||
product.attendance_point_name = next_key["attendance_point_name"]
|
||||
product.material_code = payload.material_code
|
||||
product.material_name = payload.material_name
|
||||
product.supplier = payload.supplier
|
||||
product.product_net_weight_kg = aux_values[0]
|
||||
product.product_gross_weight_kg = aux_values[1]
|
||||
product.scrap_loss_rate = aux_values[2]
|
||||
product.waste_price_yuan_per_kg = aux_values[3]
|
||||
product.device_no = next_key["device_no"]
|
||||
product.process_name = next_key["process_name"]
|
||||
product.stamping_method = payload.stamping_method
|
||||
product.operator_count = payload.operator_count or 1
|
||||
product.process_unit_price_yuan = payload.process_unit_price_yuan or 0
|
||||
product.standard_beat = 0 if (is_cleaning or is_misc) else payload.standard_beat
|
||||
product.standard_workload = 0
|
||||
for sibling in db.scalars(select(Product).where(
|
||||
Product.attendance_point_name == point_name,
|
||||
Product.product_name == next_key["product_name"],
|
||||
Product.device_no == "",
|
||||
)).all():
|
||||
sibling.product_net_weight_kg = product.product_net_weight_kg
|
||||
sibling.product_gross_weight_kg = product.product_gross_weight_kg
|
||||
sibling.scrap_loss_rate = product.scrap_loss_rate
|
||||
sibling.waste_price_yuan_per_kg = product.waste_price_yuan_per_kg
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product_out(product)
|
||||
|
||||
|
||||
@router.delete("", status_code=204)
|
||||
def delete_product(
|
||||
attendance_point_name: str,
|
||||
project_no: str,
|
||||
product_name: str,
|
||||
device_no: str = "",
|
||||
process_name: str = "",
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, attendance_point_name)
|
||||
except (ValueError, PermissionError) as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
product = db.get(
|
||||
Product,
|
||||
{
|
||||
"attendance_point_name": point_name,
|
||||
"project_no": project_no,
|
||||
"product_name": product_name,
|
||||
"device_no": "",
|
||||
"process_name": process_name,
|
||||
},
|
||||
)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="产品不存在")
|
||||
qrcode = db.get(DeviceQRCode, {
|
||||
"attendance_point_name": point_name,
|
||||
"device_no": product.product_name,
|
||||
"process_name": product.process_name or "",
|
||||
})
|
||||
if qrcode is not None:
|
||||
db.delete(qrcode)
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/import", response_model=ImportResult)
|
||||
async def import_product_excel(
|
||||
file: UploadFile = File(...),
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ImportResult:
|
||||
content = await file.read()
|
||||
try:
|
||||
result = import_products(db, content, user=user)
|
||||
except ProductImportValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return ImportResult(**result.as_dict())
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_product_excel(
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
content = export_products(db, user=user)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="products.xlsx"'},
|
||||
)
|
||||
@@ -0,0 +1,232 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import extract, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import Personnel, Product, ProductionReport, ProductionReportItem, ReconciliationLedgerEntry, ReportStatus, Role
|
||||
from app.schemas import (
|
||||
ReconciliationEntryUpdate,
|
||||
ReconciliationLedgerOut,
|
||||
ReconciliationMonthCell,
|
||||
ReconciliationRow,
|
||||
ReconciliationYearsOut,
|
||||
)
|
||||
from app.services.common import as_float, round2
|
||||
from app.services.cleaning import is_cleaning_product
|
||||
from app.services.misc_work import is_misc_product
|
||||
from app.services.process_names import process_order
|
||||
from app.services.report_lifecycle import purge_expired_voided_reports
|
||||
from app.timezone import today
|
||||
|
||||
router = APIRouter(prefix="/api/reconciliation", tags=["reconciliation"])
|
||||
|
||||
|
||||
def _latest_processes_by_product(db: Session) -> dict[tuple[str, str], set[str]]:
|
||||
products = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.device_no == "", Product.product_name != "", Product.process_name != "")
|
||||
.order_by(Product.attendance_point_name.asc(), Product.product_name.asc(), Product.process_name.asc())
|
||||
).all()
|
||||
latest: dict[tuple[str, str], tuple[tuple[int, str], set[str]]] = {}
|
||||
for product in products:
|
||||
if is_cleaning_product(product) or is_misc_product(product):
|
||||
continue
|
||||
point_name = str(product.attendance_point_name or "").strip()
|
||||
product_name = str(product.product_name or "").strip()
|
||||
process_name = str(product.process_name or "").strip()
|
||||
if not point_name or not product_name or not process_name:
|
||||
continue
|
||||
order = process_order(process_name)
|
||||
product_key = (point_name, product_name)
|
||||
current = latest.get(product_key)
|
||||
if current is None or order > current[0]:
|
||||
latest[product_key] = (order, {process_name})
|
||||
elif order == current[0]:
|
||||
current[1].add(process_name)
|
||||
return {product_key: process_names for product_key, (_order, process_names) in latest.items()}
|
||||
|
||||
|
||||
def _month_range(year: int, month: int) -> tuple[date, date]:
|
||||
start = date(year, month, 1)
|
||||
if month == 12:
|
||||
end = date(year, 12, 31)
|
||||
else:
|
||||
end = date(year, month + 1, 1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _reported_good_quantities(
|
||||
db: Session,
|
||||
*,
|
||||
year: int,
|
||||
product_keys: list[tuple[str, str]],
|
||||
latest_processes: dict[tuple[str, str], set[str]],
|
||||
) -> dict[tuple[str, str, int], float]:
|
||||
if not product_keys:
|
||||
return {}
|
||||
point_names = sorted({point_name for point_name, _product_name in product_keys})
|
||||
product_names = sorted({product_name for _point_name, product_name in product_keys})
|
||||
rows = db.execute(
|
||||
select(
|
||||
ProductionReportItem.attendance_point_name,
|
||||
ProductionReportItem.product_name,
|
||||
ProductionReportItem.process_name,
|
||||
extract("month", ProductionReport.report_date).label("month"),
|
||||
func.sum(ProductionReportItem.good_qty).label("good_qty"),
|
||||
)
|
||||
.join(ProductionReport, ProductionReport.id == ProductionReportItem.report_id)
|
||||
.where(
|
||||
ProductionReport.status == ReportStatus.approved,
|
||||
ProductionReport.is_voided.is_(False),
|
||||
extract("year", ProductionReport.report_date) == year,
|
||||
ProductionReportItem.attendance_point_name.in_(point_names),
|
||||
ProductionReportItem.product_name.in_(product_names),
|
||||
)
|
||||
.group_by(
|
||||
ProductionReportItem.attendance_point_name,
|
||||
ProductionReportItem.product_name,
|
||||
ProductionReportItem.process_name,
|
||||
extract("month", ProductionReport.report_date),
|
||||
)
|
||||
).all()
|
||||
totals: dict[tuple[str, str, int], float] = {}
|
||||
for point_name, product_name, process_name, month, good_qty in rows:
|
||||
product_key = (point_name, product_name)
|
||||
if str(process_name or "").strip() not in latest_processes.get(product_key, set()):
|
||||
continue
|
||||
key = (point_name, product_name, int(month))
|
||||
totals[key] = round2(totals.get(key, 0) + as_float(good_qty))
|
||||
return totals
|
||||
|
||||
|
||||
def _ledger_quantities(db: Session, *, year: int) -> dict[tuple[str, str, int], dict[str, float]]:
|
||||
entries = db.scalars(
|
||||
select(ReconciliationLedgerEntry)
|
||||
.where(ReconciliationLedgerEntry.year == year)
|
||||
).all()
|
||||
return {
|
||||
(entry.attendance_point_name, entry.product_name, int(entry.month)): {
|
||||
"reconciled_good_qty": as_float(entry.reconciled_good_qty),
|
||||
"return_qty": as_float(entry.return_qty),
|
||||
}
|
||||
for entry in entries
|
||||
}
|
||||
|
||||
|
||||
@router.get("/years", response_model=ReconciliationYearsOut)
|
||||
def list_reconciliation_years(
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReconciliationYearsOut:
|
||||
purge_expired_voided_reports(db)
|
||||
current_year = today().year
|
||||
years = {current_year}
|
||||
report_years = db.execute(
|
||||
select(extract("year", ProductionReport.report_date))
|
||||
.where(
|
||||
ProductionReport.status == ReportStatus.approved,
|
||||
ProductionReport.is_voided.is_(False),
|
||||
)
|
||||
.distinct()
|
||||
).all()
|
||||
years.update(int(row[0]) for row in report_years if row[0])
|
||||
ledger_years = db.execute(select(ReconciliationLedgerEntry.year).distinct()).all()
|
||||
years.update(int(row[0]) for row in ledger_years if row[0])
|
||||
return ReconciliationYearsOut(
|
||||
years=sorted(year for year in years if year <= current_year),
|
||||
current_year=current_year,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ReconciliationLedgerOut)
|
||||
def get_reconciliation_ledger(
|
||||
year: int = Query(..., ge=2000, le=2100),
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReconciliationLedgerOut:
|
||||
purge_expired_voided_reports(db)
|
||||
current = today()
|
||||
if year > current.year:
|
||||
raise HTTPException(status_code=400, detail="不能查看未来年份")
|
||||
|
||||
latest_processes = _latest_processes_by_product(db)
|
||||
product_keys = sorted(latest_processes)
|
||||
reported = _reported_good_quantities(
|
||||
db,
|
||||
year=year,
|
||||
product_keys=product_keys,
|
||||
latest_processes=latest_processes,
|
||||
)
|
||||
ledger = _ledger_quantities(db, year=year)
|
||||
visible_month_count = current.month if year == current.year else 12
|
||||
|
||||
rows = [
|
||||
ReconciliationRow(
|
||||
attendance_point_name=point_name,
|
||||
product_name=product_name,
|
||||
months=[
|
||||
ReconciliationMonthCell(
|
||||
month=month,
|
||||
reported_good_qty=reported.get((point_name, product_name, month), 0),
|
||||
reconciled_good_qty=ledger.get((point_name, product_name, month), {}).get("reconciled_good_qty", 0),
|
||||
return_qty=ledger.get((point_name, product_name, month), {}).get("return_qty", 0),
|
||||
)
|
||||
for month in range(1, visible_month_count + 1)
|
||||
],
|
||||
)
|
||||
for point_name, product_name in product_keys
|
||||
]
|
||||
return ReconciliationLedgerOut(
|
||||
year=year,
|
||||
current_year=current.year,
|
||||
current_month=current.month,
|
||||
rows=rows,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/entry", response_model=ReconciliationMonthCell)
|
||||
def save_reconciliation_entry(
|
||||
payload: ReconciliationEntryUpdate,
|
||||
user: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReconciliationMonthCell:
|
||||
current = today()
|
||||
if payload.year > current.year or (payload.year == current.year and payload.month > current.month):
|
||||
raise HTTPException(status_code=400, detail="不能维护未来月份")
|
||||
product_exists = db.scalar(
|
||||
select(Product.product_name)
|
||||
.where(
|
||||
Product.attendance_point_name == payload.attendance_point_name,
|
||||
Product.product_name == payload.product_name,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if not product_exists:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
|
||||
key = {
|
||||
"attendance_point_name": payload.attendance_point_name,
|
||||
"year": payload.year,
|
||||
"month": payload.month,
|
||||
"product_name": payload.product_name,
|
||||
}
|
||||
entry = db.get(ReconciliationLedgerEntry, key)
|
||||
if entry is None:
|
||||
entry = ReconciliationLedgerEntry(**key)
|
||||
db.add(entry)
|
||||
if payload.reconciled_good_qty is not None:
|
||||
entry.reconciled_good_qty = round2(payload.reconciled_good_qty)
|
||||
if payload.return_qty is not None:
|
||||
entry.return_qty = round2(payload.return_qty)
|
||||
entry.updated_by = user.phone
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return ReconciliationMonthCell(
|
||||
month=entry.month,
|
||||
reported_good_qty=0,
|
||||
reconciled_good_qty=as_float(entry.reconciled_good_qty),
|
||||
return_qty=as_float(entry.return_qty),
|
||||
)
|
||||
@@ -0,0 +1,656 @@
|
||||
from datetime import date, datetime
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import current_user, require_roles
|
||||
from app.models import (
|
||||
Personnel,
|
||||
Equipment,
|
||||
Product,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
ReportStatus,
|
||||
Role,
|
||||
SessionStatus,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
)
|
||||
from app.schemas import (
|
||||
CleaningReportSubmitItem,
|
||||
CleaningReportSubmitRequest,
|
||||
PageResponse,
|
||||
ReportDraftBlock,
|
||||
ReportDraftItem,
|
||||
ReportDraftOut,
|
||||
ReportSubmitRequest,
|
||||
ReportOut,
|
||||
)
|
||||
from app.services.attendance_points import require_attendance_point_access, validate_attendance_point_location
|
||||
from app.services.attendance_points import accessible_point_names
|
||||
from app.services.auto_submit import auto_submit_overdue_sessions
|
||||
from app.services.cleaning import is_cleaning_item, is_cleaning_product
|
||||
from app.services.common import as_float, normalize_device_no, paginate, round2
|
||||
from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product
|
||||
from app.services.display_names import mold_process_display_name
|
||||
from app.services.metrics import calculate_report_metrics, minutes_between, release_at_caps_work_time
|
||||
from app.services.misc_work import is_misc_product
|
||||
from app.services.mold_locks import release_session_locks
|
||||
from app.services.report_lifecycle import purge_expired_voided_reports
|
||||
from app.services.reporting_window import REPORTING_EXPIRED_DETAIL, is_reporting_expired
|
||||
from app.services.serializers import equipment_option, product_option, report_out
|
||||
from app.services.work_schedule import get_work_schedule_config
|
||||
from app.timezone import now, today
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
|
||||
CLEANING_DEVICE_LABEL = "清洗设备"
|
||||
|
||||
|
||||
def _split_device_nos(value: str | None) -> list[str]:
|
||||
return [
|
||||
normalize_device_no(part)
|
||||
for part in re.split(r"[、,,\s]+", str(value or "").strip())
|
||||
if normalize_device_no(part)
|
||||
]
|
||||
|
||||
|
||||
def _cleaning_device_nos(item: CleaningReportSubmitItem) -> list[str]:
|
||||
values: list[str] = []
|
||||
for device_no in item.device_nos or []:
|
||||
normalized = normalize_device_no(device_no)
|
||||
if normalized and normalized not in values:
|
||||
values.append(normalized)
|
||||
for device_no in _split_device_nos(item.device_no):
|
||||
if device_no not in values:
|
||||
values.append(device_no)
|
||||
return values
|
||||
|
||||
|
||||
def _report_query():
|
||||
return select(ProductionReport).options(
|
||||
selectinload(ProductionReport.items),
|
||||
selectinload(ProductionReport.audit_logs),
|
||||
selectinload(ProductionReport.employee),
|
||||
selectinload(ProductionReport.reviewer),
|
||||
selectinload(ProductionReport.voider),
|
||||
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
|
||||
)
|
||||
|
||||
|
||||
def _get_report_or_404(db: Session, report_id: int) -> ProductionReport:
|
||||
report = db.scalar(_report_query().where(ProductionReport.id == report_id))
|
||||
if report is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工记录不存在")
|
||||
return report
|
||||
|
||||
|
||||
def _ordered_session_devices(session: WorkSession):
|
||||
return sorted(
|
||||
session.devices,
|
||||
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
|
||||
)
|
||||
|
||||
|
||||
def _mold_key(product_name: str, process_name: str | None) -> tuple[str, str]:
|
||||
return (str(product_name or "").strip(), str(process_name or "").strip())
|
||||
|
||||
|
||||
def _product_for_mold(db: Session, point_name: str, mold_name: str, process_name: str | None) -> Product | None:
|
||||
return db.scalar(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name == point_name,
|
||||
Product.product_name == str(mold_name or "").strip(),
|
||||
Product.process_name == str(process_name or "").strip(),
|
||||
Product.device_no == "",
|
||||
)
|
||||
.order_by(Product.project_no.asc())
|
||||
)
|
||||
|
||||
|
||||
def _miniapp_work_order_short_code(work_order_no: str | None) -> str | None:
|
||||
normalized = str(work_order_no or "").strip()
|
||||
current_year = datetime.now().strftime("%Y")
|
||||
if len(normalized) <= 8 or not normalized.startswith(current_year) or normalized[8] != "-":
|
||||
return None
|
||||
sequence = normalized[4:8]
|
||||
if not sequence.isdigit():
|
||||
return None
|
||||
return f"JH{int(sequence):04d}"
|
||||
|
||||
|
||||
def _raw_material_batch_options(
|
||||
db: Session,
|
||||
*,
|
||||
project_no: str | None,
|
||||
product_name: str | None,
|
||||
process_name: str | None,
|
||||
) -> list[str]:
|
||||
normalized_project_no = str(project_no or "").strip()
|
||||
normalized_product_name = str(product_name or "").strip()
|
||||
normalized_process_name = str(process_name or "").strip()
|
||||
if not normalized_project_no and not normalized_product_name:
|
||||
return []
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
select distinct ledger.material_lot_no
|
||||
from pp_production_batch_ledger ledger
|
||||
join md_item product on product.id = ledger.product_item_id
|
||||
where ledger.material_lot_no is not null
|
||||
and trim(ledger.material_lot_no) <> ''
|
||||
and coalesce(ledger.miniapp_selectable, 1) = 1
|
||||
and trim(coalesce(ledger.status, '')) = '在生产'
|
||||
and (
|
||||
(:product_name <> '' and :project_no <> '' and product.item_name = :product_name and product.item_code = :project_no)
|
||||
or (:product_name <> '' and :project_no = '' and product.item_name = :product_name)
|
||||
or (:product_name = '' and :project_no <> '' and product.item_code = :project_no)
|
||||
)
|
||||
order by ledger.material_lot_no asc
|
||||
"""
|
||||
),
|
||||
{
|
||||
"project_no": normalized_project_no,
|
||||
"product_name": normalized_product_name,
|
||||
"process_name": normalized_process_name,
|
||||
},
|
||||
).all()
|
||||
options: list[str] = []
|
||||
for row in rows:
|
||||
lot_no = str(row[0] or "").strip()
|
||||
if lot_no and lot_no not in options:
|
||||
options.append(lot_no)
|
||||
return options
|
||||
|
||||
|
||||
def _cleaning_metrics(items: list[ProductionReportItem]) -> dict[str, float | str]:
|
||||
total_good_qty = sum(as_float(item.good_qty) for item in items)
|
||||
total_output_qty = sum(
|
||||
as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty)
|
||||
for item in items
|
||||
)
|
||||
return {
|
||||
"duration_minutes": 0,
|
||||
"effective_minutes": 0,
|
||||
"shift_day_minutes": 0,
|
||||
"shift_overtime_minutes": 0,
|
||||
"shift_night_minutes": 0,
|
||||
"shift_other_minutes": 0,
|
||||
"shift_meal_break_minutes": 0,
|
||||
"shift_distribution_text": "清洗报工不计工时",
|
||||
"total_good_qty": round2(total_good_qty),
|
||||
"total_output_qty": round2(total_output_qty),
|
||||
"actual_beat": 0,
|
||||
"standard_beat": 0,
|
||||
"expected_workload": 0,
|
||||
"pace_rate": 0,
|
||||
"workload_rate": 0,
|
||||
}
|
||||
|
||||
|
||||
def _draft_mold_ranges(session: WorkSession) -> list[tuple[str, str, datetime, datetime, float]]:
|
||||
if session.end_at is None:
|
||||
return []
|
||||
|
||||
ordered_devices = _ordered_session_devices(session)
|
||||
ranges_by_mold: dict[tuple[str, str], dict[str, datetime | float]] = {}
|
||||
for index, device in enumerate(ordered_devices):
|
||||
segment_start = session.start_at if index == 0 else max(session.start_at, device.scanned_at)
|
||||
segment_end = session.end_at
|
||||
if index + 1 < len(ordered_devices):
|
||||
segment_end = min(session.end_at, ordered_devices[index + 1].scanned_at)
|
||||
if release_at_caps_work_time(device) and device.released_at is not None:
|
||||
segment_end = min(segment_end, device.released_at)
|
||||
if segment_end < segment_start:
|
||||
segment_end = segment_start
|
||||
|
||||
mold_key = _mold_key(device.device_no, device.process_name)
|
||||
if mold_key not in ranges_by_mold:
|
||||
ranges_by_mold[mold_key] = {
|
||||
"start_at": segment_start,
|
||||
"end_at": segment_end,
|
||||
"duration_minutes": minutes_between(segment_start, segment_end),
|
||||
}
|
||||
continue
|
||||
|
||||
ranges_by_mold[mold_key]["start_at"] = min(ranges_by_mold[mold_key]["start_at"], segment_start)
|
||||
ranges_by_mold[mold_key]["end_at"] = max(ranges_by_mold[mold_key]["end_at"], segment_end)
|
||||
ranges_by_mold[mold_key]["duration_minutes"] = as_float(ranges_by_mold[mold_key]["duration_minutes"]) + minutes_between(segment_start, segment_end)
|
||||
|
||||
return [
|
||||
(mold_name, process_name, value["start_at"], value["end_at"], round2(value["duration_minutes"]))
|
||||
for (mold_name, process_name), value in ranges_by_mold.items()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/draft", response_model=ReportDraftOut)
|
||||
def get_report_draft(
|
||||
session_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportDraftOut:
|
||||
session = db.scalar(
|
||||
select(WorkSession)
|
||||
.options(selectinload(WorkSession.devices), selectinload(WorkSession.employee))
|
||||
.where(WorkSession.id == session_id)
|
||||
)
|
||||
if session is None or session.employee_phone != user.phone:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工流程不存在")
|
||||
if session.status != SessionStatus.reporting or session.end_at is None:
|
||||
raise HTTPException(status_code=400, detail="该流程还不能提交报工")
|
||||
if is_reporting_expired(session):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=REPORTING_EXPIRED_DETAIL)
|
||||
|
||||
blocks: list[ReportDraftBlock] = []
|
||||
equipment_options = [
|
||||
equipment_option(equipment)
|
||||
for equipment in db.scalars(
|
||||
select(Equipment)
|
||||
.where(
|
||||
Equipment.attendance_point_name == session.attendance_point_name,
|
||||
Equipment.device_type == "冲压设备",
|
||||
)
|
||||
.order_by(Equipment.device_no.asc())
|
||||
).all()
|
||||
]
|
||||
for mold_name, process_name, block_start_at, block_end_at, block_duration_minutes in _draft_mold_ranges(session):
|
||||
product_query = select(Product).where(
|
||||
Product.attendance_point_name == session.attendance_point_name,
|
||||
Product.product_name == mold_name,
|
||||
)
|
||||
if process_name:
|
||||
product_query = product_query.where(Product.process_name == process_name)
|
||||
products = db.scalars(product_query.order_by(Product.project_no, Product.process_name)).all()
|
||||
options = [product_option(product) for product in products]
|
||||
first = options[0] if options else None
|
||||
block_is_misc = any(option.is_misc for option in options)
|
||||
block_is_continuous_die = any(option.is_continuous_die for option in options)
|
||||
raw_material_batch_options = (
|
||||
_raw_material_batch_options(
|
||||
db,
|
||||
project_no=first.project_no,
|
||||
product_name=first.product_name,
|
||||
process_name=first.process_name,
|
||||
)
|
||||
if first
|
||||
else []
|
||||
)
|
||||
blocks.append(
|
||||
ReportDraftBlock(
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
device_no=mold_name,
|
||||
mold_name=mold_name,
|
||||
process_name=process_name,
|
||||
stamping_method=first.stamping_method if first else None,
|
||||
display_name=mold_process_display_name(mold_name, process_name, first.stamping_method if first else None),
|
||||
start_at=block_start_at,
|
||||
end_at=block_end_at,
|
||||
duration_minutes=block_duration_minutes,
|
||||
options=options,
|
||||
equipment_options=equipment_options,
|
||||
is_misc=block_is_misc,
|
||||
is_continuous_die=block_is_continuous_die,
|
||||
items=[
|
||||
ReportDraftItem(
|
||||
device_no=mold_name if block_is_misc else "",
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
project_no=first.project_no if first else "",
|
||||
product_name=first.product_name if first else "",
|
||||
material_code=first.material_code if first else None,
|
||||
material_name=first.material_name if first else None,
|
||||
raw_material_batch_no=None,
|
||||
raw_material_batch_options=raw_material_batch_options,
|
||||
process_name=first.process_name if first else None,
|
||||
stamping_method=first.stamping_method if first else None,
|
||||
operator_count=first.operator_count if first else 1,
|
||||
process_unit_price_yuan=first.process_unit_price_yuan if first else 0,
|
||||
changeover_count=0,
|
||||
standard_beat=first.standard_beat if first else 0,
|
||||
standard_workload=first.standard_workload if first else 0,
|
||||
good_qty=0,
|
||||
defect_qty=0,
|
||||
is_misc=block_is_misc,
|
||||
is_continuous_die=first.is_continuous_die if first else False,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
return ReportDraftOut(
|
||||
session_id=session.id,
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
employee_phone=session.employee_phone,
|
||||
employee_name=session.employee.name,
|
||||
start_at=session.start_at,
|
||||
end_at=session.end_at,
|
||||
duration_minutes=round2(minutes_between(session.start_at, session.end_at)),
|
||||
device_blocks=blocks,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ReportOut)
|
||||
def submit_report(
|
||||
payload: ReportSubmitRequest,
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
session = db.scalar(
|
||||
select(WorkSession)
|
||||
.options(selectinload(WorkSession.devices))
|
||||
.where(WorkSession.id == payload.session_id)
|
||||
)
|
||||
if session is None or session.employee_phone != user.phone:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工流程不存在")
|
||||
if session.status != SessionStatus.reporting or session.end_at is None:
|
||||
raise HTTPException(status_code=400, detail="该流程还不能提交报工")
|
||||
if is_reporting_expired(session):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=REPORTING_EXPIRED_DETAIL)
|
||||
|
||||
valid_molds = {_mold_key(device.device_no, device.process_name) for device in session.devices}
|
||||
valid_equipment = {
|
||||
equipment.device_no
|
||||
for equipment in db.scalars(select(Equipment).where(Equipment.device_type == "冲压设备")).all()
|
||||
if equipment.attendance_point_name == session.attendance_point_name
|
||||
}
|
||||
items: list[ProductionReportItem] = []
|
||||
for block in payload.device_blocks:
|
||||
mold_name = str(block.device_no or "").strip()
|
||||
process_name = str(block.process_name or "").strip()
|
||||
if _mold_key(mold_name, process_name) not in valid_molds:
|
||||
product = _product_for_mold(db, session.attendance_point_name, mold_name, process_name)
|
||||
mold_text = mold_process_display_name(mold_name, process_name, product.stamping_method if product else None)
|
||||
raise HTTPException(status_code=400, detail=f"模具 {mold_text} 不在本次流程中")
|
||||
for item in block.items:
|
||||
product = db.scalar(
|
||||
select(Product).where(
|
||||
Product.attendance_point_name == session.attendance_point_name,
|
||||
Product.project_no == item.project_no,
|
||||
Product.product_name == item.product_name,
|
||||
Product.process_name == (item.process_name or ""),
|
||||
Product.device_no == "",
|
||||
)
|
||||
)
|
||||
item_is_misc = is_misc_product(product) or item.is_misc
|
||||
if not item_is_misc and item.good_qty + item.defect_qty + item.scrap_qty <= 0:
|
||||
continue
|
||||
device_no = normalize_device_no(item.device_no)
|
||||
if item_is_misc:
|
||||
device_no = mold_name
|
||||
elif not device_no:
|
||||
raise HTTPException(status_code=400, detail=f"{mold_name} 请选择设备号")
|
||||
if not item_is_misc and valid_equipment and device_no not in valid_equipment:
|
||||
raise HTTPException(status_code=400, detail=f"设备 {device_no} 不在设备清单中")
|
||||
stamping_method = product.stamping_method if product else item.stamping_method
|
||||
item_is_continuous_die = is_continuous_die_product(product) or is_continuous_die_item(item)
|
||||
changeover_count = as_float(item.changeover_count)
|
||||
if not item_is_continuous_die:
|
||||
changeover_count = 0
|
||||
if changeover_count < 0:
|
||||
raise HTTPException(status_code=400, detail="换料次数不能为负数")
|
||||
items.append(
|
||||
ProductionReportItem(
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
device_no=device_no,
|
||||
project_no=product.project_no if product else item.project_no,
|
||||
product_name=product.product_name if product else item.product_name,
|
||||
material_code=product.material_code if product else item.material_code,
|
||||
material_name=product.material_name if product else item.material_name,
|
||||
raw_material_batch_no=item.raw_material_batch_no,
|
||||
process_name=product.process_name if product else item.process_name,
|
||||
stamping_method=stamping_method,
|
||||
operator_count=product.operator_count if product else item.operator_count,
|
||||
process_unit_price_yuan=product.process_unit_price_yuan if product else item.process_unit_price_yuan,
|
||||
changeover_count=changeover_count,
|
||||
standard_beat=0 if item_is_misc else item.standard_beat,
|
||||
standard_workload=0,
|
||||
good_qty=0 if item_is_misc else item.good_qty,
|
||||
defect_qty=0 if item_is_misc else item.defect_qty,
|
||||
scrap_qty=0 if item_is_misc else item.scrap_qty,
|
||||
started_at=item.started_at,
|
||||
remark=item.remark,
|
||||
)
|
||||
)
|
||||
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="请至少填写一项产出数量")
|
||||
|
||||
schedule = get_work_schedule_config(db, session.attendance_point_name)
|
||||
metrics = (
|
||||
_cleaning_metrics(items)
|
||||
if all(is_cleaning_item(item) for item in items)
|
||||
else calculate_report_metrics(
|
||||
session.start_at,
|
||||
session.end_at,
|
||||
items,
|
||||
devices=session.devices,
|
||||
schedule=schedule,
|
||||
)
|
||||
)
|
||||
submitted_at = now()
|
||||
report = ProductionReport(
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
session_id=session.id,
|
||||
employee_phone=user.phone,
|
||||
report_date=today(),
|
||||
start_at=session.start_at,
|
||||
end_at=session.end_at,
|
||||
duration_minutes=metrics["duration_minutes"],
|
||||
break_minutes=0,
|
||||
effective_minutes=metrics["effective_minutes"],
|
||||
total_good_qty=metrics["total_good_qty"],
|
||||
total_output_qty=metrics["total_output_qty"],
|
||||
actual_beat=metrics["actual_beat"],
|
||||
standard_beat=metrics["standard_beat"],
|
||||
expected_workload=metrics["expected_workload"],
|
||||
pace_rate=metrics["pace_rate"],
|
||||
workload_rate=metrics["workload_rate"],
|
||||
status=ReportStatus.pending,
|
||||
submitted_at=submitted_at,
|
||||
items=items,
|
||||
)
|
||||
release_session_locks(session, user.phone, "报工提交释放占用", submitted_at)
|
||||
session.status = SessionStatus.submitted
|
||||
db.add(report)
|
||||
db.commit()
|
||||
|
||||
saved = _get_report_or_404(db, report.id)
|
||||
return report_out(saved, schedule)
|
||||
|
||||
|
||||
@router.post("/cleaning", response_model=ReportOut)
|
||||
def submit_cleaning_report(
|
||||
payload: CleaningReportSubmitRequest,
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
if not payload.items:
|
||||
raise HTTPException(status_code=400, detail="请至少添加一个清洗产品")
|
||||
requested_point = next((str(item.attendance_point_name or "").strip() for item in payload.items if item.attendance_point_name), "")
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, requested_point)
|
||||
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
|
||||
try:
|
||||
validate_attendance_point_location(db, point_name, payload.latitude, payload.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
|
||||
|
||||
valid_cleaning_equipment = {
|
||||
normalize_device_no(equipment.device_no)
|
||||
for equipment in db.scalars(
|
||||
select(Equipment).where(
|
||||
Equipment.attendance_point_name == point_name,
|
||||
Equipment.device_type == "清洗设备",
|
||||
)
|
||||
).all()
|
||||
}
|
||||
grouped: dict[tuple[str, str, str, str], float] = {}
|
||||
products_by_key: dict[tuple[str, str, str, str], Product] = {}
|
||||
for item in payload.items:
|
||||
item_point_name = str(item.attendance_point_name or point_name).strip()
|
||||
if item_point_name != point_name:
|
||||
raise HTTPException(status_code=400, detail="清洗报工只能提交同一考勤点的产品")
|
||||
device_nos = _cleaning_device_nos(item)
|
||||
if not device_nos:
|
||||
raise HTTPException(status_code=400, detail="请选择清洗设备号")
|
||||
invalid_device_nos = [device_no for device_no in device_nos if device_no not in valid_cleaning_equipment]
|
||||
if invalid_device_nos:
|
||||
raise HTTPException(status_code=400, detail=f"设备 {'、'.join(invalid_device_nos)} 不是清洗设备")
|
||||
product_name = str(item.product_name or "").strip()
|
||||
process_name = str(item.process_name or "").strip()
|
||||
product = db.scalar(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name == point_name,
|
||||
Product.product_name == product_name,
|
||||
Product.process_name == process_name,
|
||||
Product.device_no == "",
|
||||
)
|
||||
.order_by(Product.project_no.asc())
|
||||
)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=400, detail=f"{mold_process_display_name(product_name, process_name, item.stamping_method)} 不在产品清单中")
|
||||
if not is_cleaning_product(product):
|
||||
raise HTTPException(status_code=400, detail=f"{mold_process_display_name(product_name, process_name, product.stamping_method)} 的冲压方式不是清洗")
|
||||
device_no_text = "、".join(device_nos)
|
||||
key = (device_no_text, product.project_no, product.product_name, product.process_name)
|
||||
products_by_key[key] = product
|
||||
grouped[key] = (
|
||||
grouped.get(key, 0.0) + as_float(item.quantity)
|
||||
)
|
||||
|
||||
report_items: list[ProductionReportItem] = []
|
||||
for key, quantity in grouped.items():
|
||||
if quantity <= 0:
|
||||
continue
|
||||
device_no, _project_no, _product_name, _process_name = key
|
||||
product = products_by_key[key]
|
||||
report_items.append(
|
||||
ProductionReportItem(
|
||||
attendance_point_name=point_name,
|
||||
device_no=device_no,
|
||||
project_no=product.project_no,
|
||||
product_name=product.product_name,
|
||||
material_code=product.material_code,
|
||||
material_name=product.material_name,
|
||||
raw_material_batch_no=None,
|
||||
process_name=product.process_name,
|
||||
stamping_method=product.stamping_method,
|
||||
operator_count=product.operator_count,
|
||||
process_unit_price_yuan=product.process_unit_price_yuan,
|
||||
changeover_count=0,
|
||||
standard_beat=0,
|
||||
standard_workload=0,
|
||||
good_qty=quantity,
|
||||
defect_qty=0,
|
||||
scrap_qty=0,
|
||||
allocated_minutes=0,
|
||||
)
|
||||
)
|
||||
|
||||
if not report_items:
|
||||
raise HTTPException(status_code=400, detail="请填写清洗数量")
|
||||
|
||||
timestamp = now()
|
||||
session = WorkSession(
|
||||
attendance_point_name=point_name,
|
||||
employee_phone=user.phone,
|
||||
start_at=timestamp,
|
||||
end_at=timestamp,
|
||||
status=SessionStatus.submitted,
|
||||
)
|
||||
for index, item in enumerate(report_items):
|
||||
session.devices.append(
|
||||
WorkSessionDevice(
|
||||
attendance_point_name=point_name,
|
||||
device_no=item.product_name,
|
||||
process_name=item.process_name or "",
|
||||
scanned_at=timestamp,
|
||||
sort_order=index,
|
||||
)
|
||||
)
|
||||
|
||||
metrics = _cleaning_metrics(report_items)
|
||||
report = ProductionReport(
|
||||
attendance_point_name=point_name,
|
||||
session=session,
|
||||
employee_phone=user.phone,
|
||||
report_date=today(),
|
||||
start_at=timestamp,
|
||||
end_at=timestamp,
|
||||
duration_minutes=0,
|
||||
break_minutes=0,
|
||||
effective_minutes=0,
|
||||
total_good_qty=metrics["total_good_qty"],
|
||||
total_output_qty=metrics["total_output_qty"],
|
||||
actual_beat=0,
|
||||
standard_beat=0,
|
||||
expected_workload=0,
|
||||
pace_rate=0,
|
||||
workload_rate=0,
|
||||
status=ReportStatus.pending,
|
||||
submitted_at=timestamp,
|
||||
items=report_items,
|
||||
)
|
||||
db.add(report)
|
||||
db.commit()
|
||||
|
||||
saved = _get_report_or_404(db, report.id)
|
||||
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
|
||||
|
||||
|
||||
@router.get("/mine", response_model=PageResponse)
|
||||
def list_my_reports(
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
status_filter: ReportStatus | None = Query(None, alias="status"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
include_voided: bool = False,
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
purge_expired_voided_reports(db)
|
||||
if user.role == Role.worker:
|
||||
auto_submit_overdue_sessions(db, employee_phone=user.phone)
|
||||
query = _report_query().where(ProductionReport.employee_phone == user.phone)
|
||||
if not include_voided:
|
||||
query = query.where(ProductionReport.is_voided.is_(False))
|
||||
if start_date:
|
||||
query = query.where(ProductionReport.report_date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ProductionReport.report_date <= end_date)
|
||||
if status_filter:
|
||||
query = query.where(ProductionReport.status == status_filter)
|
||||
query = query.order_by(ProductionReport.report_date.desc(), ProductionReport.submitted_at.desc())
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{
|
||||
**result,
|
||||
"rows": [report_out(row, get_work_schedule_config(db, row.attendance_point_name)) for row in result["rows"]],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{report_id}", response_model=ReportOut)
|
||||
def get_report(
|
||||
report_id: int,
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
purge_expired_voided_reports(db)
|
||||
if user.role == Role.worker:
|
||||
auto_submit_overdue_sessions(db, employee_phone=user.phone)
|
||||
report = _get_report_or_404(db, report_id)
|
||||
if user.role == Role.worker and report.employee_phone != user.phone:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||
if user.role == Role.admin and report.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点权限")
|
||||
return report_out(report, get_work_schedule_config(db, report.attendance_point_name))
|
||||
@@ -0,0 +1,618 @@
|
||||
from datetime import date, datetime
|
||||
import re
|
||||
|
||||
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 (
|
||||
AuditAction,
|
||||
Equipment,
|
||||
Personnel,
|
||||
Product,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
ReportAuditLog,
|
||||
ReportStatus,
|
||||
Role,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
)
|
||||
from app.schemas import ApproveReportRequest, PageResponse, RejectReportRequest, ReportOut
|
||||
from app.services.attendance_points import accessible_point_names
|
||||
from app.services.auto_submit import auto_submit_overdue_sessions
|
||||
from app.services.cleaning import is_cleaning_item, is_cleaning_product
|
||||
from app.services.common import as_float, normalize_device_no, paginate
|
||||
from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product
|
||||
from app.services.display_names import mold_process_display_name
|
||||
from app.services.metrics import calculate_report_metrics
|
||||
from app.services.misc_work import is_misc_item, is_misc_product
|
||||
from app.services.report_lifecycle import (
|
||||
can_edit_report,
|
||||
mark_report_voided,
|
||||
purge_expired_voided_reports,
|
||||
restore_voided_report,
|
||||
)
|
||||
from app.services.serializers import report_out
|
||||
from app.services.work_schedule import WorkScheduleConfig, get_work_schedule_config
|
||||
from app.timezone import now
|
||||
|
||||
router = APIRouter(prefix="/api/reviews", tags=["reviews"])
|
||||
|
||||
|
||||
def _minute_picker_noise(left: datetime | None, right: datetime | None) -> bool:
|
||||
if left is None or right is None:
|
||||
return left is right
|
||||
return left.replace(second=0, microsecond=0) == right.replace(second=0, microsecond=0)
|
||||
|
||||
|
||||
def _effective_review_time(payload_value: datetime | None, current_value: datetime) -> datetime:
|
||||
if payload_value is None:
|
||||
return current_value
|
||||
return current_value if _minute_picker_noise(payload_value, current_value) else payload_value
|
||||
|
||||
|
||||
def _split_device_nos(value: str | None) -> list[str]:
|
||||
values: list[str] = []
|
||||
for part in re.split(r"[、,,\s]+", str(value or "").strip()):
|
||||
normalized = normalize_device_no(part)
|
||||
if normalized and normalized not in values:
|
||||
values.append(normalized)
|
||||
return values
|
||||
|
||||
|
||||
def _validate_cleaning_report_devices(db: Session, report: ProductionReport) -> None:
|
||||
valid_cleaning_equipment = {
|
||||
normalize_device_no(equipment.device_no)
|
||||
for equipment in db.scalars(
|
||||
select(Equipment).where(
|
||||
Equipment.attendance_point_name == report.attendance_point_name,
|
||||
Equipment.device_type == "清洗设备",
|
||||
)
|
||||
).all()
|
||||
}
|
||||
if not valid_cleaning_equipment:
|
||||
raise HTTPException(status_code=400, detail="该考勤点暂无清洗设备,请先在设备管理中维护")
|
||||
for item in report.items:
|
||||
device_nos = _split_device_nos(item.device_no)
|
||||
if not device_nos:
|
||||
raise HTTPException(status_code=400, detail="清洗报工请选择清洗设备号")
|
||||
invalid_device_nos = [device_no for device_no in device_nos if device_no not in valid_cleaning_equipment]
|
||||
if invalid_device_nos:
|
||||
raise HTTPException(status_code=400, detail=f"设备 {'、'.join(invalid_device_nos)} 不是该考勤点的清洗设备")
|
||||
|
||||
|
||||
def _validate_normal_report_devices(db: Session, report: ProductionReport) -> None:
|
||||
normal_items = [
|
||||
item
|
||||
for item in report.items
|
||||
if not is_cleaning_item(item) and not is_misc_item(item)
|
||||
]
|
||||
if not normal_items:
|
||||
return
|
||||
valid_press_equipment = {
|
||||
normalize_device_no(equipment.device_no)
|
||||
for equipment in db.scalars(
|
||||
select(Equipment).where(
|
||||
Equipment.attendance_point_name == report.attendance_point_name,
|
||||
Equipment.device_type == "冲压设备",
|
||||
)
|
||||
).all()
|
||||
}
|
||||
if not valid_press_equipment:
|
||||
raise HTTPException(status_code=400, detail="该考勤点暂无冲压设备,请先在设备管理中维护")
|
||||
for item in normal_items:
|
||||
device_no = normalize_device_no(item.device_no)
|
||||
if not device_no:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{mold_process_display_name(item.product_name, item.process_name, item.stamping_method)} 请填写设备号",
|
||||
)
|
||||
if device_no not in valid_press_equipment:
|
||||
raise HTTPException(status_code=400, detail=f"设备 {device_no} 不是该考勤点的冲压设备")
|
||||
|
||||
|
||||
def _report_query():
|
||||
return select(ProductionReport).options(
|
||||
selectinload(ProductionReport.items),
|
||||
selectinload(ProductionReport.audit_logs),
|
||||
selectinload(ProductionReport.employee),
|
||||
selectinload(ProductionReport.reviewer),
|
||||
selectinload(ProductionReport.voider),
|
||||
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
|
||||
)
|
||||
|
||||
|
||||
def _get_report_or_404(db: Session, report_id: int) -> ProductionReport:
|
||||
report = db.scalar(_report_query().where(ProductionReport.id == report_id))
|
||||
if report is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工记录不存在")
|
||||
return report
|
||||
|
||||
|
||||
def _snapshot(report: ProductionReport) -> dict:
|
||||
return {
|
||||
"id": report.id,
|
||||
"status": str(report.status),
|
||||
"start_at": report.start_at.isoformat(),
|
||||
"end_at": report.end_at.isoformat(),
|
||||
"break_minutes": float(report.break_minutes or 0),
|
||||
"effective_minutes": float(report.effective_minutes or 0),
|
||||
"total_good_qty": float(report.total_good_qty or 0),
|
||||
"actual_beat": float(report.actual_beat or 0),
|
||||
"review_remark": report.review_remark,
|
||||
"is_multi_person_assistant": bool(report.is_multi_person_assistant),
|
||||
"multi_person_source_report_id": report.multi_person_source_report_id,
|
||||
"devices": [
|
||||
{
|
||||
"id": device.id,
|
||||
"device_no": device.device_no,
|
||||
"process_name": device.process_name,
|
||||
"scanned_at": device.scanned_at.isoformat(),
|
||||
"released_at": device.released_at.isoformat() if device.released_at else None,
|
||||
"sort_order": int(device.sort_order or 0),
|
||||
}
|
||||
for device in _ordered_session_devices(report.session)
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"device_no": item.device_no,
|
||||
"project_no": item.project_no,
|
||||
"product_name": item.product_name,
|
||||
"raw_material_batch_no": item.raw_material_batch_no,
|
||||
"process_name": item.process_name,
|
||||
"stamping_method": item.stamping_method,
|
||||
"operator_count": float(item.operator_count or 1),
|
||||
"process_unit_price_yuan": float(item.process_unit_price_yuan or 0),
|
||||
"changeover_count": float(item.changeover_count or 0),
|
||||
"remark": item.remark,
|
||||
"good_qty": float(item.good_qty or 0),
|
||||
"defect_qty": float(item.defect_qty or 0),
|
||||
"scrap_qty": float(item.scrap_qty or 0),
|
||||
}
|
||||
for item in report.items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _ordered_session_devices(session: WorkSession | None) -> list[WorkSessionDevice]:
|
||||
return sorted(
|
||||
getattr(session, "devices", []) or [],
|
||||
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
|
||||
)
|
||||
|
||||
|
||||
def _device_display_name(db: Session, report: ProductionReport, device: WorkSessionDevice) -> str:
|
||||
with db.no_autoflush:
|
||||
product = db.scalar(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name == report.attendance_point_name,
|
||||
Product.product_name == device.device_no,
|
||||
Product.process_name == (device.process_name or ""),
|
||||
Product.device_no == "",
|
||||
)
|
||||
.order_by(Product.project_no.asc())
|
||||
)
|
||||
return mold_process_display_name(
|
||||
device.device_no,
|
||||
device.process_name,
|
||||
product.stamping_method if product else None,
|
||||
)
|
||||
|
||||
|
||||
def _apply_device_corrections(
|
||||
db: Session,
|
||||
report: ProductionReport,
|
||||
payload: ApproveReportRequest,
|
||||
next_start_at: datetime,
|
||||
next_end_at: datetime,
|
||||
) -> tuple[bool, datetime]:
|
||||
if not payload.device_corrections and report.session is None:
|
||||
return False, next_start_at
|
||||
if report.session is None:
|
||||
raise HTTPException(status_code=400, detail="该报工缺少扫码模具记录,不能校正换模具时间")
|
||||
ordered_devices = _ordered_session_devices(report.session)
|
||||
if not ordered_devices:
|
||||
return False, next_start_at
|
||||
|
||||
first_device = ordered_devices[0]
|
||||
corrected_start_at = next_start_at
|
||||
pending_scanned_at: dict[int, datetime] = {}
|
||||
has_correction = False
|
||||
|
||||
if not payload.device_corrections:
|
||||
pending_scanned_at[first_device.id] = corrected_start_at
|
||||
else:
|
||||
device_map = {device.id: device for device in ordered_devices}
|
||||
seen_ids: set[int] = set()
|
||||
for correction in payload.device_corrections:
|
||||
if correction.id in seen_ids:
|
||||
raise HTTPException(status_code=400, detail="同一换模具时间不能重复校正")
|
||||
seen_ids.add(correction.id)
|
||||
device = device_map.get(correction.id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=400, detail=f"换模具记录 {correction.id} 不存在")
|
||||
next_scanned_at = _effective_review_time(correction.scanned_at, device.scanned_at)
|
||||
if device.id == first_device.id:
|
||||
corrected_start_at = next_scanned_at
|
||||
elif next_scanned_at < corrected_start_at or next_scanned_at > next_end_at:
|
||||
raise HTTPException(status_code=400, detail="换模具时间必须在上下班时间范围内")
|
||||
pending_scanned_at[device.id] = next_scanned_at
|
||||
|
||||
pending_scanned_at.setdefault(first_device.id, corrected_start_at)
|
||||
|
||||
if corrected_start_at >= next_end_at:
|
||||
raise HTTPException(status_code=400, detail="下班时间必须晚于上班时间")
|
||||
|
||||
for device_id, next_scanned_at in pending_scanned_at.items():
|
||||
if next_scanned_at > next_end_at:
|
||||
raise HTTPException(status_code=400, detail="换模具时间必须在上下班时间范围内")
|
||||
device = next(device for device in ordered_devices if device.id == device_id)
|
||||
if next_scanned_at == device.scanned_at:
|
||||
continue
|
||||
device.scanned_at = next_scanned_at
|
||||
has_correction = True
|
||||
|
||||
ordered_devices = _ordered_session_devices(report.session)
|
||||
for index in range(1, len(ordered_devices)):
|
||||
previous = ordered_devices[index - 1]
|
||||
current = ordered_devices[index]
|
||||
if current.scanned_at <= previous.scanned_at:
|
||||
previous_name = _device_display_name(db, report, previous)
|
||||
current_name = _device_display_name(db, report, current)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"换模具时间顺序不正确:{current_name} 必须晚于 {previous_name}",
|
||||
)
|
||||
return has_correction, corrected_start_at
|
||||
|
||||
|
||||
def _is_cleaning_report(report: ProductionReport) -> bool:
|
||||
return bool(report.items) and all(is_cleaning_item(item) for item in report.items)
|
||||
|
||||
|
||||
def _apply_metrics(report: ProductionReport, schedule: WorkScheduleConfig | None = None) -> None:
|
||||
if _is_cleaning_report(report):
|
||||
total_good_qty = sum(as_float(item.good_qty) for item in report.items)
|
||||
total_output_qty = sum(
|
||||
as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty)
|
||||
for item in report.items
|
||||
)
|
||||
report.duration_minutes = 0
|
||||
report.effective_minutes = 0
|
||||
report.total_good_qty = total_good_qty
|
||||
report.total_output_qty = total_output_qty
|
||||
report.actual_beat = 0
|
||||
report.standard_beat = 0
|
||||
report.expected_workload = 0
|
||||
report.pace_rate = 0
|
||||
report.workload_rate = 0
|
||||
for item in report.items:
|
||||
item.allocated_minutes = 0
|
||||
return
|
||||
|
||||
metrics = calculate_report_metrics(
|
||||
report.start_at,
|
||||
report.end_at,
|
||||
report.items,
|
||||
devices=report.session.devices if report.session else None,
|
||||
schedule=schedule,
|
||||
)
|
||||
report.duration_minutes = metrics["duration_minutes"]
|
||||
report.effective_minutes = metrics["effective_minutes"]
|
||||
report.total_good_qty = metrics["total_good_qty"]
|
||||
report.total_output_qty = metrics["total_output_qty"]
|
||||
report.actual_beat = metrics["actual_beat"]
|
||||
report.standard_beat = metrics["standard_beat"]
|
||||
report.expected_workload = metrics["expected_workload"]
|
||||
report.pace_rate = metrics["pace_rate"]
|
||||
report.workload_rate = metrics["workload_rate"]
|
||||
|
||||
|
||||
@router.get("/pending", response_model=PageResponse)
|
||||
def list_pending_reports(
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
include_voided: bool = False,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
purge_expired_voided_reports(db)
|
||||
auto_submit_overdue_sessions(db)
|
||||
query = (
|
||||
_report_query()
|
||||
.where(
|
||||
ProductionReport.status == ReportStatus.pending,
|
||||
ProductionReport.attendance_point_name.in_(accessible_point_names(db, user)),
|
||||
)
|
||||
)
|
||||
if not include_voided:
|
||||
query = query.where(ProductionReport.is_voided.is_(False))
|
||||
if start_date:
|
||||
query = query.where(ProductionReport.report_date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ProductionReport.report_date <= end_date)
|
||||
query = query.order_by(ProductionReport.submitted_at.asc())
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{
|
||||
**result,
|
||||
"rows": [report_out(row, get_work_schedule_config(db, row.attendance_point_name)) for row in result["rows"]],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/mine", response_model=PageResponse)
|
||||
def list_my_review_records(
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
status_filter: ReportStatus | None = Query(None, alias="status"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
include_voided: bool = False,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
purge_expired_voided_reports(db)
|
||||
query = _report_query().where(ProductionReport.attendance_point_name.in_(accessible_point_names(db, user)))
|
||||
if not include_voided:
|
||||
query = query.where(ProductionReport.is_voided.is_(False))
|
||||
if start_date:
|
||||
query = query.where(ProductionReport.report_date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ProductionReport.report_date <= end_date)
|
||||
if status_filter:
|
||||
query = query.where(ProductionReport.status == status_filter)
|
||||
else:
|
||||
query = query.where(ProductionReport.status != ReportStatus.pending)
|
||||
query = query.order_by(ProductionReport.reviewed_at.desc())
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{
|
||||
**result,
|
||||
"rows": [report_out(row, get_work_schedule_config(db, row.attendance_point_name)) for row in result["rows"]],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{report_id}/approve", response_model=ReportOut)
|
||||
def approve_report(
|
||||
report_id: int,
|
||||
payload: ApproveReportRequest,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
report = _get_report_or_404(db, report_id)
|
||||
if report.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点审核权限")
|
||||
if report.is_voided:
|
||||
raise HTTPException(status_code=400, detail="作废期间的报工只能查看,不能编辑")
|
||||
if not can_edit_report(report):
|
||||
raise HTTPException(status_code=400, detail="该报工已超过可编辑期限")
|
||||
before = _snapshot(report)
|
||||
schedule = get_work_schedule_config(db, report.attendance_point_name)
|
||||
item_map = {item.id: item for item in report.items}
|
||||
has_correction = False
|
||||
|
||||
next_start_at = _effective_review_time(payload.start_at, report.start_at)
|
||||
next_end_at = _effective_review_time(payload.end_at, report.end_at)
|
||||
is_cleaning = _is_cleaning_report(report)
|
||||
if is_cleaning:
|
||||
next_start_at = report.start_at
|
||||
next_end_at = report.end_at
|
||||
else:
|
||||
device_has_correction, next_start_at = _apply_device_corrections(
|
||||
db,
|
||||
report,
|
||||
payload,
|
||||
next_start_at,
|
||||
next_end_at,
|
||||
)
|
||||
if device_has_correction:
|
||||
has_correction = True
|
||||
if not is_cleaning and next_end_at <= next_start_at:
|
||||
raise HTTPException(status_code=400, detail="下班时间必须晚于上班时间")
|
||||
if next_start_at != report.start_at:
|
||||
report.start_at = next_start_at
|
||||
if report.session is not None:
|
||||
report.session.start_at = next_start_at
|
||||
has_correction = True
|
||||
if next_end_at != report.end_at:
|
||||
report.end_at = next_end_at
|
||||
if report.session is not None:
|
||||
report.session.end_at = next_end_at
|
||||
has_correction = True
|
||||
|
||||
for correction in payload.item_corrections:
|
||||
item = item_map.get(correction.id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=400, detail=f"明细 {correction.id} 不存在")
|
||||
item_is_misc = is_misc_item(item)
|
||||
|
||||
next_device_no = normalize_device_no(correction.device_no or item.device_no)
|
||||
next_project_no = correction.project_no or item.project_no
|
||||
next_product_name = correction.product_name or item.product_name
|
||||
next_process_name = correction.process_name if correction.process_name is not None else item.process_name
|
||||
next_raw_material_batch_no = (
|
||||
correction.raw_material_batch_no
|
||||
if correction.raw_material_batch_no is not None
|
||||
else item.raw_material_batch_no
|
||||
)
|
||||
if (
|
||||
next_device_no != item.device_no
|
||||
or next_project_no != item.project_no
|
||||
or next_product_name != item.product_name
|
||||
or next_process_name != item.process_name
|
||||
):
|
||||
product = db.scalar(
|
||||
select(Product).where(
|
||||
Product.attendance_point_name == report.attendance_point_name,
|
||||
Product.project_no == next_project_no,
|
||||
Product.product_name == next_product_name,
|
||||
Product.process_name == (next_process_name or ""),
|
||||
Product.device_no == "",
|
||||
)
|
||||
)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=400, detail="校正后的产品、工序和冲压方式不在产品清单中")
|
||||
if is_cleaning and not is_cleaning_product(product):
|
||||
raise HTTPException(status_code=400, detail="清洗报工只能校正为冲压方式为清洗的产品")
|
||||
if not is_cleaning and is_cleaning_product(product):
|
||||
raise HTTPException(status_code=400, detail="非清洗报工不能校正为清洗产品")
|
||||
if item_is_misc and not is_misc_product(product):
|
||||
raise HTTPException(status_code=400, detail="处理杂活明细不能校正为普通产品")
|
||||
if not item_is_misc and is_misc_product(product):
|
||||
raise HTTPException(status_code=400, detail="普通报工明细不能校正为处理杂活")
|
||||
item.device_no = next_device_no
|
||||
item.project_no = product.project_no
|
||||
item.product_name = product.product_name
|
||||
item.material_code = product.material_code
|
||||
item.material_name = product.material_name
|
||||
item.process_name = product.process_name
|
||||
item.stamping_method = product.stamping_method
|
||||
item.operator_count = product.operator_count
|
||||
item.process_unit_price_yuan = product.process_unit_price_yuan
|
||||
item.changeover_count = item.changeover_count if is_continuous_die_product(product) else 0
|
||||
item.standard_beat = as_float(product.standard_beat)
|
||||
item.standard_workload = 0
|
||||
has_correction = True
|
||||
|
||||
if next_raw_material_batch_no != item.raw_material_batch_no:
|
||||
item.raw_material_batch_no = next_raw_material_batch_no
|
||||
has_correction = True
|
||||
|
||||
if correction.changeover_count is not None:
|
||||
next_changeover_count = as_float(correction.changeover_count)
|
||||
if next_changeover_count < 0:
|
||||
raise HTTPException(status_code=400, detail="换料次数不能为负数")
|
||||
if not is_continuous_die_item(item):
|
||||
next_changeover_count = 0
|
||||
if next_changeover_count != as_float(item.changeover_count):
|
||||
item.changeover_count = next_changeover_count
|
||||
has_correction = True
|
||||
|
||||
if correction.good_qty is not None and as_float(correction.good_qty) != as_float(item.good_qty):
|
||||
item.good_qty = correction.good_qty
|
||||
has_correction = True
|
||||
if correction.defect_qty is not None and as_float(correction.defect_qty) != as_float(item.defect_qty):
|
||||
item.defect_qty = correction.defect_qty
|
||||
has_correction = True
|
||||
if correction.scrap_qty is not None and as_float(correction.scrap_qty) != as_float(item.scrap_qty):
|
||||
item.scrap_qty = correction.scrap_qty
|
||||
has_correction = True
|
||||
if item_is_misc and correction.remark is not None:
|
||||
next_remark = str(correction.remark or "").strip() or None
|
||||
if next_remark != item.remark:
|
||||
item.remark = next_remark
|
||||
has_correction = True
|
||||
|
||||
if "review_remark" in payload.model_fields_set:
|
||||
next_review_remark = str(payload.review_remark or "").strip() or None
|
||||
if next_review_remark != report.review_remark:
|
||||
report.review_remark = next_review_remark
|
||||
has_correction = True
|
||||
|
||||
if report.is_system_auto_submitted and not is_cleaning:
|
||||
for item in report.items:
|
||||
if is_misc_item(item):
|
||||
continue
|
||||
if not normalize_device_no(item.device_no):
|
||||
raise HTTPException(status_code=400, detail="系统自动提交的报工请补填设备号后再审核通过")
|
||||
if as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty) <= 0:
|
||||
raise HTTPException(status_code=400, detail="系统自动提交的报工请补填产出数量后再审核通过")
|
||||
if is_cleaning:
|
||||
_validate_cleaning_report_devices(db, report)
|
||||
else:
|
||||
_validate_normal_report_devices(db, report)
|
||||
|
||||
_apply_metrics(report, schedule)
|
||||
reviewed_at = now()
|
||||
was_pending = report.status == ReportStatus.pending
|
||||
report.status = ReportStatus.approved
|
||||
report.reviewer_phone = user.phone
|
||||
if was_pending or report.reviewed_at is None:
|
||||
report.reviewed_at = reviewed_at
|
||||
report.reject_reason = None
|
||||
|
||||
after = _snapshot(report)
|
||||
db.add(
|
||||
ReportAuditLog(
|
||||
report_id=report.id,
|
||||
reviewer_phone=user.phone,
|
||||
action=AuditAction.approve_with_correction if has_correction else AuditAction.approve,
|
||||
before_json=before,
|
||||
after_json=after,
|
||||
remark=payload.review_remark if "review_remark" in payload.model_fields_set else payload.remark,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
saved = _get_report_or_404(db, report.id)
|
||||
return report_out(saved, schedule)
|
||||
|
||||
|
||||
@router.post("/{report_id}/void", response_model=ReportOut)
|
||||
def void_report(
|
||||
report_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
purge_expired_voided_reports(db)
|
||||
report = _get_report_or_404(db, report_id)
|
||||
if report.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点作废权限")
|
||||
mark_report_voided(report, user)
|
||||
db.commit()
|
||||
saved = _get_report_or_404(db, report.id)
|
||||
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
|
||||
|
||||
|
||||
@router.post("/{report_id}/unvoid", response_model=ReportOut)
|
||||
def unvoid_report(
|
||||
report_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
purge_expired_voided_reports(db)
|
||||
report = _get_report_or_404(db, report_id)
|
||||
if report.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点撤销作废权限")
|
||||
restore_voided_report(report)
|
||||
db.commit()
|
||||
saved = _get_report_or_404(db, report.id)
|
||||
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
|
||||
|
||||
|
||||
@router.post("/{report_id}/reject", response_model=ReportOut)
|
||||
def reject_report(
|
||||
report_id: int,
|
||||
payload: RejectReportRequest,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
report = _get_report_or_404(db, report_id)
|
||||
if report.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点审核权限")
|
||||
if report.status != ReportStatus.pending:
|
||||
raise HTTPException(status_code=400, detail="只有待审核报工可以驳回")
|
||||
|
||||
before = _snapshot(report)
|
||||
report.status = ReportStatus.rejected
|
||||
report.reviewer_phone = user.phone
|
||||
report.reviewed_at = now()
|
||||
report.reject_reason = payload.reason
|
||||
after = _snapshot(report)
|
||||
db.add(
|
||||
ReportAuditLog(
|
||||
report_id=report.id,
|
||||
reviewer_phone=user.phone,
|
||||
action=AuditAction.reject,
|
||||
before_json=before,
|
||||
after_json=after,
|
||||
remark=payload.reason,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
saved = _get_report_or_404(db, report.id)
|
||||
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
|
||||
@@ -0,0 +1,334 @@
|
||||
from datetime import date
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, 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 Equipment, Personnel, ProductionReport, ReportStatus, Role
|
||||
from app.schemas import (
|
||||
PageResponse,
|
||||
UsageStatsDailyRow,
|
||||
UsageStatsDetailOut,
|
||||
UsageStatsReportRow,
|
||||
UsageStatsRow,
|
||||
)
|
||||
from app.services.attendance_points import accessible_point_names, require_attendance_point_access
|
||||
from app.services.common import round2
|
||||
from app.services.report_lifecycle import purge_expired_voided_reports
|
||||
from app.services.usage_stats import UsageStatRow, build_usage_stats
|
||||
from app.services.usage_stats_export import export_usage_stats_rows
|
||||
|
||||
router = APIRouter(prefix="/api/usage-stats", tags=["usage-stats"])
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _requested_points(db: Session, user: Personnel, attendance_point_name: str | None) -> list[str]:
|
||||
requested_name = _clean(attendance_point_name)
|
||||
if requested_name:
|
||||
try:
|
||||
return [require_attendance_point_access(db, user, requested_name)]
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
|
||||
return accessible_point_names(db, user)
|
||||
|
||||
|
||||
def _equipment_type_map(db: Session, point_names: list[str]) -> dict[tuple[str, str], str]:
|
||||
if not point_names:
|
||||
return {}
|
||||
rows = db.scalars(select(Equipment).where(Equipment.attendance_point_name.in_(point_names))).all()
|
||||
return {(_clean(row.attendance_point_name), _clean(row.device_no)): row.device_type for row in rows}
|
||||
|
||||
|
||||
def _approved_reports_query(point_names: list[str], start_date: date | None, end_date: date | None):
|
||||
query = (
|
||||
select(ProductionReport)
|
||||
.options(selectinload(ProductionReport.employee), selectinload(ProductionReport.items))
|
||||
.where(
|
||||
ProductionReport.status == ReportStatus.approved,
|
||||
ProductionReport.is_voided.is_(False),
|
||||
ProductionReport.attendance_point_name.in_(point_names),
|
||||
)
|
||||
)
|
||||
if start_date:
|
||||
query = query.where(ProductionReport.report_date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ProductionReport.report_date <= end_date)
|
||||
return query.order_by(ProductionReport.report_date.desc(), ProductionReport.id.desc())
|
||||
|
||||
|
||||
def _sort_usage_rows(rows: list[UsageStatRow], sort_by: str) -> list[UsageStatRow]:
|
||||
if sort_by == "count":
|
||||
return sorted(rows, key=lambda row: (-row.report_count, -row.value, row.name))
|
||||
return sorted(rows, key=lambda row: (-row.value, -row.report_count, row.name))
|
||||
|
||||
|
||||
def _usage_row_search_text(row: UsageStatRow) -> str:
|
||||
values = [
|
||||
row.id,
|
||||
row.category,
|
||||
row.attendance_point_name,
|
||||
row.name,
|
||||
row.object_type,
|
||||
row.metric_kind,
|
||||
row.product_name,
|
||||
row.process_name,
|
||||
row.stamping_method,
|
||||
row.value,
|
||||
row.report_count,
|
||||
*row.tags,
|
||||
]
|
||||
return " ".join(str(value or "") for value in values).lower()
|
||||
|
||||
|
||||
def _filter_usage_rows(rows: list[UsageStatRow], keyword: str | None) -> list[UsageStatRow]:
|
||||
text = _clean(keyword).lower()
|
||||
if not text:
|
||||
return rows
|
||||
return [row for row in rows if text in _usage_row_search_text(row)]
|
||||
|
||||
|
||||
def _to_schema_row(row: UsageStatRow) -> UsageStatsRow:
|
||||
return UsageStatsRow(
|
||||
id=row.id,
|
||||
category=row.category,
|
||||
attendance_point_name=row.attendance_point_name,
|
||||
name=row.name,
|
||||
object_type=row.object_type,
|
||||
metric_kind=row.metric_kind,
|
||||
value=row.value,
|
||||
report_count=row.report_count,
|
||||
product_name=row.product_name,
|
||||
process_name=row.process_name,
|
||||
stamping_method=row.stamping_method,
|
||||
tags=list(row.tags),
|
||||
)
|
||||
|
||||
|
||||
def _to_detail_out(
|
||||
row: UsageStatRow,
|
||||
*,
|
||||
daily_rows: list[UsageStatsDailyRow] | None = None,
|
||||
report_rows: list[UsageStatsReportRow] | None = None,
|
||||
) -> UsageStatsDetailOut:
|
||||
return UsageStatsDetailOut(
|
||||
id=row.id,
|
||||
category=row.category,
|
||||
attendance_point_name=row.attendance_point_name,
|
||||
name=row.name,
|
||||
object_type=row.object_type,
|
||||
metric_kind=row.metric_kind,
|
||||
value=row.value,
|
||||
report_count=row.report_count,
|
||||
product_name=row.product_name,
|
||||
process_name=row.process_name,
|
||||
stamping_method=row.stamping_method,
|
||||
tags=list(row.tags),
|
||||
daily_rows=daily_rows or [],
|
||||
report_rows=report_rows or [],
|
||||
)
|
||||
|
||||
|
||||
def _matches_target(
|
||||
row: UsageStatRow,
|
||||
*,
|
||||
id: str,
|
||||
category: str,
|
||||
attendance_point_name: str,
|
||||
name: str,
|
||||
product_name: str,
|
||||
process_name: str,
|
||||
stamping_method: str,
|
||||
metric_kind: str,
|
||||
) -> bool:
|
||||
if id:
|
||||
return row.id == id
|
||||
if row.category != category:
|
||||
return False
|
||||
if attendance_point_name and row.attendance_point_name != attendance_point_name:
|
||||
return False
|
||||
if metric_kind and row.metric_kind != metric_kind:
|
||||
return False
|
||||
if category == "device":
|
||||
return row.name == name
|
||||
return (
|
||||
row.product_name == product_name
|
||||
and row.process_name == process_name
|
||||
and row.stamping_method == stamping_method
|
||||
)
|
||||
|
||||
|
||||
def _build_usage_stats_export_response(device_rows: list[UsageStatRow], mold_rows: list[UsageStatRow]) -> Response:
|
||||
content = export_usage_stats_rows(device_rows, mold_rows)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="usage-stats.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=PageResponse)
|
||||
def usage_stats_summary(
|
||||
category: str = Query("device", pattern="^(device|mold)$"),
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
attendance_point_name: str = "",
|
||||
keyword: str = "",
|
||||
sort_by: str = Query("value", pattern="^(value|count)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
purge_expired_voided_reports(db)
|
||||
point_names = _requested_points(db, user, attendance_point_name)
|
||||
reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all()
|
||||
rows = _sort_usage_rows(
|
||||
_filter_usage_rows(
|
||||
build_usage_stats(
|
||||
reports=reports,
|
||||
category=category,
|
||||
equipment_type_by_key=_equipment_type_map(db, point_names),
|
||||
),
|
||||
keyword,
|
||||
),
|
||||
sort_by,
|
||||
)
|
||||
safe_page_size = min(100, max(1, page_size))
|
||||
total = len(rows)
|
||||
total_pages = max(1, ceil(total / safe_page_size))
|
||||
start = (page - 1) * safe_page_size
|
||||
return PageResponse(
|
||||
page=page,
|
||||
page_size=safe_page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
rows=[_to_schema_row(row) for row in rows[start : start + safe_page_size]],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_usage_stats_excel(
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
attendance_point_name: str = "",
|
||||
keyword: str = "",
|
||||
sort_by: str = Query("value", pattern="^(value|count)$"),
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
purge_expired_voided_reports(db)
|
||||
point_names = _requested_points(db, user, attendance_point_name)
|
||||
equipment_type_by_key = _equipment_type_map(db, point_names)
|
||||
reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all()
|
||||
device_rows = _sort_usage_rows(
|
||||
_filter_usage_rows(
|
||||
build_usage_stats(
|
||||
reports=reports,
|
||||
category="device",
|
||||
equipment_type_by_key=equipment_type_by_key,
|
||||
),
|
||||
keyword,
|
||||
),
|
||||
sort_by,
|
||||
)
|
||||
mold_rows = _sort_usage_rows(
|
||||
_filter_usage_rows(
|
||||
build_usage_stats(
|
||||
reports=reports,
|
||||
category="mold",
|
||||
equipment_type_by_key=equipment_type_by_key,
|
||||
),
|
||||
keyword,
|
||||
),
|
||||
sort_by,
|
||||
)
|
||||
return _build_usage_stats_export_response(device_rows, mold_rows)
|
||||
|
||||
|
||||
@router.get("/detail", response_model=UsageStatsDetailOut)
|
||||
def usage_stats_detail(
|
||||
category: str = Query("device", pattern="^(device|mold)$"),
|
||||
id: str = "",
|
||||
metric_kind: str = "",
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
attendance_point_name: str = "",
|
||||
name: str = "",
|
||||
product_name: str = "",
|
||||
process_name: str = "",
|
||||
stamping_method: str = "",
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UsageStatsDetailOut:
|
||||
purge_expired_voided_reports(db)
|
||||
point_names = _requested_points(db, user, attendance_point_name)
|
||||
equipment_type_by_key = _equipment_type_map(db, point_names)
|
||||
reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all()
|
||||
target_args = {
|
||||
"id": _clean(id),
|
||||
"category": category,
|
||||
"attendance_point_name": _clean(attendance_point_name),
|
||||
"name": _clean(name),
|
||||
"product_name": _clean(product_name),
|
||||
"process_name": _clean(process_name),
|
||||
"stamping_method": _clean(stamping_method),
|
||||
"metric_kind": _clean(metric_kind),
|
||||
}
|
||||
rows = build_usage_stats(
|
||||
reports=reports,
|
||||
category=category,
|
||||
equipment_type_by_key=equipment_type_by_key,
|
||||
)
|
||||
target = next((row for row in rows if _matches_target(row, **target_args)), None)
|
||||
if target is None:
|
||||
return UsageStatsDetailOut(
|
||||
id=target_args["id"],
|
||||
category=category,
|
||||
attendance_point_name=target_args["attendance_point_name"],
|
||||
name=target_args["name"],
|
||||
product_name=target_args["product_name"],
|
||||
process_name=target_args["process_name"],
|
||||
stamping_method=target_args["stamping_method"],
|
||||
metric_kind=target_args["metric_kind"],
|
||||
)
|
||||
|
||||
daily_rows: dict[date, UsageStatsDailyRow] = {}
|
||||
report_rows: list[UsageStatsReportRow] = []
|
||||
for report in reports:
|
||||
report_matches = build_usage_stats(
|
||||
reports=[report],
|
||||
category=category,
|
||||
equipment_type_by_key=equipment_type_by_key,
|
||||
)
|
||||
match = next((row for row in report_matches if _matches_target(row, **target_args)), None)
|
||||
if match is None:
|
||||
continue
|
||||
|
||||
day = daily_rows.setdefault(report.report_date, UsageStatsDailyRow(report_date=report.report_date))
|
||||
day.value = round2(day.value + match.value)
|
||||
day.report_count += match.report_count
|
||||
report_rows.append(
|
||||
UsageStatsReportRow(
|
||||
report_id=report.id,
|
||||
report_date=report.report_date,
|
||||
employee_phone=report.employee_phone,
|
||||
employee_name=report.employee.name if report.employee else "",
|
||||
display_name=target.name,
|
||||
value=match.value,
|
||||
report_count=match.report_count,
|
||||
)
|
||||
)
|
||||
|
||||
return _to_detail_out(
|
||||
target,
|
||||
daily_rows=sorted(daily_rows.values(), key=lambda row: row.report_date),
|
||||
report_rows=sorted(report_rows, key=lambda row: (row.report_date, row.report_id), reverse=True),
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import (
|
||||
AttendancePoint,
|
||||
DeviceQRCode,
|
||||
Equipment,
|
||||
NoticePoint,
|
||||
PersonAttendancePoint,
|
||||
Personnel,
|
||||
Product,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
ReconciliationLedgerEntry,
|
||||
Role,
|
||||
WorkSchedule,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
)
|
||||
from app.schemas import AttendancePointCreate, WorkScheduleOut, WorkScheduleSettingsUpdate, WorkScheduleUpdate
|
||||
from app.services.attendance_points import clean_point_name, ensure_default_attendance_point
|
||||
from app.services.misc_work import ensure_misc_work_product
|
||||
from app.services.serializers import attendance_point_out
|
||||
from app.services.work_schedule import get_work_schedule, normalize_schedule_payload, update_work_schedule
|
||||
|
||||
router = APIRouter(prefix="/api/work-schedule", tags=["work-schedule"])
|
||||
|
||||
|
||||
def _schedule_out(schedule: WorkSchedule, db: Session | None = None) -> WorkScheduleOut:
|
||||
points = []
|
||||
if db is not None:
|
||||
ensure_default_attendance_point(db)
|
||||
db.commit()
|
||||
points = [
|
||||
attendance_point_out(point)
|
||||
for point in db.scalars(
|
||||
select(AttendancePoint)
|
||||
.where(AttendancePoint.is_active.is_(True))
|
||||
.order_by(AttendancePoint.name)
|
||||
).all()
|
||||
]
|
||||
return WorkScheduleOut(
|
||||
id=schedule.id,
|
||||
day_start=schedule.day_start,
|
||||
day_end=schedule.day_end,
|
||||
lunch_start=schedule.lunch_start,
|
||||
lunch_end=schedule.lunch_end,
|
||||
dinner_start=schedule.dinner_start,
|
||||
dinner_end=schedule.dinner_end,
|
||||
overtime_start=schedule.overtime_start,
|
||||
overtime_end=schedule.overtime_end,
|
||||
night_start=schedule.night_start,
|
||||
night_end=schedule.night_end,
|
||||
attendance_latitude=schedule.attendance_latitude,
|
||||
attendance_longitude=schedule.attendance_longitude,
|
||||
attendance_radius_meters=schedule.attendance_radius_meters,
|
||||
auto_submit_hours=float(schedule.auto_submit_hours or 15),
|
||||
attendance_points=points,
|
||||
updated_by=schedule.updated_by,
|
||||
updated_at=schedule.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _validate_attendance_point_payload(payload: AttendancePointCreate) -> str:
|
||||
name = clean_point_name(payload.name)
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="请输入考勤点名称")
|
||||
if (payload.latitude is None) != (payload.longitude is None):
|
||||
raise HTTPException(status_code=400, detail="经纬度必须同时填写")
|
||||
if payload.latitude is not None and not (-90 <= payload.latitude <= 90):
|
||||
raise HTTPException(status_code=400, detail="纬度范围为-90到90")
|
||||
if payload.longitude is not None and not (-180 <= payload.longitude <= 180):
|
||||
raise HTTPException(status_code=400, detail="经度范围为-180到180")
|
||||
if int(payload.radius_meters or 0) <= 0:
|
||||
raise HTTPException(status_code=400, detail="考勤范围半径必须大于0米")
|
||||
try:
|
||||
normalize_schedule_payload(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"{name} {exc}") from exc
|
||||
return name
|
||||
|
||||
|
||||
def _rename_business_point(db: Session, old_name: str, new_name: str) -> None:
|
||||
if old_name == new_name:
|
||||
return
|
||||
for model in (
|
||||
PersonAttendancePoint,
|
||||
Product,
|
||||
Equipment,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
DeviceQRCode,
|
||||
NoticePoint,
|
||||
ReconciliationLedgerEntry,
|
||||
):
|
||||
db.query(model).filter(model.attendance_point_name == old_name).update(
|
||||
{model.attendance_point_name: new_name},
|
||||
synchronize_session=False,
|
||||
)
|
||||
|
||||
|
||||
def _save_attendance_point_without_commit(db: Session, payload: AttendancePointCreate) -> AttendancePoint:
|
||||
name = _validate_attendance_point_payload(payload)
|
||||
original_name = clean_point_name(payload.original_name) or name
|
||||
point = db.get(AttendancePoint, original_name)
|
||||
if point is not None and original_name != name:
|
||||
if db.get(AttendancePoint, name) is not None:
|
||||
raise HTTPException(status_code=409, detail="该考勤点名称已存在")
|
||||
next_point = AttendancePoint(name=name)
|
||||
db.add(next_point)
|
||||
db.flush()
|
||||
_rename_business_point(db, original_name, name)
|
||||
db.delete(point)
|
||||
point = next_point
|
||||
elif point is None:
|
||||
point = db.get(AttendancePoint, name)
|
||||
if point is None:
|
||||
point = AttendancePoint(name=name)
|
||||
db.add(point)
|
||||
db.flush()
|
||||
point.latitude = payload.latitude
|
||||
point.longitude = payload.longitude
|
||||
point.radius_meters = int(payload.radius_meters or 500)
|
||||
for field, value in normalize_schedule_payload(payload).items():
|
||||
setattr(point, field, value)
|
||||
point.remark = payload.remark
|
||||
point.is_active = bool(payload.is_active)
|
||||
ensure_misc_work_product(db, name)
|
||||
db.flush()
|
||||
return point
|
||||
|
||||
|
||||
@router.get("", response_model=WorkScheduleOut)
|
||||
def read_work_schedule(
|
||||
db: Session = Depends(get_db),
|
||||
) -> WorkScheduleOut:
|
||||
return _schedule_out(get_work_schedule(db), db)
|
||||
|
||||
|
||||
@router.put("", response_model=WorkScheduleOut)
|
||||
def save_work_schedule(
|
||||
payload: WorkScheduleUpdate,
|
||||
user: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> WorkScheduleOut:
|
||||
try:
|
||||
schedule = update_work_schedule(db, payload, user.phone)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
return _schedule_out(schedule, db)
|
||||
|
||||
|
||||
@router.put("/settings", response_model=WorkScheduleOut)
|
||||
def save_work_schedule_settings(
|
||||
payload: WorkScheduleSettingsUpdate,
|
||||
user: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> WorkScheduleOut:
|
||||
names = [_validate_attendance_point_payload(point) for point in payload.attendance_points]
|
||||
duplicates = sorted({name for name in names if names.count(name) > 1})
|
||||
if duplicates:
|
||||
raise HTTPException(status_code=400, detail=f"考勤点名称重复:{'、'.join(duplicates)}")
|
||||
if not names:
|
||||
raise HTTPException(status_code=400, detail="请至少维护一个考勤点")
|
||||
try:
|
||||
schedule = update_work_schedule(db, payload, user.phone, commit=False)
|
||||
for point in payload.attendance_points:
|
||||
_save_attendance_point_without_commit(db, point)
|
||||
db.commit()
|
||||
db.refresh(schedule)
|
||||
except ValueError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
return _schedule_out(schedule, db)
|
||||
Reference in New Issue
Block a user