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,3 @@
|
||||
"""交还车与多源日里程生成月度考核源表。"""
|
||||
|
||||
VERSION = "0.1.0"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)))
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
openpyxl>=3.1.5,<4
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user