Files
HIS_Sur_Data_Deal/app/main.py
2026-05-08 23:06:00 +08:00

550 lines
18 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="auto">\u81ea\u52a8\u8bc6\u522b</option>
<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>
<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("auto"),
result_name: str = Form("Result"),
preview_rows: int = Form(20),
) -> 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=True,
include_unmatched_items=True,
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,
include_basic_sheets: bool = Query(True),
include_unmatched_items: bool = Query(True),
include_summary_sheet: bool = Query(True),
) -> FileResponse:
job_dir = _get_job_dir(job_id)
try:
result_zip = create_result_zip(
job_dir,
include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet,
)
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(...),
include_basic_sheets: bool = Query(True),
include_unmatched_items: bool = Query(True),
include_summary_sheet: bool = Query(True),
) -> FileResponse:
job_dir = _get_job_dir(job_id)
try:
target = find_output_file(
job_dir,
path,
include_basic_sheets=include_basic_sheets,
include_unmatched_items=include_unmatched_items,
include_summary_sheet=include_summary_sheet,
)
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 export-link" data-base="/download/all/{html.escape(result.job_id)}" 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>
<section class="export-options">
<strong>\u5bfc\u51fa\u5185\u5bb9\u9009\u62e9</strong>
<label><input class="export-option" type="checkbox" data-param="include_basic_sheets" checked> \u57fa\u672c\u5de5\u4f5c\u8868</label>
<label><input class="export-option" type="checkbox" data-param="include_unmatched_items" checked> \u672a\u5339\u914d\u68c0\u6d4b\u5185\u5bb9\u9879</label>
<label><input class="export-option" type="checkbox" data-param="include_summary_sheet" checked> \u672a\u68c0\u6d4b\u5230\u5185\u5bb9\u6c47\u603b\u8868</label>
</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 export-link" data-base="{file_url}" 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)
unmatched_start = None
if preview and UNMATCHED in preview[0]:
unmatched_start = preview[0].index(UNMATCHED)
rows = []
for index, row in enumerate(preview):
row_class = ' class="group-header"' if _is_summary_group_header(row) else ""
cells = []
for col_index, value in enumerate((row + [""] * max_cols)[:max_cols]):
tag = "th" if index == 0 else "td"
css_class = ' class="extra-col"' if unmatched_start is not None and col_index >= unmatched_start else ""
cells.append(f"<{tag}{css_class}>{html.escape(value)}</{tag}>")
rows.append(f"<tr{row_class}>" + "".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;
}}
.export-options {{
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 10px 12px;
margin-bottom: 14px;
font-size: 14px;
}}
.export-options label {{
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-weight: 500;
}}
.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: 380px;
overflow-x: auto;
overflow-y: scroll;
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; }}
tr.group-header td {{ font-weight: 700; background: #fbfcfd; }}
th.extra-col, td.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, .export-options {{ 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>
<script>
function updateExportLinks() {{
const options = Array.from(document.querySelectorAll('.export-option'));
const params = new URLSearchParams();
options.forEach((option) => params.set(option.dataset.param, option.checked ? 'true' : 'false'));
document.querySelectorAll('.export-link').forEach((link) => {{
const base = link.dataset.base;
const sep = base.includes('?') ? '&' : '?';
link.href = base + sep + params.toString();
}});
}}
document.querySelectorAll('.export-option').forEach((option) => option.addEventListener('change', updateExportLinks));
updateExportLinks();
</script>
</body>
</html>
"""
def _is_summary_group_header(row: list[str]) -> bool:
return len(row) >= 4 and row[0] == "\u59d3\u540d" and row[1] == "\u4f4f\u9662\u53f7" and row[2] == "\u91c7\u6837\u65f6\u95f4" and row[3].startswith("\u68c0\u6d4b\u539f\u56e0")