From af6797388472d6188a542b3c4a6174653c77bc4a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 15:34:53 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(804-9):=20=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=2021=20=EB=8D=94=ED=95=A8=20=C2=B7=20?= =?UTF-8?q?=ED=91=9C=20=EA=B0=92=20=EC=A7=81=EC=A0=91=20=EC=B9=A8=20?= =?UTF-8?q?=EB=B3=84=EC=B9=AD=20=EC=9C=A0=EC=A7=80=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EC=8B=9D=20=EB=AC=B6=EC=9D=8C=20=EC=97=AC=EB=9F=AC=20=EC=97=B4?= =?UTF-8?q?(=EC=9A=94=EA=B5=AC=20692-1=20=C2=B7=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단위표준.json 「더한 단위」 21(수식 문서 항목 줄 단위 · 표에 없던 것) · 「같은 단위」 kg → ㎏ · cm → ㎝(801-5 원문 꼴 하나만) · 산림품셈 원문 줄은 그대로 - 단위 고르기 = 표 줄 + 더한 단위 · 같은 단위는 원문 꼴로 모음 · 문서 항목 줄 단위 5,912 전수가 표에 있음 - 표 값 칸에 숫자 · 식을 치면 표 · 칸 · 조건을 남김 → 화면 이름(804-5 별칭) 그대로 · 꼬리표 「직접 입력」 · 서버 셈 = 표 정보 없는 숫자와 같음 - 공식 묶음 = 폭만큼 여러 열(auto-fill 19rem · M01 1366 · 1920 = 2열 · 좁으면 1열) · 머리 · 식 글 · 고치는 줄 = 열 합침 · 긴 이름 · 표 단추 · 꼬리표 = 줄임표 + 마우스 글 - 부품 상자 단추 clip 꼴 더함(긴 글 줄임 · title 전체) - test_804_7 셋 더함 · helper 「직접」 할일 Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack.css | 47 +++++++++- A00_Common/line_stack_rows.ts | 27 ++++-- A00_Common/line_stack_text.ts | 2 + .../DataFlow/M02_DF_UnitPick.ts | 19 +++-- .../DataFlow/_standard/단위표준.json | 30 ++++++- common_util/common_util_line_stack.ts | 5 +- common_util/common_util_line_stack_pick.ts | 5 +- resources/tester/helper_804_2_line_stack.cjs | 15 ++++ resources/tester/test_804_7_line_stack_fix.py | 85 +++++++++++++++++++ ui_template/ui_template_button.css | 11 +++ ui_template/ui_template_button.ts | 6 ++ 11 files changed, 233 insertions(+), 19 deletions(-) diff --git a/A00_Common/line_stack.css b/A00_Common/line_stack.css index 97e9a1ed8..37fa8fe6b 100644 --- a/A00_Common/line_stack.css +++ b/A00_Common/line_stack.css @@ -162,14 +162,55 @@ background: var(--color-surface-raised); } +/* 공식 묶음 = 폭만큼 여러 열(요구 692-3 · 자동 채움) · 머리 · 식 글 줄 · 고치는 줄 = 열 합침 · 긴 이름 = 줄임표 */ .lstk-line.k-공식 { - flex-direction: column; - flex-wrap: nowrap; - align-items: stretch; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); + align-items: center; + column-gap: var(--spacing-16); position: relative; padding-inline-start: var(--spacing-24); } +.lstk-line.k-공식 > .lstk-fhead, +.lstk-line.k-공식 > .lstk-frow:is(.is-open, :has(> .lstk-expr)) { + grid-column: 1 / -1; +} + +.lstk-line.k-공식 > .lstk-frow:not(.is-open, :has(> .lstk-expr)) { + flex-wrap: nowrap; +} + +.lstk-line.k-공식 > .lstk-frow:not(.is-open) > .lstk-f-m { + width: 6em; +} + +.lstk-frow > .lstk-sym { + flex: none; + max-width: 50%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lstk-frow > .lstk-f-m, +.lstk-frow > .lstk-unit, +.lstk-rtools { + flex: none; +} + +.lstk-rtools { + display: inline-flex; + gap: var(--spacing-4); +} + +.lstk-frow > .lstk-tag { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .lstk-line > .lstk-x, .lstk-fhead > .lstk-x { margin-inline-start: auto; diff --git a/A00_Common/line_stack_rows.ts b/A00_Common/line_stack_rows.ts index 2a533f817..4ce169010 100644 --- a/A00_Common/line_stack_rows.ts +++ b/A00_Common/line_stack_rows.ts @@ -56,6 +56,10 @@ const LINK_TAG: Record = { 입력: "입력", }; +/** 출처 꼬리표 — 긴 글은 칸 안에서 줄임표 · 마우스 글에 전체(요구 692-3) */ +const clipTag = (text: string): HTMLElement => + el("span", { className: "lstk-tag", text, attrs: { title: text } }); + const input = ( value: string, placeholder: string, @@ -107,7 +111,7 @@ export function buildBlock( const tplName = (k: string): string => k.startsWith("my:") ? lt("My_Tpl") : (h.subDoc(k)?.이름 ?? (h.name(k) || k)); // my: = 옛 내 틀(걷음 · 요구 685) const changed = !!line.틀 && !!line.틀꼴 && shapeOf(d, line.기호) !== line.틀꼴; - const tableOnly = line.기호.every((id) => vars.get(id)?.연결?.갈래 === "표" && !lines.has(id)); + const tableOnly = line.기호.every((id) => !!vars.get(id)?.연결?.표 && !lines.has(id)); // 직접 친 표 값도 const label = !line.틀 ? lt(tableOnly && line.기호.length ? "K_표값" : "K_공식") : changed @@ -148,6 +152,7 @@ export function buildBlock( .join(" · ") + " ▾", // 고른 줄(글 조건) · 칸 variant: "ghost", size: 24, + clip: true, // 긴 줄 이름 = 칸 안에서 줄임(요구 692-3) onClick: () => void h.table(String(link.표 ?? "")).then((t) => { if (!t) return void showToast(lt("Tbl_Missing"), "warning"); @@ -161,7 +166,7 @@ export function buildBlock( }), }); b.dataset.tablePick = id; - b.title = h.name(String(link.표 ?? "")) || String(link.표 ?? ""); + b.title = `${b.title} — ${h.name(String(link.표 ?? "")) || String(link.표 ?? "")}`; return b; }; @@ -221,12 +226,17 @@ export function buildBlock( const tag = link?.갈래 === "표" && !expr ? tableBtn(id, link) - : link && LINK_TAG[link.갈래 ?? ""] && !expr + : link?.갈래 === "숫자" && link.표 && !expr ? el("span", { className: "lstk-tag", - text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`, + text: lt("Direct"), + attrs: { title: lt("Direct_Hint") }, }) - : null; + : link && LINK_TAG[link.갈래 ?? ""] && !expr + ? clipTag( + `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`, + ) + : null; const tools: HTMLElement[] = []; if (sub?.결과 && link?.키) tools.push( @@ -259,6 +269,7 @@ export function buildBlock( }), ); const name = tap(el("b", { className: "lstk-sym", text: show(id) }), id); + name.title = `${show(id)} — ${name.title}`; // 줄임표로 잘려도 전체 이름 const valueOf = expr ?? (link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : ""); @@ -318,7 +329,11 @@ export function buildBlock( ]; const row = el("div", { className: `lstk-frow ${open ? "is-open" : ""}`, - children: [...body, ...(tag ? [tag] : []), ...tools], + children: [ + ...body, + ...(tag ? [tag] : []), + ...(tools.length ? [el("span", { className: "lstk-rtools", children: tools })] : []), + ], }); row.dataset.sym = id; return row; diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index cb8b97d79..af62fec9f 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -65,6 +65,8 @@ const TEXT = { Repick: ["찾아 바꾸기", "Find and change"], Close: ["닫기", "Close"], Tbl: ["표", "Table"], + Direct: ["직접 입력", "Typed"], + Direct_Hint: ["표 값을 손으로 고침 — 표 연결 끊김", "Table value typed by hand — table link off"], Tbl_Missing: ["표를 못 읽음", "Table not found"], Tbl_Hint: ["값 칸을 누르면 그 값이 들어감", "Click a value cell to use it"], Tbl_Bound: [ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts b/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts index d21eceab2..e2eb8e686 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_DF_UnitPick.ts * 단가산출 흐름도 공용 단위 고르기 — 치면 걸러진 목록 · 고르기만(손글 안 받음 · `free` = 목록에 없는 친 글도 씀) · 「-」 = 없음 - * 목록 = 단위 표준(산림품셈 1-2-2) 단위 + 부른 쪽이 주는 마스터 결과단위 등 + 원 + * 목록 = 단위 표준(산림품셈 1-2-2) 단위 + 표의 「더한 단위」(수식 문서 줄 단위) + 부른 쪽이 주는 마스터 결과단위 등 + 원 · 「같은 단위」(kg → ㎏) 는 하나만 * 표준 표 읽는 길은 `setUnitSource` 로 바꿔 끼움(기본 = 서버 /standards) — 다른 판도 이것을 씀 * 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ @@ -43,13 +43,22 @@ interface StandardRow { /** 서버 표준 표(/standards) — 고친 표가 있으면 그것, 없으면 처음 값 */ const fromServer: UnitSource = async () => { - const table = (await cfStandards()).unit as { 줄: StandardRow[] }; - const units = table.줄 - .flatMap((r) => [r["규격 단위"], r["단위수량 단위"]]) + const table = (await cfStandards()).unit as { + 줄: StandardRow[]; + "더한 단위"?: string[]; + "같은 단위"?: Record; + }; + const same = (u: string | null | undefined): string | null | undefined => + (u && table["같은 단위"]?.[u]) || u; // kg → ㎏ 등 원문 꼴 하나만(요구 692) + const units = [ + ...table.줄.flatMap((r) => [r["규격 단위"], r["단위수량 단위"]]), + ...(table["더한 단위"] ?? []), + ] + .map(same) .filter((u): u is string => !!u); const count: Record> = {}; for (const r of table.줄) { - const u = r["단위수량 단위"]; + const u = same(r["단위수량 단위"]); const p = r["단위수량 소수자리"]; if (!u || u === "없음" || typeof p !== "number") continue; count[u] = { ...count[u], [p]: (count[u]?.[p] ?? 0) + 1 }; diff --git a/M02_MasterTemplete/DataFlow/_standard/단위표준.json b/M02_MasterTemplete/DataFlow/_standard/단위표준.json index a8a9f23fb..ed0645dc9 100644 --- a/M02_MasterTemplete/DataFlow/_standard/단위표준.json +++ b/M02_MasterTemplete/DataFlow/_standard/단위표준.json @@ -676,5 +676,33 @@ "① 설계서 수량의 단위와 소수자리 표시는 본 표에 따르며, 반올림하여 적용한다.", "② 품셈 각 항목에서 제시한 소숫자리가 본 표의 내용과 상이할 경우 항목에서 제시하는 소숫자리를 우선하여 적용한다.", "③ 본 표에 제시하지 않은 품의 경우 유사품의 규격과 단위수량을 참고하여 적용하며, C.G.S 단위로 하는 것을 원칙으로 한다." - ] + ], + "더한 단위": [ + "시간", + "식", + "㎏", + "L", + "본", + "㎾h", + "조", + "EA", + "대", + "Sh", + "g", + "동", + "통", + "병", + "㎝", + "D/M", + "연", + "SET", + "회", + "포", + "족" + ], + "같은 단위": { + "kg": "㎏", + "cm": "㎝" + }, + "더함": "「더한 단위」 = 표에 없는 수식 문서 항목 줄 단위(요구 692) · 「같은 단위」 = 원문 꼴(건설품셈 1-2-2 · ㎏ · ㎝)로 하나만 보임" } diff --git a/common_util/common_util_line_stack.ts b/common_util/common_util_line_stack.ts index 14d222a48..fd6e96163 100644 --- a/common_util/common_util_line_stack.ts +++ b/common_util/common_util_line_stack.ts @@ -337,14 +337,15 @@ export function setRow(doc: FormulaDoc, id: string, text: string): void { const n = nodeOf(t); const plain = n && (n[0] === "num" || (n[0] === "neg" && n[1][0] === "num")); let v = (doc.변수 ?? []).find((x) => varId(x) === id); + const from = v?.연결?.표 ? { 표: v.연결.표, 칸: v.연결.칸, 조건: v.연결.조건 } : {}; // 표에서 온 기호 = 별칭 · 꼬리표 남김(요구 692) if (plain || !t) { doc.식 = (doc.식 ?? []).filter((l) => l.기호 !== id); if (!v) (doc.변수 ??= []).push((v = newVar(id, { 갈래: "숫자" }))); const value = t ? Number(t) : null; - v.연결 = { 갈래: "숫자", 값: Number.isFinite(value) ? value : t }; + v.연결 = { ...from, 갈래: "숫자", 값: Number.isFinite(value) ? value : t }; return; } - if (v && v.연결?.갈래 !== "식") v.연결 = { 갈래: "식" }; + if (v && v.연결?.갈래 !== "식") v.연결 = { ...from, 갈래: "식" }; const line = (doc.식 ?? []).find((l) => l.기호 === id); if (line) line.식 = text.trim(); else (doc.식 ??= []).push({ 기호: id, 식: text.trim() }); diff --git a/common_util/common_util_line_stack_pick.ts b/common_util/common_util_line_stack_pick.ts index e29e7d67d..9bf3a97fb 100644 --- a/common_util/common_util_line_stack_pick.ts +++ b/common_util/common_util_line_stack_pick.ts @@ -155,7 +155,8 @@ export function stackLevels(items: StackItem[]): { item: number; line: number }[ } /** 표 값 기호 화면 이름(804-5 · 요구 683) — 「Q_수량2^표」 → 「Q_작업반장」(고른 줄 글 조건 첫 낱말) · 글 조건 없으면 「^표」만 뗌 - * · 「^표」 기호만 · 겹치면 2 · 3 · 문서 기호는 그대로(화면만 · 칸 글은 `aliasText` 로 오감) */ + * · 「^표」 기호만 · 겹치면 2 · 3 · 문서 기호는 그대로(화면만 · 칸 글은 `aliasText` 로 오감) + * · 직접 친 값(숫자 · 식)도 표 · 칸 · 조건을 남기므로 같은 이름(요구 692) */ export function tableAlias(doc: FormulaDoc): Record { const taken = new Set([ ...symbols(doc), @@ -167,7 +168,7 @@ export function tableAlias(doc: FormulaDoc): Record { const out: Record = {}; for (const v of doc.변수 ?? []) { const link = v.연결; - if (link?.갈래 !== "표") continue; + if (!link?.표) continue; const id = varId(v); const [s, lo, up] = splitId(id); const said = Object.values(link.조건 ?? {}) diff --git a/resources/tester/helper_804_2_line_stack.cjs b/resources/tester/helper_804_2_line_stack.cjs index 83db307ca..dea001fe6 100644 --- a/resources/tester/helper_804_2_line_stack.cjs +++ b/resources/tester/helper_804_2_line_stack.cjs @@ -239,6 +239,20 @@ function aliasOf({ 문서 }) { return { 별칭: alias, 틀림: bad }; } +/* 692-1 표 값 직접 침 — 숫자 두 번 · 식 한 번 쳐도 별칭 같음 · 고친 문서(서버 셈 대조) */ +function directOf({ 문서 }) { + const before = pk2.tableAlias(문서); + const doc = JSON.parse(JSON.stringify(문서)); + const ids = Object.keys(before); + for (const id of ids) ls.setRow(doc, id, "7"); + for (const id of ids) ls.setRow(doc, id, "8"); + const typed = pk2.tableAlias(doc); + const expr = JSON.parse(JSON.stringify(doc)); + for (const id of ids) ls.setRow(expr, id, "8 + 0"); + const kinds = ids.map((id) => varOf(doc, id).연결.갈래); + return { 전: before, 숫자: typed, 식: pk2.tableAlias(expr), 갈래: kinds, 문서: doc }; +} + const input = JSON.parse(fs.readFileSync(0, "utf8")); const job = { 왕복: roundTrip, @@ -251,5 +265,6 @@ const job = { 옮김: moveAll, 번지: placeOf, 별칭: aliasOf, + 직접: directOf, }[input.할일]; process.stdout.write(JSON.stringify(input.문서들.map(job))); diff --git a/resources/tester/test_804_7_line_stack_fix.py b/resources/tester/test_804_7_line_stack_fix.py index b1cca9a09..ce4128b9b 100644 --- a/resources/tester/test_804_7_line_stack_fix.py +++ b/resources/tester/test_804_7_line_stack_fix.py @@ -238,3 +238,88 @@ def test_M02_수식탭_M01과_같은_단추_저장_한_번() -> None: assert f"iconStart: {i}" in lab, i css = _src("ui_template/ui_template_button.css") assert ".ui-btn-row--tight .ui-btn:has(> .ui-btn__icon) > .ui-btn__label" in css + + +def test_단위표_더한_단위_문서_전수() -> None: + """804-9(요구 692-1) — 표준 단위 표 「더한 단위」 21 · kg ↔ ㎏ · cm ↔ ㎝ 하나만 · 수식 문서 항목 줄 단위 전부 표에 있음.""" + from M02_MasterTemplete.DataFlow import M02_DF_Router as df + + table = df.get_standards()["unit"] + same = table["같은 단위"] + assert same == {"kg": "㎏", "cm": "㎝"} + added = table["더한 단위"] + assert len(added) == len(set(added)) == 21 + units = { + same.get(u, u) for r in table["줄"] for u in (r["규격 단위"], r["단위수량 단위"]) if u + } | {same.get(u, u) for u in added} + assert "kg" not in units and "cm" not in units and {"㎏", "㎝"} <= units + + lines: list[str] = [] + + def walk(o) -> None: + if isinstance(o, dict): + if "비목" in o and "단위" in o: + lines.append(str(o["단위"] or "")) + for v in o.values(): + walk(v) + elif isinstance(o, list): + for v in o: + walk(v) + + for f in (ROOT / "resources/master_data/수식문서").glob("*.json"): + walk(json.loads(f.read_text(encoding="utf-8"))) + assert len(lines) > 5000 + missing = {u for u in lines if u and same.get(u, u) not in units} + assert not missing, missing + pick = _src("M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts") + assert ( + '...(table["더한 단위"] ?? []),' in pick and '(u && table["같은 단위"]?.[u]) || u' in pick + ) + + +def test_표값_직접_침_별칭_그대로() -> None: + """804-9(요구 692-1) — 표 값 칸에 숫자 · 식을 쳐도 화면 이름(별칭) 그대로 · 꼬리표 「직접 입력」 · 서버 셈 = 표 정보 없는 숫자와 같음.""" + from M02_MasterTemplete import M02_Doc_Copy as dc + from resources.tester.test_804_2_line_stack import _ts, mr + + docs = dc.folder() + keys = sorted( + k for k, d in docs.items() if any((v.get("위") == "표") for v in d.get("변수") or []) + ) + got = dict(zip(keys, _ts("직접", [{"문서": docs[k]} for k in keys]))) + named = 0 + for k, g in got.items(): + assert g["전"] == g["숫자"] == g["식"], k + assert set(g["갈래"]) <= {"숫자"}, k + named += len(g["전"]) + assert named > 500 + links = dc.ml.Links(dc.skc.whole_(), docs) + calc = lambda d: dc.mc.calc(d, (r := mr.Rec(links), r.prefetch(d))[0]) # noqa: E731 + docs_sum = {k: calc(docs[k]).get("계") for k in ("GF000099", "GF000226", "GC000211")} + for k in ("GF000099", "GF000226", "GC000211"): + typed = got[k]["문서"] + plain = json.loads(json.dumps(typed)) + for v in plain["변수"]: + link = v.get("연결") or {} + if link.get("갈래") == "숫자": + v["연결"] = {"갈래": "숫자", "값": link.get("값")} + a, b = calc(typed), calc(plain) + assert [a.get(c) for c in ("노무비", "재료비", "경비", "계")] == [ + b.get(c) for c in ("노무비", "재료비", "경비", "계") + ], k + assert a.get("계") and a.get("계") != docs_sum.get(k), (k, a.get("계")) + rows = _src("A00_Common/line_stack_rows.ts") + assert 'link?.갈래 === "숫자" && link.표 && !expr' in rows + assert "const from = v?.연결?.표 ?" in _src("common_util/common_util_line_stack.ts") + + +def test_공식_묶음_격자_긴_이름_줄임() -> None: + """804-9(요구 692-3) — 공식 묶음 = 폭만큼 여러 열 · 머리 · 식 글 · 고치는 줄 = 열 합침 · 긴 이름 · 표 단추 · 꼬리표 = 줄임표 + 마우스 글.""" + css = _src("A00_Common/line_stack.css") + assert "grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));" in css + assert ".lstk-line.k-공식 > .lstk-frow:is(.is-open, :has(> .lstk-expr))" in css + assert ".ui-btn[data-clip] > .ui-btn__label" in _src("ui_template/ui_template_button.css") + assert "clip?: boolean;" in _src("ui_template/ui_template_button.ts") + rows = _src("A00_Common/line_stack_rows.ts") + assert "clip: true," in rows and "attrs: { title: text }" in rows + assert "name.title = `${show(id)} — ${name.title}`;" in rows diff --git a/ui_template/ui_template_button.css b/ui_template/ui_template_button.css index f1d64122d..0b3e0c307 100644 --- a/ui_template/ui_template_button.css +++ b/ui_template/ui_template_button.css @@ -198,3 +198,14 @@ .ui-btn-row--tight .ui-btn:has(> .ui-btn__icon) > .ui-btn__label { display: none; } + +/* 긴 글 줄임(clip) — 칸 폭 안에서 줄임표 · 전체 글은 title */ +.ui-btn[data-clip] { + min-width: 0; + max-width: 100%; +} +.ui-btn[data-clip] > .ui-btn__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/ui_template/ui_template_button.ts b/ui_template/ui_template_button.ts index 0329b7853..1e80fcb17 100644 --- a/ui_template/ui_template_button.ts +++ b/ui_template/ui_template_button.ts @@ -58,6 +58,8 @@ export interface ButtonOptions { type?: "button" | "submit" | "reset"; /** 앞쪽 아이콘 요소 (선택) — 이름이면 단 크기 아이콘 */ iconStart?: HTMLElement | IconName; + /** 긴 글 = 칸 폭 안에서 줄임표 · 마우스 글에 전체(`data-clip`) */ + clip?: boolean; } export function createButton(opts: ButtonOptions): HTMLButtonElement { @@ -79,6 +81,10 @@ export function createButton(opts: ButtonOptions): HTMLButtonElement { btn.append(mark); } btn.append(el("span", { className: "ui-btn__label", text: opts.label })); + if (opts.clip) { + btn.dataset.clip = ""; + btn.title = opts.label; + } if (opts.disabled) btn.disabled = true; if (opts.onClick) btn.addEventListener("click", opts.onClick); return btn; From 324ae1b0479088c6c659d5d45272528c6973f7c9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 15:39:58 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(804-10):=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=C2=B7=20=EC=A7=91=EA=B2=8C=20=EC=9B=90=EB=AC=B8=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=3D=20=E3=80=8C=EC=9D=B4=EB=A6=84=20=EA=B7=9C?= =?UTF-8?q?=EA=B2=A9=20(=EC=9B=90=EB=AC=B8=EB=B2=88=ED=98=B8)=E3=80=8D=20?= =?UTF-8?q?=EA=B8=80(=EC=9A=94=EA=B5=AC=20692-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 값(원문번호)은 그대로 · 글만 바꿈 — M02 사본 저장 · 로직 부름 · 엑셀 이음 그대로 - 서버 GET /api/m02/df/machine-names(로그인만) = 기계 원문번호 → 이름 규격 · 브라우저는 한 번 받아 둠 - 수식 탭 옵션(formula_doc_options) · 단가산출 상세 옵션(calc_sheet varRow) 같은 도우미 · M01 · M02 같이 - 목록이 모두 원문번호 꼴일 때만(빈 칸 뺌) · 문서 선택 목록 원문번호 전수 이름 있음 - test_804_10 셋 Co-Authored-By: Claude Opus 5.5 --- A00_Common/calc_sheet.ts | 14 ++--- A00_Common/formula_doc_options.ts | 23 ++++++++- M02_MasterTemplete/DataFlow/M02_DF_Api.ts | 12 ++++- M02_MasterTemplete/DataFlow/M02_DF_Router.py | 16 +++++- .../test_804_10_machine_option_names.py | 51 +++++++++++++++++++ 5 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 resources/tester/test_804_10_machine_option_names.py diff --git a/A00_Common/calc_sheet.ts b/A00_Common/calc_sheet.ts index cb14d8581..5096b3401 100644 --- a/A00_Common/calc_sheet.ts +++ b/A00_Common/calc_sheet.ts @@ -17,6 +17,7 @@ import { createInputField, createSelectField } from "@ui/ui_template_input"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { formatLineMoney, formatMoney } from "../common_util/common_util_money"; +import { nameMachines, optionText } from "./formula_doc_options"; import "./calc_sheet.css"; export interface SheetLine { @@ -162,16 +163,17 @@ const lineRow = (line: SheetLine, label = line.이름): HTMLElement => export function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement { let control: HTMLElement; if (spec.고르기?.length) { - control = createSelectField({ - options: [...(value ? [] : [""]), ...spec.고르기.map(String)].map((o) => ({ - value: o, - text: o, - })), + const list = [...(value ? [] : [""]), ...spec.고르기.map(String)]; + const items = () => list.map((o) => ({ value: o, text: optionText(o) })); // 기계 번호 = 이름(요구 692-2) + const f = createSelectField({ + options: items(), value, compact: true, ariaLabel: spec.이름, onChange: onInput, - }).root; + }); + nameMachines(list, () => f.setOptions(items(), f.select.value)); + control = f.root; } else { const box = createInputField({ value, compact: true }); box.input.setAttribute("aria-label", spec.이름); diff --git a/A00_Common/formula_doc_options.ts b/A00_Common/formula_doc_options.ts index cdc35169f..a04ef7037 100644 --- a/A00_Common/formula_doc_options.ts +++ b/A00_Common/formula_doc_options.ts @@ -2,10 +2,29 @@ * formula_doc_options.ts * 수식 문서 옵션 가로(PLAN 802-3 · 802-4b-2 · 요구 655 「옵션 선택은 상단에 수평」) — 선택 · 입력 기호마다 칸 하나 * 값 = 화면 값(`given`) › 문서 값 · 고침은 부른 쪽 `set`(M01 = 화면 값만 · M02 사본 = 문서 값) · `lock` = 읽기만 + * 목록이 모두 기계 원문번호(`0101-0007`)면 글 = 「이름 규격 (원문번호)」(요구 692-2 · 값은 그대로 · 이름은 한 번 받아 둠) * ========================================================================== */ import { createInputField, createSelectField } from "@ui/ui_template_input"; import { varId, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc"; +import { cfMachineNames } from "../M02_MasterTemplete/DataFlow/M02_DF_Api"; + +const MACHINE = /^\d{4}-\d{4}$/; +let names: Record = {}; + +/** 고르기 글 — 기계 원문번호는 이름 · 규격을 앞에 */ +export const optionText = (x: string, known: Record = names): string => + known[x] ? `${known[x]} (${x})` : x; + +/** 목록이 모두 원문번호(빈 칸 빼고)면 이름을 받아 `redraw`(글만 다시) — 수식 탭 옵션 · 단가산출 상세 옵션 */ +export function nameMachines(list: string[], redraw: () => void): void { + const codes = list.filter(Boolean); + if (codes.length && codes.every((x) => MACHINE.test(x)) && codes.some((x) => !(x in names))) + void cfMachineNames().then((got) => { + names = got; + redraw(); + }); +} export function buildOptions( doc: FormulaDoc | null, @@ -22,9 +41,10 @@ export function buildOptions( const now = k in given ? given[k] : v.연결?.값; if (kind === "선택") { const list = (v.연결?.목록 ?? []).map(String); + const items = () => list.map((x) => ({ value: x, text: optionText(x) })); const f = createSelectField({ label, - options: list.map((x) => ({ value: x, text: x })), + options: items(), value: String(now ?? ""), compact: true, size: 28, @@ -32,6 +52,7 @@ export function buildOptions( onChange: (x) => set(k, v, x), }); f.root.dataset.option = k; + nameMachines(list, () => f.setOptions(items(), f.select.value)); out.push(f.root); } else { const f = createInputField({ diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts index bdaec0f98..0319e83e8 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Api.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_Api.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_DF_Api.ts * 단위 · 금액 표준 표 읽기 — 서버 `GET /api/m02/df/standards`(`M02_DF_Router.py`) - * 부르는 곳 = 단위 고르기(`M02_DF_UnitPick`) · 끝수 표준(`M02_DF_Round`) + * 부르는 곳 = 단위 고르기(`M02_DF_UnitPick`) · 끝수 표준(`M02_DF_Round`) · 옵션 기계 이름(`formula_doc_options`) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -18,3 +18,13 @@ export async function cfStandards(): Promise { if (!response.ok) throw new Error(`표준 표 읽기 실패 (${response.status})`); return (await response.json()) as CfStandards; } + +let machineNames: Promise> | null = null; + +/** 기계 원문번호 → 「이름 규격」 — 한 번 받아 둠(못 받으면 빈 표 · 다음에 다시) */ +export function cfMachineNames(): Promise> { + machineNames ??= fetch(`${API_BASE_URL}/m02/df/machine-names`, { credentials: "include" }) + .then((r) => (r.ok ? (r.json() as Promise>) : Promise.reject())) + .catch(() => ((machineNames = null), {})); + return machineNames; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Router.py b/M02_MasterTemplete/DataFlow/M02_DF_Router.py index 6d293fa2c..f33abcc85 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Router.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Router.py @@ -1,4 +1,4 @@ -"""단위 · 금액 표준 표 읽기 — `GET /api/m02/df/standards` → {unit, money, saved}. +"""단위 · 금액 표준 표 읽기 — `GET /api/m02/df/standards` → {unit, money, saved} · 기계 이름 `GET /machine-names`. 부르는 곳 = 단위 고르기(`M02_DF_UnitPick.ts`) · 끝수 표준(`M02_DF_Round.ts`). 표 = `_standard/` 파일(git 이 따라감) · 권한 = 로그인만(읽기만 · `main.py` 한 줄 · M02 줄 쌓기 단위 고르기). @@ -19,6 +19,20 @@ router = APIRouter(prefix="/api/m02/df", tags=["M02 DataFlow"]) STANDARD = Path(__file__).resolve().parent / "_standard" +@router.get("/machine-names") +def get_machine_names() -> dict: + """기계 원문번호 → 「이름 규격」(요구 692-2) — 옵션 드롭다운 라벨만 · 값은 원문번호 그대로 · 겹친 번호는 첫 줄.""" + out: dict[str, str] = {} + for data in store.loaded()[0].values(): + if data.get("그룹") != "기계": + continue + for row in data.get(store.items_key(data)) or []: + code = str(row.get("원문번호") or "") + if code and code not in out: + out[code] = f"{row.get('이름') or ''} {row.get('규격') or ''}".strip() + return out + + @router.get("/standards") def get_standards() -> dict: try: diff --git a/resources/tester/test_804_10_machine_option_names.py b/resources/tester/test_804_10_machine_option_names.py new file mode 100644 index 000000000..23b60eede --- /dev/null +++ b/resources/tester/test_804_10_machine_option_names.py @@ -0,0 +1,51 @@ +"""804-10(요구 692-2) — 옵션 드롭다운 기계 · 집게 등 원문번호 목록 = 「이름 규격 (원문번호)」 글 · 값은 그대로. + +① 서버 `GET /api/m02/df/machine-names` = 기계 원문번호 → 「이름 규격」(로그인만) +② 수식 문서 선택 목록 중 모두 원문번호 꼴인 목록 — 번호마다 이름이 있음(1,518 전수) +③ 화면 부품 `formula_doc_options.ts` — 글만 바꿈(value = 원문번호) · 이름은 한 번 받아 둠 +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from M02_MasterTemplete.DataFlow import M02_DF_Router as df + +ROOT = Path(__file__).resolve().parents[2] +CODE = re.compile(r"^\d{4}-\d{4}$") + + +def test_기계_이름_표_문서_목록_전수() -> None: + names = df.get_machine_names() + assert names["0101-0007"] == "불도저(무한궤도) 7ton" + lists: list[list[str]] = [] + for f in (ROOT / "resources/master_data/수식문서").glob("*.json"): + for doc in json.loads(f.read_text(encoding="utf-8")).get("줄") or []: + for v in doc.get("변수") or []: + link = v.get("연결") or {} + got = [str(x) for x in link.get("목록") or []] + if link.get("갈래") == "선택" and got and all(CODE.match(x) for x in got): + lists.append(got) + assert len(lists) > 200 + missing = {x for got in lists for x in got if not names.get(x)} + assert not missing, sorted(missing)[:10] + + +def test_옵션_부품_글만_바꿈() -> None: + src = (ROOT / "A00_Common/formula_doc_options.ts").read_text(encoding="utf-8") + assert "known[x] ? `${known[x]} (${x})` : x" in src + assert "list.map((x) => ({ value: x, text: optionText(x) }))" in src + assert "nameMachines(list, () => f.setOptions(items(), f.select.value));" in src + api = (ROOT / "M02_MasterTemplete/DataFlow/M02_DF_Api.ts").read_text(encoding="utf-8") + assert "machineNames ??= fetch(" in api # 한 번만 받음 + main = (ROOT / "main.py").read_text(encoding="utf-8") + at = main.index("M02_MasterTemplete.DataFlow.M02_DF_Router") + assert "dependencies=protected," in main[at : at + 120] + + +def test_상세_탭_옵션_같은_도우미() -> None: + sheet = (ROOT / "A00_Common/calc_sheet.ts").read_text(encoding="utf-8") + assert "nameMachines(list, () => f.setOptions(items(), f.select.value));" in sheet + assert "text: optionText(o)" in sheet From 9130741751080d6996de645aab2b97d10126a8d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 15:51:38 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(804-11):=20M01=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=20=EB=8B=A8=EC=B6=94=20=3D=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=EB=A7=8C=20=C2=B7=20M02=20=EC=9B=90=EB=AC=B8=20?= =?UTF-8?q?=EC=86=90=EC=9E=A1=EC=9D=B4=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20?= =?UTF-8?q?=EB=81=9D(=EC=9A=94=EA=B5=AC=20693=20=C2=B7=20694)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M01 단가산출 기초 목록 = [신규 로직]만 · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] = 로직 열면 나타남(숨김) - 요소 묶음 여덟 · 구조물 옵션 정의 · 별칭 장부 = 항목에 매인 머리 단추 없음(그대로) - M02 원문 손잡이 = M01 687-17 과 같은 규칙(메인 칸 오른쪽 여백 24 만큼 밀어 붙임) · 단가산출 · 일위대가 손잡이 오른쪽 여백 24 → 0 - test_804_11 둘 · test_m02_layer_screen 머리 줄 기대에 804-8 [되돌림] 넣음(낡은 기대 고침) Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 17 +++++---- .../M02_MasterTemplete_UI_Style.css | 5 +++ resources/tester/test_804_11_list_buttons.py | 38 +++++++++++++++++++ resources/tester/test_m02_layer_screen.py | 2 +- 4 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 resources/tester/test_804_11_list_buttons.py diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 6780f3a45..319f5f606 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -6,7 +6,7 @@ * * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. * 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임. - * 머리 단추(804-7 · 요구 687-2) = [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] + * 머리 단추(804-7 · 요구 687-2) = [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] · 목록에선 [신규 로직]만(요구 693) * [저장] = 기본정보 · 별칭(로직 줄 `/save`) + 수식(줄 쌓기 `/logic/doc/save`) 한 번에 · [되돌림] = 둘 다 저장 뒤 상태로 * [로직 삭제] = 확인 → 서버 정본에서 바로(`/logic/remove` — 로직 줄 + 수식 문서 · 부르는 곳 있으면 막고 까닭) → 로직 목록 * ========================================================================== */ @@ -234,6 +234,8 @@ export async function mountM01LogicLab( waiting.hidden = loaded; panes.root.hidden = !opened; back.hidden = views.root.hidden = listing; + // 로직을 골라야 뜻 있는 단추 = 목록에선 숨김(요구 693 · [신규 로직]만 보임) + copyButton.hidden = deleteButton.hidden = discardButton.hidden = saveButton.hidden = listing; if (!opened) return; if (!opened.row) { editor.replaceChildren(createEmpty({ text: `● ${tx("List_Deleted")}`, small: true })); @@ -528,6 +530,12 @@ export async function mountM01LogicLab( }) : showToast(tl("Copy_NeedOpen"), "info"), }); + const deleteButton = createButton({ + label: tx("Bar_Delete"), + variant: "danger", + iconStart: "trash", + onClick: () => void onDelete(), + }); // 제목 줄 = 요소 화면(인력 · 재료 …)과 같은 공용 조각 const bar = buildHeadBar({ lead: [back], @@ -542,12 +550,7 @@ export async function mountM01LogicLab( onClick: () => void onNew(), }), copyButton, - createButton({ - label: tx("Bar_Delete"), - variant: "danger", - iconStart: "trash", - onClick: () => void onDelete(), - }), + deleteButton, discardButton, saveButton, ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 9ae1c3acc..674704b72 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -103,6 +103,11 @@ html.is-bm-route .m02-main__head { min-height: 240px; } +/* 원문 손잡이 = 브라우저 오른쪽 끝(요구 694 · M01 687-17 과 같은 규칙) — 메인 칸 오른쪽 여백(24)만큼 밀어 붙임 · 본문이 그 자리까지 */ +html:not([data-size="compact"], [data-size="medium"]) .m02-main__host > .ui-side-panels-row { + margin-inline-end: calc(-1 * var(--spacing-24)); +} + /* 면적산출 · 재료산출 전체 편집 — `.ss` 는 height:100% 로 부모 높이를 받음 · 도면 편집과 같이 메인 칸을 화면 높이로 채움 */ .m02-main:has(> .m02-main__host > .ss) { box-sizing: border-box; diff --git a/resources/tester/test_804_11_list_buttons.py b/resources/tester/test_804_11_list_buttons.py new file mode 100644 index 000000000..0222d6a13 --- /dev/null +++ b/resources/tester/test_804_11_list_buttons.py @@ -0,0 +1,38 @@ +"""804-11(요구 693 · 694) — M01 목록 화면 머리 단추 · M02 원문 손잡이 오른쪽 끝. + +① M01 단가산출 기초 목록 = [신규 로직]만 · 로직을 열면 [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] 나타남(숨김 · 흐림 아님) + 요소 묶음(인력 · 재료 · 유가 · 기계 · 소요량 · 계수 · 환율 · 요율) · 구조물 옵션 정의 · 별칭 장부 = 항목 고름에 매인 머리 단추 없음(그대로) +② M02 원문 손잡이 = M01(804-7 · 687-17)과 같은 규칙 — 메인 칸 오른쪽 여백만큼 밀어 붙임(단가산출 · 일위대가 등 `m02-main__host` 바로 아래 칸) +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _src(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_M01_목록_머리_단추_숨김() -> None: + lab = _src("M01_MasterData/M01_MasterData_UI_LogicLab.ts") + line = "copyButton.hidden = deleteButton.hidden = discardButton.hidden = saveButton.hidden = listing;" + assert line in lab + at = lab.index("const drawEditor = (): void => {") + assert at < lab.index(line) < lab.index("if (!opened) return;", at) # 목록 갈래 return 앞 + assert ( + " copyButton,\n deleteButton,\n discardButton,\n saveButton," + in lab.replace("\r\n", "\n") + ) + + +def test_M02_원문_손잡이_오른쪽_끝() -> None: + css = _src("M02_MasterTemplete/M02_MasterTemplete_UI_Style.css") + m02 = ".m02-main__host > .ui-side-panels-row {\n margin-inline-end: calc(-1 * var(--spacing-24));" + assert m02 in css.replace("\r\n", "\n") + assert "padding: var(--spacing-16) var(--spacing-24);" in css # 민 만큼 = 메인 칸 오른쪽 여백 + m01 = _src("M01_MasterData/M01_MasterData_UI_LogicLab_Side.css") + cond = 'html:not([data-size="compact"], [data-size="medium"])' + assert f"{cond} .m02-main__host" in css and f"{cond} .m01-master__panel" in m01 # 같은 조건 diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 058103778..0209a2715 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -315,7 +315,7 @@ def test_구조물_조합_제목_줄_새_항목(): main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert "export function newItemButton(" in main and 'classList.add("m02-main__new")' in main assert re.search( - r"children: \[\s*back,\s*title,\s*nav\.root,\s*helpSlot,\s*slot,\s*reload,\s*actions\.reset,\s*actions\.del,\s*save," + r"children: \[\s*back,\s*title,\s*nav\.root,\s*helpSlot,\s*slot,\s*reload,\s*actions\.reset,\s*actions\.del,\s*actions\.undo,\s*save," r"\s*\.\.\.\(extras\.newItem \? \[extras\.newItem\] : \[\]\),?\s*\]", main, )