chore: initialize mini program backend
This commit is contained in:
@@ -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
|
||||
|
||||
+15
@@ -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
|
||||
@@ -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` 只能由经理修改。
|
||||
- 经理看板只统计审核通过的报工,并按 `报工日期 + 员工电话` 合并。
|
||||
- 产品唯一键为 `项目号 + 产品名称 + 使用设备号 + 工序`。
|
||||
- 人员为 `手机号 + 姓名` 主体,同一手机号可以绑定多个角色;登录时多角色手机号需要先选择角色。
|
||||
@@ -0,0 +1,2 @@
|
||||
"""嘉恒智能报工后端服务."""
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,35 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=280,
|
||||
future=True,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=engine,
|
||||
expire_on_commit=False,
|
||||
class_=Session,
|
||||
)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import PersonRole, Personnel, Role
|
||||
from app.security import verify_token
|
||||
from app.services.serializers import temporary_expired
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Personnel:
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
|
||||
|
||||
try:
|
||||
payload = verify_token(credentials.credentials)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已失效") from exc
|
||||
|
||||
phone = payload.get("sub")
|
||||
try:
|
||||
role = Role(payload.get("role"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="角色无效") from exc
|
||||
user = db.get(Personnel, phone)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="人员不存在")
|
||||
if temporary_expired(user):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
|
||||
if db.get(PersonRole, {"phone": phone, "role": role}) is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="角色不存在")
|
||||
user.role = role
|
||||
return user
|
||||
|
||||
|
||||
def require_roles(*roles: Role) -> Callable[[Personnel], Personnel]:
|
||||
def checker(user: Personnel = Depends(current_user)) -> Personnel:
|
||||
if user.role not in roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||
return user
|
||||
|
||||
return checker
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base, engine
|
||||
from app import models # noqa: F401
|
||||
from app.services.auto_submit import auto_submit_overdue_loop
|
||||
from app.services.qrcode_batch_tasks import qrcode_batch_task_loop
|
||||
from app.routers import (
|
||||
attendance_points,
|
||||
auth,
|
||||
clock,
|
||||
dashboard,
|
||||
devices,
|
||||
equipment,
|
||||
monitor,
|
||||
notices,
|
||||
people,
|
||||
products,
|
||||
reconciliation,
|
||||
reports,
|
||||
reviews,
|
||||
usage_stats,
|
||||
work_schedule,
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="嘉恒智能报工后端服务",
|
||||
version="0.1.0",
|
||||
description="FastAPI service for 嘉恒智能报工小程序",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
settings.upload_path.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=str(settings.upload_path)), name="uploads")
|
||||
|
||||
def background_tasks_enabled() -> bool:
|
||||
if settings.background_tasks_enabled is not None:
|
||||
return settings.background_tasks_enabled
|
||||
return settings.app_env == "production"
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup() -> None:
|
||||
if settings.auto_create_tables:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
app.state.auto_submit_task = None
|
||||
app.state.qrcode_batch_task = None
|
||||
if background_tasks_enabled():
|
||||
app.state.auto_submit_task = asyncio.create_task(auto_submit_overdue_loop())
|
||||
app.state.qrcode_batch_task = asyncio.create_task(qrcode_batch_task_loop())
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown() -> None:
|
||||
task = getattr(app.state, "auto_submit_task", None)
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
task = getattr(app.state, "qrcode_batch_task", None)
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(attendance_points.router)
|
||||
app.include_router(people.router)
|
||||
app.include_router(products.router)
|
||||
app.include_router(clock.router)
|
||||
app.include_router(reports.router)
|
||||
app.include_router(reviews.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(devices.router)
|
||||
app.include_router(equipment.router)
|
||||
app.include_router(monitor.router)
|
||||
app.include_router(notices.router)
|
||||
app.include_router(reconciliation.router)
|
||||
app.include_router(work_schedule.router)
|
||||
app.include_router(usage_stats.router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
+464
@@ -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"),
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""API routers."""
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import current_user, require_roles
|
||||
from app.models import (
|
||||
AttendancePoint,
|
||||
DeviceQRCode,
|
||||
Equipment,
|
||||
NoticePoint,
|
||||
PersonAttendancePoint,
|
||||
Personnel,
|
||||
Product,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
ReconciliationLedgerEntry,
|
||||
Role,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
)
|
||||
from app.schemas import AttendancePointCreate, AttendancePointOut, PageResponse
|
||||
from app.services.attendance_points import accessible_points, clean_point_name, ensure_default_attendance_point
|
||||
from app.services.common import paginate
|
||||
from app.services.misc_work import ensure_misc_work_product
|
||||
from app.services.serializers import attendance_point_out
|
||||
from app.services.work_schedule import normalize_schedule_payload
|
||||
|
||||
router = APIRouter(prefix="/api/attendance-points", tags=["attendance-points"])
|
||||
|
||||
|
||||
def _validate_payload(payload: AttendancePointCreate) -> str:
|
||||
name = clean_point_name(payload.name)
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="请输入考勤点名称")
|
||||
if (payload.latitude is None) != (payload.longitude is None):
|
||||
raise HTTPException(status_code=400, detail="经纬度必须同时填写")
|
||||
if payload.latitude is not None and not (-90 <= payload.latitude <= 90):
|
||||
raise HTTPException(status_code=400, detail="纬度范围为-90到90")
|
||||
if payload.longitude is not None and not (-180 <= payload.longitude <= 180):
|
||||
raise HTTPException(status_code=400, detail="经度范围为-180到180")
|
||||
if int(payload.radius_meters or 0) <= 0:
|
||||
raise HTTPException(status_code=400, detail="考勤范围半径必须大于0米")
|
||||
try:
|
||||
normalize_schedule_payload(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"{name} {exc}") from exc
|
||||
return name
|
||||
|
||||
|
||||
def _rename_business_point(db: Session, old_name: str, new_name: str) -> None:
|
||||
if old_name == new_name:
|
||||
return
|
||||
for model in (
|
||||
PersonAttendancePoint,
|
||||
Product,
|
||||
Equipment,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
DeviceQRCode,
|
||||
NoticePoint,
|
||||
ReconciliationLedgerEntry,
|
||||
):
|
||||
db.query(model).filter(model.attendance_point_name == old_name).update(
|
||||
{model.attendance_point_name: new_name},
|
||||
synchronize_session=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/accessible", response_model=list[AttendancePointOut])
|
||||
def list_accessible_attendance_points(
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[AttendancePointOut]:
|
||||
return [attendance_point_out(point) for point in accessible_points(db, user)]
|
||||
|
||||
|
||||
@router.get("/public", response_model=list[AttendancePointOut])
|
||||
def list_public_attendance_points(
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[AttendancePointOut]:
|
||||
points = db.scalars(
|
||||
select(AttendancePoint)
|
||||
.where(AttendancePoint.is_active.is_(True))
|
||||
.order_by(AttendancePoint.name.asc())
|
||||
).all()
|
||||
rows = []
|
||||
for point in points:
|
||||
item = attendance_point_out(point)
|
||||
item.remark = None
|
||||
item.created_at = None
|
||||
item.updated_at = None
|
||||
rows.append(item)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("", response_model=PageResponse)
|
||||
def list_attendance_points(
|
||||
keyword: str = "",
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(100, ge=1, le=500),
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
default_point = ensure_default_attendance_point(db)
|
||||
ensure_misc_work_product(db, default_point.name)
|
||||
db.commit()
|
||||
query = select(AttendancePoint).order_by(AttendancePoint.updated_at.desc(), AttendancePoint.name.asc())
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.where(or_(AttendancePoint.name.like(like), AttendancePoint.remark.like(like)))
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{**result, "rows": [attendance_point_out(row) for row in result["rows"]]})
|
||||
|
||||
|
||||
@router.post("", response_model=AttendancePointOut)
|
||||
def save_attendance_point(
|
||||
payload: AttendancePointCreate,
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AttendancePointOut:
|
||||
name = _validate_payload(payload)
|
||||
original_name = clean_point_name(payload.original_name) or name
|
||||
point = db.get(AttendancePoint, original_name)
|
||||
if point is not None and original_name != name:
|
||||
if db.get(AttendancePoint, name) is not None:
|
||||
raise HTTPException(status_code=409, detail="该考勤点名称已存在")
|
||||
next_point = AttendancePoint(name=name)
|
||||
db.add(next_point)
|
||||
db.flush()
|
||||
_rename_business_point(db, original_name, name)
|
||||
db.delete(point)
|
||||
point = next_point
|
||||
elif point is None:
|
||||
point = db.get(AttendancePoint, name)
|
||||
if point is None:
|
||||
point = AttendancePoint(name=name)
|
||||
db.add(point)
|
||||
db.flush()
|
||||
point.latitude = payload.latitude
|
||||
point.longitude = payload.longitude
|
||||
point.radius_meters = int(payload.radius_meters or 500)
|
||||
for field, value in normalize_schedule_payload(payload).items():
|
||||
setattr(point, field, value)
|
||||
point.remark = payload.remark
|
||||
point.is_active = bool(payload.is_active)
|
||||
ensure_misc_work_product(db, name)
|
||||
db.commit()
|
||||
db.refresh(point)
|
||||
return attendance_point_out(point)
|
||||
|
||||
|
||||
@router.delete("/{name}", status_code=204)
|
||||
def delete_attendance_point(
|
||||
name: str,
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
point_name = clean_point_name(name)
|
||||
point = db.get(AttendancePoint, point_name)
|
||||
if point is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="考勤点不存在")
|
||||
if point_name == ensure_default_attendance_point(db).name:
|
||||
raise HTTPException(status_code=400, detail="默认考勤点不能删除")
|
||||
point.is_active = False
|
||||
db.commit()
|
||||
@@ -0,0 +1,309 @@
|
||||
from datetime import timedelta
|
||||
from math import asin, cos, radians, sin, sqrt
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import current_user
|
||||
from app.models import AttendancePoint, PersonRole, Personnel, Role
|
||||
from app.schemas import (
|
||||
LoginSelectionResponse,
|
||||
PersonnelOut,
|
||||
RoleSelectionRequest,
|
||||
SwitchRoleRequest,
|
||||
TokenResponse,
|
||||
WechatLoginRequest,
|
||||
)
|
||||
from app.security import create_token, verify_token
|
||||
from app.services.attendance_points import clean_point_name, set_person_attendance_points
|
||||
from app.services.serializers import personnel_out, temporary_expired
|
||||
from app.services.wechat import WechatConfigError, get_phone_number
|
||||
from app.timezone import now
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
TEMPORARY_WORKER_HOURS = 24
|
||||
EARTH_RADIUS_METERS = 6371000
|
||||
|
||||
|
||||
def _distance_meters(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
||||
delta_lat = radians(lat2 - lat1)
|
||||
delta_lng = radians(lng2 - lng1)
|
||||
first_lat = radians(lat1)
|
||||
second_lat = radians(lat2)
|
||||
value = (
|
||||
sin(delta_lat / 2) * sin(delta_lat / 2)
|
||||
+ cos(first_lat) * cos(second_lat) * sin(delta_lng / 2) * sin(delta_lng / 2)
|
||||
)
|
||||
return 2 * EARTH_RADIUS_METERS * asin(sqrt(min(1, value)))
|
||||
|
||||
|
||||
def _require_temporary_attendance_point(
|
||||
db: Session,
|
||||
point_name: str | None,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
) -> str:
|
||||
name = clean_point_name(point_name)
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="创建临时工前请先进入考勤点范围")
|
||||
if latitude is None or longitude is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="创建临时工需要校验当前位置")
|
||||
point = db.get(AttendancePoint, name)
|
||||
if point is None or not point.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"没有该考勤点:{name}")
|
||||
if point.latitude is None or point.longitude is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"考勤点【{name}】未配置经纬度")
|
||||
distance = _distance_meters(float(latitude), float(longitude), float(point.latitude), float(point.longitude))
|
||||
radius = int(point.radius_meters or 500)
|
||||
if distance > radius:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"当前位置距离考勤点【{name}】约{round(distance)}米,超过{radius}米范围,不能创建临时工",
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def _token_for(person: Personnel, role: Role) -> TokenResponse:
|
||||
token = create_token({"sub": person.phone, "name": person.name, "role": str(role)})
|
||||
return TokenResponse(access_token=token, user=personnel_out(person, role))
|
||||
|
||||
|
||||
def _selection_response(person: Personnel) -> LoginSelectionResponse:
|
||||
roles = [item.role for item in person.roles]
|
||||
selection_token = create_token({
|
||||
"type": "role_selection",
|
||||
"sub": person.phone,
|
||||
"roles": [str(role) for role in roles],
|
||||
})
|
||||
return LoginSelectionResponse(
|
||||
needs_selection=True,
|
||||
selection_token=selection_token,
|
||||
matched_people=[personnel_out(person, role) for role in roles],
|
||||
)
|
||||
|
||||
|
||||
def _temporary_worker_token(phone: str) -> str:
|
||||
return create_token({
|
||||
"type": "temporary_worker_confirmation",
|
||||
"sub": phone,
|
||||
})
|
||||
|
||||
|
||||
def _temporary_worker_response(phone: str) -> LoginSelectionResponse:
|
||||
return LoginSelectionResponse(
|
||||
needs_selection=False,
|
||||
needs_temporary_worker=True,
|
||||
temporary_token=_temporary_worker_token(phone),
|
||||
temporary_phone=phone,
|
||||
)
|
||||
|
||||
|
||||
def _phone_from_temporary_token(token: str | None) -> str:
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="缺少临时工确认凭证")
|
||||
try:
|
||||
payload = verify_token(token)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工确认已失效") from exc
|
||||
if payload.get("type") != "temporary_worker_confirmation" or not payload.get("sub"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的临时工确认凭证")
|
||||
return str(payload["sub"])
|
||||
|
||||
|
||||
def _create_or_renew_temporary_worker(
|
||||
db: Session,
|
||||
phone: str,
|
||||
name: str,
|
||||
attendance_point_name: str,
|
||||
) -> Personnel:
|
||||
cleaned_name = str(name or "").strip()
|
||||
if not cleaned_name:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入临时工姓名")
|
||||
person = db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone == phone)
|
||||
)
|
||||
if person is None:
|
||||
person = Personnel(phone=phone, name=cleaned_name)
|
||||
db.add(person)
|
||||
db.flush()
|
||||
person.name = cleaned_name
|
||||
person.is_temporary = True
|
||||
person.temporary_expires_at = now() + timedelta(hours=TEMPORARY_WORKER_HOURS)
|
||||
if db.get(PersonRole, {"phone": phone, "role": Role.worker}) is None:
|
||||
db.add(PersonRole(phone=phone, role=Role.worker))
|
||||
set_person_attendance_points(db, phone, [attendance_point_name])
|
||||
db.commit()
|
||||
refreshed = db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone == phone)
|
||||
)
|
||||
if refreshed is None:
|
||||
raise HTTPException(status_code=500, detail="临时工创建失败")
|
||||
return refreshed
|
||||
|
||||
|
||||
def _login_for_phone(
|
||||
db: Session,
|
||||
phone: str,
|
||||
selected_role: Role | None = None,
|
||||
) -> LoginSelectionResponse:
|
||||
person = db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone == phone)
|
||||
)
|
||||
if person is None or not person.roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在人员清单中")
|
||||
if temporary_expired(person):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="临时工已过期,请微信授权续期")
|
||||
|
||||
roles = [item.role for item in person.roles]
|
||||
if selected_role is not None:
|
||||
if selected_role not in roles:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="该手机号没有此角色")
|
||||
token = _token_for(person, selected_role)
|
||||
return LoginSelectionResponse(
|
||||
needs_selection=False,
|
||||
access_token=token.access_token,
|
||||
user=token.user,
|
||||
)
|
||||
|
||||
if len(roles) > 1:
|
||||
return _selection_response(person)
|
||||
|
||||
token = _token_for(person, roles[0])
|
||||
return LoginSelectionResponse(
|
||||
needs_selection=False,
|
||||
access_token=token.access_token,
|
||||
user=token.user,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/wechat-login", response_model=LoginSelectionResponse)
|
||||
async def wechat_login(
|
||||
payload: WechatLoginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> LoginSelectionResponse:
|
||||
if payload.create_temporary_worker:
|
||||
phone = _phone_from_temporary_token(payload.temporary_token)
|
||||
attendance_point_name = _require_temporary_attendance_point(
|
||||
db,
|
||||
payload.temporary_attendance_point_name,
|
||||
payload.temporary_latitude,
|
||||
payload.temporary_longitude,
|
||||
)
|
||||
person = _create_or_renew_temporary_worker(db, phone, payload.temporary_name or "", attendance_point_name)
|
||||
token = _token_for(person, Role.worker)
|
||||
return LoginSelectionResponse(
|
||||
needs_selection=False,
|
||||
access_token=token.access_token,
|
||||
user=token.user,
|
||||
temporary_expires_at=person.temporary_expires_at,
|
||||
)
|
||||
|
||||
phone = payload.phone
|
||||
if not phone and payload.phone_code:
|
||||
try:
|
||||
phone = await get_phone_number(payload.phone_code)
|
||||
except WechatConfigError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
candidates = [item.strip() for item in payload.phone_candidates if item.strip()]
|
||||
if phone:
|
||||
candidates.insert(0, phone.strip())
|
||||
candidates = list(dict.fromkeys(candidates))
|
||||
|
||||
if not candidates:
|
||||
raise HTTPException(status_code=400, detail="缺少手机号或手机号授权凭证")
|
||||
|
||||
people = db.scalars(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone.in_(candidates))
|
||||
).all()
|
||||
if not people:
|
||||
return _temporary_worker_response(candidates[0])
|
||||
|
||||
if len(people) > 1 and phone not in [person.phone for person in people]:
|
||||
return LoginSelectionResponse(
|
||||
needs_selection=True,
|
||||
matched_people=[
|
||||
personnel_out(person, role.role)
|
||||
for person in people
|
||||
for role in person.roles
|
||||
],
|
||||
)
|
||||
|
||||
selected = next((person for person in people if person.phone == phone), people[0])
|
||||
if not selected.roles:
|
||||
return _temporary_worker_response(selected.phone)
|
||||
if temporary_expired(selected):
|
||||
return _temporary_worker_response(selected.phone)
|
||||
return _login_for_phone(db, selected.phone, payload.selected_role)
|
||||
|
||||
|
||||
@router.post("/select-role", response_model=TokenResponse)
|
||||
def select_role(payload: RoleSelectionRequest, db: Session = Depends(get_db)) -> TokenResponse:
|
||||
try:
|
||||
token_payload = verify_token(payload.selection_token)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="角色选择已失效") from exc
|
||||
|
||||
if token_payload.get("type") != "role_selection":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的角色选择凭证")
|
||||
if str(payload.role) not in token_payload.get("roles", []):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="该手机号没有此角色")
|
||||
|
||||
person = db.get(Personnel, token_payload.get("sub"))
|
||||
if person is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
|
||||
if temporary_expired(person):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
|
||||
if db.get(PersonRole, {"phone": person.phone, "role": payload.role}) is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="角色不存在")
|
||||
return _token_for(person, payload.role)
|
||||
|
||||
|
||||
@router.get("/me", response_model=PersonnelOut)
|
||||
def get_me(
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PersonnelOut:
|
||||
person = db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone == user.phone)
|
||||
)
|
||||
if person is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
|
||||
if temporary_expired(person):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
|
||||
return personnel_out(person, user.role)
|
||||
|
||||
|
||||
@router.post("/switch-role", response_model=TokenResponse)
|
||||
def switch_role(
|
||||
payload: SwitchRoleRequest,
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
person = db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone == user.phone)
|
||||
)
|
||||
if person is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
|
||||
if temporary_expired(person):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
|
||||
if db.get(PersonRole, {"phone": person.phone, "role": payload.role}) is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="该手机号没有此角色")
|
||||
return _token_for(person, payload.role)
|
||||
@@ -0,0 +1,516 @@
|
||||
from contextlib import nullcontext
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import Personnel, Product, Role, SessionStatus, WorkSession, WorkSessionDevice
|
||||
from app.schemas import ClockRequest, ClockSessionOut, ClockState, ContinueWorkRequest
|
||||
from app.services.attendance_points import require_attendance_point_access, validate_attendance_point_location
|
||||
from app.services.auto_submit import auto_submit_overdue_sessions
|
||||
from app.services.cleaning import is_cleaning_product
|
||||
from app.services.display_names import mold_process_display_name
|
||||
from app.services.mold_locks import (
|
||||
current_unreleased_device,
|
||||
ensure_mold_available,
|
||||
is_lockable_product,
|
||||
mold_lock_mutex,
|
||||
)
|
||||
from app.services.misc_work import is_misc_product
|
||||
from app.services.reporting_window import is_reporting_expired
|
||||
from app.services.serializers import session_out
|
||||
from app.services.work_schedule import get_work_schedule
|
||||
from app.timezone import now
|
||||
|
||||
router = APIRouter(prefix="/api/clock", tags=["clock"])
|
||||
|
||||
|
||||
def _mold_lock_context(
|
||||
db: Session,
|
||||
point_name: str,
|
||||
mold_name: str,
|
||||
process_name: str,
|
||||
product: Product | None,
|
||||
):
|
||||
if is_lockable_product(product):
|
||||
return mold_lock_mutex(db, point_name, mold_name, process_name)
|
||||
return nullcontext()
|
||||
|
||||
|
||||
def _mold_name(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _process_name(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _request_mold_name(payload: ClockRequest) -> str:
|
||||
return _mold_name(payload.mold_name or payload.device_no)
|
||||
|
||||
|
||||
def _request_process_name(payload: ClockRequest) -> str:
|
||||
return _process_name(payload.process_name)
|
||||
|
||||
|
||||
def _mold_key(mold_name: str, process_name: str) -> tuple[str, str]:
|
||||
return (_mold_name(mold_name), _process_name(process_name))
|
||||
|
||||
|
||||
def _mold_display(mold_name: str, process_name: str, stamping_method: str | None = None) -> str:
|
||||
return mold_process_display_name(mold_name, process_name, stamping_method)
|
||||
|
||||
|
||||
def _require_clock_location(
|
||||
db: Session,
|
||||
point_name: str,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
) -> None:
|
||||
try:
|
||||
validate_attendance_point_location(db, point_name, latitude, longitude, action_name="扫码报工")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _product_for_mold(db: Session, point_name: str, mold_name: str, process_name: str) -> Product | None:
|
||||
return db.scalar(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name == point_name,
|
||||
Product.product_name == mold_name,
|
||||
Product.process_name == process_name,
|
||||
Product.device_no == "",
|
||||
)
|
||||
.order_by(Product.project_no.asc())
|
||||
)
|
||||
|
||||
|
||||
def _resolve_product_for_mold(
|
||||
db: Session,
|
||||
point_name: str,
|
||||
mold_name: str,
|
||||
process_name: str,
|
||||
) -> tuple[Product | None, str]:
|
||||
process = _process_name(process_name)
|
||||
product = _product_for_mold(db, point_name, mold_name, process)
|
||||
if product is not None or process:
|
||||
return product, process
|
||||
|
||||
candidates = db.scalars(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name == point_name,
|
||||
Product.product_name == mold_name,
|
||||
Product.device_no == "",
|
||||
)
|
||||
.order_by(Product.project_no.asc(), Product.process_name.asc())
|
||||
).all()
|
||||
if len(candidates) == 1:
|
||||
product = candidates[0]
|
||||
return product, _process_name(product.process_name)
|
||||
return None, process
|
||||
|
||||
|
||||
def _active_session(db: Session, user: Personnel) -> WorkSession | None:
|
||||
auto_submit_overdue_sessions(db, employee_phone=user.phone)
|
||||
return db.scalar(
|
||||
select(WorkSession)
|
||||
.options(selectinload(WorkSession.devices))
|
||||
.where(
|
||||
WorkSession.employee_phone == user.phone,
|
||||
WorkSession.status.in_([SessionStatus.active, SessionStatus.reporting]),
|
||||
)
|
||||
.order_by(WorkSession.created_at.desc())
|
||||
)
|
||||
|
||||
|
||||
def _ordered_devices(session: WorkSession) -> list[WorkSessionDevice]:
|
||||
return sorted(
|
||||
session.devices,
|
||||
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
|
||||
)
|
||||
|
||||
|
||||
def _next_sort_order(session: WorkSession) -> int:
|
||||
orders = [int(device.sort_order or 0) for device in session.devices]
|
||||
return (max(orders) + 1) if orders else 0
|
||||
|
||||
|
||||
def _unique_molds(db: Session, devices: list[WorkSessionDevice]) -> list[str]:
|
||||
molds: list[str] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for device in devices:
|
||||
key = _mold_key(device.device_no, device.process_name)
|
||||
if key in seen:
|
||||
continue
|
||||
product = _product_for_mold(db, device.attendance_point_name, device.device_no, device.process_name)
|
||||
molds.append(_mold_display(device.device_no, device.process_name, product.stamping_method if product else None))
|
||||
seen.add(key)
|
||||
return molds
|
||||
|
||||
|
||||
def _unfinished_report_mismatch_detail(session: WorkSession, unique_molds: list[str], expired: bool) -> dict:
|
||||
mold_text = "、".join(unique_molds) if unique_molds else "原模具"
|
||||
return {
|
||||
"code": "unfinished_report_mold_mismatch",
|
||||
"message": f"报工已进入填写阶段,请扫描本次记录中的模具({mold_text})继续报工",
|
||||
"session_id": session.id,
|
||||
"devices": unique_molds,
|
||||
"reporting_expired": expired,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/state", response_model=ClockState)
|
||||
def get_clock_state(
|
||||
attendance_point_name: str = Query(""),
|
||||
device_no: str = Query(""),
|
||||
mold_name: str = Query(""),
|
||||
process_name: str = Query(""),
|
||||
latitude: float | None = Query(None),
|
||||
longitude: float | None = Query(None),
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ClockState:
|
||||
mold = _mold_name(mold_name or device_no)
|
||||
process = _process_name(process_name)
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, attendance_point_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
_require_clock_location(db, point_name, latitude, longitude)
|
||||
if not mold:
|
||||
raise HTTPException(status_code=400, detail="缺少模具名称")
|
||||
product, process = _resolve_product_for_mold(db, point_name, mold, process)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=403, detail=f"无该考勤点模具报工权限:{point_name}")
|
||||
ensure_mold_available(db, user, point_name, mold, process, product)
|
||||
stamping_method = product.stamping_method if product else None
|
||||
display_name = _mold_display(mold, process, stamping_method)
|
||||
is_cleaning = is_cleaning_product(product)
|
||||
is_misc = is_misc_product(product)
|
||||
auto_submit_hours = float(get_work_schedule(db).auto_submit_hours or 15)
|
||||
session = _active_session(db, user)
|
||||
if session is None:
|
||||
return ClockState(
|
||||
action="start",
|
||||
action_text="开始上班",
|
||||
attendance_point_name=point_name,
|
||||
device_no=mold,
|
||||
mold_name=mold,
|
||||
process_name=process,
|
||||
stamping_method=stamping_method,
|
||||
display_name=display_name,
|
||||
is_cleaning=is_cleaning,
|
||||
is_misc=is_misc,
|
||||
auto_submit_hours=auto_submit_hours,
|
||||
)
|
||||
if session.attendance_point_name != point_name:
|
||||
raise HTTPException(status_code=400, detail=f"当前上班记录属于{session.attendance_point_name}考勤点,请扫描该考勤点模具")
|
||||
|
||||
ordered_devices = _ordered_devices(session)
|
||||
molds = [_mold_key(device.device_no, device.process_name) for device in ordered_devices]
|
||||
unique_molds = _unique_molds(db, ordered_devices)
|
||||
current_device = ordered_devices[-1] if ordered_devices else None
|
||||
active_device = current_unreleased_device(session)
|
||||
current_mold = active_device.device_no if active_device else (current_device.device_no if current_device else "")
|
||||
current_process = active_device.process_name if active_device else (current_device.process_name if current_device else "")
|
||||
scanned_key = _mold_key(mold, process)
|
||||
if session.status == SessionStatus.reporting:
|
||||
if scanned_key not in set(molds):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=_unfinished_report_mismatch_detail(session, unique_molds, is_reporting_expired(session)),
|
||||
)
|
||||
if is_reporting_expired(session):
|
||||
return ClockState(
|
||||
action="refinish",
|
||||
action_text="重新报工",
|
||||
attendance_point_name=point_name,
|
||||
device_no=mold,
|
||||
mold_name=mold,
|
||||
process_name=process,
|
||||
stamping_method=stamping_method,
|
||||
display_name=display_name,
|
||||
is_cleaning=is_cleaning,
|
||||
is_misc=is_misc,
|
||||
current_mold_name=current_mold,
|
||||
current_process_name=current_process,
|
||||
session_id=session.id,
|
||||
start_at=session.start_at,
|
||||
end_at=session.end_at,
|
||||
devices=unique_molds,
|
||||
auto_submit_hours=auto_submit_hours,
|
||||
)
|
||||
return ClockState(
|
||||
action="report",
|
||||
action_text="继续报工",
|
||||
attendance_point_name=point_name,
|
||||
device_no=mold,
|
||||
mold_name=mold,
|
||||
process_name=process,
|
||||
stamping_method=stamping_method,
|
||||
display_name=display_name,
|
||||
is_cleaning=is_cleaning,
|
||||
is_misc=is_misc,
|
||||
current_mold_name=current_mold,
|
||||
current_process_name=current_process,
|
||||
session_id=session.id,
|
||||
start_at=session.start_at,
|
||||
end_at=session.end_at,
|
||||
devices=unique_molds,
|
||||
auto_submit_hours=auto_submit_hours,
|
||||
)
|
||||
|
||||
if active_device is None:
|
||||
return ClockState(
|
||||
action="switch",
|
||||
action_text="继续上工",
|
||||
attendance_point_name=point_name,
|
||||
device_no=mold,
|
||||
mold_name=mold,
|
||||
process_name=process,
|
||||
stamping_method=stamping_method,
|
||||
display_name=display_name,
|
||||
is_cleaning=is_cleaning,
|
||||
is_misc=is_misc,
|
||||
current_mold_name=current_mold,
|
||||
current_process_name=current_process,
|
||||
session_id=session.id,
|
||||
start_at=session.start_at,
|
||||
end_at=session.end_at,
|
||||
devices=unique_molds,
|
||||
auto_submit_hours=auto_submit_hours,
|
||||
)
|
||||
action = "finish" if (is_misc or scanned_key == _mold_key(current_mold, current_process)) else "switch_or_finish"
|
||||
return ClockState(
|
||||
action=action,
|
||||
action_text="下班打卡" if action == "finish" else "换模具或下班打卡",
|
||||
attendance_point_name=point_name,
|
||||
device_no=mold,
|
||||
mold_name=mold,
|
||||
process_name=process,
|
||||
stamping_method=stamping_method,
|
||||
display_name=display_name,
|
||||
is_cleaning=is_cleaning,
|
||||
is_misc=is_misc,
|
||||
current_mold_name=current_mold,
|
||||
current_process_name=current_process,
|
||||
session_id=session.id,
|
||||
start_at=session.start_at,
|
||||
end_at=session.end_at,
|
||||
devices=unique_molds,
|
||||
auto_submit_hours=auto_submit_hours,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/start", response_model=ClockSessionOut)
|
||||
def start_work(
|
||||
payload: ClockRequest,
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ClockSessionOut:
|
||||
existing = _active_session(db, user)
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="已有进行中的上班记录")
|
||||
|
||||
timestamp = now()
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, payload.attendance_point_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
mold = _request_mold_name(payload)
|
||||
process = _request_process_name(payload)
|
||||
if not mold:
|
||||
raise HTTPException(status_code=400, detail="缺少模具名称")
|
||||
product, process = _resolve_product_for_mold(db, point_name, mold, process)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=403, detail=f"无该考勤点模具报工权限:{point_name}")
|
||||
_require_clock_location(db, point_name, payload.latitude, payload.longitude)
|
||||
with _mold_lock_context(db, point_name, mold, process, product):
|
||||
ensure_mold_available(db, user, point_name, mold, process, product)
|
||||
session = WorkSession(
|
||||
attendance_point_name=point_name,
|
||||
employee_phone=user.phone,
|
||||
start_at=timestamp,
|
||||
status=SessionStatus.active,
|
||||
)
|
||||
session.devices.append(
|
||||
WorkSessionDevice(
|
||||
device_no=mold,
|
||||
attendance_point_name=point_name,
|
||||
process_name=process,
|
||||
scanned_at=timestamp,
|
||||
sort_order=0,
|
||||
)
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session_out(session, db)
|
||||
|
||||
|
||||
@router.post("/switch-device", response_model=ClockSessionOut)
|
||||
def switch_device(
|
||||
payload: ClockRequest,
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ClockSessionOut:
|
||||
session = _active_session(db, user)
|
||||
if session is None or session.status != SessionStatus.active:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有进行中的上班记录")
|
||||
|
||||
mold = _request_mold_name(payload)
|
||||
process = _request_process_name(payload)
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, payload.attendance_point_name or session.attendance_point_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
if session.attendance_point_name != point_name:
|
||||
raise HTTPException(status_code=400, detail="当前上班记录不属于该考勤点")
|
||||
_require_clock_location(db, point_name, payload.latitude, payload.longitude)
|
||||
if not mold:
|
||||
raise HTTPException(status_code=400, detail="缺少模具名称")
|
||||
product, process = _resolve_product_for_mold(db, point_name, mold, process)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=403, detail=f"无该考勤点模具报工权限:{point_name}")
|
||||
with _mold_lock_context(db, point_name, mold, process, product):
|
||||
ensure_mold_available(db, user, point_name, mold, process, product)
|
||||
ordered_devices = _ordered_devices(session)
|
||||
current_device = ordered_devices[-1] if ordered_devices else None
|
||||
current_key = _mold_key(current_device.device_no, current_device.process_name) if current_device else ("", "")
|
||||
active_device = current_unreleased_device(session)
|
||||
if active_device is None or _mold_key(mold, process) != current_key:
|
||||
timestamp = now()
|
||||
if active_device is not None:
|
||||
active_device.released_at = timestamp
|
||||
active_device.released_by = user.phone
|
||||
active_device.release_reason = "换模具释放占用"
|
||||
session.devices.append(
|
||||
WorkSessionDevice(
|
||||
attendance_point_name=point_name,
|
||||
device_no=mold,
|
||||
process_name=process,
|
||||
scanned_at=timestamp,
|
||||
sort_order=_next_sort_order(session),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session_out(session, db)
|
||||
|
||||
|
||||
@router.post("/finish", response_model=ClockSessionOut)
|
||||
def finish_work(
|
||||
payload: ClockRequest,
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ClockSessionOut:
|
||||
session = _active_session(db, user)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有进行中的上班记录")
|
||||
|
||||
mold = _request_mold_name(payload)
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, payload.attendance_point_name or session.attendance_point_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
if session.attendance_point_name != point_name:
|
||||
raise HTTPException(status_code=400, detail="当前上班记录不属于该考勤点")
|
||||
_require_clock_location(db, point_name, payload.latitude, payload.longitude)
|
||||
if not mold:
|
||||
raise HTTPException(status_code=400, detail="缺少模具名称")
|
||||
process = _request_process_name(payload)
|
||||
timestamp = now()
|
||||
if session.status == SessionStatus.reporting and not is_reporting_expired(session, timestamp):
|
||||
raise HTTPException(status_code=409, detail="当前报工还在15分钟填写时间内,请继续报工")
|
||||
if session.status not in [SessionStatus.active, SessionStatus.reporting]:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有进行中的上班记录")
|
||||
|
||||
product, process = _resolve_product_for_mold(db, point_name, mold, process)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=403, detail=f"无该考勤点模具报工权限:{point_name}")
|
||||
if is_misc_product(product) and session.status == SessionStatus.active:
|
||||
ordered_devices = _ordered_devices(session)
|
||||
current_device = ordered_devices[-1] if ordered_devices else None
|
||||
if current_device is not None:
|
||||
current_device.device_no = mold
|
||||
current_device.process_name = process
|
||||
|
||||
session.end_at = timestamp
|
||||
session.status = SessionStatus.reporting
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session_out(session, db)
|
||||
|
||||
|
||||
@router.post("/continue-work", response_model=ClockSessionOut)
|
||||
def continue_work(
|
||||
payload: ContinueWorkRequest,
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ClockSessionOut:
|
||||
session = db.scalar(
|
||||
select(WorkSession)
|
||||
.options(selectinload(WorkSession.devices))
|
||||
.where(
|
||||
WorkSession.id == payload.session_id,
|
||||
WorkSession.employee_phone == user.phone,
|
||||
)
|
||||
)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工流程不存在")
|
||||
if session.status != SessionStatus.reporting:
|
||||
raise HTTPException(status_code=400, detail="当前流程不在下班报工填报中")
|
||||
|
||||
ordered_devices = _ordered_devices(session)
|
||||
current_device = ordered_devices[-1] if ordered_devices else None
|
||||
if current_device is not None:
|
||||
product = _product_for_mold(
|
||||
db,
|
||||
session.attendance_point_name,
|
||||
current_device.device_no,
|
||||
current_device.process_name,
|
||||
)
|
||||
with _mold_lock_context(
|
||||
db,
|
||||
session.attendance_point_name,
|
||||
current_device.device_no,
|
||||
current_device.process_name,
|
||||
product,
|
||||
):
|
||||
ensure_mold_available(
|
||||
db,
|
||||
user,
|
||||
session.attendance_point_name,
|
||||
current_device.device_no,
|
||||
current_device.process_name,
|
||||
product,
|
||||
)
|
||||
if current_device.released_at is not None:
|
||||
session.devices.append(
|
||||
WorkSessionDevice(
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
device_no=current_device.device_no,
|
||||
process_name=current_device.process_name,
|
||||
scanned_at=now(),
|
||||
sort_order=_next_sort_order(session),
|
||||
)
|
||||
)
|
||||
session.end_at = None
|
||||
session.status = SessionStatus.active
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session_out(session, db)
|
||||
@@ -0,0 +1,382 @@
|
||||
from datetime import date
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import (
|
||||
Personnel,
|
||||
Product,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
ReportStatus,
|
||||
Role,
|
||||
WorkSession,
|
||||
)
|
||||
from app.schemas import DashboardRow, PageResponse
|
||||
from app.services.cleaning import is_cleaning_item, is_cleaning_product
|
||||
from app.services.common import as_float, round1, round2
|
||||
from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product
|
||||
from app.services.excel_export import export_dashboard_rows
|
||||
from app.services.metrics import calculate_report_metrics, shift_distribution_text
|
||||
from app.services.misc_work import is_misc_item, is_misc_product
|
||||
from app.services.multi_person import is_multi_person_item
|
||||
from app.services.report_lifecycle import purge_expired_voided_reports
|
||||
from app.services.serializers import report_correction_maps
|
||||
from app.services.work_schedule import get_work_schedule_config
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
def _product_for_item(
|
||||
db: Session,
|
||||
cache: dict[tuple[str, str, str, str, str], Product | None],
|
||||
item: ProductionReportItem,
|
||||
) -> Product | None:
|
||||
key = (
|
||||
item.attendance_point_name,
|
||||
item.project_no,
|
||||
item.product_name,
|
||||
item.process_name or "",
|
||||
"",
|
||||
)
|
||||
if key not in cache:
|
||||
with db.no_autoflush:
|
||||
cache[key] = db.scalar(
|
||||
select(Product).where(
|
||||
Product.project_no == item.project_no,
|
||||
Product.attendance_point_name == item.attendance_point_name,
|
||||
Product.product_name == item.product_name,
|
||||
Product.process_name == (item.process_name or ""),
|
||||
)
|
||||
)
|
||||
return cache[key]
|
||||
|
||||
|
||||
REPORT_CORRECTION_LABELS = {
|
||||
"start_at": "上班时间",
|
||||
"end_at": "下班时间",
|
||||
"review_remark": "备注",
|
||||
}
|
||||
|
||||
ITEM_CORRECTION_LABELS = {
|
||||
"device_no": "设备号",
|
||||
"project_no": "项目号",
|
||||
"product_name": "产品名称",
|
||||
"raw_material_batch_no": "材料库存批次号",
|
||||
"process_name": "工序",
|
||||
"stamping_method": "冲压方式",
|
||||
"process_unit_price_yuan": "工序单价",
|
||||
"changeover_count": "换料次数",
|
||||
"remark": "杂活事项",
|
||||
"good_qty": "成品数量",
|
||||
"defect_qty": "不良数量",
|
||||
"scrap_qty": "报废数量",
|
||||
}
|
||||
|
||||
|
||||
def _correction_value(value) -> str | float:
|
||||
if value is None:
|
||||
return "-"
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
def _append_correction_pair(group: dict, key: tuple, label: str, old_value, new_value) -> None:
|
||||
if key in group["correction_pair_keys"]:
|
||||
return
|
||||
group["correction_pair_keys"].add(key)
|
||||
group["correction_pairs"].append(
|
||||
{
|
||||
"key": "__".join(str(part) for part in key),
|
||||
"label": label,
|
||||
"old_value": _correction_value(old_value),
|
||||
"new_value": _correction_value(new_value),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _append_dashboard_corrections(
|
||||
group: dict,
|
||||
report: ProductionReport,
|
||||
item: ProductionReportItem,
|
||||
report_corrections: dict,
|
||||
item_corrections: dict,
|
||||
) -> None:
|
||||
for field, old_value in report_corrections.items():
|
||||
label = REPORT_CORRECTION_LABELS.get(field, field)
|
||||
_append_correction_pair(group, (report.id, field), label, old_value, getattr(report, field))
|
||||
for field, old_value in item_corrections.items():
|
||||
label = ITEM_CORRECTION_LABELS.get(field, field)
|
||||
_append_correction_pair(group, (report.id, item.id, field), label, old_value, getattr(item, field))
|
||||
|
||||
|
||||
def _dashboard_rows(
|
||||
db: Session,
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
include_voided: bool = False,
|
||||
) -> list[DashboardRow]:
|
||||
query = (
|
||||
select(ProductionReport)
|
||||
.options(
|
||||
selectinload(ProductionReport.employee),
|
||||
selectinload(ProductionReport.items),
|
||||
selectinload(ProductionReport.audit_logs),
|
||||
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
|
||||
)
|
||||
.where(ProductionReport.status == ReportStatus.approved)
|
||||
)
|
||||
if not include_voided:
|
||||
query = query.where(ProductionReport.is_voided.is_(False))
|
||||
if start_date:
|
||||
query = query.where(ProductionReport.report_date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ProductionReport.report_date <= end_date)
|
||||
reports = db.scalars(query).all()
|
||||
|
||||
groups: dict[tuple, dict] = {}
|
||||
product_cache: dict[tuple[str, str, str, str, str], Product | None] = {}
|
||||
schedule_cache = {}
|
||||
for report in reports:
|
||||
if report.attendance_point_name not in schedule_cache:
|
||||
schedule_cache[report.attendance_point_name] = get_work_schedule_config(db, report.attendance_point_name)
|
||||
schedule = schedule_cache[report.attendance_point_name]
|
||||
report_corrections, item_corrections, _device_corrections = report_correction_maps(report)
|
||||
report_metrics = calculate_report_metrics(
|
||||
report.start_at,
|
||||
report.end_at,
|
||||
list(report.items),
|
||||
devices=report.session.devices if report.session else None,
|
||||
schedule=schedule,
|
||||
)
|
||||
item_count = max(1, len(report.items))
|
||||
fallback_minutes = as_float(report.effective_minutes) / item_count
|
||||
employee_name = report.employee.name if report.employee else ""
|
||||
worker_type = "临时工" if (report.employee and report.employee.is_temporary) else "正式工"
|
||||
for item in report.items:
|
||||
item_misc = is_misc_item(item)
|
||||
process_name = (item.remark or item.process_name or "") if item_misc else (item.process_name or "")
|
||||
product = _product_for_item(db, product_cache, item)
|
||||
is_cleaning = is_cleaning_item(item) or is_cleaning_product(product)
|
||||
is_misc = item_misc or is_misc_product(product)
|
||||
is_continuous_die = is_continuous_die_item(item) or is_continuous_die_product(product)
|
||||
standard_beat = 0 if (is_cleaning or is_misc) else as_float(product.standard_beat if product else item.standard_beat)
|
||||
process_unit_price = as_float(item.process_unit_price_yuan)
|
||||
stamping_method = item.stamping_method or (product.stamping_method if product else None)
|
||||
key = (
|
||||
report.attendance_point_name,
|
||||
report.report_date,
|
||||
employee_name,
|
||||
report.employee_phone,
|
||||
item.project_no,
|
||||
item.product_name,
|
||||
process_name,
|
||||
stamping_method or "",
|
||||
)
|
||||
group = groups.setdefault(
|
||||
key,
|
||||
{
|
||||
"id": "__".join(str(part) for part in key),
|
||||
"attendance_point_name": report.attendance_point_name,
|
||||
"report_date": report.report_date,
|
||||
"employee_phone": report.employee_phone,
|
||||
"employee_name": employee_name,
|
||||
"worker_type": worker_type,
|
||||
"project_no": item.project_no,
|
||||
"product_name": item.product_name,
|
||||
"process_name": process_name,
|
||||
"report_ids": set(),
|
||||
"effective_minutes": 0.0,
|
||||
"shift_day_minutes": 0.0,
|
||||
"shift_overtime_minutes": 0.0,
|
||||
"shift_night_minutes": 0.0,
|
||||
"total_good_qty": 0.0,
|
||||
"total_defect_qty": 0.0,
|
||||
"total_output_qty": 0.0,
|
||||
"standard_beat": standard_beat,
|
||||
"process_unit_price_yuan": process_unit_price,
|
||||
"changeover_count": 0.0,
|
||||
"reference_wage": 0.0,
|
||||
"expected_workload": 0.0,
|
||||
"profile_no": product.profile_no if product else None,
|
||||
"material_code": product.material_code if product else item.material_code,
|
||||
"material_name": product.material_name if product else item.material_name,
|
||||
"raw_material_batch_numbers": set(),
|
||||
"device_no": item.device_no,
|
||||
"stamping_method": stamping_method,
|
||||
"operator_count": as_float(product.operator_count) if product else as_float(item.operator_count),
|
||||
"is_continuous_die": is_continuous_die,
|
||||
"is_multi_person": is_multi_person_item(item),
|
||||
"is_multi_person_assistant": False,
|
||||
"is_misc": is_misc,
|
||||
"is_system_auto_submitted": False,
|
||||
"is_voided": False,
|
||||
"review_remarks": [],
|
||||
"review_remark_keys": set(),
|
||||
"correction_pairs": [],
|
||||
"correction_pair_keys": set(),
|
||||
},
|
||||
)
|
||||
_append_dashboard_corrections(group, report, item, report_corrections, item_corrections.get(item.id, {}))
|
||||
good_qty = as_float(item.good_qty)
|
||||
defect_qty = as_float(item.defect_qty) + as_float(item.scrap_qty)
|
||||
output_qty = good_qty + defect_qty
|
||||
allocated_minutes = 0 if is_cleaning else (as_float(item.allocated_minutes) or fallback_minutes)
|
||||
expected_workload = allocated_minutes * 60 / standard_beat if standard_beat > 0 else 0
|
||||
group["report_ids"].add(report.id)
|
||||
group["effective_minutes"] += allocated_minutes
|
||||
group["expected_workload"] += expected_workload
|
||||
group["shift_day_minutes"] += as_float(getattr(item, "_shift_day_minutes", 0))
|
||||
group["shift_overtime_minutes"] += as_float(getattr(item, "_shift_overtime_minutes", 0))
|
||||
group["shift_night_minutes"] += as_float(getattr(item, "_shift_night_minutes", 0))
|
||||
group["total_good_qty"] += good_qty
|
||||
group["reference_wage"] += good_qty * process_unit_price
|
||||
group["changeover_count"] += as_float(item.changeover_count)
|
||||
group["total_defect_qty"] += defect_qty
|
||||
group["total_output_qty"] += output_qty
|
||||
group["is_system_auto_submitted"] = (
|
||||
group["is_system_auto_submitted"] or bool(report.is_system_auto_submitted)
|
||||
)
|
||||
group["is_voided"] = group["is_voided"] or bool(report.is_voided)
|
||||
group["is_multi_person"] = group["is_multi_person"] or is_multi_person_item(item)
|
||||
group["is_continuous_die"] = group["is_continuous_die"] or is_continuous_die
|
||||
group["is_multi_person_assistant"] = (
|
||||
group["is_multi_person_assistant"] or bool(report.is_multi_person_assistant)
|
||||
)
|
||||
if report.review_remark:
|
||||
review_remark = str(report.review_remark).strip()
|
||||
if review_remark and review_remark not in group["review_remark_keys"]:
|
||||
group["review_remark_keys"].add(review_remark)
|
||||
group["review_remarks"].append(review_remark)
|
||||
if item.raw_material_batch_no:
|
||||
group["raw_material_batch_numbers"].add(item.raw_material_batch_no)
|
||||
|
||||
rows: list[DashboardRow] = []
|
||||
for group in groups.values():
|
||||
actual_beat = (
|
||||
group["effective_minutes"] * 60 / group["total_output_qty"]
|
||||
if group["total_output_qty"] > 0
|
||||
else 0
|
||||
)
|
||||
standard_beat = group["standard_beat"]
|
||||
expected_workload = group["expected_workload"]
|
||||
pace_rate = (actual_beat - standard_beat) / standard_beat * 100 if standard_beat > 0 else 0
|
||||
workload_rate = (
|
||||
(group["total_good_qty"] - expected_workload) / expected_workload * 100
|
||||
if expected_workload > 0
|
||||
else 0
|
||||
)
|
||||
shift_minutes = {
|
||||
"day": group["shift_day_minutes"],
|
||||
"overtime": group["shift_overtime_minutes"],
|
||||
"night": group["shift_night_minutes"],
|
||||
}
|
||||
rows.append(
|
||||
DashboardRow(
|
||||
id=group["id"],
|
||||
attendance_point_name=group["attendance_point_name"],
|
||||
report_date=group["report_date"],
|
||||
employee_phone=group["employee_phone"],
|
||||
employee_name=group["employee_name"],
|
||||
worker_type=group["worker_type"],
|
||||
project_no=group["project_no"],
|
||||
product_name=group["product_name"],
|
||||
process_name=group["process_name"],
|
||||
report_count=len(group["report_ids"]),
|
||||
effective_minutes=round2(group["effective_minutes"]),
|
||||
shift_distribution_text=shift_distribution_text(shift_minutes),
|
||||
shift_day_minutes=round2(group["shift_day_minutes"]),
|
||||
shift_overtime_minutes=round2(group["shift_overtime_minutes"]),
|
||||
shift_night_minutes=round2(group["shift_night_minutes"]),
|
||||
shift_other_minutes=0,
|
||||
total_good_qty=round2(group["total_good_qty"]),
|
||||
total_defect_qty=round2(group["total_defect_qty"]),
|
||||
total_output_qty=round2(group["total_output_qty"]),
|
||||
actual_beat=round2(actual_beat),
|
||||
standard_beat=round2(standard_beat),
|
||||
process_unit_price_yuan=round(as_float(group["process_unit_price_yuan"]), 4),
|
||||
changeover_count=round2(group["changeover_count"]),
|
||||
reference_wage=round2(group["reference_wage"]),
|
||||
expected_workload=round1(expected_workload),
|
||||
pace_rate=round1(pace_rate),
|
||||
workload_rate=round1(workload_rate),
|
||||
pace_text=f"{'慢' if pace_rate > 0 else '快'}{abs(round1(pace_rate))}%",
|
||||
workload_text=f"{'多' if workload_rate >= 0 else '少'}{abs(round1(workload_rate))}%",
|
||||
profile_no=group["profile_no"],
|
||||
material_code=group["material_code"],
|
||||
material_name=group["material_name"],
|
||||
raw_material_batch_no="、".join(sorted(group["raw_material_batch_numbers"])) or None,
|
||||
device_no=group["device_no"],
|
||||
stamping_method=group["stamping_method"],
|
||||
operator_count=group["operator_count"],
|
||||
is_continuous_die=group["is_continuous_die"],
|
||||
is_multi_person=group["is_multi_person"],
|
||||
is_multi_person_assistant=group["is_multi_person_assistant"],
|
||||
is_misc=group["is_misc"],
|
||||
is_system_auto_submitted=group["is_system_auto_submitted"],
|
||||
is_voided=group["is_voided"],
|
||||
review_remark=";".join(group["review_remarks"]) or None,
|
||||
correction_pairs=group["correction_pairs"],
|
||||
)
|
||||
)
|
||||
|
||||
rows.sort(
|
||||
key=lambda item: (
|
||||
item.report_date,
|
||||
item.attendance_point_name,
|
||||
item.employee_phone,
|
||||
item.project_no,
|
||||
item.product_name,
|
||||
item.process_name or "",
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/reports/export")
|
||||
def export_dashboard_report_excel(
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
include_voided: bool = False,
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
purge_expired_voided_reports(db)
|
||||
content = export_dashboard_rows(_dashboard_rows(db, start_date, end_date, include_voided))
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="dashboard.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/reports", response_model=PageResponse)
|
||||
def dashboard_reports(
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
include_voided: bool = False,
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
purge_expired_voided_reports(db)
|
||||
rows = _dashboard_rows(db, start_date, end_date, include_voided)
|
||||
safe_page_size = min(100, max(1, page_size))
|
||||
total = len(rows)
|
||||
total_pages = max(1, ceil(total / safe_page_size))
|
||||
start = (page - 1) * safe_page_size
|
||||
return PageResponse(
|
||||
page=page,
|
||||
page_size=safe_page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
rows=rows[start : start + safe_page_size],
|
||||
)
|
||||
@@ -0,0 +1,505 @@
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import re
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.deps import current_user, require_roles
|
||||
from app.models import DeviceQrBatchTask, DeviceQRCode, Personnel, Product, Role
|
||||
from app.schemas import DeviceQrBatchCreate, DeviceQrBatchOut, DeviceQrBatchTaskOut, DeviceQrCreate, DeviceQrOut, PageResponse
|
||||
from app.services.attendance_points import DEFAULT_ATTENDANCE_POINT_NAME, accessible_point_names, require_attendance_point_access
|
||||
from app.services.cleaning import is_cleaning_product
|
||||
from app.services.continuous_die import is_continuous_die_product
|
||||
from app.services.display_names import mold_process_display_name, point_mold_display_name
|
||||
from app.services.misc_work import is_misc_product
|
||||
from app.services.qrcode_batch_tasks import (
|
||||
QRCODE_TASK_STATUS_COMPLETED,
|
||||
QRCODE_TASK_STATUS_FAILED,
|
||||
QRCODE_TASK_STATUS_PAUSED,
|
||||
QRCODE_TASK_STATUS_PENDING,
|
||||
QRCODE_TASK_STATUS_RUNNING,
|
||||
create_qrcode_batch_task,
|
||||
qrcode_batch_task_out,
|
||||
)
|
||||
from app.services.wechat import WechatConfigError, create_device_qrcode
|
||||
|
||||
router = APIRouter(prefix="/api/devices", tags=["devices"])
|
||||
|
||||
|
||||
def _clean_text(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _safe_file_stem(value: str) -> str:
|
||||
normalized = re.sub(r'[\\/:*?"<>|\s]+', "_", value).strip("._")
|
||||
return normalized[:120] or "模具二维码"
|
||||
|
||||
|
||||
def _unique_zip_name(used_names: set[str], stem: str, suffix: str) -> str:
|
||||
suffix = suffix if suffix else ".jpg"
|
||||
name = f"{stem}{suffix}"
|
||||
index = 2
|
||||
while name in used_names:
|
||||
name = f"{stem}_{index}{suffix}"
|
||||
index += 1
|
||||
used_names.add(name)
|
||||
return name
|
||||
|
||||
|
||||
def _qrcode_path_from_url(qr_url: str | None) -> Path | None:
|
||||
if not qr_url:
|
||||
return None
|
||||
filename = unquote(Path(urlparse(qr_url).path).name)
|
||||
if not filename:
|
||||
return None
|
||||
path = settings.upload_path / "qrcodes" / filename
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def _is_local_request_base(base_url: str) -> bool:
|
||||
host = urlparse(base_url).hostname or ""
|
||||
if host in {"localhost", "127.0.0.1", "0.0.0.0"}:
|
||||
return True
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
return address.is_private or address.is_loopback
|
||||
|
||||
|
||||
def _public_base_url(request: Request) -> str:
|
||||
request_base_url = str(request.base_url).rstrip("/")
|
||||
configured_base_url = settings.public_base_url.rstrip("/")
|
||||
if settings.app_env != "production" and _is_local_request_base(request_base_url):
|
||||
return request_base_url
|
||||
return configured_base_url or request_base_url
|
||||
|
||||
|
||||
def _product_for_mold(db: Session, point_name: str, mold_name: str, process_name: str) -> Product | None:
|
||||
return db.query(Product).filter(
|
||||
Product.attendance_point_name == point_name,
|
||||
Product.product_name == mold_name,
|
||||
Product.process_name == process_name,
|
||||
Product.device_no == "",
|
||||
).order_by(Product.project_no.asc()).first()
|
||||
|
||||
|
||||
def _scene_token(point_name: str, mold_name: str, process_name: str) -> str:
|
||||
mold_key = f"{point_name}\0{mold_name}\0{process_name}"
|
||||
return hashlib.sha1(mold_key.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _legacy_process_names(process_name: str) -> list[str]:
|
||||
process = _clean_text(process_name)
|
||||
names: list[str] = []
|
||||
if process.isdigit():
|
||||
names.append(f"{int(process)}序")
|
||||
names.append(f"{process}序")
|
||||
return list(dict.fromkeys(name for name in names if name and name != process))
|
||||
|
||||
|
||||
def _find_legacy_qrcode_scene(db: Session, token: str) -> DeviceQRCode | None:
|
||||
rows = db.query(DeviceQRCode).all()
|
||||
for row in rows:
|
||||
for legacy_process in _legacy_process_names(row.process_name):
|
||||
if _scene_token(row.attendance_point_name, row.device_no, legacy_process) == token:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
async def _generate_mold_qrcode(
|
||||
attendance_point_name: str,
|
||||
mold_name: str,
|
||||
process_name: str,
|
||||
request: Request,
|
||||
user: Personnel,
|
||||
db: Session,
|
||||
reuse_existing: bool = False,
|
||||
allow_missing_config: bool = True,
|
||||
commit: bool = True,
|
||||
) -> DeviceQrOut:
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, attendance_point_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
original = _clean_text(mold_name)
|
||||
process = _clean_text(process_name)
|
||||
if not original:
|
||||
raise HTTPException(status_code=400, detail="请选择模具名称")
|
||||
if not process:
|
||||
raise HTTPException(status_code=400, detail="请选择工序")
|
||||
product = _product_for_mold(db, point_name, original, process)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=400, detail="该考勤点下没有该产品、工序和冲压方式对应的模具")
|
||||
page = "pages/clock/clock"
|
||||
display_name = point_mold_display_name(point_name, original, process, product.stamping_method)
|
||||
scene_token = _scene_token(point_name, original, process)
|
||||
scene = f"mold={scene_token}"
|
||||
record = db.get(
|
||||
DeviceQRCode,
|
||||
{"attendance_point_name": point_name, "device_no": original, "process_name": process},
|
||||
)
|
||||
if reuse_existing and record is not None and _qrcode_path_from_url(record.qr_url) is not None:
|
||||
return DeviceQrOut(
|
||||
device_no=original,
|
||||
attendance_point_name=point_name,
|
||||
mold_name=original,
|
||||
process_name=process,
|
||||
stamping_method=product.stamping_method if product else None,
|
||||
is_cleaning=is_cleaning_product(product),
|
||||
is_misc=is_misc_product(product),
|
||||
is_continuous_die=is_continuous_die_product(product),
|
||||
display_name=display_name,
|
||||
scene=record.qr_scene or scene,
|
||||
page=page,
|
||||
qr_url=record.qr_url,
|
||||
)
|
||||
|
||||
qr_url = None
|
||||
try:
|
||||
public_base_url = _public_base_url(request)
|
||||
qr_url = await create_device_qrcode(
|
||||
display_name,
|
||||
page=page,
|
||||
scene=scene,
|
||||
public_base_url=public_base_url,
|
||||
label=f"模具 {display_name}",
|
||||
)
|
||||
except WechatConfigError as exc:
|
||||
if not allow_missing_config:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
qr_url = None
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
if record is None:
|
||||
record = DeviceQRCode(
|
||||
attendance_point_name=point_name,
|
||||
device_no=original,
|
||||
process_name=process,
|
||||
created_by=user.phone,
|
||||
)
|
||||
db.add(record)
|
||||
record.qr_scene = scene
|
||||
record.qr_url = qr_url
|
||||
if commit:
|
||||
db.commit()
|
||||
else:
|
||||
db.flush()
|
||||
|
||||
return DeviceQrOut(
|
||||
device_no=original,
|
||||
attendance_point_name=point_name,
|
||||
mold_name=original,
|
||||
process_name=process,
|
||||
stamping_method=product.stamping_method if product else None,
|
||||
is_cleaning=is_cleaning_product(product),
|
||||
is_misc=is_misc_product(product),
|
||||
is_continuous_die=is_continuous_die_product(product),
|
||||
display_name=display_name,
|
||||
scene=scene,
|
||||
page=page,
|
||||
qr_url=qr_url,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/resolve-scene", response_model=DeviceQrOut)
|
||||
def resolve_qrcode_scene(
|
||||
scene: str = Query(...),
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DeviceQrOut:
|
||||
raw = str(scene or "").strip()
|
||||
token = raw.split("=", 1)[1] if raw.startswith("mold=") else raw
|
||||
record = db.query(DeviceQRCode).filter(DeviceQRCode.qr_scene == f"mold={token}").first()
|
||||
if record is None:
|
||||
record = _find_legacy_qrcode_scene(db, token)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail="未找到模具二维码")
|
||||
if user.role != Role.manager and record.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=403, detail="无该考勤点模具二维码权限")
|
||||
product = _product_for_mold(db, record.attendance_point_name, record.device_no, record.process_name)
|
||||
display_name = point_mold_display_name(
|
||||
record.attendance_point_name,
|
||||
record.device_no,
|
||||
record.process_name,
|
||||
product.stamping_method if product else None,
|
||||
)
|
||||
return DeviceQrOut(
|
||||
device_no=record.device_no,
|
||||
attendance_point_name=record.attendance_point_name,
|
||||
mold_name=record.device_no,
|
||||
process_name=record.process_name,
|
||||
stamping_method=product.stamping_method if product else None,
|
||||
is_cleaning=is_cleaning_product(product),
|
||||
is_misc=is_misc_product(product),
|
||||
is_continuous_die=is_continuous_die_product(product),
|
||||
display_name=display_name,
|
||||
scene=record.qr_scene,
|
||||
page="pages/clock/clock",
|
||||
qr_url=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/molds/qrcode", response_model=DeviceQrOut)
|
||||
async def generate_mold_process_qrcode(
|
||||
payload: DeviceQrCreate,
|
||||
request: Request,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DeviceQrOut:
|
||||
return await _generate_mold_qrcode(
|
||||
payload.attendance_point_name,
|
||||
payload.product_name,
|
||||
payload.process_name,
|
||||
request,
|
||||
user,
|
||||
db,
|
||||
)
|
||||
|
||||
|
||||
def _validated_batch_items(db: Session, user: Personnel, payload: DeviceQrBatchCreate) -> list[dict]:
|
||||
user_point_names = set(accessible_point_names(db, user))
|
||||
seen_keys: set[tuple[str, str, str]] = set()
|
||||
normalized_items: list[dict] = []
|
||||
for item in payload.items:
|
||||
point_name = _clean_text(item.attendance_point_name) or DEFAULT_ATTENDANCE_POINT_NAME
|
||||
if point_name not in user_point_names:
|
||||
raise HTTPException(status_code=403, detail=f"无该考勤点权限:{point_name}")
|
||||
mold_name = _clean_text(item.product_name)
|
||||
process_name = _clean_text(item.process_name)
|
||||
key = (point_name, mold_name, process_name)
|
||||
if not mold_name or not process_name:
|
||||
raise HTTPException(status_code=400, detail="批量生成二维码必须包含产品名称、工序和冲压方式")
|
||||
if key in seen_keys:
|
||||
raise HTTPException(status_code=400, detail=f"重复选择模具二维码:{point_name} / {mold_process_display_name(mold_name, process_name)}")
|
||||
seen_keys.add(key)
|
||||
normalized_items.append(
|
||||
{
|
||||
"attendance_point_name": point_name,
|
||||
"product_name": mold_name,
|
||||
"process_name": process_name,
|
||||
}
|
||||
)
|
||||
if not normalized_items:
|
||||
return []
|
||||
|
||||
requested_point_names = sorted({item["attendance_point_name"] for item in normalized_items})
|
||||
requested_mold_names = sorted({item["product_name"] for item in normalized_items})
|
||||
requested_process_names = sorted({item["process_name"] for item in normalized_items})
|
||||
products = db.scalars(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name.in_(requested_point_names),
|
||||
Product.product_name.in_(requested_mold_names),
|
||||
Product.process_name.in_(requested_process_names),
|
||||
Product.device_no == "",
|
||||
)
|
||||
.order_by(Product.attendance_point_name.asc(), Product.product_name.asc(), Product.process_name.asc(), Product.project_no.asc())
|
||||
).all()
|
||||
product_by_key: dict[tuple[str, str, str], Product] = {}
|
||||
for product in products:
|
||||
product_by_key.setdefault(
|
||||
(product.attendance_point_name, product.product_name, product.process_name or ""),
|
||||
product,
|
||||
)
|
||||
for item in normalized_items:
|
||||
key = (item["attendance_point_name"], item["product_name"], item["process_name"])
|
||||
product = product_by_key.get(key)
|
||||
if product is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"该考勤点下没有该模具:{key[0]} / {mold_process_display_name(key[1], key[2])}",
|
||||
)
|
||||
return normalized_items
|
||||
|
||||
|
||||
@router.post("/molds/qrcode/batch/tasks", response_model=DeviceQrBatchTaskOut)
|
||||
def create_mold_process_qrcode_batch_task(
|
||||
payload: DeviceQrBatchCreate,
|
||||
request: Request,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DeviceQrBatchTaskOut:
|
||||
task = create_qrcode_batch_task(
|
||||
db,
|
||||
created_by=user.phone,
|
||||
items=_validated_batch_items(db, user, payload),
|
||||
)
|
||||
return qrcode_batch_task_out(task, _public_base_url(request))
|
||||
|
||||
|
||||
@router.get("/molds/qrcode/batch/tasks", response_model=PageResponse)
|
||||
def list_mold_process_qrcode_batch_tasks(
|
||||
request: Request,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(5, ge=1, le=20),
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
query = (
|
||||
db.query(DeviceQrBatchTask)
|
||||
.filter(DeviceQrBatchTask.created_by == user.phone)
|
||||
.order_by(DeviceQrBatchTask.created_at.desc(), DeviceQrBatchTask.id.desc())
|
||||
)
|
||||
total = query.count()
|
||||
safe_page_size = max(1, min(20, page_size))
|
||||
total_pages = max(1, (total + safe_page_size - 1) // safe_page_size)
|
||||
safe_page = max(1, min(page, total_pages))
|
||||
rows = query.offset((safe_page - 1) * safe_page_size).limit(safe_page_size).all()
|
||||
return PageResponse(
|
||||
page=safe_page,
|
||||
page_size=safe_page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
rows=[qrcode_batch_task_out(row, _public_base_url(request)) for row in rows],
|
||||
)
|
||||
|
||||
|
||||
def _get_own_batch_task(db: Session, user: Personnel, task_id: int) -> DeviceQrBatchTask:
|
||||
task = db.get(DeviceQrBatchTask, task_id)
|
||||
if task is None or task.created_by != user.phone:
|
||||
raise HTTPException(status_code=404, detail="二维码ZIP任务不存在")
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/molds/qrcode/batch/tasks/{task_id}/stop", response_model=DeviceQrBatchTaskOut)
|
||||
def stop_mold_process_qrcode_batch_task(
|
||||
task_id: int,
|
||||
request: Request,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DeviceQrBatchTaskOut:
|
||||
task = _get_own_batch_task(db, user, task_id)
|
||||
if task.status in {QRCODE_TASK_STATUS_COMPLETED, QRCODE_TASK_STATUS_FAILED}:
|
||||
return qrcode_batch_task_out(task, _public_base_url(request))
|
||||
task.status = QRCODE_TASK_STATUS_PAUSED
|
||||
task.error_message = None
|
||||
task.finished_at = None
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return qrcode_batch_task_out(task, _public_base_url(request))
|
||||
|
||||
|
||||
@router.post("/molds/qrcode/batch/tasks/{task_id}/resume", response_model=DeviceQrBatchTaskOut)
|
||||
def resume_mold_process_qrcode_batch_task(
|
||||
task_id: int,
|
||||
request: Request,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DeviceQrBatchTaskOut:
|
||||
task = _get_own_batch_task(db, user, task_id)
|
||||
if task.status == QRCODE_TASK_STATUS_COMPLETED:
|
||||
return qrcode_batch_task_out(task, _public_base_url(request))
|
||||
if task.status not in {QRCODE_TASK_STATUS_PAUSED, QRCODE_TASK_STATUS_FAILED}:
|
||||
return qrcode_batch_task_out(task, _public_base_url(request))
|
||||
if int(task.completed_count or 0) >= int(task.item_count or 0):
|
||||
task.status = QRCODE_TASK_STATUS_COMPLETED
|
||||
task.finished_at = task.finished_at or datetime.now()
|
||||
else:
|
||||
task.status = QRCODE_TASK_STATUS_PENDING
|
||||
task.failed_count = 0
|
||||
task.error_message = None
|
||||
task.finished_at = None
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return qrcode_batch_task_out(task, _public_base_url(request))
|
||||
|
||||
|
||||
@router.delete("/molds/qrcode/batch/tasks/{task_id}")
|
||||
def delete_mold_process_qrcode_batch_task(
|
||||
task_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, bool]:
|
||||
task = _get_own_batch_task(db, user, task_id)
|
||||
file_path = settings.upload_path / "qrcode_zips" / task.file_name
|
||||
task.status = QRCODE_TASK_STATUS_PAUSED
|
||||
db.flush()
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/molds/qrcode/batch", response_model=DeviceQrBatchOut)
|
||||
async def generate_mold_process_qrcode_batch(
|
||||
payload: DeviceQrBatchCreate,
|
||||
request: Request,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DeviceQrBatchOut:
|
||||
_validated_batch_items(db, user, payload)
|
||||
|
||||
generated: list[tuple[DeviceQrOut, Path]] = []
|
||||
for item in payload.items:
|
||||
qr = await _generate_mold_qrcode(
|
||||
item.attendance_point_name,
|
||||
item.product_name,
|
||||
item.process_name,
|
||||
request,
|
||||
user,
|
||||
db,
|
||||
reuse_existing=False,
|
||||
allow_missing_config=False,
|
||||
commit=False,
|
||||
)
|
||||
qr_path = _qrcode_path_from_url(qr.qr_url)
|
||||
if qr_path is None:
|
||||
raise HTTPException(status_code=400, detail=f"{qr.display_name} 二维码图片未生成,无法打包")
|
||||
generated.append((qr, qr_path))
|
||||
|
||||
if not generated:
|
||||
raise HTTPException(status_code=400, detail="请选择需要生成的模具二维码")
|
||||
|
||||
zip_dir = settings.upload_path / "qrcode_zips"
|
||||
zip_dir.mkdir(parents=True, exist_ok=True)
|
||||
fingerprint = hashlib.sha1(
|
||||
"|".join(
|
||||
f"{qr.attendance_point_name}\0{qr.mold_name}\0{qr.process_name}"
|
||||
for qr, _ in generated
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:10]
|
||||
file_name = f"mold-qrcodes-{datetime.now().strftime('%Y%m%d%H%M%S')}-{fingerprint}.zip"
|
||||
zip_path = zip_dir / file_name
|
||||
|
||||
used_names: set[str] = set()
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for index, (qr, qr_path) in enumerate(generated, start=1):
|
||||
stem = _safe_file_stem(f"{index:03d}_{qr.display_name or qr.mold_name}")
|
||||
archive.write(qr_path, _unique_zip_name(used_names, stem, qr_path.suffix))
|
||||
|
||||
db.commit()
|
||||
zip_url = f"{_public_base_url(request)}/uploads/qrcode_zips/{quote(file_name, safe='')}"
|
||||
return DeviceQrBatchOut(count=len(generated), file_name=file_name, zip_url=zip_url)
|
||||
|
||||
|
||||
@router.post("/molds/{mold_name}/qrcode", response_model=DeviceQrOut)
|
||||
async def generate_mold_qrcode(
|
||||
mold_name: str,
|
||||
request: Request,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DeviceQrOut:
|
||||
return await _generate_mold_qrcode(accessible_point_names(db, user)[0], mold_name, "", request, user, db)
|
||||
|
||||
|
||||
@router.post("/{device_no}/qrcode", response_model=DeviceQrOut)
|
||||
async def generate_qrcode(
|
||||
device_no: str,
|
||||
request: Request,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> DeviceQrOut:
|
||||
return await _generate_mold_qrcode(accessible_point_names(db, user)[0], device_no, "", request, user, db)
|
||||
@@ -0,0 +1,108 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import Equipment, Personnel, Role
|
||||
from app.schemas import EquipmentCreate, EquipmentOut, PageResponse
|
||||
from app.services.attendance_points import accessible_point_names, require_attendance_point_access
|
||||
from app.services.common import normalize_device_no, paginate
|
||||
from app.services.serializers import equipment_out
|
||||
|
||||
router = APIRouter(prefix="/api/equipment", tags=["equipment"])
|
||||
|
||||
|
||||
EQUIPMENT_TYPES = {"冲压设备", "清洗设备"}
|
||||
|
||||
|
||||
@router.get("", response_model=PageResponse)
|
||||
def list_equipment(
|
||||
attendance_point_name: str = "",
|
||||
keyword: str = "",
|
||||
device_type: str = "",
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
user: Personnel = Depends(require_roles(Role.worker, Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
point_names = accessible_point_names(db, user)
|
||||
query = select(Equipment).where(Equipment.attendance_point_name.in_(point_names)).order_by(Equipment.updated_at.desc(), Equipment.device_no.asc())
|
||||
if attendance_point_name:
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, attendance_point_name)
|
||||
except (ValueError, PermissionError) as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
query = query.where(Equipment.attendance_point_name == point_name)
|
||||
if device_type:
|
||||
query = query.where(Equipment.device_type == device_type)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
Equipment.attendance_point_name.like(like),
|
||||
Equipment.device_no.like(like),
|
||||
Equipment.device_type.like(like),
|
||||
Equipment.remark.like(like),
|
||||
)
|
||||
)
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{**result, "rows": [equipment_out(row) for row in result["rows"]]})
|
||||
|
||||
|
||||
@router.post("", response_model=EquipmentOut)
|
||||
def save_equipment(
|
||||
payload: EquipmentCreate,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> EquipmentOut:
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, payload.attendance_point_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
device_no = normalize_device_no(payload.device_no)
|
||||
if not device_no:
|
||||
raise HTTPException(status_code=400, detail="请输入设备号")
|
||||
if payload.device_type not in EQUIPMENT_TYPES:
|
||||
raise HTTPException(status_code=400, detail="设备类型只能是冲压设备或清洗设备")
|
||||
original_point_name = payload.original_attendance_point_name or point_name
|
||||
original_device_no = normalize_device_no(payload.original_device_no) or device_no
|
||||
original_key = {"attendance_point_name": original_point_name, "device_no": original_device_no}
|
||||
next_key = {"attendance_point_name": point_name, "device_no": device_no}
|
||||
equipment = db.get(Equipment, original_key)
|
||||
if equipment is not None and original_key != next_key:
|
||||
existed = db.get(Equipment, next_key)
|
||||
if existed is not None:
|
||||
raise HTTPException(status_code=409, detail="该设备号已存在,不能重复保存")
|
||||
equipment.attendance_point_name = point_name
|
||||
equipment.device_no = device_no
|
||||
elif equipment is None:
|
||||
equipment = db.get(Equipment, next_key)
|
||||
if equipment is None:
|
||||
equipment = Equipment(attendance_point_name=point_name, device_no=device_no)
|
||||
db.add(equipment)
|
||||
equipment.device_type = payload.device_type
|
||||
equipment.remark = payload.remark
|
||||
db.commit()
|
||||
db.refresh(equipment)
|
||||
return equipment_out(equipment)
|
||||
|
||||
|
||||
@router.delete("/{device_no}", status_code=204)
|
||||
def delete_equipment(
|
||||
device_no: str,
|
||||
attendance_point_name: str,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, attendance_point_name)
|
||||
except (ValueError, PermissionError) as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
equipment = db.get(Equipment, {"attendance_point_name": point_name, "device_no": normalize_device_no(device_no)})
|
||||
if equipment is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备不存在")
|
||||
db.delete(equipment)
|
||||
db.commit()
|
||||
@@ -0,0 +1,192 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import MoldLockFeedback, Personnel, Role, SessionStatus, WorkSession, WorkSessionDevice
|
||||
from app.schemas import MoldLockFeedbackCreate, MoldLockFeedbackOut, MonitorLockOut, PageResponse
|
||||
from app.services.attendance_points import accessible_point_names
|
||||
from app.services.common import paginate
|
||||
from app.services.mold_locks import (
|
||||
FEEDBACK_STATUS_PENDING,
|
||||
active_locks_for_points,
|
||||
current_unreleased_device,
|
||||
feedback_out,
|
||||
lock_out,
|
||||
release_lock,
|
||||
)
|
||||
from app.timezone import now
|
||||
|
||||
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
|
||||
|
||||
|
||||
def _lock_query():
|
||||
return (
|
||||
select(WorkSessionDevice)
|
||||
.options(
|
||||
selectinload(WorkSessionDevice.session).selectinload(WorkSession.devices),
|
||||
selectinload(WorkSessionDevice.session).selectinload(WorkSession.employee),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _feedback_query():
|
||||
return select(MoldLockFeedback).options(
|
||||
selectinload(MoldLockFeedback.reporter),
|
||||
selectinload(MoldLockFeedback.occupied_person),
|
||||
selectinload(MoldLockFeedback.handler),
|
||||
selectinload(MoldLockFeedback.session_device),
|
||||
)
|
||||
|
||||
|
||||
def _admin_point_names(db: Session, user: Personnel) -> list[str]:
|
||||
return accessible_point_names(db, user)
|
||||
|
||||
|
||||
def _get_admin_lock(db: Session, user: Personnel, lock_id: int) -> WorkSessionDevice:
|
||||
lock = db.scalar(_lock_query().where(WorkSessionDevice.id == lock_id))
|
||||
if lock is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="占用记录不存在")
|
||||
if lock.attendance_point_name not in _admin_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点监控权限")
|
||||
return lock
|
||||
|
||||
|
||||
def _get_admin_feedback(db: Session, user: Personnel, feedback_id: int) -> MoldLockFeedback:
|
||||
feedback = db.scalar(_feedback_query().where(MoldLockFeedback.id == feedback_id))
|
||||
if feedback is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="反馈不存在")
|
||||
if feedback.attendance_point_name not in _admin_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点监控权限")
|
||||
return feedback
|
||||
|
||||
|
||||
def _is_active_current_lock(lock: WorkSessionDevice) -> bool:
|
||||
if (
|
||||
lock.session is None
|
||||
or lock.session.status not in [SessionStatus.active, SessionStatus.reporting]
|
||||
or lock.released_at is not None
|
||||
):
|
||||
return False
|
||||
current = current_unreleased_device(lock.session)
|
||||
return bool(current and current.id == lock.id)
|
||||
|
||||
|
||||
@router.get("/locks", response_model=list[MonitorLockOut])
|
||||
def list_active_locks(
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[MonitorLockOut]:
|
||||
return [lock_out(db, lock) for lock in active_locks_for_points(db, _admin_point_names(db, user))]
|
||||
|
||||
|
||||
@router.post("/locks/{lock_id}/release")
|
||||
def force_release_lock(
|
||||
lock_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, bool]:
|
||||
_get_admin_lock(db, user, lock_id)
|
||||
try:
|
||||
release_lock(db, lock_id, user.phone, "管理员强制解除占用")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/feedbacks", response_model=PageResponse)
|
||||
def list_feedbacks(
|
||||
box: str = Query("unread", pattern="^(unread|read)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=10),
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
point_names = _admin_point_names(db, user)
|
||||
query = _feedback_query().where(MoldLockFeedback.attendance_point_name.in_(point_names))
|
||||
if box == "read":
|
||||
query = query.where(MoldLockFeedback.read_at.is_not(None)).order_by(
|
||||
MoldLockFeedback.read_at.desc(),
|
||||
MoldLockFeedback.created_at.desc(),
|
||||
)
|
||||
else:
|
||||
query = query.where(MoldLockFeedback.read_at.is_(None)).order_by(MoldLockFeedback.created_at.desc())
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{**result, "rows": [feedback_out(db, row) for row in result["rows"]]})
|
||||
|
||||
|
||||
@router.post("/feedbacks", response_model=MoldLockFeedbackOut)
|
||||
def create_feedback(
|
||||
payload: MoldLockFeedbackCreate,
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MoldLockFeedbackOut:
|
||||
lock = db.scalar(_lock_query().where(WorkSessionDevice.id == payload.lock_id))
|
||||
if lock is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="占用记录不存在")
|
||||
if lock.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点反馈权限")
|
||||
if not _is_active_current_lock(lock):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该模具占用已解除")
|
||||
if lock.session.employee_phone == user.phone:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能反馈自己的占用")
|
||||
|
||||
existing = db.scalar(
|
||||
_feedback_query().where(
|
||||
MoldLockFeedback.session_device_id == lock.id,
|
||||
MoldLockFeedback.reporter_phone == user.phone,
|
||||
MoldLockFeedback.status == FEEDBACK_STATUS_PENDING,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
return feedback_out(db, existing)
|
||||
|
||||
feedback = MoldLockFeedback(
|
||||
attendance_point_name=lock.attendance_point_name,
|
||||
mold_name=lock.device_no,
|
||||
process_name=lock.process_name,
|
||||
session_device_id=lock.id,
|
||||
reporter_phone=user.phone,
|
||||
occupied_phone=lock.session.employee_phone,
|
||||
status=FEEDBACK_STATUS_PENDING,
|
||||
)
|
||||
db.add(feedback)
|
||||
db.commit()
|
||||
feedback = db.scalar(_feedback_query().where(MoldLockFeedback.id == feedback.id))
|
||||
if feedback is None:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="反馈保存失败")
|
||||
return feedback_out(db, feedback)
|
||||
|
||||
|
||||
@router.post("/feedbacks/{feedback_id}/read", response_model=MoldLockFeedbackOut)
|
||||
def mark_feedback_read(
|
||||
feedback_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MoldLockFeedbackOut:
|
||||
feedback = _get_admin_feedback(db, user, feedback_id)
|
||||
if feedback.read_at is None:
|
||||
feedback.read_at = now()
|
||||
db.commit()
|
||||
feedback = _get_admin_feedback(db, user, feedback_id)
|
||||
return feedback_out(db, feedback)
|
||||
|
||||
|
||||
@router.post("/feedbacks/{feedback_id}/release", response_model=MoldLockFeedbackOut)
|
||||
def release_feedback_lock(
|
||||
feedback_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> MoldLockFeedbackOut:
|
||||
feedback = _get_admin_feedback(db, user, feedback_id)
|
||||
if feedback.read_at is None:
|
||||
feedback.read_at = now()
|
||||
try:
|
||||
release_lock(db, feedback.session_device_id, user.phone, "管理员通过反馈解除占用")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
feedback = _get_admin_feedback(db, user, feedback_id)
|
||||
return feedback_out(db, feedback)
|
||||
@@ -0,0 +1,146 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import current_user, require_roles
|
||||
from app.models import Notice, NoticePoint, Personnel, Role
|
||||
from app.schemas import NoticeCreate, NoticeOut, NoticeUpdate, PageResponse
|
||||
from app.services.common import paginate
|
||||
from app.services.attendance_points import accessible_point_names
|
||||
|
||||
router = APIRouter(prefix="/api/notices", tags=["notices"])
|
||||
|
||||
|
||||
def _notice_out(notice: Notice) -> NoticeOut:
|
||||
point_names = sorted([item.attendance_point_name for item in notice.attendance_points])
|
||||
return NoticeOut(
|
||||
id=notice.id,
|
||||
title=notice.title,
|
||||
content=notice.content,
|
||||
attendance_point_names=point_names,
|
||||
sort_order=notice.sort_order,
|
||||
is_active=notice.is_active,
|
||||
created_by=notice.created_by,
|
||||
creator_name=notice.creator.name if notice.creator else "",
|
||||
created_at=notice.created_at,
|
||||
updated_at=notice.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _clean_text(value: str, field_name: str) -> str:
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail=f"{field_name}不能为空")
|
||||
return text
|
||||
|
||||
|
||||
@router.get("", response_model=PageResponse)
|
||||
def list_notices(
|
||||
include_inactive: bool = False,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
if include_inactive and user.role not in (Role.admin, Role.manager):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||
|
||||
query = (
|
||||
select(Notice)
|
||||
.options(selectinload(Notice.creator), selectinload(Notice.attendance_points))
|
||||
.order_by(Notice.sort_order.asc(), Notice.updated_at.desc())
|
||||
)
|
||||
if not include_inactive:
|
||||
query = query.where(Notice.is_active.is_(True))
|
||||
point_names = accessible_point_names(db, user)
|
||||
query = query.where(Notice.attendance_points.any(NoticePoint.attendance_point_name.in_(point_names)))
|
||||
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{**result, "rows": [_notice_out(row) for row in result["rows"]]})
|
||||
|
||||
|
||||
@router.post("", response_model=NoticeOut)
|
||||
def create_notice(
|
||||
payload: NoticeCreate,
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> NoticeOut:
|
||||
allowed = set(accessible_point_names(db, user))
|
||||
point_names = payload.attendance_point_names or list(allowed)
|
||||
denied = [name for name in point_names if name not in allowed]
|
||||
if denied:
|
||||
raise HTTPException(status_code=403, detail=f"无该考勤点通知配置权限:{'、'.join(denied)}")
|
||||
notice = Notice(
|
||||
title=_clean_text(payload.title, "标题"),
|
||||
content=_clean_text(payload.content, "内容"),
|
||||
sort_order=payload.sort_order,
|
||||
is_active=payload.is_active,
|
||||
created_by=user.phone,
|
||||
)
|
||||
for name in dict.fromkeys(point_names):
|
||||
notice.attendance_points.append(NoticePoint(attendance_point_name=name))
|
||||
db.add(notice)
|
||||
db.commit()
|
||||
db.refresh(notice)
|
||||
return _notice_out(notice)
|
||||
|
||||
|
||||
@router.put("/{notice_id}", response_model=NoticeOut)
|
||||
def update_notice(
|
||||
notice_id: int,
|
||||
payload: NoticeUpdate,
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> NoticeOut:
|
||||
notice = db.scalar(
|
||||
select(Notice)
|
||||
.options(selectinload(Notice.attendance_points))
|
||||
.where(Notice.id == notice_id)
|
||||
)
|
||||
if notice is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
|
||||
allowed = set(accessible_point_names(db, user))
|
||||
if user.role == Role.admin and not any(item.attendance_point_name in allowed for item in notice.attendance_points):
|
||||
raise HTTPException(status_code=403, detail="无该考勤点通知配置权限")
|
||||
|
||||
if payload.title is not None:
|
||||
notice.title = _clean_text(payload.title, "标题")
|
||||
if payload.content is not None:
|
||||
notice.content = _clean_text(payload.content, "内容")
|
||||
if payload.sort_order is not None:
|
||||
notice.sort_order = payload.sort_order
|
||||
if payload.is_active is not None:
|
||||
notice.is_active = payload.is_active
|
||||
if payload.attendance_point_names is not None:
|
||||
denied = [name for name in payload.attendance_point_names if name not in allowed]
|
||||
if denied:
|
||||
raise HTTPException(status_code=403, detail=f"无该考勤点通知配置权限:{'、'.join(denied)}")
|
||||
notice.attendance_points.clear()
|
||||
db.flush()
|
||||
for name in dict.fromkeys(payload.attendance_point_names or list(allowed)):
|
||||
notice.attendance_points.append(NoticePoint(attendance_point_name=name))
|
||||
|
||||
db.commit()
|
||||
db.refresh(notice)
|
||||
return _notice_out(notice)
|
||||
|
||||
|
||||
@router.delete("/{notice_id}", status_code=204)
|
||||
def delete_notice(
|
||||
notice_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
notice = db.scalar(
|
||||
select(Notice)
|
||||
.options(selectinload(Notice.attendance_points))
|
||||
.where(Notice.id == notice_id)
|
||||
)
|
||||
if notice is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
|
||||
allowed = set(accessible_point_names(db, user))
|
||||
if user.role == Role.admin and not any(item.attendance_point_name in allowed for item in notice.attendance_points):
|
||||
raise HTTPException(status_code=403, detail="无该考勤点通知配置权限")
|
||||
db.delete(notice)
|
||||
db.commit()
|
||||
@@ -0,0 +1,163 @@
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import PersonAttendancePoint, PersonRole, Personnel, Role
|
||||
from app.schemas import ImportResult, PageResponse, PersonnelCreate, PersonnelOut
|
||||
from app.services.common import ROLE_NAMES
|
||||
from app.services.attendance_points import accessible_point_names, set_person_attendance_points
|
||||
from app.services.excel_export import export_people
|
||||
from app.services.excel_import import ProductImportValidationError, import_people
|
||||
from app.services.serializers import personnel_out, personnel_summary_out
|
||||
|
||||
router = APIRouter(prefix="/api/people", tags=["people"])
|
||||
|
||||
|
||||
@router.get("", response_model=PageResponse)
|
||||
def list_people(
|
||||
keyword: str = "",
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
point_names = accessible_point_names(db, user)
|
||||
query = (
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points).selectinload(PersonAttendancePoint.attendance_point))
|
||||
.where(Personnel.roles.any())
|
||||
.order_by(Personnel.created_at.desc())
|
||||
)
|
||||
if user.role == Role.admin:
|
||||
query = query.where(
|
||||
Personnel.roles.any(PersonRole.role == Role.worker),
|
||||
Personnel.attendance_points.any(PersonAttendancePoint.attendance_point_name.in_(point_names)),
|
||||
)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
matched_roles = [role for role, name in ROLE_NAMES.items() if keyword in name or keyword in role]
|
||||
query = query.where(
|
||||
or_(
|
||||
Personnel.phone.like(like),
|
||||
Personnel.name.like(like),
|
||||
Personnel.roles.any(PersonRole.role.in_(matched_roles)),
|
||||
)
|
||||
)
|
||||
safe_page = max(1, page)
|
||||
safe_page_size = min(100, max(1, page_size))
|
||||
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
|
||||
rows = db.scalars(query.limit(safe_page_size).offset((safe_page - 1) * safe_page_size)).all()
|
||||
return PageResponse(
|
||||
page=safe_page,
|
||||
page_size=safe_page_size,
|
||||
total=total,
|
||||
total_pages=max(1, (total + safe_page_size - 1) // safe_page_size),
|
||||
rows=[personnel_summary_out(row) for row in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=PersonnelOut)
|
||||
def save_person(
|
||||
payload: PersonnelCreate,
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PersonnelOut:
|
||||
if user.role == Role.admin and payload.role != Role.worker:
|
||||
raise HTTPException(status_code=403, detail="管理员只能新增或导入冲压工人")
|
||||
point_names = payload.attendance_point_names or accessible_point_names(db, user)
|
||||
if user.role == Role.admin:
|
||||
allowed = set(accessible_point_names(db, user))
|
||||
denied = [name for name in point_names if name not in allowed]
|
||||
if denied:
|
||||
raise HTTPException(status_code=403, detail=f"无该考勤点人员维护权限:{'、'.join(denied)}")
|
||||
phone = str(payload.phone or "").strip()
|
||||
original_phone = str(payload.original_phone or "").strip()
|
||||
if original_phone and original_phone != phone:
|
||||
raise HTTPException(status_code=400, detail="编辑人员不能修改手机号")
|
||||
|
||||
person = db.get(Personnel, phone)
|
||||
editing_existing = bool(original_phone)
|
||||
active_role_count = db.scalar(select(func.count()).select_from(PersonRole).where(PersonRole.phone == phone)) or 0
|
||||
if person is not None and not editing_existing and active_role_count > 0:
|
||||
raise HTTPException(status_code=409, detail="该手机号已存在,不能重复添加")
|
||||
if person is None:
|
||||
person = Personnel(phone=phone, name=payload.name)
|
||||
db.add(person)
|
||||
db.flush()
|
||||
else:
|
||||
person.name = payload.name
|
||||
person.is_temporary = False
|
||||
person.temporary_expires_at = None
|
||||
role = db.get(PersonRole, {"phone": phone, "role": payload.role})
|
||||
if role is None:
|
||||
role = PersonRole(phone=phone, role=payload.role)
|
||||
db.add(role)
|
||||
try:
|
||||
set_person_attendance_points(db, phone, point_names)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
db.commit()
|
||||
person = db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points).selectinload(PersonAttendancePoint.attendance_point))
|
||||
.where(Personnel.phone == phone)
|
||||
)
|
||||
if person is None:
|
||||
raise HTTPException(status_code=500, detail="人员保存失败")
|
||||
return personnel_out(person, payload.role)
|
||||
|
||||
|
||||
@router.delete("/{phone}", status_code=204)
|
||||
def delete_person(
|
||||
phone: str,
|
||||
role: Role | None = None,
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
person = db.get(Personnel, phone)
|
||||
if person is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
|
||||
if user.role == Role.admin:
|
||||
point_names = accessible_point_names(db, user)
|
||||
if not any(item.attendance_point_name in point_names for item in person.attendance_points):
|
||||
raise HTTPException(status_code=403, detail="无该考勤点人员维护权限")
|
||||
if role is not None and role != Role.worker:
|
||||
raise HTTPException(status_code=403, detail="管理员只能删除冲压工人")
|
||||
if role is None:
|
||||
db.query(PersonRole).filter(PersonRole.phone == phone).delete()
|
||||
else:
|
||||
person_role = db.get(PersonRole, {"phone": phone, "role": role})
|
||||
if person_role is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="角色不存在")
|
||||
db.delete(person_role)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/import", response_model=ImportResult)
|
||||
async def import_people_excel(
|
||||
file: UploadFile = File(...),
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ImportResult:
|
||||
content = await file.read()
|
||||
try:
|
||||
result = import_people(db, content, user=user)
|
||||
except ProductImportValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return ImportResult(**result.as_dict())
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_people_excel(
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
content = export_people(db, user=user)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="people.xlsx"'},
|
||||
)
|
||||
@@ -0,0 +1,360 @@
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import DeviceQRCode, MoldLockFeedback, Personnel, Product, ProductionReportItem, Role, WorkSessionDevice
|
||||
from app.schemas import ImportResult, MoldNameOut, PageResponse, ProductCreate, ProductOut
|
||||
from app.services.attendance_points import accessible_point_names, require_attendance_point_access
|
||||
from app.services.common import normalize_device_no, paginate
|
||||
from app.services.excel_import import ProductImportValidationError, import_products
|
||||
from app.services.excel_export import export_products
|
||||
from app.services.cleaning import LEGACY_CLEANING_PROCESS_NAME, is_cleaning_stamping_method
|
||||
from app.services.continuous_die import is_continuous_die_product
|
||||
from app.services.display_names import mold_process_display_name
|
||||
from app.services.misc_work import ensure_misc_work_products, is_misc_stamping_method
|
||||
from app.services.multi_person import is_multi_person_product
|
||||
from app.services.process_names import MISC_PROCESS_NAME, normalize_process_name
|
||||
from app.services.serializers import product_out
|
||||
|
||||
router = APIRouter(prefix="/api/products", tags=["products"])
|
||||
|
||||
|
||||
def _cascade_product_key_change(db: Session, original_key: dict, next_key: dict) -> None:
|
||||
if original_key == next_key:
|
||||
return
|
||||
old_point = original_key["attendance_point_name"]
|
||||
old_product = original_key["product_name"]
|
||||
old_process = original_key["process_name"]
|
||||
next_point = next_key["attendance_point_name"]
|
||||
next_product = next_key["product_name"]
|
||||
next_process = next_key["process_name"]
|
||||
|
||||
old_qr = db.get(DeviceQRCode, {
|
||||
"attendance_point_name": old_point,
|
||||
"device_no": old_product,
|
||||
"process_name": old_process,
|
||||
})
|
||||
if old_qr is not None:
|
||||
qr_target = db.get(DeviceQRCode, {
|
||||
"attendance_point_name": next_point,
|
||||
"device_no": next_product,
|
||||
"process_name": next_process,
|
||||
})
|
||||
if qr_target is not None:
|
||||
raise HTTPException(status_code=409, detail="目标产品/工序二维码已存在,不能合并覆盖")
|
||||
old_qr.attendance_point_name = next_point
|
||||
old_qr.device_no = next_product
|
||||
old_qr.process_name = next_process
|
||||
|
||||
for device in db.scalars(select(WorkSessionDevice).where(
|
||||
WorkSessionDevice.attendance_point_name == old_point,
|
||||
WorkSessionDevice.device_no == old_product,
|
||||
WorkSessionDevice.process_name == old_process,
|
||||
)).all():
|
||||
device.attendance_point_name = next_point
|
||||
device.device_no = next_product
|
||||
device.process_name = next_process
|
||||
|
||||
for item in db.scalars(select(ProductionReportItem).where(
|
||||
ProductionReportItem.attendance_point_name == old_point,
|
||||
ProductionReportItem.project_no == original_key["project_no"],
|
||||
ProductionReportItem.product_name == old_product,
|
||||
ProductionReportItem.process_name == old_process,
|
||||
)).all():
|
||||
item.attendance_point_name = next_point
|
||||
item.project_no = next_key["project_no"]
|
||||
item.product_name = next_product
|
||||
item.process_name = next_process
|
||||
|
||||
for feedback in db.scalars(select(MoldLockFeedback).where(
|
||||
MoldLockFeedback.attendance_point_name == old_point,
|
||||
MoldLockFeedback.mold_name == old_product,
|
||||
MoldLockFeedback.process_name == old_process,
|
||||
)).all():
|
||||
feedback.attendance_point_name = next_point
|
||||
feedback.mold_name = next_product
|
||||
feedback.process_name = next_process
|
||||
|
||||
|
||||
@router.get("/molds", response_model=list[MoldNameOut])
|
||||
def list_mold_names(
|
||||
keyword: str = "",
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> list[MoldNameOut]:
|
||||
point_names = accessible_point_names(db, user)
|
||||
ensure_misc_work_products(db, point_names)
|
||||
db.commit()
|
||||
query = (
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name.in_(point_names),
|
||||
Product.product_name != "",
|
||||
Product.process_name != "",
|
||||
Product.device_no == "",
|
||||
)
|
||||
.distinct()
|
||||
.order_by(Product.attendance_point_name, Product.product_name, Product.process_name)
|
||||
)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.where(or_(Product.product_name.like(like), Product.process_name.like(like), Product.stamping_method.like(like)))
|
||||
rows = db.scalars(query.limit(limit)).all()
|
||||
return [
|
||||
MoldNameOut(
|
||||
attendance_point_name=product.attendance_point_name,
|
||||
name=product.product_name,
|
||||
process_name=product.process_name or "",
|
||||
stamping_method=product.stamping_method,
|
||||
operator_count=product.operator_count,
|
||||
is_cleaning=is_cleaning_stamping_method(product.stamping_method),
|
||||
is_misc=is_misc_stamping_method(product.stamping_method),
|
||||
is_continuous_die=is_continuous_die_product(product),
|
||||
is_multi_person=is_multi_person_product(product),
|
||||
display_name=mold_process_display_name(product.product_name, product.process_name, product.stamping_method),
|
||||
)
|
||||
for product in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=PageResponse)
|
||||
def list_products(
|
||||
attendance_point_name: str = "",
|
||||
keyword: str = "",
|
||||
device_no: str = "",
|
||||
product_name: str = "",
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
user: Personnel = Depends(require_roles(Role.worker, Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
point_names = accessible_point_names(db, user)
|
||||
ensure_misc_work_products(db, point_names)
|
||||
db.commit()
|
||||
query = (
|
||||
select(Product)
|
||||
.where(Product.device_no == "", Product.attendance_point_name.in_(point_names))
|
||||
.order_by(
|
||||
Product.updated_at.desc(),
|
||||
Product.attendance_point_name.asc(),
|
||||
Product.project_no.asc(),
|
||||
Product.product_name.asc(),
|
||||
Product.process_name.asc(),
|
||||
)
|
||||
)
|
||||
if attendance_point_name:
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, attendance_point_name)
|
||||
except (ValueError, PermissionError) as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
query = query.where(Product.attendance_point_name == point_name)
|
||||
if product_name:
|
||||
query = query.where(Product.product_name == product_name)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.where(
|
||||
or_(
|
||||
Product.project_no.like(like),
|
||||
Product.product_name.like(like),
|
||||
Product.attendance_point_name.like(like),
|
||||
Product.material_code.like(like),
|
||||
Product.material_name.like(like),
|
||||
Product.supplier.like(like),
|
||||
Product.process_name.like(like),
|
||||
Product.stamping_method.like(like),
|
||||
)
|
||||
)
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{**result, "rows": [product_out(row) for row in result["rows"]]})
|
||||
|
||||
|
||||
@router.post("", response_model=ProductOut)
|
||||
def save_product(
|
||||
payload: ProductCreate,
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProductOut:
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, payload.attendance_point_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
raw_process_name = str(payload.process_name or "").strip()
|
||||
if raw_process_name == LEGACY_CLEANING_PROCESS_NAME:
|
||||
raise HTTPException(status_code=400, detail="清洗请填写在冲压方式中,工序只能填写数字,处理杂活除外")
|
||||
try:
|
||||
process_name = normalize_process_name(raw_process_name)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
is_cleaning = is_cleaning_stamping_method(payload.stamping_method)
|
||||
is_misc = is_misc_stamping_method(payload.stamping_method)
|
||||
if is_misc and process_name != MISC_PROCESS_NAME:
|
||||
raise HTTPException(status_code=400, detail="处理杂活的工序必须填写杂活")
|
||||
if not is_misc and process_name == MISC_PROCESS_NAME:
|
||||
raise HTTPException(status_code=400, detail="只有处理杂活的工序可以填写杂活")
|
||||
if not is_cleaning and not is_misc and payload.standard_beat <= 0:
|
||||
raise HTTPException(status_code=400, detail="非清洗工序必须填写标准节拍")
|
||||
|
||||
next_key = {
|
||||
"attendance_point_name": point_name,
|
||||
"project_no": payload.project_no,
|
||||
"product_name": payload.product_name,
|
||||
"device_no": "",
|
||||
"process_name": process_name,
|
||||
}
|
||||
original_key = {
|
||||
"attendance_point_name": payload.original_attendance_point_name or point_name,
|
||||
"project_no": payload.original_project_no or payload.project_no,
|
||||
"product_name": payload.original_product_name or payload.product_name,
|
||||
"device_no": normalize_device_no(payload.original_device_no if payload.original_device_no is not None else ""),
|
||||
"process_name": payload.original_process_name if payload.original_process_name is not None else process_name,
|
||||
}
|
||||
|
||||
product = db.get(Product, original_key)
|
||||
if product is not None and original_key != next_key:
|
||||
existed = db.get(Product, next_key)
|
||||
if existed is not None:
|
||||
raise HTTPException(status_code=409, detail="目标产品/工序已存在,不能合并覆盖")
|
||||
else:
|
||||
_cascade_product_key_change(db, original_key, next_key)
|
||||
product.project_no = next_key["project_no"]
|
||||
product.product_name = next_key["product_name"]
|
||||
product.device_no = next_key["device_no"]
|
||||
product.process_name = next_key["process_name"]
|
||||
elif product is None:
|
||||
product = db.get(Product, next_key)
|
||||
if product is None:
|
||||
product = Product(
|
||||
attendance_point_name=next_key["attendance_point_name"],
|
||||
project_no=next_key["project_no"],
|
||||
product_name=next_key["product_name"],
|
||||
device_no=next_key["device_no"],
|
||||
process_name=next_key["process_name"],
|
||||
)
|
||||
db.add(product)
|
||||
|
||||
aux_values = (
|
||||
payload.product_net_weight_kg,
|
||||
payload.product_gross_weight_kg,
|
||||
payload.scrap_loss_rate,
|
||||
payload.waste_price_yuan_per_kg,
|
||||
)
|
||||
if all(value is None for value in aux_values):
|
||||
sibling = next(
|
||||
(
|
||||
item for item in db.scalars(
|
||||
select(Product).where(Product.product_name == next_key["product_name"], Product.device_no == "")
|
||||
.where(Product.attendance_point_name == point_name)
|
||||
).all()
|
||||
if item.product_net_weight_kg is not None
|
||||
and item.product_gross_weight_kg is not None
|
||||
and item.scrap_loss_rate is not None
|
||||
and item.waste_price_yuan_per_kg is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
if sibling is not None:
|
||||
aux_values = (
|
||||
sibling.product_net_weight_kg,
|
||||
sibling.product_gross_weight_kg,
|
||||
sibling.scrap_loss_rate,
|
||||
sibling.waste_price_yuan_per_kg,
|
||||
)
|
||||
|
||||
product.profile_no = payload.profile_no
|
||||
product.attendance_point_name = next_key["attendance_point_name"]
|
||||
product.material_code = payload.material_code
|
||||
product.material_name = payload.material_name
|
||||
product.supplier = payload.supplier
|
||||
product.product_net_weight_kg = aux_values[0]
|
||||
product.product_gross_weight_kg = aux_values[1]
|
||||
product.scrap_loss_rate = aux_values[2]
|
||||
product.waste_price_yuan_per_kg = aux_values[3]
|
||||
product.device_no = next_key["device_no"]
|
||||
product.process_name = next_key["process_name"]
|
||||
product.stamping_method = payload.stamping_method
|
||||
product.operator_count = payload.operator_count or 1
|
||||
product.process_unit_price_yuan = payload.process_unit_price_yuan or 0
|
||||
product.standard_beat = 0 if (is_cleaning or is_misc) else payload.standard_beat
|
||||
product.standard_workload = 0
|
||||
for sibling in db.scalars(select(Product).where(
|
||||
Product.attendance_point_name == point_name,
|
||||
Product.product_name == next_key["product_name"],
|
||||
Product.device_no == "",
|
||||
)).all():
|
||||
sibling.product_net_weight_kg = product.product_net_weight_kg
|
||||
sibling.product_gross_weight_kg = product.product_gross_weight_kg
|
||||
sibling.scrap_loss_rate = product.scrap_loss_rate
|
||||
sibling.waste_price_yuan_per_kg = product.waste_price_yuan_per_kg
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product_out(product)
|
||||
|
||||
|
||||
@router.delete("", status_code=204)
|
||||
def delete_product(
|
||||
attendance_point_name: str,
|
||||
project_no: str,
|
||||
product_name: str,
|
||||
device_no: str = "",
|
||||
process_name: str = "",
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, attendance_point_name)
|
||||
except (ValueError, PermissionError) as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
product = db.get(
|
||||
Product,
|
||||
{
|
||||
"attendance_point_name": point_name,
|
||||
"project_no": project_no,
|
||||
"product_name": product_name,
|
||||
"device_no": "",
|
||||
"process_name": process_name,
|
||||
},
|
||||
)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="产品不存在")
|
||||
qrcode = db.get(DeviceQRCode, {
|
||||
"attendance_point_name": point_name,
|
||||
"device_no": product.product_name,
|
||||
"process_name": product.process_name or "",
|
||||
})
|
||||
if qrcode is not None:
|
||||
db.delete(qrcode)
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/import", response_model=ImportResult)
|
||||
async def import_product_excel(
|
||||
file: UploadFile = File(...),
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ImportResult:
|
||||
content = await file.read()
|
||||
try:
|
||||
result = import_products(db, content, user=user)
|
||||
except ProductImportValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return ImportResult(**result.as_dict())
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_product_excel(
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
content = export_products(db, user=user)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="products.xlsx"'},
|
||||
)
|
||||
@@ -0,0 +1,232 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import extract, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import Personnel, Product, ProductionReport, ProductionReportItem, ReconciliationLedgerEntry, ReportStatus, Role
|
||||
from app.schemas import (
|
||||
ReconciliationEntryUpdate,
|
||||
ReconciliationLedgerOut,
|
||||
ReconciliationMonthCell,
|
||||
ReconciliationRow,
|
||||
ReconciliationYearsOut,
|
||||
)
|
||||
from app.services.common import as_float, round2
|
||||
from app.services.cleaning import is_cleaning_product
|
||||
from app.services.misc_work import is_misc_product
|
||||
from app.services.process_names import process_order
|
||||
from app.services.report_lifecycle import purge_expired_voided_reports
|
||||
from app.timezone import today
|
||||
|
||||
router = APIRouter(prefix="/api/reconciliation", tags=["reconciliation"])
|
||||
|
||||
|
||||
def _latest_processes_by_product(db: Session) -> dict[tuple[str, str], set[str]]:
|
||||
products = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.device_no == "", Product.product_name != "", Product.process_name != "")
|
||||
.order_by(Product.attendance_point_name.asc(), Product.product_name.asc(), Product.process_name.asc())
|
||||
).all()
|
||||
latest: dict[tuple[str, str], tuple[tuple[int, str], set[str]]] = {}
|
||||
for product in products:
|
||||
if is_cleaning_product(product) or is_misc_product(product):
|
||||
continue
|
||||
point_name = str(product.attendance_point_name or "").strip()
|
||||
product_name = str(product.product_name or "").strip()
|
||||
process_name = str(product.process_name or "").strip()
|
||||
if not point_name or not product_name or not process_name:
|
||||
continue
|
||||
order = process_order(process_name)
|
||||
product_key = (point_name, product_name)
|
||||
current = latest.get(product_key)
|
||||
if current is None or order > current[0]:
|
||||
latest[product_key] = (order, {process_name})
|
||||
elif order == current[0]:
|
||||
current[1].add(process_name)
|
||||
return {product_key: process_names for product_key, (_order, process_names) in latest.items()}
|
||||
|
||||
|
||||
def _month_range(year: int, month: int) -> tuple[date, date]:
|
||||
start = date(year, month, 1)
|
||||
if month == 12:
|
||||
end = date(year, 12, 31)
|
||||
else:
|
||||
end = date(year, month + 1, 1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _reported_good_quantities(
|
||||
db: Session,
|
||||
*,
|
||||
year: int,
|
||||
product_keys: list[tuple[str, str]],
|
||||
latest_processes: dict[tuple[str, str], set[str]],
|
||||
) -> dict[tuple[str, str, int], float]:
|
||||
if not product_keys:
|
||||
return {}
|
||||
point_names = sorted({point_name for point_name, _product_name in product_keys})
|
||||
product_names = sorted({product_name for _point_name, product_name in product_keys})
|
||||
rows = db.execute(
|
||||
select(
|
||||
ProductionReportItem.attendance_point_name,
|
||||
ProductionReportItem.product_name,
|
||||
ProductionReportItem.process_name,
|
||||
extract("month", ProductionReport.report_date).label("month"),
|
||||
func.sum(ProductionReportItem.good_qty).label("good_qty"),
|
||||
)
|
||||
.join(ProductionReport, ProductionReport.id == ProductionReportItem.report_id)
|
||||
.where(
|
||||
ProductionReport.status == ReportStatus.approved,
|
||||
ProductionReport.is_voided.is_(False),
|
||||
extract("year", ProductionReport.report_date) == year,
|
||||
ProductionReportItem.attendance_point_name.in_(point_names),
|
||||
ProductionReportItem.product_name.in_(product_names),
|
||||
)
|
||||
.group_by(
|
||||
ProductionReportItem.attendance_point_name,
|
||||
ProductionReportItem.product_name,
|
||||
ProductionReportItem.process_name,
|
||||
extract("month", ProductionReport.report_date),
|
||||
)
|
||||
).all()
|
||||
totals: dict[tuple[str, str, int], float] = {}
|
||||
for point_name, product_name, process_name, month, good_qty in rows:
|
||||
product_key = (point_name, product_name)
|
||||
if str(process_name or "").strip() not in latest_processes.get(product_key, set()):
|
||||
continue
|
||||
key = (point_name, product_name, int(month))
|
||||
totals[key] = round2(totals.get(key, 0) + as_float(good_qty))
|
||||
return totals
|
||||
|
||||
|
||||
def _ledger_quantities(db: Session, *, year: int) -> dict[tuple[str, str, int], dict[str, float]]:
|
||||
entries = db.scalars(
|
||||
select(ReconciliationLedgerEntry)
|
||||
.where(ReconciliationLedgerEntry.year == year)
|
||||
).all()
|
||||
return {
|
||||
(entry.attendance_point_name, entry.product_name, int(entry.month)): {
|
||||
"reconciled_good_qty": as_float(entry.reconciled_good_qty),
|
||||
"return_qty": as_float(entry.return_qty),
|
||||
}
|
||||
for entry in entries
|
||||
}
|
||||
|
||||
|
||||
@router.get("/years", response_model=ReconciliationYearsOut)
|
||||
def list_reconciliation_years(
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReconciliationYearsOut:
|
||||
purge_expired_voided_reports(db)
|
||||
current_year = today().year
|
||||
years = {current_year}
|
||||
report_years = db.execute(
|
||||
select(extract("year", ProductionReport.report_date))
|
||||
.where(
|
||||
ProductionReport.status == ReportStatus.approved,
|
||||
ProductionReport.is_voided.is_(False),
|
||||
)
|
||||
.distinct()
|
||||
).all()
|
||||
years.update(int(row[0]) for row in report_years if row[0])
|
||||
ledger_years = db.execute(select(ReconciliationLedgerEntry.year).distinct()).all()
|
||||
years.update(int(row[0]) for row in ledger_years if row[0])
|
||||
return ReconciliationYearsOut(
|
||||
years=sorted(year for year in years if year <= current_year),
|
||||
current_year=current_year,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ReconciliationLedgerOut)
|
||||
def get_reconciliation_ledger(
|
||||
year: int = Query(..., ge=2000, le=2100),
|
||||
_: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReconciliationLedgerOut:
|
||||
purge_expired_voided_reports(db)
|
||||
current = today()
|
||||
if year > current.year:
|
||||
raise HTTPException(status_code=400, detail="不能查看未来年份")
|
||||
|
||||
latest_processes = _latest_processes_by_product(db)
|
||||
product_keys = sorted(latest_processes)
|
||||
reported = _reported_good_quantities(
|
||||
db,
|
||||
year=year,
|
||||
product_keys=product_keys,
|
||||
latest_processes=latest_processes,
|
||||
)
|
||||
ledger = _ledger_quantities(db, year=year)
|
||||
visible_month_count = current.month if year == current.year else 12
|
||||
|
||||
rows = [
|
||||
ReconciliationRow(
|
||||
attendance_point_name=point_name,
|
||||
product_name=product_name,
|
||||
months=[
|
||||
ReconciliationMonthCell(
|
||||
month=month,
|
||||
reported_good_qty=reported.get((point_name, product_name, month), 0),
|
||||
reconciled_good_qty=ledger.get((point_name, product_name, month), {}).get("reconciled_good_qty", 0),
|
||||
return_qty=ledger.get((point_name, product_name, month), {}).get("return_qty", 0),
|
||||
)
|
||||
for month in range(1, visible_month_count + 1)
|
||||
],
|
||||
)
|
||||
for point_name, product_name in product_keys
|
||||
]
|
||||
return ReconciliationLedgerOut(
|
||||
year=year,
|
||||
current_year=current.year,
|
||||
current_month=current.month,
|
||||
rows=rows,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/entry", response_model=ReconciliationMonthCell)
|
||||
def save_reconciliation_entry(
|
||||
payload: ReconciliationEntryUpdate,
|
||||
user: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReconciliationMonthCell:
|
||||
current = today()
|
||||
if payload.year > current.year or (payload.year == current.year and payload.month > current.month):
|
||||
raise HTTPException(status_code=400, detail="不能维护未来月份")
|
||||
product_exists = db.scalar(
|
||||
select(Product.product_name)
|
||||
.where(
|
||||
Product.attendance_point_name == payload.attendance_point_name,
|
||||
Product.product_name == payload.product_name,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if not product_exists:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
|
||||
key = {
|
||||
"attendance_point_name": payload.attendance_point_name,
|
||||
"year": payload.year,
|
||||
"month": payload.month,
|
||||
"product_name": payload.product_name,
|
||||
}
|
||||
entry = db.get(ReconciliationLedgerEntry, key)
|
||||
if entry is None:
|
||||
entry = ReconciliationLedgerEntry(**key)
|
||||
db.add(entry)
|
||||
if payload.reconciled_good_qty is not None:
|
||||
entry.reconciled_good_qty = round2(payload.reconciled_good_qty)
|
||||
if payload.return_qty is not None:
|
||||
entry.return_qty = round2(payload.return_qty)
|
||||
entry.updated_by = user.phone
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return ReconciliationMonthCell(
|
||||
month=entry.month,
|
||||
reported_good_qty=0,
|
||||
reconciled_good_qty=as_float(entry.reconciled_good_qty),
|
||||
return_qty=as_float(entry.return_qty),
|
||||
)
|
||||
@@ -0,0 +1,656 @@
|
||||
from datetime import date, datetime
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import current_user, require_roles
|
||||
from app.models import (
|
||||
Personnel,
|
||||
Equipment,
|
||||
Product,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
ReportStatus,
|
||||
Role,
|
||||
SessionStatus,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
)
|
||||
from app.schemas import (
|
||||
CleaningReportSubmitItem,
|
||||
CleaningReportSubmitRequest,
|
||||
PageResponse,
|
||||
ReportDraftBlock,
|
||||
ReportDraftItem,
|
||||
ReportDraftOut,
|
||||
ReportSubmitRequest,
|
||||
ReportOut,
|
||||
)
|
||||
from app.services.attendance_points import require_attendance_point_access, validate_attendance_point_location
|
||||
from app.services.attendance_points import accessible_point_names
|
||||
from app.services.auto_submit import auto_submit_overdue_sessions
|
||||
from app.services.cleaning import is_cleaning_item, is_cleaning_product
|
||||
from app.services.common import as_float, normalize_device_no, paginate, round2
|
||||
from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product
|
||||
from app.services.display_names import mold_process_display_name
|
||||
from app.services.metrics import calculate_report_metrics, minutes_between, release_at_caps_work_time
|
||||
from app.services.misc_work import is_misc_product
|
||||
from app.services.mold_locks import release_session_locks
|
||||
from app.services.report_lifecycle import purge_expired_voided_reports
|
||||
from app.services.reporting_window import REPORTING_EXPIRED_DETAIL, is_reporting_expired
|
||||
from app.services.serializers import equipment_option, product_option, report_out
|
||||
from app.services.work_schedule import get_work_schedule_config
|
||||
from app.timezone import now, today
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
|
||||
|
||||
CLEANING_DEVICE_LABEL = "清洗设备"
|
||||
|
||||
|
||||
def _split_device_nos(value: str | None) -> list[str]:
|
||||
return [
|
||||
normalize_device_no(part)
|
||||
for part in re.split(r"[、,,\s]+", str(value or "").strip())
|
||||
if normalize_device_no(part)
|
||||
]
|
||||
|
||||
|
||||
def _cleaning_device_nos(item: CleaningReportSubmitItem) -> list[str]:
|
||||
values: list[str] = []
|
||||
for device_no in item.device_nos or []:
|
||||
normalized = normalize_device_no(device_no)
|
||||
if normalized and normalized not in values:
|
||||
values.append(normalized)
|
||||
for device_no in _split_device_nos(item.device_no):
|
||||
if device_no not in values:
|
||||
values.append(device_no)
|
||||
return values
|
||||
|
||||
|
||||
def _report_query():
|
||||
return select(ProductionReport).options(
|
||||
selectinload(ProductionReport.items),
|
||||
selectinload(ProductionReport.audit_logs),
|
||||
selectinload(ProductionReport.employee),
|
||||
selectinload(ProductionReport.reviewer),
|
||||
selectinload(ProductionReport.voider),
|
||||
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
|
||||
)
|
||||
|
||||
|
||||
def _get_report_or_404(db: Session, report_id: int) -> ProductionReport:
|
||||
report = db.scalar(_report_query().where(ProductionReport.id == report_id))
|
||||
if report is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工记录不存在")
|
||||
return report
|
||||
|
||||
|
||||
def _ordered_session_devices(session: WorkSession):
|
||||
return sorted(
|
||||
session.devices,
|
||||
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
|
||||
)
|
||||
|
||||
|
||||
def _mold_key(product_name: str, process_name: str | None) -> tuple[str, str]:
|
||||
return (str(product_name or "").strip(), str(process_name or "").strip())
|
||||
|
||||
|
||||
def _product_for_mold(db: Session, point_name: str, mold_name: str, process_name: str | None) -> Product | None:
|
||||
return db.scalar(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name == point_name,
|
||||
Product.product_name == str(mold_name or "").strip(),
|
||||
Product.process_name == str(process_name or "").strip(),
|
||||
Product.device_no == "",
|
||||
)
|
||||
.order_by(Product.project_no.asc())
|
||||
)
|
||||
|
||||
|
||||
def _miniapp_work_order_short_code(work_order_no: str | None) -> str | None:
|
||||
normalized = str(work_order_no or "").strip()
|
||||
current_year = datetime.now().strftime("%Y")
|
||||
if len(normalized) <= 8 or not normalized.startswith(current_year) or normalized[8] != "-":
|
||||
return None
|
||||
sequence = normalized[4:8]
|
||||
if not sequence.isdigit():
|
||||
return None
|
||||
return f"JH{int(sequence):04d}"
|
||||
|
||||
|
||||
def _raw_material_batch_options(
|
||||
db: Session,
|
||||
*,
|
||||
project_no: str | None,
|
||||
product_name: str | None,
|
||||
process_name: str | None,
|
||||
) -> list[str]:
|
||||
normalized_project_no = str(project_no or "").strip()
|
||||
normalized_product_name = str(product_name or "").strip()
|
||||
normalized_process_name = str(process_name or "").strip()
|
||||
if not normalized_project_no and not normalized_product_name:
|
||||
return []
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
select distinct ledger.material_lot_no
|
||||
from pp_production_batch_ledger ledger
|
||||
join md_item product on product.id = ledger.product_item_id
|
||||
where ledger.material_lot_no is not null
|
||||
and trim(ledger.material_lot_no) <> ''
|
||||
and coalesce(ledger.miniapp_selectable, 1) = 1
|
||||
and trim(coalesce(ledger.status, '')) = '在生产'
|
||||
and (
|
||||
(:product_name <> '' and :project_no <> '' and product.item_name = :product_name and product.item_code = :project_no)
|
||||
or (:product_name <> '' and :project_no = '' and product.item_name = :product_name)
|
||||
or (:product_name = '' and :project_no <> '' and product.item_code = :project_no)
|
||||
)
|
||||
order by ledger.material_lot_no asc
|
||||
"""
|
||||
),
|
||||
{
|
||||
"project_no": normalized_project_no,
|
||||
"product_name": normalized_product_name,
|
||||
"process_name": normalized_process_name,
|
||||
},
|
||||
).all()
|
||||
options: list[str] = []
|
||||
for row in rows:
|
||||
lot_no = str(row[0] or "").strip()
|
||||
if lot_no and lot_no not in options:
|
||||
options.append(lot_no)
|
||||
return options
|
||||
|
||||
|
||||
def _cleaning_metrics(items: list[ProductionReportItem]) -> dict[str, float | str]:
|
||||
total_good_qty = sum(as_float(item.good_qty) for item in items)
|
||||
total_output_qty = sum(
|
||||
as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty)
|
||||
for item in items
|
||||
)
|
||||
return {
|
||||
"duration_minutes": 0,
|
||||
"effective_minutes": 0,
|
||||
"shift_day_minutes": 0,
|
||||
"shift_overtime_minutes": 0,
|
||||
"shift_night_minutes": 0,
|
||||
"shift_other_minutes": 0,
|
||||
"shift_meal_break_minutes": 0,
|
||||
"shift_distribution_text": "清洗报工不计工时",
|
||||
"total_good_qty": round2(total_good_qty),
|
||||
"total_output_qty": round2(total_output_qty),
|
||||
"actual_beat": 0,
|
||||
"standard_beat": 0,
|
||||
"expected_workload": 0,
|
||||
"pace_rate": 0,
|
||||
"workload_rate": 0,
|
||||
}
|
||||
|
||||
|
||||
def _draft_mold_ranges(session: WorkSession) -> list[tuple[str, str, datetime, datetime, float]]:
|
||||
if session.end_at is None:
|
||||
return []
|
||||
|
||||
ordered_devices = _ordered_session_devices(session)
|
||||
ranges_by_mold: dict[tuple[str, str], dict[str, datetime | float]] = {}
|
||||
for index, device in enumerate(ordered_devices):
|
||||
segment_start = session.start_at if index == 0 else max(session.start_at, device.scanned_at)
|
||||
segment_end = session.end_at
|
||||
if index + 1 < len(ordered_devices):
|
||||
segment_end = min(session.end_at, ordered_devices[index + 1].scanned_at)
|
||||
if release_at_caps_work_time(device) and device.released_at is not None:
|
||||
segment_end = min(segment_end, device.released_at)
|
||||
if segment_end < segment_start:
|
||||
segment_end = segment_start
|
||||
|
||||
mold_key = _mold_key(device.device_no, device.process_name)
|
||||
if mold_key not in ranges_by_mold:
|
||||
ranges_by_mold[mold_key] = {
|
||||
"start_at": segment_start,
|
||||
"end_at": segment_end,
|
||||
"duration_minutes": minutes_between(segment_start, segment_end),
|
||||
}
|
||||
continue
|
||||
|
||||
ranges_by_mold[mold_key]["start_at"] = min(ranges_by_mold[mold_key]["start_at"], segment_start)
|
||||
ranges_by_mold[mold_key]["end_at"] = max(ranges_by_mold[mold_key]["end_at"], segment_end)
|
||||
ranges_by_mold[mold_key]["duration_minutes"] = as_float(ranges_by_mold[mold_key]["duration_minutes"]) + minutes_between(segment_start, segment_end)
|
||||
|
||||
return [
|
||||
(mold_name, process_name, value["start_at"], value["end_at"], round2(value["duration_minutes"]))
|
||||
for (mold_name, process_name), value in ranges_by_mold.items()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/draft", response_model=ReportDraftOut)
|
||||
def get_report_draft(
|
||||
session_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportDraftOut:
|
||||
session = db.scalar(
|
||||
select(WorkSession)
|
||||
.options(selectinload(WorkSession.devices), selectinload(WorkSession.employee))
|
||||
.where(WorkSession.id == session_id)
|
||||
)
|
||||
if session is None or session.employee_phone != user.phone:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工流程不存在")
|
||||
if session.status != SessionStatus.reporting or session.end_at is None:
|
||||
raise HTTPException(status_code=400, detail="该流程还不能提交报工")
|
||||
if is_reporting_expired(session):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=REPORTING_EXPIRED_DETAIL)
|
||||
|
||||
blocks: list[ReportDraftBlock] = []
|
||||
equipment_options = [
|
||||
equipment_option(equipment)
|
||||
for equipment in db.scalars(
|
||||
select(Equipment)
|
||||
.where(
|
||||
Equipment.attendance_point_name == session.attendance_point_name,
|
||||
Equipment.device_type == "冲压设备",
|
||||
)
|
||||
.order_by(Equipment.device_no.asc())
|
||||
).all()
|
||||
]
|
||||
for mold_name, process_name, block_start_at, block_end_at, block_duration_minutes in _draft_mold_ranges(session):
|
||||
product_query = select(Product).where(
|
||||
Product.attendance_point_name == session.attendance_point_name,
|
||||
Product.product_name == mold_name,
|
||||
)
|
||||
if process_name:
|
||||
product_query = product_query.where(Product.process_name == process_name)
|
||||
products = db.scalars(product_query.order_by(Product.project_no, Product.process_name)).all()
|
||||
options = [product_option(product) for product in products]
|
||||
first = options[0] if options else None
|
||||
block_is_misc = any(option.is_misc for option in options)
|
||||
block_is_continuous_die = any(option.is_continuous_die for option in options)
|
||||
raw_material_batch_options = (
|
||||
_raw_material_batch_options(
|
||||
db,
|
||||
project_no=first.project_no,
|
||||
product_name=first.product_name,
|
||||
process_name=first.process_name,
|
||||
)
|
||||
if first
|
||||
else []
|
||||
)
|
||||
blocks.append(
|
||||
ReportDraftBlock(
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
device_no=mold_name,
|
||||
mold_name=mold_name,
|
||||
process_name=process_name,
|
||||
stamping_method=first.stamping_method if first else None,
|
||||
display_name=mold_process_display_name(mold_name, process_name, first.stamping_method if first else None),
|
||||
start_at=block_start_at,
|
||||
end_at=block_end_at,
|
||||
duration_minutes=block_duration_minutes,
|
||||
options=options,
|
||||
equipment_options=equipment_options,
|
||||
is_misc=block_is_misc,
|
||||
is_continuous_die=block_is_continuous_die,
|
||||
items=[
|
||||
ReportDraftItem(
|
||||
device_no=mold_name if block_is_misc else "",
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
project_no=first.project_no if first else "",
|
||||
product_name=first.product_name if first else "",
|
||||
material_code=first.material_code if first else None,
|
||||
material_name=first.material_name if first else None,
|
||||
raw_material_batch_no=None,
|
||||
raw_material_batch_options=raw_material_batch_options,
|
||||
process_name=first.process_name if first else None,
|
||||
stamping_method=first.stamping_method if first else None,
|
||||
operator_count=first.operator_count if first else 1,
|
||||
process_unit_price_yuan=first.process_unit_price_yuan if first else 0,
|
||||
changeover_count=0,
|
||||
standard_beat=first.standard_beat if first else 0,
|
||||
standard_workload=first.standard_workload if first else 0,
|
||||
good_qty=0,
|
||||
defect_qty=0,
|
||||
is_misc=block_is_misc,
|
||||
is_continuous_die=first.is_continuous_die if first else False,
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
return ReportDraftOut(
|
||||
session_id=session.id,
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
employee_phone=session.employee_phone,
|
||||
employee_name=session.employee.name,
|
||||
start_at=session.start_at,
|
||||
end_at=session.end_at,
|
||||
duration_minutes=round2(minutes_between(session.start_at, session.end_at)),
|
||||
device_blocks=blocks,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ReportOut)
|
||||
def submit_report(
|
||||
payload: ReportSubmitRequest,
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
session = db.scalar(
|
||||
select(WorkSession)
|
||||
.options(selectinload(WorkSession.devices))
|
||||
.where(WorkSession.id == payload.session_id)
|
||||
)
|
||||
if session is None or session.employee_phone != user.phone:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工流程不存在")
|
||||
if session.status != SessionStatus.reporting or session.end_at is None:
|
||||
raise HTTPException(status_code=400, detail="该流程还不能提交报工")
|
||||
if is_reporting_expired(session):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=REPORTING_EXPIRED_DETAIL)
|
||||
|
||||
valid_molds = {_mold_key(device.device_no, device.process_name) for device in session.devices}
|
||||
valid_equipment = {
|
||||
equipment.device_no
|
||||
for equipment in db.scalars(select(Equipment).where(Equipment.device_type == "冲压设备")).all()
|
||||
if equipment.attendance_point_name == session.attendance_point_name
|
||||
}
|
||||
items: list[ProductionReportItem] = []
|
||||
for block in payload.device_blocks:
|
||||
mold_name = str(block.device_no or "").strip()
|
||||
process_name = str(block.process_name or "").strip()
|
||||
if _mold_key(mold_name, process_name) not in valid_molds:
|
||||
product = _product_for_mold(db, session.attendance_point_name, mold_name, process_name)
|
||||
mold_text = mold_process_display_name(mold_name, process_name, product.stamping_method if product else None)
|
||||
raise HTTPException(status_code=400, detail=f"模具 {mold_text} 不在本次流程中")
|
||||
for item in block.items:
|
||||
product = db.scalar(
|
||||
select(Product).where(
|
||||
Product.attendance_point_name == session.attendance_point_name,
|
||||
Product.project_no == item.project_no,
|
||||
Product.product_name == item.product_name,
|
||||
Product.process_name == (item.process_name or ""),
|
||||
Product.device_no == "",
|
||||
)
|
||||
)
|
||||
item_is_misc = is_misc_product(product) or item.is_misc
|
||||
if not item_is_misc and item.good_qty + item.defect_qty + item.scrap_qty <= 0:
|
||||
continue
|
||||
device_no = normalize_device_no(item.device_no)
|
||||
if item_is_misc:
|
||||
device_no = mold_name
|
||||
elif not device_no:
|
||||
raise HTTPException(status_code=400, detail=f"{mold_name} 请选择设备号")
|
||||
if not item_is_misc and valid_equipment and device_no not in valid_equipment:
|
||||
raise HTTPException(status_code=400, detail=f"设备 {device_no} 不在设备清单中")
|
||||
stamping_method = product.stamping_method if product else item.stamping_method
|
||||
item_is_continuous_die = is_continuous_die_product(product) or is_continuous_die_item(item)
|
||||
changeover_count = as_float(item.changeover_count)
|
||||
if not item_is_continuous_die:
|
||||
changeover_count = 0
|
||||
if changeover_count < 0:
|
||||
raise HTTPException(status_code=400, detail="换料次数不能为负数")
|
||||
items.append(
|
||||
ProductionReportItem(
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
device_no=device_no,
|
||||
project_no=product.project_no if product else item.project_no,
|
||||
product_name=product.product_name if product else item.product_name,
|
||||
material_code=product.material_code if product else item.material_code,
|
||||
material_name=product.material_name if product else item.material_name,
|
||||
raw_material_batch_no=item.raw_material_batch_no,
|
||||
process_name=product.process_name if product else item.process_name,
|
||||
stamping_method=stamping_method,
|
||||
operator_count=product.operator_count if product else item.operator_count,
|
||||
process_unit_price_yuan=product.process_unit_price_yuan if product else item.process_unit_price_yuan,
|
||||
changeover_count=changeover_count,
|
||||
standard_beat=0 if item_is_misc else item.standard_beat,
|
||||
standard_workload=0,
|
||||
good_qty=0 if item_is_misc else item.good_qty,
|
||||
defect_qty=0 if item_is_misc else item.defect_qty,
|
||||
scrap_qty=0 if item_is_misc else item.scrap_qty,
|
||||
started_at=item.started_at,
|
||||
remark=item.remark,
|
||||
)
|
||||
)
|
||||
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="请至少填写一项产出数量")
|
||||
|
||||
schedule = get_work_schedule_config(db, session.attendance_point_name)
|
||||
metrics = (
|
||||
_cleaning_metrics(items)
|
||||
if all(is_cleaning_item(item) for item in items)
|
||||
else calculate_report_metrics(
|
||||
session.start_at,
|
||||
session.end_at,
|
||||
items,
|
||||
devices=session.devices,
|
||||
schedule=schedule,
|
||||
)
|
||||
)
|
||||
submitted_at = now()
|
||||
report = ProductionReport(
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
session_id=session.id,
|
||||
employee_phone=user.phone,
|
||||
report_date=today(),
|
||||
start_at=session.start_at,
|
||||
end_at=session.end_at,
|
||||
duration_minutes=metrics["duration_minutes"],
|
||||
break_minutes=0,
|
||||
effective_minutes=metrics["effective_minutes"],
|
||||
total_good_qty=metrics["total_good_qty"],
|
||||
total_output_qty=metrics["total_output_qty"],
|
||||
actual_beat=metrics["actual_beat"],
|
||||
standard_beat=metrics["standard_beat"],
|
||||
expected_workload=metrics["expected_workload"],
|
||||
pace_rate=metrics["pace_rate"],
|
||||
workload_rate=metrics["workload_rate"],
|
||||
status=ReportStatus.pending,
|
||||
submitted_at=submitted_at,
|
||||
items=items,
|
||||
)
|
||||
release_session_locks(session, user.phone, "报工提交释放占用", submitted_at)
|
||||
session.status = SessionStatus.submitted
|
||||
db.add(report)
|
||||
db.commit()
|
||||
|
||||
saved = _get_report_or_404(db, report.id)
|
||||
return report_out(saved, schedule)
|
||||
|
||||
|
||||
@router.post("/cleaning", response_model=ReportOut)
|
||||
def submit_cleaning_report(
|
||||
payload: CleaningReportSubmitRequest,
|
||||
user: Personnel = Depends(require_roles(Role.worker)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
if not payload.items:
|
||||
raise HTTPException(status_code=400, detail="请至少添加一个清洗产品")
|
||||
requested_point = next((str(item.attendance_point_name or "").strip() for item in payload.items if item.attendance_point_name), "")
|
||||
try:
|
||||
point_name = require_attendance_point_access(db, user, requested_point)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
try:
|
||||
validate_attendance_point_location(db, point_name, payload.latitude, payload.longitude, action_name="清洗报工")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
|
||||
valid_cleaning_equipment = {
|
||||
normalize_device_no(equipment.device_no)
|
||||
for equipment in db.scalars(
|
||||
select(Equipment).where(
|
||||
Equipment.attendance_point_name == point_name,
|
||||
Equipment.device_type == "清洗设备",
|
||||
)
|
||||
).all()
|
||||
}
|
||||
grouped: dict[tuple[str, str, str, str], float] = {}
|
||||
products_by_key: dict[tuple[str, str, str, str], Product] = {}
|
||||
for item in payload.items:
|
||||
item_point_name = str(item.attendance_point_name or point_name).strip()
|
||||
if item_point_name != point_name:
|
||||
raise HTTPException(status_code=400, detail="清洗报工只能提交同一考勤点的产品")
|
||||
device_nos = _cleaning_device_nos(item)
|
||||
if not device_nos:
|
||||
raise HTTPException(status_code=400, detail="请选择清洗设备号")
|
||||
invalid_device_nos = [device_no for device_no in device_nos if device_no not in valid_cleaning_equipment]
|
||||
if invalid_device_nos:
|
||||
raise HTTPException(status_code=400, detail=f"设备 {'、'.join(invalid_device_nos)} 不是清洗设备")
|
||||
product_name = str(item.product_name or "").strip()
|
||||
process_name = str(item.process_name or "").strip()
|
||||
product = db.scalar(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name == point_name,
|
||||
Product.product_name == product_name,
|
||||
Product.process_name == process_name,
|
||||
Product.device_no == "",
|
||||
)
|
||||
.order_by(Product.project_no.asc())
|
||||
)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=400, detail=f"{mold_process_display_name(product_name, process_name, item.stamping_method)} 不在产品清单中")
|
||||
if not is_cleaning_product(product):
|
||||
raise HTTPException(status_code=400, detail=f"{mold_process_display_name(product_name, process_name, product.stamping_method)} 的冲压方式不是清洗")
|
||||
device_no_text = "、".join(device_nos)
|
||||
key = (device_no_text, product.project_no, product.product_name, product.process_name)
|
||||
products_by_key[key] = product
|
||||
grouped[key] = (
|
||||
grouped.get(key, 0.0) + as_float(item.quantity)
|
||||
)
|
||||
|
||||
report_items: list[ProductionReportItem] = []
|
||||
for key, quantity in grouped.items():
|
||||
if quantity <= 0:
|
||||
continue
|
||||
device_no, _project_no, _product_name, _process_name = key
|
||||
product = products_by_key[key]
|
||||
report_items.append(
|
||||
ProductionReportItem(
|
||||
attendance_point_name=point_name,
|
||||
device_no=device_no,
|
||||
project_no=product.project_no,
|
||||
product_name=product.product_name,
|
||||
material_code=product.material_code,
|
||||
material_name=product.material_name,
|
||||
raw_material_batch_no=None,
|
||||
process_name=product.process_name,
|
||||
stamping_method=product.stamping_method,
|
||||
operator_count=product.operator_count,
|
||||
process_unit_price_yuan=product.process_unit_price_yuan,
|
||||
changeover_count=0,
|
||||
standard_beat=0,
|
||||
standard_workload=0,
|
||||
good_qty=quantity,
|
||||
defect_qty=0,
|
||||
scrap_qty=0,
|
||||
allocated_minutes=0,
|
||||
)
|
||||
)
|
||||
|
||||
if not report_items:
|
||||
raise HTTPException(status_code=400, detail="请填写清洗数量")
|
||||
|
||||
timestamp = now()
|
||||
session = WorkSession(
|
||||
attendance_point_name=point_name,
|
||||
employee_phone=user.phone,
|
||||
start_at=timestamp,
|
||||
end_at=timestamp,
|
||||
status=SessionStatus.submitted,
|
||||
)
|
||||
for index, item in enumerate(report_items):
|
||||
session.devices.append(
|
||||
WorkSessionDevice(
|
||||
attendance_point_name=point_name,
|
||||
device_no=item.product_name,
|
||||
process_name=item.process_name or "",
|
||||
scanned_at=timestamp,
|
||||
sort_order=index,
|
||||
)
|
||||
)
|
||||
|
||||
metrics = _cleaning_metrics(report_items)
|
||||
report = ProductionReport(
|
||||
attendance_point_name=point_name,
|
||||
session=session,
|
||||
employee_phone=user.phone,
|
||||
report_date=today(),
|
||||
start_at=timestamp,
|
||||
end_at=timestamp,
|
||||
duration_minutes=0,
|
||||
break_minutes=0,
|
||||
effective_minutes=0,
|
||||
total_good_qty=metrics["total_good_qty"],
|
||||
total_output_qty=metrics["total_output_qty"],
|
||||
actual_beat=0,
|
||||
standard_beat=0,
|
||||
expected_workload=0,
|
||||
pace_rate=0,
|
||||
workload_rate=0,
|
||||
status=ReportStatus.pending,
|
||||
submitted_at=timestamp,
|
||||
items=report_items,
|
||||
)
|
||||
db.add(report)
|
||||
db.commit()
|
||||
|
||||
saved = _get_report_or_404(db, report.id)
|
||||
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
|
||||
|
||||
|
||||
@router.get("/mine", response_model=PageResponse)
|
||||
def list_my_reports(
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
status_filter: ReportStatus | None = Query(None, alias="status"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
include_voided: bool = False,
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
purge_expired_voided_reports(db)
|
||||
if user.role == Role.worker:
|
||||
auto_submit_overdue_sessions(db, employee_phone=user.phone)
|
||||
query = _report_query().where(ProductionReport.employee_phone == user.phone)
|
||||
if not include_voided:
|
||||
query = query.where(ProductionReport.is_voided.is_(False))
|
||||
if start_date:
|
||||
query = query.where(ProductionReport.report_date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ProductionReport.report_date <= end_date)
|
||||
if status_filter:
|
||||
query = query.where(ProductionReport.status == status_filter)
|
||||
query = query.order_by(ProductionReport.report_date.desc(), ProductionReport.submitted_at.desc())
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{
|
||||
**result,
|
||||
"rows": [report_out(row, get_work_schedule_config(db, row.attendance_point_name)) for row in result["rows"]],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{report_id}", response_model=ReportOut)
|
||||
def get_report(
|
||||
report_id: int,
|
||||
user: Personnel = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
purge_expired_voided_reports(db)
|
||||
if user.role == Role.worker:
|
||||
auto_submit_overdue_sessions(db, employee_phone=user.phone)
|
||||
report = _get_report_or_404(db, report_id)
|
||||
if user.role == Role.worker and report.employee_phone != user.phone:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||
if user.role == Role.admin and report.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点权限")
|
||||
return report_out(report, get_work_schedule_config(db, report.attendance_point_name))
|
||||
@@ -0,0 +1,618 @@
|
||||
from datetime import date, datetime
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import (
|
||||
AuditAction,
|
||||
Equipment,
|
||||
Personnel,
|
||||
Product,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
ReportAuditLog,
|
||||
ReportStatus,
|
||||
Role,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
)
|
||||
from app.schemas import ApproveReportRequest, PageResponse, RejectReportRequest, ReportOut
|
||||
from app.services.attendance_points import accessible_point_names
|
||||
from app.services.auto_submit import auto_submit_overdue_sessions
|
||||
from app.services.cleaning import is_cleaning_item, is_cleaning_product
|
||||
from app.services.common import as_float, normalize_device_no, paginate
|
||||
from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product
|
||||
from app.services.display_names import mold_process_display_name
|
||||
from app.services.metrics import calculate_report_metrics
|
||||
from app.services.misc_work import is_misc_item, is_misc_product
|
||||
from app.services.report_lifecycle import (
|
||||
can_edit_report,
|
||||
mark_report_voided,
|
||||
purge_expired_voided_reports,
|
||||
restore_voided_report,
|
||||
)
|
||||
from app.services.serializers import report_out
|
||||
from app.services.work_schedule import WorkScheduleConfig, get_work_schedule_config
|
||||
from app.timezone import now
|
||||
|
||||
router = APIRouter(prefix="/api/reviews", tags=["reviews"])
|
||||
|
||||
|
||||
def _minute_picker_noise(left: datetime | None, right: datetime | None) -> bool:
|
||||
if left is None or right is None:
|
||||
return left is right
|
||||
return left.replace(second=0, microsecond=0) == right.replace(second=0, microsecond=0)
|
||||
|
||||
|
||||
def _effective_review_time(payload_value: datetime | None, current_value: datetime) -> datetime:
|
||||
if payload_value is None:
|
||||
return current_value
|
||||
return current_value if _minute_picker_noise(payload_value, current_value) else payload_value
|
||||
|
||||
|
||||
def _split_device_nos(value: str | None) -> list[str]:
|
||||
values: list[str] = []
|
||||
for part in re.split(r"[、,,\s]+", str(value or "").strip()):
|
||||
normalized = normalize_device_no(part)
|
||||
if normalized and normalized not in values:
|
||||
values.append(normalized)
|
||||
return values
|
||||
|
||||
|
||||
def _validate_cleaning_report_devices(db: Session, report: ProductionReport) -> None:
|
||||
valid_cleaning_equipment = {
|
||||
normalize_device_no(equipment.device_no)
|
||||
for equipment in db.scalars(
|
||||
select(Equipment).where(
|
||||
Equipment.attendance_point_name == report.attendance_point_name,
|
||||
Equipment.device_type == "清洗设备",
|
||||
)
|
||||
).all()
|
||||
}
|
||||
if not valid_cleaning_equipment:
|
||||
raise HTTPException(status_code=400, detail="该考勤点暂无清洗设备,请先在设备管理中维护")
|
||||
for item in report.items:
|
||||
device_nos = _split_device_nos(item.device_no)
|
||||
if not device_nos:
|
||||
raise HTTPException(status_code=400, detail="清洗报工请选择清洗设备号")
|
||||
invalid_device_nos = [device_no for device_no in device_nos if device_no not in valid_cleaning_equipment]
|
||||
if invalid_device_nos:
|
||||
raise HTTPException(status_code=400, detail=f"设备 {'、'.join(invalid_device_nos)} 不是该考勤点的清洗设备")
|
||||
|
||||
|
||||
def _validate_normal_report_devices(db: Session, report: ProductionReport) -> None:
|
||||
normal_items = [
|
||||
item
|
||||
for item in report.items
|
||||
if not is_cleaning_item(item) and not is_misc_item(item)
|
||||
]
|
||||
if not normal_items:
|
||||
return
|
||||
valid_press_equipment = {
|
||||
normalize_device_no(equipment.device_no)
|
||||
for equipment in db.scalars(
|
||||
select(Equipment).where(
|
||||
Equipment.attendance_point_name == report.attendance_point_name,
|
||||
Equipment.device_type == "冲压设备",
|
||||
)
|
||||
).all()
|
||||
}
|
||||
if not valid_press_equipment:
|
||||
raise HTTPException(status_code=400, detail="该考勤点暂无冲压设备,请先在设备管理中维护")
|
||||
for item in normal_items:
|
||||
device_no = normalize_device_no(item.device_no)
|
||||
if not device_no:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{mold_process_display_name(item.product_name, item.process_name, item.stamping_method)} 请填写设备号",
|
||||
)
|
||||
if device_no not in valid_press_equipment:
|
||||
raise HTTPException(status_code=400, detail=f"设备 {device_no} 不是该考勤点的冲压设备")
|
||||
|
||||
|
||||
def _report_query():
|
||||
return select(ProductionReport).options(
|
||||
selectinload(ProductionReport.items),
|
||||
selectinload(ProductionReport.audit_logs),
|
||||
selectinload(ProductionReport.employee),
|
||||
selectinload(ProductionReport.reviewer),
|
||||
selectinload(ProductionReport.voider),
|
||||
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
|
||||
)
|
||||
|
||||
|
||||
def _get_report_or_404(db: Session, report_id: int) -> ProductionReport:
|
||||
report = db.scalar(_report_query().where(ProductionReport.id == report_id))
|
||||
if report is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工记录不存在")
|
||||
return report
|
||||
|
||||
|
||||
def _snapshot(report: ProductionReport) -> dict:
|
||||
return {
|
||||
"id": report.id,
|
||||
"status": str(report.status),
|
||||
"start_at": report.start_at.isoformat(),
|
||||
"end_at": report.end_at.isoformat(),
|
||||
"break_minutes": float(report.break_minutes or 0),
|
||||
"effective_minutes": float(report.effective_minutes or 0),
|
||||
"total_good_qty": float(report.total_good_qty or 0),
|
||||
"actual_beat": float(report.actual_beat or 0),
|
||||
"review_remark": report.review_remark,
|
||||
"is_multi_person_assistant": bool(report.is_multi_person_assistant),
|
||||
"multi_person_source_report_id": report.multi_person_source_report_id,
|
||||
"devices": [
|
||||
{
|
||||
"id": device.id,
|
||||
"device_no": device.device_no,
|
||||
"process_name": device.process_name,
|
||||
"scanned_at": device.scanned_at.isoformat(),
|
||||
"released_at": device.released_at.isoformat() if device.released_at else None,
|
||||
"sort_order": int(device.sort_order or 0),
|
||||
}
|
||||
for device in _ordered_session_devices(report.session)
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"device_no": item.device_no,
|
||||
"project_no": item.project_no,
|
||||
"product_name": item.product_name,
|
||||
"raw_material_batch_no": item.raw_material_batch_no,
|
||||
"process_name": item.process_name,
|
||||
"stamping_method": item.stamping_method,
|
||||
"operator_count": float(item.operator_count or 1),
|
||||
"process_unit_price_yuan": float(item.process_unit_price_yuan or 0),
|
||||
"changeover_count": float(item.changeover_count or 0),
|
||||
"remark": item.remark,
|
||||
"good_qty": float(item.good_qty or 0),
|
||||
"defect_qty": float(item.defect_qty or 0),
|
||||
"scrap_qty": float(item.scrap_qty or 0),
|
||||
}
|
||||
for item in report.items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _ordered_session_devices(session: WorkSession | None) -> list[WorkSessionDevice]:
|
||||
return sorted(
|
||||
getattr(session, "devices", []) or [],
|
||||
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
|
||||
)
|
||||
|
||||
|
||||
def _device_display_name(db: Session, report: ProductionReport, device: WorkSessionDevice) -> str:
|
||||
with db.no_autoflush:
|
||||
product = db.scalar(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name == report.attendance_point_name,
|
||||
Product.product_name == device.device_no,
|
||||
Product.process_name == (device.process_name or ""),
|
||||
Product.device_no == "",
|
||||
)
|
||||
.order_by(Product.project_no.asc())
|
||||
)
|
||||
return mold_process_display_name(
|
||||
device.device_no,
|
||||
device.process_name,
|
||||
product.stamping_method if product else None,
|
||||
)
|
||||
|
||||
|
||||
def _apply_device_corrections(
|
||||
db: Session,
|
||||
report: ProductionReport,
|
||||
payload: ApproveReportRequest,
|
||||
next_start_at: datetime,
|
||||
next_end_at: datetime,
|
||||
) -> tuple[bool, datetime]:
|
||||
if not payload.device_corrections and report.session is None:
|
||||
return False, next_start_at
|
||||
if report.session is None:
|
||||
raise HTTPException(status_code=400, detail="该报工缺少扫码模具记录,不能校正换模具时间")
|
||||
ordered_devices = _ordered_session_devices(report.session)
|
||||
if not ordered_devices:
|
||||
return False, next_start_at
|
||||
|
||||
first_device = ordered_devices[0]
|
||||
corrected_start_at = next_start_at
|
||||
pending_scanned_at: dict[int, datetime] = {}
|
||||
has_correction = False
|
||||
|
||||
if not payload.device_corrections:
|
||||
pending_scanned_at[first_device.id] = corrected_start_at
|
||||
else:
|
||||
device_map = {device.id: device for device in ordered_devices}
|
||||
seen_ids: set[int] = set()
|
||||
for correction in payload.device_corrections:
|
||||
if correction.id in seen_ids:
|
||||
raise HTTPException(status_code=400, detail="同一换模具时间不能重复校正")
|
||||
seen_ids.add(correction.id)
|
||||
device = device_map.get(correction.id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=400, detail=f"换模具记录 {correction.id} 不存在")
|
||||
next_scanned_at = _effective_review_time(correction.scanned_at, device.scanned_at)
|
||||
if device.id == first_device.id:
|
||||
corrected_start_at = next_scanned_at
|
||||
elif next_scanned_at < corrected_start_at or next_scanned_at > next_end_at:
|
||||
raise HTTPException(status_code=400, detail="换模具时间必须在上下班时间范围内")
|
||||
pending_scanned_at[device.id] = next_scanned_at
|
||||
|
||||
pending_scanned_at.setdefault(first_device.id, corrected_start_at)
|
||||
|
||||
if corrected_start_at >= next_end_at:
|
||||
raise HTTPException(status_code=400, detail="下班时间必须晚于上班时间")
|
||||
|
||||
for device_id, next_scanned_at in pending_scanned_at.items():
|
||||
if next_scanned_at > next_end_at:
|
||||
raise HTTPException(status_code=400, detail="换模具时间必须在上下班时间范围内")
|
||||
device = next(device for device in ordered_devices if device.id == device_id)
|
||||
if next_scanned_at == device.scanned_at:
|
||||
continue
|
||||
device.scanned_at = next_scanned_at
|
||||
has_correction = True
|
||||
|
||||
ordered_devices = _ordered_session_devices(report.session)
|
||||
for index in range(1, len(ordered_devices)):
|
||||
previous = ordered_devices[index - 1]
|
||||
current = ordered_devices[index]
|
||||
if current.scanned_at <= previous.scanned_at:
|
||||
previous_name = _device_display_name(db, report, previous)
|
||||
current_name = _device_display_name(db, report, current)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"换模具时间顺序不正确:{current_name} 必须晚于 {previous_name}",
|
||||
)
|
||||
return has_correction, corrected_start_at
|
||||
|
||||
|
||||
def _is_cleaning_report(report: ProductionReport) -> bool:
|
||||
return bool(report.items) and all(is_cleaning_item(item) for item in report.items)
|
||||
|
||||
|
||||
def _apply_metrics(report: ProductionReport, schedule: WorkScheduleConfig | None = None) -> None:
|
||||
if _is_cleaning_report(report):
|
||||
total_good_qty = sum(as_float(item.good_qty) for item in report.items)
|
||||
total_output_qty = sum(
|
||||
as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty)
|
||||
for item in report.items
|
||||
)
|
||||
report.duration_minutes = 0
|
||||
report.effective_minutes = 0
|
||||
report.total_good_qty = total_good_qty
|
||||
report.total_output_qty = total_output_qty
|
||||
report.actual_beat = 0
|
||||
report.standard_beat = 0
|
||||
report.expected_workload = 0
|
||||
report.pace_rate = 0
|
||||
report.workload_rate = 0
|
||||
for item in report.items:
|
||||
item.allocated_minutes = 0
|
||||
return
|
||||
|
||||
metrics = calculate_report_metrics(
|
||||
report.start_at,
|
||||
report.end_at,
|
||||
report.items,
|
||||
devices=report.session.devices if report.session else None,
|
||||
schedule=schedule,
|
||||
)
|
||||
report.duration_minutes = metrics["duration_minutes"]
|
||||
report.effective_minutes = metrics["effective_minutes"]
|
||||
report.total_good_qty = metrics["total_good_qty"]
|
||||
report.total_output_qty = metrics["total_output_qty"]
|
||||
report.actual_beat = metrics["actual_beat"]
|
||||
report.standard_beat = metrics["standard_beat"]
|
||||
report.expected_workload = metrics["expected_workload"]
|
||||
report.pace_rate = metrics["pace_rate"]
|
||||
report.workload_rate = metrics["workload_rate"]
|
||||
|
||||
|
||||
@router.get("/pending", response_model=PageResponse)
|
||||
def list_pending_reports(
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
include_voided: bool = False,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
purge_expired_voided_reports(db)
|
||||
auto_submit_overdue_sessions(db)
|
||||
query = (
|
||||
_report_query()
|
||||
.where(
|
||||
ProductionReport.status == ReportStatus.pending,
|
||||
ProductionReport.attendance_point_name.in_(accessible_point_names(db, user)),
|
||||
)
|
||||
)
|
||||
if not include_voided:
|
||||
query = query.where(ProductionReport.is_voided.is_(False))
|
||||
if start_date:
|
||||
query = query.where(ProductionReport.report_date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ProductionReport.report_date <= end_date)
|
||||
query = query.order_by(ProductionReport.submitted_at.asc())
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{
|
||||
**result,
|
||||
"rows": [report_out(row, get_work_schedule_config(db, row.attendance_point_name)) for row in result["rows"]],
|
||||
})
|
||||
|
||||
|
||||
@router.get("/mine", response_model=PageResponse)
|
||||
def list_my_review_records(
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
status_filter: ReportStatus | None = Query(None, alias="status"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
include_voided: bool = False,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
purge_expired_voided_reports(db)
|
||||
query = _report_query().where(ProductionReport.attendance_point_name.in_(accessible_point_names(db, user)))
|
||||
if not include_voided:
|
||||
query = query.where(ProductionReport.is_voided.is_(False))
|
||||
if start_date:
|
||||
query = query.where(ProductionReport.report_date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ProductionReport.report_date <= end_date)
|
||||
if status_filter:
|
||||
query = query.where(ProductionReport.status == status_filter)
|
||||
else:
|
||||
query = query.where(ProductionReport.status != ReportStatus.pending)
|
||||
query = query.order_by(ProductionReport.reviewed_at.desc())
|
||||
result = paginate(db, query, page, page_size)
|
||||
return PageResponse(**{
|
||||
**result,
|
||||
"rows": [report_out(row, get_work_schedule_config(db, row.attendance_point_name)) for row in result["rows"]],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{report_id}/approve", response_model=ReportOut)
|
||||
def approve_report(
|
||||
report_id: int,
|
||||
payload: ApproveReportRequest,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
report = _get_report_or_404(db, report_id)
|
||||
if report.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点审核权限")
|
||||
if report.is_voided:
|
||||
raise HTTPException(status_code=400, detail="作废期间的报工只能查看,不能编辑")
|
||||
if not can_edit_report(report):
|
||||
raise HTTPException(status_code=400, detail="该报工已超过可编辑期限")
|
||||
before = _snapshot(report)
|
||||
schedule = get_work_schedule_config(db, report.attendance_point_name)
|
||||
item_map = {item.id: item for item in report.items}
|
||||
has_correction = False
|
||||
|
||||
next_start_at = _effective_review_time(payload.start_at, report.start_at)
|
||||
next_end_at = _effective_review_time(payload.end_at, report.end_at)
|
||||
is_cleaning = _is_cleaning_report(report)
|
||||
if is_cleaning:
|
||||
next_start_at = report.start_at
|
||||
next_end_at = report.end_at
|
||||
else:
|
||||
device_has_correction, next_start_at = _apply_device_corrections(
|
||||
db,
|
||||
report,
|
||||
payload,
|
||||
next_start_at,
|
||||
next_end_at,
|
||||
)
|
||||
if device_has_correction:
|
||||
has_correction = True
|
||||
if not is_cleaning and next_end_at <= next_start_at:
|
||||
raise HTTPException(status_code=400, detail="下班时间必须晚于上班时间")
|
||||
if next_start_at != report.start_at:
|
||||
report.start_at = next_start_at
|
||||
if report.session is not None:
|
||||
report.session.start_at = next_start_at
|
||||
has_correction = True
|
||||
if next_end_at != report.end_at:
|
||||
report.end_at = next_end_at
|
||||
if report.session is not None:
|
||||
report.session.end_at = next_end_at
|
||||
has_correction = True
|
||||
|
||||
for correction in payload.item_corrections:
|
||||
item = item_map.get(correction.id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=400, detail=f"明细 {correction.id} 不存在")
|
||||
item_is_misc = is_misc_item(item)
|
||||
|
||||
next_device_no = normalize_device_no(correction.device_no or item.device_no)
|
||||
next_project_no = correction.project_no or item.project_no
|
||||
next_product_name = correction.product_name or item.product_name
|
||||
next_process_name = correction.process_name if correction.process_name is not None else item.process_name
|
||||
next_raw_material_batch_no = (
|
||||
correction.raw_material_batch_no
|
||||
if correction.raw_material_batch_no is not None
|
||||
else item.raw_material_batch_no
|
||||
)
|
||||
if (
|
||||
next_device_no != item.device_no
|
||||
or next_project_no != item.project_no
|
||||
or next_product_name != item.product_name
|
||||
or next_process_name != item.process_name
|
||||
):
|
||||
product = db.scalar(
|
||||
select(Product).where(
|
||||
Product.attendance_point_name == report.attendance_point_name,
|
||||
Product.project_no == next_project_no,
|
||||
Product.product_name == next_product_name,
|
||||
Product.process_name == (next_process_name or ""),
|
||||
Product.device_no == "",
|
||||
)
|
||||
)
|
||||
if product is None:
|
||||
raise HTTPException(status_code=400, detail="校正后的产品、工序和冲压方式不在产品清单中")
|
||||
if is_cleaning and not is_cleaning_product(product):
|
||||
raise HTTPException(status_code=400, detail="清洗报工只能校正为冲压方式为清洗的产品")
|
||||
if not is_cleaning and is_cleaning_product(product):
|
||||
raise HTTPException(status_code=400, detail="非清洗报工不能校正为清洗产品")
|
||||
if item_is_misc and not is_misc_product(product):
|
||||
raise HTTPException(status_code=400, detail="处理杂活明细不能校正为普通产品")
|
||||
if not item_is_misc and is_misc_product(product):
|
||||
raise HTTPException(status_code=400, detail="普通报工明细不能校正为处理杂活")
|
||||
item.device_no = next_device_no
|
||||
item.project_no = product.project_no
|
||||
item.product_name = product.product_name
|
||||
item.material_code = product.material_code
|
||||
item.material_name = product.material_name
|
||||
item.process_name = product.process_name
|
||||
item.stamping_method = product.stamping_method
|
||||
item.operator_count = product.operator_count
|
||||
item.process_unit_price_yuan = product.process_unit_price_yuan
|
||||
item.changeover_count = item.changeover_count if is_continuous_die_product(product) else 0
|
||||
item.standard_beat = as_float(product.standard_beat)
|
||||
item.standard_workload = 0
|
||||
has_correction = True
|
||||
|
||||
if next_raw_material_batch_no != item.raw_material_batch_no:
|
||||
item.raw_material_batch_no = next_raw_material_batch_no
|
||||
has_correction = True
|
||||
|
||||
if correction.changeover_count is not None:
|
||||
next_changeover_count = as_float(correction.changeover_count)
|
||||
if next_changeover_count < 0:
|
||||
raise HTTPException(status_code=400, detail="换料次数不能为负数")
|
||||
if not is_continuous_die_item(item):
|
||||
next_changeover_count = 0
|
||||
if next_changeover_count != as_float(item.changeover_count):
|
||||
item.changeover_count = next_changeover_count
|
||||
has_correction = True
|
||||
|
||||
if correction.good_qty is not None and as_float(correction.good_qty) != as_float(item.good_qty):
|
||||
item.good_qty = correction.good_qty
|
||||
has_correction = True
|
||||
if correction.defect_qty is not None and as_float(correction.defect_qty) != as_float(item.defect_qty):
|
||||
item.defect_qty = correction.defect_qty
|
||||
has_correction = True
|
||||
if correction.scrap_qty is not None and as_float(correction.scrap_qty) != as_float(item.scrap_qty):
|
||||
item.scrap_qty = correction.scrap_qty
|
||||
has_correction = True
|
||||
if item_is_misc and correction.remark is not None:
|
||||
next_remark = str(correction.remark or "").strip() or None
|
||||
if next_remark != item.remark:
|
||||
item.remark = next_remark
|
||||
has_correction = True
|
||||
|
||||
if "review_remark" in payload.model_fields_set:
|
||||
next_review_remark = str(payload.review_remark or "").strip() or None
|
||||
if next_review_remark != report.review_remark:
|
||||
report.review_remark = next_review_remark
|
||||
has_correction = True
|
||||
|
||||
if report.is_system_auto_submitted and not is_cleaning:
|
||||
for item in report.items:
|
||||
if is_misc_item(item):
|
||||
continue
|
||||
if not normalize_device_no(item.device_no):
|
||||
raise HTTPException(status_code=400, detail="系统自动提交的报工请补填设备号后再审核通过")
|
||||
if as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty) <= 0:
|
||||
raise HTTPException(status_code=400, detail="系统自动提交的报工请补填产出数量后再审核通过")
|
||||
if is_cleaning:
|
||||
_validate_cleaning_report_devices(db, report)
|
||||
else:
|
||||
_validate_normal_report_devices(db, report)
|
||||
|
||||
_apply_metrics(report, schedule)
|
||||
reviewed_at = now()
|
||||
was_pending = report.status == ReportStatus.pending
|
||||
report.status = ReportStatus.approved
|
||||
report.reviewer_phone = user.phone
|
||||
if was_pending or report.reviewed_at is None:
|
||||
report.reviewed_at = reviewed_at
|
||||
report.reject_reason = None
|
||||
|
||||
after = _snapshot(report)
|
||||
db.add(
|
||||
ReportAuditLog(
|
||||
report_id=report.id,
|
||||
reviewer_phone=user.phone,
|
||||
action=AuditAction.approve_with_correction if has_correction else AuditAction.approve,
|
||||
before_json=before,
|
||||
after_json=after,
|
||||
remark=payload.review_remark if "review_remark" in payload.model_fields_set else payload.remark,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
saved = _get_report_or_404(db, report.id)
|
||||
return report_out(saved, schedule)
|
||||
|
||||
|
||||
@router.post("/{report_id}/void", response_model=ReportOut)
|
||||
def void_report(
|
||||
report_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
purge_expired_voided_reports(db)
|
||||
report = _get_report_or_404(db, report_id)
|
||||
if report.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点作废权限")
|
||||
mark_report_voided(report, user)
|
||||
db.commit()
|
||||
saved = _get_report_or_404(db, report.id)
|
||||
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
|
||||
|
||||
|
||||
@router.post("/{report_id}/unvoid", response_model=ReportOut)
|
||||
def unvoid_report(
|
||||
report_id: int,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
purge_expired_voided_reports(db)
|
||||
report = _get_report_or_404(db, report_id)
|
||||
if report.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点撤销作废权限")
|
||||
restore_voided_report(report)
|
||||
db.commit()
|
||||
saved = _get_report_or_404(db, report.id)
|
||||
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
|
||||
|
||||
|
||||
@router.post("/{report_id}/reject", response_model=ReportOut)
|
||||
def reject_report(
|
||||
report_id: int,
|
||||
payload: RejectReportRequest,
|
||||
user: Personnel = Depends(require_roles(Role.admin)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ReportOut:
|
||||
report = _get_report_or_404(db, report_id)
|
||||
if report.attendance_point_name not in accessible_point_names(db, user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点审核权限")
|
||||
if report.status != ReportStatus.pending:
|
||||
raise HTTPException(status_code=400, detail="只有待审核报工可以驳回")
|
||||
|
||||
before = _snapshot(report)
|
||||
report.status = ReportStatus.rejected
|
||||
report.reviewer_phone = user.phone
|
||||
report.reviewed_at = now()
|
||||
report.reject_reason = payload.reason
|
||||
after = _snapshot(report)
|
||||
db.add(
|
||||
ReportAuditLog(
|
||||
report_id=report.id,
|
||||
reviewer_phone=user.phone,
|
||||
action=AuditAction.reject,
|
||||
before_json=before,
|
||||
after_json=after,
|
||||
remark=payload.reason,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
saved = _get_report_or_404(db, report.id)
|
||||
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
|
||||
@@ -0,0 +1,334 @@
|
||||
from datetime import date
|
||||
from math import ceil
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import Equipment, Personnel, ProductionReport, ReportStatus, Role
|
||||
from app.schemas import (
|
||||
PageResponse,
|
||||
UsageStatsDailyRow,
|
||||
UsageStatsDetailOut,
|
||||
UsageStatsReportRow,
|
||||
UsageStatsRow,
|
||||
)
|
||||
from app.services.attendance_points import accessible_point_names, require_attendance_point_access
|
||||
from app.services.common import round2
|
||||
from app.services.report_lifecycle import purge_expired_voided_reports
|
||||
from app.services.usage_stats import UsageStatRow, build_usage_stats
|
||||
from app.services.usage_stats_export import export_usage_stats_rows
|
||||
|
||||
router = APIRouter(prefix="/api/usage-stats", tags=["usage-stats"])
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _requested_points(db: Session, user: Personnel, attendance_point_name: str | None) -> list[str]:
|
||||
requested_name = _clean(attendance_point_name)
|
||||
if requested_name:
|
||||
try:
|
||||
return [require_attendance_point_access(db, user, requested_name)]
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
|
||||
return accessible_point_names(db, user)
|
||||
|
||||
|
||||
def _equipment_type_map(db: Session, point_names: list[str]) -> dict[tuple[str, str], str]:
|
||||
if not point_names:
|
||||
return {}
|
||||
rows = db.scalars(select(Equipment).where(Equipment.attendance_point_name.in_(point_names))).all()
|
||||
return {(_clean(row.attendance_point_name), _clean(row.device_no)): row.device_type for row in rows}
|
||||
|
||||
|
||||
def _approved_reports_query(point_names: list[str], start_date: date | None, end_date: date | None):
|
||||
query = (
|
||||
select(ProductionReport)
|
||||
.options(selectinload(ProductionReport.employee), selectinload(ProductionReport.items))
|
||||
.where(
|
||||
ProductionReport.status == ReportStatus.approved,
|
||||
ProductionReport.is_voided.is_(False),
|
||||
ProductionReport.attendance_point_name.in_(point_names),
|
||||
)
|
||||
)
|
||||
if start_date:
|
||||
query = query.where(ProductionReport.report_date >= start_date)
|
||||
if end_date:
|
||||
query = query.where(ProductionReport.report_date <= end_date)
|
||||
return query.order_by(ProductionReport.report_date.desc(), ProductionReport.id.desc())
|
||||
|
||||
|
||||
def _sort_usage_rows(rows: list[UsageStatRow], sort_by: str) -> list[UsageStatRow]:
|
||||
if sort_by == "count":
|
||||
return sorted(rows, key=lambda row: (-row.report_count, -row.value, row.name))
|
||||
return sorted(rows, key=lambda row: (-row.value, -row.report_count, row.name))
|
||||
|
||||
|
||||
def _usage_row_search_text(row: UsageStatRow) -> str:
|
||||
values = [
|
||||
row.id,
|
||||
row.category,
|
||||
row.attendance_point_name,
|
||||
row.name,
|
||||
row.object_type,
|
||||
row.metric_kind,
|
||||
row.product_name,
|
||||
row.process_name,
|
||||
row.stamping_method,
|
||||
row.value,
|
||||
row.report_count,
|
||||
*row.tags,
|
||||
]
|
||||
return " ".join(str(value or "") for value in values).lower()
|
||||
|
||||
|
||||
def _filter_usage_rows(rows: list[UsageStatRow], keyword: str | None) -> list[UsageStatRow]:
|
||||
text = _clean(keyword).lower()
|
||||
if not text:
|
||||
return rows
|
||||
return [row for row in rows if text in _usage_row_search_text(row)]
|
||||
|
||||
|
||||
def _to_schema_row(row: UsageStatRow) -> UsageStatsRow:
|
||||
return UsageStatsRow(
|
||||
id=row.id,
|
||||
category=row.category,
|
||||
attendance_point_name=row.attendance_point_name,
|
||||
name=row.name,
|
||||
object_type=row.object_type,
|
||||
metric_kind=row.metric_kind,
|
||||
value=row.value,
|
||||
report_count=row.report_count,
|
||||
product_name=row.product_name,
|
||||
process_name=row.process_name,
|
||||
stamping_method=row.stamping_method,
|
||||
tags=list(row.tags),
|
||||
)
|
||||
|
||||
|
||||
def _to_detail_out(
|
||||
row: UsageStatRow,
|
||||
*,
|
||||
daily_rows: list[UsageStatsDailyRow] | None = None,
|
||||
report_rows: list[UsageStatsReportRow] | None = None,
|
||||
) -> UsageStatsDetailOut:
|
||||
return UsageStatsDetailOut(
|
||||
id=row.id,
|
||||
category=row.category,
|
||||
attendance_point_name=row.attendance_point_name,
|
||||
name=row.name,
|
||||
object_type=row.object_type,
|
||||
metric_kind=row.metric_kind,
|
||||
value=row.value,
|
||||
report_count=row.report_count,
|
||||
product_name=row.product_name,
|
||||
process_name=row.process_name,
|
||||
stamping_method=row.stamping_method,
|
||||
tags=list(row.tags),
|
||||
daily_rows=daily_rows or [],
|
||||
report_rows=report_rows or [],
|
||||
)
|
||||
|
||||
|
||||
def _matches_target(
|
||||
row: UsageStatRow,
|
||||
*,
|
||||
id: str,
|
||||
category: str,
|
||||
attendance_point_name: str,
|
||||
name: str,
|
||||
product_name: str,
|
||||
process_name: str,
|
||||
stamping_method: str,
|
||||
metric_kind: str,
|
||||
) -> bool:
|
||||
if id:
|
||||
return row.id == id
|
||||
if row.category != category:
|
||||
return False
|
||||
if attendance_point_name and row.attendance_point_name != attendance_point_name:
|
||||
return False
|
||||
if metric_kind and row.metric_kind != metric_kind:
|
||||
return False
|
||||
if category == "device":
|
||||
return row.name == name
|
||||
return (
|
||||
row.product_name == product_name
|
||||
and row.process_name == process_name
|
||||
and row.stamping_method == stamping_method
|
||||
)
|
||||
|
||||
|
||||
def _build_usage_stats_export_response(device_rows: list[UsageStatRow], mold_rows: list[UsageStatRow]) -> Response:
|
||||
content = export_usage_stats_rows(device_rows, mold_rows)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="usage-stats.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/summary", response_model=PageResponse)
|
||||
def usage_stats_summary(
|
||||
category: str = Query("device", pattern="^(device|mold)$"),
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
attendance_point_name: str = "",
|
||||
keyword: str = "",
|
||||
sort_by: str = Query("value", pattern="^(value|count)$"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PageResponse:
|
||||
purge_expired_voided_reports(db)
|
||||
point_names = _requested_points(db, user, attendance_point_name)
|
||||
reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all()
|
||||
rows = _sort_usage_rows(
|
||||
_filter_usage_rows(
|
||||
build_usage_stats(
|
||||
reports=reports,
|
||||
category=category,
|
||||
equipment_type_by_key=_equipment_type_map(db, point_names),
|
||||
),
|
||||
keyword,
|
||||
),
|
||||
sort_by,
|
||||
)
|
||||
safe_page_size = min(100, max(1, page_size))
|
||||
total = len(rows)
|
||||
total_pages = max(1, ceil(total / safe_page_size))
|
||||
start = (page - 1) * safe_page_size
|
||||
return PageResponse(
|
||||
page=page,
|
||||
page_size=safe_page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
rows=[_to_schema_row(row) for row in rows[start : start + safe_page_size]],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_usage_stats_excel(
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
attendance_point_name: str = "",
|
||||
keyword: str = "",
|
||||
sort_by: str = Query("value", pattern="^(value|count)$"),
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
purge_expired_voided_reports(db)
|
||||
point_names = _requested_points(db, user, attendance_point_name)
|
||||
equipment_type_by_key = _equipment_type_map(db, point_names)
|
||||
reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all()
|
||||
device_rows = _sort_usage_rows(
|
||||
_filter_usage_rows(
|
||||
build_usage_stats(
|
||||
reports=reports,
|
||||
category="device",
|
||||
equipment_type_by_key=equipment_type_by_key,
|
||||
),
|
||||
keyword,
|
||||
),
|
||||
sort_by,
|
||||
)
|
||||
mold_rows = _sort_usage_rows(
|
||||
_filter_usage_rows(
|
||||
build_usage_stats(
|
||||
reports=reports,
|
||||
category="mold",
|
||||
equipment_type_by_key=equipment_type_by_key,
|
||||
),
|
||||
keyword,
|
||||
),
|
||||
sort_by,
|
||||
)
|
||||
return _build_usage_stats_export_response(device_rows, mold_rows)
|
||||
|
||||
|
||||
@router.get("/detail", response_model=UsageStatsDetailOut)
|
||||
def usage_stats_detail(
|
||||
category: str = Query("device", pattern="^(device|mold)$"),
|
||||
id: str = "",
|
||||
metric_kind: str = "",
|
||||
start_date: date | None = None,
|
||||
end_date: date | None = None,
|
||||
attendance_point_name: str = "",
|
||||
name: str = "",
|
||||
product_name: str = "",
|
||||
process_name: str = "",
|
||||
stamping_method: str = "",
|
||||
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UsageStatsDetailOut:
|
||||
purge_expired_voided_reports(db)
|
||||
point_names = _requested_points(db, user, attendance_point_name)
|
||||
equipment_type_by_key = _equipment_type_map(db, point_names)
|
||||
reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all()
|
||||
target_args = {
|
||||
"id": _clean(id),
|
||||
"category": category,
|
||||
"attendance_point_name": _clean(attendance_point_name),
|
||||
"name": _clean(name),
|
||||
"product_name": _clean(product_name),
|
||||
"process_name": _clean(process_name),
|
||||
"stamping_method": _clean(stamping_method),
|
||||
"metric_kind": _clean(metric_kind),
|
||||
}
|
||||
rows = build_usage_stats(
|
||||
reports=reports,
|
||||
category=category,
|
||||
equipment_type_by_key=equipment_type_by_key,
|
||||
)
|
||||
target = next((row for row in rows if _matches_target(row, **target_args)), None)
|
||||
if target is None:
|
||||
return UsageStatsDetailOut(
|
||||
id=target_args["id"],
|
||||
category=category,
|
||||
attendance_point_name=target_args["attendance_point_name"],
|
||||
name=target_args["name"],
|
||||
product_name=target_args["product_name"],
|
||||
process_name=target_args["process_name"],
|
||||
stamping_method=target_args["stamping_method"],
|
||||
metric_kind=target_args["metric_kind"],
|
||||
)
|
||||
|
||||
daily_rows: dict[date, UsageStatsDailyRow] = {}
|
||||
report_rows: list[UsageStatsReportRow] = []
|
||||
for report in reports:
|
||||
report_matches = build_usage_stats(
|
||||
reports=[report],
|
||||
category=category,
|
||||
equipment_type_by_key=equipment_type_by_key,
|
||||
)
|
||||
match = next((row for row in report_matches if _matches_target(row, **target_args)), None)
|
||||
if match is None:
|
||||
continue
|
||||
|
||||
day = daily_rows.setdefault(report.report_date, UsageStatsDailyRow(report_date=report.report_date))
|
||||
day.value = round2(day.value + match.value)
|
||||
day.report_count += match.report_count
|
||||
report_rows.append(
|
||||
UsageStatsReportRow(
|
||||
report_id=report.id,
|
||||
report_date=report.report_date,
|
||||
employee_phone=report.employee_phone,
|
||||
employee_name=report.employee.name if report.employee else "",
|
||||
display_name=target.name,
|
||||
value=match.value,
|
||||
report_count=match.report_count,
|
||||
)
|
||||
)
|
||||
|
||||
return _to_detail_out(
|
||||
target,
|
||||
daily_rows=sorted(daily_rows.values(), key=lambda row: row.report_date),
|
||||
report_rows=sorted(report_rows, key=lambda row: (row.report_date, row.report_id), reverse=True),
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import require_roles
|
||||
from app.models import (
|
||||
AttendancePoint,
|
||||
DeviceQRCode,
|
||||
Equipment,
|
||||
NoticePoint,
|
||||
PersonAttendancePoint,
|
||||
Personnel,
|
||||
Product,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
ReconciliationLedgerEntry,
|
||||
Role,
|
||||
WorkSchedule,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
)
|
||||
from app.schemas import AttendancePointCreate, WorkScheduleOut, WorkScheduleSettingsUpdate, WorkScheduleUpdate
|
||||
from app.services.attendance_points import clean_point_name, ensure_default_attendance_point
|
||||
from app.services.misc_work import ensure_misc_work_product
|
||||
from app.services.serializers import attendance_point_out
|
||||
from app.services.work_schedule import get_work_schedule, normalize_schedule_payload, update_work_schedule
|
||||
|
||||
router = APIRouter(prefix="/api/work-schedule", tags=["work-schedule"])
|
||||
|
||||
|
||||
def _schedule_out(schedule: WorkSchedule, db: Session | None = None) -> WorkScheduleOut:
|
||||
points = []
|
||||
if db is not None:
|
||||
ensure_default_attendance_point(db)
|
||||
db.commit()
|
||||
points = [
|
||||
attendance_point_out(point)
|
||||
for point in db.scalars(
|
||||
select(AttendancePoint)
|
||||
.where(AttendancePoint.is_active.is_(True))
|
||||
.order_by(AttendancePoint.name)
|
||||
).all()
|
||||
]
|
||||
return WorkScheduleOut(
|
||||
id=schedule.id,
|
||||
day_start=schedule.day_start,
|
||||
day_end=schedule.day_end,
|
||||
lunch_start=schedule.lunch_start,
|
||||
lunch_end=schedule.lunch_end,
|
||||
dinner_start=schedule.dinner_start,
|
||||
dinner_end=schedule.dinner_end,
|
||||
overtime_start=schedule.overtime_start,
|
||||
overtime_end=schedule.overtime_end,
|
||||
night_start=schedule.night_start,
|
||||
night_end=schedule.night_end,
|
||||
attendance_latitude=schedule.attendance_latitude,
|
||||
attendance_longitude=schedule.attendance_longitude,
|
||||
attendance_radius_meters=schedule.attendance_radius_meters,
|
||||
auto_submit_hours=float(schedule.auto_submit_hours or 15),
|
||||
attendance_points=points,
|
||||
updated_by=schedule.updated_by,
|
||||
updated_at=schedule.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _validate_attendance_point_payload(payload: AttendancePointCreate) -> str:
|
||||
name = clean_point_name(payload.name)
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="请输入考勤点名称")
|
||||
if (payload.latitude is None) != (payload.longitude is None):
|
||||
raise HTTPException(status_code=400, detail="经纬度必须同时填写")
|
||||
if payload.latitude is not None and not (-90 <= payload.latitude <= 90):
|
||||
raise HTTPException(status_code=400, detail="纬度范围为-90到90")
|
||||
if payload.longitude is not None and not (-180 <= payload.longitude <= 180):
|
||||
raise HTTPException(status_code=400, detail="经度范围为-180到180")
|
||||
if int(payload.radius_meters or 0) <= 0:
|
||||
raise HTTPException(status_code=400, detail="考勤范围半径必须大于0米")
|
||||
try:
|
||||
normalize_schedule_payload(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"{name} {exc}") from exc
|
||||
return name
|
||||
|
||||
|
||||
def _rename_business_point(db: Session, old_name: str, new_name: str) -> None:
|
||||
if old_name == new_name:
|
||||
return
|
||||
for model in (
|
||||
PersonAttendancePoint,
|
||||
Product,
|
||||
Equipment,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
DeviceQRCode,
|
||||
NoticePoint,
|
||||
ReconciliationLedgerEntry,
|
||||
):
|
||||
db.query(model).filter(model.attendance_point_name == old_name).update(
|
||||
{model.attendance_point_name: new_name},
|
||||
synchronize_session=False,
|
||||
)
|
||||
|
||||
|
||||
def _save_attendance_point_without_commit(db: Session, payload: AttendancePointCreate) -> AttendancePoint:
|
||||
name = _validate_attendance_point_payload(payload)
|
||||
original_name = clean_point_name(payload.original_name) or name
|
||||
point = db.get(AttendancePoint, original_name)
|
||||
if point is not None and original_name != name:
|
||||
if db.get(AttendancePoint, name) is not None:
|
||||
raise HTTPException(status_code=409, detail="该考勤点名称已存在")
|
||||
next_point = AttendancePoint(name=name)
|
||||
db.add(next_point)
|
||||
db.flush()
|
||||
_rename_business_point(db, original_name, name)
|
||||
db.delete(point)
|
||||
point = next_point
|
||||
elif point is None:
|
||||
point = db.get(AttendancePoint, name)
|
||||
if point is None:
|
||||
point = AttendancePoint(name=name)
|
||||
db.add(point)
|
||||
db.flush()
|
||||
point.latitude = payload.latitude
|
||||
point.longitude = payload.longitude
|
||||
point.radius_meters = int(payload.radius_meters or 500)
|
||||
for field, value in normalize_schedule_payload(payload).items():
|
||||
setattr(point, field, value)
|
||||
point.remark = payload.remark
|
||||
point.is_active = bool(payload.is_active)
|
||||
ensure_misc_work_product(db, name)
|
||||
db.flush()
|
||||
return point
|
||||
|
||||
|
||||
@router.get("", response_model=WorkScheduleOut)
|
||||
def read_work_schedule(
|
||||
db: Session = Depends(get_db),
|
||||
) -> WorkScheduleOut:
|
||||
return _schedule_out(get_work_schedule(db), db)
|
||||
|
||||
|
||||
@router.put("", response_model=WorkScheduleOut)
|
||||
def save_work_schedule(
|
||||
payload: WorkScheduleUpdate,
|
||||
user: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> WorkScheduleOut:
|
||||
try:
|
||||
schedule = update_work_schedule(db, payload, user.phone)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
return _schedule_out(schedule, db)
|
||||
|
||||
|
||||
@router.put("/settings", response_model=WorkScheduleOut)
|
||||
def save_work_schedule_settings(
|
||||
payload: WorkScheduleSettingsUpdate,
|
||||
user: Personnel = Depends(require_roles(Role.manager)),
|
||||
db: Session = Depends(get_db),
|
||||
) -> WorkScheduleOut:
|
||||
names = [_validate_attendance_point_payload(point) for point in payload.attendance_points]
|
||||
duplicates = sorted({name for name in names if names.count(name) > 1})
|
||||
if duplicates:
|
||||
raise HTTPException(status_code=400, detail=f"考勤点名称重复:{'、'.join(duplicates)}")
|
||||
if not names:
|
||||
raise HTTPException(status_code=400, detail="请至少维护一个考勤点")
|
||||
try:
|
||||
schedule = update_work_schedule(db, payload, user.phone, commit=False)
|
||||
for point in payload.attendance_points:
|
||||
_save_attendance_point_without_commit(db, point)
|
||||
db.commit()
|
||||
db.refresh(schedule)
|
||||
except ValueError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
return _schedule_out(schedule, db)
|
||||
+783
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Business services."""
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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='')}"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user