add result sorting controls

This commit is contained in:
2026-05-08 23:34:46 +08:00
parent f288b29f45
commit 0c07cd1290
3 changed files with 218 additions and 1 deletions

View File

@@ -33,7 +33,7 @@ V2zip 解压后包含 `Patients_info.csv`,并按患者目录分别保存检
患者编号类型可选择自动识别。自动识别会读取 `Patients_info.csv` 中的 `pat_no`,并与 `Tests_List` 文件名或患者目录名比对:若更匹配 10 位补零编号,则使用 `pat_no`;若更匹配原始编号,则使用 `zhuyuanhao` 患者编号类型可选择自动识别。自动识别会读取 `Patients_info.csv` 中的 `pat_no`,并与 `Tests_List` 文件名或患者目录名比对:若更匹配 10 位补零编号,则使用 `pat_no`;若更匹配原始编号,则使用 `zhuyuanhao`
系统默认输出完整结果;处理完成后可在结果页通过“内容选择”立即切换预览内容,并同步影响右侧“导出此 Excel”的文件内容。可选择是否保留 系统默认输出完整结果;处理完成后可在结果页通过“内容选择”立即切换预览内容,并同步影响右侧“导出此 Excel”的文件内容。结果页还可以按姓名、采样时间或检测原因选择升序/降序排序;“未检测到内容汇总”会在每个分组标题下单独排序,不会把不同检测原因的分组混在一起。可选择是否保留:
- 基本工作表 - 基本工作表
- 未匹配检测内容项 - 未匹配检测内容项

View File

@@ -23,6 +23,15 @@ WORK_ROOT.mkdir(parents=True, exist_ok=True)
APP_TITLE = "\u68c0\u6d4b\u6570\u636e\u5904\u7406" APP_TITLE = "\u68c0\u6d4b\u6570\u636e\u5904\u7406"
UNMATCHED = "\u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9" UNMATCHED = "\u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9"
SORT_OPTIONS = {
"name": "\u59d3\u540d",
"sample_time": "\u91c7\u6837\u65f6\u95f4",
"reason": "\u68c0\u6d4b\u539f\u56e0",
}
SORT_ORDERS = {
"asc": "\u5347\u5e8f",
"desc": "\u964d\u5e8f",
}
app = FastAPI(title=APP_TITLE) app = FastAPI(title=APP_TITLE)
@@ -117,16 +126,22 @@ def result_page(
include_basic_sheets: bool = Query(True), include_basic_sheets: bool = Query(True),
include_unmatched_items: bool = Query(True), include_unmatched_items: bool = Query(True),
include_summary_sheet: bool = Query(True), include_summary_sheet: bool = Query(True),
sort_by: str = Query("sample_time"),
sort_order: str = Query("asc"),
) -> str: ) -> str:
job_dir = _get_job_dir(job_id) job_dir = _get_job_dir(job_id)
try: try:
rows = _clean_preview_rows(preview_rows) rows = _clean_preview_rows(preview_rows)
clean_sort_by = _clean_sort_by(sort_by)
clean_sort_order = _clean_sort_order(sort_order)
result = summarize_job( result = summarize_job(
job_dir, job_dir,
rows, rows,
include_basic_sheets=include_basic_sheets, include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items, include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet, include_summary_sheet=include_summary_sheet,
sort_by=clean_sort_by,
sort_order=clean_sort_order,
) )
except ProcessingError as exc: except ProcessingError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -136,6 +151,8 @@ def result_page(
include_basic_sheets=include_basic_sheets, include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items, include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet, include_summary_sheet=include_summary_sheet,
sort_by=clean_sort_by,
sort_order=clean_sort_order,
) )
@@ -145,6 +162,8 @@ def download_all(
include_basic_sheets: bool = Query(True), include_basic_sheets: bool = Query(True),
include_unmatched_items: bool = Query(True), include_unmatched_items: bool = Query(True),
include_summary_sheet: bool = Query(True), include_summary_sheet: bool = Query(True),
sort_by: str = Query("sample_time"),
sort_order: str = Query("asc"),
) -> FileResponse: ) -> FileResponse:
job_dir = _get_job_dir(job_id) job_dir = _get_job_dir(job_id)
try: try:
@@ -153,6 +172,8 @@ def download_all(
include_basic_sheets=include_basic_sheets, include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items, include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet, include_summary_sheet=include_summary_sheet,
sort_by=_clean_sort_by(sort_by),
sort_order=_clean_sort_order(sort_order),
) )
except ProcessingError as exc: except ProcessingError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -166,6 +187,8 @@ def download_file(
include_basic_sheets: bool = Query(True), include_basic_sheets: bool = Query(True),
include_unmatched_items: bool = Query(True), include_unmatched_items: bool = Query(True),
include_summary_sheet: bool = Query(True), include_summary_sheet: bool = Query(True),
sort_by: str = Query("sample_time"),
sort_order: str = Query("asc"),
) -> FileResponse: ) -> FileResponse:
job_dir = _get_job_dir(job_id) job_dir = _get_job_dir(job_id)
try: try:
@@ -175,6 +198,8 @@ def download_file(
include_basic_sheets=include_basic_sheets, include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items, include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet, include_summary_sheet=include_summary_sheet,
sort_by=_clean_sort_by(sort_by),
sort_order=_clean_sort_order(sort_order),
) )
except ProcessingError as exc: except ProcessingError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -207,12 +232,26 @@ def _checked(value: bool) -> str:
return "checked" if value else "" return "checked" if value else ""
def _selected(value: str, current: str) -> str:
return "selected" if value == current else ""
def _clean_sort_by(sort_by: str) -> str:
return sort_by if sort_by in SORT_OPTIONS else "sample_time"
def _clean_sort_order(sort_order: str) -> str:
return sort_order if sort_order in SORT_ORDERS else "asc"
def _render_result( def _render_result(
result: ProcessingResult, result: ProcessingResult,
preview_rows: int = 20, preview_rows: int = 20,
include_basic_sheets: bool = True, include_basic_sheets: bool = True,
include_unmatched_items: bool = True, include_unmatched_items: bool = True,
include_summary_sheet: bool = True, include_summary_sheet: bool = True,
sort_by: str = "sample_time",
sort_order: str = "asc",
) -> str: ) -> str:
total_sheets = sum(len(file.sheets) for file in result.files) total_sheets = sum(len(file.sheets) for file in result.files)
total_rows = sum(max(sheet.rows - 1, 0) for file in result.files for sheet in file.sheets) total_rows = sum(max(sheet.rows - 1, 0) for file in result.files for sheet in file.sheets)
@@ -230,6 +269,16 @@ def _render_result(
<label><input class="export-option" type="checkbox" data-param="include_basic_sheets" {_checked(include_basic_sheets)}> \u57fa\u672c\u5de5\u4f5c\u8868</label> <label><input class="export-option" type="checkbox" data-param="include_basic_sheets" {_checked(include_basic_sheets)}> \u57fa\u672c\u5de5\u4f5c\u8868</label>
<label><input class="export-option" type="checkbox" data-param="include_unmatched_items" {_checked(include_unmatched_items)}> \u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9\u9879</label> <label><input class="export-option" type="checkbox" data-param="include_unmatched_items" {_checked(include_unmatched_items)}> \u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9\u9879</label>
<label><input class="export-option" type="checkbox" data-param="include_summary_sheet" {_checked(include_summary_sheet)}> \u672a\u68c0\u6d4b\u5230\u5185\u5bb9\u6c47\u603b\u8868</label> <label><input class="export-option" type="checkbox" data-param="include_summary_sheet" {_checked(include_summary_sheet)}> \u672a\u68c0\u6d4b\u5230\u5185\u5bb9\u6c47\u603b\u8868</label>
<label for="sort_by">\u6392\u5e8f</label>
<select id="sort_by" class="result-option" data-param="sort_by">
<option value="name" {_selected("name", sort_by)}>\u6309\u59d3\u540d</option>
<option value="sample_time" {_selected("sample_time", sort_by)}>\u6309\u91c7\u6837\u65f6\u95f4</option>
<option value="reason" {_selected("reason", sort_by)}>\u6309\u68c0\u6d4b\u539f\u56e0</option>
</select>
<select id="sort_order" class="result-option" data-param="sort_order">
<option value="asc" {_selected("asc", sort_order)}>\u5347\u5e8f</option>
<option value="desc" {_selected("desc", sort_order)}>\u964d\u5e8f</option>
</select>
</section> </section>
<form class="row-control" method="get" action="/result/{html.escape(result.job_id)}"> <form class="row-control" method="get" action="/result/{html.escape(result.job_id)}">
<label for="preview_rows">\u6bcf\u4e2a\u5de5\u4f5c\u8868\u9884\u89c8\u884c\u6570</label> <label for="preview_rows">\u6bcf\u4e2a\u5de5\u4f5c\u8868\u9884\u89c8\u884c\u6570</label>
@@ -411,6 +460,11 @@ def _page_shell(
margin: 0; margin: 0;
font-weight: 500; font-weight: 500;
}} }}
.export-options select {{
width: auto;
min-width: 128px;
padding: 8px 10px;
}}
.checks label {{ .checks label {{
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -591,6 +645,9 @@ def _page_shell(
const options = Array.from(document.querySelectorAll('.export-option')); const options = Array.from(document.querySelectorAll('.export-option'));
const params = new URLSearchParams(); const params = new URLSearchParams();
options.forEach((option) => params.set(option.dataset.param, option.checked ? 'true' : 'false')); options.forEach((option) => params.set(option.dataset.param, option.checked ? 'true' : 'false'));
document.querySelectorAll('.result-option').forEach((option) => {{
params.set(option.dataset.param, option.value);
}});
document.querySelectorAll('.export-link').forEach((link) => {{ document.querySelectorAll('.export-link').forEach((link) => {{
const base = link.dataset.base; const base = link.dataset.base;
const sep = base.includes('?') ? '&' : '?'; const sep = base.includes('?') ? '&' : '?';
@@ -606,6 +663,9 @@ def _page_shell(
document.querySelectorAll('.export-option').forEach((option) => {{ document.querySelectorAll('.export-option').forEach((option) => {{
url.searchParams.set(option.dataset.param, option.checked ? 'true' : 'false'); url.searchParams.set(option.dataset.param, option.checked ? 'true' : 'false');
}}); }});
document.querySelectorAll('.result-option').forEach((option) => {{
url.searchParams.set(option.dataset.param, option.value);
}});
window.location.href = url.toString(); window.location.href = url.toString();
}} }}
const resultForm = document.querySelector('.row-control'); const resultForm = document.querySelector('.row-control');
@@ -619,6 +679,10 @@ def _page_shell(
updateExportLinks(); updateExportLinks();
refreshResultWithOptions(); refreshResultWithOptions();
}})); }}));
document.querySelectorAll('.result-option').forEach((option) => option.addEventListener('change', () => {{
updateExportLinks();
refreshResultWithOptions();
}}));
updateExportLinks(); updateExportLinks();
</script> </script>
</body> </body>

View File

@@ -6,6 +6,7 @@ import sys
import uuid import uuid
import zipfile import zipfile
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from pathlib import Path from pathlib import Path
from openpyxl import load_workbook from openpyxl import load_workbook
@@ -17,6 +18,8 @@ PROCESSOR_DIR = Path(__file__).resolve().parent / "processors"
SUMMARY_SHEET_NAME = "\u672a\u68c0\u6d4b\u5230\u5185\u5bb9\u6c47\u603b" SUMMARY_SHEET_NAME = "\u672a\u68c0\u6d4b\u5230\u5185\u5bb9\u6c47\u603b"
UNMATCHED_HEADER = "\u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9" UNMATCHED_HEADER = "\u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9"
UNMATCHED_FILL = PatternFill(fill_type="solid", fgColor="FCE4D6") UNMATCHED_FILL = PatternFill(fill_type="solid", fgColor="FCE4D6")
SORT_FIELDS = {"none", "name", "sample_time", "reason"}
SORT_ORDERS = {"asc", "desc"}
class ProcessingError(Exception): class ProcessingError(Exception):
@@ -59,6 +62,8 @@ def run_processing(
include_basic_sheets: bool = True, include_basic_sheets: bool = True,
include_unmatched_items: bool = True, include_unmatched_items: bool = True,
include_summary_sheet: bool = True, include_summary_sheet: bool = True,
sort_by: str = "sample_time",
sort_order: str = "asc",
) -> ProcessingResult: ) -> ProcessingResult:
if mode not in {"auto", "v1", "v2"}: if mode not in {"auto", "v1", "v2"}:
raise ProcessingError("处理模式不正确。") raise ProcessingError("处理模式不正确。")
@@ -142,6 +147,8 @@ def run_processing(
include_basic_sheets=include_basic_sheets, include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items, include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet, include_summary_sheet=include_summary_sheet,
sort_by=sort_by,
sort_order=sort_order,
) )
result_zip = job_dir / "result.zip" result_zip = job_dir / "result.zip"
@@ -160,6 +167,8 @@ def create_result_zip(
include_basic_sheets: bool = True, include_basic_sheets: bool = True,
include_unmatched_items: bool = True, include_unmatched_items: bool = True,
include_summary_sheet: bool = True, include_summary_sheet: bool = True,
sort_by: str = "sample_time",
sort_order: str = "asc",
) -> Path: ) -> Path:
output_dir = job_dir / "output" output_dir = job_dir / "output"
result_zip = job_dir / "result.zip" result_zip = job_dir / "result.zip"
@@ -176,6 +185,8 @@ def create_result_zip(
include_basic_sheets=include_basic_sheets, include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items, include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet, include_summary_sheet=include_summary_sheet,
sort_by=sort_by,
sort_order=sort_order,
) )
result_zip = export_dir / "result.zip" result_zip = export_dir / "result.zip"
@@ -189,6 +200,8 @@ def summarize_job(
include_basic_sheets: bool = True, include_basic_sheets: bool = True,
include_unmatched_items: bool = True, include_unmatched_items: bool = True,
include_summary_sheet: bool = True, include_summary_sheet: bool = True,
sort_by: str = "sample_time",
sort_order: str = "asc",
) -> ProcessingResult: ) -> ProcessingResult:
output_dir = job_dir / "output" output_dir = job_dir / "output"
if not output_dir.exists(): if not output_dir.exists():
@@ -204,6 +217,8 @@ def summarize_job(
include_basic_sheets=include_basic_sheets, include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items, include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet, include_summary_sheet=include_summary_sheet,
sort_by=sort_by,
sort_order=sort_order,
) )
xlsx_files = sorted(preview_dir.rglob("*.xlsx")) xlsx_files = sorted(preview_dir.rglob("*.xlsx"))
@@ -226,6 +241,8 @@ def find_output_file(
include_basic_sheets: bool = True, include_basic_sheets: bool = True,
include_unmatched_items: bool = True, include_unmatched_items: bool = True,
include_summary_sheet: bool = True, include_summary_sheet: bool = True,
sort_by: str = "sample_time",
sort_order: str = "asc",
) -> Path: ) -> Path:
output_dir = (job_dir / "output").resolve() output_dir = (job_dir / "output").resolve()
target = (output_dir / relpath).resolve() target = (output_dir / relpath).resolve()
@@ -241,6 +258,8 @@ def find_output_file(
include_basic_sheets=include_basic_sheets, include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items, include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet, include_summary_sheet=include_summary_sheet,
sort_by=sort_by,
sort_order=sort_order,
) )
return export_target return export_target
@@ -258,6 +277,8 @@ def _apply_export_options(
include_basic_sheets: bool, include_basic_sheets: bool,
include_unmatched_items: bool, include_unmatched_items: bool,
include_summary_sheet: bool, include_summary_sheet: bool,
sort_by: str,
sort_order: str,
) -> None: ) -> None:
if not include_basic_sheets and not include_summary_sheet: if not include_basic_sheets and not include_summary_sheet:
raise ProcessingError("\u81f3\u5c11\u9700\u8981\u5bfc\u51fa\u57fa\u672c\u5de5\u4f5c\u8868\u6216\u672a\u68c0\u6d4b\u5230\u5185\u5bb9\u6c47\u603b\u8868\u3002") raise ProcessingError("\u81f3\u5c11\u9700\u8981\u5bfc\u51fa\u57fa\u672c\u5de5\u4f5c\u8868\u6216\u672a\u68c0\u6d4b\u5230\u5185\u5bb9\u6c47\u603b\u8868\u3002")
@@ -278,6 +299,7 @@ def _apply_export_options(
if not workbook.worksheets: if not workbook.worksheets:
workbook.create_sheet(SUMMARY_SHEET_NAME) workbook.create_sheet(SUMMARY_SHEET_NAME)
_sort_workbook(workbook, sort_by, sort_order)
workbook.save(path) workbook.save(path)
finally: finally:
workbook.close() workbook.close()
@@ -434,6 +456,8 @@ def _postprocess_workbook(
include_basic_sheets: bool, include_basic_sheets: bool,
include_unmatched_items: bool, include_unmatched_items: bool,
include_summary_sheet: bool, include_summary_sheet: bool,
sort_by: str,
sort_order: str,
) -> None: ) -> None:
workbook = load_workbook(path) workbook = load_workbook(path)
try: try:
@@ -455,6 +479,7 @@ def _postprocess_workbook(
if not workbook.worksheets: if not workbook.worksheets:
workbook.create_sheet(SUMMARY_SHEET_NAME) workbook.create_sheet(SUMMARY_SHEET_NAME)
_sort_workbook(workbook, sort_by, sort_order)
workbook.save(path) workbook.save(path)
finally: finally:
workbook.close() workbook.close()
@@ -631,6 +656,134 @@ def _remove_unmatched_columns(workbook) -> None:
sheet.delete_cols(unmatched_col + 1, sheet.max_column - unmatched_col) sheet.delete_cols(unmatched_col + 1, sheet.max_column - unmatched_col)
def _sort_workbook(workbook, sort_by: str, sort_order: str) -> None:
sort_by = sort_by if sort_by in SORT_FIELDS else "sample_time"
sort_order = sort_order if sort_order in SORT_ORDERS else "asc"
if sort_by == "none":
return
reverse = sort_order == "desc"
for sheet in workbook.worksheets:
if sheet.max_row < 3:
continue
if sheet.title == SUMMARY_SHEET_NAME:
_sort_summary_sheet(sheet, sort_by, reverse)
else:
_sort_regular_sheet(sheet, sort_by, reverse)
def _sort_regular_sheet(sheet, sort_by: str, reverse: bool) -> None:
header = [_cell_text(sheet.cell(1, col).value) for col in range(1, sheet.max_column + 1)]
sort_col = _sort_column_from_header(header, sort_by)
if sort_col is None:
return
rows = _read_rows(sheet, 2, sheet.max_row)
_sort_rows(rows, sort_col, sort_by, reverse)
_write_rows(sheet, 2, rows)
def _sort_summary_sheet(sheet, sort_by: str, reverse: bool) -> None:
row_index = 1
while row_index <= sheet.max_row:
row = [_cell_text(sheet.cell(row_index, col).value) for col in range(1, min(sheet.max_column, 4) + 1)]
if not _is_summary_header_values(row):
row_index += 1
continue
header = [_cell_text(sheet.cell(row_index, col).value) for col in range(1, sheet.max_column + 1)]
sort_col = _sort_column_from_header(header, sort_by)
if sort_col is None:
row_index += 1
continue
start = row_index + 1
end = start
while end <= sheet.max_row:
next_row = [
_cell_text(sheet.cell(end, col).value)
for col in range(1, min(sheet.max_column, 4) + 1)
]
if _is_summary_header_values(next_row):
break
end += 1
if end > start:
rows = _read_rows(sheet, start, end - 1)
_sort_rows(rows, sort_col, sort_by, reverse)
_write_rows(sheet, start, rows)
row_index = end
def _sort_column_from_header(header: list[str], sort_by: str) -> int | None:
if sort_by == "name":
names = ["姓名"]
elif sort_by == "sample_time":
names = ["采样时间"]
elif sort_by == "reason":
names = ["检测原因"]
else:
return None
for index, value in enumerate(header):
if any(value == name or value.startswith(name) for name in names):
return index
return None
def _read_rows(sheet, start: int, end: int) -> list[list[object]]:
return [
[sheet.cell(row_index, col).value for col in range(1, sheet.max_column + 1)]
for row_index in range(start, end + 1)
]
def _write_rows(sheet, start: int, rows: list[list[object]]) -> None:
for offset, row in enumerate(rows):
row_index = start + offset
for col, value in enumerate(row, start=1):
sheet.cell(row_index, col).value = value
def _sort_rows(rows: list[list[object]], sort_col: int, sort_by: str, reverse: bool) -> None:
rows.sort(key=lambda row: _sort_value(row[sort_col], sort_by))
if reverse:
filled = [row for row in rows if _cell_text(row[sort_col])]
empty = [row for row in rows if not _cell_text(row[sort_col])]
filled.reverse()
rows[:] = filled + empty
def _sort_value(value: object, sort_by: str) -> tuple[int, object]:
text = _cell_text(value)
if not text:
return (1, "")
if sort_by == "sample_time":
parsed = _parse_datetime(text)
if parsed is not None:
return (0, parsed.isoformat())
return (0, text)
def _parse_datetime(value: str) -> datetime | None:
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d"):
try:
return datetime.strptime(value, fmt)
except ValueError:
continue
return None
def _is_summary_header_values(row: list[str]) -> bool:
return (
len(row) >= 4
and row[0] == "姓名"
and row[1] == "住院号"
and row[2] == "采样时间"
and row[3].startswith("检测原因")
)
def _find_header_index(header: list[str], name: str) -> int | None: def _find_header_index(header: list[str], name: str) -> int | None:
for index, value in enumerate(header): for index, value in enumerate(header):
if value == name: if value == name: