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:
@@ -64,6 +64,8 @@ export const STATE_REGISTRY = {
|
||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||
/** M02 면적산출 카드 미리보기 아래 알약 줄 보임(기본 숨김 · 상세는 늘 보임) */
|
||||
"m02-card-pills": { bucket: "pref", scope: "global" },
|
||||
/** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */
|
||||
"m01-logic-panel": { bucket: "pref", scope: "global" },
|
||||
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
|
||||
"routeedit-view": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
|
||||
|
||||
@@ -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:
|
||||
"""표 조건 칸의 값만 — 표 몸을 통째로 받지 않으려는 자리(만들기 입력 제안)."""
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,9 @@ export interface FlowPanelOpts {
|
||||
logics?(key: string): Promise<CfLogic | null>;
|
||||
/** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */
|
||||
onResize?(px: number): void;
|
||||
/** 접힘을 부르는 쪽이 정함(처음 값) — 주면 접기 단추는 onFold 만 부르고 기억도 부르는 쪽 몫 */
|
||||
folded?: boolean;
|
||||
onFold?(on: boolean): void;
|
||||
}
|
||||
|
||||
export interface FlowPanel {
|
||||
@@ -49,6 +52,8 @@ export interface FlowPanel {
|
||||
destroy(): void;
|
||||
/** 크게 보기 열기 */
|
||||
expand(): void;
|
||||
/** 밖에서 접기 · 펴기(onFold 를 부르지 않음) */
|
||||
fold(on: boolean): void;
|
||||
}
|
||||
|
||||
/* ── 너비(DOM 없음) ─────────────────────────────────────────────────── */
|
||||
@@ -105,8 +110,9 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
|
||||
});
|
||||
const { diagram, detail, summary, note, undo } = paper.parts;
|
||||
let width = storedWidth(readStore(WIDTH_KEY));
|
||||
let folded = readStore(FOLD_KEY) === "1";
|
||||
const tell = (): void => opts.onResize?.(folded ? STRIP_WIDTH : (width ?? host.clientWidth));
|
||||
let folded = opts.folded ?? readStore(FOLD_KEY) === "1";
|
||||
const tell = (): void =>
|
||||
opts.onResize?.(folded ? STRIP_WIDTH : (width ?? Math.max(PANEL_MIN, host.clientWidth)));
|
||||
|
||||
const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") =>
|
||||
createButton({ label, size: "sm", variant, onClick });
|
||||
@@ -116,7 +122,7 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
|
||||
label: tf("Toggle"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => setFolded(!folded),
|
||||
onClick: () => (opts.onFold ? opts.onFold(!folded) : setFolded(!folded)),
|
||||
});
|
||||
toggle.classList.add("m02-dfp__toggle");
|
||||
const head = el("header", {
|
||||
@@ -215,7 +221,7 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
|
||||
if (on && !folded && width === null && root.isConnected)
|
||||
width = Math.round(root.getBoundingClientRect().width);
|
||||
folded = on;
|
||||
writeStore(FOLD_KEY, on ? "1" : "0");
|
||||
if (!opts.onFold) writeStore(FOLD_KEY, on ? "1" : "0");
|
||||
root.classList.toggle("is-folded", on);
|
||||
open.hidden = on;
|
||||
strip.hidden = !on;
|
||||
@@ -290,5 +296,6 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
|
||||
root.remove();
|
||||
},
|
||||
expand,
|
||||
fold: (on) => on !== folded && setFolded(on),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
Reference in New Issue
Block a user