diff --git a/backend/main.py b/backend/main.py index efe67da..a259796 100644 --- a/backend/main.py +++ b/backend/main.py @@ -267,13 +267,13 @@ def read_excel_file(section: str): path = get_excel_path(section) if not os.path.exists(path): return None, [] - + wb = openpyxl.load_workbook(path, data_only=True) ws = wb.active - + headers = [cell.value for cell in ws[1]] rows = list(ws.iter_rows(min_row=2, values_only=True)) - + return headers, rows # ============ Upload Excel (Replace entire file) ============ @@ -285,11 +285,11 @@ async def delete_uploaded_file( """Delete the stored Excel file for a section""" if section not in ["attendance", "accident"]: raise HTTPException(status_code=400, detail="Invalid section") - + path = get_excel_path(section) if os.path.exists(path): os.remove(path) - + return {"message": f"{section} file deleted"} @app.post("/api/upload/{section}") @@ -299,7 +299,7 @@ async def upload_excel( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Upload Excel file — saves file AND imports rows into SQL table.""" + """Upload Excel file - saves file AND imports rows into SQL table.""" if section not in ["attendance", "accident"]: raise HTTPException(status_code=400, detail="Invalid section") @@ -538,13 +538,13 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]: roster_path = get_excel_path("roster") if not os.path.exists(roster_path): return None - + import openpyxl wb = openpyxl.load_workbook(roster_path, data_only=True) ws = wb.active - + headers = [cell.value for cell in ws[1]] - + # Find column indices shift_col = None ph_col = None @@ -553,10 +553,10 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]: shift_col = i elif h == 'public holiday': ph_col = i - + if shift_col is None: return None - + # Weekday mapping weekday_map = { 'Monday': '星期一', 'Tuesday': '星期二', 'Wednesday': '星期三', @@ -565,18 +565,18 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]: '星期三': '星期三', '星期四': '星期四', '星期五': '星期五', '星期六': '星期六', '星期日': '星期日' } - + day_col_map = { '星期一': None, '星期二': None, '星期三': None, '星期四': None, '星期五': None, '星期六': None, '星期日': None } - + for i, h in enumerate(headers): if h in day_col_map: day_col_map[h] = i - + target_day = weekday_map.get(weekday, weekday) - + # Find the shift row for row in ws.iter_rows(min_row=2, values_only=True): if row[shift_col] == shift_code: @@ -596,7 +596,7 @@ def get_shift_schedule_full(shift_code: str, weekday: str) -> Optional[tuple]: return (start_time, end_time, public_holiday) except: pass - + return None def parse_datetime(time_val) -> Optional[datetime]: @@ -615,7 +615,7 @@ def parse_datetime(time_val) -> Optional[datetime]: def calculate_attendance_status(check_in, check_out, shift_start, shift_end): """Calculate attendance status: late_minutes, early_minutes, ot_minutes, is_missing - + Returns: { "is_missing": bool, "late_minutes": int, @@ -641,7 +641,7 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end): "actual_in": "-", "actual_out": "-" } - + # Check for missing punch if not check_in or not check_out: result["is_missing"] = True @@ -652,20 +652,20 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end): if check_out: result["actual_out"] = check_out.strftime("%H:%M") if isinstance(check_out, datetime) else str(check_out) return result - + # Parse check-in/out times check_in_dt = parse_datetime(check_in) check_out_dt = parse_datetime(check_out) - + if not check_in_dt or not check_out_dt: result["is_missing"] = True result["status_code"] = "missing" result["status_text"] = "缺勤" return result - + result["actual_in"] = check_in_dt.strftime("%H:%M") result["actual_out"] = check_out_dt.strftime("%H:%M") - + # Skip if invalid times (Excel default "0" = 18:00:00 exactly) # Only treat second-precise 18:00:00 as the default, not 18:00:03 (real swipe) if (check_in_dt.hour == 18 and check_in_dt.minute == 0 and check_in_dt.second == 0 @@ -674,7 +674,7 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end): result["status_code"] = "missing" result["status_text"] = "缺勤" return result - + # Detect same check-in/out (exact same second = forgotten swipe) if check_in_dt and check_out_dt: if check_in_dt == check_out_dt: @@ -686,14 +686,14 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end): if not shift_start or not shift_end: # No roster data, can't calculate return result - + # Calculate late minutes check_in_time = check_in_dt.time() if check_in_time > shift_start: diff = (datetime.combine(check_in_dt.date(), check_in_time) - datetime.combine(check_in_dt.date(), shift_start)).total_seconds() / 60 result["late_minutes"] = round(diff) - + # Calculate early minutes (leaving before scheduled end) check_out_time = check_out_dt.time() if shift_end > shift_start: @@ -703,11 +703,11 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end): expected_end_dt = datetime.combine(check_out_dt.date() + timedelta(days=1), shift_end) else: expected_end_dt = datetime.combine(check_out_dt.date(), shift_end) - + if check_out_dt < expected_end_dt: diff_seconds = (expected_end_dt - check_out_dt).total_seconds() result["early_minutes"] = round(diff_seconds / 60) if diff_seconds >= 30 else 0 - + # Calculate OT minutes (leaving after scheduled end) if check_out_dt > expected_end_dt: diff_seconds = (check_out_dt - expected_end_dt).total_seconds() @@ -716,7 +716,7 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end): result["ot_minutes"] = round(diff_seconds / 60) else: result["ot_minutes"] = 0 - + # Determine status code and text status_parts = [] if result["late_minutes"] > 0: @@ -725,7 +725,7 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end): status_parts.append(f"早退{result['early_minutes']}分") if result["ot_minutes"] > 0: status_parts.append(f"OT{result['ot_minutes']}分") - + if result["late_minutes"] > 0 and result["early_minutes"] > 0: result["status_code"] = "late_early" elif result["late_minutes"] > 0 and result["ot_minutes"] > 0: @@ -740,8 +740,22 @@ def calculate_attendance_status(check_in, check_out, shift_start, shift_end): result["status_code"] = "ot" else: result["status_code"] = "normal" - + result["status_text"] = " / ".join(status_parts) if status_parts else "正常" + + # If late / early / OT > 30 min → mark as abnormal (override after status_text is set) + if result["late_minutes"] > 30 or result["early_minutes"] > 30 or result["ot_minutes"] > 30: + result["status_code"] = "abnormal" + if result["late_minutes"] > 30: + result["status_text"] = f"異常-遲到{result['late_minutes']}分" + elif result["early_minutes"] > 30: + result["status_text"] = f"異常-早退{result['early_minutes']}分" + else: + result["status_text"] = f"異常-OT{result['ot_minutes']}分" + result["late_minutes"] = 0 + result["early_minutes"] = 0 + result["ot_minutes"] = 0 + return result @@ -751,9 +765,9 @@ async def get_lateness_stats(current_user: User = Depends(get_current_user)): headers, rows = read_excel_file("attendance") if headers is None or not rows: return {"stats": [], "total_late": 0, "total_early": 0, "total_ot": 0, "total_missing": 0} - + staff_col = None; date_col = None; week_col = None; checkin_col = None; checkout_col = None; shift_col = None - + for i, h in enumerate(headers): h_lower = str(h).lower() if h else '' if 'staff' in h_lower or '員工' in h_lower or 'name' in h_lower: @@ -768,31 +782,31 @@ async def get_lateness_stats(current_user: User = Depends(get_current_user)): checkout_col = i elif '班次' in h_lower: shift_col = i - + if staff_col is None or checkin_col is None or shift_col is None: return {"error": "Missing required columns", "headers": headers} - + employee_stats = {} totals = {"late": 0, "early": 0, "ot": 0, "missing": 0} - + for row in rows: staff = row[staff_col]; check_in = row[checkin_col] check_out = row[checkout_col] if checkout_col is not None else None shift_code = row[shift_col]; week_day = row[week_col] if week_col is not None else None - + if not staff or not shift_code: continue - + shift_times = get_shift_schedule_full(shift_code, week_day or '') shift_start = shift_times[0] if shift_times else None shift_end = shift_times[1] if shift_times else None - + status = calculate_attendance_status(check_in, check_out, shift_start, shift_end) - + staff_key = str(staff) if staff_key not in employee_stats: employee_stats[staff_key] = {"name": staff, "shift": shift_code, "late_count": 0, "late_minutes": 0, "early_count": 0, "early_minutes": 0, "ot_count": 0, "ot_minutes": 0, "missing_count": 0} - + if status["is_missing"]: employee_stats[staff_key]["missing_count"] += 1 totals["missing"] += 1 @@ -809,7 +823,7 @@ async def get_lateness_stats(current_user: User = Depends(get_current_user)): employee_stats[staff_key]["ot_count"] += 1 employee_stats[staff_key]["ot_minutes"] += status["ot_minutes"] totals["ot"] += status["ot_minutes"] - + stats = sorted(employee_stats.values(), key=lambda x: x["late_minutes"], reverse=True) return { "stats": stats, @@ -831,9 +845,9 @@ async def get_lateness_records( headers, rows = read_excel_file("attendance") if headers is None or not rows: return [] - + staff_col = None; date_col = None; week_col = None; checkin_col = None; checkout_col = None; shift_col = None - + for i, h in enumerate(headers): h_lower = str(h).lower() if h else '' if 'staff' in h_lower or '員工' in h_lower or 'name' in h_lower: @@ -848,7 +862,7 @@ async def get_lateness_records( checkout_col = i elif '班次' in h_lower: shift_col = i - + records = [] for row in rows: staff_val = row[staff_col] if staff_col is not None else None @@ -857,14 +871,14 @@ async def get_lateness_records( shift_code = row[shift_col] if shift_col is not None else None week_day = row[week_col] if week_col is not None else None date_val = row[date_col] if date_col is not None else None - + if staff_val and shift_code: shift_times = get_shift_schedule_full(shift_code, week_day or '') shift_start = shift_times[0] if shift_times else None shift_end = shift_times[1] if shift_times else None - + status = calculate_attendance_status(check_in, check_out, shift_start, shift_end) - + record = { "staff": staff_val, "date": str(date_val)[:10] if date_val else None, "weekday": week_day, "shift": shift_code, @@ -873,10 +887,10 @@ async def get_lateness_records( "actual_in": status["actual_in"], "actual_out": status["actual_out"], "late_minutes": status["late_minutes"], "early_minutes": status["early_minutes"], "ot_minutes": status["ot_minutes"] } - + if staff is None or record["staff"] == staff: records.append(record) - + return records @app.delete("/api/roster") @@ -893,7 +907,7 @@ async def get_roster_info(current_user: User = Depends(get_current_user)): path = get_excel_path("roster") if not os.path.exists(path): return {"uploaded": False, "rows": 0, "columns": 0, "headers": []} - + headers, rows = read_excel_file("roster") return { "uploaded": True, @@ -910,15 +924,15 @@ async def upload_roster( """Upload Roster Excel file""" if not file.filename: raise HTTPException(status_code=400, detail="No filename provided") - + path = get_excel_path("roster") contents = await file.read() - + with open(path, "wb") as f: f.write(contents) - + headers, rows = read_excel_file("roster") - + return { "message": "Roster file uploaded successfully", "filename": file.filename, @@ -941,7 +955,7 @@ async def get_shifts(current_user: User = Depends(get_current_user)): headers, rows = read_excel_file("roster") if headers is None: return [] - + # Find columns shift_col = None desc_col = None @@ -950,7 +964,7 @@ async def get_shifts(current_user: User = Depends(get_current_user)): shift_col = i elif h and str(h).lower() in ['描述', 'description', 'desc']: desc_col = i - + shifts = [] for row in rows: shift_code = row[shift_col] if shift_col is not None else None @@ -959,7 +973,7 @@ async def get_shifts(current_user: User = Depends(get_current_user)): "code": shift_code, "description": row[desc_col] if desc_col is not None else "" }) - + return shifts @app.get("/api/roster/assignments") @@ -969,7 +983,7 @@ async def get_assignments(current_user: User = Depends(get_current_user)): headers, rows = read_excel_file("attendance") if headers is None: return {} - + # Find 班次 column shift_col = None emp_col = None @@ -978,7 +992,7 @@ async def get_assignments(current_user: User = Depends(get_current_user)): shift_col = i elif h and any(kw in str(h).lower() for kw in ['員工', 'employee', 'name', '員工名稱']): emp_col = i - + assignments = {} if shift_col is not None: for row in rows: @@ -986,7 +1000,7 @@ async def get_assignments(current_user: User = Depends(get_current_user)): shift = row[shift_col] if shift_col is not None and shift_col < len(row) else None if emp and shift: assignments[str(emp)] = str(shift) - + return assignments @app.post("/api/roster/assign") @@ -997,27 +1011,27 @@ async def assign_shift( ): """Assign employee to shift""" assignments_path = os.path.join(DATA_DIR, "shift_assignments.json") - + # Load existing if os.path.exists(assignments_path): with open(assignments_path, "r") as f: assignments = json.load(f) else: assignments = {} - + assignments[employee] = shift - + return RedirectResponse(url="/") @app.get("/api/{section}/info") async def get_section_info(section: str): """Get info about stored Excel file""" if section not in ["attendance", "accident"]: raise HTTPException(status_code=400, detail="Invalid section") - + headers, rows = read_excel_file(section) if headers is None: return {"uploaded": False, "rows": 0, "columns": 0, "headers": []} - + return { "uploaded": True, "rows": len(rows), @@ -1595,14 +1609,14 @@ async def refresh_holidays_from_gov_hk( current_user: User = Depends(get_current_admin), ): """Refresh holidays from internal static list (sourced from gov.hk). - + Admin endpoint. Adds new dates, updates existing ones (only if source=gov_hk). Manually edited holidays are NOT overwritten. """ from parsers import get_all_holidays target_years = [int(y) for y in years.split(",")] if years else [2025, 2026, 2027] all_holidays = [h for h in get_all_holidays() if h[0].year in target_years] - + added = 0 updated = 0 skipped = 0 @@ -1626,7 +1640,7 @@ async def refresh_holidays_from_gov_hk( region="HK", is_mandatory=is_mandatory, source="gov_hk", )) added += 1 - + db.commit() return { "added": added, "updated": updated, "skipped": skipped, @@ -1677,7 +1691,7 @@ async def create_leave( raise HTTPException(status_code=400, detail="leave_type must be SL / CL / AL") if body.hours < 0 or body.hours > 24: raise HTTPException(status_code=400, detail="hours must be 0-24") - + lv = LeaveRecord( employee_name=body.employee_name, employee_id=body.employee_id, @@ -1749,14 +1763,14 @@ async def upload_leaves( current_user: User = Depends(get_current_user), ): """Bulk upload leave records from CSV or Excel. - + CSV columns: employee_name,date,leave_type,hours,reason,approved_by Excel: same columns (row 1 = header) """ from io import BytesIO content = await file.read() fname = file.filename.lower() - + rows = [] if fname.endswith(".csv"): import csv, io as _io @@ -1792,7 +1806,7 @@ async def upload_leaves( }) else: raise HTTPException(status_code=400, detail="File must be CSV or Excel") - + added = 0 updated = 0 skipped = 0 @@ -1807,19 +1821,19 @@ async def upload_leaves( errors.append(f"Row {i+2}: invalid leave_type") skipped += 1 continue - + from datetime import datetime as _dt if isinstance(r["date"], str): d = _dt.strptime(r["date"], "%Y-%m-%d").date() else: d = r["date"] - + existing = db.query(LeaveRecord).filter( LeaveRecord.employee_name == r["employee_name"], LeaveRecord.date == d, LeaveRecord.leave_type == r["leave_type"], ).first() - + if existing: if existing.last_edited_at is not None: # Was manually edited - protect from overwrite @@ -1833,7 +1847,7 @@ async def upload_leaves( existing.source = "csv_upload" updated += 1 continue - + db.add(LeaveRecord( employee_name=r["employee_name"], date=d, @@ -1848,7 +1862,7 @@ async def upload_leaves( except Exception as e: errors.append(f"Row {i+2}: {str(e)}") skipped += 1 - + db.commit() return { "added": added, "updated": updated, "skipped": skipped, @@ -2013,16 +2027,16 @@ async def list_records( """List records from stored Excel file""" if section not in ["attendance", "accident"]: raise HTTPException(status_code=400, detail="Invalid section") - + headers, rows = read_excel_file(section) if headers is None: return {"total": 0, "page": page, "page_size": page_size, "data": []} - + # Pagination start = (page - 1) * page_size end = start + page_size page_rows = rows[start:end] - + # Build response with row number as id data = [] for idx, row in enumerate(page_rows, start=start + 1): @@ -2031,7 +2045,7 @@ async def list_records( if header: record[str(header)] = row[i] if i < len(row) else None data.append(record) - + return { "total": len(rows), "page": page, @@ -2045,33 +2059,33 @@ async def dashboard(section: str, current_user: User = Depends(get_current_user) """Get dashboard stats from stored Excel file""" if section not in ["attendance", "accident"]: raise HTTPException(status_code=400, detail="Invalid section") - + headers, rows = read_excel_file(section) if headers is None or not rows: return {"total": 0, "this_month": 0, "by_status": {}, "by_department": {}, "trend": []} - + today = date.today() month_start = date(today.year, today.month, 1) - + # Find date column (look for common date column names) date_col_idx = None for i, h in enumerate(headers): if h and any(kw in str(h).lower() for kw in ['date', '日期', '時間']): date_col_idx = i break - + # Find department column dept_col_idx = None for i, h in enumerate(headers): if h and any(kw in str(h).lower() for kw in ['dept', '部門', '部門名稱']): dept_col_idx = i break - + total = len(rows) this_month = 0 by_department = {} trend = [] - + for row in rows: # Count this month if date_col_idx is not None and date_col_idx < len(row): @@ -2091,12 +2105,12 @@ async def dashboard(section: str, current_user: User = Depends(get_current_user) pass if row_date and row_date >= month_start: this_month += 1 - + # Count by department if dept_col_idx is not None and dept_col_idx < len(row): dept = str(row[dept_col_idx] or 'Unknown') by_department[dept] = by_department.get(dept, 0) + 1 - + return { "total": total, "today": 0, @@ -2121,7 +2135,7 @@ async def _import_accident_to_db(db: Session, headers, rows, user_id: int) -> di Col 8 涉及的患者或相關人員 -> patient_or_staff Col 9 事件描述 -> long_description Col 10 如有事件相關照片可提供-> incident_photos - Col 11 處理情況/已採取的行動-> action_taken + Col 11 處理情況/已採取的行動-> action_taken Col 12 需要管理層介入的事項 -> needs_escalation Col 13 第 12 欄 -> (skipped) Col 14 分數 -> incident_score @@ -2839,7 +2853,7 @@ async def accident_export_excel( # User types case_no in C5 of 個案查詢 sheet. # xlsxwriter requires single quotes around sheet names containing non-ASCII. # Use proper xlsxwriter hyperlink element (write_url with internal:Sheet!Cell). - # HYPERLINK formula would cache value 0 — write_url sets display text directly. + # HYPERLINK formula would cache value 0 - write_url sets display text directly. overview.write_url(row_n, 6, f"internal:個案查詢!A1", link_fmt, @@ -2847,7 +2861,7 @@ async def accident_export_excel( tip=f"跳到「個案查詢」輸入個案 ID {r.id}") overview.set_row(row_n, 18) - # ============ Charts block — 統計圖表 (Sheet 1 總覽 右側 columns I-N) ============ + # ============ Charts block - 統計圖表 (Sheet 1 總覽 右側 columns I-N) ============ # 4 charts: Severity Pie, Severity Column, Department Bar TOP 10, Monthly Trend Line # Position: I column (right of KPI + 個案索引 blocks), so no overlap with case index. @@ -2957,7 +2971,7 @@ async def accident_export_excel( # ============ Sheet 3 (built FIRST so detail-VLOOKUP can reference it): 明細 ============ # Build it now so that 個案查詢 formulas can resolve its range properly. # However, xlsxwriter's defined_name allows forward reference; formula text is - # what matters at runtime — even if the sheet is added later, the formula string + # what matters at runtime - even if the sheet is added later, the formula string # '明細!A:O' will be valid when Excel opens the file. # We define 明細 here so that the formula range 明細!A2:O{max_row} resolves. # But xlsxwriter requires sheets to be added in the order they're defined. @@ -3017,12 +3031,12 @@ async def accident_export_excel( "align": "center", "num_format": "@"}) # B5 = label only (NOT merged with C5). Otherwise user-typed text goes to # B5 (top-left of merge) instead of C5 where the formula references. - detail.write("B5", "輸入個案 ID:", input_label_fmt) + detail.write("B5", "輸入個案 ID:", input_label_fmt) # C5 = case_no input cell (NOT merged) detail.write_string("C5", "", text_input_fmt) # Hint detail.merge_range("B6:C6", - "提示:個案 ID 喺「總覽」個案索引或「明細」表嘅 id 欄。輸入後下面自動顯示。", + "提示:個案 ID 喺「總覽」個案索引或「明細」表嘅 id 欄。輸入後下面自動顯示。", subtitle_fmt) # Detail rows: each pulls from 明細! via VLOOKUP keyed on id (col A) @@ -3061,7 +3075,7 @@ async def accident_export_excel( col_sev = col_index_lookup.get("severity", 6) col_emp = col_index_lookup.get("employee_name", 3) col_desc = col_index_lookup.get("description", 5) - # Use INDIRECT or simply use cell reference for ID — but ID varies per row. + # Use INDIRECT or simply use cell reference for ID - but ID varies per row. # Easier: each row's ID is in B; build formula with that. # VLOOKUP needs lookup_value, table, col_index, FALSE. # table 明細!$A:$O. @@ -3108,13 +3122,13 @@ async def accident_export_excel( compare_start_row = list_header_row + n_list_rows + 3 detail.write(f"B{compare_start_row}", "兩個個案 並列比較 Side-by-Side", section_fmt) detail.write(f"B{compare_start_row + 1}", - "輸入兩個個案 ID (左個案 A, 右個案 B),對比每個欄位。空白表示無資料。", + "輸入兩個個案 ID (左個案 A, 右個案 B),對比每個欄位。空白表示無資料。", subtitle_fmt) cmp_label_row = compare_start_row + 2 - detail.write(cmp_label_row, 1, "個案 A ID:", input_label_fmt) + detail.write(cmp_label_row, 1, "個案 A ID:", input_label_fmt) detail.write_string(cmp_label_row, 2, "", text_input_fmt) - detail.write(cmp_label_row, 4, "個案 B ID:", input_label_fmt) + detail.write(cmp_label_row, 4, "個案 B ID:", input_label_fmt) detail.write_string(cmp_label_row, 5, "", text_input_fmt) detail.set_column("F:F", 50) @@ -3146,7 +3160,7 @@ async def accident_export_excel( # Hint at top detail.merge_range(f"B{cmp_data_start + len(detail_fields) + 2}:C{cmp_data_start + len(detail_fields) + 2}", - "💡 全部公式 (VLOOKUP),無需啟用巨集。Excel/Numbers 都正常運作。", + "💡 全部公式 (VLOOKUP),無需啟用巨集。Excel/Numbers 都正常運作。", subtitle_fmt) # ============ Sheet 3: 明細 ============ @@ -3266,21 +3280,21 @@ async def get_record( """Get a specific record by row number""" if section not in ["attendance", "accident"]: raise HTTPException(status_code=400, detail="Invalid section") - + headers, rows = read_excel_file(section) if headers is None: raise HTTPException(status_code=404, detail="No data file found") - + idx = row_id - 1 # Convert to 0-based index if idx < 0 or idx >= len(rows): raise HTTPException(status_code=404, detail="Record not found") - + row = rows[idx] record = {"_row": row_id} for i, header in enumerate(headers): if header: record[str(header)] = row[i] if i < len(row) else None - + return record # ============ Export ============ @@ -3411,7 +3425,7 @@ async def export_excel( earliest = min((r.date for r in rows if r.date), default=None) overview.merge_range("F7:G7", earliest.isoformat() if earliest else "-", kpi_value_fmt) - # Status distribution table — simplified 6-group (B10:D) + # Status distribution table - simplified 6-group (B10:D) overview.write("B10", "出勤狀態分佈", section_fmt) overview.write("B11", "Status", header_fmt_ov) overview.write("C11", "數量", header_fmt_ov)