Files
HIS_Sur_Data_Deal/app/main.py

486 lines
16 KiB
Python

import html
import shutil
import tempfile
import uuid
from pathlib import Path
from urllib.parse import quote
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse, HTMLResponse
from .processor import (
ProcessingError,
ProcessingResult,
create_result_zip,
find_output_file,
run_processing,
summarize_job,
)
WORK_ROOT = Path(tempfile.gettempdir()) / "his_sur_data_deal_jobs"
WORK_ROOT.mkdir(parents=True, exist_ok=True)
APP_TITLE = "\u68c0\u6d4b\u6570\u636e\u5904\u7406"
UNMATCHED = "\u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9"
app = FastAPI(title=APP_TITLE)
@app.get("/", response_class=HTMLResponse)
def index() -> str:
body = f"""
<section class="panel">
<form action="/process" method="post" enctype="multipart/form-data">
<div>
<label for="file">\u5f85\u5904\u7406\u68c0\u6d4b\u6570\u636e.zip</label>
<input id="file" name="file" type="file" accept=".zip,application/zip" required>
</div>
<div class="grid">
<div>
<label for="mode">\u5904\u7406\u6a21\u5f0f</label>
<select id="mode" name="mode">
<option value="auto">\u81ea\u52a8\u8bc6\u522b</option>
<option value="v1">V1 \u6574\u6279\u6c47\u603b</option>
<option value="v2">V2 \u5355\u60a3\u8005\u6587\u4ef6</option>
</select>
</div>
<div>
<label for="data_type">\u60a3\u8005\u7f16\u53f7\u7c7b\u578b</label>
<select id="data_type" name="data_type">
<option value="pat_no">\u60a3\u8005\u53f7 pat_no</option>
<option value="zhuyuanhao">\u4f4f\u9662\u53f7 zhuyuanhao</option>
</select>
</div>
<div>
<label for="result_name">\u7ed3\u679c\u6587\u4ef6\u540d</label>
<input id="result_name" name="result_name" type="text" value="Result">
</div>
<div>
<label for="preview_rows">\u6bcf\u4e2a\u5de5\u4f5c\u8868\u9884\u89c8\u884c\u6570</label>
<input id="preview_rows" name="preview_rows" type="number" min="5" max="200" value="20">
</div>
</div>
<div class="checks">
<label><input type="checkbox" name="include_basic_sheets" value="true" checked> \u57fa\u672c\u5de5\u4f5c\u8868</label>
<label><input type="checkbox" name="include_unmatched_items" value="true" checked> \u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9\u9879</label>
<label><input type="checkbox" name="include_summary_sheet" value="true" checked> \u672a\u68c0\u6d4b\u5230\u5185\u5bb9\u6c47\u603b\u8868</label>
</div>
<button type="submit">\u5f00\u59cb\u5904\u7406</button>
</form>
<div class="note">\u9ed8\u8ba4\u8f93\u51fa\u5168\u90e8\u68c0\u6d4b\u8bb0\u5f55\uff0c\u5e76\u4fdd\u7559\u201c{UNMATCHED}\u201d\u5217\uff1b\u8be5\u5217\u4e3a\u989d\u5916\u8f85\u52a9\u4fe1\u606f\uff0c\u4fbf\u4e8e\u6838\u5bf9\u672a\u5f52\u7c7b\u9879\u76ee\u3002</div>
</section>
"""
return _page_shell(body)
@app.post("/process", response_class=HTMLResponse)
async def process(
file: UploadFile = File(...),
mode: str = Form("auto"),
data_type: str = Form("pat_no"),
result_name: str = Form("Result"),
preview_rows: int = Form(20),
include_basic_sheets: str | None = Form(None),
include_unmatched_items: str | None = Form(None),
include_summary_sheet: str | None = Form(None),
) -> str:
if not file.filename or not file.filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="\u8bf7\u4e0a\u4f20 zip \u6587\u4ef6\u3002")
job_dir = WORK_ROOT / uuid.uuid4().hex
job_dir.mkdir(parents=True, exist_ok=True)
upload_path = job_dir / "input.zip"
try:
with upload_path.open("wb") as out:
shutil.copyfileobj(file.file, out)
rows = _clean_preview_rows(preview_rows)
result = run_processing(
zip_path=upload_path,
job_dir=job_dir,
mode=mode,
data_type=data_type,
result_name=result_name,
show_not_match=True,
show_all_infos=True,
preview_rows=rows,
include_basic_sheets=include_basic_sheets == "true",
include_unmatched_items=include_unmatched_items == "true",
include_summary_sheet=include_summary_sheet == "true",
)
except ProcessingError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"\u5904\u7406\u5931\u8d25\uff1a{exc}") from exc
return _render_result(result, preview_rows=rows)
@app.get("/result/{job_id}", response_class=HTMLResponse)
def result_page(job_id: str, preview_rows: int = Query(20, ge=5, le=200)) -> str:
job_dir = _get_job_dir(job_id)
try:
rows = _clean_preview_rows(preview_rows)
result = summarize_job(job_dir, rows)
except ProcessingError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return _render_result(result, preview_rows=rows)
@app.get("/download/all/{job_id}")
def download_all(job_id: str) -> FileResponse:
job_dir = _get_job_dir(job_id)
try:
result_zip = create_result_zip(job_dir)
except ProcessingError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return FileResponse(result_zip, media_type="application/zip", filename="result.zip")
@app.get("/download/file/{job_id}")
def download_file(job_id: str, path: str = Query(...)) -> FileResponse:
job_dir = _get_job_dir(job_id)
try:
target = find_output_file(job_dir, path)
except ProcessingError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return FileResponse(
target,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=target.name,
)
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
def _get_job_dir(job_id: str) -> Path:
if not job_id.isalnum():
raise HTTPException(status_code=404, detail="\u7ed3\u679c\u4e0d\u5b58\u5728\u3002")
job_dir = (WORK_ROOT / job_id).resolve()
if not str(job_dir).startswith(str(WORK_ROOT.resolve())) or not job_dir.exists():
raise HTTPException(status_code=404, detail="\u7ed3\u679c\u4e0d\u5b58\u5728\u3002")
return job_dir
def _clean_preview_rows(preview_rows: int) -> int:
return max(5, min(int(preview_rows or 20), 200))
def _render_result(result: ProcessingResult, preview_rows: int = 20) -> str:
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)
file_items = "\n".join(_render_file(file, result.job_id) for file in result.files)
body = f"""
<section class="summary">
<div class="metric"><span>\u5904\u7406\u6a21\u5f0f</span><strong>{html.escape(result.mode.upper())}</strong></div>
<div class="metric"><span>Excel \u6587\u4ef6</span><strong>{len(result.files)}</strong></div>
<div class="metric"><span>\u5de5\u4f5c\u8868</span><strong>{total_sheets}</strong></div>
<div class="metric"><span>\u6570\u636e\u884c</span><strong>{total_rows}</strong></div>
</section>
<section class="actions">
<a class="button" href="/download/all/{html.escape(result.job_id)}">\u5bfc\u51fa\u5168\u90e8 Excel</a>
<a class="ghost" href="/">\u7ee7\u7eed\u5904\u7406\u65b0\u6587\u4ef6</a>
</section>
<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>
<input id="preview_rows" name="preview_rows" type="number" min="5" max="200" value="{preview_rows}">
<button type="submit">\u5237\u65b0\u9884\u89c8</button>
</form>
<section class="hint">\u7ed3\u679c\u4e2d\u201c{UNMATCHED}\u201d\u5217\u4e3a\u989d\u5916\u8f85\u52a9\u4fe1\u606f\uff0c\u7528\u6765\u6807\u660e\u672a\u5f52\u5165\u6807\u51c6\u9879\u76ee\u7684\u68c0\u6d4b\u5185\u5bb9\u3002</section>
<section class="results">
{file_items}
</section>
"""
return _page_shell(body, subtitle="\u5904\u7406\u5b8c\u6210\uff0c\u53ef\u5411\u4e0b\u6eda\u52a8\u67e5\u770b\u66f4\u591a\u5de5\u4f5c\u8868\uff0c\u4e5f\u53ef\u8c03\u6574\u9884\u89c8\u884c\u6570\u540e\u5237\u65b0\u3002")
def _render_file(file, job_id: str) -> str:
sheet_items = "\n".join(_render_sheet(sheet) for sheet in file.sheets)
file_url = f"/download/file/{html.escape(job_id)}?path={quote(file.relpath)}"
return f"""
<article class="file-block">
<div class="file-head">
<div>
<h2>{html.escape(file.filename)}</h2>
<p>{len(file.sheets)} \u4e2a\u5de5\u4f5c\u8868</p>
</div>
<a class="small-button" href="{file_url}">\u5bfc\u51fa\u6b64 Excel</a>
</div>
{sheet_items}
</article>
"""
def _render_sheet(sheet) -> str:
preview = sheet.preview
table = '<div class="empty">\u6b64\u5de5\u4f5c\u8868\u6ca1\u6709\u53ef\u9884\u89c8\u7684\u6570\u636e\u3002</div>'
if preview:
max_cols = min(max((len(row) for row in preview), default=0), 18)
rows = []
for index, row in enumerate(preview):
cells = []
for value in (row + [""] * max_cols)[:max_cols]:
tag = "th" if index == 0 else "td"
css_class = ' class="extra-col"' if index == 0 and value == UNMATCHED else ""
cells.append(f"<{tag}{css_class}>{html.escape(value)}</{tag}>")
rows.append("<tr>" + "".join(cells) + "</tr>")
table = f'<div class="table-wrap"><table>{"".join(rows)}</table></div>'
return f"""
<details class="sheet" open>
<summary>
<span>{html.escape(sheet.name)}</span>
<small>{sheet.rows} \u884c · {sheet.columns} \u5217</small>
</summary>
{table}
</details>
"""
def _page_shell(body: str, subtitle: str = "\u4e0a\u4f20\u201c\u5f85\u5904\u7406\u68c0\u6d4b\u6570\u636e.zip\u201d\uff0c\u5904\u7406\u5b8c\u6210\u540e\u5728\u7f51\u9875\u4e2d\u67e5\u770b\u7ed3\u679c\u3002") -> str:
return f"""
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{APP_TITLE}</title>
<style>
:root {{
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--text: #1f2937;
--muted: #64748b;
--line: #d7dde5;
--primary: #146c5c;
--primary-dark: #0f574b;
--soft: #eef7f5;
--extra: #fff7e6;
}}
* {{ box-sizing: border-box; }}
html, body {{ overflow-x: hidden; }}
body {{
margin: 0;
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
}}
.shell {{
width: min(1600px, calc(100vw - 24px));
margin: 0 auto;
padding: 24px 0;
min-width: 0;
}}
header {{ margin-bottom: 18px; }}
h1 {{
margin: 0;
font-size: 26px;
line-height: 1.2;
font-weight: 750;
letter-spacing: 0;
}}
.sub {{
margin-top: 6px;
color: var(--muted);
font-size: 14px;
}}
.panel, .file-block {{
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
box-shadow: 0 8px 22px rgba(17, 24, 39, 0.05);
min-width: 0;
}}
form {{ display: grid; gap: 16px; }}
label {{
display: block;
margin-bottom: 7px;
font-size: 14px;
font-weight: 650;
}}
input[type="file"], select, input[type="text"], input[type="number"] {{
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px 11px;
font: inherit;
background: #fff;
color: var(--text);
}}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}}
.checks {{
display: flex;
flex-wrap: wrap;
gap: 14px;
color: var(--text);
font-size: 14px;
}}
.checks label {{
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-weight: 500;
}}
button, .button, .small-button, .ghost {{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
border-radius: 6px;
padding: 0 14px;
font: inherit;
font-weight: 700;
text-decoration: none;
cursor: pointer;
}}
button, .button, .small-button {{
border: 0;
background: var(--primary);
color: #fff;
}}
button:hover, .button:hover, .small-button:hover {{ background: var(--primary-dark); }}
.ghost {{
border: 1px solid var(--line);
color: var(--text);
background: #fff;
}}
.note, .hint {{
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}}
.note {{
margin-top: 2px;
border-top: 1px solid var(--line);
padding-top: 14px;
}}
.summary {{
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}}
.metric {{
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 14px;
}}
.metric span {{
display: block;
color: var(--muted);
font-size: 13px;
margin-bottom: 8px;
}}
.metric strong {{ font-size: 24px; line-height: 1; }}
.actions, .row-control {{
display: flex;
align-items: end;
gap: 10px;
margin: 0 0 14px;
}}
.row-control label {{
margin: 0;
color: var(--muted);
font-weight: 650;
}}
.row-control input {{ width: 120px; }}
.hint {{
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 10px 12px;
margin-bottom: 14px;
}}
.results {{ display: grid; gap: 14px; min-width: 0; }}
.file-head {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 14px;
}}
h2 {{ margin: 0; font-size: 18px; letter-spacing: 0; }}
.file-head p {{
margin: 5px 0 0;
color: var(--muted);
font-size: 13px;
}}
.sheet {{
border: 1px solid var(--line);
border-radius: 8px;
margin-top: 10px;
overflow: hidden;
min-width: 0;
}}
summary {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 14px;
cursor: pointer;
background: var(--soft);
font-weight: 700;
}}
summary small {{ color: var(--muted); font-weight: 500; }}
.table-wrap {{
width: 100%;
max-width: 100%;
max-height: 620px;
overflow: auto;
background: #fff;
}}
table {{
width: max-content;
min-width: 100%;
border-collapse: collapse;
font-size: 13px;
}}
th, td {{
border-top: 1px solid var(--line);
border-right: 1px solid var(--line);
padding: 7px 10px;
text-align: left;
vertical-align: top;
white-space: nowrap;
}}
th {{ background: #fbfcfd; font-weight: 700; }}
th.extra-col {{ background: var(--extra); }}
.empty {{
color: var(--muted);
font-size: 13px;
padding: 12px 14px;
}}
@media (max-width: 760px) {{
.grid, .summary {{ grid-template-columns: 1fr; }}
.actions, .file-head, .row-control {{ align-items: stretch; flex-direction: column; }}
.row-control input {{ width: 100%; }}
button, .button, .small-button, .ghost {{ width: 100%; }}
}}
</style>
</head>
<body>
<main class="shell">
<header>
<h1>{APP_TITLE}</h1>
<div class="sub">{html.escape(subtitle)}</div>
</header>
{body}
</main>
</body>
</html>
"""