Files
FreeCADWasmDev/tools/prepare_business_acceptance_batch.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

162 lines
9.9 KiB
Python

#!/usr/bin/env python3
"""Reuse completed implementations in bounded, executable business workflows."""
import argparse
import hashlib
import json
from collections import Counter
from datetime import datetime, timezone
from business_acceptance import ROOT, accepted_ids, read, requirements
from implementation_first import load_registry
from prepare_feature_capability_batch import MODULE_ORDER
from scheduler_progress import cooling_ids, retry_details
from migration_workflows import DELIVERY_BATCH_SIZE, DELIVERY_MODULE_ORDER, PRODUCT_ORDER, delivery_route, desktop_command, workflow_family, workflow_order
def contract_ids(module: str) -> set[str]:
contract = read(ROOT / f"docs/business-acceptance/{module}.json")
command = contract.get("command", [])
if not isinstance(command, list) or len(command) < 2 or command[0] not in {"node", "python3"}:
return set()
script = (ROOT / str(command[1])).resolve()
if not script.is_relative_to(ROOT) or not script.is_file():
return set()
return {value for value in contract.get("coveredIds", []) if isinstance(value, str)}
def inventory() -> dict:
rows = requirements()
accepted = accepted_ids(rows)
receipts = load_registry().get("records", {})
ready = {record_id for record_id, entry in receipts.items()
if entry.get("status") in {"implementation_ready", "implemented_pending_use"}}
pending = [row for row in rows if row.get("implementationStatus", "routed") == "routed"
and row["id"] not in accepted and (row["id"] in ready or row.get("status") == "passed")]
covered = {module: contract_ids(module) for module in {row["module"] for row in pending}}
build_overrides = {module: read(ROOT / f"docs/business-acceptance/{module}.json").get("buildOverrides", {}) for module in covered}
cooling = cooling_ids(candidates={row["id"] for row in pending})
return {"rows": rows, "accepted": accepted, "pending": pending, "covered": covered,
"cooling": cooling, "receipts": receipts, "buildOverrides": build_overrides}
def workflow(row: dict, receipts: dict) -> str:
if desktop_command(row):
return receipts.get(row["id"], {}).get("family") or "desktop-command"
return workflow_family(row)
def candidates(state: dict, covered_only: bool = False, delivery_only: bool = False, uncovered_only: bool = False,
exclude_module: str | None = None) -> list[dict]:
groups = {}
for row in state["pending"]:
if row["module"] == exclude_module:
continue
if delivery_only and not delivery_route(row):
continue
if row["id"] in state["cooling"]:
continue
executable = row["id"] in state["covered"][row["module"]]
if covered_only and not executable:
continue
if uncovered_only and executable:
continue
family = "existing-business-cases" if executable else workflow(row, state["receipts"])
override = state.get("buildOverrides", {}).get(row["module"], {}).get(row["id"]) if executable else None
key = (row["module"], family, json.dumps(override, sort_keys=True))
groups.setdefault(key, {"module": row["module"], "family": family,
"executable": executable, "delivery": delivery_route(row), "rows": []})["rows"].append(row)
groups[key]["delivery"] = groups[key]["delivery"] or delivery_route(row)
def priority(group):
family = group["family"]
return (not group["executable"], not group["delivery"], family == "supporting-business-route",
DELIVERY_MODULE_ORDER.get(group["module"], 20 + MODULE_ORDER.get(group["module"], 50)) if group["delivery"] else MODULE_ORDER.get(group["module"], 50),
group["module"], workflow_order(family))
return sorted(groups.values(), key=priority)
def plan(state: dict, covered_only: bool = False, delivery_only: bool = False, uncovered_only: bool = False,
exclude_module: str | None = None) -> dict:
groups = candidates(state, covered_only, delivery_only, uncovered_only, exclude_module)
deferred = {row["id"] for row in state["pending"] if row["id"] in state["cooling"]
and (not delivery_only or delivery_route(row))
and (not covered_only or row["id"] in state["covered"][row["module"]])
and (not uncovered_only or row["id"] not in state["covered"][row["module"]])
and row["module"] != exclude_module}
if not groups:
return {"schema": "freecad-web-module-business-batch/v1", "status": "no_ready_batch", "records": [],
"reason": "no business acceptance batch ready", **retry_details(deferred)}
group = groups[0]
module = group["module"]
limit = 16 if group["executable"] else DELIVERY_BATCH_SIZE
selected = sorted(group["rows"], key=lambda row: (PRODUCT_ORDER.get(row["id"], 100), row["id"]))[:limit]
identity = "|".join([module, "business-acceptance", "business-acceptance", *(row["id"] for row in selected)])
return {
"schema": "freecad-web-module-business-batch/v1", "status": "ready",
"strategy": "module-business-first-deferred-validation",
"taskId": read(ROOT / "docs/current/task-state.json").get("active_task"),
"batchId": hashlib.sha256(identity.encode()).hexdigest()[:24],
"minimumRecords": 1, "singletonFallback": len(selected) == 1,
"selected": {"module": module, "tier": "business-acceptance", "family": "business-acceptance", "recordCount": len(selected)},
"businessWorkflow": group["family"], "acceptanceReady": group["executable"],
"businessLane": "development" if uncovered_only else "acceptance",
"selectionScope": "delivery" if delivery_only else "full",
"fields": ["id", "command", "status", "source", "sourceLine"],
"records": [[row["id"], row["command"], "acceptance_required", row.get("sourcePath") or row.get("source") or "web/src/module-router.ts", row.get("sourceLine", 1)] for row in selected],
"acceptanceContract": f"docs/business-acceptance/{module}.json",
"queueGroupCount": len(groups),
"pendingBusinessCount": len(state["pending"]),
"queue": [{"module": item["module"], "family": item["family"], "unresolved": len(item["rows"]),
"acceptanceReady": item["executable"]} for item in groups[:8]],
}
def write_plan_report(state: dict) -> dict:
total = Counter(row["module"] for row in state["rows"])
accepted = Counter(row["module"] for row in state["rows"] if row["id"] in state["accepted"])
pending = Counter(row["module"] for row in state["pending"])
executable = Counter(row["module"] for row in state["pending"] if row["id"] in state["covered"][row["module"]])
modules = [{"module": module, "total": total[module], "accepted": accepted[module],
"reusablePending": pending[module], "executablePending": executable[module],
"needsCases": pending[module] - executable[module],
"implementationGap": total[module] - accepted[module] - pending[module]}
for module in sorted(total, key=lambda name: (MODULE_ORDER.get(name, 50), name))]
report = {"generatedAt": datetime.now(timezone.utc).isoformat(), "total": len(state["rows"]),
"accepted": len(state["accepted"]), "reusablePending": len(state["pending"]),
"executablePending": sum(executable.values()), "modules": modules,
"nextBatch": plan(state), "policy": "executable-cases-first; alternate acceptance-development and implementation"}
output = ROOT / "docs/current/business-acceptance-plan.json"
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
lines = ["# Business Acceptance Plan", "", f"Accepted: {report['accepted']}/{report['total']}. Reusable pending: {report['reusablePending']}; executable pending: {report['executablePending']}.",
"", "Implementation receipts are candidates for acceptance, not proof of functional completion.", "",
"| Module | Total | Accepted | Reusable Pending | Executable | Needs Cases | Implementation Gap |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: |"]
lines.extend("| " + " | ".join(str(row[key]) for key in ("module", "total", "accepted", "reusablePending", "executablePending", "needsCases", "implementationGap")) + " |" for row in modules)
output.with_suffix(".md").write_text("\n".join(lines) + "\n", encoding="utf-8")
return report
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--covered-only", action="store_true")
parser.add_argument("--delivery-only", action="store_true")
parser.add_argument("--uncovered-only", action="store_true", help="select existing implementations that need real business cases")
parser.add_argument("--exclude-module", help="leave the current blocked module intact while another module advances")
parser.add_argument("--report-only", action="store_true")
args = parser.parse_args(argv)
if args.covered_only and args.uncovered_only:
parser.error("--covered-only and --uncovered-only are mutually exclusive")
state = inventory()
if args.report_only:
report = write_plan_report(state)
print(json.dumps({key: report[key] for key in ("accepted", "total", "reusablePending", "executablePending")}))
return 0
payload = plan(state, args.covered_only, args.delivery_only, args.uncovered_only, args.exclude_module)
payload["generatedAt"] = datetime.now(timezone.utc).isoformat()
(ROOT / "docs/current/module-batch-manifest.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"status": payload["status"], "remaining": len(state["pending"]), "selected": payload.get("selected"),
"acceptanceReady": payload.get("acceptanceReady")}))
return 0
if __name__ == "__main__":
raise SystemExit(main())