feat: Excel template export with meeting number auto-generation
- 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>
This commit is contained in:
@@ -96,6 +96,7 @@ class MeetingUpdate(BaseModel):
|
||||
class MeetingResponse(BaseModel):
|
||||
meeting_id: int
|
||||
uuid: str
|
||||
meeting_number: Optional[str] = None
|
||||
subject: str
|
||||
meeting_time: datetime
|
||||
location: Optional[str] = None
|
||||
|
||||
@@ -11,11 +11,67 @@ from .auth import get_current_user, is_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "..", "templates")
|
||||
# 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."""
|
||||
"""Create Excel workbook with meeting data (fallback when no template)."""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Meeting Record"
|
||||
@@ -31,77 +87,66 @@ def create_default_workbook(meeting: dict, conclusions: list, actions: list) ->
|
||||
)
|
||||
|
||||
# Title
|
||||
ws.merge_cells("A1:F1")
|
||||
ws["A1"] = "Meeting Record"
|
||||
ws.merge_cells("A1:H1")
|
||||
ws["A1"] = "會議紀錄"
|
||||
ws["A1"].font = Font(bold=True, size=16)
|
||||
ws["A1"].alignment = Alignment(horizontal="center")
|
||||
|
||||
# Metadata section
|
||||
row = 3
|
||||
metadata = [
|
||||
("Subject", meeting.get("subject", "")),
|
||||
("Date/Time", str(meeting.get("meeting_time", ""))),
|
||||
("Location", meeting.get("location", "")),
|
||||
("Chairperson", meeting.get("chairperson", "")),
|
||||
("Recorder", meeting.get("recorder", "")),
|
||||
("Attendees", meeting.get("attendees", "")),
|
||||
]
|
||||
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", "")
|
||||
|
||||
for label, value in metadata:
|
||||
ws[f"A{row}"] = label
|
||||
ws[f"A{row}"].font = label_font
|
||||
ws.merge_cells(f"B{row}:F{row}")
|
||||
ws[f"B{row}"] = value
|
||||
row += 1
|
||||
# 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")
|
||||
|
||||
# Conclusions section
|
||||
row += 1
|
||||
ws.merge_cells(f"A{row}:F{row}")
|
||||
ws[f"A{row}"] = "Conclusions"
|
||||
ws[f"A{row}"].font = header_font
|
||||
row += 1
|
||||
# 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
|
||||
|
||||
ws[f"A{row}"] = "Code"
|
||||
ws[f"B{row}"] = "Content"
|
||||
ws[f"A{row}"].font = label_font
|
||||
ws[f"B{row}"].font = label_font
|
||||
row += 1
|
||||
|
||||
for c in conclusions:
|
||||
ws[f"A{row}"] = c.get("system_code", "")
|
||||
ws.merge_cells(f"B{row}:F{row}")
|
||||
ws[f"B{row}"] = c.get("content", "")
|
||||
row += 1
|
||||
|
||||
# Action Items section
|
||||
row += 1
|
||||
ws.merge_cells(f"A{row}:F{row}")
|
||||
ws[f"A{row}"] = "Action Items"
|
||||
ws[f"A{row}"].font = header_font
|
||||
row += 1
|
||||
|
||||
headers = ["Code", "Content", "Owner", "Due Date", "Status"]
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=row, column=col, value=header)
|
||||
cell.font = label_font
|
||||
cell.border = thin_border
|
||||
row += 1
|
||||
|
||||
for a in actions:
|
||||
ws.cell(row=row, column=1, value=a.get("system_code", "")).border = thin_border
|
||||
ws.cell(row=row, column=2, value=a.get("content", "")).border = thin_border
|
||||
ws.cell(row=row, column=3, value=a.get("owner", "")).border = thin_border
|
||||
ws.cell(row=row, column=4, value=str(a.get("due_date", "") or "")).border = thin_border
|
||||
ws.cell(row=row, column=5, value=a.get("status", "")).border = thin_border
|
||||
row += 1
|
||||
# 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["A"].width = 18
|
||||
ws.column_dimensions["B"].width = 40
|
||||
ws.column_dimensions["C"].width = 15
|
||||
ws.column_dimensions["D"].width = 12
|
||||
ws.column_dimensions["E"].width = 12
|
||||
ws.column_dimensions["F"].width = 12
|
||||
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
|
||||
|
||||
@@ -110,7 +155,7 @@ def create_default_workbook(meeting: dict, conclusions: list, actions: list) ->
|
||||
async def export_meeting(
|
||||
meeting_id: int, current_user: TokenPayload = Depends(get_current_user)
|
||||
):
|
||||
"""Export meeting to Excel file."""
|
||||
"""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,)
|
||||
@@ -141,35 +186,35 @@ async def export_meeting(
|
||||
)
|
||||
actions = cursor.fetchall()
|
||||
|
||||
# Check for custom template
|
||||
template_path = os.path.join(TEMPLATE_DIR, "template.xlsx")
|
||||
# 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)
|
||||
ws = wb.active
|
||||
|
||||
# Replace placeholders
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
if cell.value and isinstance(cell.value, str):
|
||||
cell.value = (
|
||||
cell.value.replace("{{subject}}", meeting.get("subject", ""))
|
||||
.replace("{{time}}", str(meeting.get("meeting_time", "")))
|
||||
.replace("{{location}}", meeting.get("location", ""))
|
||||
.replace("{{chair}}", meeting.get("chairperson", ""))
|
||||
.replace("{{recorder}}", meeting.get("recorder", ""))
|
||||
.replace("{{attendees}}", meeting.get("attendees", ""))
|
||||
)
|
||||
wb = fill_template_workbook(wb, meeting, conclusions, actions)
|
||||
else:
|
||||
# Use default template
|
||||
# Use default template generation
|
||||
wb = create_default_workbook(meeting, conclusions, actions)
|
||||
|
||||
# Save to bytes buffer
|
||||
# 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)
|
||||
|
||||
filename = f"meeting_{meeting.get('uuid', meeting_id)}.xlsx"
|
||||
|
||||
return StreamingResponse(
|
||||
buffer,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
|
||||
@@ -28,21 +28,39 @@ def generate_system_code(prefix: str, meeting_date: date, sequence: int) -> str:
|
||||
def get_next_sequence(cursor, prefix: str, date_str: str) -> int:
|
||||
"""Get next sequence number for a given prefix and date."""
|
||||
pattern = f"{prefix}-{date_str}-%"
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT system_code FROM meeting_conclusions WHERE system_code LIKE %s
|
||||
UNION
|
||||
SELECT system_code FROM meeting_action_items WHERE system_code LIKE %s
|
||||
ORDER BY system_code DESC LIMIT 1
|
||||
""",
|
||||
(pattern, pattern),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
last_code = result["system_code"]
|
||||
last_seq = int(last_code.split("-")[-1])
|
||||
return last_seq + 1
|
||||
return 1
|
||||
|
||||
if prefix == "M":
|
||||
# For meeting numbers, search in meeting_records table
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT meeting_number FROM meeting_records WHERE meeting_number LIKE %s
|
||||
ORDER BY meeting_number DESC LIMIT 1
|
||||
""",
|
||||
(pattern,),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if result and result.get("meeting_number"):
|
||||
last_code = result["meeting_number"]
|
||||
last_seq = int(last_code.split("-")[-1])
|
||||
return last_seq + 1
|
||||
return 1
|
||||
else:
|
||||
# For conclusions (C) and action items (A)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT system_code FROM meeting_conclusions WHERE system_code LIKE %s
|
||||
UNION
|
||||
SELECT system_code FROM meeting_action_items WHERE system_code LIKE %s
|
||||
ORDER BY system_code DESC LIMIT 1
|
||||
""",
|
||||
(pattern, pattern),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
last_code = result["system_code"]
|
||||
last_seq = int(last_code.split("-")[-1])
|
||||
return last_seq + 1
|
||||
return 1
|
||||
|
||||
|
||||
@router.post("/meetings", response_model=MeetingResponse)
|
||||
@@ -56,15 +74,20 @@ async def create_meeting(
|
||||
date_str = meeting_date.strftime("%Y%m%d")
|
||||
|
||||
with get_db_cursor(commit=True) as cursor:
|
||||
# Generate meeting number
|
||||
meeting_seq = get_next_sequence(cursor, "M", date_str)
|
||||
meeting_number = generate_system_code("M", meeting_date, meeting_seq)
|
||||
|
||||
# Insert meeting record
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO meeting_records
|
||||
(uuid, subject, meeting_time, location, chairperson, recorder, attendees, transcript_blob, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
(uuid, meeting_number, subject, meeting_time, location, chairperson, recorder, attendees, transcript_blob, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
meeting_uuid,
|
||||
meeting_number,
|
||||
meeting.subject,
|
||||
meeting.meeting_time,
|
||||
meeting.location,
|
||||
@@ -133,6 +156,7 @@ async def create_meeting(
|
||||
return MeetingResponse(
|
||||
meeting_id=record["meeting_id"],
|
||||
uuid=record["uuid"],
|
||||
meeting_number=record.get("meeting_number"),
|
||||
subject=record["subject"],
|
||||
meeting_time=record["meeting_time"],
|
||||
location=record["location"],
|
||||
@@ -215,6 +239,7 @@ async def get_meeting(
|
||||
return MeetingResponse(
|
||||
meeting_id=record["meeting_id"],
|
||||
uuid=record["uuid"],
|
||||
meeting_number=record.get("meeting_number"),
|
||||
subject=record["subject"],
|
||||
meeting_time=record["meeting_time"],
|
||||
location=record["location"],
|
||||
|
||||
9
backend/migrations/001_add_meeting_number.sql
Normal file
9
backend/migrations/001_add_meeting_number.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Migration: Add meeting_number column to meeting_records table
|
||||
-- Date: 2025-12-11
|
||||
-- Description: Add meeting_number field for M-YYYYMMDD-XX format
|
||||
|
||||
ALTER TABLE meeting_records
|
||||
ADD COLUMN meeting_number VARCHAR(20) NULL AFTER uuid;
|
||||
|
||||
-- Create index for faster lookups
|
||||
CREATE INDEX idx_meeting_number ON meeting_records(meeting_number);
|
||||
BIN
backend/template/meeting_template.xlsx
Normal file
BIN
backend/template/meeting_template.xlsx
Normal file
Binary file not shown.
119
backend/tests/test_excel_export.py
Normal file
119
backend/tests/test_excel_export.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Test Excel export functionality with template filling."""
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, date
|
||||
from openpyxl import load_workbook
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.routers.export import fill_template_workbook, create_default_workbook, TEMPLATE_DIR, RECORD_DIR
|
||||
|
||||
|
||||
def test_excel_export():
|
||||
"""Test Excel generation with mock data."""
|
||||
|
||||
# Mock meeting data
|
||||
meeting = {
|
||||
"meeting_id": 1,
|
||||
"uuid": "test-uuid-123",
|
||||
"meeting_number": "M-20251211-01",
|
||||
"subject": "專案進度討論會議",
|
||||
"meeting_time": datetime(2025, 12, 11, 14, 30),
|
||||
"location": "會議室A",
|
||||
"chairperson": "王經理",
|
||||
"recorder": "李小明",
|
||||
"attendees": "張三, 李四, 王五",
|
||||
"created_by": "test@example.com",
|
||||
}
|
||||
|
||||
# Mock conclusions
|
||||
conclusions = [
|
||||
{"conclusion_id": 1, "content": "確認專案時程延後兩週", "system_code": "C-20251211-01"},
|
||||
{"conclusion_id": 2, "content": "同意增加測試人力", "system_code": "C-20251211-02"},
|
||||
{"conclusion_id": 3, "content": "下次會議改為線上進行", "system_code": "C-20251211-03"},
|
||||
]
|
||||
|
||||
# Mock action items
|
||||
actions = [
|
||||
{
|
||||
"action_id": 1,
|
||||
"content": "更新專案時程表",
|
||||
"owner": "張三",
|
||||
"due_date": date(2025, 12, 15),
|
||||
"status": "Open",
|
||||
"system_code": "A-20251211-01",
|
||||
},
|
||||
{
|
||||
"action_id": 2,
|
||||
"content": "聯繫人資部門增聘測試人員",
|
||||
"owner": "李四",
|
||||
"due_date": date(2025, 12, 20),
|
||||
"status": "In Progress",
|
||||
"system_code": "A-20251211-02",
|
||||
},
|
||||
{
|
||||
"action_id": 3,
|
||||
"content": "準備線上會議設備",
|
||||
"owner": "王五",
|
||||
"due_date": None,
|
||||
"status": "Open",
|
||||
"system_code": "A-20251211-03",
|
||||
},
|
||||
]
|
||||
|
||||
# Check paths
|
||||
template_path = os.path.join(TEMPLATE_DIR, "meeting_template.xlsx")
|
||||
print(f"Template directory: {TEMPLATE_DIR}")
|
||||
print(f"Record directory: {RECORD_DIR}")
|
||||
print(f"Template exists: {os.path.exists(template_path)}")
|
||||
|
||||
# Ensure record directory exists
|
||||
os.makedirs(RECORD_DIR, exist_ok=True)
|
||||
|
||||
# Generate filename with meeting number
|
||||
meeting_number = meeting.get("meeting_number", "")
|
||||
filename = f"{meeting_number}.xlsx" if meeting_number else f"meeting_{meeting['uuid']}.xlsx"
|
||||
output_path = os.path.join(RECORD_DIR, filename)
|
||||
|
||||
# Test with template if exists
|
||||
if os.path.exists(template_path):
|
||||
print("\n=== Testing with template ===")
|
||||
wb = load_workbook(template_path)
|
||||
wb = fill_template_workbook(wb, meeting, conclusions, actions)
|
||||
wb.save(output_path)
|
||||
print(f"Saved to: {output_path}")
|
||||
|
||||
# Verify cell values
|
||||
ws = wb.active
|
||||
print("\n--- Verification ---")
|
||||
print(f"D3 (Subject): {ws['D3'].value}")
|
||||
print(f"D4 (Time): {ws['D4'].value}")
|
||||
print(f"D5 (Chair): {ws['D5'].value}")
|
||||
print(f"F4 (Location): {ws['F4'].value}")
|
||||
print(f"F5 (Recorder): {ws['F5'].value}")
|
||||
print(f"D6 (Attendees): {ws['D6'].value}")
|
||||
print(f"C8 (Meeting Number): {ws['C8'].value}")
|
||||
print(f"D8 (Conclusions): {ws['D8'].value}")
|
||||
print(f"\nAction Items:")
|
||||
for i in range(3):
|
||||
row = 10 + i
|
||||
print(f" Row {row}: C={ws[f'C{row}'].value}, D={ws[f'D{row}'].value}, F={ws[f'F{row}'].value}, G={ws[f'G{row}'].value}, H={ws[f'H{row}'].value}")
|
||||
else:
|
||||
print("\n=== Template not found, using default generation ===")
|
||||
wb = create_default_workbook(meeting, conclusions, actions)
|
||||
wb.save(output_path)
|
||||
print(f"Saved to: {output_path}")
|
||||
|
||||
print(f"\n✅ Test completed! File saved to: {output_path}")
|
||||
|
||||
# List files in record directory
|
||||
print(f"\n--- Files in record directory ---")
|
||||
for f in os.listdir(RECORD_DIR):
|
||||
print(f" {f}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_excel_export()
|
||||
Reference in New Issue
Block a user