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:
shishengliang
2026-10-09 18:21:45 +08:00
parent 3ed3548b9d
commit 65904c8270
108 changed files with 1368801 additions and 3 deletions
+3
View File
@@ -0,0 +1,3 @@
"""交还车与多源日里程生成月度考核源表。"""
VERSION = "0.1.0"
+18
View File
@@ -0,0 +1,18 @@
import argparse
from wsgiref.simple_server import make_server
from .app import App
def main():
parser = argparse.ArgumentParser(description="月度租赁考核源表服务(本机首版)")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--data-dir", default=".assessment-data")
args = parser.parse_args()
with make_server("127.0.0.1", args.port, App(args.data_dir)) as server:
print(f"考核服务: http://127.0.0.1:{args.port},数据目录: {args.data_dir}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+126
View File
@@ -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
+203
View File
@@ -0,0 +1,203 @@
"""无 I/O 的考核计算。规则、候选车辆和来源优先级由每次请求明确给定。"""
import calendar
import re
from collections import defaultdict
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
HEADERS = ["车牌号", "考核年份", "考核月份", "部门名称", "销售经理", "客户名称",
"合同编号", "项目名称", "考核目标ID", "考核目标", "考核开始日期", "考核结束日期",
"考核天数", "应考核里程(km)", "实际行驶里程(km)", "完成率(%)", "是否达标",
"考核状态", "交车日期", "预计还车日期", "实际还车日期"]
def plate(value):
return re.sub(r"\s+", "", str(value or "")).upper()
def day(value):
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
return datetime.fromisoformat(str(value).strip()).date()
def number(value):
if value is None or isinstance(value, bool) or str(value).strip() == "":
raise ValueError("缺少数值;空值不能替代为 0")
try:
result = Decimal(str(value))
except InvalidOperation as exc:
raise ValueError(f"非数值: {value}") from exc
if not result.is_finite() or result < 0:
raise ValueError("里程及目标必须为有限的非负数")
return result
def rounded(value):
return float(value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
def dates(start, end):
return [start + timedelta(days=i) for i in range((end - start).days + 1)]
def build(lease_import, mileage_imports, config):
"""返回草稿及逐日依据。仅 ready 状态可正式导出。
规则清单定义考核范围。未列入规则的车辆只记录为 excluded,不自动套用档位。
相同来源内冲突不能自动择大;不同提供方按请求给定的顺序按日选取。
"""
if not re.fullmatch(r"\d{4}-\d{2}", str(config.get("month", ""))):
raise ValueError("month 必须是 YYYY-MM")
first = day(config["month"] + "-01")
month_days = calendar.monthrange(first.year, first.month)[1]
last = first.replace(day=month_days)
if not isinstance(config.get("test_plates"), list):
raise ValueError("必须明确提供 test_plates;无测试车辆时传 []")
tests = {plate(p) for p in config["test_plates"]}
threshold = number(config.get("test_threshold_km", 50))
priorities = config.get("source_priority", {})
for source in ("hydrogen", "tbox", "gps"):
if not isinstance(priorities.get(source), list) or not priorities[source]:
raise ValueError(f"必须明确提供 source_priority.{source}")
if len(set(priorities[source])) != len(priorities[source]):
raise ValueError(f"来源优先级有重复: {source}")
rules = defaultdict(list)
if not isinstance(config.get("rules"), list) or not config["rules"]:
raise ValueError("rules 必须明确列出参与考核车辆和有效期,不可为空")
for raw in config["rules"]:
r = dict(raw)
for field in ("plate", "target_id", "target_name", "monthly_target_km", "valid_from", "valid_to"):
if r.get(field) is None or str(r[field]).strip() == "":
raise ValueError(f"规则缺字段: {field}")
r["plate"] = plate(r["plate"])
r["start"], r["end"] = day(r["valid_from"]), day(r["valid_to"])
r["target"] = number(r["monthly_target_km"])
if r["start"] > r["end"] or r["target"] <= 0:
raise ValueError("规则有效期或月目标不合法")
rules[r["plate"]].append(r)
for p, rs in rules.items():
rs.sort(key=lambda r: r["start"])
if any(a["end"] >= b["start"] for a, b in zip(rs, rs[1:])):
raise ValueError(f"规则有效期重叠: {p}")
issues = []
def issue(code, p, message, row=None, **extra):
issues.append(dict(code=code, plate=p, message=message, source_row=row, **extra))
# 以有效规则限定本月考核范围,不把全量租赁车辆直接当作考核车辆。
relevant = {p for p, rs in rules.items() if any(r["start"] <= last and r["end"] >= first for r in rs)}
for item in lease_import.get("issues", []):
if not item.get("plate") or item["plate"] in relevant:
issues.append(dict(item))
daily = defaultdict(dict)
for imp in mileage_imports:
source, provider = imp["source"], imp["provider"]
if provider not in priorities[source]:
raise ValueError(f"提供方未列入 {source} 优先级: {provider}")
for item in imp.get("issues", []):
if not item.get("plate") or item["plate"] in relevant:
issues.append(dict(item))
for r in imp["rows"]:
d = day(r["date"])
if r["plate"] not in relevant or not first <= d <= last:
continue
key = (r["plate"], d, source)
value = number(r["km"])
evidence = dict(km=rounded(value), provider=provider, import_id=imp.get("id"),
source_row=r["source_row"])
existing = daily[key].get(provider)
if existing is not None and existing[0] != value:
issue("mileage_conflict", r["plate"], f"{d} 的 {source}/{provider} 存在不同里程", r["source_row"])
else:
daily[key][provider] = (value, evidence)
def pick(p, d, source):
available = daily.get((p, d, source), {})
return next((available[v] for v in priorities[source] if v in available), None)
segments, excluded, seen, active_plates = [], [], {}, set()
for lease in lease_import["rows"]:
p = lease["plate"]
if p not in relevant:
excluded.append(dict(plate=p, lease_id=lease["lease_id"], reason="未列入本月考核规则"))
continue
ident = lease["lease_id"]
canonical = {k: v for k, v in lease.items() if k != "source_row"}
if ident in seen:
if seen[ident] != canonical:
issue("lease_conflict", p, f"交车记录 {ident} 对应多条不同记录", lease["source_row"])
continue
seen[ident] = canonical
start = max(first, day(lease["delivery_time"]))
finish = min(last, day(lease["return_time"])) if lease["return_time"] else last
if start > finish:
continue
active_plates.add(p)
for r in rules[p]:
begin, end = max(start, r["start"]), min(finish, r["end"])
if begin <= end:
segments.append((lease, r, begin, end))
occupancy = {}
for lease, rule, start, end in segments:
for d in dates(start, end):
key = lease["plate"], d
if key in occupancy:
issue("overlapping_lease_day", lease["plate"],
f"{d} 被交车记录 {occupancy[key]} 和 {lease['lease_id']} 同时占用", lease["source_row"])
occupancy[key] = lease["lease_id"]
records, trace = [], []
for lease, rule, start, end in segments:
p = lease["plate"]
days = dates(start, end)
has_hydrogen = any(pick(p, d, "hydrogen") is not None for d in days)
source = "hydrogen" if has_hydrogen else ("gps" if p in tests else "tbox")
total, complete = Decimal(0), True
for d in days:
values = {s: pick(p, d, s) for s in ("hydrogen", "tbox", "gps")}
is_test = False
if p in tests and has_hydrogen:
if values["tbox"] is None or values["gps"] is None:
complete = False
issue("test_detection_missing", p, f"{d} 缺 TBOX/GPS,无法判断测试日", lease["source_row"])
else:
is_test = values["tbox"][0] - values["gps"][0] > threshold
chosen = values[source]
included = None
if is_test:
included = Decimal(0)
elif chosen is None:
complete = False
issue("missing_mileage_day", p, f"{d} 缺少 {source} 里程,不能按 0 处理", lease["source_row"])
else:
included = chosen[0]
total += included
trace.append(dict(plate=p, lease_id=lease["lease_id"], date=d.isoformat(),
source=source, test_day=is_test,
included_km=rounded(included) if included is not None else None,
evidence={s: v[1] if v is not None else None for s, v in values.items()}))
target = Decimal(str(rounded(rule["target"] * len(days) / month_days)))
actual = Decimal(str(rounded(total))) if complete else None
vals = [p, first.year, first.month, lease["department"], lease["manager"], lease["customer"],
lease["contract_code"], lease["project"], rule["target_id"], rule["target_name"],
start.isoformat(), end.isoformat(), len(days), float(target),
float(actual) if actual is not None else None,
rounded(actual / target * 100) if actual is not None else None,
("达标" if actual >= target else "未达标") if actual is not None else "待核对",
"已完成" if lease["return_time"] and day(lease["return_time"]) <= last else "进行中",
day(lease["delivery_time"]).isoformat(), None,
day(lease["return_time"]).isoformat() if lease["return_time"] else None]
records.append(dict(zip(HEADERS, vals)))
if not records:
issue("no_assessment_records", "", "本月没有可生成的考核记录")
records.sort(key=lambda r: (r["车牌号"], r["考核开始日期"], r["合同编号"]))
return dict(status="needs_review" if issues else "ready", month=config["month"], records=records,
issues=issues, daily_trace=trace, excluded=excluded,
configured_plates_without_active_lease=sorted(relevant - active_plates),
summary=dict(records=len(records), vehicles=len({r["车牌号"] for r in records}),
issues=len(issues), excluded_lease_records=len(excluded)))
+20
View File
@@ -0,0 +1,20 @@
{
"month": "2026-04",
"test_plates": [],
"test_threshold_km": 50,
"source_priority": {
"hydrogen": ["guangzhou"],
"tbox": ["vehicle-platform"],
"gps": ["g7", "dongfang", "xinda", "guangan"]
},
"rules": [
{
"plate": "DEMO-001",
"target_id": 1,
"target_name": "交投40辆4.5T普货",
"monthly_target_km": 3000,
"valid_from": "2026-04-01",
"valid_to": "2026-04-30"
}
]
}
+1
View File
@@ -0,0 +1 @@
openpyxl>=3.1.5,<4
+121
View File
@@ -0,0 +1,121 @@
"""文件适配器。原表作为数据读取;不执行 Query 页或单元格公式。"""
from io import BytesIO
from datetime import datetime
from zipfile import ZipFile
import openpyxl
from openpyxl.styles import Alignment, Font, PatternFill
from .core import HEADERS, day, number, plate
def open_book(blob):
if len(blob) > 20 * 1024 * 1024:
raise ValueError("文件超过 20 MB")
with ZipFile(BytesIO(blob)) as z:
if sum(x.file_size for x in z.infolist()) > 200 * 1024 * 1024:
raise ValueError("解压后文件超过 200 MB")
return openpyxl.load_workbook(BytesIO(blob), read_only=True, data_only=True)
def rows_with_headers(blob, required):
wb = open_book(blob)
try:
for ws in wb:
rows = iter(ws.values)
for n in range(1, 11):
raw = next(rows, None)
if raw is None:
break
header = [str(c).strip() if c is not None else "" for c in raw]
if required <= set(header):
if len([h for h in header if h]) != len(set(h for h in header if h)):
raise ValueError("表头存在重复字段")
result = [(i, dict(zip(header, r))) for i, r in enumerate(rows, n + 1)
if any(v is not None and v != "" for v in r)]
return ws.title, result
raise ValueError("未找到所需表头: " + ", ".join(sorted(required)))
finally:
wb.close()
def leases(blob):
fields = {"vehicle_id", "plate_number", "contract_id", "contract_code", "project_name", "customer_name",
"business_department_name", "business_manager_name", "delivery_vehicle_id", "delivery_time",
"return_vehicle_task_id", "return_time"}
sheet, raw = rows_with_headers(blob, fields)
result, issues = [], []
for n, r in raw:
p = plate(r["plate_number"])
try:
for key in ("plate_number", "delivery_vehicle_id", "delivery_time", "contract_id", "contract_code",
"customer_name", "business_department_name", "business_manager_name"):
if r[key] is None or str(r[key]).strip() == "":
raise ValueError(f"缺少 {key}")
start = datetime.fromisoformat(str(r["delivery_time"]))
finish = datetime.fromisoformat(str(r["return_time"])) if r["return_time"] else None
if finish and finish < start:
raise ValueError("还车时间早于交车时间")
if r["return_vehicle_task_id"] and finish is None:
raise ValueError("有还车任务但无还车时间,不能直接当作未还车")
result.append(dict(plate=p, vehicle_id=str(r["vehicle_id"]), lease_id=str(r["delivery_vehicle_id"]),
contract_id=str(r["contract_id"]), contract_code=str(r["contract_code"]),
project=r["project_name"] or "", customer=r["customer_name"],
department=r["business_department_name"], manager=r["business_manager_name"],
delivery_time=start.isoformat(), return_time=finish.isoformat() if finish else None,
source_row=n))
except (ValueError, TypeError) as exc:
issues.append(dict(code="invalid_lease", plate=p, source_row=n, message=str(exc)))
return dict(kind="leases", sheet=sheet, raw_rows=len(raw), rows=result, issues=issues)
def mileage(blob, source, provider):
if source not in ("hydrogen", "tbox", "gps"):
raise ValueError("source 只支持 hydrogen、tbox、gps")
if not provider or len(provider) > 100:
raise ValueError("必须提供 provider,最长 100 字符")
# 第一版采用明确的长表契约,不猜供应商列序或原始单位。
sheet, raw = rows_with_headers(blob, {"plate_number", "date", "mileage_km"})
result, issues = [], []
for n, r in raw:
p = plate(r["plate_number"])
try:
if not p:
raise ValueError("缺少车牌")
result.append(dict(plate=p, date=day(r["date"]).isoformat(),
km=str(number(r["mileage_km"])), source_row=n))
except (ValueError, TypeError) as exc:
issues.append(dict(code="invalid_mileage", plate=p, source_row=n, message=str(exc)))
return dict(kind="mileage", source=source, provider=provider, sheet=sheet,
raw_rows=len(raw), rows=result, issues=issues)
def export(result):
if result["status"] != "ready":
raise ValueError("尚有待核对问题,不能导出正式考核源表")
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "业务考核视图"
ws.append(HEADERS)
date_fields = {"考核开始日期", "考核结束日期", "交车日期", "预计还车日期", "实际还车日期"}
for r in result["records"]:
ws.append([datetime.fromisoformat(r[h]) if h in date_fields and r[h] else r[h] for h in HEADERS])
# 文本始终按字符串保存,合同/客户等内容不能变成 Excel 公式。
for row in ws:
for cell in row:
if isinstance(cell.value, str):
cell.data_type = "s"
if isinstance(cell.value, datetime):
cell.number_format = "yyyy-mm-dd"
cell.alignment = Alignment(vertical="center")
for cell in ws[1]:
cell.fill = PatternFill("solid", fgColor="4472C4")
cell.font = Font(bold=True, color="FFFFFF")
for i, h in enumerate(HEADERS, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = 24 if h in date_fields else 20
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
stream = BytesIO()
wb.save(stream)
wb.close()
return stream.getvalue()