feat: 작업보고서 시스템 완성

- 일일 공수 입력 기능
- 부적합 사항 등록 (이미지 선택사항)
- 날짜별 부적합 조회 (시간순 나열)
- 목록 관리 (인라인 편집, 작업시간 확인 버튼)
- 보고서 생성 (총 공수/부적합 시간 분리)
- JWT 인증 및 권한 관리
- Docker 기반 배포 환경 구성
This commit is contained in:
hyungi
2025-09-17 10:41:25 +09:00
commit 1339e5dded
36 changed files with 5233 additions and 0 deletions

24
backend/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM python:3.11-slim
WORKDIR /app
# 시스템 패키지 설치
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Python 의존성 설치
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 파일 복사
COPY . .
# uploads 디렉토리 생성
RUN mkdir -p /app/uploads
# 포트 노출
EXPOSE 8000
# 실행 명령
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.ext.declarative import declarative_base
import os
from typing import Generator
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://mproject:mproject2024@localhost:5432/mproject")
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,69 @@
from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean, Text, ForeignKey, Enum
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
Base = declarative_base()
class UserRole(str, enum.Enum):
ADMIN = "admin"
USER = "user"
class IssueStatus(str, enum.Enum):
NEW = "new"
PROGRESS = "progress"
COMPLETE = "complete"
class IssueCategory(str, enum.Enum):
MATERIAL_MISSING = "material_missing"
DIMENSION_DEFECT = "dimension_defect"
INCOMING_DEFECT = "incoming_defect"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
full_name = Column(String)
role = Column(Enum(UserRole), default=UserRole.USER)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
issues = relationship("Issue", back_populates="reporter")
daily_works = relationship("DailyWork", back_populates="created_by")
class Issue(Base):
__tablename__ = "issues"
id = Column(Integer, primary_key=True, index=True)
photo_path = Column(String)
category = Column(Enum(IssueCategory), nullable=False)
description = Column(Text, nullable=False)
status = Column(Enum(IssueStatus), default=IssueStatus.NEW)
reporter_id = Column(Integer, ForeignKey("users.id"))
report_date = Column(DateTime, default=datetime.utcnow)
work_hours = Column(Float, default=0)
detail_notes = Column(Text)
# Relationships
reporter = relationship("User", back_populates="issues")
class DailyWork(Base):
__tablename__ = "daily_works"
id = Column(Integer, primary_key=True, index=True)
date = Column(DateTime, nullable=False, index=True)
worker_count = Column(Integer, nullable=False)
regular_hours = Column(Float, nullable=False)
overtime_workers = Column(Integer, default=0)
overtime_hours = Column(Float, default=0)
overtime_total = Column(Float, default=0)
total_hours = Column(Float, nullable=False)
created_by_id = Column(Integer, ForeignKey("users.id"))
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
created_by = relationship("User", back_populates="daily_works")

129
backend/database/schemas.py Normal file
View File

@@ -0,0 +1,129 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, List
from enum import Enum
class UserRole(str, Enum):
admin = "admin"
user = "user"
class IssueStatus(str, Enum):
new = "new"
progress = "progress"
complete = "complete"
class IssueCategory(str, Enum):
material_missing = "material_missing"
dimension_defect = "dimension_defect"
incoming_defect = "incoming_defect"
# User schemas
class UserBase(BaseModel):
username: str
full_name: Optional[str] = None
role: UserRole = UserRole.user
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
full_name: Optional[str] = None
password: Optional[str] = None
role: Optional[UserRole] = None
is_active: Optional[bool] = None
class User(UserBase):
id: int
is_active: bool
created_at: datetime
class Config:
from_attributes = True
# Auth schemas
class Token(BaseModel):
access_token: str
token_type: str
user: User
class TokenData(BaseModel):
username: Optional[str] = None
class LoginRequest(BaseModel):
username: str
password: str
# Issue schemas
class IssueBase(BaseModel):
category: IssueCategory
description: str
class IssueCreate(IssueBase):
photo: Optional[str] = None # Base64 encoded image
class IssueUpdate(BaseModel):
category: Optional[IssueCategory] = None
description: Optional[str] = None
work_hours: Optional[float] = None
detail_notes: Optional[str] = None
status: Optional[IssueStatus] = None
photo: Optional[str] = None # Base64 encoded image for update
class Issue(IssueBase):
id: int
photo_path: Optional[str] = None
status: IssueStatus
reporter_id: int
reporter: User
report_date: datetime
work_hours: float
detail_notes: Optional[str] = None
class Config:
from_attributes = True
# Daily Work schemas
class DailyWorkBase(BaseModel):
date: datetime
worker_count: int = Field(gt=0)
overtime_workers: Optional[int] = 0
overtime_hours: Optional[float] = 0
class DailyWorkCreate(DailyWorkBase):
pass
class DailyWorkUpdate(BaseModel):
worker_count: Optional[int] = Field(None, gt=0)
overtime_workers: Optional[int] = None
overtime_hours: Optional[float] = None
class DailyWork(DailyWorkBase):
id: int
regular_hours: float
overtime_total: float
total_hours: float
created_by_id: int
created_by: User
created_at: datetime
class Config:
from_attributes = True
# Report schemas
class ReportRequest(BaseModel):
start_date: datetime
end_date: datetime
class CategoryStats(BaseModel):
material_missing: int = 0
dimension_defect: int = 0
incoming_defect: int = 0
class ReportSummary(BaseModel):
start_date: datetime
end_date: datetime
total_hours: float
total_issues: int
category_stats: CategoryStats
completed_issues: int
average_resolution_time: float

62
backend/main.py Normal file
View File

@@ -0,0 +1,62 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
import uvicorn
from database.database import engine, get_db
from database.models import Base
from routers import auth, issues, daily_work, reports
from services.auth_service import create_admin_user
# 데이터베이스 테이블 생성
Base.metadata.create_all(bind=engine)
# FastAPI 앱 생성
app = FastAPI(
title="M-Project API",
description="작업보고서 시스템 API",
version="1.0.0"
)
# CORS 설정
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 프로덕션에서는 구체적인 도메인으로 변경
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 라우터 등록
app.include_router(auth.router)
app.include_router(issues.router)
app.include_router(daily_work.router)
app.include_router(reports.router)
# 시작 시 관리자 계정 생성
@app.on_event("startup")
async def startup_event():
db = next(get_db())
create_admin_user(db)
db.close()
# 루트 엔드포인트
@app.get("/")
async def root():
return {"message": "M-Project API", "version": "1.0.0"}
# 헬스체크
@app.get("/api/health")
async def health_check():
return {"status": "healthy"}
# 전역 예외 처리
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"detail": f"Internal server error: {str(exc)}"}
)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,43 @@
-- 사용자 테이블
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
full_name VARCHAR(100),
role VARCHAR(20) DEFAULT 'user',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 이슈 테이블
CREATE TABLE IF NOT EXISTS issues (
id SERIAL PRIMARY KEY,
photo_path VARCHAR(255),
category VARCHAR(50) NOT NULL,
description TEXT NOT NULL,
status VARCHAR(20) DEFAULT 'new',
reporter_id INTEGER REFERENCES users(id),
report_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
work_hours FLOAT DEFAULT 0,
detail_notes TEXT
);
-- 일일 공수 테이블
CREATE TABLE IF NOT EXISTS daily_works (
id SERIAL PRIMARY KEY,
date DATE NOT NULL UNIQUE,
worker_count INTEGER NOT NULL,
regular_hours FLOAT NOT NULL,
overtime_workers INTEGER DEFAULT 0,
overtime_hours FLOAT DEFAULT 0,
overtime_total FLOAT DEFAULT 0,
total_hours FLOAT NOT NULL,
created_by_id INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 인덱스 생성
CREATE INDEX idx_issues_reporter_id ON issues(reporter_id);
CREATE INDEX idx_issues_status ON issues(status);
CREATE INDEX idx_issues_report_date ON issues(report_date);
CREATE INDEX idx_daily_works_date ON daily_works(date);

12
backend/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
python-multipart==0.0.6
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
sqlalchemy==2.0.23
psycopg2-binary==2.9.9
alembic==1.12.1
pydantic==2.5.0
pydantic-settings==2.1.0
pillow==10.1.0
reportlab==4.0.7

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

131
backend/routers/auth.py Normal file
View File

@@ -0,0 +1,131 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from typing import List
from database.database import get_db
from database.models import User, UserRole
from database import schemas
from services.auth_service import (
authenticate_user, create_access_token, verify_token,
get_password_hash, verify_password
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
token_data = verify_token(token, credentials_exception)
user = db.query(User).filter(User.username == token_data.username).first()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return user
async def get_current_admin(current_user: User = Depends(get_current_user)):
if current_user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
@router.post("/login", response_model=schemas.Token)
async def login(login_data: schemas.LoginRequest, db: Session = Depends(get_db)):
user = authenticate_user(db, login_data.username, login_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(data={"sub": user.username})
return {
"access_token": access_token,
"token_type": "bearer",
"user": user
}
@router.get("/me", response_model=schemas.User)
async def read_users_me(current_user: User = Depends(get_current_user)):
return current_user
@router.post("/users", response_model=schemas.User)
async def create_user(
user: schemas.UserCreate,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db)
):
# 중복 확인
db_user = db.query(User).filter(User.username == user.username).first()
if db_user:
raise HTTPException(status_code=400, detail="Username already registered")
# 사용자 생성
db_user = User(
username=user.username,
hashed_password=get_password_hash(user.password),
full_name=user.full_name,
role=user.role
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.get("/users", response_model=List[schemas.User])
async def read_users(
skip: int = 0,
limit: int = 100,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db)
):
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.put("/users/{user_id}", response_model=schemas.User)
async def update_user(
user_id: int,
user_update: schemas.UserUpdate,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db)
):
db_user = db.query(User).filter(User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
# 업데이트
update_data = user_update.dict(exclude_unset=True)
if "password" in update_data:
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
for field, value in update_data.items():
setattr(db_user, field, value)
db.commit()
db.refresh(db_user)
return db_user
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
current_admin: User = Depends(get_current_admin),
db: Session = Depends(get_db)
):
db_user = db.query(User).filter(User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
# 관리자는 삭제 불가
if db_user.role == UserRole.ADMIN:
raise HTTPException(status_code=400, detail="Cannot delete admin user")
db.delete(db_user)
db.commit()
return {"detail": "User deleted successfully"}

View File

@@ -0,0 +1,163 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime, date
from database.database import get_db
from database.models import DailyWork, User, UserRole
from database import schemas
from routers.auth import get_current_user
router = APIRouter(prefix="/api/daily-work", tags=["daily-work"])
@router.post("/", response_model=schemas.DailyWork)
async def create_daily_work(
work: schemas.DailyWorkCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# 중복 확인 (같은 날짜)
existing = db.query(DailyWork).filter(
DailyWork.date == work.date.date()
).first()
if existing:
raise HTTPException(status_code=400, detail="Daily work for this date already exists")
# 계산
regular_hours = work.worker_count * 8 # 정규 근무 8시간
overtime_total = work.overtime_workers * work.overtime_hours
total_hours = regular_hours + overtime_total
# 생성
db_work = DailyWork(
date=work.date,
worker_count=work.worker_count,
regular_hours=regular_hours,
overtime_workers=work.overtime_workers,
overtime_hours=work.overtime_hours,
overtime_total=overtime_total,
total_hours=total_hours,
created_by_id=current_user.id
)
db.add(db_work)
db.commit()
db.refresh(db_work)
return db_work
@router.get("/", response_model=List[schemas.DailyWork])
async def read_daily_works(
skip: int = 0,
limit: int = 100,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
query = db.query(DailyWork)
if start_date:
query = query.filter(DailyWork.date >= start_date)
if end_date:
query = query.filter(DailyWork.date <= end_date)
works = query.order_by(DailyWork.date.desc()).offset(skip).limit(limit).all()
return works
@router.get("/{work_id}", response_model=schemas.DailyWork)
async def read_daily_work(
work_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
if not work:
raise HTTPException(status_code=404, detail="Daily work not found")
return work
@router.put("/{work_id}", response_model=schemas.DailyWork)
async def update_daily_work(
work_id: int,
work_update: schemas.DailyWorkUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
if not work:
raise HTTPException(status_code=404, detail="Daily work not found")
# 업데이트
update_data = work_update.dict(exclude_unset=True)
# 재계산 필요한 경우
if any(key in update_data for key in ["worker_count", "overtime_workers", "overtime_hours"]):
worker_count = update_data.get("worker_count", work.worker_count)
overtime_workers = update_data.get("overtime_workers", work.overtime_workers)
overtime_hours = update_data.get("overtime_hours", work.overtime_hours)
regular_hours = worker_count * 8
overtime_total = overtime_workers * overtime_hours
total_hours = regular_hours + overtime_total
update_data["regular_hours"] = regular_hours
update_data["overtime_total"] = overtime_total
update_data["total_hours"] = total_hours
for field, value in update_data.items():
setattr(work, field, value)
db.commit()
db.refresh(work)
return work
@router.delete("/{work_id}")
async def delete_daily_work(
work_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
work = db.query(DailyWork).filter(DailyWork.id == work_id).first()
if not work:
raise HTTPException(status_code=404, detail="Daily work not found")
# 권한 확인 (관리자만 삭제 가능)
if current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Only admin can delete daily work")
db.delete(work)
db.commit()
return {"detail": "Daily work deleted successfully"}
@router.get("/stats/summary")
async def get_daily_work_stats(
start_date: Optional[date] = None,
end_date: Optional[date] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""일일 공수 통계"""
query = db.query(DailyWork)
if start_date:
query = query.filter(DailyWork.date >= start_date)
if end_date:
query = query.filter(DailyWork.date <= end_date)
works = query.all()
if not works:
return {
"total_days": 0,
"total_hours": 0,
"total_overtime": 0,
"average_daily_hours": 0
}
total_hours = sum(w.total_hours for w in works)
total_overtime = sum(w.overtime_total for w in works)
return {
"total_days": len(works),
"total_hours": total_hours,
"total_overtime": total_overtime,
"average_daily_hours": total_hours / len(works)
}

164
backend/routers/issues.py Normal file
View File

@@ -0,0 +1,164 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime
from database.database import get_db
from database.models import Issue, IssueStatus, User, UserRole
from database import schemas
from routers.auth import get_current_user
from services.file_service import save_base64_image, delete_file
router = APIRouter(prefix="/api/issues", tags=["issues"])
@router.post("/", response_model=schemas.Issue)
async def create_issue(
issue: schemas.IssueCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
# 이미지 저장
photo_path = None
if issue.photo:
photo_path = save_base64_image(issue.photo)
# Issue 생성
db_issue = Issue(
category=issue.category,
description=issue.description,
photo_path=photo_path,
reporter_id=current_user.id,
status=IssueStatus.NEW
)
db.add(db_issue)
db.commit()
db.refresh(db_issue)
return db_issue
@router.get("/", response_model=List[schemas.Issue])
async def read_issues(
skip: int = 0,
limit: int = 100,
status: Optional[IssueStatus] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
query = db.query(Issue)
# 일반 사용자는 자신의 이슈만 조회
if current_user.role == UserRole.USER:
query = query.filter(Issue.reporter_id == current_user.id)
if status:
query = query.filter(Issue.status == status)
issues = query.offset(skip).limit(limit).all()
return issues
@router.get("/{issue_id}", response_model=schemas.Issue)
async def read_issue(
issue_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
# 권한 확인
if current_user.role == UserRole.USER and issue.reporter_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to view this issue")
return issue
@router.put("/{issue_id}", response_model=schemas.Issue)
async def update_issue(
issue_id: int,
issue_update: schemas.IssueUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
# 권한 확인
if current_user.role == UserRole.USER and issue.reporter_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized to update this issue")
# 업데이트
update_data = issue_update.dict(exclude_unset=True)
# 사진이 업데이트되는 경우 처리
if "photo" in update_data:
# 기존 사진 삭제
if issue.photo_path:
delete_file(issue.photo_path)
# 새 사진 저장
if update_data["photo"]:
photo_path = save_base64_image(update_data["photo"])
update_data["photo_path"] = photo_path
else:
update_data["photo_path"] = None
# photo 필드는 제거 (DB에는 photo_path만 저장)
del update_data["photo"]
# work_hours가 입력되면 자동으로 상태를 complete로 변경
if "work_hours" in update_data and update_data["work_hours"] > 0:
if issue.status == IssueStatus.NEW:
update_data["status"] = IssueStatus.COMPLETE
for field, value in update_data.items():
setattr(issue, field, value)
db.commit()
db.refresh(issue)
return issue
@router.delete("/{issue_id}")
async def delete_issue(
issue_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
issue = db.query(Issue).filter(Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
# 권한 확인 (관리자만 삭제 가능)
if current_user.role != UserRole.ADMIN:
raise HTTPException(status_code=403, detail="Only admin can delete issues")
# 이미지 파일 삭제
if issue.photo_path:
delete_file(issue.photo_path)
db.delete(issue)
db.commit()
return {"detail": "Issue deleted successfully"}
@router.get("/stats/summary")
async def get_issue_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""이슈 통계 조회"""
query = db.query(Issue)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.USER:
query = query.filter(Issue.reporter_id == current_user.id)
total = query.count()
new = query.filter(Issue.status == IssueStatus.NEW).count()
progress = query.filter(Issue.status == IssueStatus.PROGRESS).count()
complete = query.filter(Issue.status == IssueStatus.COMPLETE).count()
return {
"total": total,
"new": new,
"progress": progress,
"complete": complete
}

130
backend/routers/reports.py Normal file
View File

@@ -0,0 +1,130 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime
from typing import List
from database.database import get_db
from database.models import Issue, DailyWork, IssueStatus, IssueCategory, User, UserRole
from database import schemas
from routers.auth import get_current_user
router = APIRouter(prefix="/api/reports", tags=["reports"])
@router.post("/summary", response_model=schemas.ReportSummary)
async def generate_report_summary(
report_request: schemas.ReportRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""보고서 요약 생성"""
start_date = report_request.start_date
end_date = report_request.end_date
# 일일 공수 합계
daily_works = db.query(DailyWork).filter(
DailyWork.date >= start_date.date(),
DailyWork.date <= end_date.date()
).all()
total_hours = sum(w.total_hours for w in daily_works)
# 이슈 통계
issues_query = db.query(Issue).filter(
Issue.report_date >= start_date,
Issue.report_date <= end_date
)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.USER:
issues_query = issues_query.filter(Issue.reporter_id == current_user.id)
issues = issues_query.all()
# 카테고리별 통계
category_stats = schemas.CategoryStats()
completed_issues = 0
total_resolution_time = 0
resolved_count = 0
for issue in issues:
# 카테고리별 카운트
if issue.category == IssueCategory.MATERIAL_MISSING:
category_stats.material_missing += 1
elif issue.category == IssueCategory.DIMENSION_DEFECT:
category_stats.dimension_defect += 1
elif issue.category == IssueCategory.INCOMING_DEFECT:
category_stats.incoming_defect += 1
# 완료된 이슈
if issue.status == IssueStatus.COMPLETE:
completed_issues += 1
if issue.work_hours > 0:
total_resolution_time += issue.work_hours
resolved_count += 1
# 평균 해결 시간
average_resolution_time = total_resolution_time / resolved_count if resolved_count > 0 else 0
return schemas.ReportSummary(
start_date=start_date,
end_date=end_date,
total_hours=total_hours,
total_issues=len(issues),
category_stats=category_stats,
completed_issues=completed_issues,
average_resolution_time=average_resolution_time
)
@router.get("/issues")
async def get_report_issues(
start_date: datetime,
end_date: datetime,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""보고서용 이슈 상세 목록"""
query = db.query(Issue).filter(
Issue.report_date >= start_date,
Issue.report_date <= end_date
)
# 일반 사용자는 자신의 이슈만
if current_user.role == UserRole.USER:
query = query.filter(Issue.reporter_id == current_user.id)
issues = query.order_by(Issue.report_date).all()
return [{
"id": issue.id,
"photo_path": issue.photo_path,
"category": issue.category,
"description": issue.description,
"status": issue.status,
"reporter_name": issue.reporter.full_name or issue.reporter.username,
"report_date": issue.report_date,
"work_hours": issue.work_hours,
"detail_notes": issue.detail_notes
} for issue in issues]
@router.get("/daily-works")
async def get_report_daily_works(
start_date: datetime,
end_date: datetime,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""보고서용 일일 공수 목록"""
works = db.query(DailyWork).filter(
DailyWork.date >= start_date.date(),
DailyWork.date <= end_date.date()
).order_by(DailyWork.date).all()
return [{
"date": work.date,
"worker_count": work.worker_count,
"regular_hours": work.regular_hours,
"overtime_workers": work.overtime_workers,
"overtime_hours": work.overtime_hours,
"overtime_total": work.overtime_total,
"total_hours": work.total_hours
} for work in works]

View File

@@ -0,0 +1,73 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
import os
from database.models import User, UserRole
from database.schemas import TokenData
# 환경 변수
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here")
ALGORITHM = os.getenv("ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7 days
# 비밀번호 암호화
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_token(token: str, credentials_exception):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
return token_data
except JWTError:
raise credentials_exception
def authenticate_user(db: Session, username: str, password: str):
user = db.query(User).filter(User.username == username).first()
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
def create_admin_user(db: Session):
"""초기 관리자 계정 생성"""
admin_username = os.getenv("ADMIN_USERNAME", "hyungi")
admin_password = os.getenv("ADMIN_PASSWORD", "djg3-jj34-X3Q3")
# 이미 존재하는지 확인
existing_admin = db.query(User).filter(User.username == admin_username).first()
if not existing_admin:
admin_user = User(
username=admin_username,
hashed_password=get_password_hash(admin_password),
full_name="관리자",
role=UserRole.ADMIN,
is_active=True
)
db.add(admin_user)
db.commit()
print(f"관리자 계정 생성됨: {admin_username}")
else:
print(f"관리자 계정이 이미 존재함: {admin_username}")

View File

@@ -0,0 +1,61 @@
import os
import base64
from datetime import datetime
from typing import Optional
import uuid
from PIL import Image
import io
UPLOAD_DIR = "/app/uploads"
def ensure_upload_dir():
"""업로드 디렉토리 생성"""
if not os.path.exists(UPLOAD_DIR):
os.makedirs(UPLOAD_DIR)
def save_base64_image(base64_string: str) -> Optional[str]:
"""Base64 이미지를 파일로 저장하고 경로 반환"""
try:
ensure_upload_dir()
# Base64 헤더 제거
if "," in base64_string:
base64_string = base64_string.split(",")[1]
# 디코딩
image_data = base64.b64decode(base64_string)
# 이미지 검증 및 형식 확인
image = Image.open(io.BytesIO(image_data))
format = image.format.lower() if image.format else 'png'
# 파일명 생성
filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:8]}.{format}"
filepath = os.path.join(UPLOAD_DIR, filename)
# 이미지 저장 (최대 크기 제한)
max_size = (1920, 1920)
image.thumbnail(max_size, Image.Resampling.LANCZOS)
if format == 'png':
image.save(filepath, 'PNG', optimize=True)
else:
image.save(filepath, 'JPEG', quality=85, optimize=True)
# 웹 경로 반환
return f"/uploads/{filename}"
except Exception as e:
print(f"이미지 저장 실패: {e}")
return None
def delete_file(filepath: str):
"""파일 삭제"""
try:
if filepath and filepath.startswith("/uploads/"):
filename = filepath.replace("/uploads/", "")
full_path = os.path.join(UPLOAD_DIR, filename)
if os.path.exists(full_path):
os.remove(full_path)
except Exception as e:
print(f"파일 삭제 실패: {e}")