chore: initialize mini program backend

This commit is contained in:
souplearn
2026-06-24 15:19:14 +08:00
commit 8f62c99893
97 changed files with 13770 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""API routers."""
+168
View File
@@ -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()
+309
View File
@@ -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)
+516
View File
@@ -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)
+382
View File
@@ -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],
)
+505
View File
@@ -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)
+108
View File
@@ -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()
+192
View File
@@ -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)
+146
View File
@@ -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()
+163
View File
@@ -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"'},
)
+360
View File
@@ -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"'},
)
+232
View File
@@ -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),
)
+656
View File
@@ -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))
+618
View File
@@ -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))
+334
View File
@@ -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),
)
+182
View File
@@ -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)