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
+6
View File
@@ -190,6 +190,12 @@ def get_logic(key: str) -> dict:
return _call(store.logic, key)
@router.get("/logic/source")
def get_logic_source(src: str) -> dict:
"""로직 원문 — 출처 글로 찾은 품셈 본문 절(읽기만)."""
return _call(make.source, src)
@router.get("/logic/auto")
def get_logic_auto(key: str) -> dict:
"""견본 입력 — 마스터 보기용 자동값(프로젝트 기본값 아님) · 흐름 로직은 설계 예시값."""
@@ -103,6 +103,79 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi
return {"total": len(hits), "sections": hits[: min(max(limit, 1), 1000)]}
# ── 로직 원문(출처 글 → 품셈 본문 절) ──────────────────────────────────
_NUMBER = re.compile(r"(?<![\d-])\d+(?:-\d+)+(?![\d-])")
def _blocks(lines: list[str]) -> list[dict]:
"""md 줄 → 화면 조각 — 제목 {h} · 표 {table} · 글 {p} · 주석 줄은 뺌."""
out: list[dict] = []
for line in lines:
text = line.strip()
if not text or text.startswith("<!--"):
continue
if text.startswith("#"):
out.append({"h": len(text) - len(text.lstrip("#")), "text": text.lstrip("#").strip()})
elif text.startswith("|"):
cells = [c.strip() for c in text.strip("|").split("|")]
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
continue # 머리 · 몸 가름 줄
if not out or "table" not in out[-1]:
out.append({"table": []})
out[-1]["table"].append(cells)
else:
out.append({"p": text})
return out
def _section(book: str, division: str, number: str) -> dict | None:
"""번호 절을 품셈 본문에서 찾음 — 없으면 끝 마디를 하나씩 떼며 위 절로(두 마디까지)."""
parts = number.split("-")
folders = [f for d, no, _t, f in _chapters(book) if d == division and int(no) == int(parts[0])]
for upto in range(len(parts), 1, -1):
want = "-".join(parts[:upto])
for folder in folders:
for md in sorted(folder.glob("*.md")):
lines = md.read_text(encoding="utf-8").split("\n")
for i, line in enumerate(lines):
m = _HEAD.match(line)
if not m or m.group(1) != want:
continue
level = len(line) - len(line.lstrip("#"))
end = next(
(
j
for j in range(i + 1, len(lines))
if _HEAD.match(lines[j])
and len(lines[j]) - len(lines[j].lstrip("#")) <= level
),
len(lines),
)
return {
"number": number,
"matched": want,
"file": md.relative_to(BODY.parent).as_posix(),
"blocks": _blocks(lines[i:end]),
}
return None
def source(src: str) -> dict:
"""로직 출처 글(예 「건설품셈 토목 1-1-2」 · 「산림품셈 3-4-2 1」)로 찾은 품셈 원문 절 —
번호가 여럿이면(「8-1-5 · 8-1-6」) 절마다 · 못 찾으면 found 빈 목록(화면 「원문 연결 없음」)."""
words = src.split()
book = words[0] if words else ""
if book not in BOOKS:
return {"source": src, "found": []}
division = (
next((d for d in cm.DIVISIONS if src.startswith(f"{book} {d} ")), "")
if book == "건설품셈"
else ""
)
found = [_section(book, division, n) for n in dict.fromkeys(_NUMBER.findall(src))]
return {"source": src, "found": [x for x in found if x]}
# ── 표 조건 값 ─────────────────────────────────────────────────────────
def table_options(file: str, key: str) -> dict:
"""표 조건 칸의 값만 — 표 몸을 통째로 받지 않으려는 자리(만들기 입력 제안)."""
+13 -3
View File
@@ -52,6 +52,7 @@ import {
type LogicFlow,
} from "./M01_MasterData_UI_LogicLab_Flow";
import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic";
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
import { buildHeadBar } from "./M01_MasterData_UI_Head";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
@@ -129,6 +130,8 @@ export async function mountM01LogicLab(
const editor = el("div", { className: "m01-logic__editor" });
// 로직 상세 두 칸 — 왼쪽 지금 내용 · 오른쪽 흐름도(패널 자리) · 너비 비율 기억
const split = buildSplit(editor);
// 오른쪽 두 패널(흐름도 · 원문) — 하나만 열림 · 흐름도는 이 칸 안에 띄움
const panes = buildLogicSide(split);
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
const calc = el("div", { className: "m01lab__calc" });
const filterHost = el("div");
@@ -273,7 +276,9 @@ export async function mountM01LogicLab(
const startFlow = async (o: Opened): Promise<void> => {
const row = o.row;
if (!row) return;
split.right.replaceChildren(el("p", { className: "m01lab-flow__wait", text: tf("Waiting") }));
panes.flowSplit.right.replaceChildren(
el("p", { className: "m01lab-flow__wait", text: tf("Waiting") }),
);
const own = flowOf(row);
const got = own || o.origKey === null ? null : await fetchFlow(o.origKey, o.file);
// 시험 입력 한 벌 — 열 때 왼쪽 시험 계산 입력을 흐름 변수 예시값으로
@@ -284,7 +289,9 @@ export async function mountM01LogicLab(
if (opened !== o) return;
try {
const made = await mountLogicFlow({
split,
split: panes.flowSplit,
folded: panes.flowFolded(),
onFold: (on) => panes.pick(on ? "none" : "flow"),
flow: clone(o.flowBase),
title: row.이름 || row.키,
readOnly: !admin,
@@ -307,9 +314,10 @@ export async function mountM01LogicLab(
});
if (opened !== o) return made.destroy();
flowNow = made;
panes.bindFlow(made.fold);
made.setExamples(o.values); // 띄우는 사이에 온 자동값까지
} catch (e) {
split.right.replaceChildren(
panes.flowSplit.right.replaceChildren(
el("p", { className: "m01-logic__muted", text: tf("Failed", String(e)) }),
);
}
@@ -344,7 +352,9 @@ export async function mountM01LogicLab(
const show = (next: Opened | null): void => {
flowNow?.destroy();
flowNow = null;
panes.bindFlow(null);
opened = next;
panes.showSource(next?.row?.출처 ?? "");
errors.hidden = true;
drawEditor();
drawCalc();
@@ -1,8 +1,8 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Detail.ts
* 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 단가산출 상세(인력·자재·경비·그 밖 묶음 + 소계)
* ③ 텍스트 수식(자리 — `_LogicLab_Formula.ts`) ④ 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
* ③ 단가산출 상세(인력·자재·경비·그 밖 묶음 + 소계) ④ 텍스트 수식(자리 — `_LogicLab_Formula.ts`)
* 호표 줄을 누르면 그 줄이 쓰는 자료 모달(`_LogicLab_Modal.ts`)
* ========================================================================== */
@@ -70,7 +70,7 @@ export interface DetailContext {
/** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */
ownHost: HTMLElement;
onChange: () => void;
/** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */
/** 시험 계산 칸(`buildCalc` 가 채움) — 둘째 컨테이너 안에 놓음 */
calcHost: HTMLElement;
/** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 ·
* 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */
@@ -487,8 +487,8 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
: null;
host.replaceChildren(
infoBox(ctx),
...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]),
section(tl("Calc_Title"), [ctx.calcHost]),
...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]),
);
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
const back = own ? host.querySelector<HTMLInputElement>(`[data-own="${own}"]`) : kept?.node;
@@ -235,6 +235,9 @@ export interface LogicFlowOpts {
/** 흐름 로직이면 왼쪽 「지금 내용」 자리(LogicLab_Detail 의 flowHost) */
leftHost(): HTMLElement | null;
onChange(lg: CfLogic): void;
/** 패널 접힘 — 부르는 쪽(오른쪽 두 패널)이 정함 */
folded?: boolean;
onFold?(on: boolean): void;
}
export interface LogicFlow {
@@ -244,6 +247,8 @@ export interface LogicFlow {
redrawLeft(): void;
/** 왼쪽 시험 계산 입력이 바뀜 — 같은 이름 변수의 예시값으로(고친 것 아님) */
setExamples(vals: Record<string, string>): void;
/** 패널 접기 · 펴기 */
fold(on: boolean): void;
destroy(): void;
}
@@ -312,6 +317,8 @@ export async function mountLogicFlow(opts: LogicFlowOpts): Promise<LogicFlow> {
readOnly: opts.readOnly,
logics: getLogic,
onResize: (px) => opts.split.setRight(px),
folded: opts.folded,
onFold: opts.onFold,
onChange: (next) => {
lg = next;
opts.onChange(next);
@@ -329,6 +336,7 @@ export async function mountLogicFlow(opts: LogicFlowOpts): Promise<LogicFlow> {
refresh();
},
redrawLeft: left,
fold: (on) => panel.fold(on),
setExamples: (vals) => {
const next = withExamples(lg, vals);
if (JSON.stringify(next) === JSON.stringify(lg)) return;
@@ -0,0 +1,54 @@
/* 로직 상세 오른쪽 두 패널 — 흐름도 · 원문 · 접힌 패널은 얇은 띠 */
.m01lab-side {
display: flex;
align-items: stretch;
min-width: 0;
}
.m01lab-side__slot {
flex: 0 0 40px;
min-width: 0;
}
.m01lab-side__slot.is-open {
flex: 1 1 auto;
}
/* 접힌 흐름도 자리 — 띠만(기다림 글 · 변환 알림은 숨김) */
.m01lab-side__slot:not(.is-open) > :not(.m01lab-flow__panel, .m01lab-src) {
display: none;
}
.m01lab-src__body {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
max-height: 80vh;
overflow: auto;
}
.m01lab-src__head {
margin: var(--spacing-8) 0 0;
}
.m01lab-src__p {
margin: 0;
white-space: pre-wrap;
line-height: 1.6;
}
.m01lab-src__file {
margin: 0;
font-size: var(--text-body-sm);
word-break: break-all;
}
.m01lab-src__none {
margin: 0;
font-weight: 600;
}
.m01lab-src__table td {
white-space: nowrap;
}
@@ -0,0 +1,191 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Side.ts
* 로직 상세 오른쪽 두 패널 — 「흐름도」(다이어그램 · 상세 내용 = M02_DF_Panel) · 「원문」(품셈 본문 절)
* 둘 중 하나만 열림(하나 열면 다른 하나 접힘) · 둘 다 접기 됨 · 접힌 패널 = 얇은 띠(머리 단추 그대로)
* 열린 패널은 계정 취향(`m01-logic-panel`)으로 기억 · 원문은 출처 글로 서버가 찾음(`GET /m01/logic/source`)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import { API_BASE_URL } from "@config/config_frontend";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import type { SplitHandle } from "./M01_MasterData_UI_LogicLab_Flow";
import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css";
import "./M01_MasterData_UI_LogicLab_Side.css";
const TEXT = {
Toggle: ["원문", "Source"],
Open: ["원문 펴기", "Open the source panel"],
Close: ["원문 접기", "Collapse the source panel"],
Loading: ["원문 찾는 중", "Looking up the source"],
None: ["원문 연결 없음", "No linked source"],
From: ["출처", "Source"],
NoSource: ["출처 없음", "No source"],
Near: ["{v} 절이 없어 위 절", "No {v} section — showing the parent"],
} as const;
const ts = (key: keyof typeof TEXT, v = ""): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
/** 접힌 띠 너비 — M02_DF_Panel 의 STRIP_WIDTH 와 같음(패널 모듈은 늦게 받으므로 따로 둠) */
const STRIP = 40;
export type PanelPick = "flow" | "source" | "none";
interface Block {
h?: number;
text?: string;
p?: string;
table?: string[][];
}
interface Found {
number: string;
matched: string;
file: string;
blocks: Block[];
}
export interface LogicSide {
/** 흐름도 패널에 넘길 칸 — right = 흐름도 자리 · setRight = 흐름도 패널이 알린 너비 */
flowSplit: SplitHandle;
flowFolded(): boolean;
pick(next: PanelPick): void;
/** 흐름도 패널이 뜨면 접기 함수 · 내리면 null */
bindFlow(fold: ((on: boolean) => void) | null): void;
/** 연 로직의 출처 글 — 원문 패널이 열려 있으면 찾음 */
showSource(src: string): void;
}
const readPick = (): PanelPick => {
const got = readStateRaw("m01-logic-panel");
return got === "source" || got === "none" ? got : "flow";
};
function blockNode(b: Block): HTMLElement {
if (b.table)
return el("div", {
className: "m01-logic__scroll",
children: [
el("table", {
className: "m01-logic__grid m01lab-src__table",
children: [
el("tbody", {
children: b.table.map((r) =>
el("tr", { children: r.map((c) => el("td", { text: c })) }),
),
}),
],
}),
],
});
if (b.h) return el("h4", { className: "m01lab-src__head", text: b.text ?? "" });
return el("p", { className: "m01lab-src__p", text: b.p ?? "" });
}
export function buildLogicSide(split: SplitHandle): LogicSide {
const flowSlot = el("div", { className: "m01lab-side__slot" });
const body = el("div", { className: "m01lab-src__body" });
const title = el("strong", { className: "m02-dfp__title" });
const toggle = createButton({
label: ts("Toggle"),
size: "sm",
variant: "ghost",
onClick: () => pick(now === "source" ? "none" : "source"),
});
toggle.classList.add("m02-dfp__toggle");
const head = el("header", { className: "m02-dfp__head", children: [toggle, title] });
const open = el("div", { className: "m02-dfp__open", children: [head, body] });
const strip = el("div", { className: "m02-dfp__strip" });
const source = el("aside", { className: "m02-dfp m01lab-src", children: [open, strip] });
const sourceSlot = el("div", { className: "m01lab-side__slot", children: [source] });
split.right.replaceChildren(
el("div", { className: "m01lab-side", children: [flowSlot, sourceSlot] }),
);
let now = readPick();
let flowPx: number | null = null;
let flowFold: ((on: boolean) => void) | null = null;
let want = "";
let shown: string | null = null;
/** 칸 너비 — 원문(또는 흐름도가 아직 안 뜸) = 두 칸 비율 · 흐름도 = 패널 너비 + 띠 · 둘 다 접힘 = 띠 둘 */
const width = (): void =>
split.setRight(
now === "source" || (now === "flow" && flowPx === null)
? null
: now === "flow"
? (flowPx ?? 0) + STRIP
: STRIP * 2,
);
const load = (): void => {
if (now !== "source" || shown === want) return;
const src = (shown = want);
title.textContent = src || ts("NoSource");
body.replaceChildren(el("p", { className: "m01-logic__muted", text: ts("Loading") }));
const none = (): void =>
body.replaceChildren(
el("p", { className: "m01lab-src__none", text: ts("None") }),
el("p", { className: "m01-logic__muted", text: `${ts("From")} · ${src || "—"}` }),
);
if (!src.trim()) return none();
void fetch(`${API_BASE_URL}/m01/logic/source?${new URLSearchParams({ src })}`, {
credentials: "include",
})
.then((r) => (r.ok ? (r.json() as Promise<{ found: Found[] }>) : { found: [] }))
.catch(() => ({ found: [] as Found[] }))
.then(({ found }) => {
if (shown !== src) return;
if (!found.length) return none();
body.replaceChildren(
...found.flatMap((f) => [
el("p", {
className: "m01-logic__muted m01lab-src__file",
text: f.matched === f.number ? f.file : `${f.file} · ${ts("Near", f.number)}`,
}),
...f.blocks.map(blockNode),
]),
);
});
};
const fold = (on: boolean): void => {
open.hidden = on;
strip.hidden = !on;
(on ? strip : head).prepend(toggle);
toggle.setAttribute("aria-expanded", String(!on));
toggle.title = ts(on ? "Open" : "Close");
};
const pick = (next: PanelPick): void => {
if (next !== now) writeStateRaw("m01-logic-panel", next);
now = next;
flowFold?.(now !== "flow"); // 칸을 줄이기 전에 — 흐름도 패널이 접기 전 너비를 재어 둠
flowSlot.classList.toggle("is-open", now === "flow");
sourceSlot.classList.toggle("is-open", now === "source");
fold(now !== "source");
width();
load();
};
pick(now);
return {
flowSplit: {
root: split.root,
right: flowSlot,
setRight: (px) => {
flowPx = px;
width();
},
},
flowFolded: () => now !== "flow",
pick,
bindFlow: (f) => {
flowFold = f;
},
showSource: (src) => {
want = src;
load();
},
};
}