Files
FCCS/fccs_report.py

240 lines
8.2 KiB
Python

"""
Utility: Generate a client-facing progress report (HTML).
Runs the same document-level completeness check as fccs_verify.py, but produces
a clean, self-contained HTML file listing ONLY the drawers with outstanding
items and, for each, the specific documents still missing from the export. It's
meant as a "check-in progress" document you can send to the client.
Drawers that are fully exported or that have no manifest are left out — the
report shows outstanding work. A headline shows how many drawers are fully
migrated so overall progress is clear.
Any outstanding drawer that also appears in crashed.txt (an FCCS converter crash
aborted its export) is badged CRASHED and sorted to the top: those are genuine
failures worth attention, as opposed to benign name-match false positives (e.g.
a collapsed "container" document whose children exported fine but whose name
lands in the filename's folder field rather than the document field).
Output goes to `report_html` (default C:\\Migration\\progress_report.html).
USAGE
-----
python fccs_report.py
"""
import os
import sys
import html
from datetime import datetime
from fccs_config import (
parse_args, load_config, load_lines,
evaluate_drawer, index_files_by_drawer, client_name_from_files,
)
PAGE_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Migration Progress Report</title>
<style>
* {{ box-sizing: border-box; }}
body {{
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #1f2933; background: #f5f7fa; margin: 0; padding: 2.5rem 1rem;
line-height: 1.5;
}}
.sheet {{
max-width: 820px; margin: 0 auto; background: #fff; border-radius: 10px;
box-shadow: 0 1px 3px rgba(16,24,40,.1), 0 1px 2px rgba(16,24,40,.06);
overflow: hidden;
}}
header {{
padding: 2rem 2.25rem 1.5rem; border-bottom: 1px solid #e4e7eb;
}}
h1 {{ margin: 0 0 .25rem; font-size: 1.5rem; font-weight: 650; }}
.date {{ color: #7b8794; font-size: .9rem; }}
.summary {{
display: flex; gap: 2rem; flex-wrap: wrap;
padding: 1.25rem 2.25rem; background: #fafbfc; border-bottom: 1px solid #e4e7eb;
}}
.stat .num {{ font-size: 1.75rem; font-weight: 680; line-height: 1; }}
.stat .lbl {{ color: #7b8794; font-size: .8rem; text-transform: uppercase;
letter-spacing: .03em; margin-top: .35rem; }}
.stat.good .num {{ color: #2e7d32; }}
.stat.flag .num {{ color: #c0392b; }}
.stat.crash .num {{ color: #b9770e; }}
.body {{ padding: 1.5rem 2.25rem 2.25rem; }}
.all-clear {{
text-align: center; padding: 2.5rem 1rem; color: #2e7d32; font-size: 1.1rem;
}}
.drawer {{
border: 1px solid #e4e7eb; border-radius: 8px; padding: 1rem 1.25rem;
margin-bottom: 1rem;
}}
.drawer:last-child {{ margin-bottom: 0; }}
.drawer.crashed {{ border-left: 4px solid #c0392b; background: #fdf6f5; }}
.drawer h2 {{
margin: 0 0 .1rem; font-size: 1.05rem; font-weight: 620;
}}
.drawer .id {{ color: #486581; font-variant-numeric: tabular-nums; }}
.badge {{
display: inline-block; margin-left: .5rem; padding: .1rem .5rem;
font-size: .72rem; font-weight: 700; letter-spacing: .04em;
text-transform: uppercase; color: #fff; background: #c0392b;
border-radius: 4px; vertical-align: middle;
}}
.drawer .meta {{ color: #7b8794; font-size: .82rem; margin-bottom: .6rem; }}
ul.docs {{ margin: 0; padding: 0; list-style: none; }}
ul.docs li {{
padding: .3rem 0 .3rem 1.5rem; position: relative; font-size: .93rem;
border-top: 1px solid #f0f2f5;
}}
ul.docs li:first-child {{ border-top: none; }}
ul.docs li::before {{
content: "\\2717"; color: #c0392b; position: absolute; left: 0; font-weight: 700;
}}
footer {{
padding: 1rem 2.25rem; border-top: 1px solid #e4e7eb; color: #9aa5b1;
font-size: .78rem;
}}
@media print {{
body {{ background: #fff; padding: 0; }}
.sheet {{ box-shadow: none; max-width: none; }}
}}
</style>
</head>
<body>
<div class="sheet">
<header>
<h1>Migration Progress Report</h1>
<div class="date">{date}</div>
</header>
<div class="summary">
<div class="stat good"><div class="num">{complete}</div>
<div class="lbl">Drawers migrated</div></div>
<div class="stat flag"><div class="num">{incomplete}</div>
<div class="lbl">Drawers outstanding</div></div>
<div class="stat crash"><div class="num">{crashed}</div>
<div class="lbl">Crashed in export</div></div>
<div class="stat"><div class="num">{total}</div>
<div class="lbl">Total drawers</div></div>
</div>
<div class="body">
{content}
</div>
<footer>Generated {date} &middot; Lists documents not yet present in the
export. Drawers not shown are fully migrated. A
<span class="badge">Crashed</span> badge marks drawers whose export was
interrupted by an error.</footer>
</div>
</body>
</html>
"""
def build_drawer_block(drawer_id, client, missing, crashed=False):
"""Return the HTML for one outstanding drawer."""
head = f'<span class="id">{html.escape(drawer_id)}</span>'
if client:
head += f' &mdash; {html.escape(client)}'
if crashed:
head += ' <span class="badge">Crashed</span>'
n = len(missing)
docs = "\n".join(
f" <li>{html.escape(m)}</li>" for m in missing
)
cls = "drawer crashed" if crashed else "drawer"
return (
f' <div class="{cls}">\n'
f' <h2>{head}</h2>\n'
f' <div class="meta">{n} document{"s" if n != 1 else ""} '
'outstanding</div>\n'
f' <ul class="docs">\n{docs}\n </ul>\n'
' </div>'
)
def main():
args = parse_args()
cfg = load_config(args.config)
export_dir = cfg.get("paths", "export_dir")
manifest_dir = cfg.get("paths", "manifest_dir")
report_html = cfg.get("paths", "report_html")
crashed_set = set(load_lines(cfg.get("paths", "crashed_file")))
if not os.path.isdir(manifest_dir):
print(f"ERROR: manifest directory not found: {manifest_dir}")
print("Run fccs_export.py first to generate manifests.")
sys.exit(1)
if not os.path.isdir(export_dir):
print(f"ERROR: export directory not found: {export_dir}")
sys.exit(1)
drawer_ids = sorted(
os.path.splitext(f)[0]
for f in os.listdir(manifest_dir)
if f.endswith(".txt")
)
if not drawer_ids:
print("No manifest files found.")
sys.exit(1)
files_by_drawer = index_files_by_drawer(export_dir)
complete = 0
outstanding = [] # (drawer_id, client, missing_list, crashed)
for drawer_id in drawer_ids:
files = files_by_drawer.get(drawer_id, [])
r = evaluate_drawer(drawer_id, files, manifest_dir)
if r["missing"]:
client = client_name_from_files(files)
outstanding.append((drawer_id, client, r["missing"],
drawer_id in crashed_set))
else:
complete += 1
# Crashed drawers are the genuine failures — surface them first.
outstanding.sort(key=lambda o: (not o[3], o[0]))
crashed_ids = [d for d, _, _, cr in outstanding if cr]
total = len(drawer_ids)
date_str = f"{datetime.now():%B %d, %Y}"
if outstanding:
content = "\n".join(
build_drawer_block(d, c, m, cr) for d, c, m, cr in outstanding
)
else:
content = (' <div class="all-clear">All drawers are fully '
"migrated &mdash; no outstanding documents.</div>")
page = PAGE_TEMPLATE.format(
date=html.escape(date_str),
complete=complete,
incomplete=len(outstanding),
crashed=len(crashed_ids),
total=total,
content=content,
)
with open(report_html, "w", encoding="utf-8") as f:
f.write(page)
print(f"Progress report written to {report_html}")
print(f" {complete}/{total} drawers fully migrated, "
f"{len(outstanding)} outstanding.")
if outstanding:
print(" Outstanding: " + ", ".join(d for d, _, _, _ in outstanding))
if crashed_ids:
print(f" Outstanding AND crashed ({len(crashed_ids)}) "
"— spot-check these first: " + ", ".join(crashed_ids))
if __name__ == "__main__":
main()