Initial commit: intercompany ledger app
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from tempfile import NamedTemporaryFile
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from bank_importer.parser import StatementParseError, parse_statement
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
WEB_ROOT = ROOT / "web"
|
||||
MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
class AppHandler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(WEB_ROOT), **kwargs)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if urlparse(self.path).path != "/api/parse":
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
try:
|
||||
filename, content = self._read_uploaded_file()
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in {".xls", ".xlsx"}:
|
||||
raise ValueError("仅支持 .xls 或 .xlsx 银行流水文件。")
|
||||
|
||||
with NamedTemporaryFile(suffix=suffix, delete=False) as temp_file:
|
||||
temp_path = Path(temp_file.name)
|
||||
temp_file.write(content)
|
||||
try:
|
||||
batches = parse_statement(temp_path)
|
||||
except StatementParseError as exc:
|
||||
message = str(exc).replace(temp_path.name, filename)
|
||||
raise StatementParseError(message) from exc
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
batch = batches[0]
|
||||
self._send_json(
|
||||
200,
|
||||
{
|
||||
"status": "parsed",
|
||||
"bank": batch.bank_name,
|
||||
"template": batch.template_id,
|
||||
"header_row": batch.header_row,
|
||||
"period_start": batch.period_start.isoformat()
|
||||
if batch.period_start
|
||||
else None,
|
||||
"period_end": batch.period_end.isoformat()
|
||||
if batch.period_end
|
||||
else None,
|
||||
"transactions": len(batch.transactions),
|
||||
"warnings": list(batch.warnings),
|
||||
},
|
||||
)
|
||||
except (ValueError, StatementParseError) as exc:
|
||||
self._send_json(422, {"status": "exception", "message": str(exc)})
|
||||
except Exception:
|
||||
self._send_json(
|
||||
500,
|
||||
{"status": "error", "message": "文件解析失败,请检查文件是否完整。"},
|
||||
)
|
||||
|
||||
def _read_uploaded_file(self) -> tuple[str, bytes]:
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
if content_length <= 0 or content_length > MAX_UPLOAD_BYTES:
|
||||
raise ValueError("文件为空或超过 20 MB 限制。")
|
||||
|
||||
content_type = self.headers.get("Content-Type", "")
|
||||
boundary_match = re.search(r"boundary=(?:\"([^\"]+)\"|([^;]+))", content_type)
|
||||
if not boundary_match:
|
||||
raise ValueError("上传请求缺少文件边界。")
|
||||
boundary = (boundary_match.group(1) or boundary_match.group(2)).encode()
|
||||
body = self.rfile.read(content_length)
|
||||
|
||||
for part in body.split(b"--" + boundary):
|
||||
if b'name="file"' not in part:
|
||||
continue
|
||||
header, separator, content = part.partition(b"\r\n\r\n")
|
||||
if not separator:
|
||||
continue
|
||||
filename_match = re.search(br'filename="([^\"]+)"', header)
|
||||
filename = (
|
||||
filename_match.group(1).decode("utf-8", errors="replace")
|
||||
if filename_match
|
||||
else "statement.xlsx"
|
||||
)
|
||||
return os.path.basename(filename), content.rstrip(b"\r\n")
|
||||
raise ValueError("上传请求中没有找到文件。")
|
||||
|
||||
def _send_json(self, status: int, payload: dict[str, object]) -> None:
|
||||
content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(content)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(content)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
port = int(os.environ.get("APP_PORT", "4173"))
|
||||
server = ThreadingHTTPServer(("127.0.0.1", port), AppHandler)
|
||||
print(f"Serving on http://127.0.0.1:{port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user