Compare commits

...

2 Commits

Author SHA1 Message Date
3d713b0fc5 move empty not-find rows to summary 2026-05-08 23:54:03 +08:00
0c07cd1290 add result sorting controls 2026-05-08 23:34:46 +08:00
3 changed files with 286 additions and 25 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,12 +456,15 @@ 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:
_normalize_unmatched_columns(workbook) _normalize_unmatched_columns(workbook)
_remove_empty_not_find_rows(workbook)
summary_records = _collect_summary_records(workbook) summary_records = _collect_summary_records(workbook)
_remove_not_found_rows(workbook)
_remove_empty_unmatched_columns(workbook)
if not include_unmatched_items: if not include_unmatched_items:
_remove_unmatched_columns(workbook) _remove_unmatched_columns(workbook)
@@ -455,6 +480,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()
@@ -484,13 +510,14 @@ def _normalize_unmatched_columns(workbook) -> None:
if item_name not in item_names: if item_name not in item_names:
item_names.append(item_name) item_names.append(item_name)
if not item_names:
if sheet.max_column >= marker_col: if sheet.max_column >= marker_col:
sheet.delete_cols(marker_col, sheet.max_column - marker_col + 1) sheet.delete_cols(marker_col, sheet.max_column - marker_col + 1)
if not item_names:
sheet.delete_cols(marker_col, 1)
continue continue
if sheet.max_column >= marker_col:
sheet.delete_cols(marker_col, sheet.max_column - marker_col + 1)
sheet.cell(1, marker_col).value = UNMATCHED_HEADER sheet.cell(1, marker_col).value = UNMATCHED_HEADER
sheet.cell(1, marker_col).font = Font(bold=True) sheet.cell(1, marker_col).font = Font(bold=True)
sheet.cell(1, marker_col).fill = UNMATCHED_FILL sheet.cell(1, marker_col).fill = UNMATCHED_FILL
@@ -509,7 +536,7 @@ def _normalize_unmatched_columns(workbook) -> None:
cell.fill = UNMATCHED_FILL cell.fill = UNMATCHED_FILL
def _remove_empty_not_find_rows(workbook) -> None: def _remove_not_found_rows(workbook) -> None:
for sheet in workbook.worksheets: for sheet in workbook.worksheets:
if sheet.title == SUMMARY_SHEET_NAME or sheet.max_row < 2: if sheet.title == SUMMARY_SHEET_NAME or sheet.max_row < 2:
continue continue
@@ -522,13 +549,7 @@ def _remove_empty_not_find_rows(workbook) -> None:
_cell_text(sheet.cell(row_index, col).value) _cell_text(sheet.cell(row_index, col).value)
for col in range(5, standard_end + 1) for col in range(5, standard_end + 1)
] ]
unmatched_values = [] if _all_standard_values_missing(standard_values):
if unmatched_index is not None:
unmatched_values = [
_cell_text(sheet.cell(row_index, col).value)
for col in range(unmatched_index + 2, sheet.max_column + 1)
]
if _all_standard_values_missing(standard_values) and not any(unmatched_values):
sheet.delete_rows(row_index, 1) sheet.delete_rows(row_index, 1)
@@ -539,8 +560,7 @@ def _collect_summary_records(workbook) -> list[dict[str, object]]:
continue continue
header = [_cell_text(sheet.cell(1, col).value) for col in range(1, sheet.max_column + 1)] header = [_cell_text(sheet.cell(1, col).value) for col in range(1, sheet.max_column + 1)]
unmatched_col = _find_header_index(header, UNMATCHED_HEADER) unmatched_col = _find_header_index(header, UNMATCHED_HEADER)
if unmatched_col is None: standard_end = unmatched_col if unmatched_col is not None else sheet.max_column
continue
for row_index in range(2, sheet.max_row + 1): for row_index in range(2, sheet.max_row + 1):
base_values = [_cell_text(sheet.cell(row_index, col).value) for col in range(1, 5)] base_values = [_cell_text(sheet.cell(row_index, col).value) for col in range(1, 5)]
@@ -548,18 +568,13 @@ def _collect_summary_records(workbook) -> list[dict[str, object]]:
continue continue
standard_values = [ standard_values = [
_cell_text(sheet.cell(row_index, col).value) _cell_text(sheet.cell(row_index, col).value)
for col in range(5, unmatched_col + 1) for col in range(5, standard_end + 1)
] ]
item_values = {
_cell_text(sheet.cell(1, col).value): _cell_text(sheet.cell(row_index, col).value)
for col in range(unmatched_col + 2, sheet.max_column + 1)
if _cell_text(sheet.cell(1, col).value)
}
item_values = {name: value for name, value in item_values.items() if value}
if not item_values:
continue
if not _all_standard_values_missing(standard_values): if not _all_standard_values_missing(standard_values):
continue continue
item_values = _summary_item_values(sheet, row_index, header, unmatched_col, standard_end)
if not item_values:
continue
records.append( records.append(
{ {
"name": base_values[0], "name": base_values[0],
@@ -573,6 +588,31 @@ def _collect_summary_records(workbook) -> list[dict[str, object]]:
return records return records
def _summary_item_values(
sheet,
row_index: int,
header: list[str],
unmatched_col: int | None,
standard_end: int,
) -> dict[str, str]:
item_values: dict[str, str] = {}
if unmatched_col is not None:
for col in range(unmatched_col + 2, sheet.max_column + 1):
item_name = _cell_text(sheet.cell(1, col).value)
item_value = _cell_text(sheet.cell(row_index, col).value)
if item_name and item_value:
item_values[item_name] = item_value
if item_values:
return item_values
for col in range(5, standard_end + 1):
item_name = header[col - 1] if col - 1 < len(header) else ""
item_value = _cell_text(sheet.cell(row_index, col).value)
if item_name and item_value:
item_values[item_name] = item_value
return item_values
def _replace_summary_sheet(workbook, records: list[dict[str, object]]) -> None: def _replace_summary_sheet(workbook, records: list[dict[str, object]]) -> None:
if SUMMARY_SHEET_NAME in workbook.sheetnames: if SUMMARY_SHEET_NAME in workbook.sheetnames:
workbook.remove(workbook[SUMMARY_SHEET_NAME]) workbook.remove(workbook[SUMMARY_SHEET_NAME])
@@ -631,6 +671,163 @@ 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 _remove_empty_unmatched_columns(workbook) -> None:
for sheet in workbook.worksheets:
if sheet.title == SUMMARY_SHEET_NAME or sheet.max_row < 1:
continue
header = [_cell_text(sheet.cell(1, col).value) for col in range(1, sheet.max_column + 1)]
unmatched_col = _find_header_index(header, UNMATCHED_HEADER)
if unmatched_col is None:
continue
first_col = unmatched_col + 1
item_cols = [
col
for col in range(first_col + 1, sheet.max_column + 1)
if _cell_text(sheet.cell(1, col).value)
]
used_item_cols = [
col
for col in item_cols
if any(_cell_text(sheet.cell(row, col).value) for row in range(2, sheet.max_row + 1))
]
if not used_item_cols:
sheet.delete_cols(first_col, sheet.max_column - unmatched_col)
continue
for col in reversed(item_cols):
if col not in used_item_cols:
sheet.delete_cols(col, 1)
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: