chore(rebar): Pier_T_rebar 작업 폴더 초기 구성 및 외부 의존 파일 통합
- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환 - Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용 - pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관) - worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md) - D:\Rhino-MCP 경로 참조를 새 위치로 치환 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
964a841de1
3978 files changed
+58013356
No files matched your search
@@ -0,0 +1,909 @@
|
||||
"""T-pier modelling app (semi-automatic MVP): local Flask UI over the engine.
|
||||
|
||||
Flow per pier: upload drawings -> search texts / review table candidates -> edit and confirm sections ->
|
||||
run gates G1..G5 one by one (each needs its sections confirmed and the previous gate approved) ->
|
||||
approve / reject each gate -> download the .3dm.
|
||||
Local only: binds 127.0.0.1 and has no authentication (do not expose it on a network).
|
||||
usage: python web/app.py then open http://127.0.0.1:5000
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, abort, flash, redirect, render_template, request, send_file, url_for
|
||||
|
||||
APP = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(APP))
|
||||
sys.path.insert(0, str(APP / "web"))
|
||||
from engine import anchors, geomread, recommend, reader, render, report_files, review as reviewlib, variability, verify # noqa: E402
|
||||
from engine.gates import run_batch_session, run_gate # noqa: E402
|
||||
from engine.pipeline import GATES, gate_ids # noqa: E402
|
||||
import store # noqa: E402
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = "local-only" # session flash messages only; the app is bound to 127.0.0.1
|
||||
RHINO_LOCK = threading.Lock() # one Rhino job at a time
|
||||
ACTIVE = {} # job id -> thread of this server process
|
||||
BATCH = {} # pier name -> batch thread of this server process
|
||||
AUTO_NOTE = "일괄 실행 자동 승인"
|
||||
GATE_NAMES = {g: n for g, n, _ in GATES}
|
||||
|
||||
|
||||
def pier_or_404(name):
|
||||
try:
|
||||
return store.Pier(name)
|
||||
except (FileNotFoundError, ValueError):
|
||||
abort(404)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html", piers=[store.Pier(n) for n in store.piers()], types=store.PIER_TYPES + ["기타"],
|
||||
templates=store.templates(), tpl=store.TPL)
|
||||
|
||||
|
||||
@app.post("/pier/new")
|
||||
def pier_new():
|
||||
try:
|
||||
p = store.create_pier(request.form["name"], request.form["type"], request.form["template"])
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
flash(str(e), "fail")
|
||||
return redirect(url_for("index"))
|
||||
flash(f"교각 '{p.name}'을(를) 만들었습니다. 템플릿 값이 들어 있으니 모든 묶음을 확인·수정한 뒤 확정하세요.", "ok")
|
||||
return redirect(url_for("pier", name=p.name))
|
||||
|
||||
|
||||
STAGE = "_추천" # data/_추천/<id>: drawings uploaded before the pier exists (S02)
|
||||
|
||||
|
||||
def stage_dir(sid):
|
||||
if not sid.isalnum():
|
||||
abort(404)
|
||||
return store.DATA / STAGE / sid
|
||||
|
||||
|
||||
@app.post("/recommend")
|
||||
def recommend_upload():
|
||||
"""S02: upload drawings first, estimate the fit answers from the texts, rank the protected templates."""
|
||||
files = [f for f in request.files.getlist("dwg") if f.filename]
|
||||
if not files or any(not f.filename.lower().endswith(".dwg") for f in files):
|
||||
flash("DWG 파일(.dwg)만 올릴 수 있습니다.", "fail")
|
||||
return redirect(url_for("index"))
|
||||
sid = uuid.uuid4().hex[:12]
|
||||
d = stage_dir(sid)
|
||||
(d / "dwg").mkdir(parents=True)
|
||||
paths = []
|
||||
for f in sorted(files, key=lambda f: f.filename):
|
||||
dst = d / "dwg" / store.safe_name(Path(f.filename).name)
|
||||
f.save(dst)
|
||||
paths.append(dst)
|
||||
try:
|
||||
audit = reader.convert(paths, d / "dxf")
|
||||
except Exception as e:
|
||||
flash(f"DWG 변환 실패: {e}", "fail")
|
||||
return redirect(url_for("index"))
|
||||
labels = [f"D{i}" for i in range(1, len(audit) + 1)]
|
||||
for a, lab in zip(audit, labels):
|
||||
a["label"] = lab
|
||||
store.wjson(d / "audit.json", audit)
|
||||
texts = reader.build_index([d / "dxf" / a["dxf"] for a in audit], labels, d / "texts.json")
|
||||
return recommend_show(sid, texts)
|
||||
|
||||
|
||||
def recommend_show(sid, texts):
|
||||
rows = next((t["rows"] for t in reader.table_candidates(texts) if t["key"] == "elevation"), [])
|
||||
est = recommend.estimate(texts, rows)
|
||||
tpls = store.templates()
|
||||
profiles = {t["name"]: recommend.profile(store.rjson(store.template_dir(t["name"]) / "params.json")) for t in tpls}
|
||||
return render_template("recommend.html", profiles=profiles, sid=sid, est=est, questions=recommend.QUESTIONS, rank=recommend.rank(est, profiles),
|
||||
tpl=store.TPL, ntexts=len(texts), nfiles=len(store.rjson(stage_dir(sid) / "audit.json", [])))
|
||||
|
||||
|
||||
@app.post("/recommend/<sid>/create")
|
||||
def recommend_create(sid):
|
||||
"""Create the pier from the chosen template and move the staged drawings into it; the answers are kept in pier.json."""
|
||||
d = stage_dir(sid)
|
||||
if not (d / "texts.json").exists():
|
||||
abort(404)
|
||||
answers = {q: request.form.get(f"ans_{q}", "").strip() for q, _ in recommend.QUESTIONS}
|
||||
answers.update({k: request.form.get(f"ans_{k}", "").strip() for k in ("rebar_shape", "rebar_method")})
|
||||
try:
|
||||
p = store.create_pier(request.form["name"], request.form["type"], request.form["template"])
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
flash(str(e), "fail")
|
||||
return redirect(url_for("index"))
|
||||
p.drawings.mkdir(parents=True, exist_ok=True)
|
||||
for item in d.iterdir():
|
||||
shutil.move(str(item), str(p.drawings / item.name))
|
||||
d.rmdir()
|
||||
answers.update({k: request.form.get(f"ans_{k}", "").strip() for k in ("skew_deg", "bearing_rot_deg", "footing_rot_deg")})
|
||||
tname = request.form["template"]
|
||||
bad = (recommend.mismatches(answers, recommend.profile(store.rjson(store.template_dir(tname[len(store.TPL):]) / "params.json")))
|
||||
if tname.startswith(store.TPL) else [])
|
||||
store.wjson(p.dir / "pier.json", {**p.info, "fit_answers": answers, "fit_mismatch": bad})
|
||||
flash(f"교각 '{p.name}'을(를) 만들었습니다. 도면 {len(p.texts())}개 문자 색인을 옮겼습니다. 모든 묶음을 확인·수정한 뒤 확정하세요.", "ok")
|
||||
return redirect(url_for("pier", name=p.name))
|
||||
|
||||
|
||||
@app.post("/pier/<name>/delete")
|
||||
def pier_delete(name):
|
||||
"""Delete = move to data/_휴지통 (recoverable). The typed name must match; refused while a job runs."""
|
||||
p = pier_or_404(name)
|
||||
if request.form.get("confirm_name", "").strip() != p.name:
|
||||
flash(f"삭제 안 됨: 확인란에 교각 이름 '{p.name}'을(를) 정확히 입력하세요.", "fail")
|
||||
elif anything_running(p):
|
||||
flash(f"삭제 안 됨: '{p.name}'은(는) 게이트를 실행 중입니다. 끝난 뒤 다시 시도하세요.", "fail")
|
||||
else:
|
||||
try:
|
||||
dst = store.delete_pier(p.name)
|
||||
flash(f"'{p.name}'을(를) 삭제했습니다(휴지통으로 이동: {dst.relative_to(store.DATA)}). "
|
||||
f"되돌리려면 이 폴더를 data 폴더로 옮기고 이름을 원래대로 바꾸세요.", "ok")
|
||||
except OSError as e:
|
||||
flash(f"삭제 실패: {e} (파일이 열려 있으면 닫고 다시 시도하세요)", "fail")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.route("/pier/<name>")
|
||||
def pier(name):
|
||||
p = pier_or_404(name)
|
||||
secs = []
|
||||
req = required(p)
|
||||
p.run_autocheck() # stage B: skipped when nothing changed
|
||||
for s in p.sections():
|
||||
secs.append(dict(s, **p.section_state(s["file"], s["key"]), meta=p.section_meta(s["file"], s["key"]),
|
||||
required=(s["file"], s["key"]) in req, chk=p.section_check(s["file"], s["key"])))
|
||||
rank = {"b": 0, "n": 1, "w": 2, "g": 3, "x": 4, "?": 1} # worst app check first, required bundles before the others
|
||||
secs.sort(key=lambda s: (0 if s["required"] else 1, rank.get(s["chk"]["st"], 1), s["file"], s["key"]))
|
||||
origin_same = [s for s in secs if s["required"] and s["meta"]["origin"]] # stage A 2026-10-16
|
||||
chain_bad = [c for c in anchors.chains(p.texts()) if not c["ok"]] # stage C 2026-10-19: R2 check of the drawing's own chains
|
||||
cnt = {k: 0 for k in "gwbnx"} # stage B 2026-10-16
|
||||
for s in secs:
|
||||
if s["required"]:
|
||||
cnt[s["chk"]["st"] if s["chk"]["st"] in cnt else "n"] += 1
|
||||
human = sum(1 for s in secs if s["required"] and s["confirmed"])
|
||||
nmatch = len(matched_targets(p))
|
||||
gates = [dict(id=g, name=GATE_NAMES[g], **p.gate_status(g)) for g in p.scope()]
|
||||
return render_template("pier.html", p=p, sections=secs, gates=gates, need=unconfirmed(p), origin_same=origin_same,
|
||||
has_origin=bool(p.info.get("origin")), grades=variability.GRADES, cnt=cnt, human=human, nreq=len(req),
|
||||
nmatch=nmatch, ST=verify.ST_NAME, chain_bad=chain_bad,
|
||||
todo=[g["id"] for g in gates if not g["approved"]], running=anything_running(p),
|
||||
out_of_scope=[g for g in gate_ids() if g not in p.scope()],
|
||||
batch=batch_state(p),
|
||||
audit=store.rjson(p.drawings / "audit.json", []), ntexts=len(p.texts()))
|
||||
|
||||
|
||||
# ---------------- drawings
|
||||
@app.post("/pier/<name>/upload")
|
||||
def upload(name):
|
||||
p = pier_or_404(name)
|
||||
files = [f for f in request.files.getlist("dwg") if f.filename]
|
||||
if not files or any(not f.filename.lower().endswith(".dwg") for f in files):
|
||||
flash("DWG 파일(.dwg)만 올릴 수 있습니다.", "fail")
|
||||
return redirect(url_for("pier", name=name))
|
||||
src = p.drawings / "dwg"
|
||||
src.mkdir(parents=True, exist_ok=True)
|
||||
paths = []
|
||||
for f in sorted(files, key=lambda f: f.filename):
|
||||
dst = src / store.safe_name(Path(f.filename).name)
|
||||
f.save(dst)
|
||||
paths.append(dst)
|
||||
try:
|
||||
audit = reader.convert(paths, p.drawings / "dxf")
|
||||
except Exception as e:
|
||||
flash(f"DWG 변환 실패: {e}", "fail")
|
||||
return redirect(url_for("pier", name=name))
|
||||
labels = [f"D{i}" for i in range(1, len(audit) + 1)]
|
||||
for a, lab in zip(audit, labels):
|
||||
a["label"] = lab
|
||||
store.wjson(p.drawings / "audit.json", audit)
|
||||
texts = reader.build_index([p.drawings / "dxf" / a["dxf"] for a in audit], labels, p.drawings / "texts.json")
|
||||
flash(f"도면 {len(audit)}장 변환, 문자 {len(texts)}개 색인", "ok")
|
||||
return redirect(url_for("pier", name=name))
|
||||
|
||||
|
||||
@app.route("/pier/<name>/search")
|
||||
def search(name):
|
||||
p = pier_or_404(name)
|
||||
q, rx = request.args.get("q", ""), bool(request.args.get("regex"))
|
||||
hits, err = [], None
|
||||
if q:
|
||||
try:
|
||||
hits = reader.search(p.texts(), q, regex=rx)
|
||||
except Exception as e:
|
||||
err = f"검색식 오류: {e}"
|
||||
audit = store.rjson(p.drawings / "audit.json", [])
|
||||
hd, hdwg, ent = request.args.get("h", "").strip(), request.args.get("dwg", ""), None
|
||||
if hd: # handle lookup of a drawing object (OPEN #19 A6)
|
||||
a = next((a for a in audit if a["label"] == hdwg), None)
|
||||
ent = reader.entity_info(p.drawings / "dxf" / a["dxf"], hd) if a else None
|
||||
if ent is None:
|
||||
err = f"{hdwg} 도면에 핸들 '{hd}' 객체가 없습니다."
|
||||
return render_template("search.html", p=p, q=q, regex=rx, hits=hits, err=err, audit=audit,
|
||||
h=hd, hdwg=hdwg, ent=ent, dumps=lambda v: json.dumps(v, ensure_ascii=False))
|
||||
|
||||
|
||||
@app.route("/pier/<name>/candidates")
|
||||
def candidates(name):
|
||||
p = pier_or_404(name)
|
||||
texts = p.texts()
|
||||
tables = reader.table_candidates(texts) if texts else []
|
||||
rows = reader.proposals(reader.field_candidates(tables), p.load("params"), p.load("expected")) if tables else []
|
||||
arows, amiss, ameta = p.anchor_rows()
|
||||
rank = {"A": 0, "B": 1, "C": 2}
|
||||
arows.sort(key=lambda r: (r["same"], rank[r["grade"]], r["field"])) # differing candidates first, best grade first
|
||||
chains = anchors.chains(texts)
|
||||
return render_template("candidates.html", p=p, tables=tables, rows=rows, els=reader.el_list(texts),
|
||||
dumps=lambda v: json.dumps(v, ensure_ascii=False), arows=arows, amiss=amiss, ameta=ameta,
|
||||
chains=chains, chain_bad=[c for c in chains if not c["ok"]],
|
||||
tpl_anchors=[t["name"] for t in store.templates() if (store.template_dir(t["name"]) / "anchors.json").exists()])
|
||||
|
||||
|
||||
@app.post("/pier/<name>/anchors/apply")
|
||||
def anchors_apply(name):
|
||||
"""Apply selected out-of-table candidates. Rules: the same candidate must not go into params and expected together (P7), a grade C candidate
|
||||
needs a reason, only differing rows are applied; the change is logged and the changed bundles need a new confirmation."""
|
||||
p = pier_or_404(name)
|
||||
rows, _m, _meta = p.anchor_rows()
|
||||
chosen = set(request.form.getlist("field"))
|
||||
sel = [r for r in rows if r["field"] in chosen and not r["same"] and not r["self_source"]]
|
||||
why = request.form.get("why", "").strip()
|
||||
key = lambda r: (r["text"], r["x"], r["y"], r["value"])
|
||||
both = {key(r) for r in sel if r["file"] == "params"} & {key(r) for r in sel if r["file"] == "expected"}
|
||||
done = store.rjson(p.dir / "anchor_log.json", []) # applied earlier: (file, text, x, y)
|
||||
prior = {(f["file"], f["text"], f["x"], f["y"]) for e in done for f in e["fields"] if "file" in f}
|
||||
both |= {key(r) for r in sel if ("expected" if r["file"] == "params" else "params", r["text"], r["x"], r["y"]) in prior}
|
||||
if not sel:
|
||||
flash("반영할 후보를 고르세요(이미 같은 값은 반영하지 않습니다).", "fail")
|
||||
elif both:
|
||||
flash("같은 도면 문자에서 나온 후보를 params와 expected에 함께 반영할 수 없습니다. 두 값이 같은 근거에서 나오면 검사가 무의미해집니다. 한쪽만 고르세요.", "fail")
|
||||
elif any(r["grade"] == "C" for r in sel) and not why:
|
||||
flash("C등급 후보는 반영 사유를 입력해야 합니다.", "fail")
|
||||
else:
|
||||
docs = {"params": p.load("params"), "expected": p.load("expected")}
|
||||
for r in sel:
|
||||
store.set_field(docs, r["field"], r["value"])
|
||||
p.save("params", docs["params"])
|
||||
p.save("expected", docs["expected"])
|
||||
log = store.rjson(p.dir / "anchor_log.json", [])
|
||||
log.append({"at": store.now(), "fields": [{"field": r["field"], "file": r["file"], "from": r["current"], "to": r["value"], "grade": r["grade"],
|
||||
"text": r["text"], "x": r["x"], "y": r["y"], "pos": f"{r['dwg']} ({r['x']:.0f}, {r['y']:.0f})"}
|
||||
for r in sel], "why": why})
|
||||
store.wjson(p.dir / "anchor_log.json", log)
|
||||
flash(f"표 밖 치수 후보 {len(sel)}건을 반영했습니다. 바뀐 묶음은 다시 확정해야 합니다.", "ok")
|
||||
return redirect(url_for("candidates", name=name))
|
||||
|
||||
|
||||
@app.post("/pier/<name>/anchors/load")
|
||||
def anchors_load(name):
|
||||
"""Copy the anchors of a protected template into a pier that has none (the person says which series the drawing belongs to)."""
|
||||
p = pier_or_404(name)
|
||||
t = request.form.get("template", "")
|
||||
try:
|
||||
src = store.template_dir(t) / "anchors.json"
|
||||
if not src.exists():
|
||||
raise FileNotFoundError(t)
|
||||
except (FileNotFoundError, ValueError):
|
||||
flash("앵커가 있는 템플릿을 고르세요.", "fail")
|
||||
return redirect(url_for("candidates", name=name))
|
||||
if (p.dir / "anchors.json").exists():
|
||||
flash("이 교각에는 이미 앵커가 있습니다.", "fail")
|
||||
else:
|
||||
shutil.copy2(src, p.dir / "anchors.json")
|
||||
flash(f"템플릿 '{t}'의 앵커를 불러왔습니다. 앱 확인이 새 도면 기준으로 다시 실행됩니다.", "ok")
|
||||
return redirect(url_for("candidates", name=name))
|
||||
|
||||
|
||||
@app.route("/pier/<name>/view/<dwg>")
|
||||
def view_drawing(name, dwg):
|
||||
"""PNG of the drawing region around (x, y) so a candidate can be checked by eye."""
|
||||
p = pier_or_404(name)
|
||||
try:
|
||||
x, y = float(request.args["x"]), float(request.args["y"])
|
||||
half = min(max(float(request.args.get("half", 3000)), 500.0), 20000.0)
|
||||
except (KeyError, ValueError):
|
||||
abort(400)
|
||||
ent = next((a for a in store.rjson(p.drawings / "audit.json", []) if a.get("label") == dwg), None)
|
||||
dxf = p.drawings / "dxf" / ent["dxf"] if ent else None
|
||||
if not dxf or not dxf.exists():
|
||||
abort(404)
|
||||
cache = p.drawings / "view_cache"
|
||||
cache.mkdir(exist_ok=True)
|
||||
out = cache / f"{dwg}_{x:.0f}_{y:.0f}_{half:.0f}.png"
|
||||
if not out.exists():
|
||||
render.render(str(dxf), x, y, half, str(out))
|
||||
return send_file(out, mimetype="image/png")
|
||||
|
||||
|
||||
@app.post("/pier/<name>/candidates/apply")
|
||||
def candidates_apply(name):
|
||||
p = pier_or_404(name)
|
||||
texts = p.texts()
|
||||
rows = reader.proposals(reader.field_candidates(reader.table_candidates(texts)), p.load("params"), p.load("expected"))
|
||||
chosen = set(request.form.getlist("row"))
|
||||
sel = [r for i, r in enumerate(rows) if str(i) in chosen and not r["same"]]
|
||||
P, E = p.load("params"), p.load("expected")
|
||||
reader.apply_proposals(sel, P, E)
|
||||
p.save("params", P)
|
||||
p.save("expected", E)
|
||||
flash(f"후보값 {len(sel)}건을 반영했습니다. 바뀐 묶음은 다시 확정해야 합니다.", "ok")
|
||||
return redirect(url_for("candidates", name=name))
|
||||
|
||||
|
||||
|
||||
# ---------------- 도면에서 읽기 (improvement #34-1, 2026-10-20): engine/geomread.py
|
||||
def _geomread(p):
|
||||
"""-> (state, P', E', changes, notes, message). state: ok / no_texts / no_dxf / frame / error."""
|
||||
texts = p.texts()
|
||||
if not texts:
|
||||
return "no_texts", None, None, [], [], "도면 문자 색인이 없습니다. 도면을 먼저 올리세요."
|
||||
ref_dir = store.template_dir("운곡교P1형")
|
||||
ref = store.rjson(ref_dir / "params.json")
|
||||
if not ref:
|
||||
return "error", None, None, [], [], "기준 템플릿(운곡교P1형)을 찾지 못했습니다."
|
||||
cov = geomread.frame_ok(texts, store.rjson(ref_dir / "anchors.json"), anchors)
|
||||
if cov is not None and cov < geomread.FRAME_MIN_COVERAGE:
|
||||
return "frame", None, None, [], [], f"도면 틀 일치율이 {cov * 100:.0f} %입니다(70 % 미만). 운곡교 P1 도면 틀(P1~P4)이 아니라서 읽지 않습니다."
|
||||
try:
|
||||
dxf = geomread.dxf_of(p.dir, store.rjson(p.drawings / "audit.json", []))
|
||||
P, E, changes, notes = geomread.read(p.load("params"), p.load("expected"), ref, texts, dxf, p.name)
|
||||
except geomread.FrameError as e:
|
||||
return "frame", None, None, [], [], str(e)
|
||||
except Exception as e: # a reader must never break the page
|
||||
return "error", None, None, [], [], f"읽는 중 오류: {type(e).__name__}: {e}"
|
||||
own = [a.get("source_sha256") for a in store.rjson(p.drawings / "audit.json", []) if a.get("source_sha256")]
|
||||
changes = changes + geomread.table_changes(P, E, texts) + geomread.extra_changes(P, E, texts, store.rjson(p.dir / "anchors.json"), own) # tables too: one screen brings every template value in line with the drawing; + anchor dimensions and planned FAIL proposals
|
||||
return "ok", P, E, changes, notes, f"도면 틀 일치율 {(cov or 0) * 100:.0f} %" if cov is not None else ""
|
||||
|
||||
|
||||
@app.route("/pier/<name>/geomread")
|
||||
def geomread_page(name):
|
||||
p = pier_or_404(name)
|
||||
state, _P, _E, changes, notes, msg = _geomread(p)
|
||||
groups = {}
|
||||
for i, c in enumerate(changes):
|
||||
groups.setdefault(c["group"], []).append((i, c))
|
||||
brief = lambda v: v if isinstance(v, str) else json.dumps(v, ensure_ascii=False) # noqa: E731
|
||||
return render_template("geomread.html", p=p, state=state, msg=msg, notes=notes, groups=groups, n=len(changes), brief=brief,
|
||||
log=store.rjson(p.dir / "read_log.json", [])[-3:], running=anything_running(p))
|
||||
|
||||
|
||||
@app.post("/pier/<name>/geomread/apply")
|
||||
def geomread_apply(name):
|
||||
"""Write the selected readings into params / expected. The page is recomputed here (indices refer to the same fresh list); a backup is taken first;
|
||||
the changed bundles lose their confirmation by themselves (the hash changes) and the app check runs again."""
|
||||
p = pier_or_404(name)
|
||||
if anything_running(p):
|
||||
flash("게이트 실행 중에는 값을 바꿀 수 없습니다.", "fail")
|
||||
return redirect(url_for("geomread_page", name=name))
|
||||
state, _P, _E, changes, notes, msg = _geomread(p)
|
||||
if state != "ok":
|
||||
flash(msg, "fail")
|
||||
return redirect(url_for("geomread_page", name=name))
|
||||
chosen = sorted({int(i) for i in request.form.getlist("row") if i.isdigit() and int(i) < len(changes)})
|
||||
if not chosen:
|
||||
flash("반영할 줄을 고르세요.", "fail")
|
||||
return redirect(url_for("geomread_page", name=name))
|
||||
P0, E0 = p.load("params"), p.load("expected")
|
||||
bk = p.dir / "backup" / f"before_geomread_{time.strftime('%Y%m%d_%H%M%S')}"
|
||||
bk.mkdir(parents=True)
|
||||
for f in ("params.json", "expected.json", "confirm.json"):
|
||||
if (p.dir / f).exists():
|
||||
shutil.copy2(p.dir / f, bk / f)
|
||||
P1, E1 = geomread.apply_selected(P0, E0, changes, chosen)
|
||||
if any(changes[i]["path"] == "planned_fail" for i in chosen):
|
||||
geomread.stamp_pending(E1, request.form.get("by", "").strip(), store.now()[:10])
|
||||
p.save("params", P1)
|
||||
p.save("expected", E1)
|
||||
# a fixed (F) bundle / fixed field changed by a reading needs a logged reason (check C7): the reading itself is the reason
|
||||
by_bundle = {}
|
||||
for i in chosen:
|
||||
c = changes[i]
|
||||
key = c["path"].split(".")[0]
|
||||
m = p.section_meta(c["file"], key)
|
||||
rel = c["path"].split(".", 1)[1] if "." in c["path"] else ""
|
||||
if m.get("grade") == "F" or (rel and rel in (m.get("fixed_fields") or [])):
|
||||
by_bundle.setdefault((c["file"], key), []).append(c["path"])
|
||||
for (fl, key), paths in by_bundle.items():
|
||||
p.log_fixed_edit(fl, key, paths, "도면에서 읽기 반영(" + (request.form.get("by", "").strip() or "-") + "): " + "; ".join(paths[:3]))
|
||||
log = store.rjson(p.dir / "read_log.json", [])
|
||||
log.append({"at": store.now(), "by": request.form.get("by", "").strip(), "count": len(chosen), "backup": bk.name,
|
||||
"fields": [f"{changes[i]['file']}:{changes[i]['path']}" for i in chosen]})
|
||||
store.wjson(p.dir / "read_log.json", log)
|
||||
p.run_autocheck(force=True)
|
||||
flash(f"도면에서 읽은 값 {len(chosen)}건을 반영했습니다(백업 {bk.name}). 바뀐 묶음은 다시 확정해야 합니다.", "ok")
|
||||
return redirect(url_for("geomread_page", name=name))
|
||||
|
||||
# ---------------- sections
|
||||
@app.route("/pier/<name>/section/<which>/<key>", methods=["GET", "POST"])
|
||||
def section(name, which, key):
|
||||
p = pier_or_404(name)
|
||||
if which not in ("params", "expected"):
|
||||
abort(404)
|
||||
d = p.load(which)
|
||||
if key not in d:
|
||||
abort(404)
|
||||
text = json.dumps(d[key], ensure_ascii=False, indent=1)
|
||||
meta = p.section_meta(which, key)
|
||||
if request.method == "POST":
|
||||
text = request.form["value"]
|
||||
reason = request.form.get("reason", "").strip()
|
||||
try:
|
||||
new = store.expand_chains(json.loads(text))
|
||||
if meta["grade"] == "V3":
|
||||
raise ValueError("생성 변수(V3)는 화면에서 직접 고칠 수 없습니다. 판독 스크립트를 다시 실행해 목록을 만든 뒤 반영하세요")
|
||||
fixed = ["(묶음 전체)"] if meta["grade"] == "F" and new != d[key] else variability.changed_fixed(d[key], new, meta["fixed_fields"])
|
||||
if fixed and not reason:
|
||||
raise ValueError(f"고정(F) 항목({', '.join(fixed)})을 바꾸면 같은 템플릿이 아닐 수 있습니다. 바꾸는 사유를 입력하세요")
|
||||
p.set_section(which, key, new)
|
||||
if fixed:
|
||||
p.log_fixed_edit(which, key, fixed, reason)
|
||||
flash("저장했습니다. 확정하려면 '확정'을 누르세요." + (" 고정 항목 변경 사유를 기록했습니다." if fixed else ""), "ok")
|
||||
return redirect(url_for("section", name=name, which=which, key=key))
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
flash(f"저장 안 됨: {e}", "fail")
|
||||
return render_template("section.html", p=p, which=which, key=key, text=text, st=p.section_state(which, key), meta=meta,
|
||||
grades=variability.GRADES)
|
||||
|
||||
|
||||
@app.post("/pier/<name>/section/<which>/<key>/confirm")
|
||||
def section_confirm(name, which, key):
|
||||
"""Confirm one bundle. A bundle the app check does not report as 일치 (주의 / 불일치 / 근거 없음) needs a reason (D3, stage B 2026-10-16)."""
|
||||
p = pier_or_404(name)
|
||||
by = request.form.get("by", "").strip()
|
||||
why = request.form.get("why", "").strip()
|
||||
p.run_autocheck()
|
||||
st = p.section_check(which, key)["st"]
|
||||
if not by:
|
||||
flash("확정자 이름을 입력하세요.", "fail")
|
||||
elif st in ("w", "b", "n") and not why:
|
||||
flash(f"{which}.{key}는 앱 확인이 '{verify.ST_NAME[st]}'입니다. 사유를 입력해야 확정할 수 있습니다.", "fail")
|
||||
else:
|
||||
p.confirm(which, key, by, why, st)
|
||||
flash(f"{which}.{key} 확정 ({by})" + (f" — 앱 확인 {verify.ST_NAME.get(st, st)}, 사유 기록" if why else ""), "ok")
|
||||
return redirect(request.form.get("next") or url_for("pier", name=name))
|
||||
|
||||
|
||||
# ---------------- app check (stage B 2026-10-16)
|
||||
@app.post("/pier/<name>/autocheck")
|
||||
def autocheck_run(name):
|
||||
p = pier_or_404(name)
|
||||
a = p.run_autocheck(force=True)
|
||||
flash(f"앱 확인을 실행했습니다 ({len(a['sections'])}개 묶음). 앱은 확정하지 않습니다.", "ok")
|
||||
return redirect(request.form.get("next") or url_for("pier", name=name))
|
||||
|
||||
|
||||
@app.route("/pier/<name>/evidence/<which>/<key>")
|
||||
def evidence(name, which, key):
|
||||
p = pier_or_404(name)
|
||||
if which not in ("params", "expected") or key not in p.load(which):
|
||||
abort(404)
|
||||
p.run_autocheck()
|
||||
return render_template("evidence.html", p=p, which=which, key=key, chk=p.section_check(which, key), st=p.section_state(which, key),
|
||||
meta=p.section_meta(which, key), grades=variability.GRADES, ST=verify.ST_NAME)
|
||||
|
||||
|
||||
def matched_targets(p):
|
||||
"""Required bundles the app check reports as 일치 and nobody has confirmed (current): the D4 bulk-confirm targets."""
|
||||
p.run_autocheck()
|
||||
out = []
|
||||
for f, k in sorted(required(p)):
|
||||
c, s = p.section_check(f, k), p.section_state(f, k)
|
||||
if c["st"] == "g" and not c["stale"] and not s["confirmed"]:
|
||||
out.append((f, k))
|
||||
return out
|
||||
|
||||
|
||||
def _targets_sha(p, targets):
|
||||
return store.sha([[f, k, p.section_check(f, k)["sha"], p.section_check(f, k)["st"]] for f, k in targets])
|
||||
|
||||
|
||||
@app.post("/pier/<name>/confirm_matched/read")
|
||||
def confirm_matched_read(name):
|
||||
"""Marker: the evidence table of the current target list was read to its end (the page posts this when the scroll box is at the bottom)."""
|
||||
p = pier_or_404(name)
|
||||
targets = matched_targets(p)
|
||||
store.wjson(p.dir / "confirm_view.json", {"sha": _targets_sha(p, targets), "at": store.now(), "n": len(targets)})
|
||||
return {"ok": True, "n": len(targets)}
|
||||
|
||||
|
||||
@app.route("/pier/<name>/confirm_matched", methods=["GET", "POST"])
|
||||
def confirm_matched(name):
|
||||
"""D4: bulk confirmation of the bundles the app check reports as 일치. The evidence table must have been opened (a marker holds the
|
||||
hash of the target list), a name is required; anything the app does not report as 일치 is not touched."""
|
||||
p = pier_or_404(name)
|
||||
targets = matched_targets(p)
|
||||
if request.method == "POST":
|
||||
by = request.form.get("by", "").strip()
|
||||
mark = store.rjson(p.dir / "confirm_view.json", {})
|
||||
if not targets:
|
||||
flash("확정할 일치 묶음이 없습니다.", "fail")
|
||||
elif mark.get("sha") != _targets_sha(p, targets):
|
||||
flash("근거표를 열어 끝까지 읽은 뒤에 확정할 수 있습니다(대상 목록이 바뀌었으면 다시 여세요).", "fail")
|
||||
elif not by:
|
||||
flash("확정자 이름을 입력하세요.", "fail")
|
||||
else:
|
||||
for f, k in targets:
|
||||
p.confirm(f, k, by, "", "g")
|
||||
flash(f"앱 확인 '일치' 묶음 {len(targets)}개를 {by}이(가) 확정했습니다. 확정 기록에는 입력한 이름만 남습니다.", "ok")
|
||||
return redirect(url_for("pier", name=name))
|
||||
return redirect(url_for("confirm_matched", name=name))
|
||||
rest = []
|
||||
for f, k in sorted(required(p)):
|
||||
if (f, k) in targets:
|
||||
continue
|
||||
s = p.section_state(f, k)
|
||||
if not s["confirmed"]:
|
||||
rest.append(dict(file=f, key=k, chk=p.section_check(f, k)))
|
||||
return render_template("confirm_matched.html", p=p, targets=[dict(file=f, key=k, chk=p.section_check(f, k)) for f, k in targets],
|
||||
rest=rest, ST=verify.ST_NAME)
|
||||
|
||||
|
||||
|
||||
# ---------------- 검토 요약 일괄 확정 (2026-10-20): one screen to review, one act to confirm
|
||||
def review_items(p):
|
||||
"""Required bundles that are not (validly) confirmed, each with the app check, what changed since the pier was created, where the values came
|
||||
from (app tools) and its review group (A 일치 / B 주의·근거 없음 / C 불일치 / D 결정 기록·참고)."""
|
||||
p.run_autocheck()
|
||||
org = store.rjson(p.dir / "origin_values.json", {})
|
||||
rl, al = store.rjson(p.dir / "read_log.json", []), store.rjson(p.dir / "anchor_log.json", [])
|
||||
docs = {"params": p.load("params"), "expected": p.load("expected")}
|
||||
out = []
|
||||
for f, k in sorted(required(p)):
|
||||
s = p.section_state(f, k)
|
||||
if s["confirmed"]:
|
||||
continue
|
||||
c = p.section_check(f, k)
|
||||
ov = (org.get(f) or {}).get(k)
|
||||
item = dict(file=f, key=k, id=f"{f}:{k}", st=c["st"], summary=c["summary"], checks=c["checks"], sha=c["sha"] if "sha" in c else "",
|
||||
stale=s["stale"], prev=(s["by"], s["at"]) if s["stale"] else None, change=reviewlib.bundle_change(docs[f][k], ov),
|
||||
prov=reviewlib.provenance(f, k, rl, al), unverified=reviewlib.not_checked(c["checks"], c["st"]), group=reviewlib.group_of(c["st"]))
|
||||
if (f, k) == ("expected", "planned_fail"):
|
||||
item["detail"] = [f"{x.get('gate', '')} {x.get('item') or x.get('item_prefix', '')} — {x.get('reason', '')} ({x.get('approved', '')})" for x in docs[f][k]]
|
||||
item["flags"] = reviewlib.flags(item)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def _review_sha(items):
|
||||
return store.sha([[i["id"], i["st"], i["change"]["text"], i.get("sha", "")] for i in items])
|
||||
|
||||
|
||||
@app.post("/pier/<name>/review/read")
|
||||
def review_read(name):
|
||||
"""Marker: the review list was shown to its end (the page posts this when its last line is visible). Holds the hash of the list that was shown."""
|
||||
p = pier_or_404(name)
|
||||
items = review_items(p)
|
||||
store.wjson(p.dir / "review_view.json", {"sha": _review_sha(items), "at": store.now(), "n": len(items)})
|
||||
return {"ok": True, "n": len(items)}
|
||||
|
||||
|
||||
@app.route("/pier/<name>/review", methods=["GET", "POST"])
|
||||
def review(name):
|
||||
"""One screen: the app prepares what changed / what it verified / what it could not verify; the person ticks what they compared with the drawing
|
||||
(every 주의·근거 없음·결정 기록 bundle needs its own tick, 일치 bundles one tick for the group), names themselves and confirms once. 불일치 bundles
|
||||
cannot be confirmed. Each confirmation record gets an informative reason (engine/review.auto_reason) + the person's note."""
|
||||
p = pier_or_404(name)
|
||||
items = review_items(p)
|
||||
if request.method == "POST":
|
||||
by, note = request.form.get("by", "").strip(), request.form.get("note", "")
|
||||
ticked = set(request.form.getlist("ack"))
|
||||
mark = store.rjson(p.dir / "review_view.json", {})
|
||||
if not by:
|
||||
flash("확정자 이름을 입력하세요.", "fail")
|
||||
elif mark.get("sha") != _review_sha(items):
|
||||
flash("검토 목록을 끝까지 읽은 뒤에 확정할 수 있습니다(목록이 바뀌었으면 다시 여세요).", "fail")
|
||||
else:
|
||||
done, skipped = [], []
|
||||
for it in items:
|
||||
if it["group"] == "C":
|
||||
continue
|
||||
if it["group"] == "A":
|
||||
if not request.form.get("ack_matched"):
|
||||
continue
|
||||
elif it["id"] not in ticked:
|
||||
skipped.append(it["id"])
|
||||
continue
|
||||
p.confirm(it["file"], it["key"], by, reviewlib.auto_reason(it, note), it["st"])
|
||||
done.append(it["id"])
|
||||
if not done:
|
||||
flash("확정한 묶음이 없습니다. 도면과 대조한 묶음에 체크하세요(일치 묶음은 아래 '읽었습니다'에 체크).", "fail")
|
||||
else:
|
||||
flash(f"{len(done)}개 묶음을 {by}이(가) 확정했습니다. 사유는 앱이 아는 사실(앱 확인·변경·출처)과 입력한 의견으로 기록했습니다."
|
||||
+ (f" 체크하지 않은 {len(skipped)}개는 미확정으로 남았습니다." if skipped else ""), "ok")
|
||||
return redirect(url_for("review", name=name) if [i for i in items if i["id"] not in done] else url_for("pier", name=name))
|
||||
return redirect(url_for("review", name=name))
|
||||
cnt = {g: sum(1 for i in items if i["group"] == g) for g in "ABCD"}
|
||||
return render_template("review.html", p=p, items=items, cnt=cnt, ST=verify.ST_NAME, by_group={g: [i for i in items if i["group"] == g] for g in "ABCD"})
|
||||
|
||||
|
||||
def required(p):
|
||||
"""Bundles any gate of the pier's scope needs (existing ones)."""
|
||||
return {(r["file"], r["key"]) for g in p.scope() for r in p.gate_requirements(g) if r["exists"]}
|
||||
|
||||
|
||||
def unconfirmed(p):
|
||||
"""Required sections (all gates) that exist and are not confirmed, without duplicates."""
|
||||
out = []
|
||||
for g in p.scope():
|
||||
for r in p.gate_requirements(g):
|
||||
if r["exists"] and not r["confirmed"] and (r["file"], r["key"]) not in out:
|
||||
out.append((r["file"], r["key"]))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- gates
|
||||
def gate_blockers(p, gate):
|
||||
out = [f"{r['file']}.{r['key']} 미확정" for r in p.gate_requirements(gate) if r["exists"] and not r["confirmed"]]
|
||||
out += [f"{r['file']}.{r['key']} 없음" for r in p.gate_requirements(gate) if not r["exists"]]
|
||||
if p.info.get("type") not in store.PIER_TYPES:
|
||||
out.append("T형이 아닌 교각")
|
||||
ids = p.scope()
|
||||
if gate not in ids:
|
||||
return [f"{gate}는 이 교각의 범위 밖"]
|
||||
k = ids.index(gate)
|
||||
if k > 0 and not p.gate_status(ids[k - 1])["approved"]:
|
||||
out.append(f"{ids[k - 1]} 미승인")
|
||||
return out
|
||||
|
||||
|
||||
def job_state(job):
|
||||
"""A queued/running job counts only while its thread lives in this server process; a job left behind by a
|
||||
stopped server (or test process) is shown as 중단됨 and may be run again."""
|
||||
if job and job["state"] in ("대기", "실행 중"):
|
||||
t = ACTIVE.get(job.get("id"))
|
||||
if job.get("pid") != os.getpid() or not (t and t.is_alive()):
|
||||
return "중단됨"
|
||||
return job and job["state"]
|
||||
|
||||
|
||||
def _gate_job(p, gate, jid):
|
||||
jp = p.run_dir / f"{gate}_job.json"
|
||||
inputs = p.inputs_sha()
|
||||
log = []
|
||||
|
||||
def put(state, **kw):
|
||||
store.wjson(jp, dict({"id": jid, "pid": os.getpid(), "state": state, "at": store.now(), "log": log}, **kw))
|
||||
|
||||
def note(m):
|
||||
log.append(m)
|
||||
put("실행 중")
|
||||
with RHINO_LOCK:
|
||||
try:
|
||||
note("Rhino 작업 시작")
|
||||
rep = run_gate(gate, p.dir / "params.json", p.dir / "expected.json", p.run_dir, log=note)
|
||||
rep["inputs_sha"], rep["job_id"] = inputs, jid
|
||||
store.wjson(p.run_dir / f"{gate}_gate.json", rep)
|
||||
put("완료")
|
||||
except Exception:
|
||||
put("오류", error=traceback.format_exc())
|
||||
|
||||
|
||||
def batch_state(p):
|
||||
"""State of the batch run of this pier (실행 중 only while its thread lives in this server process)."""
|
||||
b = store.rjson(p.run_dir / "batch.json")
|
||||
if b and b["state"] == "실행 중":
|
||||
t = BATCH.get(p.name)
|
||||
if b.get("pid") != os.getpid() or not (t and t.is_alive()):
|
||||
b = dict(b, state="중단됨", reason="killed")
|
||||
return b
|
||||
|
||||
|
||||
def _batch(p, by, gates_todo):
|
||||
bp = p.run_dir / "batch.json"
|
||||
log = []
|
||||
|
||||
started = store.now()
|
||||
|
||||
def put(state, **kw):
|
||||
"""batch.json; started = when this run began, reason (중단 only): blocked / error / fail -- the gates page tells the three apart (#34-5)"""
|
||||
store.wjson(bp, dict({"state": state, "by": by, "pid": os.getpid(), "started": started, "at": store.now(), "log": log}, **kw))
|
||||
put("실행 중")
|
||||
need = list(gates_todo)
|
||||
# 1. gates with a current PASS result: approve without running again (in order, until one needs a run)
|
||||
while need:
|
||||
g = need[0]
|
||||
st = p.gate_status(g)
|
||||
rep = st["report"] or {}
|
||||
if not (rep.get("verdict") == "PASS" and not st["stale"]):
|
||||
break
|
||||
p.set_approval(g, "승인", by, AUTO_NOTE)
|
||||
s = rep["summary"]
|
||||
log.append(f"{g} 기존 PASS 결과 {s['passed']}/{s['total']} (계획 FAIL {len(rep['planned_fails'])}) → 승인 (재실행 없음)")
|
||||
put("실행 중")
|
||||
need.pop(0)
|
||||
if not need:
|
||||
return put("완료")
|
||||
# 2. the rest: one Rhino session (gate checks + negative tests only, no per-gate save / reload, G5 saved once)
|
||||
missing = [f"{g}: {b}" for g in need for b in gate_blockers(p, g) if not b.endswith("미승인")]
|
||||
if missing:
|
||||
log.append("실행 불가: " + ", ".join(missing))
|
||||
return put("중단", reason="blocked")
|
||||
jid, inputs = uuid.uuid4().hex[:12], p.inputs_sha()
|
||||
ACTIVE[jid] = threading.current_thread()
|
||||
jlog = []
|
||||
|
||||
def jobs(state, **kw):
|
||||
for g in need:
|
||||
store.wjson(p.run_dir / f"{g}_job.json",
|
||||
dict({"id": jid, "pid": os.getpid(), "state": state, "at": store.now(), "log": jlog}, **kw))
|
||||
|
||||
def note(m):
|
||||
jlog.append(m)
|
||||
log.append(m)
|
||||
jobs("실행 중")
|
||||
put("실행 중")
|
||||
jobs("대기")
|
||||
log.append(f"{need[0]}~{need[-1]} 단일 세션 실행 (게이트 검사·음성 시험만, 중간 저장·재로드 없음)")
|
||||
put("실행 중")
|
||||
with RHINO_LOCK:
|
||||
try:
|
||||
reps = run_batch_session(need, p.dir / "params.json", p.dir / "expected.json", p.run_dir, log=note)
|
||||
except Exception:
|
||||
tb = traceback.format_exc()
|
||||
jobs("오류", error=tb)
|
||||
log.append("오류 — 게이트 화면에서 내용을 확인하세요")
|
||||
return put("중단", reason="error", error=(tb.strip().splitlines() or ["?"])[-1])
|
||||
for g in need:
|
||||
reps[g]["inputs_sha"], reps[g]["job_id"], reps[g]["at"] = inputs, jid, store.now()
|
||||
store.wjson(p.run_dir / f"{g}_gate.json", reps[g])
|
||||
jobs("완료")
|
||||
# 3. approve in order; stop at the first gate that is not PASS
|
||||
for g in need:
|
||||
rep = reps[g]
|
||||
if rep["verdict"] != "PASS":
|
||||
log.append(f"{g} {rep['verdict']} — 승인하지 않고 멈춤 (결과를 확인하세요)")
|
||||
return put("중단", reason="fail", gate=g)
|
||||
p.set_approval(g, "승인", by, AUTO_NOTE)
|
||||
s = rep["summary"]
|
||||
log.append(f"{g} PASS {s['passed']}/{s['total']} (계획 FAIL {len(rep['planned_fails'])}) → 승인")
|
||||
put("실행 중")
|
||||
put("완료")
|
||||
|
||||
|
||||
def anything_running(p):
|
||||
if (batch_state(p) or {}).get("state") == "실행 중":
|
||||
return True
|
||||
return any(job_state(p.gate_status(g)["job"]) in ("대기", "실행 중") for g in gate_ids())
|
||||
|
||||
|
||||
@app.post("/pier/<name>/gates/run_all")
|
||||
def gates_run_all(name):
|
||||
p = pier_or_404(name)
|
||||
by = request.form.get("by", "").strip()
|
||||
if not by:
|
||||
flash("판정자 이름을 입력하세요.", "fail")
|
||||
return redirect(url_for("gates", name=name))
|
||||
if anything_running(p):
|
||||
flash("이미 실행 중인 작업이 있습니다.", "fail")
|
||||
return redirect(url_for("gates", name=name))
|
||||
todo = [g for g in p.scope() if not p.gate_status(g)["approved"]]
|
||||
if not todo:
|
||||
flash("모든 게이트가 이미 승인되어 있습니다.", "ok")
|
||||
return redirect(url_for("gates", name=name))
|
||||
b = gate_blockers(p, todo[0])
|
||||
if b:
|
||||
flash(f"{todo[0]} 실행 불가: " + ", ".join(b), "fail")
|
||||
return redirect(url_for("gates", name=name))
|
||||
store.wjson(p.run_dir / "batch.json", {"state": "실행 중", "by": by, "pid": os.getpid(), "at": store.now(),
|
||||
"log": ["일괄 실행 대기"]})
|
||||
t = threading.Thread(target=_batch, args=(p, by, todo), daemon=True)
|
||||
BATCH[p.name] = t
|
||||
t.start()
|
||||
flash(f"{todo[0]}~{todo[-1]} 연속 실행을 시작했습니다. PASS인 게이트만 '{by}' 이름으로 자동 승인합니다.", "ok")
|
||||
return redirect(url_for("gates", name=name))
|
||||
|
||||
|
||||
@app.post("/pier/<name>/gate/<gate>/run")
|
||||
def gate_run(name, gate):
|
||||
p = pier_or_404(name)
|
||||
if gate not in gate_ids():
|
||||
abort(404)
|
||||
if (batch_state(p) or {}).get("state") == "실행 중":
|
||||
flash("일괄 실행 중에는 개별 실행을 할 수 없습니다.", "fail")
|
||||
return redirect(url_for("gates", name=name))
|
||||
b = gate_blockers(p, gate)
|
||||
if b:
|
||||
flash(f"{gate} 실행 불가: " + ", ".join(b), "fail")
|
||||
return redirect(url_for("gates", name=name))
|
||||
if job_state(p.gate_status(gate)["job"]) in ("대기", "실행 중"):
|
||||
flash(f"{gate}는 이미 실행 중입니다.", "fail")
|
||||
return redirect(url_for("gates", name=name))
|
||||
# Mark the job as queued (with its id) before the thread starts, so no request sees the previous run's state.
|
||||
jid = uuid.uuid4().hex[:12]
|
||||
store.wjson(p.run_dir / f"{gate}_job.json",
|
||||
{"id": jid, "pid": os.getpid(), "state": "대기", "at": store.now(), "log": []})
|
||||
t = threading.Thread(target=_gate_job, args=(p, gate, jid), daemon=True)
|
||||
ACTIVE[jid] = t
|
||||
t.start()
|
||||
flash(f"{gate} 실행을 시작했습니다. 1~2분 걸립니다.", "ok")
|
||||
return redirect(url_for("gates", name=name))
|
||||
|
||||
|
||||
@app.post("/pier/<name>/gate/<gate>/decide")
|
||||
def gate_decide(name, gate):
|
||||
p = pier_or_404(name)
|
||||
st = p.gate_status(gate)
|
||||
by, decision, note = request.form.get("by", "").strip(), request.form["decision"], request.form.get("note", "")
|
||||
if not st["report"] or st["stale"]:
|
||||
flash("판정할 최신 결과가 없습니다. 게이트를 다시 실행하세요.", "fail")
|
||||
elif not by:
|
||||
flash("판정자 이름을 입력하세요.", "fail")
|
||||
elif decision == "승인" and st["report"]["verdict"] != "PASS":
|
||||
flash("FAIL 게이트는 승인할 수 없습니다. 입력을 고치거나 계획 FAIL로 등록(expected.planned_fail)한 뒤 다시 실행하세요.", "fail")
|
||||
elif decision == "반려" and not note.strip():
|
||||
flash("반려 사유를 입력하세요.", "fail")
|
||||
else:
|
||||
p.set_approval(gate, decision, by, note)
|
||||
flash(f"{gate} {decision} ({by})", "ok")
|
||||
return redirect(url_for("gates", name=name))
|
||||
|
||||
|
||||
@app.route("/pier/<name>/gates")
|
||||
def gates(name):
|
||||
p = pier_or_404(name)
|
||||
rows = [dict(id=g, name=GATE_NAMES[g], blockers=gate_blockers(p, g), req=p.gate_requirements(g), **p.gate_status(g))
|
||||
for g in p.scope()]
|
||||
for r in rows:
|
||||
r["state"] = job_state(r["job"])
|
||||
batch = batch_state(p)
|
||||
# #34-5 (2026-10-20): a batch that did not finish must not look like a finished one -- say what stopped it, which results are older than the run,
|
||||
# and show the partial results that were computed before it stopped (never used for approval or the report)
|
||||
aborted = bool(batch) and batch["state"] in ("중단", "중단됨")
|
||||
since = (batch or {}).get("started") or (batch or {}).get("at") or ""
|
||||
for r in rows:
|
||||
r["older"] = bool(aborted and r["report"] and (r["report_at"] or "") < since)
|
||||
r["show_partial"] = bool(r["partial"] and (aborted or r["state"] in ("오류", "중단됨")))
|
||||
running = any(r["state"] in ("대기", "실행 중") for r in rows) or (batch or {}).get("state") == "실행 중"
|
||||
return render_template("gates.html", p=p, rows=rows, running=running, batch=batch, aborted=aborted,
|
||||
todo=[g for g in p.scope() if not p.gate_status(g)["approved"]],
|
||||
out_of_scope=[g for g in gate_ids() if g not in p.scope()])
|
||||
|
||||
|
||||
@app.post("/pier/<name>/report")
|
||||
def report_make(name):
|
||||
p = pier_or_404(name)
|
||||
if not any(p.gate_status(g)["report"] for g in p.scope()):
|
||||
flash("게이트 결과가 없습니다. 게이트를 먼저 실행하세요.", "fail")
|
||||
return redirect(url_for("gates", name=name))
|
||||
try:
|
||||
stale = {g for g in p.scope() if p.gate_status(g)["stale"]}
|
||||
out = report_files.write(p.dir, p.run_dir, request.form.get("by", "").strip(), stale,
|
||||
[g for g in gate_ids() if g not in p.scope()])
|
||||
except Exception as e:
|
||||
flash(f"보고서 생성 실패: {e}", "fail")
|
||||
return redirect(url_for("gates", name=name))
|
||||
c = out["check"]
|
||||
flash(f"보고서 초안 생성: {out['md'].name} · 자체 점검 {'통과' if c['ok'] else '미통과'} "
|
||||
f"(장 누락 {len(c['chapters_missing'])}, 재료표 {c['schedule_rows']}/{c['schedule_marks']}행"
|
||||
f"{' — 재료표를 읽지 못함' if c.get('schedule_unread') else ''}, 지적 {c['finding_rows']}건)",
|
||||
"ok" if c["ok"] else "fail")
|
||||
return redirect(url_for("gates", name=name))
|
||||
|
||||
|
||||
@app.route("/pier/<name>/report/<kind>")
|
||||
def report_get(name, kind):
|
||||
p = pier_or_404(name)
|
||||
files = sorted(p.run_dir.glob(f"보고서_*.{kind}")) if kind in ("md", "pdf") else []
|
||||
if not files:
|
||||
abort(404)
|
||||
return send_file(files[-1], as_attachment=True, download_name=files[-1].name)
|
||||
|
||||
|
||||
@app.route("/pier/<name>/download/<gate>")
|
||||
def download(name, gate):
|
||||
p = pier_or_404(name)
|
||||
f = p.run_dir / f"{gate}.3dm"
|
||||
rep = p.gate_status(gate)["report"] if gate in gate_ids() else None
|
||||
if not rep or not rep.get("model") or not f.exists(): # batch run: only G5 is saved
|
||||
abort(404)
|
||||
return send_file(f, as_attachment=True, download_name=f"{p.name}_{gate}.3dm")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="127.0.0.1", port=5000, debug=False)
|
||||
Binary file not shown.
@@ -0,0 +1,69 @@
|
||||
/* Prism for the whole web app (stage D, 2026-10-19): app shell + the plain HTML elements of the older screens, written with the tokens of
|
||||
prism.css (generated from the design mockup). The stage-B/C screens use the scoped .prism components on top of this. Decisions UX1-UX6:
|
||||
Prism replaces R8, status colours are Prism's with marks and words (never colour alone), Pretendard is bundled, no dark theme test. */
|
||||
html { background: var(--prism-bg-canvas); }
|
||||
body { margin: 0; background: var(--prism-bg-canvas); color: var(--prism-text-body); font-family: var(--prism-font); font-size: var(--prism-type-body-sm);
|
||||
line-height: var(--prism-lh-body); font-variant-numeric: tabular-nums; }
|
||||
* { box-sizing: border-box; }
|
||||
a { color: var(--prism-text-heading); }
|
||||
:focus-visible { outline: var(--prism-focus-ring) solid var(--prism-comp-line-strong); outline-offset: var(--prism-focus-offset); }
|
||||
|
||||
/* shell */
|
||||
header.top { height: var(--prism-comp-header-height); background: var(--prism-bg-card); border-bottom: var(--prism-stroke-hairline) solid var(--prism-comp-line);
|
||||
display: flex; align-items: center; gap: var(--prism-space-300); padding: 0 var(--prism-space-300); position: sticky; top: 0; z-index: var(--prism-z-sticky); }
|
||||
header.top .brand { display: flex; align-items: center; gap: var(--prism-space-100); font-weight: 700; font-size: var(--prism-type-body-lg); color: var(--prism-text-heading);
|
||||
text-decoration: none; letter-spacing: -0.01em; }
|
||||
header.top .brand i { width: 0.7rem; height: 0.7rem; background: var(--prism-text-accent); transform: rotate(45deg); display: block; }
|
||||
header.top nav { display: flex; align-items: center; gap: var(--prism-space-050); flex-wrap: wrap; }
|
||||
header.top nav a { display: inline-flex; align-items: center; min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-150); border-radius: var(--prism-radius-sm);
|
||||
text-decoration: none; font-weight: 500; color: var(--prism-text-body); }
|
||||
header.top nav a.pier { font-weight: 700; color: var(--prism-text-heading); }
|
||||
header.top nav a:hover { background: var(--prism-bg-canvas); }
|
||||
header.top .sep { color: var(--prism-text-subtle); }
|
||||
main.page { max-width: 80rem; margin: 0 auto; padding: var(--prism-space-300) var(--prism-space-300) var(--prism-space-600); }
|
||||
main.page > h1, main.page h1 { font-size: var(--prism-type-title); line-height: var(--prism-lh-title); color: var(--prism-text-heading); font-weight: 700; margin: var(--prism-space-100) 0 var(--prism-space-200); }
|
||||
main.page h2 { font-size: var(--prism-type-title-sm); line-height: var(--prism-lh-title); font-weight: 500; color: var(--prism-text-heading); margin: var(--prism-space-400) 0 var(--prism-space-150); }
|
||||
main.page h3 { font-size: var(--prism-type-body-lg); font-weight: 700; color: var(--prism-text-heading); margin: var(--prism-space-300) 0 var(--prism-space-100); }
|
||||
main.page p { max-width: 62rem; margin: var(--prism-space-100) 0; }
|
||||
.muted { color: var(--prism-text-subtle); }
|
||||
|
||||
/* status words of the older screens (always words, never colour alone) */
|
||||
main.page .ok, main.page .warn, main.page .fail { font-weight: 700; }
|
||||
main.page .ok { color: var(--prism-comp-state-good-fg); }
|
||||
main.page .warn { color: var(--prism-comp-state-warn-fg); }
|
||||
main.page .fail { color: var(--prism-comp-state-bad-fg); }
|
||||
.flash { border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); padding: var(--prism-space-150) var(--prism-space-200);
|
||||
margin: var(--prism-space-150) 0; background: var(--prism-bg-card); font-weight: 500; }
|
||||
.flash.ok { border-color: var(--prism-comp-state-good-fg); background: var(--prism-comp-state-good-bg); color: var(--prism-comp-state-good-fg); }
|
||||
.flash.fail { border-color: var(--prism-comp-state-bad-fg); background: var(--prism-comp-state-bad-bg); color: var(--prism-comp-state-bad-fg); border-width: var(--prism-stroke-bold); }
|
||||
.flash.warn { border-color: var(--prism-comp-state-warn-fg); background: var(--prism-comp-state-warn-bg); color: var(--prism-comp-state-warn-fg); }
|
||||
|
||||
/* tables of the older screens */
|
||||
main.page table { border-collapse: collapse; margin: var(--prism-space-100) 0; background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line);
|
||||
border-radius: var(--prism-radius-sm); font-size: var(--prism-type-body-sm); }
|
||||
main.page th, main.page td { padding: var(--prism-space-100) var(--prism-space-150); border-bottom: var(--prism-stroke-hairline) solid var(--prism-comp-line);
|
||||
text-align: left; vertical-align: top; }
|
||||
main.page th { background: var(--prism-comp-table-head-bg); font-weight: 700; white-space: nowrap; border-bottom: var(--prism-stroke-regular) solid var(--prism-comp-line); }
|
||||
main.page .wrap { overflow-x: auto; max-width: 100%; }
|
||||
main.page details { margin: var(--prism-space-100) 0; }
|
||||
main.page summary { cursor: pointer; min-height: var(--prism-comp-hit-min); display: flex; align-items: center; font-weight: 500; }
|
||||
main.page code { font-family: var(--prism-font); font-size: var(--prism-type-caption); background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line);
|
||||
border-radius: var(--prism-radius-sm); padding: 0 var(--prism-space-050); }
|
||||
main.page pre { background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); padding: var(--prism-space-150);
|
||||
white-space: pre-wrap; font-size: var(--prism-type-caption); }
|
||||
|
||||
/* buttons and fields of the older screens (the .prism blocks keep their own .btn / .field) */
|
||||
main.page button:not(.btn) { min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-200); border-radius: var(--prism-radius-sm); font: inherit; font-weight: 500;
|
||||
cursor: pointer; border: var(--prism-stroke-regular) solid var(--prism-text-heading); background: var(--prism-bg-card); color: var(--prism-text-heading); }
|
||||
main.page button:not(.btn):disabled { border-style: dashed; color: var(--prism-text-subtle); border-color: var(--prism-comp-line); cursor: not-allowed; }
|
||||
main.page input[type=text], main.page input[type=file], main.page select { min-height: var(--prism-comp-hit-min); padding: var(--prism-space-100) var(--prism-space-150); font: inherit;
|
||||
color: var(--prism-text-body); background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); }
|
||||
main.page input[type=checkbox] { width: 1.25rem; height: 1.25rem; accent-color: var(--prism-text-heading); }
|
||||
main.page textarea { width: 100%; height: 30rem; font-family: ui-monospace, Consolas, monospace; font-size: var(--prism-type-caption); line-height: 1.6; padding: var(--prism-space-150);
|
||||
background: var(--prism-bg-card); color: var(--prism-text-body); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); }
|
||||
main.page form { margin: var(--prism-space-100) 0; }
|
||||
main.page ul { padding-left: var(--prism-space-300); }
|
||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
||||
@media (max-width: 48rem) { header.top { height: auto; flex-wrap: wrap; padding: var(--prism-space-100) var(--prism-space-200); } main.page { padding: var(--prism-space-200); } }
|
||||
.skip:focus { left: var(--prism-space-200) !important; top: var(--prism-space-100); z-index: var(--prism-z-modal); background: var(--prism-bg-card); padding: var(--prism-space-100) var(--prism-space-200);
|
||||
border: var(--prism-stroke-regular) solid var(--prism-comp-line-strong); border-radius: var(--prism-radius-sm); }
|
||||
@@ -0,0 +1,136 @@
|
||||
/* generated by tools/make_prism_css.py from DOC/01_기획/웹앱디자인시안/시안/pier.css — do not edit by hand */
|
||||
@font-face { font-family: "Pretendard"; src: url("fonts/PretendardVariable.woff2") format("woff2-variations"); font-weight: 45 920; font-display: swap; }
|
||||
/* PierProof — Prism app profile tokens (design.md 3.2 / 3.3) and components. Literals live only in :root. */
|
||||
:root {
|
||||
--prism-bg-canvas: #F4F3F0; --prism-bg-card: #FFFFFF;
|
||||
--prism-text-body: #474235; --prism-text-heading: #1F524B; --prism-text-subtle: #474235; --prism-text-accent: #D05C26;
|
||||
--prism-border-default: #474235; --prism-border-strong: #1F524B;
|
||||
--prism-status-do: #005A4E; --prism-status-do-bg: #DCEAE6;
|
||||
--prism-status-dont: #8F2A11; --prism-status-dont-bg: #F6DFDA;
|
||||
--prism-status-caution: #6B4A00; --prism-status-caution-bg: #F5E7CC;
|
||||
--prism-status-tip: #0A4E86; --prism-status-tip-bg: #DDE8F3;
|
||||
--prism-space-025: 0.125rem; --prism-space-050: 0.25rem; --prism-space-100: 0.5rem; --prism-space-150: 0.75rem;
|
||||
--prism-space-200: 1rem; --prism-space-300: 1.5rem; --prism-space-400: 2rem; --prism-space-600: 3rem; --prism-space-800: 4rem;
|
||||
--prism-type-caption: 0.8125rem; --prism-lh-caption: 1.55; --prism-type-body-sm: 0.875rem; --prism-lh-body: 1.7;
|
||||
--prism-type-body: 1rem; --prism-type-body-lg: 1.125rem; --prism-type-title-sm: 1.25rem; --prism-lh-title: 1.5;
|
||||
--prism-type-title: 1.625rem; --prism-type-title-lg: 2rem;
|
||||
--prism-radius-sm: 4px; --prism-radius-md: 8px; --prism-radius-lg: 12px; --prism-radius-pill: 9999px;
|
||||
--prism-stroke-hairline: 1px; --prism-stroke-regular: 2px; --prism-stroke-bold: 3px;
|
||||
--prism-z-raised: 100; --prism-z-sticky: 200; --prism-z-scrim: 400; --prism-z-modal: 500;
|
||||
--prism-scrim: rgba(0, 0, 0, 0.32);
|
||||
--prism-motion-fast: 100ms; --prism-ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
--prism-focus-ring: 3px; --prism-focus-offset: 2px; --prism-measure-max: 38em;
|
||||
--prism-font: Pretendard, -apple-system, BlinkMacSystemFont, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
|
||||
|
||||
--prism-comp-line: var(--prism-border-default); --prism-comp-line-strong: var(--prism-border-strong);
|
||||
--prism-comp-state-good-fg: var(--prism-status-do); --prism-comp-state-good-bg: var(--prism-status-do-bg);
|
||||
--prism-comp-state-bad-fg: var(--prism-status-dont); --prism-comp-state-bad-bg: var(--prism-status-dont-bg);
|
||||
--prism-comp-state-warn-fg: var(--prism-status-caution); --prism-comp-state-warn-bg: var(--prism-status-caution-bg);
|
||||
--prism-comp-state-info-fg: var(--prism-status-tip); --prism-comp-state-info-bg: var(--prism-status-tip-bg);
|
||||
--prism-comp-rail-width: 15rem; --prism-comp-header-height: 3.5rem; --prism-comp-hit-min: 2.75rem;
|
||||
--prism-comp-table-head-bg: var(--prism-bg-canvas);
|
||||
--prism-comp-on-emphasis: var(--prism-bg-card);
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--prism-bg-canvas: #111111; --prism-bg-card: #111111; --prism-text-body: #F4F3F0; --prism-text-heading: #F4F3F0;
|
||||
--prism-text-subtle: #D3D3D3; --prism-border-default: #D3D3D3; --prism-border-strong: #F4F3F0;
|
||||
--prism-status-do: #DCEAE6; --prism-status-do-bg: #005A4E; --prism-status-dont: #F6DFDA; --prism-status-dont-bg: #8F2A11;
|
||||
--prism-status-caution: #F5E7CC; --prism-status-caution-bg: #6B4A00; --prism-status-tip: #DDE8F3; --prism-status-tip-bg: #0A4E86;
|
||||
}
|
||||
.prism { font-family: var(--prism-font); font-size: var(--prism-type-body-sm); line-height: var(--prism-lh-body); color: var(--prism-text-body); font-variant-numeric: tabular-nums; margin: var(--prism-space-200) 0; }
|
||||
.prism * { box-sizing: border-box; }
|
||||
.prism a { color: var(--prism-text-heading); }
|
||||
.prism h1, .prism h2, .prism h3, .prism p, .prism dl, .prism dd, .prism ol, .prism ul { margin: 0; padding: 0; }
|
||||
.prism h2 { font-size: var(--prism-type-title-sm); line-height: var(--prism-lh-title); font-weight: 500; color: var(--prism-text-heading); }
|
||||
.prism h3 { font-size: var(--prism-type-body-lg); font-weight: 700; color: var(--prism-text-heading); }
|
||||
.prism textarea { height: auto; min-height: 5rem; width: 100%; font-family: var(--prism-font); }
|
||||
.prism :focus-visible { outline: var(--prism-focus-ring) solid var(--prism-comp-line-strong); outline-offset: var(--prism-focus-offset); }
|
||||
.prism .eyebrow {font-size: var(--prism-type-caption); font-weight: 500; letter-spacing: 0.06em;}
|
||||
.prism .lead {max-width: var(--prism-measure-max); font-size: var(--prism-type-body);}
|
||||
.prism .cap {font-size: var(--prism-type-caption);}
|
||||
.prism .muted {color: var(--prism-text-subtle);}
|
||||
.prism .row {display: flex; gap: var(--prism-space-200); align-items: center; flex-wrap: wrap;}
|
||||
.prism .row.end {justify-content: flex-end;}
|
||||
.prism .stack {display: flex; flex-direction: column; gap: var(--prism-space-200);}
|
||||
.prism .grid3 {display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--prism-space-300);}
|
||||
.prism .grid2 {display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--prism-space-300);}
|
||||
.prism .split {display: grid; grid-template-columns: minmax(0, 4fr) minmax(0, 8fr); gap: var(--prism-space-300); align-items: start;}
|
||||
.prism .grow {flex: 1;}
|
||||
.prism .scope {background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); padding: var(--prism-space-150) var(--prism-space-200);
|
||||
display: flex; gap: var(--prism-space-400); font-size: var(--prism-type-body-sm); border-radius: var(--prism-radius-sm);}
|
||||
.prism .scope b {font-weight: 700;}
|
||||
.prism .card {background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-md); padding: var(--prism-space-200);
|
||||
display: flex; flex-direction: column; gap: var(--prism-space-150);}
|
||||
.prism .card.sunken {background: var(--prism-bg-canvas);}
|
||||
.prism .strip {background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line-strong); border-radius: var(--prism-radius-md);
|
||||
padding: var(--prism-space-200) var(--prism-space-300); display: flex; flex-direction: column; gap: var(--prism-space-150);}
|
||||
.prism .strip .concl {font-size: var(--prism-type-body-lg); font-weight: 700; color: var(--prism-text-heading); max-width: none;}
|
||||
.prism .counts {display: flex; gap: var(--prism-space-200); flex-wrap: wrap; align-items: stretch;}
|
||||
.prism .cnt {display: flex; align-items: baseline; gap: var(--prism-space-100); min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-200);
|
||||
background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); font: inherit; color: inherit; cursor: pointer;}
|
||||
.prism .cnt b {font-size: var(--prism-type-title); font-weight: 700; line-height: 1.2;}
|
||||
.prism .cnt.sel {background: var(--prism-text-heading); color: var(--prism-comp-on-emphasis); border-color: var(--prism-text-heading);}
|
||||
.prism .banner {border: var(--prism-stroke-regular) solid var(--prism-comp-state-warn-fg); background: var(--prism-comp-state-warn-bg); color: var(--prism-comp-state-warn-fg);
|
||||
border-radius: var(--prism-radius-sm); padding: var(--prism-space-150) var(--prism-space-200); display: flex; gap: var(--prism-space-150); align-items: flex-start;}
|
||||
.prism .banner b {font-weight: 700;}
|
||||
.prism .banner.tip {border-color: var(--prism-comp-state-info-fg); background: var(--prism-comp-state-info-bg); color: var(--prism-comp-state-info-fg);}
|
||||
.prism .banner.dont {border-color: var(--prism-comp-state-bad-fg); background: var(--prism-comp-state-bad-bg); color: var(--prism-comp-state-bad-fg);}
|
||||
.prism .banner.good {border-color: var(--prism-comp-state-good-fg); background: var(--prism-comp-state-good-bg); color: var(--prism-comp-state-good-fg);}
|
||||
.prism .banner .m {flex: none;}
|
||||
.prism .b {display: inline-flex; align-items: center; gap: var(--prism-space-050); padding: var(--prism-space-025) var(--prism-space-100); white-space: nowrap;
|
||||
font-size: var(--prism-type-caption); font-weight: 500; line-height: 1.5; border: var(--prism-stroke-regular) solid var(--prism-comp-line);
|
||||
border-radius: var(--prism-radius-sm); color: var(--prism-text-body); background: transparent;}
|
||||
.prism .b.good {color: var(--prism-comp-state-good-fg); background: var(--prism-comp-state-good-bg); border-color: var(--prism-comp-state-good-fg);}
|
||||
.prism .b.warn {color: var(--prism-comp-state-warn-fg); background: var(--prism-comp-state-warn-bg); border-color: var(--prism-comp-state-warn-fg);}
|
||||
.prism .b.info {color: var(--prism-comp-state-info-fg); background: var(--prism-comp-state-info-bg); border-color: var(--prism-comp-state-info-fg);}
|
||||
.prism .b.bad {color: var(--prism-comp-state-bad-fg); background: var(--prism-comp-state-bad-bg); border-color: var(--prism-comp-state-bad-fg); border-width: var(--prism-stroke-bold);}
|
||||
.prism .b.dash {border-style: dashed;}
|
||||
.prism .b.fixed {color: var(--prism-comp-on-emphasis); background: var(--prism-text-heading); border-color: var(--prism-text-heading);}
|
||||
.prism .b .m {font-size: 0.7em;}
|
||||
.prism .btn {min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-200); border-radius: var(--prism-radius-sm); font: inherit; font-weight: 500;
|
||||
cursor: pointer; border: var(--prism-stroke-regular) solid var(--prism-text-heading); background: var(--prism-bg-card); color: var(--prism-text-heading);
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: var(--prism-space-100); text-decoration: none; white-space: nowrap;}
|
||||
.prism .btn.primary {background: var(--prism-text-heading); color: var(--prism-comp-on-emphasis);}
|
||||
.prism .btn.quiet {border-color: transparent; background: transparent; text-decoration: underline;}
|
||||
.prism .btn.danger {border-color: var(--prism-comp-state-bad-fg); color: var(--prism-comp-state-bad-fg);}
|
||||
.prism .btn.sm {min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-150); font-size: var(--prism-type-caption);}
|
||||
.prism .field {display: flex; flex-direction: column; gap: var(--prism-space-050);}
|
||||
.prism .field label, .prism .lbl {font-weight: 700; font-size: var(--prism-type-body-sm);}
|
||||
.prism .req {font-weight: 500; font-size: var(--prism-type-caption); margin-left: var(--prism-space-100);}
|
||||
.prism .field input, .prism .field select, .prism .field textarea {min-height: var(--prism-comp-hit-min); padding: var(--prism-space-100) var(--prism-space-150); font: inherit;
|
||||
color: var(--prism-text-body); background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm);}
|
||||
.prism .field textarea {font-size: var(--prism-type-body-sm); line-height: 1.6; resize: none;}
|
||||
.prism .field .help {font-size: var(--prism-type-caption);}
|
||||
.prism .field.bad input, .prism .field.bad textarea {border-color: var(--prism-comp-state-bad-fg); border-width: var(--prism-stroke-bold);}
|
||||
.prism .err {color: var(--prism-comp-state-bad-fg); font-size: var(--prism-type-caption); font-weight: 500; display: flex; gap: var(--prism-space-050);}
|
||||
.prism .choice {display: flex; gap: var(--prism-space-300); align-items: center;}
|
||||
.prism .choice label {display: inline-flex; align-items: center; gap: var(--prism-space-100); min-height: var(--prism-comp-hit-min); font-weight: 500;}
|
||||
.prism .choice input {width: 1.25rem; height: 1.25rem; accent-color: var(--prism-text-heading);}
|
||||
.prism .tw {overflow-x: auto; background: var(--prism-bg-card); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm);}
|
||||
.prism table.t {width: 100%; border-collapse: collapse; font-size: var(--prism-type-body-sm);}
|
||||
.prism table.t caption {text-align: left; font-weight: 700; padding: var(--prism-space-100) var(--prism-space-150); color: var(--prism-text-heading);}
|
||||
.prism table.t th, .prism table.t td {padding: var(--prism-space-100) var(--prism-space-150); border-bottom: var(--prism-stroke-hairline) solid var(--prism-comp-line); text-align: left; vertical-align: middle;}
|
||||
.prism table.t thead th {position: sticky; top: 0; background: var(--prism-comp-table-head-bg); font-weight: 700; white-space: nowrap; border-bottom: var(--prism-stroke-regular) solid var(--prism-comp-line);}
|
||||
.prism table.t td.num, .prism table.t th.num {text-align: right;}
|
||||
.prism table.t tr.sel td {background: var(--prism-comp-table-head-bg);}
|
||||
.prism table.t tr.sep td {background: var(--prism-comp-table-head-bg); font-weight: 700; color: var(--prism-text-heading);}
|
||||
.prism table.t td a {font-weight: 700;}
|
||||
.prism .chip {display: inline-block; border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm);
|
||||
padding: 0 var(--prism-space-100); font-size: var(--prism-type-caption); white-space: nowrap; background: var(--prism-bg-card);}
|
||||
.prism dl.kv {display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--prism-space-100) var(--prism-space-300); font-size: var(--prism-type-body-sm);}
|
||||
.prism dl.kv dt {font-weight: 700;}
|
||||
.prism .pbar {height: 0.75rem; border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-pill); overflow: hidden; background: var(--prism-bg-card);}
|
||||
.prism .pbar > i {display: block; height: 100%; background: var(--prism-text-heading);}
|
||||
.prism .list {list-style: none; display: flex; flex-direction: column;}
|
||||
.prism .list li {display: flex; align-items: center; gap: var(--prism-space-100); min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-150); border-bottom: var(--prism-stroke-hairline) solid var(--prism-comp-line);}
|
||||
.prism .list li a {flex: 1; font-weight: 500; text-decoration: none;}
|
||||
.prism .list li.cur {background: var(--prism-bg-canvas);}
|
||||
.prism .list li.cur a {font-weight: 700;}
|
||||
.prism .scroll {max-height: 18rem; overflow-y: auto; border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm);}
|
||||
.prism .drawing {background: var(--prism-bg-card); border: var(--prism-stroke-regular) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm); display: block; width: 100%; height: auto;}
|
||||
.prism .toc a {display: flex; align-items: center; min-height: var(--prism-comp-hit-min); padding: 0 var(--prism-space-150); text-decoration: none; font-weight: 500; border-radius: var(--prism-radius-sm);}
|
||||
.prism .toc a.cur {background: var(--prism-bg-canvas); font-weight: 700;}
|
||||
.prism .toc ol {list-style: none;}
|
||||
.prism .num {font-variant-numeric: tabular-nums;}
|
||||
.prism pre.code {margin: 0; background: var(--prism-bg-canvas); border: var(--prism-stroke-hairline) solid var(--prism-comp-line); border-radius: var(--prism-radius-sm);
|
||||
padding: var(--prism-space-150); font-family: var(--prism-font); font-size: var(--prism-type-caption); line-height: 1.6; white-space: pre-wrap;}
|
||||
@@ -0,0 +1,494 @@
|
||||
"""Pier data store for the web app: params / expected / confirmations / drawings / gate runs per pier folder.
|
||||
|
||||
data/<pier>/pier.json name, type (T형 only), created
|
||||
data/<pier>/params.json generator params (pier-T/1)
|
||||
data/<pier>/expected.json independent drawing values for the checkers
|
||||
data/<pier>/confirm.json section confirmations {"params:footing": {"sha", "by", "at"}}
|
||||
data/<pier>/drawings/ uploaded DWG, converted DXF, texts.json, audit.json
|
||||
data/<pier>/runs/current/ gate models and results; approvals.json
|
||||
data/_템플릿/<template>/ protected templates (params, expected, template.json); not listed as piers,
|
||||
only written by tools/make_template.py (2026-10-13)
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path(__file__).resolve().parents[1]
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
from engine import anchors, reader, variability, verify # noqa: E402
|
||||
|
||||
DATA = Path(os.environ.get("PIERAPP_DATA", APP / "data"))
|
||||
PIER_TYPES = ["T형"] # first target only; other types are rejected
|
||||
|
||||
COMMON = [("params", "pier_id"), ("params", "rebar_marks"), ("params", "display"),
|
||||
("expected", "pier_id"), ("expected", "tol_mm"), ("expected", "rebar_palette"),
|
||||
("expected", "planned_fail"),
|
||||
# run settings: they change what is built and how it is checked, so they need confirmation too
|
||||
("params", "scope"), ("params", "builders"), ("expected", "checkers"), ("expected", "negative")]
|
||||
OPTIONAL = {("params", "piles"), # a pier on a spread footing has no piles (Ulsan P1)
|
||||
("params", "pile"), ("params", "shaft_rebar"), # single drilled shaft form only (Ramp-A P1, 2026-10-14)
|
||||
# no footing / lean concrete / footing rebar in the drilled shaft form: absent -> not required (Ramp-A G1 / G3
|
||||
# were blocked as "없음" in the app, found 2026-10-14)
|
||||
("params", "lean_concrete"), ("params", "footing"), ("params", "footing_rebar"),
|
||||
("params", "scope"), ("params", "builders"), # absent -> defaults (Ungok)
|
||||
("expected", "checkers"), ("expected", "negative")}
|
||||
|
||||
|
||||
def is_info(key):
|
||||
"""Reference-only section (schema tag, source note, decision record): not used by any gate."""
|
||||
return key.startswith("_") or key.endswith("_src") or key.startswith("hitl_decisions")
|
||||
GATE_SECTIONS = {
|
||||
"G1": [("params", k) for k in ("lean_concrete", "footing", "column", "coping", "piles", "pile", "bearing_seats",
|
||||
"materials")] + [("expected", "G1")],
|
||||
"G2": [("params", "column_rebar"), ("expected", "G2")],
|
||||
"G3": [("params", "footing_rebar"), ("params", "shaft_rebar"), ("expected", "G3")],
|
||||
"G4": [("params", "coping_rebar"), ("expected", "G4")],
|
||||
"G5": [("params", "sole_plates"), ("expected", "G5")],
|
||||
}
|
||||
|
||||
|
||||
def now():
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def rjson(p, default=None):
|
||||
"""Read JSON; retries briefly because on Windows a read during wjson's os.replace raises PermissionError."""
|
||||
p = Path(p)
|
||||
for i in range(20):
|
||||
if not p.exists():
|
||||
return default
|
||||
try:
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
except PermissionError:
|
||||
time.sleep(0.02 * (i + 1))
|
||||
return json.loads(p.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def wjson(p, obj):
|
||||
"""Atomic write (temp file + replace): the gate thread writes job files while page requests read them;
|
||||
a plain write_text let a reader see an empty file (JSONDecodeError, E2E test 2026-10-12)."""
|
||||
p = Path(p)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = p.with_name(f".{p.name}.{threading.get_ident()}.tmp")
|
||||
tmp.write_text(json.dumps(obj, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
for i in range(20):
|
||||
try:
|
||||
os.replace(tmp, p)
|
||||
return
|
||||
except PermissionError: # Windows: target briefly open by a reader
|
||||
time.sleep(0.05 * (i + 1))
|
||||
os.replace(tmp, p)
|
||||
|
||||
|
||||
def get_field(docs, field):
|
||||
"""Numeric value at "params:coping.profile_xz.0.1" (list indices allowed), else None."""
|
||||
which, path = field.split(":", 1)
|
||||
x = docs[which]
|
||||
for p in path.split("."):
|
||||
if isinstance(x, list):
|
||||
x = x[int(p)] if p.isdigit() and int(p) < len(x) else None
|
||||
elif isinstance(x, dict):
|
||||
x = x.get(p)
|
||||
else:
|
||||
return None
|
||||
if x is None:
|
||||
return None
|
||||
return x if isinstance(x, (int, float)) and not isinstance(x, bool) else None
|
||||
|
||||
|
||||
def set_field(docs, field, value):
|
||||
which, path = field.split(":", 1)
|
||||
x, parts = docs[which], path.split(".")
|
||||
for p in parts[:-1]:
|
||||
x = x[int(p)] if isinstance(x, list) else x[p]
|
||||
last = parts[-1]
|
||||
value = int(value) if float(value).is_integer() and isinstance(x[int(last) if isinstance(x, list) else last], int) else float(value)
|
||||
if isinstance(x, list):
|
||||
x[int(last)] = value
|
||||
else:
|
||||
x[last] = value
|
||||
|
||||
|
||||
def sha(obj):
|
||||
return hashlib.sha256(json.dumps(obj, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
CHAIN_RE = re.compile(r"^(?:(\d+)\s*@\s*)?(-?\d+(?:\.\d+)?)$")
|
||||
|
||||
|
||||
def chain(text):
|
||||
"""Dimension chain -> positions: 'start, inc, n@pitch, ...' (first value = start, then increments; 'n@p' = n
|
||||
increments of p, as '22@120' on the drawings). '-4575, 200, 2@125' -> [-4575, -4375, -4250, -4125]."""
|
||||
toks = [t.strip() for t in str(text).split(",") if t.strip()]
|
||||
if len(toks) < 1:
|
||||
raise ValueError("치수 사슬이 비어 있습니다")
|
||||
out = []
|
||||
for i, t in enumerate(toks):
|
||||
m = CHAIN_RE.match(t)
|
||||
if not m or (i == 0 and m.group(1)):
|
||||
raise ValueError(f"치수 사슬 형식 오류: '{t}' (첫 값은 시작 위치, 이후는 증분 또는 n@간격)")
|
||||
n, v = int(m.group(1) or 1), float(m.group(2))
|
||||
if n < 1:
|
||||
raise ValueError(f"치수 사슬 형식 오류: '{t}' (반복 횟수는 1 이상)")
|
||||
for _ in range(n):
|
||||
out.append(v if i == 0 else out[-1] + v)
|
||||
fl = any("." in t for t in toks)
|
||||
return [round(x, 6) if fl else int(round(x)) for x in out]
|
||||
|
||||
|
||||
def expand_chains(v):
|
||||
"""Replace every {"chain": "..."} in an edited section by its position list (OPEN #19 A5). The saved params
|
||||
hold plain lists only, so builders, checkers and fingerprints are unchanged."""
|
||||
if isinstance(v, dict):
|
||||
if set(v) == {"chain"}:
|
||||
return chain(v["chain"])
|
||||
return {k: expand_chains(x) for k, x in v.items()}
|
||||
if isinstance(v, list):
|
||||
return [expand_chains(x) for x in v]
|
||||
return v
|
||||
|
||||
|
||||
def safe_name(name):
|
||||
"""Folder / file name: keep Korean and alphanumerics, drop path separators and control characters."""
|
||||
name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "", name).strip().strip(".")
|
||||
if not name:
|
||||
raise ValueError("이름이 비어 있습니다")
|
||||
return name
|
||||
|
||||
|
||||
class Pier:
|
||||
def __init__(self, name):
|
||||
self.name = safe_name(name)
|
||||
self.dir = DATA / self.name
|
||||
if not (self.dir / "params.json").exists():
|
||||
raise FileNotFoundError(name)
|
||||
|
||||
# ---- files
|
||||
@property
|
||||
def info(self):
|
||||
return rjson(self.dir / "pier.json", {"name": self.name, "type": "T형", "created": "-"})
|
||||
|
||||
def load(self, which):
|
||||
return rjson(self.dir / f"{which}.json")
|
||||
|
||||
def save(self, which, obj):
|
||||
wjson(self.dir / f"{which}.json", obj)
|
||||
|
||||
def confirmations(self):
|
||||
return rjson(self.dir / "confirm.json", {})
|
||||
|
||||
# ---- sections
|
||||
def sections(self):
|
||||
out = []
|
||||
for which in ("params", "expected"):
|
||||
d = self.load(which)
|
||||
for k, v in d.items():
|
||||
out.append({"file": which, "key": k, "sha": sha(v), "kind": type(v).__name__})
|
||||
return out
|
||||
|
||||
def variability(self):
|
||||
"""Variability classes of this pier's bundles (own variability.json, else classified from the current values)."""
|
||||
v = rjson(self.dir / "variability.json")
|
||||
if v and v.get("sections"):
|
||||
return v
|
||||
return variability.build(self.load("params"), self.load("expected"))
|
||||
|
||||
def section_meta(self, which, key):
|
||||
"""Grade / source / fixed fields of a bundle and whether it is still the template's original value.
|
||||
origin is None for piers created before the origin record existed (unknown, not claimed either way)."""
|
||||
d = self.load(which)
|
||||
m = dict(self.variability()["sections"].get(f"{which}:{key}") or variability.classify(which, key, d.get(key)))
|
||||
if which == "params" and key in ("coping_rebar", "footing_rebar") and key in d:
|
||||
m = variability.classify(which, key, d[key]) # V2 / V3 follows the current content (explicit bars list)
|
||||
o = (self.info.get("origin") or {}).get("sha", {}).get(f"{which}:{key}")
|
||||
m["origin"] = None if o is None or key not in d else (o == sha(d[key]))
|
||||
return m
|
||||
|
||||
# ---- anchors (stage C, 2026-10-19): out-of-table dimension candidates
|
||||
def anchor_rows(self):
|
||||
"""Candidates followed from the pier's anchors in its drawing: ({rows}, missing, meta). rows: one per anchored field that was found again;
|
||||
missing: anchored fields without a candidate. Nothing is written."""
|
||||
anc, texts = rjson(self.dir / "anchors.json"), self.texts()
|
||||
if not anc or not texts:
|
||||
return [], [], {"anchors": bool(anc), "texts": bool(texts), "coverage": None, "self_source": False}
|
||||
own = [a.get("source_sha256") for a in rjson(self.drawings / "audit.json", []) if a.get("source_sha256")]
|
||||
cand = anchors.follow(anc, texts, own)
|
||||
docs = {"params": self.load("params"), "expected": self.load("expected")}
|
||||
tabs = reader.table_candidates(texts)
|
||||
anchors.cross_check(cand, anchors.table_values(reader.proposals(reader.field_candidates(tabs), docs["params"], docs["expected"])) if tabs else {})
|
||||
rows, missing = [], []
|
||||
for field in anc["fields"]:
|
||||
cur = get_field(docs, field)
|
||||
if cur is None:
|
||||
continue
|
||||
c = cand.get(field)
|
||||
if not c:
|
||||
missing.append(field)
|
||||
continue
|
||||
which, path = field.split(":", 1)
|
||||
rows.append(dict(c, field=field, file=which, path=path, current=cur, same=abs(abs(cur) - abs(c["value"])) < 1.5 and (cur >= 0) == (c["value"] >= 0)))
|
||||
cov = rows[0]["coverage"] if rows else None
|
||||
return rows, missing, {"anchors": True, "texts": True, "coverage": cov, "self_source": bool(rows and rows[0]["self_source"])}
|
||||
|
||||
# ---- app check (stage B): autocheck.json is separate from confirm.json; the app never writes confirmations
|
||||
def autocheck(self):
|
||||
return rjson(self.dir / "autocheck.json", {})
|
||||
|
||||
def _autocheck_basis(self):
|
||||
return sha([self.inputs_sha(), rjson(self.drawings / "audit.json", []), verify.VERSION, len(self.texts()), anchors.VERSION,
|
||||
sha(rjson(self.dir / "anchors.json", {}).get("fields", {}))])
|
||||
|
||||
def autocheck_current(self):
|
||||
a = self.autocheck()
|
||||
return bool(a) and a.get("basis") == self._autocheck_basis()
|
||||
|
||||
def run_autocheck(self, force=False):
|
||||
"""Run the app check of every bundle (no gate, no Rhino) and store the result; skipped when nothing changed."""
|
||||
if not force and self.autocheck_current():
|
||||
return self.autocheck()
|
||||
P0, E0 = self.load("params"), self.load("expected")
|
||||
meta = {f"{w}:{k}": self.section_meta(w, k) for w, d in (("params", P0), ("expected", E0)) for k in d}
|
||||
cur_fixed = {b: variability.fixed_view((P0 if b.startswith("params:") else E0)[b.split(":", 1)[1]], m["fixed_fields"])
|
||||
for b, m in meta.items() if m["fixed_fields"]}
|
||||
tvals = {} # fixed bundles: value in every protected template (for piers without origin record)
|
||||
for t in templates():
|
||||
td = template_dir(t["name"])
|
||||
for w in ("params", "expected"):
|
||||
tdoc = rjson(td / f"{w}.json", {})
|
||||
for k, v in tdoc.items():
|
||||
if meta.get(f"{w}:{k}", {}).get("grade") == "F":
|
||||
tvals.setdefault(f"{w}:{k}", {})[t["name"]] = json.dumps(v, sort_keys=True, ensure_ascii=False)
|
||||
arows, amiss, _ameta = self.anchor_rows()
|
||||
res = verify.verify(P0, E0, self.texts(), meta, (self.info.get("origin") or {}).get("fixed"),
|
||||
rjson(self.dir / "edit_log.json", []), cur_fixed, tvals, (arows, amiss),
|
||||
rjson(self.dir / "origin_values.json", None))
|
||||
sections = {b: dict(r, sha=sha((P0 if b.startswith("params:") else E0)[b.split(":", 1)[1]])) for b, r in res.items()}
|
||||
out = {"version": verify.VERSION, "at": now(), "basis": self._autocheck_basis(), "sections": sections}
|
||||
wjson(self.dir / "autocheck.json", out)
|
||||
return out
|
||||
|
||||
def section_check(self, which, key):
|
||||
"""Stored app-check result of a bundle: {"st", "checks", "summary", "stale"}; st "?" when never checked."""
|
||||
r = (self.autocheck().get("sections") or {}).get(f"{which}:{key}")
|
||||
if not r:
|
||||
return {"st": "?", "checks": [], "summary": "앱 확인 전", "stale": False}
|
||||
d = self.load(which)
|
||||
return dict(r, stale=key not in d or r.get("sha") != sha(d[key]))
|
||||
|
||||
def section_state(self, which, key):
|
||||
d = self.load(which)
|
||||
if key not in d:
|
||||
return {"exists": False, "confirmed": False}
|
||||
c = self.confirmations().get(f"{which}:{key}")
|
||||
ok = bool(c) and c["sha"] == sha(d[key])
|
||||
return {"exists": True, "confirmed": ok, "by": c and c["by"], "at": c and c["at"], "why": c and c.get("why"),
|
||||
"app_st": c and c.get("app_st"), "stale": bool(c) and not ok, "info": is_info(key)}
|
||||
|
||||
def confirm(self, which, key, by, why="", app_st=""):
|
||||
"""Human confirmation record (stage B 2026-10-16: + the reason `why` and the app-check status at that time `app_st`)."""
|
||||
d = self.load(which)
|
||||
conf = self.confirmations()
|
||||
rec = {"sha": sha(d[key]), "by": by, "at": now()}
|
||||
if why:
|
||||
rec["why"] = why
|
||||
if app_st:
|
||||
rec["app_st"] = app_st
|
||||
conf[f"{which}:{key}"] = rec
|
||||
wjson(self.dir / "confirm.json", conf)
|
||||
|
||||
def log_fixed_edit(self, which, key, paths, reason):
|
||||
"""Record of an edit of fixed (F) fields with its reason (D3) # stage A 2026-10-16"""
|
||||
log = rjson(self.dir / "edit_log.json", [])
|
||||
log.append({"at": now(), "section": f"{which}:{key}", "fixed_fields": paths, "reason": reason})
|
||||
wjson(self.dir / "edit_log.json", log)
|
||||
|
||||
def set_section(self, which, key, value):
|
||||
value = expand_chains(value)
|
||||
d = self.load(which)
|
||||
if key in d and type(d[key]) is not type(value):
|
||||
raise ValueError(f"형식이 바뀌었습니다: {type(d[key]).__name__} → {type(value).__name__}")
|
||||
d[key] = value
|
||||
self.save(which, d)
|
||||
|
||||
def gate_requirements(self, gate):
|
||||
need = COMMON + GATE_SECTIONS[gate]
|
||||
D = {"params": self.load("params"), "expected": self.load("expected")}
|
||||
need = [(f, k) for f, k in need if not ((f, k) in OPTIONAL and k not in D[f])]
|
||||
return [dict(file=f, key=k, **self.section_state(f, k)) for f, k in need]
|
||||
|
||||
def scope(self):
|
||||
"""Gates modelled for this pier (params["scope"], default all five)."""
|
||||
sc = self.load("params").get("scope")
|
||||
return [g for g in ("G1", "G2", "G3", "G4", "G5") if not sc or g in sc]
|
||||
|
||||
def inputs_sha(self):
|
||||
return sha([self.load("params"), self.load("expected")])
|
||||
|
||||
# ---- drawings
|
||||
@property
|
||||
def drawings(self):
|
||||
return self.dir / "drawings"
|
||||
|
||||
def texts(self):
|
||||
return rjson(self.drawings / "texts.json", [])
|
||||
|
||||
# ---- gate runs
|
||||
@property
|
||||
def run_dir(self):
|
||||
return self.dir / "runs" / "current"
|
||||
|
||||
def approvals(self):
|
||||
return rjson(self.run_dir / "approvals.json", {})
|
||||
|
||||
def set_approval(self, gate, decision, by, note):
|
||||
a = self.approvals()
|
||||
rep = rjson(self.run_dir / f"{gate}_gate.json", {})
|
||||
a[gate] = {"decision": decision, "by": by, "note": note, "at": now(), "inputs_sha": rep.get("inputs_sha")}
|
||||
wjson(self.run_dir / "approvals.json", a)
|
||||
|
||||
def gate_status(self, gate):
|
||||
rep = rjson(self.run_dir / f"{gate}_gate.json")
|
||||
appr = self.approvals().get(gate)
|
||||
cur = self.inputs_sha()
|
||||
stale = bool(rep) and rep.get("inputs_sha") != cur
|
||||
approved = bool(appr) and appr["decision"] == "승인" and appr.get("inputs_sha") == cur and not stale
|
||||
return {"report": rep, "approval": appr, "stale": stale, "approved": approved,
|
||||
"approval_stale": bool(appr) and appr["decision"] == "승인" and not approved,
|
||||
"report_at": (rep or {}).get("at") or self._mtime(self.run_dir / f"{gate}_gate.json"),
|
||||
"partial": self.partial_result(gate),
|
||||
"job": rjson(self.run_dir / f"{gate}_job.json")}
|
||||
|
||||
@staticmethod
|
||||
def _mtime(path):
|
||||
try:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(Path(path).stat().st_mtime))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
def partial_result(self, gate):
|
||||
"""Check results of a run that did not finish (#34-5): `<gate>.json` (the checker's own output) is written while the run goes on, `<gate>_gate.json`
|
||||
(the recorded result) only when it ends. A checker output newer than the recorded result is a partial result of an aborted run:
|
||||
{at, passed, total, fails: [item ...]} or None. It is shown as such and never used for approval or the report."""
|
||||
g, c = self.run_dir / f"{gate}.json", self.run_dir / f"{gate}_gate.json"
|
||||
if not g.exists() or (c.exists() and g.stat().st_mtime <= c.stat().st_mtime):
|
||||
return None
|
||||
d = rjson(g)
|
||||
if not isinstance(d, dict) or "results" not in d:
|
||||
return None
|
||||
s = d.get("summary") or {}
|
||||
fails = [r["item"] for r in d["results"] if r.get("result") == "FAIL"]
|
||||
return {"at": self._mtime(g), "passed": s.get("passed"), "total": s.get("total"), "fails": fails}
|
||||
|
||||
|
||||
def piers():
|
||||
return sorted(p.name for p in DATA.iterdir() if (p / "params.json").exists()) if DATA.exists() else []
|
||||
|
||||
|
||||
TRASH = "_휴지통"
|
||||
|
||||
|
||||
def delete_pier(name):
|
||||
"""Move the pier folder to data/_휴지통/<name>_<time> (recoverable by moving it back). Returns the new path."""
|
||||
p = Pier(name)
|
||||
dst = DATA / TRASH / f"{p.name}_{time.strftime('%Y%m%d_%H%M%S')}"
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(p.dir), str(dst))
|
||||
return dst
|
||||
|
||||
|
||||
TEMPLATES = "_템플릿"
|
||||
TPL = "tpl:" # form value prefix of a protected template
|
||||
|
||||
|
||||
def templates():
|
||||
"""Protected templates [{name, desc, source, created, scope}] from data/_템플릿/<name>/template.json."""
|
||||
root = DATA / TEMPLATES
|
||||
out = []
|
||||
if root.exists():
|
||||
for d in sorted(root.iterdir()):
|
||||
if (d / "params.json").exists() and (d / "expected.json").exists():
|
||||
info = rjson(d / "template.json", {})
|
||||
sc = rjson(d / "params.json", {}).get("scope")
|
||||
out.append(dict(name=d.name, desc=info.get("desc", ""), source=info.get("source", "-"),
|
||||
created=info.get("created", "-"), scope=sc or ["G1", "G2", "G3", "G4", "G5"]))
|
||||
return out
|
||||
|
||||
|
||||
def template_dir(name):
|
||||
d = DATA / TEMPLATES / safe_name(name)
|
||||
if not (d / "params.json").exists():
|
||||
raise FileNotFoundError(f"템플릿이 없습니다: {name}")
|
||||
return d
|
||||
|
||||
|
||||
def save_template(source, tname, desc):
|
||||
"""Snapshot params / expected as a protected template; `source` is a pier name or a folder holding
|
||||
params.json / expected.json (e.g. a pier in data/_휴지통). An older snapshot of the same name is kept
|
||||
under data/_템플릿/_이전/."""
|
||||
sdir = Path(source) if Path(source).is_dir() and (Path(source) / "params.json").exists() else Pier(source).dir
|
||||
tname = safe_name(tname)
|
||||
d = DATA / TEMPLATES / tname
|
||||
if d.exists():
|
||||
old = DATA / TEMPLATES / "_이전" / f"{tname}_{time.strftime('%Y%m%d_%H%M%S')}"
|
||||
old.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(d), str(old))
|
||||
d.mkdir(parents=True)
|
||||
for f in ("params.json", "expected.json"):
|
||||
shutil.copy2(sdir / f, d / f)
|
||||
label = sdir.name if sdir.parent == DATA else f"{sdir.parent.name}/{sdir.name}"
|
||||
wjson(d / "template.json", {"name": tname, "desc": desc, "source": label, "created": now(),
|
||||
"inputs_sha": sha([rjson(sdir / "params.json"), rjson(sdir / "expected.json")])})
|
||||
wjson(d / "variability.json", variability.build(rjson(sdir / "params.json"), rjson(sdir / "expected.json")))
|
||||
texts = rjson(sdir / "drawings" / "texts.json", [])
|
||||
if texts:
|
||||
shas = [a.get("source_sha256") for a in rjson(sdir / "drawings" / "audit.json", []) if a.get("source_sha256")]
|
||||
wjson(d / "anchors.json", anchors.learn(rjson(sdir / "params.json"), rjson(sdir / "expected.json"), texts, shas))
|
||||
return d
|
||||
|
||||
|
||||
def create_pier(name, ptype, template):
|
||||
"""New pier from a protected template ("tpl:<name>") or a copy of an existing pier (plain name)."""
|
||||
if ptype not in PIER_TYPES:
|
||||
raise ValueError(f"대상 외 교각 형식입니다: {ptype} (1차 대상은 T형만)")
|
||||
name = safe_name(name)
|
||||
if name == TRASH:
|
||||
raise ValueError(f"'{TRASH}'는 휴지통 폴더 이름이라 쓸 수 없습니다")
|
||||
if name == TEMPLATES:
|
||||
raise ValueError(f"'{TEMPLATES}'는 템플릿 폴더 이름(앱이 쓰는 폴더 이름)이라 쓸 수 없습니다")
|
||||
d = DATA / name
|
||||
if d.exists():
|
||||
raise ValueError(f"이미 있는 교각입니다: {name}")
|
||||
if template.startswith(TPL):
|
||||
src_dir = template_dir(template[len(TPL):])
|
||||
label = f"템플릿: {src_dir.name}"
|
||||
else:
|
||||
src_dir = Pier(template).dir
|
||||
label = src_dir.name
|
||||
d.mkdir(parents=True)
|
||||
for f in ("params.json", "expected.json"):
|
||||
shutil.copy2(src_dir / f, d / f)
|
||||
P0, E0 = rjson(d / "params.json"), rjson(d / "expected.json")
|
||||
var = rjson(src_dir / "variability.json") or variability.build(P0, E0)
|
||||
wjson(d / "variability.json", var)
|
||||
anc = rjson(src_dir / "anchors.json")
|
||||
if not anc and (src_dir / "drawings" / "texts.json").exists():
|
||||
shas = [a.get("source_sha256") for a in rjson(src_dir / "drawings" / "audit.json", []) if a.get("source_sha256")]
|
||||
anc = anchors.learn(P0, E0, rjson(src_dir / "drawings" / "texts.json", []), shas)
|
||||
if anc:
|
||||
wjson(d / "anchors.json", anc)
|
||||
# origin record: hashes of every bundle as copied, so a bundle that is still identical later is "템플릿 원본 그대로"
|
||||
origin = {"label": label, "at": now(), "sha": {f"{w}:{k}": sha(v) for w, dd in (("params", P0), ("expected", E0)) for k, v in dd.items()},
|
||||
"fixed": {f"{w}:{k}": variability.fixed_view(v, (var["sections"].get(f"{w}:{k}") or {}).get("fixed_fields", []))
|
||||
for w, dd in (("params", P0), ("expected", E0)) for k, v in dd.items()
|
||||
if (var["sections"].get(f"{w}:{k}") or {}).get("fixed_fields")}}
|
||||
wjson(d / "pier.json", {"name": name, "type": ptype, "created": now(), "template": label, "origin": origin})
|
||||
# creation snapshot of every value (C3b 필드 단위 원본 잔존, 2026-10-20): the app can then tell which fields of a changed bundle were never touched
|
||||
wjson(d / "origin_values.json", {"label": label, "at": now(), "reconstructed": False, "params": P0, "expected": E0})
|
||||
return Pier(name)
|
||||
@@ -0,0 +1,19 @@
|
||||
{# Prism badges (stage B, 2026-10-16). Colour is never the only signal: every badge has a mark and a word. #}
|
||||
{% macro st_badge(st) -%}
|
||||
{%- set M = {'g': ['good', '●', '일치'], 'w': ['warn', '▲', '주의'], 'b': ['bad', '■', '불일치'], 'n': ['dash', '◇', '근거 없음'], 'x': ['', '—', '해당 없음'], '?': ['dash', '◇', '앱 확인 전']} -%}
|
||||
{%- set m = M.get(st, M['n']) -%}
|
||||
<span class="b {{ m[0] }}"><span class="m" aria-hidden="true">{{ m[1] }}</span>{{ m[2] }}</span>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro grade_badge(g) -%}
|
||||
{%- set M = {'A': ['good', '●', '등급 A'], 'B': ['warn', '▲', '등급 B'], 'C': ['dash', '◇', '등급 C']} -%}
|
||||
{%- set m = M.get(g, M['C']) -%}
|
||||
<span class="b {{ m[0] }}"><span class="m" aria-hidden="true">{{ m[1] }}</span>{{ m[2] }}</span>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro human_badge(s) -%}
|
||||
{%- if s.confirmed -%}<span class="b fixed"><span class="m" aria-hidden="true">●</span>확정</span>
|
||||
{%- elif s.info -%}<span class="b dash" title="게이트가 쓰지 않는 참고 정보입니다">확정 불필요(참고)</span>
|
||||
{%- elif s.stale -%}<span class="b warn"><span class="m" aria-hidden="true">◆</span>변경됨 · 재확정 필요</span>
|
||||
{%- else -%}<span class="b dash">미확정</span>{%- endif -%}
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}T형 교각 모델링{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='prism.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='prism-app.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main" style="position:absolute;left:-999rem">본문 바로가기</a>
|
||||
<header class="top">
|
||||
<a class="brand" href="{{ url_for('index') }}"><i aria-hidden="true"></i>T형 교각 모델링 <span class="muted" style="font-weight:400">반자동 MVP</span></a>
|
||||
{% if p %}<span class="sep" aria-hidden="true">›</span><nav aria-label="교각 화면">
|
||||
<a class="pier" href="{{ url_for('pier', name=p.name) }}">{{ p.name }}</a>
|
||||
<a href="{{ url_for('search', name=p.name) }}">도면 문자 검색</a>
|
||||
<a href="{{ url_for('candidates', name=p.name) }}">표 후보값</a>
|
||||
<a href="{{ url_for('geomread_page', name=p.name) }}">도면에서 읽기</a>
|
||||
<a href="{{ url_for('gates', name=p.name) }}">게이트</a></nav>{% endif %}
|
||||
</header>
|
||||
<main class="page" id="main">
|
||||
{% with msgs = get_flashed_messages(with_categories=true) %}{% for c, m in msgs %}
|
||||
<div class="flash {{ c }}" role="status">{{ m }}</div>{% endfor %}{% endwith %}
|
||||
{% if p and p.info.fit_mismatch %}<div class="flash warn" role="alert">▲ 형식 불일치 경고: 이 교각은 선택한 템플릿과 다음 항목이 다릅니다 — {{ p.info.fit_mismatch | join(', ') }}. 같은 템플릿으로 만들 수 없는 형식일 수 있으니 모든 묶음을 특히 주의해서 확인하세요. (이 경고는 닫을 수 없습니다)</div>{% endif %}
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,116 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}표 후보값 · {{ p.name }}{% endblock %}
|
||||
{% block body %}
|
||||
<h1>표 후보값</h1>
|
||||
<p class="muted">자동 인식 범위: 교량받침 ELEVATION TABLE, 솔플레이트 상세 TABLE, 철근재료표(SD400·SD500). 운곡교 P1 도면 1세트로만 검증한 규칙입니다.
|
||||
후보값은 반드시 도면과 대조한 뒤 반영하고, 반영 후 해당 묶음을 다시 확정하세요. 그 밖의 값은 문자 검색으로 찾아 직접 입력합니다.</p>
|
||||
<p class="muted">같은 표 값이 <code>expected</code>(검사 기준) 행과 <code>params</code>(모델 생성값) 행으로 따로 나옵니다.
|
||||
둘을 함께 반영하면 표를 잘못 읽었을 때 모델과 검사가 같이 틀려 게이트가 통과하므로, params 행은 도면과 따로 대조한 뒤 선택하세요.</p>
|
||||
|
||||
{% from "_prism.html" import grade_badge %}
|
||||
<!-- stage C 2026-10-19 -->
|
||||
<div class="prism" id="out-of-table">
|
||||
<div class="stack">
|
||||
<h2>표 밖 치수 후보 (앵커 따라가기)</h2>
|
||||
{% if not ameta.anchors %}
|
||||
<div class="banner tip"><span class="m" aria-hidden="true">◆</span><div>이 교각에는 앵커가 없습니다. 앵커는 같은 형식의 도면을 이미 읽은 템플릿에서 "각 값을 도면 어디서 읽었는가"를 기억한 것입니다.
|
||||
같은 도면 틀(예: 운곡교 P1~P4)에 속한다면 템플릿을 골라 불러오세요.</div></div>
|
||||
{% if tpl_anchors %}<form method="post" action="{{ url_for('anchors_load', name=p.name) }}" class="row" style="margin:0">
|
||||
<label for="tpl">템플릿 앵커 불러오기</label>
|
||||
<select id="tpl" name="template">{% for t in tpl_anchors %}<option>{{ t }}</option>{% endfor %}</select>
|
||||
<button class="btn" type="submit">불러오기</button></form>{% endif %}
|
||||
{% elif not ameta.texts %}<p class="cap muted">도면 문자 색인이 없습니다. 도면을 먼저 올리세요.</p>
|
||||
{% else %}
|
||||
{% set na = arows|selectattr('grade', 'equalto', 'A')|list|length %}{% set nb = arows|selectattr('grade', 'equalto', 'B')|list|length %}{% set nc = arows|selectattr('grade', 'equalto', 'C')|list|length %}
|
||||
{% set nd = arows|rejectattr('same')|list|length %}
|
||||
<section class="strip">
|
||||
<p class="concl">후보 {{ arows|length }}건 — 현재 값과 다름 {{ nd }}건 · A {{ na }} · B {{ nb }} · C {{ nc }} · 후보를 못 찾음 {{ amiss|length }}건</p>
|
||||
<p class="cap">도면 틀 일치율 {{ '%d' % ((ameta.coverage or 0) * 100) }} % (앵커를 만든 도면과 이 도면에 공통인 문자의 비율). 70 % 미만이면 틀이 다른 도면으로 보고 모든 후보를 C등급으로 낮춥니다.
|
||||
A = 같은 치수가 여러 곳에서 같게 읽힘 · B = 한 곳 또는 일부만 · C = 근거 약함(목록만, 반영 시 사유 필요). 시험에서 A등급은 틀린 후보가 없었고 B등급은 약 15 %가 틀렸습니다.</p>
|
||||
</section>
|
||||
{% if ameta.self_source %}<div class="banner"><span class="m" aria-hidden="true">▲</span><div>이 앵커는 이 교각 자신의 도면에서 만들어졌습니다. 같은 도면을 다시 읽은 것이라 앱 확인의 근거로 쓰지 않습니다.</div></div>{% endif %}
|
||||
{% if arows %}
|
||||
<form method="post" action="{{ url_for('anchors_apply', name=p.name) }}" class="stack">
|
||||
<div class="tw"><table class="t">
|
||||
<caption>표 밖 치수 후보 — 다른 것부터, 등급 좋은 것부터</caption>
|
||||
<thead><tr><th scope="col">반영</th><th scope="col">대상</th><th class="num" scope="col">현재</th><th class="num" scope="col">후보</th><th scope="col">등급</th><th scope="col">근거</th><th scope="col">도면 위치</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in arows %}
|
||||
<tr{% if not r.same %} class="sel"{% endif %}>
|
||||
<td>{% if not r.same and not r.self_source %}<input type="checkbox" name="field" value="{{ r.field }}" aria-label="{{ r.field }} 반영" style="width:1.25rem;height:1.25rem">{% endif %}</td>
|
||||
<td><b>{{ r.field }}</b></td><td class="num">{{ r.current }}</td><td class="num">{{ r.value }}</td>
|
||||
<td>{{ grade_badge(r.grade) }}{% if r.same %} <span class="cap muted">같음</span>{% endif %}</td>
|
||||
<td class="cap">{{ r.followed }}곳 중 {{ r.agree }}곳 일치{% if r.shared > 1 %} · 같은 값의 필드 {{ r.shared }}종{% endif %}
|
||||
{% if r.table_confirmed %}<br><b>표 값과도 일치</b>{% elif r.table is defined %}<br><b>표 값 {{ r.table }}와 다름 — 표를 따르세요</b>{% endif %}</td>
|
||||
<td><span class="chip">{{ r.dwg }} '{{ r.text }}' ({{ '%.0f' % r.x }}, {{ '%.0f' % r.y }})</span>
|
||||
<a class="cap" href="{{ url_for('view_drawing', name=p.name, dwg=r.dwg, x=r.x, y=r.y) }}" target="_blank">도면 보기</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="row"><span class="cap">빠른 선택(A등급 중 현재 값과 다른 것):</span>
|
||||
<button class="btn sm" type="button" data-sel-a="expected">expected만</button>
|
||||
<button class="btn sm" type="button" data-sel-a="params">params만</button>
|
||||
<button class="btn sm quiet" type="button" data-sel-a="">선택 해제</button>
|
||||
<span class="cap muted">params와 expected에 같은 도면 문자를 함께 반영할 수 없어 한쪽씩 고릅니다.</span></div>
|
||||
<div class="field"><label for="why">반영 사유<span class="req">C등급 후보 반영에는 필수</span></label><input id="why" type="text" name="why" style="max-width:36rem"></div>
|
||||
<div class="banner tip"><span class="m" aria-hidden="true">◆</span><div>같은 도면 문자에서 나온 후보를 params와 expected에 함께 반영할 수 없습니다(검사의 독립성). 한쪽만 고르고, 나머지는 도면에서 따로 확인하세요.</div></div>
|
||||
<div class="row"><button class="btn primary" type="submit">선택한 후보 반영</button></div>
|
||||
</form>
|
||||
<script>
|
||||
(function () {
|
||||
/* sel-a */
|
||||
var rows = document.querySelectorAll('#out-of-table tbody tr');
|
||||
document.querySelectorAll('[data-sel-a]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var file = btn.getAttribute('data-sel-a');
|
||||
rows.forEach(function (tr) {
|
||||
var cb = tr.querySelector('input[type=checkbox][name=field]');
|
||||
if (!cb) return;
|
||||
var isA = tr.textContent.indexOf('등급 A') >= 0;
|
||||
cb.checked = !!file && isA && cb.value.indexOf(file + ':') === 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if chains %}
|
||||
<h3>도면 치수 사슬 검산 (R2)</h3>
|
||||
<p class="cap">{{ chains|length }}개 중 검산(개수 × 간격 = 합계)에 맞지 않는 것 {{ chain_bad|length }}개{% if chain_bad %}:{% endif %}
|
||||
{% for c in chain_bad %}<span class="b bad"><span class="m" aria-hidden="true">■</span>{{ c.dwg }} {{ c.text }} → 계산 {{ c.n * c.pitch }} mm</span> <a class="cap" href="{{ url_for('view_drawing', name=p.name, dwg=c.dwg, x=c.x, y=c.y) }}" target="_blank">도면 보기</a> {% endfor %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>후보값 비교 ({{ rows|length }}건 · 다름 {{ rows|rejectattr('same')|list|length }}건)</h2>
|
||||
{% if rows %}
|
||||
<form method="post" action="{{ url_for('candidates_apply', name=p.name) }}">
|
||||
<div class="wrap"><table>
|
||||
<tr><th>반영</th><th>대상</th><th>현재값</th><th>후보값</th><th>상태</th><th>근거</th></tr>
|
||||
{% for r in rows %}
|
||||
<tr><td>{% if not r.same %}<input type="checkbox" name="row" value="{{ loop.index0 }}">{% endif %}</td>
|
||||
<td><code>{{ r.file }}.{{ r.path|join('.') }}</code></td>
|
||||
<td><code>{{ dumps(r.current) }}</code></td><td><code>{{ dumps(r.candidate) }}</code></td>
|
||||
<td>{% if r.same %}<span class="ok">일치</span>{% else %}<span class="warn">다름</span>{% endif %}</td>
|
||||
<td>{{ r.src }}</td></tr>
|
||||
{% endfor %}
|
||||
</table></div>
|
||||
<p><button>선택한 후보값 반영</button></p>
|
||||
</form>
|
||||
{% else %}<p class="muted">인식된 표가 없습니다.</p>{% endif %}
|
||||
|
||||
<h2>인식한 표 ({{ tables|length }})</h2>
|
||||
{% for t in tables %}
|
||||
<details><summary>{{ t.key }} · {{ t.anchor.dwg }} '{{ t.anchor.text }}' ({{ t.anchor.x }}, {{ t.anchor.y }}) · {{ t.rows|length }}행</summary>
|
||||
<div class="wrap"><table>{% for r in t.rows %}<tr>{% for c in r.cells %}<td>{{ c.text }}</td>{% endfor %}</tr>{% endfor %}</table></div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
|
||||
<h2>EL 표기 ({{ els|length }})</h2>
|
||||
<p class="muted">EL 문자는 위치에 따라 뜻이 달라 자동 반영하지 않습니다.</p>
|
||||
<table><tr><th>도면</th><th>레이어</th><th>X</th><th>Y</th><th>문자</th><th>mm</th></tr>
|
||||
{% for e in els %}<tr><td>{{ e.dwg }}</td><td>{{ e.layer }}</td><td>{{ e.x }}</td><td>{{ e.y }}</td><td>{{ e.text }}</td><td>{{ e.value_mm }}</td></tr>{% endfor %}
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_prism.html" import st_badge %}
|
||||
{% block title %}일치 묶음 일괄 확정{% endblock %}
|
||||
{% block body %}
|
||||
<div class="prism">
|
||||
<div class="stack">
|
||||
<div class="stack" style="gap:var(--prism-space-100)">
|
||||
<span class="eyebrow">입력 묶음 · 일괄 확정</span>
|
||||
<h1 style="font-size:var(--prism-type-title);color:var(--prism-text-heading);margin:0">앱 확인 '일치' 묶음 일괄 확정</h1>
|
||||
</div>
|
||||
{% if targets %}
|
||||
<section class="strip">
|
||||
<p class="concl">일치 {{ targets|length }}개를 확정합니다. 주의·불일치·근거 없음·미확인 {{ rest|length }}개는 대상이 아닙니다.</p>
|
||||
<p class="cap">근거표를 <b>끝까지 내려</b> 읽어야 확정할 수 있습니다. 확정 기록에는 입력한 이름만 남고, 앱은 확정하지 않습니다.</p>
|
||||
</section>
|
||||
<h3>근거표 <span class="cap muted">끝까지 내려 확인합니다</span></h3>
|
||||
<div class="scroll" id="sc" tabindex="0" role="region" aria-label="일치 묶음 근거표" style="max-height:20rem">
|
||||
<table class="t">
|
||||
<caption style="position:absolute;left:-999rem">일치 묶음 근거</caption>
|
||||
<thead><tr><th scope="col">묶음</th><th scope="col">근거</th><th scope="col">출처·위치</th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in targets %}
|
||||
{% set c = (t.chk.checks | selectattr('st', 'equalto', 'g') | selectattr('strength', 'in', ['S', 'F', 'C']) | list) %}
|
||||
<tr><td><b>{{ t.file }}.{{ t.key }}</b></td><td>{% if c %}{{ c[0].id }} {{ c[0].text }}{% else %}{{ t.chk.summary }}{% endif %}</td>
|
||||
<td>{% if c and c[0].pos %}<span class="chip">{{ c[0].pos }}</span>{% endif %}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details class="card sunken">
|
||||
<summary style="min-height:2.75rem;display:flex;align-items:center;cursor:pointer;font-weight:700">제외된 묶음 {{ rest|length }}개 — 누르면 사유를 봅니다</summary>
|
||||
<table class="t"><thead><tr><th scope="col">묶음</th><th scope="col">앱 확인</th><th scope="col">사유</th></tr></thead><tbody>
|
||||
{% for r in rest %}
|
||||
<tr><td><a href="{{ url_for('evidence', name=p.name, which=r.file, key=r.key) }}">{{ r.file }}.{{ r.key }}</a></td><td>{{ st_badge(r.chk.st) }}</td><td>{{ r.chk.summary }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody></table>
|
||||
</details>
|
||||
<form method="post" id="mf" class="stack">
|
||||
<div class="field"><label for="mby">확정자<span class="req">필수</span></label>
|
||||
<input id="mby" type="text" name="by" required style="max-width:20rem" disabled>
|
||||
<span class="help" id="hint">근거표를 끝까지 내리면 입력할 수 있습니다.</span></div>
|
||||
<div class="row end"><a class="btn" href="{{ url_for('pier', name=p.name) }}">취소</a>
|
||||
<button class="btn primary" id="mok" type="submit" disabled>일치 {{ targets|length }}개 확정</button></div>
|
||||
</form>
|
||||
<script>
|
||||
(function () {
|
||||
var sc = document.getElementById('sc'), by = document.getElementById('mby'), ok = document.getElementById('mok'), hint = document.getElementById('hint'), sent = false;
|
||||
function done() {
|
||||
if (sent) return; sent = true;
|
||||
fetch({{ url_for('confirm_matched_read', name=p.name)|tojson }}, {method: 'POST'}).then(function () {
|
||||
by.disabled = false; ok.disabled = false; hint.textContent = '근거표를 끝까지 읽었습니다. 이름을 입력하고 확정하세요.'; by.focus();
|
||||
});
|
||||
}
|
||||
function chk() { if (sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 2) done(); }
|
||||
sc.addEventListener('scroll', chk); chk();
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<section class="strip"><p class="concl">확정할 일치 묶음이 없습니다.</p>
|
||||
<p class="cap">모든 일치 묶음이 확정되었거나, 앱 확인이 아직 '일치'인 묶음이 없습니다. 주의·불일치·근거 없음 묶음은 근거 보기에서 사유와 함께 하나씩 확정합니다.</p></section>
|
||||
{% if rest %}<div class="tw"><table class="t"><thead><tr><th scope="col">묶음</th><th scope="col">앱 확인</th><th scope="col">사유</th></tr></thead><tbody>
|
||||
{% for r in rest %}<tr><td><a href="{{ url_for('evidence', name=p.name, which=r.file, key=r.key) }}">{{ r.file }}.{{ r.key }}</a></td><td>{{ st_badge(r.chk.st) }}</td><td>{{ r.chk.summary }}</td></tr>{% endfor %}
|
||||
</tbody></table></div>{% endif %}
|
||||
<div class="row"><a class="btn" href="{{ url_for('pier', name=p.name) }}">묶음 표로 돌아가기</a></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_prism.html" import st_badge, human_badge %}
|
||||
{% block title %}근거 보기 {{ which }}.{{ key }}{% endblock %}
|
||||
{% block body %}
|
||||
<div class="prism">
|
||||
<div class="stack">
|
||||
<div class="stack" style="gap:var(--prism-space-100)">
|
||||
<span class="eyebrow">입력 묶음 · 근거 보기</span>
|
||||
<h1 style="font-size:var(--prism-type-title);color:var(--prism-text-heading);margin:0">{{ which }}.{{ key }}</h1>
|
||||
</div>
|
||||
<section class="strip" aria-label="결론">
|
||||
<p class="concl">{{ st_badge(chk.st) }} {{ chk.summary }}</p>
|
||||
{% if chk.stale %}<p class="cap">▲ 값이 바뀌어 이 결과는 낡았습니다. [전체 확인]으로 다시 확인하세요.</p>{% endif %}
|
||||
<dl class="kv">
|
||||
<dt>변수 등급</dt><dd>{% if st.info %}참고{% else %}{{ meta.grade }} {{ grades[meta.grade] }}{% endif %} <span class="muted">— {{ meta.note }}</span></dd>
|
||||
<dt>값 출처(일반)</dt><dd>{{ meta.source|join(' · ') }}{% if meta.origin %} · <span class="b warn"><span class="m" aria-hidden="true">▲</span>템플릿 원본 그대로</span>{% endif %}</dd>
|
||||
<dt>앱 확인</dt><dd>{{ st_badge(chk.st) }}</dd>
|
||||
<dt>사람 확정</dt><dd>{{ human_badge(st) }} {% if st.confirmed %}<span class="muted">{{ st.by }} · {{ st.at }}{% if st.app_st %} · 확정 당시 앱 확인 {{ ST.get(st.app_st, st.app_st) }}{% endif %}</span>{% endif %}
|
||||
{% if st.why %}<br><span class="muted">사유: {{ st.why }}</span>{% endif %}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<div class="tw"><table class="t">
|
||||
<caption>앱 확인 8종 (해당하는 것만 수행)</caption>
|
||||
<thead><tr><th scope="col">검사</th><th scope="col">결과</th><th scope="col">무엇을 무엇과 비교했는가</th><th scope="col">출처·위치</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in chk.checks %}
|
||||
<tr><td><b>{{ c.id }}</b> {{ c.name }}</td><td>{{ st_badge(c.st) }}</td><td>{{ c.text }}</td><td>{% if c.pos %}<span class="chip">{{ c.pos }}</span>{% endif %}</td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4">앱 확인 전입니다. [전체 확인]을 누르세요.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
<div class="banner tip"><span class="m" aria-hidden="true">◆</span>
|
||||
<div>'일치'는 "독립 근거(도면 표·재료표 수량·템플릿)와 값이 같다"는 뜻이지 "도면이 맞다"는 뜻이 아닙니다. 앱은 확정하지 않으며, 근거를 읽은 사람이 확정합니다.
|
||||
철근 위치·좌표와 표 밖 치수(EL·치수 사슬)는 아직 앱이 대조하지 못합니다(C단계 예정).</div></div>
|
||||
|
||||
{% if not st.info %}
|
||||
<section class="card" aria-label="확정">
|
||||
<h3>확정</h3>
|
||||
<form method="post" action="{{ url_for('section_confirm', name=p.name, which=which, key=key) }}" class="stack">
|
||||
<input type="hidden" name="next" value="{{ url_for('evidence', name=p.name, which=which, key=key) }}">
|
||||
<div class="field"><label for="by">확정자<span class="req">필수</span></label>
|
||||
<input id="by" type="text" name="by" required style="max-width:20rem"></div>
|
||||
<div class="field"><label for="why">사유<span class="req">{% if chk.st in ('w', 'b', 'n') %}앱 확인이 '일치'가 아니므로 필수{% else %}선택{% endif %}</span></label>
|
||||
<textarea id="why" name="why" rows="3" {% if chk.st in ('w', 'b', 'n') %}required{% endif %}></textarea>
|
||||
<span class="help">무엇을 어떻게 확인했는지 적습니다(예: 도면 D1 정면도 EL 문자와 직접 대조).</span></div>
|
||||
<div class="row"><button class="btn primary" type="submit">저장된 값으로 확정</button>
|
||||
<a class="btn" href="{{ url_for('section', name=p.name, which=which, key=key) }}">값 편집</a>
|
||||
<a class="btn quiet" href="{{ url_for('pier', name=p.name) }}">묶음 표로 돌아가기</a></div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}게이트 · {{ p.name }}{% endblock %}
|
||||
{% block body %}
|
||||
{% if running %}<meta http-equiv="refresh" content="5">{% endif %}
|
||||
<h1>게이트 G1 → G5</h1>
|
||||
<p class="muted">게이트마다 G1부터 그 단계까지 새로 만들고, 저장한 파일을 다시 열어 검사합니다(재로드).
|
||||
PASS 조건: 계획 FAIL 외 FAIL 0 · 수평/수직 음성 시험 검출 · 재로드 객체 수 일치 · 앞 게이트 객체 불변.
|
||||
다음 게이트는 앞 게이트가 승인되어야 실행할 수 있습니다.{% if running %} <b>실행 중 — 5초마다 새로 고칩니다.</b>{% endif %}</p>
|
||||
|
||||
{% if out_of_scope %}<p class="muted">이 교각의 범위 밖 게이트: {{ out_of_scope|join(', ') }} (실행·승인 대상 아님)</p>{% endif %}
|
||||
<h2>일괄 승인 (필요하면 실행)</h2>
|
||||
{% if batch %}<p>최근 일괄 실행: <b class="{{ 'ok' if batch.state == '완료' else ('warn' if batch.state in ('실행 중', '중단됨') else 'fail') }}">{{ batch.state }}</b>
|
||||
· 판정자 {{ batch.by }} · {% if batch.started %}{{ batch.started }} 시작 · {% endif %}{{ batch.at }}{% if batch.state != '실행 중' %} 끝{% endif %}</p>
|
||||
{% if aborted %}<div class="flash fail" role="alert" id="batch-aborted">
|
||||
<b>이 일괄 실행은 끝까지 가지 못했습니다.</b>
|
||||
{% if batch.reason == 'error' %}모델을 만들거나 검사하는 도중 <b>오류</b>가 나서 멈췄습니다{% if batch.error %}: <code>{{ batch.error }}</code>{% endif %}.
|
||||
{% elif batch.reason == 'fail' %}<b>{{ batch.gate }}</b>가 FAIL이라 승인하지 않고 멈췄습니다.
|
||||
{% elif batch.reason == 'blocked' %}실행 조건이 맞지 않아 시작하지 못했습니다.
|
||||
{% elif batch.reason == 'killed' %}서버가 멈추거나 다시 켜져서 끝나지 못했습니다.
|
||||
{% else %}이유가 기록되지 않았습니다.{% endif %}
|
||||
<ul>
|
||||
<li>아래 게이트별 표에서 <b>"이전 실행 결과"</b>로 표시된 것은 이번 실행에서 갱신되지 않은 옛 결과입니다. 그 결과나 승인 기록을 이번 입력의 결과로 읽지 마세요.</li>
|
||||
<li>중단되기 전에 계산된 <b>임시 결과</b>가 있으면 해당 게이트 표 위에 따로 표시합니다. 승인과 보고서에는 쓰이지 않습니다.</li>
|
||||
<li>원인을 해결한 뒤 일괄 승인을 다시 실행하세요.</li>
|
||||
</ul></div>{% endif %}
|
||||
<ul>{% for m in batch.log %}<li>{{ m }}</li>{% endfor %}</ul>{% endif %}
|
||||
{% if todo %}
|
||||
<form method="post" action="{{ url_for('gates_run_all', name=p.name) }}"
|
||||
onsubmit="return confirm('{{ todo[0] }}~{{ todo[-1] }}를 차례로 승인합니다. 최신 PASS 결과가 있는 게이트는 바로 승인하고, 결과가 없으면 실행한 뒤 PASS일 때만 승인합니다. 진행할까요?');">
|
||||
판정자 <input type="text" name="by" required>
|
||||
<button {% if running %}disabled{% endif %}>{{ todo[0] if todo|length == 1 else todo[0] ~ '~' ~ todo[-1] }} 일괄 승인</button>
|
||||
</form>
|
||||
<p class="muted">최신 PASS 결과가 있는 게이트는 다시 실행하지 않고 바로 승인합니다. 결과가 없거나 입력이 바뀐 게이트는 실행한 뒤 PASS일 때만 승인합니다. FAIL·오류·실행 조건 미충족이면 그 게이트에서 멈추고 승인하지 않습니다.
|
||||
승인 기록의 의견란에 '일괄 실행 자동 승인'이 남습니다. 결과는 아래 게이트별 표에서 확인하세요.</p>
|
||||
{% else %}<p class="ok">모든 게이트가 승인되어 있습니다.</p>{% endif %}
|
||||
|
||||
<h2>검토보고서 (초안)</h2>
|
||||
<form method="post" action="{{ url_for('report_make', name=p.name) }}">
|
||||
작성자 <input type="text" name="by"> <button {% if running %}disabled{% endif %}>보고서 초안 생성 (MD + PDF)</button>
|
||||
<a href="{{ url_for('report_get', name=p.name, kind='md') }}">MD 받기</a> · <a href="{{ url_for('report_get', name=p.name, kind='pdf') }}">PDF 받기</a>
|
||||
</form>
|
||||
<p class="muted">게이트 결과·도면 표·승인 기록으로 자동 작성합니다. 승인되지 않은 게이트가 있으면 '미완료'로 표시됩니다. 판정 확정과 3~5장 서술은 담당자가 합니다.</p>
|
||||
|
||||
{% for g in rows %}
|
||||
<h2>{{ g.id }} {{ g.name }} —
|
||||
{% if g.report %}{% if g.stale %}<span class="warn">입력 변경 · 재실행 필요</span>{% elif g.report.verdict == 'PASS' %}<span class="ok">PASS</span>{% else %}<span class="fail">FAIL</span>{% endif %}{% else %}<span class="muted">미실행</span>{% endif %}
|
||||
{% if g.approved %}· <span class="ok">승인</span>{% elif g.approval_stale %}· <span class="warn">승인 기록은 이전 입력 것(무효)</span>{% elif g.approval %}· {{ g.approval.decision }}{% endif %}</h2>
|
||||
{% if g.report %}<p class="muted">결과 시각 {{ g.report_at or '-' }}{% if g.report.mode == 'batch' %} · 일괄 실행{% endif %}
|
||||
{% if g.older %}<b class="warn">이전 실행 결과 — 이번 일괄 실행에서 갱신되지 않았습니다</b>{% endif %}</p>{% endif %}
|
||||
{% if g.show_partial %}{% set pr = g.partial %}<div class="flash warn" id="partial-{{ g.id }}"><b>중단된 실행의 임시 결과</b> ({{ pr.at }}) — 검사 {{ pr.passed }} / {{ pr.total }} PASS,
|
||||
FAIL {{ pr.fails|length }}건{% if pr.fails %}: {{ pr.fails[:6]|join(' · ') }}{% if pr.fails|length > 6 %} 외 {{ pr.fails|length - 6 }}건{% endif %}{% endif %}.
|
||||
계획된 예외는 구분하지 않은 검사기 원본 결과이며 승인·보고서에는 쓰이지 않습니다.</div>{% endif %}
|
||||
|
||||
{% if g.state in ('대기', '실행 중') %}<p class="warn">{{ g.state }}: {{ g.job.log[-1] if g.job.log else '' }}</p>
|
||||
{% elif g.state == '중단됨' %}<p class="warn">중단됨: 이전 실행이 끝나지 않은 채 서버가 멈췄습니다. 다시 실행하세요.</p>
|
||||
{% elif g.state == '오류' %}<p class="fail">오류</p><pre>{{ g.job.error }}</pre>{% endif %}
|
||||
|
||||
{% if g.blockers %}<p>실행 조건 미충족: {% for b in g.blockers %}<span class="fail">{{ b }}</span>{% if not loop.last %}, {% endif %}{% endfor %}</p>
|
||||
{% else %}<form method="post" action="{{ url_for('gate_run', name=p.name, gate=g.id) }}"><button {% if running %}disabled{% endif %}>{{ g.id }} 실행</button></form>{% endif %}
|
||||
|
||||
{% if g.report %}{% set r = g.report %}
|
||||
<table>
|
||||
<tr><th>검사</th><td>{{ r.summary.passed }} / {{ r.summary.total }} PASS ({{ r.checker }})</td></tr>
|
||||
<tr><th>미계획 FAIL</th><td>{% if r.unplanned_fails %}<span class="fail">FAIL {{ r.unplanned_fails|length }}건</span><ul>{% for f in r.unplanned_fails %}<li>{{ f }}</li>{% endfor %}</ul>{% else %}<span class="ok">0건</span>{% endif %}</td></tr>
|
||||
<tr><th>계획 FAIL (승인된 예외)</th><td>{% if r.planned_fails %}<span class="warn">계획 FAIL {{ r.planned_fails|length }}건</span><ul>{% for f in r.planned_fails %}<li>{{ f.item }} — {{ f.reason }}</li>{% endfor %}</ul>{% else %}0건{% endif %}</td></tr>
|
||||
<tr><th>음성 시험</th><td>{% for k, v in r.negative.items() %}{{ '수평' if k == 'negH' else '수직' }} {{ v.move }} →
|
||||
{% if v.detected %}<span class="ok">검출</span> ({{ v.new_fails|join(', ') }}){% else %}<span class="fail">미검출</span>{% endif %}<br>{% endfor %}</td></tr>
|
||||
<tr><th>재로드</th><td>{% if r.reload.ok is none %}<span class="muted">생략</span> {{ r.reload.skipped }}{% elif r.reload.ok %}<span class="ok">일치</span> 생성 {{ r.reload.built }} / 다시 연 파일 {{ r.reload.reopened }}{% else %}<span class="fail">불일치</span> 생성 {{ r.reload.built }} / 다시 연 파일 {{ r.reload.reopened }}{% endif %}</td></tr>
|
||||
<tr><th>앞 게이트 객체 불변</th><td>{% if r.unchanged.ok is none %}<span class="muted">생략</span> {{ r.unchanged.skipped }}{% elif r.unchanged.ok %}<span class="ok">불변</span> {{ r.unchanged.compared }}개 비교{% if r.unchanged.against %} (기준 {{ r.unchanged.against }}){% endif %}{% else %}<span class="fail">변경</span> {{ r.unchanged.compared }}개 비교{% endif %}</td></tr>
|
||||
<tr><th>모델</th><td>{% if r.model %}<a href="{{ url_for('download', name=p.name, gate=g.id) }}">{{ p.name }}_{{ g.id }}.3dm 내려받기</a>{% else %}<span class="muted">일괄 실행 — 중간 모델 저장 안 함 (G5 모델에 포함)</span>{% endif %} · {{ r.secs }}초{% if r.mode == 'batch' %} (일괄 세션 {{ r.session_secs }}초){% endif %}</td></tr>
|
||||
</table>
|
||||
{% if not g.stale %}
|
||||
<form method="post" action="{{ url_for('gate_decide', name=p.name, gate=g.id) }}">
|
||||
판정자 <input type="text" name="by" required> 의견 <input type="text" name="note" size="40" placeholder="반려 시 필수">
|
||||
<button name="decision" value="승인" {% if r.verdict != 'PASS' %}disabled{% endif %}>승인</button>
|
||||
<button name="decision" value="반려">반려</button>
|
||||
</form>{% endif %}
|
||||
{% if g.approval %}<p class="muted">최근 판정: {{ g.approval.decision }} · {{ g.approval.by }} · {{ g.approval.at }}{% if g.approval.note %} · {{ g.approval.note }}{% endif %}</p>{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}도면에서 읽기 · {{ p.name }}{% endblock %}
|
||||
{% block body %}
|
||||
<h1>도면에서 읽기</h1>
|
||||
<p class="muted">한 화면에서 <b>템플릿 원본 값을 모두 도면 값으로</b> 바꿉니다. 운곡교 P1 도면 틀(P1~P4)의 첫 번째 도면(D1)과 철근재료표에서 코핑·기초·말뚝·기둥·받침·철근 값을 읽어 <code>params</code>(모델 생성값)와 <code>expected</code>(검사 기준)의 현재 값과 비교합니다.
|
||||
읽기 규칙은 모두 완성된 교각(P1)에서 같은 값이 재현되는지 먼저 확인한 것입니다(<code>tools/test_geomread.py</code>). 이 도구는 아무것도 자동으로 바꾸지 않고, 고른 줄만 반영합니다. 반영한 묶음은 다시 확정해야 합니다.</p>
|
||||
{% if running %}<p class="warn">게이트 실행 중에는 값을 바꿀 수 없습니다.</p>{% endif %}
|
||||
{% if state != 'ok' %}
|
||||
<p class="fail">{{ msg }}</p>
|
||||
{% else %}
|
||||
<p class="muted">{{ msg }}{% if msg %} · {% endif %}현재 값과 다른 줄 {{ n }}건</p>
|
||||
<div class="flash warn">params와 expected를 같은 도면 읽기에서 채우면 둘이 서로 같은 것은 <b>독립 근거가 아닙니다</b>(앱 확인 C9). 받침 표·솔플레이트 표·재료표 값(아래 "표 값" 구역)도 이 화면에 함께 나오며, 같은 값을 <a href="{{ url_for('candidates', name=p.name) }}">표 후보값</a> 화면에서 따로 골라 반영할 수도 있습니다.
|
||||
추론 줄(예: 좌측 스터럽 윗면)은 기본으로 선택되어 있지 않습니다.</div>
|
||||
{% if notes %}<h2>확인이 필요한 점</h2><ul>{% for t in notes %}<li>{{ t }}</li>{% endfor %}</ul>{% endif %}
|
||||
{% if not n %}<p class="ok">읽은 값이 모두 현재 값과 같습니다(1.5 mm 반올림 안).</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('geomread_apply', name=p.name) }}">
|
||||
{% for g, items in groups.items() %}
|
||||
<h2>{{ g }} ({{ items|length }}건)</h2>
|
||||
<div class="wrap"><table>
|
||||
<thead><tr><th>반영</th><th>대상</th><th>현재</th><th>읽은 값</th><th>근거</th></tr></thead>
|
||||
<tbody>{% for i, c in items %}
|
||||
<tr><td><input type="checkbox" name="row" value="{{ i }}" {% if not c.inferred %}checked{% endif %} aria-label="{{ c.file }}:{{ c.path }} 반영"></td>
|
||||
<td><code>{{ c.file }}:{{ c.path }}</code></td>
|
||||
<td>{{ brief(c.old)|truncate(60) }}</td><td><b>{{ brief(c.new)|truncate(60) }}</b></td>
|
||||
<td>{{ c.why }}{% if c.inferred %} <span class="warn">추론</span>{% endif %}</td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
{% endfor %}
|
||||
<p>반영자 <input type="text" name="by" required> <button {% if running %}disabled{% endif %}>선택한 줄 반영</button></p>
|
||||
<p class="muted">반영 전에 <code>params.json</code>·<code>expected.json</code>·<code>confirm.json</code>을 <code>backup/before_geomread_…</code>에 보관하고, 반영 기록은 <code>read_log.json</code>에 남깁니다.</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if log %}<h2>최근 반영 기록</h2><ul>{% for e in log %}<li>{{ e.at }} · {{ e.by or '-' }} · {{ e.count }}건 · {{ e.backup }}</li>{% endfor %}</ul>{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h1>교각 목록</h1>
|
||||
<table>
|
||||
<tr><th>교각</th><th>형식</th><th>만든 날</th><th>템플릿</th><th>삭제</th></tr>
|
||||
{% for p in piers %}
|
||||
<tr><td><a href="{{ url_for('pier', name=p.name) }}">{{ p.name }}</a></td><td>{{ p.info.type }}</td>
|
||||
<td>{{ p.info.created }}</td><td>{{ p.info.template or '-' }}</td>
|
||||
<td><form method="post" action="{{ url_for('pier_delete', name=p.name) }}" style="margin:0"
|
||||
onsubmit="return confirm('교각 \'{{ p.name }}\'을(를) 삭제합니다. 도면·입력·결과가 모두 휴지통(data/_휴지통)으로 옮겨집니다. 계속할까요?');">
|
||||
<input type="text" name="confirm_name" placeholder="교각 이름 입력" size="12" required>
|
||||
<button>삭제</button></form></td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p class="muted">삭제하려면 해당 줄의 칸에 교각 이름을 똑같이 입력하고 [삭제]를 누르세요. 삭제한 교각은 data/_휴지통 폴더로 옮겨지며, 폴더를 다시 옮기면 복구됩니다. 게이트 실행 중인 교각은 삭제할 수 없습니다.</p>
|
||||
|
||||
<h2>도면으로 템플릿 추천</h2>
|
||||
<form method="post" action="{{ url_for('recommend_upload') }}" enctype="multipart/form-data">
|
||||
교각 도면(DWG, 여러 장) <input type="file" name="dwg" accept=".dwg" multiple required>
|
||||
<button>도면 읽고 추천 받기</button>
|
||||
</form>
|
||||
<p class="muted">도면을 먼저 올리면 가장 비슷한 템플릿을 순위로 보여 줍니다. 추천은 제안이며, 아래 6문항을 사람이 확인한 뒤 교각을 만듭니다. 도면을 올리지 않고 만들려면 아래 '새 교각'을 쓰세요.</p>
|
||||
|
||||
<h2>새 교각</h2>
|
||||
<form method="post" action="{{ url_for('pier_new') }}">
|
||||
이름 <input type="text" name="name" required>
|
||||
형식 <select name="type">{% for t in types %}<option>{{ t }}</option>{% endfor %}</select>
|
||||
템플릿 <select name="template">
|
||||
{% if templates %}<optgroup label="보호 템플릿">{% for t in templates %}<option value="{{ tpl }}{{ t.name }}">{{ t.name }}</option>{% endfor %}</optgroup>{% endif %}
|
||||
<optgroup label="기존 교각 복사">{% for p in piers %}<option value="{{ p.name }}">{{ p.name }}</option>{% endfor %}</optgroup>
|
||||
</select>
|
||||
<button>만들기</button>
|
||||
</form>
|
||||
<p class="muted">1차 대상은 T형 교각뿐입니다. 다른 형식은 만들 수 없습니다.
|
||||
템플릿의 매개변수·기대값이 복사되므로, 새 도면 값으로 모든 묶음을 확인·수정한 뒤 확정해야 게이트를 실행할 수 있습니다.</p>
|
||||
|
||||
{% if templates %}
|
||||
<h2>보호 템플릿</h2>
|
||||
<table>
|
||||
<tr><th>템플릿</th><th>형식 설명</th><th>모델링 게이트</th><th>원본 교각</th><th>저장일</th></tr>
|
||||
{% for t in templates %}
|
||||
<tr><td>{{ t.name }}</td><td>{{ t.desc }}</td><td>{{ t.scope | join(', ') }}</td><td>{{ t.source }}</td><td>{{ t.created }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p class="muted">보호 템플릿은 data/_템플릿 폴더에 고정 사본으로 저장되어, 원본 교각을 고치거나 삭제해도 바뀌지 않습니다.
|
||||
앱 화면에서는 고칠 수 없습니다. 형식이 같은 교각이면 보호 템플릿에서 시작하는 것을 권장합니다.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ p.name }}{% endblock %}
|
||||
{% block body %}
|
||||
<h1>{{ p.name }} <span class="muted">({{ p.info.type }})</span></h1>
|
||||
|
||||
<h2>1. 도면</h2>
|
||||
{% if audit %}
|
||||
<table><tr><th>구분</th><th>DWG</th><th>DXF</th><th>DWG SHA-256</th></tr>
|
||||
{% for a in audit %}<tr><td>{{ a.label }}</td><td>{{ a.source }}</td><td>{{ a.dxf }}</td><td><code>{{ a.source_sha256[:16] }}…</code></td></tr>{% endfor %}
|
||||
</table>
|
||||
<p>색인 문자 {{ ntexts }}개 · <a href="{{ url_for('search', name=p.name) }}">문자 검색</a> · <a href="{{ url_for('candidates', name=p.name) }}">표 후보값</a> · <a href="{{ url_for('geomread_page', name=p.name) }}">도면에서 읽기</a></p>
|
||||
{% else %}<p class="muted">올린 도면이 없습니다.</p>{% endif %}
|
||||
<form method="post" action="{{ url_for('upload', name=p.name) }}" enctype="multipart/form-data">
|
||||
<input type="file" name="dwg" accept=".dwg" multiple> <button>올리기·변환</button>
|
||||
<span class="muted">파일 이름 순서대로 D1, D2, …로 부릅니다. 다시 올리면 색인을 새로 만듭니다.</span>
|
||||
</form>
|
||||
|
||||
<h2>2. 입력 묶음 확인·확정</h2>
|
||||
<!-- stage A 2026-10-16 / stage B 2026-10-16 -->
|
||||
{% from "_prism.html" import st_badge, human_badge %}
|
||||
<div class="prism" id="bundles">
|
||||
<div class="stack">
|
||||
<section class="strip" aria-label="요약">
|
||||
<p class="concl">필요 {{ nreq }}개 중 앱 확인 일치 {{ cnt.g }} · 주의 {{ cnt.w }} · 불일치 {{ cnt.b }} · 근거 없음 {{ cnt.n }} · 해당 없음 {{ cnt.x }} · 사람 확정 {{ human }}</p>
|
||||
<div class="row">
|
||||
<form method="post" action="{{ url_for('autocheck_run', name=p.name) }}" style="margin:0"><button class="btn" type="submit">전체 확인</button></form>
|
||||
<a class="btn" href="{{ url_for('geomread_page', name=p.name) }}">도면에서 읽기</a>
|
||||
<a class="btn primary" href="{{ url_for('review', name=p.name) }}">검토 요약으로 확정</a>
|
||||
{% if nmatch %}<a class="btn" href="{{ url_for('confirm_matched', name=p.name) }}">일치 묶음만 확정 ({{ nmatch }}개)</a>{% endif %}
|
||||
</div>
|
||||
<p class="cap">앱 확인은 도면 표·재료표 수량·고정 항목을 앱이 대조한 결과이며 <b>앱은 확정하지 않습니다</b>. '일치'는 "독립 근거와 값이 같다"는 뜻이지 "도면이 맞다"는 뜻이 아닙니다.
|
||||
<b>변수 등급</b> V1 값 · V2 목록 · V3 생성(화면 편집 차단) · F 고정(바꾸면 사유 필요).</p>
|
||||
</section>
|
||||
{% if chain_bad %}
|
||||
<div class="banner dont" role="alert"><span class="m" aria-hidden="true">■</span><div>도면 치수 사슬 {{ chain_bad|length }}건이 검산에 맞지 않습니다(개수 × 간격 ≠ 합계) — 도면 자체의 오류일 수 있습니다:
|
||||
{% for c in chain_bad %}<span class="chip">{{ c.dwg }} {{ c.text }} (계산 {{ c.n * c.pitch }} mm)</span> {% endfor %}
|
||||
<a href="{{ url_for('candidates', name=p.name) }}">표 후보값 화면에서 보기</a></div></div>
|
||||
{% endif %}
|
||||
{% if origin_same %}
|
||||
<div class="banner" role="alert"><span class="m" aria-hidden="true">▲</span><div>템플릿 원본 그대로인 필수 묶음 {{ origin_same|length }}개 — 템플릿을 복사한 뒤 한 번도 바뀌지 않았습니다. 새 도면과 대조(후보 반영·값 수정)하기 전에 확정하면 원본 교각의 모델이 PASS로 나올 수 있습니다:
|
||||
{% for s in origin_same %}<a href="{{ url_for('evidence', name=p.name, which=s.file, key=s.key) }}">{{ s.file }}.{{ s.key }}</a>{% if not loop.last %}, {% endif %}{% endfor %}</div></div>
|
||||
{% elif not has_origin %}<p class="cap muted">이 교각은 템플릿 복사 기록이 없어 '템플릿 원본 그대로' 여부를 표시하지 않습니다.</p>{% endif %}
|
||||
<div class="tw"><table class="t">
|
||||
<caption>입력 묶음 {{ sections|length }}개 — 앱 확인이 나쁜 것부터</caption>
|
||||
<thead><tr><th scope="col">묶음</th><th scope="col">변수 등급</th><th scope="col">값 출처</th><th scope="col">앱 확인</th><th scope="col">사람 확정</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in sections %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('section', name=p.name, which=s.file, key=s.key) }}">{{ s.file }}.{{ s.key }}</a>{% if not s.required %} <span class="cap muted">(필수 아님)</span>{% endif %}</td>
|
||||
<td>{% if s.info %}<span class="muted">참고</span>{% else %}<span title="{{ s.meta.note }}">{{ s.meta.grade }} {{ grades[s.meta.grade] }}</span>{% endif %}</td>
|
||||
<td>{% if s.meta.origin and s.required %}<span class="b warn"><span class="m" aria-hidden="true">▲</span>템플릿 원본 그대로</span>{% elif s.meta.origin %}<span class="muted">템플릿 원본 그대로</span>{% else %}{{ s.meta.source|join(' · ') }}{% endif %}</td>
|
||||
<td>{{ st_badge(s.chk.st) }} <span class="cap">{{ s.chk.summary }}</span> <a class="cap" href="{{ url_for('evidence', name=p.name, which=s.file, key=s.key) }}">근거 보기</a></td>
|
||||
<td>{{ human_badge(s) }}{% if s.by %} <span class="cap muted">{{ s.by }} · {{ s.at }}</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if need %}<p class="muted">게이트에 필요한 묶음 {{ need|length }}개가 아직 확정되지 않았습니다. 앱 확인이 '일치'인 묶음은 위의 [일치 묶음 일괄 확정]으로, 나머지는 '근거 보기'에서 사유와 함께 하나씩 확정합니다.</p>
|
||||
{% else %}<p class="ok">게이트에 필요한 묶음이 모두 확정되어 있습니다.</p>{% endif %}
|
||||
|
||||
<h2>3. 게이트</h2>
|
||||
{% if out_of_scope %}<p class="muted">범위 밖: {{ out_of_scope|join(', ') }} — 이 교각에서는 모델링하지 않습니다.</p>{% endif %}
|
||||
<table><tr><th>게이트</th><th>내용</th><th>판정</th><th>승인</th></tr>
|
||||
{% for g in gates %}
|
||||
<tr><td>{{ g.id }}</td><td>{{ g.name }}</td>
|
||||
<td>{% if g.report %}{% if g.stale %}<span class="warn">입력 변경 · 재실행 필요</span>{% elif g.report.verdict == 'PASS' %}<span class="ok">PASS</span>{% else %}<span class="fail">FAIL</span>{% endif %}{% else %}<span class="muted">미실행</span>{% endif %}</td>
|
||||
<td>{% if g.approved %}<span class="ok">승인</span>{% elif g.approval %}{{ g.approval.decision }}{% else %}<span class="muted">-</span>{% endif %}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if running %}<p class="warn">실행 중입니다{% if batch and batch.log %} — {{ batch.log[-1] }}{% endif %}.
|
||||
<a href="{{ url_for('gates', name=p.name) }}">게이트 화면에서 진행 보기</a></p>
|
||||
{% elif todo %}
|
||||
<form method="post" action="{{ url_for('gates_run_all', name=p.name) }}"
|
||||
onsubmit="return confirm('{{ todo[0] }}~{{ todo[-1] }}를 차례로 승인합니다. 최신 PASS 결과가 있는 게이트는 바로 승인하고, 결과가 없으면 실행한 뒤 PASS일 때만 승인합니다. 진행할까요?');">
|
||||
판정자 <input type="text" name="by" required>
|
||||
<button {% if need %}disabled title="먼저 묶음을 확정하세요"{% endif %}>{{ todo[0] if todo|length == 1 else todo[0] ~ '~' ~ todo[-1] }} 일괄 승인</button>
|
||||
<span class="muted">{% if need %}먼저 위의 묶음을 확정하세요. {% endif %}최신 PASS 결과가 있으면 바로 승인하고, 없으면 실행한 뒤 PASS일 때만 승인합니다. FAIL·오류가 나면 멈춥니다. 진행은 게이트 화면에서 보입니다.</span>
|
||||
</form>
|
||||
{% else %}<p class="ok">모든 게이트가 승인되어 있습니다.</p>{% endif %}
|
||||
{% if batch and not running %}<p class="muted">최근 일괄 실행: {{ batch.state }} · {{ batch.by }} · {{ batch.at }}{% if batch.log %} — {{ batch.log[-1] }}{% endif %}</p>{% endif %}
|
||||
<p><a href="{{ url_for('gates', name=p.name) }}">게이트 화면으로</a> (게이트별 결과·개별 실행·보고서)</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h1>도면으로 템플릿 추천</h1>
|
||||
<p class="muted">도면 {{ nfiles }}장, 문자 {{ ntexts }}개를 읽었습니다. 아래 추정은 <b>제안</b>이며 확정이 아닙니다. 맞지 않으면 아래 6문항에서 바꾸세요.</p>
|
||||
|
||||
<h2>1. 추천 순위</h2>
|
||||
{% set asked = rank[0].asked if rank else 0 %}
|
||||
{% if rank and rank[0].matched < asked %}
|
||||
<p class="warn">▲ 판단 불가: 추정한 {{ asked }}문항이 모두 맞는 템플릿이 없습니다. 가장 가까운 템플릿과 다른 문항을 확인하고, 필요하면 생성기·검사기를 추가해야 합니다.</p>
|
||||
{% endif %}
|
||||
<table>
|
||||
<tr><th>순위</th><th>템플릿</th><th>일치</th>{% for q, label in questions %}<th>{{ label }}</th>{% endfor %}</tr>
|
||||
{% for r in rank %}
|
||||
<tr><td>{{ loop.index }}</td><td>{{ r.name }}</td><td>{{ r.matched }} / {{ r.asked }}</td>
|
||||
{% for q, label in questions %}<td>{{ r.profile[q] }}{% if q in r.hits %} {% if r.hits[q] %}<span class="ok">● 일치</span>{% else %}<span class="fail">■ 다름</span>{% endif %}{% endif %}</td>{% endfor %}</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<h2>2. 추정 근거</h2>
|
||||
<table>
|
||||
<tr><th>문항</th><th>추정</th><th>근거(도면 위치)</th><th>신뢰</th></tr>
|
||||
{% for q, label in questions %}{% set e = est[q] %}
|
||||
<tr><td>{{ label }}</td><td>{% if e[0] is none %}<span class="warn">▲ 판단 불가</span>{% else %}{{ e[0] }}{% endif %}</td><td>{{ e[1] }}</td><td>{{ e[2] or '-' }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p class="muted">코핑 하면 형상은 정면도의 큰 R 문자(10 m 이상 = 곡선)만 보며, 작은 R(평면 모서리 등)은 제외합니다. 기초 형식은 '말뚝' 문자 유무로 정하므로, 도면에 말뚝 문구가 없는데 말뚝이 있는 경우는 틀립니다. 평면의 사각·직각과 각도는 문항이 아니라 값이라 여기서 묻지 않습니다.</p>
|
||||
|
||||
<h2>3. 6문항 확인 후 교각 만들기</h2>
|
||||
<form method="post" action="{{ url_for('recommend_create', sid=sid) }}">
|
||||
<table>
|
||||
{% for q, label in questions %}
|
||||
<tr><th>{{ label }}</th><td><input type="text" name="ans_{{ q }}" value="{{ '' if est[q][0] is none else est[q][0] }}" required></td></tr>
|
||||
{% endfor %}
|
||||
<tr><th>철근 형상 종류가 템플릿의 재료표 형식 열과 같은가</th><td><select name="ans_rebar_shape"><option>미확인</option><option>예</option><option>아니오</option></select></td></tr>
|
||||
<tr><th>철근 생성 방식(규칙 값 / 철근별 중심선)이 같은가</th><td><select name="ans_rebar_method"><option>미확인</option><option>예</option><option>아니오</option></select></td></tr>
|
||||
</table>
|
||||
<h3>각도 값 확인 <span class="muted">(템플릿을 가르지 않는 값. 도면에서 읽어 입력하며 비워도 됩니다)</span></h3>
|
||||
<table>
|
||||
<tr><th>코핑 평면 skew (°)</th><td><input type="text" name="ans_skew_deg" size="8"></td>
|
||||
<th>받침 회전 (°)</th><td><input type="text" name="ans_bearing_rot_deg" size="8"></td>
|
||||
<th>기초 회전 (°)</th><td><input type="text" name="ans_footing_rot_deg" size="8"></td></tr>
|
||||
</table>
|
||||
<h3>결과 판정</h3>
|
||||
<p id="verdict" class="muted" role="status">템플릿과 답을 고르면 판정이 나옵니다.</p>
|
||||
<p>이름 <input type="text" name="name" required>
|
||||
형식 <select name="type"><option>T형</option></select>
|
||||
템플릿 <select name="template">{% for r in rank %}<option value="{{ tpl }}{{ r.name }}">{{ loop.index }}위 {{ r.name }}</option>{% endfor %}</select>
|
||||
<button id="mk">교각 만들기</button></p>
|
||||
</form>
|
||||
<p class="muted">[교각 만들기]를 누를 때 교각 폴더가 만들어지고 올린 도면이 그 안으로 옮겨집니다. 6문항 답은 교각 정보(pier.json)에 저장됩니다.
|
||||
'다름'이 있는데 진행하면 형식이 다른 도면을 같은 템플릿에 넣는 것이므로, 만든 뒤 모든 묶음을 특히 주의해서 확인하세요.</p>
|
||||
<script>
|
||||
(function () {
|
||||
var PROF = {{ profiles | tojson }}, QS = {{ questions | map(attribute=0) | list | tojson }}, LAB = {{ questions | map(attribute=1) | list | tojson }};
|
||||
var f = document.querySelector('form'), v = document.getElementById('verdict'), mk = document.getElementById('mk');
|
||||
function judge() {
|
||||
var t = f.template.value.replace('{{ tpl }}', ''), pr = PROF[t], bad = [];
|
||||
QS.forEach(function (q, i) { if (String(pr[q]) !== f['ans_' + q].value.trim()) bad.push(LAB[i]); });
|
||||
if (f.ans_rebar_shape.value === '아니오') bad.push('철근 형상 종류');
|
||||
if (f.ans_rebar_method.value === '아니오') bad.push('철근 생성 방식');
|
||||
var unk = (f.ans_rebar_shape.value === '미확인' ? 1 : 0) + (f.ans_rebar_method.value === '미확인' ? 1 : 0);
|
||||
if (bad.length) { v.className = 'fail'; v.textContent = '■ 같은 템플릿으로 만들 수 없음 — 다른 문항: ' + bad.join(', ') + '. 가장 가까운 템플릿에 생성기·검사기를 추가해야 합니다. 진행하면 형식 불일치 경고가 교각에 계속 표시됩니다.'; mk.textContent = '그래도 만들기'; }
|
||||
else { v.className = 'ok'; v.textContent = '● 이 템플릿으로 만들 수 있음' + (unk ? ' (철근 문항 ' + unk + '개는 미확인)' : ''); mk.textContent = '교각 만들기'; }
|
||||
}
|
||||
f.addEventListener('input', judge); f.addEventListener('change', judge); judge();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,108 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_prism.html" import st_badge %}
|
||||
{% block title %}검토 요약 확정 · {{ p.name }}{% endblock %}
|
||||
{% block body %}
|
||||
<div class="prism">
|
||||
<div class="stack">
|
||||
<div class="stack" style="gap:var(--prism-space-100)">
|
||||
<span class="eyebrow">입력 묶음 · 검토 요약 확정</span>
|
||||
<h1 style="font-size:var(--prism-type-title);color:var(--prism-text-heading);margin:0">{{ p.name }} — 한 화면에서 검토하고 한 번에 확정</h1>
|
||||
</div>
|
||||
{% if not items %}
|
||||
<section class="strip"><p class="concl">확정이 필요한 묶음이 없습니다.</p>
|
||||
<p class="cap">필요한 묶음이 모두 확정되어 있습니다. 값을 고치면 해당 묶음만 다시 여기에 나타납니다.</p></section>
|
||||
<div class="row"><a class="btn" href="{{ url_for('pier', name=p.name) }}">교각 화면으로</a></div>
|
||||
{% else %}
|
||||
<section class="strip">
|
||||
<p class="concl">확정이 필요한 묶음 {{ items|length }}개 — 일치 {{ cnt.A }} · 주의·근거 없음 {{ cnt.B }} · 결정 기록 {{ cnt.D }} · 불일치 {{ cnt.C }}</p>
|
||||
<p class="cap">앱이 <b>무엇이 바뀌었는지</b>, <b>앱이 무엇을 확인했는지</b>, <b>앱이 확인하지 못한 것</b>을 정리했습니다. 확정은 사람의 기록이므로 <b>도면과 대조한 묶음에 직접 체크</b>해야 하고,
|
||||
체크하지 않은 묶음은 미확정으로 남습니다. 사유는 앱이 아는 사실(앱 확인·변경·출처)과 아래 의견으로 자동 기록됩니다.</p>
|
||||
</section>
|
||||
<form method="post" id="rf" class="stack">
|
||||
|
||||
{% if by_group.C %}
|
||||
<h2>불일치 {{ cnt.C }}개 — 확정할 수 없습니다</h2>
|
||||
<div class="banner dont" role="alert"><span class="m" aria-hidden="true">■</span><div>값을 고치거나 도면과 다른 이유를 해결한 뒤 다시 확인하세요(묶음을 열어 고치면 이 화면에 다시 나타납니다).</div></div>
|
||||
<div class="tw"><table class="t"><thead><tr><th scope="col">묶음</th><th scope="col">앱 확인</th><th scope="col">불일치 내용</th></tr></thead><tbody>
|
||||
{% for i in by_group.C %}<tr><td><a href="{{ url_for('evidence', name=p.name, which=i.file, key=i.key) }}">{{ i.id }}</a></td><td>{{ st_badge(i.st) }}</td><td>{{ i.summary }}</td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
{% endif %}
|
||||
|
||||
{% if by_group.B %}
|
||||
<h2>주의·근거 없음 {{ cnt.B }}개 — 도면과 대조한 것에 체크</h2>
|
||||
<div class="row"><button class="btn" type="button" id="all-on" data-flagged="{{ by_group.B|selectattr('flags')|list|length + by_group.D|selectattr('flags')|list|length }}">주의·근거 없음·결정 기록 모두 도면과 대조함으로 체크</button>
|
||||
<button class="btn quiet" type="button" id="all-off">모두 해제</button></div>
|
||||
<p class="cap">앱이 독립적으로 대조하지 못했거나 일부만 확인한 묶음입니다. 아래 "앱이 확인하지 못한 것"을 도면과 직접 대조한 뒤 체크하세요. 체크 하나가 그 묶음의 확정 기록입니다.</p>
|
||||
<div class="tw"><table class="t"><thead><tr><th scope="col">대조함</th><th scope="col">묶음</th><th scope="col">바뀐 것</th><th scope="col">앱이 확인하지 못한 것</th><th scope="col">값의 출처</th></tr></thead><tbody>
|
||||
{% for i in by_group.B %}
|
||||
<tr><td><input type="checkbox" name="ack" value="{{ i.id }}" aria-label="{{ i.id }} 도면과 대조함" style="width:1.4rem;height:1.4rem"></td>
|
||||
<td><b><a href="{{ url_for('evidence', name=p.name, which=i.file, key=i.key) }}">{{ i.id }}</a></b><br>{{ st_badge(i.st) }}{% if i.stale %} <span class="cap muted">변경됨(이전 확정 {{ i.prev[0] }})</span>{% endif %}</td>
|
||||
<td>{{ i.change.text }}{% for fl in i.flags %}<br><span class="b warn"><span class="m" aria-hidden="true">▲</span>{{ fl }}</span>{% endfor %}</td>
|
||||
<td>{% if i.unverified %}<ul style="margin:0;padding-left:1.1rem">{% for u in i.unverified[:3] %}<li>{{ u }}</li>{% endfor %}</ul>{% else %}{{ i.summary }}{% endif %}</td>
|
||||
<td>{% if i.prov %}{% for pv in i.prov %}<span class="chip">{{ pv }}</span> {% endfor %}{% else %}<span class="cap muted">기록 없음</span>{% endif %}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody></table></div>
|
||||
{% endif %}
|
||||
|
||||
{% if by_group.D %}
|
||||
<h2>사람 결정 기록 {{ cnt.D }}개 — 내용을 읽고 체크</h2>
|
||||
<div class="tw"><table class="t"><thead><tr><th scope="col">읽음</th><th scope="col">묶음</th><th scope="col">내용</th></tr></thead><tbody>
|
||||
{% for i in by_group.D %}
|
||||
<tr><td><input type="checkbox" name="ack" value="{{ i.id }}" aria-label="{{ i.id }} 읽었음" style="width:1.4rem;height:1.4rem"></td>
|
||||
<td><b>{{ i.id }}</b><br>{{ st_badge(i.st) }}</td>
|
||||
<td>{% if i.detail %}<ul style="margin:0;padding-left:1.1rem">{% for d in i.detail %}<li>{{ d }}</li>{% endfor %}</ul>
|
||||
<p class="cap muted">계획된 FAIL(승인된 예외)은 도면과 재료표의 알려진 차이를 받아들이는 사람의 결정입니다. 설계자 확인 전 임시 등록은 사유에 그렇게 적혀 있어야 합니다.</p>
|
||||
{% else %}{{ i.change.text }}{% endif %}{% for fl in i.flags %}<br><span class="b warn"><span class="m" aria-hidden="true">▲</span>{{ fl }}</span>{% endfor %}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody></table></div>
|
||||
{% endif %}
|
||||
|
||||
{% if by_group.A %}
|
||||
<h2>앱 확인 일치 {{ cnt.A }}개 — 근거를 훑어보세요</h2>
|
||||
<div class="tw"><table class="t"><thead><tr><th scope="col">묶음</th><th scope="col">바뀐 것</th><th scope="col">앱이 확인한 것</th></tr></thead><tbody>
|
||||
{% for i in by_group.A %}
|
||||
{% set c = (i.checks | selectattr('st', 'equalto', 'g') | selectattr('strength', 'in', ['S', 'F', 'C']) | list) %}
|
||||
<tr><td><b><a href="{{ url_for('evidence', name=p.name, which=i.file, key=i.key) }}">{{ i.id }}</a></b></td><td>{{ i.change.text }}</td>
|
||||
<td>{% if c %}{{ c[0].id }} {{ c[0].text }}{% if c[0].pos %} <span class="chip">{{ c[0].pos }}</span>{% endif %}{% else %}{{ i.summary }}{% endif %}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody></table></div>
|
||||
<label class="row" style="gap:.5rem"><input type="checkbox" name="ack_matched" value="1" style="width:1.4rem;height:1.4rem"> 일치 {{ cnt.A }}개의 근거를 읽었습니다(앱이 독립 근거와 같다고 확인한 묶음입니다)</label>
|
||||
{% endif %}
|
||||
|
||||
<h2>확정</h2>
|
||||
<div class="field"><label for="rby">확정자<span class="req">필수</span></label>
|
||||
<input id="rby" type="text" name="by" required style="max-width:20rem" disabled>
|
||||
<span class="help" id="rhint">목록을 끝까지 내리면 입력할 수 있습니다.</span></div>
|
||||
<div class="field"><label for="rnote">공통 의견 (선택)</label>
|
||||
<input id="rnote" type="text" name="note" style="max-width:40rem" placeholder="예: 운곡교 P5 도면(2026-10 개정)과 대조" disabled>
|
||||
<span class="help">모든 확정 기록의 사유 끝에 붙습니다. 앱이 쓰는 사유(앱 확인·변경·출처)는 따로 자동으로 남습니다.</span></div>
|
||||
<div class="row end"><a class="btn" href="{{ url_for('pier', name=p.name) }}">취소</a>
|
||||
<button class="btn primary" id="rok" type="submit" disabled>선택한 묶음 확정</button></div>
|
||||
<div id="rend" aria-hidden="true" style="height:1px"></div>
|
||||
</form>
|
||||
<script>
|
||||
(function () {
|
||||
var on = document.getElementById('all-on'), off = document.getElementById('all-off');
|
||||
function boxes() { return document.querySelectorAll('input[name=ack]'); }
|
||||
if (on) on.addEventListener('click', function () {
|
||||
var n = parseInt(on.getAttribute('data-flagged') || '0', 10);
|
||||
if (n && !confirm('주의 표시가 붙은 묶음 ' + n + '개(템플릿 원본 그대로이거나 설계자 확인 전 임시 등록)가 포함됩니다. 모두 도면과 대조했습니까?')) return;
|
||||
boxes().forEach(function (b) { b.checked = true; });
|
||||
});
|
||||
if (off) off.addEventListener('click', function () { boxes().forEach(function (b) { b.checked = false; }); });
|
||||
})();
|
||||
(function () {
|
||||
var end = document.getElementById('rend'), by = document.getElementById('rby'), note = document.getElementById('rnote'), ok = document.getElementById('rok'), hint = document.getElementById('rhint'), sent = false;
|
||||
function done() {
|
||||
if (sent) return; sent = true;
|
||||
fetch({{ url_for('review_read', name=p.name)|tojson }}, {method: 'POST'}).then(function () {
|
||||
by.disabled = false; note.disabled = false; ok.disabled = false; hint.textContent = '목록을 끝까지 읽었습니다. 이름을 입력하고 확정하세요.'; by.focus();
|
||||
});
|
||||
}
|
||||
if ('IntersectionObserver' in window) { new IntersectionObserver(function (e) { if (e[0].isIntersecting) done(); }).observe(end); } else { done(); }
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}문자 검색 · {{ p.name }}{% endblock %}
|
||||
{% block body %}
|
||||
<h1>도면 문자 검색</h1>
|
||||
{% if not audit %}<p class="fail">도면이 없습니다. 교각 화면에서 DWG를 먼저 올리세요.</p>{% endif %}
|
||||
<form>
|
||||
<input type="text" name="q" value="{{ q }}" size="40" placeholder="예: EL. 26.208, 9.350, @250">
|
||||
<label><input type="checkbox" name="regex" value="1" {% if regex %}checked{% endif %}> 정규식</label>
|
||||
<button>검색</button>
|
||||
</form>
|
||||
{% if err %}<p class="fail">{{ err }}</p>{% endif %}
|
||||
{% if q %}<p>{{ hits|length }}건{% if hits|length >= 200 %} (최대 200건만 표시){% endif %}</p>
|
||||
<div class="wrap"><table>
|
||||
<tr><th>도면</th><th>레이어</th><th>X</th><th>Y</th><th>글자 높이</th><th>문자</th></tr>
|
||||
{% for t in hits %}<tr><td>{{ t.dwg }}</td><td>{{ t.layer }}</td><td>{{ t.x }}</td><td>{{ t.y }}</td><td>{{ t.h }}</td><td>{{ t.text }}</td></tr>{% endfor %}
|
||||
</table></div>{% endif %}
|
||||
<h2>핸들 조회</h2>
|
||||
<p class="muted">CAD에서 확인한 객체 핸들(예: 21A76)로 선·폴리선·호·원·문자·블록의 좌표·길이·반지름을 봅니다. 값은 자동 반영하지 않습니다.</p>
|
||||
<form>
|
||||
<select name="dwg">{% for a in audit %}<option value="{{ a.label }}" {% if a.label == hdwg %}selected{% endif %}>{{ a.label }} · {{ a.source }}</option>{% endfor %}</select>
|
||||
<input type="text" name="h" value="{{ h }}" size="10" placeholder="핸들">
|
||||
<button>조회</button>
|
||||
</form>
|
||||
{% if ent %}<p>{{ hdwg }} · <code>{{ ent.handle }}</code> · {{ ent.type }} · 레이어 {{ ent.layer }}</p>
|
||||
<div class="wrap"><table>{% for k, v in ent['values'].items() %}<tr><th>{{ k }}</th><td><code>{{ dumps(v) }}</code></td></tr>{% endfor %}</table></div>{% endif %}
|
||||
<p class="muted">좌표는 도면(DXF) 좌표이며 모델 좌표가 아닙니다. 이 도면 세트에서는 치수가 치수 객체가 아니라 문자로 그려져 있어 문자 검색으로 찾습니다.</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ which }}.{{ key }}{% endblock %}
|
||||
{% block body %}
|
||||
<h1>{{ which }}.{{ key }}
|
||||
{% if st.confirmed %}<span class="ok">확정</span> <span class="muted">{{ st.by }} · {{ st.at }}</span>
|
||||
{% elif st.info %}<span class="muted">확정 불필요(참고)</span>
|
||||
{% elif st.stale %}<span class="warn">변경됨 · 재확정 필요</span>{% else %}<span class="muted">미확정</span>{% endif %}</h1>
|
||||
<!-- stage A 2026-10-16 -->
|
||||
<p><b>{{ meta.grade }} {{ grades[meta.grade] }}</b> · 값 출처 {{ meta.source|join(' · ') }}{% if meta.origin %} · <span class="warn">▲ 템플릿 원본 그대로</span>{% endif %}
|
||||
<span class="muted"> — {{ meta.note }}{% if meta.fixed_fields %} (고정 항목: {{ meta.fixed_fields|join(', ') }}){% endif %}</span></p>
|
||||
{% if meta.grade == 'V3' %}<p class="fail">■ 생성 변수(V3)는 화면에서 직접 고칠 수 없습니다. 판독 스크립트를 다시 실행해 목록을 만든 뒤 반영하세요. 저장 단추는 거부됩니다.</p>
|
||||
{% elif meta.grade == 'F' or meta.fixed_fields %}<p class="warn">▲ 이 묶음에는 고정(F) 항목이 있습니다. 바꾸면 같은 템플릿이 아닐 수 있으므로 <b>바꾸는 사유</b>를 입력해야 저장되고, 사유는 기록됩니다.</p>{% endif %}
|
||||
<p class="muted">{% if which == 'expected' %}검사 기대값입니다. 생성용 params와 따로, 도면 치수 문자·표에서 직접 옮긴 값이어야 합니다(독립성).{% else %}모델 생성용 매개변수입니다. 단위 mm, Z = EL × 1000, 교각 중심 원점(X 교축직각, Y 교축).{% endif %}
|
||||
값은 JSON으로 편집합니다. 최상위 형식(객체·목록·숫자 등)은 바꿀 수 없습니다.</p>
|
||||
<p class="muted">위치 목록은 치수 사슬로 입력할 수 있습니다: <code>{"chain": "-4575, 200, 2@125, 30@250"}</code>
|
||||
— 첫 값은 시작 위치, 나머지는 증분이며 <code>n@p</code>는 간격 p를 n번 반복합니다. 저장하면 숫자 목록으로 펼쳐집니다.</p>
|
||||
<form method="post">
|
||||
<textarea name="value" spellcheck="false">{{ text }}</textarea>
|
||||
{% if meta.grade == 'F' or meta.fixed_fields %}<p>고정 항목을 바꾸는 사유 <input type="text" name="reason" size="60"></p>{% endif %}
|
||||
<p><button>저장</button></p>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('section_confirm', name=p.name, which=which, key=key) }}">
|
||||
확정자 <input type="text" name="by" required>
|
||||
<input type="hidden" name="next" value="{{ url_for('section', name=p.name, which=which, key=key) }}">
|
||||
사유 <input type="text" name="why" size="50" placeholder="앱 확인이 '일치'가 아니면 필수">
|
||||
<button>저장된 값으로 확정</button> <a href="{{ url_for('evidence', name=p.name, which=which, key=key) }}">근거 보기</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user