feat: Phase 1 잔여 기능 구현 완료

- 품질시험 API: schemas/quality.py + api/quality.py (CRUD, 합격률 요약, 자동 합불 판정)
- PDF 생성: WeasyPrint + Jinja2 (작업일보/검측요청서/보고서 템플릿 + /pdf 다운로드 엔드포인트)
- RAG 시드 스크립트: scripts/seed_rag.py (PDF/TXT 청킹, 배치 임베딩, CLI)
- APScheduler: 날씨 3시간 주기 자동 수집 + 경보 평가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sinmb79
2026-03-24 21:39:05 +09:00
parent 2a4950d8a0
commit 0156d8ca4f
13 changed files with 990 additions and 3 deletions
+16
View File
@@ -1,6 +1,7 @@
import uuid
from datetime import date
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import Response
from sqlalchemy import select
from app.deps import CurrentUser, DB
from app.models.daily_report import DailyReport, InputSource
@@ -9,6 +10,7 @@ from app.schemas.daily_report import (
DailyReportCreate, DailyReportUpdate, DailyReportGenerateRequest, DailyReportResponse
)
from app.services.daily_report_gen import generate_work_content
from app.services.pdf_service import generate_daily_report_pdf
router = APIRouter(prefix="/projects/{project_id}/daily-reports", tags=["작업일보"])
@@ -97,6 +99,20 @@ async def update_report(project_id: uuid.UUID, report_id: uuid.UUID, data: Daily
return report
@router.get("/{report_id}/pdf")
async def download_report_pdf(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
"""작업일보 PDF 다운로드"""
r = await db.execute(select(DailyReport).where(DailyReport.id == report_id, DailyReport.project_id == project_id))
report = r.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="일보를 찾을 수 없습니다")
project = await _get_project_or_404(project_id, db)
pdf_bytes = generate_daily_report_pdf(report, project)
filename = f"daily_report_{report.report_date}.pdf"
return Response(content=pdf_bytes, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"})
@router.delete("/{report_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_report(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(DailyReport).where(DailyReport.id == report_id, DailyReport.project_id == project_id))
+16
View File
@@ -1,11 +1,13 @@
import uuid
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import Response
from sqlalchemy import select
from app.deps import CurrentUser, DB
from app.models.inspection import InspectionRequest
from app.models.project import Project, WBSItem
from app.schemas.inspection import InspectionCreate, InspectionUpdate, InspectionGenerateRequest, InspectionResponse
from app.services.inspection_gen import generate_checklist
from app.services.pdf_service import generate_inspection_pdf
router = APIRouter(prefix="/projects/{project_id}/inspections", tags=["검측요청서"])
@@ -96,6 +98,20 @@ async def update_inspection(project_id: uuid.UUID, inspection_id: uuid.UUID, dat
return insp
@router.get("/{inspection_id}/pdf")
async def download_inspection_pdf(project_id: uuid.UUID, inspection_id: uuid.UUID, db: DB, current_user: CurrentUser):
"""검측요청서 PDF 다운로드"""
r = await db.execute(select(InspectionRequest).where(InspectionRequest.id == inspection_id, InspectionRequest.project_id == project_id))
insp = r.scalar_one_or_none()
if not insp:
raise HTTPException(status_code=404, detail="검측요청서를 찾을 수 없습니다")
project = await _get_project_or_404(project_id, db)
pdf_bytes = generate_inspection_pdf(insp, project)
filename = f"inspection_{insp.requested_date}_{insp.inspection_type}.pdf"
return Response(content=pdf_bytes, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"})
@router.delete("/{inspection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_inspection(project_id: uuid.UUID, inspection_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(InspectionRequest).where(InspectionRequest.id == inspection_id, InspectionRequest.project_id == project_id))
+137
View File
@@ -0,0 +1,137 @@
import uuid
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select, func
from app.deps import CurrentUser, DB
from app.models.quality import QualityTest, QualityResult
from app.models.project import Project
from app.schemas.quality import QualityTestCreate, QualityTestUpdate, QualityTestResponse
router = APIRouter(prefix="/projects/{project_id}/quality", tags=["품질시험"])
async def _get_project_or_404(project_id: uuid.UUID, db: DB) -> Project:
result = await db.execute(select(Project).where(Project.id == project_id))
p = result.scalar_one_or_none()
if not p:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다")
return p
@router.get("", response_model=list[QualityTestResponse])
async def list_quality_tests(
project_id: uuid.UUID,
db: DB,
current_user: CurrentUser,
test_type: str | None = None,
result: QualityResult | None = None,
):
query = select(QualityTest).where(QualityTest.project_id == project_id)
if test_type:
query = query.where(QualityTest.test_type == test_type)
if result:
query = query.where(QualityTest.result == result)
query = query.order_by(QualityTest.test_date.desc())
rows = await db.execute(query)
return rows.scalars().all()
@router.post("", response_model=QualityTestResponse, status_code=status.HTTP_201_CREATED)
async def create_quality_test(
project_id: uuid.UUID,
data: QualityTestCreate,
db: DB,
current_user: CurrentUser,
):
await _get_project_or_404(project_id, db)
test = QualityTest(**data.model_dump(), project_id=project_id)
db.add(test)
await db.commit()
await db.refresh(test)
return test
@router.get("/summary")
async def quality_summary(project_id: uuid.UUID, db: DB, current_user: CurrentUser):
"""프로젝트 품질시험 합격률 요약"""
total_q = await db.execute(
select(func.count()).where(QualityTest.project_id == project_id)
)
total = total_q.scalar() or 0
pass_q = await db.execute(
select(func.count()).where(
QualityTest.project_id == project_id,
QualityTest.result == QualityResult.PASS,
)
)
passed = pass_q.scalar() or 0
return {
"total": total,
"passed": passed,
"failed": total - passed,
"pass_rate": round(passed / total * 100, 1) if total > 0 else None,
}
@router.get("/{test_id}", response_model=QualityTestResponse)
async def get_quality_test(
project_id: uuid.UUID, test_id: uuid.UUID, db: DB, current_user: CurrentUser
):
result = await db.execute(
select(QualityTest).where(
QualityTest.id == test_id, QualityTest.project_id == project_id
)
)
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="품질시험 기록을 찾을 수 없습니다")
return test
@router.put("/{test_id}", response_model=QualityTestResponse)
async def update_quality_test(
project_id: uuid.UUID,
test_id: uuid.UUID,
data: QualityTestUpdate,
db: DB,
current_user: CurrentUser,
):
result = await db.execute(
select(QualityTest).where(
QualityTest.id == test_id, QualityTest.project_id == project_id
)
)
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="품질시험 기록을 찾을 수 없습니다")
update_data = data.model_dump(exclude_none=True)
# 측정값/기준값 변경 시 합격 여부 재계산
new_measured = update_data.get("measured_value", test.measured_value)
new_design = update_data.get("design_value", test.design_value)
if "result" not in update_data and new_design is not None:
update_data["result"] = QualityResult.PASS if new_measured >= new_design else QualityResult.FAIL
for field, value in update_data.items():
setattr(test, field, value)
await db.commit()
await db.refresh(test)
return test
@router.delete("/{test_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_quality_test(
project_id: uuid.UUID, test_id: uuid.UUID, db: DB, current_user: CurrentUser
):
result = await db.execute(
select(QualityTest).where(
QualityTest.id == test_id, QualityTest.project_id == project_id
)
)
test = result.scalar_one_or_none()
if not test:
raise HTTPException(status_code=404, detail="품질시험 기록을 찾을 수 없습니다")
await db.delete(test)
await db.commit()
+16
View File
@@ -1,6 +1,7 @@
import uuid
from datetime import date
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import Response
from sqlalchemy import select, func
from app.deps import CurrentUser, DB
from app.models.report import Report, ReportType
@@ -9,6 +10,7 @@ from app.models.weather import WeatherAlert
from app.models.project import Project
from app.schemas.report import ReportGenerateRequest, ReportResponse
from app.services.report_gen import generate_weekly_report, generate_monthly_report
from app.services.pdf_service import generate_report_pdf
router = APIRouter(prefix="/projects/{project_id}/reports", tags=["공정보고서"])
@@ -134,6 +136,20 @@ async def get_report(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, curren
return report
@router.get("/{report_id}/pdf")
async def download_report_pdf(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
"""공정보고서 PDF 다운로드"""
r = await db.execute(select(Report).where(Report.id == report_id, Report.project_id == project_id))
report = r.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="보고서를 찾을 수 없습니다")
project = await _get_project_or_404(project_id, db)
pdf_bytes = generate_report_pdf(report, project)
filename = f"report_{report.report_type.value}_{report.period_start}.pdf"
return Response(content=pdf_bytes, media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"})
@router.delete("/{report_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_report(project_id: uuid.UUID, report_id: uuid.UUID, db: DB, current_user: CurrentUser):
result = await db.execute(select(Report).where(Report.id == report_id, Report.project_id == project_id))