Compare commits

..

4 Commits

Author SHA1 Message Date
15e85f9f04 fix nas compose build context 2026-05-09 00:42:27 +08:00
71b30bc4c2 add compose deployment docs 2026-05-09 00:35:33 +08:00
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
6 changed files with 516 additions and 38 deletions

View File

@@ -4,4 +4,4 @@ __pycache__/
.venv/ .venv/
uploads/ uploads/
jobs/ jobs/
data/

181
README.md
View File

@@ -1,6 +1,15 @@
# HIS_Sur_Data_Deal # HIS_Sur_Data_Deal
网页端检测数据处理工具。上传 `待处理检测数据.zip` 后,服务会自动识别 V1/V2 数据结构,调用原处理脚本生成 Excel并在网页中展示结果摘要、工作表统计和数据预览;用户可调整每个工作表的预览行数,并按需导出单个 Excel 网页端检测数据处理工具。上传 `待处理检测数据.zip` 后,系统会自动识别 V1/V2 数据结构,调用原处理脚本生成 Excel并在网页中展示结果摘要、工作表统计和数据预览。
处理完成后可在结果页:
- 调整每个工作表的预览行数。
- 选择是否显示基本工作表、未匹配检测内容项、未检测到内容汇总表。
- 按姓名、采样时间、检测原因选择升序或降序排序。
- 导出当前内容选择和排序后的单个 Excel。
“未检测到内容汇总”会在每个“姓名 / 住院号 / 采样时间 / 检测原因”分组标题下单独排序,不会把不同检测原因的分组混在一起。
## 本地运行 ## 本地运行
@@ -9,7 +18,11 @@ pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000 uvicorn app.main:app --host 0.0.0.0 --port 8000
``` ```
访问 `http://localhost:8000` 访问
```text
http://localhost:8000
```
## Docker 构建与运行 ## Docker 构建与运行
@@ -18,7 +31,145 @@ docker build -t his-sur-data-deal .
docker run --rm -p 8000:8000 his-sur-data-deal docker run --rm -p 8000:8000 his-sur-data-deal
``` ```
## 推送镜像示例 访问:
```text
http://localhost:8000
```
## Docker Compose 通用部署
通用配置文件:
```text
docker_compose.yaml
```
这个配置是本地构建配置,不会从远程镜像仓库拉取 `his-sur-data-deal:latest`。运行时必须在项目源码目录执行,目录中需要包含:
```text
Dockerfile
requirements.txt
app/
docker_compose.yaml
```
启动:
```bash
docker compose -f docker_compose.yaml up -d --build
```
默认映射:
```text
宿主机 8000 -> 容器 8000
```
访问:
```text
http://服务器IP:8000
```
## frpc + NPM 反向代理到公网域名
如果本服务运行在内网机器,想通过公网服务器域名访问,可按下面链路处理:
```text
浏览器
-> 公网域名
-> 公网服务器 Nginx Proxy Manager
-> 公网服务器 frps 暴露端口
-> 内网机器 frpc
-> 内网 Docker 服务 127.0.0.1:8000
```
内网机器运行本项目:
```bash
docker compose -f docker_compose.yaml up -d --build
```
内网机器 frpc 示例:
```toml
serverAddr = "公网服务器IP"
serverPort = 7000
[[proxies]]
name = "his-sur-data-deal"
type = "tcp"
localIP = "127.0.0.1"
localPort = 8000
remotePort = 18004
```
公网服务器 Nginx Proxy Manager 中新增 Proxy Host
```text
Domain Names: 你的域名,例如 his.example.com
Scheme: http
Forward Hostname / IP: 127.0.0.1
Forward Port: 18004
Websockets Support: 可不开
Block Common Exploits: 建议开启
SSL: 按需申请 Let's Encrypt
```
如果公网服务器的 NPM 与 frps 不在同一台机器,则 `Forward Hostname / IP` 填写 frps 所在服务器的内网或公网 IP。
## NAS Docker Compose 部署
NAS 地址:
```text
http://192.168.31.5/
```
NAS 专用配置文件:
```text
docker_compose-Nas.yaml
```
这个配置同样是本地构建配置不依赖远程镜像仓库。NAS 上请把整个项目源码放到下面目录:
```text
/share/Container/his_sur_data_deal
```
该目录中必须包含:
```text
/share/Container/his_sur_data_deal/Dockerfile
/share/Container/his_sur_data_deal/requirements.txt
/share/Container/his_sur_data_deal/app/
/share/Container/his_sur_data_deal/docker_compose-Nas.yaml
```
如果只在 NAS 面板里粘贴 yaml而没有把 `Dockerfile``app/` 一起放到上述路径NAS 会报 `failed to read dockerfile: open Dockerfile: no such file or directory`
启动:
```bash
cd /share/Container/his_sur_data_deal
docker compose -f docker_compose-Nas.yaml up -d --build
```
默认映射:
```text
NAS 4004 -> 容器 8000
```
访问:
```text
http://192.168.31.5:4004
```
## 镜像推送示例
```bash ```bash
docker tag his-sur-data-deal 192.168.31.5:5002/admin/his-sur-data-deal:latest docker tag his-sur-data-deal 192.168.31.5:5002/admin/his-sur-data-deal:latest
@@ -27,19 +178,23 @@ docker push 192.168.31.5:5002/admin/his-sur-data-deal:latest
## 数据模式 ## 数据模式
V1zip 解压后包含 `Patients_info.csv``Tests_List``Tests_Detail_List`,输出一个汇总 Excel。 V1
V2zip 解压后包含 `Patients_info.csv`,并按患者目录分别保存检测汇总和具体检测,输出多个患者 Excel - zip 解压后包含 `Patients_info.csv``Tests_List``Tests_Detail_List`
- 输出一个汇总 Excel。
患者编号类型可选择自动识别。自动识别会读取 `Patients_info.csv` 中的 `pat_no`,并与 `Tests_List` 文件名或患者目录名比对:若更匹配 10 位补零编号,则使用 `pat_no`;若更匹配原始编号,则使用 `zhuyuanhao` V2
系统默认输出完整结果;处理完成后可在结果页通过“内容选择”立即切换预览内容,并同步影响右侧“导出此 Excel”的文件内容。可选择是否保留 - zip 解压后包含 `Patients_info.csv`
- 按患者目录分别保存检测汇总和具体检测。
- 输出多个患者 Excel。
- 基本工作表 患者编号类型可选择自动识别。自动识别会读取 `Patients_info.csv` 中的 `pat_no`,并与 `Tests_List` 文件名或患者目录名比对:如果更匹配 10 位补零编号,则使用 `pat_no`;如果更匹配原始编号,则使用 `zhuyuanhao`
- 未匹配检测内容项
- 未检测到内容汇总表
“未检测到内容汇总”会收集标准字段全部为 `Not_Find`、但存在未匹配检测内容的记录,并按检测原因排序汇总。 ## 结果规则
未匹配检测内容会被规范化为独立列:表头为未匹配检测项目名,数据行仅保存对应检测值 - 系统默认输出完整结果
如果某个工作表没有任何未匹配检测项目,则不会显示“未匹配检测内容”标识列。标准字段全为 `Not_Find` 且没有未匹配值的空结果行会被移除。 - `未匹配检测内容` 会规范化为独立列:表头为未匹配检测项目名,数据行只保存对应检测值。
- 如果某个工作表没有任何未匹配检测项目,不显示 `未匹配检测内容` 标识列。
- 标准检测字段全为 `Not_Find` 的记录会移入 `未检测到内容汇总`,不继续留在原基本工作表中。
- 导出 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:
sheet.delete_cols(marker_col, sheet.max_column - marker_col + 1)
continue
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
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:

34
docker_compose-Nas.yaml Normal file
View File

@@ -0,0 +1,34 @@
services:
his_sur_data_deal:
build:
context: /share/Container/his_sur_data_deal
dockerfile: Dockerfile
args:
HTTP_PROXY: http://192.168.31.7:7893
HTTPS_PROXY: http://192.168.31.7:7893
NO_PROXY: localhost,127.0.0.1,192.168.31.0/24,his_sur_data_deal
pull_policy: never
image: his-sur-data-deal:latest
container_name: his_sur_data_deal
restart: unless-stopped
ports:
- "4004:8000"
volumes:
- /share/Container/his_sur_data_deal/data/jobs:/tmp/his_sur_data_deal_jobs
environment:
TZ: Asia/Shanghai
HTTP_PROXY: http://192.168.31.7:7893
HTTPS_PROXY: http://192.168.31.7:7893
http_proxy: http://192.168.31.7:7893
https_proxy: http://192.168.31.7:7893
NO_PROXY: localhost,127.0.0.1,192.168.31.0/24,his_sur_data_deal
healthcheck:
test:
[
"CMD-SHELL",
"python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read()\"",
]
interval: 10s
timeout: 5s
retries: 6
start_period: 20s

28
docker_compose.yaml Normal file
View File

@@ -0,0 +1,28 @@
services:
his_sur_data_deal:
build:
context: .
dockerfile: Dockerfile
pull_policy: never
image: his-sur-data-deal:latest
container_name: his_sur_data_deal
restart: unless-stopped
ports:
- "8000:8000"
volumes:
- his_sur_data_deal_jobs:/tmp/his_sur_data_deal_jobs
environment:
TZ: Asia/Shanghai
healthcheck:
test:
[
"CMD-SHELL",
"python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read()\"",
]
interval: 10s
timeout: 5s
retries: 6
start_period: 20s
volumes:
his_sur_data_deal_jobs: