# -*- coding: utf-8 -*- """ PRA Analysis Web App 上傳 HLA Fusion XLS → 自動分析 PRA Class I / II → 產生報告 """ import io import os import re import sys import tempfile from pathlib import Path from collections import OrderedDict from datetime import datetime from flask import Flask, render_template, request, send_file, jsonify, redirect, url_for, session as flask_session from PRA import NC_BEAD, PC_BEAD, _parse_allele_list app = Flask(__name__) app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB app.secret_key = 'pra-analysis-2025' DEFAULT_USERS = {'NEPH': 'NEPH12345', 'okokyytt@gmail.com': '1234'} ADMIN_USER = 'okokyytt@gmail.com' import db as _db _db.seed_default_users(DEFAULT_USERS, admin_user=ADMIN_USER) def login_required(f): from functools import wraps @wraps(f) def decorated(*args, **kwargs): if not flask_session.get('logged_in'): return redirect(url_for('login')) return f(*args, **kwargs) return decorated # ============================================================ # 分析邏輯 # ============================================================ def build_qc_comment(nc_raw, pc_raw, beads_detail): """ QC 檢查 → 自動產生 Comment。 nc_raw: 病人樣本的 NC bead (001) Raw 值 pc_raw: NC 樣本的 PC bead (002) Raw 值(plate-level) beads_detail: list of bead dicts, each with 'bead' and 'count' """ lines = [] # NC background check if nc_raw > 1500: lines.append('Uninterpretable due to high background bindings, please repeat') elif nc_raw > 500: lines.append('High background bindings') # PC signal check (must be > 500) if pc_raw <= 500: lines.append('Low PC signal, please repeat') # PC/NC ratio check if nc_raw > 0 and pc_raw > 0: ratio = pc_raw / nc_raw if ratio < 2: lines.append('Uninterpretable, please repeat') # Bead count check low_beads = [(b['bead'], b['count']) for b in beads_detail if b.get('count', 0) > 0 and b['count'] < 80] if low_beads: lines.append('Low HLA Beads count, please repeat') return '\n'.join(lines) def clean_sero(sero_str): """清理 sero 字串: 'A2, , B46, , Bw6, , Cw1,' → 'A2, B46, Cw1'""" if not sero_str: return '' skip = {'Bw4', 'Bw6', ''} parts = [s.strip() for s in sero_str.split(',')] parts = [s for s in parts if s not in skip] return ', '.join(parts) def clean_allele(allele_str): """清理 allele 字串: - 移除 =alias (DPB1*04:01=DPB1*105:01 → DPB1*04:01) - 移除 /276N, /163N null allele 後綴 - 移除 dash (-) 條目 - 移除空條目 """ if not allele_str: return '' parts = [a.strip() for a in allele_str.split(',')] cleaned = [p for p in parts if p and p != '-'] return ', '.join(cleaned) def calculate_pra(bead_results, threshold='X6'): threshold_rxn = int(threshold[1:]) total = len(bead_results) positive = sum(1 for r in bead_results.values() if r['rxn'] >= threshold_rxn) pra = round(positive / total * 100) if total > 0 else 0 return pra, positive, total def get_confident_alleles(bead_results, bead_hla_map, threshold=0.8): """ 篩選 confident alleles:80% Rule + Gray Zone(PRA1/PRA2 通用) Step 1: 有 X2/X1 → 硬性排除 Step 2: 只有 X4 → 80% rule(X6X8/total ≥ 80% 才列入) Step 3: 全部 X6/X8 → 直接列入 """ def _clean(ag): ag = ag.split('/')[0] ag = ag.split('=')[0] return ag # 收集每個 allele 在不同 Rxn 層級的 bead allele_beads = {} # allele -> {'x6x8': set, 'x4': set, 'x2x1': set} for bid, r in bead_results.items(): hla = bead_hla_map.get(bid, {}) alleles = {_clean(a) for a in _parse_allele_list(hla.get('allele', ''))} for ag in alleles: if ag not in allele_beads: allele_beads[ag] = {'x6x8': set(), 'x4': set(), 'x2x1': set()} if r['rxn'] >= 6: allele_beads[ag]['x6x8'].add(bid) elif r['rxn'] >= 4: allele_beads[ag]['x4'].add(bid) else: allele_beads[ag]['x2x1'].add(bid) # 建 allele→sero 對照 a2s = _build_allele_to_sero(bead_hla_map) confident = set() decisions = [] # 每個 allele 的判定過程 for ag, levels in allele_beads.items(): n68 = len(levels['x6x8']) n4 = len(levels['x4']) n21 = len(levels['x2x1']) sero = a2s.get(ag, '') # 必須有 X6/X8 if not levels['x6x8']: continue # Step 1: 有 X2/X1 → 硬性排除 if levels['x2x1']: decisions.append({'allele': ag, 'sero': sero, 'x6x8': n68, 'x4': n4, 'x2x1': n21, 'decision': 'Excluded (X2/X1)'}) continue # Step 2: 只有 X4 → 80% rule if levels['x4']: total = n68 + n4 ratio = n68 / total if ratio >= threshold: confident.add(ag) decisions.append({'allele': ag, 'sero': sero, 'x6x8': n68, 'x4': n4, 'x2x1': n21, 'decision': 'Assign'}) else: decisions.append({'allele': ag, 'sero': sero, 'x6x8': n68, 'x4': n4, 'x2x1': n21, 'decision': f'Excluded (<80%: {ratio*100:.1f}%)'}) else: # 全部都是 X6/X8,直接列入 confident.add(ag) decisions.append({'allele': ag, 'sero': sero, 'x6x8': n68, 'x4': n4, 'x2x1': n21, 'decision': 'Assign'}) decisions.sort(key=lambda d: d['allele']) return confident, decisions def _build_allele_to_sero(bead_hla_map): """從 bead map 建立 allele→sero 對照""" mapping = {} for bid, hla in bead_hla_map.items(): sero_parts = [s.strip() for s in hla.get('sero', '').split(',')] allele_parts = [a.strip() for a in hla.get('allele', '').split(',')] skip = {'Bw4', 'Bw6', ''} sero_by_locus = {} for s in sero_parts: if s in skip: continue # 判斷 locus if s.startswith('A'): sero_by_locus.setdefault('A', []).append(s) elif s.startswith('B'): sero_by_locus.setdefault('B', []).append(s) elif s.startswith('Cw') or s.startswith('C'): sero_by_locus.setdefault('C', []).append(s) elif s.startswith('DR'): sero_by_locus.setdefault('DR', []).append(s) elif s.startswith('DQ'): sero_by_locus.setdefault('DQ', []).append(s) elif s.startswith('DP'): sero_by_locus.setdefault('DP', []).append(s) allele_by_locus = {} for a in allele_parts: if not a or a == '-': continue if a.startswith('A'): allele_by_locus.setdefault('A', []).append(a) elif a.startswith('B'): allele_by_locus.setdefault('B', []).append(a) elif a.startswith('C'): allele_by_locus.setdefault('C', []).append(a) elif a.startswith('DRB'): allele_by_locus.setdefault('DR', []).append(a) elif a.startswith('DQB'): allele_by_locus.setdefault('DQ', []).append(a) # DQ sero = DQB1 only elif a.startswith('DPB'): allele_by_locus.setdefault('DP', []).append(a) # DP sero = DPB1 only # DQA1/DPA1 不建 sero mapping(用 allele 格式顯示) for locus in sero_by_locus: seros = sero_by_locus[locus] alleles = allele_by_locus.get(locus, []) for i, ag in enumerate(alleles): if i < len(seros): # 同時建立原始和清理後的 mapping mapping[ag] = seros[i] clean_ag = ag.split('/')[0].split('=')[0] if clean_ag != ag: mapping[clean_ag] = seros[i] return mapping def build_sero_mfi_stats(beads_detail, confident_alleles, bead_hla_map): """ 計算每個 confident sero 的 Max/Mean Normal MFI。 回傳 list of dict: [{sero, alleles, max_mfi, mean_mfi, count, beads}, ...] 按 max_mfi 降序排列。 """ a2s = _build_allele_to_sero(bead_hla_map) skip_sero = {'Bw4', 'Bw6', ''} # 找出 confident sero set, DQA1/DQB1/DPA1/DPB1 用 allele 本身當 key conf_seros = set() sero_alleles = {} # sero -> set of confident alleles for ag in confident_alleles: prefix = ag.split('*')[0] if '*' in ag else '' if prefix in ('DQA1', 'DQB1', 'DPA1', 'DPB1'): # 用 allele 本身當 key conf_seros.add(ag) sero_alleles[ag] = {ag} else: sero = a2s.get(ag) if sero and sero not in skip_sero: conf_seros.add(sero) sero_alleles.setdefault(sero, set()).add(ag) # 收集每個 confident sero/allele 在正陽性 bead 上的 Normal 值 sero_normals = {} sero_beads = {} for b in beads_detail: if b['rxn'] < 6: continue hla = bead_hla_map.get(b['bead'], {}) # Sero level sero_parts = [s.strip() for s in hla.get('sero', '').split(',')] for s in sero_parts: if s in skip_sero: continue if s in conf_seros: sero_normals.setdefault(s, []).append(b['normal']) sero_beads.setdefault(s, []).append(b['bead']) # Allele level (DQA1/DQB1/DPA1/DPB1) for ag in _parse_allele_list(hla.get('allele', '')): if ag in conf_seros: sero_normals.setdefault(ag, []).append(b['normal']) sero_beads.setdefault(ag, []).append(b['bead']) # 組裝結果 stats = [] for sero in conf_seros: normals = sero_normals.get(sero, []) if not normals: continue alleles_str = ', '.join(sorted(sero_alleles.get(sero, set()))) stats.append({ 'sero': sero, 'alleles': alleles_str, 'max_mfi': round(max(normals), 1), 'mean_mfi': round(sum(normals) / len(normals), 1), 'count': len(normals), 'beads': ', '.join(sero_beads.get(sero, [])), }) # 排序: 按 locus 分組, 再按 max_mfi 降序 def sort_key(x): s = x['sero'] if s.startswith('A'): locus = 0 elif s.startswith('B'): locus = 1 elif s.startswith('Cw') or s.startswith('C'): locus = 2 elif s.startswith('DR'): locus = 0 elif s.startswith('DQ'): locus = 1 elif s.startswith('DP'): locus = 2 else: locus = 9 return (locus, -x['max_mfi']) stats.sort(key=sort_key) return stats def generate_specificity(confident_alleles, bead_hla_map): """ 將 confident alleles 轉為 Specificity 字串。 Class I: A11(A*11:02) A23 B7 Cw1 Class II: DR1 DR4 DQ5 DQ7 DQA1(*05:01 *03:03) DQB1(*03:01) DP2 DPA1(*01:03) DPB1(*04:01) """ if not confident_alleles: return '(-)' a2s = _build_allele_to_sero(bead_hla_map) # 統計每個 sero group 在整個 bead panel 上有哪些 allele all_alleles_per_sero = {} for bid, hla in bead_hla_map.items(): for ag in _parse_allele_list(hla.get('allele', '')): sero = a2s.get(ag) if sero: all_alleles_per_sero.setdefault(sero, set()).add(ag) # 清理 allele 名稱(移除 /276N, =DPB1*105:01 等後綴) def clean_allele(ag): ag = ag.split('/')[0] # remove /276N ag = ag.split('=')[0] # remove =DPB1*105:01 return ag # 分組: # DQA1/DPA1 (alpha chain, 無 sero) → allele 格式 # DQB1/DPB1 (beta chain, 有 sero) → 回歸 sero 對照 # 其他 → sero 對照 sero_groups = {} allele_groups = {'DQA1': set(), 'DPA1': set()} for ag in confident_alleles: ag_clean = clean_allele(ag) prefix = ag_clean.split('*')[0] if '*' in ag_clean else '' if prefix in allele_groups: # DQA1/DPA1 → allele 格式 allele_groups[prefix].add(ag_clean) else: # DQB1/DPB1 和其他 → sero 對照 sero = a2s.get(ag) if not sero: sero = a2s.get(ag_clean) if sero: sero_groups.setdefault(sero, set()).add(ag_clean) # 排序: 按 locus 再按數字 import re as _re def sort_key(s): if s.startswith('A'): locus = 0 elif s.startswith('B'): locus = 1 elif s.startswith('Cw'): locus = 2 elif s.startswith('DR'): locus = 0 elif s.startswith('DQ'): locus = 1 elif s.startswith('DP'): locus = 2 else: locus = 9 # 提取數字排序 m = _re.search(r'(\d+)', s) num = int(m.group(1)) if m else 0 return (locus, num, s) parts = [] # 分 DR/DQ sero, DQA1, DP sero, DPA1 四段輸出 dr_parts = [] dq_parts = [] dp_parts = [] other_parts = [] for sero in sorted(sero_groups.keys(), key=sort_key): conf = sero_groups[sero] alleles_str = ' '.join(f'{a}' for a in sorted(conf)) entry = f'{sero}({alleles_str})' if sero.startswith('DR'): dr_parts.append(entry) elif sero.startswith('DQ'): dq_parts.append(entry) elif sero.startswith('DP'): dp_parts.append(entry) else: other_parts.append(entry) # 順序: DR → DQ sero → DQA1 → DP sero → DPA1 → 其他(A,B,Cw) parts.extend(other_parts) # A, B, Cw (Class I) parts.extend(dr_parts) parts.extend(dq_parts) for ag in sorted(allele_groups['DQA1']): parts.append(f'{ag}') parts.extend(dp_parts) for ag in sorted(allele_groups['DPA1']): parts.append(f'{ag}') return ' '.join(parts) def parse_xls_file(raw_bytes): """ 解析 HLA Fusion XLS 報告。 可能包含一個病人或 NC 報告。 回傳 dict: {sample_name, pra_class, date, beads_detail, ...} """ import xlrd wb = xlrd.open_workbook(file_contents=raw_bytes) sh = wb.sheet_by_index(0) # 讀取 metadata sample_name = str(sh.cell_value(0, 0)).strip() if not sample_name: # Row 1 可能有 PATIENT: xxx if sh.nrows > 1: r1 = str(sh.cell_value(1, 0)).strip() if r1.startswith('PATIENT'): sample_name = str(sh.cell_value(1, 1)).strip() if sh.ncols > 1 else r1 session = '' date_val = '' catalog = '' # 掃描 header rows 找 TEST DATE / SESSION / CATALOG(直到找到 date_val 為止) # TEST DATE label 和值的欄位位置依 XLS 版本不同,可能在 col 34/38 等 import re as _re max_header_rows = min(sh.nrows, 15) for r in range(max_header_rows): if date_val and session and catalog: break row = [str(sh.cell_value(r, c)).strip() for c in range(sh.ncols)] for i, v in enumerate(row): if not session and v in ('SESSION :', 'SESSION:'): for j in range(i + 1, len(row)): if row[j]: session = row[j]; break if not date_val and v in ('TEST DATE :', 'TEST DATE:'): for j in range(i + 1, len(row)): if row[j] and '/' in row[j]: date_val = row[j]; break if not catalog and v in ('CATALOG :', 'CATALOG:'): for j in range(i + 1, len(row)): if row[j]: catalog = row[j]; break # 仍然沒抓到 TEST DATE → 從 SESSION 撈第一組 8 位數日期 if not date_val and session: m = _re.search(r'\b(\d{8})\b', session) if m: digits = m.group(1) date_val = f'{digits[:4]}/{digits[4:6]}/{digits[6:8]}' # 最後 fallback:掃整張表任何含 'YYYY/M/D' 或 'YYYY-M-D' 的 cell if not date_val: date_re = _re.compile(r'\b(20\d{2})[/-](\d{1,2})[/-](\d{1,2})\b') for r in range(min(sh.nrows, 20)): for c in range(sh.ncols): m = date_re.search(str(sh.cell_value(r, c))) if m: date_val = f'{m.group(1)}/{int(m.group(2))}/{int(m.group(3))}' break if date_val: break # 偵測 PRA class pra_class = 'PRA1' if 'LS2PRA' in catalog.upper() or 'PRA2' in catalog.upper(): pra_class = 'PRA2' elif 'PRA2' in session.upper(): pra_class = 'PRA2' # 讀取 bead 資料 (col 0=BeadID, 3=Raw, 11=NS_Raw, 15=Normal, 20=Ratio, 22=Rxn, 26=Count, 29=Sero, 35=Allele) beads_detail = [] nc_raw = 0 pc_raw = 0 for r in range(9, sh.nrows): bid = str(sh.cell_value(r, 0)).strip() if not bid or bid == 'BeadID': continue # 整數 bead ID → 補零到 3 位 try: bid_int = int(float(bid)) bid = f'{bid_int:03d}' except (ValueError, TypeError): continue raw_val = sh.cell_value(r, 3) if sh.ncols > 3 else 0 ns_raw = sh.cell_value(r, 11) if sh.ncols > 11 else 0 normal = sh.cell_value(r, 15) if sh.ncols > 15 else 0 ratio = sh.cell_value(r, 20) if sh.ncols > 20 else 0 rxn_val = sh.cell_value(r, 22) if sh.ncols > 22 else '' count_val = sh.cell_value(r, 26) if sh.ncols > 26 else 0 sero_raw = str(sh.cell_value(r, 29)).strip() if sh.ncols > 29 else '' sero = clean_sero(sero_raw) allele = str(sh.cell_value(r, 35)).strip() if sh.ncols > 35 else '' # 處理數值 try: raw_val = float(raw_val) if raw_val != '' else 0 except (ValueError, TypeError): raw_val = 0 try: ns_raw = float(ns_raw) if ns_raw != '' else 0 except (ValueError, TypeError): ns_raw = 0 try: normal = float(normal) if normal != '' else 0 except (ValueError, TypeError): normal = 0 try: ratio = float(ratio) if ratio != '' else 0 except (ValueError, TypeError): ratio = 0 try: count_val = int(float(count_val)) if count_val != '' else 0 except (ValueError, TypeError): count_val = 0 # Rxn: 可能是數字 1/2/4/6/8 或 'NC'/'PC' rxn_str = str(rxn_val).strip() if rxn_str in ('NC', 'nc'): nc_raw = raw_val continue elif rxn_str in ('PC', 'pc'): pc_raw = raw_val continue try: rxn_int = int(float(rxn_val)) except (ValueError, TypeError): continue # 跳過沒有 sero/allele 的 bead(非 HLA bead) if not sero and not allele: continue beads_detail.append({ 'bead': bid, 'rxn': rxn_int, 'raw': round(raw_val, 1), 'ns_raw': round(ns_raw, 1), 'normal': round(normal, 2), 'ratio': round(ratio, 2), 'count': count_val, 'sero': sero, 'allele': clean_allele(allele), }) if not beads_detail: return None # 沒有有效的 HLA bead 資料 # 計算 PRA% total = len(beads_detail) pra_all = {} for t in ['X2', 'X4', 'X6', 'X8']: t_rxn = int(t[1:]) pos = sum(1 for b in beads_detail if b['rxn'] >= t_rxn) pra_all[t] = round(pos / total * 100) if total > 0 else 0 pra6 = pra_all['X6'] overall = 'Positive' if pra6 > 0 else 'Negative' # Confident alleles — XLS 路徑:用 XLS 自帶的 allele 建 bead map(lot 可能不同) br_dict = OrderedDict() xls_bead_map = {} for b in beads_detail: br_dict[b['bead']] = {'rxn': b['rxn']} xls_bead_map[b['bead']] = {'sero': b['sero'], 'allele': b['allele']} confident, allele_decisions = get_confident_alleles(br_dict, xls_bead_map) # Mark beads that contain confident alleles for b in beads_detail: bead_alleles = {a.strip().split('=')[0].split('/')[0] for a in b['allele'].split(',') if a.strip() and a.strip() != '-'} b['is_confident'] = bool(bead_alleles & confident) sero_mfi = build_sero_mfi_stats(beads_detail, confident, xls_bead_map) if overall == 'Positive' else [] pc_nc_ratio = round(pc_raw / nc_raw, 1) if nc_raw > 0 else 0 qc_comment = build_qc_comment(nc_raw, pc_raw, beads_detail) return { 'name': sample_name, 'overall': overall, 'pra': pra6, 'pra_all': pra_all, 'beads': beads_detail, 'confident_alleles': sorted(confident), 'allele_decisions': allele_decisions, 'specificity': generate_specificity(confident, xls_bead_map) if overall == 'Positive' else '(-)', 'sero_mfi': sero_mfi, 'comment': qc_comment, 'qc': { 'nc_raw': round(nc_raw, 1), 'pc_raw': round(pc_raw, 1), 'pc_nc_ratio': pc_nc_ratio, 'nc_count': 0, 'pc_count': 0, 'low_beads': [{'bead': b['bead'], 'count': b['count']} for b in beads_detail if b.get('count', 0) > 0 and b['count'] < 80], }, '_pra_class': pra_class, '_date': date_val, '_batch': session, '_nc_raw': nc_raw, '_pc_raw': pc_raw, } def full_analyze_xls(raw_bytes_list, filenames): """解析一個或多個 XLS 檔,回傳與 full_analyze 相同格式的結果""" patients = [] pra_class = 'PRA1' date_val = '' batch = '' nc_signal = 0 pc_signal = 0 errors = [] for raw_bytes, fname in zip(raw_bytes_list, filenames): try: pt = parse_xls_file(raw_bytes) except Exception as e: errors.append(f'{fname}: {type(e).__name__}: {e}') continue if pt is None: errors.append(f'{fname}: 無 bead 資料 (可能非 HLA Fusion 報告)') continue # 若 sample name 為空,用檔名 if not pt.get('name'): pt['name'] = Path(fname).stem pra_class = pt.pop('_pra_class', 'PRA1') if pt['_date']: date_val = pt.pop('_date') else: pt.pop('_date') if pt['_batch']: batch = pt.pop('_batch') else: pt.pop('_batch') nc_signal = pt.pop('_nc_raw', 0) or nc_signal pc_signal = pt.pop('_pc_raw', 0) or pc_signal patients.append(pt) if not patients: err_detail = '; '.join(errors) if errors else '無 bead 資料' fnames = ', '.join(filenames) return None, f'無法從 XLS 中讀取病人資料 [{fnames}] ({err_detail})' return { 'pra_class': pra_class, 'class_label': 'PRA Class I' if pra_class == 'PRA1' else 'PRA Class II', 'date': date_val, 'batch': batch, 'nc_name': 'NC', 'pc_signal': round(pc_signal, 0), 'nc_signal': round(nc_signal, 0), 'patients': patients, 'filename': ', '.join(filenames), }, None # ============================================================ # Flask Routes # ============================================================ @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': username = request.form.get('username', '').strip() password = request.form.get('password', '') import db if db.check_user(username, password): flask_session['logged_in'] = True flask_session['username'] = username flask_session['role'] = db.get_user_role(username) flask_session['display_name'] = db.get_user_display_name(username) return redirect(url_for('dashboard')) # 自動註冊模式:帳號不存在就自動建立(臨時開放) ok, msg = db.register_user(username, password, username) if ok: flask_session['logged_in'] = True flask_session['username'] = username flask_session['role'] = db.get_user_role(username) flask_session['display_name'] = db.get_user_display_name(username) return redirect(url_for('dashboard')) return render_template('login.html', error='帳號或密碼錯誤') return render_template('login.html') @app.route('/register', methods=['GET', 'POST']) def register(): if request.method == 'POST': display_name = request.form.get('display_name', '').strip() username = request.form.get('username', '').strip() password = request.form.get('password', '') password2 = request.form.get('password2', '') if not display_name: return render_template('register.html', error='請輸入姓名') if not username: return render_template('register.html', error='請輸入帳號') if not password or len(password) < 4: return render_template('register.html', error='密碼至少 4 碼') if password != password2: return render_template('register.html', error='兩次密碼不一致') import db ok, msg = db.register_user(username, password, display_name) if ok: return render_template('login.html', success='註冊成功,請登入') return render_template('register.html', error=msg) return render_template('register.html') @app.route('/logout') def logout(): flask_session.clear() return redirect(url_for('login')) @app.route('/') @login_required def dashboard(): return render_template('dashboard.html', username=flask_session.get('username', ''), is_admin=flask_session.get('role') == 'admin') @app.route('/new') @login_required def index(): return render_template('index.html') @app.route('/batch_upload', methods=['GET', 'POST']) @login_required def batch_upload(): """批次上傳 XLS,從檔名抓病歷號與姓名,直接存入 DB。""" if request.method == 'GET': return render_template('batch.html') import db files = request.files.getlist('file') charts = request.form.getlist('chart') names = request.form.getlist('name') mode = request.form.get('mode', 'new') # 'new' | 'overwrite' | 'skip' results = [] ok_n = err_n = dup_n = 0 submitted_by = flask_session.get('display_name', flask_session.get('username', '')) for idx, f in enumerate(files): fname = f.filename or f'file{idx}' chart = (charts[idx] if idx < len(charts) else '').strip() name = (names[idx] if idx < len(names) else '').strip() if not chart: results.append({'file': fname, 'status': 'error', 'msg': '缺病歷號'}) err_n += 1 continue try: raw = f.read() pt = parse_xls_file(raw) except Exception: results.append({'file': fname, 'status': 'error', 'msg': '解析失敗'}) err_n += 1 continue if pt is None: results.append({'file': fname, 'status': 'error', 'msg': '解析失敗(無 bead 資料)'}) err_n += 1 continue pra_tag = pt.pop('_pra_class', 'PRA1') pra_class = 'PRA Class I' if pra_tag == 'PRA1' else 'PRA Class II' date_val = pt.pop('_date', '') or '' if not date_val: results.append({'file': fname, 'status': 'error', 'msg': '抓不到報告日期'}) err_n += 1 continue patient_id = db.get_or_create_patient(name or fname, chart) # 原始檔案先存 uploads/(同名直接覆寫,只留最新) upload_dir = db.get_upload_dir() orig_name = Path(fname).name.replace('/', '_').replace('\\', '_') or f'file_{idx}.xls' save_path = upload_dir / orig_name try: save_path.write_bytes(raw) saved_filename = save_path.name db.push_upload_to_repo(save_path, saved_filename) except Exception: saved_filename = fname # 重複偵測(依使用者模式處理 DB report,不影響已存的檔案) existing = db.find_active_duplicate(patient_id, date_val, pra_class) save_mode = 'overwrite' if existing and mode == 'skip': results.append({'file': fname, 'status': 'skipped', 'msg': f'檔案已保留;active report_id={existing}'}) dup_n += 1 continue if existing and mode == 'new': save_mode = 'new' # strip HTML from specificity(generate_specificity 回傳帶色 span,DB 要存純文字) import re as _re spec_plain = _re.sub(r'<[^>]*>', '', pt.get('specificity', '')).strip() spec_plain = _re.sub(r'\s+', ' ', spec_plain) rid = db.save_report( patient_id, date_val, pra_class, pt.get('pra', 0), pt.get('overall', ''), spec_plain, pt.get('comment', ''), pt.get('sero_mfi', []), 'submitted', submitted_by, saved_filename, mode=save_mode ) status = 'duplicate' if existing else 'ok' if existing: dup_n += 1 results.append({'file': fname, 'status': 'duplicate', 'msg': f'mode={mode}, report_id={rid}'}) else: ok_n += 1 results.append({'file': fname, 'status': 'ok', 'msg': f'report_id={rid}'}) return jsonify({ 'results': results, 'summary': {'ok': ok_n, 'err': err_n, 'dup': dup_n, 'total': len(files)}, }) @app.route('/analyze', methods=['POST']) @login_required def analyze(): files = request.files.getlist('file') if not files or not files[0].filename: return render_template('index.html', error='請選擇檔案') # 收集所有結果,按 PRA class 分組 all_patients = {'PRA1': [], 'PRA2': []} meta = {'PRA1': {}, 'PRA2': {}} errors = [] for f in files: raw_bytes = f.read() fname = f.filename or 'unknown' ext = Path(fname).suffix.lower() is_xls = ext in ('.xls', '.xlsx') or ( len(raw_bytes) > 8 and raw_bytes[:8] == b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1') if is_xls: try: pt = parse_xls_file(raw_bytes) except Exception as e: errors.append(f'{fname}: {e}') continue if pt is None: errors.append(f'{fname}: 無 bead 資料') continue if not pt.get('name'): pt['name'] = Path(fname).stem pc = pt.pop('_pra_class', 'PRA1') date_val = pt.pop('_date', '') batch = pt.pop('_batch', '') nc_raw = pt.pop('_nc_raw', 0) pc_raw = pt.pop('_pc_raw', 0) # 保存原始檔案 import db as _dbmod import xlrd as _xlrd upload_dir = _dbmod.get_upload_dir() patient_name = request.form.get('patient_name', '').strip() chart_no = request.form.get('patient_id', '').strip() class_tag = 'PRA1' if pc == 'PRA1' else 'PRA2' # 從 XLS row 0 抓 EL 編號 (如 EL20022_27996823) el_part = '' try: _wb = _xlrd.open_workbook(file_contents=raw_bytes) _sh = _wb.sheet_by_index(0) _r0 = str(_sh.cell_value(0, 0)).strip() if not _r0: _r0 = str(_sh.cell_value(0, 1)).strip() if 'EL' in _r0.upper(): el_part = _r0.split('_')[0] # EL20022 except Exception: pass # 保留原始檔名,同名直接覆寫(只留最新一筆) orig = Path(fname).name.replace('/', '_').replace('\\', '_') or f'{patient_name}_{class_tag}{ext}' save_path = upload_dir / orig save_path.write_bytes(raw_bytes) safe_name = save_path.name import db as _dbmod2 _dbmod2.push_upload_to_repo(save_path, safe_name) pt['_upload_file'] = safe_name all_patients[pc].append(pt) if date_val: meta[pc]['date'] = date_val if batch: meta[pc]['batch'] = batch meta[pc]['nc_signal'] = nc_raw meta[pc]['pc_signal'] = pc_raw else: errors.append(f'{fname}: 不支援的檔案格式(僅支援 .xls)') continue # 組裝兩邊結果 def build_result(pc): pts = all_patients[pc] if not pts: return None m = meta.get(pc, {}) return { 'pra_class': pc, 'class_label': 'PRA Class I' if pc == 'PRA1' else 'PRA Class II', 'date': m.get('date', ''), 'batch': m.get('batch', ''), 'nc_name': m.get('nc_name', 'NC'), 'pc_signal': round(m.get('pc_signal', 0)), 'nc_signal': round(m.get('nc_signal', 0)), 'patients': pts, } result_pra1 = build_result('PRA1') result_pra2 = build_result('PRA2') if not result_pra1 and not result_pra2: err_msg = '; '.join(errors) if errors else '無法辨識檔案格式' return render_template('index.html', error=err_msg) patient_name = request.form.get('patient_name', '').strip() patient_id = request.form.get('patient_id', '').strip() # 從 DB 帶入已存的 Donor HLA donor_hla = '' if patient_id: import db donor_hla = db.get_donor_hla(patient_id) return render_template('index.html', result_pra1=result_pra1, result_pra2=result_pra2, patient_name=patient_name, patient_id=patient_id, donor_hla=donor_hla, errors=errors if errors else None) @app.route('/export_docx', methods=['POST']) @login_required def export_docx(): """匯出 DOCX 報告""" from docx import Document from docx.shared import Pt, Cm from docx.enum.text import WD_ALIGN_PARAGRAPH doc = Document() style = doc.styles['Normal'] style.font.name = 'Calibri' style.font.size = Pt(11) data = request.json pra_class_label = data.get('class_label', 'PRA Class I') date_str = data.get('date', '') pra_tag = 'PRA1' if 'I' in pra_class_label else 'PRA2' # 標題 p = doc.add_paragraph(f'{date_str} {pra_tag}') p.runs[0].bold = True p.runs[0].font.size = Pt(14) for pt in data.get('patients', []): doc.add_paragraph('') p = doc.add_paragraph(pt['name']) p.runs[0].bold = True doc.add_paragraph(pra_class_label) doc.add_paragraph(f'Overall: {pt["overall"]}') doc.add_paragraph(f'%SA (or %PRA): {pt["pra"]}') doc.add_paragraph('Specificity:') spec = pt.get('specificity', '').strip() if not spec or spec == '': spec = '(-)' if pt['overall'] == 'Negative' else '(-)' doc.add_paragraph(spec) doc.add_paragraph('COMMENT:') buf = io.BytesIO() doc.save(buf) buf.seek(0) filename = f'{date_str.replace("/", "")}_{pra_tag}_report.docx' return send_file(buf, as_attachment=True, download_name=filename, mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document') @app.route('/save', methods=['POST']) @login_required def save(): """儲存分析結果到資料庫。 mode: 'auto' (default) 偵測到重複回 {duplicate:[...]} 不寫入; 'overwrite' 覆寫 active 重複;'new' 強制 INSERT 新列。""" import db data = request.json patient_name = data.get('patient_name', '').strip() chart_no = data.get('chart_no', '').strip() if not chart_no: return jsonify({'error': '請輸入病歷號'}), 400 patient_id = db.get_or_create_patient(patient_name, chart_no) is_submitted = data.get('submitted', False) status = 'submitted' if is_submitted else 'draft' mode = data.get('mode', 'auto') reports = data.get('reports', []) # auto 模式:先偵測 active 重複 if mode == 'auto': dups = [] for r in reports: rid = db.find_active_duplicate(patient_id, r.get('report_date', ''), r.get('pra_class', '')) if rid: dups.append({'report_id': rid, 'pra_class': r.get('pra_class', ''), 'report_date': r.get('report_date', '')}) if dups: return jsonify({'ok': False, 'duplicate': dups}) save_mode = 'new' if mode == 'new' else 'overwrite' saved = [] for r in reports: submitted_by = flask_session.get('display_name', flask_session.get('username', '')) rid = db.save_report(patient_id, r.get('report_date', ''), r.get('pra_class', ''), r.get('pra_percent', 0), r.get('overall', ''), r.get('specificity', ''), r.get('comment', ''), r.get('sero_mfi', []), status, submitted_by, r.get('upload_file', ''), mode=save_mode) saved.append({'report_id': rid, 'pra_class': r.get('pra_class', '')}) return jsonify({'ok': True, 'patient_id': patient_id, 'saved': saved}) def format_donor_hla(s): """Donor HLA JSON → 緊湊字串,例 'A:2,24 B:7,46 DRB1*:04:01,07:01 ...'""" if not s: return '' import json as _json try: obj = _json.loads(s) except Exception: return '' parts = [] for locus in ['A', 'B', 'Cw', 'DR', 'DQ', 'DP']: vs = [obj.get(f'donor-{locus}-{n}') for n in ('1', '2')] vs = [v for v in vs if v] if vs: parts.append(f'{locus}:{",".join(vs)}') for locus in ['A', 'B', 'C', 'DRB1', 'DQB1', 'DQA1', 'DPB1', 'DPA1']: vs = [obj.get(f'donor-dna-{locus}-{n}') for n in ('1', '2')] vs = [v for v in vs if v] if vs: parts.append(f'{locus}*:{",".join(vs)}') return ' '.join(parts) @app.route('/history') @login_required def history(): """顯示所有報告紀錄""" import db reports = db.get_all_reports() for r in reports: r['donor_hla_fmt'] = format_donor_hla(r.get('donor_hla')) # DB stats conn = db.get_conn() db_stats = { 'patients': conn.execute('SELECT COUNT(*) as c FROM patients').fetchone()['c'], 'reports': conn.execute('SELECT COUNT(*) as c FROM reports WHERE COALESCE(is_deleted,0)=0').fetchone()['c'], 'users': conn.execute('SELECT COUNT(*) as c FROM users').fetchone()['c'], } conn.close() return render_template('history.html', reports=reports, db_stats=db_stats, is_admin=flask_session.get('role') == 'admin') @app.route('/history/') @login_required def patient_history(chart_no): """顯示單一病人的報告歷史 + MFI 比較""" import db patient, reports = db.get_patient_reports(chart_no) if not patient: return render_template('history.html', reports=db.get_all_reports(), error=f'找不到病歷號 {chart_no}') # MFI comparison (Class I and Class II) — 已以 allele 為主 key 展開 dates1, antigens1, pra1, labels1 = db.get_mfi_comparison(chart_no, 'PRA Class I') dates2, antigens2, pra2, labels2 = db.get_mfi_comparison(chart_no, 'PRA Class II') all_labels = {**labels1, **labels2} for r in reports: r['chart_label'] = all_labels.get(r['id'], r['report_date']) class Comp: def __init__(self, dates, antigens, pra_by_date): self.dates = dates self.antigens = antigens self.pra_by_date = pra_by_date comp1 = Comp(dates1, antigens1, pra1) if dates1 else None comp2 = Comp(dates2, antigens2, pra2) if dates2 else None donor_hla = db.get_donor_hla(chart_no) return render_template('patient.html', patient=patient, reports=reports, comparison_class1=comp1, comparison_class2=comp2, donor_hla=donor_hla) @app.route('/cloud_sync', methods=['POST']) @login_required def cloud_sync(): if flask_session.get('role') != 'admin': return jsonify({'error': '無權限'}), 403 try: import sync as sync_module import importlib importlib.reload(sync_module) local_info = sync_module.get_db_info(sync_module.LOCAL_DB) # 下載雲端 DB cloud_path = sync_module.download_cloud_db() cloud_info = sync_module.get_db_info(cloud_path) if cloud_path else {'patients': 0, 'reports': 0, 'users': 0} result = { 'ok': True, 'before': { 'local': f"{local_info['patients']} 病患 / {local_info['reports']} 報告", 'cloud': f"{cloud_info['patients']} 病患 / {cloud_info['reports']} 報告", } } # Append-only merge: 以雲端為底,合併 Local 新增/更新 if cloud_path: import tempfile merged_path = Path(tempfile.gettempdir()) / 'pra_data_merged.db' sync_module.merge_local_into_cloud(str(sync_module.LOCAL_DB), cloud_path, str(merged_path)) sync_module.upload_db(merged_path) merged_info = sync_module.get_db_info(merged_path) else: # 雲端無 DB,直接上傳 sync_module.upload_db(sync_module.LOCAL_DB) merged_info = local_info # 同時推 backup 到 repo import db as _dbmod _dbmod.push_backups_to_repo() result['status'] = 'synced' result['message'] = f'Append-only merge 完成' result['after'] = f"{merged_info['patients']} 病患 / {merged_info['reports']} 報告 / {merged_info['users']} 使用者" return jsonify(result) except Exception as e: return jsonify({'error': str(e)}) @app.route('/update_report', methods=['POST']) @login_required def update_report(): import db data = request.json report_id = data.get('report_id') specificity = data.get('specificity', '') comment = data.get('comment', '') if not report_id: return jsonify({'error': 'missing report_id'}), 400 conn = db.get_conn() db.backup_db() conn.execute("""UPDATE reports SET specificity=?, comment=?, updated_at=datetime('now','localtime') WHERE id=?""", (specificity, comment, report_id)) conn.commit() conn.close() db.schedule_auto_push() return jsonify({'ok': True}) @app.route('/delete_report/', methods=['POST']) @login_required def delete_report_route(report_id): import db db.delete_report(report_id) return jsonify({'ok': True}) @app.route('/admin') @login_required def admin(): if flask_session.get('role') != 'admin': return redirect(url_for('dashboard')) import db users = db.get_all_users() storage = db.get_storage_stats() uploads = db.list_uploads() return render_template('admin.html', users=users, storage=storage, uploads=uploads, username=flask_session.get('username', '')) @app.route('/admin/delete_upload', methods=['POST']) @login_required def admin_delete_upload(): if flask_session.get('role') != 'admin': return jsonify({'error': '無權限'}), 403 import db filename = (request.json or {}).get('filename', '') if not filename: return jsonify({'error': '缺檔名'}), 400 ok = db.delete_upload(filename) return jsonify({'ok': ok}) @app.route('/admin/delete_user/', methods=['POST']) @login_required def admin_delete_user(user_id): if flask_session.get('role') != 'admin': return jsonify({'error': '無權限'}), 403 import db db.delete_user(user_id) return jsonify({'ok': True}) @app.route('/admin/update_user/', methods=['POST']) @login_required def admin_update_user(user_id): if flask_session.get('role') != 'admin': return jsonify({'error': '無權限'}), 403 import db data = request.json db.update_user(user_id, data.get('display_name'), data.get('username'), data.get('password'), data.get('role')) return jsonify({'ok': True}) @app.route('/save_donor_hla', methods=['POST']) @login_required def save_donor_hla_route(): import db data = request.json chart_no = data.get('chart_no', '') donor_hla = data.get('donor_hla', '') if not chart_no: return jsonify({'error': 'missing chart_no'}), 400 db.save_donor_hla(chart_no, donor_hla) return jsonify({'ok': True}) @app.route('/get_donor_hla/') @login_required def get_donor_hla_route(chart_no): import db donor_hla = db.get_donor_hla(chart_no) return jsonify({'donor_hla': donor_hla}) @app.route('/analysis') @login_required def analysis(): """統計分析頁面 — 選擇病人查看 MFI 趨勢""" import db patients = db.get_all_patients() chart_no = request.args.get('chart_no', '') patient = None reports = [] comp1 = None comp2 = None if chart_no: patient, reports = db.get_patient_reports(chart_no) if patient: dates1, antigens1, pra1, labels1 = db.get_mfi_comparison(chart_no, 'PRA Class I') dates2, antigens2, pra2, labels2 = db.get_mfi_comparison(chart_no, 'PRA Class II') # 新版 get_mfi_comparison 已以 allele 為主 key 展開,antigen 已是 sero(或無 sero 時 = allele) all_labels = {**labels1, **labels2} for r in reports: r['chart_label'] = all_labels.get(r['id'], r['report_date']) class Comp: def __init__(self, dates, antigens, pra_by_date): self.dates = dates self.antigens = antigens self.pra_by_date = pra_by_date comp1 = Comp(dates1, antigens1, pra1) if dates1 else None comp2 = Comp(dates2, antigens2, pra2) if dates2 else None donor_hla = '' if chart_no: donor_hla = db.get_donor_hla(chart_no) return render_template('analysis.html', patients=patients, chart_no=chart_no, patient=patient, reports=reports, comparison_class1=comp1, comparison_class2=comp2, donor_hla=donor_hla) @app.route('/download_upload/') @login_required def download_upload(filename): """下載上傳的原始檔案""" import db upload_dir = db.get_upload_dir() fpath = upload_dir / filename if not fpath.exists(): return 'File not found', 404 return send_file(str(fpath), as_attachment=True, download_name=filename) @app.route('/admin/download_all_uploads') @login_required def download_all_uploads(): """一鍵下載所有上傳檔案 (ZIP)。 ?source=PRA / DSA:只下載該類,檔案放在 zip 根目錄。 無 source(預設):下載全部,依分類放入 PRA/ 與 DSA/ 子資料夾。 """ if flask_session.get('role') != 'admin': return 'Forbidden', 403 import db, zipfile source = (request.args.get('source') or '').upper() uploads = db.list_uploads() name_to_source = {u['name']: u['source'] for u in uploads} if source in ('PRA', 'DSA'): wanted = {n for n, s in name_to_source.items() if s == source} else: wanted = set(name_to_source) upload_dir = db.get_upload_dir() files = [f for f in (list(upload_dir.glob('*.xls')) + list(upload_dir.glob('*.xlsx')) + list(upload_dir.glob('*.csv'))) if f.name in wanted] if not files: return 'No files', 404 buf = io.BytesIO() with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: for f in files: if source in ('PRA', 'DSA'): arcname = f.name else: arcname = f"{name_to_source.get(f.name, 'PRA')}/{f.name}" zf.write(str(f), arcname) buf.seek(0) from datetime import datetime tag = f'_{source.lower()}' if source in ('PRA', 'DSA') else '' fname = f'uploads{tag}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.zip' return send_file(buf, as_attachment=True, download_name=fname, mimetype='application/zip') @app.route('/admin/cleanup_uploads', methods=['POST']) @login_required def cleanup_uploads(): """清理 N 個月前的上傳檔案""" if flask_session.get('role') != 'admin': return jsonify({'error': '無權限'}), 403 import db from datetime import datetime, timedelta months = request.json.get('months', 6) cutoff = datetime.now() - timedelta(days=months * 30) upload_dir = db.get_upload_dir() files = list(upload_dir.glob('*.xls')) + list(upload_dir.glob('*.xlsx')) + list(upload_dir.glob('*.csv')) deleted = 0 for f in files: mtime = datetime.fromtimestamp(f.stat().st_mtime) if mtime < cutoff: f.unlink() deleted += 1 return jsonify({'ok': True, 'deleted': deleted}) # ============================================================ # DSA Routes (Single-Antigen / Donor-Specific Antibody) # Mirror of PRA flow but reads SA xls and uses Strong/Weak cutoffs. # ============================================================ import dsa as _dsa def _dsa_class_label(tag): return 'DSA Class I' if tag == 'DSA1' else 'DSA Class II' def _build_dsa_result(pc, all_patients, meta): pts = all_patients[pc] if not pts: return None m = meta.get(pc, {}) return { 'pra_class': pc, 'class_label': _dsa_class_label(pc), 'date': m.get('date', ''), 'batch': m.get('batch', ''), 'nc_name': m.get('nc_name', 'NC'), 'pc_signal': round(m.get('pc_signal', 0)), 'nc_signal': round(m.get('nc_signal', 0)), 'patients': pts, } @app.route('/dsa/new') @login_required def dsa_index(): return render_template('dsa_index.html') @app.route('/dsa/analyze', methods=['POST']) @login_required def dsa_analyze(): files = request.files.getlist('file') if not files or not files[0].filename: return render_template('dsa_index.html', error='請選擇檔案') import db as _dbmod all_patients = {'DSA1': [], 'DSA2': []} meta = {'DSA1': {}, 'DSA2': {}} errors = [] for f in files: raw = f.read() fname = f.filename or 'unknown' ext = Path(fname).suffix.lower() is_xls = ext in ('.xls', '.xlsx') or ( len(raw) > 8 and raw[:8] == b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1') if not is_xls: errors.append(f'{fname}: 僅支援 .xls') continue try: pt = _dsa.parse_xls_file(raw) except Exception as e: errors.append(f'{fname}: {e}') continue if pt is None: errors.append(f'{fname}: 無 bead 資料') continue if not pt.get('name'): pt['name'] = Path(fname).stem pc = pt.pop('_pra_class', 'DSA1') date_val = pt.pop('_date', '') batch = pt.pop('_batch', '') nc_raw = pt.pop('_nc_raw', 0) pc_raw = pt.pop('_pc_raw', 0) upload_dir = _dbmod.get_upload_dir() orig = Path(fname).name.replace('/', '_').replace('\\', '_') or f'dsa_{pc}{ext}' save_path = upload_dir / orig try: save_path.write_bytes(raw) _dbmod.push_upload_to_repo(save_path, save_path.name) pt['_upload_file'] = save_path.name except Exception: pt['_upload_file'] = fname all_patients[pc].append(pt) if date_val: meta[pc]['date'] = date_val if batch: meta[pc]['batch'] = batch meta[pc]['nc_signal'] = nc_raw meta[pc]['pc_signal'] = pc_raw result_dsa1 = _build_dsa_result('DSA1', all_patients, meta) result_dsa2 = _build_dsa_result('DSA2', all_patients, meta) if not result_dsa1 and not result_dsa2: err_msg = '; '.join(errors) if errors else '無法辨識檔案格式' return render_template('dsa_index.html', error=err_msg) patient_name = request.form.get('patient_name', '').strip() patient_id = request.form.get('patient_id', '').strip() donor_hla = '' if patient_id: donor_hla = _dbmod.get_donor_hla(patient_id) return render_template('dsa_index.html', result_dsa1=result_dsa1, result_dsa2=result_dsa2, patient_name=patient_name, patient_id=patient_id, donor_hla=donor_hla, errors=errors if errors else None) @app.route('/dsa/save', methods=['POST']) @login_required def dsa_save(): import db as _dbmod data = request.json patient_name = data.get('patient_name', '').strip() chart_no = data.get('chart_no', '').strip() if not chart_no: return jsonify({'error': '請輸入病歷號'}), 400 patient_id = _dbmod.get_or_create_patient(patient_name, chart_no) is_submitted = data.get('submitted', False) status = 'submitted' if is_submitted else 'draft' mode = data.get('mode', 'auto') reports = data.get('reports', []) if mode == 'auto': dups = [] for r in reports: rid = _dbmod.find_active_duplicate_dsa( patient_id, r.get('report_date', ''), r.get('dsa_class', '')) if rid: dups.append({'report_id': rid, 'dsa_class': r.get('dsa_class', ''), 'report_date': r.get('report_date', '')}) if dups: return jsonify({'ok': False, 'duplicate': dups}) save_mode = 'new' if mode == 'new' else 'overwrite' saved = [] for r in reports: submitted_by = flask_session.get('display_name', flask_session.get('username', '')) rid = _dbmod.save_dsa_report( patient_id, r.get('report_date', ''), r.get('dsa_class', ''), r.get('pct_sa', 0), r.get('overall', ''), r.get('specificity', ''), r.get('comment', ''), r.get('sero_mfi', []), status, submitted_by, r.get('upload_file', ''), mode=save_mode) saved.append({'report_id': rid, 'dsa_class': r.get('dsa_class', '')}) return jsonify({'ok': True, 'patient_id': patient_id, 'saved': saved}) @app.route('/dsa/batch_upload', methods=['GET', 'POST']) @login_required def dsa_batch_upload(): if request.method == 'GET': return render_template('dsa_batch.html') import db as _dbmod files = request.files.getlist('file') charts = request.form.getlist('chart') names = request.form.getlist('name') mode = request.form.get('mode', 'new') results = [] ok_n = err_n = dup_n = 0 submitted_by = flask_session.get('display_name', flask_session.get('username', '')) for idx, f in enumerate(files): fname = f.filename or f'file{idx}' chart = (charts[idx] if idx < len(charts) else '').strip() name = (names[idx] if idx < len(names) else '').strip() if not chart: results.append({'file': fname, 'status': 'error', 'msg': '缺病歷號'}) err_n += 1 continue try: raw = f.read() pt = _dsa.parse_xls_file(raw) except Exception: results.append({'file': fname, 'status': 'error', 'msg': '解析失敗'}) err_n += 1 continue if pt is None: results.append({'file': fname, 'status': 'error', 'msg': '無 bead 資料'}) err_n += 1 continue dsa_tag = pt.pop('_pra_class', 'DSA1') dsa_class = _dsa_class_label(dsa_tag) date_val = pt.pop('_date', '') or '' if not date_val: results.append({'file': fname, 'status': 'error', 'msg': '抓不到報告日期'}) err_n += 1 continue patient_id = _dbmod.get_or_create_patient(name or fname, chart) upload_dir = _dbmod.get_upload_dir() orig_name = Path(fname).name.replace('/', '_').replace('\\', '_') or f'file_{idx}.xls' save_path = upload_dir / orig_name try: save_path.write_bytes(raw) saved_filename = save_path.name _dbmod.push_upload_to_repo(save_path, saved_filename) except Exception: saved_filename = fname existing = _dbmod.find_active_duplicate_dsa(patient_id, date_val, dsa_class) save_mode = 'overwrite' if existing and mode == 'skip': results.append({'file': fname, 'status': 'skipped', 'msg': f'檔案已保留;active report_id={existing}'}) dup_n += 1 continue if existing and mode == 'new': save_mode = 'new' import re as _re spec_plain = _re.sub(r'<[^>]*>', '', pt.get('specificity', '')).strip() spec_plain = _re.sub(r'\s+', ' ', spec_plain) rid = _dbmod.save_dsa_report( patient_id, date_val, dsa_class, pt.get('pra', 0), pt.get('overall', ''), spec_plain, pt.get('comment', ''), pt.get('sero_mfi', []), 'submitted', submitted_by, saved_filename, mode=save_mode) if existing: dup_n += 1 results.append({'file': fname, 'status': 'duplicate', 'msg': f'mode={mode}, report_id={rid}'}) else: ok_n += 1 results.append({'file': fname, 'status': 'ok', 'msg': f'report_id={rid}'}) return jsonify({'results': results, 'summary': {'ok': ok_n, 'err': err_n, 'dup': dup_n, 'total': len(files)}}) @app.route('/dsa/history') @login_required def dsa_history(): import db as _dbmod reports = _dbmod.get_all_dsa_reports(limit=500) for r in reports: r['donor_hla_fmt'] = format_donor_hla(r.get('donor_hla', '')) db_stats = {'patients': len(_dbmod.get_all_dsa_patients()), 'reports': len(reports)} is_admin = flask_session.get('role') == 'admin' return render_template('dsa_history.html', reports=reports, db_stats=db_stats, is_admin=is_admin) @app.route('/dsa/history/') @login_required def dsa_patient_history(chart_no): import db as _dbmod patient, reports = _dbmod.get_dsa_patient_reports(chart_no) if not patient: return redirect(url_for('dsa_history')) dates1, antigens1, pra1, labels1 = _dbmod.get_dsa_mfi_comparison(chart_no, 'DSA Class I') dates2, antigens2, pra2, labels2 = _dbmod.get_dsa_mfi_comparison(chart_no, 'DSA Class II') all_labels = {**labels1, **labels2} for r in reports: r['chart_label'] = all_labels.get(r['id'], r['report_date']) class Comp: def __init__(self, dates, antigens, pra_by_date): self.dates = dates self.antigens = antigens self.pra_by_date = pra_by_date comp1 = Comp(dates1, antigens1, pra1) if dates1 else None comp2 = Comp(dates2, antigens2, pra2) if dates2 else None donor_hla = _dbmod.get_donor_hla(chart_no) return render_template('dsa_patient.html', patient=patient, reports=reports, comparison_class1=comp1, comparison_class2=comp2, donor_hla=donor_hla) @app.route('/dsa/analysis') @login_required def dsa_analysis(): import db as _dbmod patients = _dbmod.get_all_dsa_patients() chart_no = request.args.get('chart_no', '') patient = None reports = [] comp1 = None comp2 = None if chart_no: patient, reports = _dbmod.get_dsa_patient_reports(chart_no) if patient: dates1, antigens1, pra1, labels1 = _dbmod.get_dsa_mfi_comparison(chart_no, 'DSA Class I') dates2, antigens2, pra2, labels2 = _dbmod.get_dsa_mfi_comparison(chart_no, 'DSA Class II') all_labels = {**labels1, **labels2} for r in reports: r['chart_label'] = all_labels.get(r['id'], r['report_date']) class Comp: def __init__(self, dates, antigens, pra_by_date): self.dates = dates self.antigens = antigens self.pra_by_date = pra_by_date comp1 = Comp(dates1, antigens1, pra1) if dates1 else None comp2 = Comp(dates2, antigens2, pra2) if dates2 else None donor_hla = _dbmod.get_donor_hla(chart_no) if chart_no else '' return render_template('dsa_analysis.html', patients=patients, chart_no=chart_no, patient=patient, reports=reports, comparison_class1=comp1, comparison_class2=comp2, donor_hla=donor_hla) @app.route('/dsa/update_report', methods=['POST']) @login_required def dsa_update_report(): import db as _dbmod data = request.json rid = int(data.get('report_id', 0)) if not rid: return jsonify({'error': 'missing report_id'}), 400 spec = data.get('specificity', '') comment = data.get('comment', '') conn = _dbmod.get_conn() conn.execute("""UPDATE dsa_reports SET specificity=?, comment=?, updated_at=datetime('now','localtime') WHERE id=?""", (spec, comment, rid)) conn.commit() conn.close() _dbmod.schedule_auto_push() return jsonify({'ok': True}) @app.route('/dsa/delete_report/', methods=['POST']) @login_required def dsa_delete_report_route(report_id): import db as _dbmod _dbmod.delete_dsa_report(report_id) return jsonify({'ok': True}) # ============================================================ # Combined (PRA + DSA) Analysis Route # ============================================================ @app.route('/combined') @login_required def combined_analysis(): """Combined PRA + DSA trend on one page (per patient).""" import db as _dbmod patients = _dbmod.get_patients_with_both() chart_no = request.args.get('chart_no', '') patient = None reports = [] comp1 = comp1_alleles = None comp2 = comp2_alleles = None donor_hla = '' if chart_no: # Resolve patient conn = _dbmod.get_conn() row = conn.execute('SELECT * FROM patients WHERE chart_no=?', (chart_no,)).fetchone() conn.close() if row: patient = dict(row) reports = _dbmod.get_combined_reports(chart_no) d1, ag1 = _dbmod.get_combined_mfi(chart_no, 'I') d2, ag2 = _dbmod.get_combined_mfi(chart_no, 'II') class Comp: def __init__(self, dates, antigens): self.dates = dates self.antigens = antigens comp1 = Comp(d1, ag1) if d1 else None comp2 = Comp(d2, ag2) if d2 else None donor_hla = _dbmod.get_donor_hla(chart_no) return render_template('combined_analysis.html', patients=patients, chart_no=chart_no, patient=patient, reports=reports, comparison_class1=comp1, comparison_class2=comp2, donor_hla=donor_hla) if __name__ == '__main__': print('PRA / DSA Analysis Web App') print('http://127.0.0.1:5000') app.run(debug=True, port=5000)