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:
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
+7
-3
@@ -2,14 +2,17 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
from app.config import settings
|
||||
from app.api import auth, projects, tasks, daily_reports, reports, inspections, weather, rag, kakao, permits, settings as settings_router
|
||||
from app.api import auth, projects, tasks, daily_reports, reports, inspections, weather, rag, kakao, permits, quality, settings as settings_router
|
||||
from app.services.scheduler import start_scheduler, stop_scheduler
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: seed default work types, check DB connection
|
||||
# Startup
|
||||
start_scheduler()
|
||||
yield
|
||||
# Shutdown: cleanup resources
|
||||
# Shutdown
|
||||
stop_scheduler()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -41,6 +44,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(rag.router, prefix=api_prefix)
|
||||
app.include_router(kakao.router, prefix=api_prefix)
|
||||
app.include_router(permits.router, prefix=api_prefix)
|
||||
app.include_router(quality.router, prefix=api_prefix)
|
||||
app.include_router(settings_router.router, prefix=api_prefix)
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from pydantic import BaseModel, model_validator
|
||||
from app.models.quality import QualityResult
|
||||
|
||||
|
||||
class QualityTestCreate(BaseModel):
|
||||
wbs_item_id: uuid.UUID | None = None
|
||||
test_type: str # compression_strength, slump, compaction, etc.
|
||||
test_date: date
|
||||
location_detail: str | None = None
|
||||
design_value: float | None = None
|
||||
measured_value: float
|
||||
unit: str
|
||||
result: QualityResult | None = None # auto-calculated if design_value provided
|
||||
lab_name: str | None = None
|
||||
report_number: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def auto_result(self) -> "QualityTestCreate":
|
||||
if self.result is None:
|
||||
if self.design_value is not None:
|
||||
self.result = QualityResult.PASS if self.measured_value >= self.design_value else QualityResult.FAIL
|
||||
else:
|
||||
self.result = QualityResult.PASS
|
||||
return self
|
||||
|
||||
|
||||
class QualityTestUpdate(BaseModel):
|
||||
test_date: date | None = None
|
||||
location_detail: str | None = None
|
||||
design_value: float | None = None
|
||||
measured_value: float | None = None
|
||||
unit: str | None = None
|
||||
result: QualityResult | None = None
|
||||
lab_name: str | None = None
|
||||
report_number: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class QualityTestResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
wbs_item_id: uuid.UUID | None
|
||||
test_type: str
|
||||
test_date: date
|
||||
location_detail: str | None
|
||||
design_value: float | None
|
||||
measured_value: float
|
||||
unit: str
|
||||
result: QualityResult
|
||||
lab_name: str | None
|
||||
report_number: str | None
|
||||
notes: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
PDF 생성 서비스 — WeasyPrint + Jinja2
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
|
||||
_jinja_env = Environment(loader=FileSystemLoader(str(TEMPLATES_DIR)), autoescape=True)
|
||||
|
||||
INSPECTION_TYPE_LABELS = {
|
||||
"rebar": "철근 배근 검측",
|
||||
"formwork": "거푸집 검측",
|
||||
"concrete": "콘크리트 타설 검측",
|
||||
"pipe_burial": "관 매설 검측",
|
||||
"compaction": "다짐 검측",
|
||||
"waterproofing": "방수 검측",
|
||||
"finishing": "마감 검측",
|
||||
}
|
||||
|
||||
REPORT_TYPE_LABELS = {
|
||||
"weekly": "주간",
|
||||
"monthly": "월간",
|
||||
}
|
||||
|
||||
REPORT_STATUS_LABELS = {
|
||||
"draft": "초안",
|
||||
"reviewed": "검토완료",
|
||||
"submitted": "제출완료",
|
||||
}
|
||||
|
||||
|
||||
def _render_html(template_name: str, **context) -> str:
|
||||
template = _jinja_env.get_template(template_name)
|
||||
return template.render(now=datetime.now().strftime("%Y-%m-%d %H:%M"), **context)
|
||||
|
||||
|
||||
def _html_to_pdf(html: str) -> bytes:
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
return HTML(string=html).write_pdf()
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"WeasyPrint가 설치되지 않았습니다. `pip install weasyprint` 실행 후 재시도하세요."
|
||||
)
|
||||
|
||||
|
||||
def generate_daily_report_pdf(report, project) -> bytes:
|
||||
html = _render_html("daily_report.html", report=report, project=project)
|
||||
return _html_to_pdf(html)
|
||||
|
||||
|
||||
def generate_inspection_pdf(inspection, project) -> bytes:
|
||||
type_label = INSPECTION_TYPE_LABELS.get(inspection.inspection_type, inspection.inspection_type)
|
||||
html = _render_html(
|
||||
"inspection.html",
|
||||
inspection=inspection,
|
||||
project=project,
|
||||
inspection_type_label=type_label,
|
||||
)
|
||||
return _html_to_pdf(html)
|
||||
|
||||
|
||||
def generate_report_pdf(report, project) -> bytes:
|
||||
type_label = REPORT_TYPE_LABELS.get(report.report_type.value, report.report_type.value)
|
||||
status_label = REPORT_STATUS_LABELS.get(report.status.value, report.status.value)
|
||||
period_label = f"{report.period_start} ~ {report.period_end} ({type_label})"
|
||||
html = _render_html(
|
||||
"report.html",
|
||||
report=report,
|
||||
project=project,
|
||||
report_type_label=type_label,
|
||||
status_label=status_label,
|
||||
period_label=period_label,
|
||||
content_json=report.content_json,
|
||||
ai_draft_text=report.ai_draft_text,
|
||||
)
|
||||
return _html_to_pdf(html)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
APScheduler 날씨 자동 수집 배치
|
||||
- 3시간마다 활성 프로젝트의 날씨 데이터를 수집
|
||||
- 수집 후 날씨 경보 평가
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.project import Project
|
||||
from app.models.weather import WeatherData, WeatherAlert
|
||||
from app.services.weather_service import fetch_short_term_forecast, evaluate_alerts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: AsyncIOScheduler | None = None
|
||||
|
||||
|
||||
async def _collect_weather_for_all_projects():
|
||||
"""활성 프로젝트 전체의 날씨를 수집하고 경보를 평가합니다."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(Project).where(Project.status == "active")
|
||||
)
|
||||
projects = result.scalars().all()
|
||||
|
||||
if not projects:
|
||||
return
|
||||
|
||||
logger.info(f"날씨 수집 시작: {len(projects)}개 프로젝트")
|
||||
|
||||
for project in projects:
|
||||
# 프로젝트에 KMA 격자 좌표가 없으면 스킵
|
||||
if not project.kma_nx or not project.kma_ny:
|
||||
continue
|
||||
|
||||
try:
|
||||
forecasts = await fetch_short_term_forecast(project.kma_nx, project.kma_ny)
|
||||
|
||||
today = date.today().isoformat()
|
||||
today_forecasts = [f for f in forecasts if f.get("date") == today]
|
||||
|
||||
if today_forecasts:
|
||||
# 오늘 날씨 데이터 upsert (중복 저장 방지)
|
||||
existing = await db.execute(
|
||||
select(WeatherData).where(
|
||||
WeatherData.project_id == project.id,
|
||||
WeatherData.forecast_date == date.today(),
|
||||
)
|
||||
)
|
||||
weather_row = existing.scalar_one_or_none()
|
||||
|
||||
if not weather_row:
|
||||
# 최고/최저 기온 계산
|
||||
temps = [f.get("temperature") for f in today_forecasts if f.get("temperature") is not None]
|
||||
precips = [f.get("precipitation_mm", 0) or 0 for f in today_forecasts]
|
||||
wind_speeds = [f.get("wind_speed", 0) or 0 for f in today_forecasts]
|
||||
|
||||
weather_row = WeatherData(
|
||||
project_id=project.id,
|
||||
forecast_date=date.today(),
|
||||
temperature_max=max(temps) if temps else None,
|
||||
temperature_min=min(temps) if temps else None,
|
||||
precipitation_mm=sum(precips),
|
||||
wind_speed_max=max(wind_speeds) if wind_speeds else None,
|
||||
sky_condition=today_forecasts[0].get("sky_condition"),
|
||||
raw_forecast=today_forecasts,
|
||||
)
|
||||
db.add(weather_row)
|
||||
await db.flush()
|
||||
|
||||
# 날씨 경보 평가 (활성 태스크 기반)
|
||||
from app.models.task import Task
|
||||
tasks_result = await db.execute(
|
||||
select(Task).where(
|
||||
Task.project_id == project.id,
|
||||
Task.status.in_(["not_started", "in_progress"]),
|
||||
)
|
||||
)
|
||||
tasks = tasks_result.scalars().all()
|
||||
|
||||
# 오늘 이미 생성된 경보 확인
|
||||
existing_alerts = await db.execute(
|
||||
select(WeatherAlert).where(
|
||||
WeatherAlert.project_id == project.id,
|
||||
WeatherAlert.alert_date == date.today(),
|
||||
)
|
||||
)
|
||||
already_alerted = existing_alerts.scalars().all()
|
||||
alerted_types = {a.alert_type for a in already_alerted}
|
||||
|
||||
new_alerts = evaluate_alerts(
|
||||
forecasts=today_forecasts,
|
||||
tasks=tasks,
|
||||
existing_alert_types=alerted_types,
|
||||
)
|
||||
|
||||
for alert_data in new_alerts:
|
||||
alert = WeatherAlert(
|
||||
project_id=project.id,
|
||||
alert_date=date.today(),
|
||||
**alert_data,
|
||||
)
|
||||
db.add(alert)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"프로젝트 {project.id} 날씨 수집 실패: {e}")
|
||||
continue
|
||||
|
||||
await db.commit()
|
||||
logger.info("날씨 수집 완료")
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""FastAPI 시작 시 스케줄러를 초기화하고 시작합니다."""
|
||||
global _scheduler
|
||||
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||
|
||||
# 3시간마다 날씨 수집
|
||||
_scheduler.add_job(
|
||||
_collect_weather_for_all_projects,
|
||||
trigger=IntervalTrigger(hours=3),
|
||||
id="weather_collect",
|
||||
name="날씨 데이터 자동 수집",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=300, # 5분 내 누락 허용
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info("APScheduler 시작: 날씨 수집 3시간 주기")
|
||||
return _scheduler
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""FastAPI 종료 시 스케줄러를 중지합니다."""
|
||||
global _scheduler
|
||||
if _scheduler and _scheduler.running:
|
||||
_scheduler.shutdown(wait=False)
|
||||
logger.info("APScheduler 종료")
|
||||
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap');
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Noto Sans KR', sans-serif; font-size: 11pt; color: #111; padding: 20mm; }
|
||||
h1 { text-align: center; font-size: 18pt; font-weight: 700; margin-bottom: 4mm; }
|
||||
.subtitle { text-align: center; font-size: 11pt; margin-bottom: 8mm; color: #444; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
|
||||
th, td { border: 1px solid #888; padding: 3mm 4mm; vertical-align: top; }
|
||||
th { background: #f0f0f0; font-weight: 700; text-align: center; width: 28%; }
|
||||
.section-title { font-size: 12pt; font-weight: 700; background: #e8e8e8; padding: 2mm 4mm; margin: 5mm 0 2mm; }
|
||||
.workers-table th { width: auto; }
|
||||
.footer { margin-top: 10mm; text-align: right; font-size: 10pt; color: #555; }
|
||||
.badge { display: inline-block; padding: 1mm 3mm; border-radius: 3px; font-size: 9pt; font-weight: 700; }
|
||||
.badge-draft { background: #fef3c7; color: #92400e; }
|
||||
.badge-confirmed { background: #d1fae5; color: #065f46; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>작 업 일 보</h1>
|
||||
<div class="subtitle">{{ project.name }}</div>
|
||||
|
||||
<table>
|
||||
<tr><th>공사명</th><td colspan="3">{{ project.name }}</td></tr>
|
||||
<tr>
|
||||
<th>일자</th><td>{{ report.report_date }}</td>
|
||||
<th>날씨</th><td>{{ report.weather_summary or '-' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>기온 (최고/최저)</th>
|
||||
<td>{{ report.temperature_high }}°C / {{ report.temperature_low }}°C</td>
|
||||
<th>상태</th>
|
||||
<td>
|
||||
{% if report.status.value == 'confirmed' %}
|
||||
<span class="badge badge-confirmed">확인완료</span>
|
||||
{% else %}
|
||||
<span class="badge badge-draft">초안</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="section-title">▶ 투입 인원</div>
|
||||
{% if report.workers_count %}
|
||||
<table class="workers-table">
|
||||
<tr>
|
||||
{% for key in report.workers_count %}<th>{{ key }}</th>{% endfor %}
|
||||
<th>합계</th>
|
||||
</tr>
|
||||
<tr>
|
||||
{% set total = namespace(n=0) %}
|
||||
{% for key, val in report.workers_count.items() %}
|
||||
<td style="text-align:center">{{ val }}명</td>
|
||||
{% set total.n = total.n + val %}
|
||||
{% endfor %}
|
||||
<td style="text-align:center;font-weight:700">{{ total.n }}명</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% else %}
|
||||
<table><tr><td style="color:#888">투입 인원 정보 없음</td></tr></table>
|
||||
{% endif %}
|
||||
|
||||
{% if report.equipment_list %}
|
||||
<div class="section-title">▶ 투입 장비</div>
|
||||
<table>
|
||||
<tr><th>장비명</th><th>규격</th><th>수량</th><th>비고</th></tr>
|
||||
{% for eq in report.equipment_list %}
|
||||
<tr>
|
||||
<td>{{ eq.get('type', '-') }}</td>
|
||||
<td>{{ eq.get('spec', '-') }}</td>
|
||||
<td style="text-align:center">{{ eq.get('count', 1) }}대</td>
|
||||
<td>{{ eq.get('notes', '') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-title">▶ 작업 내용</div>
|
||||
<table>
|
||||
<tr><td style="min-height:30mm; white-space:pre-wrap">{{ report.work_content or '-' }}</td></tr>
|
||||
</table>
|
||||
|
||||
{% if report.issues %}
|
||||
<div class="section-title">▶ 특이사항 / 문제점</div>
|
||||
<table>
|
||||
<tr><td style="white-space:pre-wrap">{{ report.issues }}</td></tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
작성일시: {{ now }}
|
||||
{% if report.ai_generated %}AI 보조 작성{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap');
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Noto Sans KR', sans-serif; font-size: 11pt; color: #111; padding: 20mm; }
|
||||
h1 { text-align: center; font-size: 18pt; font-weight: 700; margin-bottom: 8mm; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
|
||||
th, td { border: 1px solid #888; padding: 3mm 4mm; vertical-align: middle; }
|
||||
th { background: #f0f0f0; font-weight: 700; text-align: center; width: 28%; }
|
||||
.section-title { font-size: 12pt; font-weight: 700; background: #e8e8e8; padding: 2mm 4mm; margin: 5mm 0 2mm; }
|
||||
.checklist-item { display: flex; align-items: flex-start; padding: 2mm 0; border-bottom: 1px solid #ddd; }
|
||||
.check-box { width: 6mm; height: 6mm; border: 1px solid #666; margin-right: 3mm; flex-shrink: 0; margin-top: 1mm; }
|
||||
.check-num { color: #888; margin-right: 2mm; min-width: 8mm; }
|
||||
.badge { display: inline-block; padding: 1mm 3mm; border-radius: 3px; font-size: 9pt; font-weight: 700; }
|
||||
.badge-pass { background: #d1fae5; color: #065f46; }
|
||||
.badge-fail { background: #fee2e2; color: #991b1b; }
|
||||
.badge-conditional { background: #fef3c7; color: #92400e; }
|
||||
.sign-area { display: flex; justify-content: flex-end; gap: 10mm; margin-top: 10mm; }
|
||||
.sign-box { border: 1px solid #888; width: 35mm; text-align: center; }
|
||||
.sign-box .title { background: #f0f0f0; padding: 2mm; border-bottom: 1px solid #888; font-size: 9pt; }
|
||||
.sign-box .space { height: 15mm; }
|
||||
.footer { margin-top: 8mm; text-align: right; font-size: 10pt; color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>검 측 요 청 서</h1>
|
||||
|
||||
<table>
|
||||
<tr><th>공사명</th><td colspan="3">{{ project.name }}</td></tr>
|
||||
<tr>
|
||||
<th>검측 항목</th>
|
||||
<td>{{ inspection_type_label }}</td>
|
||||
<th>요청일</th>
|
||||
<td>{{ inspection.requested_date }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>위치 / 부위</th>
|
||||
<td>{{ inspection.location_detail or '-' }}</td>
|
||||
<th>결과</th>
|
||||
<td>
|
||||
{% if inspection.result %}
|
||||
{% if inspection.result.value == 'pass' %}<span class="badge badge-pass">합격</span>
|
||||
{% elif inspection.result.value == 'fail' %}<span class="badge badge-fail">불합격</span>
|
||||
{% else %}<span class="badge badge-conditional">조건부합격</span>{% endif %}
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if inspection.inspector_name %}
|
||||
<tr><th>검측자</th><td colspan="3">{{ inspection.inspector_name }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
{% if inspection.checklist_items %}
|
||||
<div class="section-title">▶ 검측 체크리스트</div>
|
||||
<table>
|
||||
<tr><th style="width:8%">No.</th><th style="width:50%">검측 항목</th><th style="width:22%">기준값</th><th style="width:20%">확인</th></tr>
|
||||
{% for item in inspection.checklist_items %}
|
||||
<tr>
|
||||
<td style="text-align:center">{{ loop.index }}</td>
|
||||
<td>{{ item.get('item', item) if item is mapping else item }}</td>
|
||||
<td>{{ item.get('standard', '') if item is mapping else '' }}</td>
|
||||
<td style="text-align:center">□</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if inspection.notes %}
|
||||
<div class="section-title">▶ 특이사항</div>
|
||||
<table><tr><td style="white-space:pre-wrap">{{ inspection.notes }}</td></tr></table>
|
||||
{% endif %}
|
||||
|
||||
<div class="sign-area">
|
||||
<div class="sign-box">
|
||||
<div class="title">현장대리인</div>
|
||||
<div class="space"></div>
|
||||
<div style="padding:2mm;font-size:9pt">(인)</div>
|
||||
</div>
|
||||
<div class="sign-box">
|
||||
<div class="title">감독관</div>
|
||||
<div class="space"></div>
|
||||
<div style="padding:2mm;font-size:9pt">(인)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">출력일시: {{ now }}{% if inspection.ai_generated %} AI 보조 작성{% endif %}</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap');
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Noto Sans KR', sans-serif; font-size: 11pt; color: #111; padding: 20mm; }
|
||||
h1 { text-align: center; font-size: 18pt; font-weight: 700; margin-bottom: 4mm; }
|
||||
.subtitle { text-align: center; font-size: 11pt; color: #444; margin-bottom: 8mm; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
|
||||
th, td { border: 1px solid #888; padding: 3mm 4mm; vertical-align: top; }
|
||||
th { background: #f0f0f0; font-weight: 700; text-align: center; width: 25%; }
|
||||
.section-title { font-size: 12pt; font-weight: 700; background: #e8e8e8; padding: 2mm 4mm; margin: 5mm 0 2mm; }
|
||||
.content-block { border: 1px solid #ccc; padding: 4mm; min-height: 20mm; white-space: pre-wrap; line-height: 1.8; }
|
||||
.footer { margin-top: 10mm; text-align: right; font-size: 10pt; color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ report_type_label }} 공정보고서</h1>
|
||||
<div class="subtitle">{{ project.name }} | {{ period_label }}</div>
|
||||
|
||||
<table>
|
||||
<tr><th>공사명</th><td colspan="3">{{ project.name }}</td></tr>
|
||||
<tr>
|
||||
<th>보고 기간</th><td>{{ period_label }}</td>
|
||||
<th>상태</th><td>{{ status_label }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if content_json %}
|
||||
{% if content_json.get('work_summary') %}
|
||||
<div class="section-title">▶ 주요 작업 내용</div>
|
||||
<div class="content-block">{{ content_json.work_summary }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if content_json.get('overall_progress') is not none %}
|
||||
<div class="section-title">▶ 공정률</div>
|
||||
<table><tr><th>종합 공정률</th><td>{{ content_json.overall_progress }}%</td></tr></table>
|
||||
{% endif %}
|
||||
|
||||
{% if content_json.get('issues') %}
|
||||
<div class="section-title">▶ 문제점 및 조치사항</div>
|
||||
<div class="content-block">{{ content_json.issues }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if content_json.get('next_plan') %}
|
||||
<div class="section-title">▶ 다음 기간 예정 작업</div>
|
||||
<div class="content-block">{{ content_json.next_plan }}</div>
|
||||
{% endif %}
|
||||
{% elif ai_draft_text %}
|
||||
<div class="section-title">▶ AI 작성 보고서 초안</div>
|
||||
<div class="content-block">{{ ai_draft_text }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">출력일시: {{ now }}</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user