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 @@
"""嘉恒智能报工后端服务."""
+51
View File
@@ -0,0 +1,51 @@
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote_plus
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_env: str = "development"
app_timezone: str = "Asia/Shanghai"
jwt_secret: str = Field(default="change_me_to_a_long_random_string")
token_expire_minutes: int = 60 * 24 * 30
auto_create_tables: bool = False
background_tasks_enabled: bool | None = None
mysql_host: str = "127.0.0.1"
mysql_port: int = 3306
mysql_database: str = "jiaheng_erp"
mysql_user: str = "root"
mysql_password: str = ""
wechat_app_id: str = ""
wechat_app_secret: str = ""
wechat_qr_env_version: str = "release"
public_base_url: str = "http://127.0.0.1:8000"
upload_dir: str = "uploads"
@property
def database_url(self) -> str:
user = quote_plus(self.mysql_user)
password = quote_plus(self.mysql_password)
database = quote_plus(self.mysql_database)
return (
f"mysql+pymysql://{user}:{password}@"
f"{self.mysql_host}:{self.mysql_port}/{database}?charset=utf8mb4"
)
@property
def upload_path(self) -> Path:
return Path(self.upload_dir).resolve()
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+35
View File
@@ -0,0 +1,35 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
class Base(DeclarativeBase):
pass
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
pool_recycle=280,
future=True,
)
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
expire_on_commit=False,
class_=Session,
)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+49
View File
@@ -0,0 +1,49 @@
from collections.abc import Callable
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import PersonRole, Personnel, Role
from app.security import verify_token
from app.services.serializers import temporary_expired
bearer = HTTPBearer(auto_error=False)
def current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
db: Session = Depends(get_db),
) -> Personnel:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
try:
payload = verify_token(credentials.credentials)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已失效") from exc
phone = payload.get("sub")
try:
role = Role(payload.get("role"))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="角色无效") from exc
user = db.get(Personnel, phone)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="人员不存在")
if temporary_expired(user):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
if db.get(PersonRole, {"phone": phone, "role": role}) is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="角色不存在")
user.role = role
return user
def require_roles(*roles: Role) -> Callable[[Personnel], Personnel]:
def checker(user: Personnel = Depends(current_user)) -> Personnel:
if user.role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
return user
return checker
+103
View File
@@ -0,0 +1,103 @@
import asyncio
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.database import Base, engine
from app import models # noqa: F401
from app.services.auto_submit import auto_submit_overdue_loop
from app.services.qrcode_batch_tasks import qrcode_batch_task_loop
from app.routers import (
attendance_points,
auth,
clock,
dashboard,
devices,
equipment,
monitor,
notices,
people,
products,
reconciliation,
reports,
reviews,
usage_stats,
work_schedule,
)
def create_app() -> FastAPI:
app = FastAPI(
title="嘉恒智能报工后端服务",
version="0.1.0",
description="FastAPI service for 嘉恒智能报工小程序",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
settings.upload_path.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(settings.upload_path)), name="uploads")
def background_tasks_enabled() -> bool:
if settings.background_tasks_enabled is not None:
return settings.background_tasks_enabled
return settings.app_env == "production"
@app.on_event("startup")
async def startup() -> None:
if settings.auto_create_tables:
Base.metadata.create_all(bind=engine)
app.state.auto_submit_task = None
app.state.qrcode_batch_task = None
if background_tasks_enabled():
app.state.auto_submit_task = asyncio.create_task(auto_submit_overdue_loop())
app.state.qrcode_batch_task = asyncio.create_task(qrcode_batch_task_loop())
@app.on_event("shutdown")
async def shutdown() -> None:
task = getattr(app.state, "auto_submit_task", None)
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
task = getattr(app.state, "qrcode_batch_task", None)
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
app.include_router(auth.router)
app.include_router(attendance_points.router)
app.include_router(people.router)
app.include_router(products.router)
app.include_router(clock.router)
app.include_router(reports.router)
app.include_router(reviews.router)
app.include_router(dashboard.router)
app.include_router(devices.router)
app.include_router(equipment.router)
app.include_router(monitor.router)
app.include_router(notices.router)
app.include_router(reconciliation.router)
app.include_router(work_schedule.router)
app.include_router(usage_stats.router)
return app
app = create_app()
+464
View File
@@ -0,0 +1,464 @@
from datetime import date, datetime
from enum import StrEnum
from sqlalchemy import (
BigInteger,
Boolean,
Date,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
)
from sqlalchemy.dialects.mysql import JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
from app.timezone import now
class Role(StrEnum):
worker = "worker"
admin = "admin"
manager = "manager"
class SessionStatus(StrEnum):
active = "active"
reporting = "reporting"
submitted = "submitted"
cancelled = "cancelled"
class ReportStatus(StrEnum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class AuditAction(StrEnum):
approve = "approve"
approve_with_correction = "approve_with_correction"
reject = "reject"
manager_break_update = "manager_break_update"
class AttendancePoint(Base):
__tablename__ = "attendance_points"
name: Mapped[str] = mapped_column(String(128), primary_key=True)
latitude: Mapped[float | None] = mapped_column(Numeric(10, 7))
longitude: Mapped[float | None] = mapped_column(Numeric(10, 7))
radius_meters: Mapped[int] = mapped_column(Integer, nullable=False, default=500)
day_start: Mapped[str] = mapped_column(String(5), nullable=False, default="08:00")
day_end: Mapped[str] = mapped_column(String(5), nullable=False, default="17:20")
lunch_start: Mapped[str] = mapped_column(String(5), nullable=False, default="11:40")
lunch_end: Mapped[str] = mapped_column(String(5), nullable=False, default="12:40")
dinner_start: Mapped[str] = mapped_column(String(5), nullable=False, default="17:20")
dinner_end: Mapped[str] = mapped_column(String(5), nullable=False, default="18:00")
overtime_start: Mapped[str] = mapped_column(String(5), nullable=False, default="18:00")
overtime_end: Mapped[str] = mapped_column(String(5), nullable=False, default="20:00")
night_start: Mapped[str] = mapped_column(String(5), nullable=False, default="20:00")
night_end: Mapped[str] = mapped_column(String(5), nullable=False, default="06:00")
remark: Mapped[str | None] = mapped_column(String(500))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
class Personnel(Base):
__tablename__ = "personnel"
phone: Mapped[str] = mapped_column(String(20), primary_key=True)
name: Mapped[str] = mapped_column(String(64), nullable=False)
is_temporary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
temporary_expires_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
roles: Mapped[list["PersonRole"]] = relationship(
back_populates="person",
cascade="all, delete-orphan",
)
attendance_points: Mapped[list["PersonAttendancePoint"]] = relationship(
back_populates="person",
cascade="all, delete-orphan",
)
class PersonRole(Base):
__tablename__ = "person_roles"
phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), primary_key=True)
role: Mapped[Role] = mapped_column(Enum(Role), primary_key=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
person: Mapped[Personnel] = relationship(back_populates="roles")
class PersonAttendancePoint(Base):
__tablename__ = "person_attendance_points"
phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), primary_key=True)
attendance_point_name: Mapped[str] = mapped_column(
ForeignKey("attendance_points.name"),
primary_key=True,
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
person: Mapped[Personnel] = relationship(back_populates="attendance_points")
attendance_point: Mapped[AttendancePoint] = relationship()
class Product(Base):
__tablename__ = "products"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, default="")
project_no: Mapped[str] = mapped_column(String(64), primary_key=True)
product_name: Mapped[str] = mapped_column(String(255), primary_key=True)
device_no: Mapped[str] = mapped_column(String(64), primary_key=True)
process_name: Mapped[str] = mapped_column(String(128), primary_key=True, default="")
profile_no: Mapped[str | None] = mapped_column(String(128))
material_code: Mapped[str | None] = mapped_column(String(128))
material_name: Mapped[str | None] = mapped_column(String(255))
supplier: Mapped[str | None] = mapped_column(String(255))
product_net_weight_kg: Mapped[float | None] = mapped_column(Numeric(12, 4))
product_gross_weight_kg: Mapped[float | None] = mapped_column(Numeric(12, 4))
scrap_loss_rate: Mapped[float | None] = mapped_column(Numeric(10, 6))
waste_price_yuan_per_kg: Mapped[float | None] = mapped_column(Numeric(12, 4))
stamping_method: Mapped[str | None] = mapped_column(String(128))
operator_count: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=1)
process_unit_price_yuan: Mapped[float] = mapped_column(Numeric(12, 4), nullable=False, default=0)
standard_beat: Mapped[float] = mapped_column(Numeric(12, 3), nullable=False)
standard_workload: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
__table_args__ = (
Index("idx_products_device_no", "device_no"),
Index("idx_products_material_code", "material_code"),
)
class Equipment(Base):
__tablename__ = "equipment"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, default="")
device_no: Mapped[str] = mapped_column(String(64), primary_key=True)
device_type: Mapped[str] = mapped_column(String(32), nullable=False, default="冲压设备")
remark: Mapped[str | None] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
class WorkSchedule(Base):
__tablename__ = "work_schedules"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
day_start: Mapped[str] = mapped_column(String(5), nullable=False, default="08:00")
day_end: Mapped[str] = mapped_column(String(5), nullable=False, default="17:20")
lunch_start: Mapped[str] = mapped_column(String(5), nullable=False, default="11:40")
lunch_end: Mapped[str] = mapped_column(String(5), nullable=False, default="12:40")
dinner_start: Mapped[str] = mapped_column(String(5), nullable=False, default="17:20")
dinner_end: Mapped[str] = mapped_column(String(5), nullable=False, default="18:00")
overtime_start: Mapped[str] = mapped_column(String(5), nullable=False, default="18:00")
overtime_end: Mapped[str] = mapped_column(String(5), nullable=False, default="20:00")
night_start: Mapped[str] = mapped_column(String(5), nullable=False, default="20:00")
night_end: Mapped[str] = mapped_column(String(5), nullable=False, default="06:00")
attendance_latitude: Mapped[float | None] = mapped_column(Numeric(10, 7))
attendance_longitude: Mapped[float | None] = mapped_column(Numeric(10, 7))
attendance_radius_meters: Mapped[int] = mapped_column(Integer, nullable=False, default=500)
auto_submit_hours: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=15)
updated_by: Mapped[str | None] = mapped_column(String(20))
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
class WorkSession(Base):
__tablename__ = "work_sessions"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
attendance_point_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
start_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
end_at: Mapped[datetime | None] = mapped_column(DateTime)
status: Mapped[SessionStatus] = mapped_column(
Enum(SessionStatus),
nullable=False,
default=SessionStatus.active,
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
employee: Mapped[Personnel] = relationship()
devices: Mapped[list["WorkSessionDevice"]] = relationship(
back_populates="session",
cascade="all, delete-orphan",
order_by="WorkSessionDevice.sort_order",
)
__table_args__ = (
Index("idx_sessions_employee_status", "employee_phone", "status"),
)
class WorkSessionDevice(Base):
__tablename__ = "work_session_devices"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("work_sessions.id"), nullable=False)
attendance_point_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
device_no: Mapped[str] = mapped_column(String(255), nullable=False)
process_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
scanned_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
released_at: Mapped[datetime | None] = mapped_column(DateTime)
released_by: Mapped[str | None] = mapped_column(String(20))
release_reason: Mapped[str | None] = mapped_column(String(120))
session: Mapped[WorkSession] = relationship(back_populates="devices")
__table_args__ = (
Index("idx_session_devices_session", "session_id"),
Index("idx_session_devices_device", "device_no"),
Index("idx_session_devices_release", "released_at"),
)
class ProductionReport(Base):
__tablename__ = "production_reports"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
attendance_point_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
session_id: Mapped[int] = mapped_column(ForeignKey("work_sessions.id"), nullable=False, unique=True)
employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
report_date: Mapped[date] = mapped_column(Date, nullable=False)
start_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
end_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
duration_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
break_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
effective_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
total_good_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
total_output_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
actual_beat: Mapped[float] = mapped_column(Numeric(12, 3), nullable=False, default=0)
standard_beat: Mapped[float] = mapped_column(Numeric(12, 3), nullable=False, default=0)
expected_workload: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
pace_rate: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
workload_rate: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
status: Mapped[ReportStatus] = mapped_column(
Enum(ReportStatus),
nullable=False,
default=ReportStatus.pending,
)
reviewer_phone: Mapped[str | None] = mapped_column(ForeignKey("personnel.phone"))
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime)
reject_reason: Mapped[str | None] = mapped_column(String(500))
review_remark: Mapped[str | None] = mapped_column(String(500))
submitted_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
is_system_auto_submitted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
auto_submit_reason: Mapped[str | None] = mapped_column(String(120))
is_multi_person_assistant: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
multi_person_source_report_id: Mapped[int | None] = mapped_column(BigInteger)
is_voided: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
voided_at: Mapped[datetime | None] = mapped_column(DateTime)
voided_by: Mapped[str | None] = mapped_column(ForeignKey("personnel.phone"))
unvoid_deadline_at: Mapped[datetime | None] = mapped_column(DateTime)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
employee: Mapped[Personnel] = relationship(foreign_keys=[employee_phone])
reviewer: Mapped[Personnel | None] = relationship(foreign_keys=[reviewer_phone])
voider: Mapped[Personnel | None] = relationship(foreign_keys=[voided_by])
session: Mapped[WorkSession] = relationship()
items: Mapped[list["ProductionReportItem"]] = relationship(
back_populates="report",
cascade="all, delete-orphan",
)
audit_logs: Mapped[list["ReportAuditLog"]] = relationship(
order_by="ReportAuditLog.created_at",
)
__table_args__ = (
Index("idx_reports_employee_date", "employee_phone", "report_date"),
Index("idx_reports_status_date", "status", "report_date"),
Index("idx_reports_reviewer", "reviewer_phone", "reviewed_at"),
Index("idx_reports_voided", "is_voided", "voided_at"),
)
class ProductionReportItem(Base):
__tablename__ = "production_report_items"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
report_id: Mapped[int] = mapped_column(ForeignKey("production_reports.id"), nullable=False)
attendance_point_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
device_no: Mapped[str] = mapped_column(String(255), nullable=False)
project_no: Mapped[str] = mapped_column(String(64), nullable=False)
product_name: Mapped[str] = mapped_column(String(255), nullable=False)
material_code: Mapped[str | None] = mapped_column(String(128))
material_name: Mapped[str | None] = mapped_column(String(255))
raw_material_batch_no: Mapped[str | None] = mapped_column(String(128))
process_name: Mapped[str | None] = mapped_column(String(128))
stamping_method: Mapped[str | None] = mapped_column(String(128))
operator_count: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=1)
process_unit_price_yuan: Mapped[float] = mapped_column(Numeric(12, 4), nullable=False, default=0)
changeover_count: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
standard_beat: Mapped[float] = mapped_column(Numeric(12, 3), nullable=False, default=0)
standard_workload: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
good_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
defect_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
scrap_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
allocated_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
started_at: Mapped[datetime | None] = mapped_column(DateTime)
remark: Mapped[str | None] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
report: Mapped[ProductionReport] = relationship(back_populates="items")
__table_args__ = (
Index("idx_report_items_report", "report_id"),
Index("idx_report_items_product", "project_no", "product_name"),
)
class ReportAuditLog(Base):
__tablename__ = "report_audit_logs"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
report_id: Mapped[int] = mapped_column(ForeignKey("production_reports.id"), nullable=False)
reviewer_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
action: Mapped[AuditAction] = mapped_column(Enum(AuditAction), nullable=False)
before_json: Mapped[dict | None] = mapped_column(JSON)
after_json: Mapped[dict | None] = mapped_column(JSON)
remark: Mapped[str | None] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
__table_args__ = (
Index("idx_audit_report", "report_id"),
Index("idx_audit_reviewer", "reviewer_phone", "created_at"),
)
class MoldLockFeedback(Base):
__tablename__ = "mold_lock_feedbacks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
attendance_point_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
mold_name: Mapped[str] = mapped_column(String(255), nullable=False)
process_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
session_device_id: Mapped[int] = mapped_column(ForeignKey("work_session_devices.id"), nullable=False)
reporter_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
occupied_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
read_at: Mapped[datetime | None] = mapped_column(DateTime)
handled_at: Mapped[datetime | None] = mapped_column(DateTime)
handled_by: Mapped[str | None] = mapped_column(ForeignKey("personnel.phone"))
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
session_device: Mapped[WorkSessionDevice] = relationship()
reporter: Mapped[Personnel] = relationship(foreign_keys=[reporter_phone])
occupied_person: Mapped[Personnel] = relationship(foreign_keys=[occupied_phone])
handler: Mapped[Personnel | None] = relationship(foreign_keys=[handled_by])
__table_args__ = (
Index("idx_mold_lock_feedback_point_read", "attendance_point_name", "read_at", "created_at"),
Index("idx_mold_lock_feedback_status", "status", "created_at"),
)
class DeviceQRCode(Base):
__tablename__ = "device_qrcodes"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, default="")
device_no: Mapped[str] = mapped_column(String(255), primary_key=True)
process_name: Mapped[str] = mapped_column(String(128), primary_key=True, default="")
qr_scene: Mapped[str] = mapped_column(String(128), nullable=False)
qr_url: Mapped[str | None] = mapped_column(String(500))
created_by: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
class DeviceQrBatchTask(Base):
__tablename__ = "device_qrcode_batch_tasks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
item_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
completed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
failed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
items_json: Mapped[list] = mapped_column(JSON, nullable=False)
zip_url: Mapped[str | None] = mapped_column(String(500))
error_message: Mapped[str | None] = mapped_column(String(1000))
created_by: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime)
finished_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
creator: Mapped[Personnel] = relationship()
__table_args__ = (
Index("idx_device_qrcode_batch_tasks_creator", "created_by", "created_at"),
Index("idx_device_qrcode_batch_tasks_status", "status", "created_at"),
)
class Notice(Base):
__tablename__ = "notices"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(120), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_by: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
creator: Mapped[Personnel] = relationship()
attendance_points: Mapped[list["NoticePoint"]] = relationship(
back_populates="notice",
cascade="all, delete-orphan",
)
__table_args__ = (
Index("idx_notices_active_sort", "is_active", "sort_order"),
Index("idx_notices_updated", "updated_at"),
)
class NoticePoint(Base):
__tablename__ = "notice_points"
notice_id: Mapped[int] = mapped_column(ForeignKey("notices.id"), primary_key=True)
attendance_point_name: Mapped[str] = mapped_column(
ForeignKey("attendance_points.name"),
primary_key=True,
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
notice: Mapped[Notice] = relationship(back_populates="attendance_points")
attendance_point: Mapped[AttendancePoint] = relationship()
class ReconciliationLedgerEntry(Base):
__tablename__ = "reconciliation_ledger_entries"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, default="")
year: Mapped[int] = mapped_column(Integer, primary_key=True)
month: Mapped[int] = mapped_column(Integer, primary_key=True)
product_name: Mapped[str] = mapped_column(String(255), primary_key=True)
reconciled_good_qty: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, default=0)
return_qty: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, default=0)
updated_by: Mapped[str | None] = mapped_column(String(20))
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
__table_args__ = (
Index("idx_reconciliation_product", "product_name"),
Index("idx_reconciliation_updated", "updated_at"),
)
+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)
+783
View File
@@ -0,0 +1,783 @@
from datetime import date, datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from app.models import ReportStatus, Role
class Page(BaseModel):
page: int = 1
page_size: int = 10
total: int = 0
total_pages: int = 1
class PageResponse(BaseModel):
page: int
page_size: int
total: int
total_pages: int
rows: list[Any]
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: "PersonnelOut"
class AttendancePointBase(BaseModel):
name: str = Field(min_length=1, max_length=128)
latitude: float | None = None
longitude: float | None = None
radius_meters: int = Field(default=500, ge=1)
day_start: str = Field(default="08:00", pattern=r"^\d{1,2}:\d{2}$")
day_end: str = Field(default="17:20", pattern=r"^\d{1,2}:\d{2}$")
lunch_start: str = Field(default="11:40", pattern=r"^\d{1,2}:\d{2}$")
lunch_end: str = Field(default="12:40", pattern=r"^\d{1,2}:\d{2}$")
dinner_start: str = Field(default="17:20", pattern=r"^\d{1,2}:\d{2}$")
dinner_end: str = Field(default="18:00", pattern=r"^\d{1,2}:\d{2}$")
overtime_start: str = Field(default="18:00", pattern=r"^\d{1,2}:\d{2}$")
overtime_end: str = Field(default="20:00", pattern=r"^\d{1,2}:\d{2}$")
night_start: str = Field(default="20:00", pattern=r"^\d{1,2}:\d{2}$")
night_end: str = Field(default="06:00", pattern=r"^\d{1,2}:\d{2}$")
remark: str | None = Field(default=None, max_length=500)
is_active: bool = True
class AttendancePointCreate(AttendancePointBase):
original_name: str | None = None
class AttendancePointOut(AttendancePointBase):
created_at: datetime | None = None
updated_at: datetime | None = None
class LoginSelectionResponse(BaseModel):
needs_selection: bool
matched_people: list["PersonnelOut"] = []
selection_token: str | None = None
access_token: str | None = None
token_type: str = "bearer"
user: "PersonnelOut | None" = None
needs_temporary_worker: bool = False
temporary_token: str | None = None
temporary_phone: str | None = None
temporary_expires_at: datetime | None = None
class WechatLoginRequest(BaseModel):
phone_code: str | None = None
phone: str | None = None
phone_candidates: list[str] = []
selected_role: Role | None = None
create_temporary_worker: bool = False
temporary_token: str | None = None
temporary_name: str | None = None
temporary_attendance_point_name: str | None = None
temporary_latitude: float | None = None
temporary_longitude: float | None = None
class RoleSelectionRequest(BaseModel):
selection_token: str
role: Role
class SwitchRoleRequest(BaseModel):
role: Role
class PersonnelBase(BaseModel):
phone: str = Field(min_length=5, max_length=20)
name: str = Field(min_length=1, max_length=64)
role: Role
attendance_point_names: list[str] = []
class PersonnelCreate(PersonnelBase):
original_phone: str | None = None
class PersonnelOut(PersonnelBase):
model_config = ConfigDict(from_attributes=True)
role_name: str
roles: list[Role] = Field(default_factory=list)
role_names: list[str] = Field(default_factory=list)
attendance_points: list[AttendancePointOut] = Field(default_factory=list)
is_temporary: bool = False
temporary_expires_at: datetime | None = None
temporary_expired: bool = False
class ProductBase(BaseModel):
attendance_point_name: str = Field(min_length=1, max_length=128)
project_no: str = Field(min_length=1, max_length=64)
product_name: str = Field(min_length=1, max_length=255)
profile_no: str | None = None
material_code: str | None = None
material_name: str | None = None
supplier: str | None = None
product_net_weight_kg: float | None = None
product_gross_weight_kg: float | None = None
scrap_loss_rate: float | None = None
waste_price_yuan_per_kg: float | None = None
device_no: str = ""
process_name: str = Field(min_length=1, max_length=128)
stamping_method: str | None = None
operator_count: float = 1
process_unit_price_yuan: float = Field(default=0, ge=0)
standard_beat: float = Field(default=0, ge=0)
standard_workload: float | None = None
class ProductCreate(ProductBase):
original_attendance_point_name: str | None = None
original_project_no: str | None = None
original_product_name: str | None = None
original_device_no: str | None = None
original_process_name: str | None = None
class ProductOut(ProductBase):
standard_workload: float
display_name: str
class MoldNameOut(BaseModel):
attendance_point_name: str = ""
name: str
process_name: str = ""
stamping_method: str | None = None
operator_count: float = 1
is_cleaning: bool = False
is_misc: bool = False
is_continuous_die: bool = False
is_multi_person: bool = False
display_name: str = ""
class DeviceQrCreate(BaseModel):
attendance_point_name: str = Field(min_length=1, max_length=128)
product_name: str = Field(min_length=1, max_length=255)
process_name: str = Field(min_length=1, max_length=128)
class DeviceQrBatchCreate(BaseModel):
items: list[DeviceQrCreate] = Field(min_length=1, max_length=500)
class DeviceQrBatchOut(BaseModel):
count: int
file_name: str
zip_url: str
class DeviceQrBatchTaskOut(BaseModel):
id: int
file_name: str
status: str
status_name: str
item_count: int
completed_count: int
failed_count: int = 0
progress_percent: float = 0
zip_url: str | None = None
error_message: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
created_at: datetime
updated_at: datetime
class EquipmentBase(BaseModel):
attendance_point_name: str = Field(min_length=1, max_length=128)
device_no: str = Field(min_length=1, max_length=64)
device_type: Literal["冲压设备", "清洗设备"] = "冲压设备"
remark: str | None = Field(default=None, max_length=500)
class EquipmentCreate(EquipmentBase):
original_attendance_point_name: str | None = None
original_device_no: str | None = None
class EquipmentOut(EquipmentBase):
created_at: datetime
updated_at: datetime
class WorkScheduleBase(BaseModel):
day_start: str = Field(pattern=r"^\d{1,2}:\d{2}$")
day_end: str = Field(pattern=r"^\d{1,2}:\d{2}$")
lunch_start: str = Field(pattern=r"^\d{1,2}:\d{2}$")
lunch_end: str = Field(pattern=r"^\d{1,2}:\d{2}$")
dinner_start: str = Field(pattern=r"^\d{1,2}:\d{2}$")
dinner_end: str = Field(pattern=r"^\d{1,2}:\d{2}$")
overtime_start: str = Field(pattern=r"^\d{1,2}:\d{2}$")
overtime_end: str = Field(pattern=r"^\d{1,2}:\d{2}$")
night_start: str = Field(pattern=r"^\d{1,2}:\d{2}$")
night_end: str = Field(pattern=r"^\d{1,2}:\d{2}$")
attendance_latitude: float | None = None
attendance_longitude: float | None = None
attendance_radius_meters: int | None = Field(default=500, ge=1)
auto_submit_hours: float = Field(default=15, ge=1, le=168)
class WorkScheduleUpdate(WorkScheduleBase):
pass
class WorkScheduleSettingsUpdate(WorkScheduleBase):
attendance_points: list[AttendancePointCreate] = Field(default_factory=list)
class WorkScheduleOut(WorkScheduleBase):
id: int
attendance_points: list[AttendancePointOut] = Field(default_factory=list)
updated_by: str | None = None
updated_at: datetime
class ImportResult(BaseModel):
imported: int
updated: int
skipped: int
errors: list[str]
class ClockState(BaseModel):
action: Literal["start", "switch", "switch_or_finish", "finish", "report", "refinish"]
action_text: str
attendance_point_name: str = ""
device_no: str
mold_name: str = ""
process_name: str = ""
stamping_method: str | None = None
display_name: str = ""
is_cleaning: bool = False
is_misc: bool = False
current_mold_name: str = ""
current_process_name: str = ""
session_id: int | None = None
start_at: datetime | None = None
end_at: datetime | None = None
devices: list[str] = []
auto_submit_hours: float = 15
class MoldLockConflictDetail(BaseModel):
code: str = "mold_occupied"
message: str
lock_id: int
attendance_point_name: str = ""
mold_name: str = ""
process_name: str = ""
mold_display_name: str = ""
occupied_phone: str = ""
occupied_name: str = ""
class ClockRequest(BaseModel):
attendance_point_name: str = ""
device_no: str = ""
mold_name: str = ""
process_name: str = ""
latitude: float | None = None
longitude: float | None = None
class ContinueWorkRequest(BaseModel):
session_id: int
class ClockSessionOut(BaseModel):
id: int
attendance_point_name: str = ""
employee_phone: str
start_at: datetime
end_at: datetime | None
status: str
devices: list[str]
class ProductOption(BaseModel):
attendance_point_name: str = ""
project_no: str
product_name: str
material_code: str | None = None
material_name: str | None = None
device_no: str
process_name: str | None = None
stamping_method: str | None = None
operator_count: float = 1
process_unit_price_yuan: float = 0
standard_beat: float
standard_workload: float
is_cleaning: bool = False
is_misc: bool = False
is_continuous_die: bool = False
is_multi_person: bool = False
display_name: str
class EquipmentOption(BaseModel):
attendance_point_name: str = ""
device_no: str
device_type: str = "冲压设备"
remark: str | None = None
display_name: str
class ReportDraftItem(BaseModel):
attendance_point_name: str = ""
device_no: str = ""
project_no: str = ""
product_name: str = ""
material_code: str | None = None
material_name: str | None = None
raw_material_batch_no: str | None = None
raw_material_batch_options: list[str] = []
process_name: str | None = None
stamping_method: str | None = None
operator_count: float = 1
process_unit_price_yuan: float = 0
changeover_count: float = 0
standard_beat: float = 0
standard_workload: float = 0
good_qty: float = 0
defect_qty: float = 0
scrap_qty: float = 0
is_misc: bool = False
is_continuous_die: bool = False
remark: str | None = None
class ReportDraftBlock(BaseModel):
attendance_point_name: str = ""
device_no: str
mold_name: str = ""
process_name: str = ""
stamping_method: str | None = None
display_name: str = ""
start_at: datetime
end_at: datetime
duration_minutes: float = 0
options: list[ProductOption]
equipment_options: list[EquipmentOption] = []
items: list[ReportDraftItem]
is_misc: bool = False
is_continuous_die: bool = False
class ReportDraftOut(BaseModel):
session_id: int
attendance_point_name: str = ""
employee_phone: str
employee_name: str
start_at: datetime
end_at: datetime
duration_minutes: float
device_blocks: list[ReportDraftBlock]
class ReportSubmitItem(BaseModel):
attendance_point_name: str = ""
device_no: str = ""
project_no: str
product_name: str
material_code: str | None = None
material_name: str | None = None
raw_material_batch_no: str | None = None
process_name: str | None = None
stamping_method: str | None = None
operator_count: float = 1
process_unit_price_yuan: float = 0
changeover_count: float = Field(default=0, ge=0)
standard_beat: float
standard_workload: float = 0
good_qty: float = Field(default=0, ge=0)
defect_qty: float = Field(default=0, ge=0)
scrap_qty: float = Field(default=0, ge=0)
started_at: datetime | None = None
is_misc: bool = False
is_continuous_die: bool = False
remark: str | None = None
class ReportSubmitBlock(BaseModel):
attendance_point_name: str = ""
device_no: str
process_name: str = ""
items: list[ReportSubmitItem]
class ReportSubmitRequest(BaseModel):
session_id: int
device_blocks: list[ReportSubmitBlock]
class CleaningReportSubmitItem(BaseModel):
attendance_point_name: str = ""
device_no: str = ""
device_nos: list[str] = Field(default_factory=list)
product_name: str = Field(min_length=1, max_length=255)
process_name: str = Field(min_length=1, max_length=128)
stamping_method: str | None = None
quantity: float = Field(gt=0)
class CleaningReportSubmitRequest(BaseModel):
items: list[CleaningReportSubmitItem]
latitude: float | None = None
longitude: float | None = None
class ReportMetrics(BaseModel):
effective_minutes: float
shift_distribution_text: str = ""
shift_day_minutes: float = 0
shift_overtime_minutes: float = 0
shift_night_minutes: float = 0
shift_other_minutes: float = 0
shift_meal_break_minutes: float = 0
total_good_qty: float
total_output_qty: float
actual_beat: float
standard_beat: float
expected_workload: float
pace_rate: float
workload_rate: float
class ReportItemOut(BaseModel):
id: int | None = None
attendance_point_name: str = ""
device_no: str
project_no: str
product_name: str
material_code: str | None = None
material_name: str | None = None
raw_material_batch_no: str | None = None
process_name: str | None = None
stamping_method: str | None = None
operator_count: float = 1
process_unit_price_yuan: float = 0
changeover_count: float = 0
standard_beat: float
standard_workload: float
good_qty: float
defect_qty: float
scrap_qty: float
allocated_minutes: float = 0
started_at: datetime | None = None
is_misc: bool = False
is_continuous_die: bool = False
is_multi_person: bool = False
remark: str | None = None
corrections: dict[str, Any] = Field(default_factory=dict)
class ReportDeviceSegmentOut(BaseModel):
id: int
attendance_point_name: str = ""
device_no: str
mold_name: str = ""
process_name: str = ""
stamping_method: str | None = None
display_name: str = ""
scanned_at: datetime
released_at: datetime | None = None
sort_order: int = 0
corrections: dict[str, Any] = Field(default_factory=dict)
class ReportOut(BaseModel):
id: int
attendance_point_name: str = ""
session_id: int
employee_phone: str
employee_name: str
report_date: date
start_at: datetime
end_at: datetime
duration_minutes: float
break_minutes: float
status: ReportStatus
status_name: str
reviewer_phone: str | None = None
reviewer_name: str | None = None
reviewed_at: datetime | None = None
reject_reason: str | None = None
review_remark: str | None = None
submitted_at: datetime
is_system_auto_submitted: bool = False
auto_submit_reason: str | None = None
is_multi_person_assistant: bool = False
multi_person_source_report_id: int | None = None
is_voided: bool = False
voided_at: datetime | None = None
voided_by: str | None = None
voided_by_name: str | None = None
unvoid_deadline_at: datetime | None = None
can_void: bool = False
can_unvoid: bool = False
can_edit: bool = False
is_modified: bool = False
device_segments: list[ReportDeviceSegmentOut] = Field(default_factory=list)
items: list[ReportItemOut]
metrics: ReportMetrics
result_text: str
corrections: dict[str, Any] = Field(default_factory=dict)
class ReportItemCorrection(BaseModel):
id: int
device_no: str | None = None
project_no: str | None = None
product_name: str | None = None
process_name: str | None = None
raw_material_batch_no: str | None = None
changeover_count: float | None = Field(default=None, ge=0)
good_qty: float | None = Field(default=None, ge=0)
defect_qty: float | None = Field(default=None, ge=0)
scrap_qty: float | None = Field(default=None, ge=0)
remark: str | None = None
class ReportDeviceCorrection(BaseModel):
id: int
scanned_at: datetime | None = None
class ApproveReportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
start_at: datetime | None = None
end_at: datetime | None = None
device_corrections: list[ReportDeviceCorrection] = Field(default_factory=list)
item_corrections: list[ReportItemCorrection] = []
review_remark: str | None = Field(default=None, max_length=500)
remark: str | None = None
class RejectReportRequest(BaseModel):
reason: str = "需重新填报"
class DeviceQrOut(BaseModel):
attendance_point_name: str = ""
device_no: str
process_name: str = ""
stamping_method: str | None = None
is_cleaning: bool = False
is_misc: bool = False
is_continuous_die: bool = False
mold_name: str = ""
display_name: str = ""
scene: str
page: str
qr_url: str | None = None
class DashboardRow(BaseModel):
id: str
attendance_point_name: str = ""
report_date: date
employee_phone: str
employee_name: str
project_no: str
product_name: str
process_name: str | None = None
report_count: int
effective_minutes: float
shift_distribution_text: str = ""
shift_day_minutes: float = 0
shift_overtime_minutes: float = 0
shift_night_minutes: float = 0
shift_other_minutes: float = 0
total_good_qty: float
total_defect_qty: float
total_output_qty: float
actual_beat: float
standard_beat: float
expected_workload: float
pace_rate: float
workload_rate: float
pace_text: str
workload_text: str
profile_no: str | None = None
material_code: str | None = None
material_name: str | None = None
raw_material_batch_no: str | None = None
device_no: str | None = None
stamping_method: str | None = None
operator_count: float | None = None
process_unit_price_yuan: float = 0
changeover_count: float = 0
reference_wage: float = 0
worker_type: str = "正式工"
is_multi_person: bool = False
is_multi_person_assistant: bool = False
is_misc: bool = False
is_continuous_die: bool = False
is_system_auto_submitted: bool = False
is_voided: bool = False
review_remark: str | None = None
correction_pairs: list[dict[str, Any]] = Field(default_factory=list)
class UsageStatsRow(BaseModel):
id: str
category: str
attendance_point_name: str = ""
name: str
object_type: str
metric_kind: str
value: float = 0
report_count: int = 0
product_name: str = ""
process_name: str = ""
stamping_method: str = ""
tags: list[str] = Field(default_factory=list)
class UsageStatsDailyRow(BaseModel):
report_date: date
value: float = 0
report_count: int = 0
class UsageStatsReportRow(BaseModel):
report_id: int
report_date: date
employee_phone: str = ""
employee_name: str = ""
display_name: str = ""
value: float = 0
report_count: int = 1
class UsageStatsDetailOut(BaseModel):
id: str = ""
category: str
attendance_point_name: str = ""
name: str = ""
object_type: str = ""
metric_kind: str = ""
value: float = 0
report_count: int = 0
product_name: str = ""
process_name: str = ""
stamping_method: str = ""
tags: list[str] = Field(default_factory=list)
daily_rows: list[UsageStatsDailyRow] = Field(default_factory=list)
report_rows: list[UsageStatsReportRow] = Field(default_factory=list)
class NoticeCreate(BaseModel):
title: str = Field(min_length=1, max_length=120)
content: str = Field(min_length=1, max_length=1000)
attendance_point_names: list[str] = []
sort_order: int = 0
is_active: bool = True
class NoticeUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=120)
content: str | None = Field(default=None, min_length=1, max_length=1000)
attendance_point_names: list[str] | None = None
sort_order: int | None = None
is_active: bool | None = None
class NoticeOut(BaseModel):
id: int
title: str
content: str
attendance_point_names: list[str] = []
sort_order: int
is_active: bool
created_by: str
creator_name: str
created_at: datetime
updated_at: datetime
class ReconciliationMonthCell(BaseModel):
month: int
reported_good_qty: float = 0
reconciled_good_qty: float = 0
return_qty: float = 0
class ReconciliationRow(BaseModel):
attendance_point_name: str = ""
product_name: str
months: list[ReconciliationMonthCell]
class ReconciliationLedgerOut(BaseModel):
year: int
current_year: int
current_month: int
rows: list[ReconciliationRow]
class ReconciliationYearsOut(BaseModel):
years: list[int]
current_year: int
class MonitorLockOut(BaseModel):
id: int
attendance_point_name: str = ""
mold_name: str
process_name: str = ""
stamping_method: str | None = None
mold_display_name: str
occupied_phone: str
occupied_name: str
occupied_start_at: datetime
occupied_minutes: float
class MoldLockFeedbackOut(BaseModel):
id: int
attendance_point_name: str = ""
mold_name: str
process_name: str = ""
stamping_method: str | None = None
mold_display_name: str
lock_id: int
reporter_phone: str
reporter_name: str
occupied_phone: str
occupied_name: str
feedback_at: datetime
read_at: datetime | None = None
handled_at: datetime | None = None
status: str
status_name: str
can_release: bool = True
class MoldLockFeedbackCreate(BaseModel):
lock_id: int
class ReconciliationEntryUpdate(BaseModel):
attendance_point_name: str = Field(min_length=1, max_length=128)
year: int = Field(ge=2000, le=2100)
month: int = Field(ge=1, le=12)
product_name: str = Field(min_length=1, max_length=255)
reconciled_good_qty: float | None = Field(default=None, ge=0)
return_qty: float | None = Field(default=None, ge=0)
PersonnelOut.model_rebuild()
TokenResponse.model_rebuild()
LoginSelectionResponse.model_rebuild()
+57
View File
@@ -0,0 +1,57 @@
import base64
from datetime import timedelta
import hashlib
import hmac
import json
import time
from typing import Any
from app.config import settings
from app.timezone import now
def _b64encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64decode(raw: str) -> bytes:
padding = "=" * (-len(raw) % 4)
return base64.urlsafe_b64decode(raw + padding)
def create_token(payload: dict[str, Any]) -> str:
expires_at = now() + timedelta(minutes=settings.token_expire_minutes)
body = {
**payload,
"exp": int(expires_at.timestamp()),
"iat": int(time.time()),
}
encoded_body = _b64encode(json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode())
signature = hmac.new(
settings.jwt_secret.encode(),
encoded_body.encode(),
hashlib.sha256,
).digest()
return f"{encoded_body}.{_b64encode(signature)}"
def verify_token(token: str) -> dict[str, Any]:
try:
encoded_body, encoded_signature = token.split(".", 1)
except ValueError as exc:
raise ValueError("invalid token") from exc
expected = hmac.new(
settings.jwt_secret.encode(),
encoded_body.encode(),
hashlib.sha256,
).digest()
actual = _b64decode(encoded_signature)
if not hmac.compare_digest(expected, actual):
raise ValueError("invalid token")
payload = json.loads(_b64decode(encoded_body))
if payload.get("exp", 0) < int(time.time()):
raise ValueError("token expired")
return payload
+2
View File
@@ -0,0 +1,2 @@
"""Business services."""
+132
View File
@@ -0,0 +1,132 @@
from math import asin, cos, radians, sin, sqrt
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models import AttendancePoint, PersonAttendancePoint, Personnel, Role
DEFAULT_ATTENDANCE_POINT_NAME = "宁波嘉恒智能科技有限公司"
EARTH_RADIUS_METERS = 6371000
def clean_point_name(value: str | None) -> str:
return str(value or "").strip()
def ensure_default_attendance_point(db: Session) -> AttendancePoint:
point = db.get(AttendancePoint, DEFAULT_ATTENDANCE_POINT_NAME)
if point is not None:
return point
point = AttendancePoint(
name=DEFAULT_ATTENDANCE_POINT_NAME,
radius_meters=500,
is_active=True,
)
db.add(point)
db.flush()
return point
def attendance_point_names(db: Session) -> list[str]:
names = list(db.scalars(select(AttendancePoint.name).where(AttendancePoint.is_active.is_(True)).order_by(AttendancePoint.name)))
if not names:
point = ensure_default_attendance_point(db)
db.commit()
names = [point.name]
return names
def accessible_point_names(db: Session, user: Personnel) -> list[str]:
if user.role == Role.manager:
return attendance_point_names(db)
rows = list(
db.scalars(
select(PersonAttendancePoint.attendance_point_name)
.join(AttendancePoint, AttendancePoint.name == PersonAttendancePoint.attendance_point_name)
.where(
PersonAttendancePoint.phone == user.phone,
AttendancePoint.is_active.is_(True),
)
.order_by(PersonAttendancePoint.attendance_point_name)
)
)
return rows
def accessible_points(db: Session, user: Personnel) -> list[AttendancePoint]:
names = accessible_point_names(db, user)
return list(
db.scalars(
select(AttendancePoint)
.where(AttendancePoint.name.in_(names), AttendancePoint.is_active.is_(True))
.order_by(AttendancePoint.name)
)
)
def require_attendance_point_access(db: Session, user: Personnel, point_name: str | None) -> str:
name = clean_point_name(point_name) or DEFAULT_ATTENDANCE_POINT_NAME
if db.get(AttendancePoint, name) is None:
raise ValueError(f"没有该考勤点:{name}")
if user.role != Role.manager and name not in accessible_point_names(db, user):
raise PermissionError(f"无该考勤点权限:{name}")
return name
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 validate_attendance_point_location(
db: Session,
point_name: str,
latitude: float | None,
longitude: float | None,
*,
action_name: str = "扫码报工",
) -> None:
if latitude is None or longitude is None:
raise ValueError(f"{action_name}需要校验当前位置")
point = db.get(AttendancePoint, clean_point_name(point_name))
if point is None or not point.is_active:
raise ValueError(f"没有该考勤点:{point_name}")
if point.latitude is None or point.longitude is None:
raise ValueError(f"考勤点【{point.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 PermissionError(
f"当前位置距离考勤点【{point.name}】约{round(distance)}米,超过{radius}米范围,不能{action_name}"
)
def person_with_access_points(db: Session, phone: str) -> Personnel | None:
return db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone == phone)
)
def set_person_attendance_points(db: Session, phone: str, point_names: list[str]) -> None:
cleaned = [clean_point_name(name) for name in point_names if clean_point_name(name)]
if not cleaned:
cleaned = [ensure_default_attendance_point(db).name]
valid_names = set(attendance_point_names(db))
missing = [name for name in cleaned if name not in valid_names]
if missing:
raise ValueError(f"没有该考勤点:{'、'.join(missing)}")
db.query(PersonAttendancePoint).filter(PersonAttendancePoint.phone == phone).delete()
db.flush()
for name in dict.fromkeys(cleaned):
db.add(PersonAttendancePoint(phone=phone, attendance_point_name=name))
+230
View File
@@ -0,0 +1,230 @@
import asyncio
import logging
from datetime import datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.database import SessionLocal
from app.models import (
Product,
ProductionReport,
ProductionReportItem,
ReportStatus,
SessionStatus,
WorkSession,
WorkSessionDevice,
)
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.work_schedule import DEFAULT_AUTO_SUBMIT_HOURS, get_work_schedule, get_work_schedule_config
from app.timezone import now
AUTO_SUBMIT_INTERVAL_SECONDS = 300
AUTO_SUBMIT_REASON = "超时-系统自动提交"
logger = logging.getLogger(__name__)
def _ordered_session_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 _mold_key(product_name: str, process_name: str | None) -> tuple[str, str]:
return (str(product_name or "").strip(), str(process_name or "").strip())
def _draft_mold_ranges(session: WorkSession) -> list[tuple[str, str, datetime, datetime]]:
if session.end_at is None:
return []
ordered_devices = _ordered_session_devices(session)
ranges_by_mold: dict[tuple[str, str], dict[str, datetime]] = {}
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,
}
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)
return [
(mold_name, process_name, value["start_at"], value["end_at"])
for (mold_name, process_name), value in ranges_by_mold.items()
]
def _product_for_mold(db: Session, session: WorkSession, mold_name: str, process_name: str) -> Product | None:
return db.scalar(
select(Product)
.where(
Product.attendance_point_name == session.attendance_point_name,
Product.product_name == mold_name,
Product.process_name == process_name,
Product.device_no == "",
)
.order_by(Product.project_no.asc())
)
def _fallback_mold_ranges(session: WorkSession) -> list[tuple[str, str, datetime, datetime]]:
if session.end_at is None:
return []
ordered_devices = _ordered_session_devices(session)
if not ordered_devices:
return []
first = ordered_devices[0]
return [(first.device_no, first.process_name or "", session.start_at, session.end_at)]
def _build_auto_report_items(db: Session, session: WorkSession) -> list[ProductionReportItem]:
report_items: list[ProductionReportItem] = []
mold_ranges = _draft_mold_ranges(session) or _fallback_mold_ranges(session)
for mold_name, process_name, block_start_at, _block_end_at in mold_ranges:
product = _product_for_mold(db, session, mold_name, process_name)
item_is_misc = is_misc_product(product)
report_items.append(
ProductionReportItem(
attendance_point_name=session.attendance_point_name,
device_no=mold_name if item_is_misc else "",
project_no=product.project_no if product else "",
product_name=product.product_name if product else mold_name,
material_code=product.material_code if product else None,
material_name=product.material_name if product else None,
raw_material_batch_no=None,
process_name=product.process_name if product else process_name,
stamping_method=product.stamping_method if product else None,
operator_count=product.operator_count if product else 1,
process_unit_price_yuan=product.process_unit_price_yuan if product else 0,
standard_beat=0 if item_is_misc else (product.standard_beat if product else 0),
standard_workload=0,
good_qty=0,
defect_qty=0,
scrap_qty=0,
started_at=block_start_at,
remark=None,
)
)
return report_items
def auto_submit_session(db: Session, session: WorkSession, submitted_at: datetime | None = None) -> ProductionReport | None:
existing_report = db.scalar(select(ProductionReport).where(ProductionReport.session_id == session.id))
if existing_report is not None:
session.status = SessionStatus.submitted
return None
timestamp = submitted_at or now()
session.end_at = timestamp
session.status = SessionStatus.submitted
release_session_locks(session, None, AUTO_SUBMIT_REASON, timestamp)
items = _build_auto_report_items(db, session)
schedule = get_work_schedule_config(db, session.attendance_point_name)
metrics = calculate_report_metrics(
session.start_at,
session.end_at,
items,
devices=session.devices,
schedule=schedule,
)
report = ProductionReport(
attendance_point_name=session.attendance_point_name,
session_id=session.id,
employee_phone=session.employee_phone,
report_date=timestamp.date(),
start_at=session.start_at,
end_at=session.end_at,
duration_minutes=metrics["duration_minutes"] if items else minutes_between(session.start_at, session.end_at),
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=timestamp,
is_system_auto_submitted=True,
auto_submit_reason=AUTO_SUBMIT_REASON,
items=items,
)
db.add(report)
return report
def auto_submit_overdue_sessions(
db: Session,
current_time: datetime | None = None,
employee_phone: str | None = None,
limit: int = 100,
) -> int:
timestamp = current_time or now()
try:
auto_submit_hours = float(get_work_schedule(db).auto_submit_hours or DEFAULT_AUTO_SUBMIT_HOURS)
except Exception:
db.rollback()
auto_submit_hours = DEFAULT_AUTO_SUBMIT_HOURS
cutoff = timestamp - timedelta(hours=auto_submit_hours)
query = (
select(WorkSession)
.options(selectinload(WorkSession.devices))
.where(
WorkSession.status == SessionStatus.active,
WorkSession.start_at <= cutoff,
)
.order_by(WorkSession.start_at.asc())
)
if employee_phone:
query = query.where(WorkSession.employee_phone == employee_phone)
if db.get_bind().dialect.name in {"mysql", "postgresql"}:
query = query.with_for_update(skip_locked=True)
query = query.limit(limit)
sessions = db.scalars(query).all()
submitted_count = 0
for session in sessions:
if auto_submit_session(db, session, timestamp) is not None:
submitted_count += 1
if sessions:
db.commit()
return submitted_count
async def auto_submit_overdue_loop(interval_seconds: int = AUTO_SUBMIT_INTERVAL_SECONDS) -> None:
while True:
try:
submitted_count = await asyncio.to_thread(_run_auto_submit_once)
if submitted_count:
logger.info("auto submitted overdue work sessions: %s", submitted_count)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("auto submit overdue work sessions failed")
await asyncio.sleep(interval_seconds)
def _run_auto_submit_once() -> int:
with SessionLocal() as db:
return auto_submit_overdue_sessions(db)
+22
View File
@@ -0,0 +1,22 @@
CLEANING_STAMPING_METHOD = "清洗"
LEGACY_CLEANING_PROCESS_NAME = "清洗"
def clean_text(value: str | None) -> str:
return str(value or "").strip()
def is_cleaning_stamping_method(value: str | None) -> bool:
return clean_text(value) == CLEANING_STAMPING_METHOD
def is_legacy_cleaning_process(value: str | None) -> bool:
return clean_text(value) == LEGACY_CLEANING_PROCESS_NAME
def is_cleaning_product(product) -> bool:
return bool(product) and is_cleaning_stamping_method(getattr(product, "stamping_method", None))
def is_cleaning_item(item) -> bool:
return is_cleaning_stamping_method(getattr(item, "stamping_method", None))
+63
View File
@@ -0,0 +1,63 @@
from decimal import Decimal
from math import ceil
from typing import Any
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
ROLE_NAMES = {
"worker": "冲压工人",
"admin": "管理员",
"manager": "经理",
}
REPORT_STATUS_NAMES = {
"pending": "待审核",
"approved": "已通过",
"rejected": "已驳回",
}
def normalize_device_no(value: str | None) -> str:
return str(value or "").strip()
def as_float(value: Any) -> float:
if isinstance(value, Decimal):
return float(value)
if value is None:
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def round1(value: Any) -> float:
return round(as_float(value), 1)
def round2(value: Any) -> float:
return round(as_float(value), 2)
def round3(value: Any) -> float:
return round(as_float(value), 3)
def paginate(db: Session, query: Select, page: int, page_size: int) -> dict[str, Any]:
safe_page = max(1, int(page or 1))
safe_page_size = min(100, max(1, int(page_size or 10)))
count_query = select(func.count()).select_from(query.order_by(None).subquery())
total = db.scalar(count_query) or 0
total_pages = max(1, ceil(total / safe_page_size))
safe_page = min(safe_page, total_pages)
rows = db.scalars(query.limit(safe_page_size).offset((safe_page - 1) * safe_page_size)).all()
return {
"page": safe_page,
"page_size": safe_page_size,
"total": total,
"total_pages": total_pages,
"rows": rows,
}
+17
View File
@@ -0,0 +1,17 @@
CONTINUOUS_DIE_STAMPING_METHOD = "连续模"
def clean_text(value: str | None) -> str:
return str(value or "").strip()
def is_continuous_die_stamping_method(value: str | None) -> bool:
return clean_text(value) == CONTINUOUS_DIE_STAMPING_METHOD
def is_continuous_die_product(product) -> bool:
return bool(product) and is_continuous_die_stamping_method(getattr(product, "stamping_method", None))
def is_continuous_die_item(item) -> bool:
return is_continuous_die_stamping_method(getattr(item, "stamping_method", None))
+25
View File
@@ -0,0 +1,25 @@
def _clean(value: str | None) -> str:
return str(value or "").strip()
def mold_process_display_name(
product_name: str | None,
process_name: str | None,
stamping_method: str | None = None,
) -> str:
parts = [_clean(product_name), _clean(process_name) or "-"]
stamping = _clean(stamping_method)
if stamping:
parts.append(stamping)
return " / ".join(part for part in parts if part)
def point_mold_display_name(
point_name: str | None,
product_name: str | None,
process_name: str | None,
stamping_method: str | None = None,
) -> str:
point = _clean(point_name)
mold_text = mold_process_display_name(product_name, process_name, stamping_method)
return f"{point} / {mold_text}" if point else mold_text
+219
View File
@@ -0,0 +1,219 @@
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import PatternFill
from sqlalchemy import and_, not_, or_, select
from sqlalchemy.orm import Session
from app.models import PersonAttendancePoint, PersonRole, Personnel, Product, Role
from app.services.attendance_points import accessible_point_names
from app.services.common import ROLE_NAMES, as_float
from app.services.misc_work import MISC_WORK_PROCESS_NAME, MISC_WORK_PRODUCT_NAME, MISC_WORK_STAMPING_METHOD
from app.services.process_names import export_process_name
def _workbook_bytes(workbook: Workbook) -> bytes:
output = BytesIO()
workbook.save(output)
return output.getvalue()
def _optional_float(value):
return "" if value is None else as_float(value)
def export_products(db: Session, user=None) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "产品清单"
sheet.append([
"考勤点",
"项目号",
"型材号",
"产品名称",
"物料编码",
"物料名称",
"供应商",
"工序",
"冲压方式",
"操作人数",
"工序单价",
"不包含物料转运节拍",
"标准节拍",
"产品净重(kg)",
"产品毛重(kg)",
"允许报废率",
"废料单价(元/kg)",
])
query = select(Product).where(
Product.device_no == "",
or_(Product.stamping_method.is_(None), Product.stamping_method != MISC_WORK_STAMPING_METHOD),
not_(and_(Product.product_name == MISC_WORK_PRODUCT_NAME, Product.process_name == MISC_WORK_PROCESS_NAME)),
)
if user is not None:
query = query.where(Product.attendance_point_name.in_(accessible_point_names(db, user)))
products = db.scalars(query.order_by(Product.attendance_point_name, Product.project_no, Product.product_name, Product.process_name)).all()
for product in products:
sheet.append([
product.attendance_point_name,
product.project_no,
product.profile_no,
product.product_name,
product.material_code,
product.material_name,
product.supplier,
export_process_name(product.process_name),
product.stamping_method,
as_float(product.operator_count),
as_float(product.process_unit_price_yuan),
"",
as_float(product.standard_beat),
_optional_float(product.product_net_weight_kg),
_optional_float(product.product_gross_weight_kg),
_optional_float(product.scrap_loss_rate),
_optional_float(product.waste_price_yuan_per_kg),
])
return _workbook_bytes(workbook)
def export_people(db: Session, user=None) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "人员清单"
sheet.append(["考勤点", "电话号", "姓名", "角色"])
query = select(PersonRole).join(PersonRole.person).order_by(Personnel.name, PersonRole.role)
if user is not None and user.role == Role.admin:
point_names = accessible_point_names(db, user)
query = query.where(
PersonRole.role == Role.worker,
Personnel.attendance_points.any(PersonAttendancePoint.attendance_point_name.in_(point_names)),
)
people = db.scalars(query).all()
for item in people:
point_names = sorted({point.attendance_point_name for point in item.person.attendance_points})
sheet.append([
"、".join(point_names),
item.phone,
item.person.name,
ROLE_NAMES.get(str(item.role), str(item.role)),
])
return _workbook_bytes(workbook)
def export_dashboard_rows(rows: list) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "报工看板"
warn_fill = PatternFill(fill_type="solid", fgColor="FFFF6666")
void_fill = PatternFill(fill_type="solid", fgColor="FFD9D9D9")
sheet.append([
"考勤点",
"报工日期",
"员工姓名",
"员工电话",
"工种",
"项目号",
"产品名称",
"工序",
"有效工时(时)",
"班种分布",
"成品数量",
"不良数量",
"标准工作量",
"实际节拍",
"标准节拍",
"型材号",
"物料编码",
"物料名称",
"材料库存批次号",
"使用设备",
"冲压方式",
"换料次数",
"操作人数",
"工序单价",
"参考工资",
"报工标记",
"校正项",
"备注",
])
wage_summary: dict[tuple[str, str, str], dict[str, float | str]] = {}
for row_index, row in enumerate(rows, start=2):
report_flags = []
if getattr(row, "is_voided", False):
report_flags.append("作废")
if getattr(row, "is_multi_person", False):
report_flags.append("多人协作")
if getattr(row, "is_continuous_die", False):
report_flags.append("连续模")
correction_text = ";".join(
f"{item.get('label', '')} {item.get('old_value', '-')}→{item.get('new_value', '-')}"
for item in getattr(row, "correction_pairs", [])
)
sheet.append([
row.attendance_point_name,
row.report_date.isoformat(),
row.employee_name,
row.employee_phone,
row.worker_type,
row.project_no,
row.product_name,
row.process_name,
round(as_float(row.effective_minutes) / 60, 2),
row.shift_distribution_text,
row.total_good_qty,
row.total_defect_qty,
row.expected_workload,
row.actual_beat,
row.standard_beat,
row.profile_no,
row.material_code,
row.material_name,
row.raw_material_batch_no,
row.device_no,
row.stamping_method,
row.changeover_count,
row.operator_count,
row.process_unit_price_yuan,
row.reference_wage,
"、".join(report_flags),
correction_text,
getattr(row, "review_remark", None),
])
summary_key = (row.employee_phone, row.employee_name, row.worker_type)
summary = wage_summary.setdefault(
summary_key,
{
"employee_phone": row.employee_phone,
"employee_name": row.employee_name,
"worker_type": row.worker_type,
"total_good_qty": 0.0,
"reference_wage": 0.0,
"detail_count": 0.0,
},
)
summary["total_good_qty"] = as_float(summary["total_good_qty"]) + as_float(row.total_good_qty)
summary["reference_wage"] = as_float(summary["reference_wage"]) + as_float(row.reference_wage)
summary["detail_count"] = as_float(summary["detail_count"]) + 1
actual_beat = as_float(row.actual_beat)
standard_beat = as_float(row.standard_beat)
total_good_qty = as_float(row.total_good_qty)
expected_workload = as_float(row.expected_workload)
if standard_beat > 0 and (actual_beat > standard_beat or actual_beat < standard_beat * 0.7):
sheet.cell(row=row_index, column=14).fill = warn_fill
if expected_workload > 0 and (total_good_qty < expected_workload or total_good_qty > expected_workload * 1.3):
sheet.cell(row=row_index, column=11).fill = warn_fill
if getattr(row, "is_voided", False):
for cell in sheet[row_index]:
cell.fill = void_fill
summary_sheet = workbook.create_sheet("工资汇总")
summary_sheet.append(["员工姓名", "员工电话", "工种", "明细行数", "成品数量合计", "参考工资合计"])
for summary in sorted(wage_summary.values(), key=lambda item: (str(item["employee_name"]), str(item["employee_phone"]))):
summary_sheet.append([
summary["employee_name"],
summary["employee_phone"],
summary["worker_type"],
int(as_float(summary["detail_count"])),
round(as_float(summary["total_good_qty"]), 2),
round(as_float(summary["reference_wage"]), 2),
])
return _workbook_bytes(workbook)
+406
View File
@@ -0,0 +1,406 @@
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from typing import Any
from openpyxl import load_workbook
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import PersonRole, Personnel, Product, Role
from app.services.attendance_points import (
DEFAULT_ATTENDANCE_POINT_NAME,
accessible_point_names,
attendance_point_names,
set_person_attendance_points,
)
from app.services.cleaning import LEGACY_CLEANING_PROCESS_NAME, is_cleaning_stamping_method
from app.services.misc_work import is_misc_stamping_method
from app.services.process_names import normalize_numeric_process_name
@dataclass
class ImportStats:
imported: int = 0
updated: int = 0
skipped: int = 0
errors: list[str] | None = None
def as_dict(self) -> dict[str, Any]:
return {
"imported": self.imported,
"updated": self.updated,
"skipped": self.skipped,
"errors": self.errors or [],
}
PRODUCT_HEADERS = {
"考勤点": "attendance_point_name",
"项目号": "project_no",
"型材号": "profile_no",
"产品名称": "product_name",
"物料编码": "material_code",
"物料名称": "material_name",
"供应商": "supplier",
"产品净重(kg)": "product_net_weight_kg",
"产品毛重(kg)": "product_gross_weight_kg",
"允许报废率": "scrap_loss_rate",
"废料单价(元/kg)": "waste_price_yuan_per_kg",
"工序": "process_name",
"冲压方式": "stamping_method",
"操作人数": "operator_count",
"工序单价": "process_unit_price_yuan",
"标准节拍": "standard_beat",
}
PRODUCT_AUX_FIELDS = (
("product_net_weight_kg", "产品净重(kg)"),
("product_gross_weight_kg", "产品毛重(kg)"),
("scrap_loss_rate", "允许报废率"),
("waste_price_yuan_per_kg", "废料单价(元/kg)"),
)
class ProductImportValidationError(ValueError):
pass
PERSON_HEADERS = {
"考勤点": "attendance_point_names",
"电话号": "phone",
"手机号": "phone",
"姓名": "name",
"角色": "role",
}
ROLE_MAP = {
"冲压工人": Role.worker,
"员工": Role.worker,
"worker": Role.worker,
"管理员": Role.admin,
"admin": Role.admin,
"经理": Role.manager,
"manager": Role.manager,
}
def _text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _split_names(value: Any) -> list[str]:
text = _text(value)
if not text:
return []
for sep in [",", ",", ";", ";", "/", "|"]:
text = text.replace(sep, "、")
return [item.strip() for item in text.split("、") if item.strip()]
def _number(value: Any, default: float = 0) -> float:
try:
if value is None or value == "":
return default
return float(value)
except (TypeError, ValueError):
return default
def _optional_number(value: Any) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _normalized_aux_value(value: Any) -> str:
text = _text(value)
if text == "":
return ""
try:
return str(Decimal(text).normalize())
except InvalidOperation:
return text
def _rows_from_excel(content: bytes) -> tuple[list[str], list[dict[str, Any]]]:
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return [], []
headers = [_text(value) for value in rows[0]]
mapped_rows = []
for values in rows[1:]:
mapped_rows.append({headers[index]: value for index, value in enumerate(values) if index < len(headers)})
return headers, mapped_rows
def _validate_product_aux_consistency(db: Session, rows: list[dict[str, Any]]) -> None:
aux_headers = {label: field for field, label in PRODUCT_AUX_FIELDS}
if not any(header in row for row in rows for header in aux_headers):
return
grouped: dict[str, dict[str, tuple[int, str, str]]] = {}
errors: list[str] = []
for index, raw in enumerate(rows, start=2):
point_name = _text(raw.get("考勤点")) or DEFAULT_ATTENDANCE_POINT_NAME
product_name = _text(raw.get("产品名称"))
process_name = _text(raw.get("工序"))
if not product_name:
continue
values = tuple(
_normalized_aux_value(raw.get(label))
for _, label in PRODUCT_AUX_FIELDS
)
group_key = f"{point_name}||{product_name}"
first_by_product = grouped.setdefault(group_key, {})
previous = first_by_product.get("values")
current_text = ",".join(
f"{label}={value or '空'}"
for (_, label), value in zip(PRODUCT_AUX_FIELDS, values)
)
if previous is None:
first_by_product["values"] = (index, process_name, current_text)
first_by_product["raw_values"] = (-1, "", "|".join(values))
continue
previous_raw = first_by_product["raw_values"][2]
if previous_raw != "|".join(values):
first_row, first_process, first_text = previous
errors.append(
f"考勤点【{point_name}】产品【{product_name}】不同工序的ERP辅助字段不一致:"
f"第{first_row}行({first_process or '未填写工序'})为【{first_text}】,"
f"第{index}行({process_name or '未填写工序'})为【{current_text}】"
)
if errors:
detail = ";".join(errors[:10])
if len(errors) > 10:
detail += f";另有{len(errors) - 10}处不一致"
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
def _validate_point_names(db: Session, names: list[str], user=None, *, action: str) -> None:
known = set(attendance_point_names(db))
missing = [name for name in names if name not in known]
if missing:
raise ProductImportValidationError(f"没有该考勤点:{'、'.join(sorted(set(missing)))}。本次导入已取消,数据库未写入。")
if user is not None and user.role != Role.manager:
allowed = set(accessible_point_names(db, user))
denied = [name for name in names if name not in allowed]
if denied:
raise ProductImportValidationError(f"无该考勤点{action}权限:{'、'.join(sorted(set(denied)))}。本次导入已取消,数据库未写入。")
def _validate_product_process_names(rows: list[dict[str, Any]]) -> None:
errors: list[str] = []
for index, raw in enumerate(rows, start=2):
if not any(_text(value) for value in raw.values()):
continue
raw_process_name = _text(raw.get("工序"))
stamping_method = _text(raw.get("冲压方式"))
is_misc = is_misc_stamping_method(stamping_method)
if is_misc:
errors.append(f"第{index}行处理杂活由系统自动生成,不能通过产品清单导入")
continue
if raw_process_name == LEGACY_CLEANING_PROCESS_NAME:
errors.append(f"第{index}行工序不能填写清洗,请把清洗填写在冲压方式中")
continue
try:
normalize_numeric_process_name(raw_process_name)
except ValueError:
errors.append(f"第{index}行工序必须是数字")
continue
if errors:
detail = ";".join(errors[:10])
if len(errors) > 10:
detail += f";另有{len(errors) - 10}处错误"
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
def import_products(db: Session, content: bytes, user=None) -> ImportStats:
_, rows = _rows_from_excel(content)
_validate_product_aux_consistency(db, rows)
_validate_product_process_names(rows)
point_values = [
_text(raw.get("考勤点")) or DEFAULT_ATTENDANCE_POINT_NAME
for raw in rows
if any(_text(value) for value in raw.values())
]
_validate_point_names(db, point_values, user, action="产品导入")
stats = ImportStats(errors=[])
last_project_no = ""
seen: dict[tuple[str, str, str, str, str], Product] = {}
aux_by_product: dict[tuple[str, str], tuple[float | None, float | None, float | None, float | None]] = {}
for index, raw in enumerate(rows, start=2):
data = {field: raw[header] for header, field in PRODUCT_HEADERS.items() if header in raw}
attendance_point_name = _text(data.get("attendance_point_name")) or DEFAULT_ATTENDANCE_POINT_NAME
project_no = _text(data.get("project_no")) or last_project_no
product_name = _text(data.get("product_name"))
device_no = ""
raw_process_name = _text(data.get("process_name"))
stamping_method = _text(data.get("stamping_method"))
is_cleaning = is_cleaning_stamping_method(stamping_method)
is_misc = is_misc_stamping_method(stamping_method)
standard_beat = _number(data.get("standard_beat"))
if project_no:
last_project_no = project_no
if is_misc:
stats.skipped += 1
stats.errors.append(f"第{index}行处理杂活由系统自动生成,不能通过产品清单导入")
continue
if raw_process_name == LEGACY_CLEANING_PROCESS_NAME:
stats.skipped += 1
stats.errors.append(f"第{index}行工序不能填写清洗,请把清洗填写在冲压方式中,工序只能填写数字")
continue
try:
process_name = normalize_numeric_process_name(raw_process_name)
except ValueError:
stats.skipped += 1
stats.errors.append(f"第{index}行工序必须是数字")
continue
if not project_no or not product_name or not process_name or (not is_cleaning and not is_misc and standard_beat <= 0):
stats.skipped += 1
stats.errors.append(f"第{index}行缺少项目号、产品名称、工序或标准节拍(冲压方式为清洗时标准节拍可空)")
continue
key = (attendance_point_name, project_no, product_name, device_no, process_name)
if key in seen:
stats.skipped += 1
stats.errors.append(f"第{index}行重复产品/工序:{attendance_point_name} / {project_no} / {product_name} / {process_name}")
continue
product = seen.get(key)
if product is None:
product = db.get(
Product,
{
"attendance_point_name": attendance_point_name,
"project_no": project_no,
"product_name": product_name,
"device_no": device_no,
"process_name": process_name,
},
)
if product is None:
product = Product(
attendance_point_name=attendance_point_name,
project_no=project_no,
product_name=product_name,
device_no=device_no,
process_name=process_name,
)
db.add(product)
stats.imported += 1
else:
stats.updated += 1
seen[key] = product
product.profile_no = _text(data.get("profile_no")) or None
product.material_code = _text(data.get("material_code")) or None
product.material_name = _text(data.get("material_name")) or None
product.supplier = _text(data.get("supplier")) or None
if "product_net_weight_kg" in data:
product.product_net_weight_kg = _optional_number(data.get("product_net_weight_kg"))
if "product_gross_weight_kg" in data:
product.product_gross_weight_kg = _optional_number(data.get("product_gross_weight_kg"))
if "scrap_loss_rate" in data:
product.scrap_loss_rate = _optional_number(data.get("scrap_loss_rate"))
if "waste_price_yuan_per_kg" in data:
product.waste_price_yuan_per_kg = _optional_number(data.get("waste_price_yuan_per_kg"))
aux_by_product[(attendance_point_name, product_name)] = (
product.product_net_weight_kg,
product.product_gross_weight_kg,
product.scrap_loss_rate,
product.waste_price_yuan_per_kg,
)
product.device_no = device_no
product.process_name = process_name
product.stamping_method = stamping_method or None
product.operator_count = _number(data.get("operator_count"), 1) or 1
product.process_unit_price_yuan = _number(data.get("process_unit_price_yuan"), 0)
product.standard_beat = 0 if (is_cleaning or is_misc) else standard_beat
product.standard_workload = 0
if stats.errors:
db.rollback()
detail = ";".join(stats.errors[:20])
if len(stats.errors) > 20:
detail += f";另有{len(stats.errors) - 20}处错误"
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
for (attendance_point_name, product_name), values in aux_by_product.items():
for sibling in db.scalars(
select(Product).where(
Product.attendance_point_name == attendance_point_name,
Product.product_name == product_name,
Product.device_no == "",
)
).all():
sibling.product_net_weight_kg = values[0]
sibling.product_gross_weight_kg = values[1]
sibling.scrap_loss_rate = values[2]
sibling.waste_price_yuan_per_kg = values[3]
db.commit()
return stats
def import_people(db: Session, content: bytes, user=None) -> ImportStats:
_, rows = _rows_from_excel(content)
row_point_names: list[str] = []
for raw in rows:
row_point_names.extend(_split_names(raw.get("考勤点")) or [DEFAULT_ATTENDANCE_POINT_NAME])
_validate_point_names(db, row_point_names, user, action="人员导入")
stats = ImportStats(errors=[])
for index, raw in enumerate(rows, start=2):
data = {field: raw[header] for header, field in PERSON_HEADERS.items() if header in raw}
phone = _text(data.get("phone"))
name = _text(data.get("name"))
role_text = _text(data.get("role"))
role = ROLE_MAP.get(role_text)
point_names = _split_names(data.get("attendance_point_names")) or [DEFAULT_ATTENDANCE_POINT_NAME]
if not phone or not name or role is None:
stats.skipped += 1
stats.errors.append(f"第{index}行缺少电话号、姓名或有效角色")
continue
if user is not None and user.role == Role.admin and role != Role.worker:
stats.skipped += 1
stats.errors.append(f"第{index}行管理员只能导入冲压工人")
continue
person = db.get(Personnel, phone)
if person is None:
person = Personnel(phone=phone)
db.add(person)
stats.imported += 1
else:
stats.updated += 1
person.name = name
person.is_temporary = False
person.temporary_expires_at = None
person_role = db.get(PersonRole, {"phone": phone, "role": role})
if person_role is None:
db.add(PersonRole(phone=phone, role=role))
try:
set_person_attendance_points(db, phone, point_names)
except ValueError as exc:
stats.skipped += 1
stats.errors.append(f"第{index}行{exc}")
if stats.errors:
db.rollback()
detail = ";".join(stats.errors[:20])
if len(stats.errors) > 20:
detail += f";另有{len(stats.errors) - 20}处错误"
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
db.commit()
return stats
+506
View File
@@ -0,0 +1,506 @@
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from typing import Protocol
from app.services.common import as_float, normalize_device_no, round1, round2
from app.services.misc_work import is_misc_item
from app.services.work_schedule import DEFAULT_WORK_SCHEDULE_CONFIG, WorkScheduleConfig
class ReportItemLike(Protocol):
device_no: str
product_name: str
process_name: str | None
started_at: datetime | None
standard_beat: float
standard_workload: float
stamping_method: str | None
good_qty: float
defect_qty: float
scrap_qty: float
allocated_minutes: float
class ReportDeviceLike(Protocol):
device_no: str
process_name: str | None
scanned_at: datetime
released_at: datetime | None
sort_order: int
SHIFT_BUCKETS = ("day", "overtime", "night")
SHIFT_LABELS = {
"day": "白班",
"overtime": "加班",
"night": "夜班",
}
MEAL_END_GRACE_MINUTES = 5
def minutes_between(start_at: datetime, end_at: datetime) -> float:
seconds = max(0.0, (end_at - start_at).total_seconds())
return seconds / 60
def _combine(day: date, value: time) -> datetime:
return datetime.combine(day, value)
def _parse_schedule_time(value: str) -> time:
hour, minute = [int(part) for part in str(value).split(":", 1)]
return time(hour, minute)
def _schedule_interval(base_date: date, start_text: str, end_text: str) -> tuple[datetime, datetime]:
start = _combine(base_date, _parse_schedule_time(start_text))
end = _combine(base_date, _parse_schedule_time(end_text))
if end <= start:
end += timedelta(days=1)
return start, end
def _overlap_minutes(
start_at: datetime,
end_at: datetime,
interval_start: datetime,
interval_end: datetime,
) -> float:
start = max(start_at, interval_start)
end = min(end_at, interval_end)
return minutes_between(start, end) if end > start else 0.0
def _covered_meal_overlap_minutes(
start_at: datetime,
end_at: datetime,
meal_start: datetime,
meal_end: datetime,
) -> float:
if end_at <= meal_end + timedelta(minutes=MEAL_END_GRACE_MINUTES):
return 0.0
if start_at <= meal_start and end_at >= meal_end:
return _overlap_minutes(start_at, end_at, meal_start, meal_end)
return 0.0
def _iter_shift_base_dates(start_at: datetime, end_at: datetime):
current = start_at.date() - timedelta(days=1)
last = end_at.date()
while current <= last:
yield current
current += timedelta(days=1)
def _raw_shift_minutes(
start_at: datetime,
end_at: datetime,
schedule: WorkScheduleConfig | None = None,
) -> dict[str, float]:
active_schedule = schedule or DEFAULT_WORK_SCHEDULE_CONFIG
minutes = {
"day": 0.0,
"overtime": 0.0,
"night": 0.0,
"meal_break": 0.0,
}
if end_at <= start_at:
return minutes
for current_date in _iter_shift_base_dates(start_at, end_at):
meal_intervals = [
_schedule_interval(current_date, active_schedule.lunch_start, active_schedule.lunch_end),
_schedule_interval(current_date, active_schedule.dinner_start, active_schedule.dinner_end),
]
shift_intervals = {
"day": [_schedule_interval(current_date, active_schedule.day_start, active_schedule.day_end)],
"overtime": [
_schedule_interval(current_date, active_schedule.overtime_start, active_schedule.overtime_end)
],
"night": [_schedule_interval(current_date, active_schedule.night_start, active_schedule.night_end)],
}
for meal_start, meal_end in meal_intervals:
minutes["meal_break"] += _covered_meal_overlap_minutes(
start_at,
end_at,
meal_start,
meal_end,
)
for bucket, intervals in shift_intervals.items():
for interval_start, interval_end in intervals:
raw_minutes = _overlap_minutes(start_at, end_at, interval_start, interval_end)
shift_start = max(start_at, interval_start)
shift_end = min(end_at, interval_end)
meal_minutes = sum(
_overlap_minutes(shift_start, shift_end, meal_start, meal_end)
for meal_start, meal_end in meal_intervals
if (
start_at <= meal_start
and end_at >= meal_end
and end_at > meal_end + timedelta(minutes=MEAL_END_GRACE_MINUTES)
)
)
minutes[bucket] += max(0.0, raw_minutes - meal_minutes)
return minutes
def shift_minutes_between(
start_at: datetime,
end_at: datetime,
break_minutes: float = 0,
schedule: WorkScheduleConfig | None = None,
) -> dict[str, float]:
return _raw_shift_minutes(start_at, end_at, schedule)
def effective_work_minutes_between(
start_at: datetime,
end_at: datetime,
break_minutes: float = 0,
schedule: WorkScheduleConfig | None = None,
) -> float:
shift_minutes = shift_minutes_between(start_at, end_at, break_minutes, schedule)
return sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
def format_hours(minutes: float) -> str:
text = f"{round(as_float(minutes) / 60, 2):.2f}".rstrip("0").rstrip(".")
return text or "0"
def shift_distribution_text(shift_minutes: dict[str, float]) -> str:
effective_minutes = sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
parts = [
f"{SHIFT_LABELS[key]}{format_hours(shift_minutes.get(key, 0.0))}小时"
for key in SHIFT_BUCKETS
if round(as_float(shift_minutes.get(key, 0.0)), 2) > 0
]
detail = f"({'、'.join(parts)})" if parts else ""
return f"工时 {format_hours(effective_minutes)}小时{detail}"
def _set_item_shift_minutes(item: ReportItemLike, shift_minutes: dict[str, float]) -> None:
for key in SHIFT_BUCKETS:
setattr(item, f"_shift_{key}_minutes", as_float(shift_minutes.get(key, 0.0)))
def _add_item_shift_minutes(item: ReportItemLike, shift_minutes: dict[str, float]) -> None:
for key in SHIFT_BUCKETS:
setattr(
item,
f"_shift_{key}_minutes",
_get_item_shift_minutes(item, key) + as_float(shift_minutes.get(key, 0.0)),
)
def _get_item_shift_minutes(item: ReportItemLike, key: str) -> float:
return as_float(getattr(item, f"_shift_{key}_minutes", 0.0))
def _scale_item_shift_minutes(item: ReportItemLike, scale: float) -> None:
for key in SHIFT_BUCKETS:
setattr(item, f"_shift_{key}_minutes", _get_item_shift_minutes(item, key) * scale)
def _mold_key(product_name: str | None, process_name: str | None = "") -> str:
return f"{normalize_device_no(product_name)}\0{str(process_name or '').strip()}"
WORK_TIME_IGNORED_RELEASE_REASONS = {
"换模具释放占用",
"报工提交释放占用",
"超时-系统自动提交",
}
def release_at_caps_work_time(device: ReportDeviceLike) -> bool:
if getattr(device, "released_at", None) is None:
return False
reason = str(getattr(device, "release_reason", "") or "").strip()
return reason not in WORK_TIME_IGNORED_RELEASE_REASONS
def _device_segment_ranges(
start_at: datetime,
end_at: datetime,
devices: list[ReportDeviceLike] | None,
) -> dict[str, list[tuple[datetime, datetime]]]:
ordered_devices = sorted(
devices or [],
key=lambda device: (int(getattr(device, "sort_order", 0) or 0), device.scanned_at),
)
if not ordered_devices:
return {}
segment_ranges: dict[str, list[tuple[datetime, datetime]]] = defaultdict(list)
for index, device in enumerate(ordered_devices):
segment_start = start_at if index == 0 else max(start_at, device.scanned_at)
segment_end = end_at
if index + 1 < len(ordered_devices):
segment_end = min(end_at, ordered_devices[index + 1].scanned_at)
released_at = getattr(device, "released_at", None)
if release_at_caps_work_time(device) and released_at is not None:
segment_end = min(segment_end, released_at)
if segment_end > segment_start:
segment_ranges[_mold_key(device.device_no, getattr(device, "process_name", ""))].append(
(segment_start, segment_end)
)
return segment_ranges
def _segment_ranges_shift_minutes(
segment_ranges: dict[str, list[tuple[datetime, datetime]]],
schedule: WorkScheduleConfig | None = None,
) -> dict[str, float]:
minutes = {key: 0.0 for key in SHIFT_BUCKETS}
for ranges in segment_ranges.values():
for segment_start, segment_end in ranges:
shift_minutes = shift_minutes_between(segment_start, segment_end, schedule=schedule)
for key in SHIFT_BUCKETS:
minutes[key] += shift_minutes.get(key, 0.0)
return minutes
def _clamp_datetime(value: datetime, start_at: datetime, end_at: datetime) -> datetime:
if value < start_at:
return start_at
if value > end_at:
return end_at
return value
def _allocate_device_item_minutes(
device_items: list[ReportItemLike],
segment_start: datetime,
segment_end: datetime,
schedule: WorkScheduleConfig | None = None,
) -> None:
if not device_items:
return
grouped_items: dict[datetime, list[ReportItemLike]] = defaultdict(list)
for item in device_items:
item_start = getattr(item, "started_at", None) or segment_start
grouped_items[_clamp_datetime(item_start, segment_start, segment_end)].append(item)
first_item_start = min(grouped_items)
if first_item_start > segment_start:
grouped_items[segment_start].extend(grouped_items.pop(first_item_start))
ordered_starts = sorted(grouped_items)
for index, item_start in enumerate(ordered_starts):
item_end = segment_end
if index + 1 < len(ordered_starts):
item_end = ordered_starts[index + 1]
shift_minutes = shift_minutes_between(item_start, item_end, schedule=schedule)
minutes = sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
items_at_start = grouped_items[item_start]
minutes_per_item = minutes / len(items_at_start)
for item in items_at_start:
item.allocated_minutes = as_float(item.allocated_minutes) + minutes_per_item
_add_item_shift_minutes(
item,
{key: shift_minutes.get(key, 0.0) / len(items_at_start) for key in SHIFT_BUCKETS},
)
def _allocate_item_minutes(
start_at: datetime,
end_at: datetime,
items: list[ReportItemLike],
effective_minutes: float,
devices: list[ReportDeviceLike] | None,
schedule: WorkScheduleConfig | None = None,
) -> None:
if not items:
return
for item in items:
item.allocated_minutes = 0.0
_set_item_shift_minutes(item, {})
segment_ranges = _device_segment_ranges(start_at, end_at, devices)
if not segment_ranges:
shift_minutes = shift_minutes_between(start_at, end_at, schedule=schedule)
minutes_per_item = effective_minutes / len(items)
for item in items:
item.allocated_minutes = minutes_per_item
_set_item_shift_minutes(
item,
{key: shift_minutes.get(key, 0.0) / len(items) for key in SHIFT_BUCKETS},
)
return
covered_shift_minutes = _segment_ranges_shift_minutes(segment_ranges, schedule)
target_effective_minutes = min(effective_minutes, sum(covered_shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS))
target_shift_minutes = covered_shift_minutes
items_by_device: dict[str, list[ReportItemLike]] = defaultdict(list)
for item in items:
mold_key = _mold_key(
getattr(item, "product_name", "") or item.device_no,
getattr(item, "process_name", ""),
)
items_by_device[mold_key].append(item)
allocated_item_ids: set[int] = set()
for device_no, ranges in segment_ranges.items():
device_items = items_by_device.get(device_no, [])
if not device_items:
continue
for segment_range in ranges:
_allocate_device_item_minutes(device_items, segment_range[0], segment_range[1], schedule)
for item in device_items:
allocated_item_ids.add(id(item))
unmatched_items = [item for item in items if id(item) not in allocated_item_ids]
if unmatched_items:
allocated_minutes = sum(as_float(item.allocated_minutes) for item in items)
remaining_minutes = max(0.0, target_effective_minutes - allocated_minutes)
minutes_per_item = remaining_minutes / len(unmatched_items)
allocated_shift_minutes = {
key: sum(_get_item_shift_minutes(item, key) for item in items)
for key in SHIFT_BUCKETS
}
remaining_shift_minutes = {
key: max(0.0, target_shift_minutes.get(key, 0.0) - allocated_shift_minutes[key])
for key in SHIFT_BUCKETS
}
for item in unmatched_items:
item.allocated_minutes = minutes_per_item
_set_item_shift_minutes(
item,
{key: remaining_shift_minutes[key] / len(unmatched_items) for key in SHIFT_BUCKETS},
)
allocated_minutes = sum(as_float(item.allocated_minutes) for item in items)
if target_effective_minutes <= 0:
for item in items:
item.allocated_minutes = 0.0
_set_item_shift_minutes(item, {})
elif allocated_minutes <= 0:
minutes_per_item = target_effective_minutes / len(items)
for item in items:
item.allocated_minutes = minutes_per_item
_set_item_shift_minutes(
item,
{key: target_shift_minutes.get(key, 0.0) / len(items) for key in SHIFT_BUCKETS},
)
elif abs(allocated_minutes - target_effective_minutes) > 0.000001:
scale = target_effective_minutes / allocated_minutes
for item in items:
item.allocated_minutes = as_float(item.allocated_minutes) * scale
_scale_item_shift_minutes(item, scale)
def calculate_report_metrics(
start_at: datetime,
end_at: datetime,
items: list[ReportItemLike],
break_minutes: float = 0,
devices: list[ReportDeviceLike] | None = None,
schedule: WorkScheduleConfig | None = None,
) -> dict[str, float]:
segment_ranges = _device_segment_ranges(start_at, end_at, devices)
if segment_ranges:
duration_minutes = sum(
minutes_between(segment_start, segment_end)
for ranges in segment_ranges.values()
for segment_start, segment_end in ranges
)
shift_minutes = _segment_ranges_shift_minutes(segment_ranges, schedule)
shift_minutes["meal_break"] = sum(
shift_minutes_between(segment_start, segment_end, break_minutes, schedule).get("meal_break", 0.0)
for ranges in segment_ranges.values()
for segment_start, segment_end in ranges
)
else:
duration_minutes = minutes_between(start_at, end_at)
shift_minutes = shift_minutes_between(start_at, end_at, break_minutes, schedule)
effective_minutes = sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
_allocate_item_minutes(start_at, end_at, items, effective_minutes, devices, schedule)
total_good_qty = 0.0
total_output_qty = 0.0
weighted_standard_beat = 0.0
standard_weight = 0.0
expected_workload = 0.0
productive_minutes = 0.0
for item in items:
if is_misc_item(item):
try:
item.standard_workload = 0
except AttributeError:
pass
continue
good_qty = as_float(item.good_qty)
defect_qty = as_float(item.defect_qty)
scrap_qty = as_float(item.scrap_qty)
output_qty = good_qty + defect_qty + scrap_qty
beat = as_float(item.standard_beat)
workload = as_float(item.allocated_minutes) * 60 / beat if beat > 0 else 0
weight = max(1.0, output_qty)
try:
item.standard_workload = round1(workload)
except AttributeError:
pass
total_good_qty += good_qty
total_output_qty += output_qty
weighted_standard_beat += beat * weight
standard_weight += weight
expected_workload += workload
productive_minutes += as_float(item.allocated_minutes)
actual_beat = productive_minutes * 60 / total_output_qty if total_output_qty > 0 else 0
standard_beat = weighted_standard_beat / standard_weight if standard_weight > 0 else 0
pace_rate = (actual_beat - standard_beat) / standard_beat * 100 if standard_beat > 0 else 0
workload_rate = (
(total_good_qty - expected_workload) / expected_workload * 100
if expected_workload > 0
else 0
)
return {
"duration_minutes": round2(duration_minutes),
"effective_minutes": round2(effective_minutes),
"shift_day_minutes": round2(shift_minutes["day"]),
"shift_overtime_minutes": round2(shift_minutes["overtime"]),
"shift_night_minutes": round2(shift_minutes["night"]),
"shift_other_minutes": 0,
"shift_meal_break_minutes": round2(shift_minutes["meal_break"]),
"shift_distribution_text": shift_distribution_text(shift_minutes),
"total_good_qty": round2(total_good_qty),
"total_output_qty": round2(total_output_qty),
"actual_beat": round2(actual_beat),
"standard_beat": round2(standard_beat),
"expected_workload": round1(expected_workload),
"pace_rate": round1(pace_rate),
"workload_rate": round1(workload_rate),
}
def build_result_text(metrics: dict[str, float]) -> str:
pace_rate = as_float(metrics.get("pace_rate"))
workload_rate = as_float(metrics.get("workload_rate"))
fast_abnormal = pace_rate < -30
large_abnormal = workload_rate > 30
pace_text = (
f"今日节拍慢于标准节拍{abs(pace_rate)}%"
if pace_rate > 0
else f"今日节拍快于标准节拍{abs(pace_rate)}%"
)
workload_text = (
f"今日工作量大于标准工作量{abs(workload_rate)}%"
if workload_rate >= 0
else f"今日工作量小于标准工作量{abs(workload_rate)}%"
)
if fast_abnormal or large_abnormal:
suffix = "数据明显超出标准,请自我核对一下是否报工填错。"
elif pace_rate <= 0 and workload_rate >= 0:
suffix = "今天真的很棒呢!"
else:
suffix = "后续需要加油喽!"
return f"{pace_text},{workload_text},{suffix}"
+72
View File
@@ -0,0 +1,72 @@
from sqlalchemy.orm import Session
from app.models import Product
MISC_WORK_PRODUCT_NAME = "处理杂活"
MISC_WORK_PROCESS_NAME = "杂活"
MISC_WORK_STAMPING_METHOD = "处理杂活"
MISC_WORK_PROJECT_NO = "MISC"
MISC_WORK_MATERIAL_CODE = "MISC-WORK"
MISC_WORK_MATERIAL_NAME = "处理杂活"
def is_misc_stamping_method(value: str | None) -> bool:
return str(value or "").strip() == MISC_WORK_STAMPING_METHOD
def is_misc_product(product) -> bool:
return bool(product) and (
is_misc_stamping_method(getattr(product, "stamping_method", None))
or (
getattr(product, "product_name", None) == MISC_WORK_PRODUCT_NAME
and getattr(product, "process_name", None) == MISC_WORK_PROCESS_NAME
)
)
def is_misc_item(item) -> bool:
return bool(item) and (
is_misc_stamping_method(getattr(item, "stamping_method", None))
or (
getattr(item, "product_name", None) == MISC_WORK_PRODUCT_NAME
and getattr(item, "process_name", None) == MISC_WORK_PROCESS_NAME
)
)
def ensure_misc_work_product(db: Session, attendance_point_name: str) -> Product:
product = db.get(
Product,
{
"attendance_point_name": attendance_point_name,
"project_no": MISC_WORK_PROJECT_NO,
"product_name": MISC_WORK_PRODUCT_NAME,
"device_no": "",
"process_name": MISC_WORK_PROCESS_NAME,
},
)
if product is None:
product = Product(
attendance_point_name=attendance_point_name,
project_no=MISC_WORK_PROJECT_NO,
product_name=MISC_WORK_PRODUCT_NAME,
device_no="",
process_name=MISC_WORK_PROCESS_NAME,
)
db.add(product)
product.profile_no = product.profile_no or "MISC"
product.material_code = product.material_code or MISC_WORK_MATERIAL_CODE
product.material_name = product.material_name or MISC_WORK_MATERIAL_NAME
product.supplier = product.supplier or "系统内置"
product.stamping_method = MISC_WORK_STAMPING_METHOD
product.operator_count = 1
product.process_unit_price_yuan = 0
product.standard_beat = 0
product.standard_workload = 0
return product
def ensure_misc_work_products(db: Session, attendance_point_names: list[str]) -> None:
for name in attendance_point_names:
ensure_misc_work_product(db, name)
+287
View File
@@ -0,0 +1,287 @@
from contextlib import contextmanager
import hashlib
from fastapi import HTTPException
from sqlalchemy import select, text
from sqlalchemy.orm import Session, selectinload
from app.models import (
MoldLockFeedback,
Personnel,
Product,
SessionStatus,
WorkSession,
WorkSessionDevice,
)
from app.schemas import MoldLockFeedbackOut, MonitorLockOut
from app.services.cleaning import is_cleaning_product
from app.services.common import round2
from app.services.display_names import point_mold_display_name
from app.services.metrics import minutes_between
from app.services.misc_work import is_misc_product
from app.services.multi_person import is_multi_person_product
from app.timezone import now
FEEDBACK_STATUS_PENDING = "pending"
FEEDBACK_STATUS_HANDLED = "handled"
MOLD_LOCK_PREFIX = "jh_mold_lock"
def mold_display_name(
point_name: str,
mold_name: str,
process_name: str | None,
stamping_method: str | None = None,
) -> str:
return point_mold_display_name(point_name, mold_name, process_name, stamping_method)
def is_lockable_product(product: Product | None) -> bool:
return (
bool(product)
and not is_cleaning_product(product)
and not is_misc_product(product)
and not is_multi_person_product(product)
)
def _mutex_key(point_name: str, mold_name: str, process_name: str) -> str:
raw = f"{point_name}\0{mold_name}\0{process_name}"
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return f"{MOLD_LOCK_PREFIX}:{digest}"
@contextmanager
def mold_lock_mutex(
db: Session,
point_name: str,
mold_name: str,
process_name: str,
*,
timeout_seconds: int = 5,
):
bind = db.get_bind()
if bind.dialect.name != "mysql":
yield
return
key = _mutex_key(point_name, mold_name, process_name)
acquired = db.scalar(
text("select get_lock(:lock_key, :timeout_seconds)"),
{"lock_key": key, "timeout_seconds": timeout_seconds},
)
if acquired != 1:
raise HTTPException(status_code=409, detail="模具占用检查繁忙,请稍后重试")
try:
yield
finally:
db.execute(text("select release_lock(:lock_key)"), {"lock_key": key})
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 _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 current_unreleased_device(session: WorkSession) -> WorkSessionDevice | None:
devices = [device for device in _ordered_devices(session) if device.released_at is None]
return devices[-1] if devices else None
def _is_session_current_device(session: WorkSession, device: WorkSessionDevice) -> bool:
current = current_unreleased_device(session)
return bool(current and current.id == device.id)
def active_lock_for_mold(
db: Session,
point_name: str,
mold_name: str,
process_name: str,
) -> WorkSessionDevice | None:
rows = db.scalars(
select(WorkSessionDevice)
.options(
selectinload(WorkSessionDevice.session).selectinload(WorkSession.devices),
selectinload(WorkSessionDevice.session).selectinload(WorkSession.employee),
)
.join(WorkSession, WorkSession.id == WorkSessionDevice.session_id)
.where(
WorkSession.status.in_([SessionStatus.active, SessionStatus.reporting]),
WorkSessionDevice.released_at.is_(None),
WorkSessionDevice.attendance_point_name == point_name,
WorkSessionDevice.device_no == mold_name,
WorkSessionDevice.process_name == process_name,
)
.order_by(WorkSessionDevice.scanned_at.desc(), WorkSessionDevice.id.desc())
).all()
for device in rows:
if _is_session_current_device(device.session, device):
return device
return None
def active_locks_for_points(db: Session, point_names: list[str]) -> list[WorkSessionDevice]:
rows = db.scalars(
select(WorkSessionDevice)
.options(
selectinload(WorkSessionDevice.session).selectinload(WorkSession.devices),
selectinload(WorkSessionDevice.session).selectinload(WorkSession.employee),
)
.join(WorkSession, WorkSession.id == WorkSessionDevice.session_id)
.where(
WorkSession.status.in_([SessionStatus.active, SessionStatus.reporting]),
WorkSessionDevice.released_at.is_(None),
WorkSessionDevice.attendance_point_name.in_(point_names),
)
.order_by(WorkSessionDevice.scanned_at.desc(), WorkSessionDevice.id.desc())
).all()
locks: list[WorkSessionDevice] = []
seen_sessions: set[int] = set()
for device in rows:
session_id = int(device.session_id)
if session_id in seen_sessions or not _is_session_current_device(device.session, device):
continue
product = _product_for_mold(db, device.attendance_point_name, device.device_no, device.process_name)
if not is_lockable_product(product):
continue
locks.append(device)
seen_sessions.add(session_id)
return locks
def release_session_locks(
session: WorkSession,
released_by: str | None,
reason: str,
released_at=None,
) -> None:
timestamp = released_at or now()
for device in session.devices:
if device.released_at is None:
device.released_at = timestamp
device.released_by = released_by
device.release_reason = reason
def ensure_mold_available(
db: Session,
user: Personnel,
point_name: str,
mold_name: str,
process_name: str,
product: Product | None,
) -> None:
if not is_lockable_product(product):
return
lock_process_name = str(process_name or (product.process_name if product else "") or "").strip()
lock = active_lock_for_mold(db, point_name, mold_name, lock_process_name)
if lock is None or lock.session.employee_phone == user.phone:
return
occupied_name = lock.session.employee.name if lock.session and lock.session.employee else ""
message = f"已经被{occupied_name or lock.session.employee_phone}占用,请联系管理员解除占用"
raise HTTPException(
status_code=423,
detail={
"code": "mold_occupied",
"message": message,
"lock_id": lock.id,
"attendance_point_name": point_name,
"mold_name": mold_name,
"process_name": process_name,
"mold_display_name": mold_display_name(point_name, mold_name, process_name, product.stamping_method if product else None),
"occupied_phone": lock.session.employee_phone,
"occupied_name": occupied_name,
},
)
def release_lock(
db: Session,
lock_id: int,
released_by: str,
reason: str = "管理员强制解除占用",
) -> WorkSessionDevice:
lock = db.scalar(
select(WorkSessionDevice)
.options(selectinload(WorkSessionDevice.session).selectinload(WorkSession.devices))
.where(WorkSessionDevice.id == lock_id)
)
if lock is None:
raise ValueError("占用记录不存在")
if lock.released_at is None:
lock.released_at = now()
lock.released_by = released_by
lock.release_reason = reason
mark_feedbacks_handled(db, lock.id, released_by)
return lock
def mark_feedbacks_handled(db: Session, lock_id: int, handled_by: str) -> None:
timestamp = now()
feedbacks = db.scalars(
select(MoldLockFeedback).where(
MoldLockFeedback.session_device_id == lock_id,
MoldLockFeedback.status == FEEDBACK_STATUS_PENDING,
)
).all()
for feedback in feedbacks:
feedback.status = FEEDBACK_STATUS_HANDLED
feedback.handled_at = timestamp
feedback.handled_by = handled_by
def lock_out(db: Session, lock: WorkSessionDevice) -> MonitorLockOut:
occupied_name = lock.session.employee.name if lock.session and lock.session.employee else ""
product = _product_for_mold(db, lock.attendance_point_name, lock.device_no, lock.process_name)
return MonitorLockOut(
id=lock.id,
attendance_point_name=lock.attendance_point_name,
mold_name=lock.device_no,
process_name=lock.process_name,
stamping_method=product.stamping_method if product else None,
mold_display_name=mold_display_name(lock.attendance_point_name, lock.device_no, lock.process_name, product.stamping_method if product else None),
occupied_phone=lock.session.employee_phone,
occupied_name=occupied_name,
occupied_start_at=lock.scanned_at,
occupied_minutes=round2(minutes_between(lock.scanned_at, now())),
)
def feedback_out(db: Session, feedback: MoldLockFeedback) -> MoldLockFeedbackOut:
product = _product_for_mold(db, feedback.attendance_point_name, feedback.mold_name, feedback.process_name)
return MoldLockFeedbackOut(
id=feedback.id,
attendance_point_name=feedback.attendance_point_name,
mold_name=feedback.mold_name,
process_name=feedback.process_name,
stamping_method=product.stamping_method if product else None,
mold_display_name=mold_display_name(feedback.attendance_point_name, feedback.mold_name, feedback.process_name, product.stamping_method if product else None),
lock_id=feedback.session_device_id,
reporter_phone=feedback.reporter_phone,
reporter_name=feedback.reporter.name if feedback.reporter else "",
occupied_phone=feedback.occupied_phone,
occupied_name=feedback.occupied_person.name if feedback.occupied_person else "",
feedback_at=feedback.created_at,
read_at=feedback.read_at,
handled_at=feedback.handled_at,
status=feedback.status,
status_name="已处理" if feedback.status == FEEDBACK_STATUS_HANDLED else "未处理",
can_release=feedback.status != FEEDBACK_STATUS_HANDLED,
)
+9
View File
@@ -0,0 +1,9 @@
from app.services.common import as_float
def is_multi_person_product(product) -> bool:
return bool(product) and as_float(getattr(product, "operator_count", 1)) > 1
def is_multi_person_item(item) -> bool:
return as_float(getattr(item, "operator_count", 1)) > 1
+43
View File
@@ -0,0 +1,43 @@
import re
MISC_PROCESS_NAME = "杂活"
def normalize_process_name(value: str | None) -> str:
text = str(value or "").strip()
if text == MISC_PROCESS_NAME:
return MISC_PROCESS_NAME
if not re.fullmatch(r"\d+", text):
raise ValueError("工序只能填写正整数,或填写杂活")
number = int(text)
if number <= 0:
raise ValueError("工序只能填写正整数,或填写杂活")
return str(number)
def normalize_numeric_process_name(value: str | None) -> str:
text = str(value or "").strip()
if not re.fullmatch(r"\d+", text):
raise ValueError("工序必须填写正整数")
number = int(text)
if number <= 0:
raise ValueError("工序必须填写正整数")
return str(number)
def export_process_name(value: str | None) -> str:
text = str(value or "").strip()
if text == MISC_PROCESS_NAME:
return MISC_PROCESS_NAME
legacy_match = re.fullmatch(r"0*(\d+)序", text)
if legacy_match:
return str(int(legacy_match.group(1)))
if re.fullmatch(r"\d+", text):
return str(int(text))
return text
def process_order(process_name: str | None) -> tuple[int, str]:
text = export_process_name(process_name)
return (int(text), text) if re.fullmatch(r"\d+", text) else (0, text)
+327
View File
@@ -0,0 +1,327 @@
import asyncio
import hashlib
import logging
import re
import uuid
import zipfile
from datetime import datetime
from pathlib import Path
from urllib.parse import quote, unquote, urlparse
from sqlalchemy import select
from app.config import settings
from app.database import SessionLocal
from app.models import DeviceQrBatchTask, DeviceQRCode, Product
from app.schemas import DeviceQrBatchTaskOut
from app.services.cleaning import is_cleaning_product
from app.services.display_names import point_mold_display_name
from app.services.misc_work import is_misc_product
from app.services.wechat import create_device_qrcode
from app.timezone import now
QRCODE_TASK_STATUS_PENDING = "pending"
QRCODE_TASK_STATUS_RUNNING = "running"
QRCODE_TASK_STATUS_PAUSED = "paused"
QRCODE_TASK_STATUS_COMPLETED = "completed"
QRCODE_TASK_STATUS_FAILED = "failed"
QRCODE_BATCH_TASK_INTERVAL_SECONDS = 3
QRCODE_TASK_STATUS_NAMES = {
QRCODE_TASK_STATUS_PENDING: "等待生成",
QRCODE_TASK_STATUS_RUNNING: "生成中",
QRCODE_TASK_STATUS_PAUSED: "已停止",
QRCODE_TASK_STATUS_COMPLETED: "已完成",
QRCODE_TASK_STATUS_FAILED: "生成失败",
}
logger = logging.getLogger(__name__)
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 _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 _product_for_mold(db, 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 _public_base_url() -> str:
return settings.public_base_url.rstrip("/") or "http://127.0.0.1:8000"
def _task_file_name(items: list[dict]) -> str:
fingerprint = hashlib.sha1(
"|".join(
f"{item.get('attendance_point_name', '')}\0{item.get('product_name', '')}\0{item.get('process_name', '')}"
for item in items
).encode("utf-8")
).hexdigest()[:10]
unique = uuid.uuid4().hex[:8]
return f"mold-qrcodes-{datetime.now().strftime('%Y%m%d%H%M%S')}-{fingerprint}-{unique}.zip"
def _items_signature(items: list[dict]) -> tuple[tuple[str, str, str], ...]:
return tuple(
sorted(
(
str(item.get("attendance_point_name") or "").strip(),
str(item.get("product_name") or "").strip(),
str(item.get("process_name") or "").strip(),
)
for item in items
)
)
def create_qrcode_batch_task(db, *, created_by: str, items: list[dict]) -> DeviceQrBatchTask:
signature = _items_signature(items)
active_tasks = db.scalars(
select(DeviceQrBatchTask)
.where(
DeviceQrBatchTask.created_by == created_by,
DeviceQrBatchTask.status.in_([
QRCODE_TASK_STATUS_PENDING,
QRCODE_TASK_STATUS_RUNNING,
QRCODE_TASK_STATUS_PAUSED,
]),
)
.order_by(DeviceQrBatchTask.created_at.desc(), DeviceQrBatchTask.id.desc())
).all()
for active_task in active_tasks:
if _items_signature(list(active_task.items_json or [])) == signature:
return active_task
task = DeviceQrBatchTask(
file_name=_task_file_name(items),
status=QRCODE_TASK_STATUS_PENDING,
item_count=len(items),
completed_count=0,
failed_count=0,
items_json=items,
created_by=created_by,
)
db.add(task)
db.commit()
db.refresh(task)
return task
def qrcode_batch_task_out(task: DeviceQrBatchTask, public_base_url: str | None = None) -> DeviceQrBatchTaskOut:
item_count = max(0, int(task.item_count or 0))
completed_count = max(0, int(task.completed_count or 0))
if task.status == QRCODE_TASK_STATUS_COMPLETED:
progress_percent = 100.0
elif item_count <= 0:
progress_percent = 0.0
else:
progress_percent = round(min(100, completed_count / item_count * 100), 1)
zip_url = task.zip_url
if public_base_url and task.file_name:
zip_url = f"{public_base_url.rstrip('/')}/uploads/qrcode_zips/{quote(task.file_name, safe='')}"
return DeviceQrBatchTaskOut(
id=task.id,
file_name=task.file_name,
status=task.status,
status_name=QRCODE_TASK_STATUS_NAMES.get(task.status, task.status),
item_count=item_count,
completed_count=completed_count,
failed_count=max(0, int(task.failed_count or 0)),
progress_percent=progress_percent,
zip_url=zip_url,
error_message=task.error_message,
started_at=task.started_at,
finished_at=task.finished_at,
created_at=task.created_at,
updated_at=task.updated_at,
)
def recover_running_qrcode_batch_tasks() -> None:
with SessionLocal() as db:
tasks = db.scalars(
select(DeviceQrBatchTask).where(DeviceQrBatchTask.status == QRCODE_TASK_STATUS_RUNNING)
).all()
for task in tasks:
task.status = QRCODE_TASK_STATUS_PENDING
task.failed_count = 0
task.error_message = "服务重启后重新排队"
task.finished_at = None
if tasks:
db.commit()
def _claim_next_qrcode_batch_task() -> int | None:
with SessionLocal() as db:
query = (
select(DeviceQrBatchTask)
.where(DeviceQrBatchTask.status == QRCODE_TASK_STATUS_PENDING)
.order_by(DeviceQrBatchTask.created_at.asc(), DeviceQrBatchTask.id.asc())
.limit(1)
)
if db.get_bind().dialect.name in {"mysql", "postgresql"}:
query = query.with_for_update(skip_locked=True)
task = db.scalar(query)
if task is None:
return None
task.status = QRCODE_TASK_STATUS_RUNNING
if task.started_at is None:
task.started_at = now()
task.finished_at = None
task.failed_count = 0
task.error_message = None
db.commit()
return int(task.id)
async def _generate_task_qrcode(db, task: DeviceQrBatchTask, item: dict) -> tuple[str, Path]:
point_name = str(item.get("attendance_point_name") or "").strip()
mold_name = str(item.get("product_name") or "").strip()
process_name = str(item.get("process_name") or "").strip()
product = _product_for_mold(db, point_name, mold_name, process_name)
if product is None:
raise RuntimeError(f"产品清单中已不存在:{point_name} / {mold_name} / {process_name}")
display_name = point_mold_display_name(point_name, mold_name, process_name, product.stamping_method)
scene = f"mold={_scene_token(point_name, mold_name, process_name)}"
qr_url = await create_device_qrcode(
display_name,
page="pages/clock/clock",
scene=scene,
public_base_url=_public_base_url(),
label=f"模具 {display_name}",
)
record = db.get(
DeviceQRCode,
{"attendance_point_name": point_name, "device_no": mold_name, "process_name": process_name},
)
if record is None:
record = DeviceQRCode(
attendance_point_name=point_name,
device_no=mold_name,
process_name=process_name,
created_by=task.created_by,
)
db.add(record)
record.qr_scene = scene
record.qr_url = qr_url
db.commit()
qr_path = _qrcode_path_from_url(qr_url)
if qr_path is None:
raise RuntimeError(f"{display_name} 二维码图片未生成,无法打包")
return display_name, qr_path
async def process_qrcode_batch_task(task_id: int) -> None:
zip_dir = settings.upload_path / "qrcode_zips"
zip_dir.mkdir(parents=True, exist_ok=True)
used_names: set[str] = set()
with SessionLocal() as db:
task = db.get(DeviceQrBatchTask, task_id)
if task is None:
return
items = list(task.items_json or [])
zip_path = zip_dir / task.file_name
completed_count = max(0, min(int(task.completed_count or 0), len(items)))
if completed_count and not zip_path.exists():
completed_count = 0
task.completed_count = 0
db.commit()
zip_mode = "a" if completed_count > 0 else "w"
try:
with zipfile.ZipFile(zip_path, zip_mode, zipfile.ZIP_DEFLATED) as archive:
for index, item in enumerate(items, start=1):
if index <= completed_count:
continue
db.refresh(task)
if task.status == QRCODE_TASK_STATUS_PAUSED:
task.finished_at = None
db.commit()
return
if task.status != QRCODE_TASK_STATUS_RUNNING:
return
display_name, qr_path = await _generate_task_qrcode(db, task, item)
stem = _safe_file_stem(f"{index:03d}_{display_name}")
archive.write(qr_path, _unique_zip_name(set(archive.namelist()), stem, qr_path.suffix))
task.completed_count = index
task.failed_count = 0
task.updated_at = now()
db.commit()
task.status = QRCODE_TASK_STATUS_COMPLETED
task.completed_count = len(items)
task.failed_count = 0
task.zip_url = f"{_public_base_url()}/uploads/qrcode_zips/{quote(task.file_name, safe='')}"
task.error_message = None
task.finished_at = now()
db.commit()
except Exception as exc:
db.rollback()
task = db.get(DeviceQrBatchTask, task_id)
if task is None:
zip_path.unlink(missing_ok=True)
else:
completed_count = int(task.completed_count or 0)
task.status = QRCODE_TASK_STATUS_FAILED
task.failed_count = max(1, int(task.item_count or 0) - completed_count)
task.error_message = str(exc)[:1000]
task.finished_at = now()
db.commit()
raise
async def qrcode_batch_task_loop(interval_seconds: int = QRCODE_BATCH_TASK_INTERVAL_SECONDS) -> None:
await asyncio.to_thread(recover_running_qrcode_batch_tasks)
while True:
try:
task_id = await asyncio.to_thread(_claim_next_qrcode_batch_task)
if task_id is None:
await asyncio.sleep(interval_seconds)
continue
await process_qrcode_batch_task(task_id)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("qrcode batch task failed")
await asyncio.sleep(interval_seconds)
+94
View File
@@ -0,0 +1,94 @@
from datetime import timedelta
from fastapi import HTTPException, status
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, selectinload
from app.models import (
MoldLockFeedback,
Personnel,
ProductionReport,
ProductionReportItem,
ReportAuditLog,
WorkSession,
)
from app.timezone import now
VOID_RETENTION_DAYS = 30
APPROVED_EDIT_DAYS = 30
def void_deadline(voided_at=None):
return (voided_at or now()) + timedelta(days=VOID_RETENTION_DAYS)
def can_edit_report(report: ProductionReport) -> bool:
if bool(report.is_voided):
return False
if str(report.status) == "pending":
return True
if str(report.status) != "approved" or report.reviewed_at is None:
return False
return report.reviewed_at >= now() - timedelta(days=APPROVED_EDIT_DAYS)
def can_unvoid_report(report: ProductionReport) -> bool:
if not bool(report.is_voided) or report.voided_at is None:
return False
return report.voided_at > now() - timedelta(days=VOID_RETENTION_DAYS)
def mark_report_voided(report: ProductionReport, user: Personnel) -> None:
if bool(report.is_voided):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该报工记录已作废")
timestamp = now()
report.is_voided = True
report.voided_at = timestamp
report.voided_by = user.phone
report.unvoid_deadline_at = void_deadline(timestamp)
def restore_voided_report(report: ProductionReport) -> None:
if not bool(report.is_voided):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该报工记录未作废")
if not can_unvoid_report(report):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="作废已超过30天,不能撤销作废")
report.is_voided = False
report.voided_at = None
report.voided_by = None
report.unvoid_deadline_at = None
def purge_expired_voided_reports(db: Session) -> int:
cutoff = now() - timedelta(days=VOID_RETENTION_DAYS)
reports = db.scalars(
select(ProductionReport)
.options(selectinload(ProductionReport.session).selectinload(WorkSession.devices))
.where(
ProductionReport.is_voided.is_(True),
ProductionReport.voided_at.is_not(None),
ProductionReport.voided_at <= cutoff,
)
.limit(100)
).all()
if not reports:
return 0
for report in reports:
session = report.session
if session is not None:
session_device_ids = [device.id for device in session.devices if device.id is not None]
if session_device_ids:
db.execute(
delete(MoldLockFeedback).where(
MoldLockFeedback.session_device_id.in_(session_device_ids)
)
)
db.execute(delete(ReportAuditLog).where(ReportAuditLog.report_id == report.id))
db.execute(delete(ProductionReportItem).where(ProductionReportItem.report_id == report.id))
db.delete(report)
if session is not None:
db.delete(session)
db.commit()
return len(reports)
+14
View File
@@ -0,0 +1,14 @@
from datetime import datetime, timedelta
from app.models import WorkSession
from app.timezone import now
REPORTING_EXPIRE_MINUTES = 15
REPORTING_EXPIRED_DETAIL = "报工填写已超过15分钟,请重新扫码点击重新报工"
def is_reporting_expired(session: WorkSession, current_time: datetime | None = None) -> bool:
if session.end_at is None:
return False
timestamp = current_time or now()
return timestamp - session.end_at > timedelta(minutes=REPORTING_EXPIRE_MINUTES)
+490
View File
@@ -0,0 +1,490 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import (
AttendancePoint,
AuditAction,
Equipment,
PersonRole,
Personnel,
Product,
ProductionReport,
ProductionReportItem,
WorkSession,
WorkSessionDevice,
)
from app.schemas import (
AttendancePointOut,
ClockSessionOut,
EquipmentOption,
EquipmentOut,
PersonnelOut,
ProductOption,
ProductOut,
ReportDeviceSegmentOut,
ReportItemOut,
ReportMetrics,
ReportOut,
)
from app.services.cleaning import is_cleaning_item, is_cleaning_product
from app.services.common import REPORT_STATUS_NAMES, ROLE_NAMES, as_float
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 build_result_text, calculate_report_metrics
from app.services.misc_work import is_misc_item, is_misc_product
from app.services.multi_person import is_multi_person_item, is_multi_person_product
from app.services.process_names import export_process_name
from app.services.report_lifecycle import can_edit_report, can_unvoid_report
from app.services.work_schedule import WorkScheduleConfig
from app.timezone import now
def temporary_expired(person: Personnel) -> bool:
return bool(
person.is_temporary
and (person.temporary_expires_at is None or person.temporary_expires_at <= now())
)
def attendance_point_out(point: AttendancePoint) -> AttendancePointOut:
return AttendancePointOut(
name=point.name,
latitude=None if point.latitude is None else float(point.latitude),
longitude=None if point.longitude is None else float(point.longitude),
radius_meters=int(point.radius_meters or 500),
day_start=point.day_start,
day_end=point.day_end,
lunch_start=point.lunch_start,
lunch_end=point.lunch_end,
dinner_start=point.dinner_start,
dinner_end=point.dinner_end,
overtime_start=point.overtime_start,
overtime_end=point.overtime_end,
night_start=point.night_start,
night_end=point.night_end,
remark=point.remark,
is_active=bool(point.is_active),
created_at=point.created_at,
updated_at=point.updated_at,
)
def personnel_out(person: Personnel, role=None) -> PersonnelOut:
selected_role = role if role is not None else getattr(person, "role", None)
roles = sorted([item.role for item in person.roles], key=lambda item: str(item))
points = sorted(
[item.attendance_point for item in getattr(person, "attendance_points", []) if item.attendance_point is not None],
key=lambda item: item.name,
)
return PersonnelOut(
phone=person.phone,
name=person.name,
role=selected_role,
role_name=ROLE_NAMES.get(str(selected_role), str(selected_role)),
roles=roles,
role_names=[ROLE_NAMES.get(str(item), str(item)) for item in roles],
attendance_point_names=[point.name for point in points],
attendance_points=[attendance_point_out(point) for point in points],
is_temporary=bool(person.is_temporary),
temporary_expires_at=person.temporary_expires_at,
temporary_expired=temporary_expired(person),
)
def person_role_out(person_role: PersonRole) -> PersonnelOut:
return personnel_out(person_role.person, person_role.role)
def personnel_summary_out(person: Personnel) -> dict:
roles = sorted([str(item.role) for item in person.roles])
points = sorted(
[item.attendance_point for item in getattr(person, "attendance_points", []) if item.attendance_point is not None],
key=lambda item: item.name,
)
return {
"phone": person.phone,
"name": person.name,
"roles": roles,
"role_names": [ROLE_NAMES.get(role, role) for role in roles],
"role": roles[0] if roles else "",
"role_name": "、".join(ROLE_NAMES.get(role, role) for role in roles),
"attendance_point_names": [point.name for point in points],
"attendance_points": [attendance_point_out(point) for point in points],
"is_temporary": bool(person.is_temporary),
"temporary_expires_at": person.temporary_expires_at,
"temporary_expired": temporary_expired(person),
}
def product_out(product: Product) -> ProductOut:
optional_float = lambda value: None if value is None else as_float(value)
return ProductOut(
attendance_point_name=product.attendance_point_name,
project_no=product.project_no,
product_name=product.product_name,
profile_no=product.profile_no,
material_code=product.material_code,
material_name=product.material_name,
supplier=product.supplier,
product_net_weight_kg=optional_float(product.product_net_weight_kg),
product_gross_weight_kg=optional_float(product.product_gross_weight_kg),
scrap_loss_rate=optional_float(product.scrap_loss_rate),
waste_price_yuan_per_kg=optional_float(product.waste_price_yuan_per_kg),
device_no=product.device_no,
process_name=product.process_name,
stamping_method=product.stamping_method,
operator_count=as_float(product.operator_count),
process_unit_price_yuan=as_float(product.process_unit_price_yuan),
standard_beat=as_float(product.standard_beat),
standard_workload=as_float(product.standard_workload),
display_name=f"{product.project_no} / {mold_process_display_name(product.product_name, product.process_name, product.stamping_method)}",
)
def product_option(product: Product) -> ProductOption:
data = product_out(product).model_dump()
data["is_cleaning"] = is_cleaning_product(product)
data["is_misc"] = is_misc_product(product)
data["is_continuous_die"] = is_continuous_die_product(product)
data["is_multi_person"] = is_multi_person_product(product)
return ProductOption(**data)
def equipment_out(equipment: Equipment) -> EquipmentOut:
return EquipmentOut(
attendance_point_name=equipment.attendance_point_name,
device_no=equipment.device_no,
device_type=equipment.device_type or "冲压设备",
remark=equipment.remark,
created_at=equipment.created_at,
updated_at=equipment.updated_at,
)
def equipment_option(equipment: Equipment) -> EquipmentOption:
display_name = equipment.device_no
if equipment.remark:
display_name = f"{equipment.device_no} / {equipment.remark}"
return EquipmentOption(
attendance_point_name=equipment.attendance_point_name,
device_no=equipment.device_no,
device_type=equipment.device_type or "冲压设备",
remark=equipment.remark,
display_name=display_name,
)
def _session_device_stamping_method(
db: Session | None,
session: WorkSession,
device: WorkSessionDevice,
) -> str | None:
if db is None:
return None
point_name = device.attendance_point_name or session.attendance_point_name
product = db.scalar(
select(Product)
.where(
Product.attendance_point_name == 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 product.stamping_method if product else None
def session_out(session: WorkSession, db: Session | None = None) -> ClockSessionOut:
return ClockSessionOut(
id=session.id,
attendance_point_name=session.attendance_point_name,
employee_phone=session.employee_phone,
start_at=session.start_at,
end_at=session.end_at,
status=str(session.status),
devices=[
mold_process_display_name(
device.device_no,
device.process_name,
_session_device_stamping_method(db, session, device),
)
for device in session.devices
],
)
def _changed(old_value, new_value) -> bool:
return str(old_value or "") != str(new_value or "")
def _parse_datetime(value) -> datetime | None:
if isinstance(value, datetime):
return value
if not value:
return None
try:
return datetime.fromisoformat(str(value))
except ValueError:
return None
def _datetime_changed(old_value, new_value) -> bool:
old_dt = _parse_datetime(old_value)
new_dt = _parse_datetime(new_value)
if old_dt is None or new_dt is None:
return _changed(old_value, new_value)
if old_dt.replace(second=0, microsecond=0) == new_dt.replace(second=0, microsecond=0):
return False
return old_dt != new_dt
def _numeric_changed(old_value, new_value) -> bool:
return round(as_float(old_value), 4) != round(as_float(new_value), 4)
def _process_name_changed(old_value, new_value) -> bool:
return export_process_name(old_value) != export_process_name(new_value)
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 report_correction_maps(report: ProductionReport) -> tuple[dict, dict[int, dict], dict[int, dict]]:
logs = [
log for log in getattr(report, "audit_logs", [])
if log.action == AuditAction.approve_with_correction and log.before_json
]
if not logs:
return {}, {}, {}
log = sorted(logs, key=lambda item: item.created_at)[-1]
before = log.before_json or {}
report_corrections = {}
if _datetime_changed(before.get("start_at"), report.start_at.isoformat()):
report_corrections["start_at"] = before.get("start_at")
if _datetime_changed(before.get("end_at"), report.end_at.isoformat()):
report_corrections["end_at"] = before.get("end_at")
if _changed(before.get("review_remark"), report.review_remark):
report_corrections["review_remark"] = before.get("review_remark")
before_items = {
item.get("id"): item
for item in before.get("items", [])
if item.get("id") is not None
}
item_corrections: dict[int, dict] = {}
text_fields = [
"device_no",
"project_no",
"product_name",
"raw_material_batch_no",
"process_name",
"stamping_method",
"remark",
]
numeric_fields = ["process_unit_price_yuan", "changeover_count", "good_qty", "defect_qty", "scrap_qty"]
for item in report.items:
old = before_items.get(item.id)
if not old:
continue
changes = {}
for field in text_fields:
if field == "process_name":
if _process_name_changed(old.get(field), getattr(item, field)):
changes[field] = export_process_name(old.get(field))
elif _changed(old.get(field), getattr(item, field)):
changes[field] = old.get(field)
for field in numeric_fields:
if _numeric_changed(old.get(field), getattr(item, field)):
changes[field] = as_float(old.get(field))
if changes:
item_corrections[item.id] = changes
before_devices = {
device.get("id"): device
for device in before.get("devices", [])
if device.get("id") is not None
}
device_corrections: dict[int, dict] = {}
for device in _ordered_session_devices(report.session if report.session else None):
old = before_devices.get(device.id)
if old and _datetime_changed(old.get("scanned_at"), device.scanned_at.isoformat()):
device_corrections[device.id] = {"scanned_at": old.get("scanned_at")}
return report_corrections, item_corrections, device_corrections
def report_device_segment_out(
device: WorkSessionDevice,
corrections: dict | None = None,
stamping_method: str | None = None,
) -> ReportDeviceSegmentOut:
process_name = device.process_name or ""
display_name = mold_process_display_name(device.device_no, process_name, stamping_method)
return ReportDeviceSegmentOut(
id=device.id,
attendance_point_name=device.attendance_point_name,
device_no=device.device_no,
mold_name=device.device_no,
process_name=process_name,
stamping_method=stamping_method,
display_name=display_name,
scanned_at=device.scanned_at,
released_at=device.released_at,
sort_order=int(device.sort_order or 0),
corrections=corrections or {},
)
def report_item_out(item: ProductionReportItem, corrections: dict | None = None) -> ReportItemOut:
return ReportItemOut(
id=item.id,
attendance_point_name=item.attendance_point_name,
device_no=item.device_no,
project_no=item.project_no,
product_name=item.product_name,
material_code=item.material_code,
material_name=item.material_name,
raw_material_batch_no=item.raw_material_batch_no,
process_name=item.process_name,
stamping_method=item.stamping_method,
operator_count=as_float(item.operator_count),
process_unit_price_yuan=as_float(item.process_unit_price_yuan),
changeover_count=as_float(item.changeover_count),
standard_beat=as_float(item.standard_beat),
standard_workload=as_float(item.standard_workload),
good_qty=as_float(item.good_qty),
defect_qty=as_float(item.defect_qty),
scrap_qty=as_float(item.scrap_qty),
allocated_minutes=as_float(item.allocated_minutes),
started_at=item.started_at,
is_misc=is_misc_item(item),
is_continuous_die=is_continuous_die_item(item),
is_multi_person=is_multi_person_item(item),
remark=item.remark,
corrections=corrections or {},
)
def _report_is_cleaning(report: ProductionReport) -> bool:
return bool(report.items) and all(is_cleaning_item(item) for item in report.items)
def _report_is_misc_only(report: ProductionReport) -> bool:
return bool(report.items) and all(is_misc_item(item) for item in report.items)
def _cleaning_report_metrics(report: ProductionReport) -> dict:
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
)
return {
"duration_minutes": 0,
"effective_minutes": 0,
"shift_distribution_text": "清洗报工不计工时",
"shift_day_minutes": 0,
"shift_overtime_minutes": 0,
"shift_night_minutes": 0,
"shift_other_minutes": 0,
"shift_meal_break_minutes": 0,
"total_good_qty": total_good_qty,
"total_output_qty": total_output_qty,
"actual_beat": 0,
"standard_beat": 0,
"expected_workload": 0,
"pace_rate": 0,
"workload_rate": 0,
}
def report_out(report: ProductionReport, schedule: WorkScheduleConfig | None = None) -> ReportOut:
is_cleaning_report = _report_is_cleaning(report)
calculated_metrics = (
_cleaning_report_metrics(report)
if is_cleaning_report
else calculate_report_metrics(
report.start_at,
report.end_at,
list(report.items),
devices=report.session.devices if report.session else None,
schedule=schedule,
)
)
metrics = {
"effective_minutes": as_float(calculated_metrics["effective_minutes"]),
"shift_distribution_text": calculated_metrics["shift_distribution_text"],
"shift_day_minutes": as_float(calculated_metrics["shift_day_minutes"]),
"shift_overtime_minutes": as_float(calculated_metrics["shift_overtime_minutes"]),
"shift_night_minutes": as_float(calculated_metrics["shift_night_minutes"]),
"shift_other_minutes": as_float(calculated_metrics["shift_other_minutes"]),
"shift_meal_break_minutes": as_float(calculated_metrics["shift_meal_break_minutes"]),
"total_good_qty": as_float(calculated_metrics["total_good_qty"]),
"total_output_qty": as_float(calculated_metrics["total_output_qty"]),
"actual_beat": as_float(calculated_metrics["actual_beat"]),
"standard_beat": as_float(calculated_metrics["standard_beat"]),
"expected_workload": as_float(calculated_metrics["expected_workload"]),
"pace_rate": as_float(calculated_metrics["pace_rate"]),
"workload_rate": as_float(calculated_metrics["workload_rate"]),
}
report_corrections, item_corrections, device_corrections = report_correction_maps(report)
is_modified = bool(report_corrections or item_corrections or device_corrections)
stamping_by_mold = {
(item.product_name, item.process_name or ""): item.stamping_method
for item in report.items
}
return ReportOut(
id=report.id,
attendance_point_name=report.attendance_point_name,
session_id=report.session_id,
employee_phone=report.employee_phone,
employee_name=report.employee.name if report.employee else "",
report_date=report.report_date,
start_at=report.start_at,
end_at=report.end_at,
duration_minutes=as_float(calculated_metrics["duration_minutes"]),
break_minutes=as_float(report.break_minutes),
status=report.status,
status_name=REPORT_STATUS_NAMES.get(str(report.status), str(report.status)),
reviewer_phone=report.reviewer_phone,
reviewer_name=report.reviewer.name if report.reviewer else None,
reviewed_at=report.reviewed_at,
reject_reason=report.reject_reason,
review_remark=report.review_remark,
submitted_at=report.submitted_at,
is_system_auto_submitted=bool(report.is_system_auto_submitted),
auto_submit_reason=report.auto_submit_reason,
is_multi_person_assistant=bool(report.is_multi_person_assistant),
multi_person_source_report_id=report.multi_person_source_report_id,
is_voided=bool(report.is_voided),
voided_at=report.voided_at,
voided_by=report.voided_by,
voided_by_name=report.voider.name if report.voider else None,
unvoid_deadline_at=report.unvoid_deadline_at,
can_void=not bool(report.is_voided),
can_unvoid=can_unvoid_report(report),
can_edit=can_edit_report(report),
is_modified=is_modified,
device_segments=[
report_device_segment_out(
device,
device_corrections.get(device.id, {}),
stamping_by_mold.get((device.device_no, device.process_name or "")),
)
for device in _ordered_session_devices(report.session if report.session else None)
],
items=[report_item_out(item, item_corrections.get(item.id, {})) for item in report.items],
metrics=ReportMetrics(**metrics),
result_text=(
"清洗报工已提交,等待管理员审核"
if is_cleaning_report
else ("处理杂活已提交,等待管理员审核" if _report_is_misc_only(report) else build_result_text(metrics))
),
corrections=report_corrections,
)
+256
View File
@@ -0,0 +1,256 @@
import json
import re
from dataclasses import dataclass, field
from typing import Any
from app.services.cleaning import is_cleaning_item
from app.services.common import as_float, normalize_device_no, round2
from app.services.continuous_die import is_continuous_die_item
from app.services.display_names import point_mold_display_name
from app.services.misc_work import is_misc_item
from app.services.multi_person import is_multi_person_item
DEVICE_TYPE_STAMPING = "冲压设备"
DEVICE_TYPE_CLEANING = "清洗设备"
MOLD_TYPE_NORMAL = "普通模具"
MOLD_TYPE_CLEANING = "清洗模具"
MOLD_TYPE_CONTINUOUS_DIE = "连续模"
MOLD_TYPE_MULTI_PERSON = "多人工序"
METRIC_KIND_MINUTES = "minutes"
METRIC_KIND_QUANTITY = "quantity"
TAG_CLEANING = "清洗"
TAG_CONTINUOUS_DIE = "连续模"
TAG_MULTI_PERSON = "多人协作"
@dataclass
class UsageStatRow:
id: str
category: str
name: str
metric_kind: str
object_type: str
value: float = 0
report_count: int = 0
tags: list[str] = field(default_factory=list)
attendance_point_name: str = ""
product_name: str = ""
process_name: str = ""
stamping_method: str = ""
class UsageStatAccumulator:
def __init__(self, category: str):
self.category = category
self._rows: dict[tuple[Any, ...], UsageStatRow] = {}
def add(
self,
key: tuple[Any, ...],
*,
name: str,
metric_kind: str,
object_type: str,
value: float,
tags: list[str] | None = None,
attendance_point_name: str = "",
product_name: str = "",
process_name: str = "",
stamping_method: str = "",
) -> None:
row = self._rows.get(key)
if row is None:
row = UsageStatRow(
id=_build_row_id(
self.category,
attendance_point_name,
name,
product_name,
process_name,
stamping_method,
metric_kind,
),
category=self.category,
name=name,
metric_kind=metric_kind,
object_type=object_type,
tags=[],
attendance_point_name=attendance_point_name,
product_name=product_name,
process_name=process_name,
stamping_method=stamping_method,
)
self._rows[key] = row
elif _object_type_priority(object_type) > _object_type_priority(row.object_type):
row.object_type = object_type
row.value += as_float(value)
row.report_count += 1
for tag in tags or []:
if tag and tag not in row.tags:
row.tags.append(tag)
def rows(self) -> list[UsageStatRow]:
rows = list(self._rows.values())
for row in rows:
row.value = round2(row.value)
return rows
def split_cleaning_device_nos(value: str | None) -> list[str]:
device_nos: list[str] = []
for part in (part.strip() for part in re.split(r"[、,,]", _clean(value))):
if part and part not in device_nos:
device_nos.append(part)
return device_nos
def build_usage_stats(
*,
reports: list,
category: str,
equipment_type_by_key: dict[tuple[str, str], str],
) -> list[UsageStatRow]:
if category == "device":
return _build_device_usage_stats(reports, equipment_type_by_key)
if category == "mold":
return _build_mold_usage_stats(reports)
raise ValueError("category must be device or mold")
def _build_device_usage_stats(
reports: list,
equipment_type_by_key: dict[tuple[str, str], str],
) -> list[UsageStatRow]:
acc = UsageStatAccumulator(category="device")
for item in _iter_report_items(reports):
if is_misc_item(item):
continue
point_name = _clean(getattr(item, "attendance_point_name", None))
if is_cleaning_item(item):
device_nos = split_cleaning_device_nos(getattr(item, "device_no", None))
if not device_nos:
continue
value = as_float(getattr(item, "good_qty", 0)) / len(device_nos)
for device_no in device_nos:
object_type = equipment_type_by_key.get((point_name, device_no), DEVICE_TYPE_CLEANING)
acc.add(
(point_name, device_no, METRIC_KIND_QUANTITY),
name=device_no,
metric_kind=METRIC_KIND_QUANTITY,
object_type=object_type,
value=value,
tags=[TAG_CLEANING],
attendance_point_name=point_name,
)
continue
device_no = normalize_device_no(getattr(item, "device_no", None))
if not device_no:
continue
acc.add(
(point_name, device_no, METRIC_KIND_MINUTES),
name=device_no,
metric_kind=METRIC_KIND_MINUTES,
object_type=equipment_type_by_key.get((point_name, device_no), DEVICE_TYPE_STAMPING),
value=as_float(getattr(item, "allocated_minutes", 0)),
attendance_point_name=point_name,
)
return acc.rows()
def _build_mold_usage_stats(reports: list) -> list[UsageStatRow]:
acc = UsageStatAccumulator(category="mold")
for item in _iter_report_items(reports):
if is_misc_item(item):
continue
point_name = _clean(getattr(item, "attendance_point_name", None))
product_name = _clean(getattr(item, "product_name", None))
process_name = _clean(getattr(item, "process_name", None))
stamping_method = _clean(getattr(item, "stamping_method", None))
metric_kind = METRIC_KIND_QUANTITY if is_cleaning_item(item) else METRIC_KIND_MINUTES
value_field = "good_qty" if metric_kind == METRIC_KIND_QUANTITY else "allocated_minutes"
tags = _item_tags(item)
object_type = _mold_object_type(item)
acc.add(
(point_name, product_name, process_name, stamping_method, metric_kind),
name=point_mold_display_name(point_name, product_name, process_name, stamping_method),
metric_kind=metric_kind,
object_type=object_type,
value=as_float(getattr(item, value_field, 0)),
tags=tags,
attendance_point_name=point_name,
product_name=product_name,
process_name=process_name,
stamping_method=stamping_method,
)
return acc.rows()
def _iter_report_items(reports: list):
for report in reports:
yield from getattr(report, "items", []) or []
def _item_tags(item) -> list[str]:
tags: list[str] = []
if is_cleaning_item(item):
tags.append(TAG_CLEANING)
if is_continuous_die_item(item):
tags.append(TAG_CONTINUOUS_DIE)
if is_multi_person_item(item):
tags.append(TAG_MULTI_PERSON)
return tags
def _mold_object_type(item) -> str:
if is_cleaning_item(item):
return MOLD_TYPE_CLEANING
if is_continuous_die_item(item):
return MOLD_TYPE_CONTINUOUS_DIE
if is_multi_person_item(item):
return MOLD_TYPE_MULTI_PERSON
return MOLD_TYPE_NORMAL
def _object_type_priority(value: str) -> int:
return {
MOLD_TYPE_NORMAL: 0,
DEVICE_TYPE_STAMPING: 0,
DEVICE_TYPE_CLEANING: 0,
MOLD_TYPE_MULTI_PERSON: 1,
MOLD_TYPE_CONTINUOUS_DIE: 2,
MOLD_TYPE_CLEANING: 3,
}.get(value, 0)
def _build_row_id(
category: str,
attendance_point_name: str,
name: str,
product_name: str,
process_name: str,
stamping_method: str,
metric_kind: str,
) -> str:
return json.dumps(
[
_clean(category),
_clean(attendance_point_name),
_clean(name),
_clean(product_name),
_clean(process_name),
_clean(stamping_method),
_clean(metric_kind),
],
ensure_ascii=False,
separators=(",", ":"),
)
def _clean(value: str | None) -> str:
return "" if value is None else str(value).strip()
+83
View File
@@ -0,0 +1,83 @@
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
DEVICE_HEADERS = ["考勤点", "设备号", "设备类型", "使用时长(分钟)", "使用时长(小时)", "使用时长(天)", "清洗数量", "报工次数"]
MOLD_HEADERS = ["考勤点", "产品名称", "工序", "冲压方式", "使用时长(分钟)", "使用时长(小时)", "使用时长(天)", "清洗数量", "报工次数"]
def export_usage_stats_rows(device_rows: list, mold_rows: list) -> bytes:
workbook = Workbook()
device_sheet = workbook.active
device_sheet.title = "设备统计"
mold_sheet = workbook.create_sheet("模具统计")
_write_device_sheet(device_sheet, device_rows)
_write_mold_sheet(mold_sheet, mold_rows)
output = BytesIO()
workbook.save(output)
return output.getvalue()
def _write_device_sheet(sheet, rows: list) -> None:
_append_headers(sheet, DEVICE_HEADERS)
for row in rows:
minutes, hours, days, quantity = _metric_values(row)
sheet.append(
[
row.attendance_point_name,
row.name,
row.object_type,
minutes,
hours,
days,
quantity,
row.report_count,
]
)
_set_widths(sheet, [16, 16, 16, 16, 16, 14, 12, 12])
def _write_mold_sheet(sheet, rows: list) -> None:
_append_headers(sheet, MOLD_HEADERS)
for row in rows:
minutes, hours, days, quantity = _metric_values(row)
sheet.append(
[
row.attendance_point_name,
row.product_name,
row.process_name,
row.stamping_method,
minutes,
hours,
days,
quantity,
row.report_count,
]
)
_set_widths(sheet, [16, 24, 12, 16, 16, 16, 14, 12, 12])
def _metric_values(row) -> tuple[float, float, float, float]:
if row.metric_kind == "quantity":
return 0, 0, 0, row.value
minutes = row.value
return minutes, round(minutes / 60, 2), round(minutes / 1440, 2), 0
def _append_headers(sheet, headers: list[str]) -> None:
sheet.append(headers)
fill = PatternFill(fill_type="solid", fgColor="FFEFEFEF")
font = Font(bold=True)
for cell in sheet[1]:
cell.fill = fill
cell.font = font
def _set_widths(sheet, widths: list[int]) -> None:
for index, width in enumerate(widths, start=1):
column_letter = sheet.cell(row=1, column=index).column_letter
sheet.column_dimensions[column_letter].width = width
+216
View File
@@ -0,0 +1,216 @@
from pathlib import Path
from io import BytesIO
import hashlib
import os
import re
from urllib.parse import quote
import httpx
from PIL import Image, ImageDraw, ImageFont
from app.config import settings
class WechatConfigError(RuntimeError):
pass
def _qrcode_file_suffix(content_type: str) -> str:
normalized = content_type.lower()
if "image/png" in normalized:
return ".png"
if "image/jpeg" in normalized or "image/jpg" in normalized:
return ".jpg"
return ".jpg"
def _qrcode_file_name(device_no: str, suffix: str) -> str:
safe_name = re.sub(r"[^0-9A-Za-z_.-]+", "_", device_no).strip("._")[:80] or "device"
digest = hashlib.sha1(device_no.encode("utf-8")).hexdigest()[:8]
return f"{safe_name}-{digest}{suffix}"
def _font_candidates() -> list[Path]:
configured = os.getenv("JH_QR_FONT_PATH")
paths = []
if configured:
paths.append(Path(configured))
paths.extend([
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"),
Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"),
Path("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"),
Path("/usr/local/share/fonts/NotoSansCJK-Regular.ttc"),
Path("/usr/local/share/fonts/NotoSansSC-Regular.otf"),
Path("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"),
Path("/usr/share/fonts/truetype/arphic/ukai.ttc"),
Path("/System/Library/Fonts/PingFang.ttc"),
Path("/System/Library/Fonts/Hiragino Sans GB.ttc"),
Path("/System/Library/Fonts/STHeiti Medium.ttc"),
Path("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"),
Path("/Library/Fonts/Arial Unicode.ttf"),
Path("C:/Windows/Fonts/msyh.ttc"),
Path("C:/Windows/Fonts/simhei.ttf"),
])
return paths
def _font_can_render_chinese(font) -> bool:
glyphs: set[bytes] = set()
for char in "模具宁嘉恒科":
image = Image.new("L", (140, 140), 0)
draw = ImageDraw.Draw(image)
draw.text((8, 8), char, font=font, fill=255)
bbox = image.getbbox()
if bbox is None:
return False
glyphs.add(image.crop(bbox).tobytes())
return len(glyphs) >= 5
def _load_label_font(size: int):
for path in _font_candidates():
if path.exists():
try:
font = ImageFont.truetype(str(path), size=size)
if _font_can_render_chinese(font):
return font
except OSError:
continue
raise RuntimeError("服务器缺少可用中文字体,无法生成带中文底部文字的二维码")
def _text_width(draw: ImageDraw.ImageDraw, text: str, font) -> int:
if not text:
return 0
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
def _wrap_label_text(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]:
normalized = " ".join(str(text or "").split())
if not normalized:
return []
lines: list[str] = []
current = ""
for char in normalized:
candidate = f"{current}{char}"
if current and _text_width(draw, candidate, font) > max_width:
lines.append(current)
current = char
continue
current = candidate
if current:
lines.append(current)
return lines
def _draw_qrcode_label(content: bytes, suffix: str, label: str) -> bytes:
label_text = str(label or "").strip()
if not label_text:
return content
image = Image.open(BytesIO(content)).convert("RGB")
original_width, original_height = image.size
width = max(original_width, 860)
if width != original_width:
height = round(original_height * width / original_width)
image = image.resize((width, height), Image.Resampling.LANCZOS)
else:
height = original_height
font_size = max(54, min(78, width // 12))
font = _load_label_font(font_size)
line_gap = max(10, font_size // 4)
horizontal_padding = max(42, width // 14)
vertical_padding = max(28, width // 18)
measure = ImageDraw.Draw(Image.new("RGB", (width, 10), "white"))
lines = _wrap_label_text(measure, label_text, font, width - horizontal_padding * 2)
if len(lines) > 5:
lines = lines[:5]
ellipsis = "..."
while lines[-1] and _text_width(measure, f"{lines[-1]}{ellipsis}", font) > width - horizontal_padding * 2:
lines[-1] = lines[-1][:-1]
lines[-1] = f"{lines[-1]}{ellipsis}"
line_height = font_size + line_gap
label_height = vertical_padding * 2 + line_height * max(1, len(lines)) - line_gap
canvas = Image.new("RGB", (width, height + label_height), "white")
canvas.paste(image, (0, 0))
draw = ImageDraw.Draw(canvas)
y = height + vertical_padding
for line in lines:
draw.text((horizontal_padding, y), line, font=font, fill=(20, 32, 48))
y += line_height
output = BytesIO()
if suffix.lower() in {".jpg", ".jpeg"}:
canvas.save(output, format="JPEG", quality=95)
else:
canvas.save(output, format="PNG")
return output.getvalue()
async def get_access_token() -> str:
if not settings.wechat_app_id or not settings.wechat_app_secret:
raise WechatConfigError("未配置 WECHAT_APP_ID 或 WECHAT_APP_SECRET")
params = {
"grant_type": "client_credential",
"appid": settings.wechat_app_id,
"secret": settings.wechat_app_secret,
}
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get("https://api.weixin.qq.com/cgi-bin/token", params=params)
data = response.json()
token = data.get("access_token")
if not token:
raise RuntimeError(data.get("errmsg") or "获取微信 access_token 失败")
return token
async def get_phone_number(phone_code: str) -> str:
token = await get_access_token()
url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={token}"
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(url, json={"code": phone_code})
data = response.json()
phone_info = data.get("phone_info") or {}
phone = phone_info.get("phoneNumber") or phone_info.get("purePhoneNumber")
if not phone:
raise RuntimeError(data.get("errmsg") or "获取微信手机号失败")
return phone
async def create_device_qrcode(
device_no: str,
page: str,
scene: str,
public_base_url: str,
label: str | None = None,
) -> str | None:
token = await get_access_token()
url = f"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={token}"
payload = {
"scene": scene,
"page": page,
"check_path": False,
"env_version": settings.wechat_qr_env_version,
}
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(url, json=payload)
content_type = response.headers.get("content-type", "")
if "application/json" in content_type:
data = response.json()
raise RuntimeError(data.get("errmsg") or "生成设备小程序码失败")
qr_dir = settings.upload_path / "qrcodes"
qr_dir.mkdir(parents=True, exist_ok=True)
suffix = _qrcode_file_suffix(content_type)
file_name = _qrcode_file_name(device_no, suffix)
file_path = Path(qr_dir) / file_name
file_path.write_bytes(_draw_qrcode_label(response.content, suffix, label or device_no))
return f"{public_base_url.rstrip('/')}/uploads/qrcodes/{quote(file_name, safe='')}"
+158
View File
@@ -0,0 +1,158 @@
from dataclasses import dataclass
import re
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.models import AttendancePoint, WorkSchedule
TIME_PATTERN = re.compile(r"^\d{1,2}:\d{2}$")
@dataclass(frozen=True)
class WorkScheduleConfig:
day_start: str
day_end: str
lunch_start: str
lunch_end: str
dinner_start: str
dinner_end: str
overtime_start: str
overtime_end: str
night_start: str
night_end: str
DEFAULT_WORK_SCHEDULE = {
"day_start": "08:00",
"day_end": "17:20",
"lunch_start": "11:40",
"lunch_end": "12:40",
"dinner_start": "17:20",
"dinner_end": "18:00",
"overtime_start": "18:00",
"overtime_end": "20:00",
"night_start": "20:00",
"night_end": "06:00",
}
DEFAULT_WORK_SCHEDULE_CONFIG = WorkScheduleConfig(**DEFAULT_WORK_SCHEDULE)
DEFAULT_ATTENDANCE_RADIUS_METERS = 500
DEFAULT_AUTO_SUBMIT_HOURS = 15
def normalize_time_text(value: str) -> str:
text = str(value or "").strip()
if not TIME_PATTERN.match(text):
raise ValueError("时间格式必须为 HH:MM")
hour_text, minute_text = text.split(":", 1)
hour = int(hour_text)
minute = int(minute_text)
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
raise ValueError("时间必须在 00:00-23:59 范围内")
return f"{hour:02d}:{minute:02d}"
def schedule_to_config(schedule: WorkSchedule | None) -> WorkScheduleConfig:
if schedule is None:
return DEFAULT_WORK_SCHEDULE_CONFIG
return WorkScheduleConfig(
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,
)
def attendance_point_to_config(point: AttendancePoint | None) -> WorkScheduleConfig | None:
if point is None:
return None
return WorkScheduleConfig(
day_start=point.day_start or DEFAULT_WORK_SCHEDULE["day_start"],
day_end=point.day_end or DEFAULT_WORK_SCHEDULE["day_end"],
lunch_start=point.lunch_start or DEFAULT_WORK_SCHEDULE["lunch_start"],
lunch_end=point.lunch_end or DEFAULT_WORK_SCHEDULE["lunch_end"],
dinner_start=point.dinner_start or DEFAULT_WORK_SCHEDULE["dinner_start"],
dinner_end=point.dinner_end or DEFAULT_WORK_SCHEDULE["dinner_end"],
overtime_start=point.overtime_start or DEFAULT_WORK_SCHEDULE["overtime_start"],
overtime_end=point.overtime_end or DEFAULT_WORK_SCHEDULE["overtime_end"],
night_start=point.night_start or DEFAULT_WORK_SCHEDULE["night_start"],
night_end=point.night_end or DEFAULT_WORK_SCHEDULE["night_end"],
)
def get_work_schedule(db: Session) -> WorkSchedule:
schedule = db.get(WorkSchedule, 1)
if schedule is not None:
return schedule
schedule = WorkSchedule(id=1, **DEFAULT_WORK_SCHEDULE)
db.add(schedule)
db.commit()
db.refresh(schedule)
return schedule
def get_work_schedule_config(db: Session, attendance_point_name: str | None = None) -> WorkScheduleConfig:
try:
point_name = str(attendance_point_name or "").strip()
if point_name:
point_config = attendance_point_to_config(db.get(AttendancePoint, point_name))
if point_config is not None:
return point_config
return schedule_to_config(get_work_schedule(db))
except SQLAlchemyError:
db.rollback()
return DEFAULT_WORK_SCHEDULE_CONFIG
def normalize_schedule_payload(payload) -> dict[str, str]:
return {
field: normalize_time_text(getattr(payload, field, DEFAULT_WORK_SCHEDULE[field]))
for field in DEFAULT_WORK_SCHEDULE
}
def update_work_schedule(db: Session, payload, updated_by: str, *, commit: bool = True) -> WorkSchedule:
values = normalize_schedule_payload(payload)
attendance_latitude = getattr(payload, "attendance_latitude", None)
attendance_longitude = getattr(payload, "attendance_longitude", None)
attendance_radius_meters = getattr(payload, "attendance_radius_meters", None)
if (attendance_latitude is None) != (attendance_longitude is None):
raise ValueError("考勤经纬度必须同时配置")
if attendance_latitude is not None and not (-90 <= attendance_latitude <= 90):
raise ValueError("考勤纬度必须在 -90 到 90 之间")
if attendance_longitude is not None and not (-180 <= attendance_longitude <= 180):
raise ValueError("考勤经度必须在 -180 到 180 之间")
if attendance_radius_meters is None:
attendance_radius_meters = DEFAULT_ATTENDANCE_RADIUS_METERS
if int(attendance_radius_meters) <= 0:
raise ValueError("考勤范围半径必须大于0米")
auto_submit_hours = getattr(payload, "auto_submit_hours", DEFAULT_AUTO_SUBMIT_HOURS)
try:
auto_submit_hours = float(auto_submit_hours)
except (TypeError, ValueError) as exc:
raise ValueError("系统自动提交时长必须是数字") from exc
if auto_submit_hours < 1 or auto_submit_hours > 168:
raise ValueError("系统自动提交时长必须在1到168小时之间")
schedule = get_work_schedule(db)
for field, value in values.items():
setattr(schedule, field, value)
schedule.attendance_latitude = attendance_latitude
schedule.attendance_longitude = attendance_longitude
schedule.attendance_radius_meters = int(attendance_radius_meters)
schedule.auto_submit_hours = auto_submit_hours
schedule.updated_by = updated_by
db.add(schedule)
if commit:
db.commit()
db.refresh(schedule)
else:
db.flush()
return schedule
+17
View File
@@ -0,0 +1,17 @@
from datetime import date, datetime
from zoneinfo import ZoneInfo
from app.config import settings
def tz() -> ZoneInfo:
return ZoneInfo(settings.app_timezone)
def now() -> datetime:
return datetime.now(tz()).replace(tzinfo=None)
def today() -> date:
return now().date()