deneve07 commited on
Commit
f39b348
·
verified ·
1 Parent(s): a2eaf32

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +410 -162
app.py CHANGED
@@ -12,10 +12,8 @@ DATA_FILE = "data.csv"
12
  def load_fixed_database():
13
  if os.path.exists(DATA_FILE):
14
  try:
15
- # 讀取固定的健保資料庫
16
  df = pd.read_csv(DATA_FILE)
17
  df.columns = df.columns.str.strip()
18
- # 清理欄位前後空白
19
  for col in ['成分', '劑型', '劑量', '廠商', '年度']:
20
  if col in df.columns:
21
  df[col] = df[col].astype(str).str.strip()
@@ -24,19 +22,17 @@ def load_fixed_database():
24
  print(f"讀取本地資料庫失敗: {e}")
25
  return pd.DataFrame()
26
  else:
27
- print(f"錯誤:找不到固定資料庫檔案 {DATA_FILE},請確保檔案已上傳至同目錄。")
28
  return pd.DataFrame()
29
 
30
- # 程式啟動時載入全域資料
31
  df_global = load_fixed_database()
32
  if not df_global.empty:
33
  ALL_COMPONENTS = sorted(df_global['成分'].dropna().unique())
34
- INIT_MESSAGE = f"✅ 成功自動載入固定資料庫!共 {len(df_global)} 筆數據,包含 {len(ALL_COMPONENTS)} 種成分。"
35
  else:
36
  ALL_COMPONENTS = []
37
- INIT_MESSAGE = "❌ 警告:在目錄下找到 data.csv 檔案,請將資料庫檔案上傳至 Space 檔案庫中。"
38
 
39
- # --- 2. 輔助函式:劑量排序邏輯 ---
40
  def parse_dosage_to_numeric(dose_str):
41
  if not isinstance(dose_str, str):
42
  return 0.0
@@ -48,64 +44,75 @@ def parse_dosage_to_numeric(dose_str):
48
  return val
49
  return 0.0
50
 
51
- # --- 3. 動態搜尋成分(如同 Excel 篩選功能) ---
52
  def update_component_choices(search_text):
53
  if not ALL_COMPONENTS:
54
- return gr.update(choices=[])
55
-
56
- # 修正:如果使用者沒有輸入任何關鍵字,保持空的選項不顯示,避免畫面太混亂
57
  if not search_text or search_text.strip() == "":
58
- return gr.update(choices=[])
59
-
60
- # 修正原本放在 return 後面的縮排錯誤
61
  search_text = search_text.strip().lower()
62
  filtered = [c for c in ALL_COMPONENTS if search_text in c.lower()]
63
- return gr.update(choices=filtered)
 
 
 
 
 
 
 
64
 
65
- # --- 4. 產出標準化 Excel 報表 ---
66
- def generate_standard_excel(selected_components):
 
 
 
 
 
 
 
 
 
 
67
  if df_global.empty:
68
- return None, "❌ 系統未成功載入 data.csv 資料庫,請檢查 Space 中的檔案是否存在。"
69
- if not selected_components:
70
- return None, "❌ 請至少勾選一個成分品項!"
 
 
 
 
 
 
71
 
72
- # 篩選選定的成分資料
73
- df_filtered = df_global[df_global['成分'].isin(selected_components)].copy()
74
  if df_filtered.empty:
75
- return None, "❌ 找不到相關資料!"
76
-
77
- # 自動判斷數量欄位名稱
78
  qty_col = '數量(顆)' if '數量(顆)' in df_filtered.columns else [col for col in df_filtered.columns if '數量' in col][0]
79
-
80
- # 【新增:強制將數量欄位轉換為數值型態,並移除非數字字元】
81
  df_filtered[qty_col] = df_filtered[qty_col].astype(str).str.replace(',', '').str.strip()
82
  df_filtered[qty_col] = pd.to_numeric(df_filtered[qty_col], errors='coerce').fillna(0)
83
-
84
- # 進行樞紐分析 (Pivot Table)
85
  pivot_df = df_filtered.groupby(['成分', '劑型', '劑量', '廠商', '年度'])[qty_col].sum().unstack(fill_value=0)
86
-
87
- # 確保三年欄位皆完整
88
  for year_col in ['2022年', '2023年', '2024年']:
89
  if year_col not in pivot_df.columns:
90
  pivot_df[year_col] = 0
91
 
92
  pivot_df = pivot_df.reindex(columns=['2022年', '2023年', '2024年']).reset_index()
93
-
94
- # 高階排序:劑量由小到大 -> 2024年數量由大到小
95
  pivot_df['dose_numeric'] = pivot_df['劑量'].apply(parse_dosage_to_numeric)
96
- pivot_df = pivot_df.sort_values(by=['dose_numeric', '2024年'], ascending=[True, False]).drop(columns=['dose_numeric'])
97
-
98
- # 建立全新 Excel 工作簿
 
 
 
99
  wb = Workbook()
100
  ws = wb.active
101
  ws.title = "廠商排名報表"
102
  ws.views.sheetView[0].showGridLines = True
103
-
104
- # 樣式與底色定義
105
  font_family = "微軟正黑體"
106
- header_fill = PatternFill(start_color="1F497D", end_color="1F497D", fill_type="solid") # 標題深藍
107
- subtotal_fill = PatternFill(start_color="DCE6F1", end_color="DCE6F1", fill_type="solid") # 合計淡藍
108
- total_fill = PatternFill(start_color="B8CCE4", end_color="B8CCE4", fill_type="solid") # 總計中藍
109
 
110
  header_font = Font(name=font_family, size=12, bold=True, color="FFFFFF")
111
  data_font = Font(name=font_family, size=12)
@@ -116,197 +123,438 @@ def generate_standard_excel(selected_components):
116
  right_align = Alignment(horizontal="right", vertical="center")
117
 
118
  thin_side = Side(border_style="thin", color="D9D9D9")
 
119
  cell_border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
- # 寫入標題列 (欄位依序排列,包含換行)
122
- headers = ["成分", "劑型", "劑量", "廠商", "2022年\n數量", "2023年\n數量", "2024年\n數量", "2024年\n占比(%)"]
123
- ws.append(headers)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  ws.row_dimensions[1].height = 30
125
- for col_idx, h in enumerate(headers, 1):
 
 
126
  cell = ws.cell(row=1, column=col_idx)
127
  cell.font = header_font
128
  cell.fill = header_fill
129
  cell.alignment = center_align
130
  cell.border = cell_border
131
-
132
- unique_doses = sorted(pivot_df['劑量'].unique(), key=parse_dosage_to_numeric)
 
 
 
133
 
134
  grand_total_2022 = 0
135
  grand_total_2023 = 0
136
  grand_total_2024 = 0
137
-
138
  current_row = 2
139
- # 按劑量拆分群組寫入
140
- for dose in unique_doses:
141
- dose_group = pivot_df[pivot_df['劑量'] == dose]
142
- dose_2024_sum = dose_group['2024年'].sum()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
- dose_subtotal_2022 = 0
145
- dose_subtotal_2023 = 0
146
- dose_subtotal_2024 = 0
 
147
 
148
- is_first_row = True
 
 
149
 
150
  for _, row in dose_group.iterrows():
151
- qty_2022 = row['2022年']
152
- qty_2023 = row['2023年']
153
- qty_2024 = row['2024年']
154
  ratio = (qty_2024 / dose_2024_sum) if dose_2024_sum > 0 else 0.0
155
 
156
- # 同劑量重複廠商:前三欄留白不重複顯示
157
- c_val = row['成分'] if is_first_row else ""
158
- f_val = row['劑'] if is_first_row else ""
159
- d_val = row['劑量'] if is_first_row else ""
160
 
161
  ws.append([c_val, f_val, d_val, row['廠商'], qty_2022, qty_2023, qty_2024, ratio])
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
- # 設定一般資料列格式
164
  for col_idx in range(1, 9):
165
  cell = ws.cell(row=current_row, column=col_idx)
166
  cell.font = data_font
167
  cell.border = cell_border
168
- if col_idx in [1, 2, 3]:
169
- cell.alignment = center_align
170
- elif col_idx == 4:
171
- cell.alignment = left_align
172
- elif col_idx in [5, 6, 7]:
173
- cell.alignment = right_align
174
- cell.number_format = '#,##0' # 千分位、至個位數
175
- elif col_idx == 8:
176
- cell.alignment = right_align
177
- cell.number_format = '0.0%' # 占比至小數點第一位
178
 
179
- dose_subtotal_2022 += qty_2022
180
- dose_subtotal_2023 += qty_2023
181
- dose_subtotal_2024 += qty_2024
182
  current_row += 1
183
- is_first_row = False
 
 
 
184
 
185
- # 寫入「劑量合計
186
  ws.append(["", "", f"{dose} 合計", "", dose_subtotal_2022, dose_subtotal_2023, dose_subtotal_2024, 1.0])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  for col_idx in range(1, 9):
188
  cell = ws.cell(row=current_row, column=col_idx)
189
  cell.font = bold_font
190
  cell.fill = subtotal_fill
191
- cell.border = cell_border
192
- if col_idx == 3:
193
- cell.alignment = center_align
194
- elif col_idx in [5, 6, 7]:
195
- cell.alignment = right_align
196
- cell.number_format = '#,##0'
197
- elif col_idx == 8:
198
- cell.alignment = right_align
199
- cell.number_format = '0.0%'
200
 
201
  grand_total_2022 += dose_subtotal_2022
202
  grand_total_2023 += dose_subtotal_2023
203
  grand_total_2024 += dose_subtotal_2024
204
  current_row += 1
205
-
206
- # 寫入「總計
207
- ws.append(["", "", "總計", "", grand_total_2022, grand_total_2023, grand_total_2024, 1.0])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  for col_idx in range(1, 9):
209
  cell = ws.cell(row=current_row, column=col_idx)
210
  cell.font = bold_font
211
  cell.fill = total_fill
212
  cell.border = cell_border
213
- if col_idx == 3:
214
- cell.alignment = center_align
215
- elif col_idx in [5, 6, 7]:
216
- cell.alignment = right_align
217
- cell.number_format = '#,##0'
218
- elif col_idx == 8:
219
- cell.alignment = right_align
220
- cell.number_format = '0.0%'
221
-
222
- # 自動調整欄寬
223
  for col in ws.columns:
224
- max_len = 0
225
  col_letter = get_column_letter(col[0].column)
 
 
 
 
226
  for cell in col:
227
  val_str = str(cell.value or '')
228
  lines = val_str.split('\n')
229
  for line in lines:
230
  line_len = sum(2 if '\u4e00' <= char <= '\u9fff' else 1 for char in line)
231
- if line_len > max_len:
232
- max_len = line_len
233
  ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
234
-
235
- # 專業列印與邊界設定 (公分轉英吋)
236
  ws.sheet_properties.pageSetUpPr.fitToPage = True
237
- ws.page_setup.fitToWidth = 1 # 欄位強迫縮放放入單頁
238
- ws.page_setup.fitToHeight = 0 # 高度自動延伸
239
- ws.page_margins.top = 2.7 / 2.54
240
- ws.page_margins.bottom = 2.5 / 2.54
241
- ws.page_margins.left = 1.5 / 2.54
242
- ws.page_margins.right = 1.5 / 2.54
243
- ws.page_margins.header = 1.5 / 2.54
244
- ws.page_margins.footer = 1.0 / 2.54
 
 
 
 
 
 
 
245
 
246
- # 動態變數計算 (用於頁首)
247
- comp_names = "、".join(df_filtered['成分'].unique())
248
- form_mapping = {"注射劑": "Inj.", "一般錠劑膠囊劑": "Tab./Cap.", "膜衣錠": "F.C. Tab.", "膠囊劑": "Cap.", "錠劑": "Tab."}
249
- unique_forms = df_filtered['劑型'].unique()
250
- form_abbr = f" {form_mapping.get(unique_forms[0], unique_forms[0])}" if len(unique_forms) == 1 else ""
251
- doses_str = "".join(unique_doses)
 
 
 
 
 
 
252
 
253
- # 頁首頁尾設定 (&16&B Excel 內建 16級字+粗體 語法)
254
- header_string = f"&16&B{comp_names}{form_abbr} {doses_str}廠商申報量排名"
255
- ws.oddHeader.center.text = header_string
256
- ws.oddFooter.left.text = "&12中央健康保險署 政府資料開放平台 2024年資料"
257
- ws.oddFooter.right.text = "&12https://data.gov.tw/dataset/22131"
258
 
259
- ws.page_setup.scaleWithDoc = True
260
- ws.page_setup.alignWithMargins = True
 
 
 
 
261
 
262
- # 儲存 Excel
263
- output_filename = "Standardized_Report.xlsx"
264
- wb.save(output_filename)
265
 
266
- return output_filename, f"🎉 成功!已為您匯出完整的廠商排名 Excel 報表。"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
- # --- 5. 簡潔的 Gradio 使用者介面 (移除上傳區塊) ---
269
- with gr.Blocks(title="健保申報量標準化 Excel 產出工具") as demo:
270
- gr.Markdown("# 💊 健保申報量標準化 Excel 自動化產出工具")
 
 
 
 
 
 
271
  gr.Markdown(f"**系統狀態:** {INIT_MESSAGE}")
272
 
 
 
273
  with gr.Row():
 
274
  with gr.Column(scale=1):
275
- gr.Markdown("### 🔍 第一步:輸入成分篩選(支援單方、複方模糊比對)")
276
- search_input = gr.Textbox(
277
- label="輸入成分關鍵字",
278
- placeholder="例如:Levofloxacin 或 Cilostazol",
279
- value=""
280
- )
281
-
282
- # 【主要修改處】:將原本的 choices=ALL_COMPONENTS 改為 choices=[]
283
- # 這樣一打開網頁時,第二步就會是乾淨的,只有輸入關鍵字後才會顯示對應成分。
284
- component_choices = gr.CheckboxGroup(
285
- label="📋 第二步:勾選欲納入報表的成分品項 (可多選)",
286
- choices=[],
287
- value=[]
288
- )
289
-
290
- submit_btn = gr.Button("🚀 產生標準化 Excel 報表", variant="primary")
291
 
 
292
  with gr.Column(scale=1):
293
- gr.Markdown("### 📥 第三步:下載產出檔案")
294
  status_output = gr.Textbox(label="系統處理結果", interactive=False)
295
- file_output = gr.File(label="點擊下載產出的 Excel 報表")
 
 
 
296
 
297
- # 監聽搜尋輸入框變更,即時過濾全域資料庫的成分
298
- search_input.change(
299
- fn=update_component_choices,
300
- inputs=search_input,
301
- outputs=component_choices
302
- )
303
 
304
- # 點擊按鈕一鍵生成報表
 
 
 
 
 
 
 
 
305
  submit_btn.click(
306
- fn=generate_standard_excel,
307
- inputs=component_choices,
308
- outputs=[file_output, status_output]
309
  )
 
 
 
310
 
311
  if __name__ == "__main__":
312
  demo.launch()
 
12
  def load_fixed_database():
13
  if os.path.exists(DATA_FILE):
14
  try:
 
15
  df = pd.read_csv(DATA_FILE)
16
  df.columns = df.columns.str.strip()
 
17
  for col in ['成分', '劑型', '劑量', '廠商', '年度']:
18
  if col in df.columns:
19
  df[col] = df[col].astype(str).str.strip()
 
22
  print(f"讀取本地資料庫失敗: {e}")
23
  return pd.DataFrame()
24
  else:
 
25
  return pd.DataFrame()
26
 
 
27
  df_global = load_fixed_database()
28
  if not df_global.empty:
29
  ALL_COMPONENTS = sorted(df_global['成分'].dropna().unique())
30
+ INIT_MESSAGE = f"✅ 成功載入資料庫!共 {len(df_global)} 筆數據,包含 {len(ALL_COMPONENTS)} 種成分。"
31
  else:
32
  ALL_COMPONENTS = []
33
+ INIT_MESSAGE = "❌ 未找到 data.csv 檔案,請將資料庫檔案上傳至同目錄。"
34
 
35
+ # --- 2. 輔助函式 ---
36
  def parse_dosage_to_numeric(dose_str):
37
  if not isinstance(dose_str, str):
38
  return 0.0
 
44
  return val
45
  return 0.0
46
 
47
+ # --- 3. 動態介面連動函式 ---
48
  def update_component_choices(search_text):
49
  if not ALL_COMPONENTS:
50
+ return gr.update(choices=[], value=[])
 
 
51
  if not search_text or search_text.strip() == "":
52
+ return gr.update(choices=[], value=[])
 
 
53
  search_text = search_text.strip().lower()
54
  filtered = [c for c in ALL_COMPONENTS if search_text in c.lower()]
55
+ return gr.update(choices=filtered, value=[])
56
+
57
+ def update_form_choices(selected_comps):
58
+ if not selected_comps or df_global.empty:
59
+ return gr.update(choices=[], value=[])
60
+ df_filtered = df_global[df_global['成分'].isin(selected_comps)]
61
+ forms = sorted(df_filtered['劑型'].dropna().unique())
62
+ return gr.update(choices=forms, value=forms)
63
 
64
+ def update_dose_choices(selected_comps, selected_forms):
65
+ if not selected_comps or not selected_forms or df_global.empty:
66
+ return gr.update(choices=[], value=[])
67
+ df_filtered = df_global[
68
+ (df_global['成分'].isin(selected_comps)) &
69
+ (df_global['劑型'].isin(selected_forms))
70
+ ]
71
+ doses = sorted(df_filtered['劑量'].dropna().unique(), key=parse_dosage_to_numeric)
72
+ return gr.update(choices=doses, value=doses)
73
+
74
+ # --- 4. 產出標準化 Excel 與 HTML 預覽報表 ---
75
+ def generate_reports(selected_components, selected_forms, selected_doses):
76
  if df_global.empty:
77
+ return None, "❌ 系統未成功載入 data.csv 資料庫。", ""
78
+ if not selected_components or not selected_forms or not selected_doses:
79
+ return None, "❌ 請確認成分、劑型、劑量皆已勾選!", ""
80
+
81
+ df_filtered = df_global[
82
+ (df_global['成分'].isin(selected_components)) &
83
+ (df_global['劑型'].isin(selected_forms)) &
84
+ (df_global['劑量'].isin(selected_doses))
85
+ ].copy()
86
 
 
 
87
  if df_filtered.empty:
88
+ return None, "❌ 找不到符合該條件的資料!", ""
89
+
 
90
  qty_col = '數量(顆)' if '數量(顆)' in df_filtered.columns else [col for col in df_filtered.columns if '數量' in col][0]
 
 
91
  df_filtered[qty_col] = df_filtered[qty_col].astype(str).str.replace(',', '').str.strip()
92
  df_filtered[qty_col] = pd.to_numeric(df_filtered[qty_col], errors='coerce').fillna(0)
93
+
 
94
  pivot_df = df_filtered.groupby(['成分', '劑型', '劑量', '廠商', '年度'])[qty_col].sum().unstack(fill_value=0)
 
 
95
  for year_col in ['2022年', '2023年', '2024年']:
96
  if year_col not in pivot_df.columns:
97
  pivot_df[year_col] = 0
98
 
99
  pivot_df = pivot_df.reindex(columns=['2022年', '2023年', '2024年']).reset_index()
 
 
100
  pivot_df['dose_numeric'] = pivot_df['劑量'].apply(parse_dosage_to_numeric)
101
+ pivot_df = pivot_df.sort_values(
102
+ by=['成分', '劑型', 'dose_numeric', '2024年'],
103
+ ascending=[True, True, True, False]
104
+ ).drop(columns=['dose_numeric'])
105
+
106
+ # --- Excel 處理邏輯 ---
107
  wb = Workbook()
108
  ws = wb.active
109
  ws.title = "廠商排名報表"
110
  ws.views.sheetView[0].showGridLines = True
111
+
 
112
  font_family = "微軟正黑體"
113
+ header_fill = PatternFill(start_color="1F497D", end_color="1F497D", fill_type="solid")
114
+ subtotal_fill = PatternFill(start_color="DCE6F1", end_color="DCE6F1", fill_type="solid")
115
+ total_fill = PatternFill(start_color="B8CCE4", end_color="B8CCE4", fill_type="solid")
116
 
117
  header_font = Font(name=font_family, size=12, bold=True, color="FFFFFF")
118
  data_font = Font(name=font_family, size=12)
 
123
  right_align = Alignment(horizontal="right", vertical="center")
124
 
125
  thin_side = Side(border_style="thin", color="D9D9D9")
126
+ thick_bottom_side = Side(border_style="medium", color="000000")
127
  cell_border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
128
+ thick_bottom_border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thick_bottom_side)
129
+
130
+ # --- HTML 處理邏輯 ---
131
+ html_content = f"""
132
+ <style>
133
+ .report-container {{
134
+ background: white;
135
+ padding: 10px;
136
+ width: 100%;
137
+ box-sizing: border-box;
138
+ -webkit-text-size-adjust: 100%; /* 【關鍵 1】嚴格禁止手機瀏覽器自動縮小整個網頁的字體 */
139
+ }}
140
+ .table-responsive {{
141
+ width: 100%;
142
+ overflow-x: auto;
143
+ -webkit-overflow-scrolling: touch;
144
+ padding-bottom: 10px;
145
+ }}
146
+ .report-table {{
147
+ width: max-content;
148
+ min-width: 100%;
149
+ border-collapse: collapse;
150
+ font-family: '微軟正黑體', sans-serif;
151
+ font-size: 15px !important; /* 【關鍵 2】從原本的 13px 放大至 15px,並加上 !important 強制生效 */
152
+ }}
153
+ .report-table th, .report-table td {{
154
+ border: 1px solid #D9D9D9;
155
+ padding: 8px 12px;
156
+ white-space: nowrap !important; /* 【關鍵 3】絕對不換行,讓表格直接往右邊長過去 */
157
+ }}
158
+ .report-table th {{
159
+ background-color: #1F497D !important;
160
+ color: #FFFFFF !important;
161
+ text-align: center;
162
+ font-weight: bold;
163
+ }}
164
+ .thick-bottom {{ border-bottom: 2px solid black !important; }}
165
+ </style>
166
 
167
+ <div id="report-capture-area" class="report-container">
168
+ """
169
+
170
+ comp_names = "、".join(df_filtered['成分'].unique())
171
+ form_mapping = {"注射劑": "Inj.", "一般錠劑膠囊劑": "Tab./Cap.", "膜衣錠": "F.C. Tab.", "膠囊劑": "Cap.", "錠劑": "Tab."}
172
+ unique_forms = df_filtered['劑型'].unique()
173
+ form_abbr = f" {form_mapping.get(unique_forms[0], unique_forms[0])}" if len(unique_forms) == 1 else ""
174
+ doses_for_header = sorted(df_filtered['劑量'].unique(), key=parse_dosage_to_numeric)
175
+ doses_str = "、".join(doses_for_header)
176
+ header_title_text = f"{comp_names}{form_abbr} {doses_str}廠商申報量排名"
177
+
178
+ html_content += f'<h2 style="text-align:center; font-family: \'微軟正黑體\'; font-weight:bold;">{header_title_text}</h2>'
179
+ html_content += '<div class="table-responsive"><table class="report-table">'
180
+
181
+ headers = ["成分", "劑型", "劑量", "廠商", "2022年<br>數量", "2023年<br>數量", "2024年<br>數量", "2024年<br>占比(%)"]
182
+ ws_headers = [h.replace("<br>", "\n") for h in headers]
183
+ ws.append(ws_headers)
184
  ws.row_dimensions[1].height = 30
185
+
186
+ html_content += "<tr>"
187
+ for col_idx, h in enumerate(ws_headers, 1):
188
  cell = ws.cell(row=1, column=col_idx)
189
  cell.font = header_font
190
  cell.fill = header_fill
191
  cell.alignment = center_align
192
  cell.border = cell_border
193
+ html_content += f"<th style='color: #FFFFFF !important; background-color: #1F497D !important;'>{headers[col_idx-1]}</th>"
194
+ html_content += "</tr>"
195
+
196
+ unique_groups = pivot_df[['成分', '劑型', '劑量']].drop_duplicates()
197
+ unique_groups_list = list(unique_groups.iterrows())
198
 
199
  grand_total_2022 = 0
200
  grand_total_2023 = 0
201
  grand_total_2024 = 0
 
202
  current_row = 2
203
+
204
+ last_comp = None
205
+ last_form = None
206
+
207
+ for idx, (_, group_keys) in enumerate(unique_groups_list):
208
+ comp = group_keys['成分']
209
+ form = group_keys['劑型']
210
+ dose = group_keys['劑量']
211
+
212
+ is_last_of_form = False
213
+ if idx == len(unique_groups_list) - 1:
214
+ is_last_of_form = True
215
+ else:
216
+ next_comp = unique_groups_list[idx+1][1]['成分']
217
+ next_form = unique_groups_list[idx+1][1]['劑型']
218
+ if comp != next_comp or form != next_form:
219
+ is_last_of_form = True
220
+
221
+ dose_group = pivot_df[
222
+ (pivot_df['成分'] == comp) &
223
+ (pivot_df['劑型'] == form) &
224
+ (pivot_df['劑量'] == dose)
225
+ ]
226
 
227
+ dose_2024_sum = dose_group['2024年'].sum()
228
+ dose_subtotal_2022 = dose_group['2022年'].sum()
229
+ dose_subtotal_2023 = dose_group['2023年'].sum()
230
+ dose_subtotal_2024 = dose_2024_sum
231
 
232
+ print_comp = (comp != last_comp)
233
+ print_form = print_comp or (form != last_form)
234
+ is_first_row_in_dose = True
235
 
236
  for _, row in dose_group.iterrows():
237
+ qty_2022 = float(row['2022年'])
238
+ qty_2023 = float(row['2023年'])
239
+ qty_2024 = float(row['2024年'])
240
  ratio = (qty_2024 / dose_2024_sum) if dose_2024_sum > 0 else 0.0
241
 
242
+ c_val = row['成分'] if print_comp and is_first_row_in_dose else ""
243
+ f_val = row['劑型'] if print_form and is_first_row_in_dose else ""
244
+ d_val = row['劑'] if is_first_row_in_dose else ""
 
245
 
246
  ws.append([c_val, f_val, d_val, row['廠商'], qty_2022, qty_2023, qty_2024, ratio])
247
+ ws.row_dimensions[current_row].height = 25
248
+
249
+ html_content += f"<tr>"
250
+ html_content += f"<td style='text-align:center;'>{c_val}</td>"
251
+ html_content += f"<td style='text-align:center;'>{f_val}</td>"
252
+ html_content += f"<td style='text-align:center;'>{d_val}</td>"
253
+ html_content += f"<td style='text-align:left;'>{row['廠商']}</td>"
254
+ html_content += f"<td style='text-align:right;'>{qty_2022:,.0f}</td>"
255
+ html_content += f"<td style='text-align:right;'>{qty_2023:,.0f}</td>"
256
+ html_content += f"<td style='text-align:right;'>{qty_2024:,.0f}</td>"
257
+ html_content += f"<td style='text-align:right;'>{ratio:.1%}</td>"
258
+ html_content += "</tr>"
259
 
 
260
  for col_idx in range(1, 9):
261
  cell = ws.cell(row=current_row, column=col_idx)
262
  cell.font = data_font
263
  cell.border = cell_border
264
+ if col_idx in [1, 2, 3]: cell.alignment = center_align
265
+ elif col_idx == 4: cell.alignment = left_align
266
+ elif col_idx in [5, 6, 7]: cell.alignment = right_align; cell.number_format = '#,##0'
267
+ elif col_idx == 8: cell.alignment = right_align; cell.number_format = '0.0%'
 
 
 
 
 
 
268
 
 
 
 
269
  current_row += 1
270
+ is_first_row_in_dose = False
271
+
272
+ last_comp = comp
273
+ last_form = form
274
 
275
+ # 合計列
276
  ws.append(["", "", f"{dose} 合計", "", dose_subtotal_2022, dose_subtotal_2023, dose_subtotal_2024, 1.0])
277
+ ws.merge_cells(start_row=current_row, start_column=3, end_row=current_row, end_column=4)
278
+ ws.row_dimensions[current_row].height = 25
279
+
280
+ thick_class = "thick-bottom" if is_last_of_form else ""
281
+ html_content += f"""
282
+ <tr style="background-color: #DCE6F1; font-weight: bold;">
283
+ <td class="{thick_class}"></td><td class="{thick_class}"></td>
284
+ <td colspan="2" class="{thick_class}" style="text-align:center;">{dose} 合計</td>
285
+ <td class="{thick_class}" style="text-align:right;">{dose_subtotal_2022:,.0f}</td>
286
+ <td class="{thick_class}" style="text-align:right;">{dose_subtotal_2023:,.0f}</td>
287
+ <td class="{thick_class}" style="text-align:right;">{dose_subtotal_2024:,.0f}</td>
288
+ <td class="{thick_class}" style="text-align:right;">100.0%</td>
289
+ </tr>
290
+ """
291
+
292
  for col_idx in range(1, 9):
293
  cell = ws.cell(row=current_row, column=col_idx)
294
  cell.font = bold_font
295
  cell.fill = subtotal_fill
296
+ cell.border = thick_bottom_border if is_last_of_form else cell_border
297
+ if col_idx == 3: cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=False)
298
+ elif col_idx in [5, 6, 7]: cell.alignment = right_align; cell.number_format = '#,##0'
299
+ elif col_idx == 8: cell.alignment = right_align; cell.number_format = '0.0%'
 
 
 
 
 
300
 
301
  grand_total_2022 += dose_subtotal_2022
302
  grand_total_2023 += dose_subtotal_2023
303
  grand_total_2024 += dose_subtotal_2024
304
  current_row += 1
305
+
306
+ # 總計列
307
+ ws.append(["總計", "", "", "", grand_total_2022, grand_total_2023, grand_total_2024, 1.0])
308
+ ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=4)
309
+ ws.row_dimensions[current_row].height = 25
310
+
311
+ html_content += f"""
312
+ <tr style="background-color: #B8CCE4; font-weight: bold;">
313
+ <td colspan="4" style="text-align:center;">總計</td>
314
+ <td style="text-align:right;">{grand_total_2022:,.0f}</td>
315
+ <td style="text-align:right;">{grand_total_2023:,.0f}</td>
316
+ <td style="text-align:right;">{grand_total_2024:,.0f}</td>
317
+ <td style="text-align:right;">100.0%</td>
318
+ </tr>
319
+ </table></div>
320
+ """
321
+
322
+ # 頁尾
323
+ html_content += """
324
+ <div style="display:flex; justify-content:space-between; margin-top: 15px; font-size: 12px; color: #555;">
325
+ <span>中央健康保險署 政府資料開放平台 2024年資料</span>
326
+ <span>https://data.gov.tw/dataset/22131</span>
327
+ </div>
328
+ </div>
329
+ """
330
+
331
  for col_idx in range(1, 9):
332
  cell = ws.cell(row=current_row, column=col_idx)
333
  cell.font = bold_font
334
  cell.fill = total_fill
335
  cell.border = cell_border
336
+ if col_idx == 1: cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=False)
337
+ elif col_idx in [5, 6, 7]: cell.alignment = right_align; cell.number_format = '#,##0'
338
+ elif col_idx == 8: cell.alignment = right_align; cell.number_format = '0.0%'
339
+
 
 
 
 
 
 
340
  for col in ws.columns:
 
341
  col_letter = get_column_letter(col[0].column)
342
+ if col_letter == 'H':
343
+ ws.column_dimensions[col_letter].width = 11.5
344
+ continue
345
+ max_len = 0
346
  for cell in col:
347
  val_str = str(cell.value or '')
348
  lines = val_str.split('\n')
349
  for line in lines:
350
  line_len = sum(2 if '\u4e00' <= char <= '\u9fff' else 1 for char in line)
351
+ if line_len > max_len: max_len = line_len
 
352
  ws.column_dimensions[col_letter].width = max(max_len + 4, 12)
353
+
 
354
  ws.sheet_properties.pageSetUpPr.fitToPage = True
355
+ ws.page_setup.fitToWidth = 1
356
+ ws.page_setup.fitToHeight = 0
357
+ ws.page_margins.top = 2.7 / 2.54; ws.page_margins.bottom = 2.5 / 2.54
358
+ ws.page_margins.left = 1.5 / 2.54; ws.page_margins.right = 1.5 / 2.54
359
+ ws.page_margins.header = 1.5 / 2.54; ws.page_margins.footer = 1.0 / 2.54
360
+
361
+ header_string_for_print = f'&"微軟正黑體,Bold"&16{header_title_text}'
362
+ ws.oddHeader.center.text = header_string_for_print
363
+ ws.oddFooter.left.text = '&"微軟正黑體,Regular"&12中央健康保險署 政府資料開放平台 2024年資料'
364
+ ws.oddFooter.right.text = '&"微軟正黑體,Regular"&12https://data.gov.tw/dataset/22131'
365
+ ws.page_setup.scaleWithDoc = True
366
+ ws.page_setup.alignWithMargins = True
367
+
368
+ safe_filename = re.sub(r'[\\/*?:"<>|]', "_", header_title_text) + ".xlsx"
369
+ wb.save(safe_filename)
370
 
371
+ success_msg = "🎉 成功!已順利產出「Excel 報表」與下方「預覽畫面」。請點擊下方按鈕下載檔案或存為圖片!"
372
+ return safe_filename, success_msg, html_content
373
+
374
+
375
+ # --- 5. 終極優化:完美截圖與手機互動 JavaScript ---
376
+ download_js = """
377
+ function() {
378
+ var element = document.getElementById('report-capture-area');
379
+ if (!element) {
380
+ alert('請先產生報表再下載圖片!');
381
+ return [];
382
+ }
383
 
384
+ if (typeof html2canvas === 'undefined') {
385
+ alert('截圖套件載入中,請稍後再試或重新整理網頁。');
386
+ return [];
387
+ }
 
388
 
389
+ var table = document.querySelector('.report-table');
390
+ var wrapper = document.querySelector('.table-responsive');
391
+
392
+ var originalElementWidth = element.style.width;
393
+ var originalWrapperOverflow = wrapper ? wrapper.style.overflowX : '';
394
+ var targetWidth = table ? (table.offsetWidth + 20) : element.scrollWidth;
395
 
396
+ element.style.width = targetWidth + 'px';
397
+ if(wrapper) wrapper.style.overflowX = 'visible';
 
398
 
399
+ var titleElement = element.querySelector('h2');
400
+ var fileName = titleElement ? titleElement.innerText.replace(/[\\\\/*?:"<>|]/g, "_") : '廠商排名報表';
401
+
402
+ html2canvas(element, {
403
+ scale: 2,
404
+ backgroundColor: '#FFFFFF',
405
+ width: targetWidth,
406
+ windowWidth: targetWidth
407
+ }).then(function(canvas) {
408
+
409
+ // 截圖完畢,瞬間把排版縮回去
410
+ element.style.width = originalElementWidth;
411
+ if(wrapper) wrapper.style.overflowX = originalWrapperOverflow;
412
+
413
+ // 【修正 1:強化手機與平板判斷,涵蓋 iPad 的桌面網站模式】
414
+ var isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
415
+
416
+ canvas.toBlob(function(blob) {
417
+ var file = new File([blob], fileName + '.png', { type: 'image/png' });
418
+ var imgDataUrl = canvas.toDataURL('image/png');
419
+ // 【修正 2:優先嘗試 Web Share API,呼叫原生「儲存影像」】
420
+ if (isMobile && navigator.canShare && navigator.canShare({ files: [file] })) {
421
+ navigator.share({
422
+ files: [file],
423
+ title: fileName
424
+ }).then(() => {
425
+ console.log('成功呼叫原生分享選單');
426
+ }).catch((error) => {
427
+ // 若使用者取消或 iframe 權限遭擋,降級使用長按視窗
428
+ console.log('無法使用原生分享,改用長按模式', error);
429
+ showLongPressModal(imgDataUrl);
430
+ });
431
+ }
432
+ // 【修正 3:若不支援 Share API,強制顯示長按視窗】
433
+ else if (isMobile) {
434
+ showLongPressModal(imgDataUrl);
435
+ }
436
+ // 電腦版維持自動下載
437
+ else {
438
+ var link = document.createElement('a');
439
+ link.download = fileName + '.png';
440
+ link.href = imgDataUrl;
441
+ link.click();
442
+ }
443
+ }, 'image/png');
444
+ // 將長按視窗獨立為函式,方便降級呼叫
445
+ function showLongPressModal(imgSrc) {
446
+ var modal = document.createElement('div');
447
+ modal.style.position = 'fixed';
448
+ modal.style.top = '0';
449
+ modal.style.left = '0';
450
+ modal.style.width = '100vw';
451
+ modal.style.height = '100vh';
452
+ modal.style.backgroundColor = 'rgba(0,0,0,0.85)';
453
+ modal.style.zIndex = '9999';
454
+ modal.style.display = 'flex';
455
+ modal.style.flexDirection = 'column';
456
+ modal.style.alignItems = 'center';
457
+ modal.style.justifyContent = 'center';
458
+
459
+ var closeBtn = document.createElement('button');
460
+ closeBtn.innerText = '✕ 關閉';
461
+ closeBtn.style.position = 'absolute';
462
+ closeBtn.style.top = '20px';
463
+ closeBtn.style.right = '20px';
464
+ closeBtn.style.padding = '8px 16px';
465
+ closeBtn.style.fontSize = '16px';
466
+ closeBtn.style.backgroundColor = '#ff4444';
467
+ closeBtn.style.color = 'white';
468
+ closeBtn.style.border = 'none';
469
+ closeBtn.style.borderRadius = '5px';
470
+ closeBtn.style.fontWeight = 'bold';
471
+ closeBtn.onclick = function() { document.body.removeChild(modal); };
472
+
473
+ var hint = document.createElement('div');
474
+ hint.innerText = '👇 請「長按」下方圖片,選擇「儲存到照片 / 儲存影像」';
475
+ hint.style.color = 'white';
476
+ hint.style.fontSize = '16px';
477
+ hint.style.fontWeight = 'bold';
478
+ hint.style.marginBottom = '20px';
479
+ hint.style.padding = '10px';
480
+ hint.style.textAlign = 'center';
481
+ hint.style.backgroundColor = '#1F497D';
482
+ hint.style.borderRadius = '8px';
483
+
484
+ var img = document.createElement('img');
485
+ img.src = imgSrc;
486
+ img.style.maxWidth = '95%';
487
+ img.style.maxHeight = '75vh';
488
+ img.style.border = '2px solid white';
489
+ img.style.borderRadius = '5px';
490
+ img.style.objectFit = 'contain';
491
+
492
+ // 確保圖片支援 iOS 長按呼叫選單
493
+ img.style.webkitTouchCallout = 'default';
494
+ img.style.userSelect = 'auto';
495
+
496
+ modal.appendChild(closeBtn);
497
+ modal.appendChild(hint);
498
+ modal.appendChild(img);
499
+ document.body.appendChild(modal);
500
+ }
501
+
502
+ });
503
+ return [];
504
+ }
505
+ """
506
 
507
+
508
+ # --- 6. 介面層 (載入 html2canvas 套件) ---
509
+ with gr.Blocks(
510
+ theme=gr.themes.Default(primary_hue="blue", secondary_hue="slate"), # 【新增這行】將系統按鈕主色調改為藍色
511
+ title="健保資料庫數據分析工具",
512
+ head='<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>'
513
+ ) as demo:
514
+
515
+ gr.Markdown("# 💊 健保資料庫數據分析工具")
516
  gr.Markdown(f"**系統狀態:** {INIT_MESSAGE}")
517
 
518
+ gr.Markdown("⚠️ **提醒使用手機版的同仁:請務必使用系統預設瀏覽器(如 Safari 或 Chrome)開啟本網頁,才能成功下載檔案與圖片。**")
519
+
520
  with gr.Row():
521
+ # 左側區塊:輸入條件
522
  with gr.Column(scale=1):
523
+ gr.Markdown("### 🔍 輸入條件")
524
+ search_input = gr.Textbox(label="第一步:輸入成分關鍵字", placeholder="例如:Levofloxacin", value="")
525
+ component_choices = gr.CheckboxGroup(label="📋 第二步:勾選成分品項 (可多選)", choices=[], value=[])
526
+ form_choices = gr.CheckboxGroup(label="💊 第三步:勾選欲包含的劑型", choices=[], value=[])
527
+ dose_choices = gr.CheckboxGroup(label="🧪 第四步:勾選欲包含的劑量", choices=[], value=[])
528
+ submit_btn = gr.Button("🚀 第五步:產生 Excel 報表與預覽", variant="primary")
 
 
 
 
 
 
 
 
 
 
529
 
530
+ # 右側區塊:下載與輸出
531
  with gr.Column(scale=1):
532
+ gr.Markdown("### 📥 報表與圖片下載")
533
  status_output = gr.Textbox(label="系統處理結果", interactive=False)
534
+
535
+ # 【修改重點 1】:將 gr.File 換成 gr.DownloadButton,設定 variant="primary" 和 size="lg" 讓它變成大橘色按鈕
536
+ download_excel_btn = gr.DownloadButton("📄 一鍵下載 Excel 報表", variant="primary", size="lg")
537
+ download_img_btn = gr.Button("🖼️ 一鍵下載為高畫質圖片 (PNG)", variant="primary", size="lg")
538
 
539
+ gr.Markdown("---")
 
 
 
 
 
540
 
541
+ gr.Markdown("### 網頁即時預覽")
542
+ html_output = gr.HTML(label="報表預覽區")
543
+
544
+ # --- 綁定事件 ---
545
+ search_input.change(fn=update_component_choices, inputs=search_input, outputs=component_choices)
546
+ component_choices.change(fn=update_form_choices, inputs=component_choices, outputs=form_choices)
547
+ form_choices.change(fn=update_dose_choices, inputs=[component_choices, form_choices], outputs=dose_choices)
548
+
549
+ # 【修改重點 2】:將原本輸出對象的 file_output 替換為 download_excel_btn
550
  submit_btn.click(
551
+ fn=generate_reports,
552
+ inputs=[component_choices, form_choices, dose_choices],
553
+ outputs=[download_excel_btn, status_output, html_output]
554
  )
555
+
556
+ # 綁定進化版的 JS 截圖指令
557
+ download_img_btn.click(fn=None, js=download_js)
558
 
559
  if __name__ == "__main__":
560
  demo.launch()