chore: initialize mini program backend

This commit is contained in:
souplearn
2026-06-24 15:19:14 +08:00
commit 8f62c99893
97 changed files with 13770 additions and 0 deletions
+17
View File
@@ -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
View File
@@ -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
+56
View File
@@ -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` 只能由经理修改。
- 经理看板只统计审核通过的报工,并按 `报工日期 + 员工电话` 合并。
- 产品唯一键为 `项目号 + 产品名称 + 使用设备号 + 工序`。
- 人员为 `手机号 + 姓名` 主体,同一手机号可以绑定多个角色;登录时多角色手机号需要先选择角色。
+2
View File
@@ -0,0 +1,2 @@
"""嘉恒智能报工后端服务."""
+51
View File
@@ -0,0 +1,51 @@
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote_plus
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_env: str = "development"
app_timezone: str = "Asia/Shanghai"
jwt_secret: str = Field(default="change_me_to_a_long_random_string")
token_expire_minutes: int = 60 * 24 * 30
auto_create_tables: bool = False
background_tasks_enabled: bool | None = None
mysql_host: str = "127.0.0.1"
mysql_port: int = 3306
mysql_database: str = "jiaheng_erp"
mysql_user: str = "root"
mysql_password: str = ""
wechat_app_id: str = ""
wechat_app_secret: str = ""
wechat_qr_env_version: str = "release"
public_base_url: str = "http://127.0.0.1:8000"
upload_dir: str = "uploads"
@property
def database_url(self) -> str:
user = quote_plus(self.mysql_user)
password = quote_plus(self.mysql_password)
database = quote_plus(self.mysql_database)
return (
f"mysql+pymysql://{user}:{password}@"
f"{self.mysql_host}:{self.mysql_port}/{database}?charset=utf8mb4"
)
@property
def upload_path(self) -> Path:
return Path(self.upload_dir).resolve()
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+35
View File
@@ -0,0 +1,35 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
class Base(DeclarativeBase):
pass
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
pool_recycle=280,
future=True,
)
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
expire_on_commit=False,
class_=Session,
)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+49
View File
@@ -0,0 +1,49 @@
from collections.abc import Callable
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import PersonRole, Personnel, Role
from app.security import verify_token
from app.services.serializers import temporary_expired
bearer = HTTPBearer(auto_error=False)
def current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
db: Session = Depends(get_db),
) -> Personnel:
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
try:
payload = verify_token(credentials.credentials)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已失效") from exc
phone = payload.get("sub")
try:
role = Role(payload.get("role"))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="角色无效") from exc
user = db.get(Personnel, phone)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="人员不存在")
if temporary_expired(user):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
if db.get(PersonRole, {"phone": phone, "role": role}) is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="角色不存在")
user.role = role
return user
def require_roles(*roles: Role) -> Callable[[Personnel], Personnel]:
def checker(user: Personnel = Depends(current_user)) -> Personnel:
if user.role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
return user
return checker
+103
View File
@@ -0,0 +1,103 @@
import asyncio
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import settings
from app.database import Base, engine
from app import models # noqa: F401
from app.services.auto_submit import auto_submit_overdue_loop
from app.services.qrcode_batch_tasks import qrcode_batch_task_loop
from app.routers import (
attendance_points,
auth,
clock,
dashboard,
devices,
equipment,
monitor,
notices,
people,
products,
reconciliation,
reports,
reviews,
usage_stats,
work_schedule,
)
def create_app() -> FastAPI:
app = FastAPI(
title="嘉恒智能报工后端服务",
version="0.1.0",
description="FastAPI service for 嘉恒智能报工小程序",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
settings.upload_path.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(settings.upload_path)), name="uploads")
def background_tasks_enabled() -> bool:
if settings.background_tasks_enabled is not None:
return settings.background_tasks_enabled
return settings.app_env == "production"
@app.on_event("startup")
async def startup() -> None:
if settings.auto_create_tables:
Base.metadata.create_all(bind=engine)
app.state.auto_submit_task = None
app.state.qrcode_batch_task = None
if background_tasks_enabled():
app.state.auto_submit_task = asyncio.create_task(auto_submit_overdue_loop())
app.state.qrcode_batch_task = asyncio.create_task(qrcode_batch_task_loop())
@app.on_event("shutdown")
async def shutdown() -> None:
task = getattr(app.state, "auto_submit_task", None)
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
task = getattr(app.state, "qrcode_batch_task", None)
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
app.include_router(auth.router)
app.include_router(attendance_points.router)
app.include_router(people.router)
app.include_router(products.router)
app.include_router(clock.router)
app.include_router(reports.router)
app.include_router(reviews.router)
app.include_router(dashboard.router)
app.include_router(devices.router)
app.include_router(equipment.router)
app.include_router(monitor.router)
app.include_router(notices.router)
app.include_router(reconciliation.router)
app.include_router(work_schedule.router)
app.include_router(usage_stats.router)
return app
app = create_app()
+464
View File
@@ -0,0 +1,464 @@
from datetime import date, datetime
from enum import StrEnum
from sqlalchemy import (
BigInteger,
Boolean,
Date,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
)
from sqlalchemy.dialects.mysql import JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
from app.timezone import now
class Role(StrEnum):
worker = "worker"
admin = "admin"
manager = "manager"
class SessionStatus(StrEnum):
active = "active"
reporting = "reporting"
submitted = "submitted"
cancelled = "cancelled"
class ReportStatus(StrEnum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class AuditAction(StrEnum):
approve = "approve"
approve_with_correction = "approve_with_correction"
reject = "reject"
manager_break_update = "manager_break_update"
class AttendancePoint(Base):
__tablename__ = "attendance_points"
name: Mapped[str] = mapped_column(String(128), primary_key=True)
latitude: Mapped[float | None] = mapped_column(Numeric(10, 7))
longitude: Mapped[float | None] = mapped_column(Numeric(10, 7))
radius_meters: Mapped[int] = mapped_column(Integer, nullable=False, default=500)
day_start: Mapped[str] = mapped_column(String(5), nullable=False, default="08:00")
day_end: Mapped[str] = mapped_column(String(5), nullable=False, default="17:20")
lunch_start: Mapped[str] = mapped_column(String(5), nullable=False, default="11:40")
lunch_end: Mapped[str] = mapped_column(String(5), nullable=False, default="12:40")
dinner_start: Mapped[str] = mapped_column(String(5), nullable=False, default="17:20")
dinner_end: Mapped[str] = mapped_column(String(5), nullable=False, default="18:00")
overtime_start: Mapped[str] = mapped_column(String(5), nullable=False, default="18:00")
overtime_end: Mapped[str] = mapped_column(String(5), nullable=False, default="20:00")
night_start: Mapped[str] = mapped_column(String(5), nullable=False, default="20:00")
night_end: Mapped[str] = mapped_column(String(5), nullable=False, default="06:00")
remark: Mapped[str | None] = mapped_column(String(500))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
class Personnel(Base):
__tablename__ = "personnel"
phone: Mapped[str] = mapped_column(String(20), primary_key=True)
name: Mapped[str] = mapped_column(String(64), nullable=False)
is_temporary: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
temporary_expires_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
roles: Mapped[list["PersonRole"]] = relationship(
back_populates="person",
cascade="all, delete-orphan",
)
attendance_points: Mapped[list["PersonAttendancePoint"]] = relationship(
back_populates="person",
cascade="all, delete-orphan",
)
class PersonRole(Base):
__tablename__ = "person_roles"
phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), primary_key=True)
role: Mapped[Role] = mapped_column(Enum(Role), primary_key=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
person: Mapped[Personnel] = relationship(back_populates="roles")
class PersonAttendancePoint(Base):
__tablename__ = "person_attendance_points"
phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), primary_key=True)
attendance_point_name: Mapped[str] = mapped_column(
ForeignKey("attendance_points.name"),
primary_key=True,
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
person: Mapped[Personnel] = relationship(back_populates="attendance_points")
attendance_point: Mapped[AttendancePoint] = relationship()
class Product(Base):
__tablename__ = "products"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, default="")
project_no: Mapped[str] = mapped_column(String(64), primary_key=True)
product_name: Mapped[str] = mapped_column(String(255), primary_key=True)
device_no: Mapped[str] = mapped_column(String(64), primary_key=True)
process_name: Mapped[str] = mapped_column(String(128), primary_key=True, default="")
profile_no: Mapped[str | None] = mapped_column(String(128))
material_code: Mapped[str | None] = mapped_column(String(128))
material_name: Mapped[str | None] = mapped_column(String(255))
supplier: Mapped[str | None] = mapped_column(String(255))
product_net_weight_kg: Mapped[float | None] = mapped_column(Numeric(12, 4))
product_gross_weight_kg: Mapped[float | None] = mapped_column(Numeric(12, 4))
scrap_loss_rate: Mapped[float | None] = mapped_column(Numeric(10, 6))
waste_price_yuan_per_kg: Mapped[float | None] = mapped_column(Numeric(12, 4))
stamping_method: Mapped[str | None] = mapped_column(String(128))
operator_count: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=1)
process_unit_price_yuan: Mapped[float] = mapped_column(Numeric(12, 4), nullable=False, default=0)
standard_beat: Mapped[float] = mapped_column(Numeric(12, 3), nullable=False)
standard_workload: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
__table_args__ = (
Index("idx_products_device_no", "device_no"),
Index("idx_products_material_code", "material_code"),
)
class Equipment(Base):
__tablename__ = "equipment"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, default="")
device_no: Mapped[str] = mapped_column(String(64), primary_key=True)
device_type: Mapped[str] = mapped_column(String(32), nullable=False, default="冲压设备")
remark: Mapped[str | None] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
class WorkSchedule(Base):
__tablename__ = "work_schedules"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
day_start: Mapped[str] = mapped_column(String(5), nullable=False, default="08:00")
day_end: Mapped[str] = mapped_column(String(5), nullable=False, default="17:20")
lunch_start: Mapped[str] = mapped_column(String(5), nullable=False, default="11:40")
lunch_end: Mapped[str] = mapped_column(String(5), nullable=False, default="12:40")
dinner_start: Mapped[str] = mapped_column(String(5), nullable=False, default="17:20")
dinner_end: Mapped[str] = mapped_column(String(5), nullable=False, default="18:00")
overtime_start: Mapped[str] = mapped_column(String(5), nullable=False, default="18:00")
overtime_end: Mapped[str] = mapped_column(String(5), nullable=False, default="20:00")
night_start: Mapped[str] = mapped_column(String(5), nullable=False, default="20:00")
night_end: Mapped[str] = mapped_column(String(5), nullable=False, default="06:00")
attendance_latitude: Mapped[float | None] = mapped_column(Numeric(10, 7))
attendance_longitude: Mapped[float | None] = mapped_column(Numeric(10, 7))
attendance_radius_meters: Mapped[int] = mapped_column(Integer, nullable=False, default=500)
auto_submit_hours: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=15)
updated_by: Mapped[str | None] = mapped_column(String(20))
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
class WorkSession(Base):
__tablename__ = "work_sessions"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
attendance_point_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
start_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
end_at: Mapped[datetime | None] = mapped_column(DateTime)
status: Mapped[SessionStatus] = mapped_column(
Enum(SessionStatus),
nullable=False,
default=SessionStatus.active,
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
employee: Mapped[Personnel] = relationship()
devices: Mapped[list["WorkSessionDevice"]] = relationship(
back_populates="session",
cascade="all, delete-orphan",
order_by="WorkSessionDevice.sort_order",
)
__table_args__ = (
Index("idx_sessions_employee_status", "employee_phone", "status"),
)
class WorkSessionDevice(Base):
__tablename__ = "work_session_devices"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
session_id: Mapped[int] = mapped_column(ForeignKey("work_sessions.id"), nullable=False)
attendance_point_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
device_no: Mapped[str] = mapped_column(String(255), nullable=False)
process_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
scanned_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
sort_order: Mapped[int] = mapped_column(nullable=False, default=0)
released_at: Mapped[datetime | None] = mapped_column(DateTime)
released_by: Mapped[str | None] = mapped_column(String(20))
release_reason: Mapped[str | None] = mapped_column(String(120))
session: Mapped[WorkSession] = relationship(back_populates="devices")
__table_args__ = (
Index("idx_session_devices_session", "session_id"),
Index("idx_session_devices_device", "device_no"),
Index("idx_session_devices_release", "released_at"),
)
class ProductionReport(Base):
__tablename__ = "production_reports"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
attendance_point_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
session_id: Mapped[int] = mapped_column(ForeignKey("work_sessions.id"), nullable=False, unique=True)
employee_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
report_date: Mapped[date] = mapped_column(Date, nullable=False)
start_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
end_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
duration_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
break_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
effective_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
total_good_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
total_output_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
actual_beat: Mapped[float] = mapped_column(Numeric(12, 3), nullable=False, default=0)
standard_beat: Mapped[float] = mapped_column(Numeric(12, 3), nullable=False, default=0)
expected_workload: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
pace_rate: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
workload_rate: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
status: Mapped[ReportStatus] = mapped_column(
Enum(ReportStatus),
nullable=False,
default=ReportStatus.pending,
)
reviewer_phone: Mapped[str | None] = mapped_column(ForeignKey("personnel.phone"))
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime)
reject_reason: Mapped[str | None] = mapped_column(String(500))
review_remark: Mapped[str | None] = mapped_column(String(500))
submitted_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
is_system_auto_submitted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
auto_submit_reason: Mapped[str | None] = mapped_column(String(120))
is_multi_person_assistant: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
multi_person_source_report_id: Mapped[int | None] = mapped_column(BigInteger)
is_voided: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
voided_at: Mapped[datetime | None] = mapped_column(DateTime)
voided_by: Mapped[str | None] = mapped_column(ForeignKey("personnel.phone"))
unvoid_deadline_at: Mapped[datetime | None] = mapped_column(DateTime)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
employee: Mapped[Personnel] = relationship(foreign_keys=[employee_phone])
reviewer: Mapped[Personnel | None] = relationship(foreign_keys=[reviewer_phone])
voider: Mapped[Personnel | None] = relationship(foreign_keys=[voided_by])
session: Mapped[WorkSession] = relationship()
items: Mapped[list["ProductionReportItem"]] = relationship(
back_populates="report",
cascade="all, delete-orphan",
)
audit_logs: Mapped[list["ReportAuditLog"]] = relationship(
order_by="ReportAuditLog.created_at",
)
__table_args__ = (
Index("idx_reports_employee_date", "employee_phone", "report_date"),
Index("idx_reports_status_date", "status", "report_date"),
Index("idx_reports_reviewer", "reviewer_phone", "reviewed_at"),
Index("idx_reports_voided", "is_voided", "voided_at"),
)
class ProductionReportItem(Base):
__tablename__ = "production_report_items"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
report_id: Mapped[int] = mapped_column(ForeignKey("production_reports.id"), nullable=False)
attendance_point_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
device_no: Mapped[str] = mapped_column(String(255), nullable=False)
project_no: Mapped[str] = mapped_column(String(64), nullable=False)
product_name: Mapped[str] = mapped_column(String(255), nullable=False)
material_code: Mapped[str | None] = mapped_column(String(128))
material_name: Mapped[str | None] = mapped_column(String(255))
raw_material_batch_no: Mapped[str | None] = mapped_column(String(128))
process_name: Mapped[str | None] = mapped_column(String(128))
stamping_method: Mapped[str | None] = mapped_column(String(128))
operator_count: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=1)
process_unit_price_yuan: Mapped[float] = mapped_column(Numeric(12, 4), nullable=False, default=0)
changeover_count: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
standard_beat: Mapped[float] = mapped_column(Numeric(12, 3), nullable=False, default=0)
standard_workload: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
good_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
defect_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
scrap_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
allocated_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
started_at: Mapped[datetime | None] = mapped_column(DateTime)
remark: Mapped[str | None] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
report: Mapped[ProductionReport] = relationship(back_populates="items")
__table_args__ = (
Index("idx_report_items_report", "report_id"),
Index("idx_report_items_product", "project_no", "product_name"),
)
class ReportAuditLog(Base):
__tablename__ = "report_audit_logs"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
report_id: Mapped[int] = mapped_column(ForeignKey("production_reports.id"), nullable=False)
reviewer_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
action: Mapped[AuditAction] = mapped_column(Enum(AuditAction), nullable=False)
before_json: Mapped[dict | None] = mapped_column(JSON)
after_json: Mapped[dict | None] = mapped_column(JSON)
remark: Mapped[str | None] = mapped_column(String(500))
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
__table_args__ = (
Index("idx_audit_report", "report_id"),
Index("idx_audit_reviewer", "reviewer_phone", "created_at"),
)
class MoldLockFeedback(Base):
__tablename__ = "mold_lock_feedbacks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
attendance_point_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
mold_name: Mapped[str] = mapped_column(String(255), nullable=False)
process_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
session_device_id: Mapped[int] = mapped_column(ForeignKey("work_session_devices.id"), nullable=False)
reporter_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
occupied_phone: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
read_at: Mapped[datetime | None] = mapped_column(DateTime)
handled_at: Mapped[datetime | None] = mapped_column(DateTime)
handled_by: Mapped[str | None] = mapped_column(ForeignKey("personnel.phone"))
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
session_device: Mapped[WorkSessionDevice] = relationship()
reporter: Mapped[Personnel] = relationship(foreign_keys=[reporter_phone])
occupied_person: Mapped[Personnel] = relationship(foreign_keys=[occupied_phone])
handler: Mapped[Personnel | None] = relationship(foreign_keys=[handled_by])
__table_args__ = (
Index("idx_mold_lock_feedback_point_read", "attendance_point_name", "read_at", "created_at"),
Index("idx_mold_lock_feedback_status", "status", "created_at"),
)
class DeviceQRCode(Base):
__tablename__ = "device_qrcodes"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, default="")
device_no: Mapped[str] = mapped_column(String(255), primary_key=True)
process_name: Mapped[str] = mapped_column(String(128), primary_key=True, default="")
qr_scene: Mapped[str] = mapped_column(String(128), nullable=False)
qr_url: Mapped[str | None] = mapped_column(String(500))
created_by: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
class DeviceQrBatchTask(Base):
__tablename__ = "device_qrcode_batch_tasks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
item_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
completed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
failed_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
items_json: Mapped[list] = mapped_column(JSON, nullable=False)
zip_url: Mapped[str | None] = mapped_column(String(500))
error_message: Mapped[str | None] = mapped_column(String(1000))
created_by: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
started_at: Mapped[datetime | None] = mapped_column(DateTime)
finished_at: Mapped[datetime | None] = mapped_column(DateTime)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
creator: Mapped[Personnel] = relationship()
__table_args__ = (
Index("idx_device_qrcode_batch_tasks_creator", "created_by", "created_at"),
Index("idx_device_qrcode_batch_tasks_status", "status", "created_at"),
)
class Notice(Base):
__tablename__ = "notices"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(120), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_by: Mapped[str] = mapped_column(ForeignKey("personnel.phone"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
creator: Mapped[Personnel] = relationship()
attendance_points: Mapped[list["NoticePoint"]] = relationship(
back_populates="notice",
cascade="all, delete-orphan",
)
__table_args__ = (
Index("idx_notices_active_sort", "is_active", "sort_order"),
Index("idx_notices_updated", "updated_at"),
)
class NoticePoint(Base):
__tablename__ = "notice_points"
notice_id: Mapped[int] = mapped_column(ForeignKey("notices.id"), primary_key=True)
attendance_point_name: Mapped[str] = mapped_column(
ForeignKey("attendance_points.name"),
primary_key=True,
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
notice: Mapped[Notice] = relationship(back_populates="attendance_points")
attendance_point: Mapped[AttendancePoint] = relationship()
class ReconciliationLedgerEntry(Base):
__tablename__ = "reconciliation_ledger_entries"
attendance_point_name: Mapped[str] = mapped_column(String(128), primary_key=True, default="")
year: Mapped[int] = mapped_column(Integer, primary_key=True)
month: Mapped[int] = mapped_column(Integer, primary_key=True)
product_name: Mapped[str] = mapped_column(String(255), primary_key=True)
reconciled_good_qty: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, default=0)
return_qty: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, default=0)
updated_by: Mapped[str | None] = mapped_column(String(20))
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
__table_args__ = (
Index("idx_reconciliation_product", "product_name"),
Index("idx_reconciliation_updated", "updated_at"),
)
+2
View File
@@ -0,0 +1,2 @@
"""API routers."""
+168
View File
@@ -0,0 +1,168 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import current_user, require_roles
from app.models import (
AttendancePoint,
DeviceQRCode,
Equipment,
NoticePoint,
PersonAttendancePoint,
Personnel,
Product,
ProductionReport,
ProductionReportItem,
ReconciliationLedgerEntry,
Role,
WorkSession,
WorkSessionDevice,
)
from app.schemas import AttendancePointCreate, AttendancePointOut, PageResponse
from app.services.attendance_points import accessible_points, clean_point_name, ensure_default_attendance_point
from app.services.common import paginate
from app.services.misc_work import ensure_misc_work_product
from app.services.serializers import attendance_point_out
from app.services.work_schedule import normalize_schedule_payload
router = APIRouter(prefix="/api/attendance-points", tags=["attendance-points"])
def _validate_payload(payload: AttendancePointCreate) -> str:
name = clean_point_name(payload.name)
if not name:
raise HTTPException(status_code=400, detail="请输入考勤点名称")
if (payload.latitude is None) != (payload.longitude is None):
raise HTTPException(status_code=400, detail="经纬度必须同时填写")
if payload.latitude is not None and not (-90 <= payload.latitude <= 90):
raise HTTPException(status_code=400, detail="纬度范围为-90到90")
if payload.longitude is not None and not (-180 <= payload.longitude <= 180):
raise HTTPException(status_code=400, detail="经度范围为-180到180")
if int(payload.radius_meters or 0) <= 0:
raise HTTPException(status_code=400, detail="考勤范围半径必须大于0米")
try:
normalize_schedule_payload(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"{name} {exc}") from exc
return name
def _rename_business_point(db: Session, old_name: str, new_name: str) -> None:
if old_name == new_name:
return
for model in (
PersonAttendancePoint,
Product,
Equipment,
WorkSession,
WorkSessionDevice,
ProductionReport,
ProductionReportItem,
DeviceQRCode,
NoticePoint,
ReconciliationLedgerEntry,
):
db.query(model).filter(model.attendance_point_name == old_name).update(
{model.attendance_point_name: new_name},
synchronize_session=False,
)
@router.get("/accessible", response_model=list[AttendancePointOut])
def list_accessible_attendance_points(
user: Personnel = Depends(current_user),
db: Session = Depends(get_db),
) -> list[AttendancePointOut]:
return [attendance_point_out(point) for point in accessible_points(db, user)]
@router.get("/public", response_model=list[AttendancePointOut])
def list_public_attendance_points(
db: Session = Depends(get_db),
) -> list[AttendancePointOut]:
points = db.scalars(
select(AttendancePoint)
.where(AttendancePoint.is_active.is_(True))
.order_by(AttendancePoint.name.asc())
).all()
rows = []
for point in points:
item = attendance_point_out(point)
item.remark = None
item.created_at = None
item.updated_at = None
rows.append(item)
return rows
@router.get("", response_model=PageResponse)
def list_attendance_points(
keyword: str = "",
page: int = Query(1, ge=1),
page_size: int = Query(100, ge=1, le=500),
_: Personnel = Depends(require_roles(Role.manager)),
db: Session = Depends(get_db),
) -> PageResponse:
default_point = ensure_default_attendance_point(db)
ensure_misc_work_product(db, default_point.name)
db.commit()
query = select(AttendancePoint).order_by(AttendancePoint.updated_at.desc(), AttendancePoint.name.asc())
if keyword:
like = f"%{keyword}%"
query = query.where(or_(AttendancePoint.name.like(like), AttendancePoint.remark.like(like)))
result = paginate(db, query, page, page_size)
return PageResponse(**{**result, "rows": [attendance_point_out(row) for row in result["rows"]]})
@router.post("", response_model=AttendancePointOut)
def save_attendance_point(
payload: AttendancePointCreate,
_: Personnel = Depends(require_roles(Role.manager)),
db: Session = Depends(get_db),
) -> AttendancePointOut:
name = _validate_payload(payload)
original_name = clean_point_name(payload.original_name) or name
point = db.get(AttendancePoint, original_name)
if point is not None and original_name != name:
if db.get(AttendancePoint, name) is not None:
raise HTTPException(status_code=409, detail="该考勤点名称已存在")
next_point = AttendancePoint(name=name)
db.add(next_point)
db.flush()
_rename_business_point(db, original_name, name)
db.delete(point)
point = next_point
elif point is None:
point = db.get(AttendancePoint, name)
if point is None:
point = AttendancePoint(name=name)
db.add(point)
db.flush()
point.latitude = payload.latitude
point.longitude = payload.longitude
point.radius_meters = int(payload.radius_meters or 500)
for field, value in normalize_schedule_payload(payload).items():
setattr(point, field, value)
point.remark = payload.remark
point.is_active = bool(payload.is_active)
ensure_misc_work_product(db, name)
db.commit()
db.refresh(point)
return attendance_point_out(point)
@router.delete("/{name}", status_code=204)
def delete_attendance_point(
name: str,
_: Personnel = Depends(require_roles(Role.manager)),
db: Session = Depends(get_db),
) -> None:
point_name = clean_point_name(name)
point = db.get(AttendancePoint, point_name)
if point is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="考勤点不存在")
if point_name == ensure_default_attendance_point(db).name:
raise HTTPException(status_code=400, detail="默认考勤点不能删除")
point.is_active = False
db.commit()
+309
View File
@@ -0,0 +1,309 @@
from datetime import timedelta
from math import asin, cos, radians, sin, sqrt
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.deps import current_user
from app.models import AttendancePoint, PersonRole, Personnel, Role
from app.schemas import (
LoginSelectionResponse,
PersonnelOut,
RoleSelectionRequest,
SwitchRoleRequest,
TokenResponse,
WechatLoginRequest,
)
from app.security import create_token, verify_token
from app.services.attendance_points import clean_point_name, set_person_attendance_points
from app.services.serializers import personnel_out, temporary_expired
from app.services.wechat import WechatConfigError, get_phone_number
from app.timezone import now
router = APIRouter(prefix="/api/auth", tags=["auth"])
TEMPORARY_WORKER_HOURS = 24
EARTH_RADIUS_METERS = 6371000
def _distance_meters(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
delta_lat = radians(lat2 - lat1)
delta_lng = radians(lng2 - lng1)
first_lat = radians(lat1)
second_lat = radians(lat2)
value = (
sin(delta_lat / 2) * sin(delta_lat / 2)
+ cos(first_lat) * cos(second_lat) * sin(delta_lng / 2) * sin(delta_lng / 2)
)
return 2 * EARTH_RADIUS_METERS * asin(sqrt(min(1, value)))
def _require_temporary_attendance_point(
db: Session,
point_name: str | None,
latitude: float | None,
longitude: float | None,
) -> str:
name = clean_point_name(point_name)
if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="创建临时工前请先进入考勤点范围")
if latitude is None or longitude is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="创建临时工需要校验当前位置")
point = db.get(AttendancePoint, name)
if point is None or not point.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"没有该考勤点:{name}")
if point.latitude is None or point.longitude is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"考勤点【{name}】未配置经纬度")
distance = _distance_meters(float(latitude), float(longitude), float(point.latitude), float(point.longitude))
radius = int(point.radius_meters or 500)
if distance > radius:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"当前位置距离考勤点【{name}】约{round(distance)}米,超过{radius}米范围,不能创建临时工",
)
return name
def _token_for(person: Personnel, role: Role) -> TokenResponse:
token = create_token({"sub": person.phone, "name": person.name, "role": str(role)})
return TokenResponse(access_token=token, user=personnel_out(person, role))
def _selection_response(person: Personnel) -> LoginSelectionResponse:
roles = [item.role for item in person.roles]
selection_token = create_token({
"type": "role_selection",
"sub": person.phone,
"roles": [str(role) for role in roles],
})
return LoginSelectionResponse(
needs_selection=True,
selection_token=selection_token,
matched_people=[personnel_out(person, role) for role in roles],
)
def _temporary_worker_token(phone: str) -> str:
return create_token({
"type": "temporary_worker_confirmation",
"sub": phone,
})
def _temporary_worker_response(phone: str) -> LoginSelectionResponse:
return LoginSelectionResponse(
needs_selection=False,
needs_temporary_worker=True,
temporary_token=_temporary_worker_token(phone),
temporary_phone=phone,
)
def _phone_from_temporary_token(token: str | None) -> str:
if not token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="缺少临时工确认凭证")
try:
payload = verify_token(token)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工确认已失效") from exc
if payload.get("type") != "temporary_worker_confirmation" or not payload.get("sub"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的临时工确认凭证")
return str(payload["sub"])
def _create_or_renew_temporary_worker(
db: Session,
phone: str,
name: str,
attendance_point_name: str,
) -> Personnel:
cleaned_name = str(name or "").strip()
if not cleaned_name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入临时工姓名")
person = db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone == phone)
)
if person is None:
person = Personnel(phone=phone, name=cleaned_name)
db.add(person)
db.flush()
person.name = cleaned_name
person.is_temporary = True
person.temporary_expires_at = now() + timedelta(hours=TEMPORARY_WORKER_HOURS)
if db.get(PersonRole, {"phone": phone, "role": Role.worker}) is None:
db.add(PersonRole(phone=phone, role=Role.worker))
set_person_attendance_points(db, phone, [attendance_point_name])
db.commit()
refreshed = db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone == phone)
)
if refreshed is None:
raise HTTPException(status_code=500, detail="临时工创建失败")
return refreshed
def _login_for_phone(
db: Session,
phone: str,
selected_role: Role | None = None,
) -> LoginSelectionResponse:
person = db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone == phone)
)
if person is None or not person.roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="手机号不在人员清单中")
if temporary_expired(person):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="临时工已过期,请微信授权续期")
roles = [item.role for item in person.roles]
if selected_role is not None:
if selected_role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="该手机号没有此角色")
token = _token_for(person, selected_role)
return LoginSelectionResponse(
needs_selection=False,
access_token=token.access_token,
user=token.user,
)
if len(roles) > 1:
return _selection_response(person)
token = _token_for(person, roles[0])
return LoginSelectionResponse(
needs_selection=False,
access_token=token.access_token,
user=token.user,
)
@router.post("/wechat-login", response_model=LoginSelectionResponse)
async def wechat_login(
payload: WechatLoginRequest,
db: Session = Depends(get_db),
) -> LoginSelectionResponse:
if payload.create_temporary_worker:
phone = _phone_from_temporary_token(payload.temporary_token)
attendance_point_name = _require_temporary_attendance_point(
db,
payload.temporary_attendance_point_name,
payload.temporary_latitude,
payload.temporary_longitude,
)
person = _create_or_renew_temporary_worker(db, phone, payload.temporary_name or "", attendance_point_name)
token = _token_for(person, Role.worker)
return LoginSelectionResponse(
needs_selection=False,
access_token=token.access_token,
user=token.user,
temporary_expires_at=person.temporary_expires_at,
)
phone = payload.phone
if not phone and payload.phone_code:
try:
phone = await get_phone_number(payload.phone_code)
except WechatConfigError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
candidates = [item.strip() for item in payload.phone_candidates if item.strip()]
if phone:
candidates.insert(0, phone.strip())
candidates = list(dict.fromkeys(candidates))
if not candidates:
raise HTTPException(status_code=400, detail="缺少手机号或手机号授权凭证")
people = db.scalars(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone.in_(candidates))
).all()
if not people:
return _temporary_worker_response(candidates[0])
if len(people) > 1 and phone not in [person.phone for person in people]:
return LoginSelectionResponse(
needs_selection=True,
matched_people=[
personnel_out(person, role.role)
for person in people
for role in person.roles
],
)
selected = next((person for person in people if person.phone == phone), people[0])
if not selected.roles:
return _temporary_worker_response(selected.phone)
if temporary_expired(selected):
return _temporary_worker_response(selected.phone)
return _login_for_phone(db, selected.phone, payload.selected_role)
@router.post("/select-role", response_model=TokenResponse)
def select_role(payload: RoleSelectionRequest, db: Session = Depends(get_db)) -> TokenResponse:
try:
token_payload = verify_token(payload.selection_token)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="角色选择已失效") from exc
if token_payload.get("type") != "role_selection":
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的角色选择凭证")
if str(payload.role) not in token_payload.get("roles", []):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="该手机号没有此角色")
person = db.get(Personnel, token_payload.get("sub"))
if person is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
if temporary_expired(person):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
if db.get(PersonRole, {"phone": person.phone, "role": payload.role}) is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="角色不存在")
return _token_for(person, payload.role)
@router.get("/me", response_model=PersonnelOut)
def get_me(
user: Personnel = Depends(current_user),
db: Session = Depends(get_db),
) -> PersonnelOut:
person = db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone == user.phone)
)
if person is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
if temporary_expired(person):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
return personnel_out(person, user.role)
@router.post("/switch-role", response_model=TokenResponse)
def switch_role(
payload: SwitchRoleRequest,
user: Personnel = Depends(current_user),
db: Session = Depends(get_db),
) -> TokenResponse:
person = db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone == user.phone)
)
if person is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
if temporary_expired(person):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="临时工已过期,请微信授权续期")
if db.get(PersonRole, {"phone": person.phone, "role": payload.role}) is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="该手机号没有此角色")
return _token_for(person, payload.role)
+516
View File
@@ -0,0 +1,516 @@
from contextlib import nullcontext
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.deps import require_roles
from app.models import Personnel, Product, Role, SessionStatus, WorkSession, WorkSessionDevice
from app.schemas import ClockRequest, ClockSessionOut, ClockState, ContinueWorkRequest
from app.services.attendance_points import require_attendance_point_access, validate_attendance_point_location
from app.services.auto_submit import auto_submit_overdue_sessions
from app.services.cleaning import is_cleaning_product
from app.services.display_names import mold_process_display_name
from app.services.mold_locks import (
current_unreleased_device,
ensure_mold_available,
is_lockable_product,
mold_lock_mutex,
)
from app.services.misc_work import is_misc_product
from app.services.reporting_window import is_reporting_expired
from app.services.serializers import session_out
from app.services.work_schedule import get_work_schedule
from app.timezone import now
router = APIRouter(prefix="/api/clock", tags=["clock"])
def _mold_lock_context(
db: Session,
point_name: str,
mold_name: str,
process_name: str,
product: Product | None,
):
if is_lockable_product(product):
return mold_lock_mutex(db, point_name, mold_name, process_name)
return nullcontext()
def _mold_name(value: str | None) -> str:
return str(value or "").strip()
def _process_name(value: str | None) -> str:
return str(value or "").strip()
def _request_mold_name(payload: ClockRequest) -> str:
return _mold_name(payload.mold_name or payload.device_no)
def _request_process_name(payload: ClockRequest) -> str:
return _process_name(payload.process_name)
def _mold_key(mold_name: str, process_name: str) -> tuple[str, str]:
return (_mold_name(mold_name), _process_name(process_name))
def _mold_display(mold_name: str, process_name: str, stamping_method: str | None = None) -> str:
return mold_process_display_name(mold_name, process_name, stamping_method)
def _require_clock_location(
db: Session,
point_name: str,
latitude: float | None,
longitude: float | None,
) -> None:
try:
validate_attendance_point_location(db, point_name, latitude, longitude, action_name="扫码报工")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
def _product_for_mold(db: Session, point_name: str, mold_name: str, process_name: str) -> Product | None:
return db.scalar(
select(Product)
.where(
Product.attendance_point_name == point_name,
Product.product_name == mold_name,
Product.process_name == process_name,
Product.device_no == "",
)
.order_by(Product.project_no.asc())
)
def _resolve_product_for_mold(
db: Session,
point_name: str,
mold_name: str,
process_name: str,
) -> tuple[Product | None, str]:
process = _process_name(process_name)
product = _product_for_mold(db, point_name, mold_name, process)
if product is not None or process:
return product, process
candidates = db.scalars(
select(Product)
.where(
Product.attendance_point_name == point_name,
Product.product_name == mold_name,
Product.device_no == "",
)
.order_by(Product.project_no.asc(), Product.process_name.asc())
).all()
if len(candidates) == 1:
product = candidates[0]
return product, _process_name(product.process_name)
return None, process
def _active_session(db: Session, user: Personnel) -> WorkSession | None:
auto_submit_overdue_sessions(db, employee_phone=user.phone)
return db.scalar(
select(WorkSession)
.options(selectinload(WorkSession.devices))
.where(
WorkSession.employee_phone == user.phone,
WorkSession.status.in_([SessionStatus.active, SessionStatus.reporting]),
)
.order_by(WorkSession.created_at.desc())
)
def _ordered_devices(session: WorkSession) -> list[WorkSessionDevice]:
return sorted(
session.devices,
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
)
def _next_sort_order(session: WorkSession) -> int:
orders = [int(device.sort_order or 0) for device in session.devices]
return (max(orders) + 1) if orders else 0
def _unique_molds(db: Session, devices: list[WorkSessionDevice]) -> list[str]:
molds: list[str] = []
seen: set[tuple[str, str]] = set()
for device in devices:
key = _mold_key(device.device_no, device.process_name)
if key in seen:
continue
product = _product_for_mold(db, device.attendance_point_name, device.device_no, device.process_name)
molds.append(_mold_display(device.device_no, device.process_name, product.stamping_method if product else None))
seen.add(key)
return molds
def _unfinished_report_mismatch_detail(session: WorkSession, unique_molds: list[str], expired: bool) -> dict:
mold_text = "、".join(unique_molds) if unique_molds else "原模具"
return {
"code": "unfinished_report_mold_mismatch",
"message": f"报工已进入填写阶段,请扫描本次记录中的模具({mold_text})继续报工",
"session_id": session.id,
"devices": unique_molds,
"reporting_expired": expired,
}
@router.get("/state", response_model=ClockState)
def get_clock_state(
attendance_point_name: str = Query(""),
device_no: str = Query(""),
mold_name: str = Query(""),
process_name: str = Query(""),
latitude: float | None = Query(None),
longitude: float | None = Query(None),
user: Personnel = Depends(require_roles(Role.worker)),
db: Session = Depends(get_db),
) -> ClockState:
mold = _mold_name(mold_name or device_no)
process = _process_name(process_name)
try:
point_name = require_attendance_point_access(db, user, attendance_point_name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
_require_clock_location(db, point_name, latitude, longitude)
if not mold:
raise HTTPException(status_code=400, detail="缺少模具名称")
product, process = _resolve_product_for_mold(db, point_name, mold, process)
if product is None:
raise HTTPException(status_code=403, detail=f"无该考勤点模具报工权限:{point_name}")
ensure_mold_available(db, user, point_name, mold, process, product)
stamping_method = product.stamping_method if product else None
display_name = _mold_display(mold, process, stamping_method)
is_cleaning = is_cleaning_product(product)
is_misc = is_misc_product(product)
auto_submit_hours = float(get_work_schedule(db).auto_submit_hours or 15)
session = _active_session(db, user)
if session is None:
return ClockState(
action="start",
action_text="开始上班",
attendance_point_name=point_name,
device_no=mold,
mold_name=mold,
process_name=process,
stamping_method=stamping_method,
display_name=display_name,
is_cleaning=is_cleaning,
is_misc=is_misc,
auto_submit_hours=auto_submit_hours,
)
if session.attendance_point_name != point_name:
raise HTTPException(status_code=400, detail=f"当前上班记录属于{session.attendance_point_name}考勤点,请扫描该考勤点模具")
ordered_devices = _ordered_devices(session)
molds = [_mold_key(device.device_no, device.process_name) for device in ordered_devices]
unique_molds = _unique_molds(db, ordered_devices)
current_device = ordered_devices[-1] if ordered_devices else None
active_device = current_unreleased_device(session)
current_mold = active_device.device_no if active_device else (current_device.device_no if current_device else "")
current_process = active_device.process_name if active_device else (current_device.process_name if current_device else "")
scanned_key = _mold_key(mold, process)
if session.status == SessionStatus.reporting:
if scanned_key not in set(molds):
raise HTTPException(
status_code=400,
detail=_unfinished_report_mismatch_detail(session, unique_molds, is_reporting_expired(session)),
)
if is_reporting_expired(session):
return ClockState(
action="refinish",
action_text="重新报工",
attendance_point_name=point_name,
device_no=mold,
mold_name=mold,
process_name=process,
stamping_method=stamping_method,
display_name=display_name,
is_cleaning=is_cleaning,
is_misc=is_misc,
current_mold_name=current_mold,
current_process_name=current_process,
session_id=session.id,
start_at=session.start_at,
end_at=session.end_at,
devices=unique_molds,
auto_submit_hours=auto_submit_hours,
)
return ClockState(
action="report",
action_text="继续报工",
attendance_point_name=point_name,
device_no=mold,
mold_name=mold,
process_name=process,
stamping_method=stamping_method,
display_name=display_name,
is_cleaning=is_cleaning,
is_misc=is_misc,
current_mold_name=current_mold,
current_process_name=current_process,
session_id=session.id,
start_at=session.start_at,
end_at=session.end_at,
devices=unique_molds,
auto_submit_hours=auto_submit_hours,
)
if active_device is None:
return ClockState(
action="switch",
action_text="继续上工",
attendance_point_name=point_name,
device_no=mold,
mold_name=mold,
process_name=process,
stamping_method=stamping_method,
display_name=display_name,
is_cleaning=is_cleaning,
is_misc=is_misc,
current_mold_name=current_mold,
current_process_name=current_process,
session_id=session.id,
start_at=session.start_at,
end_at=session.end_at,
devices=unique_molds,
auto_submit_hours=auto_submit_hours,
)
action = "finish" if (is_misc or scanned_key == _mold_key(current_mold, current_process)) else "switch_or_finish"
return ClockState(
action=action,
action_text="下班打卡" if action == "finish" else "换模具或下班打卡",
attendance_point_name=point_name,
device_no=mold,
mold_name=mold,
process_name=process,
stamping_method=stamping_method,
display_name=display_name,
is_cleaning=is_cleaning,
is_misc=is_misc,
current_mold_name=current_mold,
current_process_name=current_process,
session_id=session.id,
start_at=session.start_at,
end_at=session.end_at,
devices=unique_molds,
auto_submit_hours=auto_submit_hours,
)
@router.post("/start", response_model=ClockSessionOut)
def start_work(
payload: ClockRequest,
user: Personnel = Depends(require_roles(Role.worker)),
db: Session = Depends(get_db),
) -> ClockSessionOut:
existing = _active_session(db, user)
if existing is not None:
raise HTTPException(status_code=409, detail="已有进行中的上班记录")
timestamp = now()
try:
point_name = require_attendance_point_access(db, user, payload.attendance_point_name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
mold = _request_mold_name(payload)
process = _request_process_name(payload)
if not mold:
raise HTTPException(status_code=400, detail="缺少模具名称")
product, process = _resolve_product_for_mold(db, point_name, mold, process)
if product is None:
raise HTTPException(status_code=403, detail=f"无该考勤点模具报工权限:{point_name}")
_require_clock_location(db, point_name, payload.latitude, payload.longitude)
with _mold_lock_context(db, point_name, mold, process, product):
ensure_mold_available(db, user, point_name, mold, process, product)
session = WorkSession(
attendance_point_name=point_name,
employee_phone=user.phone,
start_at=timestamp,
status=SessionStatus.active,
)
session.devices.append(
WorkSessionDevice(
device_no=mold,
attendance_point_name=point_name,
process_name=process,
scanned_at=timestamp,
sort_order=0,
)
)
db.add(session)
db.commit()
db.refresh(session)
return session_out(session, db)
@router.post("/switch-device", response_model=ClockSessionOut)
def switch_device(
payload: ClockRequest,
user: Personnel = Depends(require_roles(Role.worker)),
db: Session = Depends(get_db),
) -> ClockSessionOut:
session = _active_session(db, user)
if session is None or session.status != SessionStatus.active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有进行中的上班记录")
mold = _request_mold_name(payload)
process = _request_process_name(payload)
try:
point_name = require_attendance_point_access(db, user, payload.attendance_point_name or session.attendance_point_name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
if session.attendance_point_name != point_name:
raise HTTPException(status_code=400, detail="当前上班记录不属于该考勤点")
_require_clock_location(db, point_name, payload.latitude, payload.longitude)
if not mold:
raise HTTPException(status_code=400, detail="缺少模具名称")
product, process = _resolve_product_for_mold(db, point_name, mold, process)
if product is None:
raise HTTPException(status_code=403, detail=f"无该考勤点模具报工权限:{point_name}")
with _mold_lock_context(db, point_name, mold, process, product):
ensure_mold_available(db, user, point_name, mold, process, product)
ordered_devices = _ordered_devices(session)
current_device = ordered_devices[-1] if ordered_devices else None
current_key = _mold_key(current_device.device_no, current_device.process_name) if current_device else ("", "")
active_device = current_unreleased_device(session)
if active_device is None or _mold_key(mold, process) != current_key:
timestamp = now()
if active_device is not None:
active_device.released_at = timestamp
active_device.released_by = user.phone
active_device.release_reason = "换模具释放占用"
session.devices.append(
WorkSessionDevice(
attendance_point_name=point_name,
device_no=mold,
process_name=process,
scanned_at=timestamp,
sort_order=_next_sort_order(session),
)
)
db.commit()
db.refresh(session)
return session_out(session, db)
@router.post("/finish", response_model=ClockSessionOut)
def finish_work(
payload: ClockRequest,
user: Personnel = Depends(require_roles(Role.worker)),
db: Session = Depends(get_db),
) -> ClockSessionOut:
session = _active_session(db, user)
if session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有进行中的上班记录")
mold = _request_mold_name(payload)
try:
point_name = require_attendance_point_access(db, user, payload.attendance_point_name or session.attendance_point_name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
if session.attendance_point_name != point_name:
raise HTTPException(status_code=400, detail="当前上班记录不属于该考勤点")
_require_clock_location(db, point_name, payload.latitude, payload.longitude)
if not mold:
raise HTTPException(status_code=400, detail="缺少模具名称")
process = _request_process_name(payload)
timestamp = now()
if session.status == SessionStatus.reporting and not is_reporting_expired(session, timestamp):
raise HTTPException(status_code=409, detail="当前报工还在15分钟填写时间内,请继续报工")
if session.status not in [SessionStatus.active, SessionStatus.reporting]:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="没有进行中的上班记录")
product, process = _resolve_product_for_mold(db, point_name, mold, process)
if product is None:
raise HTTPException(status_code=403, detail=f"无该考勤点模具报工权限:{point_name}")
if is_misc_product(product) and session.status == SessionStatus.active:
ordered_devices = _ordered_devices(session)
current_device = ordered_devices[-1] if ordered_devices else None
if current_device is not None:
current_device.device_no = mold
current_device.process_name = process
session.end_at = timestamp
session.status = SessionStatus.reporting
db.commit()
db.refresh(session)
return session_out(session, db)
@router.post("/continue-work", response_model=ClockSessionOut)
def continue_work(
payload: ContinueWorkRequest,
user: Personnel = Depends(require_roles(Role.worker)),
db: Session = Depends(get_db),
) -> ClockSessionOut:
session = db.scalar(
select(WorkSession)
.options(selectinload(WorkSession.devices))
.where(
WorkSession.id == payload.session_id,
WorkSession.employee_phone == user.phone,
)
)
if session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工流程不存在")
if session.status != SessionStatus.reporting:
raise HTTPException(status_code=400, detail="当前流程不在下班报工填报中")
ordered_devices = _ordered_devices(session)
current_device = ordered_devices[-1] if ordered_devices else None
if current_device is not None:
product = _product_for_mold(
db,
session.attendance_point_name,
current_device.device_no,
current_device.process_name,
)
with _mold_lock_context(
db,
session.attendance_point_name,
current_device.device_no,
current_device.process_name,
product,
):
ensure_mold_available(
db,
user,
session.attendance_point_name,
current_device.device_no,
current_device.process_name,
product,
)
if current_device.released_at is not None:
session.devices.append(
WorkSessionDevice(
attendance_point_name=session.attendance_point_name,
device_no=current_device.device_no,
process_name=current_device.process_name,
scanned_at=now(),
sort_order=_next_sort_order(session),
)
)
session.end_at = None
session.status = SessionStatus.active
db.commit()
db.refresh(session)
return session_out(session, db)
+382
View File
@@ -0,0 +1,382 @@
from datetime import date
from math import ceil
from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.deps import require_roles
from app.models import (
Personnel,
Product,
ProductionReport,
ProductionReportItem,
ReportStatus,
Role,
WorkSession,
)
from app.schemas import DashboardRow, PageResponse
from app.services.cleaning import is_cleaning_item, is_cleaning_product
from app.services.common import as_float, round1, round2
from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product
from app.services.excel_export import export_dashboard_rows
from app.services.metrics import calculate_report_metrics, shift_distribution_text
from app.services.misc_work import is_misc_item, is_misc_product
from app.services.multi_person import is_multi_person_item
from app.services.report_lifecycle import purge_expired_voided_reports
from app.services.serializers import report_correction_maps
from app.services.work_schedule import get_work_schedule_config
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
def _product_for_item(
db: Session,
cache: dict[tuple[str, str, str, str, str], Product | None],
item: ProductionReportItem,
) -> Product | None:
key = (
item.attendance_point_name,
item.project_no,
item.product_name,
item.process_name or "",
"",
)
if key not in cache:
with db.no_autoflush:
cache[key] = db.scalar(
select(Product).where(
Product.project_no == item.project_no,
Product.attendance_point_name == item.attendance_point_name,
Product.product_name == item.product_name,
Product.process_name == (item.process_name or ""),
)
)
return cache[key]
REPORT_CORRECTION_LABELS = {
"start_at": "上班时间",
"end_at": "下班时间",
"review_remark": "备注",
}
ITEM_CORRECTION_LABELS = {
"device_no": "设备号",
"project_no": "项目号",
"product_name": "产品名称",
"raw_material_batch_no": "材料库存批次号",
"process_name": "工序",
"stamping_method": "冲压方式",
"process_unit_price_yuan": "工序单价",
"changeover_count": "换料次数",
"remark": "杂活事项",
"good_qty": "成品数量",
"defect_qty": "不良数量",
"scrap_qty": "报废数量",
}
def _correction_value(value) -> str | float:
if value is None:
return "-"
if hasattr(value, "isoformat"):
return value.isoformat()
return value
def _append_correction_pair(group: dict, key: tuple, label: str, old_value, new_value) -> None:
if key in group["correction_pair_keys"]:
return
group["correction_pair_keys"].add(key)
group["correction_pairs"].append(
{
"key": "__".join(str(part) for part in key),
"label": label,
"old_value": _correction_value(old_value),
"new_value": _correction_value(new_value),
}
)
def _append_dashboard_corrections(
group: dict,
report: ProductionReport,
item: ProductionReportItem,
report_corrections: dict,
item_corrections: dict,
) -> None:
for field, old_value in report_corrections.items():
label = REPORT_CORRECTION_LABELS.get(field, field)
_append_correction_pair(group, (report.id, field), label, old_value, getattr(report, field))
for field, old_value in item_corrections.items():
label = ITEM_CORRECTION_LABELS.get(field, field)
_append_correction_pair(group, (report.id, item.id, field), label, old_value, getattr(item, field))
def _dashboard_rows(
db: Session,
start_date: date | None = None,
end_date: date | None = None,
include_voided: bool = False,
) -> list[DashboardRow]:
query = (
select(ProductionReport)
.options(
selectinload(ProductionReport.employee),
selectinload(ProductionReport.items),
selectinload(ProductionReport.audit_logs),
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
)
.where(ProductionReport.status == ReportStatus.approved)
)
if not include_voided:
query = query.where(ProductionReport.is_voided.is_(False))
if start_date:
query = query.where(ProductionReport.report_date >= start_date)
if end_date:
query = query.where(ProductionReport.report_date <= end_date)
reports = db.scalars(query).all()
groups: dict[tuple, dict] = {}
product_cache: dict[tuple[str, str, str, str, str], Product | None] = {}
schedule_cache = {}
for report in reports:
if report.attendance_point_name not in schedule_cache:
schedule_cache[report.attendance_point_name] = get_work_schedule_config(db, report.attendance_point_name)
schedule = schedule_cache[report.attendance_point_name]
report_corrections, item_corrections, _device_corrections = report_correction_maps(report)
report_metrics = calculate_report_metrics(
report.start_at,
report.end_at,
list(report.items),
devices=report.session.devices if report.session else None,
schedule=schedule,
)
item_count = max(1, len(report.items))
fallback_minutes = as_float(report.effective_minutes) / item_count
employee_name = report.employee.name if report.employee else ""
worker_type = "临时工" if (report.employee and report.employee.is_temporary) else "正式工"
for item in report.items:
item_misc = is_misc_item(item)
process_name = (item.remark or item.process_name or "") if item_misc else (item.process_name or "")
product = _product_for_item(db, product_cache, item)
is_cleaning = is_cleaning_item(item) or is_cleaning_product(product)
is_misc = item_misc or is_misc_product(product)
is_continuous_die = is_continuous_die_item(item) or is_continuous_die_product(product)
standard_beat = 0 if (is_cleaning or is_misc) else as_float(product.standard_beat if product else item.standard_beat)
process_unit_price = as_float(item.process_unit_price_yuan)
stamping_method = item.stamping_method or (product.stamping_method if product else None)
key = (
report.attendance_point_name,
report.report_date,
employee_name,
report.employee_phone,
item.project_no,
item.product_name,
process_name,
stamping_method or "",
)
group = groups.setdefault(
key,
{
"id": "__".join(str(part) for part in key),
"attendance_point_name": report.attendance_point_name,
"report_date": report.report_date,
"employee_phone": report.employee_phone,
"employee_name": employee_name,
"worker_type": worker_type,
"project_no": item.project_no,
"product_name": item.product_name,
"process_name": process_name,
"report_ids": set(),
"effective_minutes": 0.0,
"shift_day_minutes": 0.0,
"shift_overtime_minutes": 0.0,
"shift_night_minutes": 0.0,
"total_good_qty": 0.0,
"total_defect_qty": 0.0,
"total_output_qty": 0.0,
"standard_beat": standard_beat,
"process_unit_price_yuan": process_unit_price,
"changeover_count": 0.0,
"reference_wage": 0.0,
"expected_workload": 0.0,
"profile_no": product.profile_no if product else None,
"material_code": product.material_code if product else item.material_code,
"material_name": product.material_name if product else item.material_name,
"raw_material_batch_numbers": set(),
"device_no": item.device_no,
"stamping_method": stamping_method,
"operator_count": as_float(product.operator_count) if product else as_float(item.operator_count),
"is_continuous_die": is_continuous_die,
"is_multi_person": is_multi_person_item(item),
"is_multi_person_assistant": False,
"is_misc": is_misc,
"is_system_auto_submitted": False,
"is_voided": False,
"review_remarks": [],
"review_remark_keys": set(),
"correction_pairs": [],
"correction_pair_keys": set(),
},
)
_append_dashboard_corrections(group, report, item, report_corrections, item_corrections.get(item.id, {}))
good_qty = as_float(item.good_qty)
defect_qty = as_float(item.defect_qty) + as_float(item.scrap_qty)
output_qty = good_qty + defect_qty
allocated_minutes = 0 if is_cleaning else (as_float(item.allocated_minutes) or fallback_minutes)
expected_workload = allocated_minutes * 60 / standard_beat if standard_beat > 0 else 0
group["report_ids"].add(report.id)
group["effective_minutes"] += allocated_minutes
group["expected_workload"] += expected_workload
group["shift_day_minutes"] += as_float(getattr(item, "_shift_day_minutes", 0))
group["shift_overtime_minutes"] += as_float(getattr(item, "_shift_overtime_minutes", 0))
group["shift_night_minutes"] += as_float(getattr(item, "_shift_night_minutes", 0))
group["total_good_qty"] += good_qty
group["reference_wage"] += good_qty * process_unit_price
group["changeover_count"] += as_float(item.changeover_count)
group["total_defect_qty"] += defect_qty
group["total_output_qty"] += output_qty
group["is_system_auto_submitted"] = (
group["is_system_auto_submitted"] or bool(report.is_system_auto_submitted)
)
group["is_voided"] = group["is_voided"] or bool(report.is_voided)
group["is_multi_person"] = group["is_multi_person"] or is_multi_person_item(item)
group["is_continuous_die"] = group["is_continuous_die"] or is_continuous_die
group["is_multi_person_assistant"] = (
group["is_multi_person_assistant"] or bool(report.is_multi_person_assistant)
)
if report.review_remark:
review_remark = str(report.review_remark).strip()
if review_remark and review_remark not in group["review_remark_keys"]:
group["review_remark_keys"].add(review_remark)
group["review_remarks"].append(review_remark)
if item.raw_material_batch_no:
group["raw_material_batch_numbers"].add(item.raw_material_batch_no)
rows: list[DashboardRow] = []
for group in groups.values():
actual_beat = (
group["effective_minutes"] * 60 / group["total_output_qty"]
if group["total_output_qty"] > 0
else 0
)
standard_beat = group["standard_beat"]
expected_workload = group["expected_workload"]
pace_rate = (actual_beat - standard_beat) / standard_beat * 100 if standard_beat > 0 else 0
workload_rate = (
(group["total_good_qty"] - expected_workload) / expected_workload * 100
if expected_workload > 0
else 0
)
shift_minutes = {
"day": group["shift_day_minutes"],
"overtime": group["shift_overtime_minutes"],
"night": group["shift_night_minutes"],
}
rows.append(
DashboardRow(
id=group["id"],
attendance_point_name=group["attendance_point_name"],
report_date=group["report_date"],
employee_phone=group["employee_phone"],
employee_name=group["employee_name"],
worker_type=group["worker_type"],
project_no=group["project_no"],
product_name=group["product_name"],
process_name=group["process_name"],
report_count=len(group["report_ids"]),
effective_minutes=round2(group["effective_minutes"]),
shift_distribution_text=shift_distribution_text(shift_minutes),
shift_day_minutes=round2(group["shift_day_minutes"]),
shift_overtime_minutes=round2(group["shift_overtime_minutes"]),
shift_night_minutes=round2(group["shift_night_minutes"]),
shift_other_minutes=0,
total_good_qty=round2(group["total_good_qty"]),
total_defect_qty=round2(group["total_defect_qty"]),
total_output_qty=round2(group["total_output_qty"]),
actual_beat=round2(actual_beat),
standard_beat=round2(standard_beat),
process_unit_price_yuan=round(as_float(group["process_unit_price_yuan"]), 4),
changeover_count=round2(group["changeover_count"]),
reference_wage=round2(group["reference_wage"]),
expected_workload=round1(expected_workload),
pace_rate=round1(pace_rate),
workload_rate=round1(workload_rate),
pace_text=f"{'慢' if pace_rate > 0 else '快'}{abs(round1(pace_rate))}%",
workload_text=f"{'多' if workload_rate >= 0 else '少'}{abs(round1(workload_rate))}%",
profile_no=group["profile_no"],
material_code=group["material_code"],
material_name=group["material_name"],
raw_material_batch_no="、".join(sorted(group["raw_material_batch_numbers"])) or None,
device_no=group["device_no"],
stamping_method=group["stamping_method"],
operator_count=group["operator_count"],
is_continuous_die=group["is_continuous_die"],
is_multi_person=group["is_multi_person"],
is_multi_person_assistant=group["is_multi_person_assistant"],
is_misc=group["is_misc"],
is_system_auto_submitted=group["is_system_auto_submitted"],
is_voided=group["is_voided"],
review_remark=";".join(group["review_remarks"]) or None,
correction_pairs=group["correction_pairs"],
)
)
rows.sort(
key=lambda item: (
item.report_date,
item.attendance_point_name,
item.employee_phone,
item.project_no,
item.product_name,
item.process_name or "",
),
reverse=True,
)
return rows
@router.get("/reports/export")
def export_dashboard_report_excel(
start_date: date | None = None,
end_date: date | None = None,
include_voided: bool = False,
_: Personnel = Depends(require_roles(Role.manager)),
db: Session = Depends(get_db),
) -> Response:
purge_expired_voided_reports(db)
content = export_dashboard_rows(_dashboard_rows(db, start_date, end_date, include_voided))
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="dashboard.xlsx"'},
)
@router.get("/reports", response_model=PageResponse)
def dashboard_reports(
start_date: date | None = None,
end_date: date | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
include_voided: bool = False,
_: Personnel = Depends(require_roles(Role.manager)),
db: Session = Depends(get_db),
) -> PageResponse:
purge_expired_voided_reports(db)
rows = _dashboard_rows(db, start_date, end_date, include_voided)
safe_page_size = min(100, max(1, page_size))
total = len(rows)
total_pages = max(1, ceil(total / safe_page_size))
start = (page - 1) * safe_page_size
return PageResponse(
page=page,
page_size=safe_page_size,
total=total,
total_pages=total_pages,
rows=rows[start : start + safe_page_size],
)
+505
View File
@@ -0,0 +1,505 @@
import hashlib
import ipaddress
import re
import zipfile
from datetime import datetime
from pathlib import Path
from urllib.parse import quote, unquote, urlparse
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.deps import current_user, require_roles
from app.models import DeviceQrBatchTask, DeviceQRCode, Personnel, Product, Role
from app.schemas import DeviceQrBatchCreate, DeviceQrBatchOut, DeviceQrBatchTaskOut, DeviceQrCreate, DeviceQrOut, PageResponse
from app.services.attendance_points import DEFAULT_ATTENDANCE_POINT_NAME, accessible_point_names, require_attendance_point_access
from app.services.cleaning import is_cleaning_product
from app.services.continuous_die import is_continuous_die_product
from app.services.display_names import mold_process_display_name, point_mold_display_name
from app.services.misc_work import is_misc_product
from app.services.qrcode_batch_tasks import (
QRCODE_TASK_STATUS_COMPLETED,
QRCODE_TASK_STATUS_FAILED,
QRCODE_TASK_STATUS_PAUSED,
QRCODE_TASK_STATUS_PENDING,
QRCODE_TASK_STATUS_RUNNING,
create_qrcode_batch_task,
qrcode_batch_task_out,
)
from app.services.wechat import WechatConfigError, create_device_qrcode
router = APIRouter(prefix="/api/devices", tags=["devices"])
def _clean_text(value: str | None) -> str:
return str(value or "").strip()
def _safe_file_stem(value: str) -> str:
normalized = re.sub(r'[\\/:*?"<>|\s]+', "_", value).strip("._")
return normalized[:120] or "模具二维码"
def _unique_zip_name(used_names: set[str], stem: str, suffix: str) -> str:
suffix = suffix if suffix else ".jpg"
name = f"{stem}{suffix}"
index = 2
while name in used_names:
name = f"{stem}_{index}{suffix}"
index += 1
used_names.add(name)
return name
def _qrcode_path_from_url(qr_url: str | None) -> Path | None:
if not qr_url:
return None
filename = unquote(Path(urlparse(qr_url).path).name)
if not filename:
return None
path = settings.upload_path / "qrcodes" / filename
return path if path.is_file() else None
def _is_local_request_base(base_url: str) -> bool:
host = urlparse(base_url).hostname or ""
if host in {"localhost", "127.0.0.1", "0.0.0.0"}:
return True
try:
address = ipaddress.ip_address(host)
except ValueError:
return False
return address.is_private or address.is_loopback
def _public_base_url(request: Request) -> str:
request_base_url = str(request.base_url).rstrip("/")
configured_base_url = settings.public_base_url.rstrip("/")
if settings.app_env != "production" and _is_local_request_base(request_base_url):
return request_base_url
return configured_base_url or request_base_url
def _product_for_mold(db: Session, point_name: str, mold_name: str, process_name: str) -> Product | None:
return db.query(Product).filter(
Product.attendance_point_name == point_name,
Product.product_name == mold_name,
Product.process_name == process_name,
Product.device_no == "",
).order_by(Product.project_no.asc()).first()
def _scene_token(point_name: str, mold_name: str, process_name: str) -> str:
mold_key = f"{point_name}\0{mold_name}\0{process_name}"
return hashlib.sha1(mold_key.encode("utf-8")).hexdigest()[:16]
def _legacy_process_names(process_name: str) -> list[str]:
process = _clean_text(process_name)
names: list[str] = []
if process.isdigit():
names.append(f"{int(process)}序")
names.append(f"{process}序")
return list(dict.fromkeys(name for name in names if name and name != process))
def _find_legacy_qrcode_scene(db: Session, token: str) -> DeviceQRCode | None:
rows = db.query(DeviceQRCode).all()
for row in rows:
for legacy_process in _legacy_process_names(row.process_name):
if _scene_token(row.attendance_point_name, row.device_no, legacy_process) == token:
return row
return None
async def _generate_mold_qrcode(
attendance_point_name: str,
mold_name: str,
process_name: str,
request: Request,
user: Personnel,
db: Session,
reuse_existing: bool = False,
allow_missing_config: bool = True,
commit: bool = True,
) -> DeviceQrOut:
try:
point_name = require_attendance_point_access(db, user, attendance_point_name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
original = _clean_text(mold_name)
process = _clean_text(process_name)
if not original:
raise HTTPException(status_code=400, detail="请选择模具名称")
if not process:
raise HTTPException(status_code=400, detail="请选择工序")
product = _product_for_mold(db, point_name, original, process)
if product is None:
raise HTTPException(status_code=400, detail="该考勤点下没有该产品、工序和冲压方式对应的模具")
page = "pages/clock/clock"
display_name = point_mold_display_name(point_name, original, process, product.stamping_method)
scene_token = _scene_token(point_name, original, process)
scene = f"mold={scene_token}"
record = db.get(
DeviceQRCode,
{"attendance_point_name": point_name, "device_no": original, "process_name": process},
)
if reuse_existing and record is not None and _qrcode_path_from_url(record.qr_url) is not None:
return DeviceQrOut(
device_no=original,
attendance_point_name=point_name,
mold_name=original,
process_name=process,
stamping_method=product.stamping_method if product else None,
is_cleaning=is_cleaning_product(product),
is_misc=is_misc_product(product),
is_continuous_die=is_continuous_die_product(product),
display_name=display_name,
scene=record.qr_scene or scene,
page=page,
qr_url=record.qr_url,
)
qr_url = None
try:
public_base_url = _public_base_url(request)
qr_url = await create_device_qrcode(
display_name,
page=page,
scene=scene,
public_base_url=public_base_url,
label=f"模具 {display_name}",
)
except WechatConfigError as exc:
if not allow_missing_config:
raise HTTPException(status_code=400, detail=str(exc)) from exc
qr_url = None
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
if record is None:
record = DeviceQRCode(
attendance_point_name=point_name,
device_no=original,
process_name=process,
created_by=user.phone,
)
db.add(record)
record.qr_scene = scene
record.qr_url = qr_url
if commit:
db.commit()
else:
db.flush()
return DeviceQrOut(
device_no=original,
attendance_point_name=point_name,
mold_name=original,
process_name=process,
stamping_method=product.stamping_method if product else None,
is_cleaning=is_cleaning_product(product),
is_misc=is_misc_product(product),
is_continuous_die=is_continuous_die_product(product),
display_name=display_name,
scene=scene,
page=page,
qr_url=qr_url,
)
@router.get("/resolve-scene", response_model=DeviceQrOut)
def resolve_qrcode_scene(
scene: str = Query(...),
user: Personnel = Depends(current_user),
db: Session = Depends(get_db),
) -> DeviceQrOut:
raw = str(scene or "").strip()
token = raw.split("=", 1)[1] if raw.startswith("mold=") else raw
record = db.query(DeviceQRCode).filter(DeviceQRCode.qr_scene == f"mold={token}").first()
if record is None:
record = _find_legacy_qrcode_scene(db, token)
if record is None:
raise HTTPException(status_code=404, detail="未找到模具二维码")
if user.role != Role.manager and record.attendance_point_name not in accessible_point_names(db, user):
raise HTTPException(status_code=403, detail="无该考勤点模具二维码权限")
product = _product_for_mold(db, record.attendance_point_name, record.device_no, record.process_name)
display_name = point_mold_display_name(
record.attendance_point_name,
record.device_no,
record.process_name,
product.stamping_method if product else None,
)
return DeviceQrOut(
device_no=record.device_no,
attendance_point_name=record.attendance_point_name,
mold_name=record.device_no,
process_name=record.process_name,
stamping_method=product.stamping_method if product else None,
is_cleaning=is_cleaning_product(product),
is_misc=is_misc_product(product),
is_continuous_die=is_continuous_die_product(product),
display_name=display_name,
scene=record.qr_scene,
page="pages/clock/clock",
qr_url=None,
)
@router.post("/molds/qrcode", response_model=DeviceQrOut)
async def generate_mold_process_qrcode(
payload: DeviceQrCreate,
request: Request,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> DeviceQrOut:
return await _generate_mold_qrcode(
payload.attendance_point_name,
payload.product_name,
payload.process_name,
request,
user,
db,
)
def _validated_batch_items(db: Session, user: Personnel, payload: DeviceQrBatchCreate) -> list[dict]:
user_point_names = set(accessible_point_names(db, user))
seen_keys: set[tuple[str, str, str]] = set()
normalized_items: list[dict] = []
for item in payload.items:
point_name = _clean_text(item.attendance_point_name) or DEFAULT_ATTENDANCE_POINT_NAME
if point_name not in user_point_names:
raise HTTPException(status_code=403, detail=f"无该考勤点权限:{point_name}")
mold_name = _clean_text(item.product_name)
process_name = _clean_text(item.process_name)
key = (point_name, mold_name, process_name)
if not mold_name or not process_name:
raise HTTPException(status_code=400, detail="批量生成二维码必须包含产品名称、工序和冲压方式")
if key in seen_keys:
raise HTTPException(status_code=400, detail=f"重复选择模具二维码:{point_name} / {mold_process_display_name(mold_name, process_name)}")
seen_keys.add(key)
normalized_items.append(
{
"attendance_point_name": point_name,
"product_name": mold_name,
"process_name": process_name,
}
)
if not normalized_items:
return []
requested_point_names = sorted({item["attendance_point_name"] for item in normalized_items})
requested_mold_names = sorted({item["product_name"] for item in normalized_items})
requested_process_names = sorted({item["process_name"] for item in normalized_items})
products = db.scalars(
select(Product)
.where(
Product.attendance_point_name.in_(requested_point_names),
Product.product_name.in_(requested_mold_names),
Product.process_name.in_(requested_process_names),
Product.device_no == "",
)
.order_by(Product.attendance_point_name.asc(), Product.product_name.asc(), Product.process_name.asc(), Product.project_no.asc())
).all()
product_by_key: dict[tuple[str, str, str], Product] = {}
for product in products:
product_by_key.setdefault(
(product.attendance_point_name, product.product_name, product.process_name or ""),
product,
)
for item in normalized_items:
key = (item["attendance_point_name"], item["product_name"], item["process_name"])
product = product_by_key.get(key)
if product is None:
raise HTTPException(
status_code=400,
detail=f"该考勤点下没有该模具:{key[0]} / {mold_process_display_name(key[1], key[2])}",
)
return normalized_items
@router.post("/molds/qrcode/batch/tasks", response_model=DeviceQrBatchTaskOut)
def create_mold_process_qrcode_batch_task(
payload: DeviceQrBatchCreate,
request: Request,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> DeviceQrBatchTaskOut:
task = create_qrcode_batch_task(
db,
created_by=user.phone,
items=_validated_batch_items(db, user, payload),
)
return qrcode_batch_task_out(task, _public_base_url(request))
@router.get("/molds/qrcode/batch/tasks", response_model=PageResponse)
def list_mold_process_qrcode_batch_tasks(
request: Request,
page: int = Query(1, ge=1),
page_size: int = Query(5, ge=1, le=20),
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> PageResponse:
query = (
db.query(DeviceQrBatchTask)
.filter(DeviceQrBatchTask.created_by == user.phone)
.order_by(DeviceQrBatchTask.created_at.desc(), DeviceQrBatchTask.id.desc())
)
total = query.count()
safe_page_size = max(1, min(20, page_size))
total_pages = max(1, (total + safe_page_size - 1) // safe_page_size)
safe_page = max(1, min(page, total_pages))
rows = query.offset((safe_page - 1) * safe_page_size).limit(safe_page_size).all()
return PageResponse(
page=safe_page,
page_size=safe_page_size,
total=total,
total_pages=total_pages,
rows=[qrcode_batch_task_out(row, _public_base_url(request)) for row in rows],
)
def _get_own_batch_task(db: Session, user: Personnel, task_id: int) -> DeviceQrBatchTask:
task = db.get(DeviceQrBatchTask, task_id)
if task is None or task.created_by != user.phone:
raise HTTPException(status_code=404, detail="二维码ZIP任务不存在")
return task
@router.post("/molds/qrcode/batch/tasks/{task_id}/stop", response_model=DeviceQrBatchTaskOut)
def stop_mold_process_qrcode_batch_task(
task_id: int,
request: Request,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> DeviceQrBatchTaskOut:
task = _get_own_batch_task(db, user, task_id)
if task.status in {QRCODE_TASK_STATUS_COMPLETED, QRCODE_TASK_STATUS_FAILED}:
return qrcode_batch_task_out(task, _public_base_url(request))
task.status = QRCODE_TASK_STATUS_PAUSED
task.error_message = None
task.finished_at = None
db.commit()
db.refresh(task)
return qrcode_batch_task_out(task, _public_base_url(request))
@router.post("/molds/qrcode/batch/tasks/{task_id}/resume", response_model=DeviceQrBatchTaskOut)
def resume_mold_process_qrcode_batch_task(
task_id: int,
request: Request,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> DeviceQrBatchTaskOut:
task = _get_own_batch_task(db, user, task_id)
if task.status == QRCODE_TASK_STATUS_COMPLETED:
return qrcode_batch_task_out(task, _public_base_url(request))
if task.status not in {QRCODE_TASK_STATUS_PAUSED, QRCODE_TASK_STATUS_FAILED}:
return qrcode_batch_task_out(task, _public_base_url(request))
if int(task.completed_count or 0) >= int(task.item_count or 0):
task.status = QRCODE_TASK_STATUS_COMPLETED
task.finished_at = task.finished_at or datetime.now()
else:
task.status = QRCODE_TASK_STATUS_PENDING
task.failed_count = 0
task.error_message = None
task.finished_at = None
db.commit()
db.refresh(task)
return qrcode_batch_task_out(task, _public_base_url(request))
@router.delete("/molds/qrcode/batch/tasks/{task_id}")
def delete_mold_process_qrcode_batch_task(
task_id: int,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> dict[str, bool]:
task = _get_own_batch_task(db, user, task_id)
file_path = settings.upload_path / "qrcode_zips" / task.file_name
task.status = QRCODE_TASK_STATUS_PAUSED
db.flush()
db.delete(task)
db.commit()
if file_path.exists():
file_path.unlink()
return {"ok": True}
@router.post("/molds/qrcode/batch", response_model=DeviceQrBatchOut)
async def generate_mold_process_qrcode_batch(
payload: DeviceQrBatchCreate,
request: Request,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> DeviceQrBatchOut:
_validated_batch_items(db, user, payload)
generated: list[tuple[DeviceQrOut, Path]] = []
for item in payload.items:
qr = await _generate_mold_qrcode(
item.attendance_point_name,
item.product_name,
item.process_name,
request,
user,
db,
reuse_existing=False,
allow_missing_config=False,
commit=False,
)
qr_path = _qrcode_path_from_url(qr.qr_url)
if qr_path is None:
raise HTTPException(status_code=400, detail=f"{qr.display_name} 二维码图片未生成,无法打包")
generated.append((qr, qr_path))
if not generated:
raise HTTPException(status_code=400, detail="请选择需要生成的模具二维码")
zip_dir = settings.upload_path / "qrcode_zips"
zip_dir.mkdir(parents=True, exist_ok=True)
fingerprint = hashlib.sha1(
"|".join(
f"{qr.attendance_point_name}\0{qr.mold_name}\0{qr.process_name}"
for qr, _ in generated
).encode("utf-8")
).hexdigest()[:10]
file_name = f"mold-qrcodes-{datetime.now().strftime('%Y%m%d%H%M%S')}-{fingerprint}.zip"
zip_path = zip_dir / file_name
used_names: set[str] = set()
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as archive:
for index, (qr, qr_path) in enumerate(generated, start=1):
stem = _safe_file_stem(f"{index:03d}_{qr.display_name or qr.mold_name}")
archive.write(qr_path, _unique_zip_name(used_names, stem, qr_path.suffix))
db.commit()
zip_url = f"{_public_base_url(request)}/uploads/qrcode_zips/{quote(file_name, safe='')}"
return DeviceQrBatchOut(count=len(generated), file_name=file_name, zip_url=zip_url)
@router.post("/molds/{mold_name}/qrcode", response_model=DeviceQrOut)
async def generate_mold_qrcode(
mold_name: str,
request: Request,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> DeviceQrOut:
return await _generate_mold_qrcode(accessible_point_names(db, user)[0], mold_name, "", request, user, db)
@router.post("/{device_no}/qrcode", response_model=DeviceQrOut)
async def generate_qrcode(
device_no: str,
request: Request,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> DeviceQrOut:
return await _generate_mold_qrcode(accessible_point_names(db, user)[0], device_no, "", request, user, db)
+108
View File
@@ -0,0 +1,108 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import require_roles
from app.models import Equipment, Personnel, Role
from app.schemas import EquipmentCreate, EquipmentOut, PageResponse
from app.services.attendance_points import accessible_point_names, require_attendance_point_access
from app.services.common import normalize_device_no, paginate
from app.services.serializers import equipment_out
router = APIRouter(prefix="/api/equipment", tags=["equipment"])
EQUIPMENT_TYPES = {"冲压设备", "清洗设备"}
@router.get("", response_model=PageResponse)
def list_equipment(
attendance_point_name: str = "",
keyword: str = "",
device_type: str = "",
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
user: Personnel = Depends(require_roles(Role.worker, Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> PageResponse:
point_names = accessible_point_names(db, user)
query = select(Equipment).where(Equipment.attendance_point_name.in_(point_names)).order_by(Equipment.updated_at.desc(), Equipment.device_no.asc())
if attendance_point_name:
try:
point_name = require_attendance_point_access(db, user, attendance_point_name)
except (ValueError, PermissionError) as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
query = query.where(Equipment.attendance_point_name == point_name)
if device_type:
query = query.where(Equipment.device_type == device_type)
if keyword:
like = f"%{keyword}%"
query = query.where(
or_(
Equipment.attendance_point_name.like(like),
Equipment.device_no.like(like),
Equipment.device_type.like(like),
Equipment.remark.like(like),
)
)
result = paginate(db, query, page, page_size)
return PageResponse(**{**result, "rows": [equipment_out(row) for row in result["rows"]]})
@router.post("", response_model=EquipmentOut)
def save_equipment(
payload: EquipmentCreate,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> EquipmentOut:
try:
point_name = require_attendance_point_access(db, user, payload.attendance_point_name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
device_no = normalize_device_no(payload.device_no)
if not device_no:
raise HTTPException(status_code=400, detail="请输入设备号")
if payload.device_type not in EQUIPMENT_TYPES:
raise HTTPException(status_code=400, detail="设备类型只能是冲压设备或清洗设备")
original_point_name = payload.original_attendance_point_name or point_name
original_device_no = normalize_device_no(payload.original_device_no) or device_no
original_key = {"attendance_point_name": original_point_name, "device_no": original_device_no}
next_key = {"attendance_point_name": point_name, "device_no": device_no}
equipment = db.get(Equipment, original_key)
if equipment is not None and original_key != next_key:
existed = db.get(Equipment, next_key)
if existed is not None:
raise HTTPException(status_code=409, detail="该设备号已存在,不能重复保存")
equipment.attendance_point_name = point_name
equipment.device_no = device_no
elif equipment is None:
equipment = db.get(Equipment, next_key)
if equipment is None:
equipment = Equipment(attendance_point_name=point_name, device_no=device_no)
db.add(equipment)
equipment.device_type = payload.device_type
equipment.remark = payload.remark
db.commit()
db.refresh(equipment)
return equipment_out(equipment)
@router.delete("/{device_no}", status_code=204)
def delete_equipment(
device_no: str,
attendance_point_name: str,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> None:
try:
point_name = require_attendance_point_access(db, user, attendance_point_name)
except (ValueError, PermissionError) as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
equipment = db.get(Equipment, {"attendance_point_name": point_name, "device_no": normalize_device_no(device_no)})
if equipment is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备不存在")
db.delete(equipment)
db.commit()
+192
View File
@@ -0,0 +1,192 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.deps import require_roles
from app.models import MoldLockFeedback, Personnel, Role, SessionStatus, WorkSession, WorkSessionDevice
from app.schemas import MoldLockFeedbackCreate, MoldLockFeedbackOut, MonitorLockOut, PageResponse
from app.services.attendance_points import accessible_point_names
from app.services.common import paginate
from app.services.mold_locks import (
FEEDBACK_STATUS_PENDING,
active_locks_for_points,
current_unreleased_device,
feedback_out,
lock_out,
release_lock,
)
from app.timezone import now
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
def _lock_query():
return (
select(WorkSessionDevice)
.options(
selectinload(WorkSessionDevice.session).selectinload(WorkSession.devices),
selectinload(WorkSessionDevice.session).selectinload(WorkSession.employee),
)
)
def _feedback_query():
return select(MoldLockFeedback).options(
selectinload(MoldLockFeedback.reporter),
selectinload(MoldLockFeedback.occupied_person),
selectinload(MoldLockFeedback.handler),
selectinload(MoldLockFeedback.session_device),
)
def _admin_point_names(db: Session, user: Personnel) -> list[str]:
return accessible_point_names(db, user)
def _get_admin_lock(db: Session, user: Personnel, lock_id: int) -> WorkSessionDevice:
lock = db.scalar(_lock_query().where(WorkSessionDevice.id == lock_id))
if lock is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="占用记录不存在")
if lock.attendance_point_name not in _admin_point_names(db, user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点监控权限")
return lock
def _get_admin_feedback(db: Session, user: Personnel, feedback_id: int) -> MoldLockFeedback:
feedback = db.scalar(_feedback_query().where(MoldLockFeedback.id == feedback_id))
if feedback is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="反馈不存在")
if feedback.attendance_point_name not in _admin_point_names(db, user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点监控权限")
return feedback
def _is_active_current_lock(lock: WorkSessionDevice) -> bool:
if (
lock.session is None
or lock.session.status not in [SessionStatus.active, SessionStatus.reporting]
or lock.released_at is not None
):
return False
current = current_unreleased_device(lock.session)
return bool(current and current.id == lock.id)
@router.get("/locks", response_model=list[MonitorLockOut])
def list_active_locks(
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> list[MonitorLockOut]:
return [lock_out(db, lock) for lock in active_locks_for_points(db, _admin_point_names(db, user))]
@router.post("/locks/{lock_id}/release")
def force_release_lock(
lock_id: int,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> dict[str, bool]:
_get_admin_lock(db, user, lock_id)
try:
release_lock(db, lock_id, user.phone, "管理员强制解除占用")
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
db.commit()
return {"ok": True}
@router.get("/feedbacks", response_model=PageResponse)
def list_feedbacks(
box: str = Query("unread", pattern="^(unread|read)$"),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=10),
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> PageResponse:
point_names = _admin_point_names(db, user)
query = _feedback_query().where(MoldLockFeedback.attendance_point_name.in_(point_names))
if box == "read":
query = query.where(MoldLockFeedback.read_at.is_not(None)).order_by(
MoldLockFeedback.read_at.desc(),
MoldLockFeedback.created_at.desc(),
)
else:
query = query.where(MoldLockFeedback.read_at.is_(None)).order_by(MoldLockFeedback.created_at.desc())
result = paginate(db, query, page, page_size)
return PageResponse(**{**result, "rows": [feedback_out(db, row) for row in result["rows"]]})
@router.post("/feedbacks", response_model=MoldLockFeedbackOut)
def create_feedback(
payload: MoldLockFeedbackCreate,
user: Personnel = Depends(require_roles(Role.worker)),
db: Session = Depends(get_db),
) -> MoldLockFeedbackOut:
lock = db.scalar(_lock_query().where(WorkSessionDevice.id == payload.lock_id))
if lock is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="占用记录不存在")
if lock.attendance_point_name not in accessible_point_names(db, user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点反馈权限")
if not _is_active_current_lock(lock):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该模具占用已解除")
if lock.session.employee_phone == user.phone:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能反馈自己的占用")
existing = db.scalar(
_feedback_query().where(
MoldLockFeedback.session_device_id == lock.id,
MoldLockFeedback.reporter_phone == user.phone,
MoldLockFeedback.status == FEEDBACK_STATUS_PENDING,
)
)
if existing is not None:
return feedback_out(db, existing)
feedback = MoldLockFeedback(
attendance_point_name=lock.attendance_point_name,
mold_name=lock.device_no,
process_name=lock.process_name,
session_device_id=lock.id,
reporter_phone=user.phone,
occupied_phone=lock.session.employee_phone,
status=FEEDBACK_STATUS_PENDING,
)
db.add(feedback)
db.commit()
feedback = db.scalar(_feedback_query().where(MoldLockFeedback.id == feedback.id))
if feedback is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="反馈保存失败")
return feedback_out(db, feedback)
@router.post("/feedbacks/{feedback_id}/read", response_model=MoldLockFeedbackOut)
def mark_feedback_read(
feedback_id: int,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> MoldLockFeedbackOut:
feedback = _get_admin_feedback(db, user, feedback_id)
if feedback.read_at is None:
feedback.read_at = now()
db.commit()
feedback = _get_admin_feedback(db, user, feedback_id)
return feedback_out(db, feedback)
@router.post("/feedbacks/{feedback_id}/release", response_model=MoldLockFeedbackOut)
def release_feedback_lock(
feedback_id: int,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> MoldLockFeedbackOut:
feedback = _get_admin_feedback(db, user, feedback_id)
if feedback.read_at is None:
feedback.read_at = now()
try:
release_lock(db, feedback.session_device_id, user.phone, "管理员通过反馈解除占用")
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
db.commit()
feedback = _get_admin_feedback(db, user, feedback_id)
return feedback_out(db, feedback)
+146
View File
@@ -0,0 +1,146 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.deps import current_user, require_roles
from app.models import Notice, NoticePoint, Personnel, Role
from app.schemas import NoticeCreate, NoticeOut, NoticeUpdate, PageResponse
from app.services.common import paginate
from app.services.attendance_points import accessible_point_names
router = APIRouter(prefix="/api/notices", tags=["notices"])
def _notice_out(notice: Notice) -> NoticeOut:
point_names = sorted([item.attendance_point_name for item in notice.attendance_points])
return NoticeOut(
id=notice.id,
title=notice.title,
content=notice.content,
attendance_point_names=point_names,
sort_order=notice.sort_order,
is_active=notice.is_active,
created_by=notice.created_by,
creator_name=notice.creator.name if notice.creator else "",
created_at=notice.created_at,
updated_at=notice.updated_at,
)
def _clean_text(value: str, field_name: str) -> str:
text = value.strip()
if not text:
raise HTTPException(status_code=400, detail=f"{field_name}不能为空")
return text
@router.get("", response_model=PageResponse)
def list_notices(
include_inactive: bool = False,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
user: Personnel = Depends(current_user),
db: Session = Depends(get_db),
) -> PageResponse:
if include_inactive and user.role not in (Role.admin, Role.manager):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
query = (
select(Notice)
.options(selectinload(Notice.creator), selectinload(Notice.attendance_points))
.order_by(Notice.sort_order.asc(), Notice.updated_at.desc())
)
if not include_inactive:
query = query.where(Notice.is_active.is_(True))
point_names = accessible_point_names(db, user)
query = query.where(Notice.attendance_points.any(NoticePoint.attendance_point_name.in_(point_names)))
result = paginate(db, query, page, page_size)
return PageResponse(**{**result, "rows": [_notice_out(row) for row in result["rows"]]})
@router.post("", response_model=NoticeOut)
def create_notice(
payload: NoticeCreate,
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> NoticeOut:
allowed = set(accessible_point_names(db, user))
point_names = payload.attendance_point_names or list(allowed)
denied = [name for name in point_names if name not in allowed]
if denied:
raise HTTPException(status_code=403, detail=f"无该考勤点通知配置权限:{'、'.join(denied)}")
notice = Notice(
title=_clean_text(payload.title, "标题"),
content=_clean_text(payload.content, "内容"),
sort_order=payload.sort_order,
is_active=payload.is_active,
created_by=user.phone,
)
for name in dict.fromkeys(point_names):
notice.attendance_points.append(NoticePoint(attendance_point_name=name))
db.add(notice)
db.commit()
db.refresh(notice)
return _notice_out(notice)
@router.put("/{notice_id}", response_model=NoticeOut)
def update_notice(
notice_id: int,
payload: NoticeUpdate,
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> NoticeOut:
notice = db.scalar(
select(Notice)
.options(selectinload(Notice.attendance_points))
.where(Notice.id == notice_id)
)
if notice is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
allowed = set(accessible_point_names(db, user))
if user.role == Role.admin and not any(item.attendance_point_name in allowed for item in notice.attendance_points):
raise HTTPException(status_code=403, detail="无该考勤点通知配置权限")
if payload.title is not None:
notice.title = _clean_text(payload.title, "标题")
if payload.content is not None:
notice.content = _clean_text(payload.content, "内容")
if payload.sort_order is not None:
notice.sort_order = payload.sort_order
if payload.is_active is not None:
notice.is_active = payload.is_active
if payload.attendance_point_names is not None:
denied = [name for name in payload.attendance_point_names if name not in allowed]
if denied:
raise HTTPException(status_code=403, detail=f"无该考勤点通知配置权限:{'、'.join(denied)}")
notice.attendance_points.clear()
db.flush()
for name in dict.fromkeys(payload.attendance_point_names or list(allowed)):
notice.attendance_points.append(NoticePoint(attendance_point_name=name))
db.commit()
db.refresh(notice)
return _notice_out(notice)
@router.delete("/{notice_id}", status_code=204)
def delete_notice(
notice_id: int,
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> None:
notice = db.scalar(
select(Notice)
.options(selectinload(Notice.attendance_points))
.where(Notice.id == notice_id)
)
if notice is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在")
allowed = set(accessible_point_names(db, user))
if user.role == Role.admin and not any(item.attendance_point_name in allowed for item in notice.attendance_points):
raise HTTPException(status_code=403, detail="无该考勤点通知配置权限")
db.delete(notice)
db.commit()
+163
View File
@@ -0,0 +1,163 @@
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import Response
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.deps import require_roles
from app.models import PersonAttendancePoint, PersonRole, Personnel, Role
from app.schemas import ImportResult, PageResponse, PersonnelCreate, PersonnelOut
from app.services.common import ROLE_NAMES
from app.services.attendance_points import accessible_point_names, set_person_attendance_points
from app.services.excel_export import export_people
from app.services.excel_import import ProductImportValidationError, import_people
from app.services.serializers import personnel_out, personnel_summary_out
router = APIRouter(prefix="/api/people", tags=["people"])
@router.get("", response_model=PageResponse)
def list_people(
keyword: str = "",
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> PageResponse:
point_names = accessible_point_names(db, user)
query = (
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points).selectinload(PersonAttendancePoint.attendance_point))
.where(Personnel.roles.any())
.order_by(Personnel.created_at.desc())
)
if user.role == Role.admin:
query = query.where(
Personnel.roles.any(PersonRole.role == Role.worker),
Personnel.attendance_points.any(PersonAttendancePoint.attendance_point_name.in_(point_names)),
)
if keyword:
like = f"%{keyword}%"
matched_roles = [role for role, name in ROLE_NAMES.items() if keyword in name or keyword in role]
query = query.where(
or_(
Personnel.phone.like(like),
Personnel.name.like(like),
Personnel.roles.any(PersonRole.role.in_(matched_roles)),
)
)
safe_page = max(1, page)
safe_page_size = min(100, max(1, page_size))
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
rows = db.scalars(query.limit(safe_page_size).offset((safe_page - 1) * safe_page_size)).all()
return PageResponse(
page=safe_page,
page_size=safe_page_size,
total=total,
total_pages=max(1, (total + safe_page_size - 1) // safe_page_size),
rows=[personnel_summary_out(row) for row in rows],
)
@router.post("", response_model=PersonnelOut)
def save_person(
payload: PersonnelCreate,
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> PersonnelOut:
if user.role == Role.admin and payload.role != Role.worker:
raise HTTPException(status_code=403, detail="管理员只能新增或导入冲压工人")
point_names = payload.attendance_point_names or accessible_point_names(db, user)
if user.role == Role.admin:
allowed = set(accessible_point_names(db, user))
denied = [name for name in point_names if name not in allowed]
if denied:
raise HTTPException(status_code=403, detail=f"无该考勤点人员维护权限:{'、'.join(denied)}")
phone = str(payload.phone or "").strip()
original_phone = str(payload.original_phone or "").strip()
if original_phone and original_phone != phone:
raise HTTPException(status_code=400, detail="编辑人员不能修改手机号")
person = db.get(Personnel, phone)
editing_existing = bool(original_phone)
active_role_count = db.scalar(select(func.count()).select_from(PersonRole).where(PersonRole.phone == phone)) or 0
if person is not None and not editing_existing and active_role_count > 0:
raise HTTPException(status_code=409, detail="该手机号已存在,不能重复添加")
if person is None:
person = Personnel(phone=phone, name=payload.name)
db.add(person)
db.flush()
else:
person.name = payload.name
person.is_temporary = False
person.temporary_expires_at = None
role = db.get(PersonRole, {"phone": phone, "role": payload.role})
if role is None:
role = PersonRole(phone=phone, role=payload.role)
db.add(role)
try:
set_person_attendance_points(db, phone, point_names)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
db.commit()
person = db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points).selectinload(PersonAttendancePoint.attendance_point))
.where(Personnel.phone == phone)
)
if person is None:
raise HTTPException(status_code=500, detail="人员保存失败")
return personnel_out(person, payload.role)
@router.delete("/{phone}", status_code=204)
def delete_person(
phone: str,
role: Role | None = None,
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> None:
person = db.get(Personnel, phone)
if person is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="人员不存在")
if user.role == Role.admin:
point_names = accessible_point_names(db, user)
if not any(item.attendance_point_name in point_names for item in person.attendance_points):
raise HTTPException(status_code=403, detail="无该考勤点人员维护权限")
if role is not None and role != Role.worker:
raise HTTPException(status_code=403, detail="管理员只能删除冲压工人")
if role is None:
db.query(PersonRole).filter(PersonRole.phone == phone).delete()
else:
person_role = db.get(PersonRole, {"phone": phone, "role": role})
if person_role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="角色不存在")
db.delete(person_role)
db.commit()
@router.post("/import", response_model=ImportResult)
async def import_people_excel(
file: UploadFile = File(...),
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> ImportResult:
content = await file.read()
try:
result = import_people(db, content, user=user)
except ProductImportValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return ImportResult(**result.as_dict())
@router.get("/export")
def export_people_excel(
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> Response:
content = export_people(db, user=user)
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="people.xlsx"'},
)
+360
View File
@@ -0,0 +1,360 @@
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import Response
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import require_roles
from app.models import DeviceQRCode, MoldLockFeedback, Personnel, Product, ProductionReportItem, Role, WorkSessionDevice
from app.schemas import ImportResult, MoldNameOut, PageResponse, ProductCreate, ProductOut
from app.services.attendance_points import accessible_point_names, require_attendance_point_access
from app.services.common import normalize_device_no, paginate
from app.services.excel_import import ProductImportValidationError, import_products
from app.services.excel_export import export_products
from app.services.cleaning import LEGACY_CLEANING_PROCESS_NAME, is_cleaning_stamping_method
from app.services.continuous_die import is_continuous_die_product
from app.services.display_names import mold_process_display_name
from app.services.misc_work import ensure_misc_work_products, is_misc_stamping_method
from app.services.multi_person import is_multi_person_product
from app.services.process_names import MISC_PROCESS_NAME, normalize_process_name
from app.services.serializers import product_out
router = APIRouter(prefix="/api/products", tags=["products"])
def _cascade_product_key_change(db: Session, original_key: dict, next_key: dict) -> None:
if original_key == next_key:
return
old_point = original_key["attendance_point_name"]
old_product = original_key["product_name"]
old_process = original_key["process_name"]
next_point = next_key["attendance_point_name"]
next_product = next_key["product_name"]
next_process = next_key["process_name"]
old_qr = db.get(DeviceQRCode, {
"attendance_point_name": old_point,
"device_no": old_product,
"process_name": old_process,
})
if old_qr is not None:
qr_target = db.get(DeviceQRCode, {
"attendance_point_name": next_point,
"device_no": next_product,
"process_name": next_process,
})
if qr_target is not None:
raise HTTPException(status_code=409, detail="目标产品/工序二维码已存在,不能合并覆盖")
old_qr.attendance_point_name = next_point
old_qr.device_no = next_product
old_qr.process_name = next_process
for device in db.scalars(select(WorkSessionDevice).where(
WorkSessionDevice.attendance_point_name == old_point,
WorkSessionDevice.device_no == old_product,
WorkSessionDevice.process_name == old_process,
)).all():
device.attendance_point_name = next_point
device.device_no = next_product
device.process_name = next_process
for item in db.scalars(select(ProductionReportItem).where(
ProductionReportItem.attendance_point_name == old_point,
ProductionReportItem.project_no == original_key["project_no"],
ProductionReportItem.product_name == old_product,
ProductionReportItem.process_name == old_process,
)).all():
item.attendance_point_name = next_point
item.project_no = next_key["project_no"]
item.product_name = next_product
item.process_name = next_process
for feedback in db.scalars(select(MoldLockFeedback).where(
MoldLockFeedback.attendance_point_name == old_point,
MoldLockFeedback.mold_name == old_product,
MoldLockFeedback.process_name == old_process,
)).all():
feedback.attendance_point_name = next_point
feedback.mold_name = next_product
feedback.process_name = next_process
@router.get("/molds", response_model=list[MoldNameOut])
def list_mold_names(
keyword: str = "",
limit: int = Query(100, ge=1, le=500),
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> list[MoldNameOut]:
point_names = accessible_point_names(db, user)
ensure_misc_work_products(db, point_names)
db.commit()
query = (
select(Product)
.where(
Product.attendance_point_name.in_(point_names),
Product.product_name != "",
Product.process_name != "",
Product.device_no == "",
)
.distinct()
.order_by(Product.attendance_point_name, Product.product_name, Product.process_name)
)
if keyword:
like = f"%{keyword}%"
query = query.where(or_(Product.product_name.like(like), Product.process_name.like(like), Product.stamping_method.like(like)))
rows = db.scalars(query.limit(limit)).all()
return [
MoldNameOut(
attendance_point_name=product.attendance_point_name,
name=product.product_name,
process_name=product.process_name or "",
stamping_method=product.stamping_method,
operator_count=product.operator_count,
is_cleaning=is_cleaning_stamping_method(product.stamping_method),
is_misc=is_misc_stamping_method(product.stamping_method),
is_continuous_die=is_continuous_die_product(product),
is_multi_person=is_multi_person_product(product),
display_name=mold_process_display_name(product.product_name, product.process_name, product.stamping_method),
)
for product in rows
]
@router.get("", response_model=PageResponse)
def list_products(
attendance_point_name: str = "",
keyword: str = "",
device_no: str = "",
product_name: str = "",
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
user: Personnel = Depends(require_roles(Role.worker, Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> PageResponse:
point_names = accessible_point_names(db, user)
ensure_misc_work_products(db, point_names)
db.commit()
query = (
select(Product)
.where(Product.device_no == "", Product.attendance_point_name.in_(point_names))
.order_by(
Product.updated_at.desc(),
Product.attendance_point_name.asc(),
Product.project_no.asc(),
Product.product_name.asc(),
Product.process_name.asc(),
)
)
if attendance_point_name:
try:
point_name = require_attendance_point_access(db, user, attendance_point_name)
except (ValueError, PermissionError) as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
query = query.where(Product.attendance_point_name == point_name)
if product_name:
query = query.where(Product.product_name == product_name)
if keyword:
like = f"%{keyword}%"
query = query.where(
or_(
Product.project_no.like(like),
Product.product_name.like(like),
Product.attendance_point_name.like(like),
Product.material_code.like(like),
Product.material_name.like(like),
Product.supplier.like(like),
Product.process_name.like(like),
Product.stamping_method.like(like),
)
)
result = paginate(db, query, page, page_size)
return PageResponse(**{**result, "rows": [product_out(row) for row in result["rows"]]})
@router.post("", response_model=ProductOut)
def save_product(
payload: ProductCreate,
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> ProductOut:
try:
point_name = require_attendance_point_access(db, user, payload.attendance_point_name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
raw_process_name = str(payload.process_name or "").strip()
if raw_process_name == LEGACY_CLEANING_PROCESS_NAME:
raise HTTPException(status_code=400, detail="清洗请填写在冲压方式中,工序只能填写数字,处理杂活除外")
try:
process_name = normalize_process_name(raw_process_name)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
is_cleaning = is_cleaning_stamping_method(payload.stamping_method)
is_misc = is_misc_stamping_method(payload.stamping_method)
if is_misc and process_name != MISC_PROCESS_NAME:
raise HTTPException(status_code=400, detail="处理杂活的工序必须填写杂活")
if not is_misc and process_name == MISC_PROCESS_NAME:
raise HTTPException(status_code=400, detail="只有处理杂活的工序可以填写杂活")
if not is_cleaning and not is_misc and payload.standard_beat <= 0:
raise HTTPException(status_code=400, detail="非清洗工序必须填写标准节拍")
next_key = {
"attendance_point_name": point_name,
"project_no": payload.project_no,
"product_name": payload.product_name,
"device_no": "",
"process_name": process_name,
}
original_key = {
"attendance_point_name": payload.original_attendance_point_name or point_name,
"project_no": payload.original_project_no or payload.project_no,
"product_name": payload.original_product_name or payload.product_name,
"device_no": normalize_device_no(payload.original_device_no if payload.original_device_no is not None else ""),
"process_name": payload.original_process_name if payload.original_process_name is not None else process_name,
}
product = db.get(Product, original_key)
if product is not None and original_key != next_key:
existed = db.get(Product, next_key)
if existed is not None:
raise HTTPException(status_code=409, detail="目标产品/工序已存在,不能合并覆盖")
else:
_cascade_product_key_change(db, original_key, next_key)
product.project_no = next_key["project_no"]
product.product_name = next_key["product_name"]
product.device_no = next_key["device_no"]
product.process_name = next_key["process_name"]
elif product is None:
product = db.get(Product, next_key)
if product is None:
product = Product(
attendance_point_name=next_key["attendance_point_name"],
project_no=next_key["project_no"],
product_name=next_key["product_name"],
device_no=next_key["device_no"],
process_name=next_key["process_name"],
)
db.add(product)
aux_values = (
payload.product_net_weight_kg,
payload.product_gross_weight_kg,
payload.scrap_loss_rate,
payload.waste_price_yuan_per_kg,
)
if all(value is None for value in aux_values):
sibling = next(
(
item for item in db.scalars(
select(Product).where(Product.product_name == next_key["product_name"], Product.device_no == "")
.where(Product.attendance_point_name == point_name)
).all()
if item.product_net_weight_kg is not None
and item.product_gross_weight_kg is not None
and item.scrap_loss_rate is not None
and item.waste_price_yuan_per_kg is not None
),
None,
)
if sibling is not None:
aux_values = (
sibling.product_net_weight_kg,
sibling.product_gross_weight_kg,
sibling.scrap_loss_rate,
sibling.waste_price_yuan_per_kg,
)
product.profile_no = payload.profile_no
product.attendance_point_name = next_key["attendance_point_name"]
product.material_code = payload.material_code
product.material_name = payload.material_name
product.supplier = payload.supplier
product.product_net_weight_kg = aux_values[0]
product.product_gross_weight_kg = aux_values[1]
product.scrap_loss_rate = aux_values[2]
product.waste_price_yuan_per_kg = aux_values[3]
product.device_no = next_key["device_no"]
product.process_name = next_key["process_name"]
product.stamping_method = payload.stamping_method
product.operator_count = payload.operator_count or 1
product.process_unit_price_yuan = payload.process_unit_price_yuan or 0
product.standard_beat = 0 if (is_cleaning or is_misc) else payload.standard_beat
product.standard_workload = 0
for sibling in db.scalars(select(Product).where(
Product.attendance_point_name == point_name,
Product.product_name == next_key["product_name"],
Product.device_no == "",
)).all():
sibling.product_net_weight_kg = product.product_net_weight_kg
sibling.product_gross_weight_kg = product.product_gross_weight_kg
sibling.scrap_loss_rate = product.scrap_loss_rate
sibling.waste_price_yuan_per_kg = product.waste_price_yuan_per_kg
db.commit()
db.refresh(product)
return product_out(product)
@router.delete("", status_code=204)
def delete_product(
attendance_point_name: str,
project_no: str,
product_name: str,
device_no: str = "",
process_name: str = "",
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> None:
try:
point_name = require_attendance_point_access(db, user, attendance_point_name)
except (ValueError, PermissionError) as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
product = db.get(
Product,
{
"attendance_point_name": point_name,
"project_no": project_no,
"product_name": product_name,
"device_no": "",
"process_name": process_name,
},
)
if product is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="产品不存在")
qrcode = db.get(DeviceQRCode, {
"attendance_point_name": point_name,
"device_no": product.product_name,
"process_name": product.process_name or "",
})
if qrcode is not None:
db.delete(qrcode)
db.delete(product)
db.commit()
@router.post("/import", response_model=ImportResult)
async def import_product_excel(
file: UploadFile = File(...),
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> ImportResult:
content = await file.read()
try:
result = import_products(db, content, user=user)
except ProductImportValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return ImportResult(**result.as_dict())
@router.get("/export")
def export_product_excel(
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> Response:
content = export_products(db, user=user)
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="products.xlsx"'},
)
+232
View File
@@ -0,0 +1,232 @@
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import extract, func, select
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import require_roles
from app.models import Personnel, Product, ProductionReport, ProductionReportItem, ReconciliationLedgerEntry, ReportStatus, Role
from app.schemas import (
ReconciliationEntryUpdate,
ReconciliationLedgerOut,
ReconciliationMonthCell,
ReconciliationRow,
ReconciliationYearsOut,
)
from app.services.common import as_float, round2
from app.services.cleaning import is_cleaning_product
from app.services.misc_work import is_misc_product
from app.services.process_names import process_order
from app.services.report_lifecycle import purge_expired_voided_reports
from app.timezone import today
router = APIRouter(prefix="/api/reconciliation", tags=["reconciliation"])
def _latest_processes_by_product(db: Session) -> dict[tuple[str, str], set[str]]:
products = db.scalars(
select(Product)
.where(Product.device_no == "", Product.product_name != "", Product.process_name != "")
.order_by(Product.attendance_point_name.asc(), Product.product_name.asc(), Product.process_name.asc())
).all()
latest: dict[tuple[str, str], tuple[tuple[int, str], set[str]]] = {}
for product in products:
if is_cleaning_product(product) or is_misc_product(product):
continue
point_name = str(product.attendance_point_name or "").strip()
product_name = str(product.product_name or "").strip()
process_name = str(product.process_name or "").strip()
if not point_name or not product_name or not process_name:
continue
order = process_order(process_name)
product_key = (point_name, product_name)
current = latest.get(product_key)
if current is None or order > current[0]:
latest[product_key] = (order, {process_name})
elif order == current[0]:
current[1].add(process_name)
return {product_key: process_names for product_key, (_order, process_names) in latest.items()}
def _month_range(year: int, month: int) -> tuple[date, date]:
start = date(year, month, 1)
if month == 12:
end = date(year, 12, 31)
else:
end = date(year, month + 1, 1)
return start, end
def _reported_good_quantities(
db: Session,
*,
year: int,
product_keys: list[tuple[str, str]],
latest_processes: dict[tuple[str, str], set[str]],
) -> dict[tuple[str, str, int], float]:
if not product_keys:
return {}
point_names = sorted({point_name for point_name, _product_name in product_keys})
product_names = sorted({product_name for _point_name, product_name in product_keys})
rows = db.execute(
select(
ProductionReportItem.attendance_point_name,
ProductionReportItem.product_name,
ProductionReportItem.process_name,
extract("month", ProductionReport.report_date).label("month"),
func.sum(ProductionReportItem.good_qty).label("good_qty"),
)
.join(ProductionReport, ProductionReport.id == ProductionReportItem.report_id)
.where(
ProductionReport.status == ReportStatus.approved,
ProductionReport.is_voided.is_(False),
extract("year", ProductionReport.report_date) == year,
ProductionReportItem.attendance_point_name.in_(point_names),
ProductionReportItem.product_name.in_(product_names),
)
.group_by(
ProductionReportItem.attendance_point_name,
ProductionReportItem.product_name,
ProductionReportItem.process_name,
extract("month", ProductionReport.report_date),
)
).all()
totals: dict[tuple[str, str, int], float] = {}
for point_name, product_name, process_name, month, good_qty in rows:
product_key = (point_name, product_name)
if str(process_name or "").strip() not in latest_processes.get(product_key, set()):
continue
key = (point_name, product_name, int(month))
totals[key] = round2(totals.get(key, 0) + as_float(good_qty))
return totals
def _ledger_quantities(db: Session, *, year: int) -> dict[tuple[str, str, int], dict[str, float]]:
entries = db.scalars(
select(ReconciliationLedgerEntry)
.where(ReconciliationLedgerEntry.year == year)
).all()
return {
(entry.attendance_point_name, entry.product_name, int(entry.month)): {
"reconciled_good_qty": as_float(entry.reconciled_good_qty),
"return_qty": as_float(entry.return_qty),
}
for entry in entries
}
@router.get("/years", response_model=ReconciliationYearsOut)
def list_reconciliation_years(
_: Personnel = Depends(require_roles(Role.manager)),
db: Session = Depends(get_db),
) -> ReconciliationYearsOut:
purge_expired_voided_reports(db)
current_year = today().year
years = {current_year}
report_years = db.execute(
select(extract("year", ProductionReport.report_date))
.where(
ProductionReport.status == ReportStatus.approved,
ProductionReport.is_voided.is_(False),
)
.distinct()
).all()
years.update(int(row[0]) for row in report_years if row[0])
ledger_years = db.execute(select(ReconciliationLedgerEntry.year).distinct()).all()
years.update(int(row[0]) for row in ledger_years if row[0])
return ReconciliationYearsOut(
years=sorted(year for year in years if year <= current_year),
current_year=current_year,
)
@router.get("", response_model=ReconciliationLedgerOut)
def get_reconciliation_ledger(
year: int = Query(..., ge=2000, le=2100),
_: Personnel = Depends(require_roles(Role.manager)),
db: Session = Depends(get_db),
) -> ReconciliationLedgerOut:
purge_expired_voided_reports(db)
current = today()
if year > current.year:
raise HTTPException(status_code=400, detail="不能查看未来年份")
latest_processes = _latest_processes_by_product(db)
product_keys = sorted(latest_processes)
reported = _reported_good_quantities(
db,
year=year,
product_keys=product_keys,
latest_processes=latest_processes,
)
ledger = _ledger_quantities(db, year=year)
visible_month_count = current.month if year == current.year else 12
rows = [
ReconciliationRow(
attendance_point_name=point_name,
product_name=product_name,
months=[
ReconciliationMonthCell(
month=month,
reported_good_qty=reported.get((point_name, product_name, month), 0),
reconciled_good_qty=ledger.get((point_name, product_name, month), {}).get("reconciled_good_qty", 0),
return_qty=ledger.get((point_name, product_name, month), {}).get("return_qty", 0),
)
for month in range(1, visible_month_count + 1)
],
)
for point_name, product_name in product_keys
]
return ReconciliationLedgerOut(
year=year,
current_year=current.year,
current_month=current.month,
rows=rows,
)
@router.put("/entry", response_model=ReconciliationMonthCell)
def save_reconciliation_entry(
payload: ReconciliationEntryUpdate,
user: Personnel = Depends(require_roles(Role.manager)),
db: Session = Depends(get_db),
) -> ReconciliationMonthCell:
current = today()
if payload.year > current.year or (payload.year == current.year and payload.month > current.month):
raise HTTPException(status_code=400, detail="不能维护未来月份")
product_exists = db.scalar(
select(Product.product_name)
.where(
Product.attendance_point_name == payload.attendance_point_name,
Product.product_name == payload.product_name,
)
.limit(1)
)
if not product_exists:
raise HTTPException(status_code=404, detail="产品不存在")
key = {
"attendance_point_name": payload.attendance_point_name,
"year": payload.year,
"month": payload.month,
"product_name": payload.product_name,
}
entry = db.get(ReconciliationLedgerEntry, key)
if entry is None:
entry = ReconciliationLedgerEntry(**key)
db.add(entry)
if payload.reconciled_good_qty is not None:
entry.reconciled_good_qty = round2(payload.reconciled_good_qty)
if payload.return_qty is not None:
entry.return_qty = round2(payload.return_qty)
entry.updated_by = user.phone
db.commit()
db.refresh(entry)
return ReconciliationMonthCell(
month=entry.month,
reported_good_qty=0,
reconciled_good_qty=as_float(entry.reconciled_good_qty),
return_qty=as_float(entry.return_qty),
)
+656
View File
@@ -0,0 +1,656 @@
from datetime import date, datetime
import re
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select, text
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.deps import current_user, require_roles
from app.models import (
Personnel,
Equipment,
Product,
ProductionReport,
ProductionReportItem,
ReportStatus,
Role,
SessionStatus,
WorkSession,
WorkSessionDevice,
)
from app.schemas import (
CleaningReportSubmitItem,
CleaningReportSubmitRequest,
PageResponse,
ReportDraftBlock,
ReportDraftItem,
ReportDraftOut,
ReportSubmitRequest,
ReportOut,
)
from app.services.attendance_points import require_attendance_point_access, validate_attendance_point_location
from app.services.attendance_points import accessible_point_names
from app.services.auto_submit import auto_submit_overdue_sessions
from app.services.cleaning import is_cleaning_item, is_cleaning_product
from app.services.common import as_float, normalize_device_no, paginate, round2
from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product
from app.services.display_names import mold_process_display_name
from app.services.metrics import calculate_report_metrics, minutes_between, release_at_caps_work_time
from app.services.misc_work import is_misc_product
from app.services.mold_locks import release_session_locks
from app.services.report_lifecycle import purge_expired_voided_reports
from app.services.reporting_window import REPORTING_EXPIRED_DETAIL, is_reporting_expired
from app.services.serializers import equipment_option, product_option, report_out
from app.services.work_schedule import get_work_schedule_config
from app.timezone import now, today
router = APIRouter(prefix="/api/reports", tags=["reports"])
CLEANING_DEVICE_LABEL = "清洗设备"
def _split_device_nos(value: str | None) -> list[str]:
return [
normalize_device_no(part)
for part in re.split(r"[、,,\s]+", str(value or "").strip())
if normalize_device_no(part)
]
def _cleaning_device_nos(item: CleaningReportSubmitItem) -> list[str]:
values: list[str] = []
for device_no in item.device_nos or []:
normalized = normalize_device_no(device_no)
if normalized and normalized not in values:
values.append(normalized)
for device_no in _split_device_nos(item.device_no):
if device_no not in values:
values.append(device_no)
return values
def _report_query():
return select(ProductionReport).options(
selectinload(ProductionReport.items),
selectinload(ProductionReport.audit_logs),
selectinload(ProductionReport.employee),
selectinload(ProductionReport.reviewer),
selectinload(ProductionReport.voider),
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
)
def _get_report_or_404(db: Session, report_id: int) -> ProductionReport:
report = db.scalar(_report_query().where(ProductionReport.id == report_id))
if report is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工记录不存在")
return report
def _ordered_session_devices(session: WorkSession):
return sorted(
session.devices,
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
)
def _mold_key(product_name: str, process_name: str | None) -> tuple[str, str]:
return (str(product_name or "").strip(), str(process_name or "").strip())
def _product_for_mold(db: Session, point_name: str, mold_name: str, process_name: str | None) -> Product | None:
return db.scalar(
select(Product)
.where(
Product.attendance_point_name == point_name,
Product.product_name == str(mold_name or "").strip(),
Product.process_name == str(process_name or "").strip(),
Product.device_no == "",
)
.order_by(Product.project_no.asc())
)
def _miniapp_work_order_short_code(work_order_no: str | None) -> str | None:
normalized = str(work_order_no or "").strip()
current_year = datetime.now().strftime("%Y")
if len(normalized) <= 8 or not normalized.startswith(current_year) or normalized[8] != "-":
return None
sequence = normalized[4:8]
if not sequence.isdigit():
return None
return f"JH{int(sequence):04d}"
def _raw_material_batch_options(
db: Session,
*,
project_no: str | None,
product_name: str | None,
process_name: str | None,
) -> list[str]:
normalized_project_no = str(project_no or "").strip()
normalized_product_name = str(product_name or "").strip()
normalized_process_name = str(process_name or "").strip()
if not normalized_project_no and not normalized_product_name:
return []
rows = db.execute(
text(
"""
select distinct ledger.material_lot_no
from pp_production_batch_ledger ledger
join md_item product on product.id = ledger.product_item_id
where ledger.material_lot_no is not null
and trim(ledger.material_lot_no) <> ''
and coalesce(ledger.miniapp_selectable, 1) = 1
and trim(coalesce(ledger.status, '')) = '在生产'
and (
(:product_name <> '' and :project_no <> '' and product.item_name = :product_name and product.item_code = :project_no)
or (:product_name <> '' and :project_no = '' and product.item_name = :product_name)
or (:product_name = '' and :project_no <> '' and product.item_code = :project_no)
)
order by ledger.material_lot_no asc
"""
),
{
"project_no": normalized_project_no,
"product_name": normalized_product_name,
"process_name": normalized_process_name,
},
).all()
options: list[str] = []
for row in rows:
lot_no = str(row[0] or "").strip()
if lot_no and lot_no not in options:
options.append(lot_no)
return options
def _cleaning_metrics(items: list[ProductionReportItem]) -> dict[str, float | str]:
total_good_qty = sum(as_float(item.good_qty) for item in items)
total_output_qty = sum(
as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty)
for item in items
)
return {
"duration_minutes": 0,
"effective_minutes": 0,
"shift_day_minutes": 0,
"shift_overtime_minutes": 0,
"shift_night_minutes": 0,
"shift_other_minutes": 0,
"shift_meal_break_minutes": 0,
"shift_distribution_text": "清洗报工不计工时",
"total_good_qty": round2(total_good_qty),
"total_output_qty": round2(total_output_qty),
"actual_beat": 0,
"standard_beat": 0,
"expected_workload": 0,
"pace_rate": 0,
"workload_rate": 0,
}
def _draft_mold_ranges(session: WorkSession) -> list[tuple[str, str, datetime, datetime, float]]:
if session.end_at is None:
return []
ordered_devices = _ordered_session_devices(session)
ranges_by_mold: dict[tuple[str, str], dict[str, datetime | float]] = {}
for index, device in enumerate(ordered_devices):
segment_start = session.start_at if index == 0 else max(session.start_at, device.scanned_at)
segment_end = session.end_at
if index + 1 < len(ordered_devices):
segment_end = min(session.end_at, ordered_devices[index + 1].scanned_at)
if release_at_caps_work_time(device) and device.released_at is not None:
segment_end = min(segment_end, device.released_at)
if segment_end < segment_start:
segment_end = segment_start
mold_key = _mold_key(device.device_no, device.process_name)
if mold_key not in ranges_by_mold:
ranges_by_mold[mold_key] = {
"start_at": segment_start,
"end_at": segment_end,
"duration_minutes": minutes_between(segment_start, segment_end),
}
continue
ranges_by_mold[mold_key]["start_at"] = min(ranges_by_mold[mold_key]["start_at"], segment_start)
ranges_by_mold[mold_key]["end_at"] = max(ranges_by_mold[mold_key]["end_at"], segment_end)
ranges_by_mold[mold_key]["duration_minutes"] = as_float(ranges_by_mold[mold_key]["duration_minutes"]) + minutes_between(segment_start, segment_end)
return [
(mold_name, process_name, value["start_at"], value["end_at"], round2(value["duration_minutes"]))
for (mold_name, process_name), value in ranges_by_mold.items()
]
@router.get("/draft", response_model=ReportDraftOut)
def get_report_draft(
session_id: int,
user: Personnel = Depends(require_roles(Role.worker)),
db: Session = Depends(get_db),
) -> ReportDraftOut:
session = db.scalar(
select(WorkSession)
.options(selectinload(WorkSession.devices), selectinload(WorkSession.employee))
.where(WorkSession.id == session_id)
)
if session is None or session.employee_phone != user.phone:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工流程不存在")
if session.status != SessionStatus.reporting or session.end_at is None:
raise HTTPException(status_code=400, detail="该流程还不能提交报工")
if is_reporting_expired(session):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=REPORTING_EXPIRED_DETAIL)
blocks: list[ReportDraftBlock] = []
equipment_options = [
equipment_option(equipment)
for equipment in db.scalars(
select(Equipment)
.where(
Equipment.attendance_point_name == session.attendance_point_name,
Equipment.device_type == "冲压设备",
)
.order_by(Equipment.device_no.asc())
).all()
]
for mold_name, process_name, block_start_at, block_end_at, block_duration_minutes in _draft_mold_ranges(session):
product_query = select(Product).where(
Product.attendance_point_name == session.attendance_point_name,
Product.product_name == mold_name,
)
if process_name:
product_query = product_query.where(Product.process_name == process_name)
products = db.scalars(product_query.order_by(Product.project_no, Product.process_name)).all()
options = [product_option(product) for product in products]
first = options[0] if options else None
block_is_misc = any(option.is_misc for option in options)
block_is_continuous_die = any(option.is_continuous_die for option in options)
raw_material_batch_options = (
_raw_material_batch_options(
db,
project_no=first.project_no,
product_name=first.product_name,
process_name=first.process_name,
)
if first
else []
)
blocks.append(
ReportDraftBlock(
attendance_point_name=session.attendance_point_name,
device_no=mold_name,
mold_name=mold_name,
process_name=process_name,
stamping_method=first.stamping_method if first else None,
display_name=mold_process_display_name(mold_name, process_name, first.stamping_method if first else None),
start_at=block_start_at,
end_at=block_end_at,
duration_minutes=block_duration_minutes,
options=options,
equipment_options=equipment_options,
is_misc=block_is_misc,
is_continuous_die=block_is_continuous_die,
items=[
ReportDraftItem(
device_no=mold_name if block_is_misc else "",
attendance_point_name=session.attendance_point_name,
project_no=first.project_no if first else "",
product_name=first.product_name if first else "",
material_code=first.material_code if first else None,
material_name=first.material_name if first else None,
raw_material_batch_no=None,
raw_material_batch_options=raw_material_batch_options,
process_name=first.process_name if first else None,
stamping_method=first.stamping_method if first else None,
operator_count=first.operator_count if first else 1,
process_unit_price_yuan=first.process_unit_price_yuan if first else 0,
changeover_count=0,
standard_beat=first.standard_beat if first else 0,
standard_workload=first.standard_workload if first else 0,
good_qty=0,
defect_qty=0,
is_misc=block_is_misc,
is_continuous_die=first.is_continuous_die if first else False,
)
],
)
)
return ReportDraftOut(
session_id=session.id,
attendance_point_name=session.attendance_point_name,
employee_phone=session.employee_phone,
employee_name=session.employee.name,
start_at=session.start_at,
end_at=session.end_at,
duration_minutes=round2(minutes_between(session.start_at, session.end_at)),
device_blocks=blocks,
)
@router.post("", response_model=ReportOut)
def submit_report(
payload: ReportSubmitRequest,
user: Personnel = Depends(require_roles(Role.worker)),
db: Session = Depends(get_db),
) -> ReportOut:
session = db.scalar(
select(WorkSession)
.options(selectinload(WorkSession.devices))
.where(WorkSession.id == payload.session_id)
)
if session is None or session.employee_phone != user.phone:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工流程不存在")
if session.status != SessionStatus.reporting or session.end_at is None:
raise HTTPException(status_code=400, detail="该流程还不能提交报工")
if is_reporting_expired(session):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=REPORTING_EXPIRED_DETAIL)
valid_molds = {_mold_key(device.device_no, device.process_name) for device in session.devices}
valid_equipment = {
equipment.device_no
for equipment in db.scalars(select(Equipment).where(Equipment.device_type == "冲压设备")).all()
if equipment.attendance_point_name == session.attendance_point_name
}
items: list[ProductionReportItem] = []
for block in payload.device_blocks:
mold_name = str(block.device_no or "").strip()
process_name = str(block.process_name or "").strip()
if _mold_key(mold_name, process_name) not in valid_molds:
product = _product_for_mold(db, session.attendance_point_name, mold_name, process_name)
mold_text = mold_process_display_name(mold_name, process_name, product.stamping_method if product else None)
raise HTTPException(status_code=400, detail=f"模具 {mold_text} 不在本次流程中")
for item in block.items:
product = db.scalar(
select(Product).where(
Product.attendance_point_name == session.attendance_point_name,
Product.project_no == item.project_no,
Product.product_name == item.product_name,
Product.process_name == (item.process_name or ""),
Product.device_no == "",
)
)
item_is_misc = is_misc_product(product) or item.is_misc
if not item_is_misc and item.good_qty + item.defect_qty + item.scrap_qty <= 0:
continue
device_no = normalize_device_no(item.device_no)
if item_is_misc:
device_no = mold_name
elif not device_no:
raise HTTPException(status_code=400, detail=f"{mold_name} 请选择设备号")
if not item_is_misc and valid_equipment and device_no not in valid_equipment:
raise HTTPException(status_code=400, detail=f"设备 {device_no} 不在设备清单中")
stamping_method = product.stamping_method if product else item.stamping_method
item_is_continuous_die = is_continuous_die_product(product) or is_continuous_die_item(item)
changeover_count = as_float(item.changeover_count)
if not item_is_continuous_die:
changeover_count = 0
if changeover_count < 0:
raise HTTPException(status_code=400, detail="换料次数不能为负数")
items.append(
ProductionReportItem(
attendance_point_name=session.attendance_point_name,
device_no=device_no,
project_no=product.project_no if product else item.project_no,
product_name=product.product_name if product else item.product_name,
material_code=product.material_code if product else item.material_code,
material_name=product.material_name if product else item.material_name,
raw_material_batch_no=item.raw_material_batch_no,
process_name=product.process_name if product else item.process_name,
stamping_method=stamping_method,
operator_count=product.operator_count if product else item.operator_count,
process_unit_price_yuan=product.process_unit_price_yuan if product else item.process_unit_price_yuan,
changeover_count=changeover_count,
standard_beat=0 if item_is_misc else item.standard_beat,
standard_workload=0,
good_qty=0 if item_is_misc else item.good_qty,
defect_qty=0 if item_is_misc else item.defect_qty,
scrap_qty=0 if item_is_misc else item.scrap_qty,
started_at=item.started_at,
remark=item.remark,
)
)
if not items:
raise HTTPException(status_code=400, detail="请至少填写一项产出数量")
schedule = get_work_schedule_config(db, session.attendance_point_name)
metrics = (
_cleaning_metrics(items)
if all(is_cleaning_item(item) for item in items)
else calculate_report_metrics(
session.start_at,
session.end_at,
items,
devices=session.devices,
schedule=schedule,
)
)
submitted_at = now()
report = ProductionReport(
attendance_point_name=session.attendance_point_name,
session_id=session.id,
employee_phone=user.phone,
report_date=today(),
start_at=session.start_at,
end_at=session.end_at,
duration_minutes=metrics["duration_minutes"],
break_minutes=0,
effective_minutes=metrics["effective_minutes"],
total_good_qty=metrics["total_good_qty"],
total_output_qty=metrics["total_output_qty"],
actual_beat=metrics["actual_beat"],
standard_beat=metrics["standard_beat"],
expected_workload=metrics["expected_workload"],
pace_rate=metrics["pace_rate"],
workload_rate=metrics["workload_rate"],
status=ReportStatus.pending,
submitted_at=submitted_at,
items=items,
)
release_session_locks(session, user.phone, "报工提交释放占用", submitted_at)
session.status = SessionStatus.submitted
db.add(report)
db.commit()
saved = _get_report_or_404(db, report.id)
return report_out(saved, schedule)
@router.post("/cleaning", response_model=ReportOut)
def submit_cleaning_report(
payload: CleaningReportSubmitRequest,
user: Personnel = Depends(require_roles(Role.worker)),
db: Session = Depends(get_db),
) -> ReportOut:
if not payload.items:
raise HTTPException(status_code=400, detail="请至少添加一个清洗产品")
requested_point = next((str(item.attendance_point_name or "").strip() for item in payload.items if item.attendance_point_name), "")
try:
point_name = require_attendance_point_access(db, user, requested_point)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
try:
validate_attendance_point_location(db, point_name, payload.latitude, payload.longitude, action_name="清洗报工")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
valid_cleaning_equipment = {
normalize_device_no(equipment.device_no)
for equipment in db.scalars(
select(Equipment).where(
Equipment.attendance_point_name == point_name,
Equipment.device_type == "清洗设备",
)
).all()
}
grouped: dict[tuple[str, str, str, str], float] = {}
products_by_key: dict[tuple[str, str, str, str], Product] = {}
for item in payload.items:
item_point_name = str(item.attendance_point_name or point_name).strip()
if item_point_name != point_name:
raise HTTPException(status_code=400, detail="清洗报工只能提交同一考勤点的产品")
device_nos = _cleaning_device_nos(item)
if not device_nos:
raise HTTPException(status_code=400, detail="请选择清洗设备号")
invalid_device_nos = [device_no for device_no in device_nos if device_no not in valid_cleaning_equipment]
if invalid_device_nos:
raise HTTPException(status_code=400, detail=f"设备 {'、'.join(invalid_device_nos)} 不是清洗设备")
product_name = str(item.product_name or "").strip()
process_name = str(item.process_name or "").strip()
product = db.scalar(
select(Product)
.where(
Product.attendance_point_name == point_name,
Product.product_name == product_name,
Product.process_name == process_name,
Product.device_no == "",
)
.order_by(Product.project_no.asc())
)
if product is None:
raise HTTPException(status_code=400, detail=f"{mold_process_display_name(product_name, process_name, item.stamping_method)} 不在产品清单中")
if not is_cleaning_product(product):
raise HTTPException(status_code=400, detail=f"{mold_process_display_name(product_name, process_name, product.stamping_method)} 的冲压方式不是清洗")
device_no_text = "、".join(device_nos)
key = (device_no_text, product.project_no, product.product_name, product.process_name)
products_by_key[key] = product
grouped[key] = (
grouped.get(key, 0.0) + as_float(item.quantity)
)
report_items: list[ProductionReportItem] = []
for key, quantity in grouped.items():
if quantity <= 0:
continue
device_no, _project_no, _product_name, _process_name = key
product = products_by_key[key]
report_items.append(
ProductionReportItem(
attendance_point_name=point_name,
device_no=device_no,
project_no=product.project_no,
product_name=product.product_name,
material_code=product.material_code,
material_name=product.material_name,
raw_material_batch_no=None,
process_name=product.process_name,
stamping_method=product.stamping_method,
operator_count=product.operator_count,
process_unit_price_yuan=product.process_unit_price_yuan,
changeover_count=0,
standard_beat=0,
standard_workload=0,
good_qty=quantity,
defect_qty=0,
scrap_qty=0,
allocated_minutes=0,
)
)
if not report_items:
raise HTTPException(status_code=400, detail="请填写清洗数量")
timestamp = now()
session = WorkSession(
attendance_point_name=point_name,
employee_phone=user.phone,
start_at=timestamp,
end_at=timestamp,
status=SessionStatus.submitted,
)
for index, item in enumerate(report_items):
session.devices.append(
WorkSessionDevice(
attendance_point_name=point_name,
device_no=item.product_name,
process_name=item.process_name or "",
scanned_at=timestamp,
sort_order=index,
)
)
metrics = _cleaning_metrics(report_items)
report = ProductionReport(
attendance_point_name=point_name,
session=session,
employee_phone=user.phone,
report_date=today(),
start_at=timestamp,
end_at=timestamp,
duration_minutes=0,
break_minutes=0,
effective_minutes=0,
total_good_qty=metrics["total_good_qty"],
total_output_qty=metrics["total_output_qty"],
actual_beat=0,
standard_beat=0,
expected_workload=0,
pace_rate=0,
workload_rate=0,
status=ReportStatus.pending,
submitted_at=timestamp,
items=report_items,
)
db.add(report)
db.commit()
saved = _get_report_or_404(db, report.id)
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
@router.get("/mine", response_model=PageResponse)
def list_my_reports(
start_date: date | None = None,
end_date: date | None = None,
status_filter: ReportStatus | None = Query(None, alias="status"),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
include_voided: bool = False,
user: Personnel = Depends(current_user),
db: Session = Depends(get_db),
) -> PageResponse:
purge_expired_voided_reports(db)
if user.role == Role.worker:
auto_submit_overdue_sessions(db, employee_phone=user.phone)
query = _report_query().where(ProductionReport.employee_phone == user.phone)
if not include_voided:
query = query.where(ProductionReport.is_voided.is_(False))
if start_date:
query = query.where(ProductionReport.report_date >= start_date)
if end_date:
query = query.where(ProductionReport.report_date <= end_date)
if status_filter:
query = query.where(ProductionReport.status == status_filter)
query = query.order_by(ProductionReport.report_date.desc(), ProductionReport.submitted_at.desc())
result = paginate(db, query, page, page_size)
return PageResponse(**{
**result,
"rows": [report_out(row, get_work_schedule_config(db, row.attendance_point_name)) for row in result["rows"]],
})
@router.get("/{report_id}", response_model=ReportOut)
def get_report(
report_id: int,
user: Personnel = Depends(current_user),
db: Session = Depends(get_db),
) -> ReportOut:
purge_expired_voided_reports(db)
if user.role == Role.worker:
auto_submit_overdue_sessions(db, employee_phone=user.phone)
report = _get_report_or_404(db, report_id)
if user.role == Role.worker and report.employee_phone != user.phone:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
if user.role == Role.admin and report.attendance_point_name not in accessible_point_names(db, user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点权限")
return report_out(report, get_work_schedule_config(db, report.attendance_point_name))
+618
View File
@@ -0,0 +1,618 @@
from datetime import date, datetime
import re
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.deps import require_roles
from app.models import (
AuditAction,
Equipment,
Personnel,
Product,
ProductionReport,
ProductionReportItem,
ReportAuditLog,
ReportStatus,
Role,
WorkSession,
WorkSessionDevice,
)
from app.schemas import ApproveReportRequest, PageResponse, RejectReportRequest, ReportOut
from app.services.attendance_points import accessible_point_names
from app.services.auto_submit import auto_submit_overdue_sessions
from app.services.cleaning import is_cleaning_item, is_cleaning_product
from app.services.common import as_float, normalize_device_no, paginate
from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product
from app.services.display_names import mold_process_display_name
from app.services.metrics import calculate_report_metrics
from app.services.misc_work import is_misc_item, is_misc_product
from app.services.report_lifecycle import (
can_edit_report,
mark_report_voided,
purge_expired_voided_reports,
restore_voided_report,
)
from app.services.serializers import report_out
from app.services.work_schedule import WorkScheduleConfig, get_work_schedule_config
from app.timezone import now
router = APIRouter(prefix="/api/reviews", tags=["reviews"])
def _minute_picker_noise(left: datetime | None, right: datetime | None) -> bool:
if left is None or right is None:
return left is right
return left.replace(second=0, microsecond=0) == right.replace(second=0, microsecond=0)
def _effective_review_time(payload_value: datetime | None, current_value: datetime) -> datetime:
if payload_value is None:
return current_value
return current_value if _minute_picker_noise(payload_value, current_value) else payload_value
def _split_device_nos(value: str | None) -> list[str]:
values: list[str] = []
for part in re.split(r"[、,,\s]+", str(value or "").strip()):
normalized = normalize_device_no(part)
if normalized and normalized not in values:
values.append(normalized)
return values
def _validate_cleaning_report_devices(db: Session, report: ProductionReport) -> None:
valid_cleaning_equipment = {
normalize_device_no(equipment.device_no)
for equipment in db.scalars(
select(Equipment).where(
Equipment.attendance_point_name == report.attendance_point_name,
Equipment.device_type == "清洗设备",
)
).all()
}
if not valid_cleaning_equipment:
raise HTTPException(status_code=400, detail="该考勤点暂无清洗设备,请先在设备管理中维护")
for item in report.items:
device_nos = _split_device_nos(item.device_no)
if not device_nos:
raise HTTPException(status_code=400, detail="清洗报工请选择清洗设备号")
invalid_device_nos = [device_no for device_no in device_nos if device_no not in valid_cleaning_equipment]
if invalid_device_nos:
raise HTTPException(status_code=400, detail=f"设备 {'、'.join(invalid_device_nos)} 不是该考勤点的清洗设备")
def _validate_normal_report_devices(db: Session, report: ProductionReport) -> None:
normal_items = [
item
for item in report.items
if not is_cleaning_item(item) and not is_misc_item(item)
]
if not normal_items:
return
valid_press_equipment = {
normalize_device_no(equipment.device_no)
for equipment in db.scalars(
select(Equipment).where(
Equipment.attendance_point_name == report.attendance_point_name,
Equipment.device_type == "冲压设备",
)
).all()
}
if not valid_press_equipment:
raise HTTPException(status_code=400, detail="该考勤点暂无冲压设备,请先在设备管理中维护")
for item in normal_items:
device_no = normalize_device_no(item.device_no)
if not device_no:
raise HTTPException(
status_code=400,
detail=f"{mold_process_display_name(item.product_name, item.process_name, item.stamping_method)} 请填写设备号",
)
if device_no not in valid_press_equipment:
raise HTTPException(status_code=400, detail=f"设备 {device_no} 不是该考勤点的冲压设备")
def _report_query():
return select(ProductionReport).options(
selectinload(ProductionReport.items),
selectinload(ProductionReport.audit_logs),
selectinload(ProductionReport.employee),
selectinload(ProductionReport.reviewer),
selectinload(ProductionReport.voider),
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
)
def _get_report_or_404(db: Session, report_id: int) -> ProductionReport:
report = db.scalar(_report_query().where(ProductionReport.id == report_id))
if report is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工记录不存在")
return report
def _snapshot(report: ProductionReport) -> dict:
return {
"id": report.id,
"status": str(report.status),
"start_at": report.start_at.isoformat(),
"end_at": report.end_at.isoformat(),
"break_minutes": float(report.break_minutes or 0),
"effective_minutes": float(report.effective_minutes or 0),
"total_good_qty": float(report.total_good_qty or 0),
"actual_beat": float(report.actual_beat or 0),
"review_remark": report.review_remark,
"is_multi_person_assistant": bool(report.is_multi_person_assistant),
"multi_person_source_report_id": report.multi_person_source_report_id,
"devices": [
{
"id": device.id,
"device_no": device.device_no,
"process_name": device.process_name,
"scanned_at": device.scanned_at.isoformat(),
"released_at": device.released_at.isoformat() if device.released_at else None,
"sort_order": int(device.sort_order or 0),
}
for device in _ordered_session_devices(report.session)
],
"items": [
{
"id": item.id,
"device_no": item.device_no,
"project_no": item.project_no,
"product_name": item.product_name,
"raw_material_batch_no": item.raw_material_batch_no,
"process_name": item.process_name,
"stamping_method": item.stamping_method,
"operator_count": float(item.operator_count or 1),
"process_unit_price_yuan": float(item.process_unit_price_yuan or 0),
"changeover_count": float(item.changeover_count or 0),
"remark": item.remark,
"good_qty": float(item.good_qty or 0),
"defect_qty": float(item.defect_qty or 0),
"scrap_qty": float(item.scrap_qty or 0),
}
for item in report.items
],
}
def _ordered_session_devices(session: WorkSession | None) -> list[WorkSessionDevice]:
return sorted(
getattr(session, "devices", []) or [],
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
)
def _device_display_name(db: Session, report: ProductionReport, device: WorkSessionDevice) -> str:
with db.no_autoflush:
product = db.scalar(
select(Product)
.where(
Product.attendance_point_name == report.attendance_point_name,
Product.product_name == device.device_no,
Product.process_name == (device.process_name or ""),
Product.device_no == "",
)
.order_by(Product.project_no.asc())
)
return mold_process_display_name(
device.device_no,
device.process_name,
product.stamping_method if product else None,
)
def _apply_device_corrections(
db: Session,
report: ProductionReport,
payload: ApproveReportRequest,
next_start_at: datetime,
next_end_at: datetime,
) -> tuple[bool, datetime]:
if not payload.device_corrections and report.session is None:
return False, next_start_at
if report.session is None:
raise HTTPException(status_code=400, detail="该报工缺少扫码模具记录,不能校正换模具时间")
ordered_devices = _ordered_session_devices(report.session)
if not ordered_devices:
return False, next_start_at
first_device = ordered_devices[0]
corrected_start_at = next_start_at
pending_scanned_at: dict[int, datetime] = {}
has_correction = False
if not payload.device_corrections:
pending_scanned_at[first_device.id] = corrected_start_at
else:
device_map = {device.id: device for device in ordered_devices}
seen_ids: set[int] = set()
for correction in payload.device_corrections:
if correction.id in seen_ids:
raise HTTPException(status_code=400, detail="同一换模具时间不能重复校正")
seen_ids.add(correction.id)
device = device_map.get(correction.id)
if device is None:
raise HTTPException(status_code=400, detail=f"换模具记录 {correction.id} 不存在")
next_scanned_at = _effective_review_time(correction.scanned_at, device.scanned_at)
if device.id == first_device.id:
corrected_start_at = next_scanned_at
elif next_scanned_at < corrected_start_at or next_scanned_at > next_end_at:
raise HTTPException(status_code=400, detail="换模具时间必须在上下班时间范围内")
pending_scanned_at[device.id] = next_scanned_at
pending_scanned_at.setdefault(first_device.id, corrected_start_at)
if corrected_start_at >= next_end_at:
raise HTTPException(status_code=400, detail="下班时间必须晚于上班时间")
for device_id, next_scanned_at in pending_scanned_at.items():
if next_scanned_at > next_end_at:
raise HTTPException(status_code=400, detail="换模具时间必须在上下班时间范围内")
device = next(device for device in ordered_devices if device.id == device_id)
if next_scanned_at == device.scanned_at:
continue
device.scanned_at = next_scanned_at
has_correction = True
ordered_devices = _ordered_session_devices(report.session)
for index in range(1, len(ordered_devices)):
previous = ordered_devices[index - 1]
current = ordered_devices[index]
if current.scanned_at <= previous.scanned_at:
previous_name = _device_display_name(db, report, previous)
current_name = _device_display_name(db, report, current)
raise HTTPException(
status_code=400,
detail=f"换模具时间顺序不正确:{current_name} 必须晚于 {previous_name}",
)
return has_correction, corrected_start_at
def _is_cleaning_report(report: ProductionReport) -> bool:
return bool(report.items) and all(is_cleaning_item(item) for item in report.items)
def _apply_metrics(report: ProductionReport, schedule: WorkScheduleConfig | None = None) -> None:
if _is_cleaning_report(report):
total_good_qty = sum(as_float(item.good_qty) for item in report.items)
total_output_qty = sum(
as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty)
for item in report.items
)
report.duration_minutes = 0
report.effective_minutes = 0
report.total_good_qty = total_good_qty
report.total_output_qty = total_output_qty
report.actual_beat = 0
report.standard_beat = 0
report.expected_workload = 0
report.pace_rate = 0
report.workload_rate = 0
for item in report.items:
item.allocated_minutes = 0
return
metrics = calculate_report_metrics(
report.start_at,
report.end_at,
report.items,
devices=report.session.devices if report.session else None,
schedule=schedule,
)
report.duration_minutes = metrics["duration_minutes"]
report.effective_minutes = metrics["effective_minutes"]
report.total_good_qty = metrics["total_good_qty"]
report.total_output_qty = metrics["total_output_qty"]
report.actual_beat = metrics["actual_beat"]
report.standard_beat = metrics["standard_beat"]
report.expected_workload = metrics["expected_workload"]
report.pace_rate = metrics["pace_rate"]
report.workload_rate = metrics["workload_rate"]
@router.get("/pending", response_model=PageResponse)
def list_pending_reports(
start_date: date | None = None,
end_date: date | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
include_voided: bool = False,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> PageResponse:
purge_expired_voided_reports(db)
auto_submit_overdue_sessions(db)
query = (
_report_query()
.where(
ProductionReport.status == ReportStatus.pending,
ProductionReport.attendance_point_name.in_(accessible_point_names(db, user)),
)
)
if not include_voided:
query = query.where(ProductionReport.is_voided.is_(False))
if start_date:
query = query.where(ProductionReport.report_date >= start_date)
if end_date:
query = query.where(ProductionReport.report_date <= end_date)
query = query.order_by(ProductionReport.submitted_at.asc())
result = paginate(db, query, page, page_size)
return PageResponse(**{
**result,
"rows": [report_out(row, get_work_schedule_config(db, row.attendance_point_name)) for row in result["rows"]],
})
@router.get("/mine", response_model=PageResponse)
def list_my_review_records(
start_date: date | None = None,
end_date: date | None = None,
status_filter: ReportStatus | None = Query(None, alias="status"),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
include_voided: bool = False,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> PageResponse:
purge_expired_voided_reports(db)
query = _report_query().where(ProductionReport.attendance_point_name.in_(accessible_point_names(db, user)))
if not include_voided:
query = query.where(ProductionReport.is_voided.is_(False))
if start_date:
query = query.where(ProductionReport.report_date >= start_date)
if end_date:
query = query.where(ProductionReport.report_date <= end_date)
if status_filter:
query = query.where(ProductionReport.status == status_filter)
else:
query = query.where(ProductionReport.status != ReportStatus.pending)
query = query.order_by(ProductionReport.reviewed_at.desc())
result = paginate(db, query, page, page_size)
return PageResponse(**{
**result,
"rows": [report_out(row, get_work_schedule_config(db, row.attendance_point_name)) for row in result["rows"]],
})
@router.post("/{report_id}/approve", response_model=ReportOut)
def approve_report(
report_id: int,
payload: ApproveReportRequest,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> ReportOut:
report = _get_report_or_404(db, report_id)
if report.attendance_point_name not in accessible_point_names(db, user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点审核权限")
if report.is_voided:
raise HTTPException(status_code=400, detail="作废期间的报工只能查看,不能编辑")
if not can_edit_report(report):
raise HTTPException(status_code=400, detail="该报工已超过可编辑期限")
before = _snapshot(report)
schedule = get_work_schedule_config(db, report.attendance_point_name)
item_map = {item.id: item for item in report.items}
has_correction = False
next_start_at = _effective_review_time(payload.start_at, report.start_at)
next_end_at = _effective_review_time(payload.end_at, report.end_at)
is_cleaning = _is_cleaning_report(report)
if is_cleaning:
next_start_at = report.start_at
next_end_at = report.end_at
else:
device_has_correction, next_start_at = _apply_device_corrections(
db,
report,
payload,
next_start_at,
next_end_at,
)
if device_has_correction:
has_correction = True
if not is_cleaning and next_end_at <= next_start_at:
raise HTTPException(status_code=400, detail="下班时间必须晚于上班时间")
if next_start_at != report.start_at:
report.start_at = next_start_at
if report.session is not None:
report.session.start_at = next_start_at
has_correction = True
if next_end_at != report.end_at:
report.end_at = next_end_at
if report.session is not None:
report.session.end_at = next_end_at
has_correction = True
for correction in payload.item_corrections:
item = item_map.get(correction.id)
if item is None:
raise HTTPException(status_code=400, detail=f"明细 {correction.id} 不存在")
item_is_misc = is_misc_item(item)
next_device_no = normalize_device_no(correction.device_no or item.device_no)
next_project_no = correction.project_no or item.project_no
next_product_name = correction.product_name or item.product_name
next_process_name = correction.process_name if correction.process_name is not None else item.process_name
next_raw_material_batch_no = (
correction.raw_material_batch_no
if correction.raw_material_batch_no is not None
else item.raw_material_batch_no
)
if (
next_device_no != item.device_no
or next_project_no != item.project_no
or next_product_name != item.product_name
or next_process_name != item.process_name
):
product = db.scalar(
select(Product).where(
Product.attendance_point_name == report.attendance_point_name,
Product.project_no == next_project_no,
Product.product_name == next_product_name,
Product.process_name == (next_process_name or ""),
Product.device_no == "",
)
)
if product is None:
raise HTTPException(status_code=400, detail="校正后的产品、工序和冲压方式不在产品清单中")
if is_cleaning and not is_cleaning_product(product):
raise HTTPException(status_code=400, detail="清洗报工只能校正为冲压方式为清洗的产品")
if not is_cleaning and is_cleaning_product(product):
raise HTTPException(status_code=400, detail="非清洗报工不能校正为清洗产品")
if item_is_misc and not is_misc_product(product):
raise HTTPException(status_code=400, detail="处理杂活明细不能校正为普通产品")
if not item_is_misc and is_misc_product(product):
raise HTTPException(status_code=400, detail="普通报工明细不能校正为处理杂活")
item.device_no = next_device_no
item.project_no = product.project_no
item.product_name = product.product_name
item.material_code = product.material_code
item.material_name = product.material_name
item.process_name = product.process_name
item.stamping_method = product.stamping_method
item.operator_count = product.operator_count
item.process_unit_price_yuan = product.process_unit_price_yuan
item.changeover_count = item.changeover_count if is_continuous_die_product(product) else 0
item.standard_beat = as_float(product.standard_beat)
item.standard_workload = 0
has_correction = True
if next_raw_material_batch_no != item.raw_material_batch_no:
item.raw_material_batch_no = next_raw_material_batch_no
has_correction = True
if correction.changeover_count is not None:
next_changeover_count = as_float(correction.changeover_count)
if next_changeover_count < 0:
raise HTTPException(status_code=400, detail="换料次数不能为负数")
if not is_continuous_die_item(item):
next_changeover_count = 0
if next_changeover_count != as_float(item.changeover_count):
item.changeover_count = next_changeover_count
has_correction = True
if correction.good_qty is not None and as_float(correction.good_qty) != as_float(item.good_qty):
item.good_qty = correction.good_qty
has_correction = True
if correction.defect_qty is not None and as_float(correction.defect_qty) != as_float(item.defect_qty):
item.defect_qty = correction.defect_qty
has_correction = True
if correction.scrap_qty is not None and as_float(correction.scrap_qty) != as_float(item.scrap_qty):
item.scrap_qty = correction.scrap_qty
has_correction = True
if item_is_misc and correction.remark is not None:
next_remark = str(correction.remark or "").strip() or None
if next_remark != item.remark:
item.remark = next_remark
has_correction = True
if "review_remark" in payload.model_fields_set:
next_review_remark = str(payload.review_remark or "").strip() or None
if next_review_remark != report.review_remark:
report.review_remark = next_review_remark
has_correction = True
if report.is_system_auto_submitted and not is_cleaning:
for item in report.items:
if is_misc_item(item):
continue
if not normalize_device_no(item.device_no):
raise HTTPException(status_code=400, detail="系统自动提交的报工请补填设备号后再审核通过")
if as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty) <= 0:
raise HTTPException(status_code=400, detail="系统自动提交的报工请补填产出数量后再审核通过")
if is_cleaning:
_validate_cleaning_report_devices(db, report)
else:
_validate_normal_report_devices(db, report)
_apply_metrics(report, schedule)
reviewed_at = now()
was_pending = report.status == ReportStatus.pending
report.status = ReportStatus.approved
report.reviewer_phone = user.phone
if was_pending or report.reviewed_at is None:
report.reviewed_at = reviewed_at
report.reject_reason = None
after = _snapshot(report)
db.add(
ReportAuditLog(
report_id=report.id,
reviewer_phone=user.phone,
action=AuditAction.approve_with_correction if has_correction else AuditAction.approve,
before_json=before,
after_json=after,
remark=payload.review_remark if "review_remark" in payload.model_fields_set else payload.remark,
)
)
db.commit()
saved = _get_report_or_404(db, report.id)
return report_out(saved, schedule)
@router.post("/{report_id}/void", response_model=ReportOut)
def void_report(
report_id: int,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> ReportOut:
purge_expired_voided_reports(db)
report = _get_report_or_404(db, report_id)
if report.attendance_point_name not in accessible_point_names(db, user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点作废权限")
mark_report_voided(report, user)
db.commit()
saved = _get_report_or_404(db, report.id)
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
@router.post("/{report_id}/unvoid", response_model=ReportOut)
def unvoid_report(
report_id: int,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> ReportOut:
purge_expired_voided_reports(db)
report = _get_report_or_404(db, report_id)
if report.attendance_point_name not in accessible_point_names(db, user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点撤销作废权限")
restore_voided_report(report)
db.commit()
saved = _get_report_or_404(db, report.id)
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
@router.post("/{report_id}/reject", response_model=ReportOut)
def reject_report(
report_id: int,
payload: RejectReportRequest,
user: Personnel = Depends(require_roles(Role.admin)),
db: Session = Depends(get_db),
) -> ReportOut:
report = _get_report_or_404(db, report_id)
if report.attendance_point_name not in accessible_point_names(db, user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点审核权限")
if report.status != ReportStatus.pending:
raise HTTPException(status_code=400, detail="只有待审核报工可以驳回")
before = _snapshot(report)
report.status = ReportStatus.rejected
report.reviewer_phone = user.phone
report.reviewed_at = now()
report.reject_reason = payload.reason
after = _snapshot(report)
db.add(
ReportAuditLog(
report_id=report.id,
reviewer_phone=user.phone,
action=AuditAction.reject,
before_json=before,
after_json=after,
remark=payload.reason,
)
)
db.commit()
saved = _get_report_or_404(db, report.id)
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
+334
View File
@@ -0,0 +1,334 @@
from datetime import date
from math import ceil
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.deps import require_roles
from app.models import Equipment, Personnel, ProductionReport, ReportStatus, Role
from app.schemas import (
PageResponse,
UsageStatsDailyRow,
UsageStatsDetailOut,
UsageStatsReportRow,
UsageStatsRow,
)
from app.services.attendance_points import accessible_point_names, require_attendance_point_access
from app.services.common import round2
from app.services.report_lifecycle import purge_expired_voided_reports
from app.services.usage_stats import UsageStatRow, build_usage_stats
from app.services.usage_stats_export import export_usage_stats_rows
router = APIRouter(prefix="/api/usage-stats", tags=["usage-stats"])
def _clean(value: str | None) -> str:
return str(value or "").strip()
def _requested_points(db: Session, user: Personnel, attendance_point_name: str | None) -> list[str]:
requested_name = _clean(attendance_point_name)
if requested_name:
try:
return [require_attendance_point_access(db, user, requested_name)]
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
return accessible_point_names(db, user)
def _equipment_type_map(db: Session, point_names: list[str]) -> dict[tuple[str, str], str]:
if not point_names:
return {}
rows = db.scalars(select(Equipment).where(Equipment.attendance_point_name.in_(point_names))).all()
return {(_clean(row.attendance_point_name), _clean(row.device_no)): row.device_type for row in rows}
def _approved_reports_query(point_names: list[str], start_date: date | None, end_date: date | None):
query = (
select(ProductionReport)
.options(selectinload(ProductionReport.employee), selectinload(ProductionReport.items))
.where(
ProductionReport.status == ReportStatus.approved,
ProductionReport.is_voided.is_(False),
ProductionReport.attendance_point_name.in_(point_names),
)
)
if start_date:
query = query.where(ProductionReport.report_date >= start_date)
if end_date:
query = query.where(ProductionReport.report_date <= end_date)
return query.order_by(ProductionReport.report_date.desc(), ProductionReport.id.desc())
def _sort_usage_rows(rows: list[UsageStatRow], sort_by: str) -> list[UsageStatRow]:
if sort_by == "count":
return sorted(rows, key=lambda row: (-row.report_count, -row.value, row.name))
return sorted(rows, key=lambda row: (-row.value, -row.report_count, row.name))
def _usage_row_search_text(row: UsageStatRow) -> str:
values = [
row.id,
row.category,
row.attendance_point_name,
row.name,
row.object_type,
row.metric_kind,
row.product_name,
row.process_name,
row.stamping_method,
row.value,
row.report_count,
*row.tags,
]
return " ".join(str(value or "") for value in values).lower()
def _filter_usage_rows(rows: list[UsageStatRow], keyword: str | None) -> list[UsageStatRow]:
text = _clean(keyword).lower()
if not text:
return rows
return [row for row in rows if text in _usage_row_search_text(row)]
def _to_schema_row(row: UsageStatRow) -> UsageStatsRow:
return UsageStatsRow(
id=row.id,
category=row.category,
attendance_point_name=row.attendance_point_name,
name=row.name,
object_type=row.object_type,
metric_kind=row.metric_kind,
value=row.value,
report_count=row.report_count,
product_name=row.product_name,
process_name=row.process_name,
stamping_method=row.stamping_method,
tags=list(row.tags),
)
def _to_detail_out(
row: UsageStatRow,
*,
daily_rows: list[UsageStatsDailyRow] | None = None,
report_rows: list[UsageStatsReportRow] | None = None,
) -> UsageStatsDetailOut:
return UsageStatsDetailOut(
id=row.id,
category=row.category,
attendance_point_name=row.attendance_point_name,
name=row.name,
object_type=row.object_type,
metric_kind=row.metric_kind,
value=row.value,
report_count=row.report_count,
product_name=row.product_name,
process_name=row.process_name,
stamping_method=row.stamping_method,
tags=list(row.tags),
daily_rows=daily_rows or [],
report_rows=report_rows or [],
)
def _matches_target(
row: UsageStatRow,
*,
id: str,
category: str,
attendance_point_name: str,
name: str,
product_name: str,
process_name: str,
stamping_method: str,
metric_kind: str,
) -> bool:
if id:
return row.id == id
if row.category != category:
return False
if attendance_point_name and row.attendance_point_name != attendance_point_name:
return False
if metric_kind and row.metric_kind != metric_kind:
return False
if category == "device":
return row.name == name
return (
row.product_name == product_name
and row.process_name == process_name
and row.stamping_method == stamping_method
)
def _build_usage_stats_export_response(device_rows: list[UsageStatRow], mold_rows: list[UsageStatRow]) -> Response:
content = export_usage_stats_rows(device_rows, mold_rows)
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="usage-stats.xlsx"'},
)
@router.get("/summary", response_model=PageResponse)
def usage_stats_summary(
category: str = Query("device", pattern="^(device|mold)$"),
start_date: date | None = None,
end_date: date | None = None,
attendance_point_name: str = "",
keyword: str = "",
sort_by: str = Query("value", pattern="^(value|count)$"),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> PageResponse:
purge_expired_voided_reports(db)
point_names = _requested_points(db, user, attendance_point_name)
reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all()
rows = _sort_usage_rows(
_filter_usage_rows(
build_usage_stats(
reports=reports,
category=category,
equipment_type_by_key=_equipment_type_map(db, point_names),
),
keyword,
),
sort_by,
)
safe_page_size = min(100, max(1, page_size))
total = len(rows)
total_pages = max(1, ceil(total / safe_page_size))
start = (page - 1) * safe_page_size
return PageResponse(
page=page,
page_size=safe_page_size,
total=total,
total_pages=total_pages,
rows=[_to_schema_row(row) for row in rows[start : start + safe_page_size]],
)
@router.get("/export")
def export_usage_stats_excel(
start_date: date | None = None,
end_date: date | None = None,
attendance_point_name: str = "",
keyword: str = "",
sort_by: str = Query("value", pattern="^(value|count)$"),
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> Response:
purge_expired_voided_reports(db)
point_names = _requested_points(db, user, attendance_point_name)
equipment_type_by_key = _equipment_type_map(db, point_names)
reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all()
device_rows = _sort_usage_rows(
_filter_usage_rows(
build_usage_stats(
reports=reports,
category="device",
equipment_type_by_key=equipment_type_by_key,
),
keyword,
),
sort_by,
)
mold_rows = _sort_usage_rows(
_filter_usage_rows(
build_usage_stats(
reports=reports,
category="mold",
equipment_type_by_key=equipment_type_by_key,
),
keyword,
),
sort_by,
)
return _build_usage_stats_export_response(device_rows, mold_rows)
@router.get("/detail", response_model=UsageStatsDetailOut)
def usage_stats_detail(
category: str = Query("device", pattern="^(device|mold)$"),
id: str = "",
metric_kind: str = "",
start_date: date | None = None,
end_date: date | None = None,
attendance_point_name: str = "",
name: str = "",
product_name: str = "",
process_name: str = "",
stamping_method: str = "",
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> UsageStatsDetailOut:
purge_expired_voided_reports(db)
point_names = _requested_points(db, user, attendance_point_name)
equipment_type_by_key = _equipment_type_map(db, point_names)
reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all()
target_args = {
"id": _clean(id),
"category": category,
"attendance_point_name": _clean(attendance_point_name),
"name": _clean(name),
"product_name": _clean(product_name),
"process_name": _clean(process_name),
"stamping_method": _clean(stamping_method),
"metric_kind": _clean(metric_kind),
}
rows = build_usage_stats(
reports=reports,
category=category,
equipment_type_by_key=equipment_type_by_key,
)
target = next((row for row in rows if _matches_target(row, **target_args)), None)
if target is None:
return UsageStatsDetailOut(
id=target_args["id"],
category=category,
attendance_point_name=target_args["attendance_point_name"],
name=target_args["name"],
product_name=target_args["product_name"],
process_name=target_args["process_name"],
stamping_method=target_args["stamping_method"],
metric_kind=target_args["metric_kind"],
)
daily_rows: dict[date, UsageStatsDailyRow] = {}
report_rows: list[UsageStatsReportRow] = []
for report in reports:
report_matches = build_usage_stats(
reports=[report],
category=category,
equipment_type_by_key=equipment_type_by_key,
)
match = next((row for row in report_matches if _matches_target(row, **target_args)), None)
if match is None:
continue
day = daily_rows.setdefault(report.report_date, UsageStatsDailyRow(report_date=report.report_date))
day.value = round2(day.value + match.value)
day.report_count += match.report_count
report_rows.append(
UsageStatsReportRow(
report_id=report.id,
report_date=report.report_date,
employee_phone=report.employee_phone,
employee_name=report.employee.name if report.employee else "",
display_name=target.name,
value=match.value,
report_count=match.report_count,
)
)
return _to_detail_out(
target,
daily_rows=sorted(daily_rows.values(), key=lambda row: row.report_date),
report_rows=sorted(report_rows, key=lambda row: (row.report_date, row.report_id), reverse=True),
)
+182
View File
@@ -0,0 +1,182 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.database import get_db
from app.deps import require_roles
from app.models import (
AttendancePoint,
DeviceQRCode,
Equipment,
NoticePoint,
PersonAttendancePoint,
Personnel,
Product,
ProductionReport,
ProductionReportItem,
ReconciliationLedgerEntry,
Role,
WorkSchedule,
WorkSession,
WorkSessionDevice,
)
from app.schemas import AttendancePointCreate, WorkScheduleOut, WorkScheduleSettingsUpdate, WorkScheduleUpdate
from app.services.attendance_points import clean_point_name, ensure_default_attendance_point
from app.services.misc_work import ensure_misc_work_product
from app.services.serializers import attendance_point_out
from app.services.work_schedule import get_work_schedule, normalize_schedule_payload, update_work_schedule
router = APIRouter(prefix="/api/work-schedule", tags=["work-schedule"])
def _schedule_out(schedule: WorkSchedule, db: Session | None = None) -> WorkScheduleOut:
points = []
if db is not None:
ensure_default_attendance_point(db)
db.commit()
points = [
attendance_point_out(point)
for point in db.scalars(
select(AttendancePoint)
.where(AttendancePoint.is_active.is_(True))
.order_by(AttendancePoint.name)
).all()
]
return WorkScheduleOut(
id=schedule.id,
day_start=schedule.day_start,
day_end=schedule.day_end,
lunch_start=schedule.lunch_start,
lunch_end=schedule.lunch_end,
dinner_start=schedule.dinner_start,
dinner_end=schedule.dinner_end,
overtime_start=schedule.overtime_start,
overtime_end=schedule.overtime_end,
night_start=schedule.night_start,
night_end=schedule.night_end,
attendance_latitude=schedule.attendance_latitude,
attendance_longitude=schedule.attendance_longitude,
attendance_radius_meters=schedule.attendance_radius_meters,
auto_submit_hours=float(schedule.auto_submit_hours or 15),
attendance_points=points,
updated_by=schedule.updated_by,
updated_at=schedule.updated_at,
)
def _validate_attendance_point_payload(payload: AttendancePointCreate) -> str:
name = clean_point_name(payload.name)
if not name:
raise HTTPException(status_code=400, detail="请输入考勤点名称")
if (payload.latitude is None) != (payload.longitude is None):
raise HTTPException(status_code=400, detail="经纬度必须同时填写")
if payload.latitude is not None and not (-90 <= payload.latitude <= 90):
raise HTTPException(status_code=400, detail="纬度范围为-90到90")
if payload.longitude is not None and not (-180 <= payload.longitude <= 180):
raise HTTPException(status_code=400, detail="经度范围为-180到180")
if int(payload.radius_meters or 0) <= 0:
raise HTTPException(status_code=400, detail="考勤范围半径必须大于0米")
try:
normalize_schedule_payload(payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"{name} {exc}") from exc
return name
def _rename_business_point(db: Session, old_name: str, new_name: str) -> None:
if old_name == new_name:
return
for model in (
PersonAttendancePoint,
Product,
Equipment,
WorkSession,
WorkSessionDevice,
ProductionReport,
ProductionReportItem,
DeviceQRCode,
NoticePoint,
ReconciliationLedgerEntry,
):
db.query(model).filter(model.attendance_point_name == old_name).update(
{model.attendance_point_name: new_name},
synchronize_session=False,
)
def _save_attendance_point_without_commit(db: Session, payload: AttendancePointCreate) -> AttendancePoint:
name = _validate_attendance_point_payload(payload)
original_name = clean_point_name(payload.original_name) or name
point = db.get(AttendancePoint, original_name)
if point is not None and original_name != name:
if db.get(AttendancePoint, name) is not None:
raise HTTPException(status_code=409, detail="该考勤点名称已存在")
next_point = AttendancePoint(name=name)
db.add(next_point)
db.flush()
_rename_business_point(db, original_name, name)
db.delete(point)
point = next_point
elif point is None:
point = db.get(AttendancePoint, name)
if point is None:
point = AttendancePoint(name=name)
db.add(point)
db.flush()
point.latitude = payload.latitude
point.longitude = payload.longitude
point.radius_meters = int(payload.radius_meters or 500)
for field, value in normalize_schedule_payload(payload).items():
setattr(point, field, value)
point.remark = payload.remark
point.is_active = bool(payload.is_active)
ensure_misc_work_product(db, name)
db.flush()
return point
@router.get("", response_model=WorkScheduleOut)
def read_work_schedule(
db: Session = Depends(get_db),
) -> WorkScheduleOut:
return _schedule_out(get_work_schedule(db), db)
@router.put("", response_model=WorkScheduleOut)
def save_work_schedule(
payload: WorkScheduleUpdate,
user: Personnel = Depends(require_roles(Role.manager)),
db: Session = Depends(get_db),
) -> WorkScheduleOut:
try:
schedule = update_work_schedule(db, payload, user.phone)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return _schedule_out(schedule, db)
@router.put("/settings", response_model=WorkScheduleOut)
def save_work_schedule_settings(
payload: WorkScheduleSettingsUpdate,
user: Personnel = Depends(require_roles(Role.manager)),
db: Session = Depends(get_db),
) -> WorkScheduleOut:
names = [_validate_attendance_point_payload(point) for point in payload.attendance_points]
duplicates = sorted({name for name in names if names.count(name) > 1})
if duplicates:
raise HTTPException(status_code=400, detail=f"考勤点名称重复:{'、'.join(duplicates)}")
if not names:
raise HTTPException(status_code=400, detail="请至少维护一个考勤点")
try:
schedule = update_work_schedule(db, payload, user.phone, commit=False)
for point in payload.attendance_points:
_save_attendance_point_without_commit(db, point)
db.commit()
db.refresh(schedule)
except ValueError as exc:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except HTTPException:
db.rollback()
raise
return _schedule_out(schedule, db)
+783
View File
@@ -0,0 +1,783 @@
from datetime import date, datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from app.models import ReportStatus, Role
class Page(BaseModel):
page: int = 1
page_size: int = 10
total: int = 0
total_pages: int = 1
class PageResponse(BaseModel):
page: int
page_size: int
total: int
total_pages: int
rows: list[Any]
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: "PersonnelOut"
class AttendancePointBase(BaseModel):
name: str = Field(min_length=1, max_length=128)
latitude: float | None = None
longitude: float | None = None
radius_meters: int = Field(default=500, ge=1)
day_start: str = Field(default="08:00", pattern=r"^\d{1,2}:\d{2}$")
day_end: str = Field(default="17:20", pattern=r"^\d{1,2}:\d{2}$")
lunch_start: str = Field(default="11:40", pattern=r"^\d{1,2}:\d{2}$")
lunch_end: str = Field(default="12:40", pattern=r"^\d{1,2}:\d{2}$")
dinner_start: str = Field(default="17:20", pattern=r"^\d{1,2}:\d{2}$")
dinner_end: str = Field(default="18:00", pattern=r"^\d{1,2}:\d{2}$")
overtime_start: str = Field(default="18:00", pattern=r"^\d{1,2}:\d{2}$")
overtime_end: str = Field(default="20:00", pattern=r"^\d{1,2}:\d{2}$")
night_start: str = Field(default="20:00", pattern=r"^\d{1,2}:\d{2}$")
night_end: str = Field(default="06:00", pattern=r"^\d{1,2}:\d{2}$")
remark: str | None = Field(default=None, max_length=500)
is_active: bool = True
class AttendancePointCreate(AttendancePointBase):
original_name: str | None = None
class AttendancePointOut(AttendancePointBase):
created_at: datetime | None = None
updated_at: datetime | None = None
class LoginSelectionResponse(BaseModel):
needs_selection: bool
matched_people: list["PersonnelOut"] = []
selection_token: str | None = None
access_token: str | None = None
token_type: str = "bearer"
user: "PersonnelOut | None" = None
needs_temporary_worker: bool = False
temporary_token: str | None = None
temporary_phone: str | None = None
temporary_expires_at: datetime | None = None
class WechatLoginRequest(BaseModel):
phone_code: str | None = None
phone: str | None = None
phone_candidates: list[str] = []
selected_role: Role | None = None
create_temporary_worker: bool = False
temporary_token: str | None = None
temporary_name: str | None = None
temporary_attendance_point_name: str | None = None
temporary_latitude: float | None = None
temporary_longitude: float | None = None
class RoleSelectionRequest(BaseModel):
selection_token: str
role: Role
class SwitchRoleRequest(BaseModel):
role: Role
class PersonnelBase(BaseModel):
phone: str = Field(min_length=5, max_length=20)
name: str = Field(min_length=1, max_length=64)
role: Role
attendance_point_names: list[str] = []
class PersonnelCreate(PersonnelBase):
original_phone: str | None = None
class PersonnelOut(PersonnelBase):
model_config = ConfigDict(from_attributes=True)
role_name: str
roles: list[Role] = Field(default_factory=list)
role_names: list[str] = Field(default_factory=list)
attendance_points: list[AttendancePointOut] = Field(default_factory=list)
is_temporary: bool = False
temporary_expires_at: datetime | None = None
temporary_expired: bool = False
class ProductBase(BaseModel):
attendance_point_name: str = Field(min_length=1, max_length=128)
project_no: str = Field(min_length=1, max_length=64)
product_name: str = Field(min_length=1, max_length=255)
profile_no: str | None = None
material_code: str | None = None
material_name: str | None = None
supplier: str | None = None
product_net_weight_kg: float | None = None
product_gross_weight_kg: float | None = None
scrap_loss_rate: float | None = None
waste_price_yuan_per_kg: float | None = None
device_no: str = ""
process_name: str = Field(min_length=1, max_length=128)
stamping_method: str | None = None
operator_count: float = 1
process_unit_price_yuan: float = Field(default=0, ge=0)
standard_beat: float = Field(default=0, ge=0)
standard_workload: float | None = None
class ProductCreate(ProductBase):
original_attendance_point_name: str | None = None
original_project_no: str | None = None
original_product_name: str | None = None
original_device_no: str | None = None
original_process_name: str | None = None
class ProductOut(ProductBase):
standard_workload: float
display_name: str
class MoldNameOut(BaseModel):
attendance_point_name: str = ""
name: str
process_name: str = ""
stamping_method: str | None = None
operator_count: float = 1
is_cleaning: bool = False
is_misc: bool = False
is_continuous_die: bool = False
is_multi_person: bool = False
display_name: str = ""
class DeviceQrCreate(BaseModel):
attendance_point_name: str = Field(min_length=1, max_length=128)
product_name: str = Field(min_length=1, max_length=255)
process_name: str = Field(min_length=1, max_length=128)
class DeviceQrBatchCreate(BaseModel):
items: list[DeviceQrCreate] = Field(min_length=1, max_length=500)
class DeviceQrBatchOut(BaseModel):
count: int
file_name: str
zip_url: str
class DeviceQrBatchTaskOut(BaseModel):
id: int
file_name: str
status: str
status_name: str
item_count: int
completed_count: int
failed_count: int = 0
progress_percent: float = 0
zip_url: str | None = None
error_message: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
created_at: datetime
updated_at: datetime
class EquipmentBase(BaseModel):
attendance_point_name: str = Field(min_length=1, max_length=128)
device_no: str = Field(min_length=1, max_length=64)
device_type: Literal["冲压设备", "清洗设备"] = "冲压设备"
remark: str | None = Field(default=None, max_length=500)
class EquipmentCreate(EquipmentBase):
original_attendance_point_name: str | None = None
original_device_no: str | None = None
class EquipmentOut(EquipmentBase):
created_at: datetime
updated_at: datetime
class WorkScheduleBase(BaseModel):
day_start: str = Field(pattern=r"^\d{1,2}:\d{2}$")
day_end: str = Field(pattern=r"^\d{1,2}:\d{2}$")
lunch_start: str = Field(pattern=r"^\d{1,2}:\d{2}$")
lunch_end: str = Field(pattern=r"^\d{1,2}:\d{2}$")
dinner_start: str = Field(pattern=r"^\d{1,2}:\d{2}$")
dinner_end: str = Field(pattern=r"^\d{1,2}:\d{2}$")
overtime_start: str = Field(pattern=r"^\d{1,2}:\d{2}$")
overtime_end: str = Field(pattern=r"^\d{1,2}:\d{2}$")
night_start: str = Field(pattern=r"^\d{1,2}:\d{2}$")
night_end: str = Field(pattern=r"^\d{1,2}:\d{2}$")
attendance_latitude: float | None = None
attendance_longitude: float | None = None
attendance_radius_meters: int | None = Field(default=500, ge=1)
auto_submit_hours: float = Field(default=15, ge=1, le=168)
class WorkScheduleUpdate(WorkScheduleBase):
pass
class WorkScheduleSettingsUpdate(WorkScheduleBase):
attendance_points: list[AttendancePointCreate] = Field(default_factory=list)
class WorkScheduleOut(WorkScheduleBase):
id: int
attendance_points: list[AttendancePointOut] = Field(default_factory=list)
updated_by: str | None = None
updated_at: datetime
class ImportResult(BaseModel):
imported: int
updated: int
skipped: int
errors: list[str]
class ClockState(BaseModel):
action: Literal["start", "switch", "switch_or_finish", "finish", "report", "refinish"]
action_text: str
attendance_point_name: str = ""
device_no: str
mold_name: str = ""
process_name: str = ""
stamping_method: str | None = None
display_name: str = ""
is_cleaning: bool = False
is_misc: bool = False
current_mold_name: str = ""
current_process_name: str = ""
session_id: int | None = None
start_at: datetime | None = None
end_at: datetime | None = None
devices: list[str] = []
auto_submit_hours: float = 15
class MoldLockConflictDetail(BaseModel):
code: str = "mold_occupied"
message: str
lock_id: int
attendance_point_name: str = ""
mold_name: str = ""
process_name: str = ""
mold_display_name: str = ""
occupied_phone: str = ""
occupied_name: str = ""
class ClockRequest(BaseModel):
attendance_point_name: str = ""
device_no: str = ""
mold_name: str = ""
process_name: str = ""
latitude: float | None = None
longitude: float | None = None
class ContinueWorkRequest(BaseModel):
session_id: int
class ClockSessionOut(BaseModel):
id: int
attendance_point_name: str = ""
employee_phone: str
start_at: datetime
end_at: datetime | None
status: str
devices: list[str]
class ProductOption(BaseModel):
attendance_point_name: str = ""
project_no: str
product_name: str
material_code: str | None = None
material_name: str | None = None
device_no: str
process_name: str | None = None
stamping_method: str | None = None
operator_count: float = 1
process_unit_price_yuan: float = 0
standard_beat: float
standard_workload: float
is_cleaning: bool = False
is_misc: bool = False
is_continuous_die: bool = False
is_multi_person: bool = False
display_name: str
class EquipmentOption(BaseModel):
attendance_point_name: str = ""
device_no: str
device_type: str = "冲压设备"
remark: str | None = None
display_name: str
class ReportDraftItem(BaseModel):
attendance_point_name: str = ""
device_no: str = ""
project_no: str = ""
product_name: str = ""
material_code: str | None = None
material_name: str | None = None
raw_material_batch_no: str | None = None
raw_material_batch_options: list[str] = []
process_name: str | None = None
stamping_method: str | None = None
operator_count: float = 1
process_unit_price_yuan: float = 0
changeover_count: float = 0
standard_beat: float = 0
standard_workload: float = 0
good_qty: float = 0
defect_qty: float = 0
scrap_qty: float = 0
is_misc: bool = False
is_continuous_die: bool = False
remark: str | None = None
class ReportDraftBlock(BaseModel):
attendance_point_name: str = ""
device_no: str
mold_name: str = ""
process_name: str = ""
stamping_method: str | None = None
display_name: str = ""
start_at: datetime
end_at: datetime
duration_minutes: float = 0
options: list[ProductOption]
equipment_options: list[EquipmentOption] = []
items: list[ReportDraftItem]
is_misc: bool = False
is_continuous_die: bool = False
class ReportDraftOut(BaseModel):
session_id: int
attendance_point_name: str = ""
employee_phone: str
employee_name: str
start_at: datetime
end_at: datetime
duration_minutes: float
device_blocks: list[ReportDraftBlock]
class ReportSubmitItem(BaseModel):
attendance_point_name: str = ""
device_no: str = ""
project_no: str
product_name: str
material_code: str | None = None
material_name: str | None = None
raw_material_batch_no: str | None = None
process_name: str | None = None
stamping_method: str | None = None
operator_count: float = 1
process_unit_price_yuan: float = 0
changeover_count: float = Field(default=0, ge=0)
standard_beat: float
standard_workload: float = 0
good_qty: float = Field(default=0, ge=0)
defect_qty: float = Field(default=0, ge=0)
scrap_qty: float = Field(default=0, ge=0)
started_at: datetime | None = None
is_misc: bool = False
is_continuous_die: bool = False
remark: str | None = None
class ReportSubmitBlock(BaseModel):
attendance_point_name: str = ""
device_no: str
process_name: str = ""
items: list[ReportSubmitItem]
class ReportSubmitRequest(BaseModel):
session_id: int
device_blocks: list[ReportSubmitBlock]
class CleaningReportSubmitItem(BaseModel):
attendance_point_name: str = ""
device_no: str = ""
device_nos: list[str] = Field(default_factory=list)
product_name: str = Field(min_length=1, max_length=255)
process_name: str = Field(min_length=1, max_length=128)
stamping_method: str | None = None
quantity: float = Field(gt=0)
class CleaningReportSubmitRequest(BaseModel):
items: list[CleaningReportSubmitItem]
latitude: float | None = None
longitude: float | None = None
class ReportMetrics(BaseModel):
effective_minutes: float
shift_distribution_text: str = ""
shift_day_minutes: float = 0
shift_overtime_minutes: float = 0
shift_night_minutes: float = 0
shift_other_minutes: float = 0
shift_meal_break_minutes: float = 0
total_good_qty: float
total_output_qty: float
actual_beat: float
standard_beat: float
expected_workload: float
pace_rate: float
workload_rate: float
class ReportItemOut(BaseModel):
id: int | None = None
attendance_point_name: str = ""
device_no: str
project_no: str
product_name: str
material_code: str | None = None
material_name: str | None = None
raw_material_batch_no: str | None = None
process_name: str | None = None
stamping_method: str | None = None
operator_count: float = 1
process_unit_price_yuan: float = 0
changeover_count: float = 0
standard_beat: float
standard_workload: float
good_qty: float
defect_qty: float
scrap_qty: float
allocated_minutes: float = 0
started_at: datetime | None = None
is_misc: bool = False
is_continuous_die: bool = False
is_multi_person: bool = False
remark: str | None = None
corrections: dict[str, Any] = Field(default_factory=dict)
class ReportDeviceSegmentOut(BaseModel):
id: int
attendance_point_name: str = ""
device_no: str
mold_name: str = ""
process_name: str = ""
stamping_method: str | None = None
display_name: str = ""
scanned_at: datetime
released_at: datetime | None = None
sort_order: int = 0
corrections: dict[str, Any] = Field(default_factory=dict)
class ReportOut(BaseModel):
id: int
attendance_point_name: str = ""
session_id: int
employee_phone: str
employee_name: str
report_date: date
start_at: datetime
end_at: datetime
duration_minutes: float
break_minutes: float
status: ReportStatus
status_name: str
reviewer_phone: str | None = None
reviewer_name: str | None = None
reviewed_at: datetime | None = None
reject_reason: str | None = None
review_remark: str | None = None
submitted_at: datetime
is_system_auto_submitted: bool = False
auto_submit_reason: str | None = None
is_multi_person_assistant: bool = False
multi_person_source_report_id: int | None = None
is_voided: bool = False
voided_at: datetime | None = None
voided_by: str | None = None
voided_by_name: str | None = None
unvoid_deadline_at: datetime | None = None
can_void: bool = False
can_unvoid: bool = False
can_edit: bool = False
is_modified: bool = False
device_segments: list[ReportDeviceSegmentOut] = Field(default_factory=list)
items: list[ReportItemOut]
metrics: ReportMetrics
result_text: str
corrections: dict[str, Any] = Field(default_factory=dict)
class ReportItemCorrection(BaseModel):
id: int
device_no: str | None = None
project_no: str | None = None
product_name: str | None = None
process_name: str | None = None
raw_material_batch_no: str | None = None
changeover_count: float | None = Field(default=None, ge=0)
good_qty: float | None = Field(default=None, ge=0)
defect_qty: float | None = Field(default=None, ge=0)
scrap_qty: float | None = Field(default=None, ge=0)
remark: str | None = None
class ReportDeviceCorrection(BaseModel):
id: int
scanned_at: datetime | None = None
class ApproveReportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
start_at: datetime | None = None
end_at: datetime | None = None
device_corrections: list[ReportDeviceCorrection] = Field(default_factory=list)
item_corrections: list[ReportItemCorrection] = []
review_remark: str | None = Field(default=None, max_length=500)
remark: str | None = None
class RejectReportRequest(BaseModel):
reason: str = "需重新填报"
class DeviceQrOut(BaseModel):
attendance_point_name: str = ""
device_no: str
process_name: str = ""
stamping_method: str | None = None
is_cleaning: bool = False
is_misc: bool = False
is_continuous_die: bool = False
mold_name: str = ""
display_name: str = ""
scene: str
page: str
qr_url: str | None = None
class DashboardRow(BaseModel):
id: str
attendance_point_name: str = ""
report_date: date
employee_phone: str
employee_name: str
project_no: str
product_name: str
process_name: str | None = None
report_count: int
effective_minutes: float
shift_distribution_text: str = ""
shift_day_minutes: float = 0
shift_overtime_minutes: float = 0
shift_night_minutes: float = 0
shift_other_minutes: float = 0
total_good_qty: float
total_defect_qty: float
total_output_qty: float
actual_beat: float
standard_beat: float
expected_workload: float
pace_rate: float
workload_rate: float
pace_text: str
workload_text: str
profile_no: str | None = None
material_code: str | None = None
material_name: str | None = None
raw_material_batch_no: str | None = None
device_no: str | None = None
stamping_method: str | None = None
operator_count: float | None = None
process_unit_price_yuan: float = 0
changeover_count: float = 0
reference_wage: float = 0
worker_type: str = "正式工"
is_multi_person: bool = False
is_multi_person_assistant: bool = False
is_misc: bool = False
is_continuous_die: bool = False
is_system_auto_submitted: bool = False
is_voided: bool = False
review_remark: str | None = None
correction_pairs: list[dict[str, Any]] = Field(default_factory=list)
class UsageStatsRow(BaseModel):
id: str
category: str
attendance_point_name: str = ""
name: str
object_type: str
metric_kind: str
value: float = 0
report_count: int = 0
product_name: str = ""
process_name: str = ""
stamping_method: str = ""
tags: list[str] = Field(default_factory=list)
class UsageStatsDailyRow(BaseModel):
report_date: date
value: float = 0
report_count: int = 0
class UsageStatsReportRow(BaseModel):
report_id: int
report_date: date
employee_phone: str = ""
employee_name: str = ""
display_name: str = ""
value: float = 0
report_count: int = 1
class UsageStatsDetailOut(BaseModel):
id: str = ""
category: str
attendance_point_name: str = ""
name: str = ""
object_type: str = ""
metric_kind: str = ""
value: float = 0
report_count: int = 0
product_name: str = ""
process_name: str = ""
stamping_method: str = ""
tags: list[str] = Field(default_factory=list)
daily_rows: list[UsageStatsDailyRow] = Field(default_factory=list)
report_rows: list[UsageStatsReportRow] = Field(default_factory=list)
class NoticeCreate(BaseModel):
title: str = Field(min_length=1, max_length=120)
content: str = Field(min_length=1, max_length=1000)
attendance_point_names: list[str] = []
sort_order: int = 0
is_active: bool = True
class NoticeUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=120)
content: str | None = Field(default=None, min_length=1, max_length=1000)
attendance_point_names: list[str] | None = None
sort_order: int | None = None
is_active: bool | None = None
class NoticeOut(BaseModel):
id: int
title: str
content: str
attendance_point_names: list[str] = []
sort_order: int
is_active: bool
created_by: str
creator_name: str
created_at: datetime
updated_at: datetime
class ReconciliationMonthCell(BaseModel):
month: int
reported_good_qty: float = 0
reconciled_good_qty: float = 0
return_qty: float = 0
class ReconciliationRow(BaseModel):
attendance_point_name: str = ""
product_name: str
months: list[ReconciliationMonthCell]
class ReconciliationLedgerOut(BaseModel):
year: int
current_year: int
current_month: int
rows: list[ReconciliationRow]
class ReconciliationYearsOut(BaseModel):
years: list[int]
current_year: int
class MonitorLockOut(BaseModel):
id: int
attendance_point_name: str = ""
mold_name: str
process_name: str = ""
stamping_method: str | None = None
mold_display_name: str
occupied_phone: str
occupied_name: str
occupied_start_at: datetime
occupied_minutes: float
class MoldLockFeedbackOut(BaseModel):
id: int
attendance_point_name: str = ""
mold_name: str
process_name: str = ""
stamping_method: str | None = None
mold_display_name: str
lock_id: int
reporter_phone: str
reporter_name: str
occupied_phone: str
occupied_name: str
feedback_at: datetime
read_at: datetime | None = None
handled_at: datetime | None = None
status: str
status_name: str
can_release: bool = True
class MoldLockFeedbackCreate(BaseModel):
lock_id: int
class ReconciliationEntryUpdate(BaseModel):
attendance_point_name: str = Field(min_length=1, max_length=128)
year: int = Field(ge=2000, le=2100)
month: int = Field(ge=1, le=12)
product_name: str = Field(min_length=1, max_length=255)
reconciled_good_qty: float | None = Field(default=None, ge=0)
return_qty: float | None = Field(default=None, ge=0)
PersonnelOut.model_rebuild()
TokenResponse.model_rebuild()
LoginSelectionResponse.model_rebuild()
+57
View File
@@ -0,0 +1,57 @@
import base64
from datetime import timedelta
import hashlib
import hmac
import json
import time
from typing import Any
from app.config import settings
from app.timezone import now
def _b64encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64decode(raw: str) -> bytes:
padding = "=" * (-len(raw) % 4)
return base64.urlsafe_b64decode(raw + padding)
def create_token(payload: dict[str, Any]) -> str:
expires_at = now() + timedelta(minutes=settings.token_expire_minutes)
body = {
**payload,
"exp": int(expires_at.timestamp()),
"iat": int(time.time()),
}
encoded_body = _b64encode(json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode())
signature = hmac.new(
settings.jwt_secret.encode(),
encoded_body.encode(),
hashlib.sha256,
).digest()
return f"{encoded_body}.{_b64encode(signature)}"
def verify_token(token: str) -> dict[str, Any]:
try:
encoded_body, encoded_signature = token.split(".", 1)
except ValueError as exc:
raise ValueError("invalid token") from exc
expected = hmac.new(
settings.jwt_secret.encode(),
encoded_body.encode(),
hashlib.sha256,
).digest()
actual = _b64decode(encoded_signature)
if not hmac.compare_digest(expected, actual):
raise ValueError("invalid token")
payload = json.loads(_b64decode(encoded_body))
if payload.get("exp", 0) < int(time.time()):
raise ValueError("token expired")
return payload
+2
View File
@@ -0,0 +1,2 @@
"""Business services."""
+132
View File
@@ -0,0 +1,132 @@
from math import asin, cos, radians, sin, sqrt
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.models import AttendancePoint, PersonAttendancePoint, Personnel, Role
DEFAULT_ATTENDANCE_POINT_NAME = "宁波嘉恒智能科技有限公司"
EARTH_RADIUS_METERS = 6371000
def clean_point_name(value: str | None) -> str:
return str(value or "").strip()
def ensure_default_attendance_point(db: Session) -> AttendancePoint:
point = db.get(AttendancePoint, DEFAULT_ATTENDANCE_POINT_NAME)
if point is not None:
return point
point = AttendancePoint(
name=DEFAULT_ATTENDANCE_POINT_NAME,
radius_meters=500,
is_active=True,
)
db.add(point)
db.flush()
return point
def attendance_point_names(db: Session) -> list[str]:
names = list(db.scalars(select(AttendancePoint.name).where(AttendancePoint.is_active.is_(True)).order_by(AttendancePoint.name)))
if not names:
point = ensure_default_attendance_point(db)
db.commit()
names = [point.name]
return names
def accessible_point_names(db: Session, user: Personnel) -> list[str]:
if user.role == Role.manager:
return attendance_point_names(db)
rows = list(
db.scalars(
select(PersonAttendancePoint.attendance_point_name)
.join(AttendancePoint, AttendancePoint.name == PersonAttendancePoint.attendance_point_name)
.where(
PersonAttendancePoint.phone == user.phone,
AttendancePoint.is_active.is_(True),
)
.order_by(PersonAttendancePoint.attendance_point_name)
)
)
return rows
def accessible_points(db: Session, user: Personnel) -> list[AttendancePoint]:
names = accessible_point_names(db, user)
return list(
db.scalars(
select(AttendancePoint)
.where(AttendancePoint.name.in_(names), AttendancePoint.is_active.is_(True))
.order_by(AttendancePoint.name)
)
)
def require_attendance_point_access(db: Session, user: Personnel, point_name: str | None) -> str:
name = clean_point_name(point_name) or DEFAULT_ATTENDANCE_POINT_NAME
if db.get(AttendancePoint, name) is None:
raise ValueError(f"没有该考勤点:{name}")
if user.role != Role.manager and name not in accessible_point_names(db, user):
raise PermissionError(f"无该考勤点权限:{name}")
return name
def distance_meters(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
delta_lat = radians(lat2 - lat1)
delta_lng = radians(lng2 - lng1)
first_lat = radians(lat1)
second_lat = radians(lat2)
value = (
sin(delta_lat / 2) * sin(delta_lat / 2)
+ cos(first_lat) * cos(second_lat) * sin(delta_lng / 2) * sin(delta_lng / 2)
)
return 2 * EARTH_RADIUS_METERS * asin(sqrt(min(1, value)))
def validate_attendance_point_location(
db: Session,
point_name: str,
latitude: float | None,
longitude: float | None,
*,
action_name: str = "扫码报工",
) -> None:
if latitude is None or longitude is None:
raise ValueError(f"{action_name}需要校验当前位置")
point = db.get(AttendancePoint, clean_point_name(point_name))
if point is None or not point.is_active:
raise ValueError(f"没有该考勤点:{point_name}")
if point.latitude is None or point.longitude is None:
raise ValueError(f"考勤点【{point.name}】未配置经纬度")
distance = distance_meters(float(latitude), float(longitude), float(point.latitude), float(point.longitude))
radius = int(point.radius_meters or 500)
if distance > radius:
raise PermissionError(
f"当前位置距离考勤点【{point.name}】约{round(distance)}米,超过{radius}米范围,不能{action_name}"
)
def person_with_access_points(db: Session, phone: str) -> Personnel | None:
return db.scalar(
select(Personnel)
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
.where(Personnel.phone == phone)
)
def set_person_attendance_points(db: Session, phone: str, point_names: list[str]) -> None:
cleaned = [clean_point_name(name) for name in point_names if clean_point_name(name)]
if not cleaned:
cleaned = [ensure_default_attendance_point(db).name]
valid_names = set(attendance_point_names(db))
missing = [name for name in cleaned if name not in valid_names]
if missing:
raise ValueError(f"没有该考勤点:{'、'.join(missing)}")
db.query(PersonAttendancePoint).filter(PersonAttendancePoint.phone == phone).delete()
db.flush()
for name in dict.fromkeys(cleaned):
db.add(PersonAttendancePoint(phone=phone, attendance_point_name=name))
+230
View File
@@ -0,0 +1,230 @@
import asyncio
import logging
from datetime import datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.database import SessionLocal
from app.models import (
Product,
ProductionReport,
ProductionReportItem,
ReportStatus,
SessionStatus,
WorkSession,
WorkSessionDevice,
)
from app.services.metrics import calculate_report_metrics, minutes_between, release_at_caps_work_time
from app.services.misc_work import is_misc_product
from app.services.mold_locks import release_session_locks
from app.services.work_schedule import DEFAULT_AUTO_SUBMIT_HOURS, get_work_schedule, get_work_schedule_config
from app.timezone import now
AUTO_SUBMIT_INTERVAL_SECONDS = 300
AUTO_SUBMIT_REASON = "超时-系统自动提交"
logger = logging.getLogger(__name__)
def _ordered_session_devices(session: WorkSession) -> list[WorkSessionDevice]:
return sorted(
session.devices,
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
)
def _mold_key(product_name: str, process_name: str | None) -> tuple[str, str]:
return (str(product_name or "").strip(), str(process_name or "").strip())
def _draft_mold_ranges(session: WorkSession) -> list[tuple[str, str, datetime, datetime]]:
if session.end_at is None:
return []
ordered_devices = _ordered_session_devices(session)
ranges_by_mold: dict[tuple[str, str], dict[str, datetime]] = {}
for index, device in enumerate(ordered_devices):
segment_start = session.start_at if index == 0 else max(session.start_at, device.scanned_at)
segment_end = session.end_at
if index + 1 < len(ordered_devices):
segment_end = min(session.end_at, ordered_devices[index + 1].scanned_at)
if release_at_caps_work_time(device) and device.released_at is not None:
segment_end = min(segment_end, device.released_at)
if segment_end < segment_start:
segment_end = segment_start
mold_key = _mold_key(device.device_no, device.process_name)
if mold_key not in ranges_by_mold:
ranges_by_mold[mold_key] = {
"start_at": segment_start,
"end_at": segment_end,
}
continue
ranges_by_mold[mold_key]["start_at"] = min(ranges_by_mold[mold_key]["start_at"], segment_start)
ranges_by_mold[mold_key]["end_at"] = max(ranges_by_mold[mold_key]["end_at"], segment_end)
return [
(mold_name, process_name, value["start_at"], value["end_at"])
for (mold_name, process_name), value in ranges_by_mold.items()
]
def _product_for_mold(db: Session, session: WorkSession, mold_name: str, process_name: str) -> Product | None:
return db.scalar(
select(Product)
.where(
Product.attendance_point_name == session.attendance_point_name,
Product.product_name == mold_name,
Product.process_name == process_name,
Product.device_no == "",
)
.order_by(Product.project_no.asc())
)
def _fallback_mold_ranges(session: WorkSession) -> list[tuple[str, str, datetime, datetime]]:
if session.end_at is None:
return []
ordered_devices = _ordered_session_devices(session)
if not ordered_devices:
return []
first = ordered_devices[0]
return [(first.device_no, first.process_name or "", session.start_at, session.end_at)]
def _build_auto_report_items(db: Session, session: WorkSession) -> list[ProductionReportItem]:
report_items: list[ProductionReportItem] = []
mold_ranges = _draft_mold_ranges(session) or _fallback_mold_ranges(session)
for mold_name, process_name, block_start_at, _block_end_at in mold_ranges:
product = _product_for_mold(db, session, mold_name, process_name)
item_is_misc = is_misc_product(product)
report_items.append(
ProductionReportItem(
attendance_point_name=session.attendance_point_name,
device_no=mold_name if item_is_misc else "",
project_no=product.project_no if product else "",
product_name=product.product_name if product else mold_name,
material_code=product.material_code if product else None,
material_name=product.material_name if product else None,
raw_material_batch_no=None,
process_name=product.process_name if product else process_name,
stamping_method=product.stamping_method if product else None,
operator_count=product.operator_count if product else 1,
process_unit_price_yuan=product.process_unit_price_yuan if product else 0,
standard_beat=0 if item_is_misc else (product.standard_beat if product else 0),
standard_workload=0,
good_qty=0,
defect_qty=0,
scrap_qty=0,
started_at=block_start_at,
remark=None,
)
)
return report_items
def auto_submit_session(db: Session, session: WorkSession, submitted_at: datetime | None = None) -> ProductionReport | None:
existing_report = db.scalar(select(ProductionReport).where(ProductionReport.session_id == session.id))
if existing_report is not None:
session.status = SessionStatus.submitted
return None
timestamp = submitted_at or now()
session.end_at = timestamp
session.status = SessionStatus.submitted
release_session_locks(session, None, AUTO_SUBMIT_REASON, timestamp)
items = _build_auto_report_items(db, session)
schedule = get_work_schedule_config(db, session.attendance_point_name)
metrics = calculate_report_metrics(
session.start_at,
session.end_at,
items,
devices=session.devices,
schedule=schedule,
)
report = ProductionReport(
attendance_point_name=session.attendance_point_name,
session_id=session.id,
employee_phone=session.employee_phone,
report_date=timestamp.date(),
start_at=session.start_at,
end_at=session.end_at,
duration_minutes=metrics["duration_minutes"] if items else minutes_between(session.start_at, session.end_at),
break_minutes=0,
effective_minutes=metrics["effective_minutes"],
total_good_qty=metrics["total_good_qty"],
total_output_qty=metrics["total_output_qty"],
actual_beat=metrics["actual_beat"],
standard_beat=metrics["standard_beat"],
expected_workload=metrics["expected_workload"],
pace_rate=metrics["pace_rate"],
workload_rate=metrics["workload_rate"],
status=ReportStatus.pending,
submitted_at=timestamp,
is_system_auto_submitted=True,
auto_submit_reason=AUTO_SUBMIT_REASON,
items=items,
)
db.add(report)
return report
def auto_submit_overdue_sessions(
db: Session,
current_time: datetime | None = None,
employee_phone: str | None = None,
limit: int = 100,
) -> int:
timestamp = current_time or now()
try:
auto_submit_hours = float(get_work_schedule(db).auto_submit_hours or DEFAULT_AUTO_SUBMIT_HOURS)
except Exception:
db.rollback()
auto_submit_hours = DEFAULT_AUTO_SUBMIT_HOURS
cutoff = timestamp - timedelta(hours=auto_submit_hours)
query = (
select(WorkSession)
.options(selectinload(WorkSession.devices))
.where(
WorkSession.status == SessionStatus.active,
WorkSession.start_at <= cutoff,
)
.order_by(WorkSession.start_at.asc())
)
if employee_phone:
query = query.where(WorkSession.employee_phone == employee_phone)
if db.get_bind().dialect.name in {"mysql", "postgresql"}:
query = query.with_for_update(skip_locked=True)
query = query.limit(limit)
sessions = db.scalars(query).all()
submitted_count = 0
for session in sessions:
if auto_submit_session(db, session, timestamp) is not None:
submitted_count += 1
if sessions:
db.commit()
return submitted_count
async def auto_submit_overdue_loop(interval_seconds: int = AUTO_SUBMIT_INTERVAL_SECONDS) -> None:
while True:
try:
submitted_count = await asyncio.to_thread(_run_auto_submit_once)
if submitted_count:
logger.info("auto submitted overdue work sessions: %s", submitted_count)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("auto submit overdue work sessions failed")
await asyncio.sleep(interval_seconds)
def _run_auto_submit_once() -> int:
with SessionLocal() as db:
return auto_submit_overdue_sessions(db)
+22
View File
@@ -0,0 +1,22 @@
CLEANING_STAMPING_METHOD = "清洗"
LEGACY_CLEANING_PROCESS_NAME = "清洗"
def clean_text(value: str | None) -> str:
return str(value or "").strip()
def is_cleaning_stamping_method(value: str | None) -> bool:
return clean_text(value) == CLEANING_STAMPING_METHOD
def is_legacy_cleaning_process(value: str | None) -> bool:
return clean_text(value) == LEGACY_CLEANING_PROCESS_NAME
def is_cleaning_product(product) -> bool:
return bool(product) and is_cleaning_stamping_method(getattr(product, "stamping_method", None))
def is_cleaning_item(item) -> bool:
return is_cleaning_stamping_method(getattr(item, "stamping_method", None))
+63
View File
@@ -0,0 +1,63 @@
from decimal import Decimal
from math import ceil
from typing import Any
from sqlalchemy import Select, func, select
from sqlalchemy.orm import Session
ROLE_NAMES = {
"worker": "冲压工人",
"admin": "管理员",
"manager": "经理",
}
REPORT_STATUS_NAMES = {
"pending": "待审核",
"approved": "已通过",
"rejected": "已驳回",
}
def normalize_device_no(value: str | None) -> str:
return str(value or "").strip()
def as_float(value: Any) -> float:
if isinstance(value, Decimal):
return float(value)
if value is None:
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def round1(value: Any) -> float:
return round(as_float(value), 1)
def round2(value: Any) -> float:
return round(as_float(value), 2)
def round3(value: Any) -> float:
return round(as_float(value), 3)
def paginate(db: Session, query: Select, page: int, page_size: int) -> dict[str, Any]:
safe_page = max(1, int(page or 1))
safe_page_size = min(100, max(1, int(page_size or 10)))
count_query = select(func.count()).select_from(query.order_by(None).subquery())
total = db.scalar(count_query) or 0
total_pages = max(1, ceil(total / safe_page_size))
safe_page = min(safe_page, total_pages)
rows = db.scalars(query.limit(safe_page_size).offset((safe_page - 1) * safe_page_size)).all()
return {
"page": safe_page,
"page_size": safe_page_size,
"total": total,
"total_pages": total_pages,
"rows": rows,
}
+17
View File
@@ -0,0 +1,17 @@
CONTINUOUS_DIE_STAMPING_METHOD = "连续模"
def clean_text(value: str | None) -> str:
return str(value or "").strip()
def is_continuous_die_stamping_method(value: str | None) -> bool:
return clean_text(value) == CONTINUOUS_DIE_STAMPING_METHOD
def is_continuous_die_product(product) -> bool:
return bool(product) and is_continuous_die_stamping_method(getattr(product, "stamping_method", None))
def is_continuous_die_item(item) -> bool:
return is_continuous_die_stamping_method(getattr(item, "stamping_method", None))
+25
View File
@@ -0,0 +1,25 @@
def _clean(value: str | None) -> str:
return str(value or "").strip()
def mold_process_display_name(
product_name: str | None,
process_name: str | None,
stamping_method: str | None = None,
) -> str:
parts = [_clean(product_name), _clean(process_name) or "-"]
stamping = _clean(stamping_method)
if stamping:
parts.append(stamping)
return " / ".join(part for part in parts if part)
def point_mold_display_name(
point_name: str | None,
product_name: str | None,
process_name: str | None,
stamping_method: str | None = None,
) -> str:
point = _clean(point_name)
mold_text = mold_process_display_name(product_name, process_name, stamping_method)
return f"{point} / {mold_text}" if point else mold_text
+219
View File
@@ -0,0 +1,219 @@
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import PatternFill
from sqlalchemy import and_, not_, or_, select
from sqlalchemy.orm import Session
from app.models import PersonAttendancePoint, PersonRole, Personnel, Product, Role
from app.services.attendance_points import accessible_point_names
from app.services.common import ROLE_NAMES, as_float
from app.services.misc_work import MISC_WORK_PROCESS_NAME, MISC_WORK_PRODUCT_NAME, MISC_WORK_STAMPING_METHOD
from app.services.process_names import export_process_name
def _workbook_bytes(workbook: Workbook) -> bytes:
output = BytesIO()
workbook.save(output)
return output.getvalue()
def _optional_float(value):
return "" if value is None else as_float(value)
def export_products(db: Session, user=None) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "产品清单"
sheet.append([
"考勤点",
"项目号",
"型材号",
"产品名称",
"物料编码",
"物料名称",
"供应商",
"工序",
"冲压方式",
"操作人数",
"工序单价",
"不包含物料转运节拍",
"标准节拍",
"产品净重(kg)",
"产品毛重(kg)",
"允许报废率",
"废料单价(元/kg)",
])
query = select(Product).where(
Product.device_no == "",
or_(Product.stamping_method.is_(None), Product.stamping_method != MISC_WORK_STAMPING_METHOD),
not_(and_(Product.product_name == MISC_WORK_PRODUCT_NAME, Product.process_name == MISC_WORK_PROCESS_NAME)),
)
if user is not None:
query = query.where(Product.attendance_point_name.in_(accessible_point_names(db, user)))
products = db.scalars(query.order_by(Product.attendance_point_name, Product.project_no, Product.product_name, Product.process_name)).all()
for product in products:
sheet.append([
product.attendance_point_name,
product.project_no,
product.profile_no,
product.product_name,
product.material_code,
product.material_name,
product.supplier,
export_process_name(product.process_name),
product.stamping_method,
as_float(product.operator_count),
as_float(product.process_unit_price_yuan),
"",
as_float(product.standard_beat),
_optional_float(product.product_net_weight_kg),
_optional_float(product.product_gross_weight_kg),
_optional_float(product.scrap_loss_rate),
_optional_float(product.waste_price_yuan_per_kg),
])
return _workbook_bytes(workbook)
def export_people(db: Session, user=None) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "人员清单"
sheet.append(["考勤点", "电话号", "姓名", "角色"])
query = select(PersonRole).join(PersonRole.person).order_by(Personnel.name, PersonRole.role)
if user is not None and user.role == Role.admin:
point_names = accessible_point_names(db, user)
query = query.where(
PersonRole.role == Role.worker,
Personnel.attendance_points.any(PersonAttendancePoint.attendance_point_name.in_(point_names)),
)
people = db.scalars(query).all()
for item in people:
point_names = sorted({point.attendance_point_name for point in item.person.attendance_points})
sheet.append([
"、".join(point_names),
item.phone,
item.person.name,
ROLE_NAMES.get(str(item.role), str(item.role)),
])
return _workbook_bytes(workbook)
def export_dashboard_rows(rows: list) -> bytes:
workbook = Workbook()
sheet = workbook.active
sheet.title = "报工看板"
warn_fill = PatternFill(fill_type="solid", fgColor="FFFF6666")
void_fill = PatternFill(fill_type="solid", fgColor="FFD9D9D9")
sheet.append([
"考勤点",
"报工日期",
"员工姓名",
"员工电话",
"工种",
"项目号",
"产品名称",
"工序",
"有效工时(时)",
"班种分布",
"成品数量",
"不良数量",
"标准工作量",
"实际节拍",
"标准节拍",
"型材号",
"物料编码",
"物料名称",
"材料库存批次号",
"使用设备",
"冲压方式",
"换料次数",
"操作人数",
"工序单价",
"参考工资",
"报工标记",
"校正项",
"备注",
])
wage_summary: dict[tuple[str, str, str], dict[str, float | str]] = {}
for row_index, row in enumerate(rows, start=2):
report_flags = []
if getattr(row, "is_voided", False):
report_flags.append("作废")
if getattr(row, "is_multi_person", False):
report_flags.append("多人协作")
if getattr(row, "is_continuous_die", False):
report_flags.append("连续模")
correction_text = ";".join(
f"{item.get('label', '')} {item.get('old_value', '-')}→{item.get('new_value', '-')}"
for item in getattr(row, "correction_pairs", [])
)
sheet.append([
row.attendance_point_name,
row.report_date.isoformat(),
row.employee_name,
row.employee_phone,
row.worker_type,
row.project_no,
row.product_name,
row.process_name,
round(as_float(row.effective_minutes) / 60, 2),
row.shift_distribution_text,
row.total_good_qty,
row.total_defect_qty,
row.expected_workload,
row.actual_beat,
row.standard_beat,
row.profile_no,
row.material_code,
row.material_name,
row.raw_material_batch_no,
row.device_no,
row.stamping_method,
row.changeover_count,
row.operator_count,
row.process_unit_price_yuan,
row.reference_wage,
"、".join(report_flags),
correction_text,
getattr(row, "review_remark", None),
])
summary_key = (row.employee_phone, row.employee_name, row.worker_type)
summary = wage_summary.setdefault(
summary_key,
{
"employee_phone": row.employee_phone,
"employee_name": row.employee_name,
"worker_type": row.worker_type,
"total_good_qty": 0.0,
"reference_wage": 0.0,
"detail_count": 0.0,
},
)
summary["total_good_qty"] = as_float(summary["total_good_qty"]) + as_float(row.total_good_qty)
summary["reference_wage"] = as_float(summary["reference_wage"]) + as_float(row.reference_wage)
summary["detail_count"] = as_float(summary["detail_count"]) + 1
actual_beat = as_float(row.actual_beat)
standard_beat = as_float(row.standard_beat)
total_good_qty = as_float(row.total_good_qty)
expected_workload = as_float(row.expected_workload)
if standard_beat > 0 and (actual_beat > standard_beat or actual_beat < standard_beat * 0.7):
sheet.cell(row=row_index, column=14).fill = warn_fill
if expected_workload > 0 and (total_good_qty < expected_workload or total_good_qty > expected_workload * 1.3):
sheet.cell(row=row_index, column=11).fill = warn_fill
if getattr(row, "is_voided", False):
for cell in sheet[row_index]:
cell.fill = void_fill
summary_sheet = workbook.create_sheet("工资汇总")
summary_sheet.append(["员工姓名", "员工电话", "工种", "明细行数", "成品数量合计", "参考工资合计"])
for summary in sorted(wage_summary.values(), key=lambda item: (str(item["employee_name"]), str(item["employee_phone"]))):
summary_sheet.append([
summary["employee_name"],
summary["employee_phone"],
summary["worker_type"],
int(as_float(summary["detail_count"])),
round(as_float(summary["total_good_qty"]), 2),
round(as_float(summary["reference_wage"]), 2),
])
return _workbook_bytes(workbook)
+406
View File
@@ -0,0 +1,406 @@
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from typing import Any
from openpyxl import load_workbook
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import PersonRole, Personnel, Product, Role
from app.services.attendance_points import (
DEFAULT_ATTENDANCE_POINT_NAME,
accessible_point_names,
attendance_point_names,
set_person_attendance_points,
)
from app.services.cleaning import LEGACY_CLEANING_PROCESS_NAME, is_cleaning_stamping_method
from app.services.misc_work import is_misc_stamping_method
from app.services.process_names import normalize_numeric_process_name
@dataclass
class ImportStats:
imported: int = 0
updated: int = 0
skipped: int = 0
errors: list[str] | None = None
def as_dict(self) -> dict[str, Any]:
return {
"imported": self.imported,
"updated": self.updated,
"skipped": self.skipped,
"errors": self.errors or [],
}
PRODUCT_HEADERS = {
"考勤点": "attendance_point_name",
"项目号": "project_no",
"型材号": "profile_no",
"产品名称": "product_name",
"物料编码": "material_code",
"物料名称": "material_name",
"供应商": "supplier",
"产品净重(kg)": "product_net_weight_kg",
"产品毛重(kg)": "product_gross_weight_kg",
"允许报废率": "scrap_loss_rate",
"废料单价(元/kg)": "waste_price_yuan_per_kg",
"工序": "process_name",
"冲压方式": "stamping_method",
"操作人数": "operator_count",
"工序单价": "process_unit_price_yuan",
"标准节拍": "standard_beat",
}
PRODUCT_AUX_FIELDS = (
("product_net_weight_kg", "产品净重(kg)"),
("product_gross_weight_kg", "产品毛重(kg)"),
("scrap_loss_rate", "允许报废率"),
("waste_price_yuan_per_kg", "废料单价(元/kg)"),
)
class ProductImportValidationError(ValueError):
pass
PERSON_HEADERS = {
"考勤点": "attendance_point_names",
"电话号": "phone",
"手机号": "phone",
"姓名": "name",
"角色": "role",
}
ROLE_MAP = {
"冲压工人": Role.worker,
"员工": Role.worker,
"worker": Role.worker,
"管理员": Role.admin,
"admin": Role.admin,
"经理": Role.manager,
"manager": Role.manager,
}
def _text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _split_names(value: Any) -> list[str]:
text = _text(value)
if not text:
return []
for sep in [",", ",", ";", ";", "/", "|"]:
text = text.replace(sep, "、")
return [item.strip() for item in text.split("、") if item.strip()]
def _number(value: Any, default: float = 0) -> float:
try:
if value is None or value == "":
return default
return float(value)
except (TypeError, ValueError):
return default
def _optional_number(value: Any) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _normalized_aux_value(value: Any) -> str:
text = _text(value)
if text == "":
return ""
try:
return str(Decimal(text).normalize())
except InvalidOperation:
return text
def _rows_from_excel(content: bytes) -> tuple[list[str], list[dict[str, Any]]]:
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return [], []
headers = [_text(value) for value in rows[0]]
mapped_rows = []
for values in rows[1:]:
mapped_rows.append({headers[index]: value for index, value in enumerate(values) if index < len(headers)})
return headers, mapped_rows
def _validate_product_aux_consistency(db: Session, rows: list[dict[str, Any]]) -> None:
aux_headers = {label: field for field, label in PRODUCT_AUX_FIELDS}
if not any(header in row for row in rows for header in aux_headers):
return
grouped: dict[str, dict[str, tuple[int, str, str]]] = {}
errors: list[str] = []
for index, raw in enumerate(rows, start=2):
point_name = _text(raw.get("考勤点")) or DEFAULT_ATTENDANCE_POINT_NAME
product_name = _text(raw.get("产品名称"))
process_name = _text(raw.get("工序"))
if not product_name:
continue
values = tuple(
_normalized_aux_value(raw.get(label))
for _, label in PRODUCT_AUX_FIELDS
)
group_key = f"{point_name}||{product_name}"
first_by_product = grouped.setdefault(group_key, {})
previous = first_by_product.get("values")
current_text = ",".join(
f"{label}={value or '空'}"
for (_, label), value in zip(PRODUCT_AUX_FIELDS, values)
)
if previous is None:
first_by_product["values"] = (index, process_name, current_text)
first_by_product["raw_values"] = (-1, "", "|".join(values))
continue
previous_raw = first_by_product["raw_values"][2]
if previous_raw != "|".join(values):
first_row, first_process, first_text = previous
errors.append(
f"考勤点【{point_name}】产品【{product_name}】不同工序的ERP辅助字段不一致:"
f"第{first_row}行({first_process or '未填写工序'})为【{first_text}】,"
f"第{index}行({process_name or '未填写工序'})为【{current_text}】"
)
if errors:
detail = ";".join(errors[:10])
if len(errors) > 10:
detail += f";另有{len(errors) - 10}处不一致"
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
def _validate_point_names(db: Session, names: list[str], user=None, *, action: str) -> None:
known = set(attendance_point_names(db))
missing = [name for name in names if name not in known]
if missing:
raise ProductImportValidationError(f"没有该考勤点:{'、'.join(sorted(set(missing)))}。本次导入已取消,数据库未写入。")
if user is not None and user.role != Role.manager:
allowed = set(accessible_point_names(db, user))
denied = [name for name in names if name not in allowed]
if denied:
raise ProductImportValidationError(f"无该考勤点{action}权限:{'、'.join(sorted(set(denied)))}。本次导入已取消,数据库未写入。")
def _validate_product_process_names(rows: list[dict[str, Any]]) -> None:
errors: list[str] = []
for index, raw in enumerate(rows, start=2):
if not any(_text(value) for value in raw.values()):
continue
raw_process_name = _text(raw.get("工序"))
stamping_method = _text(raw.get("冲压方式"))
is_misc = is_misc_stamping_method(stamping_method)
if is_misc:
errors.append(f"第{index}行处理杂活由系统自动生成,不能通过产品清单导入")
continue
if raw_process_name == LEGACY_CLEANING_PROCESS_NAME:
errors.append(f"第{index}行工序不能填写清洗,请把清洗填写在冲压方式中")
continue
try:
normalize_numeric_process_name(raw_process_name)
except ValueError:
errors.append(f"第{index}行工序必须是数字")
continue
if errors:
detail = ";".join(errors[:10])
if len(errors) > 10:
detail += f";另有{len(errors) - 10}处错误"
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
def import_products(db: Session, content: bytes, user=None) -> ImportStats:
_, rows = _rows_from_excel(content)
_validate_product_aux_consistency(db, rows)
_validate_product_process_names(rows)
point_values = [
_text(raw.get("考勤点")) or DEFAULT_ATTENDANCE_POINT_NAME
for raw in rows
if any(_text(value) for value in raw.values())
]
_validate_point_names(db, point_values, user, action="产品导入")
stats = ImportStats(errors=[])
last_project_no = ""
seen: dict[tuple[str, str, str, str, str], Product] = {}
aux_by_product: dict[tuple[str, str], tuple[float | None, float | None, float | None, float | None]] = {}
for index, raw in enumerate(rows, start=2):
data = {field: raw[header] for header, field in PRODUCT_HEADERS.items() if header in raw}
attendance_point_name = _text(data.get("attendance_point_name")) or DEFAULT_ATTENDANCE_POINT_NAME
project_no = _text(data.get("project_no")) or last_project_no
product_name = _text(data.get("product_name"))
device_no = ""
raw_process_name = _text(data.get("process_name"))
stamping_method = _text(data.get("stamping_method"))
is_cleaning = is_cleaning_stamping_method(stamping_method)
is_misc = is_misc_stamping_method(stamping_method)
standard_beat = _number(data.get("standard_beat"))
if project_no:
last_project_no = project_no
if is_misc:
stats.skipped += 1
stats.errors.append(f"第{index}行处理杂活由系统自动生成,不能通过产品清单导入")
continue
if raw_process_name == LEGACY_CLEANING_PROCESS_NAME:
stats.skipped += 1
stats.errors.append(f"第{index}行工序不能填写清洗,请把清洗填写在冲压方式中,工序只能填写数字")
continue
try:
process_name = normalize_numeric_process_name(raw_process_name)
except ValueError:
stats.skipped += 1
stats.errors.append(f"第{index}行工序必须是数字")
continue
if not project_no or not product_name or not process_name or (not is_cleaning and not is_misc and standard_beat <= 0):
stats.skipped += 1
stats.errors.append(f"第{index}行缺少项目号、产品名称、工序或标准节拍(冲压方式为清洗时标准节拍可空)")
continue
key = (attendance_point_name, project_no, product_name, device_no, process_name)
if key in seen:
stats.skipped += 1
stats.errors.append(f"第{index}行重复产品/工序:{attendance_point_name} / {project_no} / {product_name} / {process_name}")
continue
product = seen.get(key)
if product is None:
product = db.get(
Product,
{
"attendance_point_name": attendance_point_name,
"project_no": project_no,
"product_name": product_name,
"device_no": device_no,
"process_name": process_name,
},
)
if product is None:
product = Product(
attendance_point_name=attendance_point_name,
project_no=project_no,
product_name=product_name,
device_no=device_no,
process_name=process_name,
)
db.add(product)
stats.imported += 1
else:
stats.updated += 1
seen[key] = product
product.profile_no = _text(data.get("profile_no")) or None
product.material_code = _text(data.get("material_code")) or None
product.material_name = _text(data.get("material_name")) or None
product.supplier = _text(data.get("supplier")) or None
if "product_net_weight_kg" in data:
product.product_net_weight_kg = _optional_number(data.get("product_net_weight_kg"))
if "product_gross_weight_kg" in data:
product.product_gross_weight_kg = _optional_number(data.get("product_gross_weight_kg"))
if "scrap_loss_rate" in data:
product.scrap_loss_rate = _optional_number(data.get("scrap_loss_rate"))
if "waste_price_yuan_per_kg" in data:
product.waste_price_yuan_per_kg = _optional_number(data.get("waste_price_yuan_per_kg"))
aux_by_product[(attendance_point_name, product_name)] = (
product.product_net_weight_kg,
product.product_gross_weight_kg,
product.scrap_loss_rate,
product.waste_price_yuan_per_kg,
)
product.device_no = device_no
product.process_name = process_name
product.stamping_method = stamping_method or None
product.operator_count = _number(data.get("operator_count"), 1) or 1
product.process_unit_price_yuan = _number(data.get("process_unit_price_yuan"), 0)
product.standard_beat = 0 if (is_cleaning or is_misc) else standard_beat
product.standard_workload = 0
if stats.errors:
db.rollback()
detail = ";".join(stats.errors[:20])
if len(stats.errors) > 20:
detail += f";另有{len(stats.errors) - 20}处错误"
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
for (attendance_point_name, product_name), values in aux_by_product.items():
for sibling in db.scalars(
select(Product).where(
Product.attendance_point_name == attendance_point_name,
Product.product_name == product_name,
Product.device_no == "",
)
).all():
sibling.product_net_weight_kg = values[0]
sibling.product_gross_weight_kg = values[1]
sibling.scrap_loss_rate = values[2]
sibling.waste_price_yuan_per_kg = values[3]
db.commit()
return stats
def import_people(db: Session, content: bytes, user=None) -> ImportStats:
_, rows = _rows_from_excel(content)
row_point_names: list[str] = []
for raw in rows:
row_point_names.extend(_split_names(raw.get("考勤点")) or [DEFAULT_ATTENDANCE_POINT_NAME])
_validate_point_names(db, row_point_names, user, action="人员导入")
stats = ImportStats(errors=[])
for index, raw in enumerate(rows, start=2):
data = {field: raw[header] for header, field in PERSON_HEADERS.items() if header in raw}
phone = _text(data.get("phone"))
name = _text(data.get("name"))
role_text = _text(data.get("role"))
role = ROLE_MAP.get(role_text)
point_names = _split_names(data.get("attendance_point_names")) or [DEFAULT_ATTENDANCE_POINT_NAME]
if not phone or not name or role is None:
stats.skipped += 1
stats.errors.append(f"第{index}行缺少电话号、姓名或有效角色")
continue
if user is not None and user.role == Role.admin and role != Role.worker:
stats.skipped += 1
stats.errors.append(f"第{index}行管理员只能导入冲压工人")
continue
person = db.get(Personnel, phone)
if person is None:
person = Personnel(phone=phone)
db.add(person)
stats.imported += 1
else:
stats.updated += 1
person.name = name
person.is_temporary = False
person.temporary_expires_at = None
person_role = db.get(PersonRole, {"phone": phone, "role": role})
if person_role is None:
db.add(PersonRole(phone=phone, role=role))
try:
set_person_attendance_points(db, phone, point_names)
except ValueError as exc:
stats.skipped += 1
stats.errors.append(f"第{index}行{exc}")
if stats.errors:
db.rollback()
detail = ";".join(stats.errors[:20])
if len(stats.errors) > 20:
detail += f";另有{len(stats.errors) - 20}处错误"
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
db.commit()
return stats
+506
View File
@@ -0,0 +1,506 @@
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from typing import Protocol
from app.services.common import as_float, normalize_device_no, round1, round2
from app.services.misc_work import is_misc_item
from app.services.work_schedule import DEFAULT_WORK_SCHEDULE_CONFIG, WorkScheduleConfig
class ReportItemLike(Protocol):
device_no: str
product_name: str
process_name: str | None
started_at: datetime | None
standard_beat: float
standard_workload: float
stamping_method: str | None
good_qty: float
defect_qty: float
scrap_qty: float
allocated_minutes: float
class ReportDeviceLike(Protocol):
device_no: str
process_name: str | None
scanned_at: datetime
released_at: datetime | None
sort_order: int
SHIFT_BUCKETS = ("day", "overtime", "night")
SHIFT_LABELS = {
"day": "白班",
"overtime": "加班",
"night": "夜班",
}
MEAL_END_GRACE_MINUTES = 5
def minutes_between(start_at: datetime, end_at: datetime) -> float:
seconds = max(0.0, (end_at - start_at).total_seconds())
return seconds / 60
def _combine(day: date, value: time) -> datetime:
return datetime.combine(day, value)
def _parse_schedule_time(value: str) -> time:
hour, minute = [int(part) for part in str(value).split(":", 1)]
return time(hour, minute)
def _schedule_interval(base_date: date, start_text: str, end_text: str) -> tuple[datetime, datetime]:
start = _combine(base_date, _parse_schedule_time(start_text))
end = _combine(base_date, _parse_schedule_time(end_text))
if end <= start:
end += timedelta(days=1)
return start, end
def _overlap_minutes(
start_at: datetime,
end_at: datetime,
interval_start: datetime,
interval_end: datetime,
) -> float:
start = max(start_at, interval_start)
end = min(end_at, interval_end)
return minutes_between(start, end) if end > start else 0.0
def _covered_meal_overlap_minutes(
start_at: datetime,
end_at: datetime,
meal_start: datetime,
meal_end: datetime,
) -> float:
if end_at <= meal_end + timedelta(minutes=MEAL_END_GRACE_MINUTES):
return 0.0
if start_at <= meal_start and end_at >= meal_end:
return _overlap_minutes(start_at, end_at, meal_start, meal_end)
return 0.0
def _iter_shift_base_dates(start_at: datetime, end_at: datetime):
current = start_at.date() - timedelta(days=1)
last = end_at.date()
while current <= last:
yield current
current += timedelta(days=1)
def _raw_shift_minutes(
start_at: datetime,
end_at: datetime,
schedule: WorkScheduleConfig | None = None,
) -> dict[str, float]:
active_schedule = schedule or DEFAULT_WORK_SCHEDULE_CONFIG
minutes = {
"day": 0.0,
"overtime": 0.0,
"night": 0.0,
"meal_break": 0.0,
}
if end_at <= start_at:
return minutes
for current_date in _iter_shift_base_dates(start_at, end_at):
meal_intervals = [
_schedule_interval(current_date, active_schedule.lunch_start, active_schedule.lunch_end),
_schedule_interval(current_date, active_schedule.dinner_start, active_schedule.dinner_end),
]
shift_intervals = {
"day": [_schedule_interval(current_date, active_schedule.day_start, active_schedule.day_end)],
"overtime": [
_schedule_interval(current_date, active_schedule.overtime_start, active_schedule.overtime_end)
],
"night": [_schedule_interval(current_date, active_schedule.night_start, active_schedule.night_end)],
}
for meal_start, meal_end in meal_intervals:
minutes["meal_break"] += _covered_meal_overlap_minutes(
start_at,
end_at,
meal_start,
meal_end,
)
for bucket, intervals in shift_intervals.items():
for interval_start, interval_end in intervals:
raw_minutes = _overlap_minutes(start_at, end_at, interval_start, interval_end)
shift_start = max(start_at, interval_start)
shift_end = min(end_at, interval_end)
meal_minutes = sum(
_overlap_minutes(shift_start, shift_end, meal_start, meal_end)
for meal_start, meal_end in meal_intervals
if (
start_at <= meal_start
and end_at >= meal_end
and end_at > meal_end + timedelta(minutes=MEAL_END_GRACE_MINUTES)
)
)
minutes[bucket] += max(0.0, raw_minutes - meal_minutes)
return minutes
def shift_minutes_between(
start_at: datetime,
end_at: datetime,
break_minutes: float = 0,
schedule: WorkScheduleConfig | None = None,
) -> dict[str, float]:
return _raw_shift_minutes(start_at, end_at, schedule)
def effective_work_minutes_between(
start_at: datetime,
end_at: datetime,
break_minutes: float = 0,
schedule: WorkScheduleConfig | None = None,
) -> float:
shift_minutes = shift_minutes_between(start_at, end_at, break_minutes, schedule)
return sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
def format_hours(minutes: float) -> str:
text = f"{round(as_float(minutes) / 60, 2):.2f}".rstrip("0").rstrip(".")
return text or "0"
def shift_distribution_text(shift_minutes: dict[str, float]) -> str:
effective_minutes = sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
parts = [
f"{SHIFT_LABELS[key]}{format_hours(shift_minutes.get(key, 0.0))}小时"
for key in SHIFT_BUCKETS
if round(as_float(shift_minutes.get(key, 0.0)), 2) > 0
]
detail = f"({'、'.join(parts)})" if parts else ""
return f"工时 {format_hours(effective_minutes)}小时{detail}"
def _set_item_shift_minutes(item: ReportItemLike, shift_minutes: dict[str, float]) -> None:
for key in SHIFT_BUCKETS:
setattr(item, f"_shift_{key}_minutes", as_float(shift_minutes.get(key, 0.0)))
def _add_item_shift_minutes(item: ReportItemLike, shift_minutes: dict[str, float]) -> None:
for key in SHIFT_BUCKETS:
setattr(
item,
f"_shift_{key}_minutes",
_get_item_shift_minutes(item, key) + as_float(shift_minutes.get(key, 0.0)),
)
def _get_item_shift_minutes(item: ReportItemLike, key: str) -> float:
return as_float(getattr(item, f"_shift_{key}_minutes", 0.0))
def _scale_item_shift_minutes(item: ReportItemLike, scale: float) -> None:
for key in SHIFT_BUCKETS:
setattr(item, f"_shift_{key}_minutes", _get_item_shift_minutes(item, key) * scale)
def _mold_key(product_name: str | None, process_name: str | None = "") -> str:
return f"{normalize_device_no(product_name)}\0{str(process_name or '').strip()}"
WORK_TIME_IGNORED_RELEASE_REASONS = {
"换模具释放占用",
"报工提交释放占用",
"超时-系统自动提交",
}
def release_at_caps_work_time(device: ReportDeviceLike) -> bool:
if getattr(device, "released_at", None) is None:
return False
reason = str(getattr(device, "release_reason", "") or "").strip()
return reason not in WORK_TIME_IGNORED_RELEASE_REASONS
def _device_segment_ranges(
start_at: datetime,
end_at: datetime,
devices: list[ReportDeviceLike] | None,
) -> dict[str, list[tuple[datetime, datetime]]]:
ordered_devices = sorted(
devices or [],
key=lambda device: (int(getattr(device, "sort_order", 0) or 0), device.scanned_at),
)
if not ordered_devices:
return {}
segment_ranges: dict[str, list[tuple[datetime, datetime]]] = defaultdict(list)
for index, device in enumerate(ordered_devices):
segment_start = start_at if index == 0 else max(start_at, device.scanned_at)
segment_end = end_at
if index + 1 < len(ordered_devices):
segment_end = min(end_at, ordered_devices[index + 1].scanned_at)
released_at = getattr(device, "released_at", None)
if release_at_caps_work_time(device) and released_at is not None:
segment_end = min(segment_end, released_at)
if segment_end > segment_start:
segment_ranges[_mold_key(device.device_no, getattr(device, "process_name", ""))].append(
(segment_start, segment_end)
)
return segment_ranges
def _segment_ranges_shift_minutes(
segment_ranges: dict[str, list[tuple[datetime, datetime]]],
schedule: WorkScheduleConfig | None = None,
) -> dict[str, float]:
minutes = {key: 0.0 for key in SHIFT_BUCKETS}
for ranges in segment_ranges.values():
for segment_start, segment_end in ranges:
shift_minutes = shift_minutes_between(segment_start, segment_end, schedule=schedule)
for key in SHIFT_BUCKETS:
minutes[key] += shift_minutes.get(key, 0.0)
return minutes
def _clamp_datetime(value: datetime, start_at: datetime, end_at: datetime) -> datetime:
if value < start_at:
return start_at
if value > end_at:
return end_at
return value
def _allocate_device_item_minutes(
device_items: list[ReportItemLike],
segment_start: datetime,
segment_end: datetime,
schedule: WorkScheduleConfig | None = None,
) -> None:
if not device_items:
return
grouped_items: dict[datetime, list[ReportItemLike]] = defaultdict(list)
for item in device_items:
item_start = getattr(item, "started_at", None) or segment_start
grouped_items[_clamp_datetime(item_start, segment_start, segment_end)].append(item)
first_item_start = min(grouped_items)
if first_item_start > segment_start:
grouped_items[segment_start].extend(grouped_items.pop(first_item_start))
ordered_starts = sorted(grouped_items)
for index, item_start in enumerate(ordered_starts):
item_end = segment_end
if index + 1 < len(ordered_starts):
item_end = ordered_starts[index + 1]
shift_minutes = shift_minutes_between(item_start, item_end, schedule=schedule)
minutes = sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
items_at_start = grouped_items[item_start]
minutes_per_item = minutes / len(items_at_start)
for item in items_at_start:
item.allocated_minutes = as_float(item.allocated_minutes) + minutes_per_item
_add_item_shift_minutes(
item,
{key: shift_minutes.get(key, 0.0) / len(items_at_start) for key in SHIFT_BUCKETS},
)
def _allocate_item_minutes(
start_at: datetime,
end_at: datetime,
items: list[ReportItemLike],
effective_minutes: float,
devices: list[ReportDeviceLike] | None,
schedule: WorkScheduleConfig | None = None,
) -> None:
if not items:
return
for item in items:
item.allocated_minutes = 0.0
_set_item_shift_minutes(item, {})
segment_ranges = _device_segment_ranges(start_at, end_at, devices)
if not segment_ranges:
shift_minutes = shift_minutes_between(start_at, end_at, schedule=schedule)
minutes_per_item = effective_minutes / len(items)
for item in items:
item.allocated_minutes = minutes_per_item
_set_item_shift_minutes(
item,
{key: shift_minutes.get(key, 0.0) / len(items) for key in SHIFT_BUCKETS},
)
return
covered_shift_minutes = _segment_ranges_shift_minutes(segment_ranges, schedule)
target_effective_minutes = min(effective_minutes, sum(covered_shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS))
target_shift_minutes = covered_shift_minutes
items_by_device: dict[str, list[ReportItemLike]] = defaultdict(list)
for item in items:
mold_key = _mold_key(
getattr(item, "product_name", "") or item.device_no,
getattr(item, "process_name", ""),
)
items_by_device[mold_key].append(item)
allocated_item_ids: set[int] = set()
for device_no, ranges in segment_ranges.items():
device_items = items_by_device.get(device_no, [])
if not device_items:
continue
for segment_range in ranges:
_allocate_device_item_minutes(device_items, segment_range[0], segment_range[1], schedule)
for item in device_items:
allocated_item_ids.add(id(item))
unmatched_items = [item for item in items if id(item) not in allocated_item_ids]
if unmatched_items:
allocated_minutes = sum(as_float(item.allocated_minutes) for item in items)
remaining_minutes = max(0.0, target_effective_minutes - allocated_minutes)
minutes_per_item = remaining_minutes / len(unmatched_items)
allocated_shift_minutes = {
key: sum(_get_item_shift_minutes(item, key) for item in items)
for key in SHIFT_BUCKETS
}
remaining_shift_minutes = {
key: max(0.0, target_shift_minutes.get(key, 0.0) - allocated_shift_minutes[key])
for key in SHIFT_BUCKETS
}
for item in unmatched_items:
item.allocated_minutes = minutes_per_item
_set_item_shift_minutes(
item,
{key: remaining_shift_minutes[key] / len(unmatched_items) for key in SHIFT_BUCKETS},
)
allocated_minutes = sum(as_float(item.allocated_minutes) for item in items)
if target_effective_minutes <= 0:
for item in items:
item.allocated_minutes = 0.0
_set_item_shift_minutes(item, {})
elif allocated_minutes <= 0:
minutes_per_item = target_effective_minutes / len(items)
for item in items:
item.allocated_minutes = minutes_per_item
_set_item_shift_minutes(
item,
{key: target_shift_minutes.get(key, 0.0) / len(items) for key in SHIFT_BUCKETS},
)
elif abs(allocated_minutes - target_effective_minutes) > 0.000001:
scale = target_effective_minutes / allocated_minutes
for item in items:
item.allocated_minutes = as_float(item.allocated_minutes) * scale
_scale_item_shift_minutes(item, scale)
def calculate_report_metrics(
start_at: datetime,
end_at: datetime,
items: list[ReportItemLike],
break_minutes: float = 0,
devices: list[ReportDeviceLike] | None = None,
schedule: WorkScheduleConfig | None = None,
) -> dict[str, float]:
segment_ranges = _device_segment_ranges(start_at, end_at, devices)
if segment_ranges:
duration_minutes = sum(
minutes_between(segment_start, segment_end)
for ranges in segment_ranges.values()
for segment_start, segment_end in ranges
)
shift_minutes = _segment_ranges_shift_minutes(segment_ranges, schedule)
shift_minutes["meal_break"] = sum(
shift_minutes_between(segment_start, segment_end, break_minutes, schedule).get("meal_break", 0.0)
for ranges in segment_ranges.values()
for segment_start, segment_end in ranges
)
else:
duration_minutes = minutes_between(start_at, end_at)
shift_minutes = shift_minutes_between(start_at, end_at, break_minutes, schedule)
effective_minutes = sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
_allocate_item_minutes(start_at, end_at, items, effective_minutes, devices, schedule)
total_good_qty = 0.0
total_output_qty = 0.0
weighted_standard_beat = 0.0
standard_weight = 0.0
expected_workload = 0.0
productive_minutes = 0.0
for item in items:
if is_misc_item(item):
try:
item.standard_workload = 0
except AttributeError:
pass
continue
good_qty = as_float(item.good_qty)
defect_qty = as_float(item.defect_qty)
scrap_qty = as_float(item.scrap_qty)
output_qty = good_qty + defect_qty + scrap_qty
beat = as_float(item.standard_beat)
workload = as_float(item.allocated_minutes) * 60 / beat if beat > 0 else 0
weight = max(1.0, output_qty)
try:
item.standard_workload = round1(workload)
except AttributeError:
pass
total_good_qty += good_qty
total_output_qty += output_qty
weighted_standard_beat += beat * weight
standard_weight += weight
expected_workload += workload
productive_minutes += as_float(item.allocated_minutes)
actual_beat = productive_minutes * 60 / total_output_qty if total_output_qty > 0 else 0
standard_beat = weighted_standard_beat / standard_weight if standard_weight > 0 else 0
pace_rate = (actual_beat - standard_beat) / standard_beat * 100 if standard_beat > 0 else 0
workload_rate = (
(total_good_qty - expected_workload) / expected_workload * 100
if expected_workload > 0
else 0
)
return {
"duration_minutes": round2(duration_minutes),
"effective_minutes": round2(effective_minutes),
"shift_day_minutes": round2(shift_minutes["day"]),
"shift_overtime_minutes": round2(shift_minutes["overtime"]),
"shift_night_minutes": round2(shift_minutes["night"]),
"shift_other_minutes": 0,
"shift_meal_break_minutes": round2(shift_minutes["meal_break"]),
"shift_distribution_text": shift_distribution_text(shift_minutes),
"total_good_qty": round2(total_good_qty),
"total_output_qty": round2(total_output_qty),
"actual_beat": round2(actual_beat),
"standard_beat": round2(standard_beat),
"expected_workload": round1(expected_workload),
"pace_rate": round1(pace_rate),
"workload_rate": round1(workload_rate),
}
def build_result_text(metrics: dict[str, float]) -> str:
pace_rate = as_float(metrics.get("pace_rate"))
workload_rate = as_float(metrics.get("workload_rate"))
fast_abnormal = pace_rate < -30
large_abnormal = workload_rate > 30
pace_text = (
f"今日节拍慢于标准节拍{abs(pace_rate)}%"
if pace_rate > 0
else f"今日节拍快于标准节拍{abs(pace_rate)}%"
)
workload_text = (
f"今日工作量大于标准工作量{abs(workload_rate)}%"
if workload_rate >= 0
else f"今日工作量小于标准工作量{abs(workload_rate)}%"
)
if fast_abnormal or large_abnormal:
suffix = "数据明显超出标准,请自我核对一下是否报工填错。"
elif pace_rate <= 0 and workload_rate >= 0:
suffix = "今天真的很棒呢!"
else:
suffix = "后续需要加油喽!"
return f"{pace_text},{workload_text},{suffix}"
+72
View File
@@ -0,0 +1,72 @@
from sqlalchemy.orm import Session
from app.models import Product
MISC_WORK_PRODUCT_NAME = "处理杂活"
MISC_WORK_PROCESS_NAME = "杂活"
MISC_WORK_STAMPING_METHOD = "处理杂活"
MISC_WORK_PROJECT_NO = "MISC"
MISC_WORK_MATERIAL_CODE = "MISC-WORK"
MISC_WORK_MATERIAL_NAME = "处理杂活"
def is_misc_stamping_method(value: str | None) -> bool:
return str(value or "").strip() == MISC_WORK_STAMPING_METHOD
def is_misc_product(product) -> bool:
return bool(product) and (
is_misc_stamping_method(getattr(product, "stamping_method", None))
or (
getattr(product, "product_name", None) == MISC_WORK_PRODUCT_NAME
and getattr(product, "process_name", None) == MISC_WORK_PROCESS_NAME
)
)
def is_misc_item(item) -> bool:
return bool(item) and (
is_misc_stamping_method(getattr(item, "stamping_method", None))
or (
getattr(item, "product_name", None) == MISC_WORK_PRODUCT_NAME
and getattr(item, "process_name", None) == MISC_WORK_PROCESS_NAME
)
)
def ensure_misc_work_product(db: Session, attendance_point_name: str) -> Product:
product = db.get(
Product,
{
"attendance_point_name": attendance_point_name,
"project_no": MISC_WORK_PROJECT_NO,
"product_name": MISC_WORK_PRODUCT_NAME,
"device_no": "",
"process_name": MISC_WORK_PROCESS_NAME,
},
)
if product is None:
product = Product(
attendance_point_name=attendance_point_name,
project_no=MISC_WORK_PROJECT_NO,
product_name=MISC_WORK_PRODUCT_NAME,
device_no="",
process_name=MISC_WORK_PROCESS_NAME,
)
db.add(product)
product.profile_no = product.profile_no or "MISC"
product.material_code = product.material_code or MISC_WORK_MATERIAL_CODE
product.material_name = product.material_name or MISC_WORK_MATERIAL_NAME
product.supplier = product.supplier or "系统内置"
product.stamping_method = MISC_WORK_STAMPING_METHOD
product.operator_count = 1
product.process_unit_price_yuan = 0
product.standard_beat = 0
product.standard_workload = 0
return product
def ensure_misc_work_products(db: Session, attendance_point_names: list[str]) -> None:
for name in attendance_point_names:
ensure_misc_work_product(db, name)
+287
View File
@@ -0,0 +1,287 @@
from contextlib import contextmanager
import hashlib
from fastapi import HTTPException
from sqlalchemy import select, text
from sqlalchemy.orm import Session, selectinload
from app.models import (
MoldLockFeedback,
Personnel,
Product,
SessionStatus,
WorkSession,
WorkSessionDevice,
)
from app.schemas import MoldLockFeedbackOut, MonitorLockOut
from app.services.cleaning import is_cleaning_product
from app.services.common import round2
from app.services.display_names import point_mold_display_name
from app.services.metrics import minutes_between
from app.services.misc_work import is_misc_product
from app.services.multi_person import is_multi_person_product
from app.timezone import now
FEEDBACK_STATUS_PENDING = "pending"
FEEDBACK_STATUS_HANDLED = "handled"
MOLD_LOCK_PREFIX = "jh_mold_lock"
def mold_display_name(
point_name: str,
mold_name: str,
process_name: str | None,
stamping_method: str | None = None,
) -> str:
return point_mold_display_name(point_name, mold_name, process_name, stamping_method)
def is_lockable_product(product: Product | None) -> bool:
return (
bool(product)
and not is_cleaning_product(product)
and not is_misc_product(product)
and not is_multi_person_product(product)
)
def _mutex_key(point_name: str, mold_name: str, process_name: str) -> str:
raw = f"{point_name}\0{mold_name}\0{process_name}"
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return f"{MOLD_LOCK_PREFIX}:{digest}"
@contextmanager
def mold_lock_mutex(
db: Session,
point_name: str,
mold_name: str,
process_name: str,
*,
timeout_seconds: int = 5,
):
bind = db.get_bind()
if bind.dialect.name != "mysql":
yield
return
key = _mutex_key(point_name, mold_name, process_name)
acquired = db.scalar(
text("select get_lock(:lock_key, :timeout_seconds)"),
{"lock_key": key, "timeout_seconds": timeout_seconds},
)
if acquired != 1:
raise HTTPException(status_code=409, detail="模具占用检查繁忙,请稍后重试")
try:
yield
finally:
db.execute(text("select release_lock(:lock_key)"), {"lock_key": key})
def _product_for_mold(db: Session, point_name: str, mold_name: str, process_name: str) -> Product | None:
return db.scalar(
select(Product)
.where(
Product.attendance_point_name == point_name,
Product.product_name == mold_name,
Product.process_name == process_name,
Product.device_no == "",
)
.order_by(Product.project_no.asc())
)
def _ordered_devices(session: WorkSession) -> list[WorkSessionDevice]:
return sorted(
session.devices,
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
)
def current_unreleased_device(session: WorkSession) -> WorkSessionDevice | None:
devices = [device for device in _ordered_devices(session) if device.released_at is None]
return devices[-1] if devices else None
def _is_session_current_device(session: WorkSession, device: WorkSessionDevice) -> bool:
current = current_unreleased_device(session)
return bool(current and current.id == device.id)
def active_lock_for_mold(
db: Session,
point_name: str,
mold_name: str,
process_name: str,
) -> WorkSessionDevice | None:
rows = db.scalars(
select(WorkSessionDevice)
.options(
selectinload(WorkSessionDevice.session).selectinload(WorkSession.devices),
selectinload(WorkSessionDevice.session).selectinload(WorkSession.employee),
)
.join(WorkSession, WorkSession.id == WorkSessionDevice.session_id)
.where(
WorkSession.status.in_([SessionStatus.active, SessionStatus.reporting]),
WorkSessionDevice.released_at.is_(None),
WorkSessionDevice.attendance_point_name == point_name,
WorkSessionDevice.device_no == mold_name,
WorkSessionDevice.process_name == process_name,
)
.order_by(WorkSessionDevice.scanned_at.desc(), WorkSessionDevice.id.desc())
).all()
for device in rows:
if _is_session_current_device(device.session, device):
return device
return None
def active_locks_for_points(db: Session, point_names: list[str]) -> list[WorkSessionDevice]:
rows = db.scalars(
select(WorkSessionDevice)
.options(
selectinload(WorkSessionDevice.session).selectinload(WorkSession.devices),
selectinload(WorkSessionDevice.session).selectinload(WorkSession.employee),
)
.join(WorkSession, WorkSession.id == WorkSessionDevice.session_id)
.where(
WorkSession.status.in_([SessionStatus.active, SessionStatus.reporting]),
WorkSessionDevice.released_at.is_(None),
WorkSessionDevice.attendance_point_name.in_(point_names),
)
.order_by(WorkSessionDevice.scanned_at.desc(), WorkSessionDevice.id.desc())
).all()
locks: list[WorkSessionDevice] = []
seen_sessions: set[int] = set()
for device in rows:
session_id = int(device.session_id)
if session_id in seen_sessions or not _is_session_current_device(device.session, device):
continue
product = _product_for_mold(db, device.attendance_point_name, device.device_no, device.process_name)
if not is_lockable_product(product):
continue
locks.append(device)
seen_sessions.add(session_id)
return locks
def release_session_locks(
session: WorkSession,
released_by: str | None,
reason: str,
released_at=None,
) -> None:
timestamp = released_at or now()
for device in session.devices:
if device.released_at is None:
device.released_at = timestamp
device.released_by = released_by
device.release_reason = reason
def ensure_mold_available(
db: Session,
user: Personnel,
point_name: str,
mold_name: str,
process_name: str,
product: Product | None,
) -> None:
if not is_lockable_product(product):
return
lock_process_name = str(process_name or (product.process_name if product else "") or "").strip()
lock = active_lock_for_mold(db, point_name, mold_name, lock_process_name)
if lock is None or lock.session.employee_phone == user.phone:
return
occupied_name = lock.session.employee.name if lock.session and lock.session.employee else ""
message = f"已经被{occupied_name or lock.session.employee_phone}占用,请联系管理员解除占用"
raise HTTPException(
status_code=423,
detail={
"code": "mold_occupied",
"message": message,
"lock_id": lock.id,
"attendance_point_name": point_name,
"mold_name": mold_name,
"process_name": process_name,
"mold_display_name": mold_display_name(point_name, mold_name, process_name, product.stamping_method if product else None),
"occupied_phone": lock.session.employee_phone,
"occupied_name": occupied_name,
},
)
def release_lock(
db: Session,
lock_id: int,
released_by: str,
reason: str = "管理员强制解除占用",
) -> WorkSessionDevice:
lock = db.scalar(
select(WorkSessionDevice)
.options(selectinload(WorkSessionDevice.session).selectinload(WorkSession.devices))
.where(WorkSessionDevice.id == lock_id)
)
if lock is None:
raise ValueError("占用记录不存在")
if lock.released_at is None:
lock.released_at = now()
lock.released_by = released_by
lock.release_reason = reason
mark_feedbacks_handled(db, lock.id, released_by)
return lock
def mark_feedbacks_handled(db: Session, lock_id: int, handled_by: str) -> None:
timestamp = now()
feedbacks = db.scalars(
select(MoldLockFeedback).where(
MoldLockFeedback.session_device_id == lock_id,
MoldLockFeedback.status == FEEDBACK_STATUS_PENDING,
)
).all()
for feedback in feedbacks:
feedback.status = FEEDBACK_STATUS_HANDLED
feedback.handled_at = timestamp
feedback.handled_by = handled_by
def lock_out(db: Session, lock: WorkSessionDevice) -> MonitorLockOut:
occupied_name = lock.session.employee.name if lock.session and lock.session.employee else ""
product = _product_for_mold(db, lock.attendance_point_name, lock.device_no, lock.process_name)
return MonitorLockOut(
id=lock.id,
attendance_point_name=lock.attendance_point_name,
mold_name=lock.device_no,
process_name=lock.process_name,
stamping_method=product.stamping_method if product else None,
mold_display_name=mold_display_name(lock.attendance_point_name, lock.device_no, lock.process_name, product.stamping_method if product else None),
occupied_phone=lock.session.employee_phone,
occupied_name=occupied_name,
occupied_start_at=lock.scanned_at,
occupied_minutes=round2(minutes_between(lock.scanned_at, now())),
)
def feedback_out(db: Session, feedback: MoldLockFeedback) -> MoldLockFeedbackOut:
product = _product_for_mold(db, feedback.attendance_point_name, feedback.mold_name, feedback.process_name)
return MoldLockFeedbackOut(
id=feedback.id,
attendance_point_name=feedback.attendance_point_name,
mold_name=feedback.mold_name,
process_name=feedback.process_name,
stamping_method=product.stamping_method if product else None,
mold_display_name=mold_display_name(feedback.attendance_point_name, feedback.mold_name, feedback.process_name, product.stamping_method if product else None),
lock_id=feedback.session_device_id,
reporter_phone=feedback.reporter_phone,
reporter_name=feedback.reporter.name if feedback.reporter else "",
occupied_phone=feedback.occupied_phone,
occupied_name=feedback.occupied_person.name if feedback.occupied_person else "",
feedback_at=feedback.created_at,
read_at=feedback.read_at,
handled_at=feedback.handled_at,
status=feedback.status,
status_name="已处理" if feedback.status == FEEDBACK_STATUS_HANDLED else "未处理",
can_release=feedback.status != FEEDBACK_STATUS_HANDLED,
)
+9
View File
@@ -0,0 +1,9 @@
from app.services.common import as_float
def is_multi_person_product(product) -> bool:
return bool(product) and as_float(getattr(product, "operator_count", 1)) > 1
def is_multi_person_item(item) -> bool:
return as_float(getattr(item, "operator_count", 1)) > 1
+43
View File
@@ -0,0 +1,43 @@
import re
MISC_PROCESS_NAME = "杂活"
def normalize_process_name(value: str | None) -> str:
text = str(value or "").strip()
if text == MISC_PROCESS_NAME:
return MISC_PROCESS_NAME
if not re.fullmatch(r"\d+", text):
raise ValueError("工序只能填写正整数,或填写杂活")
number = int(text)
if number <= 0:
raise ValueError("工序只能填写正整数,或填写杂活")
return str(number)
def normalize_numeric_process_name(value: str | None) -> str:
text = str(value or "").strip()
if not re.fullmatch(r"\d+", text):
raise ValueError("工序必须填写正整数")
number = int(text)
if number <= 0:
raise ValueError("工序必须填写正整数")
return str(number)
def export_process_name(value: str | None) -> str:
text = str(value or "").strip()
if text == MISC_PROCESS_NAME:
return MISC_PROCESS_NAME
legacy_match = re.fullmatch(r"0*(\d+)序", text)
if legacy_match:
return str(int(legacy_match.group(1)))
if re.fullmatch(r"\d+", text):
return str(int(text))
return text
def process_order(process_name: str | None) -> tuple[int, str]:
text = export_process_name(process_name)
return (int(text), text) if re.fullmatch(r"\d+", text) else (0, text)
+327
View File
@@ -0,0 +1,327 @@
import asyncio
import hashlib
import logging
import re
import uuid
import zipfile
from datetime import datetime
from pathlib import Path
from urllib.parse import quote, unquote, urlparse
from sqlalchemy import select
from app.config import settings
from app.database import SessionLocal
from app.models import DeviceQrBatchTask, DeviceQRCode, Product
from app.schemas import DeviceQrBatchTaskOut
from app.services.cleaning import is_cleaning_product
from app.services.display_names import point_mold_display_name
from app.services.misc_work import is_misc_product
from app.services.wechat import create_device_qrcode
from app.timezone import now
QRCODE_TASK_STATUS_PENDING = "pending"
QRCODE_TASK_STATUS_RUNNING = "running"
QRCODE_TASK_STATUS_PAUSED = "paused"
QRCODE_TASK_STATUS_COMPLETED = "completed"
QRCODE_TASK_STATUS_FAILED = "failed"
QRCODE_BATCH_TASK_INTERVAL_SECONDS = 3
QRCODE_TASK_STATUS_NAMES = {
QRCODE_TASK_STATUS_PENDING: "等待生成",
QRCODE_TASK_STATUS_RUNNING: "生成中",
QRCODE_TASK_STATUS_PAUSED: "已停止",
QRCODE_TASK_STATUS_COMPLETED: "已完成",
QRCODE_TASK_STATUS_FAILED: "生成失败",
}
logger = logging.getLogger(__name__)
def _safe_file_stem(value: str) -> str:
normalized = re.sub(r'[\\/:*?"<>|\s]+', "_", value).strip("._")
return normalized[:120] or "模具二维码"
def _unique_zip_name(used_names: set[str], stem: str, suffix: str) -> str:
suffix = suffix if suffix else ".jpg"
name = f"{stem}{suffix}"
index = 2
while name in used_names:
name = f"{stem}_{index}{suffix}"
index += 1
used_names.add(name)
return name
def _qrcode_path_from_url(qr_url: str | None) -> Path | None:
if not qr_url:
return None
filename = unquote(Path(urlparse(qr_url).path).name)
if not filename:
return None
path = settings.upload_path / "qrcodes" / filename
return path if path.is_file() else None
def _scene_token(point_name: str, mold_name: str, process_name: str) -> str:
mold_key = f"{point_name}\0{mold_name}\0{process_name}"
return hashlib.sha1(mold_key.encode("utf-8")).hexdigest()[:16]
def _product_for_mold(db, point_name: str, mold_name: str, process_name: str) -> Product | None:
return db.scalar(
select(Product)
.where(
Product.attendance_point_name == point_name,
Product.product_name == mold_name,
Product.process_name == process_name,
Product.device_no == "",
)
.order_by(Product.project_no.asc())
)
def _public_base_url() -> str:
return settings.public_base_url.rstrip("/") or "http://127.0.0.1:8000"
def _task_file_name(items: list[dict]) -> str:
fingerprint = hashlib.sha1(
"|".join(
f"{item.get('attendance_point_name', '')}\0{item.get('product_name', '')}\0{item.get('process_name', '')}"
for item in items
).encode("utf-8")
).hexdigest()[:10]
unique = uuid.uuid4().hex[:8]
return f"mold-qrcodes-{datetime.now().strftime('%Y%m%d%H%M%S')}-{fingerprint}-{unique}.zip"
def _items_signature(items: list[dict]) -> tuple[tuple[str, str, str], ...]:
return tuple(
sorted(
(
str(item.get("attendance_point_name") or "").strip(),
str(item.get("product_name") or "").strip(),
str(item.get("process_name") or "").strip(),
)
for item in items
)
)
def create_qrcode_batch_task(db, *, created_by: str, items: list[dict]) -> DeviceQrBatchTask:
signature = _items_signature(items)
active_tasks = db.scalars(
select(DeviceQrBatchTask)
.where(
DeviceQrBatchTask.created_by == created_by,
DeviceQrBatchTask.status.in_([
QRCODE_TASK_STATUS_PENDING,
QRCODE_TASK_STATUS_RUNNING,
QRCODE_TASK_STATUS_PAUSED,
]),
)
.order_by(DeviceQrBatchTask.created_at.desc(), DeviceQrBatchTask.id.desc())
).all()
for active_task in active_tasks:
if _items_signature(list(active_task.items_json or [])) == signature:
return active_task
task = DeviceQrBatchTask(
file_name=_task_file_name(items),
status=QRCODE_TASK_STATUS_PENDING,
item_count=len(items),
completed_count=0,
failed_count=0,
items_json=items,
created_by=created_by,
)
db.add(task)
db.commit()
db.refresh(task)
return task
def qrcode_batch_task_out(task: DeviceQrBatchTask, public_base_url: str | None = None) -> DeviceQrBatchTaskOut:
item_count = max(0, int(task.item_count or 0))
completed_count = max(0, int(task.completed_count or 0))
if task.status == QRCODE_TASK_STATUS_COMPLETED:
progress_percent = 100.0
elif item_count <= 0:
progress_percent = 0.0
else:
progress_percent = round(min(100, completed_count / item_count * 100), 1)
zip_url = task.zip_url
if public_base_url and task.file_name:
zip_url = f"{public_base_url.rstrip('/')}/uploads/qrcode_zips/{quote(task.file_name, safe='')}"
return DeviceQrBatchTaskOut(
id=task.id,
file_name=task.file_name,
status=task.status,
status_name=QRCODE_TASK_STATUS_NAMES.get(task.status, task.status),
item_count=item_count,
completed_count=completed_count,
failed_count=max(0, int(task.failed_count or 0)),
progress_percent=progress_percent,
zip_url=zip_url,
error_message=task.error_message,
started_at=task.started_at,
finished_at=task.finished_at,
created_at=task.created_at,
updated_at=task.updated_at,
)
def recover_running_qrcode_batch_tasks() -> None:
with SessionLocal() as db:
tasks = db.scalars(
select(DeviceQrBatchTask).where(DeviceQrBatchTask.status == QRCODE_TASK_STATUS_RUNNING)
).all()
for task in tasks:
task.status = QRCODE_TASK_STATUS_PENDING
task.failed_count = 0
task.error_message = "服务重启后重新排队"
task.finished_at = None
if tasks:
db.commit()
def _claim_next_qrcode_batch_task() -> int | None:
with SessionLocal() as db:
query = (
select(DeviceQrBatchTask)
.where(DeviceQrBatchTask.status == QRCODE_TASK_STATUS_PENDING)
.order_by(DeviceQrBatchTask.created_at.asc(), DeviceQrBatchTask.id.asc())
.limit(1)
)
if db.get_bind().dialect.name in {"mysql", "postgresql"}:
query = query.with_for_update(skip_locked=True)
task = db.scalar(query)
if task is None:
return None
task.status = QRCODE_TASK_STATUS_RUNNING
if task.started_at is None:
task.started_at = now()
task.finished_at = None
task.failed_count = 0
task.error_message = None
db.commit()
return int(task.id)
async def _generate_task_qrcode(db, task: DeviceQrBatchTask, item: dict) -> tuple[str, Path]:
point_name = str(item.get("attendance_point_name") or "").strip()
mold_name = str(item.get("product_name") or "").strip()
process_name = str(item.get("process_name") or "").strip()
product = _product_for_mold(db, point_name, mold_name, process_name)
if product is None:
raise RuntimeError(f"产品清单中已不存在:{point_name} / {mold_name} / {process_name}")
display_name = point_mold_display_name(point_name, mold_name, process_name, product.stamping_method)
scene = f"mold={_scene_token(point_name, mold_name, process_name)}"
qr_url = await create_device_qrcode(
display_name,
page="pages/clock/clock",
scene=scene,
public_base_url=_public_base_url(),
label=f"模具 {display_name}",
)
record = db.get(
DeviceQRCode,
{"attendance_point_name": point_name, "device_no": mold_name, "process_name": process_name},
)
if record is None:
record = DeviceQRCode(
attendance_point_name=point_name,
device_no=mold_name,
process_name=process_name,
created_by=task.created_by,
)
db.add(record)
record.qr_scene = scene
record.qr_url = qr_url
db.commit()
qr_path = _qrcode_path_from_url(qr_url)
if qr_path is None:
raise RuntimeError(f"{display_name} 二维码图片未生成,无法打包")
return display_name, qr_path
async def process_qrcode_batch_task(task_id: int) -> None:
zip_dir = settings.upload_path / "qrcode_zips"
zip_dir.mkdir(parents=True, exist_ok=True)
used_names: set[str] = set()
with SessionLocal() as db:
task = db.get(DeviceQrBatchTask, task_id)
if task is None:
return
items = list(task.items_json or [])
zip_path = zip_dir / task.file_name
completed_count = max(0, min(int(task.completed_count or 0), len(items)))
if completed_count and not zip_path.exists():
completed_count = 0
task.completed_count = 0
db.commit()
zip_mode = "a" if completed_count > 0 else "w"
try:
with zipfile.ZipFile(zip_path, zip_mode, zipfile.ZIP_DEFLATED) as archive:
for index, item in enumerate(items, start=1):
if index <= completed_count:
continue
db.refresh(task)
if task.status == QRCODE_TASK_STATUS_PAUSED:
task.finished_at = None
db.commit()
return
if task.status != QRCODE_TASK_STATUS_RUNNING:
return
display_name, qr_path = await _generate_task_qrcode(db, task, item)
stem = _safe_file_stem(f"{index:03d}_{display_name}")
archive.write(qr_path, _unique_zip_name(set(archive.namelist()), stem, qr_path.suffix))
task.completed_count = index
task.failed_count = 0
task.updated_at = now()
db.commit()
task.status = QRCODE_TASK_STATUS_COMPLETED
task.completed_count = len(items)
task.failed_count = 0
task.zip_url = f"{_public_base_url()}/uploads/qrcode_zips/{quote(task.file_name, safe='')}"
task.error_message = None
task.finished_at = now()
db.commit()
except Exception as exc:
db.rollback()
task = db.get(DeviceQrBatchTask, task_id)
if task is None:
zip_path.unlink(missing_ok=True)
else:
completed_count = int(task.completed_count or 0)
task.status = QRCODE_TASK_STATUS_FAILED
task.failed_count = max(1, int(task.item_count or 0) - completed_count)
task.error_message = str(exc)[:1000]
task.finished_at = now()
db.commit()
raise
async def qrcode_batch_task_loop(interval_seconds: int = QRCODE_BATCH_TASK_INTERVAL_SECONDS) -> None:
await asyncio.to_thread(recover_running_qrcode_batch_tasks)
while True:
try:
task_id = await asyncio.to_thread(_claim_next_qrcode_batch_task)
if task_id is None:
await asyncio.sleep(interval_seconds)
continue
await process_qrcode_batch_task(task_id)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("qrcode batch task failed")
await asyncio.sleep(interval_seconds)
+94
View File
@@ -0,0 +1,94 @@
from datetime import timedelta
from fastapi import HTTPException, status
from sqlalchemy import delete, select
from sqlalchemy.orm import Session, selectinload
from app.models import (
MoldLockFeedback,
Personnel,
ProductionReport,
ProductionReportItem,
ReportAuditLog,
WorkSession,
)
from app.timezone import now
VOID_RETENTION_DAYS = 30
APPROVED_EDIT_DAYS = 30
def void_deadline(voided_at=None):
return (voided_at or now()) + timedelta(days=VOID_RETENTION_DAYS)
def can_edit_report(report: ProductionReport) -> bool:
if bool(report.is_voided):
return False
if str(report.status) == "pending":
return True
if str(report.status) != "approved" or report.reviewed_at is None:
return False
return report.reviewed_at >= now() - timedelta(days=APPROVED_EDIT_DAYS)
def can_unvoid_report(report: ProductionReport) -> bool:
if not bool(report.is_voided) or report.voided_at is None:
return False
return report.voided_at > now() - timedelta(days=VOID_RETENTION_DAYS)
def mark_report_voided(report: ProductionReport, user: Personnel) -> None:
if bool(report.is_voided):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该报工记录已作废")
timestamp = now()
report.is_voided = True
report.voided_at = timestamp
report.voided_by = user.phone
report.unvoid_deadline_at = void_deadline(timestamp)
def restore_voided_report(report: ProductionReport) -> None:
if not bool(report.is_voided):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该报工记录未作废")
if not can_unvoid_report(report):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="作废已超过30天,不能撤销作废")
report.is_voided = False
report.voided_at = None
report.voided_by = None
report.unvoid_deadline_at = None
def purge_expired_voided_reports(db: Session) -> int:
cutoff = now() - timedelta(days=VOID_RETENTION_DAYS)
reports = db.scalars(
select(ProductionReport)
.options(selectinload(ProductionReport.session).selectinload(WorkSession.devices))
.where(
ProductionReport.is_voided.is_(True),
ProductionReport.voided_at.is_not(None),
ProductionReport.voided_at <= cutoff,
)
.limit(100)
).all()
if not reports:
return 0
for report in reports:
session = report.session
if session is not None:
session_device_ids = [device.id for device in session.devices if device.id is not None]
if session_device_ids:
db.execute(
delete(MoldLockFeedback).where(
MoldLockFeedback.session_device_id.in_(session_device_ids)
)
)
db.execute(delete(ReportAuditLog).where(ReportAuditLog.report_id == report.id))
db.execute(delete(ProductionReportItem).where(ProductionReportItem.report_id == report.id))
db.delete(report)
if session is not None:
db.delete(session)
db.commit()
return len(reports)
+14
View File
@@ -0,0 +1,14 @@
from datetime import datetime, timedelta
from app.models import WorkSession
from app.timezone import now
REPORTING_EXPIRE_MINUTES = 15
REPORTING_EXPIRED_DETAIL = "报工填写已超过15分钟,请重新扫码点击重新报工"
def is_reporting_expired(session: WorkSession, current_time: datetime | None = None) -> bool:
if session.end_at is None:
return False
timestamp = current_time or now()
return timestamp - session.end_at > timedelta(minutes=REPORTING_EXPIRE_MINUTES)
+490
View File
@@ -0,0 +1,490 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import (
AttendancePoint,
AuditAction,
Equipment,
PersonRole,
Personnel,
Product,
ProductionReport,
ProductionReportItem,
WorkSession,
WorkSessionDevice,
)
from app.schemas import (
AttendancePointOut,
ClockSessionOut,
EquipmentOption,
EquipmentOut,
PersonnelOut,
ProductOption,
ProductOut,
ReportDeviceSegmentOut,
ReportItemOut,
ReportMetrics,
ReportOut,
)
from app.services.cleaning import is_cleaning_item, is_cleaning_product
from app.services.common import REPORT_STATUS_NAMES, ROLE_NAMES, as_float
from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product
from app.services.display_names import mold_process_display_name
from app.services.metrics import build_result_text, calculate_report_metrics
from app.services.misc_work import is_misc_item, is_misc_product
from app.services.multi_person import is_multi_person_item, is_multi_person_product
from app.services.process_names import export_process_name
from app.services.report_lifecycle import can_edit_report, can_unvoid_report
from app.services.work_schedule import WorkScheduleConfig
from app.timezone import now
def temporary_expired(person: Personnel) -> bool:
return bool(
person.is_temporary
and (person.temporary_expires_at is None or person.temporary_expires_at <= now())
)
def attendance_point_out(point: AttendancePoint) -> AttendancePointOut:
return AttendancePointOut(
name=point.name,
latitude=None if point.latitude is None else float(point.latitude),
longitude=None if point.longitude is None else float(point.longitude),
radius_meters=int(point.radius_meters or 500),
day_start=point.day_start,
day_end=point.day_end,
lunch_start=point.lunch_start,
lunch_end=point.lunch_end,
dinner_start=point.dinner_start,
dinner_end=point.dinner_end,
overtime_start=point.overtime_start,
overtime_end=point.overtime_end,
night_start=point.night_start,
night_end=point.night_end,
remark=point.remark,
is_active=bool(point.is_active),
created_at=point.created_at,
updated_at=point.updated_at,
)
def personnel_out(person: Personnel, role=None) -> PersonnelOut:
selected_role = role if role is not None else getattr(person, "role", None)
roles = sorted([item.role for item in person.roles], key=lambda item: str(item))
points = sorted(
[item.attendance_point for item in getattr(person, "attendance_points", []) if item.attendance_point is not None],
key=lambda item: item.name,
)
return PersonnelOut(
phone=person.phone,
name=person.name,
role=selected_role,
role_name=ROLE_NAMES.get(str(selected_role), str(selected_role)),
roles=roles,
role_names=[ROLE_NAMES.get(str(item), str(item)) for item in roles],
attendance_point_names=[point.name for point in points],
attendance_points=[attendance_point_out(point) for point in points],
is_temporary=bool(person.is_temporary),
temporary_expires_at=person.temporary_expires_at,
temporary_expired=temporary_expired(person),
)
def person_role_out(person_role: PersonRole) -> PersonnelOut:
return personnel_out(person_role.person, person_role.role)
def personnel_summary_out(person: Personnel) -> dict:
roles = sorted([str(item.role) for item in person.roles])
points = sorted(
[item.attendance_point for item in getattr(person, "attendance_points", []) if item.attendance_point is not None],
key=lambda item: item.name,
)
return {
"phone": person.phone,
"name": person.name,
"roles": roles,
"role_names": [ROLE_NAMES.get(role, role) for role in roles],
"role": roles[0] if roles else "",
"role_name": "、".join(ROLE_NAMES.get(role, role) for role in roles),
"attendance_point_names": [point.name for point in points],
"attendance_points": [attendance_point_out(point) for point in points],
"is_temporary": bool(person.is_temporary),
"temporary_expires_at": person.temporary_expires_at,
"temporary_expired": temporary_expired(person),
}
def product_out(product: Product) -> ProductOut:
optional_float = lambda value: None if value is None else as_float(value)
return ProductOut(
attendance_point_name=product.attendance_point_name,
project_no=product.project_no,
product_name=product.product_name,
profile_no=product.profile_no,
material_code=product.material_code,
material_name=product.material_name,
supplier=product.supplier,
product_net_weight_kg=optional_float(product.product_net_weight_kg),
product_gross_weight_kg=optional_float(product.product_gross_weight_kg),
scrap_loss_rate=optional_float(product.scrap_loss_rate),
waste_price_yuan_per_kg=optional_float(product.waste_price_yuan_per_kg),
device_no=product.device_no,
process_name=product.process_name,
stamping_method=product.stamping_method,
operator_count=as_float(product.operator_count),
process_unit_price_yuan=as_float(product.process_unit_price_yuan),
standard_beat=as_float(product.standard_beat),
standard_workload=as_float(product.standard_workload),
display_name=f"{product.project_no} / {mold_process_display_name(product.product_name, product.process_name, product.stamping_method)}",
)
def product_option(product: Product) -> ProductOption:
data = product_out(product).model_dump()
data["is_cleaning"] = is_cleaning_product(product)
data["is_misc"] = is_misc_product(product)
data["is_continuous_die"] = is_continuous_die_product(product)
data["is_multi_person"] = is_multi_person_product(product)
return ProductOption(**data)
def equipment_out(equipment: Equipment) -> EquipmentOut:
return EquipmentOut(
attendance_point_name=equipment.attendance_point_name,
device_no=equipment.device_no,
device_type=equipment.device_type or "冲压设备",
remark=equipment.remark,
created_at=equipment.created_at,
updated_at=equipment.updated_at,
)
def equipment_option(equipment: Equipment) -> EquipmentOption:
display_name = equipment.device_no
if equipment.remark:
display_name = f"{equipment.device_no} / {equipment.remark}"
return EquipmentOption(
attendance_point_name=equipment.attendance_point_name,
device_no=equipment.device_no,
device_type=equipment.device_type or "冲压设备",
remark=equipment.remark,
display_name=display_name,
)
def _session_device_stamping_method(
db: Session | None,
session: WorkSession,
device: WorkSessionDevice,
) -> str | None:
if db is None:
return None
point_name = device.attendance_point_name or session.attendance_point_name
product = db.scalar(
select(Product)
.where(
Product.attendance_point_name == point_name,
Product.product_name == device.device_no,
Product.process_name == (device.process_name or ""),
Product.device_no == "",
)
.order_by(Product.project_no.asc())
)
return product.stamping_method if product else None
def session_out(session: WorkSession, db: Session | None = None) -> ClockSessionOut:
return ClockSessionOut(
id=session.id,
attendance_point_name=session.attendance_point_name,
employee_phone=session.employee_phone,
start_at=session.start_at,
end_at=session.end_at,
status=str(session.status),
devices=[
mold_process_display_name(
device.device_no,
device.process_name,
_session_device_stamping_method(db, session, device),
)
for device in session.devices
],
)
def _changed(old_value, new_value) -> bool:
return str(old_value or "") != str(new_value or "")
def _parse_datetime(value) -> datetime | None:
if isinstance(value, datetime):
return value
if not value:
return None
try:
return datetime.fromisoformat(str(value))
except ValueError:
return None
def _datetime_changed(old_value, new_value) -> bool:
old_dt = _parse_datetime(old_value)
new_dt = _parse_datetime(new_value)
if old_dt is None or new_dt is None:
return _changed(old_value, new_value)
if old_dt.replace(second=0, microsecond=0) == new_dt.replace(second=0, microsecond=0):
return False
return old_dt != new_dt
def _numeric_changed(old_value, new_value) -> bool:
return round(as_float(old_value), 4) != round(as_float(new_value), 4)
def _process_name_changed(old_value, new_value) -> bool:
return export_process_name(old_value) != export_process_name(new_value)
def _ordered_session_devices(session: WorkSession | None) -> list[WorkSessionDevice]:
return sorted(
getattr(session, "devices", []) or [],
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
)
def report_correction_maps(report: ProductionReport) -> tuple[dict, dict[int, dict], dict[int, dict]]:
logs = [
log for log in getattr(report, "audit_logs", [])
if log.action == AuditAction.approve_with_correction and log.before_json
]
if not logs:
return {}, {}, {}
log = sorted(logs, key=lambda item: item.created_at)[-1]
before = log.before_json or {}
report_corrections = {}
if _datetime_changed(before.get("start_at"), report.start_at.isoformat()):
report_corrections["start_at"] = before.get("start_at")
if _datetime_changed(before.get("end_at"), report.end_at.isoformat()):
report_corrections["end_at"] = before.get("end_at")
if _changed(before.get("review_remark"), report.review_remark):
report_corrections["review_remark"] = before.get("review_remark")
before_items = {
item.get("id"): item
for item in before.get("items", [])
if item.get("id") is not None
}
item_corrections: dict[int, dict] = {}
text_fields = [
"device_no",
"project_no",
"product_name",
"raw_material_batch_no",
"process_name",
"stamping_method",
"remark",
]
numeric_fields = ["process_unit_price_yuan", "changeover_count", "good_qty", "defect_qty", "scrap_qty"]
for item in report.items:
old = before_items.get(item.id)
if not old:
continue
changes = {}
for field in text_fields:
if field == "process_name":
if _process_name_changed(old.get(field), getattr(item, field)):
changes[field] = export_process_name(old.get(field))
elif _changed(old.get(field), getattr(item, field)):
changes[field] = old.get(field)
for field in numeric_fields:
if _numeric_changed(old.get(field), getattr(item, field)):
changes[field] = as_float(old.get(field))
if changes:
item_corrections[item.id] = changes
before_devices = {
device.get("id"): device
for device in before.get("devices", [])
if device.get("id") is not None
}
device_corrections: dict[int, dict] = {}
for device in _ordered_session_devices(report.session if report.session else None):
old = before_devices.get(device.id)
if old and _datetime_changed(old.get("scanned_at"), device.scanned_at.isoformat()):
device_corrections[device.id] = {"scanned_at": old.get("scanned_at")}
return report_corrections, item_corrections, device_corrections
def report_device_segment_out(
device: WorkSessionDevice,
corrections: dict | None = None,
stamping_method: str | None = None,
) -> ReportDeviceSegmentOut:
process_name = device.process_name or ""
display_name = mold_process_display_name(device.device_no, process_name, stamping_method)
return ReportDeviceSegmentOut(
id=device.id,
attendance_point_name=device.attendance_point_name,
device_no=device.device_no,
mold_name=device.device_no,
process_name=process_name,
stamping_method=stamping_method,
display_name=display_name,
scanned_at=device.scanned_at,
released_at=device.released_at,
sort_order=int(device.sort_order or 0),
corrections=corrections or {},
)
def report_item_out(item: ProductionReportItem, corrections: dict | None = None) -> ReportItemOut:
return ReportItemOut(
id=item.id,
attendance_point_name=item.attendance_point_name,
device_no=item.device_no,
project_no=item.project_no,
product_name=item.product_name,
material_code=item.material_code,
material_name=item.material_name,
raw_material_batch_no=item.raw_material_batch_no,
process_name=item.process_name,
stamping_method=item.stamping_method,
operator_count=as_float(item.operator_count),
process_unit_price_yuan=as_float(item.process_unit_price_yuan),
changeover_count=as_float(item.changeover_count),
standard_beat=as_float(item.standard_beat),
standard_workload=as_float(item.standard_workload),
good_qty=as_float(item.good_qty),
defect_qty=as_float(item.defect_qty),
scrap_qty=as_float(item.scrap_qty),
allocated_minutes=as_float(item.allocated_minutes),
started_at=item.started_at,
is_misc=is_misc_item(item),
is_continuous_die=is_continuous_die_item(item),
is_multi_person=is_multi_person_item(item),
remark=item.remark,
corrections=corrections or {},
)
def _report_is_cleaning(report: ProductionReport) -> bool:
return bool(report.items) and all(is_cleaning_item(item) for item in report.items)
def _report_is_misc_only(report: ProductionReport) -> bool:
return bool(report.items) and all(is_misc_item(item) for item in report.items)
def _cleaning_report_metrics(report: ProductionReport) -> dict:
total_good_qty = sum(as_float(item.good_qty) for item in report.items)
total_output_qty = sum(
as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty)
for item in report.items
)
return {
"duration_minutes": 0,
"effective_minutes": 0,
"shift_distribution_text": "清洗报工不计工时",
"shift_day_minutes": 0,
"shift_overtime_minutes": 0,
"shift_night_minutes": 0,
"shift_other_minutes": 0,
"shift_meal_break_minutes": 0,
"total_good_qty": total_good_qty,
"total_output_qty": total_output_qty,
"actual_beat": 0,
"standard_beat": 0,
"expected_workload": 0,
"pace_rate": 0,
"workload_rate": 0,
}
def report_out(report: ProductionReport, schedule: WorkScheduleConfig | None = None) -> ReportOut:
is_cleaning_report = _report_is_cleaning(report)
calculated_metrics = (
_cleaning_report_metrics(report)
if is_cleaning_report
else calculate_report_metrics(
report.start_at,
report.end_at,
list(report.items),
devices=report.session.devices if report.session else None,
schedule=schedule,
)
)
metrics = {
"effective_minutes": as_float(calculated_metrics["effective_minutes"]),
"shift_distribution_text": calculated_metrics["shift_distribution_text"],
"shift_day_minutes": as_float(calculated_metrics["shift_day_minutes"]),
"shift_overtime_minutes": as_float(calculated_metrics["shift_overtime_minutes"]),
"shift_night_minutes": as_float(calculated_metrics["shift_night_minutes"]),
"shift_other_minutes": as_float(calculated_metrics["shift_other_minutes"]),
"shift_meal_break_minutes": as_float(calculated_metrics["shift_meal_break_minutes"]),
"total_good_qty": as_float(calculated_metrics["total_good_qty"]),
"total_output_qty": as_float(calculated_metrics["total_output_qty"]),
"actual_beat": as_float(calculated_metrics["actual_beat"]),
"standard_beat": as_float(calculated_metrics["standard_beat"]),
"expected_workload": as_float(calculated_metrics["expected_workload"]),
"pace_rate": as_float(calculated_metrics["pace_rate"]),
"workload_rate": as_float(calculated_metrics["workload_rate"]),
}
report_corrections, item_corrections, device_corrections = report_correction_maps(report)
is_modified = bool(report_corrections or item_corrections or device_corrections)
stamping_by_mold = {
(item.product_name, item.process_name or ""): item.stamping_method
for item in report.items
}
return ReportOut(
id=report.id,
attendance_point_name=report.attendance_point_name,
session_id=report.session_id,
employee_phone=report.employee_phone,
employee_name=report.employee.name if report.employee else "",
report_date=report.report_date,
start_at=report.start_at,
end_at=report.end_at,
duration_minutes=as_float(calculated_metrics["duration_minutes"]),
break_minutes=as_float(report.break_minutes),
status=report.status,
status_name=REPORT_STATUS_NAMES.get(str(report.status), str(report.status)),
reviewer_phone=report.reviewer_phone,
reviewer_name=report.reviewer.name if report.reviewer else None,
reviewed_at=report.reviewed_at,
reject_reason=report.reject_reason,
review_remark=report.review_remark,
submitted_at=report.submitted_at,
is_system_auto_submitted=bool(report.is_system_auto_submitted),
auto_submit_reason=report.auto_submit_reason,
is_multi_person_assistant=bool(report.is_multi_person_assistant),
multi_person_source_report_id=report.multi_person_source_report_id,
is_voided=bool(report.is_voided),
voided_at=report.voided_at,
voided_by=report.voided_by,
voided_by_name=report.voider.name if report.voider else None,
unvoid_deadline_at=report.unvoid_deadline_at,
can_void=not bool(report.is_voided),
can_unvoid=can_unvoid_report(report),
can_edit=can_edit_report(report),
is_modified=is_modified,
device_segments=[
report_device_segment_out(
device,
device_corrections.get(device.id, {}),
stamping_by_mold.get((device.device_no, device.process_name or "")),
)
for device in _ordered_session_devices(report.session if report.session else None)
],
items=[report_item_out(item, item_corrections.get(item.id, {})) for item in report.items],
metrics=ReportMetrics(**metrics),
result_text=(
"清洗报工已提交,等待管理员审核"
if is_cleaning_report
else ("处理杂活已提交,等待管理员审核" if _report_is_misc_only(report) else build_result_text(metrics))
),
corrections=report_corrections,
)
+256
View File
@@ -0,0 +1,256 @@
import json
import re
from dataclasses import dataclass, field
from typing import Any
from app.services.cleaning import is_cleaning_item
from app.services.common import as_float, normalize_device_no, round2
from app.services.continuous_die import is_continuous_die_item
from app.services.display_names import point_mold_display_name
from app.services.misc_work import is_misc_item
from app.services.multi_person import is_multi_person_item
DEVICE_TYPE_STAMPING = "冲压设备"
DEVICE_TYPE_CLEANING = "清洗设备"
MOLD_TYPE_NORMAL = "普通模具"
MOLD_TYPE_CLEANING = "清洗模具"
MOLD_TYPE_CONTINUOUS_DIE = "连续模"
MOLD_TYPE_MULTI_PERSON = "多人工序"
METRIC_KIND_MINUTES = "minutes"
METRIC_KIND_QUANTITY = "quantity"
TAG_CLEANING = "清洗"
TAG_CONTINUOUS_DIE = "连续模"
TAG_MULTI_PERSON = "多人协作"
@dataclass
class UsageStatRow:
id: str
category: str
name: str
metric_kind: str
object_type: str
value: float = 0
report_count: int = 0
tags: list[str] = field(default_factory=list)
attendance_point_name: str = ""
product_name: str = ""
process_name: str = ""
stamping_method: str = ""
class UsageStatAccumulator:
def __init__(self, category: str):
self.category = category
self._rows: dict[tuple[Any, ...], UsageStatRow] = {}
def add(
self,
key: tuple[Any, ...],
*,
name: str,
metric_kind: str,
object_type: str,
value: float,
tags: list[str] | None = None,
attendance_point_name: str = "",
product_name: str = "",
process_name: str = "",
stamping_method: str = "",
) -> None:
row = self._rows.get(key)
if row is None:
row = UsageStatRow(
id=_build_row_id(
self.category,
attendance_point_name,
name,
product_name,
process_name,
stamping_method,
metric_kind,
),
category=self.category,
name=name,
metric_kind=metric_kind,
object_type=object_type,
tags=[],
attendance_point_name=attendance_point_name,
product_name=product_name,
process_name=process_name,
stamping_method=stamping_method,
)
self._rows[key] = row
elif _object_type_priority(object_type) > _object_type_priority(row.object_type):
row.object_type = object_type
row.value += as_float(value)
row.report_count += 1
for tag in tags or []:
if tag and tag not in row.tags:
row.tags.append(tag)
def rows(self) -> list[UsageStatRow]:
rows = list(self._rows.values())
for row in rows:
row.value = round2(row.value)
return rows
def split_cleaning_device_nos(value: str | None) -> list[str]:
device_nos: list[str] = []
for part in (part.strip() for part in re.split(r"[、,,]", _clean(value))):
if part and part not in device_nos:
device_nos.append(part)
return device_nos
def build_usage_stats(
*,
reports: list,
category: str,
equipment_type_by_key: dict[tuple[str, str], str],
) -> list[UsageStatRow]:
if category == "device":
return _build_device_usage_stats(reports, equipment_type_by_key)
if category == "mold":
return _build_mold_usage_stats(reports)
raise ValueError("category must be device or mold")
def _build_device_usage_stats(
reports: list,
equipment_type_by_key: dict[tuple[str, str], str],
) -> list[UsageStatRow]:
acc = UsageStatAccumulator(category="device")
for item in _iter_report_items(reports):
if is_misc_item(item):
continue
point_name = _clean(getattr(item, "attendance_point_name", None))
if is_cleaning_item(item):
device_nos = split_cleaning_device_nos(getattr(item, "device_no", None))
if not device_nos:
continue
value = as_float(getattr(item, "good_qty", 0)) / len(device_nos)
for device_no in device_nos:
object_type = equipment_type_by_key.get((point_name, device_no), DEVICE_TYPE_CLEANING)
acc.add(
(point_name, device_no, METRIC_KIND_QUANTITY),
name=device_no,
metric_kind=METRIC_KIND_QUANTITY,
object_type=object_type,
value=value,
tags=[TAG_CLEANING],
attendance_point_name=point_name,
)
continue
device_no = normalize_device_no(getattr(item, "device_no", None))
if not device_no:
continue
acc.add(
(point_name, device_no, METRIC_KIND_MINUTES),
name=device_no,
metric_kind=METRIC_KIND_MINUTES,
object_type=equipment_type_by_key.get((point_name, device_no), DEVICE_TYPE_STAMPING),
value=as_float(getattr(item, "allocated_minutes", 0)),
attendance_point_name=point_name,
)
return acc.rows()
def _build_mold_usage_stats(reports: list) -> list[UsageStatRow]:
acc = UsageStatAccumulator(category="mold")
for item in _iter_report_items(reports):
if is_misc_item(item):
continue
point_name = _clean(getattr(item, "attendance_point_name", None))
product_name = _clean(getattr(item, "product_name", None))
process_name = _clean(getattr(item, "process_name", None))
stamping_method = _clean(getattr(item, "stamping_method", None))
metric_kind = METRIC_KIND_QUANTITY if is_cleaning_item(item) else METRIC_KIND_MINUTES
value_field = "good_qty" if metric_kind == METRIC_KIND_QUANTITY else "allocated_minutes"
tags = _item_tags(item)
object_type = _mold_object_type(item)
acc.add(
(point_name, product_name, process_name, stamping_method, metric_kind),
name=point_mold_display_name(point_name, product_name, process_name, stamping_method),
metric_kind=metric_kind,
object_type=object_type,
value=as_float(getattr(item, value_field, 0)),
tags=tags,
attendance_point_name=point_name,
product_name=product_name,
process_name=process_name,
stamping_method=stamping_method,
)
return acc.rows()
def _iter_report_items(reports: list):
for report in reports:
yield from getattr(report, "items", []) or []
def _item_tags(item) -> list[str]:
tags: list[str] = []
if is_cleaning_item(item):
tags.append(TAG_CLEANING)
if is_continuous_die_item(item):
tags.append(TAG_CONTINUOUS_DIE)
if is_multi_person_item(item):
tags.append(TAG_MULTI_PERSON)
return tags
def _mold_object_type(item) -> str:
if is_cleaning_item(item):
return MOLD_TYPE_CLEANING
if is_continuous_die_item(item):
return MOLD_TYPE_CONTINUOUS_DIE
if is_multi_person_item(item):
return MOLD_TYPE_MULTI_PERSON
return MOLD_TYPE_NORMAL
def _object_type_priority(value: str) -> int:
return {
MOLD_TYPE_NORMAL: 0,
DEVICE_TYPE_STAMPING: 0,
DEVICE_TYPE_CLEANING: 0,
MOLD_TYPE_MULTI_PERSON: 1,
MOLD_TYPE_CONTINUOUS_DIE: 2,
MOLD_TYPE_CLEANING: 3,
}.get(value, 0)
def _build_row_id(
category: str,
attendance_point_name: str,
name: str,
product_name: str,
process_name: str,
stamping_method: str,
metric_kind: str,
) -> str:
return json.dumps(
[
_clean(category),
_clean(attendance_point_name),
_clean(name),
_clean(product_name),
_clean(process_name),
_clean(stamping_method),
_clean(metric_kind),
],
ensure_ascii=False,
separators=(",", ":"),
)
def _clean(value: str | None) -> str:
return "" if value is None else str(value).strip()
+83
View File
@@ -0,0 +1,83 @@
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
DEVICE_HEADERS = ["考勤点", "设备号", "设备类型", "使用时长(分钟)", "使用时长(小时)", "使用时长(天)", "清洗数量", "报工次数"]
MOLD_HEADERS = ["考勤点", "产品名称", "工序", "冲压方式", "使用时长(分钟)", "使用时长(小时)", "使用时长(天)", "清洗数量", "报工次数"]
def export_usage_stats_rows(device_rows: list, mold_rows: list) -> bytes:
workbook = Workbook()
device_sheet = workbook.active
device_sheet.title = "设备统计"
mold_sheet = workbook.create_sheet("模具统计")
_write_device_sheet(device_sheet, device_rows)
_write_mold_sheet(mold_sheet, mold_rows)
output = BytesIO()
workbook.save(output)
return output.getvalue()
def _write_device_sheet(sheet, rows: list) -> None:
_append_headers(sheet, DEVICE_HEADERS)
for row in rows:
minutes, hours, days, quantity = _metric_values(row)
sheet.append(
[
row.attendance_point_name,
row.name,
row.object_type,
minutes,
hours,
days,
quantity,
row.report_count,
]
)
_set_widths(sheet, [16, 16, 16, 16, 16, 14, 12, 12])
def _write_mold_sheet(sheet, rows: list) -> None:
_append_headers(sheet, MOLD_HEADERS)
for row in rows:
minutes, hours, days, quantity = _metric_values(row)
sheet.append(
[
row.attendance_point_name,
row.product_name,
row.process_name,
row.stamping_method,
minutes,
hours,
days,
quantity,
row.report_count,
]
)
_set_widths(sheet, [16, 24, 12, 16, 16, 16, 14, 12, 12])
def _metric_values(row) -> tuple[float, float, float, float]:
if row.metric_kind == "quantity":
return 0, 0, 0, row.value
minutes = row.value
return minutes, round(minutes / 60, 2), round(minutes / 1440, 2), 0
def _append_headers(sheet, headers: list[str]) -> None:
sheet.append(headers)
fill = PatternFill(fill_type="solid", fgColor="FFEFEFEF")
font = Font(bold=True)
for cell in sheet[1]:
cell.fill = fill
cell.font = font
def _set_widths(sheet, widths: list[int]) -> None:
for index, width in enumerate(widths, start=1):
column_letter = sheet.cell(row=1, column=index).column_letter
sheet.column_dimensions[column_letter].width = width
+216
View File
@@ -0,0 +1,216 @@
from pathlib import Path
from io import BytesIO
import hashlib
import os
import re
from urllib.parse import quote
import httpx
from PIL import Image, ImageDraw, ImageFont
from app.config import settings
class WechatConfigError(RuntimeError):
pass
def _qrcode_file_suffix(content_type: str) -> str:
normalized = content_type.lower()
if "image/png" in normalized:
return ".png"
if "image/jpeg" in normalized or "image/jpg" in normalized:
return ".jpg"
return ".jpg"
def _qrcode_file_name(device_no: str, suffix: str) -> str:
safe_name = re.sub(r"[^0-9A-Za-z_.-]+", "_", device_no).strip("._")[:80] or "device"
digest = hashlib.sha1(device_no.encode("utf-8")).hexdigest()[:8]
return f"{safe_name}-{digest}{suffix}"
def _font_candidates() -> list[Path]:
configured = os.getenv("JH_QR_FONT_PATH")
paths = []
if configured:
paths.append(Path(configured))
paths.extend([
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"),
Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"),
Path("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"),
Path("/usr/local/share/fonts/NotoSansCJK-Regular.ttc"),
Path("/usr/local/share/fonts/NotoSansSC-Regular.otf"),
Path("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"),
Path("/usr/share/fonts/truetype/arphic/ukai.ttc"),
Path("/System/Library/Fonts/PingFang.ttc"),
Path("/System/Library/Fonts/Hiragino Sans GB.ttc"),
Path("/System/Library/Fonts/STHeiti Medium.ttc"),
Path("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"),
Path("/Library/Fonts/Arial Unicode.ttf"),
Path("C:/Windows/Fonts/msyh.ttc"),
Path("C:/Windows/Fonts/simhei.ttf"),
])
return paths
def _font_can_render_chinese(font) -> bool:
glyphs: set[bytes] = set()
for char in "模具宁嘉恒科":
image = Image.new("L", (140, 140), 0)
draw = ImageDraw.Draw(image)
draw.text((8, 8), char, font=font, fill=255)
bbox = image.getbbox()
if bbox is None:
return False
glyphs.add(image.crop(bbox).tobytes())
return len(glyphs) >= 5
def _load_label_font(size: int):
for path in _font_candidates():
if path.exists():
try:
font = ImageFont.truetype(str(path), size=size)
if _font_can_render_chinese(font):
return font
except OSError:
continue
raise RuntimeError("服务器缺少可用中文字体,无法生成带中文底部文字的二维码")
def _text_width(draw: ImageDraw.ImageDraw, text: str, font) -> int:
if not text:
return 0
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[2] - bbox[0]
def _wrap_label_text(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]:
normalized = " ".join(str(text or "").split())
if not normalized:
return []
lines: list[str] = []
current = ""
for char in normalized:
candidate = f"{current}{char}"
if current and _text_width(draw, candidate, font) > max_width:
lines.append(current)
current = char
continue
current = candidate
if current:
lines.append(current)
return lines
def _draw_qrcode_label(content: bytes, suffix: str, label: str) -> bytes:
label_text = str(label or "").strip()
if not label_text:
return content
image = Image.open(BytesIO(content)).convert("RGB")
original_width, original_height = image.size
width = max(original_width, 860)
if width != original_width:
height = round(original_height * width / original_width)
image = image.resize((width, height), Image.Resampling.LANCZOS)
else:
height = original_height
font_size = max(54, min(78, width // 12))
font = _load_label_font(font_size)
line_gap = max(10, font_size // 4)
horizontal_padding = max(42, width // 14)
vertical_padding = max(28, width // 18)
measure = ImageDraw.Draw(Image.new("RGB", (width, 10), "white"))
lines = _wrap_label_text(measure, label_text, font, width - horizontal_padding * 2)
if len(lines) > 5:
lines = lines[:5]
ellipsis = "..."
while lines[-1] and _text_width(measure, f"{lines[-1]}{ellipsis}", font) > width - horizontal_padding * 2:
lines[-1] = lines[-1][:-1]
lines[-1] = f"{lines[-1]}{ellipsis}"
line_height = font_size + line_gap
label_height = vertical_padding * 2 + line_height * max(1, len(lines)) - line_gap
canvas = Image.new("RGB", (width, height + label_height), "white")
canvas.paste(image, (0, 0))
draw = ImageDraw.Draw(canvas)
y = height + vertical_padding
for line in lines:
draw.text((horizontal_padding, y), line, font=font, fill=(20, 32, 48))
y += line_height
output = BytesIO()
if suffix.lower() in {".jpg", ".jpeg"}:
canvas.save(output, format="JPEG", quality=95)
else:
canvas.save(output, format="PNG")
return output.getvalue()
async def get_access_token() -> str:
if not settings.wechat_app_id or not settings.wechat_app_secret:
raise WechatConfigError("未配置 WECHAT_APP_ID 或 WECHAT_APP_SECRET")
params = {
"grant_type": "client_credential",
"appid": settings.wechat_app_id,
"secret": settings.wechat_app_secret,
}
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get("https://api.weixin.qq.com/cgi-bin/token", params=params)
data = response.json()
token = data.get("access_token")
if not token:
raise RuntimeError(data.get("errmsg") or "获取微信 access_token 失败")
return token
async def get_phone_number(phone_code: str) -> str:
token = await get_access_token()
url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={token}"
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(url, json={"code": phone_code})
data = response.json()
phone_info = data.get("phone_info") or {}
phone = phone_info.get("phoneNumber") or phone_info.get("purePhoneNumber")
if not phone:
raise RuntimeError(data.get("errmsg") or "获取微信手机号失败")
return phone
async def create_device_qrcode(
device_no: str,
page: str,
scene: str,
public_base_url: str,
label: str | None = None,
) -> str | None:
token = await get_access_token()
url = f"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={token}"
payload = {
"scene": scene,
"page": page,
"check_path": False,
"env_version": settings.wechat_qr_env_version,
}
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(url, json=payload)
content_type = response.headers.get("content-type", "")
if "application/json" in content_type:
data = response.json()
raise RuntimeError(data.get("errmsg") or "生成设备小程序码失败")
qr_dir = settings.upload_path / "qrcodes"
qr_dir.mkdir(parents=True, exist_ok=True)
suffix = _qrcode_file_suffix(content_type)
file_name = _qrcode_file_name(device_no, suffix)
file_path = Path(qr_dir) / file_name
file_path.write_bytes(_draw_qrcode_label(response.content, suffix, label or device_no))
return f"{public_base_url.rstrip('/')}/uploads/qrcodes/{quote(file_name, safe='')}"
+158
View File
@@ -0,0 +1,158 @@
from dataclasses import dataclass
import re
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.models import AttendancePoint, WorkSchedule
TIME_PATTERN = re.compile(r"^\d{1,2}:\d{2}$")
@dataclass(frozen=True)
class WorkScheduleConfig:
day_start: str
day_end: str
lunch_start: str
lunch_end: str
dinner_start: str
dinner_end: str
overtime_start: str
overtime_end: str
night_start: str
night_end: str
DEFAULT_WORK_SCHEDULE = {
"day_start": "08:00",
"day_end": "17:20",
"lunch_start": "11:40",
"lunch_end": "12:40",
"dinner_start": "17:20",
"dinner_end": "18:00",
"overtime_start": "18:00",
"overtime_end": "20:00",
"night_start": "20:00",
"night_end": "06:00",
}
DEFAULT_WORK_SCHEDULE_CONFIG = WorkScheduleConfig(**DEFAULT_WORK_SCHEDULE)
DEFAULT_ATTENDANCE_RADIUS_METERS = 500
DEFAULT_AUTO_SUBMIT_HOURS = 15
def normalize_time_text(value: str) -> str:
text = str(value or "").strip()
if not TIME_PATTERN.match(text):
raise ValueError("时间格式必须为 HH:MM")
hour_text, minute_text = text.split(":", 1)
hour = int(hour_text)
minute = int(minute_text)
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
raise ValueError("时间必须在 00:00-23:59 范围内")
return f"{hour:02d}:{minute:02d}"
def schedule_to_config(schedule: WorkSchedule | None) -> WorkScheduleConfig:
if schedule is None:
return DEFAULT_WORK_SCHEDULE_CONFIG
return WorkScheduleConfig(
day_start=schedule.day_start,
day_end=schedule.day_end,
lunch_start=schedule.lunch_start,
lunch_end=schedule.lunch_end,
dinner_start=schedule.dinner_start,
dinner_end=schedule.dinner_end,
overtime_start=schedule.overtime_start,
overtime_end=schedule.overtime_end,
night_start=schedule.night_start,
night_end=schedule.night_end,
)
def attendance_point_to_config(point: AttendancePoint | None) -> WorkScheduleConfig | None:
if point is None:
return None
return WorkScheduleConfig(
day_start=point.day_start or DEFAULT_WORK_SCHEDULE["day_start"],
day_end=point.day_end or DEFAULT_WORK_SCHEDULE["day_end"],
lunch_start=point.lunch_start or DEFAULT_WORK_SCHEDULE["lunch_start"],
lunch_end=point.lunch_end or DEFAULT_WORK_SCHEDULE["lunch_end"],
dinner_start=point.dinner_start or DEFAULT_WORK_SCHEDULE["dinner_start"],
dinner_end=point.dinner_end or DEFAULT_WORK_SCHEDULE["dinner_end"],
overtime_start=point.overtime_start or DEFAULT_WORK_SCHEDULE["overtime_start"],
overtime_end=point.overtime_end or DEFAULT_WORK_SCHEDULE["overtime_end"],
night_start=point.night_start or DEFAULT_WORK_SCHEDULE["night_start"],
night_end=point.night_end or DEFAULT_WORK_SCHEDULE["night_end"],
)
def get_work_schedule(db: Session) -> WorkSchedule:
schedule = db.get(WorkSchedule, 1)
if schedule is not None:
return schedule
schedule = WorkSchedule(id=1, **DEFAULT_WORK_SCHEDULE)
db.add(schedule)
db.commit()
db.refresh(schedule)
return schedule
def get_work_schedule_config(db: Session, attendance_point_name: str | None = None) -> WorkScheduleConfig:
try:
point_name = str(attendance_point_name or "").strip()
if point_name:
point_config = attendance_point_to_config(db.get(AttendancePoint, point_name))
if point_config is not None:
return point_config
return schedule_to_config(get_work_schedule(db))
except SQLAlchemyError:
db.rollback()
return DEFAULT_WORK_SCHEDULE_CONFIG
def normalize_schedule_payload(payload) -> dict[str, str]:
return {
field: normalize_time_text(getattr(payload, field, DEFAULT_WORK_SCHEDULE[field]))
for field in DEFAULT_WORK_SCHEDULE
}
def update_work_schedule(db: Session, payload, updated_by: str, *, commit: bool = True) -> WorkSchedule:
values = normalize_schedule_payload(payload)
attendance_latitude = getattr(payload, "attendance_latitude", None)
attendance_longitude = getattr(payload, "attendance_longitude", None)
attendance_radius_meters = getattr(payload, "attendance_radius_meters", None)
if (attendance_latitude is None) != (attendance_longitude is None):
raise ValueError("考勤经纬度必须同时配置")
if attendance_latitude is not None and not (-90 <= attendance_latitude <= 90):
raise ValueError("考勤纬度必须在 -90 到 90 之间")
if attendance_longitude is not None and not (-180 <= attendance_longitude <= 180):
raise ValueError("考勤经度必须在 -180 到 180 之间")
if attendance_radius_meters is None:
attendance_radius_meters = DEFAULT_ATTENDANCE_RADIUS_METERS
if int(attendance_radius_meters) <= 0:
raise ValueError("考勤范围半径必须大于0米")
auto_submit_hours = getattr(payload, "auto_submit_hours", DEFAULT_AUTO_SUBMIT_HOURS)
try:
auto_submit_hours = float(auto_submit_hours)
except (TypeError, ValueError) as exc:
raise ValueError("系统自动提交时长必须是数字") from exc
if auto_submit_hours < 1 or auto_submit_hours > 168:
raise ValueError("系统自动提交时长必须在1到168小时之间")
schedule = get_work_schedule(db)
for field, value in values.items():
setattr(schedule, field, value)
schedule.attendance_latitude = attendance_latitude
schedule.attendance_longitude = attendance_longitude
schedule.attendance_radius_meters = int(attendance_radius_meters)
schedule.auto_submit_hours = auto_submit_hours
schedule.updated_by = updated_by
db.add(schedule)
if commit:
db.commit()
db.refresh(schedule)
else:
db.flush()
return schedule
+17
View File
@@ -0,0 +1,17 @@
from datetime import date, datetime
from zoneinfo import ZoneInfo
from app.config import settings
def tz() -> ZoneInfo:
return ZoneInfo(settings.app_timezone)
def now() -> datetime:
return datetime.now(tz()).replace(tzinfo=None)
def today() -> date:
return now().date()
+21
View File
@@ -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"
+10
View File
@@ -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()
+31
View File
@@ -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()
+76
View File
@@ -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()
+18
View File
@@ -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()
+207
View File
@@ -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()
+41
View File
@@ -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()
+32
View File
@@ -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()
+35
View File
@@ -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()
+51
View File
@@ -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()
+124
View File
@@ -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()
+57
View File
@@ -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()
+20
View File
@@ -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()
+57
View File
@@ -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()
+165
View File
@@ -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()
+45
View File
@@ -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()
+52
View File
@@ -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()
+41
View File
@@ -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()
+29
View File
@@ -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()
+32
View File
@@ -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()
+45
View File
@@ -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()
+33
View File
@@ -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()
+35
View File
@@ -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()
+36
View File
@@ -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()
+31
View File
@@ -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()
+46
View File
@@ -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()
+75
View File
@@ -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()
+59
View File
@@ -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()
+79
View File
@@ -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()
+45
View File
@@ -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
+282
View File
@@ -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
+126
View File
@@ -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()
+96
View File
@@ -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
+648
View File
@@ -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)
+181
View File
@@ -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()