feat(M01): 단가산출 로직 상세 차례 · 오른쪽 흐름도/원문 두 패널

- 좌측 차례 = 기본정보 › 시험 계산 › 단가산출 상세 › 텍스트 수식
- 오른쪽 패널 둘(흐름도 · 원문) — 하나만 열림 · 둘 다 접기 · 머리 토글 단추 · 열린 패널 계정 취향(m01-logic-panel)
- 흐름도 패널 접힘을 부르는 쪽이 정하는 길(folded · onFold · fold)
- 원문 패널 = 출처 글로 품셈 본문 절을 찾아 보임(GET /api/m01/logic/source) · 못 찾으면 「원문 연결 없음」 + 출처
- 시험 test_m01_logic_source.py · 화면 시나리오 ui_checks/44-4_로직상세_패널.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
This commit is contained in:
2026-10-05 11:31:09 +09:00
co-authored by Claude Opus 5.5
parent 576b12eb7d
commit 7d39f90a0e
11 changed files with 520 additions and 11 deletions
+55
View File
@@ -0,0 +1,55 @@
"""M01 로직 원문 — 출처 글 → 품셈 본문 절(44-4). 원본은 읽기만."""
from __future__ import annotations
import json
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Make as make
def test_build_civil_section_with_table() -> None:
got = make.source("건설품셈 토목 1-1-2")
assert len(got["found"]) == 1
one = got["found"][0]
assert one["matched"] == "1-1-2"
assert one["file"].startswith("원가계산/건설공사_표준품셈/본문/02_토목부문/제01장_")
assert one["blocks"][0]["h"] == 3 and one["blocks"][0]["text"].startswith("1-1-2 유도선")
table = next(b["table"] for b in one["blocks"] if "table" in b)
assert table[0][0] == "구분" and ["특별인부", "인", "2", "1,350", "1,560"] in table
# 다음 절(1-1-3 …) 앞에서 끊김 · 주석 줄 뺌
assert not any("1-1-3" in b.get("text", "") for b in one["blocks"])
assert not any(b.get("p", "").startswith("<!--") for b in one["blocks"])
def test_forest_and_many_numbers() -> None:
forest = make.source("산림품셈 3-4-2 1")["found"]
assert [x["matched"] for x in forest] == ["3-4-2"]
many = make.source("건설품셈 공통 8-1-5 · 8-1-6")["found"]
assert [x["number"] for x in many] == ["8-1-5", "8-1-6"]
def test_no_link() -> None:
assert make.source("현장 자체 단가")["found"] == []
assert make.source("건설품셈 토목 99-9-9")["found"] == []
def test_most_master_logics_link() -> None:
"""정본 로직 출처 대부분이 원문 절에 닿음."""
hit = total = 0
for path in store.FOLDER.glob("로직_*품셈_*.json"):
for row in json.loads(path.read_text(encoding="utf-8"))["줄"]:
total += 1
hit += bool(make.source(row.get("출처", ""))["found"])
assert total > 1000 and hit / total > 0.9, (hit, total)
def test_route() -> None:
app = FastAPI()
app.include_router(router_module.router)
got = TestClient(app).get("/api/m01/logic/source", params={"src": "산림품셈 3-1"}).json()
assert got["found"][0]["matched"] == "3-1"
@@ -0,0 +1,103 @@
/* 44-4 M01 단가산출 로직 상세 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎 · JSON 돌려줌.
*
* 시작 화면: M01 마스터 데이터(#/m01-master-data · 시스템 관리자 로그인) · 아무 화면이나.
* 로직 GC000277(건설품셈 토목 1-1-2 유도선 설치 및 해체)을 엶 · 고치지 않음 · 끝에 패널 취향을 처음 값으로 되돌림.
* 기대 수치:
* order = ["기본정보","시험 계산","단가산출 상세","텍스트 수식"](흐름 로직이면 셋째 · 넷째는 흐름 보기 안)
* flow = {flow:true, source:false, pref:"flow"} · 오른쪽 칸 너비 > 300
* source = {flow:false, source:true, pref:"source"} · srcHead 에 「1-1-2 유도선」 · srcTable 에 「특별인부」
* none = {flow:false, source:false, pref:"none"} · 오른쪽 칸 너비 = 80(띠 둘)
* back = {flow:true, source:false, pref:"flow"}(흐름도 띠 단추로 다시 폄)
* noLink = 「원문 연결 없음」 + 「출처 · …」(서버가 빈 목록을 줄 때 · fetch 를 잠시 바꿔 봄)
* restored = 처음 취향 값
*/
(async () => {
const W = (ms) => new Promise((r) => setTimeout(r, ms));
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
const until = async (f, ms = 15000) => {
for (let t = 0; t < ms; t += 200) {
const v = f();
if (v) return v;
await W(200);
}
return null;
};
const PREF = "aislo:pref:m01-logic-panel";
const first = localStorage.getItem(PREF);
const out = {};
const openLogic = async (key) => {
sessionStorage.setItem("m01.logic.pending", key);
// 왼쪽 「단가산출 로직」 컨테이너 제목 — 펼칠 때 로직 화면으로(펼쳐져 있으면 접었다 폄)
const tab = $$(".ui-collapsible__title").find((h) => h.innerText.trim() === "단가산출 로직");
if (!tab) return false;
if (!tab.closest(".ui-collapsible").classList.contains("is-collapsed")) tab.click();
tab.click();
return until(() => {
const t = $(".m02-dfp__title")?.innerText ?? "";
return !$(".m01lab-flow")?.hidden && $(".m01lab-side .m02-dfp") && t && t !== "";
});
};
if (!(await openLogic("GC000277"))) return JSON.stringify({ error: "로직 못 엶" });
await W(800);
out.order = $$(".m01lab-flow__left .m01lab__box > .m01-logic__section-head h3").map((h) =>
h.innerText.trim(),
);
const slots = () => $$(".m01lab-side__slot");
const state = () => ({
flow:
slots()[0].classList.contains("is-open") &&
!$(".m02-dfp", slots()[0]).classList.contains("is-folded"),
source: slots()[1].classList.contains("is-open") && !$(".m02-dfp__open", slots()[1]).hidden,
pref: localStorage.getItem(PREF),
width: Math.round($(".m01lab-flow__right").getBoundingClientRect().width),
});
const flowToggle = () => $(".m02-dfp__toggle", slots()[0]);
const srcToggle = () => $(".m02-dfp__toggle", slots()[1]);
// 처음 = 흐름도 열림으로 맞춤
if (!state().flow) {
if (state().source) srcToggle().click();
await W(100);
flowToggle().click();
await W(400);
}
out.flow = state();
srcToggle().click();
await until(() => $(".m01lab-src__head"));
out.source = state();
out.srcHead = $(".m01lab-src__head")?.innerText;
out.srcFile = $(".m01lab-src__file")?.innerText;
out.srcTable = $$(".m01lab-src__table td").some((td) => td.innerText === "특별인부");
srcToggle().click();
await W(400);
out.none = state();
flowToggle().click();
await W(600);
out.back = state();
// 원문 연결 없음 — 서버가 빈 목록을 줄 때
srcToggle().click();
const real = window.fetch;
window.fetch = (u, o) =>
String(u).includes("/m01/logic/source")
? Promise.resolve(new Response(JSON.stringify({ found: [] }), { status: 200 }))
: real(u, o);
try {
await openLogic("GC000338");
await until(() => $(".m01lab-src__none"));
out.noLink = [
$(".m01lab-src__none")?.innerText,
$(".m01lab-src__body .m01-logic__muted")?.innerText,
];
} finally {
window.fetch = real;
}
// 되돌림 — 처음 취향(없으면 흐름도)
const want = first ?? "flow";
if (want !== "source") srcToggle().click();
await W(200);
if (want === "flow") flowToggle().click();
await W(300);
out.restored = localStorage.getItem(PREF) ?? "flow";
out.first = first;
return JSON.stringify(out);
})();