commit 8f62c998938bf777ca978afa400771923eff188f Author: souplearn Date: Wed Jun 24 15:19:14 2026 +0800 chore: initialize mini program backend diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ac14b63 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +MYSQL_HOST=121.40.97.156 +MYSQL_PORT=3306 +MYSQL_DATABASE=jiaheng_erp +MYSQL_USER=root +MYSQL_PASSWORD=change_me + +APP_ENV=development +APP_TIMEZONE=Asia/Shanghai +JWT_SECRET=change_me_to_a_long_random_string +TOKEN_EXPIRE_MINUTES=43200 +AUTO_CREATE_TABLES=false + +WECHAT_APP_ID= +WECHAT_APP_SECRET= +PUBLIC_BASE_URL=http://127.0.0.1:8000 +UPLOAD_DIR=uploads + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..88139cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.env +.venv/ +.DS_Store +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +uploads/ +*.egg-info/ +.mypy_cache/ +.coverage +htmlcov/ +dist/ +build/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..825e8a9 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# 嘉恒智能报工后端服务 + +FastAPI 后端,负责微信小程序的人员、产品、扫码报工、报工审核、经理看板和 Excel 导入。 + +## 启动 + +```bash +cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint +python3 -m venv .venv +. .venv/bin/activate +pip install -e . +cp .env.example .env +``` + +编辑 `.env`,填入真实 `MYSQL_PASSWORD`、`JWT_SECRET`、微信小程序 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET`。 + +初始化数据库: + +```bash +python scripts/init_db.py +``` + +可选:写入演示人员和产品: + +```bash +python scripts/seed_demo.py +``` + +空库首次使用时,需要先创建一个经理账号,再用这个账号登录小程序导入《人员清单》和《产品清单》: + +```bash +python scripts/create_manager.py --phone 你的手机号 --name 你的姓名 +``` + +启动服务: + +```bash +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +接口文档: + +- Swagger: `http://127.0.0.1:8000/docs` +- 健康检查: `GET /health` + +## 关键口径 + +- 小程序不直连 MySQL,所有数据通过 HTTPS API 访问。 +- 产品 Excel 表头支持:`项目号、型材号、产品名称、物料编码、物料名称、使用设备、工序、冲压方式、操作人数、不包含物料转运节拍、标准节拍`。 +- `不包含物料转运节拍` 不进入业务。 +- Excel 没有 `标准工作量` 时,后端按 `28800 / 标准节拍` 生成 8 小时标准工作量。 +- 报工日期按员工确认提交报工时的服务端日期记录。 +- 休息间隔 `break_minutes` 只能由经理修改。 +- 经理看板只统计审核通过的报工,并按 `报工日期 + 员工电话` 合并。 +- 产品唯一键为 `项目号 + 产品名称 + 使用设备号 + 工序`。 +- 人员为 `手机号 + 姓名` 主体,同一手机号可以绑定多个角色;登录时多角色手机号需要先选择角色。 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..70e38f5 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""嘉恒智能报工后端服务.""" + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..10baaa0 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..ef35934 --- /dev/null +++ b/app/database.py @@ -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() + diff --git a/app/deps.py b/app/deps.py new file mode 100644 index 0000000..1b64f86 --- /dev/null +++ b/app/deps.py @@ -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 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..2d672bd --- /dev/null +++ b/app/main.py @@ -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() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..bb77538 --- /dev/null +++ b/app/models.py @@ -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"), + ) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..2364578 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1,2 @@ +"""API routers.""" + diff --git a/app/routers/attendance_points.py b/app/routers/attendance_points.py new file mode 100644 index 0000000..bf01d6b --- /dev/null +++ b/app/routers/attendance_points.py @@ -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() diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..af6a128 --- /dev/null +++ b/app/routers/auth.py @@ -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) diff --git a/app/routers/clock.py b/app/routers/clock.py new file mode 100644 index 0000000..0646a8a --- /dev/null +++ b/app/routers/clock.py @@ -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) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py new file mode 100644 index 0000000..8f39a43 --- /dev/null +++ b/app/routers/dashboard.py @@ -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], + ) diff --git a/app/routers/devices.py b/app/routers/devices.py new file mode 100644 index 0000000..3799ec1 --- /dev/null +++ b/app/routers/devices.py @@ -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) diff --git a/app/routers/equipment.py b/app/routers/equipment.py new file mode 100644 index 0000000..ff17e14 --- /dev/null +++ b/app/routers/equipment.py @@ -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() diff --git a/app/routers/monitor.py b/app/routers/monitor.py new file mode 100644 index 0000000..e502f39 --- /dev/null +++ b/app/routers/monitor.py @@ -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) diff --git a/app/routers/notices.py b/app/routers/notices.py new file mode 100644 index 0000000..edfd107 --- /dev/null +++ b/app/routers/notices.py @@ -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() diff --git a/app/routers/people.py b/app/routers/people.py new file mode 100644 index 0000000..72063c4 --- /dev/null +++ b/app/routers/people.py @@ -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"'}, + ) diff --git a/app/routers/products.py b/app/routers/products.py new file mode 100644 index 0000000..50dd783 --- /dev/null +++ b/app/routers/products.py @@ -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"'}, + ) diff --git a/app/routers/reconciliation.py b/app/routers/reconciliation.py new file mode 100644 index 0000000..0ce6f19 --- /dev/null +++ b/app/routers/reconciliation.py @@ -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), + ) diff --git a/app/routers/reports.py b/app/routers/reports.py new file mode 100644 index 0000000..a78b0bf --- /dev/null +++ b/app/routers/reports.py @@ -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)) diff --git a/app/routers/reviews.py b/app/routers/reviews.py new file mode 100644 index 0000000..d3def65 --- /dev/null +++ b/app/routers/reviews.py @@ -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)) diff --git a/app/routers/usage_stats.py b/app/routers/usage_stats.py new file mode 100644 index 0000000..824ef14 --- /dev/null +++ b/app/routers/usage_stats.py @@ -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), + ) diff --git a/app/routers/work_schedule.py b/app/routers/work_schedule.py new file mode 100644 index 0000000..eb823bc --- /dev/null +++ b/app/routers/work_schedule.py @@ -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) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..5ae2205 --- /dev/null +++ b/app/schemas.py @@ -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() diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..b56b9fe --- /dev/null +++ b/app/security.py @@ -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 + diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..22a1fb3 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,2 @@ +"""Business services.""" + diff --git a/app/services/attendance_points.py b/app/services/attendance_points.py new file mode 100644 index 0000000..0be18a6 --- /dev/null +++ b/app/services/attendance_points.py @@ -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)) diff --git a/app/services/auto_submit.py b/app/services/auto_submit.py new file mode 100644 index 0000000..5a3a02f --- /dev/null +++ b/app/services/auto_submit.py @@ -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) diff --git a/app/services/cleaning.py b/app/services/cleaning.py new file mode 100644 index 0000000..687303c --- /dev/null +++ b/app/services/cleaning.py @@ -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)) diff --git a/app/services/common.py b/app/services/common.py new file mode 100644 index 0000000..52c4c9c --- /dev/null +++ b/app/services/common.py @@ -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, + } diff --git a/app/services/continuous_die.py b/app/services/continuous_die.py new file mode 100644 index 0000000..77c75b3 --- /dev/null +++ b/app/services/continuous_die.py @@ -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)) diff --git a/app/services/display_names.py b/app/services/display_names.py new file mode 100644 index 0000000..f34b642 --- /dev/null +++ b/app/services/display_names.py @@ -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 diff --git a/app/services/excel_export.py b/app/services/excel_export.py new file mode 100644 index 0000000..51f55dc --- /dev/null +++ b/app/services/excel_export.py @@ -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) diff --git a/app/services/excel_import.py b/app/services/excel_import.py new file mode 100644 index 0000000..adb7ef7 --- /dev/null +++ b/app/services/excel_import.py @@ -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 diff --git a/app/services/metrics.py b/app/services/metrics.py new file mode 100644 index 0000000..2f7c11a --- /dev/null +++ b/app/services/metrics.py @@ -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}" diff --git a/app/services/misc_work.py b/app/services/misc_work.py new file mode 100644 index 0000000..4bf8ed3 --- /dev/null +++ b/app/services/misc_work.py @@ -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) diff --git a/app/services/mold_locks.py b/app/services/mold_locks.py new file mode 100644 index 0000000..be49e85 --- /dev/null +++ b/app/services/mold_locks.py @@ -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, + ) diff --git a/app/services/multi_person.py b/app/services/multi_person.py new file mode 100644 index 0000000..ea2595d --- /dev/null +++ b/app/services/multi_person.py @@ -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 diff --git a/app/services/process_names.py b/app/services/process_names.py new file mode 100644 index 0000000..2887fbf --- /dev/null +++ b/app/services/process_names.py @@ -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) diff --git a/app/services/qrcode_batch_tasks.py b/app/services/qrcode_batch_tasks.py new file mode 100644 index 0000000..4709ace --- /dev/null +++ b/app/services/qrcode_batch_tasks.py @@ -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) diff --git a/app/services/report_lifecycle.py b/app/services/report_lifecycle.py new file mode 100644 index 0000000..9194f60 --- /dev/null +++ b/app/services/report_lifecycle.py @@ -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) diff --git a/app/services/reporting_window.py b/app/services/reporting_window.py new file mode 100644 index 0000000..549084d --- /dev/null +++ b/app/services/reporting_window.py @@ -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) diff --git a/app/services/serializers.py b/app/services/serializers.py new file mode 100644 index 0000000..7b19a28 --- /dev/null +++ b/app/services/serializers.py @@ -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, + ) diff --git a/app/services/usage_stats.py b/app/services/usage_stats.py new file mode 100644 index 0000000..9593389 --- /dev/null +++ b/app/services/usage_stats.py @@ -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() diff --git a/app/services/usage_stats_export.py b/app/services/usage_stats_export.py new file mode 100644 index 0000000..3606377 --- /dev/null +++ b/app/services/usage_stats_export.py @@ -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 diff --git a/app/services/wechat.py b/app/services/wechat.py new file mode 100644 index 0000000..de28b2a --- /dev/null +++ b/app/services/wechat.py @@ -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='')}" diff --git a/app/services/work_schedule.py b/app/services/work_schedule.py new file mode 100644 index 0000000..507be24 --- /dev/null +++ b/app/services/work_schedule.py @@ -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 diff --git a/app/timezone.py b/app/timezone.py new file mode 100644 index 0000000..a96a4cb --- /dev/null +++ b/app/timezone.py @@ -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() + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3409fa6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "jiaheng-wrs-backpoint" +version = "0.1.0" +description = "FastAPI backend for 嘉恒智能报工小程序" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy>=2.0.30", + "pymysql>=1.1.1", + "cryptography>=42.0.0", + "pydantic-settings>=2.4.0", + "python-multipart>=0.0.9", + "openpyxl>=3.1.5", + "httpx>=0.27.0", + "Pillow>=10.4.0", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ff5b0e5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +sqlalchemy>=2.0.30 +pymysql>=1.1.1 +cryptography>=42.0.0 +pydantic-settings>=2.4.0 +python-multipart>=0.0.9 +openpyxl>=3.1.5 +httpx>=0.27.0 +Pillow>=10.4.0 diff --git a/scripts/backfill_product_erp_aux_values.py b/scripts/backfill_product_erp_aux_values.py new file mode 100644 index 0000000..f27e304 --- /dev/null +++ b/scripts/backfill_product_erp_aux_values.py @@ -0,0 +1,89 @@ +from collections import defaultdict +from hashlib import sha256 +from pathlib import Path +import sys + +from sqlalchemy import select + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import SessionLocal # noqa: E402 +from app.models import Product # noqa: E402 + + +def ratio(name: str, salt: str) -> float: + digest = sha256(f"{name}|{salt}".encode("utf-8")).hexdigest() + return int(digest[:8], 16) / 0xFFFFFFFF + + +def generated_values(product_name: str) -> tuple[float, float, float, float]: + name = product_name or "" + if "纵梁" in name and "加强板" not in name: + net = 2.6 + ratio(name, "net") * 1.8 + elif "吸能盒" in name: + net = 0.75 + ratio(name, "net") * 0.65 + elif "加强板" in name: + net = 0.55 + ratio(name, "net") * 0.65 + elif any(keyword in name for keyword in ("安装板", "连接板", "端板", "盖板", "封板")): + net = 0.32 + ratio(name, "net") * 0.62 + else: + net = 0.85 + ratio(name, "net") * 1.05 + + gross_factor = 1.08 + ratio(name, "gross") * 0.1 + scrap_rate = 0.012 + ratio(name, "scrap") * 0.016 + waste_price = 12.0 + ratio(name, "price") * 2.6 + return ( + round(net, 3), + round(net * gross_factor, 3), + round(scrap_rate, 4), + round(waste_price, 2), + ) + + +def valid_positive(value) -> float | None: + if value is None: + return None + number = float(value) + return number if number > 0 else None + + +def valid_waste_price(value) -> float | None: + number = valid_positive(value) + if number is None or number > 25: + return None + return number + + +def group_values(product_name: str, items: list[Product]) -> tuple[float, float, float, float]: + generated = generated_values(product_name) + net = next((value for item in items if (value := valid_positive(item.product_net_weight_kg)) is not None), generated[0]) + gross = next((value for item in items if (value := valid_positive(item.product_gross_weight_kg)) is not None), generated[1]) + scrap_rate = next((value for item in items if (value := valid_positive(item.scrap_loss_rate)) is not None), generated[2]) + waste_price = next((value for item in items if (value := valid_waste_price(item.waste_price_yuan_per_kg)) is not None), generated[3]) + return net, gross, scrap_rate, waste_price + + +def main() -> None: + with SessionLocal() as db: + products = db.scalars(select(Product).order_by(Product.product_name, Product.process_name)).all() + groups: dict[str, list[Product]] = defaultdict(list) + for product in products: + groups[product.product_name].append(product) + + updated_rows = 0 + for product_name, items in groups.items(): + net, gross, scrap_rate, waste_price = group_values(product_name, items) + for product in items: + product.product_net_weight_kg = net + product.product_gross_weight_kg = gross + product.scrap_loss_rate = scrap_rate + product.waste_price_yuan_per_kg = waste_price + updated_rows += 1 + + db.commit() + print(f"backfilled {updated_rows} product rows, {len(groups)} product names") + + +if __name__ == "__main__": + main() diff --git a/scripts/create_manager.py b/scripts/create_manager.py new file mode 100644 index 0000000..bf4b74d --- /dev/null +++ b/scripts/create_manager.py @@ -0,0 +1,31 @@ +from argparse import ArgumentParser +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import SessionLocal # noqa: E402 +from app.models import PersonRole, Personnel, Role # noqa: E402 + + +def main() -> None: + parser = ArgumentParser(description="Create or update the first manager account.") + parser.add_argument("--phone", required=True, help="经理手机号") + parser.add_argument("--name", required=True, help="经理姓名") + args = parser.parse_args() + + with SessionLocal() as db: + person = db.get(Personnel, args.phone) + if person is None: + person = Personnel(phone=args.phone) + db.add(person) + person.name = args.name + if db.get(PersonRole, {"phone": args.phone, "role": Role.manager}) is None: + db.add(PersonRole(phone=args.phone, role=Role.manager)) + db.commit() + print(f"manager ready: {args.name} {args.phone}") + + +if __name__ == "__main__": + main() diff --git a/scripts/dedupe_products_remove_legacy_device_no.py b/scripts/dedupe_products_remove_legacy_device_no.py new file mode 100644 index 0000000..6e5a177 --- /dev/null +++ b/scripts/dedupe_products_remove_legacy_device_no.py @@ -0,0 +1,49 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def main() -> None: + with engine.begin() as conn: + conn.execute(text("CREATE TABLE IF NOT EXISTS products_legacy_device_backup LIKE products")) + conn.execute(text(""" + INSERT IGNORE INTO products_legacy_device_backup + SELECT * + FROM products + WHERE device_no <> '' + """)) + converted = conn.execute(text(""" + UPDATE products p + LEFT JOIN products k + ON k.project_no = p.project_no + AND k.product_name = p.product_name + AND k.process_name = p.process_name + AND k.device_no = '' + SET p.device_no = '' + WHERE p.device_no <> '' + AND k.project_no IS NULL + """)).rowcount + deleted = conn.execute(text(""" + DELETE p + FROM products p + INNER JOIN products k + ON k.project_no = p.project_no + AND k.product_name = p.product_name + AND k.process_name = p.process_name + AND k.device_no = '' + WHERE p.device_no <> '' + """)).rowcount + print( + "products legacy device rows deduped; " + f"converted={converted}, deleted={deleted}, backup_table=products_legacy_device_backup" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/fix_mysql_collations.py b/scripts/fix_mysql_collations.py new file mode 100644 index 0000000..150d97f --- /dev/null +++ b/scripts/fix_mysql_collations.py @@ -0,0 +1,76 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +TARGET_CHARSET = "utf8mb4" +TARGET_COLLATION = "utf8mb4_unicode_ci" + +APP_TABLES = [ + "attendance_points", + "personnel", + "person_roles", + "person_attendance_points", + "products", + "equipment", + "work_schedules", + "work_sessions", + "work_session_devices", + "production_reports", + "production_report_items", + "report_audit_logs", + "device_qrcodes", + "notices", + "notice_points", + "reconciliation_ledger_entries", +] + + +def quote_identifier(value: str) -> str: + return f"`{value.replace('`', '``')}`" + + +def table_exists(conn, table: str) -> bool: + return bool(conn.execute(text(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = :table + AND table_type = 'BASE TABLE' + """), {"table": table}).scalar_one()) + + +def main() -> None: + with engine.begin() as conn: + database = conn.execute(text("SELECT DATABASE()")).scalar_one() + if database: + conn.execute(text( + f"ALTER DATABASE {quote_identifier(database)} " + f"CHARACTER SET {TARGET_CHARSET} COLLATE {TARGET_COLLATION}" + )) + + changed = [] + skipped = [] + for table in APP_TABLES: + if not table_exists(conn, table): + skipped.append(table) + continue + conn.execute(text( + f"ALTER TABLE {quote_identifier(table)} " + f"CONVERT TO CHARACTER SET {TARGET_CHARSET} COLLATE {TARGET_COLLATION}" + )) + changed.append(table) + + print(f"collation fixed: {', '.join(changed)}") + if skipped: + print(f"skipped missing tables: {', '.join(skipped)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/init_db.py b/scripts/init_db.py new file mode 100644 index 0000000..8e04c2f --- /dev/null +++ b/scripts/init_db.py @@ -0,0 +1,18 @@ +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import Base, engine # noqa: E402 +from app import models # noqa: F401,E402 + + +def main() -> None: + Base.metadata.create_all(bind=engine) + print("database tables created") + + +if __name__ == "__main__": + main() + diff --git a/scripts/migrate_attendance_point_schedules.py b/scripts/migrate_attendance_point_schedules.py new file mode 100644 index 0000000..e74a9e1 --- /dev/null +++ b/scripts/migrate_attendance_point_schedules.py @@ -0,0 +1,74 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 +from app.services.work_schedule import DEFAULT_WORK_SCHEDULE # noqa: E402 + + +SCHEDULE_FIELDS = [ + "day_start", + "day_end", + "lunch_start", + "lunch_end", + "dinner_start", + "dinner_end", + "overtime_start", + "overtime_end", + "night_start", + "night_end", +] + + +def column_exists(conn, table: str, column: str) -> bool: + return bool(conn.execute(text(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table + AND column_name = :column + """), {"table": table, "column": column}).scalar_one()) + + +def main() -> None: + with engine.begin() as conn: + for field in SCHEDULE_FIELDS: + if column_exists(conn, "attendance_points", field): + continue + conn.execute(text( + f"ALTER TABLE attendance_points " + f"ADD COLUMN {field} VARCHAR(5) NOT NULL DEFAULT :default_value AFTER radius_meters" + ), {"default_value": DEFAULT_WORK_SCHEDULE[field]}) + + has_global_schedule = bool(conn.execute(text(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'work_schedules' + """)).scalar_one()) + if has_global_schedule: + set_clause = ", ".join( + f"ap.{field} = COALESCE(ws.{field}, :{field})" + for field in SCHEDULE_FIELDS + ) + conn.execute(text(f""" + UPDATE attendance_points ap + JOIN work_schedules ws ON ws.id = 1 + SET {set_clause} + """), DEFAULT_WORK_SCHEDULE) + + for field in SCHEDULE_FIELDS: + conn.execute(text( + f"UPDATE attendance_points SET {field} = :default_value " + f"WHERE {field} IS NULL OR {field} = ''" + ), {"default_value": DEFAULT_WORK_SCHEDULE[field]}) + + print("attendance point schedules migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_attendance_points.py b/scripts/migrate_attendance_points.py new file mode 100644 index 0000000..72f7f76 --- /dev/null +++ b/scripts/migrate_attendance_points.py @@ -0,0 +1,207 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 +from app.services.attendance_points import DEFAULT_ATTENDANCE_POINT_NAME # noqa: E402 + +TARGET_COLLATION = "utf8mb4_unicode_ci" + + +def column_exists(conn, table: str, column: str) -> bool: + return bool(conn.execute(text(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table + AND column_name = :column + """), {"table": table, "column": column}).scalar_one()) + + +def table_exists(conn, table: str) -> bool: + return bool(conn.execute(text(""" + SELECT COUNT(*) + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = :table + """), {"table": table}).scalar_one()) + + +def primary_columns(conn, table: str) -> list[str]: + rows = conn.execute(text(""" + SELECT column_name + FROM information_schema.key_column_usage + WHERE table_schema = DATABASE() + AND table_name = :table + AND constraint_name = 'PRIMARY' + ORDER BY ordinal_position + """), {"table": table}).all() + return [row[0] for row in rows] + + +def index_exists(conn, table: str, index_name: str) -> bool: + return bool(conn.execute(text(""" + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = :table + AND index_name = :index_name + """), {"table": table, "index_name": index_name}).scalar_one()) + + +def create_index(conn, table: str, index_name: str, columns: str) -> None: + if not index_exists(conn, table, index_name): + conn.execute(text(f"CREATE INDEX {index_name} ON {table} ({columns})")) + + +def add_point_column(conn, table: str, after: str | None = None) -> None: + if column_exists(conn, table, "attendance_point_name"): + return + after_clause = f" AFTER {after}" if after else "" + conn.execute(text( + f"ALTER TABLE {table} " + f"ADD COLUMN attendance_point_name VARCHAR(128) COLLATE {TARGET_COLLATION} NOT NULL DEFAULT :default_name{after_clause}" + ), {"default_name": DEFAULT_ATTENDANCE_POINT_NAME}) + + +def ensure_primary(conn, table: str, columns: list[str]) -> None: + if primary_columns(conn, table) == columns: + return + if primary_columns(conn, table): + conn.execute(text(f"ALTER TABLE {table} DROP PRIMARY KEY")) + joined = ", ".join(columns) + conn.execute(text(f"ALTER TABLE {table} ADD PRIMARY KEY ({joined})")) + + +def main() -> None: + with engine.begin() as conn: + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS attendance_points ( + name VARCHAR(128) NOT NULL PRIMARY KEY, + latitude DECIMAL(10, 7) NULL, + longitude DECIMAL(10, 7) NULL, + radius_meters INT NOT NULL DEFAULT 500, + day_start VARCHAR(5) NOT NULL DEFAULT '08:00', + day_end VARCHAR(5) NOT NULL DEFAULT '17:20', + lunch_start VARCHAR(5) NOT NULL DEFAULT '11:40', + lunch_end VARCHAR(5) NOT NULL DEFAULT '12:40', + dinner_start VARCHAR(5) NOT NULL DEFAULT '17:20', + dinner_end VARCHAR(5) NOT NULL DEFAULT '18:00', + overtime_start VARCHAR(5) NOT NULL DEFAULT '18:00', + overtime_end VARCHAR(5) NOT NULL DEFAULT '20:00', + night_start VARCHAR(5) NOT NULL DEFAULT '20:00', + night_end VARCHAR(5) NOT NULL DEFAULT '06:00', + remark VARCHAR(500) NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """)) + conn.execute(text(""" + INSERT IGNORE INTO attendance_points ( + name, latitude, longitude, radius_meters, remark, is_active + ) + SELECT + :name, + attendance_latitude, + attendance_longitude, + COALESCE(attendance_radius_meters, 500), + '默认考勤点', + TRUE + FROM work_schedules + WHERE id = 1 + """), {"name": DEFAULT_ATTENDANCE_POINT_NAME}) + conn.execute(text(""" + INSERT IGNORE INTO attendance_points (name, radius_meters, remark, is_active) + VALUES (:name, 500, '默认考勤点', TRUE) + """), {"name": DEFAULT_ATTENDANCE_POINT_NAME}) + + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS person_attendance_points ( + phone VARCHAR(20) NOT NULL, + attendance_point_name VARCHAR(128) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (phone, attendance_point_name), + INDEX idx_person_points_point (attendance_point_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """)) + conn.execute(text(""" + INSERT IGNORE INTO person_attendance_points (phone, attendance_point_name) + SELECT phone, :name FROM personnel + """), {"name": DEFAULT_ATTENDANCE_POINT_NAME}) + + if table_exists(conn, "products"): + add_point_column(conn, "products") + ensure_primary(conn, "products", [ + "attendance_point_name", + "project_no", + "product_name", + "device_no", + "process_name", + ]) + create_index(conn, "products", "idx_products_point", "attendance_point_name") + + if table_exists(conn, "equipment"): + add_point_column(conn, "equipment") + ensure_primary(conn, "equipment", ["attendance_point_name", "device_no"]) + create_index(conn, "equipment", "idx_equipment_point", "attendance_point_name") + + if table_exists(conn, "work_sessions"): + add_point_column(conn, "work_sessions", "id") + create_index(conn, "work_sessions", "idx_sessions_point_status", "attendance_point_name, status") + + if table_exists(conn, "work_session_devices"): + add_point_column(conn, "work_session_devices", "session_id") + create_index(conn, "work_session_devices", "idx_session_devices_point", "attendance_point_name") + + if table_exists(conn, "production_reports"): + add_point_column(conn, "production_reports", "id") + create_index(conn, "production_reports", "idx_reports_point_status", "attendance_point_name, status, report_date") + + if table_exists(conn, "production_report_items"): + add_point_column(conn, "production_report_items", "report_id") + conn.execute(text(""" + UPDATE production_report_items item + JOIN production_reports report ON report.id = item.report_id + SET item.attendance_point_name = report.attendance_point_name + WHERE item.attendance_point_name = '' + OR item.attendance_point_name IS NULL + """)) + create_index(conn, "production_report_items", "idx_report_items_point_product", "attendance_point_name, project_no, product_name") + + if table_exists(conn, "device_qrcodes"): + add_point_column(conn, "device_qrcodes") + ensure_primary(conn, "device_qrcodes", ["attendance_point_name", "device_no", "process_name"]) + + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS notice_points ( + notice_id BIGINT NOT NULL, + attendance_point_name VARCHAR(128) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (notice_id, attendance_point_name), + INDEX idx_notice_points_point (attendance_point_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """)) + conn.execute(text(""" + INSERT IGNORE INTO notice_points (notice_id, attendance_point_name) + SELECT id, :name FROM notices + """), {"name": DEFAULT_ATTENDANCE_POINT_NAME}) + + if table_exists(conn, "reconciliation_ledger_entries"): + add_point_column(conn, "reconciliation_ledger_entries") + ensure_primary(conn, "reconciliation_ledger_entries", [ + "attendance_point_name", + "year", + "month", + "product_name", + ]) + + print("attendance points migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_auto_submitted_reports.py b/scripts/migrate_auto_submitted_reports.py new file mode 100644 index 0000000..b4d0f30 --- /dev/null +++ b/scripts/migrate_auto_submitted_reports.py @@ -0,0 +1,41 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def _column_exists(conn, table_name: str, column_name: str) -> bool: + return bool(conn.execute( + text(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND column_name = :column_name + """), + {"table_name": table_name, "column_name": column_name}, + ).scalar_one()) + + +def main() -> None: + with engine.begin() as conn: + if not _column_exists(conn, "production_reports", "is_system_auto_submitted"): + conn.execute(text(""" + ALTER TABLE production_reports + ADD COLUMN is_system_auto_submitted TINYINT(1) NOT NULL DEFAULT 0 AFTER submitted_at + """)) + if not _column_exists(conn, "production_reports", "auto_submit_reason"): + conn.execute(text(""" + ALTER TABLE production_reports + ADD COLUMN auto_submit_reason VARCHAR(120) NULL AFTER is_system_auto_submitted + """)) + print("auto submitted report columns migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_cleaning_multi_equipment.py b/scripts/migrate_cleaning_multi_equipment.py new file mode 100644 index 0000000..5f59b2b --- /dev/null +++ b/scripts/migrate_cleaning_multi_equipment.py @@ -0,0 +1,19 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def main() -> None: + with engine.begin() as conn: + conn.execute(text("ALTER TABLE production_report_items MODIFY device_no VARCHAR(255) NOT NULL")) + print("production_report_items.device_no migrated to varchar(255)") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_cleaning_to_stamping_method.py b/scripts/migrate_cleaning_to_stamping_method.py new file mode 100644 index 0000000..331a5dd --- /dev/null +++ b/scripts/migrate_cleaning_to_stamping_method.py @@ -0,0 +1,128 @@ +from pathlib import Path +import re +import sys + +from sqlalchemy import select, text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import SessionLocal, engine # noqa: E402 +from app.models import DeviceQRCode, Product, ProductionReportItem, WorkSessionDevice # noqa: E402 +from app.services.cleaning import CLEANING_STAMPING_METHOD, LEGACY_CLEANING_PROCESS_NAME # noqa: E402 + + +def _column_exists(conn, table_name: str, column_name: str) -> bool: + return bool(conn.execute( + text(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND column_name = :column_name + """), + {"table_name": table_name, "column_name": column_name}, + ).scalar_one()) + + +def _sequence_number(process_name: str) -> int | None: + matched = re.search(r"\d+", str(process_name or "")) + return int(matched.group(0)) if matched else None + + +def _next_process_name(products: list[Product]) -> str: + existing = {str(product.process_name or "").strip() for product in products} + max_number = max( + (number for number in (_sequence_number(value) for value in existing) if number is not None), + default=0, + ) + number = max_number + 1 + while f"{number}序" in existing: + number += 1 + return f"{number}序" + + +def main() -> None: + with engine.begin() as conn: + if not _column_exists(conn, "production_report_items", "stamping_method"): + conn.execute(text(""" + ALTER TABLE production_report_items + ADD COLUMN stamping_method VARCHAR(128) NULL AFTER process_name + """)) + + with SessionLocal() as db: + cleaning_products = db.scalars( + select(Product).where(Product.process_name == LEGACY_CLEANING_PROCESS_NAME) + ).all() + migrated_products = 0 + for product in cleaning_products: + old_process_name = product.process_name + siblings = db.scalars( + select(Product).where( + Product.project_no == product.project_no, + Product.product_name == product.product_name, + Product.device_no == product.device_no, + ) + ).all() + next_process_name = _next_process_name(siblings) + + old_qr = db.get(DeviceQRCode, { + "device_no": product.product_name, + "process_name": old_process_name, + }) + if old_qr is not None: + old_qr.process_name = next_process_name + + for session_device in db.scalars( + select(WorkSessionDevice).where( + WorkSessionDevice.device_no == product.product_name, + WorkSessionDevice.process_name == old_process_name, + ) + ).all(): + session_device.process_name = next_process_name + + for report_item in db.scalars( + select(ProductionReportItem).where( + ProductionReportItem.project_no == product.project_no, + ProductionReportItem.product_name == product.product_name, + ProductionReportItem.process_name == old_process_name, + ) + ).all(): + report_item.process_name = next_process_name + report_item.stamping_method = CLEANING_STAMPING_METHOD + report_item.standard_beat = 0 + report_item.standard_workload = 0 + report_item.allocated_minutes = 0 + + product.process_name = next_process_name + product.stamping_method = CLEANING_STAMPING_METHOD + product.standard_beat = 0 + product.standard_workload = 0 + migrated_products += 1 + + for product in db.scalars( + select(Product).where(Product.stamping_method == CLEANING_STAMPING_METHOD) + ).all(): + product.standard_beat = 0 + product.standard_workload = 0 + + for report_item in db.scalars(select(ProductionReportItem)).all(): + if report_item.stamping_method: + continue + product = db.scalar( + select(Product).where( + Product.project_no == report_item.project_no, + Product.product_name == report_item.product_name, + Product.process_name == (report_item.process_name or ""), + Product.device_no == "", + ) + ) + if product is not None: + report_item.stamping_method = product.stamping_method + + db.commit() + print(f"cleaning migrated to stamping_method, products={migrated_products}") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_continuous_die_changeover_count.py b/scripts/migrate_continuous_die_changeover_count.py new file mode 100644 index 0000000..0864178 --- /dev/null +++ b/scripts/migrate_continuous_die_changeover_count.py @@ -0,0 +1,33 @@ +from sqlalchemy import text + +from app.database import engine + + +def main() -> None: + with engine.begin() as conn: + exists = conn.execute( + text( + """ + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'production_report_items' + AND column_name = 'changeover_count' + """ + ) + ).scalar() + if not exists: + conn.execute( + text( + """ + ALTER TABLE production_report_items + ADD COLUMN changeover_count DECIMAL(12, 2) NOT NULL DEFAULT 0 + AFTER process_unit_price_yuan + """ + ) + ) + print("migrate_continuous_die_changeover_count done") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_equipment.py b/scripts/migrate_equipment.py new file mode 100644 index 0000000..754c764 --- /dev/null +++ b/scripts/migrate_equipment.py @@ -0,0 +1,32 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def main() -> None: + with engine.begin() as conn: + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS equipment ( + device_no VARCHAR(64) NOT NULL PRIMARY KEY, + remark VARCHAR(500) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + conn.execute(text(""" + INSERT IGNORE INTO equipment (device_no, remark) + SELECT DISTINCT device_no, '由历史产品清单带入' + FROM products + WHERE device_no IS NOT NULL AND device_no <> '' + """)) + print("equipment migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_equipment_device_type.py b/scripts/migrate_equipment_device_type.py new file mode 100644 index 0000000..4a8654c --- /dev/null +++ b/scripts/migrate_equipment_device_type.py @@ -0,0 +1,35 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def main() -> None: + with engine.begin() as conn: + exists = conn.execute(text(""" + SELECT COUNT(1) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'equipment' + AND column_name = 'device_type' + """)).scalar() + if not exists: + conn.execute(text(""" + ALTER TABLE equipment + ADD COLUMN device_type VARCHAR(32) NOT NULL DEFAULT '冲压设备' AFTER device_no + """)) + conn.execute(text(""" + UPDATE equipment + SET device_type = '冲压设备' + WHERE device_type IS NULL OR device_type = '' + """)) + print("equipment device type migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_misc_work.py b/scripts/migrate_misc_work.py new file mode 100644 index 0000000..15b42b0 --- /dev/null +++ b/scripts/migrate_misc_work.py @@ -0,0 +1,51 @@ +from pathlib import Path +import sys + +from sqlalchemy import select, text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import SessionLocal, engine # noqa: E402 +from app.models import AttendancePoint # noqa: E402 +from app.services.misc_work import ensure_misc_work_product # noqa: E402 + + +def _column_exists(conn, table_name: str, column_name: str) -> bool: + dialect = conn.dialect.name + if dialect == "mysql": + return bool(conn.execute( + text(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND column_name = :column_name + """), + {"table_name": table_name, "column_name": column_name}, + ).scalar_one()) + rows = conn.execute(text(f"PRAGMA table_info({table_name})")).all() + return any(row[1] == column_name for row in rows) + + +def main() -> None: + with engine.begin() as conn: + if not _column_exists(conn, "production_report_items", "remark"): + conn.execute(text(""" + ALTER TABLE production_report_items + ADD COLUMN remark VARCHAR(500) NULL + """)) + + with SessionLocal() as db: + points = db.scalars( + select(AttendancePoint).where(AttendancePoint.is_active.is_(True)) + ).all() + for point in points: + ensure_misc_work_product(db, point.name) + db.commit() + + print(f"misc work migrated, attendance_points={len(points)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_mold_lock_release_on_submit.py b/scripts/migrate_mold_lock_release_on_submit.py new file mode 100644 index 0000000..80e8ff8 --- /dev/null +++ b/scripts/migrate_mold_lock_release_on_submit.py @@ -0,0 +1,36 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def main() -> None: + with engine.begin() as conn: + result = conn.execute( + text( + """ + UPDATE work_session_devices d + JOIN work_sessions s ON s.id = d.session_id + SET d.released_at = NULL, + d.released_by = NULL, + d.release_reason = NULL + WHERE s.status = 'reporting' + AND d.release_reason IN ('下班释放占用', '历史非活动记录迁移释放占用') + AND NOT EXISTS ( + SELECT 1 + FROM production_reports r + WHERE r.session_id = s.id + ) + """ + ) + ) + print(f"restored reporting mold locks: {result.rowcount}") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_mold_locks.py b/scripts/migrate_mold_locks.py new file mode 100644 index 0000000..596aed1 --- /dev/null +++ b/scripts/migrate_mold_locks.py @@ -0,0 +1,124 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def column_exists(conn, table_name: str, column_name: str) -> bool: + return bool( + conn.execute( + text( + """ + SELECT COUNT(1) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND column_name = :column_name + """ + ), + {"table_name": table_name, "column_name": column_name}, + ).scalar() + ) + + +def index_exists(conn, table_name: str, index_name: str) -> bool: + return bool( + conn.execute( + text( + """ + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND index_name = :index_name + """ + ), + {"table_name": table_name, "index_name": index_name}, + ).scalar() + ) + + +def main() -> None: + with engine.begin() as conn: + if not column_exists(conn, "work_session_devices", "released_at"): + conn.execute(text("ALTER TABLE work_session_devices ADD COLUMN released_at DATETIME NULL AFTER sort_order")) + if not column_exists(conn, "work_session_devices", "released_by"): + conn.execute(text("ALTER TABLE work_session_devices ADD COLUMN released_by VARCHAR(20) NULL AFTER released_at")) + if not column_exists(conn, "work_session_devices", "release_reason"): + conn.execute(text("ALTER TABLE work_session_devices ADD COLUMN release_reason VARCHAR(120) NULL AFTER released_by")) + if not index_exists(conn, "work_session_devices", "idx_session_devices_release"): + conn.execute(text("CREATE INDEX idx_session_devices_release ON work_session_devices (released_at)")) + + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS mold_lock_feedbacks ( + id BIGINT NOT NULL AUTO_INCREMENT, + attendance_point_name VARCHAR(128) NOT NULL DEFAULT '', + mold_name VARCHAR(255) NOT NULL, + process_name VARCHAR(128) NOT NULL DEFAULT '', + session_device_id BIGINT NOT NULL, + reporter_phone VARCHAR(20) NOT NULL, + occupied_phone VARCHAR(20) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + read_at DATETIME NULL, + handled_at DATETIME NULL, + handled_by VARCHAR(20) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_mold_lock_feedback_point_read (attendance_point_name, read_at, created_at), + KEY idx_mold_lock_feedback_status (status, created_at), + KEY idx_mold_lock_feedback_device (session_device_id), + CONSTRAINT fk_mold_lock_feedback_device + FOREIGN KEY (session_device_id) REFERENCES work_session_devices (id), + CONSTRAINT fk_mold_lock_feedback_reporter + FOREIGN KEY (reporter_phone) REFERENCES personnel (phone), + CONSTRAINT fk_mold_lock_feedback_occupied + FOREIGN KEY (occupied_phone) REFERENCES personnel (phone), + CONSTRAINT fk_mold_lock_feedback_handler + FOREIGN KEY (handled_by) REFERENCES personnel (phone) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + ) + + conn.execute( + text( + """ + UPDATE work_session_devices d + JOIN work_sessions s ON s.id = d.session_id + SET d.released_at = COALESCE(s.end_at, s.updated_at, NOW()), + d.release_reason = '历史非活动记录迁移释放占用' + WHERE d.released_at IS NULL + AND s.status NOT IN ('active', 'reporting') + """ + ) + ) + conn.execute( + text( + """ + UPDATE work_session_devices d + JOIN work_sessions s ON s.id = d.session_id + JOIN ( + SELECT session_id, MAX(sort_order) AS max_sort_order + FROM work_session_devices + GROUP BY session_id + ) latest ON latest.session_id = d.session_id + SET d.released_at = NOW(), + d.release_reason = '历史切换记录迁移释放占用' + WHERE d.released_at IS NULL + AND s.status = 'active' + AND d.sort_order < latest.max_sort_order + """ + ) + ) + print("mold lock columns and feedback table migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_mold_process_qrcode.py b/scripts/migrate_mold_process_qrcode.py new file mode 100644 index 0000000..d37f45b --- /dev/null +++ b/scripts/migrate_mold_process_qrcode.py @@ -0,0 +1,57 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def _column_exists(conn, table_name: str, column_name: str) -> bool: + return bool(conn.execute(text(""" + SELECT COUNT(1) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND column_name = :column_name + """), {"table_name": table_name, "column_name": column_name}).scalar()) + + +def _primary_columns(conn, table_name: str) -> list[str]: + rows = conn.execute(text(""" + SELECT column_name + FROM information_schema.key_column_usage + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND constraint_name = 'PRIMARY' + ORDER BY ordinal_position + """), {"table_name": table_name}).all() + return [row[0] for row in rows] + + +def main() -> None: + with engine.begin() as conn: + if not _column_exists(conn, "work_session_devices", "process_name"): + conn.execute(text(""" + ALTER TABLE work_session_devices + ADD COLUMN process_name VARCHAR(128) NOT NULL DEFAULT '' AFTER device_no + """)) + + if not _column_exists(conn, "device_qrcodes", "process_name"): + conn.execute(text(""" + ALTER TABLE device_qrcodes + ADD COLUMN process_name VARCHAR(128) NOT NULL DEFAULT '' AFTER device_no + """)) + + primary_columns = _primary_columns(conn, "device_qrcodes") + if primary_columns != ["device_no", "process_name"]: + conn.execute(text("ALTER TABLE device_qrcodes DROP PRIMARY KEY")) + conn.execute(text("ALTER TABLE device_qrcodes ADD PRIMARY KEY (device_no, process_name)")) + + print("mold process qrcode migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_mold_qrcode.py b/scripts/migrate_mold_qrcode.py new file mode 100644 index 0000000..34bb0b7 --- /dev/null +++ b/scripts/migrate_mold_qrcode.py @@ -0,0 +1,20 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def main() -> None: + with engine.begin() as conn: + conn.execute(text("ALTER TABLE work_session_devices MODIFY device_no VARCHAR(255) NOT NULL")) + conn.execute(text("ALTER TABLE device_qrcodes MODIFY device_no VARCHAR(255) NOT NULL")) + print("mold qrcode fields migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_multi_person_reports.py b/scripts/migrate_multi_person_reports.py new file mode 100644 index 0000000..4bd0d86 --- /dev/null +++ b/scripts/migrate_multi_person_reports.py @@ -0,0 +1,57 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def _column_exists(conn, table_name: str, column_name: str) -> bool: + return bool(conn.execute( + text(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND column_name = :column_name + """), + {"table_name": table_name, "column_name": column_name}, + ).scalar_one()) + + +def main() -> None: + with engine.begin() as conn: + if not _column_exists(conn, "production_report_items", "operator_count"): + conn.execute(text(""" + ALTER TABLE production_report_items + ADD COLUMN operator_count DECIMAL(10, 2) NOT NULL DEFAULT 1 AFTER stamping_method + """)) + if not _column_exists(conn, "production_reports", "is_multi_person_assistant"): + conn.execute(text(""" + ALTER TABLE production_reports + ADD COLUMN is_multi_person_assistant TINYINT(1) NOT NULL DEFAULT 0 AFTER auto_submit_reason + """)) + if not _column_exists(conn, "production_reports", "multi_person_source_report_id"): + conn.execute(text(""" + ALTER TABLE production_reports + ADD COLUMN multi_person_source_report_id BIGINT NULL AFTER is_multi_person_assistant + """)) + conn.execute(text(""" + UPDATE production_report_items item + JOIN products product + ON product.attendance_point_name = item.attendance_point_name + AND product.project_no = item.project_no + AND product.product_name = item.product_name + AND product.process_name = IFNULL(item.process_name, '') + AND product.device_no = '' + SET item.operator_count = product.operator_count + WHERE item.operator_count IS NULL OR item.operator_count <= 1 + """)) + print("multi person report columns migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_numeric_process_names.py b/scripts/migrate_numeric_process_names.py new file mode 100644 index 0000000..4b9f9c6 --- /dev/null +++ b/scripts/migrate_numeric_process_names.py @@ -0,0 +1,165 @@ +from pathlib import Path +import re +import sys + +from sqlalchemy import select + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import SessionLocal # noqa: E402 +from app.models import DeviceQRCode, MoldLockFeedback, Product, ProductionReportItem, WorkSessionDevice # noqa: E402 + + +LEGACY_PROCESS_PATTERN = re.compile(r"0*(\d+)序") + + +def normalized(value: str | None) -> str | None: + text = str(value or "").strip() + matched = LEGACY_PROCESS_PATTERN.fullmatch(text) + if matched: + return str(int(matched.group(1))) + return None + + +def normalize_products(db, skipped: list[str]) -> int: + updated = 0 + products = db.scalars( + select(Product) + .where(Product.device_no == "") + .order_by(Product.attendance_point_name, Product.project_no, Product.product_name, Product.process_name) + ).all() + for product in products: + next_process_name = normalized(product.process_name) + if next_process_name is None or next_process_name == product.process_name: + continue + next_key = { + "attendance_point_name": product.attendance_point_name, + "project_no": product.project_no, + "product_name": product.product_name, + "device_no": product.device_no, + "process_name": next_process_name, + } + if db.get(Product, next_key) is not None: + skipped.append( + f"products: {product.attendance_point_name} / {product.project_no} / " + f"{product.product_name} / {product.process_name}" + ) + continue + qr_conflict = db.get(DeviceQRCode, { + "attendance_point_name": product.attendance_point_name, + "device_no": product.product_name, + "process_name": next_process_name, + }) + if qr_conflict is not None: + skipped.append( + f"products: {product.attendance_point_name} / {product.project_no} / " + f"{product.product_name} / {product.process_name} (二维码冲突)" + ) + continue + + old_process_name = product.process_name + point_name = product.attendance_point_name + product_name = product.product_name + + for qr in db.scalars(select(DeviceQRCode).where( + DeviceQRCode.attendance_point_name == point_name, + DeviceQRCode.device_no == product_name, + DeviceQRCode.process_name == old_process_name, + )).all(): + qr.process_name = next_process_name + + for device in db.scalars(select(WorkSessionDevice).where( + WorkSessionDevice.attendance_point_name == point_name, + WorkSessionDevice.device_no == product_name, + WorkSessionDevice.process_name == old_process_name, + )).all(): + device.process_name = next_process_name + + for item in db.scalars(select(ProductionReportItem).where( + ProductionReportItem.attendance_point_name == point_name, + ProductionReportItem.product_name == product_name, + ProductionReportItem.process_name == old_process_name, + )).all(): + item.process_name = next_process_name + + for feedback in db.scalars(select(MoldLockFeedback).where( + MoldLockFeedback.attendance_point_name == point_name, + MoldLockFeedback.mold_name == product_name, + MoldLockFeedback.process_name == old_process_name, + )).all(): + feedback.process_name = next_process_name + + product.process_name = next_process_name + updated += 1 + return updated + + +def normalize_remaining_references(db, skipped: list[str]) -> dict[str, int]: + counts = { + "device_qrcodes": 0, + "work_session_devices": 0, + "production_report_items": 0, + "mold_lock_feedbacks": 0, + } + for qr in db.scalars(select(DeviceQRCode)).all(): + next_process_name = normalized(qr.process_name) + if next_process_name is None or next_process_name == qr.process_name: + continue + next_key = { + "attendance_point_name": qr.attendance_point_name, + "device_no": qr.device_no, + "process_name": next_process_name, + } + if db.get(DeviceQRCode, next_key) is not None: + skipped.append(f"device_qrcodes: {qr.attendance_point_name} / {qr.device_no} / {qr.process_name}") + continue + qr.process_name = next_process_name + counts["device_qrcodes"] += 1 + + for device in db.scalars(select(WorkSessionDevice)).all(): + next_process_name = normalized(device.process_name) + if next_process_name is None or next_process_name == device.process_name: + continue + device.process_name = next_process_name + counts["work_session_devices"] += 1 + + for item in db.scalars(select(ProductionReportItem)).all(): + next_process_name = normalized(item.process_name) + if next_process_name is None or next_process_name == item.process_name: + continue + item.process_name = next_process_name + counts["production_report_items"] += 1 + + for feedback in db.scalars(select(MoldLockFeedback)).all(): + next_process_name = normalized(feedback.process_name) + if next_process_name is None or next_process_name == feedback.process_name: + continue + feedback.process_name = next_process_name + counts["mold_lock_feedbacks"] += 1 + + return counts + + +def main() -> None: + skipped: list[str] = [] + with SessionLocal() as db: + product_updates = normalize_products(db, skipped) + reference_updates = normalize_remaining_references(db, skipped) + db.commit() + + reference_total = sum(reference_updates.values()) + print( + "numeric process names migrated, " + f"products={product_updates}, references={reference_total}, skipped={len(skipped)}" + ) + for name, count in reference_updates.items(): + print(f"{name}={count}") + for item in skipped[:20]: + print(f"skipped conflict: {item}") + if len(skipped) > 20: + print(f"... and {len(skipped) - 20} more") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_person_roles.py b/scripts/migrate_person_roles.py new file mode 100644 index 0000000..62db348 --- /dev/null +++ b/scripts/migrate_person_roles.py @@ -0,0 +1,45 @@ +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from sqlalchemy import text # noqa: E402 + +from app.database import SessionLocal # noqa: E402 + + +def main() -> None: + with SessionLocal() as db: + db.execute( + text( + """ + CREATE TABLE IF NOT EXISTS person_roles ( + phone VARCHAR(20) COLLATE utf8mb4_unicode_ci NOT NULL, + role ENUM('worker','admin','manager') COLLATE utf8mb4_unicode_ci NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (phone, role), + CONSTRAINT fk_person_roles_person + FOREIGN KEY (phone) REFERENCES personnel(phone) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + ) + columns = db.execute(text("SHOW COLUMNS FROM personnel LIKE 'role'")).all() + if columns: + db.execute( + text( + """ + INSERT IGNORE INTO person_roles (phone, role, created_at) + SELECT phone, role, COALESCE(created_at, CURRENT_TIMESTAMP) + FROM personnel + """ + ) + ) + db.execute(text("ALTER TABLE personnel DROP COLUMN role")) + db.commit() + print("personnel migrated to one phone/name with multiple roles") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_process_unit_price.py b/scripts/migrate_process_unit_price.py new file mode 100644 index 0000000..5f21d55 --- /dev/null +++ b/scripts/migrate_process_unit_price.py @@ -0,0 +1,52 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def _column_exists(conn, table_name: str, column_name: str) -> bool: + return bool(conn.execute( + text(""" + SELECT COUNT(1) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND column_name = :column_name + """), + {"table_name": table_name, "column_name": column_name}, + ).scalar()) + + +def main() -> None: + with engine.begin() as conn: + if not _column_exists(conn, "products", "process_unit_price_yuan"): + conn.execute(text(""" + ALTER TABLE products + ADD COLUMN process_unit_price_yuan DECIMAL(12,4) NOT NULL DEFAULT 0 AFTER operator_count + """)) + if not _column_exists(conn, "production_report_items", "process_unit_price_yuan"): + conn.execute(text(""" + ALTER TABLE production_report_items + ADD COLUMN process_unit_price_yuan DECIMAL(12,4) NOT NULL DEFAULT 0 AFTER operator_count + """)) + conn.execute(text(""" + UPDATE production_report_items item + JOIN products product + ON product.attendance_point_name = item.attendance_point_name + AND product.project_no = item.project_no + AND product.product_name = item.product_name + AND product.process_name = IFNULL(item.process_name, '') + AND product.device_no = '' + SET item.process_unit_price_yuan = product.process_unit_price_yuan + WHERE item.process_unit_price_yuan IS NULL OR item.process_unit_price_yuan = 0 + """)) + print("process unit price columns migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_product_erp_aux_fields.py b/scripts/migrate_product_erp_aux_fields.py new file mode 100644 index 0000000..851857f --- /dev/null +++ b/scripts/migrate_product_erp_aux_fields.py @@ -0,0 +1,41 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +FIELDS = [ + ("product_net_weight_kg", "DECIMAL(12,4) NULL", "material_name"), + ("product_gross_weight_kg", "DECIMAL(12,4) NULL", "product_net_weight_kg"), + ("scrap_loss_rate", "DECIMAL(10,6) NULL", "product_gross_weight_kg"), + ("waste_price_yuan_per_kg", "DECIMAL(12,4) NULL", "scrap_loss_rate"), +] + + +def column_exists(conn, column_name: str) -> bool: + return bool(conn.execute(text(""" + SELECT COUNT(1) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'products' + AND column_name = :column_name + """), {"column_name": column_name}).scalar()) + + +def main() -> None: + with engine.begin() as conn: + for column_name, column_type, after_column in FIELDS: + if not column_exists(conn, column_name): + conn.execute(text( + f"ALTER TABLE products ADD COLUMN {column_name} {column_type} AFTER {after_column}" + )) + print("product ERP auxiliary fields migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_product_key.py b/scripts/migrate_product_key.py new file mode 100644 index 0000000..ffc1143 --- /dev/null +++ b/scripts/migrate_product_key.py @@ -0,0 +1,29 @@ +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from sqlalchemy import text # noqa: E402 + +from app.database import SessionLocal # noqa: E402 + + +def main() -> None: + with SessionLocal() as db: + db.execute(text("UPDATE products SET process_name = '' WHERE process_name IS NULL")) + db.execute(text("ALTER TABLE products DROP PRIMARY KEY")) + db.execute(text("ALTER TABLE products MODIFY device_no VARCHAR(64) NOT NULL")) + db.execute(text("ALTER TABLE products MODIFY process_name VARCHAR(128) NOT NULL")) + db.execute( + text( + "ALTER TABLE products " + "ADD PRIMARY KEY (project_no, product_name, device_no, process_name)" + ) + ) + db.commit() + print("products primary key migrated to project_no + product_name + device_no + process_name") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_product_supplier.py b/scripts/migrate_product_supplier.py new file mode 100644 index 0000000..92bb399 --- /dev/null +++ b/scripts/migrate_product_supplier.py @@ -0,0 +1,32 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def column_exists(conn, column_name: str) -> bool: + return bool(conn.execute(text(""" + SELECT COUNT(1) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'products' + AND column_name = :column_name + """), {"column_name": column_name}).scalar()) + + +def main() -> None: + with engine.begin() as conn: + if not column_exists(conn, "supplier"): + conn.execute(text( + "ALTER TABLE products ADD COLUMN supplier VARCHAR(255) NULL AFTER material_name" + )) + print("product supplier field migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_qrcode_batch_tasks.py b/scripts/migrate_qrcode_batch_tasks.py new file mode 100644 index 0000000..9ef2533 --- /dev/null +++ b/scripts/migrate_qrcode_batch_tasks.py @@ -0,0 +1,45 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def main() -> None: + with engine.begin() as conn: + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS device_qrcode_batch_tasks ( + id BIGINT NOT NULL AUTO_INCREMENT, + file_name VARCHAR(255) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + item_count INT NOT NULL DEFAULT 0, + completed_count INT NOT NULL DEFAULT 0, + failed_count INT NOT NULL DEFAULT 0, + items_json JSON NOT NULL, + zip_url VARCHAR(500) NULL, + error_message VARCHAR(1000) NULL, + created_by VARCHAR(20) NOT NULL, + started_at DATETIME NULL, + finished_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_device_qrcode_batch_tasks_creator (created_by, created_at), + KEY idx_device_qrcode_batch_tasks_status (status, created_at), + CONSTRAINT fk_device_qrcode_batch_tasks_creator + FOREIGN KEY (created_by) REFERENCES personnel (phone) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + ) + print("qrcode batch tasks migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_reconciliation_ledger.py b/scripts/migrate_reconciliation_ledger.py new file mode 100644 index 0000000..aa009c4 --- /dev/null +++ b/scripts/migrate_reconciliation_ledger.py @@ -0,0 +1,33 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def main() -> None: + with engine.begin() as conn: + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS reconciliation_ledger_entries ( + year INT NOT NULL, + month INT NOT NULL, + product_name VARCHAR(255) NOT NULL, + reconciled_good_qty DECIMAL(14, 2) NOT NULL DEFAULT 0, + return_qty DECIMAL(14, 2) NOT NULL DEFAULT 0, + updated_by VARCHAR(20) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (year, month, product_name), + INDEX idx_reconciliation_product (product_name), + INDEX idx_reconciliation_updated (updated_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + print("reconciliation ledger migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_reconciliation_return_qty.py b/scripts/migrate_reconciliation_return_qty.py new file mode 100644 index 0000000..df8d2b8 --- /dev/null +++ b/scripts/migrate_reconciliation_return_qty.py @@ -0,0 +1,34 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def column_exists(conn, column_name: str) -> bool: + return bool(conn.execute(text(""" + SELECT COUNT(1) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'reconciliation_ledger_entries' + AND column_name = :column_name + """), {"column_name": column_name}).scalar()) + + +def main() -> None: + with engine.begin() as conn: + if not column_exists(conn, "return_qty"): + conn.execute(text( + "ALTER TABLE reconciliation_ledger_entries " + "ADD COLUMN return_qty DECIMAL(14, 2) NOT NULL DEFAULT 0 " + "AFTER reconciled_good_qty" + )) + print("reconciliation return quantity migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_report_item_raw_material_batch.py b/scripts/migrate_report_item_raw_material_batch.py new file mode 100644 index 0000000..647b2da --- /dev/null +++ b/scripts/migrate_report_item_raw_material_batch.py @@ -0,0 +1,30 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def main() -> None: + with engine.begin() as conn: + exists = conn.execute(text(""" + SELECT COUNT(1) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'production_report_items' + AND column_name = 'raw_material_batch_no' + """)).scalar() + if not exists: + conn.execute(text(""" + ALTER TABLE production_report_items + ADD COLUMN raw_material_batch_no VARCHAR(128) NULL AFTER material_name + """)) + print("report item raw material batch migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_report_item_started_at.py b/scripts/migrate_report_item_started_at.py new file mode 100644 index 0000000..37df752 --- /dev/null +++ b/scripts/migrate_report_item_started_at.py @@ -0,0 +1,35 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def main() -> None: + with engine.begin() as conn: + columns = conn.execute( + text( + "SELECT COUNT(*) FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + "AND TABLE_NAME = 'production_report_items' " + "AND COLUMN_NAME = 'started_at'" + ) + ).scalar() + if columns: + print("production_report_items.started_at already exists") + return + conn.execute( + text( + "ALTER TABLE production_report_items " + "ADD COLUMN started_at DATETIME NULL AFTER allocated_minutes" + ) + ) + print("added production_report_items.started_at") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_report_review_remark.py b/scripts/migrate_report_review_remark.py new file mode 100644 index 0000000..1650e6a --- /dev/null +++ b/scripts/migrate_report_review_remark.py @@ -0,0 +1,36 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def _column_exists(conn, table_name: str, column_name: str) -> bool: + return bool(conn.execute( + text(""" + SELECT COUNT(1) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :table_name + AND column_name = :column_name + """), + {"table_name": table_name, "column_name": column_name}, + ).scalar()) + + +def main() -> None: + with engine.begin() as conn: + if not _column_exists(conn, "production_reports", "review_remark"): + conn.execute(text(""" + ALTER TABLE production_reports + ADD COLUMN review_remark VARCHAR(500) NULL AFTER reject_reason + """)) + print("report review remark column migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_temporary_workers.py b/scripts/migrate_temporary_workers.py new file mode 100644 index 0000000..0e62bcb --- /dev/null +++ b/scripts/migrate_temporary_workers.py @@ -0,0 +1,31 @@ +from sqlalchemy import text + +from app.database import SessionLocal + + +def column_exists(db, table_name: str, column_name: str) -> bool: + return bool(db.execute(text(f"SHOW COLUMNS FROM {table_name} LIKE :column"), {"column": column_name}).first()) + + +def main() -> None: + with SessionLocal() as db: + if not column_exists(db, "personnel", "is_temporary"): + db.execute( + text( + "ALTER TABLE personnel " + "ADD COLUMN is_temporary TINYINT(1) NOT NULL DEFAULT 0 AFTER name" + ) + ) + if not column_exists(db, "personnel", "temporary_expires_at"): + db.execute( + text( + "ALTER TABLE personnel " + "ADD COLUMN temporary_expires_at DATETIME NULL AFTER is_temporary" + ) + ) + db.commit() + print("temporary worker columns migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_voided_reports.py b/scripts/migrate_voided_reports.py new file mode 100644 index 0000000..9ae0a28 --- /dev/null +++ b/scripts/migrate_voided_reports.py @@ -0,0 +1,46 @@ +from sqlalchemy import inspect, text + +from app.database import engine + + +def column_exists(inspector, table_name: str, column_name: str) -> bool: + return any(column["name"] == column_name for column in inspector.get_columns(table_name)) + + +def index_exists(inspector, table_name: str, index_name: str) -> bool: + return any(index["name"] == index_name for index in inspector.get_indexes(table_name)) + + +def main() -> None: + with engine.begin() as conn: + inspector = inspect(conn) + if not column_exists(inspector, "production_reports", "is_voided"): + conn.execute(text(""" + ALTER TABLE production_reports + ADD COLUMN is_voided TINYINT(1) NOT NULL DEFAULT 0 AFTER multi_person_source_report_id + """)) + if not column_exists(inspector, "production_reports", "voided_at"): + conn.execute(text(""" + ALTER TABLE production_reports + ADD COLUMN voided_at DATETIME NULL AFTER is_voided + """)) + if not column_exists(inspector, "production_reports", "voided_by"): + conn.execute(text(""" + ALTER TABLE production_reports + ADD COLUMN voided_by VARCHAR(20) NULL AFTER voided_at + """)) + if not column_exists(inspector, "production_reports", "unvoid_deadline_at"): + conn.execute(text(""" + ALTER TABLE production_reports + ADD COLUMN unvoid_deadline_at DATETIME NULL AFTER voided_by + """)) + inspector = inspect(conn) + if not index_exists(inspector, "production_reports", "idx_reports_voided"): + conn.execute(text(""" + CREATE INDEX idx_reports_voided ON production_reports (is_voided, voided_at) + """)) + print("voided report columns migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_work_schedule.py b/scripts/migrate_work_schedule.py new file mode 100644 index 0000000..7017ec8 --- /dev/null +++ b/scripts/migrate_work_schedule.py @@ -0,0 +1,75 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 +from app.services.work_schedule import DEFAULT_WORK_SCHEDULE # noqa: E402 + + +def main() -> None: + with engine.begin() as conn: + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS work_schedules ( + id INT NOT NULL PRIMARY KEY, + day_start VARCHAR(5) NOT NULL, + day_end VARCHAR(5) NOT NULL, + lunch_start VARCHAR(5) NOT NULL, + lunch_end VARCHAR(5) NOT NULL, + dinner_start VARCHAR(5) NOT NULL, + dinner_end VARCHAR(5) NOT NULL, + overtime_start VARCHAR(5) NOT NULL, + overtime_end VARCHAR(5) NOT NULL, + night_start VARCHAR(5) NOT NULL, + night_end VARCHAR(5) NOT NULL, + attendance_latitude DECIMAL(10, 7) NULL, + attendance_longitude DECIMAL(10, 7) NULL, + attendance_radius_meters INT NOT NULL DEFAULT 500, + auto_submit_hours DECIMAL(10, 2) NOT NULL DEFAULT 15, + updated_by VARCHAR(20) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + exists = conn.execute( + text("SELECT COUNT(*) FROM work_schedules WHERE id = 1") + ).scalar_one() + if not exists: + conn.execute( + text(""" + INSERT INTO work_schedules ( + id, + day_start, + day_end, + lunch_start, + lunch_end, + dinner_start, + dinner_end, + overtime_start, + overtime_end, + night_start, + night_end + ) VALUES ( + 1, + :day_start, + :day_end, + :lunch_start, + :lunch_end, + :dinner_start, + :dinner_end, + :overtime_start, + :overtime_end, + :night_start, + :night_end + ) + """), + DEFAULT_WORK_SCHEDULE, + ) + print("work_schedules migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_work_schedule_attendance_location.py b/scripts/migrate_work_schedule_attendance_location.py new file mode 100644 index 0000000..2f8ddd7 --- /dev/null +++ b/scripts/migrate_work_schedule_attendance_location.py @@ -0,0 +1,46 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def column_exists(conn, column_name: str) -> bool: + return bool(conn.execute( + text(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'work_schedules' + AND column_name = :column_name + """), + {"column_name": column_name}, + ).scalar_one()) + + +def main() -> None: + with engine.begin() as conn: + if not column_exists(conn, "attendance_latitude"): + conn.execute(text(""" + ALTER TABLE work_schedules + ADD COLUMN attendance_latitude DECIMAL(10, 7) NULL AFTER night_end + """)) + if not column_exists(conn, "attendance_longitude"): + conn.execute(text(""" + ALTER TABLE work_schedules + ADD COLUMN attendance_longitude DECIMAL(10, 7) NULL AFTER attendance_latitude + """)) + if not column_exists(conn, "attendance_radius_meters"): + conn.execute(text(""" + ALTER TABLE work_schedules + ADD COLUMN attendance_radius_meters INT NOT NULL DEFAULT 500 AFTER attendance_longitude + """)) + print("work_schedules attendance location migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_work_schedule_auto_submit_hours.py b/scripts/migrate_work_schedule_auto_submit_hours.py new file mode 100644 index 0000000..11b93ef --- /dev/null +++ b/scripts/migrate_work_schedule_auto_submit_hours.py @@ -0,0 +1,41 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def column_exists(conn, column_name: str) -> bool: + return bool(conn.execute( + text(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'work_schedules' + AND column_name = :column_name + """), + {"column_name": column_name}, + ).scalar_one()) + + +def main() -> None: + with engine.begin() as conn: + if not column_exists(conn, "auto_submit_hours"): + conn.execute(text(""" + ALTER TABLE work_schedules + ADD COLUMN auto_submit_hours DECIMAL(10, 2) NOT NULL DEFAULT 15 AFTER attendance_radius_meters + """)) + conn.execute(text(""" + UPDATE work_schedules + SET auto_submit_hours = 15 + WHERE auto_submit_hours IS NULL OR auto_submit_hours <= 0 + """)) + print("work_schedules auto_submit_hours migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/migrate_work_session_repeated_molds.py b/scripts/migrate_work_session_repeated_molds.py new file mode 100644 index 0000000..63c727c --- /dev/null +++ b/scripts/migrate_work_session_repeated_molds.py @@ -0,0 +1,37 @@ +from pathlib import Path +import sys + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import engine # noqa: E402 + + +def main() -> None: + with engine.begin() as conn: + session_index_exists = conn.execute(text(""" + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'work_session_devices' + AND index_name = 'idx_session_devices_session' + """)).scalar() + if not session_index_exists: + conn.execute(text("CREATE INDEX idx_session_devices_session ON work_session_devices (session_id)")) + + index_exists = conn.execute(text(""" + SELECT COUNT(1) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'work_session_devices' + AND index_name = 'uk_session_device' + """)).scalar() + if index_exists: + conn.execute(text("ALTER TABLE work_session_devices DROP INDEX uk_session_device")) + print("work session repeated molds migrated") + + +if __name__ == "__main__": + main() diff --git a/scripts/recalculate_report_metrics.py b/scripts/recalculate_report_metrics.py new file mode 100644 index 0000000..461b8bb --- /dev/null +++ b/scripts/recalculate_report_metrics.py @@ -0,0 +1,59 @@ +from argparse import ArgumentParser +from pathlib import Path +import sys + +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import SessionLocal # noqa: E402 +from app.models import ProductionReport, WorkSession # noqa: E402 +from app.services.metrics import calculate_report_metrics # noqa: E402 +from app.services.work_schedule import get_work_schedule_config # noqa: E402 + + +def apply_metrics(report: ProductionReport, schedule) -> None: + metrics = calculate_report_metrics( + report.start_at, + report.end_at, + list(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"] + + +def main() -> None: + parser = ArgumentParser(description="Recalculate production report metrics.") + parser.add_argument("--report-id", type=int, help="只重算指定报工 ID") + args = parser.parse_args() + + query = select(ProductionReport).options( + selectinload(ProductionReport.items), + selectinload(ProductionReport.session).selectinload(WorkSession.devices), + ) + if args.report_id: + query = query.where(ProductionReport.id == args.report_id) + + with SessionLocal() as db: + reports = db.scalars(query).all() + for report in reports: + schedule = get_work_schedule_config(db, report.attendance_point_name) + apply_metrics(report, schedule) + db.commit() + + print(f"recalculated reports: {len(reports)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/seed_demo.py b/scripts/seed_demo.py new file mode 100644 index 0000000..d4ff6e1 --- /dev/null +++ b/scripts/seed_demo.py @@ -0,0 +1,79 @@ +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from app.database import SessionLocal # noqa: E402 +from app.models import PersonRole, Personnel, Product, Role # noqa: E402 + + +PEOPLE = [ + {"phone": "13800000001", "name": "张三", "role": Role.worker}, + {"phone": "13800000002", "name": "李四", "role": Role.admin}, + {"phone": "13800000003", "name": "王经理", "role": Role.manager}, +] + +PRODUCTS = [ + { + "project_no": "W02", + "profile_no": "XUS-157-6001-003", + "product_name": "LAC-56011041后防撞梁左吸能盒端板", + "material_code": "110101002022", + "material_name": "LAC-56011041-后防撞梁左吸能盒端板-半成品", + "device_no": "20#", + "process_name": "1序", + "stamping_method": "切边冲孔", + "operator_count": 1, + "standard_beat": 10.984, + "standard_workload": 2622, + }, + { + "project_no": "W02", + "profile_no": "XUS-157-6009-001", + "product_name": "LAC-56010050/0014后防撞梁左连接板", + "material_code": "110501000186", + "material_name": "LAC-56010050-后防撞梁左连接板-半成品", + "device_no": "18#", + "process_name": "1序", + "stamping_method": "切边冲孔", + "operator_count": 1, + "standard_beat": 5.112, + "standard_workload": 5634, + }, +] + + +def main() -> None: + with SessionLocal() as db: + for item in PEOPLE: + person = db.get(Personnel, item["phone"]) or Personnel(phone=item["phone"]) + person.name = item["name"] + db.add(person) + if db.get(PersonRole, {"phone": item["phone"], "role": item["role"]}) is None: + db.add(PersonRole(phone=item["phone"], role=item["role"])) + + for item in PRODUCTS: + product = db.get( + Product, + { + "project_no": item["project_no"], + "product_name": item["product_name"], + "device_no": item["device_no"], + "process_name": item["process_name"], + }, + ) or Product( + project_no=item["project_no"], + product_name=item["product_name"], + device_no=item["device_no"], + process_name=item["process_name"], + ) + for key, value in item.items(): + setattr(product, key, value) + db.add(product) + db.commit() + print("demo data seeded") + + +if __name__ == "__main__": + main() diff --git a/tests/test_continuous_die.py b/tests/test_continuous_die.py new file mode 100644 index 0000000..36dcd75 --- /dev/null +++ b/tests/test_continuous_die.py @@ -0,0 +1,45 @@ +from types import SimpleNamespace + +from app.services.continuous_die import ( + CONTINUOUS_DIE_STAMPING_METHOD, + is_continuous_die_stamping_method, +) +from app.services.serializers import product_option + + +def test_continuous_die_stamping_method_is_trimmed_exact_match(): + assert CONTINUOUS_DIE_STAMPING_METHOD == "连续模" + assert is_continuous_die_stamping_method("连续模") is True + assert is_continuous_die_stamping_method(" 连续模 ") is True + assert is_continuous_die_stamping_method("连续模具") is False + assert is_continuous_die_stamping_method("清洗") is False + assert is_continuous_die_stamping_method(None) is False + + +def test_product_option_marks_continuous_die(): + product = SimpleNamespace( + attendance_point_name="嘉恒", + project_no="P001", + product_name="测试产品", + profile_no=None, + material_code=None, + material_name=None, + supplier=None, + product_net_weight_kg=None, + product_gross_weight_kg=None, + scrap_loss_rate=None, + waste_price_yuan_per_kg=None, + device_no="", + process_name="1", + stamping_method=" 连续模 ", + operator_count=1, + process_unit_price_yuan=0, + standard_beat=10, + standard_workload=0, + ) + + option = product_option(product) + + assert option.stamping_method == " 连续模 " + assert option.is_continuous_die is True + assert option.is_cleaning is False diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 0000000..46271d8 --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,282 @@ +from datetime import datetime, timedelta + +from app.services.metrics import calculate_report_metrics +from app.services.work_schedule import WorkScheduleConfig + + +class Item: + def __init__( + self, + device_no: str = "23#", + good_qty: float = 2, + standard_beat: float = 21.12, + standard_workload: float = 1513, + started_at: datetime | None = None, + ) -> None: + self.device_no = device_no + self.started_at = started_at + self.standard_beat = standard_beat + self.standard_workload = standard_workload + self.good_qty = good_qty + self.defect_qty = 0 + self.scrap_qty = 0 + self.allocated_minutes = 0 + + +class Device: + def __init__( + self, + device_no: str, + scanned_at: datetime, + sort_order: int, + released_at: datetime | None = None, + release_reason: str | None = None, + ) -> None: + self.device_no = device_no + self.scanned_at = scanned_at + self.sort_order = sort_order + self.released_at = released_at + self.release_reason = release_reason + + +def test_short_report_uses_actual_seconds_without_minute_floor(): + start_at = datetime(2026, 5, 3, 1, 4, 50) + end_at = start_at + timedelta(seconds=2) + + metrics = calculate_report_metrics(start_at, end_at, [Item()]) + + assert metrics["duration_minutes"] == 0.03 + assert metrics["effective_minutes"] == 0.03 + assert metrics["actual_beat"] == 1.0 + + +def test_multi_device_report_allocates_minutes_by_switch_time(): + start_at = datetime(2026, 5, 3, 1, 25, 55) + switch_at = start_at + timedelta(seconds=30) + end_at = switch_at + timedelta(seconds=30) + first_item = Item(device_no="23#", good_qty=3) + second_item = Item(device_no="28#", good_qty=6) + + metrics = calculate_report_metrics( + start_at, + end_at, + [first_item, second_item], + devices=[ + Device("23#", start_at, 0), + Device("28#", switch_at, 1), + ], + ) + + assert first_item.allocated_minutes == 0.5 + assert second_item.allocated_minutes == 0.5 + assert first_item.allocated_minutes * 60 / first_item.good_qty == 10 + assert second_item.allocated_minutes * 60 / second_item.good_qty == 5 + assert metrics["duration_minutes"] == 1 + + +def test_multi_device_report_last_segment_uses_report_end_time(): + start_at = datetime(2026, 5, 3, 1, 56, 57) + switch_at = start_at + timedelta(seconds=30) + end_at = start_at + timedelta(seconds=44) + first_item = Item(device_no="23#", good_qty=3) + second_item = Item(device_no="28#", good_qty=5) + + metrics = calculate_report_metrics( + start_at, + end_at, + [first_item, second_item], + devices=[ + Device("23#", start_at, 0), + Device("28#", switch_at, 1), + ], + ) + + total_allocated_seconds = round((first_item.allocated_minutes + second_item.allocated_minutes) * 60) + assert metrics["duration_minutes"] == 0.73 + assert round(first_item.allocated_minutes * 60) == 30 + assert round(second_item.allocated_minutes * 60) == 14 + assert total_allocated_seconds == 44 + + +def test_same_device_items_split_by_item_start_time(): + start_at = datetime(2026, 5, 3, 21, 51, 35) + product_switch_at = start_at + timedelta(seconds=20) + end_at = start_at + timedelta(seconds=50) + first_item = Item(device_no="28#", good_qty=1, started_at=start_at) + second_item = Item(device_no="28#", good_qty=3, started_at=product_switch_at) + + metrics = calculate_report_metrics( + start_at, + end_at, + [first_item, second_item], + devices=[ + Device("28#", start_at, 0), + ], + ) + + assert round(first_item.allocated_minutes * 60) == 20 + assert round(second_item.allocated_minutes * 60) == 30 + assert metrics["duration_minutes"] == 0.83 + + +def test_meal_break_is_deducted_and_shift_distribution_is_marked(): + start_at = datetime(2026, 5, 4, 8, 0, 0) + end_at = datetime(2026, 5, 4, 20, 0, 0) + item = Item(good_qty=10) + + metrics = calculate_report_metrics(start_at, end_at, [item]) + + assert metrics["duration_minutes"] == 720 + assert metrics["shift_meal_break_minutes"] == 100 + assert metrics["effective_minutes"] == 620 + assert metrics["shift_day_minutes"] == 500 + assert metrics["shift_overtime_minutes"] == 120 + assert metrics["shift_distribution_text"] == "工时 10.33小时(白班8.33小时、加班2小时)" + + +def test_partial_meal_tail_is_not_deducted_when_report_ends_during_meal(): + schedule = WorkScheduleConfig( + day_start="08:00", + day_end="17:00", + lunch_start="11:40", + lunch_end="12:40", + dinner_start="17:00", + dinner_end="17:30", + overtime_start="17:00", + overtime_end="20:00", + night_start="20:00", + night_end="06:00", + ) + start_at = datetime(2026, 5, 4, 16, 0, 0) + end_at = datetime(2026, 5, 4, 17, 10, 0) + + metrics = calculate_report_metrics(start_at, end_at, [Item()], schedule=schedule) + + assert metrics["duration_minutes"] == 70 + assert metrics["shift_meal_break_minutes"] == 0 + assert metrics["effective_minutes"] == 70 + assert metrics["shift_day_minutes"] == 60 + assert metrics["shift_overtime_minutes"] == 10 + + +def test_meal_end_grace_keeps_minutes_until_five_minutes_after_meal(): + schedule = WorkScheduleConfig( + day_start="08:00", + day_end="17:00", + lunch_start="11:40", + lunch_end="12:40", + dinner_start="17:00", + dinner_end="17:30", + overtime_start="17:00", + overtime_end="20:00", + night_start="20:00", + night_end="06:00", + ) + start_at = datetime(2026, 5, 4, 16, 0, 0) + end_at = datetime(2026, 5, 4, 17, 35, 0) + + metrics = calculate_report_metrics(start_at, end_at, [Item()], schedule=schedule) + + assert metrics["duration_minutes"] == 95 + assert metrics["shift_meal_break_minutes"] == 0 + assert metrics["effective_minutes"] == 95 + assert metrics["shift_day_minutes"] == 60 + assert metrics["shift_overtime_minutes"] == 35 + + +def test_meal_break_is_deducted_after_grace_window(): + schedule = WorkScheduleConfig( + day_start="08:00", + day_end="17:00", + lunch_start="11:40", + lunch_end="12:40", + dinner_start="17:00", + dinner_end="17:30", + overtime_start="17:00", + overtime_end="20:00", + night_start="20:00", + night_end="06:00", + ) + start_at = datetime(2026, 5, 4, 16, 0, 0) + end_at = datetime(2026, 5, 4, 17, 36, 0) + + metrics = calculate_report_metrics(start_at, end_at, [Item()], schedule=schedule) + + assert metrics["duration_minutes"] == 96 + assert metrics["shift_meal_break_minutes"] == 30 + assert metrics["effective_minutes"] == 66 + assert metrics["shift_day_minutes"] == 60 + assert metrics["shift_overtime_minutes"] == 6 + + +def test_full_meal_break_is_deducted_when_report_covers_whole_meal(): + schedule = WorkScheduleConfig( + day_start="08:00", + day_end="17:00", + lunch_start="11:40", + lunch_end="12:40", + dinner_start="17:00", + dinner_end="17:30", + overtime_start="17:00", + overtime_end="20:00", + night_start="20:00", + night_end="06:00", + ) + start_at = datetime(2026, 5, 4, 16, 0, 0) + end_at = datetime(2026, 5, 4, 18, 0, 0) + + metrics = calculate_report_metrics(start_at, end_at, [Item()], schedule=schedule) + + assert metrics["duration_minutes"] == 120 + assert metrics["shift_meal_break_minutes"] == 30 + assert metrics["effective_minutes"] == 90 + assert metrics["shift_day_minutes"] == 60 + assert metrics["shift_overtime_minutes"] == 30 + + +def test_admin_corrected_start_uses_first_device_from_report_start(): + start_at = datetime(2026, 5, 27, 10, 0, 0) + scanned_at = datetime(2026, 5, 27, 13, 56, 11) + submitted_at = datetime(2026, 5, 27, 13, 56, 53) + end_at = datetime(2026, 5, 27, 13, 59, 0) + item = Item(device_no="开口挡圈-3528221-05", good_qty=2700, standard_beat=1, started_at=scanned_at) + + metrics = calculate_report_metrics( + start_at, + end_at, + [item], + devices=[ + Device( + "开口挡圈-3528221-05", + scanned_at, + 0, + released_at=submitted_at, + release_reason="报工提交释放占用", + ), + ], + ) + + assert metrics["duration_minutes"] == 239 + assert metrics["effective_minutes"] == 179 + assert item.allocated_minutes == 179 + assert metrics["shift_distribution_text"] == "工时 2.98小时(白班2.98小时)" + + +def test_admin_forced_release_still_caps_work_time(): + start_at = datetime(2026, 5, 27, 8, 0, 0) + release_at = datetime(2026, 5, 27, 9, 0, 0) + end_at = datetime(2026, 5, 27, 12, 0, 0) + item = Item(device_no="A", good_qty=10) + + metrics = calculate_report_metrics( + start_at, + end_at, + [item], + devices=[ + Device("A", start_at, 0, released_at=release_at, release_reason="管理员强制解除占用"), + ], + ) + + assert metrics["duration_minutes"] == 60 + assert metrics["effective_minutes"] == 60 + assert item.allocated_minutes == 60 diff --git a/tests/test_product_delete.py b/tests/test_product_delete.py new file mode 100644 index 0000000..e3a44f4 --- /dev/null +++ b/tests/test_product_delete.py @@ -0,0 +1,126 @@ +from datetime import date, datetime + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +from app.database import Base +from app.models import ( + AttendancePoint, + DeviceQRCode, + Personnel, + Product, + ProductionReport, + ProductionReportItem, + ReportStatus, + Role, + SessionStatus, + WorkSession, + WorkSessionDevice, +) +from app.routers.products import delete_product + + +def _db_session(): + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + SessionLocal = sessionmaker(bind=engine, future=True) + return SessionLocal() + + +def test_delete_product_removes_qrcode_but_keeps_history(): + db = _db_session() + try: + point = "测试考勤点" + user = Personnel(phone="13900000000", name="管理员") + user.role = Role.manager + db.add_all([ + AttendancePoint(name=point, radius_meters=500, is_active=True), + user, + Product( + attendance_point_name=point, + project_no="测试", + product_name="测试", + device_no="", + process_name="1", + stamping_method="普通", + operator_count=1, + standard_beat=1, + standard_workload=0, + ), + DeviceQRCode( + attendance_point_name=point, + device_no="测试", + process_name="1", + qr_scene="mold=old", + created_by=user.phone, + ), + ]) + db.flush() + session = WorkSession( + id=1, + attendance_point_name=point, + employee_phone=user.phone, + start_at=datetime(2026, 6, 23, 8, 0), + status=SessionStatus.submitted, + ) + db.add(session) + db.flush() + report = ProductionReport( + id=1, + attendance_point_name=point, + session_id=session.id, + employee_phone=user.phone, + report_date=date(2026, 6, 23), + start_at=datetime(2026, 6, 23, 8, 0), + end_at=datetime(2026, 6, 23, 9, 0), + status=ReportStatus.pending, + ) + db.add(report) + db.flush() + history_device = WorkSessionDevice( + id=1, + session_id=session.id, + attendance_point_name=point, + device_no="测试", + process_name="1", + scanned_at=datetime(2026, 6, 23, 8, 0), + sort_order=0, + ) + history_item = ProductionReportItem( + id=1, + report_id=report.id, + attendance_point_name=point, + device_no="28#", + project_no="测试", + product_name="测试", + process_name="1", + standard_beat=1, + ) + db.add_all([history_device, history_item]) + db.commit() + + delete_product( + attendance_point_name=point, + project_no="测试", + product_name="测试", + process_name="1", + user=user, + db=db, + ) + + assert db.get(Product, { + "attendance_point_name": point, + "project_no": "测试", + "product_name": "测试", + "device_no": "", + "process_name": "1", + }) is None + assert db.get(DeviceQRCode, { + "attendance_point_name": point, + "device_no": "测试", + "process_name": "1", + }) is None + assert db.scalar(select(ProductionReportItem).where(ProductionReportItem.id == history_item.id)) is not None + assert db.scalar(select(WorkSessionDevice).where(WorkSessionDevice.id == history_device.id)) is not None + finally: + db.close() diff --git a/tests/test_review_corrections.py b/tests/test_review_corrections.py new file mode 100644 index 0000000..95061b2 --- /dev/null +++ b/tests/test_review_corrections.py @@ -0,0 +1,96 @@ +from datetime import datetime +from types import SimpleNamespace + +from app.routers.reviews import _apply_device_corrections +from app.routers.reviews import _effective_review_time +from app.schemas import ApproveReportRequest, ReportDeviceCorrection +from app.services.serializers import _datetime_changed + + +def _device(id_: int, name: str, scanned_at: datetime, sort_order: int): + return SimpleNamespace( + id=id_, + device_no=name, + process_name="1", + scanned_at=scanned_at, + sort_order=sort_order, + ) + + +def _report(devices): + return SimpleNamespace(session=SimpleNamespace(devices=devices)) + + +def test_first_device_correction_syncs_report_start_time(): + first_scan = datetime(2026, 5, 27, 8, 0) + next_start = datetime(2026, 5, 27, 8, 0) + next_end = datetime(2026, 5, 27, 10, 0) + first = _device(1, "A", first_scan, 0) + second = _device(2, "B", datetime(2026, 5, 27, 9, 30), 1) + payload = ApproveReportRequest( + device_corrections=[ + ReportDeviceCorrection(id=1, scanned_at=datetime(2026, 5, 27, 8, 30)) + ] + ) + + changed, corrected_start = _apply_device_corrections(None, _report([first, second]), payload, next_start, next_end) + + assert changed is True + assert corrected_start == datetime(2026, 5, 27, 8, 30) + assert first.scanned_at == corrected_start + + +def test_top_start_time_syncs_first_device_when_device_corrections_are_empty(): + first = _device(1, "A", datetime(2026, 5, 27, 13, 56, 11), 0) + payload = ApproveReportRequest() + + changed, corrected_start = _apply_device_corrections( + None, + _report([first]), + payload, + datetime(2026, 5, 27, 10, 0), + datetime(2026, 5, 27, 13, 59), + ) + + assert changed is True + assert corrected_start == datetime(2026, 5, 27, 10, 0) + assert first.scanned_at == corrected_start + + +def test_non_first_device_correction_does_not_change_report_start_time(): + first = _device(1, "A", datetime(2026, 5, 27, 8, 0), 0) + second = _device(2, "B", datetime(2026, 5, 27, 9, 0), 1) + payload = ApproveReportRequest( + device_corrections=[ + ReportDeviceCorrection(id=2, scanned_at=datetime(2026, 5, 27, 9, 30)) + ] + ) + + changed, corrected_start = _apply_device_corrections( + None, + _report([first, second]), + payload, + datetime(2026, 5, 27, 8, 0), + datetime(2026, 5, 27, 10, 0), + ) + + assert changed is True + assert corrected_start == datetime(2026, 5, 27, 8, 0) + assert first.scanned_at == datetime(2026, 5, 27, 8, 0) + assert second.scanned_at == datetime(2026, 5, 27, 9, 30) + + +def test_same_minute_picker_value_keeps_original_seconds(): + current = datetime(2026, 5, 29, 15, 10, 6) + payload = datetime(2026, 5, 29, 15, 10, 0) + + assert _effective_review_time(payload, current) == current + assert _datetime_changed(current.isoformat(), payload.isoformat()) is False + + +def test_adjacent_minute_picker_value_is_real_correction(): + current = datetime(2026, 5, 29, 15, 10, 6) + payload = datetime(2026, 5, 29, 15, 11, 0) + + assert _effective_review_time(payload, current) == payload + assert _datetime_changed(current.isoformat(), payload.isoformat()) is True diff --git a/tests/test_usage_stats.py b/tests/test_usage_stats.py new file mode 100644 index 0000000..4086473 --- /dev/null +++ b/tests/test_usage_stats.py @@ -0,0 +1,648 @@ +from io import BytesIO +from types import SimpleNamespace +import json + +from openpyxl import load_workbook + +from app.routers.usage_stats import ( + _build_usage_stats_export_response, + _filter_usage_rows, + _matches_target, + _sort_usage_rows, + _to_detail_out, + _to_schema_row, +) +from app.schemas import UsageStatsDetailOut, UsageStatsRow +from app.services.usage_stats import UsageStatRow +from app.services.usage_stats import build_usage_stats, split_cleaning_device_nos +from app.services.usage_stats_export import export_usage_stats_rows + + +def _report(*items): + return SimpleNamespace(items=list(items)) + + +def _item(**overrides): + values = { + "attendance_point_name": "嘉恒", + "product_name": "产品A", + "process_name": "1", + "stamping_method": "普通", + "operator_count": 1, + "device_no": "28#", + "allocated_minutes": 0, + "good_qty": 0, + } + values.update(overrides) + return SimpleNamespace(**values) + + +def test_split_cleaning_device_nos_supports_chinese_and_ascii_commas(): + assert split_cleaning_device_nos("清洗机A、清洗机B, 清洗机C") == ["清洗机A", "清洗机B", "清洗机C"] + assert split_cleaning_device_nos(None) == [] + assert split_cleaning_device_nos("") == [] + + +def test_split_cleaning_device_nos_deduplicates_in_order(): + assert split_cleaning_device_nos("A、A、B") == ["A", "B"] + + +def test_build_usage_stats_for_stamping_device_uses_allocated_minutes(): + rows = build_usage_stats( + reports=[_report(_item(device_no="28#", allocated_minutes=75, good_qty=12))], + category="device", + equipment_type_by_key={("嘉恒", "28#"): "冲压设备"}, + ) + + assert len(rows) == 1 + row = rows[0] + assert row.name == "28#" + assert row.metric_kind == "minutes" + assert row.value == 75 + assert row.report_count == 1 + assert row.object_type == "冲压设备" + assert row.id + assert "device" in row.id + assert "嘉恒" in row.id + assert "28#" in row.id + assert "minutes" in row.id + + +def test_build_usage_stats_for_cleaning_device_splits_quantity_evenly(): + rows = build_usage_stats( + reports=[ + _report( + _item( + device_no="清洗机A、清洗机B", + stamping_method="清洗", + good_qty=100, + allocated_minutes=0, + ) + ) + ], + category="device", + equipment_type_by_key={("嘉恒", "清洗机A"): "清洗设备", ("嘉恒", "清洗机B"): "清洗设备"}, + ) + + assert [(row.name, row.metric_kind, row.value, row.report_count) for row in rows] == [ + ("清洗机A", "quantity", 50, 1), + ("清洗机B", "quantity", 50, 1), + ] + assert [row.object_type for row in rows] == ["清洗设备", "清洗设备"] + + +def test_build_usage_stats_for_cleaning_device_deduplicates_before_splitting_quantity(): + rows = build_usage_stats( + reports=[ + _report( + _item( + device_no="清洗机A、清洗机A、清洗机B", + stamping_method="清洗", + good_qty=100, + ) + ) + ], + category="device", + equipment_type_by_key={}, + ) + + assert [(row.name, row.value, row.report_count) for row in rows] == [ + ("清洗机A", 50, 1), + ("清洗机B", 50, 1), + ] + + +def test_build_usage_stats_for_device_skips_misc_items(): + rows = build_usage_stats( + reports=[ + _report( + _item( + product_name="处理杂活", + process_name="杂活", + stamping_method="处理杂活", + device_no="28#", + allocated_minutes=30, + ) + ) + ], + category="device", + equipment_type_by_key={("嘉恒", "28#"): "冲压设备"}, + ) + + assert rows == [] + + +def test_build_usage_stats_for_non_cleaning_device_ignores_cleaning_equipment_type(): + rows = build_usage_stats( + reports=[_report(_item(device_no="清洗机A", allocated_minutes=75, good_qty=12))], + category="device", + equipment_type_by_key={("嘉恒", "清洗机A"): "清洗设备"}, + ) + + assert len(rows) == 1 + assert rows[0].name == "清洗机A" + assert rows[0].metric_kind == "minutes" + assert rows[0].value == 75 + assert rows[0].object_type == "清洗设备" + + +def test_build_usage_stats_for_cleaning_device_marks_cleaning_tag(): + rows = build_usage_stats( + reports=[ + _report( + _item( + device_no="清洗机A", + stamping_method="清洗", + good_qty=100, + allocated_minutes=0, + ) + ) + ], + category="device", + equipment_type_by_key={("嘉恒", "清洗机A"): "清洗设备"}, + ) + + assert len(rows) == 1 + assert "清洗" in rows[0].tags + assert "清洗设备" not in rows[0].tags + assert rows[0].object_type == "清洗设备" + + +def test_build_usage_stats_for_mold_merges_by_point_product_process_and_stamping_method(): + rows = build_usage_stats( + reports=[ + _report( + _item(stamping_method="连续模", allocated_minutes=30), + _item(stamping_method="连续模", allocated_minutes=45), + ) + ], + category="mold", + equipment_type_by_key={}, + ) + + assert len(rows) == 1 + row = rows[0] + assert row.attendance_point_name == "嘉恒" + assert row.product_name == "产品A" + assert row.process_name == "1" + assert row.stamping_method == "连续模" + assert row.metric_kind == "minutes" + assert row.value == 75 + assert row.report_count == 2 + assert row.object_type == "连续模" + assert "连续模" in row.tags + + +def test_build_usage_stats_for_mold_ids_distinguish_point_and_process(): + rows = build_usage_stats( + reports=[ + _report( + _item(attendance_point_name="嘉恒", process_name="1", allocated_minutes=30), + _item(attendance_point_name="二厂", process_name="1", allocated_minutes=40), + _item(attendance_point_name="嘉恒", process_name="2", allocated_minutes=50), + ) + ], + category="mold", + equipment_type_by_key={}, + ) + + ids = {row.id for row in rows} + assert len(rows) == 3 + assert len(ids) == 3 + + +def test_build_usage_stats_preserves_numeric_zero_identity_fields(): + rows = build_usage_stats( + reports=[_report(_item(product_name=0, process_name=0, allocated_minutes=10))], + category="mold", + equipment_type_by_key={}, + ) + + assert len(rows) == 1 + assert rows[0].product_name == "0" + assert rows[0].process_name == "0" + assert json.loads(rows[0].id)[3] == "0" + assert json.loads(rows[0].id)[4] == "0" + + +def test_build_usage_stats_uses_structured_ids_when_fields_contain_delimiter(): + rows = build_usage_stats( + reports=[ + _report( + _item(attendance_point_name="A||B", product_name="C", process_name="1", allocated_minutes=10), + _item(attendance_point_name="A", product_name="B||C", process_name="1", allocated_minutes=20), + ) + ], + category="mold", + equipment_type_by_key={}, + ) + + ids = {row.id for row in rows} + assert len(ids) == 2 + assert all(json.loads(row_id) for row_id in ids) + assert {tuple(json.loads(row_id)[:5]) for row_id in ids} == { + ("mold", "A||B", "A||B / C / 1 / 普通", "C", "1"), + ("mold", "A", "A / B||C / 1 / 普通", "B||C", "1"), + } + + +def test_build_usage_stats_for_cleaning_mold_uses_good_quantity(): + rows = build_usage_stats( + reports=[_report(_item(stamping_method="清洗", allocated_minutes=80, good_qty=64))], + category="mold", + equipment_type_by_key={}, + ) + + assert len(rows) == 1 + assert rows[0].metric_kind == "quantity" + assert rows[0].value == 64 + assert rows[0].object_type == "清洗模具" + assert "清洗" in rows[0].tags + + +def test_build_usage_stats_for_mold_skips_misc_and_marks_multi_person(): + rows = build_usage_stats( + reports=[ + _report( + _item( + product_name="处理杂活", + process_name="杂活", + stamping_method="处理杂活", + allocated_minutes=30, + ), + _item(operator_count=2, allocated_minutes=40), + ) + ], + category="mold", + equipment_type_by_key={}, + ) + + assert len(rows) == 1 + assert rows[0].product_name == "产品A" + assert rows[0].value == 40 + assert rows[0].object_type == "多人工序" + assert "多人协作" in rows[0].tags + + +def test_build_usage_stats_for_non_cleaning_device_defaults_object_type_to_stamping_device(): + rows = build_usage_stats( + reports=[_report(_item(device_no="29#", allocated_minutes=25))], + category="device", + equipment_type_by_key={}, + ) + + assert len(rows) == 1 + assert rows[0].object_type == "冲压设备" + + +def test_build_usage_stats_for_cleaning_device_defaults_object_type_to_cleaning_device(): + rows = build_usage_stats( + reports=[ + _report( + _item( + device_no="清洗机Z", + stamping_method="清洗", + good_qty=10, + ) + ) + ], + category="device", + equipment_type_by_key={}, + ) + + assert len(rows) == 1 + assert rows[0].object_type == "清洗设备" + + +def test_filter_usage_rows_searches_all_visible_fields(): + rows = [ + UsageStatRow( + id="device-1", + category="device", + name="28#", + metric_kind="minutes", + object_type="冲压设备", + value=360, + report_count=2, + attendance_point_name="嘉恒", + ), + UsageStatRow( + id="mold-1", + category="mold", + name="旭升 / 产品B / 2 / 连续模", + metric_kind="minutes", + object_type="连续模", + value=45, + report_count=9, + attendance_point_name="旭升", + product_name="产品B", + process_name="2", + stamping_method="连续模", + tags=["连续模", "多人协作"], + ), + ] + + assert _filter_usage_rows(rows, "") == rows + assert [row.id for row in _filter_usage_rows(rows, "嘉恒")] == ["device-1"] + assert [row.id for row in _filter_usage_rows(rows, "冲压")] == ["device-1"] + assert [row.id for row in _filter_usage_rows(rows, "产品B")] == ["mold-1"] + assert [row.id for row in _filter_usage_rows(rows, "多人协作")] == ["mold-1"] + assert [row.id for row in _filter_usage_rows(rows, "360")] == ["device-1"] + assert [row.id for row in _filter_usage_rows(rows, "9")] == ["mold-1"] + + +def test_export_usage_stats_rows_writes_device_and_mold_sheets(): + device_rows = [ + UsageStatRow( + id="device-minutes", + category="device", + name="28#", + metric_kind="minutes", + object_type="冲压设备", + value=360, + report_count=2, + attendance_point_name="嘉恒", + ), + UsageStatRow( + id="device-quantity", + category="device", + name="清洗机A", + metric_kind="quantity", + object_type="清洗设备", + value=30, + report_count=1, + attendance_point_name="嘉恒", + ), + ] + mold_rows = [ + UsageStatRow( + id="mold-minutes", + category="mold", + name="嘉恒 / 产品A / 1 / 普通", + metric_kind="minutes", + object_type="普通模具", + value=45, + report_count=3, + attendance_point_name="嘉恒", + product_name="产品A", + process_name="1", + stamping_method="普通", + ), + UsageStatRow( + id="mold-quantity", + category="mold", + name="嘉恒 / 产品B / 2 / 清洗", + metric_kind="quantity", + object_type="清洗模具", + value=18, + report_count=1, + attendance_point_name="嘉恒", + product_name="产品B", + process_name="2", + stamping_method="清洗", + ), + ] + + workbook = load_workbook(BytesIO(export_usage_stats_rows(device_rows, mold_rows))) + + assert workbook.sheetnames == ["设备统计", "模具统计"] + device_sheet = workbook["设备统计"] + mold_sheet = workbook["模具统计"] + assert [cell.value for cell in device_sheet[1]] == ["考勤点", "设备号", "设备类型", "使用时长(分钟)", "使用时长(小时)", "使用时长(天)", "清洗数量", "报工次数"] + assert [cell.value for cell in mold_sheet[1]] == ["考勤点", "产品名称", "工序", "冲压方式", "使用时长(分钟)", "使用时长(小时)", "使用时长(天)", "清洗数量", "报工次数"] + assert [cell.value for cell in device_sheet[2]] == ["嘉恒", "28#", "冲压设备", 360, 6, 0.25, 0, 2] + assert [cell.value for cell in device_sheet[3]] == ["嘉恒", "清洗机A", "清洗设备", 0, 0, 0, 30, 1] + assert [cell.value for cell in mold_sheet[2]] == ["嘉恒", "产品A", "1", "普通", 45, 0.75, 0.03, 0, 3] + assert [cell.value for cell in mold_sheet[3]] == ["嘉恒", "产品B", "2", "清洗", 0, 0, 0, 18, 1] + + +def test_build_usage_stats_export_response_returns_xlsx_attachment(): + device_rows = [ + UsageStatRow( + id="device-minutes", + category="device", + name="28#", + metric_kind="minutes", + object_type="冲压设备", + value=75, + report_count=2, + attendance_point_name="嘉恒", + ) + ] + mold_rows = [ + UsageStatRow( + id="mold-minutes", + category="mold", + name="嘉恒 / 产品A / 1 / 普通", + metric_kind="minutes", + object_type="普通模具", + value=45, + report_count=3, + attendance_point_name="嘉恒", + product_name="产品A", + process_name="1", + stamping_method="普通", + ) + ] + + response = _build_usage_stats_export_response(device_rows, mold_rows) + + assert response.media_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + assert "usage-stats.xlsx" in response.headers["Content-Disposition"] + workbook = load_workbook(BytesIO(response.body)) + assert workbook.sheetnames == ["设备统计", "模具统计"] + + +def test_sort_usage_rows_by_value_desc_then_report_count_desc_then_name(): + rows = [ + UsageStatRow(id="b", category="device", name="B", metric_kind="minutes", object_type="冲压设备", value=10, report_count=1), + UsageStatRow(id="c", category="device", name="C", metric_kind="minutes", object_type="冲压设备", value=20, report_count=1), + UsageStatRow(id="a", category="device", name="A", metric_kind="minutes", object_type="冲压设备", value=20, report_count=3), + ] + + sorted_rows = _sort_usage_rows(rows, "value") + + assert [row.name for row in sorted_rows] == ["A", "C", "B"] + + +def test_sort_usage_rows_by_count_desc_then_value_desc_then_name(): + rows = [ + UsageStatRow(id="b", category="device", name="B", metric_kind="minutes", object_type="冲压设备", value=20, report_count=2), + UsageStatRow(id="c", category="device", name="C", metric_kind="minutes", object_type="冲压设备", value=30, report_count=1), + UsageStatRow(id="a", category="device", name="A", metric_kind="minutes", object_type="冲压设备", value=30, report_count=2), + ] + + sorted_rows = _sort_usage_rows(rows, "count") + + assert [row.name for row in sorted_rows] == ["A", "B", "C"] + + +def test_to_schema_row_maps_usage_stat_fields(): + row = UsageStatRow( + id="row-1", + category="mold", + name="嘉恒 / 产品A / 1 / 普通", + metric_kind="minutes", + object_type="普通模具", + value=12.34, + report_count=5, + tags=["多人协作"], + attendance_point_name="嘉恒", + product_name="产品A", + process_name="1", + stamping_method="普通", + ) + + schema_row = _to_schema_row(row) + + assert isinstance(schema_row, UsageStatsRow) + assert schema_row.model_dump() == { + "id": "row-1", + "category": "mold", + "name": "嘉恒 / 产品A / 1 / 普通", + "metric_kind": "minutes", + "object_type": "普通模具", + "value": 12.34, + "report_count": 5, + "tags": ["多人协作"], + "attendance_point_name": "嘉恒", + "product_name": "产品A", + "process_name": "1", + "stamping_method": "普通", + } + + +def test_to_detail_out_maps_target_identity_fields(): + row = UsageStatRow( + id="target-1", + category="mold", + name="嘉恒 / 产品A / 1 / 连续模", + metric_kind="minutes", + object_type="连续模", + value=88, + report_count=4, + tags=["连续模"], + attendance_point_name="嘉恒", + product_name="产品A", + process_name="1", + stamping_method="连续模", + ) + + detail = _to_detail_out(row) + + assert isinstance(detail, UsageStatsDetailOut) + assert detail.model_dump() == { + "id": "target-1", + "category": "mold", + "attendance_point_name": "嘉恒", + "name": "嘉恒 / 产品A / 1 / 连续模", + "object_type": "连续模", + "metric_kind": "minutes", + "value": 88, + "report_count": 4, + "product_name": "产品A", + "process_name": "1", + "stamping_method": "连续模", + "tags": ["连续模"], + "daily_rows": [], + "report_rows": [], + } + + +def test_usage_stats_detail_out_identity_fields_have_empty_defaults(): + detail = UsageStatsDetailOut(category="device") + + assert detail.id == "" + assert detail.product_name == "" + assert detail.process_name == "" + assert detail.stamping_method == "" + assert detail.tags == [] + + +def test_matches_target_allows_accessible_point_when_attendance_point_name_is_empty(): + row = UsageStatRow( + id="row-1", + category="device", + name="28#", + metric_kind="minutes", + object_type="冲压设备", + attendance_point_name="嘉恒", + ) + + assert _matches_target( + row, + id="", + category="device", + attendance_point_name="", + name="28#", + product_name="", + process_name="", + stamping_method="", + metric_kind="", + ) + + +def test_matches_target_uses_metric_kind_to_disambiguate_same_device_name(): + minutes_row = UsageStatRow( + id="minutes-id", + category="device", + name="清洗机A", + metric_kind="minutes", + object_type="清洗设备", + attendance_point_name="嘉恒", + ) + quantity_row = UsageStatRow( + id="quantity-id", + category="device", + name="清洗机A", + metric_kind="quantity", + object_type="清洗设备", + attendance_point_name="嘉恒", + ) + + target_args = { + "id": "", + "category": "device", + "attendance_point_name": "", + "name": "清洗机A", + "product_name": "", + "process_name": "", + "stamping_method": "", + "metric_kind": "quantity", + } + + assert not _matches_target(minutes_row, **target_args) + assert _matches_target(quantity_row, **target_args) + + +def test_matches_target_uses_id_before_ambiguous_device_fields(): + minutes_row = UsageStatRow( + id="minutes-id", + category="device", + name="清洗机A", + metric_kind="minutes", + object_type="清洗设备", + attendance_point_name="嘉恒", + ) + quantity_row = UsageStatRow( + id="quantity-id", + category="device", + name="清洗机A", + metric_kind="quantity", + object_type="清洗设备", + attendance_point_name="嘉恒", + ) + + target_args = { + "id": "minutes-id", + "category": "device", + "attendance_point_name": "", + "name": "清洗机A", + "product_name": "", + "process_name": "", + "stamping_method": "", + "metric_kind": "quantity", + } + + assert _matches_target(minutes_row, **target_args) + assert not _matches_target(quantity_row, **target_args) diff --git a/tests/test_work_order_batch_options.py b/tests/test_work_order_batch_options.py new file mode 100644 index 0000000..c78ef7b --- /dev/null +++ b/tests/test_work_order_batch_options.py @@ -0,0 +1,181 @@ +from datetime import datetime + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from app.routers import reports + + +def _session(): + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + with engine.begin() as connection: + connection.execute(text("create table md_item (id integer primary key, item_code text, item_name text)")) + connection.execute( + text( + """ + create table pp_production_batch_ledger ( + id integer primary key, + material_lot_no text, + product_item_id integer, + status text, + miniapp_selectable integer + ) + """ + ) + ) + SessionLocal = sessionmaker(bind=engine, future=True) + return SessionLocal() + + +def test_raw_material_batch_options_returns_material_lot_numbers_for_product_process(monkeypatch): + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 2, 10, 0, 0) + + monkeypatch.setattr(reports, "datetime", FrozenDateTime) + db = _session() + try: + db.execute( + text("insert into md_item (id, item_code, item_name) values (1, 'FG-001', '钢制碗'), (2, 'FG-002', '钢制盆')") + ) + db.execute( + text( + """ + insert into pp_production_batch_ledger (id, material_lot_no, product_item_id, status, miniapp_selectable) + values + (1, 'YL0001', 1, '在生产', 1), + (2, 'YL0002', 1, '在生产', 1), + (3, 'YL0003', 1, '锁单', 0), + (4, '', 1, '在生产', 1), + (5, null, 1, '在生产', 1), + (6, 'YL0004', 2, '在生产', 1) + """ + ) + ) + db.commit() + + options = reports._raw_material_batch_options( + db, + project_no="FG-001", + product_name="钢制碗", + process_name="1序", + ) + + assert options == ["YL0001", "YL0002"] + finally: + db.close() + + +def test_raw_material_batch_options_matches_project_no_when_product_name_is_empty(monkeypatch): + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 2, 10, 0, 0) + + monkeypatch.setattr(reports, "datetime", FrozenDateTime) + db = _session() + try: + db.execute(text("insert into md_item (id, item_code, item_name) values (1, 'FG-001', '钢制碗')")) + db.execute( + text( + """ + insert into pp_production_batch_ledger (id, material_lot_no, product_item_id, status, miniapp_selectable) + values (1, 'YL0001', 1, '在生产', 1) + """ + ) + ) + db.commit() + + options = reports._raw_material_batch_options( + db, + project_no="FG-001", + product_name="", + process_name="1序", + ) + + assert options == ["YL0001"] + finally: + db.close() + + +def test_raw_material_batch_options_uses_project_no_to_narrow_duplicate_product_names(monkeypatch): + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 2, 10, 0, 0) + + monkeypatch.setattr(reports, "datetime", FrozenDateTime) + db = _session() + try: + db.execute( + text( + """ + insert into md_item (id, item_code, item_name) + values + (1, 'FG-001', '钢制碗'), + (2, 'FG-002', '钢制碗') + """ + ) + ) + db.execute( + text( + """ + insert into pp_production_batch_ledger (id, material_lot_no, product_item_id, status, miniapp_selectable) + values + (1, 'YL0001', 1, '在生产', 1), + (2, 'YL0002', 2, '在生产', 1) + """ + ) + ) + db.commit() + + options = reports._raw_material_batch_options( + db, + project_no="FG-001", + product_name="钢制碗", + process_name="1序", + ) + + assert options == ["YL0001"] + finally: + db.close() + + +def test_raw_material_batch_options_excludes_locked_and_unselectable_ledgers(monkeypatch): + class FrozenDateTime(datetime): + @classmethod + def now(cls, tz=None): # noqa: ANN001 + _ = tz + return cls(2026, 6, 2, 10, 0, 0) + + monkeypatch.setattr(reports, "datetime", FrozenDateTime) + db = _session() + try: + db.execute(text("insert into md_item (id, item_code, item_name) values (1, 'FG-001', '钢制碗')")) + db.execute( + text( + """ + insert into pp_production_batch_ledger (id, material_lot_no, product_item_id, status, miniapp_selectable) + values + (1, 'YL0001', 1, '锁单', 0), + (2, 'YL0002', 1, '在生产', 0), + (3, 'YL0003', 1, '在生产', 1) + """ + ) + ) + db.commit() + + options = reports._raw_material_batch_options( + db, + project_no="FG-001", + product_name="钢制碗", + process_name="1序", + ) + + assert options == ["YL0003"] + finally: + db.close()