plokmii commited on
Commit
834bcd3
·
verified ·
1 Parent(s): 7ca1c9c

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +1070 -0
app.py ADDED
@@ -0,0 +1,1070 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ PRA Analysis Web App
4
+ 上傳 xPONENT CSV → 自動分析 PRA Class I / II → 產生報告
5
+ """
6
+
7
+ import io
8
+ import os
9
+ import re
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+ from collections import OrderedDict
14
+ from datetime import datetime
15
+
16
+ from flask import Flask, render_template, request, send_file, jsonify, redirect, url_for, session as flask_session
17
+
18
+ # 匯入 PRA.py 核心邏輯
19
+ from PRA import (
20
+ parse_xponent_csv, get_rxn, load_cutoffs, find_nc_sample,
21
+ NC_BEAD, PC_BEAD, CONTROL_BEADS,
22
+ BEAD_HLA_LOT21, _parse_allele_list,
23
+ )
24
+
25
+ app = Flask(__name__)
26
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB
27
+ app.secret_key = 'pra-analysis-2025'
28
+
29
+ DEFAULT_USERS = {'NEPH': 'NEPH12345', 'okokyytt@gmail.com': '1234'}
30
+
31
+ ADMIN_USER = 'okokyytt@gmail.com'
32
+
33
+ import db as _db
34
+ _db.seed_default_users(DEFAULT_USERS, admin_user=ADMIN_USER)
35
+
36
+
37
+ def login_required(f):
38
+ from functools import wraps
39
+ @wraps(f)
40
+ def decorated(*args, **kwargs):
41
+ if not flask_session.get('logged_in'):
42
+ return redirect(url_for('login'))
43
+ return f(*args, **kwargs)
44
+ return decorated
45
+
46
+ CUTOFFS = load_cutoffs()
47
+
48
+ # ============================================================
49
+ # PRA2 Lot 20 — LABScreen PRA Class II Bead-to-HLA Mapping
50
+ # 35 HLA beads: 038-068, 090-094, 096, 098
51
+ # ============================================================
52
+
53
+ BEAD_HLA_LOT20 = OrderedDict([
54
+ ('038', {'sero': 'DR1, DR18, DR52, DQ4, DQ5, DP1',
55
+ 'allele': 'DRB1*01:02, DRB1*03:02, DRB3*01:62, DQA1*01:01, DQA1*04:01, DQB1*04:02, DQB1*05:01, DPA1*02:01, DPA1*02:02, DPB1*01:01'}),
56
+ ('039', {'sero': 'DR1, DR16, DQ5, DP3, DP4',
57
+ 'allele': 'DRB1*01:01, DRB1*16:01, DQA1*01:01, DQA1*01:02, DQB1*05:01, DQB1*05:02, DPA1*01:03, DPB1*03:01, DPB1*04:01'}),
58
+ ('042', {'sero': 'DR1, DR4, DQ7, DQ5, DP4',
59
+ 'allele': 'DRB1*01:01, DRB1*04:01, DRB4*01:03:01:02N, DQA1*01:01, DQA1*03:03, DQB1*03:01/276N, DQB1*05:01, DPA1*01:03, DPB1*04:01'}),
60
+ ('043', {'sero': 'DR1, DR7, DR53, DR51, DQ2, DQ5, DP2, DP4',
61
+ 'allele': 'DRB1*01:01, DRB1*07:01, DRB4*01:01, DRB5*01:01, DQA1*01:01, DQA1*02:01, DQB1*02:02, DQB1*05:01, DPA1*01:03, DPB1*02:01, DPB1*04:01'}),
62
+ ('044', {'sero': 'DR103, DR17, DR52, DQ2, DQ5, DP2, DP106',
63
+ 'allele': 'DRB1*01:03, DRB1*03:01, DRB3*02:02, DQA1*01:01, DQA1*05:01, DQB1*02:01/163N, DQB1*05:01, DPA1*01:03, DPA1*02:01, DPB1*02:01, DPB1*106:01'}),
64
+ ('045', {'sero': 'DR103, DR13, DR52, DQ7, DP2, DP131',
65
+ 'allele': 'DRB1*01:03, DRB1*13:04, DRB3*02:02, DQA1*05:05, DQB1*03:01, DQB1*03:19, DPA1*01:03, DPA1*02:01, DPB1*02:01, DPB1*131:01'}),
66
+ ('046', {'sero': 'DR4, DR11, DR52, DR53, DQ7, DQ8, DP2, DP4',
67
+ 'allele': 'DRB1*04:02, DRB1*11:04, DRB3*02:02, DRB4*01:03, DQA1*03:01, DQA1*05:05, DQB1*03:01, DQB1*03:02, DPA1*01:03, DPA1*02:01, DPB1*02:01, DPB1*04:01'}),
68
+ ('047', {'sero': 'DR4, DR12, DR52, DR53, DQ7, DQ8, DP2, DP5',
69
+ 'allele': 'DRB1*04:04, DRB1*12:02, DRB3*03:01, DRB4*01:03, DQA1*03:01, DQA1*06:01, DQB1*03:01, DQB1*03:02, DPA1*02:01, DPA1*02:02, DPB1*02:01, DPB1*05:01'}),
70
+ ('048', {'sero': 'DR4, DR14, DR52, DR53, DQ7, DQ5, DP1, DP3',
71
+ 'allele': 'DRB1*04:01, DRB1*14:54, DRB3*02:02, DRB4*01:03, DQA1*01:04, DQA1*03:03, DQB1*03:01/276N, DQB1*05:03, DPA1*01:03, DPA1*02:01, DPB1*01:01, DPB1*03:01'}),
72
+ ('050', {'sero': 'DR7, DR11, DR52, DR53, DQ2, DQ7, DP1, DP4',
73
+ 'allele': 'DRB1*07:01, DRB1*11:04, DRB3*02:02, DRB4*01:01, DQA1*02:01, DQA1*05:05, DQB1*02:02, DQB1*03:01, DPA1*01:03, DPA1*02:01, DPB1*01:01, DPB1*04:02'}),
74
+ ('051', {'sero': 'DR4, DR8, DR53, DQ8, DQ4, DP1, DP4',
75
+ 'allele': 'DRB1*04:04, DRB1*08:01, DRB4*01:03, DQA1*03:01, DQA1*04:02, DQB1*03:02, DQB1*04:02, DPA1*01:03, DPA1*02:01, DPB1*01:01, DPB1*04:02'}),
76
+ ('052', {'sero': 'DR9, DR11, DR52, DR53, DQ7, DQ9, DP5',
77
+ 'allele': 'DRB1*09:01, DRB1*11:01, DRB3*02:02, DRB4*01:03, DQA1*03:02, DQA1*05:05, DQB1*03:01, DQB1*03:03, DPA1*02:02, DPB1*05:01'}),
78
+ ('053', {'sero': 'DR9, DR10, DR53, DQ9, DQ5, DP5',
79
+ 'allele': 'DRB1*09:01, DRB1*10:01, DRB4*01:03, DQA1*01:05, DQA1*03:02, DQB1*03:03, DQB1*05:01, DPA1*02:02, DPB1*05:01'}),
80
+ ('054', {'sero': 'DR7, DR12, DR52, DQ9, DQ5, DP3, DP14',
81
+ 'allele': 'DRB1*07:01, DRB1*12:02, DRB3*03:01, DRB4*01:03:01:02N, DQA1*01:02, DQA1*02:01, DQB1*03:03, DQB1*05:02, DPA1*02:01, DPA1*02:02, DPB1*03:01, DPB1*14:01'}),
82
+ ('055', {'sero': 'DR7, DR15, DR53, DR51, DQ2, DQ6, DP4',
83
+ 'allele': 'DRB1*07:01, DRB1*15:01, DRB4*01:03, DRB5*01:01, DQA1*01:02, DQA1*02:01, DQB1*02:02, DQB1*06:03, DPA1*01:03, DPB1*04:01, DPB1*04:02'}),
84
+ ('056', {'sero': 'DR17, DR7, DR52, DR53, DQ2, DP2',
85
+ 'allele': 'DRB1*03:01, DRB1*07:01, DRB3*02:02, DRB4*01:01, DQA1*03:03, DQA1*05:01, DQB1*02:01/163N, DQB1*02:02, DPA1*01:03, DPB1*02:01'}),
86
+ ('057', {'sero': 'DR7, DR10, DR53, DQ2, DQ5, DP2, DP4',
87
+ 'allele': 'DRB1*07:01, DRB1*10:01, DRB4*01:01, DQA1*01:05, DQA1*03:03, DQB1*02:02, DQB1*05:01, DPA1*01:03, DPB1*02:01, DPB1*04:01'}),
88
+ ('058', {'sero': 'DR8, DR12, DR52, DQ9, DQ4, DP5',
89
+ 'allele': 'DRB1*08:02, DRB1*12:01, DRB3*01:01, DQA1*03:02, DQA1*04:01, DQB1*03:03, DQB1*04:02, DPA1*02:01, DPA1*02:02, DPB1*05:01'}),
90
+ ('059', {'sero': 'DR8, DR14, DR52, DQ4, DQ5, DP4',
91
+ 'allele': 'DRB1*08:01, DRB1*14:01, DRB3*02:24, DQA1*01:04, DQA1*04:01, DQB1*04:02, DQB1*05:03, DPA1*01:03, DPB1*04:01=DPB1*105:01, DPB1*04:02=DPB1*126:01'}),
92
+ ('060', {'sero': 'DR9, DR12, DR52, DR53, DQ7, DQ9, DP5',
93
+ 'allele': 'DRB1*09:01, DRB1*12:02, DRB3*02:02, DRB4*01:03, DQA1*03:02, DQA1*06:01, DQB1*03:01, DQB1*03:03, DPA1*02:02, DPB1*05:01'}),
94
+ ('061', {'sero': 'DR11, DR12, DR52, DQ7, DQ6, DP4, DP18',
95
+ 'allele': 'DRB1*11:01, DRB1*12:02, DRB3*02:02, DRB3*03:01, DQA1*01:02, DQA1*06:01, DQB1*03:01, DQB1*06:02, DPA1*01:03, DPB1*04:01, DPB1*18:01'}),
96
+ ('062', {'sero': 'DR4, DR11, DR52, DR53, DQ7, DP2, DP17',
97
+ 'allele': 'DRB1*04:03, DRB1*11:02, DRB3*02:02, DRB4*01:03, DQA1*03:01, DQA1*05:05, DQB1*03:04, DQB1*03:19, DPA1*01:03, DPA1*02:01, DPB1*02:01, DPB1*17:01'}),
98
+ ('063', {'sero': 'DR11, DR15, DR52, DR51, DQ6, DP2, DP18',
99
+ 'allele': 'DRB1*11:01, DRB1*15:03, DRB3*02:02, DRB5*01:01, DQA1*01:02, DQB1*06:02, DPA1*01:03, DPB1*02:01, DPB1*18:01'}),
100
+ ('064', {'sero': 'DR18, DR12, DR52, DQ4, DQ5, DP1, DP18',
101
+ 'allele': 'DRB1*03:02, DRB1*12:01, DRB3*01:01, DRB3*01:62, DQA1*01:05, DQA1*04:01, DQB1*04:02, DQB1*05:01, DPA1*01:03, DPA1*02:02, DPB1*01:01, DPB1*18:01'}),
102
+ ('065', {'sero': 'DR13, DR15, DR52, DR51, DQ6, DP3, DP13',
103
+ 'allele': 'DRB1*13:02, DRB1*15:01, DRB3*03:01, DRB5*01:01, DQA1*01:02, DQB1*06:02, DQB1*06:04, DPA1*01:03, DPA1*02:01, DPB1*03:01, DPB1*13:01'}),
104
+ ('066', {'sero': 'DR17, DR13, DR52, DQ2, DQ5, DP1, DP13',
105
+ 'allele': 'DRB1*03:01, DRB1*13:02, DRB3*02:02, DRB3*03:01, DQA1*01:02, DQA1*05:01, DQB1*02:01/163N, DQB1*05:01, DPA1*02:01, DPB1*01:01, DPB1*13:01'}),
106
+ ('067', {'sero': 'DR13, DR16, DR52, DR51, DQ7, DQ5, DP1, DP2',
107
+ 'allele': 'DRB1*13:03, DRB1*16:01, DRB3*01:01, DRB5*02:02, DQA1*01:02, DQA1*05:05, DQB1*03:01, DQB1*05:02, DPA1*01:03, DPA1*02:02, DPB1*01:01, DPB1*02:02'}),
108
+ ('068', {'sero': 'DR13, DR14, DR52, DQ5, DQ6, DP1, DP5',
109
+ 'allele': 'DRB1*13:01, DRB1*14:54, DRB3*01:01, DRB3*02:02, DQA1*01:03, DQA1*01:04, DQB1*05:03, DQB1*06:03, DPA1*02:02, DPB1*01:01, DPB1*05:01'}),
110
+ ('090', {'sero': 'DR14, DR16, DR52, DR51, DQ7, DP4, DP13',
111
+ 'allele': 'DRB1*14:02, DRB1*16:02, DRB3*01:01, DRB5*02:02, DQA1*05:03, DQA1*05:05, DQB1*03:01, DPA1*01:03, DPA1*02:01, DPB1*04:01, DPB1*13:01'}),
112
+ ('091', {'sero': 'DR15, DR16, DR51, DQ5, DQ6, DP3, DP105',
113
+ 'allele': 'DRB1*15:03, DRB1*16:02, DRB5*01:01, DRB5*02:21, DQA1*01:02, DQB1*05:02, DQB1*06:02, DPA1*01:03, DPA1*03:01, DPB1*03:01, DPB1*105:01'}),
114
+ ('092', {'sero': 'DR17, DR9, DR52, DR53, DQ2, DQ9, DP1, DP14',
115
+ 'allele': 'DRB1*03:01, DRB1*09:01, DRB3*01:01, DRB4*01:03, DQA1*03:02, DQA1*05:01, DQB1*02:01, DQB1*03:03, DPA1*02:01, DPB1*01:01, DPB1*14:01'}),
116
+ ('093', {'sero': 'DR18, DR10, DR52, DQ4, DQ5, DP1, DP104',
117
+ 'allele': 'DRB1*03:02, DRB1*10:01, DRB3*01:62, DQA1*01:05, DQA1*04:01, DQB1*04:02, DQB1*05:01, DPA1*01:03, DPA1*02:02, DPB1*01:01, DPB1*104:01'}),
118
+ ('094', {'sero': 'DR1, DR8, DQ4, DQ5, DP4, DP11',
119
+ 'allele': 'DRB1*01:01, DRB1*08:01, DQA1*01:01, DQA1*04:01, DQB1*04:02, DQB1*05:01, DPA1*01:03, DPA1*02:01, DPB1*04:02, DPB1*11:01'}),
120
+ ('096', {'sero': 'DR17, DR13, DR52, DQ2, DQ6, DP2, DP5',
121
+ 'allele': 'DRB1*03:01, DRB1*13:02, DRB3*02:02, DRB3*03:01, DQA1*01:02, DQA1*05:01, DQB1*02:01, DQB1*06:09, DPA1*02:01, DPB1*02:01, DPB1*05:01'}),
122
+ ('098', {'sero': 'DR9, DR15, DR53, DQ2, DQ5, DP13',
123
+ 'allele': 'DRB1*09:01, DRB1*15:02, DRB4*01:01, DRB5*01:08:01N, DQA1*01:01, DQA1*03:03, DQB1*02:02, DQB1*05:01, DPA1*02:01, DPB1*13:01'}),
124
+ ])
125
+
126
+
127
+ # ============================================================
128
+ # 分析邏輯 (通用 PRA1 / PRA2)
129
+ # ============================================================
130
+
131
+ def clean_sero(sero_str):
132
+ """清理 sero 字串: 'A2, , B46, , Bw6, , Cw1,' → 'A2, B46, Cw1'"""
133
+ if not sero_str:
134
+ return ''
135
+ skip = {'Bw4', 'Bw6', 'DR51', 'DR52', 'DR53', ''}
136
+ parts = [s.strip() for s in sero_str.split(',')]
137
+ parts = [s for s in parts if s not in skip]
138
+ return ', '.join(parts)
139
+
140
+
141
+ def detect_pra_class(metadata, bead_ids):
142
+ """根據 protocol 名稱或 bead ID 自動偵測 PRA Class"""
143
+ protocol = ''
144
+ if 'ProtocolName' in metadata:
145
+ protocol = metadata['ProtocolName'][0].upper()
146
+ if 'LS1PRA' in protocol or 'CLASS I' in protocol:
147
+ return 'PRA1'
148
+ if 'LS2PRA' in protocol or 'CLASS II' in protocol:
149
+ return 'PRA2'
150
+ # 用 bead ID 判斷
151
+ pra2_beads = set(BEAD_HLA_LOT20.keys())
152
+ pra1_beads = set(BEAD_HLA_LOT21.keys())
153
+ bid_set = set(bead_ids)
154
+ if bid_set & pra2_beads:
155
+ return 'PRA2'
156
+ if bid_set & pra1_beads:
157
+ return 'PRA1'
158
+ return 'PRA1'
159
+
160
+
161
+ def get_bead_map(pra_class):
162
+ return BEAD_HLA_LOT20 if pra_class == 'PRA2' else BEAD_HLA_LOT21
163
+
164
+
165
+ def analyze_sample(sample_medians, nc_medians, bead_ids, bead_hla_map):
166
+ """分析單一樣本 (通用)"""
167
+ sample_nc_bead = sample_medians.get(NC_BEAD, 0)
168
+ nc_nc_bead = nc_medians.get(NC_BEAD, 0)
169
+ results = OrderedDict()
170
+ for bid in bead_ids:
171
+ if bid in CONTROL_BEADS or bid not in bead_hla_map:
172
+ continue
173
+ raw = sample_medians.get(bid, 0)
174
+ ns_raw = nc_medians.get(bid, 0)
175
+ normal = max(0.0, raw - sample_nc_bead - ns_raw + nc_nc_bead)
176
+ denom = ns_raw * sample_nc_bead
177
+ ratio = (raw * nc_nc_bead) / denom if denom > 0 else 0.0
178
+ rxn = get_rxn(normal)
179
+ results[bid] = {
180
+ 'raw': raw, 'ns_raw': ns_raw,
181
+ 'sample_nc': sample_nc_bead, 'nsnc': nc_nc_bead,
182
+ 'normal': round(normal, 2), 'ratio': round(ratio, 2), 'rxn': rxn,
183
+ }
184
+ return results
185
+
186
+
187
+ def calculate_pra(bead_results, threshold='X6'):
188
+ threshold_rxn = int(threshold[1:])
189
+ total = len(bead_results)
190
+ positive = sum(1 for r in bead_results.values() if r['rxn'] >= threshold_rxn)
191
+ pra = round(positive / total * 100) if total > 0 else 0
192
+ return pra, positive, total
193
+
194
+
195
+ def get_confident_alleles(bead_results, bead_hla_map):
196
+ """
197
+ 篩選 confident alleles:allele 層級檢查。
198
+ 每個 allele 獨立判斷:只在 X6/X8 bead 出現、不在任何 X2/X4 bead 出現。
199
+ 同一個 sero 的不同 allele 互不影響。
200
+ 例: A*11:01 在 X2 → 排除 A*11:01
201
+ A*11:02 只在 X8 → 保留 A*11:02 → 報告寫 A11(A*11:02)
202
+ """
203
+ x6x8 = set()
204
+ x2x4 = set()
205
+ for bid, r in bead_results.items():
206
+ hla = bead_hla_map.get(bid, {})
207
+ alleles = _parse_allele_list(hla.get('allele', ''))
208
+ if r['rxn'] >= 6:
209
+ x6x8.update(alleles)
210
+ elif r['rxn'] in (2, 4):
211
+ x2x4.update(alleles)
212
+ return x6x8 - x2x4
213
+
214
+
215
+ def _build_allele_to_sero(bead_hla_map):
216
+ """從 bead map 建立 allele→sero 對照"""
217
+ mapping = {}
218
+ for bid, hla in bead_hla_map.items():
219
+ sero_parts = [s.strip() for s in hla.get('sero', '').split(',')]
220
+ allele_parts = [a.strip() for a in hla.get('allele', '').split(',')]
221
+ skip = {'Bw4', 'Bw6', 'DR51', 'DR52', 'DR53', ''}
222
+ sero_by_locus = {}
223
+ for s in sero_parts:
224
+ if s in skip:
225
+ continue
226
+ # 判斷 locus
227
+ if s.startswith('A'):
228
+ sero_by_locus.setdefault('A', []).append(s)
229
+ elif s.startswith('B'):
230
+ sero_by_locus.setdefault('B', []).append(s)
231
+ elif s.startswith('Cw') or s.startswith('C'):
232
+ sero_by_locus.setdefault('C', []).append(s)
233
+ elif s.startswith('DR'):
234
+ sero_by_locus.setdefault('DR', []).append(s)
235
+ elif s.startswith('DQ'):
236
+ sero_by_locus.setdefault('DQ', []).append(s)
237
+ elif s.startswith('DP'):
238
+ sero_by_locus.setdefault('DP', []).append(s)
239
+
240
+ allele_by_locus = {}
241
+ for a in allele_parts:
242
+ if not a or a == '-':
243
+ continue
244
+ if a.startswith('A'):
245
+ allele_by_locus.setdefault('A', []).append(a)
246
+ elif a.startswith('B'):
247
+ allele_by_locus.setdefault('B', []).append(a)
248
+ elif a.startswith('C'):
249
+ allele_by_locus.setdefault('C', []).append(a)
250
+ elif a.startswith('DRB'):
251
+ allele_by_locus.setdefault('DR', []).append(a)
252
+ elif a.startswith('DQA') or a.startswith('DQB'):
253
+ allele_by_locus.setdefault('DQ', []).append(a)
254
+ elif a.startswith('DPA') or a.startswith('DPB'):
255
+ allele_by_locus.setdefault('DP', []).append(a)
256
+
257
+ for locus in sero_by_locus:
258
+ seros = sero_by_locus[locus]
259
+ alleles = allele_by_locus.get(locus, [])
260
+ for i, ag in enumerate(alleles):
261
+ if i < len(seros):
262
+ mapping[ag] = seros[i]
263
+ return mapping
264
+
265
+
266
+ def build_sero_mfi_stats(beads_detail, confident_alleles, bead_hla_map):
267
+ """
268
+ 計算每個 confident sero 的 Max/Mean Normal MFI。
269
+ 回傳 list of dict: [{sero, alleles, max_mfi, mean_mfi, count, beads}, ...]
270
+ 按 max_mfi 降序排列。
271
+ """
272
+ a2s = _build_allele_to_sero(bead_hla_map)
273
+ skip_sero = {'Bw4', 'Bw6', 'DR51', 'DR52', 'DR53', ''}
274
+
275
+ # 找出 confident sero set
276
+ conf_seros = set()
277
+ sero_alleles = {} # sero -> set of confident alleles
278
+ for ag in confident_alleles:
279
+ sero = a2s.get(ag)
280
+ if sero and sero not in skip_sero:
281
+ conf_seros.add(sero)
282
+ sero_alleles.setdefault(sero, set()).add(ag)
283
+
284
+ # 收集每個 confident sero 在正陽性 bead 上的 Normal 值
285
+ sero_normals = {} # sero -> [normal values]
286
+ sero_beads = {} # sero -> [bead ids]
287
+ for b in beads_detail:
288
+ if b['rxn'] < 6:
289
+ continue
290
+ hla = bead_hla_map.get(b['bead'], {})
291
+ sero_parts = [s.strip() for s in hla.get('sero', '').split(',')]
292
+ for s in sero_parts:
293
+ if s in skip_sero:
294
+ continue
295
+ if s in conf_seros:
296
+ sero_normals.setdefault(s, []).append(b['normal'])
297
+ sero_beads.setdefault(s, []).append(b['bead'])
298
+
299
+ # 組裝結果
300
+ stats = []
301
+ for sero in conf_seros:
302
+ normals = sero_normals.get(sero, [])
303
+ if not normals:
304
+ continue
305
+ alleles_str = ', '.join(sorted(sero_alleles.get(sero, set())))
306
+ stats.append({
307
+ 'sero': sero,
308
+ 'alleles': alleles_str,
309
+ 'max_mfi': round(max(normals), 1),
310
+ 'mean_mfi': round(sum(normals) / len(normals), 1),
311
+ 'count': len(normals),
312
+ 'beads': ', '.join(sero_beads.get(sero, [])),
313
+ })
314
+
315
+ # 排序: 按 locus 分組, 再按 max_mfi 降序
316
+ def sort_key(x):
317
+ s = x['sero']
318
+ if s.startswith('A'):
319
+ locus = 0
320
+ elif s.startswith('B'):
321
+ locus = 1
322
+ elif s.startswith('Cw') or s.startswith('C'):
323
+ locus = 2
324
+ elif s.startswith('DR'):
325
+ locus = 0
326
+ elif s.startswith('DQ'):
327
+ locus = 1
328
+ elif s.startswith('DP'):
329
+ locus = 2
330
+ else:
331
+ locus = 9
332
+ return (locus, -x['max_mfi'])
333
+
334
+ stats.sort(key=sort_key)
335
+ return stats
336
+
337
+
338
+ def generate_specificity(confident_alleles, bead_hla_map):
339
+ """
340
+ 將 confident alleles 轉為 sero 格式的 Specificity 字串。
341
+ 例: A11(A*11:02) A23 A24 B7 B62(B*15:01) Cw1
342
+ """
343
+ if not confident_alleles:
344
+ return '(-)'
345
+
346
+ a2s = _build_allele_to_sero(bead_hla_map)
347
+
348
+ # 統計每個 sero group 在整個 bead panel 上有哪些 allele
349
+ all_alleles_per_sero = {}
350
+ for bid, hla in bead_hla_map.items():
351
+ for ag in _parse_allele_list(hla.get('allele', '')):
352
+ sero = a2s.get(ag)
353
+ if sero:
354
+ all_alleles_per_sero.setdefault(sero, set()).add(ag)
355
+
356
+ # 將 confident alleles 按 sero 分組
357
+ sero_groups = {}
358
+ for ag in confident_alleles:
359
+ sero = a2s.get(ag)
360
+ if sero:
361
+ sero_groups.setdefault(sero, set()).add(ag)
362
+
363
+ # 排序
364
+ def sort_key(s):
365
+ if s.startswith('A'):
366
+ return (0, s)
367
+ elif s.startswith('B'):
368
+ return (1, s)
369
+ elif s.startswith('Cw'):
370
+ return (2, s)
371
+ elif s.startswith('DR'):
372
+ return (0, s)
373
+ elif s.startswith('DQ'):
374
+ return (1, s)
375
+ elif s.startswith('DP'):
376
+ return (2, s)
377
+ return (9, s)
378
+
379
+ parts = []
380
+ for sero in sorted(sero_groups.keys(), key=sort_key):
381
+ conf = sero_groups[sero]
382
+ total = all_alleles_per_sero.get(sero, set())
383
+ if conf >= total:
384
+ # 所有 allele 都是 confident → 只寫 sero
385
+ parts.append(sero)
386
+ else:
387
+ # 部分 → sero(allele1 allele2)
388
+ parts.append(f'{sero}({" ".join(sorted(conf))})')
389
+
390
+ return ' '.join(parts)
391
+
392
+
393
+ def parse_xls_file(raw_bytes):
394
+ """
395
+ 解析 HLA Fusion XLS 報告。
396
+ 可能包含一個病人或 NC 報告。
397
+ 回傳 dict: {sample_name, pra_class, date, beads_detail, ...}
398
+ """
399
+ import xlrd
400
+ wb = xlrd.open_workbook(file_contents=raw_bytes)
401
+ sh = wb.sheet_by_index(0)
402
+
403
+ # 讀取 metadata
404
+ sample_name = str(sh.cell_value(0, 0)).strip()
405
+ if not sample_name:
406
+ # Row 1 可能有 PATIENT: xxx
407
+ if sh.nrows > 1:
408
+ r1 = str(sh.cell_value(1, 0)).strip()
409
+ if r1.startswith('PATIENT'):
410
+ sample_name = str(sh.cell_value(1, 1)).strip() if sh.ncols > 1 else r1
411
+ session = ''
412
+ date_val = ''
413
+ catalog = ''
414
+ # 掃描 row 3 和 row 5 找 key-value (value 可能在 label 後面任何欄位)
415
+ for r in [3, 5]:
416
+ if r >= sh.nrows:
417
+ continue
418
+ row = [str(sh.cell_value(r, c)).strip() for c in range(min(sh.ncols, 15))]
419
+ for i, v in enumerate(row):
420
+ if v == 'SESSION :' or v == 'SESSION:':
421
+ for j in range(i + 1, len(row)):
422
+ if row[j]:
423
+ session = row[j]; break
424
+ if v == 'TEST DATE :' or v == 'TEST DATE:':
425
+ for j in range(i + 1, len(row)):
426
+ if row[j]:
427
+ date_val = row[j]; break
428
+ if v == 'CATALOG :' or v == 'CATALOG:':
429
+ for j in range(i + 1, len(row)):
430
+ if row[j]:
431
+ catalog = row[j]; break
432
+
433
+ # 偵測 PRA class
434
+ pra_class = 'PRA1'
435
+ if 'LS2PRA' in catalog.upper() or 'PRA2' in catalog.upper():
436
+ pra_class = 'PRA2'
437
+ elif 'PRA2' in session.upper():
438
+ pra_class = 'PRA2'
439
+ bead_map = get_bead_map(pra_class)
440
+
441
+ # 讀取 bead 資料 (col 0=BeadID, 3=Raw, 11=NS_Raw, 15=Normal, 20=Ratio, 22=Rxn, 26=Count, 29=Sero, 35=Allele)
442
+ beads_detail = []
443
+ nc_raw = 0
444
+ pc_raw = 0
445
+ for r in range(9, sh.nrows):
446
+ bid = str(sh.cell_value(r, 0)).strip()
447
+ if not bid or bid == 'BeadID':
448
+ continue
449
+ # 整數 bead ID → 補零到 3 位
450
+ try:
451
+ bid_int = int(float(bid))
452
+ bid = f'{bid_int:03d}'
453
+ except (ValueError, TypeError):
454
+ continue
455
+
456
+ raw_val = sh.cell_value(r, 3) if sh.ncols > 3 else 0
457
+ ns_raw = sh.cell_value(r, 11) if sh.ncols > 11 else 0
458
+ normal = sh.cell_value(r, 15) if sh.ncols > 15 else 0
459
+ ratio = sh.cell_value(r, 20) if sh.ncols > 20 else 0
460
+ rxn_val = sh.cell_value(r, 22) if sh.ncols > 22 else ''
461
+ count_val = sh.cell_value(r, 26) if sh.ncols > 26 else 0
462
+ sero_raw = str(sh.cell_value(r, 29)).strip() if sh.ncols > 29 else ''
463
+ sero = clean_sero(sero_raw)
464
+ allele = str(sh.cell_value(r, 35)).strip() if sh.ncols > 35 else ''
465
+
466
+ # 處理數值
467
+ try:
468
+ raw_val = float(raw_val) if raw_val != '' else 0
469
+ except (ValueError, TypeError):
470
+ raw_val = 0
471
+ try:
472
+ ns_raw = float(ns_raw) if ns_raw != '' else 0
473
+ except (ValueError, TypeError):
474
+ ns_raw = 0
475
+ try:
476
+ normal = float(normal) if normal != '' else 0
477
+ except (ValueError, TypeError):
478
+ normal = 0
479
+ try:
480
+ ratio = float(ratio) if ratio != '' else 0
481
+ except (ValueError, TypeError):
482
+ ratio = 0
483
+ try:
484
+ count_val = int(float(count_val)) if count_val != '' else 0
485
+ except (ValueError, TypeError):
486
+ count_val = 0
487
+
488
+ # Rxn: 可能是數字 1/2/4/6/8 或 'NC'/'PC'
489
+ rxn_str = str(rxn_val).strip()
490
+ if rxn_str in ('NC', 'nc'):
491
+ nc_raw = raw_val
492
+ continue
493
+ elif rxn_str in ('PC', 'pc'):
494
+ pc_raw = raw_val
495
+ continue
496
+
497
+ try:
498
+ rxn_int = int(float(rxn_val))
499
+ except (ValueError, TypeError):
500
+ continue
501
+
502
+ if bid not in bead_map:
503
+ continue
504
+
505
+ beads_detail.append({
506
+ 'bead': bid, 'rxn': rxn_int,
507
+ 'raw': round(raw_val, 1), 'ns_raw': round(ns_raw, 1),
508
+ 'normal': round(normal, 2), 'ratio': round(ratio, 2),
509
+ 'count': count_val,
510
+ 'sero': sero,
511
+ 'allele': allele,
512
+ })
513
+
514
+ if not beads_detail:
515
+ return None # 沒有有效的 HLA bead 資料
516
+
517
+ # 計算 PRA%
518
+ total = len(beads_detail)
519
+ pra_all = {}
520
+ for t in ['X2', 'X4', 'X6', 'X8']:
521
+ t_rxn = int(t[1:])
522
+ pos = sum(1 for b in beads_detail if b['rxn'] >= t_rxn)
523
+ pra_all[t] = round(pos / total * 100) if total > 0 else 0
524
+
525
+ pra6 = pra_all['X6']
526
+ overall = 'Positive' if pra6 > 0 else 'Negative'
527
+
528
+ # Confident alleles (sero 層級檢查)
529
+ # 將 beads_detail 轉為 get_confident_alleles 需要的格式
530
+ br_dict = OrderedDict()
531
+ for b in beads_detail:
532
+ br_dict[b['bead']] = {'rxn': b['rxn']}
533
+ confident = get_confident_alleles(br_dict, bead_map)
534
+
535
+ sero_mfi = build_sero_mfi_stats(beads_detail, confident, bead_map) if overall == 'Positive' else []
536
+
537
+ return {
538
+ 'name': sample_name,
539
+ 'overall': overall,
540
+ 'pra': pra6,
541
+ 'pra_all': pra_all,
542
+ 'beads': beads_detail,
543
+ 'confident_alleles': sorted(confident),
544
+ 'specificity': generate_specificity(confident, bead_map) if overall == 'Positive' else '(-)',
545
+ 'sero_mfi': sero_mfi,
546
+ '_pra_class': pra_class,
547
+ '_date': date_val,
548
+ '_batch': session,
549
+ '_nc_raw': nc_raw,
550
+ '_pc_raw': pc_raw,
551
+ }
552
+
553
+
554
+ def full_analyze_xls(raw_bytes_list, filenames):
555
+ """解析一個或多個 XLS 檔,回傳與 full_analyze 相同格式的結果"""
556
+ patients = []
557
+ pra_class = 'PRA1'
558
+ date_val = ''
559
+ batch = ''
560
+ nc_signal = 0
561
+ pc_signal = 0
562
+
563
+ errors = []
564
+ for raw_bytes, fname in zip(raw_bytes_list, filenames):
565
+ try:
566
+ pt = parse_xls_file(raw_bytes)
567
+ except Exception as e:
568
+ errors.append(f'{fname}: {type(e).__name__}: {e}')
569
+ continue
570
+ if pt is None:
571
+ errors.append(f'{fname}: 無 bead 資料 (可能非 HLA Fusion 報告)')
572
+ continue
573
+ # 若 sample name 為空,用檔名
574
+ if not pt.get('name'):
575
+ pt['name'] = Path(fname).stem
576
+ pra_class = pt.pop('_pra_class', 'PRA1')
577
+ if pt['_date']:
578
+ date_val = pt.pop('_date')
579
+ else:
580
+ pt.pop('_date')
581
+ if pt['_batch']:
582
+ batch = pt.pop('_batch')
583
+ else:
584
+ pt.pop('_batch')
585
+ nc_signal = pt.pop('_nc_raw', 0) or nc_signal
586
+ pc_signal = pt.pop('_pc_raw', 0) or pc_signal
587
+ patients.append(pt)
588
+
589
+ if not patients:
590
+ err_detail = '; '.join(errors) if errors else '無 bead 資料'
591
+ fnames = ', '.join(filenames)
592
+ return None, f'無法從 XLS 中讀取病人資料 [{fnames}] ({err_detail})'
593
+
594
+ return {
595
+ 'pra_class': pra_class,
596
+ 'class_label': 'PRA Class I' if pra_class == 'PRA1' else 'PRA Class II',
597
+ 'date': date_val,
598
+ 'batch': batch,
599
+ 'nc_name': 'NC',
600
+ 'pc_signal': round(pc_signal, 0),
601
+ 'nc_signal': round(nc_signal, 0),
602
+ 'patients': patients,
603
+ 'filename': ', '.join(filenames),
604
+ }, None
605
+
606
+
607
+ def full_analyze(csv_content, filename='upload.csv'):
608
+ """完整分析流程 (CSV),回傳結構化結果"""
609
+ # 寫入暫存檔 (以 UTF-8 寫入)
610
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix='.csv', mode='wb')
611
+ tmp.write(csv_content.encode('utf-8-sig'))
612
+ tmp.close()
613
+
614
+ try:
615
+ metadata, bead_ids, data_blocks = parse_xponent_csv(tmp.name)
616
+ finally:
617
+ os.unlink(tmp.name)
618
+
619
+ pra_class = detect_pra_class(metadata, bead_ids)
620
+ bead_map = get_bead_map(pra_class)
621
+
622
+ median_data = data_blocks.get('Median') or data_blocks.get('Avg Net MFI')
623
+ if not median_data:
624
+ return None, 'CSV 中找不到 Median 資料'
625
+
626
+ count_data = data_blocks.get('Count', {})
627
+ sample_names = list(median_data.keys())
628
+ nc_name = find_nc_sample(sample_names)
629
+ if not nc_name:
630
+ return None, '找不到 NC 樣本'
631
+
632
+ nc_medians = median_data[nc_name]
633
+ date_val = metadata.get('Date', [''])[0] if 'Date' in metadata else ''
634
+ batch = metadata.get('Batch', [''])[0] if 'Batch' in metadata else ''
635
+
636
+ # PC/NC QC
637
+ pc_val = nc_medians.get(PC_BEAD, 0)
638
+ nc_val = nc_medians.get(NC_BEAD, 0)
639
+
640
+ patient_samples = [s for s in sample_names if s != nc_name]
641
+ patients = []
642
+
643
+ for sname in patient_samples:
644
+ br = analyze_sample(median_data[sname], nc_medians, bead_ids, bead_map)
645
+ pra6, pos6, tot = calculate_pra(br, 'X6')
646
+ overall = 'Positive' if pra6 > 0 else 'Negative'
647
+ confident = get_confident_alleles(br, bead_map)
648
+
649
+ # bead 明細
650
+ beads_detail = []
651
+ sample_counts = count_data.get(sname, {})
652
+ for bid, r in br.items():
653
+ hla = bead_map.get(bid, {})
654
+ beads_detail.append({
655
+ 'bead': bid, 'rxn': r['rxn'],
656
+ 'raw': round(r['raw'], 1), 'ns_raw': round(r['ns_raw'], 1),
657
+ 'normal': r['normal'], 'ratio': r['ratio'],
658
+ 'count': int(sample_counts.get(bid, 0)),
659
+ 'sero': clean_sero(hla.get('sero', '')),
660
+ 'allele': hla.get('allele', ''),
661
+ })
662
+
663
+ # PRA at all thresholds
664
+ pra_all = {}
665
+ for t in ['X2', 'X4', 'X6', 'X8']:
666
+ p, _, _ = calculate_pra(br, t)
667
+ pra_all[t] = p
668
+
669
+ patients.append({
670
+ 'name': sname,
671
+ 'overall': overall,
672
+ 'pra': pra6,
673
+ 'pra_all': pra_all,
674
+ 'beads': beads_detail,
675
+ 'confident_alleles': sorted(confident),
676
+ 'specificity': generate_specificity(confident, bead_map) if overall == 'Positive' else '(-)',
677
+ 'sero_mfi': build_sero_mfi_stats(beads_detail, confident, bead_map) if overall == 'Positive' else [],
678
+ })
679
+
680
+ result = {
681
+ 'pra_class': pra_class,
682
+ 'class_label': 'PRA Class I' if pra_class == 'PRA1' else 'PRA Class II',
683
+ 'date': date_val,
684
+ 'batch': batch,
685
+ 'nc_name': nc_name,
686
+ 'pc_signal': round(pc_val, 0),
687
+ 'nc_signal': round(nc_val, 0),
688
+ 'patients': patients,
689
+ 'filename': filename,
690
+ }
691
+ return result, None
692
+
693
+
694
+ # ============================================================
695
+ # Flask Routes
696
+ # ============================================================
697
+
698
+ @app.route('/login', methods=['GET', 'POST'])
699
+ def login():
700
+ if request.method == 'POST':
701
+ username = request.form.get('username', '').strip()
702
+ password = request.form.get('password', '')
703
+ import db
704
+ if db.check_user(username, password):
705
+ flask_session['logged_in'] = True
706
+ flask_session['username'] = username
707
+ flask_session['role'] = db.get_user_role(username)
708
+ flask_session['display_name'] = db.get_user_display_name(username)
709
+ return redirect(url_for('dashboard'))
710
+ return render_template('login.html', error='帳號或密碼錯誤')
711
+ return render_template('login.html')
712
+
713
+
714
+ @app.route('/register', methods=['GET', 'POST'])
715
+ def register():
716
+ if request.method == 'POST':
717
+ display_name = request.form.get('display_name', '').strip()
718
+ username = request.form.get('username', '').strip()
719
+ password = request.form.get('password', '')
720
+ password2 = request.form.get('password2', '')
721
+ if not display_name:
722
+ return render_template('register.html', error='請輸入姓名')
723
+ if not username:
724
+ return render_template('register.html', error='請輸入帳號')
725
+ if not password or len(password) < 4:
726
+ return render_template('register.html', error='密碼至少 4 碼')
727
+ if password != password2:
728
+ return render_template('register.html', error='兩次密碼不一致')
729
+ import db
730
+ ok, msg = db.register_user(username, password, display_name)
731
+ if ok:
732
+ return render_template('login.html', success='註冊成功,請登入')
733
+ return render_template('register.html', error=msg)
734
+ return render_template('register.html')
735
+
736
+
737
+ @app.route('/logout')
738
+ def logout():
739
+ flask_session.clear()
740
+ return redirect(url_for('login'))
741
+
742
+
743
+ @app.route('/')
744
+ @login_required
745
+ def dashboard():
746
+ return render_template('dashboard.html',
747
+ username=flask_session.get('username', ''),
748
+ is_admin=flask_session.get('role') == 'admin')
749
+
750
+
751
+ @app.route('/new')
752
+ @login_required
753
+ def index():
754
+ return render_template('index.html')
755
+
756
+
757
+ @app.route('/analyze', methods=['POST'])
758
+ @login_required
759
+ def analyze():
760
+ files = request.files.getlist('file')
761
+ if not files or not files[0].filename:
762
+ return render_template('index.html', error='請選擇檔案')
763
+
764
+ # 收集所有結果,按 PRA class 分組
765
+ all_patients = {'PRA1': [], 'PRA2': []}
766
+ meta = {'PRA1': {}, 'PRA2': {}}
767
+ errors = []
768
+
769
+ for f in files:
770
+ raw_bytes = f.read()
771
+ fname = f.filename or 'unknown'
772
+ ext = Path(fname).suffix.lower()
773
+ is_xls = ext in ('.xls', '.xlsx') or (
774
+ len(raw_bytes) > 8 and raw_bytes[:8] == b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1')
775
+
776
+ if is_xls:
777
+ try:
778
+ pt = parse_xls_file(raw_bytes)
779
+ except Exception as e:
780
+ errors.append(f'{fname}: {e}')
781
+ continue
782
+ if pt is None:
783
+ errors.append(f'{fname}: 無 bead 資料')
784
+ continue
785
+ if not pt.get('name'):
786
+ pt['name'] = Path(fname).stem
787
+ pc = pt.pop('_pra_class', 'PRA1')
788
+ date_val = pt.pop('_date', '')
789
+ batch = pt.pop('_batch', '')
790
+ nc_raw = pt.pop('_nc_raw', 0)
791
+ pc_raw = pt.pop('_pc_raw', 0)
792
+ all_patients[pc].append(pt)
793
+ if date_val:
794
+ meta[pc]['date'] = date_val
795
+ if batch:
796
+ meta[pc]['batch'] = batch
797
+ meta[pc]['nc_signal'] = nc_raw
798
+ meta[pc]['pc_signal'] = pc_raw
799
+ else:
800
+ # CSV
801
+ content = None
802
+ for enc in ['utf-8-sig', 'utf-8', 'cp1252', 'latin-1', 'big5', 'cp950']:
803
+ try:
804
+ content = raw_bytes.decode(enc)
805
+ if '"Results"' in content or '"Median"' in content:
806
+ break
807
+ except (UnicodeDecodeError, UnicodeError):
808
+ continue
809
+ if content is None:
810
+ content = raw_bytes.decode('latin-1')
811
+ result, error = full_analyze(content, fname)
812
+ if error:
813
+ errors.append(f'{fname}: {error}')
814
+ continue
815
+ pc = result['pra_class']
816
+ all_patients[pc].extend(result['patients'])
817
+ meta[pc] = {
818
+ 'date': result['date'], 'batch': result['batch'],
819
+ 'nc_signal': result['nc_signal'], 'pc_signal': result['pc_signal'],
820
+ 'nc_name': result['nc_name'],
821
+ }
822
+
823
+ # 組裝兩邊結果
824
+ def build_result(pc):
825
+ pts = all_patients[pc]
826
+ if not pts:
827
+ return None
828
+ m = meta.get(pc, {})
829
+ return {
830
+ 'pra_class': pc,
831
+ 'class_label': 'PRA Class I' if pc == 'PRA1' else 'PRA Class II',
832
+ 'date': m.get('date', ''),
833
+ 'batch': m.get('batch', ''),
834
+ 'nc_name': m.get('nc_name', 'NC'),
835
+ 'pc_signal': round(m.get('pc_signal', 0)),
836
+ 'nc_signal': round(m.get('nc_signal', 0)),
837
+ 'patients': pts,
838
+ }
839
+
840
+ result_pra1 = build_result('PRA1')
841
+ result_pra2 = build_result('PRA2')
842
+
843
+ if not result_pra1 and not result_pra2:
844
+ err_msg = '; '.join(errors) if errors else '無法辨識檔案格式'
845
+ return render_template('index.html', error=err_msg)
846
+
847
+ patient_name = request.form.get('patient_name', '').strip()
848
+ patient_id = request.form.get('patient_id', '').strip()
849
+
850
+ return render_template('index.html',
851
+ result_pra1=result_pra1,
852
+ result_pra2=result_pra2,
853
+ patient_name=patient_name,
854
+ patient_id=patient_id,
855
+ errors=errors if errors else None)
856
+
857
+
858
+ @app.route('/export_docx', methods=['POST'])
859
+ @login_required
860
+ def export_docx():
861
+ """匯出 DOCX 報告"""
862
+ from docx import Document
863
+ from docx.shared import Pt, Cm
864
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
865
+
866
+ doc = Document()
867
+ style = doc.styles['Normal']
868
+ style.font.name = 'Calibri'
869
+ style.font.size = Pt(11)
870
+
871
+ data = request.json
872
+ pra_class_label = data.get('class_label', 'PRA Class I')
873
+ date_str = data.get('date', '')
874
+ pra_tag = 'PRA1' if 'I' in pra_class_label else 'PRA2'
875
+
876
+ # 標題
877
+ p = doc.add_paragraph(f'{date_str} {pra_tag}')
878
+ p.runs[0].bold = True
879
+ p.runs[0].font.size = Pt(14)
880
+
881
+ for pt in data.get('patients', []):
882
+ doc.add_paragraph('')
883
+ p = doc.add_paragraph(pt['name'])
884
+ p.runs[0].bold = True
885
+
886
+ doc.add_paragraph(pra_class_label)
887
+ doc.add_paragraph(f'Overall: {pt["overall"]}')
888
+ doc.add_paragraph(f'%SA (or %PRA): {pt["pra"]}')
889
+ doc.add_paragraph('Specificity:')
890
+
891
+ spec = pt.get('specificity', '').strip()
892
+ if not spec or spec == '':
893
+ spec = '(-)' if pt['overall'] == 'Negative' else '(-)'
894
+ doc.add_paragraph(spec)
895
+ doc.add_paragraph('COMMENT:')
896
+
897
+ buf = io.BytesIO()
898
+ doc.save(buf)
899
+ buf.seek(0)
900
+
901
+ filename = f'{date_str.replace("/", "")}_{pra_tag}_report.docx'
902
+ return send_file(buf, as_attachment=True, download_name=filename,
903
+ mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document')
904
+
905
+
906
+ @app.route('/save', methods=['POST'])
907
+ @login_required
908
+ def save():
909
+ """儲存分析結果到資料庫"""
910
+ import db
911
+ data = request.json
912
+ patient_name = data.get('patient_name', '').strip()
913
+ chart_no = data.get('chart_no', '').strip()
914
+ if not chart_no:
915
+ return jsonify({'error': '請輸入病歷號'}), 400
916
+
917
+ patient_id = db.get_or_create_patient(patient_name, chart_no)
918
+ is_submitted = data.get('submitted', False)
919
+ status = 'submitted' if is_submitted else 'draft'
920
+ saved = []
921
+
922
+ for r in data.get('reports', []):
923
+ report_date = r.get('report_date', '')
924
+ pra_class = r.get('pra_class', '')
925
+ pra_percent = r.get('pra_percent', 0)
926
+ overall = r.get('overall', '')
927
+ specificity = r.get('specificity', '')
928
+ comment = r.get('comment', '')
929
+ sero_mfi = r.get('sero_mfi', [])
930
+
931
+ submitted_by = flask_session.get('display_name', flask_session.get('username', ''))
932
+ rid = db.save_report(patient_id, report_date, pra_class, pra_percent,
933
+ overall, specificity, comment, sero_mfi, status, submitted_by)
934
+ saved.append({'report_id': rid, 'pra_class': pra_class})
935
+
936
+ return jsonify({'ok': True, 'patient_id': patient_id, 'saved': saved})
937
+
938
+
939
+ @app.route('/history')
940
+ @login_required
941
+ def history():
942
+ """顯示所有報告紀錄"""
943
+ import db
944
+ reports = db.get_all_reports()
945
+ return render_template('history.html', reports=reports)
946
+
947
+
948
+ @app.route('/history/<chart_no>')
949
+ @login_required
950
+ def patient_history(chart_no):
951
+ """顯示單一病人的報告歷史 + MFI 比較"""
952
+ import db
953
+ patient, reports = db.get_patient_reports(chart_no)
954
+ if not patient:
955
+ return render_template('history.html', reports=db.get_all_reports(),
956
+ error=f'找不到病歷號 {chart_no}')
957
+
958
+ # MFI comparison (Class I and Class II)
959
+ dates1, antigens1, pra1 = db.get_mfi_comparison(chart_no, 'PRA Class I')
960
+ dates2, antigens2, pra2 = db.get_mfi_comparison(chart_no, 'PRA Class II')
961
+
962
+ class Comp:
963
+ def __init__(self, dates, antigens, pra_by_date):
964
+ self.dates = dates
965
+ self.antigens = antigens
966
+ self.pra_by_date = pra_by_date
967
+
968
+ comp1 = Comp(dates1, antigens1, pra1) if dates1 else None
969
+ comp2 = Comp(dates2, antigens2, pra2) if dates2 else None
970
+
971
+ return render_template('patient.html', patient=patient, reports=reports,
972
+ comparison_class1=comp1, comparison_class2=comp2)
973
+
974
+
975
+ @app.route('/delete_report/<int:report_id>', methods=['POST'])
976
+ @login_required
977
+ def delete_report_route(report_id):
978
+ import db
979
+ db.delete_report(report_id)
980
+ return jsonify({'ok': True})
981
+
982
+
983
+ @app.route('/admin')
984
+ @login_required
985
+ def admin():
986
+ if flask_session.get('role') != 'admin':
987
+ return redirect(url_for('dashboard'))
988
+ import db
989
+ users = db.get_all_users()
990
+ return render_template('admin.html', users=users,
991
+ username=flask_session.get('username', ''))
992
+
993
+
994
+ @app.route('/admin/delete_user/<int:user_id>', methods=['POST'])
995
+ @login_required
996
+ def admin_delete_user(user_id):
997
+ if flask_session.get('role') != 'admin':
998
+ return jsonify({'error': '無權限'}), 403
999
+ import db
1000
+ db.delete_user(user_id)
1001
+ return jsonify({'ok': True})
1002
+
1003
+
1004
+ @app.route('/admin/update_user/<int:user_id>', methods=['POST'])
1005
+ @login_required
1006
+ def admin_update_user(user_id):
1007
+ if flask_session.get('role') != 'admin':
1008
+ return jsonify({'error': '無權限'}), 403
1009
+ import db
1010
+ data = request.json
1011
+ db.update_user(user_id, data.get('display_name'), data.get('username'),
1012
+ data.get('password'), data.get('role'))
1013
+ return jsonify({'ok': True})
1014
+
1015
+
1016
+ @app.route('/save_donor_hla', methods=['POST'])
1017
+ @login_required
1018
+ def save_donor_hla_route():
1019
+ import db
1020
+ data = request.json
1021
+ chart_no = data.get('chart_no', '')
1022
+ donor_hla = data.get('donor_hla', '')
1023
+ if not chart_no:
1024
+ return jsonify({'error': 'missing chart_no'}), 400
1025
+ db.save_donor_hla(chart_no, donor_hla)
1026
+ return jsonify({'ok': True})
1027
+
1028
+
1029
+ @app.route('/analysis')
1030
+ @login_required
1031
+ def analysis():
1032
+ """統計分析頁面 — 選擇病人查看 MFI 趨勢"""
1033
+ import db
1034
+ patients = db.get_all_patients()
1035
+ chart_no = request.args.get('chart_no', '')
1036
+
1037
+ patient = None
1038
+ reports = []
1039
+ comp1 = None
1040
+ comp2 = None
1041
+
1042
+ if chart_no:
1043
+ patient, reports = db.get_patient_reports(chart_no)
1044
+ if patient:
1045
+ dates1, antigens1, pra1 = db.get_mfi_comparison(chart_no, 'PRA Class I')
1046
+ dates2, antigens2, pra2 = db.get_mfi_comparison(chart_no, 'PRA Class II')
1047
+
1048
+ class Comp:
1049
+ def __init__(self, dates, antigens, pra_by_date):
1050
+ self.dates = dates
1051
+ self.antigens = antigens
1052
+ self.pra_by_date = pra_by_date
1053
+
1054
+ comp1 = Comp(dates1, antigens1, pra1) if dates1 else None
1055
+ comp2 = Comp(dates2, antigens2, pra2) if dates2 else None
1056
+
1057
+ donor_hla = ''
1058
+ if chart_no:
1059
+ donor_hla = db.get_donor_hla(chart_no)
1060
+
1061
+ return render_template('analysis.html', patients=patients, chart_no=chart_no,
1062
+ patient=patient, reports=reports,
1063
+ comparison_class1=comp1, comparison_class2=comp2,
1064
+ donor_hla=donor_hla)
1065
+
1066
+
1067
+ if __name__ == '__main__':
1068
+ print('PRA Analysis Web App')
1069
+ print('http://127.0.0.1:5000')
1070
+ app.run(debug=True, port=5000)