- Add meeting_number field (M-YYYYMMDD-XX format) with auto-generation - Refactor Excel export to use cell coordinates instead of placeholders - Export files saved to backend/record/ directory with meeting number filename - Add database migration for meeting_number column - Add start.sh script for managing frontend/backend/sidecar services - Update OpenSpec documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
223 lines
7.7 KiB
Python
223 lines
7.7 KiB
Python
from fastapi import APIRouter, HTTPException, Depends
|
|
from fastapi.responses import StreamingResponse
|
|
from openpyxl import Workbook, load_workbook
|
|
from openpyxl.styles import Font, Alignment, Border, Side
|
|
import io
|
|
import os
|
|
|
|
from ..database import get_db_cursor
|
|
from ..models import TokenPayload
|
|
from .auth import get_current_user, is_admin
|
|
|
|
router = APIRouter()
|
|
|
|
# Directory paths
|
|
TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "template")
|
|
RECORD_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "record")
|
|
|
|
|
|
def fill_template_workbook(
|
|
wb: Workbook, meeting: dict, conclusions: list, actions: list
|
|
) -> Workbook:
|
|
"""Fill Excel template with meeting data using cell coordinates."""
|
|
ws = wb.active
|
|
|
|
# Fill meeting metadata using cell coordinates
|
|
# D3: 會議主題
|
|
ws["D3"] = meeting.get("subject", "")
|
|
# D4: 會議時間
|
|
meeting_time = meeting.get("meeting_time")
|
|
if meeting_time:
|
|
ws["D4"] = meeting_time.strftime("%Y-%m-%d %H:%M") if hasattr(meeting_time, 'strftime') else str(meeting_time)
|
|
# D5: 會議主席
|
|
ws["D5"] = meeting.get("chairperson", "")
|
|
# F4: 會議地點
|
|
ws["F4"] = meeting.get("location", "")
|
|
# F5: 記錄人員
|
|
ws["F5"] = meeting.get("recorder", "")
|
|
# D6: 會議參與人員
|
|
ws["D6"] = meeting.get("attendees", "")
|
|
# C8: 會議編號
|
|
ws["C8"] = meeting.get("meeting_number", "")
|
|
|
|
# D8: 會議結論 (多條用換行合併)
|
|
if conclusions:
|
|
conclusion_texts = [c.get("content", "") for c in conclusions]
|
|
ws["D8"] = "\n".join(conclusion_texts)
|
|
# Enable text wrap for multi-line content
|
|
ws["D8"].alignment = Alignment(wrap_text=True, vertical="top")
|
|
|
|
# Fill action items starting from row 10
|
|
# C: 編號, D: 內容, F: 負責人, G: 預計完成日期, H: 執行現況
|
|
start_row = 10
|
|
for i, action in enumerate(actions):
|
|
row = start_row + i
|
|
# C column: 代辦事項編號
|
|
ws[f"C{row}"] = action.get("system_code", "")
|
|
# D column: 事項內容
|
|
ws[f"D{row}"] = action.get("content", "")
|
|
# F column: 負責人
|
|
ws[f"F{row}"] = action.get("owner", "")
|
|
# G column: 預計完成日期
|
|
due_date = action.get("due_date")
|
|
if due_date:
|
|
ws[f"G{row}"] = due_date.strftime("%Y-%m-%d") if hasattr(due_date, 'strftime') else str(due_date)
|
|
else:
|
|
ws[f"G{row}"] = ""
|
|
# H column: 執行現況 (status)
|
|
ws[f"H{row}"] = action.get("status", "")
|
|
|
|
return wb
|
|
|
|
|
|
def create_default_workbook(meeting: dict, conclusions: list, actions: list) -> Workbook:
|
|
"""Create Excel workbook with meeting data (fallback when no template)."""
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = "Meeting Record"
|
|
|
|
# Styles
|
|
header_font = Font(bold=True, size=14)
|
|
label_font = Font(bold=True)
|
|
thin_border = Border(
|
|
left=Side(style="thin"),
|
|
right=Side(style="thin"),
|
|
top=Side(style="thin"),
|
|
bottom=Side(style="thin"),
|
|
)
|
|
|
|
# Title
|
|
ws.merge_cells("A1:H1")
|
|
ws["A1"] = "會議紀錄"
|
|
ws["A1"].font = Font(bold=True, size=16)
|
|
ws["A1"].alignment = Alignment(horizontal="center")
|
|
|
|
# Metadata section
|
|
ws["C3"] = "會議主題:"
|
|
ws["D3"] = meeting.get("subject", "")
|
|
ws["C4"] = "會議時間:"
|
|
meeting_time = meeting.get("meeting_time")
|
|
ws["D4"] = meeting_time.strftime("%Y-%m-%d %H:%M") if meeting_time and hasattr(meeting_time, 'strftime') else str(meeting_time or "")
|
|
ws["C5"] = "會議主席:"
|
|
ws["D5"] = meeting.get("chairperson", "")
|
|
ws["E4"] = "會議地點:"
|
|
ws["F4"] = meeting.get("location", "")
|
|
ws["E5"] = "記錄人員:"
|
|
ws["F5"] = meeting.get("recorder", "")
|
|
ws["C6"] = "參與人員:"
|
|
ws["D6"] = meeting.get("attendees", "")
|
|
|
|
# Meeting number and conclusions
|
|
ws["C8"] = meeting.get("meeting_number", "")
|
|
ws["C8"].font = label_font
|
|
if conclusions:
|
|
conclusion_texts = [c.get("content", "") for c in conclusions]
|
|
ws["D8"] = "\n".join(conclusion_texts)
|
|
ws["D8"].alignment = Alignment(wrap_text=True, vertical="top")
|
|
|
|
# Action Items header
|
|
row = 9
|
|
headers = ["編號", "事項內容", "", "負責人", "預計完成日期", "執行現況"]
|
|
cols = ["C", "D", "E", "F", "G", "H"]
|
|
for col, header in zip(cols, headers):
|
|
ws[f"{col}{row}"] = header
|
|
ws[f"{col}{row}"].font = label_font
|
|
ws[f"{col}{row}"].border = thin_border
|
|
|
|
# Action Items data
|
|
start_row = 10
|
|
for i, action in enumerate(actions):
|
|
r = start_row + i
|
|
ws[f"C{r}"] = action.get("system_code", "")
|
|
ws[f"C{r}"].border = thin_border
|
|
ws[f"D{r}"] = action.get("content", "")
|
|
ws[f"D{r}"].border = thin_border
|
|
ws[f"F{r}"] = action.get("owner", "")
|
|
ws[f"F{r}"].border = thin_border
|
|
due_date = action.get("due_date")
|
|
ws[f"G{r}"] = due_date.strftime("%Y-%m-%d") if due_date and hasattr(due_date, 'strftime') else str(due_date or "")
|
|
ws[f"G{r}"].border = thin_border
|
|
ws[f"H{r}"] = action.get("status", "")
|
|
ws[f"H{r}"].border = thin_border
|
|
|
|
# Adjust column widths
|
|
ws.column_dimensions["C"].width = 18
|
|
ws.column_dimensions["D"].width = 40
|
|
ws.column_dimensions["E"].width = 5
|
|
ws.column_dimensions["F"].width = 15
|
|
ws.column_dimensions["G"].width = 15
|
|
ws.column_dimensions["H"].width = 15
|
|
|
|
return wb
|
|
|
|
|
|
@router.get("/meetings/{meeting_id}/export")
|
|
async def export_meeting(
|
|
meeting_id: int, current_user: TokenPayload = Depends(get_current_user)
|
|
):
|
|
"""Export meeting to Excel file using template."""
|
|
with get_db_cursor() as cursor:
|
|
cursor.execute(
|
|
"SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,)
|
|
)
|
|
meeting = cursor.fetchone()
|
|
|
|
if not meeting:
|
|
raise HTTPException(status_code=404, detail="Meeting not found")
|
|
|
|
# Check access
|
|
if not is_admin(current_user):
|
|
if (
|
|
meeting["created_by"] != current_user.email
|
|
and meeting["recorder"] != current_user.email
|
|
and current_user.email not in (meeting["attendees"] or "")
|
|
):
|
|
raise HTTPException(status_code=403, detail="Access denied")
|
|
|
|
# Get conclusions
|
|
cursor.execute(
|
|
"SELECT * FROM meeting_conclusions WHERE meeting_id = %s", (meeting_id,)
|
|
)
|
|
conclusions = cursor.fetchall()
|
|
|
|
# Get action items
|
|
cursor.execute(
|
|
"SELECT * FROM meeting_action_items WHERE meeting_id = %s", (meeting_id,)
|
|
)
|
|
actions = cursor.fetchall()
|
|
|
|
# Check for template file
|
|
template_path = os.path.join(TEMPLATE_DIR, "meeting_template.xlsx")
|
|
if os.path.exists(template_path):
|
|
# Load and fill template
|
|
wb = load_workbook(template_path)
|
|
wb = fill_template_workbook(wb, meeting, conclusions, actions)
|
|
else:
|
|
# Use default template generation
|
|
wb = create_default_workbook(meeting, conclusions, actions)
|
|
|
|
# Generate filename with meeting number
|
|
meeting_number = meeting.get("meeting_number", "")
|
|
if meeting_number:
|
|
filename = f"{meeting_number}.xlsx"
|
|
else:
|
|
filename = f"meeting_{meeting.get('uuid', meeting_id)}.xlsx"
|
|
|
|
# Ensure record directory exists
|
|
os.makedirs(RECORD_DIR, exist_ok=True)
|
|
|
|
# Save to record directory
|
|
record_path = os.path.join(RECORD_DIR, filename)
|
|
wb.save(record_path)
|
|
|
|
# Save to bytes buffer for download
|
|
buffer = io.BytesIO()
|
|
wb.save(buffer)
|
|
buffer.seek(0)
|
|
|
|
return StreamingResponse(
|
|
buffer,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|