feat: add assessment workbench and September reconciliation
Preserve August hydrogen revision and September audit evidence; calculate interrupted vehicle history across all prior months. Record remaining carry-policy and layout-baseline discrepancies.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""本机 HTTP API;不可直接作为公网生产服务使用。"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs
|
||||
from zipfile import BadZipFile
|
||||
|
||||
from . import VERSION
|
||||
from .core import build
|
||||
from .workbooks import leases, mileage, export
|
||||
|
||||
|
||||
def encoded(value):
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, directory):
|
||||
self.directory = Path(directory)
|
||||
self.directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def save(self, kind, value):
|
||||
content = encoded(value)
|
||||
identifier = hashlib.sha256(content).hexdigest()
|
||||
folder = self.directory / kind
|
||||
folder.mkdir(exist_ok=True)
|
||||
target = folder / (identifier + ".json")
|
||||
# 同一输入重复提交得到同一标识;所有记录以内容寻址,旧快照不被逻辑更新。
|
||||
if not target.exists():
|
||||
fd, temporary = tempfile.mkstemp(dir=folder)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(content)
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
return identifier
|
||||
|
||||
def load(self, kind, identifier):
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", identifier):
|
||||
raise ValueError("非法快照标识")
|
||||
p = self.directory / kind / (identifier + ".json")
|
||||
if not p.is_file():
|
||||
raise FileNotFoundError(identifier)
|
||||
return dict(json.loads(p.read_text()), id=identifier)
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, directory):
|
||||
self.store = Store(directory)
|
||||
|
||||
def __call__(self, env, respond):
|
||||
code = 200
|
||||
mime = "application/json; charset=utf-8"
|
||||
try:
|
||||
path, method = env.get("PATH_INFO", ""), env["REQUEST_METHOD"]
|
||||
query = parse_qs(env.get("QUERY_STRING", ""))
|
||||
if method == "GET" and path == "/health":
|
||||
payload = dict(status="ok", version=VERSION)
|
||||
elif method == "POST" and path in ("/imports/leases", "/imports/mileage"):
|
||||
blob = self.body(env)
|
||||
if path.endswith("/leases"):
|
||||
payload = leases(blob)
|
||||
else:
|
||||
payload = mileage(blob, query.get("source", [""])[0], query.get("provider", [""])[0])
|
||||
payload["sha256"] = hashlib.sha256(blob).hexdigest()
|
||||
payload["parser_version"] = VERSION
|
||||
identifier = self.store.save("imports", payload)
|
||||
payload = dict(id=identifier, kind=payload["kind"], raw_rows=payload["raw_rows"],
|
||||
accepted_rows=len(payload["rows"]), issues=payload["issues"])
|
||||
code = 201
|
||||
elif method == "GET" and path.startswith("/imports/"):
|
||||
payload = self.store.load("imports", path.removeprefix("/imports/"))
|
||||
elif method == "POST" and path == "/assessments":
|
||||
request = json.loads(self.body(env))
|
||||
if not isinstance(request, dict) or not isinstance(request.get("config"), dict):
|
||||
raise ValueError("请求必须为对象,且包含 config 对象")
|
||||
lease = self.store.load("imports", request["lease_import_id"])
|
||||
if lease["kind"] != "leases":
|
||||
raise ValueError("lease_import_id 类型错误")
|
||||
mileages = [self.store.load("imports", i) for i in request["mileage_import_ids"]]
|
||||
if any(i["kind"] != "mileage" for i in mileages):
|
||||
raise ValueError("mileage_import_ids 类型错误")
|
||||
result = build(lease, mileages, request["config"])
|
||||
result.update(engine_version=VERSION, inputs=request)
|
||||
identifier = self.store.save("assessments", result)
|
||||
payload = dict(id=identifier, status=result["status"], summary=result["summary"], issues=result["issues"])
|
||||
code = 201
|
||||
elif method == "GET" and path.startswith("/assessments/"):
|
||||
tail = path.removeprefix("/assessments/")
|
||||
wants_excel = tail.endswith("/export.xlsx")
|
||||
identifier = tail.removesuffix("/export.xlsx") if wants_excel else tail
|
||||
result = self.store.load("assessments", identifier)
|
||||
if wants_excel:
|
||||
if result["status"] != "ready":
|
||||
code, payload = 409, dict(error="仍有待核对问题", issues=result["issues"])
|
||||
else:
|
||||
payload = export(result)
|
||||
mime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
else:
|
||||
payload = result
|
||||
else:
|
||||
code, payload = 404, dict(error="接口不存在")
|
||||
except FileNotFoundError:
|
||||
code, payload = 404, dict(error="快照不存在")
|
||||
except (ValueError, KeyError, TypeError, BadZipFile) as exc:
|
||||
code, payload = 400, dict(error=str(exc))
|
||||
data = payload if isinstance(payload, bytes) else encoded(payload)
|
||||
labels = {200: "OK", 201: "Created", 400: "Bad Request", 404: "Not Found", 409: "Conflict"}
|
||||
respond(f"{code} {labels[code]}", [("Content-Type", mime), ("Content-Length", str(len(data))),
|
||||
("Cache-Control", "no-store")])
|
||||
return [data]
|
||||
|
||||
@staticmethod
|
||||
def body(env):
|
||||
size = int(env.get("CONTENT_LENGTH") or 0)
|
||||
if not 0 < size <= 20 * 1024 * 1024:
|
||||
raise ValueError("请求体需大于 0 且不超过 20 MB")
|
||||
content = env["wsgi.input"].read(size)
|
||||
if len(content) != size:
|
||||
raise ValueError("请求体不完整")
|
||||
return content
|
||||
Reference in New Issue
Block a user