From d27dba4f231c45691506a4d1c2887bf3479ac705 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:21:56 +0900 Subject: [PATCH 01/30] =?UTF-8?q?test(716-3):=20=EB=82=A1=EC=9D=80=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EB=91=98=EC=9D=84=20716-3=20=EA=BC=B4?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20M02=20=EC=99=BC?= =?UTF-8?q?=EC=AA=BD=20=EC=B9=B8=20=EC=B0=A8=EB=A1=80=20=C2=B7=20reveal=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EA=B0=88=EB=9E=98=20=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_m02_hopyo_screen::test_좌측_판_자리와_권한 — 옛 `extras ?` 자리 대신 일위대가 › 구조물 조합 › 구분선 › 불러오기 차례 확인 - test_197_1_dashboard_option_card::test_reveal_옵션만_줄을_누름 → test_reveal_옵션은_M01_로 — 옵션 정의가 M01 로 옮겨 M02 reveal 은 일위대가 · 도면만 펼침 - 기능 흠 아님 · 716-3 이 UI_Side.ts 를 바꿔 옛 꼴 기대가 낡음 Co-Authored-By: Claude Opus 5.5 --- .../tester/test_197_1_dashboard_option_card.py | 17 +++++------------ resources/tester/test_m02_hopyo_screen.py | 5 +++-- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/resources/tester/test_197_1_dashboard_option_card.py b/resources/tester/test_197_1_dashboard_option_card.py index 12c9b6c4b..fe1701798 100644 --- a/resources/tester/test_197_1_dashboard_option_card.py +++ b/resources/tester/test_197_1_dashboard_option_card.py @@ -82,20 +82,13 @@ def test_해시_진입_셋(): assert got["open"] == want and got["root"] == "master" and got["company"] == "3" -def test_reveal_옵션만_줄을_누름(): +def test_reveal_옵션은_M01_로(): side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") body = side.split("const reveal = ")[1].split("\n };")[0] - assert "if (!sec) return;" in body # 시스템 층 아니면 옵션 · 도면 줄 없음 - assert 'sec.classList.remove("is-collapsed");' in body # 도면 = 펼치기만 - assert ( - 'if (target === "옵션") options.body.querySelector("button")?.click();' in body - ) - # 옵션 컨테이너 안 단추 = 「구조물 옵션 정의」 줄 하나뿐 - options = _text(M02 / "StructOptions" / "M02_SO_Options.ts") - assert options.count('el("button"') == 1 - assert "openDefScreen" in options - # 옵션 컨테이너 = 구조물 옵션 정의 줄 · 시스템 층에만 - assert "옵션: section(options.title, options.body)," in side + # 716-1 · 716-3 — 옵션 정의는 M01 로 옮김 · M02 reveal 은 일위대가 · 도면만 펼침 + assert 'target === "일위대가조합" ? precomboSec : target === "도면" ? drawing : null' in body + assert 'sec?.classList.remove("is-collapsed");' in body + assert "options" not in body and "옵션: section(" not in side page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert 'if (entry.open !== "구조물조합") side.reveal(entry.open);' in page diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index bf8781d47..1714b7fe0 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -338,8 +338,9 @@ def test_좌측_판_자리와_권한(): side = _text("M02_MasterTemplete_UI_Side.ts") assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1] - assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),") - assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?") + # 716-3 차례 — 일위대가 › 구조물 조합 › 구분선 › 불러오기 + order = ("precomboSec,", "summary,", "createDivider(),", "section(hopyo.title, hopyo.body),") + assert [host.index(k) for k in order] == sorted(host.index(k) for k in order) assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 assert "hopyo.paint();" in side.split("function paint(): void {")[1] hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") From cc588889f6cac223a064b5b77446fb48043b80a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:22:27 +0900 Subject: [PATCH 02/30] =?UTF-8?q?feat(718-1):=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20=EA=B2=B0=EA=B3=BC=20=E3=80=8C=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EC=82=B0=EC=B6=9C=E3=80=8D=20=EC=BC=AC=20=E2=80=94=20?= =?UTF-8?q?=EC=82=B0=EA=B7=BC=20=EB=B8=94=EB=A1=9D=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EB=B0=94=ED=83=95=20=EC=97=86=EB=8A=94=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C(715-2=20=EB=B0=9B=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Hopyo_Sangeun.py — 단산 블록을 부르는 첫 엑셀 줄 1 단위 단가를 고정 더한줄 하나로 · 본뜬 「HP-###### 단산N」 · 같은 본뜬은 그대로(겹침 없음) - 길 POST /layers/{층}/hopyo/{키}/sangeun · [끝내기] 지문 기록에 단가산출도 셈 - 마법사: 결과 「단가산출」 체크 켬 → ② 걸음 [단가산출 만들기] · 알림 글 「일위대가 · 단가산출」 - 시험: test_718_1 단가산출 더함 · 낡은 대조 고침 — 716-3 왼쪽 판 차례(extras 없어짐) · SANGEUN_READY 대조 - 고정 더한줄 셈 값(엔진 값묶음)은 715-1b 뒤 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Sangeun.py | 103 ++++++++++++++++++ M02_MasterTemplete/M02_Labor_Router.py | 15 ++- .../M02_MasterTemplete_Hopyo_Wizard.ts | 58 +++++++--- resources/tester/test_718_1_hopyo_import.py | 33 +++++- resources/tester/test_m02_hopyo_screen.py | 6 +- 5 files changed, 198 insertions(+), 17 deletions(-) create mode 100644 M02_MasterTemplete/M02_Hopyo_Sangeun.py diff --git a/M02_MasterTemplete/M02_Hopyo_Sangeun.py b/M02_MasterTemplete/M02_Hopyo_Sangeun.py new file mode 100644 index 000000000..af0c86cda --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Sangeun.py @@ -0,0 +1,103 @@ +"""M02 일위대가 불러오기 → 단가산출(PLAN 718-1 · 요구서 내역12 4장 22) — +산근(단산) 블록 = 바탕 없는 단가산출. + +블록마다 SK 하나 — 그 블록을 부르는 첫 엑셀 줄(「단산N참조」)의 1 단위 단가 +(노무비 · 재료비 · 경비)를 고정 더한줄 하나로(`M02_Hopyo_Fixed` 꼴 · 엑셀 숫자 그대로 · +서버가 파일에서 읽음) · 이름 = 블록 이름(없으면 줄 품명) · 단위 = 줄 단위(없으면 블록 · 「식」) · +구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 단산N」, 파일, 원본, +확인} · 같은 본뜬이 이 층에 있으면 그대로(겹침 없음) · 만드는 층 = 부르는 층(마법사에서 고른 층). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from common_util.common_util_time import utc_now_text +from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Labor_Ref as refs + +ORIGIN = "실무자료" +KIND = "sangeun" + + +def mark(key: str, no: int) -> str: + return f"{key} 단산{no}" + + +def made(folder: str | Path, key: str | None = None) -> dict[str, str]: + """이 층 실무자료 단가산출 `{본뜬 키: SK 키}`(`key` = 그 HP 것만).""" + out = {} + for path in sorted((Path(folder) / KIND).glob("SK-*.json")): + try: + got = json.loads(path.read_bytes()).get("본뜬") + except (OSError, ValueError, AttributeError): + continue + if isinstance(got, dict) and got.get("층") == ORIGIN: + if key is None or str(got.get("키") or "").startswith(f"{key} "): + out[str(got.get("키"))] = path.stem + return out + + +def _doc(line: dict, block: dict, no: int, hp: dict, stamp: str, who: Any) -> dict[str, Any]: + name = str(block.get("이름") or line.get("이름") or "").strip() or f"단산{no}" + unit = str(line.get("단위") or block.get("단위") or "").strip() or "식" + spec = str(line.get("규격") or "").strip() + price = {k: hfix._plain(v) for k, v in hfix._parts(line.get("단가")).items()} + fx = { + "이름": name, + "규격": spec, + "단위": unit, + "단가": price, + "이음": dict.fromkeys(fixed.LINKS), + "출처": {"파일": hp.get("파일"), "호표": line.get("_호표"), "줄": int(line.get("차례", 0))}, + } + return { + "종류": "단가산출", + "이름": name, + "단위": unit, + "구분": ORIGIN, + "상세구분": str(hp.get("파일") or ""), + "본뜬": { + "층": ORIGIN, + "키": mark(hp["키"], no), + "파일": hp.get("파일"), + "원본": list(refs.here()), + "확인": {"누가": who, "때": stamp}, + }, + "바탕": None, + "입력값": {}, + "열린입력": [], + "고름": {}, + "자재표시": {}, + "덮개": {}, + "더한줄": [{"고정": fx, "이름": name, "단위": unit, "수량": 1}], + "뺀줄": [], + } + + +def build(folder: Path, key: str, who: Any = None) -> dict[str, Any]: + """[단가산출 만들기] — 산근 블록마다 바탕 없는 단가산출 → + `{블록, 만듦 [{번, 이름, 키}], 그대로}`.""" + hp = hopyo_store.read(folder, key) + blocks = {int(b["번"]): b for b in hp.get("단산") or [] if b.get("번") is not None} + calls: dict[int, dict] = {} + for h in hp.get("호표") or []: + for line in h.get("줄") or []: + ref = line.get("참조") or {} + no = int(ref.get("번") or 0) if ref.get("종류") == "단산" else 0 + if no in blocks and no not in calls: + calls[no] = {**line, "_호표": h.get("번")} + have, stamp = made(folder, key), utc_now_text() + out: dict[str, Any] = {"블록": len(calls), "만듦": [], "그대로": 0} + for no in sorted(calls): + if mark(key, no) in have: + out["그대로"] += 1 + continue + doc = _doc(calls[no], blocks[no], no, hp, stamp, who) + got = refs.create(KIND, doc) + out["만듦"].append({"번": no, "이름": doc["이름"], "키": got["이름"]}) + return out diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 5931b2b67..03da5df8c 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -32,6 +32,7 @@ from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink +from M02_MasterTemplete import M02_Hopyo_Sangeun as hopyo_sangeun from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs @@ -206,7 +207,8 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: when = str(old.get("끝낸때") or "")[:10] raise store.StoreError( 409, - f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 일위대가 {made}) — 다시 만들지 않음", + f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 만든 일위대가 · 단가산출 {made})" + " — 다시 만들지 않음", ) if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None): return {**same, "이어서": True} @@ -465,6 +467,7 @@ def _finish(folder: Path, key: str) -> dict[str, Any]: made = sorted( {sc for mark, sc in hopyo_build.confirmed(folder).items() if mark.startswith(head)} ) + made += sorted(hopyo_sangeun.made(folder, key).values()) # 단가산출(SK) if (cache_path := _later("M02_Hopyo_Fit", "cache_path")) is not None: try: got = cache_path(folder, key) @@ -477,6 +480,16 @@ def _finish(folder: Path, key: str) -> dict[str, Any]: return {"지움": key, "만든": len(made)} +@router.post("/layers/{layer}/hopyo/{key}/sangeun") +def hopyo_make_sangeun( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """불러오기 결과 「단가산출」(718-1) — 산근 블록마다 바탕 없는 단가산출(파일 층 = 이 층) → + `{블록, 만듦, 그대로}`.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, hopyo_sangeun.build, folder, key, _who(session)) + + class PasteBody(_Strict): """엑셀 붙여넣기(705-2) — `글` = 붙인 글 그대로 · `열` = 기억한 열 짝(없으면 머리 글 · 짐작 + 물음) · `줄만` = 열린 문서에 줄로 · `있음` = 문서의 고정 줄 가름(이름 겹침 피함).""" diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts index f76885502..302eccf0e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts @@ -1,11 +1,12 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Wizard.ts * 일위대가 불러오기 = 한시 마법사(PLAN 718-1 · 요구서 내역12 4장 22) — 본 칸 한 화면 · 걸음 셋: - * ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 · 단가산출 = 715-2 뒤 켬) · 엑셀 고르기 → 올림 + * ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 늘 · 단가산출 체크) · 엑셀 고르기 → 올림 * (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서 * ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] → - * 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 - * ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 별칭 장부 남음 · 지문 기록 + * 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 · + * 단가산출 체크면 [단가산출 만들기] = 산근 블록마다 바탕 없는 단가산출(서버 `M02_Hopyo_Sangeun` · 겹침 없음) + * ③ [끝내기] = 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 · 단가산출 · 별칭 장부 남음 · 지문 기록 * 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김. * ========================================================================== */ @@ -23,6 +24,7 @@ import { type HopyoFile, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; +import { call } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer"; const LAYERS: { layer: ManageLayer; text: string }[] = [ @@ -35,9 +37,6 @@ const STEPS = [ { value: "2", label: "② 보기 · 확인 · 만들기" }, { value: "3", label: "③ 끝내기" }, ]; -/** 단가산출 결과 — M02 단가산출 서고(715-2)가 들어오면 켬 */ -export const SANGEUN_READY = false; -const SANGEUN_WHY = "M02 단가산출 칸(715-2)이 들어온 뒤 켬"; type Got = HopyoFile & { 이어서?: boolean }; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); @@ -70,6 +69,7 @@ export function buildHopyoWizard(): HTMLElement { }); let layers: ManageLayer[] = []; let pick: ManageLayer = "personal"; + let wantSangeun = false; // 결과 「단가산출」 체크 const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null }); async function first(): Promise { @@ -112,15 +112,17 @@ export function buildHopyoWizard(): HTMLElement { }); seg.root.dataset.hopyoLayer = ""; const ilwi = createCheckbox("일위대가(고정 줄)", { checked: true, disabled: true }); - const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { disabled: !SANGEUN_READY }); - sangeun.root.title = SANGEUN_READY ? "" : SANGEUN_WHY; + const sangeun = createCheckbox("단가산출(산근 블록 · 바탕 없음)", { + checked: wantSangeun, + onChange: (on) => (wantSangeun = on), + }); + sangeun.root.dataset.hopyoSangeun = ""; const busy = el("div", { className: "m02-hopyo" }); body.replaceChildren( createText("결과 층", "muted", 14), el("div", { className: "ui-toolbar", children: [seg.root] }), createText("결과", "muted", 14), el("div", { className: "ui-toolbar", children: [ilwi.root, sangeun.root] }), - ...(SANGEUN_READY ? [] : [createText(`단가산출 — ${SANGEUN_WHY}`, "hint")]), el("div", { className: "ui-toolbar", children: [upload, input] }), createPanelHead({ title: "작업 중", level: 4, divider: true }).root, busy, @@ -169,6 +171,14 @@ export function buildHopyoWizard(): HTMLElement { ) .catch((error: unknown) => showToast(why(error), "error")), }); + const sangeun = createButton({ + label: "단가산출 만들기", + variant: "filled", + onClick: () => void makeSangeun(), + }); + sangeun.title = + "엑셀 산근 블록마다 바탕 없는 단가산출(엑셀 단가 그대로 · 이미 만든 블록은 그대로)"; + sangeun.dataset.hopyoSangeunMake = ""; const finish = createButton({ label: "끝내기", variant: "ghost", onClick: () => void end() }); finish.dataset.hopyoFinish = ""; const back = createButton({ label: "처음으로", variant: "link", onClick: () => void first() }); @@ -177,22 +187,44 @@ export function buildHopyoWizard(): HTMLElement { body.replaceChildren( createText(`${file.파일} · ${textOf(layer)} 층 · 호표 ${file.호표수}${wrong}`, "muted", 14), createText( - "엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가는 고른 층에 들어감", + "엑셀 보기 창에서 [모두 고정으로 들여오기] 또는 호표 [수정] → 확인하고 만들기 · [구조물 조합으로] · 만든 일위대가 · 단가산출은 고른 층에 들어감", "hint", ), - el("div", { className: "ui-toolbar", children: [view, finish, back] }), + el("div", { + className: "ui-toolbar", + children: [view, ...(wantSangeun ? [sangeun] : []), finish, back], + }), ); + async function makeSangeun(): Promise { + sangeun.disabled = true; + try { + const got = await call<{ 블록: number; 만듦: unknown[]; 그대로: number }>( + `/layers/${layer}/hopyo/${encodeURIComponent(file.키)}/sangeun`, + { method: "POST" }, + ); + const kind = got.블록 ? "success" : "warning"; + showToast( + `단가산출 ${got.만듦.length} 만듦 · 그대로 ${got.그대로} · 산근 블록 ${got.블록}`, + kind, + ); + } catch (error) { + showToast(why(error), "error"); + } finally { + sangeun.disabled = false; + } + } + async function end(): Promise { steps.set("3"); const ok = await showConfirmDialog( - `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가와 별칭 장부는 남음 · 일위대가를 만들었으면 같은 엑셀은 다시 올리지 못함`, + `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출과 별칭 장부는 남음 · 하나라도 만들었으면 같은 엑셀은 다시 올리지 못함`, "끝내기", ); if (!ok) return steps.set("2"); try { const got = (await deleteHopyo(scope, file.키)) as { 만든?: number }; - showToast(`끝냄 — 만든 일위대가 ${got.만든 ?? 0} · 올린 파일 지움`, "success"); + showToast(`끝냄 — 만든 일위대가 · 단가산출 ${got.만든 ?? 0} · 올린 파일 지움`, "success"); } catch (error) { showToast(why(error), "error"); return steps.set("2"); diff --git a/resources/tester/test_718_1_hopyo_import.py b/resources/tester/test_718_1_hopyo_import.py index c16492b5e..72f75bb11 100644 --- a/resources/tester/test_718_1_hopyo_import.py +++ b/resources/tester/test_718_1_hopyo_import.py @@ -45,7 +45,7 @@ def test_이어서_끝내기_지움_겹침_막음(client): block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내 assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"] - assert "일위대가 68" in block.json()["detail"] and _keys(client) == [] + assert "단가산출 68" in block.json()["detail"] and _keys(client) == [] assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before @@ -65,3 +65,34 @@ def test_옛_HP_목록에서만_숨김(client): assert client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"] == [] assert _post(client).json()["키"] == "HP-000008" # 옛 번 뒤로 assert old.is_file() and _keys(client) == ["HP-000008"] + + +def test_결과_단가산출_산근_블록_바탕_없음_겹침_없음(client): + folder = layers.personal_dir(7, 42) + assert _post(client).json()["키"] == "HP-000001" + url = URL.format("personal") + "/HP-000001/sangeun" + got = client.post(url) + assert got.status_code == 200, got.text + got = got.json() + assert got["블록"] > 0 and len(got["만듦"]) == got["블록"] and got["그대로"] == 0 + docs = [json.loads(p.read_bytes()) for p in sorted((folder / "sangeun").glob("SK-*.json"))] + assert len(docs) == got["블록"] + one = docs[0] + assert one["바탕"] is None and one["본뜬"]["키"].startswith("HP-000001 단산") + assert one["구분"] == "실무자료" and len(one["더한줄"]) == 1 and one["단위"] + hp = hs.read(folder, "HP-000001") + no = int(one["본뜬"]["키"].rsplit("단산", 1)[1]) + line = next( # 그 블록을 부르는 첫 엑셀 줄 = 엑셀 단가 그대로 + x + for h in hp["호표"] + for x in h["줄"] + if (x.get("참조") or {}).get("종류") == "단산" and x["참조"]["번"] == no + ) + price = one["더한줄"][0]["고정"]["단가"] + assert sum(price.values()) == sum((line["단가"] or {}).get(k) or 0 for k in price) + again = client.post(url).json() # 다시 = 그대로(겹침 없음) + assert again["만듦"] == [] and again["그대로"] == got["블록"] + res = client.delete(URL.format("personal") + "/HP-000001").json() + assert res["만든"] == got["블록"] # 단가산출만 만들어도 지문 기록 + assert len(list((folder / "sangeun").glob("SK-*.json"))) == got["블록"] # 남음 + assert _post(client).status_code == 409 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index bf8781d47..4a3702686 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -338,8 +338,10 @@ def test_좌측_판_자리와_권한(): side = _text("M02_MasterTemplete_UI_Side.ts") assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1] + # 716-3 차례 — 일위대가 › 구조물 조합 › 구분선 › 일위대가 불러오기 › 도면 양식 assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),") - assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?") + assert host.index("createDivider(),") < host.index("section(hopyo.title, hopyo.body),") + assert host.index("section(hopyo.title, hopyo.body),") < host.index("drawing ?") assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한 assert "hopyo.paint();" in side.split("function paint(): void {")[1] hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts") @@ -354,7 +356,7 @@ def test_좌측_판_자리와_권한(): assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만 assert 'let pick: ManageLayer = "personal";' in wizard # 기본 개인 - assert "export const SANGEUN_READY = false;" in wizard # 단가산출 결과 = 715-2 뒤 켬 + assert "let wantSangeun = false;" in wizard and "/sangeun`" in wizard # 결과 단가산출 체크 def test_로직_추가_한_곳과_파일_규칙(): From 0c956a6c61a3e32ae789517e1905b33ed7852ee6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:26:22 +0900 Subject: [PATCH 03/30] =?UTF-8?q?feat(711-2):=20M01=20=EC=9E=AC=EB=A3=8C?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=ED=95=98=EB=82=98=20=E2=80=94=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=91=9C=20=EC=A4=84=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20=EA=B3=84=EC=82=B0=20=EC=9E=90=EC=9E=AC=20=EC=B9=B8?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20=EB=AC=BC?= =?UTF-8?q?=EA=B0=80=20=EB=84=B7=20=EA=B0=80=EB=A1=9C=20=C2=B7=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EA=B3=A0=EB=A5=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상세 쪽 openMaterialModal 기준 — 위 물가자료 · 유통물가 · 물가정보 · 거래가격 가로 넷(빈 칸 「-」 · 시험 계산이 쓴 값 굵게) - 후보 = /materials(조건 · 단위 거름) · 조건 없으면 이름 찾기 · 찾기 칸(검색어로 먼저 거름 · 없으면 재료 전체 찾기) · 목록 값 열 뺌 - 줄 누르면 고름(시험 입력 · 저장 안 함) → 다시 셈 · 상세 표 재료 줄에서 골라도 시험 계산 자재 칸 따라감 - 기계 고르기 · M02 일위대가 [바꾸기]는 PickTable 그대로 Co-Authored-By: Claude Opus 5.5 --- .../M01_MasterData_UI_LogicLab_Detail.ts | 9 +- .../M01_MasterData_UI_LogicLab_Modal.ts | 276 +++++++++++++----- .../M01_MasterData_UI_LogicLab_Style.css | 10 + .../M01_MasterData_UI_LogicLab_Text.ts | 5 + .../M01_MasterData_UI_Logic_CalcPick.ts | 46 ++- 5 files changed, 257 insertions(+), 89 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index dac172596..b67d95ca6 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -22,6 +22,7 @@ import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Ed import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; +import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; import { condText, @@ -281,7 +282,7 @@ function entries(ctx: DetailContext): Entry[] { open: (self) => openMaterialModal({ unitFrag: self.unitFrag, - unitRef: self.unitRef, + unitRef: line?.품목?.키 ?? self.unitRef, unitText: self.unitText, qtyFrag: self.piecesFrag, title: item.이름 ?? (cond ? condLabel(cond) : item.요소), @@ -290,6 +291,12 @@ function entries(ctx: DetailContext): Entry[] { prices: ctx.prices, middles, values: ctx.values, + // 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜) + kind: item.종류, + usedSlot: line?.품목?.값칸, + ...(item.종류 === "재료" + ? { onPick: (it) => pickMaterial(ctx.calcHost, ctx.values, i, it) } + : {}), }), }; return spread(entry, line); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts index 161995cd6..f28b870fd 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts @@ -3,18 +3,23 @@ * 자료 모달 둘 — * ① 줄 모달(`openMaterialModal`) — 줄을 누르면 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 * 바로 보임(알약 목록 없이) · 그 아래 한 줄 풀이 + 원문 수량 식 + * 재료 줄 = 위 물가 넷 가로(빈 칸 「-」 · 시험 계산이 쓴 값 굵게) + 후보 목록(찾기 · `onPick` 이면 누르면 고름) + * — 상세 표 줄 · 시험 계산 자재 칸이 같은 모달(711-2) · 기계 고르기는 `Logic_PickTable` 그대로 * ② 표 모달(`openTableModal`) — 수량 칸 안 알약(찾기(...) 표 참조)을 누르면 그 표만 보임 * 표는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처 * 표 읽기 = `/elements` → `/table`(`Logic_Note.loadTable`) · 단가 = 로직 화면이 이미 받은 `prices` * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; +import { createButton, createInputField, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { + fetchMaterials, searchElements, type ElementBrief, type NamedFormula, } from "./M01_MasterData_UI_Logic_Api"; +import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch"; +import { createStat, statGrid } from "@ui/ui_template_stat"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; import { explain, loadTable, type TableRow } from "./M01_MasterData_UI_Logic_Note"; @@ -81,6 +86,12 @@ export interface ModalTarget { unitText?: string; /** 서버 수량 조각 — 한 줄 풀이를 조각으로(알약 포함) · 없으면 식 풀이 글 */ qtyFrag?: Fragment[]; + /** 호표 줄 종류 — 「재료」면 후보 목록(고르기 조건 줄이 아니어도) */ + kind?: string; + /** 시험 계산이 쓴 값 칸(계산 답 `품목.값칸`) — 위 물가 넷 가운데 굵게 */ + usedSlot?: string; + /** 있으면 후보 줄을 눌러 고름(시험 입력 · 저장 안 함) — 고르면 모달 닫힘 */ + onPick?: (item: { ref: string; label: string }) => void; } /** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */ @@ -176,7 +187,71 @@ function goButton(target: MasterJump): HTMLElement { }); } -function priceView(ref: string, brief: ElementBrief): HTMLElement { +/** 자재품목 값 칸 넷 — 위 가로 줄(관급은 뺌) */ +const SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격"]; +const MARKET_FILE = "재료_자재품목.json"; +const marketRows = new Map>(); +/** 자재품목 줄 하나(값 칸 넷) — 키로 찾음 · 한 번만 물음 */ +const marketRow = (ref: string): Promise => { + if (!marketRows.has(ref)) + marketRows.set( + ref, + fetchRows(MARKET_FILE, 1, 5, ref) + .then((got) => got.rows.find((r) => r["키"] === ref) ?? null) + .catch(() => null), + ); + return marketRows.get(ref)!; +}; + +/** 자재품목 줄 단가 — 이름 · 키 · 규격 + 물가 넷 가로(빈 칸 「-」 · 시험 계산이 쓴 값 굵게 · 모르면 낮은 값) */ +function marketView( + ref: string, + brief: ElementBrief | null, + usedSlot?: string, + onValue?: (value: unknown) => void, +): HTMLElement { + const head = el("strong"); + const spec = el("p", { className: "ui-text ui-text--muted" }); + const slots = el("div"); + const paint = (name: unknown, size: unknown, values: Record): void => { + head.textContent = `${String(name ?? ref)} · ${ref}`; + spec.textContent = size ? `${tl("Modal_Spec")} ${String(size)}` : ""; + spec.hidden = !size; + const nums = SLOTS.map((s) => values[s]).filter((v): v is number => typeof v === "number"); + const used = SLOTS.includes(usedSlot ?? "") + ? usedSlot + : SLOTS.find((s) => nums.length && values[s] === Math.min(...nums)); + slots.replaceChildren( + statGrid( + SLOTS.map((s) => { + const card = createStat({ + label: s, + value: values[s] === null || values[s] === undefined ? "-" : formatPrice(values[s]), + compact: true, + }); + card.classList.toggle("is-used", s === used); + return card; + }), + ), + ); + if (used) onValue?.(values[used]); + }; + paint(brief?.이름, brief?.규격, brief?.값들 ?? {}); + if (!brief?.값들) void marketRow(ref).then((row) => row && paint(row["이름"], row["규격"], row)); + return el("div", { + className: "m01lab__data m01lab__slots", + attrs: { "data-price": ref }, + children: [ + head, + spec, + slots, + goButton({ kind: "element", group: "재료", ref, file: brief?.file ?? MARKET_FILE }), + ], + }); +} + +function priceView(ref: string, brief: ElementBrief, usedSlot?: string): HTMLElement { + if (ref.startsWith("MT")) return marketView(ref, brief, usedSlot); const cols = Object.entries(brief.값칸 ?? {}); const slots = Object.entries(brief.값들 ?? {}).filter(([, v]) => v !== null && v !== undefined); return el("div", { @@ -207,8 +282,8 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement { }); } -/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움 */ -function openDialog(title: string, mount: (body: HTMLElement) => void): void { +/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움(닫기 함수도 받음) */ +function openDialog(title: string, mount: (body: HTMLElement, close: () => void) => void): void { openModal({ title, closeLabel: tl("Modal_Close"), @@ -220,58 +295,122 @@ function openDialog(title: string, mount: (body: HTMLElement) => void): void { const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text }); -/** 재료 고르기 줄 후보 목록 — 잡힌 품목(`pickedRef`)을 노란 줄로 · 후보가 100건을 넘으면 앞 100건만 옴 */ -function candidateView( - cond: PickCond, - pickedRef: string, - onPicked: (item: ElementBrief | null) => void, -): HTMLElement { - const box = el("div", { className: "m01lab__data", attrs: { "data-candidates": "1" } }); - box.append(muted("…")); - void searchElements("재료", seedOf(cond)) +const free = (v: unknown): string => { + const text = String(v ?? ""); + return text.includes("{") ? "" : text; // `{입력}` 자리는 계산 때 정해짐 — 거름에 안 씀 +}; +const squash = (v: unknown): string => + String(v ?? "") + .replace(/\s+/g, "") + .toLowerCase(); + +/** 재료 줄 후보 목록 — 고르기 조건 안 후보(`/materials` · 단위 거름) · 조건이 없으면 이름 찾기 · + * 찾기 칸 = 받은 후보 안에서 거름(없으면 재료 전체 이름 찾기) · 잡힌 품목(`pickedRef`) 노란 줄 맨 위 · + * `onPick` 이면 줄을 누르면 고름 · 값은 위 가로 칸이 보여 목록엔 값 열 없음 */ +function candidateView(target: ModalTarget, pickedRef: string, close: () => void): HTMLElement { + const cond = target.cond; + const sub = free(cond?.구분); + const list = el("div"); + const count = el("strong"); + const field = createInputField({ type: "search", placeholder: tl("Modal_Find") }); + const box = el("div", { + className: "m01lab__data", + attrs: { "data-candidates": "1" }, + children: [count, field.root, list], + }); + list.append(muted("…")); + let base: ElementBrief[] = []; + const draw = (items: ElementBrief[], total: number): void => { + const sorted = [ + ...items.filter((it) => it.ref === pickedRef), + ...items.filter((it) => it.ref !== pickedRef), + ]; + const more = total > items.length ? ` (${items.length})` : ""; + count.textContent = `${tl(target.onPick ? "Modal_PickList" : "Modal_Candidates")} ${total}${more}`; + const rows = sorted.map((it) => { + const hit = it.ref === pickedRef; + const tr = el("tr", { + className: `${hit ? "m01lab__hit" : ""}${target.onPick ? " m01lab__pickrow" : ""}`, + attrs: { ...(hit ? { "data-hit": "1" } : {}), "data-ref": it.ref }, + children: [ + el("td", { text: it.이름 ?? it.ref }), + el("td", { text: it.규격 ?? "" }), + el("td", { text: String(it.단위 ?? "") }), + ], + }); + const onPick = target.onPick; + if (onPick) + tr.addEventListener("click", () => { + close(); + onPick({ ref: it.ref, label: `${it.이름 ?? ""} ${it.규격 ?? ""}`.trim() || it.ref }); + }); + return tr; + }); + list.replaceChildren( + rows.length + ? el("div", { + className: "m01-logic__scroll m01lab__cands", + children: [ + el("table", { + className: "ui-table ui-table--data m01-logic__grid m01lab__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: [tl("Col_Name"), tl("Modal_Spec"), tl("Col_Unit")].map((h) => + el("th", { text: h }), + ), + }), + ], + }), + el("tbody", { children: rows }), + ], + }), + ], + }) + : muted(tl("Pill_NoPick")), + ); + }; + // 조건 없는 재료 줄 = 그 요소 이름 첫 낱말로 찾음(「모르타르, 서울 1:1」 → 「모르타르」) + const known = target.element ? target.prices[target.element] : null; + const seed = cond ? seedOf(cond) : headWord(known?.이름 ?? target.title).replace(/[,,].*$/, ""); + const first = sub + ? fetchMaterials({ + sub, + detail: free(cond?.상세구분), + spec: free(cond?.규격), + unit: target.unit ?? "", + }) + : searchElements("재료", seed); + let total = 0; + let timer = 0; + let seq = 0; + /** 찾기 칸 글로 거름 — 받은 후보 안에서 · 하나도 없으면 재료 전체 이름 찾기 */ + const apply = (): void => { + const mine = ++seq; + const q = field.input.value.trim(); + const words = q.split(/\s+/).map(squash).filter(Boolean); + if (!words.length) return draw(base, total); + const hits = base.filter((it) => + words.every((w) => squash(`${it.이름 ?? ""} ${it.규격 ?? ""} ${it.ref}`).includes(w)), + ); + if (hits.length) return draw(hits, hits.length); + void searchElements("재료", q) + .then((got) => mine === seq && draw(got.items, got.total)) + .catch(() => undefined); + }; + void first .then((got) => { - onPicked(got.items.find((it) => it.ref === pickedRef) ?? null); - const rows = got.items.map((it) => - el("tr", { - className: it.ref === pickedRef ? "m01lab__hit" : "", - attrs: it.ref === pickedRef ? { "data-hit": "1" } : {}, - children: [ - el("td", { text: it.이름 ?? it.ref }), - el("td", { text: it.규격 ?? "" }), - el("td", { text: String(it.단위 ?? "") }), - el("td", { className: "m01-logic__money", text: formatPrice(it.값) }), - ], - }), - ); - const more = got.total > got.items.length ? ` (${got.items.length})` : ""; - box.replaceChildren( - el("strong", { text: `${tl("Modal_Candidates")} ${got.total}${more}` }), - el("div", { - className: "m01-logic__scroll", - children: [ - el("table", { - className: "ui-table ui-table--data m01-logic__grid m01lab__table", - children: [ - el("thead", { - children: [ - el("tr", { - children: [ - tl("Col_Name"), - tl("Modal_Spec"), - tl("Col_Unit"), - tl("Modal_Value"), - ].map((h) => el("th", { text: h })), - }), - ], - }), - el("tbody", { children: rows }), - ], - }), - ], - }), - ); + base = got.items; + total = got.total; + field.input.value = sub ? free(cond?.["검색어"]) : ""; // 엔진이 고르는 검색어로 먼저 거름 · 지우면 조건 안 전부 + apply(); }) - .catch(() => box.replaceChildren(muted(tl("Pill_NoPick")))); + .catch(() => list.replaceChildren(muted(tl("Pill_NoPick")))); + field.input.addEventListener("input", () => { + window.clearTimeout(timer); + timer = window.setTimeout(apply, 300); + }); return box; } @@ -285,7 +424,7 @@ function pickedOf(cond: PickCond, values: Record, fallback: stri /** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) · * 재료 고르기 줄(`cond`)은 잡힌 품목 단가 + 후보 목록 */ export function openMaterialModal(target: ModalTarget): void { - openDialog(target.title, (body) => { + openDialog(target.title, (body, close) => { const cond = target.cond; const element = target.element ?? ""; const base = element.split(".")[0]; @@ -312,7 +451,7 @@ export function openMaterialModal(target: ModalTarget): void { setSum(known); const rate = el("div", { className: "m01lab__modal-body" }); if (known && priceRef) { - rate.append(priceView(priceRef, known)); + rate.append(priceView(priceRef, known, target.usedSlot)); } else if (logicKey) { rate.append( el("p", { @@ -327,7 +466,12 @@ export function openMaterialModal(target: ModalTarget): void { }), ); } else if (cond && priceRef) { - rate.append(muted("…")); // 후보 목록이 오면 그 줄로 채움 — 100건 밖이면 키로 따로 찾아 옴 + // 서버가 잡은 품목 · 고른 품목 — 자재품목 줄을 키로 읽어 물가 넷 + rate.append( + marketView(priceRef, null, target.usedSlot, (value) => + setSum({ ref: priceRef, 값: value } as ElementBrief), + ), + ); } else if (cond) { rate.append(muted(tl("Pill_NoPick"))); } else if (element.includes("{")) { @@ -358,23 +502,7 @@ export function openMaterialModal(target: ModalTarget): void { } body.replaceChildren( rate, - ...(cond - ? [ - candidateView(cond, priceRef, (item) => { - if (known || !priceRef) return; - const show = (brief: ElementBrief | null): void => { - rate.replaceChildren( - brief ? priceView(priceRef, brief) : muted(`${priceRef} — ${tl("Pill_NoValue")}`), - ); - setSum(brief); - }; - if (item) return show(item); - void searchElements("재료", priceRef) - .then((got) => show(got.items.find((it) => it.ref === priceRef) ?? null)) - .catch(() => show(null)); - }), - ] - : []), + ...(cond || target.kind === "재료" ? [candidateView(target, priceRef, close)] : []), muted(tl("Modal_Sum")), sum, el("details", { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index 0a8e482bc..0292d5cd7 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -215,3 +215,13 @@ .m01lab__now { margin: 0; } + +/* 재료 모달(711-2) — 위 물가 넷 가로 · 시험 계산이 쓴 값만 굵게 · 후보 목록은 모달 안 스크롤 */ +.m01lab__slots .ui-stat:not(.is-used) .ui-stat__value { + font-weight: 400; +} + +.m01lab__cands { + max-height: 40vh; + overflow: auto; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 1b977355b..8ecee9c54 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -83,6 +83,11 @@ const TEXT = { "후보 목록 (노란 줄 = 시험 계산이 쓰는 품목)", "Candidates (yellow = used in the trial)", ], + Modal_PickList: [ + "후보 목록 — 줄을 누르면 그 품목으로 시험 계산 (노란 줄 = 지금 쓰는 품목)", + "Candidates — click a row to use it in the trial (yellow = in use)", + ], + Modal_Find: ["이름 · 규격으로 찾기", "Search by name or spec"], Modal_Logic: ["다른 로직을 부름", "Calls another logic"], Modal_NoData: ["표나 단가 자료가 없는 줄 — 수량 식만 있음", "No table or price data"], Modal_Hit: ["노란 줄 = 지금 넣은 값으로 걸린 줄", "Highlighted row matches the trial values"], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts b/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts index 1b608b92e..c7388dfa2 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M01_MasterData_UI_Logic_CalcPick.ts * 시험 계산의 재료 · 기계 고르기 — 재료 칸마다 잡힌 품목 이름(누르면 고르기) · 기계 입력은 단추 하나 - * 누르면 마스터 표 모달(`PickTable`) · 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함 + * 재료 = 상세 표 줄과 같은 재료 모달(`openMaterialModal` · 711-2) · 기계 = 마스터 표 모달(`PickTable`) + * 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함 * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; @@ -9,7 +10,7 @@ import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch"; import type { CalcLine, ElementBrief, LogicInput, LogicRow } from "./M01_MasterData_UI_Logic_Api"; import { openPickTable } from "./M01_MasterData_UI_Logic_PickTable"; import { tx } from "./M01_MasterData_UI_Logic_Text"; -import { condText, headWord, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal"; +import { condText, openMaterialModal, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal"; interface Chosen { ref: string; @@ -24,6 +25,19 @@ const picksOf = (values: Record): Map => { return mine; }; +/** 재료 고름(시험 입력 · 저장 안 함) — 시험 계산 칸(`host`)의 자재 글을 바꾸고 다시 셈 · 상세 표 줄 모달이 씀 */ +export function pickMaterial( + host: HTMLElement, + values: Record, + i: number, + it: Chosen, +): void { + picksOf(values).set(i, it); + const link = host.querySelector(`[data-mat-idx="${i}"]`); + if (link) link.textContent = it.label; + host.querySelector("[data-calc-run]")?.click(); +} + /** 계산 요청 몸의 `고름` — 고른 것이 없으면 안 실음 */ export function pickBody(values: Record): { 고름?: Record } { const mine = picksOf(values); @@ -78,9 +92,6 @@ export function materialRows( const held = mine.get(i); const nowName = held?.label ?? (brief ? `${brief.이름 ?? ""} ${brief.규격 ?? ""}`.trim() : "") ?? ""; - const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label] - .map(headWord) - .filter((w, k, all) => w && all.indexOf(w) === k); // 잡힌 품목 이름 = 링크 단추 — 누르면 고르기 모달([품목 고르기] 단추 걷음) const current = createButton({ label: nowName || tx("Mat_NoPrice"), @@ -90,20 +101,26 @@ export function materialRows( current.dataset.matIdx = String(i); current.dataset.ref = brief?.ref ?? ""; current.title = tx("Pick_Item"); + const title = `${label}${item.규격 ? ` ${item.규격}` : ""}`; const pick = (): void => - openPickTable({ + openMaterialModal({ + title, + expr: item.수량, + ...(cond ? { cond } : { element: item.요소 }), + unit: item.단위, kind: "재료", - title: `${tx("Pick_MatTitle")} · ${label}`, - seed: seeds[0] ?? "", - alt: seeds.slice(1), - isCurrent: (r) => String(r["키"]) === (mine.get(i)?.ref ?? current.dataset.ref), - onPick: (r) => { - mine.set(i, { ref: String(r["키"]), label: nameOf(r) }); - current.textContent = nameOf(r); + prices, + middles: row.중간 ?? [], + values, + unitRef: mine.get(i)?.ref || current.dataset.ref || undefined, + usedSlot: current.dataset.slot || undefined, + onPick: (it) => { + mine.set(i, it); + current.textContent = it.label; again(); }, }); - return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current); + return line(title, current); }); } @@ -117,6 +134,7 @@ export function showItems( host.querySelectorAll("[data-mat-idx]").forEach((span) => { const i = Number(span.dataset.matIdx); const item = lines[i]?.품목; + span.dataset.slot = item?.값칸 ?? ""; // 시험 계산이 쓴 값 칸 — 모달 위 가로 칸 굵게 if (!item || mine.has(i)) return; span.dataset.ref = item.키; span.textContent = `${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키; From b54950d28bbf22d8de5b58d80b8c88fd098003af Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:34:29 +0900 Subject: [PATCH 04/30] =?UTF-8?q?feat(719-1):=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=82=AC=EB=B3=B8=20SK=20=ED=92=80?= =?UTF-8?q?=EC=96=B4=20=EA=B5=B3=ED=9E=98=20=E2=80=94=20=EA=B3=A0=EC=B9=9C?= =?UTF-8?q?=20=EC=A4=84=20=3D=20=EC=82=AC=EB=B3=B8=20=EB=8B=A8=EA=B0=80.js?= =?UTF-8?q?on=20=EB=A1=9C=EC=A7=81=20=EC=A4=84=20=C2=B7=20=EA=B5=B3?= =?UTF-8?q?=ED=9E=8C=20=ED=82=A4=20GX9#####=20(SK=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=ED=91=9C=20=ED=95=B4=EC=8B=9C=20=C2=B7=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?SK=20=3D=20=EA=B0=99=EC=9D=80=20=ED=82=A4)=20=C2=B7=20B09=20?= =?UTF-8?q?=EC=84=9C=EA=B3=A0=20manifest=20=EC=9B=90=EB=B3=B8=ED=8C=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Item_Sangeun: 참조사본 SK → M02_Sangeun_Calc.resolve 고친 줄 → 로직_단가산출.json 줄 · 사본 SK = 바탕 굳힌 키 · 덮개 0 · 원본 · 바탕판 · 짝잃음 근거 - M02_Item_Source: labor_doc_rows 가 굳힘 · price_bundle 이 굳힌 줄과 그 줄이 닿는 마스터 줄 · 고른 자재 담음 - M02_Item_Prices: 수집기 줄 하나 길(row) 떼어 둠 · 요소 없는 줄(참조 · 고정 더한줄) 건너뜀 - B09 Bill_Library: 서고 사본 manifest 원본판 = 일위대가.json 지문(source_version) - 시험 test_719_1_sk_freeze 4(임시 storage) Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Bill_Library.py | 12 +- M02_MasterTemplete/M02_Item_Prices.py | 15 ++- M02_MasterTemplete/M02_Item_Sangeun.py | 102 +++++++++++++++ M02_MasterTemplete/M02_Item_Source.py | 17 ++- resources/tester/test_719_1_sk_freeze.py | 120 ++++++++++++++++++ 5 files changed, 257 insertions(+), 9 deletions(-) create mode 100644 M02_MasterTemplete/M02_Item_Sangeun.py create mode 100644 resources/tester/test_719_1_sk_freeze.py diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index 78b52bec9..4b31102a2 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -54,6 +54,7 @@ from M02_MasterTemplete.M02_Item_Source import ( labor_doc_rows, price_bundle, read_json, + version_of, ) LIBRARY = "library" @@ -100,6 +101,12 @@ def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]: } +def source_version(files: dict[str, Any]) -> str: + """서고 사본 원본판(719-1) = 일위대가.json 지문 — 굳힌 SC · SK 문서 + 풀어 굳힌 SK 줄(바탕 + M01 줄 · 바탕판) · 단가는 뺌(단가 바뀜은 [업데이트] 단가 굳힘 몫) · 값 덮기 전에 셈.""" + return version_of(files.get(LABOR_FILE)) + + def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, dict]: """서고 원본 → (사본 파일들, 원본 표지).""" if kind not in KINDS: @@ -247,7 +254,7 @@ def import_copy( if isinstance(read_json(library_dir(project_root) / name / MANIFEST_NAME), dict): return {"폴더": name, "만듦": False} manifest = {"종류": kind, "원본": origin, "가져온날": now_text(), "가져온이": who} - _write(project_root, name, files, manifest, values) + _write(project_root, name, files, {**manifest, "원본판": source_version(files)}, values) return {"폴더": name, "만듦": True} @@ -293,7 +300,8 @@ def resolve(project_root: str | Path, ref: dict[str, Any], dist: Any) -> str | N got = _build(kind, layer, owner, key) except (LookupError, ValueError): return None - got = (got[0], {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None}) + made = {"종류": kind, "원본": got[1], "가져온날": now_text(), "가져온이": None} + got = (got[0], {**made, "원본판": source_version(got[0])}) files, manifest = got values = haul_values(lines_of(files), ref.get("given"), ref.get("own"), dist) name = base + (f"~{tag(values)}" if values else "") diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index 8094536f2..a74ea02db 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -44,7 +44,7 @@ def _texts(row: dict) -> list[str]: out.append(row["결과"]["식"]) for item in row.get("호표", []): out.append(item["수량"]) - if not isinstance(item.get("요소"), dict): + if isinstance(item.get("요소"), str): # 단가산출 참조 · 고정 더한줄 = 요소 없음(719-1) out.append(item["요소"]) out += [extra["식"] for extra in row.get("덧줄", [])] return out @@ -200,6 +200,10 @@ class _Collector: return self.logics.add(key) self.keys.add(key) + self.row(row) + + def row(self, row: dict) -> None: + """로직 줄 하나가 닿는 것 — 마스터 로직 · 굳힌 단가산출 줄(719-1 · 마스터 밖 줄).""" if isinstance(row.get("흐름"), dict): # 흐름 로직 — 옛 칸 대신 흐름이 쓰는 것을 모음 self.flow(row) return @@ -221,12 +225,17 @@ class _Collector: self.pick(row, item["요소"], item.get("단위")) -def collect(logic_keys: list[str], materials: list[str] | None = None) -> dict[str, Any]: - """담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}`.""" +def collect( + logic_keys: list[str], materials: list[str] | None = None, extra: list[dict] | None = None +) -> dict[str, Any]: + """담은 로직 + 자재(MT)의 닫힌 단가 묶음 — `{파일 이름: 파일(줄은 모은 것만 · 차례 그대로)}` · + `extra` = 마스터 밖 로직 줄(굳힌 단가산출 719-1) — 그 줄이 닿는 것만 모음(줄은 안 담음).""" files, whole = master() collector = _Collector(whole) for key in logic_keys: collector.logic(key) + for row in extra or []: + collector.row(row) for code in materials or []: collector.add(code) out: dict[str, Any] = {} diff --git a/M02_MasterTemplete/M02_Item_Sangeun.py b/M02_MasterTemplete/M02_Item_Sangeun.py new file mode 100644 index 000000000..0b5962009 --- /dev/null +++ b/M02_MasterTemplete/M02_Item_Sangeun.py @@ -0,0 +1,102 @@ +"""프로젝트 사본 — M02 단가산출(SK) 풀어 굳힘(PLAN 719-1 · 요구서 내역12 원칙 3 · 4장 14). + +사본을 만들 때(구조물 사본 `M02_Item_Source.build_source` · B09 서고 사본 `Bill_Library`) 굳힌 +참조사본 안 SK 문서마다 고친 줄(`M02_Sangeun_Calc.resolve` = 바탕 사슬 + 덮개 · 더한줄 · 뺀줄)을 +풀어 사본 `단가.json` 로직 줄 하나로 굳힘 — 사본 SK 문서는 그 줄만 바탕으로 가리킴(덮개 0 · +연결 끊김). 셈은 그대로 `M02_Sangeun_Calc` 한 벌(사본 색인 = 사본 `단가.json`). + +굳힌 키 = SK 참조 이름표(`ref_id` = 층 · 주인 · 키)에서 정해지는 `GX9#####`(해시 · 겹치면 +이름표 차례로 다음 번호) — 같은 SK = 같은 굳힌 키([업데이트] 때 B09 덮개 cover · cid · 입력값 · +고른자재가 짝을 안 잃음). 원본 층 문서는 읽기만. +사본 SK 문서 = {종류 · 키 · 이름 · 단위 · 구분 · 상세구분, 바탕 {로직: 굳힌 키}, 입력값(사슬 합), +열린입력, 켜쌓기율, 고름 · 자재표시(굳힌 줄 cid), 덮개 {}, 더한줄 [], 뺀줄 [], +굳힌줄(= 단가.json 줄), 원본(원래 SK 문서 · 「M02 에서 고침」 근거), 바탕판, 짝잃음}. +단가산출 속 일위대가(SC) · SK 더한줄 = 참조 줄 그대로 → 사본 셈이 굳힌 참조사본으로 펼침. +""" + +from __future__ import annotations + +import hashlib +from typing import Any + +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +#: 사본 `단가.json` 안 굳힌 SK 줄 파일(`master_formula.Master` 가 그룹 「로직」 으로 읽음) +FILE = "로직_단가산출.json" +ROW = "굳힌줄" +_HEAD, _LOW, _SPAN = "GX", 900000, 99999 # GX900000 ~ GX999998(999999 = M01 저장 전 초안) + + +def frozen_key(rid: str, taken: set[str]) -> str: + """SK 이름표 → 굳힌 로직 키 — 해시 자리 · 이미 쓴 키면 다음 번호(제자리 `taken` 에 더함).""" + at = int(hashlib.sha256(rid.encode("utf-8")).hexdigest(), 16) % _SPAN + while (key := f"{_HEAD}{_LOW + at:06d}") in taken: + at = (at + 1) % _SPAN + taken.add(key) + return key + + +def is_sangeun(doc: Any) -> bool: + return isinstance(doc, dict) and doc.get("종류") == sd.TITLE + + +def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + """참조사본 `{ref_id: 문서}` → SK 를 풀어 굳힌 참조사본(새 묶음 · 받은 것 안 고침) · + 못 푼 SK(없는 바탕 · 순환) = 그대로(사본 셈이 까닭을 냄).""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + sks = sorted(rid for rid, doc in copies.items() if is_sangeun(doc)) + if not sks: + return copies + whole = m01.loaded()[1] + + def read(ref: dict) -> dict | None: + return copies.get(refs.ref_id(ref)) + + out, taken = dict(copies), set() + for rid in sks: + doc = copies[rid] + try: + got = calc.resolve(doc, whole, read, (rid,)) + except sd.DocError: + continue + key = frozen_key(rid, taken) + row = prices.to_json({**got["줄"], "키": key}) + ids = sd.line_ids(row) + frozen = {k: doc.get(k) for k in ("종류", "키", "이름", "단위", "구분", "상세구분")} + frozen |= { + "바탕": {"로직": key}, + "입력값": prices.to_json(got["입력값"]), + "열린입력": list(doc.get("열린입력") or []), + "고름": {ids[int(n)]: mt for n, mt in got["고름"].items()}, + "자재표시": {ids[int(n)]: one for n, one in got["자재표시"].items()}, + "덮개": {}, + "더한줄": [], + "뺀줄": [], + ROW: row, + "원본": doc, + "바탕판": got["바탕판"], + "짝잃음": got["짝잃음"], + } + if got["켜쌓기율"] is not None: + frozen["켜쌓기율"] = prices.to_json(got["켜쌓기율"]) + out[rid] = frozen + return out + + +def rows(copies: dict[str, Any]) -> list[dict[str, Any]]: + """굳힌 SK 줄 — 사본 `단가.json` `FILE` 에 넣음(이름표 차례).""" + return [doc[ROW] for _, doc in sorted(copies.items()) if is_sangeun(doc) and ROW in doc] + + +def materials(copies: dict[str, Any]) -> list[str]: + """굳힌 SK 고른 자재 키 — 단가 묶음에 담음.""" + return [ + str(mt) + for doc in copies.values() + if is_sangeun(doc) + for mt in (doc.get("고름") or {}).values() + ] diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 47a0196c9..489ad8ea0 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -5,7 +5,7 @@ `일위대가.json` = 층 `labor/<열id>.json` 문서(39-1c · 없으면 옛 UA 조합 줄 · 규격 열 = summary_item `spec` 열 수량을 손으로 굳힘 · 704-4) + 담은 로직 줄(M01 시스템) + 알약(가져온 때 그대로) + - 참조사본(단가승격 · 사전조합을 값으로 굳힘) · + 참조사본(단가승격 · 사전조합을 값으로 굳힘 · 단가산출 SK = 풀어 굳힌 로직 줄 719-1) · `단가.json` 계산에 닿는 마스터 줄 전부(담은 로직 + 일위대가 자재 MT) `원본판` = 모은 내용의 해시 — 지금 층 · 마스터에서 다시 모은 판과 대어 「마스터가 바뀜」 을 앎. 프로젝트가 고치는 값(`입력값.json` · `고른자재.json`)은 원본이 아니라 여기서 안 모음. @@ -93,11 +93,12 @@ def labor_rows(ua_key: str) -> dict[str, Any] | None: def labor_doc_rows(doc: dict[str, Any]) -> dict[str, Any]: """일위대가 문서(39-1a) + 담은 로직 줄 + 알약(`M02_Labor_Doc.pills` · 가져온 때 그대로 — 복사본 계산이 마스터를 안 읽게) + 참조사본 `{ref_id: 문서}`(50-3 · 원본이 바뀌어도 그대로).""" + from M02_MasterTemplete import M02_Item_Sangeun as sk from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Ref as refs rows = {**logic_rows(doc), "알약": labor_doc.pills(doc)} - copies = refs.frozen(doc) + copies = sk.freeze(refs.frozen(doc)) # 단가산출(SK) = 풀어 굳힌 로직 줄(719-1) return {**rows, "참조사본": copies} if copies else rows @@ -162,11 +163,14 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: """사본 단가 묶음(`단가.json`) — 담은 로직 · 굳힌 참조 로직 · 자재 · 고른 자재 · 사전조합 자재 · 개인 자재(`root` = 원본 층 · B09 설계내역서 서고 사본도 이 길 · 204-2).""" from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Item_Sangeun as sk from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs + copies = (labor or {}).get("참조사본") or {} + sk_rows = sk.rows(copies) # 굳힌 단가산출 줄(719-1) — 그 줄이 닿는 마스터 줄도 모음 logic_keys = [str(one.get("키")) for one in (labor or {}).get("로직", [])] - logic_keys += refs.frozen_logics((labor or {}).get("참조사본") or {}) # 굳힌 단가승격 로직 + logic_keys += refs.frozen_logics(copies) # 굳힌 단가승격 로직 doc = (labor or {}).get("줄") or {} materials = [str(m.get("코드")) for m in doc.get("자재") or []] materials += [ # 고른 로직 자재(103-1) 품목 줄도 — 복사본 계산이 그 단가를 씀 @@ -174,8 +178,13 @@ def price_bundle(labor: dict[str, Any] | None, root: Path) -> dict[str, Any]: ] materials += refs.frozen_materials((labor or {}).get("참조사본") or {}) # 175-2 사전조합 자재 materials += fixed.link_keys(doc) # 705-1 고정 줄이 잇는 노임 · 자재 · 기계 + materials += sk.materials(copies) wanted = logic_keys + [code for code in materials if code] - bundle = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} + bundle = ( + prices.to_json(prices.collect(logic_keys, materials, sk_rows)) if wanted or sk_rows else {} + ) + if sk_rows: + bundle[sk.FILE] = {"그룹": "로직", "원문": "자체", "줄": sk_rows} if mine := own.bundle( [c for c in materials if own.is_own(c)], root ): # 개인 자재(148-3 · 사전조합 것도 175-2) — 원본 목록이 바뀌어도 사본 그대로 diff --git a/resources/tester/test_719_1_sk_freeze.py b/resources/tester/test_719_1_sk_freeze.py new file mode 100644 index 000000000..bcaf387f8 --- /dev/null +++ b/resources/tester/test_719_1_sk_freeze.py @@ -0,0 +1,120 @@ +"""719-1 프로젝트 사본 — M02 단가산출(SK) 풀어 굳힘(`M02_Item_Sangeun` · `M02_Item_Source` · +`M02_Item_Prices` · `B09_Estimation_Bill_Library`) — 임시 storage(견본 층 · 실제 M01 읽기만). + +구조물 사본 · B09 서고 사본 셈 = M02 층 SK 셈 · 굳힌 줄은 사본 `단가.json` 로직 줄 · 원본 SK 를 +지워도 같은 값 · 같은 SK = 같은 굳힌 키(다시 복사 · SK 고친 뒤도) · 원본 층 지문 그대로 · +서고 manifest 원본판 = SK 바뀌면 다름. +""" + +# ruff: noqa: F811 — 임시 층 fixture `world` 를 들여 씀 +import hashlib +import json + +import pytest + +from B09_Estimation import B09_Estimation_Bill_Library as library +from M02_MasterTemplete import M02_Item_Prices as prices +from M02_MasterTemplete import M02_Item_Sangeun as sk +from M02_MasterTemplete import M02_Item_Source as source +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_715_sangeun_store import PCT, _sk +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +COVER = {"labor:LB000036#0": {"qty": 0.5}} +ADD = [{"종류": "인력", "요소": "LB000002", "수량": 0.1}] + + +def _print(folder): + """층 폴더 지문 — 파일 이름 · 바이트.""" + out = hashlib.sha256() + for path in sorted(p for p in folder.rglob("*") if p.is_file()): + out.update(str(path.relative_to(folder)).encode() + path.read_bytes()) + return out.hexdigest() + + +def _top(ref, qty=2): + with store.at(ref["folder"]): + doc = labor.clean({"열": "rv", "담은로직": [{"참조": ref["ref"], "수량": {"손": qty}}]}) + return doc, labor.calc(doc, {})["줄"][0] + + +def _mine(world): + key = _sk(world["personal"], 바탕={"로직": PCT}, 덮개=COVER, 더한줄=ADD) + return key, {"folder": world["personal"], "ref": {**MINE, "키": key}} + + +def test_구조물_사본_굳힌_줄_값_같음_원본_지워도(world): + key, ref = _mine(world) + doc, live = _top(ref) + assert not live["까닭"] and live["기준"]["계"] > 0 + before = _print(world["personal"]) + rows = source.labor_doc_rows(doc) + bundle = source.price_bundle(rows, world["personal"]) + assert _print(world["personal"]) == before # 원본 층 안 건드림 + rid = refs.ref_id(ref["ref"]) + frozen = rows["참조사본"][rid] + fk = frozen["바탕"]["로직"] + assert fk == sk.frozen_key(rid, set()) and fk.startswith("GX9") + assert frozen["덮개"] == {} and frozen["더한줄"] == [] and frozen["뺀줄"] == [] + assert frozen["원본"]["덮개"]["labor:LB000036#0"]["qty"] == 0.5 # 고친 근거 + assert [r["키"] for r in bundle[sk.FILE]["줄"]] == [fk] + json.dumps(rows["참조사본"], ensure_ascii=False) # 사본 파일로 쓸 수 있는 꼴(Decimal 없음) + (world["personal"] / "sangeun" / f"{key}.json").unlink() # 원본이 사라져도 사본만 봄 + got = prices.calc(rows, json.loads(json.dumps(bundle, ensure_ascii=False)), {}, {}, None) + line = got["줄"][0] + assert not line["까닭"] + for part in ("노무비", "재료비", "경비", "계"): + assert line["기준"][part] == pytest.approx(live["기준"][part], rel=1e-9) + assert line["반영"]["계"] == pytest.approx(live["반영"]["계"], rel=1e-9) + + +def test_같은_SK_같은_굳힌_키_다른_SK_다른_키(world): + key, ref = _mine(world) + doc, _ = _top(ref) + rid = refs.ref_id(ref["ref"]) + first = source.labor_doc_rows(doc)["참조사본"][rid]["바탕"]["로직"] + with store.at(world["personal"]): # SK 를 고쳐도 굳힌 키 그대로(B09 덮개 짝 지킴) + now = store.read("sangeun", key) + store.write("sangeun", key, now["판"], {**now["문서"], "덮개": {}}) + again = source.labor_doc_rows(doc)["참조사본"][rid] + assert again["바탕"]["로직"] == first and again["원본"]["덮개"] == {} + other = refs.ref_id({"층": "company", "주인": "7", "키": key}) # 같은 번호 다른 층 + assert sk.frozen_key(other, set()) != first + taken = {first} + assert sk.frozen_key(rid, taken) != first and len(taken) == 2 # 겹치면 다음 번호 + + +def test_B09_서고_사본_값_같음_원본판(world, tmp_path): + _, ref = _mine(world) + with store.at(world["personal"]): + sc = refs.create( + "precombo", + {"이름": "조합", "단위": "㎡", "담은로직": [{"참조": ref["ref"], "수량": {"손": 2}}]}, + )["이름"] + _, live = _top({"folder": world["personal"], "ref": {**MINE, "키": sc}}, 1) + before = _print(world["personal"]) + root = tmp_path / "proj" + made = library.import_copy(root, "precombo", "personal", "7/42", sc) + assert made["만듦"] and _print(world["personal"]) == before + folder = library.library_dir(root) / made["폴더"] + read = {p.name: json.loads(p.read_text(encoding="utf-8")) for p in folder.glob("*.json")} + got = prices.calc(read[source.LABOR_FILE], read[source.PRICE_FILE], {}, {}, None)["줄"][0] + assert not got["까닭"] + assert got["기준"]["계"] == pytest.approx(live["기준"]["계"], rel=1e-9) + mark = read["manifest.json"]["원본판"] + built = library._build("precombo", "personal", "7/42", sc)[0] + assert library.source_version(built) == mark # 다시 모아도 같은 판 + again = library.import_copy(root, "precombo", "personal", "7/42", sc) + assert again == {"폴더": made["폴더"], "만듦": False} + with store.at(world["personal"]): # SK 고침 → 원본판 다름 + now = store.read("sangeun", ref["ref"]["키"]) + store.write("sangeun", ref["ref"]["키"], now["판"], {**now["문서"], "덮개": {}}) + assert library.source_version(library._build("precombo", "personal", "7/42", sc)[0]) != mark + + +def test_SK_없는_사본_그대로(): + """SK 없는 참조사본 = 받은 묶음 그대로(기존 사본 · 공사 금액 전후 같음).""" + copies = {"x": {"종류": "사전조합", "담은로직": []}} + assert sk.freeze(copies) is copies and sk.rows(copies) == [] and sk.materials(copies) == [] From ceec2a99dad3c67e47f93c4af0eb6a32dd2bc489 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:35:23 +0900 Subject: [PATCH 05/30] =?UTF-8?q?feat(716-4):=20=EC=84=9C=EA=B3=A0=20?= =?UTF-8?q?=EB=82=98=EB=AC=B4=20=ED=8F=B4=EB=8D=94=20=E2=80=94=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=EC=9D=B8=EC=9E=90=20=C2=B7=20=EC=95=88=EC=97=90=20?= =?UTF-8?q?=EB=93=A0=20=EA=B2=83=EC=A7=B8=20=EC=A7=80=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?=EC=93=B0=EB=8A=94=20=EA=B3=B3=20409=20=C2=B7=20=EC=97=AC?= =?UTF-8?q?=EB=9F=BF=20=C2=B7=20=EB=B3=B5=EC=82=AC=20=EB=B6=99=EC=97=AC?= =?UTF-8?q?=EB=84=A3=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 `M02_Precombo_Folders` — 종류 인자(precombo · sangeun · 자리 파일 `{종류}_폴더.json`) · 폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 409 + 그 이름 · 지우는 것끼리 참조는 됨) · 문서 지우기 · `대상` 여럿 옮기기 · 지우기 · 붙여넣기(새 키 · 본뜬 = 원본 · 같은 폴더 이름 「(1)」) - 길 `M02_Labor_Router` — `/layers/{층}/{종류}-tree` GET · POST · `/paste`(옛 precombo-tree 주소 그대로) - 화면 새 `UI_Side_Library.ts` — 일위대가 · 단가산출 두 칸이 같이 쓰는 서고 칸(Ctrl · Shift 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V · Delete · 지우기 확인 창 · 끌어 옮기기 여럿) · `UI_Side_Precombo.ts` = 일위대가 몫만 넘김 - `Api_Fetch` — 나무 부르기 종류 인자 · `pasteLibraryTree` · `newLibrary` 종류 sangeun - 시험 — 새 `test_716_4_library_folders.py`(임시 storage) · 낡은 것 고침: `test_m02_precombo_folders`(빈 폴더만 지움 → 안에 든 것째 · 「없는 사전조합」 → 「없는 문서」 · FILE → file_of) · `test_m02_precombo_scope` + helper(나무 짜기 · 서고 길 글이 Library.ts 로 옮김) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Labor_Router.py | 51 +- .../M02_MasterTemplete_Api_Fetch.ts | 36 +- .../M02_MasterTemplete_UI_Side_Library.ts | 451 ++++++++++++++++++ .../M02_MasterTemplete_UI_Side_Precombo.ts | 307 +----------- M02_MasterTemplete/M02_Precombo_Folders.py | 194 ++++++-- .../tester/helper_m02_precombo_scope.cjs | 7 +- .../tester/test_716_4_library_folders.py | 119 +++++ resources/tester/test_m02_precombo_folders.py | 15 +- resources/tester/test_m02_precombo_scope.py | 24 +- 9 files changed, 842 insertions(+), 362 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts create mode 100644 resources/tester/test_716_4_library_folders.py diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 5931b2b67..b7126e051 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -3,7 +3,8 @@ 문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 · 여기는 저장하지 않는 셈만 + 서고(단가승격 · 사전조합 · 50-3) 새로 · 복사(키는 서버가 줌 · 고침 · 지우기는 양식 길 그대로) + 개인 자재 목록 · 추가(148-2 · 개인 층만 · 품번은 서버) + -사전조합 나무 자유 폴더(158-1 · `M02_Precombo_Folders` · 읽기 = 층 읽기 · 고침 = 층 쓰기) + +서고 나무 자유 폴더(158-1 · 716-4 `{종류}-tree` · `M02_Precombo_Folders` · 읽기 = 층 읽기 · +고침 = 층 쓰기) + 일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) · 338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 · 올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`) · @@ -46,6 +47,9 @@ from M02_MasterTemplete.M02_MasterTemplete_Router_Items import ( _call, _root, ) +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import ( + PasteBody as TreePasteBody, +) router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"]) @@ -130,25 +134,44 @@ def new_library( return _call(folder, _copy, kind, doc, (got.layer, got.owner), ref) -@router.get("/layers/{layer}/precombo-tree") -def precombo_tree( - layer: Layer, owner: str | None = None, session: Session = Depends(verify_session) -) -> dict: - """사전조합 나무 폴더 — `{판, 폴더: [경로 …], 자리: {키: 경로}}`(자리 없는 문서 = 뿌리).""" - return _call(_root(session, layer, owner), pre_folders.view) - - -@router.post("/layers/{layer}/precombo-tree") -def edit_precombo_tree( +@router.get("/layers/{layer}/{kind}-tree") +def library_tree( layer: Layer, + kind: Literal["precombo", "sangeun"], + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """서고 나무 폴더(일위대가 · 단가산출) — `{판, 폴더: [경로 …], 자리: {키: 경로}}` · + 자리 없는 문서 = 뿌리.""" + return _call(_root(session, layer, owner), pre_folders.view, kind) + + +@router.post("/layers/{layer}/{kind}-tree") +def edit_library_tree( + layer: Layer, + kind: Literal["precombo", "sangeun"], body: TreeBody, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - """새 폴더 · 이름 바꾸기 · 옮기기(폴더 · 문서 `키`) · 지우기(빈 폴더만) → 새 나무 · - 판 다르면 409.""" + """새 폴더 · 이름 바꾸기 · 옮기기 · 지우기(폴더 = 안에 든 문서째 · 쓰는 곳 있으면 409) · + 여럿 = `대상` → 새 나무 · 판 다르면 409.""" folder = _root(session, layer, owner, write=True) - return _call(folder, pre_folders.edit, body.model_dump()) + return _call(folder, pre_folders.edit, body.model_dump(), kind) + + +@router.post("/layers/{layer}/{kind}-tree/paste") +def paste_library_tree( + layer: Layer, + kind: Literal["precombo", "sangeun"], + body: TreePasteBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """복사한 층(읽기 권한) 문서 · 폴더 → 이 층(쓰기 권한) `경로` 아래 새 키 → 새 나무 + 알림.""" + target = _root(session, layer, owner, write=True) + source = _root(session, body.from_layer, body.from_owner) + return _call(target, pre_folders.paste, source, body.대상, body.경로, kind) @router.get("/layers/{layer}/materials") diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 6da5621f7..205097490 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -150,7 +150,7 @@ export type LibraryBody = { 문서: Record } | { 본뜬: { 층: Layer; 주인: string | null; 키: string } }; export const newLibrary = ( layer: Layer, - kind: "precombo", + kind: LibraryKind, body: LibraryBody, owner?: string | null, ): Promise => @@ -159,25 +159,49 @@ export const newLibrary = ( body: JSON.stringify(body), }); -/** 사전조합 나무 자유 폴더(158-1) — 자리 없는 문서 = 뿌리 · 판이 다르면 409(`StaleError`) */ +/** 서고 나무 자유 폴더(158-1 · 716-4) — 일위대가(precombo) · 단가산출(sangeun) 따로 · + * 자리 없는 문서 = 뿌리 · 판이 다르면 409(`StaleError`) · 지우기 = 안에 든 문서째(쓰는 곳 있으면 409) */ +export type LibraryKind = "precombo" | "sangeun"; export interface PrecomboTree { 판: string; 폴더: string[][]; 자리: Record; + 알림?: { op: string; 지운문서?: string[]; 만든문서?: string[]; 바뀐이름?: string[] }; } -export const fetchPrecomboTree = (layer: Layer, owner: string | null): Promise => - call(owned(layer, `/layers/${layer}/precombo-tree`, owner)); +const libTree = (kind: LibraryKind, layer: Layer, owner: string | null, tail = ""): string => + owned(layer, `/layers/${layer}/${kind}-tree${tail}`, owner); +export const fetchPrecomboTree = ( + layer: Layer, + owner: string | null, + kind: LibraryKind = "precombo", +): Promise => call(libTree(kind, layer, owner)); export const editPrecomboTree = ( layer: Layer, edit: Pick & { op: "add-folder" | "rename" | "move" | "delete"; + 대상?: TreeRef[]; }, 판: string, owner: string | null, + kind: LibraryKind = "precombo", ): Promise => - call(owned(layer, `/layers/${layer}/precombo-tree`, owner), { + call(libTree(kind, layer, owner), { method: "POST", body: JSON.stringify({ ...edit, 판 }) }); +/** 복사해 둔 문서 · 폴더 → 이 층 `dest` 아래 새 키(폴더는 안의 것까지 · 같은 폴더 이름 「(1)」) */ +export const pasteLibraryTree = ( + kind: LibraryKind, + layer: Layer, + owner: string | null, + clip: TreeClip, + dest: string[], +): Promise => + call(libTree(kind, layer, owner, "/paste"), { method: "POST", - body: JSON.stringify({ ...edit, 판 }), + body: JSON.stringify({ + from_layer: clip.from.layer, + from_owner: clip.from.owner, + 대상: clip.refs, + 경로: dest, + }), }); /* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) --- diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts new file mode 100644 index 000000000..a2180e8f8 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Library.ts @@ -0,0 +1,451 @@ +/* ============================================================================= + * M02_MasterTemplete_UI_Side_Library.ts + * 좌측 서고 칸(716-4) — 일위대가(`precombo`) · 단가산출(`sangeun`) 두 칸이 같이 쓰는 층 단추 + 그 층 나무. + * 칸마다 다른 것(종류 · 목록 고름 · 새 문서 몸 · 글)은 `LibraryOptions` 로 받음(일위대가 = `UI_Side_Precombo`). + * 나무 = 자유 폴더(158-1 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) — 폴더 자리만 서버 + * (`{종류}-tree`)에 적음 · 문서 파일은 안 옮김. 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 …] · [이름 바꾸기] · + * [지우기](안에 든 문서째 + 확인 창 · 쓰는 곳 있으면 서버 409 까닭 그대로) · 빈 자리 = 뿌리 메뉴 · 끌어 옮기기. + * 여럿(구조물 조합 나무 꼴 · `TreePanel_Pick`) — Ctrl · Shift 클릭 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V · + * Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키). + * 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫). + * ========================================================================== */ + +import { isCtrlKey } from "../A00_Common/key_shortcut"; +import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { createEmpty } from "@ui/ui_template_empty"; +import { t as L } from "@ui/ui_template_locale"; +import { + createTreeRow, + openTreeMenu, + renderTree, + type TreeMenuItem, + type TreeNode, +} from "@ui/ui_template_tree"; +import { + StaleError, + editPrecomboTree, + fetchPrecomboTree, + listTemplates, + newLibrary, + pasteLibraryTree, + type Layer, + type LibraryKind, + type PrecomboTree, +} from "./M02_MasterTemplete_Api_Fetch"; +import { nameDialog } from "./M02_MasterTemplete_Library_List"; +import type { Root } from "./M02_MasterTemplete_Layer"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; +import { folderIdOf, samePath } from "./M02_MasterTemplete_TreePanel_Model"; +import { + clipOf, + keepClip, + pickClick, + refOf, + type Target, +} from "./M02_MasterTemplete_TreePanel_Pick"; +import type { Selection } from "./M02_MasterTemplete_UI_Main"; + +/** 서고 칸 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */ +export interface LibraryScope { + root: Root; + layer: Layer; + owner: string | null; + canWrite: boolean; +} + +export interface LibraryOptions { + kind: LibraryKind; + scope: () => LibraryScope; + /** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */ + bar: () => HTMLElement; + confirmLeave: () => Promise; + onOpen: (sel: Selection | null) => void; + getOpen: () => Selection | null; + /** 그 층 목록표 고름 — 문서 열기의 바탕(종류 · 층 · 주인 · 권한) */ + list: (scope: LibraryScope) => Selection; + /** [여기에 새 …] 문서 몸 */ + blank: (name: string) => Record; + /** 빈 나무 글 · 새 문서 메뉴 글 */ + text: { none: string; newHere: string }; + /** 문서 줄 덧메뉴(706-1 회사에 올리기 요청) — 없거나 빈 목록이면 메뉴 없음 */ + more?: (key: string, name: string) => TreeMenuItem[]; +} + +export interface LibraryHandle { + body: HTMLElement; + /** 고름 표시만 */ + paint: () => void; + /** 층 단추 · 나무를 그 층 목록으로 다시 그림 */ + refresh: () => Promise; +} + +export interface LibraryRow { + 이름: string; + 종류: string; + 제목?: string | null; + 구분?: string | null; + 상세구분?: string | null; +} + +export interface LibraryFolder { + path: string[]; + folders: LibraryFolder[]; + docs: LibraryRow[]; +} + +/** 폴더 나무(158-1) — 적어 둔 폴더(그 차례) + 문서 자리(없으면 뿌리 · 모르는 폴더면 그 폴더를 만듦) */ +export function buildLibraryFolders( + rows: LibraryRow[], + view: Pick, +): LibraryFolder { + const root: LibraryFolder = { path: [], folders: [], docs: [] }; + const at = (path: string[]): LibraryFolder => + path.reduce((node, name, i) => { + let next = node.folders.find((f) => f.path[i] === name); + if (!next) { + next = { path: path.slice(0, i + 1), folders: [], docs: [] }; + node.folders.push(next); + } + return next; + }, root); + view.폴더.forEach(at); + for (const row of rows) at(view.자리[row.이름] ?? []).docs.push(row); + return root; +} + +/** 폴더 안 문서 수(아래 폴더까지) */ +export const docCount = (node: LibraryFolder): number => + node.docs.length + node.folders.reduce((n, f) => n + docCount(f), 0); + +type TreeBody = Parameters[1]; +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); + +export function buildLibrarySection(opt: LibraryOptions): LibraryHandle { + const kind = opt.kind; + const clipKind = `library:${kind}`; + const barHost = el("div"); + const treeHost = el("div", { + className: "ui-tree m02-tree__body", + attrs: { "data-library-tree": kind, tabindex: "0" }, + }); + const leaves = new Map(); + const toggled = new Map(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로 + const targets = new Map(); + const counts = new Map(); + let picked = new Set(); + let anchor: string | null = null; + let rows: LibraryRow[] = []; + let view: PrecomboTree | null = null; + let seq = 0; + let busy = false; + + const open = async (row: LibraryRow): Promise => { + const at = opt.scope(); + if (!(await opt.confirmLeave())) return; + opt.onOpen({ + ...opt.list(at), + name: row.이름, + label: row.제목 ? `${row.이름} ${row.제목}` : row.이름, + }); + }; + + /** 나무 고침 한 번 — 판 다름 = 다시 읽음 · 막힘 까닭(같은 이름 · 쓰는 곳) 그대로 */ + const apply = async (run: (판: string) => Promise): Promise => { + if (!view || busy) return; + busy = true; + try { + view = await run(view.판); + const gone = view.알림?.지운문서 ?? []; + if (gone.length) { + rows = rows.filter((r) => !gone.includes(r.이름)); + const cur = opt.getOpen(); + if (cur && cur.kind === kind && gone.includes(cur.name)) opt.onOpen(null); + } + if (view.알림?.만든문서?.length) await refresh(true); + else draw(); + showToast(L("M02_TreeDone"), "success"); + } catch (error) { + if (error instanceof StaleError && error.message === "HTTP 409") { + await refresh(); + showToast(L("M02_TreeStale"), "error"); + } else { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } + } finally { + busy = false; + } + }; + const edit = async (body: TreeBody): Promise => { + const at = opt.scope(); + for (const path of [body.경로, body.새경로]) + path?.forEach((_, i) => toggled.set(folderIdOf(path.slice(0, i + 1)), true)); + await apply((판) => editPrecomboTree(at.layer, body, 판, at.owner, kind)); + }; + + /** [여기에 새 …] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */ + const make = (path: string[]): void => { + const at = opt.scope(); + nameDialog(opt.text.newHere, "", async (name) => { + const got = await newLibrary(at.layer, kind, { 문서: opt.blank(name) }, at.owner); + showToast(L("M02_Created"), "success"); + await refresh(); + if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path }); + await open({ 이름: got.이름, 종류: kind, 제목: name }); + }); + }; + const addFolder = async (parent: string[]): Promise => { + const got = await askText(L("M02_TreeAddFolder"), [ + { key: "name", label: L("M02_TreeFolderName") }, + ]); + if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name }); + }; + const rename = async (path: string[]): Promise => { + const was = path[path.length - 1]; + const got = await askText(L("M02_TreeRename"), [ + { key: "name", label: L("M02_TreeFolderName"), value: was }, + ]); + if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name }); + }; + + /* --- 지우기 · 여럿 · 복사 붙여넣기(구조물 조합 나무 꼴) --- */ + const pickedTargets = (): Target[] => + [...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t); + /** 지우기 — 확인 창(폴더 = 안의 문서 수) 뒤 한 번에 · 쓰는 곳이 있으면 서버가 막음(그 이름) */ + const remove = async (list: Target[]): Promise => { + if (!list.length || !opt.scope().canWrite) return; + const [one] = list; + const ask = + list.length > 1 + ? L("M02_TreeDeleteManyAsk").replace("{n}", String(list.length)) + : one.kind === "item" + ? L("M02_TreeDeleteItem").replace("{value}", one.item.이름) + : L("M02_TreeDeleteFolder") + .replace("{value}", one.path[one.path.length - 1]) + .replace("{n}", String(counts.get(folderIdOf(one.path)) ?? 0)); + if (!(await showConfirmDialog(ask, L("M02_Delete")))) return; + await edit({ op: "delete", 대상: list.map(refOf) }); + }; + const copy = (list: Target[]): void => { + if (!list.length) return; + const at = opt.scope(); + keepClip({ kind: clipKind, from: { layer: at.layer, owner: at.owner }, refs: list.map(refOf) }); + showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success"); + }; + const paste = async (dest: string[]): Promise => { + const got = clipOf(clipKind); + const at = opt.scope(); + if (!got || !at.canWrite) return; + toggled.set(folderIdOf(dest), true); + await apply(() => pasteLibraryTree(kind, at.layer, at.owner, got, dest)); + }; + + const menuAt = (path: string[]): TreeMenuItem[] => { + const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); + const item = (label: string, run: () => void): TreeMenuItem => ({ + label, + run, + disabledReason: off, + }); + const got = clipOf(clipKind); + const target: Target = { kind: "folder", path }; + return [ + item(L("M02_TreeAddFolder"), () => void addFolder(path)), + item(opt.text.newHere, () => make(path)), + ...(path.length + ? [ + item(L("M02_TreeRename"), () => void rename(path)), + item(L("M02_Delete"), () => void remove([target])), + { label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) }, + ] + : []), + ...(got + ? [item(L("M02_TreePaste").replace("{n}", String(got.refs.length)), () => void paste(path))] + : []), + ]; + }; + /** 오른쪽 클릭 — 고른 것 안의 줄이면 고른 것 전부(복사 · 지우기) · 아니면 그 줄 하나 */ + const menuFor = (id: string, single: () => TreeMenuItem[]): TreeMenuItem[] => { + if (!picked.has(id)) setPicked(new Set([id]), id); + const list = pickedTargets(); + if (list.length < 2) return single(); + const n = String(list.length); + const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); + return [ + { label: L("M02_TreeCopy").replace("{n}", n), run: () => copy(list) }, + { + label: L("M02_TreeDeleteMany").replace("{n}", n), + run: () => void remove(list), + disabledReason: off, + }, + ]; + }; + + const leaf = (row: LibraryRow, folder: string[]): TreeNode => { + const id = `doc:${row.이름}`; + const label = row.제목 || row.이름; + const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label }; + const target: Target = { kind: "item", item }; + targets.set(id, target); + const single = (): TreeMenuItem[] => [ + { label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) }, + { + label: L("M02_Delete"), + run: () => void remove([target]), + disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"), + }, + ...(opt.more?.(row.이름, label) ?? []), + ]; + const menu = () => menuFor(id, single); + return { id, label, tip: row.이름, count: null, run: () => void open(row), menu }; + }; + const folderNode = (node: LibraryFolder): TreeNode => { + const id = folderIdOf(node.path); + const count = docCount(node); + targets.set(id, { kind: "folder", path: node.path }); + counts.set(id, count); + return { + id, + label: node.path[node.path.length - 1], + count, + folder: true, + open: toggled.get(id) ?? count > 0, + children: kids(node), + menu: () => menuFor(id, () => menuAt(node.path)), + }; + }; + const kids = (node: LibraryFolder): TreeNode[] => [ + ...node.folders.map(folderNode), + ...node.docs.map((row) => leaf(row, node.path)), + ]; + + /** 끌어 옮기기(구조물 조합 나무와 같은 부품) — 고른 것 전부 · 폴더 줄 위 = 그 안 · 빈 자리 = 뿌리 */ + const drag = attachTreeDrag({ + body: treeHost, + targetOf: (id) => targets.get(id), + listFor: (id) => + picked.size > 1 && picked.has(id) + ? pickedTargets() + : targets.has(id) + ? [targets.get(id)!] + : null, + move: (list, dest) => { + const from = (t: Target): string[] => + t.kind === "item" ? t.item.경로.slice(0, -1) : t.path.slice(0, -1); + const moving = list.filter((t) => !samePath(from(t), dest)); + if (moving.length) void edit({ op: "move", 대상: moving.map(refOf), 새경로: dest }); + }, + }); + + const visibleIds = (): string[] => + [...treeHost.querySelectorAll("[data-node]")] + .filter((b) => !b.closest("[hidden]")) + .map((b) => b.dataset.node ?? ""); + const setPicked = (next: Set, at: string | null): void => { + picked = next; + anchor = at; + for (const row of treeHost.querySelectorAll("[data-node]")) + row.classList.toggle("is-picked", picked.size > 1 && picked.has(row.dataset.node ?? "")); + }; + + const draw = (): void => { + leaves.clear(); + targets.clear(); + counts.clear(); + const tree = buildLibraryFolders(rows, view ?? { 폴더: [], 자리: {} }); + if (!tree.docs.length && !tree.folders.length) + return void treeHost.replaceChildren(createEmpty({ text: opt.text.none, small: true })); + const canWrite = opt.scope().canWrite; + treeHost.replaceChildren( + ...renderTree(kids(tree), (node, caret, depth, onClick) => { + const row = createTreeRow(node, caret, depth, () => { + onClick(); + if (!node.children?.length) return; + toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸"); + }); + row.dataset.node = node.id; + // Ctrl · Shift 클릭 = 고르기만(열기 · 접기 안 함) — 줄 본디 click 보다 먼저(capture) + row.addEventListener( + "click", + (event) => { + const mod = { ctrl: event.ctrlKey || event.metaKey, shift: event.shiftKey }; + const got = pickClick(picked, anchor, node.id, mod, visibleIds()); + if (got.handled) event.stopImmediatePropagation(); + setPicked(got.picked, got.anchor); + }, + { capture: true }, + ); + if (node.run) { + row.dataset.libraryKey = node.id.slice(4); + leaves.set(node.id.slice(4), row); + } + if (canWrite) drag.arm(row, node.id); + return row; + }), + ); + setPicked(new Set([...picked].filter((id) => targets.has(id))), anchor); + paint(); + }; + + // 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 문서 · 붙여넣기) + treeHost.addEventListener("contextmenu", (event) => { + if (!view || (event.target as HTMLElement).closest("[data-node]")) return; + event.preventDefault(); + openTreeMenu(event, menuAt([])); + }); + // 키 — Delete = 고른 것(없으면 초점 줄) 지움 · Ctrl+C 복사 · Ctrl+V = 초점 폴더 안(아니면 뿌리) + treeHost.addEventListener("keydown", (event) => { + const row = (event.target as HTMLElement).closest("[data-node]"); + const id = row?.dataset.node ?? ""; + const target = targets.get(id); + const list = picked.has(id) ? pickedTargets() : target ? [target] : []; + if (event.key === "Delete" && list.length) { + event.preventDefault(); + void remove(list); + } else if (isCtrlKey(event, "c") && list.length) { + event.preventDefault(); + copy(list); + } else if (isCtrlKey(event, "v")) { + event.preventDefault(); + void paste(target?.kind === "folder" ? target.path : []); + } + }); + + const paint = (): void => { + const cur = opt.getOpen(); + const at = opt.scope(); + const mine = + cur && cur.kind === kind && cur.layer === at.layer && (cur.owner ?? null) === at.owner; + for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key); + }; + + /** `keep` = 나무 자리 그대로 두고 목록만 다시(붙여넣기 뒤 새 문서) */ + const refresh = async (keep = false): Promise => { + const mine = ++seq; + const at = opt.scope(); + barHost.replaceChildren(opt.bar()); + if (!keep) treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true })); + const [got, tree] = await Promise.all([ + // 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리 + listTemplates(at.layer, null, at.owner).then( + (list): LibraryRow[] => list.filter((r) => r.종류 === kind), + (): LibraryRow[] => [], + ), + keep && view + ? Promise.resolve(view) + : fetchPrecomboTree(at.layer, at.owner, kind).catch(() => null), + ]); + if (mine !== seq) return; + rows = got; + view = tree; + draw(); + }; + + const body = el("div", { + className: "m02-side__items ui-sidebar-list", + attrs: { "data-library": kind }, + children: [barHost, treeHost], + }); + return { body, paint, refresh: () => refresh() }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index 38125c298..b8a79ae16 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -1,44 +1,22 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side_Precombo.ts - * 좌측 「일위대가」 컨테이너(50-3 ③ · 51-4 · 134-3) — 구조물 조합 아래 · 층 단추(페이지가 만듦 · - * 페이지 층과 따로 · 마스터 · 회사 · 내 개인) + 그 층 일위대가 나무. - * 나무 = 자유 폴더(158-1 · 구조물 조합 탐색기와 같은 꼴 · 깊이 자유 · 층마다 따로 · 자리 없는 문서 = 뿌리) — - * 폴더 오른쪽 클릭 [새 폴더] · [여기에 새 일위대가] · [이름 바꾸기] · [지우기](빈 폴더만) · 빈 자리 오른쪽 - * 클릭 = 뿌리 메뉴 · 문서 · 폴더 끌어 옮기기. 폴더 자리만 서버(`precombo-tree`)에 적음 — 문서 파일은 안 옮김. + * 좌측 「일위대가」 칸(50-3 ③ · 51-4 · 134-3) — 서고 칸(`UI_Side_Library` · 716-4)에 일위대가 몫만 줌: + * 종류 precombo · 그 층 목록표 고름 · 새 문서 몸 · 글. 층 단추는 페이지가 만듦(페이지 층과 따로). * 구분 · 상세구분은 문서 칸(상세 머리 · 고르기 창 거름 · `precomboClasses`) — 나무 폴더와 무관. * 문서 누름 = 편집 칸(UI_Main 종류 precombo = 일위대가 상세 모드) · 켜진 층 단추 다시 누름 = 목록표 * (`Library_List` · 생성 · 복사 · 삭제는 거기) · 그 층 주인(`owner`) · 고칠 권한(`canWrite`)은 고름에 실어 보냄. * ========================================================================== */ -import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; -import { t as L } from "@ui/ui_template_locale"; -import { - createTreeRow, - openTreeMenu, - renderTree, - type TreeMenuItem, - type TreeNode, -} from "@ui/ui_template_tree"; -import { - StaleError, - editPrecomboTree, - fetchPrecomboTree, - listTemplates, - newLibrary, - type Kind, - type Layer, - type PrecomboTree, -} from "./M02_MasterTemplete_Api_Fetch"; -import { nameDialog } from "./M02_MasterTemplete_Library_List"; -import type { Root } from "./M02_MasterTemplete_Layer"; +import type { TreeMenuItem } from "@ui/ui_template_tree"; +import type { Kind } from "./M02_MasterTemplete_Api_Fetch"; import type { StructDef } from "./StructOptions/M02_SO_Def_Model"; import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; -import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag"; -import { folderIdOf, samePath } from "./M02_MasterTemplete_TreePanel_Model"; -import type { Target } from "./M02_MasterTemplete_TreePanel_Pick"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; -import { createEmpty } from "@ui/ui_template_empty"; +import { + buildLibrarySection, + type LibraryHandle, + type LibraryScope, +} from "./M02_MasterTemplete_UI_Side_Library"; export type PrecomboKind = Extract; @@ -60,12 +38,7 @@ export const precomboClasses = ( })); /** 사전조합 층(134-3) — 페이지 층과 따로 · `owner` null = 자기 층 */ -export interface PrecomboScope { - root: Root; - layer: Layer; - owner: string | null; - canWrite: boolean; -} +export type PrecomboScope = LibraryScope; /** 그 층 목록표 고름(켜진 층 단추 다시 누름) */ export const precomboList = (scope: PrecomboScope): Selection => ({ @@ -89,257 +62,13 @@ export interface PrecomboOptions { more?: (key: string, name: string) => TreeMenuItem[]; } -export interface PrecomboHandle { - body: HTMLElement; - /** 고름 표시만 */ - paint: () => void; - /** 층 단추 · 나무를 그 층 목록으로 다시 그림 */ - refresh: () => Promise; -} +export type PrecomboHandle = LibraryHandle; -interface Row { - 이름: string; - 종류: string; - 제목?: string | null; - 구분?: string | null; - 상세구분?: string | null; -} - -export interface PrecomboFolder { - path: string[]; - folders: PrecomboFolder[]; - docs: Row[]; -} - -/** 폴더 나무(158-1) — 적어 둔 폴더(그 차례) + 문서 자리(없으면 뿌리 · 모르는 폴더면 그 폴더를 만듦) */ -export function buildPrecomboFolders( - rows: Row[], - view: Pick, -): PrecomboFolder { - const root: PrecomboFolder = { path: [], folders: [], docs: [] }; - const at = (path: string[]): PrecomboFolder => - path.reduce((node, name, i) => { - let next = node.folders.find((f) => f.path[i] === name); - if (!next) { - next = { path: path.slice(0, i + 1), folders: [], docs: [] }; - node.folders.push(next); - } - return next; - }, root); - view.폴더.forEach(at); - for (const row of rows) at(view.자리[row.이름] ?? []).docs.push(row); - return root; -} - -/** 폴더 안 문서 수(아래 폴더까지) */ -export const docCount = (node: PrecomboFolder): number => - node.docs.length + node.folders.reduce((n, f) => n + docCount(f), 0); - -type TreeBody = Parameters[1]; -const why = (error: unknown): string => (error instanceof Error ? error.message : ""); - -export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { - const barHost = el("div"); - const treeHost = el("div", { className: "ui-tree", attrs: { "data-precombo-tree": "" } }); - const leaves = new Map(); - const toggled = new Map(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로 - const targets = new Map(); - let rows: Row[] = []; - let view: PrecomboTree | null = null; - let seq = 0; - let busy = false; - - const open = async (row: Row): Promise => { - const at = opt.scope(); - if (!(await opt.confirmLeave())) return; - opt.onOpen({ - ...precomboList(at), - name: row.이름, - label: row.제목 ? `${row.이름} ${row.제목}` : row.이름, - }); - }; - - /** 나무 고침 한 번 — 판 다름 = 다시 읽음 · 막힘 까닭(빈 폴더 아님 · 같은 이름) 그대로 */ - const edit = async (body: TreeBody): Promise => { - const at = opt.scope(); - if (!view || busy) return; - busy = true; - try { - view = await editPrecomboTree(at.layer, body, view.판, at.owner); - for (const path of [body.경로, body.새경로]) - path?.forEach((_, i) => toggled.set(folderIdOf(path.slice(0, i + 1)), true)); - draw(); - showToast(L("M02_TreeDone"), "success"); - } catch (error) { - if (error instanceof StaleError && error.message === "HTTP 409") { - await refresh(); - showToast(L("M02_TreeStale"), "error"); - } else { - showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); - } - } finally { - busy = false; - } - }; - - /** [여기에 새 일위대가] — 뿌리에 생성 → 폴더면 그 자리로 옮김 → 나무 다시 · 상세 엶 */ - const make = (path: string[]): void => { - const at = opt.scope(); - nameDialog(tc("Lib_NewHere"), "", async (name) => { - const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }; - const got = await newLibrary(at.layer, "precombo", { 문서 }, at.owner); - showToast(L("M02_Created"), "success"); - await refresh(); - if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path }); - await open({ 이름: got.이름, 종류: "precombo", 제목: name }); - }); - }; - const addFolder = async (parent: string[]): Promise => { - const got = await askText(L("M02_TreeAddFolder"), [ - { key: "name", label: L("M02_TreeFolderName") }, - ]); - if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name }); - }; - const rename = async (path: string[]): Promise => { - const was = path[path.length - 1]; - const got = await askText(L("M02_TreeRename"), [ - { key: "name", label: L("M02_TreeFolderName"), value: was }, - ]); - if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name }); - }; - /** 지우기 — 빈 폴더만(안에 문서가 있으면 알림만) */ - const remove = async (path: string[], count: number): Promise => { - if (count) return void showToast(tc("Lib_FolderFull").replace("{n}", String(count)), "error"); - const ask = tc("Lib_FolderDelete").replace("{value}", path[path.length - 1]); - if (await showConfirmDialog(ask, L("M02_Delete"))) await edit({ op: "delete", 경로: path }); - }; - - const menuAt = (path: string[], count: number): TreeMenuItem[] => { - const off = opt.scope().canWrite ? undefined : tc("ReadOnly"); - const item = (label: string, run: () => void): TreeMenuItem => ({ - label, - run, - disabledReason: off, - }); - return [ - item(L("M02_TreeAddFolder"), () => void addFolder(path)), - item(tc("Lib_NewHere"), () => make(path)), - ...(path.length - ? [ - item(L("M02_TreeRename"), () => void rename(path)), - item(L("M02_Delete"), () => void remove(path, count)), - ] - : []), - ]; - }; - - const leaf = (row: Row, folder: string[]): TreeNode => { - const id = `doc:${row.이름}`; - const label = row.제목 || row.이름; - const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label }; - targets.set(id, { kind: "item", item }); - const menu = () => opt.more?.(row.이름, label) ?? []; - return { id, label, tip: row.이름, count: null, run: () => void open(row), menu }; - }; - const folderNode = (node: PrecomboFolder): TreeNode => { - const id = folderIdOf(node.path); - const count = docCount(node); - targets.set(id, { kind: "folder", path: node.path }); - return { - id, - label: node.path[node.path.length - 1], - count, - folder: true, - open: toggled.get(id) ?? count > 0, - children: kids(node), - menu: () => menuAt(node.path, count), - }; - }; - const kids = (node: PrecomboFolder): TreeNode[] => [ - ...node.folders.map(folderNode), - ...node.docs.map((row) => leaf(row, node.path)), - ]; - - /** 끌어 옮기기(구조물 조합 나무와 같은 부품) — 폴더 줄 위 = 그 안 · 빈 자리 = 뿌리 · 제자리면 안 함 */ - const drag = attachTreeDrag({ - body: treeHost, - targetOf: (id) => targets.get(id), - listFor: (id) => (targets.has(id) ? [targets.get(id)!] : null), - move: ([t], dest) => { - const from = t.kind === "item" ? t.item.경로.slice(0, -1) : t.path.slice(0, -1); - if (samePath(from, dest)) return; - void edit( - t.kind === "item" - ? { op: "move", 키: t.item.key, 새경로: dest } - : { op: "move", 경로: t.path, 새경로: dest }, - ); - }, +export const buildPrecomboSection = (opt: PrecomboOptions): PrecomboHandle => + buildLibrarySection({ + ...opt, + kind: "precombo", + list: precomboList, + blank: (name) => ({ 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }), + text: { none: tc("Lib_None"), newHere: tc("Lib_NewHere") }, }); - - const draw = (): void => { - leaves.clear(); - targets.clear(); - const tree = buildPrecomboFolders(rows, view ?? { 폴더: [], 자리: {} }); - if (!tree.docs.length && !tree.folders.length) - return void treeHost.replaceChildren(createEmpty({ text: tc("Lib_None"), small: true })); - const canWrite = opt.scope().canWrite; - treeHost.replaceChildren( - ...renderTree(kids(tree), (node, caret, depth, onClick) => { - const row = createTreeRow(node, caret, depth, () => { - onClick(); - if (!node.children?.length) return; - toggled.set(node.id, row.querySelector(".ui-tree__caret")?.textContent !== "▸"); - }); - row.dataset.node = node.id; - if (node.run) { - row.dataset.precomboKey = node.id.slice(4); - leaves.set(node.id.slice(4), row); - } - if (canWrite) drag.arm(row, node.id); - return row; - }), - ); - paint(); - }; - - // 빈 자리 오른쪽 클릭 = 뿌리 메뉴(새 폴더 · 새 일위대가) - treeHost.addEventListener("contextmenu", (event) => { - if (!view || (event.target as HTMLElement).closest("[data-node]")) return; - event.preventDefault(); - openTreeMenu(event, menuAt([], 0)); - }); - - const paint = (): void => { - const cur = opt.getOpen(); - const at = opt.scope(); - const mine = - cur && isPrecombo(cur.kind) && cur.layer === at.layer && (cur.owner ?? null) === at.owner; - for (const [key, row] of leaves) row.classList.toggle("is-active", !!mine && cur.name === key); - }; - - const refresh = async (): Promise => { - const mine = ++seq; - const at = opt.scope(); - barHost.replaceChildren(opt.bar()); - treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true })); - const [got, tree] = await Promise.all([ - // 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리 - listTemplates(at.layer, null, at.owner).then( - (list): Row[] => list.filter((r) => r.종류 === "precombo"), - (): Row[] => [], - ), - fetchPrecomboTree(at.layer, at.owner).catch(() => null), - ]); - if (mine !== seq) return; - rows = got; - view = tree; - draw(); - }; - - const body = el("div", { - className: "m02-side__items ui-sidebar-list", - attrs: { "data-precombo": "" }, - children: [barHost, treeHost], - }); - return { body, paint, refresh }; -} diff --git a/M02_MasterTemplete/M02_Precombo_Folders.py b/M02_MasterTemplete/M02_Precombo_Folders.py index 110768d11..9e418fe33 100644 --- a/M02_MasterTemplete/M02_Precombo_Folders.py +++ b/M02_MasterTemplete/M02_Precombo_Folders.py @@ -1,32 +1,42 @@ -"""M02 일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1) — 구조물 조합 나무와 같은 꼴(깊이 자유). +"""M02 서고 나무 = 자유 폴더(PLAN 158-1 · 716-4) — 일위대가(`precombo`) · 단가산출(`sangeun`) 한 벌. -자리 = 층 뿌리 `precombo_폴더.json` `{폴더: [경로 …], 자리: {키: 경로}}` — -층(마스터 · 회사 · 개인)마다 따로. 문서 파일(`{층}/precombo/SC-######.json`)은 안 옮김 · -폴더 자리만 적음 · 자리 없는 문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 · -문서 옮기기)은 판이 다르면 409 · 지우기는 빈 폴더만(안에 문서가 있으면 409). +자리 = 층 뿌리 `{종류}_폴더.json` `{폴더: [경로 …], 자리: {키: 경로}}` — 종류 · 층(마스터 · 회사 · +개인)마다 따로. 문서 파일(`{층}/{종류}/SC-######.json`)은 안 옮김 · 폴더 자리만 적음 · 자리 없는 +문서 = 뿌리. 고침(폴더 더하기 · 이름 바꾸기 · 옮기기 · 지우기 · 문서 옮기기 · 지우기)은 판이 다르면 +409 · 여럿(`대상`) = 옮기기 · 지우기를 차례로 · 저장 한 번. 지우기 = 안에 든 문서째(716-4) — +모두 검사 뒤 한 번에 · 바깥에 쓰는 곳이 있으면 409 + 그 이름(지우는 것끼리 참조는 막지 않음). +붙여넣기(`paste`) = 복사한 층 문서 · 폴더(안의 것까지) → 지금 층 새 키 · 같은 폴더 이름 「(1)」 · +못 읽는 참조는 같이 복사(`M02_Labor_Ref.carry`). 이름 · 경로 검사 · 같은 이름 「(1)」 은 `M02_MasterTemplete_Tree` 한 벌. """ from __future__ import annotations import json +from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Tree as tree +from M02_MasterTemplete import M02_Template_Layers as layers -FILE = "precombo_폴더.json" OPS = ("add-folder", "rename", "move", "delete") +USERS = (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")) # refs.users 꼴 -def _keys() -> set[str]: - """지금 층에 있는 사전조합 키(파일 이름).""" - return {p.stem for p in (store.root() / "precombo").glob("[!._]*.json")} +def file_of(kind: str) -> str: + return f"{kind}_폴더.json" -def _read() -> tuple[dict[str, Any], str]: - path = store.root() / FILE +def _keys(kind: str, root: Path | None = None) -> set[str]: + """그 층에 있는 서고 키(파일 이름).""" + return {p.stem for p in ((root or store.root()) / kind).glob("[!._]*.json")} + + +def _read(kind: str, root: Path | None = None) -> tuple[dict[str, Any], str]: + path = (root or store.root()) / file_of(kind) if not path.is_file(): return {"폴더": [], "자리": {}}, "" raw = path.read_bytes() @@ -45,29 +55,38 @@ def _folders(doc: dict[str, Any], keys: set[str]) -> list[list[str]]: return out +def _places(doc: dict[str, Any], keys: set[str]) -> dict[str, list[str]]: + return {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys} + + def view_of(doc: dict[str, Any], 판: str, keys: set[str]) -> dict[str, Any]: - places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys} + places = _places(doc, keys) return {"판": 판, "폴더": _folders(doc, keys), "자리": {k: p for k, p in places.items() if p}} -def view() -> dict[str, Any]: - doc, 판 = _read() - return view_of(doc, 판, _keys()) +def view(kind: str = "precombo") -> dict[str, Any]: + doc, 판 = _read(kind) + return view_of(doc, 판, _keys(kind)) def _shift(path: list[str], old: list[str], new: list[str]) -> list[str]: return [*new, *path[len(old) :]] if tree._under(path, old) else path +def _out(folders: list[list[str]], places: dict[str, list[str]]) -> dict[str, Any]: + return {"폴더": folders, "자리": {k: p for k, p in sorted(places.items()) if p}} + + def apply( doc: dict[str, Any], body: dict[str, Any], keys: set[str] ) -> tuple[dict[str, Any], dict[str, Any]]: - """고침 하나 → (새 자리 문서, 알림) — 순수 함수(저장은 `edit`).""" + """고침 하나 → (새 자리 문서, 알림) — 순수 함수(저장 · 문서 파일 지우기는 `edit`). + 지우기 알림 `지운문서` = 지울 문서 키(폴더 안 · 아래 폴더까지).""" op = body.get("op") if op not in OPS: raise tree.TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}") folders = _folders(doc, keys) - places = {k: tree._clean(p) for k, p in (doc.get("자리") or {}).items() if k in keys} + places = _places(doc, keys) where = tree._path(body.get("경로")) key = str(body.get("키") or "") note: dict[str, Any] = {"op": op} @@ -78,6 +97,8 @@ def apply( if not path and what: raise tree.TreeError(422, f"{what} 폴더 「경로」 가 없음") + if key and op in ("move", "delete") and key not in keys: + raise tree.TreeError(404, f"없는 문서 「{key}」") if op == "add-folder": need(where, "") made = [*where, tree._name(body.get("이름"))] @@ -86,12 +107,13 @@ def apply( folders.append(made) note["경로"] = made elif op == "move" and key: - if key not in keys: - raise tree.TreeError(404, f"없는 사전조합 「{key}」") to = tree._path(body.get("새경로"), "새경로") need(to, "") places[key] = to note["경로"] = to + elif op == "delete" and key: + places.pop(key, None) + note["지운문서"] = [key] elif op in ("rename", "move"): need(where, "바꿀" if op == "rename" else "옮길") if op == "rename": @@ -109,28 +131,136 @@ def apply( folders = [_shift(f, where, new) for f in folders] places = {k: _shift(p, where, new) for k, p in places.items()} note["경로"] = new - else: # delete — 빈 폴더만 + else: # delete — 안에 든 문서째(716-4) need(where, "지울") inside = sorted(k for k, p in places.items() if tree._under(p, where)) - if inside: - raise tree.TreeError(409, f"폴더 안에 사전조합 {len(inside)}개 — 먼저 옮기거나 지움") folders = [f for f in folders if not tree._under(f, where)] + places = {k: p for k, p in places.items() if k not in inside} note["경로"] = where - out = {"폴더": folders, "자리": {k: p for k, p in sorted(places.items()) if p}} - return out, note + note["지운문서"] = inside + return _out(folders, places), note + + +def apply_many( + doc: dict[str, Any], body: dict[str, Any], keys: set[str] +) -> tuple[dict[str, Any], dict[str, Any]]: + """`대상` 이 있으면 옮기기 · 지우기를 하나씩 차례로(하나라도 막히면 아무것도 안 함) · + 없으면 `apply`.""" + targets = body.get("대상") + if targets is None: + return apply(doc, body, keys) + op = body.get("op") + if op not in ("move", "delete") or not targets: + raise tree.TreeError(422, "여러 개는 옮기기 · 지우기만 — 「대상」 이 필요함") + note: dict[str, Any] = {"op": op, "지운문서": [], "바뀐이름": []} + for one in targets: + if not isinstance(one, dict): + raise tree.TreeError(422, "「대상」 은 {경로, 키?} 목록") + part = { + "op": op, + "경로": one.get("경로"), + "키": one.get("키"), + "새경로": body.get("새경로"), + } + if op == "delete" and not part["키"] and not tree._path(part["경로"]): + continue # 맨 위는 못 지움 + try: + doc, got = apply(doc, part, keys) + except tree.TreeError as e: + if e.status == 404 and op == "delete": + continue # 고른 폴더 안의 것 — 앞에서 이미 지움 + raise + note["지운문서"] += [k for k in got.get("지운문서", []) if k not in note["지운문서"]] + note["바뀐이름"] += got.get("바뀐이름", []) + return doc, note + + +def _refuse_used(kind: str, gone: list[str]) -> None: + """지울 문서를 바깥(지우는 것 밖)에서 쓰면 409 + 「어디 이름 ← 키」 — + 모든 층 훑음(프로젝트 뺌).""" + layer, owner = refs.here() + rids = {refs.ref_id({"층": layer, "주인": owner, "키": k}) for k in gone} + mine = {(store.root() / kind / f"{k}.json").resolve() for k in gone} + found = [] + for what_kind, what in USERS: + for at, path in layers.layer_files(what_kind): + if path.resolve() in mine: + continue + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + hit = sorted({r["키"] for r in refs.refs_in(doc) if refs.ref_id(r) in rids}) + if hit: + name = doc.get("일위대가번") or doc.get("이름") or path.stem + found.append(f"{what} {at} 「{name}」 ← {' · '.join(hit)}") + if found: + raise store.StoreError(409, "쓰는 곳이 있어 못 지움 — " + " · ".join(found)) # ponytail: 지운 문서 자리는 다음 고침 때 걷힘 — 그 사이 같은 키로 새로 만들면 옛 자리에 보임 # (키 재사용을 막을 때 함께 고침) -def edit(body: dict[str, Any]) -> dict[str, Any]: - """지금 층 자리 파일을 고쳐 씀 → 새 나무 + 알림 · 판 다르면 409.""" - doc, 판 = _read() +def edit(body: dict[str, Any], kind: str = "precombo") -> dict[str, Any]: + """지금 층 자리 파일을 고쳐 씀(지우기면 문서 파일째) → 새 나무 + 알림 · 판 다르면 409.""" + doc, 판 = _read(kind) if 판 != str(body.get("판") or ""): - raise store.StoreError(409, {"stale": [FILE], "판": 판}) - keys = _keys() + raise store.StoreError(409, {"stale": [file_of(kind)], "판": 판}) + keys = _keys(kind) try: - out, note = apply(doc, body, keys) + out, note = apply_many(doc, body, keys) except tree.TreeError as e: raise store.StoreError(e.status, e.detail) from e - atomic_write_json(store.root() / FILE, out) - return {**view(), "알림": note} + gone = note.get("지운문서") or [] + if gone: + _refuse_used(kind, gone) # 모두 검사 뒤 한 번에 + for key in gone: + (store.root() / kind / f"{key}.json").unlink(missing_ok=True) + atomic_write_json(store.root() / file_of(kind), out) + return {**view(kind), "알림": note} + + +# ponytail: 붙여넣기 도중 막히면 앞서 만든 사본은 뿌리에 남음 — 한 번에 되돌림이 필요하면 그때 +def paste(source: Path, picks: list[dict[str, Any]], dest: Any, kind: str) -> dict[str, Any]: + """복사한 층(`source`) 고른 것 `[{경로, 키?}]` → 지금 층 `dest` 아래 새 키(본뜬 = 원본) · + 폴더는 안의 것 · 아래 폴더까지 · 같은 폴더 이름 「(1)」 → 새 나무 + 알림 `만든문서`.""" + src = refs.where(source) + src_keys = _keys(kind, source) + src_doc, _ = _read(kind, source) + src_folders, src_places = _folders(src_doc, src_keys), _places(src_doc, src_keys) + doc, _ = _read(kind) + keys = _keys(kind) + folders, places = _folders(doc, keys), _places(doc, keys) + try: + to = tree._path(dest) + if to and to not in folders: + raise tree.TreeError(404, f"없는 폴더 「{' / '.join(to)}」") + except tree.TreeError as e: + raise store.StoreError(e.status, e.detail) from e + + def copy(key: str) -> str: + ref = {"층": src[0], "주인": src[1], "키": key} + got = refs.read_ref(ref) + if got is None: + raise store.StoreError(404, f"없는 문서 「{key}」") + return refs.create(kind, refs.carry(refs.origin_mark(got, ref), src))["이름"] + + made: list[str] = [] + for pick in picks: + key = str(pick.get("키") or "") if isinstance(pick, dict) else "" + path = tree._clean(pick.get("경로")) if isinstance(pick, dict) else [] + if key: + if key not in src_keys: + raise store.StoreError(404, f"없는 문서 「{key}」") + made.append(copy(key)) + places[made[-1]] = to + continue + if not path or path not in src_folders: + raise store.StoreError(404, f"없는 폴더 「{' / '.join(path)}」") + top = tree.unique_path([*to, path[-1]], lambda p: p in folders) + folders += [[*top, *f[len(path) :]] for f in src_folders if tree._under(f, path)] + for k, p in sorted(src_places.items()): + if tree._under(p, path): + made.append(copy(k)) + places[made[-1]] = [*top, *p[len(path) :]] + atomic_write_json(store.root() / file_of(kind), _out(folders, places)) + return {**view(kind), "알림": {"op": "paste", "경로": to, "만든문서": made}} diff --git a/resources/tester/helper_m02_precombo_scope.cjs b/resources/tester/helper_m02_precombo_scope.cjs index c6a04c4c4..97287d1fa 100644 --- a/resources/tester/helper_m02_precombo_scope.cjs +++ b/resources/tester/helper_m02_precombo_scope.cjs @@ -1,5 +1,5 @@ /* M02 일위대가 사전조합 층(134-3) 헬퍼 — `M02_MasterTemplete_Api_Fetch.ts` 명시 owner 길 · - * `M02_MasterTemplete_UI_Side_Precombo.ts` `buildPrecomboFolders`(158-1 자유 폴더) 를 트랜스파일해 Node 로 돌림 · + * `M02_MasterTemplete_UI_Side_Library.ts` `buildLibraryFolders`(158-1 자유 폴더 · 716-4) 를 트랜스파일해 Node 로 돌림 · * `precomboClasses`(134-5 · 구조물군 › 종류 + 공통) · fetch 대역이 부른 주소를 모음 · * stdin = {rows, def?, view?} · stdout = {urls, tree, classes} JSON. */ const fs = require("fs"); @@ -31,6 +31,7 @@ global.fetch = async (url) => { const side = loadTs( path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Precombo.ts"), ); + const lib = loadTs(path.join(ROOT, "M02_MasterTemplete", "M02_MasterTemplete_UI_Side_Library.ts")); api.setActiveLayer("personal", "7/99"); // 페이지 = 남의 개인을 보는 중 await api.readTemplate("personal", "labor", "x1", null); // 페이지 층 = 그 사람 await api.readTemplate("personal", "precombo", "SC-000001", null, null); // 사전조합 = 내 개인 @@ -38,11 +39,11 @@ global.fetch = async (url) => { await api.saveTemplate("system", "precombo", "SC-000002", null, "v", {}, null); const shape = (f) => ({ path: f.path, - count: side.docCount(f), + count: lib.docCount(f), docs: f.docs.map((r) => r.이름), folders: f.folders.map(shape), }); - const tree = shape(side.buildPrecomboFolders(input.rows, input.view ?? { 폴더: [], 자리: {} })); + const tree = shape(lib.buildLibraryFolders(input.rows, input.view ?? { 폴더: [], 자리: {} })); const classes = input.def ? side.precomboClasses(input.def) : null; // 134-5 process.stdout.write(JSON.stringify({ urls, tree, classes })); })(); diff --git a/resources/tester/test_716_4_library_folders.py b/resources/tester/test_716_4_library_folders.py new file mode 100644 index 000000000..08e79a5e3 --- /dev/null +++ b/resources/tester/test_716_4_library_folders.py @@ -0,0 +1,119 @@ +"""서고 나무 폴더(PLAN 716-4) — 종류 인자 · 안에 든 것째 지움 · 쓰는 곳 409 · 여럿 · 복사 붙여넣기. + +임시 storage. + +`/api/m02/layers/{층}/{종류}-tree` = 일위대가(precombo) · 단가산출(sangeun) 따로 · +폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 409 + 그 이름 · +지우는 것끼리 참조는 됨) · +`대상` 여럿 옮기기 · 지우기 · `…-tree/paste` = 새 키 · 같은 폴더 이름 「(1)」 · 본뜬 = 원본. +""" + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from M02_MasterTemplete import M02_Precombo_Folders as pre +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_precombo_folders import Tree, client # noqa: F401 — 같은 임시 층 + +MINE = {"층": "personal", "주인": "7/42"} +SK = "/api/m02/layers/personal/library/sangeun" + + +def _sk(c: TestClient, name: str, **doc) -> str: + body = {"문서": {"이름": name, "단위": "㎡", "더한줄": [], **doc}} + res = c.post(SK, json=body) + assert res.status_code == 200, res.text + return res.json()["이름"] + + +def _files(kind: str) -> list[str]: + return sorted(p.stem for p in (layers.personal_dir(7, 42) / kind).glob("*.json")) + + +def _sk_tree(c: TestClient) -> Tree: + t = Tree(c) + t.url = "/api/m02/layers/personal/sangeun-tree" + t.now = c.get(t.url).json() + return t + + +def test_종류마다_따로_자리_파일(client: TestClient): # noqa: F811 + _sk(client, "갑") + sk = _sk_tree(client) + sk.do(op="add-folder", 경로=[], 이름="단가") + sk.do(op="move", 키="SK-000001", 새경로=["단가"]) + assert Tree(client).now["폴더"] == [] # 일위대가 나무는 그대로 + root = layers.personal_dir(7, 42) + assert (root / pre.file_of("sangeun")).is_file() + assert not (root / pre.file_of("precombo")).exists() + assert client.get("/api/m02/layers/personal/labor-tree").status_code == 422 # 모르는 종류 + + +def test_폴더_지우기는_안에_든_것째_지우는_것끼리_참조는_됨(client: TestClient): # noqa: F811 + a = _sk(client, "바탕") + b = _sk(client, "위", 바탕={"참조": {**MINE, "키": a}}) + sk = _sk_tree(client) + sk.do(op="add-folder", 경로=[], 이름="묶음") + sk.do(op="add-folder", 경로=["묶음"], 이름="안") + sk.do(op="move", 키=a, 새경로=["묶음", "안"]) + sk.do(op="move", 키=b, 새경로=["묶음"]) + got = sk.do(op="delete", 경로=["묶음"]) + assert sorted(got["알림"]["지운문서"]) == [a, b] + assert _files("sangeun") == [] and sk.now["폴더"] == [] + + +def test_바깥에서_쓰면_409_그_이름_아무것도_안_지움(client: TestClient): # noqa: F811 + a = _sk(client, "바탕") + body = {"문서": {"이름": "쓰는 일위대가", "담은로직": [{"참조": {**MINE, "키": a}}]}} + assert client.post("/api/m02/layers/personal/library/precombo", json=body).status_code == 200 + sk = _sk_tree(client) + sk.do(op="add-folder", 경로=[], 이름="묶음") + sk.do(op="move", 키=a, 새경로=["묶음"]) + detail = sk.do(409, op="delete", 경로=["묶음"])["detail"] + assert "쓰는 곳" in detail and "「쓰는 일위대가」" in detail and a in detail + assert _files("sangeun") == [a] and client.get(sk.url).json()["폴더"] == [["묶음"]] + + +def test_여럿_옮기기_지우기(client: TestClient): # noqa: F811 + t = Tree(client) # 개인 층 일위대가 SC-000001~3 + t.do(op="add-folder", 경로=[], 이름="가") + t.do(op="add-folder", 경로=[], 이름="나") + t.do( + op="move", 대상=[{"키": "SC-000001"}, {"키": "SC-000002"}, {"경로": ["나"]}], 새경로=["가"] + ) + assert t.now["폴더"] == [["가"], ["가", "나"]] + assert t.now["자리"] == {"SC-000001": ["가"], "SC-000002": ["가"]} + got = t.do(op="delete", 대상=[{"경로": ["가"]}, {"키": "SC-000001"}, {"키": "SC-000003"}]) + assert sorted(got["알림"]["지운문서"]) == ["SC-000001", "SC-000002", "SC-000003"] + assert _files("precombo") == [] and t.now["폴더"] == [] + + +def test_복사_붙여넣기_새_키_같은_이름_폴더는_1(client: TestClient): # noqa: F811 + t = Tree(client) + t.do(op="add-folder", 경로=[], 이름="가") + t.do(op="add-folder", 경로=["가"], 이름="속") + t.do(op="move", 키="SC-000001", 새경로=["가", "속"]) + url = "/api/m02/layers/personal/precombo-tree/paste" + picks = [{"경로": ["가"]}, {"경로": [], "키": "SC-000002"}] + res = client.post(url, json={"from_layer": "personal", "대상": picks, "경로": ["가"]}) + assert res.status_code == 200, res.text + got = res.json() + assert got["알림"]["만든문서"] == ["SC-000004", "SC-000005"] + assert ["가", "가"] in got["폴더"] and ["가", "가", "속"] in got["폴더"] + assert got["자리"]["SC-000004"] == ["가", "가", "속"] and got["자리"]["SC-000005"] == ["가"] + again = client.post(url, json={"from_layer": "personal", "대상": [{"경로": ["가"]}]}).json() + assert ["가(1)"] in again["폴더"] # 같은 이름 = 「(1)」 + doc = client.get("/api/m02/layers/personal/templates/precombo/SC-000005").json()["문서"] + assert doc["이름"] == "나" and doc["본뜬"]["키"] == "SC-000002" + bad = client.post(url, json={"from_layer": "personal", "대상": [{"키": "SC-000099"}]}) + assert bad.status_code == 404 + + +@pytest.mark.parametrize("kind", ["precombo", "sangeun"]) +def test_순수_지우기_알림(kind: str, tmp_path: Path): + doc = {"폴더": [["가"], ["가", "나"]], "자리": {"X-1": ["가", "나"], "X-2": []}} + out, note = pre.apply(doc, {"op": "delete", "경로": ["가"]}, {"X-1", "X-2"}) + assert out == {"폴더": [], "자리": {}} and note["지운문서"] == ["X-1"] + assert pre.file_of(kind) == f"{kind}_폴더.json" diff --git a/resources/tester/test_m02_precombo_folders.py b/resources/tester/test_m02_precombo_folders.py index 3c19a088f..b3b4e98c2 100644 --- a/resources/tester/test_m02_precombo_folders.py +++ b/resources/tester/test_m02_precombo_folders.py @@ -1,7 +1,7 @@ """일위대가 사전조합 나무 = 자유 폴더(PLAN 158-1 · `M02_Precombo_Folders`) — 임시 storage. 새 폴더(깊이 자유) · 문서 옮기기(파일은 제자리 · 자리만) · 폴더 이름 바꾸기 · -옮기기(아래 자리 따라감) · 지우기(빈 폴더만 · 문서 있으면 409 까닭) · 판 409 · +옮기기(아래 자리 따라감) · 지우기(안에 든 문서째 · 716-4) · 판 409 · 층(마스터 · 회사 · 개인)마다 따로 · 문서 수(양식 목록) 그대로 · 쓰기 권한 = 층 규칙(마스터 403). """ @@ -88,7 +88,7 @@ def test_문서_옮기기는_자리만_파일과_문서_수_그대로(client: Te assert t.now["자리"] == {"SC-000001": ["옹벽", "찰쌓기"], "SC-000002": ["옹벽"]} t.do(op="move", 키="SC-000002", 새경로=[]) # 뿌리로 = 자리 지움 assert t.now["자리"] == {"SC-000001": ["옹벽", "찰쌓기"]} - assert t.do(404, op="move", 키="SC-000009", 새경로=[])["detail"].startswith("없는 사전조합") + assert t.do(404, op="move", 키="SC-000009", 새경로=[])["detail"].startswith("없는 문서") assert t.do(404, op="move", 키="SC-000001", 새경로=["없음"]) personal = layers.personal_dir(7, 42) assert sorted(p.name for p in (personal / "precombo").iterdir()) == [ @@ -97,7 +97,7 @@ def test_문서_옮기기는_자리만_파일과_문서_수_그대로(client: Te "SC-000003.json", ] assert _docs(client) == ["SC-000001", "SC-000002", "SC-000003"] # 자리 파일은 목록에 안 섞임 - assert (personal / pre.FILE).is_file() + assert (personal / pre.file_of("precombo")).is_file() def test_폴더_이름_바꾸기_옮기기는_아래_자리가_따라감(client: TestClient): @@ -117,17 +117,16 @@ def test_폴더_이름_바꾸기_옮기기는_아래_자리가_따라감(client: assert t.now["알림"]["바뀐이름"] == ["나(1)"] and t.now["자리"]["SC-000001"] == ["나(1)"] -def test_지우기는_빈_폴더만_문서가_있으면_까닭(client: TestClient): +def test_지우기는_안에_든_문서째(client: TestClient): t = Tree(client) t.do(op="add-folder", 경로=[], 이름="가") t.do(op="add-folder", 경로=["가"], 이름="나") t.do(op="add-folder", 경로=["가", "나"], 이름="다") t.do(op="move", 키="SC-000003", 새경로=["가", "나", "다"]) - assert "사전조합 1개" in t.do(409, op="delete", 경로=["가"])["detail"] - t.do(op="move", 키="SC-000003", 새경로=[]) - t.do(op="delete", 경로=["가"]) # 빈 아래 폴더까지 + got = t.do(op="delete", 경로=["가"]) # 아래 폴더 · 문서까지(716-4) + assert got["알림"]["지운문서"] == ["SC-000003"] assert t.now["폴더"] == [] and t.now["자리"] == {} - assert _docs(client) == ["SC-000001", "SC-000002", "SC-000003"] + assert _docs(client) == ["SC-000001", "SC-000002"] def test_판이_다르면_409_고침_안_함(client: TestClient): diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 081da91c9..1271ecae6 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -84,7 +84,8 @@ def test_고름에_owner_권한_서고_길에_명시_owner(): side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") assert "PRECOMBO_TITLE" not in side # 716-3 — 제목은 locale 키 「일위대가」 assert "owner: scope.owner," in side and "canWrite: scope.canWrite," in side - assert "listTemplates(at.layer, null, at.owner)" in side + lib_side = _text("M02_MasterTemplete_UI_Side_Library.ts") # 716-4 서고 칸 한 벌 + assert "listTemplates(at.layer, null, at.owner)" in lib_side main = _text("M02_MasterTemplete_UI_Main.ts") assert ( "const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin);" @@ -100,23 +101,26 @@ def test_고름에_owner_권한_서고_길에_명시_owner(): def test_분류_고정_폴더_지움_자유_폴더_메뉴_끌어_옮기기(): - side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") + pre_side = _text("M02_MasterTemplete_UI_Side_Precombo.ts") + side = _text("M02_MasterTemplete_UI_Side_Library.ts") # 716-4 서고 칸 한 벌 for gone in ("groupRows", "fetchStructureDef", "Lib_Unsorted", "shape"): - assert gone not in side, gone - assert "fetchPrecomboTree(at.layer, at.owner)" in side - assert "editPrecomboTree(at.layer, body, view.판, at.owner)" in side + assert gone not in side and gone not in pre_side, gone + assert "fetchPrecomboTree(at.layer, at.owner, kind)" in side + assert "editPrecomboTree(at.layer, body, 판, at.owner, kind)" in side menu = side.split("const menuAt = (")[1].split("\n };")[0] - for label in ('L("M02_TreeAddFolder")', 'tc("Lib_NewHere")', 'L("M02_TreeRename")'): + for label in ('L("M02_TreeAddFolder")', "opt.text.newHere", 'L("M02_TreeRename")'): assert label in menu, label - assert 'L("M02_Delete"), () => void remove(path, count)' in menu + assert 'item(L("M02_Delete"), () => void remove([target]))' in menu assert 'opt.scope().canWrite ? undefined : tc("ReadOnly")' in menu # 읽기만 층 = 흐림 remove = side.split("const remove = async (")[1].split("\n };")[0] - assert 'if (count) return void showToast(tc("Lib_FolderFull")' in remove # 빈 폴더만 + assert "showConfirmDialog(ask" in remove and 'op: "delete", 대상:' in remove # 안에 든 것째 + assert 'newHere: tc("Lib_NewHere")' in pre_side + assert '({ 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] })' in pre_side make = side.split("const make = (path: string[]): void => {")[1].split("\n };")[0] - assert 'const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] };' in make + assert "{ 문서: opt.blank(name) }" in make assert 'if (path.length) await edit({ op: "move", 키: got.이름, 새경로: path });' in make assert "attachTreeDrag({" in side and "if (canWrite) drag.arm(row, node.id);" in side - assert "openTreeMenu(event, menuAt([], 0));" in side # 빈 자리 = 뿌리 메뉴 + assert "openTreeMenu(event, menuAt([]));" in side # 빈 자리 = 뿌리 메뉴 assert "open: toggled.get(id) ?? count > 0," in side lib = _text("M02_MasterTemplete_Library_List.ts") assert "export const nameDialog = (" in lib From 69d989e5c804b1914652a592e47f540d33c58b68 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:39:36 +0900 Subject: [PATCH 06/30] =?UTF-8?q?feat(717-1):=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=83=81=EC=84=B8=20=EB=B6=80=ED=92=88=20?= =?UTF-8?q?=EA=B8=B8=20=EC=A3=BC=EC=9E=85=20=C2=B7=20M02=20logic-lib=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=C2=B7=20=EC=85=88=20=EA=B8=B8=20=EB=84=93?= =?UTF-8?q?=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Logic_Api.ts · Api_Fetch.ts · Logic_Note.ts — `/m01` 박힘 대신 setLogicApiPaths({ read, calc }) 길 묶음 · 안 넘기면 /m01 그대로 · 저장 · 만들기 · 관리자 길은 늘 /m01 - 새 M02_Logic_Lib_Read.py — /api/m02/logic-lib/ 로그인 길(logic/auto · logic/flow · elements · pick · materials · rows(인력 · 재료 · 기계만) · table(소요량 · 계수만) · calc · text) · M01 길 함수 그대로 부름 · row = 저장 안 한 줄 셈만 · 쓰기 없음 - main.py 등록(로그인만) · 지킴 시험 test_717_1_logic_lib.py + helper Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Api_Fetch.ts | 26 ++++-- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 39 +++++--- .../M01_MasterData_UI_Logic_Note.ts | 4 +- M02_MasterTemplete/M02_Logic_Lib_Read.py | 73 +++++++++++++++ main.py | 5 + .../tester/helper_717_1_logic_api_paths.cjs | 47 ++++++++++ resources/tester/test_717_1_logic_lib.py | 93 +++++++++++++++++++ 7 files changed, 268 insertions(+), 19 deletions(-) create mode 100644 M02_MasterTemplete/M02_Logic_Lib_Read.py create mode 100644 resources/tester/helper_717_1_logic_api_paths.cjs create mode 100644 resources/tester/test_717_1_logic_lib.py diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index c2eaeb337..05f9f82d2 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -4,6 +4,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { logicApiPaths } from "./M01_MasterData_UI_Logic_Api"; /** 줄·표 한 개 — 파일 그대로(한글 칸). */ export type Row = Record; @@ -89,8 +90,12 @@ export type SaveResult = | { status: 422; errors: string[] } | { status: number; detail: string }; -async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: T }> { - const response = await fetch(`${API_BASE_URL}/m01${path}`, { +async function call( + path: string, + init: RequestInit = {}, + base = "/m01", +): Promise<{ status: number; body: T }> { + const response = await fetch(`${API_BASE_URL}${base}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, @@ -98,9 +103,13 @@ async function call(path: string, init: RequestInit = {}): Promise<{ status: return { status: response.status, body: (await response.json()) as T }; } -async function get(path: string, params: Record): Promise { +async function get( + path: string, + params: Record, + base = "/m01", +): Promise { const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])); - const { status, body } = await call(`${path}?${query}`); + const { status, body } = await call(`${path}?${query}`, {}, base); if (status !== 200) throw new Error(String(body.detail ?? `HTTP ${status}`)); return body; } @@ -121,7 +130,11 @@ export const fetchRows = ( detail = "", state = "", ): Promise => - get("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state }); + get( + "/rows", + { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state }, + logicApiPaths().read, // 고르기 표 · 품목 찾기 — 상세 부품 길(717-1) + ); /** 한 테이블 파일은 `file` · 장 그룹(소요량 · 계수 · 로직)은 `group` 으로 부름. */ export const fetchSubsAll = (where: { @@ -174,7 +187,8 @@ export interface PickItem { export const fetchPick = ( kind: PickKind, q: string, -): Promise<{ total: number; items: PickItem[] }> => get("/pick", { kind, q, limit: 50 }); +): Promise<{ total: number; items: PickItem[] }> => + get("/pick", { kind, q, limit: 50 }, logicApiPaths().read); /** 자재 찾기 — `/pick?kind=price` 감쌈 · 이름·규격 낱말이 모두 든 줄 */ export const searchMaterials = async ( diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 947a8a044..2ebcfbef4 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -190,8 +190,22 @@ export class ApiError extends Error { } } -async function request(path: string, body?: unknown): Promise { - const response = await fetch(`${API_BASE_URL}/m01${path}`, { +/** 상세 부품이 부르는 길 앞머리(717-1) — 읽기(로직 · 요소 · 재료 · 표 · 줄) · 셈(`/calc` · `/text`) + * · 기본 = M01(`/m01` · 시스템 관리자) · M02 = `{ read: "/m02/logic-lib", calc: "/m02/logic-lib" }`(로그인) */ +export interface LogicApiPaths { + read: string; + calc: string; +} +const M01_PATHS: LogicApiPaths = { read: "/m01", calc: "/m01" }; +let paths = M01_PATHS; +/** 상세를 그리기 전에 한 번 — 안 넘기면 M01 길로 되돌림 · 저장 · 만들기 길은 늘 `/m01` */ +export const setLogicApiPaths = (next?: LogicApiPaths): void => { + paths = next ?? M01_PATHS; +}; +export const logicApiPaths = (): LogicApiPaths => paths; + +async function request(path: string, body?: unknown, base = "/m01"): Promise { + const response = await fetch(`${API_BASE_URL}${base}${path}`, { method: body === undefined ? "GET" : "POST", credentials: "include", headers: { "Content-Type": "application/json" }, @@ -205,13 +219,16 @@ async function request(path: string, body?: unknown): Promise { const query = (params: Record): string => new URLSearchParams(params).toString(); export const fetchLogics = (): Promise => - request<{ logics: LogicSummary[] }>("/logics").then((d) => d.logics); + request<{ logics: LogicSummary[] }>("/logics", undefined, paths.read).then((d) => d.logics); /** 왼쪽 트리 목록 — 서버가 준 구분·상세구분(장 차례대로) */ export const fetchLogicSubs = (): Promise => - request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`).then((d) => d.subs); + request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`, undefined, paths.read).then( + (d) => d.subs, + ); -export const fetchLogic = (key: string): Promise => request(`/logic?${query({ key })}`); +export const fetchLogic = (key: string): Promise => + request(`/logic?${query({ key })}`, undefined, paths.read); export const fetchLogicFiles = (): Promise => request<{ files: LogicFile[] }>(`/groups/${encodeURIComponent("로직")}/files`).then( @@ -222,10 +239,10 @@ export const searchElements = ( group: string, q: string, ): Promise<{ total: number; items: ElementBrief[] }> => - request(`/elements?${query({ group, q, limit: "100" })}`); + request(`/elements?${query({ group, q, limit: "100" })}`, undefined, paths.read); export const searchPrice = (q: string): Promise<{ total: number; items: ElementBrief[] }> => - request(`/pick?${query({ kind: "price", q, limit: "100" })}`); + request(`/pick?${query({ kind: "price", q, limit: "100" })}`, undefined, paths.read); export const fetchMaterials = (cond: { sub: string; @@ -235,11 +252,11 @@ export const fetchMaterials = (cond: { /** 호표 줄 단위 — 주면 그 단위 줄만 */ unit?: string; }): Promise<{ total: number; 기본: string | null; items: ElementBrief[] }> => - request(`/materials?${query({ ...cond, limit: "100" })}`); + request(`/materials?${query({ ...cond, limit: "100" })}`, undefined, paths.read); /** 자동값 길(`GET /logic/auto`) — 입력 이름 → 견본값 · 서버에 아직 없거나 값이 없으면 빈 채로(화면은 그냥 안 채움) */ export const fetchAuto = (key: string): Promise> => - request<{ 값?: Record }>(`/logic/auto?${query({ key })}`) + request<{ 값?: Record }>(`/logic/auto?${query({ key })}`, undefined, paths.read) .then((d) => d.값 ?? {}) .catch(() => ({})); @@ -250,7 +267,7 @@ export const runCalc = (body: { file?: string; /** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */ 고름?: Record; -}): Promise => request("/calc", body); +}): Promise => request("/calc", body, paths.calc); export const runText = (body: { key: string; @@ -259,7 +276,7 @@ export const runText = (body: { file?: string; /** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */ 고름?: Record; -}): Promise => request("/text", body); +}): Promise => request("/text", body, paths.calc); export const saveFiles = ( files: SaveFile[], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Note.ts b/M01_MasterData/M01_MasterData_UI_Logic_Note.ts index 40e41ed6f..f9936df40 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Note.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Note.ts @@ -9,7 +9,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { el } from "@ui/ui_template_elements"; -import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api"; +import { logicApiPaths, type ElementBrief, type NamedFormula } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { tx } from "./M01_MasterData_UI_Logic_Text"; @@ -31,7 +31,7 @@ const tableCache = new Map>(); async function getJson(path: string): Promise { try { - const res = await fetch(`${API_BASE_URL}/m01${path}`); + const res = await fetch(`${API_BASE_URL}${logicApiPaths().read}${path}`); if (!res.ok) return null; return (await res.json()) as T; } catch { diff --git a/M02_MasterTemplete/M02_Logic_Lib_Read.py b/M02_MasterTemplete/M02_Logic_Lib_Read.py new file mode 100644 index 000000000..ae6231d52 --- /dev/null +++ b/M02_MasterTemplete/M02_Logic_Lib_Read.py @@ -0,0 +1,73 @@ +"""단가산출 로직 상세 읽기 · 셈 길(PLAN 717-1) — `/api/m02/logic-lib/…` · 로그인만(`main.py`). + +M01 상세 부품(`M01_MasterData_UI_Logic_Api.ts` · `Api_Fetch.ts` · `Logic_Note.ts`)이 길 앞머리만 +`/m02/logic-lib` 로 바꿔 부름 — 길 · 몸 · 답 = M01 `/api/m01/…` 와 같음(M01 길 함수를 부름). +쓰기 없음 — `/calc` · `/text` 의 `row`(저장 안 한 고친 줄)는 메모리 사본에서만 셈(`Store._swap`). +`/logics` · `/subs` · `/logic` · 원문은 `M02_MasterTemplete_Router_Combos.py` 에 있음. +""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from M01_MasterData import M01_MasterData_Router as m01 + +router = APIRouter(prefix="/api/m02/logic-lib", tags=["M02 Logic Lib Read"]) + +#: `/rows` 로 줄을 내줄 요소 파일 앞머리 — 인력 · 재료 · 기계(고르기 표 · 품목 찾기) +ROW_FILES = ("인력", "재료", "기계") +#: `/table` 로 내줄 표 파일 앞머리 — 소요량 · 계수(설명 카드 원문 표) +TABLE_FILES = ("소요량_", "계수_") + + +@router.get("/logic/auto") +def get_auto(key: str) -> dict: + return m01.get_logic_auto(key) + + +@router.get("/logic/flow") +def get_flow(key: str, file: str = "") -> dict: + """흐름 보기(읽기 · 변환해도 파일 안 바뀜).""" + return m01.get_logic_flow(key, file) + + +@router.get("/elements") +def get_elements(group: str, q: str = "", limit: int = 50) -> dict: + return m01.get_elements(group, q, min(limit, 200)) + + +@router.get("/pick") +def get_pick(kind: str, q: str = "", limit: int = 50) -> dict: + return m01.get_pick(kind, q, min(limit, 200)) + + +@router.get("/materials") +def get_materials( + sub: str, detail: str = "", spec: str = "", region: str = "", unit: str = "", limit: int = 50 +) -> dict: + return m01.get_materials(sub, detail, spec, region, unit, min(limit, 200)) + + +@router.get("/rows") +def get_rows(file: str, page: int = 1, size: int = 50, q: str = "", sub: str = "") -> dict: + if not file.startswith(ROW_FILES): + raise HTTPException(status_code=403, detail="요소 파일(인력 · 재료 · 기계)만 읽음") + return m01.get_rows(file, page, size, q, False, sub, "", "") + + +@router.get("/table") +def get_table(file: str, key: str) -> dict: + if not file.startswith(TABLE_FILES): + raise HTTPException(status_code=403, detail="소요량 · 계수 표만 읽음") + return m01.get_table(file, key) + + +@router.post("/calc") +def post_calc(body: m01.CalcBody) -> dict: + """M01 `/calc` 와 같음 — `row` = 저장 안 한 고친 줄(셈만 · 쓰기 없음).""" + return m01.post_calc(body) + + +@router.post("/text") +def post_text(body: m01.CalcBody) -> dict: + return m01.post_text(body) diff --git a/main.py b/main.py index d375c0544..982dfdf54 100644 --- a/main.py +++ b/main.py @@ -563,6 +563,11 @@ app.include_router(m02_items_router, dependencies=protected) app.include_router(m02_combos_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) app.include_router(m02_labor_router, dependencies=protected) +# 단가산출 로직 상세 읽기 · 셈(PLAN 717-1) — 로그인만 · 쓰기 없음 +app.include_router( + __import__("M02_MasterTemplete.M02_Logic_Lib_Read", fromlist=["router"]).router, + dependencies=protected, +) # 단가산출(SK) 셈 길(PLAN 715-1b) — 로그인 · 층 읽기 권한은 길 안에서 app.include_router( __import__("M02_MasterTemplete.M02_Sangeun_Router", fromlist=["router"]).router, diff --git a/resources/tester/helper_717_1_logic_api_paths.cjs b/resources/tester/helper_717_1_logic_api_paths.cjs new file mode 100644 index 000000000..b67d706d8 --- /dev/null +++ b/resources/tester/helper_717_1_logic_api_paths.cjs @@ -0,0 +1,47 @@ +/* 717-1 헬퍼 — 상세 부품 길 주입 · 가짜 fetch 로 부른 주소만 모음. 짝 = `test_717_1_logic_lib.py`. */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const { load } = makeLoader({ + root: ROOT, + stubs: { M01_MasterData_UI_Logic_Edit: {}, M01_MasterData_UI_Logic_Text: {} }, +}); +const urls = []; +global.fetch = async (url) => { + urls.push(String(url).replace("http://fake", "")); + return { + ok: true, + status: 200, + statusText: "", + json: async () => ({ logics: [], subs: [], items: [], rows: [] }), + }; +}; + +const api = load("M01_MasterData", "M01_MasterData_UI_Logic_Api.ts"); +const rows = load("M01_MasterData", "M01_MasterData_Api_Fetch.ts"); +const note = load("M01_MasterData", "M01_MasterData_UI_Logic_Note.ts"); + +async function round(tag) { + await api.fetchLogic("K"); + await api.fetchAuto("K"); + await api.searchElements("재료", "q"); + await api.fetchMaterials({ sub: "s" }); + await api.runCalc({ key: "K", inputs: {} }); + await api.runText({ key: "K", inputs: {} }); + await rows.fetchRows("기계.json", 1, 5, "q"); + await rows.fetchPick("price", "q"); + await note.loadTable(`T-${tag}`); + await rows.fetchGroups(); // 관리자 길 — 늘 /m01 + await api.saveFiles([]); // 저장 — 늘 /m01 + return urls.splice(0).map((u) => u.split("?")[0]); +} + +(async () => { + const out = { before: await round("a") }; + api.setLogicApiPaths({ read: "/m02/logic-lib", calc: "/m02/calc-x" }); + out.injected = await round("b"); + api.setLogicApiPaths(); + out.after = await round("c"); + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_717_1_logic_lib.py b/resources/tester/test_717_1_logic_lib.py new file mode 100644 index 000000000..aa97c99c2 --- /dev/null +++ b/resources/tester/test_717_1_logic_lib.py @@ -0,0 +1,93 @@ +"""717-1 상세 부품 공용 — 길 주입(TS) · M02 `logic-lib` 읽기 · 셈 길(로그인 · 쓰기 없음). + +TS = `helper_717_1_logic_api_paths.cjs`(가짜 fetch 주소) · 서버 = 정본 읽기만(파일 지문 같음). +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from M01_MasterData import M01_MasterData_Store as store +from M02_MasterTemplete import M02_Logic_Lib_Read as lib + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_717_1_logic_api_paths.cjs" +KEY = "GF000001" # 경계표시 — 보통인부 한 줄 +BASE = "/api/m02/logic-lib" + + +@pytest.mark.skipif(not (ROOT / "config" / "node_modules").is_dir(), reason="의존성 미설치") +def test_길_주입_없으면_m01_있으면_넘긴_길() -> None: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + out = json.loads(done.stdout) + assert out["before"] == out["after"] # 안 넘기면 지금 그대로 + assert all(u.startswith("/m01/") for u in out["before"]) + shared, admin = out["injected"][:-2], out["injected"][-2:] + assert all(u.startswith(("/m02/logic-lib/", "/m02/calc-x/")) for u in shared) + assert [u for u in shared if u.startswith("/m02/calc-x/")] == [ + "/m02/calc-x/calc", + "/m02/calc-x/text", + ] + assert admin == ["/m01/groups", "/m01/save"] # 관리자 · 저장 길은 늘 M01 + + +@pytest.fixture(scope="module") +def user() -> TestClient: + """main.py 와 같은 등록(로그인만) · 일반 사용자.""" + app = FastAPI() + app.include_router(lib.router, dependencies=[Depends(verify_session)]) + app.dependency_overrides[verify_session] = lambda: { + "user_id": 42, "company_id": 7, "role": "USER", "is_master": False, + } # fmt: skip + return TestClient(app) + + +def _prints() -> dict[str, str]: + return {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in store.FOLDER.glob("*.json")} + + +def test_일반_계정_읽기(user: TestClient) -> None: + assert user.get(f"{BASE}/logic/auto", params={"key": KEY}).status_code == 200 + assert user.get(f"{BASE}/elements", params={"group": "인력", "q": "LB000002"}).json()["items"] + assert user.get(f"{BASE}/pick", params={"kind": "price", "q": "시멘트"}).status_code == 200 + assert user.get(f"{BASE}/rows", params={"file": "기계.json", "size": 3}).json()["rows"] + assert user.get(f"{BASE}/materials", params={"sub": "시멘트"}).status_code == 200 + flow = user.get(f"{BASE}/logic/flow", params={"key": KEY}) + assert flow.status_code == 200 and "flow" in flow.json() + found = user.get(f"{BASE}/elements", params={"group": "소요량", "q": "QF000025"}).json() + file = next(i["file"] for i in found["items"] if i["ref"] == "QF000025") + assert user.get(f"{BASE}/table", params={"file": file, "key": "QF000025"}).json()["table"] + + +def test_요소_표_밖_파일은_막음(user: TestClient) -> None: + logic_file = store.logics("", "", "", None, "")[0]["file"] + assert user.get(f"{BASE}/rows", params={"file": logic_file}).status_code == 403 + assert user.get(f"{BASE}/table", params={"file": "기계.json", "key": "x"}).status_code == 403 + assert user.get(f"{BASE}/rows", params={"file": "재료_../x.json"}).status_code == 404 + + +def test_저장_안_한_줄_셈_쓰기_없음(user: TestClient) -> None: + before = _prints() + saved = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}}).json() + row = store.logic(KEY)["logic"] + same = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row}).json() + line = row["호표"][0] + row2 = {**row, "호표": [{**line, "수량": f"({line['수량']}) * 2"}, *row["호표"][1:]]} + double = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row2}).json() + text = user.post(f"{BASE}/text", json={"key": KEY, "inputs": {}, "row": row2}).json() + assert saved["ok"] and same["sums"] == saved["sums"] + assert double["sums"]["노무비"] == pytest.approx(saved["sums"]["노무비"] * 2) + assert text["ok"] and text["계"] == pytest.approx(double["sums"]["계"], rel=1e-6) + assert store.logic(KEY)["logic"]["호표"][0]["수량"] == line["수량"] # 정본 그대로 + assert _prints() == before From 3d4bcaea5997d9d197f8a5f66ceb06b2686f7369 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:42:00 +0900 Subject: [PATCH 07/30] =?UTF-8?q?feat(713-2):=20M01=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B8=20=EA=B0=9C=EC=A0=95=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=20=EC=B0=BD=20=E2=80=94=20/save=20=EA=B0=9C=EC=A0=95=20409=20?= =?UTF-8?q?=EB=B0=9B=EC=9C=BC=EB=A9=B4=20=EB=B0=94=EB=80=8C=EB=8A=94=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=20=EC=A4=84(=ED=8C=8C=EC=9D=BC=20=C2=B7=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=C2=B7=20=EB=B0=94=EB=80=9C)=20=EC=B0=BD?= =?UTF-8?q?=20=C2=B7=20=E3=80=8C=EC=9B=90=EB=AC=B8=20=EA=B0=9C=EC=A0=95=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=E3=80=8D=20=ED=99=95=EC=9D=B8=20=EB=92=A4=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EB=AA=B8=EC=97=90=20=EA=B0=9C=EC=A0=95?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20true=20=EB=8B=A4=EC=8B=9C=20=EB=B3=B4?= =?UTF-8?q?=EB=83=84=20=C2=B7=20=EC=B7=A8=EC=86=8C=20=3D=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EA=B7=B8=EB=8C=80=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M01_MasterData_UI_LogicLab_Revise.ts(reviseHits · askRevise · saveRevised) · 부품 상자 모달 · 목록 줄 · 배지 - LogicLab [저장](흐름 저장 · 지우기 · 더하기 같은 길)이 saveRevised 로 · 줄 수 그대로(627) - Logic_Api saveFiles 에 개정확인 인자(기본 false) - 옛 409 stale 은 토스트 그대로 - 새 지킴 시험 test_713_2_revise_ui Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 6 +- .../M01_MasterData_UI_LogicLab_Revise.ts | 103 ++++++++++++++++++ M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 3 +- resources/tester/test_713_2_revise_ui.py | 34 ++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Revise.ts create mode 100644 resources/tester/test_713_2_revise_ui.py diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 877fb2cf8..7af76d2a3 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -51,6 +51,7 @@ import { type LogicFlow, } from "./M01_MasterData_UI_LogicLab_Flow"; import { askBasicInfo } from "./M01_MasterData_UI_LogicLab_Basic"; +import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise"; import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side"; import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View"; import { buildHeadBar } from "./M01_MasterData_UI_Head"; @@ -501,9 +502,8 @@ export async function mountM01LogicLab( showLoadingOverlay(); try { // 키 없는 새 줄은 서버가 새 키를 줌(added) — 저장 뒤 그 로직을 엶 - const saved = (await saveFiles([...byFile.values()])) as { - added?: { file: string; key: string }[]; - }; + const saved = await saveRevised((ok) => saveFiles([...byFile.values()], ok)); + if (!saved) return; // 개정 확인 취소 = 캐시 그대로 const was = opened; drafts = {}; await reload(); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Revise.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Revise.ts new file mode 100644 index 000000000..624452773 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Revise.ts @@ -0,0 +1,103 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Revise.ts + * 로직 정본 개정 확인 창(PLAN 713-2 · 요구서 내역12 4장 10) — `/save` 409 `{개정, 까닭}` 를 받으면 + * 바뀌는 정본 줄(파일 · 로직 · 바뀜)을 보이고 「원문 개정 반영」 확인을 받음. + * 확인 = 부르는 쪽이 같은 몸에 `개정확인: true` 로 다시 보냄 · 취소 = 아무것도 안 바뀜(캐시 그대로) + * 옛 409(`stale` — 그 사이 파일 바뀜)는 이 창 대상 밖 · `reviseHits` 가 null + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, hideLoadingOverlay, showLoadingOverlay } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { createListRow } from "@ui/ui_template_list_row"; +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; +import { ApiError } from "./M01_MasterData_UI_Logic_Api"; + +/** 서버 `Store_Revise.changed` 한 줄 */ +export interface ReviseHit { + file: string; + key: string | null; + op: "edit" | "add" | "delete"; + 이름?: string | null; +} + +const TEXT = { + Title: ["원문 개정 반영", "Apply source revision"], + Lead: [ + "로직 정본(품셈 원문) 줄을 바꿈 — 원문이 개정된 때만 저장 · 아래 {n} 줄", + "This changes canonical logic rows (source text) — save only for a source revision · {n} rows below", + ], + Ok: ["원문 개정 반영", "Apply revision"], + Cancel: ["취소", "Cancel"], + New: ["새 줄", "New row"], + edit: ["고침", "Edit"], + add: ["더함", "Add"], + delete: ["지움", "Delete"], +} as const satisfies Record; + +const t = (key: keyof typeof TEXT, n = 0): string => + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{n}", String(n)); + +const TONE: Record = { edit: "warning", add: "info", delete: "danger" }; + +/** 개정 409 면 바뀌는 줄 · 아니면 null */ +export function reviseHits(error: unknown): ReviseHit[] | null { + if (!(error instanceof ApiError) || error.status !== 409) return null; + const hits = (error.detail as { 개정?: ReviseHit[] } | null)?.개정; + return Array.isArray(hits) && hits.length ? hits : null; +} + +/** 확인 창 — 「원문 개정 반영」이면 true · 취소 · 닫기 · Esc · 바깥 누름이면 false */ +export function askRevise(hits: ReviseHit[]): Promise { + return new Promise((resolve) => { + openModal({ + title: t("Title"), + closeLabel: t("Cancel"), + width: "40rem", + // [취소] · Esc · 바깥 누름 = 닫기 길(묻는 길) · 확인은 `shut` 이라 이 길을 안 거침 + beforeClose: () => { + resolve(false); + return true; + }, + mount: (body, shut, actions) => { + body.append( + el("p", { text: t("Lead", hits.length) }), + ...hits.map((h) => + createListRow({ + label: `${h.key ?? t("New")} ${h.이름 ?? ""}`.trim(), + sub: h.file, + trailing: createBadge(t(h.op), TONE[h.op]), + size: 36, + }), + ), + ); + actions.append( + createButton({ + label: t("Ok"), + variant: "danger", + onClick: () => { + shut(); + resolve(true); + }, + }), + ); + }, + }); + }); +} + +/** 저장 한 번 — 개정 409 면 창을 띄워 확인 뒤 `개정확인: true` 로 다시 보냄 · 취소면 null(캐시 그대로) */ +export async function saveRevised( + save: (confirmed: boolean) => Promise, +): Promise<{ added?: { file: string; key: string }[] } | null> { + try { + return (await save(false)) as { added?: { file: string; key: string }[] }; + } catch (error) { + const hits = reviseHits(error); + if (!hits) throw error; + hideLoadingOverlay(); // 창이 가림막 아래로 숨지 않게 + const ok = await askRevise(hits); + showLoadingOverlay(); + return ok ? ((await save(true)) as { added?: { file: string; key: string }[] }) : null; + } +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 947a8a044..1f4264f10 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -263,7 +263,8 @@ export const runText = (body: { export const saveFiles = ( files: SaveFile[], -): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files }); + 개정확인 = false, +): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files, 개정확인 }); /** 본떠 만들기 — 정본·자체 둘 다 본뜸 · 새 키(`GX…`)는 서버가 줌(계약 5장) */ export const copyLogic = ( diff --git a/resources/tester/test_713_2_revise_ui.py b/resources/tester/test_713_2_revise_ui.py new file mode 100644 index 000000000..274ff2bfd --- /dev/null +++ b/resources/tester/test_713_2_revise_ui.py @@ -0,0 +1,34 @@ +"""713-2 정본 개정 확인 창 지킴 시험 — 글로 잡음(화면 · 서버 없음). + +- `/save` 몸에 `개정확인` 이 실림 · 로직 화면 [저장]은 `saveRevised` 한 길(개정 409 → 창 → 다시 보냄) +- 창은 부품 상자(모달 · 목록 줄 · 배지)만 · 옛 `stale` 409 는 이 창 대상 밖 +""" + +from pathlib import Path + +M01 = Path(__file__).resolve().parents[2] / "M01_MasterData" + + +def _read(name: str) -> str: + return (M01 / f"M01_MasterData_UI_{name}.ts").read_text(encoding="utf-8") + + +def test_save_body_carries_confirm(): + api = _read("Logic_Api") + assert 'request("/save", { files, 개정확인 })' in api + + +def test_lab_save_goes_through_revise(): + lab = _read("LogicLab") + assert "saveRevised((ok) => saveFiles(" in lab + assert "await saveFiles(" not in lab.replace("saveRevised((ok) => saveFiles(", "") + + +def test_revise_window_parts_only(): + rev = _read("LogicLab_Revise") + for part in ("ui_template_modal", "ui_template_list_row", "ui_template_badge"): + assert f"@ui/{part}" in rev + assert " Date: Sun, 1 Nov 2026 15:42:14 +0900 Subject: [PATCH 08/30] =?UTF-8?q?fix(716-3):=20Hopyo=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EA=B8=80=20=E3=80=8C=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9?= =?UTF-8?q?=E3=80=8D=20=E2=86=92=20=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=E3=80=8D=20=E2=80=94=20M02=5FPrecomboTitle=20?= =?UTF-8?q?=ED=82=A4=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 확인 창 [일위대가 생성] 단추 · 엑셀 보기 · 다시 잇기 · 장부 · 고르기 알림 · 구조물 조합으로 설명 - 서버 줄 상태 글 「확인된 일위대가」 - test_m02_hopyo_screen 단추 글 단언을 키 꼴로 고침 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Line.py | 2 +- M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts | 11 ++++++++--- .../M02_MasterTemplete_Hopyo_CheckRows.ts | 6 +++++- .../M02_MasterTemplete_Hopyo_Ledger.ts | 3 ++- M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts | 3 ++- .../M02_MasterTemplete_Hopyo_Relink.ts | 5 +++-- M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts | 12 ++++++++---- .../M02_MasterTemplete_Hopyo_ToItem.ts | 3 ++- resources/tester/test_m02_hopyo_screen.py | 2 +- 9 files changed, 32 insertions(+), 15 deletions(-) diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index 3cb722f25..501c1633a 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -251,7 +251,7 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A target = {"호표": no, "이름": h.get("이름", ""), "단위": h.get("단위"), "입력값": {}} target.update(못읽음=[], 출처={}, 환산=conv, 몫=None, 배=conv["배"]) ok = bool(made) and conv["배"] is not None and conv["갈래"] != "추정" - text = f"확인된 사전조합 {made}" if made else f"제{no}호표 먼저 확인" + text = f"확인된 일위대가 {made}" if made else f"제{no}호표 먼저 확인" return {**out, "상태": "호표", "체크": ok, "대상": [target], "글": text} if kind == "노임": return {**out, "상태": "노임", "글": "노임 · 그릇 없음"} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index f293e9ce3..09f4e5166 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -26,6 +26,7 @@ import { } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { openModal } from "@ui/ui_template_modal"; +import { t as L } from "@ui/ui_template_locale"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; import { buildHopyo, @@ -429,7 +430,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { const ask = whole ? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}` : `품셈 줄 ${put.length}${diff ? `(품 다름 ${diff})` : ""} · 고정 줄 ${h.줄.length - put.length - outs}` + - ` · 뺌 ${outs} — ${money}(미리 셈 · 끝수 차 있음) — 사전조합 생성`; + ` · 뺌 ${outs} — ${money}(미리 셈 · 끝수 차 있음) — ${L("M02_PrecomboTitle")} 생성`; if (!(await showConfirmDialog(ask, "생성"))) return; const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no }; showLoadingOverlay(); @@ -530,7 +531,11 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { draw(); }); const whole = !!h.통째 && (d.통째 ?? h.통째.체크); - const go = createButton({ label: "사전조합 생성", variant: "filled", size: "sm" }); + const go = createButton({ + label: `${L("M02_PrecomboTitle")} 생성`, + variant: "filled", + size: "sm", + }); go.setAttribute("data-hopyo-make", ""); go.addEventListener("click", () => void make(h)); tools.push(all); @@ -573,7 +578,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement { ...(h.만든 ? [ note( - `만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}` + + `만든 ${L("M02_PrecomboTitle")} ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}` + (h.만든.품셈 ? ` · 품셈 줄 ${h.만든.품셈.length} · 고정 줄 ${h.만든.고정 ?? 0}` : ""), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts index 5b3d90d3a..577378746 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -12,6 +12,7 @@ * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; import type { HopyoLine, HopyoPart } from "./M02_MasterTemplete_Hopyo_Api"; import { checkable, @@ -170,7 +171,10 @@ function actsCell(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLEl createButton({ label, variant: "ghost", size: "sm", onClick: run }); const out = el("select", { className: "ui-input ui-input--sm", - attrs: { title: "로직없음 · 사용안함 — 사전조합에 안 넣고 장부에 남김", "data-out": "" }, + attrs: { + title: `로직없음 · 사용안함 — ${L("M02_PrecomboTitle")}에 안 넣고 장부에 남김`, + "data-out": "", + }, children: OUT_TEXT.map(([value, text]) => el("option", { text, attrs: { value } })), }) as HTMLSelectElement; out.value = ctx.d.뺌[line.차례] ?? ""; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts index 0407a7891..f4aaa3a11 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Ledger.ts @@ -11,6 +11,7 @@ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { t as L } from "@ui/ui_template_locale"; import { readLedger, saveLedger, @@ -326,7 +327,7 @@ export function buildHopyoLedger(opt: LedgerOptions): HTMLElement { className: "ui-toolbar m02-hopyo__bar", children: [ el("strong", { text: LEDGER_TITLE }), - note(`${LEDGER_HINT} · 사전조합 생성 때 남음 · 아래 층이 이김`), + note(`${LEDGER_HINT} · ${L("M02_PrecomboTitle")} 생성 때 남음 · 아래 층이 이김`), search, kind, ...(opt.canWrite ? [save, drop] : []), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts index def3c122d..a24be99b8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -7,6 +7,7 @@ * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; import { listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api"; import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -82,7 +83,7 @@ export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra { export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise { const alias = row.이름.slice(0, ALIAS_MAX).trim() || null; if (row.확인) { - showToast(`「${row.이름}」 → 확인한 사전조합 ${row.확인.키}`, "success"); + showToast(`「${row.이름}」 → 확인한 ${L("M02_PrecomboTitle")} ${row.확인.키}`, "success"); return lib.pick(row.확인, row.이름, alias); } try { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts index 128d7003a..8f038fb87 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink.ts @@ -12,6 +12,7 @@ import { createButton, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { createDataTable, type DataRow } from "@ui/ui_template_data_table"; import { createText } from "@ui/ui_template_text"; +import { t as L } from "@ui/ui_template_locale"; import { pageScope, relinkApply, @@ -43,7 +44,7 @@ const won = (v: number | null | undefined): string => export function relinkKeys(keys: string[]): RelinkSource { const scope = pageScope(); return { - title: `이 공사 단가로 다시 잇기 — 사전조합 ${keys.length}`, + title: `이 공사 단가로 다시 잇기 — ${L("M02_PrecomboTitle")} ${keys.length}`, priceLabel: "최신 판 단가", rows: () => relinkRows(scope, { 키: keys }), apply: async (changes) => { @@ -51,7 +52,7 @@ export function relinkKeys(keys: string[]): RelinkSource { const sum = (k: "전" | "후"): number => got.문서.reduce((a, d) => a + (d[k] ?? 0), 0); return { back: got.되돌림, - note: `바뀐 줄 ${got.바뀜} · 사전조합 ${got.문서.length} · 1 단위 값 합 ${won(sum("전"))} → ${won(sum("후"))}`, + note: `바뀐 줄 ${got.바뀜} · ${L("M02_PrecomboTitle")} ${got.문서.length} · 1 단위 값 합 ${won(sum("전"))} → ${won(sum("후"))}`, }; }, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index ddb3f3de5..8fadc13bf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -24,6 +24,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; +import { t as L } from "@ui/ui_template_locale"; import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; @@ -239,7 +240,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { variant: "danger", size: "sm", onClick: async () => { - const ask = `실무자료 「${opt.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`; + const ask = `실무자료 「${opt.파일}」을 지움 — 만든 ${L("M02_PrecomboTitle")} · 별칭 장부는 그대로`; if (!(await showConfirmDialog(ask, "삭제"))) return; try { await deleteHopyo(opt.scope, opt.key); @@ -427,7 +428,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { const nos = [...checked].sort((a, b) => a - b); const fresh = nos.filter((no) => !hasDraft(draftKey(opt.scope, opt.key, no))).length; const ask = - `고른 호표 ${nos.length}개를 체크한 줄로 사전조합 생성` + + `고른 호표 ${nos.length}개를 체크한 줄로 ${L("M02_PrecomboTitle")} 생성` + (fresh ? ` — 확인 창에서 안 고친 ${fresh}개는 서버 제안 체크 줄` : ""); if (!(await showConfirmDialog(ask, "저장"))) return; go.disabled = true; @@ -510,7 +511,10 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { } if (ok) document.dispatchEvent(new Event(HOPYO_BUILT)); result.replaceChildren( - table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], [...rows, ...skipped]), + table( + ["호표", `${L("M02_PrecomboTitle")}`, "빠진 줄", "값 차이(참고)"], + [...rows, ...skipped], + ), ); const all = order.length + skipped.length; showToast(`만듦 ${ok} / ${all}`, ok === all ? "success" : "warning"); @@ -526,7 +530,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void { /** [모두 고정으로 들여오기](705-3) — 물음 한 번 · 서버가 호표 모두를 하위부터(숫자 = 서버가 파일에서) */ async function importAll(): Promise { const ask = - `호표 ${briefs.length}개를 엑셀 숫자 그대로(고정 줄) 사전조합으로 들여옴 — ` + + `호표 ${briefs.length}개를 엑셀 숫자 그대로(고정 줄) ${L("M02_PrecomboTitle")}으로 들여옴 — ` + "이미 만든 호표는 그대로 · 품셈 맞춤은 뒤에 호표 [수정]에서 [품셈으로 바꾸기]"; if (!(await showConfirmDialog(ask, "들여오기"))) return; fixAll.disabled = true; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts index 01f117d13..708ce9d96 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ToItem.ts @@ -17,6 +17,7 @@ import { createCheckbox } from "@ui/ui_template_checkbox"; import { createInputField } from "@ui/ui_template_input"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; +import { t as L } from "@ui/ui_template_locale"; import { buildHopyoItem, hopyoSiblings, @@ -101,7 +102,7 @@ export async function toItem(o: ToItemOptions): Promise { actions.append(go); body.append( createText( - "체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — 사전조합을 거치지 않고 구조물 조합 항목을 바로 만듦", + `체크한 줄 = 품셈 줄 · 나머지 = 고정 줄(엑셀 숫자 그대로) — ${L("M02_PrecomboTitle")}을 거치지 않고 구조물 조합 항목을 바로 만듦`, "muted", ), name.root, diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 892adfe59..4bc8efb8f 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -308,7 +308,7 @@ def test_옛_즉시_확정과_바로_만들기_없음(): users = sorted(name for name, text in texts.items() if "buildHopyo(" in text) assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤 check = texts["Hopyo_Check"] - assert '"사전조합 생성"' in check and "맞음 · 별칭 일괄 체크" in check + assert '`${L("M02_PrecomboTitle")} 생성`' in check and "맞음 · 별칭 일괄 체크" in check assert "확인하고" not in check # 359-4 명사형 assert "showLoadingOverlay()" in check and "dropDraft(k)" in check assert "m02-hopyo-check:" in check and "POLL_MS" in check From 915feb436eba2f31fb06851bec5281e4b38a5f38 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:49:52 +0900 Subject: [PATCH 09/30] =?UTF-8?q?feat(714-3):=20M01=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=8B=9D=20=EC=9E=85=EB=A0=A5=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=E2=80=94=20=EC=A4=84=EC=9D=B4=20=EC=93=B0=EB=8A=94?= =?UTF-8?q?=20=EC=8B=9C=ED=97=98=20=EC=9E=85=EB=A0=A5(=ED=91=9C=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=ED=96=89=20=EC=97=B4=EC=87=A0=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=A1=B0=EA=B0=81)=EC=9D=84=20=EA=B0=92?= =?UTF-8?q?=20=EC=8B=9D=20=EC=95=84=EB=9E=98=20=EB=8B=A8=EC=B6=94=EB=A1=9C?= =?UTF-8?q?=20=C2=B7=20=EB=88=84=EB=A5=B4=EB=A9=B4=20=EA=B7=B8=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=EA=B3=A0=EB=A5=B4=EA=B8=B0/=EA=B8=80=20=EC=B9=B8?= =?UTF-8?q?=20=C2=B7=20=EB=B0=94=EA=BE=B8=EB=A9=B4=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=9E=85=EB=A0=A5=20=EA=B3=A0=EC=B9=A8=20?= =?UTF-8?q?+=20=EB=8B=A4=EC=8B=9C=20=EC=85=88=20=C2=B7=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B8=20=C2=B7=20=EC=BA=90=EC=8B=9C=20=EC=95=88=20=EB=B0=94?= =?UTF-8?q?=EB=80=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Formula.ts usedInputs(순수) · inputChip(부품 상자 단추 · 고르기 · 입력 칸) - Detail → Formula 에 입력 · 값 · onInput 넘김 · LogicLab syncLeft(loud) = 흐름 예시값도 같은 길 · 줄 수 그대로(627) - Logic_Api TextPiece 에 이름 조각 값 칸 - 새 지킴 시험 test_714_3_formula_inputs Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_UI_LogicLab.ts | 6 +- .../M01_MasterData_UI_LogicLab_Detail.ts | 5 +- .../M01_MasterData_UI_LogicLab_Formula.css | 12 +++ .../M01_MasterData_UI_LogicLab_Formula.ts | 100 +++++++++++++++++- .../M01_MasterData_UI_LogicLab_Text.ts | 4 + M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 2 + resources/tester/test_714_3_formula_inputs.py | 62 +++++++++++ 7 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 resources/tester/test_714_3_formula_inputs.py diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 7af76d2a3..e32601f29 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -99,7 +99,6 @@ const calcRow = (row: LogicRow | null): LogicRow | null => { const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); - const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; function loadDrafts(): Record { @@ -257,6 +256,7 @@ export async function mountM01LogicLab( values: current.values, ownHost: current.ownHost, onChange: touch, + onInput: (name, v) => syncLeft(current, { [name]: v }, true), calcHost: calc, flowHost: flowOf(current.row) ? current.flowHost : undefined, admin, @@ -332,11 +332,11 @@ export async function mountM01LogicLab( }; /** 흐름 예시값 → 왼쪽 시험 계산 입력(이름이 있는 칸만) · 바뀌면 왼쪽을 다시 그리고 셈 */ - const syncLeft = (o: Opened, ex: Record): void => { + const syncLeft = (o: Opened, ex: Record, loud = false): void => { const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름)); const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v); if (!changed.length) return; - quiet = true; + quiet = !loud; // 텍스트 수식 고침(loud)은 흐름 예시값에도 — 시험 계산 칸 고침과 같은 길 for (const [k, v] of changed) o.values[k] = v; quiet = false; drawCalc(); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index b67d95ca6..ed14e9e49 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -82,6 +82,8 @@ export interface DetailContext { flowHost?: HTMLElement; /** 시스템 관리자 — 기본 로직 안내 글이 다름(흐름도에서 고침 / 보기만) */ admin?: boolean; + /** 714-3 텍스트 수식 입력 단추 — 시험 계산 입력 고침 + 다시 셈 */ + onInput?: (name: string, value: string) => void; } type Group = "labor" | "material" | "cost" | "other"; @@ -511,7 +513,8 @@ function hoBox(ctx: DetailContext): HTMLElement { /** 상세 화면 넷을 `host` 에 쌓음 */ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { const formula = el("div", { className: "m01lab__formula" }); - buildFormula(formula, { text: ctx.text }); + const fx = { inputs: ctx.row.입력, values: ctx.values, onInput: ctx.onInput }; + buildFormula(formula, { text: ctx.text, ...fx }); // 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림 const active = document.activeElement; const kept = diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css index 3a69e8091..0b09d07e7 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css @@ -37,6 +37,18 @@ padding: var(--spacing-4); } +/* 714-3 줄이 쓰는 입력 — 누르면 그 자리 고르기/글 칸 */ +.m01lab-fx__vars { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); + margin-top: var(--spacing-4); +} + +.m01lab-fx__var .ui-field { + min-width: 6rem; +} + @media (max-width: 600px) { .m01lab-fx, .m01lab-fx__head, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index bec7c9a5d..d14afae32 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -3,10 +3,13 @@ * 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음) * 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이 * 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게 + * 714-3 — 줄이 쓰는 입력(표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 → + * 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8) * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; -import type { TextAnswer } from "./M01_MasterData_UI_Logic_Api"; +import { createButton, el } from "@ui/ui_template_elements"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import type { LogicInput, TextAnswer, TextLine } from "./M01_MasterData_UI_Logic_Api"; import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; @@ -17,6 +20,77 @@ export type PreviewTextAnswer = TextAnswer & { 계산됨?: boolean }; export interface FormulaContext { text: PreviewTextAnswer | null; + /** 714-3 — 시험 계산 입력 · 지금 값 · 고친 뒤 할 일(값 넣고 다시 셈) · 없으면 단추 없음 */ + inputs?: LogicInput[]; + values?: Record; + onInput?: (name: string, value: string) => void; +} + +/* ── 순수 셈: 줄이 쓰는 입력 ── */ +/** 줄의 조각에서 시험 입력 이름 → 그 줄이 쓴 값 — 표 찾기 행 열쇠 · 이름 조각(`값` 이 붙은 이름) · 차례 = 처음 나온 차례 */ +export function usedInputs( + line: Pick, + names: string[], +): [string, string][] { + const out = new Map(); + for (const p of [...(line.조각 ?? []), ...(line.요소조각 ?? [])]) { + const row = p.kind === "표" ? p.참조?.행 : null; + if (row && typeof row === "object") + for (const [k, v] of Object.entries(row)) + if (names.includes(k) && !out.has(k)) out.set(k, String(v)); + if (p.kind === "값" && p.값 !== undefined && names.includes(p.글) && !out.has(p.글)) + out.set(p.글, String(p.값)); + } + return [...out]; +} +/* ── 순수 셈 끝 ── */ + +/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` */ +function inputChip(spec: LogicInput, shown: string, onInput: (v: string) => void): HTMLElement { + const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } }); + const chip = createButton({ + label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`, + variant: "ghost", + size: 24, + onClick: (ev) => { + ev.stopPropagation(); // 줄 누름(밝히기 고정)과 안 겹치게 + let over = false; // Enter 뒤 blur · 고름 뒤 blur 가 두 번 부르지 않게 + const done = (v: string | null): void => { + if (over) return; + over = true; + if (v !== null && v.trim() !== "" && v !== shown) onInput(v.trim()); + else wrap.replaceChildren(chip); + }; + let field: HTMLElement; + if (spec.고르기?.length) { + const pick = createSelectField({ + options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), + value: shown, + size: 24, + ariaLabel: spec.이름, + onChange: (v) => done(v), + }); + pick.select.addEventListener("blur", () => done(null)); + field = pick.root; + } else { + const box = createInputField({ value: shown, size: 24 }); + box.input.setAttribute("aria-label", spec.이름); + box.input.addEventListener("keydown", (k) => { + if (k.isComposing) return; + if (k.key === "Enter") done(box.input.value); + if (k.key === "Escape") (k.preventDefault(), done(null)); + }); + box.input.addEventListener("blur", () => done(box.input.value)); + field = box.root; + } + field.addEventListener("click", (e) => e.stopPropagation()); + wrap.replaceChildren(field); + field.querySelector("select, input")?.focus(); + }, + }); + chip.title = tl("Formula_Var"); + wrap.append(chip); + return wrap; } const muted = (text: string): HTMLElement => el("p", { className: "ui-text ui-text--muted", text }); @@ -47,6 +121,27 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { ], }); const rows: HTMLElement[] = []; + const specs = ctx.onInput ? (ctx.inputs ?? []) : []; + const vars = (line: TextLine): HTMLElement[] => { + const used = usedInputs( + line, + specs.map((x) => x.이름), + ); + if (!used.length || !ctx.onInput) return []; + const onInput = ctx.onInput; + return [ + el("div", { + className: "m01lab-fx__vars", + children: used.map(([name, was]) => + inputChip( + specs.find((x) => x.이름 === name)!, + ctx.values?.[name] || was, + (v) => onInput(name, v), + ), + ), + }), + ]; + }; const calculated = text.계산됨 !== false; // [계산] 뒤 응답은 표식이 안 붙음 — 미리보기만 false const light = (pair: string, on: boolean): void => rows.forEach((r) => r.dataset.pair === pair && r.classList.toggle("is-on", on)); @@ -64,6 +159,7 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { children: calculated ? [ el("div", { text: `${line.이름} : ${line.글}` }), + ...vars(line), ...(line.까닭 ? [ el("div", { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 8ecee9c54..52b4329de 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -61,6 +61,10 @@ const TEXT = { Modal_Close: ["닫기", "Close"], Formula_Left: ["변수 이름 식", "Formula with names"], Formula_Right: ["값이 들어간 식", "Formula with values"], + Formula_Var: [ + "누르면 시험 계산 입력을 고침 · 다시 셈(정본 안 바뀜)", + "Click to change the trial input and recalculate (source unchanged)", + ], Modal_Sum: ["한 줄 풀이", "In one line"], Pill_Hint: ["쓰인 자료 — 누르면 펼침", "Data used — tap to open"], Modal_UnitPrice: ["단가", "Unit price"], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 379832f60..6890e0432 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -124,6 +124,8 @@ export type CalcAnswer = export interface TextPiece { kind: "요소" | "표" | "로직" | "값" | "기호" | "글"; 글: string; + /** 이름 조각(`kind: "값"` · 글 = 이름)이 가진 그 값 — 714-3 입력 단추 */ + 값?: string | number; 참조?: { 테이블?: string; 키: string; diff --git a/resources/tester/test_714_3_formula_inputs.py b/resources/tester/test_714_3_formula_inputs.py new file mode 100644 index 000000000..8b527c437 --- /dev/null +++ b/resources/tester/test_714_3_formula_inputs.py @@ -0,0 +1,62 @@ +"""714-3 텍스트 수식 입력 단추 지킴 시험 — 줄이 쓰는 입력 고르기(순수 함수 · node) + 이음 글. + +`M01_MasterData_UI_LogicLab_Formula.ts` 「순수 셈」 구간만 떼어 돌림(화면 · 서버 없음). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +M01 = Path(__file__).resolve().parents[2] / "M01_MasterData" +FX = M01 / "M01_MasterData_UI_LogicLab_Formula.ts" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + + +def _run(body: str) -> object: + text = FX.read_text(encoding="utf-8") + pure = text[text.index("/* ── 순수 셈: 줄이 쓰는 입력 ── */") : text.index("/* ── 순수 셈 끝 ── */")] + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", pure + "\n" + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +def test_표_행_열쇠와_이름_조각(): + got = _run( + """ +const line = { + 조각: [ + { kind: "표", 글: "25 · 깬잡석", 참조: { 키: "QF1", 행: { 뒷길이: 25, 돌: "깬잡석", 줄번호: 3 } } }, + { kind: "기호", 글: "*" }, + { kind: "값", 글: "두께", 값: "20" }, + { kind: "값", 글: "0.009" }, + { kind: "값", 글: "뒷길이", 값: "99" }, + ], + 요소조각: [{ kind: "표", 글: "x", 참조: { 키: "QF2", 행: 4 } }], +}; +console.log(JSON.stringify(usedInputs(line, ["뒷길이", "돌", "두께", "쌓기"]))); +""" + ) + # 처음 나온 값 · 입력 아닌 열쇠(줄번호) · 이름 없는 수(0.009) · 행 번호(4) 는 뺌 + assert got == [["뒷길이", "25"], ["돌", "깬잡석"], ["두께", "20"]] + + +def test_빈_줄(): + assert _run('console.log(JSON.stringify(usedInputs({}, ["a"])))') == [] + + +def test_이음_시험_입력만_고침(): + lab = (M01 / "M01_MasterData_UI_LogicLab.ts").read_text(encoding="utf-8") + detail = (M01 / "M01_MasterData_UI_LogicLab_Detail.ts").read_text(encoding="utf-8") + # 텍스트 수식 고침 = 시험 계산 입력 + 다시 셈(흐름 예시값과 같은 길) · 캐시(drafts)에 안 들어감 + assert "onInput: (name, v) => syncLeft(current, { [name]: v }, true)" in lab + assert "onInput: ctx.onInput" in detail + fx = FX.read_text(encoding="utf-8") + assert "touch" not in fx and "drafts" not in fx From c0851ee63b3555faea369273299bf6446ecd49fe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:59:51 +0900 Subject: [PATCH 10/30] =?UTF-8?q?feat(717-2a):=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EB=AA=A9=EB=A1=9D=20=EC=A4=84=20=EB=8D=A7?= =?UTF-8?q?=EC=B9=B8=20=E2=80=94=20=EB=B0=94=ED=83=95(=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=ED=82=A4=20=C2=B7=20=EC=9D=B4=EB=A6=84=20|=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20SK=20|=20=EC=97=86=EC=9D=8C)=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=9C=20=EC=A4=84=20=EC=88=98=20=C2=B7=20=EC=93=B0=EB=8A=94?= =?UTF-8?q?=20=EA=B3=B3=20=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Labor_Ref.summary sangeun 갈래만(브레인 답 A · 그 함수 밖 안 만짐) - 지킴 시험 test_717_2a_sangeun_list 2(임시 storage) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Labor_Ref.py | 13 +++++ resources/tester/test_717_2a_sangeun_list.py | 54 ++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 resources/tester/test_717_2a_sangeun_list.py diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index ae47ac4be..f8ede9600 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -299,6 +299,19 @@ def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) -> layer, owner = where(folder) base = expand({"층": layer, "주인": owner, "키": key}).get("기준") or {} out["기준금액"] = float(base["계"]) if "계" in base else None + # 717-2a 목록 열 — 바탕(로직 「키 이름」 | 참조 키 | None) · 고친 줄 수 · 쓰는 곳 수 + if kind == "sangeun": + top, (layer, owner) = doc.get("바탕") or {}, where(folder) + logic = top.get("로직") + out["바탕"] = logic or (top.get("참조") or {}).get("키") + if logic and expand is not None: + try: + out["바탕"] = f"{logic} {expand.whole.logic(logic).get('이름') or ''}".strip() + except Exception: # noqa: BLE001 — 없는 로직 = 키만(셈 때 까닭) + pass + out["고친줄수"] = sum(len(doc.get(k) or ()) for k in ("덮개", "더한줄", "뺀줄")) + # ponytail: SK 마다 모든 층 훑음(users) — 목록이 느려지면 층 훑기 한 번 셈으로 + out["쓰는곳수"] = len(users({"층": layer, "주인": owner, "키": key})) return out diff --git a/resources/tester/test_717_2a_sangeun_list.py b/resources/tester/test_717_2a_sangeun_list.py new file mode 100644 index 000000000..ac19b209a --- /dev/null +++ b/resources/tester/test_717_2a_sangeun_list.py @@ -0,0 +1,54 @@ +"""M02 단가산출 칸 목록 줄(PLAN 717-2a · `M02_Labor_Ref.summary` sangeun 갈래) — 임시 storage. + +바탕 = 로직 「키 이름」 · 참조 = SK 키 · 없음 = None · 고친 줄 수 = 덮개 + 더한줄 + 뺀줄 · +쓰는 곳 수 = 그 SK 를 바탕 · 더한줄로 담은 문서 수 · 일위대가(precombo) 줄엔 안 붙음. +""" + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete.M02_Labor_Expand import Expand +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" + + +def _sk(folder, **doc): + with store.at(folder): + return refs.create("sangeun", {"이름": "목록 시험", "단위": "㎡", "더한줄": [], **doc})[ + "이름" + ] + + +def _row(folder, kind, key): + return refs.summary(folder, kind, key, Expand(m01.loaded()[1])) + + +def test_sangeun_row_base_edits_users(world): + mine = world["personal"] + a = _sk(mine, 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}}, 뺀줄=["pct:x#0"]) + name = m01.loaded()[1].logic(PCT)["이름"] + row = _row(mine, "sangeun", a) + assert row["바탕"] == f"{PCT} {name}".strip() + assert row["고친줄수"] == 2 and row["쓰는곳수"] == 0 + assert row["제목"] == "목록 시험" and row["단위"] == "㎡" + # 바탕 참조 SK · 더한줄 하나 → 그 SK 쓰는 곳 1 + b = _sk( + mine, + 바탕={"참조": {**MINE, "키": a}}, + 더한줄=[{"종류": "인력", "요소": "LB000036", "이름": "보통인부", "단위": "인", "수량": 1}], + ) + row_b = _row(mine, "sangeun", b) + assert row_b["바탕"] == a and row_b["고친줄수"] == 1 + assert _row(mine, "sangeun", a)["쓰는곳수"] == 1 + # 바탕 없음 + c = _sk(mine) + row_c = _row(mine, "sangeun", c) + assert row_c["바탕"] is None and row_c["고친줄수"] == 0 + + +def test_precombo_row_has_no_sangeun_fields(world): + with store.at(world["personal"]): + sc = refs.create("precombo", {"이름": "조합", "담은로직": []})["이름"] + row = _row(world["personal"], "precombo", sc) + assert not {"바탕", "고친줄수", "쓰는곳수"} & set(row) From a64e3ca398668e45f358e82374180d631cf86fae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:00:03 +0900 Subject: [PATCH 11/30] =?UTF-8?q?feat(717-2a):=20M02=20=EC=99=BC=EC=AA=BD?= =?UTF-8?q?=20=EB=A7=A8=20=EC=9C=84=20=E3=80=8C=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=E3=80=8D=20=EC=B9=B8=20=E2=80=94=20=EC=B8=B5=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=EB=94=B0=EB=A1=9C(=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8)=20=C2=B7=20=ED=8F=B4=EB=8D=94=20=EB=82=98?= =?UTF-8?q?=EB=AC=B4=20=C2=B7=20=EB=AA=A9=EB=A1=9D=ED=91=9C=20=C2=B7=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=9E=90=EB=A6=AC=20=C2=B7=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 UI_Side_Sangeun.ts — 서고 칸(716-4) kind sangeun · 목록표 고름 sangeunList - 새 Sangeun_View.ts — 이름 · 단위 · 바탕 · 덮개 · 더한 줄 · 뺀 줄 · 열린 입력 요약 + 상세 자리표(717-2b 가 갈음) - UI_Main 종류 sangeun 갈래 · 목록표 조건에 sangeun · Library_List 열 표(단가산출 = 바탕 · 고친 줄 · 쓰는 곳) · sangeunBlank(단위 식 · 바탕 없음) - UI_Page 단가산출 층 상태 sk · UI_Side 맨 위 칸 · reveal 「단가산출」 · Layer open 「단가산출」 · Api Kind sangeun - m_settings_nav 대시보드 마스터 템플릿 묶음 「단가산출」 줄(?open=단가산출) · locale_m2 키 - 낡은 글 대조 시험 넷 고침(197_1 · b01_settings_cards · m02_layer_screen · m02_precombo_scope) — 대시보드 줄 하나 늘고 OPENS · reveal · 목록 열 표 · 칸 차례가 바뀜 Co-Authored-By: Claude Opus 5.5 --- A00_Common/m_settings_nav.ts | 1 + .../M02_MasterTemplete_Api_Fetch.ts | 5 +- .../M02_MasterTemplete_Layer.ts | 6 +- .../M02_MasterTemplete_Library_List.ts | 82 ++++++++++++------- .../M02_MasterTemplete_Sangeun_View.ts | 61 ++++++++++++++ .../M02_MasterTemplete_UI_Main.ts | 8 +- .../M02_MasterTemplete_UI_Page.ts | 12 +++ .../M02_MasterTemplete_UI_Side.ts | 47 +++++++++-- .../M02_MasterTemplete_UI_Side_Sangeun.ts | 41 ++++++++++ .../test_197_1_dashboard_option_card.py | 15 ++-- resources/tester/test_b01_settings_cards.py | 16 ++-- resources/tester/test_m02_layer_screen.py | 3 +- resources/tester/test_m02_precombo_scope.py | 3 +- ui_template/ui_template_locale_m2.ts | 16 ++++ 14 files changed, 259 insertions(+), 57 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_UI_Side_Sangeun.ts diff --git a/A00_Common/m_settings_nav.ts b/A00_Common/m_settings_nav.ts index fff8b3264..8c7cbeef3 100644 --- a/A00_Common/m_settings_nav.ts +++ b/A00_Common/m_settings_nav.ts @@ -65,6 +65,7 @@ export function settingLinks(user: SettingUser, companyId: number | string | nul m01("로직", L("B01_Dashboard_CardLogic"), read, "로직"), m01("옵션", L("B01_Dashboard_CardStructOptionDef"), desc, "옵션"), m01("별칭", L("B01_Dashboard_CardAliasLedger"), desc, "별칭"), + m02(L("B01_Dashboard_CardSangeun"), "단가산출", comboRoot("master"), desc), m02(L("B01_Dashboard_CardPrecombo"), "일위대가조합", comboRoot("master"), desc), m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc), m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 205097490..9d608315a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -10,8 +10,9 @@ export type Layer = "system" | "company" | "personal" | "project"; /** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure · * 「면적산출」 = area(구조물집계표 열마다 한 통합문서) · * 「일위대가」 = labor(열마다 문서 · 39장) · - * 「사전조합」 = precombo(일위대가 사전조합 서고 · 50-3 · 키가 이름 · 옛 단가승격 promote 는 134-2 에서 뺌) */ -export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "precombo"; + * 「사전조합」 = precombo(일위대가 사전조합 서고 · 50-3 · 키가 이름 · 옛 단가승격 promote 는 134-2 에서 뺌) · + * 「단가산출」 = sangeun(SK- 서고 · 715-2 · 717-2a) */ +export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "precombo" | "sangeun"; export interface TemplateInfo { 종류: Kind; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 242e0fa69..721f67c2a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Layer.ts * M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시 - * `#/m02-master-template?open=구조물조합|옵션|도면&root=master|company|personal&owner=…&company=…&pre=…` + * `#/m02-master-template?open=구조물조합|옵션|도면|일위대가조합|단가산출&root=master|company|personal&owner=…&company=…&pre=…` * (옛 `?layer=` 도 root 로 읽음 · `pre` = 일위대가 사전조합 층 · 134-3) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) · * 회사 없음 안내 · 회사용 읽기만일 때 [개인용 복사] 막대. * ========================================================================== */ @@ -16,7 +16,7 @@ import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_A export type ManageLayer = "system" | "company" | "personal"; /** 해시 · 화면의 뿌리 이름 — 서버 층 이름은 `layerOfRoot` */ export type Root = "master" | "company" | "personal"; -export type OpenTarget = "구조물조합" | "옵션" | "도면" | "일위대가조합"; +export type OpenTarget = "구조물조합" | "옵션" | "도면" | "일위대가조합" | "단가산출"; export interface Entry { root: Root; @@ -29,7 +29,7 @@ export interface Entry { pre: Root | null; } -const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합"]; +const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합", "단가산출"]; const ROOTS: Root[] = ["master", "company", "personal"]; export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts index 7a1be7e48..7a3446ca4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Library_List.ts - * 일위대가 사전조합 서고 목록표(51-4) — 좌측 사전조합 층 단추를 누르면 상세 칸에 뜸. - * 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세. + * 서고 목록표(51-4 · 717-2a) — 좌측 일위대가 · 단가산출 층 단추를 누르면 상세 칸에 뜸. + * 표 = 고름 · 키 · 구분 · 상세구분(51-12) · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세 · + * 단가산출 = 구분 둘 대신 바탕(M01 로직 · 없으면 「바탕 없음」) · 고친 줄 수 · 쓰는 곳 수(서버 `summary`). * 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로). * 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import. * ========================================================================== */ @@ -28,10 +29,10 @@ import { saveTemplate, type Layer, type LibraryBody, + type LibraryKind, type TemplateInfo, } from "./M02_MasterTemplete_Api_Fetch"; import { tc } from "./M02_MasterTemplete_Labor_Text"; -import type { PrecomboKind } from "./M02_MasterTemplete_UI_Side_Precombo"; /** 목록 줄 — 서버가 문서 `이름`(제목) · 단위 · 1 단위 기준 계를 실어 보냄 */ type Row = TemplateInfo & { @@ -40,13 +41,16 @@ type Row = TemplateInfo & { 기준금액?: number | null; 구분?: string | null; 상세구분?: string | null; + 바탕?: string | null; + 고친줄수?: number; + 쓰는곳수?: number; }; export interface LibraryListOptions { layer: Layer; /** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) */ owner: string | null; - kind: PrecomboKind; + kind: LibraryKind; canEdit: boolean; /** 줄 누름 · 생성 · 복사 뒤 — 그 문서 상세 */ open: (key: string, label: string) => void; @@ -60,6 +64,36 @@ const money = (v: number | null | undefined): string => v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); export const libraryLabel = (row: Row): string => (row.제목 ? `${row.이름} ${row.제목}` : row.이름); +/** 새 단가산출 몸(717-2a) — 바탕 없음 · 빈 더한줄 · 단위 「식」(서버 필수 · 상세에서 고침 · 717-2b) */ +export const sangeunBlank = (name: string): Record => ({ + 이름: name, + 단위: "식", + 바탕: null, + 더한줄: [], +}); +/** 표 열 — 머리 글 · 줄 값(일위대가 = 구분 둘 · 단가산출 = 바탕 · 고친 줄 · 쓰는 곳) */ +const COLUMNS: Record [string, (row: Row) => string][]> = { + precombo: () => [ + [tc("Lib_Key"), (r) => r.이름], + [tc("Sub"), (r) => r.구분 || tc("Lib_Unsorted")], // 빈 구분 = 미분류(149-2 · 나무와 같은 말) + [tc("Detail"), (r) => r.상세구분 ?? ""], + [tc("Lib_Name"), (r) => r.제목 ?? ""], + [tc("Lib_Unit"), (r) => r.단위 ?? ""], + [tc("Lib_Base"), (r) => money(r.기준금액)], + [tc("Lib_Date"), (r) => r.수정일], + ], + sangeun: () => [ + [tc("Lib_Key"), (r) => r.이름], + [tc("Lib_Name"), (r) => r.제목 ?? ""], + [tc("Lib_Unit"), (r) => r.단위 ?? ""], + [L("M02_SangeunBase"), (r) => r.바탕 || L("M02_SangeunNoBase")], + [L("M02_SangeunEdited"), (r) => String(r.고친줄수 ?? 0)], + [L("M02_SangeunUsers"), (r) => String(r.쓰는곳수 ?? 0)], + [tc("Lib_Base"), (r) => money(r.기준금액)], + [tc("Lib_Date"), (r) => r.수정일], + ], +}; + /** 이름 창(생성 · 복사 · 사전조합 나무 폴더 메뉴 149-1) — [생성] = `onOk` 성공하면 닫힘 */ export const nameDialog = ( title: string, @@ -101,6 +135,7 @@ export function mountLibraryList( tools: HTMLElement, opt: LibraryListOptions, ): { getDoc: () => null; destroy: () => void } { + const columns = COLUMNS[opt.kind](); let rows: Row[] = []; let picked: string | null = null; let page = 1; @@ -144,15 +179,9 @@ export function mountLibraryList( attrs: { "data-lib-key": row.이름 }, children: [ pick, - ...[ - row.이름, - row.구분 || tc("Lib_Unsorted"), // 빈 구분 = 미분류(149-2 · 나무와 같은 말) - row.상세구분 ?? "", - row.제목 ?? "", - row.단위 ?? "", - money(row.기준금액), - row.수정일, - ].map((v) => el("td", { text: v, attrs: { title: v } })), + ...columns + .map(([, value]) => value(row)) + .map((v) => el("td", { text: v, attrs: { title: v } })), ], }); tr.addEventListener("click", () => opt.open(row.이름, libraryLabel(row))); @@ -160,7 +189,12 @@ export function mountLibraryList( }) : [ el("tr", { - children: [el("td", { text: L("M02_NoTemplates"), attrs: { colspan: "8" } })], + children: [ + el("td", { + text: L("M02_NoTemplates"), + attrs: { colspan: String(columns.length + 1) }, + }), + ], }), ]), ); @@ -191,7 +225,10 @@ export function mountLibraryList( /** 사전조합 = 이름 · 구분 · 상세구분 빈 값(미분류 · 134-5 · 상세에서 고름) · 빈 묶음 */ btnNew.addEventListener("click", () => { nameDialog(L("M02_NewTitle"), "", async (name) => { - const 문서 = { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }; + const 문서 = + opt.kind === "sangeun" + ? sangeunBlank(name) + : { 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] }; await create({ 문서 }, name); }); }); @@ -244,16 +281,7 @@ export function mountLibraryList( page = 1; draw(); }); - const heads = [ - "", - "Lib_Key", - "Sub", - "Detail", - "Lib_Name", - "Lib_Unit", - "Lib_Base", - "Lib_Date", - ] as const; + const heads = ["", ...columns.map(([head]) => head)]; const gridBox = el("div", { className: "ui-table-wrap m01-master__grid-wrap", attrs: { "data-lib-list": opt.kind }, @@ -262,9 +290,7 @@ export function mountLibraryList( className: "ui-table m01-master__grid", children: [ el("thead", { - children: [ - el("tr", { children: heads.map((h) => el("th", { text: h ? tc(h) : "" })) }), - ], + children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })], }), body, ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts new file mode 100644 index 000000000..da3307701 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts @@ -0,0 +1,61 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_View.ts + * 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄). + * 지금 = 이름 · 단위 · 바탕 · 고친 줄 요약(덮개 · 더한 줄 · 뺀 줄 · 열린 입력) + 상세 자리표. + * 717-2b(sub2) = 자리표 줄을 `mountSangeunDetail(slot, doc, opt)`(`Sangeun_Detail.ts`)로 갈음 · + * [저장]은 그 손잡이 `getDoc` · 고침 알림은 `opt.onChanged`. 지금 [저장] = 읽은 문서 그대로. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { createEmpty } from "@ui/ui_template_empty"; +import { createKv } from "@ui/ui_template_kv"; +import { t as L } from "@ui/ui_template_locale"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import type { Selection } from "./M02_MasterTemplete_UI_Main"; + +/** SK 문서 중 이 자리가 읽는 칸(꼴 = 서버 `M02_Sangeun_Doc`) */ +export interface SangeunDoc { + 키?: string; + 이름?: string; + 단위?: string; + 바탕?: { 로직?: string; 참조?: { 키?: string } } | null; + 덮개?: Record; + 더한줄?: unknown[]; + 뺀줄?: unknown[]; + 열린입력?: string[]; +} + +/** 고름(층 · 주인 · SK 키 = name) + 읽기만 · 고침 알림 */ +export type SangeunViewOptions = Pick & { + readOnly: boolean; + onChanged: (dirty: boolean) => void; +}; + +/** 바탕 한 줄 — 로직 키 · 참조 SK 키 · 없으면 「바탕 없음」 */ +export const baseText = (doc: SangeunDoc): string => + doc.바탕?.로직 ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"); + +export function mountSangeunView( + host: HTMLElement, + doc: unknown, + _opt: SangeunViewOptions, +): { getDoc: () => unknown; destroy: () => void } { + const sk = (doc ?? {}) as SangeunDoc; + const count = (v: unknown[] | Record | undefined): number => + Array.isArray(v) ? v.length : Object.keys(v ?? {}).length; + const head = createKv([ + { label: L("M02_Name"), value: sk.이름 ?? "" }, + { label: tc("Lib_Unit"), value: sk.단위 ?? "" }, + { label: L("M02_SangeunBase"), value: baseText(sk) }, + { label: L("M02_SangeunCover"), value: count(sk.덮개) }, + { label: L("M02_SangeunAdded"), value: count(sk.더한줄) }, + { label: L("M02_SangeunRemoved"), value: count(sk.뺀줄) }, + { label: L("M02_SangeunOpened"), value: (sk.열린입력 ?? []).join(" · ") || null }, + ]); + const slot = el("div", { + attrs: { "data-sangeun-detail": sk.키 ?? "" }, + children: [createEmpty({ text: L("M02_SangeunDetailSoon") })], + }); + host.replaceChildren(el("div", { attrs: { "data-sangeun-view": "" }, children: [head, slot] })); + return { getDoc: () => doc, destroy: () => host.replaceChildren() }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index a0a616c8f..8a9182d22 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -357,6 +357,12 @@ export function createMain( const readOnly = !writable(at); const mine = seq; // 모듈 · 집계표 번호를 기다리는 사이 다른 화면(옵션 정의 등)이 열렸으면 그만둠 host.replaceChildren(); + if (at.kind === "sangeun") { + const { mountSangeunView } = await import("./M02_MasterTemplete_Sangeun_View"); // 717-2a + const onChanged = (d: boolean): boolean => (dirty = d && !readOnly); + if (mine === seq) editor = mountSangeunView(host, doc, { ...at, readOnly, onChanged }); + return; + } if (at.kind === "table") { const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>( SHEET_MODS, @@ -503,7 +509,7 @@ export function createMain( return empty(""); } title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a - if (isPrecombo(next.kind) && !next.name) { + if ((isPrecombo(next.kind) || next.kind === "sangeun") && !next.name) { // 서고 목록표(51-4) — 이름 "" · 저장할 것 없음 · 줄 누름 = 그 문서 상세 save.hidden = true; const { mountLibraryList } = await import("./M02_MasterTemplete_Library_List"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 436262468..4ff1e2265 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -53,6 +53,7 @@ import { canEdit, createMain, newItemButton, type Selection } from "./M02_Master import { buildSide } from "./M02_MasterTemplete_UI_Side"; import { ITEM_BUILT } from "./M02_MasterTemplete_Hopyo_ToItem"; import { precomboList, type PrecomboScope } from "./M02_MasterTemplete_UI_Side_Precombo"; +import { sangeunList } from "./M02_MasterTemplete_UI_Side_Sangeun"; import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick"; import "./M02_MasterTemplete_UI_Style.css"; @@ -248,6 +249,16 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.confirmLeave().then((ok) => ok && void main.open(precomboList(pre))), }); + // 단가산출 층(717-2a) — 일위대가 층과 따로 · 기본 개인(못 가면 마스터) · 주소에 안 실음 + let sk = preScope("personal"); + const skBar = (): HTMLElement => + createLayerBar({ + user, + active: { root: sk.root, owner: sk.owner }, + company, + go: (next) => ((sk = preScope(next)), void side.refreshSangeun()), + reselect: () => void main.confirmLeave().then((ok) => ok && void main.open(sangeunList(sk))), + }); const side = buildSide({ layer, isAdmin, @@ -264,6 +275,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise share.precomboMenu(pre.root === "personal" && !pre.owner, key, name), }, + sangeun: { scope: () => sk, bar: skBar }, }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 7f1d5b88d..5a86c8d5a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,8 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts - * 마스터 템플릿 좌측 패널 차례(716-3) — 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` · + * 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` · * 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 · - * `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서. 「단가산출」 칸은 맨 위(717-2a). + * `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서. * 구조물 옵션 정의는 M01 로 옮김(716-1). * 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2). @@ -36,6 +36,7 @@ import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check"; import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo"; +import { buildSangeunSection, type SangeunOptions } from "./M02_MasterTemplete_UI_Side_Sangeun"; import { createEmpty } from "@ui/ui_template_empty"; import { createDivider } from "@ui/ui_template_divider"; @@ -67,6 +68,8 @@ export interface SideOptions { tree?: HTMLElement[]; /** 「일위대가」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */ precombo: Pick; + /** 「단가산출」 층(717-2a · 일위대가 층과 따로 · 층 단추는 페이지가 만듦) */ + sangeun: Pick; } export interface SideHandle { @@ -75,9 +78,11 @@ export interface SideHandle { /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ paint: () => void; /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침 · 도면은 시스템 층에만 · 옵션은 M01 로 옮겨 아무 일 없음 */ - reveal: (target: "옵션" | "도면" | "일위대가조합") => void; + reveal: (target: "옵션" | "도면" | "일위대가조합" | "단가산출") => void; /** 사전조합 층이 바뀜(134-3) — 그 컨테이너만 다시 그림 */ refreshPrecombo: () => Promise; + /** 단가산출 층이 바뀜(717-2a) — 그 컨테이너만 다시 그림 */ + refreshSangeun: () => Promise; } /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ @@ -133,7 +138,7 @@ export function buildSide(opt: SideOptions): SideHandle { openOnExpand(summary, async () => { if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null); }); - // 일위대가 서고(50-3 · 134-3) — 맨 위 칸 · 층은 페이지 층과 따로 + // 일위대가 서고(50-3 · 134-3) — 단가산출 다음 칸 · 층은 페이지 층과 따로 const precombo = buildPrecomboSection({ ...opt.precombo, confirmLeave: opt.confirmLeave, @@ -141,10 +146,18 @@ export function buildSide(opt: SideOptions): SideHandle { getOpen: opt.getOpen, }); const precomboSec = section(L("M02_PrecomboTitle"), precombo.body); + // 단가산출 서고(717-2a) — 맨 위 칸 · 층은 일위대가 층과 따로 + const sangeun = buildSangeunSection({ + ...opt.sangeun, + confirmLeave: opt.confirmLeave, + onOpen: opt.onOpen, + getOpen: opt.getOpen, + }); + const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body); // 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝 const onBuilt = (): void => precombo.body.isConnected - ? void precombo.refresh() + ? void Promise.all([precombo.refresh(), sangeun.refresh()]) : document.removeEventListener(HOPYO_BUILT, onBuilt); document.addEventListener(HOPYO_BUILT, onBuilt); // 도면 양식은 시스템 층(마스터 템플릿)에만 @@ -171,6 +184,7 @@ export function buildSide(opt: SideOptions): SideHandle { const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", children: [ + sangeunSec, precomboSec, summary, createDivider(), @@ -229,6 +243,7 @@ export function buildSide(opt: SideOptions): SideHandle { * (양식이 열려 있으면 실무문서 고름은 지움) */ function paint(): void { paintList(); + sangeun.paint(); precombo.paint(); hopyo.paint(); paintButtons(); @@ -253,7 +268,7 @@ export function buildSide(opt: SideOptions): SideHandle { if (mine !== seq) return; paintList(); paintButtons(); - await precombo.refresh(); + await Promise.all([precombo.refresh(), sangeun.refresh()]); } /* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */ @@ -333,11 +348,25 @@ export function buildSide(opt: SideOptions): SideHandle { } } - const reveal = (target: "옵션" | "도면" | "일위대가조합"): void => { - const sec = target === "일위대가조합" ? precomboSec : target === "도면" ? drawing : null; + const reveal = (target: "옵션" | "도면" | "일위대가조합" | "단가산출"): void => { + const sec = + target === "단가산출" + ? sangeunSec + : target === "일위대가조합" + ? precomboSec + : target === "도면" + ? drawing + : null; sec?.classList.remove("is-collapsed"); }; void refresh(); - return { root, refresh, paint, reveal, refreshPrecombo: precombo.refresh }; + return { + root, + refresh, + paint, + reveal, + refreshPrecombo: precombo.refresh, + refreshSangeun: sangeun.refresh, + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Sangeun.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Sangeun.ts new file mode 100644 index 000000000..724755454 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Sangeun.ts @@ -0,0 +1,41 @@ +/* ============================================================================= + * M02_MasterTemplete_UI_Side_Sangeun.ts + * 좌측 맨 위 「단가산출」 칸(717-2a · 요구서 4장 12) — 서고 칸(`UI_Side_Library` · 716-4)에 단가산출 몫만 줌: + * 종류 sangeun(SK-) · 그 층 목록표 고름 · 새 문서 몸 · 글. 층 단추는 페이지가 만듦(칸마다 따로 · 기본 개인). + * 문서 누름 = 편집 칸(UI_Main 종류 sangeun = `Sangeun_View`) · 켜진 층 단추 다시 누름 = 목록표(`Library_List`). + * ========================================================================== */ + +import { t as L } from "@ui/ui_template_locale"; +import { sangeunBlank } from "./M02_MasterTemplete_Library_List"; +import type { Selection } from "./M02_MasterTemplete_UI_Main"; +import { + buildLibrarySection, + type LibraryHandle, + type LibraryOptions, + type LibraryScope, +} from "./M02_MasterTemplete_UI_Side_Library"; + +/** 그 층 목록표 고름(켜진 층 단추 다시 누름) */ +export const sangeunList = (scope: LibraryScope): Selection => ({ + layer: scope.layer, + owner: scope.owner, + canWrite: scope.canWrite, + projectId: null, + kind: "sangeun", + name: "", + label: L("M02_SangeunTitle"), +}); + +export type SangeunOptions = Pick< + LibraryOptions, + "scope" | "bar" | "confirmLeave" | "onOpen" | "getOpen" +>; + +export const buildSangeunSection = (opt: SangeunOptions): LibraryHandle => + buildLibrarySection({ + ...opt, + kind: "sangeun", + list: sangeunList, + blank: sangeunBlank, + text: { none: L("M02_SangeunNone"), newHere: L("M02_SangeunNewHere") }, + }); diff --git a/resources/tester/test_197_1_dashboard_option_card.py b/resources/tester/test_197_1_dashboard_option_card.py index fe1701798..2fe8b5116 100644 --- a/resources/tester/test_197_1_dashboard_option_card.py +++ b/resources/tester/test_197_1_dashboard_option_card.py @@ -49,16 +49,17 @@ def test_줄_해시_옵션은_M01(): from test_b01_settings_cards import _cards cards = _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) - heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:7]] + heads = [c[2].split("?")[0] + "?" + c[2].split("?")[1].split("&")[0] for c in cards[1:8]] assert heads == [ "m01-master-data?open=로직", "m01-master-data?open=옵션", "m01-master-data?open=별칭", # 718-3 + "m02-master-template?open=단가산출", # 717-2a "m02-master-template?open=일위대가조합", "m02-master-template?open=구조물조합", "m02-master-template?open=도면", ] - assert all("&root=master" in c[2] for c in cards[4:7]) + assert all("&root=master" in c[2] for c in cards[4:8]) @needs_node @@ -77,7 +78,7 @@ def test_해시_진입_셋(): + fn.group(0).replace("export function", "function") + "\nconsole.log(JSON.stringify(entryFromHash(null)));" ) - for want in ("구조물조합", "옵션", "도면", "일위대가조합"): + for want in ("구조물조합", "옵션", "도면", "일위대가조합", "단가산출"): got = _run(script, f"open={want}&root=master&company=3") assert got["open"] == want and got["root"] == "master" and got["company"] == "3" @@ -85,8 +86,12 @@ def test_해시_진입_셋(): def test_reveal_옵션은_M01_로(): side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") body = side.split("const reveal = ")[1].split("\n };")[0] - # 716-1 · 716-3 — 옵션 정의는 M01 로 옮김 · M02 reveal 은 일위대가 · 도면만 펼침 - assert 'target === "일위대가조합" ? precomboSec : target === "도면" ? drawing : null' in body + # 716-1 · 716-3 · 717-2a — 옵션 정의는 M01 로 옮김 · M02 reveal 은 단가산출 · 일위대가 · 도면만 펼침 + flat = " ".join(body.split()) + assert ( + 'target === "단가산출" ? sangeunSec : target === "일위대가조합" ? precomboSec' + ' : target === "도면" ? drawing : null' + ) in flat assert 'sec?.classList.remove("is-collapsed");' in body assert "options" not in body and "옵션: section(" not in side page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index ae486748a..d89b76154 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -97,22 +97,23 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite") @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") -def test_시스템_관리자_줄_칸별_여덟_고른_회사_해시(): - # 716-2 · 718-3 — 대시보드 설계 기본값 = 칸별 줄: M01 4(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의 · - # 별칭 장부) + - # M02 3(일위대가 · 구조물 조합 · 도면 양식) + 계산 자리 +def test_시스템_관리자_줄_칸별_아홉_고른_회사_해시(): + # 716-2 · 718-3 · 717-2a — 대시보드 설계 기본값 = 칸별 줄: M01 4(마스터 요소 · 단가산출 로직 · + # 구조물 옵션 정의 · 별칭 장부) + + # M02 4(단가산출 · 일위대가 · 구조물 조합 · 도면 양식) + 계산 자리 assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [ ["M01_Title", READ_SRC, M01], ["B01_Dashboard_CardLogic", READ_SRC, f"{M01}?open=로직"], ["B01_Dashboard_CardStructOptionDef", WRITE_M, f"{M01}?open=옵션"], ["B01_Dashboard_CardAliasLedger", WRITE_M, f"{M01}?open=별칭"], + ["B01_Dashboard_CardSangeun", WRITE_M, f"{M02}?open=단가산출&root=master&company=3"], ["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=master&company=3"], ["B01_Dashboard_CardStructCombo", WRITE_M, f"{M02}?open=구조물조합&root=master&company=3"], ["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"], CALC_SITES, ] # 회사가 없으면 해시에 안 실음 - assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[5][2] == ( + assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[6][2] == ( f"{M02}?open=구조물조합&root=master" ) @@ -243,7 +244,8 @@ def test_구조물_조합_기본_층_개인(): 134-4 — 층 없는 주소도 개인(회사 없으면 마스터) · 사전조합 기본 층도 같은 규칙.""" combo = f"{M02}?open=구조물조합&root=personal" rows = _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": 1}, 3) - assert rows[4:7] == [ + assert rows[4:8] == [ + ["B01_Dashboard_CardSangeun", WRITE_M, f"{M02}?open=단가산출&root=personal&company=3"], ["B01_Dashboard_CardPrecombo", WRITE_M, f"{M02}?open=일위대가조합&root=personal&company=3"], ["B01_Dashboard_CardStructCombo", WRITE_M, f"{combo}&company=3"], ["B01_Dashboard_CardDrawing", WRITE_M, f"{M02}?open=도면&root=master&company=3"], @@ -254,7 +256,7 @@ def test_구조물_조합_기본_층_개인(): assert _cards({"role": "USER", "is_master": True, "company_id": 1})[0][2] == combo assert _cards({"role": "USER", "is_master": False, "company_id": 1})[0][2] == combo # 개인 층 없음(회사 없음) = 지금 규칙 - assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[5][2] == ( + assert _cards({"role": "SYSTEM_ADMIN", "is_master": False, "company_id": None})[6][2] == ( f"{M02}?open=구조물조합&root=master" ) assert _cards({"role": "ADMIN", "is_master": False, "company_id": None})[0][2] == ( diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 4dff150c1..a74df5040 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -89,7 +89,8 @@ def test_주소_인자를_라우트에서_뗌(): assert ( 'raw === "master" || raw === "company" || raw === "personal"' in layer ) # 없음 = 기본(134-4) - assert '["구조물조합", "옵션", "도면", "일위대가조합"]' in layer # 일위대가 조합 서고 = 50-3 + # 일위대가 조합 서고 = 50-3 · 단가산출 = 717-2a + assert '["구조물조합", "옵션", "도면", "일위대가조합", "단가산출"]' in layer picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts") assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index 1271ecae6..921f89074 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -129,7 +129,7 @@ def test_분류_고정_폴더_지움_자유_폴더_메뉴_끌어_옮기기(): def test_목록표_빈_구분_미분류_고르기_창_새_분류_거름(): """149-2 — 목록표 빈 구분 = 미분류 · 고르기 창 거름 = 품셈 구분 + 사전조합 새 분류""" lib = _text("M02_MasterTemplete_Library_List.ts") - assert 'row.구분 || tc("Lib_Unsorted"),' in lib + assert '(r) => r.구분 || tc("Lib_Unsorted")],' in lib # 717-2a 열 표(COLUMNS) pick = _text("M02_MasterTemplete_Labor_Pick.ts") assert "new Map([...subs, ...classes].map((s) => [s.name, new Set(s.details)]))" in pick assert "classes = precomboClasses(def);" in pick @@ -143,6 +143,7 @@ def test_왼쪽_칸_차례_일위대가_구조물조합_구분선_불러오기_7 side = _text("M02_MasterTemplete_UI_Side.ts") order = side[side.index("m02-side__lists") :] marks = [ + "sangeunSec,", # 717-2a 맨 위 "precomboSec,", "summary,", "createDivider()", diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 6ae515608..cfc15a584 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -191,4 +191,20 @@ export const ui_locales_m2 = { M02_HopyoImport: ["일위대가 불러오기", "Import unit-price sheets"], B01_Dashboard_CardPrecombo: ["일위대가", "Unit-cost items"], M02_PrecomboTitle: ["일위대가", "Unit-cost items"], + B01_Dashboard_CardSangeun: ["단가산출", "Unit-price analyses"], + M02_SangeunTitle: ["단가산출", "Unit-price analyses"], + M02_SangeunNone: ["단가산출 없음", "No unit-price analyses"], + M02_SangeunNewHere: ["여기에 새 단가산출", "New unit-price analysis here"], + M02_SangeunBase: ["바탕", "Base"], + M02_SangeunNoBase: ["바탕 없음", "No base"], + M02_SangeunEdited: ["고친 줄", "Edited rows"], + M02_SangeunUsers: ["쓰는 곳", "Used by"], + M02_SangeunCover: ["덮개", "Overrides"], + M02_SangeunAdded: ["더한 줄", "Added rows"], + M02_SangeunRemoved: ["뺀 줄", "Removed rows"], + M02_SangeunOpened: ["열린 입력", "Open inputs"], + M02_SangeunDetailSoon: [ + "단가산출 상세 표 · 고침 — 준비 중(717-2b)", + "Unit-price detail table and editing — coming next (717-2b)", + ], } as const; From 6d5b78f83eecbbd2df814356c0f5a43b23484c86 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:02:13 +0900 Subject: [PATCH 12/30] =?UTF-8?q?feat(712-1):=20=EC=9D=B8=EB=A0=A5=20?= =?UTF-8?q?=C2=B7=20=EC=9E=AC=EB=A3=8C=20=C2=B7=20=EA=B2=BD=EB=B9=84=20?= =?UTF-8?q?=EB=AC=B6=EC=9D=8C=20=EC=85=88=20=EB=95=8C=20=ED=8C=8C=EC=83=9D?= =?UTF-8?q?=20=C2=B7=20=EA=B8=B0=EA=B3=84=EA=B2=BD=EB=B9=84=20=ED=8C=90?= =?UTF-8?q?=EB=B3=84=20=ED=95=9C=20=EA=B3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 master_bundle — 호표 · 덧줄 = 비목 따라 · 비목 없는 로직 줄 = 기계경비면 경비 + 장비 · 아니면 하위 - 기계경비 판별 = 결과단위 원/시간(is_machine) — M02 엑셀 맞춤(이름 「시간당」) · B09 산근 · 산근 텍스트(입력 「기계」) 갈아 끼움 - 엔진 run 줄 덧칸 「묶음」 · 기계경비 줄 「장비」(출력 꼬리표만 · 노 · 재 · 경 몫은 줄 비목 그대로) - 정본 칸 안 더함 · 5,911 줄 중 하위 31 · 기계경비 줄 1,044 - 지킴 시험 test_712_1_bundle Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Sangeun.py | 2 +- B09_Estimation/B09_Estimation_Sangeun_Text.py | 2 +- M02_MasterTemplete/M02_Hopyo_Ours.py | 3 +- .../master_data/scripts/master_bundle.py | 60 +++++++++++++++++ .../master_data/scripts/master_formula.py | 2 + resources/tester/test_712_1_bundle.py | 64 +++++++++++++++++++ 6 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 resources/master_data/scripts/master_bundle.py create mode 100644 resources/tester/test_712_1_bundle.py diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 02549d389..6364407a4 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -242,7 +242,7 @@ def call_of(whole: Any, item: dict, env: dict) -> dict[str, Any] | None: target = None if not target: return None - if any(s.get("이름") == "기계" for s in target.get("입력", [])): + if mf.mb.is_machine(target): # 기계경비 판별 한 곳(712-1) keys = _captured(whole, node, env) return {"machine": keys[0] if keys else None} given = {name: mf.evaluate(expr, env, whole, 1) for name, expr in node[2]} diff --git a/B09_Estimation/B09_Estimation_Sangeun_Text.py b/B09_Estimation/B09_Estimation_Sangeun_Text.py index 84c005b79..53d112e4d 100644 --- a/B09_Estimation/B09_Estimation_Sangeun_Text.py +++ b/B09_Estimation/B09_Estimation_Sangeun_Text.py @@ -276,7 +276,7 @@ def _logic_lines(whole, item, env, qty_text, q, price_of) -> list[tuple[str, dic except mf.FormulaError: target = None name = item.get("이름", "") - if target and any(s.get("이름") == "기계" for s in target.get("입력", [])): + if mf.mb.is_machine(target): # 기계경비 판별 한 곳(712-1) keys = sg._captured(whole, node, env) got = price_of(keys[0]) if keys else None if not got: diff --git a/M02_MasterTemplete/M02_Hopyo_Ours.py b/M02_MasterTemplete/M02_Hopyo_Ours.py index 92272c4ad..90c6619c1 100644 --- a/M02_MasterTemplete/M02_Hopyo_Ours.py +++ b/M02_MasterTemplete/M02_Hopyo_Ours.py @@ -32,6 +32,7 @@ from M01_MasterData import M01_MasterData_Store_Cache as m01_cache from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums +mb = m01.cm.mf.mb # 묶음 · 기계경비 판별(712-1) FP_VERSION = 1 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림 ROOT = Path(__file__).resolve().parents[1] _PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?") @@ -84,7 +85,7 @@ def whole() -> Any: def is_mach_logic(row: dict[str, Any]) -> bool: - return "시간당" in str(row.get("이름") or "") + return mb.is_machine(row) # 기계경비 판별 한 곳(712-1 · 결과단위 원/시간) def eq_of(code: Any) -> tuple[set[str], set[float]]: diff --git a/resources/master_data/scripts/master_bundle.py b/resources/master_data/scripts/master_bundle.py new file mode 100644 index 000000000..282e813cc --- /dev/null +++ b/resources/master_data/scripts/master_bundle.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +"""인력 · 재료 · 경비 묶음 — **셈 때 파생**(정본에 칸 없음 · PLAN 712-1 · 요구서 원칙 5). + +규칙 한 줄: +- 호표 · 덧줄 = `비목` 따라(노무비 → 인력 · 재료비 → 재료 · 경비 → 경비). +- 비목 없는 로직 줄 = 부르는 로직이 기계경비(`is_machine`)면 경비 + `장비` · 아니면 하위. + 두 줄 모두 엔진 줄 `비목` 에 노무비 · 재료비 · 경비 세 몫이 그대로 남음(한 줄 · 흩어 넣기 둘 다). + +기계경비 판별 = `is_machine` 한 곳 — M01 엔진 · M02 엑셀 맞춤(`M02_Hopyo_Ours`) · +B09 산근(`B09_Estimation_Sangeun` · `_Text`)이 같이 씀. +엔진 줄 덧칸 = `묶음`(출력 꼬리표만 · 입력 값 덧칸 `값묶음` 은 715-1b 몫) · 기계경비 줄 `장비` true. +""" + +from __future__ import annotations + +import re + +import master_formula as mf + +BUNDLES = ("인력", "재료", "경비", "하위") +BY_ITEM = {"노무비": "인력", "재료비": "재료", "경비": "경비"} +BY_KIND = {"인력": "인력", "재료": "재료", "기계": "경비"} # 비목 빈 옛 줄 받침 + + +def is_machine(row: dict | None) -> bool: + """기계경비 로직 — 돈 로직이고 결과단위 「원/시간」(작업량 「㎥/시간」 · 이름 「시간당」 아님).""" + if not row or "결과" in row or isinstance(row.get("흐름"), dict): + return False + return re.sub(r"\s", "", str(row.get("결과단위") or "")) == "원/시간" + + +def called(master, item: dict, env: dict | None = None) -> dict | None: + """로직 줄이 부르는 로직 줄(정본) — 못 찾으면 None.""" + try: + node = mf.parse(item.get("요소") or "") + if not node or node[0] != "logic": + return None + key = mf.fill(node[1], env) if env is not None and "{" in node[1] else node[1] + return master.logic(key) + except (mf.FormulaError, mf.EmptyInput, KeyError, TypeError): + return None + + +def bundle(master, item: dict, env: dict | None = None) -> tuple[str, bool]: + """줄 하나(호표 · 덧줄 정본 줄) → (묶음, 장비인가).""" + if item.get("비목") in BY_ITEM: + return BY_ITEM[item["비목"]], False + if item.get("종류") == "로직": + return ("경비", True) if is_machine(called(master, item, env)) else ("하위", False) + return BY_KIND.get(str(item.get("종류")), "하위"), False + + +def tag(master, row: dict, lines: list[dict], env: dict | None = None) -> list[dict]: + """엔진 `run` 줄에 `묶음`(+ 기계경비 줄 `장비`) — 줄 차례 = 호표 줄 하나씩 뒤 덧줄 하나씩.""" + for item, line in zip([*row.get("호표", []), *row.get("덧줄", [])], lines): + name, machine = bundle(master, item, env) + line["묶음"] = name + if machine: + line["장비"] = True + return lines diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 4fdda195a..29c4665bb 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -657,6 +657,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: } ) sums = {k: cut(row, v, "계") for k, v in sums.items()} # 비목 합마다 끊고 계는 그 합 + mb.tag(master, row, lines, env) # 줄마다 인력 · 재료 · 경비 · 하위 묶음(712-1) return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)} @@ -676,6 +677,7 @@ from master_material import ( # noqa: E402, F401 pick, ) import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위 +import master_bundle as mb # noqa: E402 — 인력 · 재료 · 경비 묶음 from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로 _check_cut, _check_node, diff --git a/resources/tester/test_712_1_bundle.py b/resources/tester/test_712_1_bundle.py new file mode 100644 index 000000000..272a93b0c --- /dev/null +++ b/resources/tester/test_712_1_bundle.py @@ -0,0 +1,64 @@ +"""712-1 지킴 — 인력 · 재료 · 경비 묶음 셈 때 파생 · 기계경비 판별 한 곳(`master_bundle`).""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "resources/master_data/scripts")) +sys.path.insert(0, str(ROOT)) + +import check_master as cm # noqa: E402 +import master_auto as mau # noqa: E402 +import master_bundle as mb # noqa: E402 +import master_formula as mf # noqa: E402 + +FILES = cm.load() +MASTER = mf.Master(FILES) +LOGICS = {r["키"]: r for d in FILES.values() if d.get("그룹") == "로직" for r in d.get("줄") or []} + + +def test_every_line_bundled(): + """호표 · 덧줄 전수 — 하위(못 가름) ≤ 31 · 나머지는 인력 · 재료 · 경비 · 기계경비 줄 = 경비.""" + count = dict.fromkeys(mb.BUNDLES, 0) + machine = 0 + for row in LOGICS.values(): + for item in [*row.get("호표", []), *row.get("덧줄", [])]: + name, is_mach = mb.bundle(MASTER, item) + count[name] += 1 + machine += is_mach + if is_mach: + assert name == "경비" + assert sum(count.values()) > 5_000 + assert count["하위"] <= 31, count + assert machine >= 1_000, machine + + +def test_machine_rule_one_place(): + """기계경비 = 결과단위 원/시간 돈 로직 — 작업량(㎥/시간) · 이름 「시간당」 은 아님.""" + machines = {k for k, r in LOGICS.items() if mb.is_machine(r)} + assert {"GC000267", "GC000268", "GF000145"} <= machines + assert not machines & {"GF000102", "GC000422", "GC000260"} # 작업량 · ㎡/시간 · 원/㎡ + sys.path.insert(0, str(ROOT)) + from M02_MasterTemplete import M02_Hopyo_Ours as ours + + assert all(ours.is_mach_logic(r) == mb.is_machine(r) for r in LOGICS.values()) + + +def test_run_lines_tagged(): + """엔진 `run` 줄마다 `묶음` · 기계경비 줄은 `장비` + 비목 노 · 재 · 경 세 몫.""" + seen = set() + for key in ("GF000169", "GF000103"): + row = LOGICS[key] + given = {k: v for k, v in mau.values(MASTER, row).items() if v is not None} + got = mf.run(MASTER, key, given) + for line in got["줄"]: + assert line["묶음"] in mb.BUNDLES + seen.add(line["묶음"]) + if line.get("장비"): + assert set(line["비목"]) == set(mf.COST_ITEMS) + total = sum((line["비목"].get(k) or Decimal(0)) for line in got["줄"] for k in line["비목"]) + assert abs(total - got["계"]) < 1 + assert {"인력", "재료", "하위"} <= seen From cddb28b63a6fdf2d2b9c1d3a1a0ecd8fa03f8527 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:03:36 +0900 Subject: [PATCH 13/30] =?UTF-8?q?feat(714-2):=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=ED=8B=80=20=E2=80=94=20A00=5FCommon/calc=5Fsheet.t?= =?UTF-8?q?s(704-2=20=EA=B3=84=EC=82=B0=EC=84=9C=20=EC=A4=84=20=EC=98=AC?= =?UTF-8?q?=EB=A6=BC=20=C2=B7=20=EB=AC=B6=EC=9D=8C=20=EB=A8=B8=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=B9=B8=20=C2=B7=20=EB=B0=94=EA=BE=B8?= =?UTF-8?q?=EB=A9=B4=20onInput)=20=C2=B7=20M02=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EA=B8=B0=20=C2=B7=20M01=20=E3=80=8C?= =?UTF-8?q?=EC=9D=B8=EB=A0=A5=C2=B7=EC=9E=AC=EB=A3=8C=C2=B7=EA=B2=BD?= =?UTF-8?q?=EB=B9=84=E3=80=8D=20=EB=B3=B4=EA=B8=B0=EA=B0=80=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=8C=EB=A1=9C=20=EC=94=80(=EC=9E=A5=EB=B9=84=20=ED=95=9C?= =?UTF-8?q?=20=EC=A4=84=20=C2=B7=20=EA=B8=B0=EC=A4=80=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=C2=B7=20=ED=99=98=EC=82=B0=20=EA=B7=BC=EA=B1=B0=EB=8A=94=20714?= =?UTF-8?q?-1=20=EB=92=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 704-2 Labor_Sheet 줄 · 소계 · 계 = calcSheetRows(표본 셋 DOM 전후 같음) - 새 M01_MasterData_UI_LogicLab_Est.ts — /text 묶음 그대로 · 묶음이 쓰는 시험 입력(714-3 usedInputs) = 머리 변수 칸 · 「준비 중」 자리 걷음 - 낡은 시험 test_704_logic_standard 고침 — 계산서가 계산 줄 부품을 공용 계산서 안에서 씀(import 자리만 바뀜) - 새 지킴 시험 test_714_2_calc_sheet Co-Authored-By: Claude Opus 5.5 --- A00_Common/calc_sheet.ts | 106 ++++++++++++++++++ .../M01_MasterData_UI_LogicLab_Detail.ts | 7 +- .../M01_MasterData_UI_LogicLab_Est.ts | 49 ++++++++ .../M01_MasterData_UI_LogicLab_Style.css | 8 ++ .../M01_MasterData_UI_LogicLab_Text.ts | 4 - .../M02_MasterTemplete_Labor_Sheet.ts | 27 ++--- resources/tester/test_704_logic_standard.py | 3 +- resources/tester/test_714_2_calc_sheet.py | 47 ++++++++ 8 files changed, 224 insertions(+), 27 deletions(-) create mode 100644 A00_Common/calc_sheet.ts create mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts create mode 100644 resources/tester/test_714_2_calc_sheet.py diff --git a/A00_Common/calc_sheet.ts b/A00_Common/calc_sheet.ts new file mode 100644 index 000000000..b8422bdc3 --- /dev/null +++ b/A00_Common/calc_sheet.ts @@ -0,0 +1,106 @@ +/* ============================================================================= + * calc_sheet.ts + * 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5) — 704-2 M02 계산서 보기를 올린 것. + * 쓰는 곳 = M01 「인력·재료·경비」 보기 · M02 기준 목록 계산서 보기(704-2) · (뒤에) 일위대가 모달 · B09 단가산출근거 + * 꼴 = 묶음마다 [머리(변수 칸이 있을 때만) · 쓰는 입력 값 줄] · 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계 + * 값 = 서버 셈 그대로 찍음(화면에서 다시 셈 안 함) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈 + * 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 요소 · 페이지 CSS 없음 + * ========================================================================== */ + +import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "@ui/ui_template_calc_row"; +import { el } from "@ui/ui_template_elements"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { createText } from "@ui/ui_template_text"; + +export interface SheetLine { + 이름: string; + /** 서버 값 식 「단가 * 품 = 금액」 — 「= 금액」 은 떼고 「*」 → 「×」 */ + 글: string; + 금액: number | null; +} +export interface SheetGroup { + 비목: string; + 소계: number | null; + 끝수?: string; + 줄: SheetLine[]; +} +export interface SheetInput { + 이름: string; + 단위?: string; + 고르기?: (string | number)[]; +} +export interface SheetOptions { + 묶음: SheetGroup[]; + 계: number | null; + /** 맨 아래 계 줄 이름(「계 (원/㎡)」) */ + sumLabel: string; + /** 변수 칸 — 입력 꼴 · 지금 값 · 묶음이 쓰는 입력 이름 · 바꾼 뒤 할 일(없으면 변수 칸 없음) */ + inputs?: SheetInput[]; + values?: Record; + uses?: (group: SheetGroup, index: number) => string[]; + onInput?: (name: string, value: string) => void; +} + +const won = (v: number | null, digits = 0): string => + v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); + +const out = (v: number | null, key = false, digits = 0): HTMLElement => + el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) }); + +/** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */ +export const sheetFormula = (text: string): string => + text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × "); + +/** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */ +function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement { + let control: HTMLElement; + if (spec.고르기?.length) { + control = createSelectField({ + options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })), + value, + compact: true, + ariaLabel: spec.이름, + onChange: onInput, + }).root; + } else { + const box = createInputField({ value, compact: true }); + box.input.setAttribute("aria-label", spec.이름); + box.input.addEventListener("change", () => { + if (box.input.value.trim() && box.input.value !== value) onInput(box.input.value.trim()); + }); + control = box.root; + } + const row = createCalcRow(spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, control); + row.dataset.sheetVar = spec.이름; + return row; +} + +/** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */ +export function calcSheetRows(o: SheetOptions): HTMLElement[] { + const rows: HTMLElement[] = []; + const specs = new Map((o.onInput ? (o.inputs ?? []) : []).map((s) => [s.이름, s])); + o.묶음.forEach((g, gi) => { + const vars = (o.uses?.(g, gi) ?? []).filter((n) => specs.has(n)); + if (vars.length && o.onInput) { + const onInput = o.onInput; + const head = createText(g.비목, "muted", 14); + head.dataset.sheetHead = g.비목; + rows.push(head); + for (const n of vars) + rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v))); + } + for (const line of g.줄) + rows.push( + createCalcFormulaRow( + line.이름, + createCalcFormula(sheetFormula(line.글)), + out(line.금액, false, 1), + ), + ); + rows.push( + createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)), + ); + }); + rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true))); + return rows; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index ed14e9e49..bedd8c879 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -24,6 +24,7 @@ import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; +import { buildEst } from "./M01_MasterData_UI_LogicLab_Est"; import { condText, openMaterialModal, @@ -38,7 +39,6 @@ import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; import "./M01_MasterData_UI_LogicLab_Pill.css"; import { createBadge } from "@ui/ui_template_badge"; -import { createEmpty } from "@ui/ui_template_empty"; import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View"; /** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */ @@ -525,7 +525,10 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { host.replaceChildren( ...infoBox(ctx), block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"), - block(section(tl("View_Est"), [createEmpty({ text: tl("View_Soon"), small: true })]), "est"), + block( + section(tl("View_Est"), [buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx })]), + "est", + ), ...(ctx.flowHost ? [block(nowInputs(ctx.values), "ho"), ctx.flowHost] : [hoBox(ctx), block(section(tl("Formula_Title"), [formula]), "formula")]), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts new file mode 100644 index 000000000..adef8655f --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * M01_MasterData_UI_LogicLab_Est.ts + * 「인력·재료·경비」 보기(PLAN 714-2) — 공용 단가산출 계산서(`A00_Common/calc_sheet.ts`)에 시험 계산 읽는 식(`/text`)을 그대로. + * 묶음 머리 변수 칸 = 그 묶음 줄들이 쓰는 시험 입력(714-3 `usedInputs`) · 바꾸면 시험 계산 입력 고침 + 다시 셈(정본 안 바뀜) + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; +import { calcSheetRows } from "../A00_Common/calc_sheet"; +import type { LogicInput } from "./M01_MasterData_UI_Logic_Api"; +import { safeReason } from "./M01_MasterData_UI_Logic_Text"; +import { usedInputs, type PreviewTextAnswer } from "./M01_MasterData_UI_LogicLab_Formula"; +import { tl } from "./M01_MasterData_UI_LogicLab_Text"; + +export interface EstContext { + text: PreviewTextAnswer | null; + unit: string; + inputs: LogicInput[]; + values: Record; + onInput?: (name: string, value: string) => void; +} + +export function buildEst(ctx: EstContext): HTMLElement { + const host = el("div", { className: "m01lab-est", attrs: { "data-est": "" } }); + const text = ctx.text; + if (!text || (text.ok && text.계산됨 === false)) { + host.append(createText(tl("Formula_Slot"), "muted")); + return host; + } + if (!text.ok) { + host.append(createText(safeReason(text.reason), "error")); + return host; + } + const names = ctx.inputs.map((x) => x.이름); + host.append( + ...calcSheetRows({ + 묶음: text.groups, + 계: text.계, + sumLabel: `${tl("Total")} (${ctx.unit || "원"})`, + inputs: ctx.inputs, + values: ctx.values, + onInput: ctx.onInput, + uses: (_, gi) => [ + ...new Set(text.groups[gi].줄.flatMap((line) => usedInputs(line, names).map(([n]) => n))), + ], + }), + ); + return host; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index 0292d5cd7..a1fbc0bd6 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -225,3 +225,11 @@ max-height: 40vh; overflow: auto; } + +/* 714-2 「인력·재료·경비」 — 공용 계산서 줄을 세로로 */ +.m01lab-est { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 52b4329de..aea6cd7b6 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -30,10 +30,6 @@ const TEXT = { Calc_Title: ["시험 계산", "Trial calculation"], View_Est: ["인력·재료·경비", "Labor·material·expense"], View_Label: ["보기", "View"], - View_Soon: [ - "준비 중 — 인력 · 재료 · 경비 묶음 보기", - "Coming soon — labor · material · expense view", - ], Now_Inputs: ["지금 입력", "Current inputs"], Now_None: ["없음", "none"], G_labor: ["인력", "Labor"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts index 1c22a7caa..d21620c12 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts @@ -2,23 +2,17 @@ * M02_MasterTemplete_Labor_Sheet.ts * 계산서 보기(704-2) — 기준 목록 줄을 누르면 담기 전에 한 장: 맨 위 「출처 · 판」 · 비목마다 * 「이름 | 노임 × 품 | 금액」 줄 · 소계 · 계(서버 M01 엔진 값 · `/logic-lib/sheet`) · 곁에 원문 띠 · - * [담기] = 부르는 쪽 담기 동작 그대로(`put`). + * [담기] = 부르는 쪽 담기 동작 그대로(`put`). 줄 · 소계 · 계 = 공용 계산서(`A00_Common/calc_sheet.ts` · 714-2). * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; -import { createCalcFormula, createCalcFormulaRow } from "@ui/ui_template_calc_row"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; +import { calcSheetRows } from "../A00_Common/calc_sheet"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; import { fetchStandardSheet, type StandardRow } from "./M02_MasterTemplete_Labor_Api"; import { tc } from "./M02_MasterTemplete_Labor_Text"; -const won = (v: number | null, digits = 0): string => - v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); - -const out = (v: number | null, key = false, digits = 0): HTMLElement => - el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) }); - export function openStandardSheet(row: StandardRow, put: () => void): void { const sheet = el("div", { className: "m02-std-sheet__body", attrs: { "data-std-sheet": "" } }); sheet.append(createText(tc("Loading"))); @@ -51,19 +45,12 @@ export function openStandardSheet(row: StandardRow, put: () => void): void { createText(head, "muted", 14), createText([got.출처, got.판].filter(Boolean).join(" · "), "hint"), ]; - for (const g of got.묶음) { - for (const line of g.줄) { - const text = line.글.replace(/ = [^=]*$/, "").replaceAll(" * ", " × "); - lines.push( - createCalcFormulaRow(line.이름, createCalcFormula(text), out(line.금액, false, 1)), - ); - } - lines.push( - createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)), - ); - } lines.push( - createCalcFormulaRow(`${tc("Std_Sum")} (원/${got.단위 || "식"})`, null, out(got.계, true)), + ...calcSheetRows({ + 묶음: got.묶음, + 계: got.계, + sumLabel: `${tc("Std_Sum")} (원/${got.단위 || "식"})`, + }), ); sheet.replaceChildren(...lines); source.show(got.출처); diff --git a/resources/tester/test_704_logic_standard.py b/resources/tester/test_704_logic_standard.py index bfd9f02b8..133ed7766 100644 --- a/resources/tester/test_704_logic_standard.py +++ b/resources/tester/test_704_logic_standard.py @@ -120,7 +120,8 @@ def test_화면_기준_목록_계산서_담기() -> None: assert "openStandardSheet(row, () => {\n close();\n done(logic);" in pick assert "(!work.select.value || x.공종 === work.select.value)" in pick sheet = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet.ts").read_text("utf-8") - assert 'from "@ui/ui_template_calc_row"' in sheet and "createSourceView(" in sheet + # 줄 · 소계 · 계 = 공용 단가산출 계산서(714-2 · 계산 줄 부품을 그 안에서 씀) + assert 'from "../A00_Common/calc_sheet"' in sheet and "createSourceView(" in sheet for name in ("Labor_Pick", "Labor_Sheet"): text = (ROOT / f"M02_MasterTemplete/M02_MasterTemplete_{name}.ts").read_text("utf-8") assert len(text.splitlines()) <= 700 diff --git a/resources/tester/test_714_2_calc_sheet.py b/resources/tester/test_714_2_calc_sheet.py new file mode 100644 index 000000000..b2c8772d5 --- /dev/null +++ b/resources/tester/test_714_2_calc_sheet.py @@ -0,0 +1,47 @@ +"""714-2 공용 단가산출 계산서 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음). + +- 704-2 M02 계산서 보기 · M01 「인력·재료·경비」 보기가 한 벌(`A00_Common/calc_sheet.ts`)을 씀 +- 계산서는 부품 상자만(날 단추 · 입력 · 표 없음) · 화면에서 다시 셈 안 함(값 = 서버) +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SHEET = ROOT / "A00_Common" / "calc_sheet.ts" + + +def _text(p: Path) -> str: + return p.read_text(encoding="utf-8") + + +def test_한_벌을_씀(): + m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Labor_Sheet.ts") + est = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Est.ts") + detail = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Detail.ts") + assert "calcSheetRows(" in m02 and "createCalcFormulaRow" not in m02 + assert "calcSheetRows(" in est and "buildEst(" in detail + + +def test_부품_상자만(): + s = _text(SHEET) + assert " Date: Sun, 1 Nov 2026 16:03:54 +0900 Subject: [PATCH 14/30] =?UTF-8?q?feat(712-2):=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=8C=20master=5Funit=20=E2=80=94=20=EB=B3=84?= =?UTF-8?q?=EC=B9=AD=20=C2=B7=20=EA=B0=9C=EC=88=98=20=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=C2=B7=20=EA=B2=B0=EA=B3=BC=EB=8B=A8=EC=9C=84=20=EB=B0=B0=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0=20=C2=B7=20=EA=B0=99=EC=9D=80=20=EC=84=B1?= =?UTF-8?q?=EA=B2=A9=20=ED=99=98=EC=82=B0=20=C2=B7=20=EA=B7=BC=EA=B1=B0=20?= =?UTF-8?q?=EA=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 별칭(master_material) · 같은 성격 환산(master_pick) · 결과단위 배 읽기 셋(Store_Combo · Hopyo_Ours · master_basis) · 보이는 꼴 별칭(master_basis) 갈아 끼움 · 엑셀 맞춤(Hopyo_Match)은 master_pick 이름 그대로 이음 - 개수 묶음(개 · EA · 본 · 매) 1:1 — 본 ↔ 개 단위경고 4 줄 사라짐 · 값 같음 - 부피 ↔ 무게 = 단위중량 표 CC000337 고른 줄 받을 때만(기본 줄 없음) · 밀도 · ㎾h 경고 글만 - B08 /1000 · /40 손 식 = 실무 엑셀 G열 식이라 그대로(시험이 같은 값 대조) - 지킴 시험 test_712_2_unit Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Store_Combo.py | 12 +- M02_MasterTemplete/M02_Hopyo_Ours.py | 7 +- resources/master_data/scripts/master_basis.py | 8 +- .../master_data/scripts/master_material.py | 6 +- resources/master_data/scripts/master_pick.py | 31 +---- resources/master_data/scripts/master_unit.py | 121 ++++++++++++++++++ resources/tester/test_712_2_unit.py | 59 +++++++++ 7 files changed, 195 insertions(+), 49 deletions(-) create mode 100644 resources/master_data/scripts/master_unit.py create mode 100644 resources/tester/test_712_2_unit.py diff --git a/M01_MasterData/M01_MasterData_Store_Combo.py b/M01_MasterData/M01_MasterData_Store_Combo.py index 2b7163325..8034e63eb 100644 --- a/M01_MasterData/M01_MasterData_Store_Combo.py +++ b/M01_MasterData/M01_MasterData_Store_Combo.py @@ -17,6 +17,7 @@ from config import config_system from M01_MasterData import M01_MasterData_Store as store cm, mk, mcb = store.cm, store.mk, store.mcb +import master_unit as mu # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤(712-2) FILE = mcb.FILE OWNERS = mcb.OWNERS @@ -150,9 +151,6 @@ def combo(key: str) -> dict: } -_PER = re.compile( - r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)" -) # 「원/1,000본」 = 배 1000 _TAIL = re.compile(r"\s*\([^()]*\)\s*$") @@ -177,7 +175,7 @@ def combo_pills(row: dict) -> dict: got = whole.logic(code) except cm.mf.FormulaError: continue - per = _PER.findall(str(got.get("결과단위") or "")) + per = mu.per(got.get("결과단위")) # 결과단위 배 읽기 한 벌(712-2) if not per: continue # ponytail: 분모가 여럿(「원/㎡ · 원/㎥(단위당 따름)」)이면 「㎡ · ㎥」 한 글 — 입력이 고름 @@ -187,9 +185,9 @@ def combo_pills(row: dict) -> dict: (name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []} ) pill["로직"].append(code) - times = per[0][0].replace(",", "") - if len(per) == 1 and times and float(times) != 1: - pill["배"] = float(times) if "." in times else int(times) + times = per[0][0] + if len(per) == 1 and times != 1: + pill["배"] = int(times) if times == times.to_integral_value() else float(times) return {"키": row.get("키"), "이름": row.get("이름"), "물량": list(pills.values()), "조건": []} diff --git a/M02_MasterTemplete/M02_Hopyo_Ours.py b/M02_MasterTemplete/M02_Hopyo_Ours.py index 90c6619c1..f46178624 100644 --- a/M02_MasterTemplete/M02_Hopyo_Ours.py +++ b/M02_MasterTemplete/M02_Hopyo_Ours.py @@ -32,10 +32,9 @@ from M01_MasterData import M01_MasterData_Store_Cache as m01_cache from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums -mb = m01.cm.mf.mb # 묶음 · 기계경비 판별(712-1) +mb, mu = m01.cm.mf.mb, m01.cm.mf.mp.mm.mu # 묶음(712-1) · 단위(712-2) 한 벌 FP_VERSION = 1 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림 ROOT = Path(__file__).resolve().parents[1] -_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?") _SUB = re.compile(r"^\s*로직\(\s*(\w+)\s*(?:,(.*))?\)\s*$") EQ_CODE = re.compile(r"^\d{4}-\d{4}$") _PCT = re.compile(r"\*\s*([\d.]+)\s*/\s*100") @@ -96,8 +95,8 @@ def eq_of(code: Any) -> tuple[set[str], set[float]]: def times_of(row: dict[str, Any]) -> float: - per = _PER.findall(str(row.get("결과단위") or "")) - return float(per[0].replace(",", "")) if per and per[0] else 1.0 + per = mu.per(row.get("결과단위")) # 결과단위 배 읽기 한 벌(712-2) + return float(per[0][0]) if per else 1.0 def _args(text: str | None) -> dict[str, str]: diff --git a/resources/master_data/scripts/master_basis.py b/resources/master_data/scripts/master_basis.py index cf60c6928..ba753509b 100644 --- a/resources/master_data/scripts/master_basis.py +++ b/resources/master_data/scripts/master_basis.py @@ -15,10 +15,9 @@ import re from decimal import Decimal from functools import lru_cache +import master_unit as mu from check_master_body import section_of -# ponytail: 단위 별칭은 여기 짧은 표 — 712-2 단위 모듈이 서면 그쪽 별칭으로 갈아 끼움 -_ALIAS = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"} _UNITS = sorted( ( "㎡ ㎥ ㎢ ㎞ km ㎜ mm ㎝ cm m ha ton 톤 t kg ㎏ 개소 개 본 대 회 조 기 틀 " @@ -33,11 +32,10 @@ _UNIT_AT_END = re.compile( ) _HANGUL = re.compile(r"[가-힣]") _HEAD = re.compile(r"^\(\s*(?:단위\s*:\s*)?([^()]*당[^()]*)\)$") # 원문 머리 줄 「(단위: ㎥당)」 -_RESULT = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*)?\s*([^\s·(\d,.][^\s·(]*)") # 「원/10㎡」 def unit(text: str) -> str: - return _ALIAS.get(text, text) + return mu.shown(text) # 보이는 꼴 별칭 한 벌(712-2 `master_unit`) def _times(text: str | None) -> Decimal: @@ -97,7 +95,7 @@ def basis(table: dict) -> dict: def result_per(result_unit) -> list[tuple[Decimal, str]]: """로직 결과단위 분모 — 「원/10㎡」 → [(10, ㎡)] · 여럿이면 여럿.""" - return [(_times(n), unit(u)) for n, u in _RESULT.findall(str(result_unit or ""))] + return [(n, unit(u)) for n, u in mu.per(result_unit)] def _formula_text(row: dict) -> str: diff --git a/resources/master_data/scripts/master_material.py b/resources/master_data/scripts/master_material.py index 569ea6025..beac0589c 100644 --- a/resources/master_data/scripts/master_material.py +++ b/resources/master_data/scripts/master_material.py @@ -12,6 +12,8 @@ import re import unicodedata from decimal import Decimal +import master_unit as mu + LINKED = "MP" # 품셈재료 테이블ID MARKET = "MT" # 자재품목 테이블ID PRICED = "MO" # 유가전력 테이블ID — 값 한 칸 @@ -27,7 +29,6 @@ PICK_KEYS = ( ) # 재료 고르기 조건 칸 (`_틀.md` 7장) REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀 TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음 -_UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"} def plain(s) -> str: @@ -41,8 +42,7 @@ def plain_spec(s) -> str: def plain_unit(s) -> str: - s = plain(s).replace("ℓ", "l") - return _UNITS.get(s, s) + return mu.plain(s) # 별칭 · 맞대기 꼴 한 벌(712-2) def _offers(master: mf.Master, key: str) -> list[tuple[Decimal, str]]: diff --git a/resources/master_data/scripts/master_pick.py b/resources/master_data/scripts/master_pick.py index b656e47fc..fdbcb02a2 100644 --- a/resources/master_data/scripts/master_pick.py +++ b/resources/master_data/scripts/master_pick.py @@ -9,18 +9,10 @@ from __future__ import annotations from contextlib import contextmanager from contextvars import ContextVar -from decimal import Decimal import master_material as mm +from master_unit import UNITS, convert, kind_of, same_kind # noqa: F401 — 단위 한 벌(712-2) -# 「같은 성격」 단위 — {성격: {정규 단위: 기준 단위 몇 배}} (키는 `plain_unit` 을 거친 꼴) -UNITS = { - "무게": {"g": 1, "kg": 1000, "톤": 1000000}, - "길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000}, - "넓이": {"cm2": 1, "m2": 10000}, - "부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000}, -} -_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()} REGIONS = ( "전국평균 전국 서울 경기 강원 충북 충남 전북 전남 경북 경남 부산 대구 인천 대전 울산 세종 제주 광주" " 전남광주" @@ -60,27 +52,6 @@ def machine(spec: dict, value) -> bool: return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks) -# ── 단위 ─────────────────────────────────────────────────────────────── -def kind_of(unit) -> str | None: - got = _SCALE.get(mm.plain_unit(unit)) - return got[0] if got else None - - -def same_kind(a, b) -> bool: - """단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피).""" - pa, pb = mm.plain_unit(a), mm.plain_unit(b) - return pa == pb or (kind_of(a) is not None and kind_of(a) == kind_of(b)) - - -def convert(price, have, want): - """`have` 단위 단가 → `want` 단위 단가 — 같은 성격이 아니면 그대로.""" - if price is None or not want or mm.plain_unit(have) == mm.plain_unit(want): - return price - if not same_kind(have, want) or kind_of(have) is None: - return price - return price * Decimal(_SCALE[mm.plain_unit(want)][1]) / Decimal(_SCALE[mm.plain_unit(have)][1]) - - # ── 찾기 ─────────────────────────────────────────────────────────────── def _words(term) -> tuple[list[str], list[str]]: """(이름·규격에 든 낱말, 지역 낱말) — 지역 낱말은 고르는 차례에만 쓰임.""" diff --git a/resources/master_data/scripts/master_unit.py b/resources/master_data/scripts/master_unit.py new file mode 100644 index 000000000..07d8e56f2 --- /dev/null +++ b/resources/master_data/scripts/master_unit.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +"""단위 한 벌 — 별칭 · 개수 묶음 · 결과단위 배 읽기 · 같은 성격 환산 · 환산 근거 글(PLAN 712-2). + +쓰는 곳: 재료 단가 → 줄 단위(`master_pick`) · 자재 맞대기 별칭(`master_material.plain_unit`) · +조합 알약 배(`M01_MasterData_Store_Combo`) · 엑셀 맞춤 배(`M02_Hopyo_Ours` · `_Match`) · +표 기준배(`master_basis`) · 계산서 줄 환산 근거(714-1). 서버 단독(③) — TS 는 서버가 준 글만 씀. +- 개수 묶음(개 · EA · 본 · 매) = 1 : 1 같은 성격 · 조 · 세트는 묶음 밖(한 조 = 여럿). +- 부피 ↔ 무게 = 단위중량 표 CC000337 에서 고른 줄(`unit_weight`)을 받을 때만 · 기본 줄 없음 + (사용자 확인 거리) · 밀도(kg ↔ ℓ) · ㎾h 등은 환산 없이 경고 글만. +""" + +from __future__ import annotations + +import re +import unicodedata +from decimal import Decimal + +# 맞대기 꼴(`plain`) 별칭 — NFKC · 소문자 · 빈칸 없앤 뒤 +ALIAS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"} +# 보이는 꼴(`shown`) 별칭 — 표 기준 · 결과단위 분모(원문 기호 ㎡ · ㎥ 그대로) +SHOWN = {"ea": "개", "톤": "ton", "t": "ton", "㎞": "km", "hr": "시간", "min": "분", "㎏": "kg"} +# 같은 성격 — {성격: {맞대기 단위: 기준 단위 몇 배}} +UNITS = { + "무게": {"g": 1, "kg": 1000, "톤": 1000000}, + "길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000}, + "넓이": {"cm2": 1, "m2": 10000}, + "부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000}, + "개수": {"개": 1, "본": 1, "매": 1}, +} +_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()} +WEIGHT_TABLE = "CC000337" # 건설품셈 공통 1-2-4 재료의 단위중량(㎏/㎥) +# 결과단위 분모 — 「원/10㎡」 → (10, ㎡) · 「원/1,000본」 → (1000, 본) +_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)") + + +def plain(unit) -> str: + """맞대기 꼴 — 「㎥」 = 「m3」 · 「EA」 = 「개」 · 「Ton」 = 「톤」 · 「리터」 = 「ℓ」.""" + s = re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(unit or "")).lower()) + s = s.replace("ℓ", "l") + return ALIAS.get(s, s) + + +def shown(unit) -> str: + return SHOWN.get(unit, unit) + + +def per(result_unit) -> list[tuple[Decimal, str]]: + """결과단위 분모마다 (배, 단위 원문 글) — 배 없으면 1 · 분모가 여럿이면 여럿.""" + return [ + (Decimal(n.replace(",", "")) if n else Decimal(1), u) + for n, u in _PER.findall(str(result_unit or "")) + ] + + +def kind_of(unit) -> str | None: + got = _SCALE.get(plain(unit)) + return got[0] if got else None + + +def same_kind(a, b) -> bool: + """단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피 · 개수).""" + return plain(a) == plain(b) or (kind_of(a) is not None and kind_of(a) == kind_of(b)) + + +def _weighs(a, b) -> bool: + return {kind_of(a), kind_of(b)} == {"무게", "부피"} + + +def factor(have, want, density=None) -> Decimal | None: + """`have` 1 단위가 `want` 몇 단위인가 — 단가는 ÷, 수량은 ×. + 부피 ↔ 무게는 단위중량(㎏/㎥) `density` 가 있을 때만 · 못 하면 None.""" + a, b = plain(have), plain(want) + if a == b: + return Decimal(1) + if same_kind(a, b): + return Decimal(_SCALE[a][1]) / Decimal(_SCALE[b][1]) + if density and _weighs(a, b): + grams_per_cm3 = Decimal(str(density)) / Decimal(1000) + base = Decimal(_SCALE[a][1]) * ( + grams_per_cm3 if kind_of(a) == "부피" else 1 / grams_per_cm3 + ) + return base / Decimal(_SCALE[b][1]) + return None + + +def convert(price, have, want, density=None): + """`have` 단위 단가 → `want` 단위 단가 — 못 바꾸면 그대로(경고는 부르는 쪽).""" + if price is None or not want or not have or plain(have) == plain(want): + return price + if same_kind(have, want): # × 뒤 ÷ — 옛 셈과 같은 Decimal 꼴(자릿수) + return price * Decimal(_SCALE[plain(want)][1]) / Decimal(_SCALE[plain(have)][1]) + got = factor(want, have, density) # want 1 단위 = have 몇 단위 + return price if got is None or got == 1 else price * got + + +def note(have, want, density=None) -> str: + """환산 근거 글 — 「kg → 톤 ×1000」 · 「본 = 개(개수 1:1)」 · 못 바꾸면 까닭.""" + if not have or not want or plain(have) == plain(want): + return "" + got = factor(want, have, density) + if got is not None: + if kind_of(have) == "개수" and got == 1: + return f"{have} = {want}(개수 1:1)" + how = f" · 단위중량 {density}㎏/㎥" if _weighs(have, want) else "" + return f"{have} → {want} ×{_num(got)}{how}" + if _weighs(have, want): + return f"{have} ↔ {want} — 단위중량({WEIGHT_TABLE}) 골라야 환산 · 지금 그대로" + return f"{have} ↔ {want} — 성격 다름 · 환산 없음(경고)" + + +def _num(x: Decimal) -> str: + return format(x.normalize(), "f") if x == x.to_integral_value() else format(x, "f")[:10] + + +def unit_weight(master, kind: str, shape: str, end: str = "위") -> Decimal | None: + """단위중량 표에서 고른 줄의 ㎏/㎥ — 기본 줄 없음(고르지 않으면 None).""" + for row in master.get(WEIGHT_TABLE).get("줄") or []: + if row.get("종별") == kind and row.get("형상") == shape: + got = row.get(end) + return None if got is None else Decimal(str(got)) + return None diff --git a/resources/tester/test_712_2_unit.py b/resources/tester/test_712_2_unit.py new file mode 100644 index 000000000..c09f638b5 --- /dev/null +++ b/resources/tester/test_712_2_unit.py @@ -0,0 +1,59 @@ +"""712-2 지킴 — 단위 한 벌(`master_unit`) · 다섯 자리가 같은 모듈을 씀 · 알약 배 전수.""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "resources/master_data/scripts")) +sys.path.insert(0, str(ROOT)) + +import check_master as cm # noqa: E402 +import master_basis as mbs # noqa: E402 +import master_formula as mf # noqa: E402 +import master_material as mm # noqa: E402 +import master_pick as mp # noqa: E402 +import master_unit as mu # noqa: E402 + + +def test_alias_and_count_group(): + assert mu.plain("EA") == mu.plain("ea") == "개" + assert mu.plain("Ton") == mu.plain("t") == mu.plain("톤") == "톤" + assert mu.plain("㎥") == "m3" and mu.plain("리터") == mu.plain("ℓ") == "ℓ" + assert mu.same_kind("본", "개") and mu.same_kind("매", "EA") + assert not mu.same_kind("조", "개") # 한 조 = 여럿 — 묶음 밖 + assert mu.note("본", "개") == "본 = 개(개수 1:1)" + assert mm.plain_unit("EA") == mu.plain("EA") + assert mp.convert is mu.convert and mp.UNITS is mu.UNITS + + +def test_same_kind_convert(): + assert mu.convert(Decimal(1000), "톤", "kg") == Decimal(1) # 톤 당 1000 원 = kg 당 1 원 + assert mu.convert(Decimal(5), "kg", "톤") == Decimal(5000) + assert mu.convert(Decimal(7), "m", "km") == Decimal(7000) + assert mu.factor("kg", "ton") == Decimal("0.001") # B08 「/1000」(kg → ton) 손 식과 같은 값 + assert mu.note("kg", "톤") == "kg → 톤 ×1000" + assert mu.convert(Decimal(3), "kg", "ℓ") == Decimal(3) # 밀도 = 경고만 · 그대로 + assert "성격 다름" in mu.note("ℓ", "㎾h") + + +def test_volume_weight_only_when_picked(): + """부피 ↔ 무게 = 단위중량 표 고른 줄이 있을 때만 · 기본 줄 없음.""" + master = mf.Master(cm.load()) + assert mu.factor("㎥", "톤") is None + assert "단위중량(CC000337)" in mu.note("㎥", "톤") + rho = mu.unit_weight(master, "모래", "건조") + assert rho is not None and rho > 1000 + assert mu.factor("㎥", "톤", rho) == rho / 1000 # 1 ㎥ = ρ/1000 톤 + assert mu.convert(Decimal(100), "㎥", "톤", rho) == Decimal(100) * 1000 / rho + assert mu.unit_weight(master, "없는 종별", "건조") is None + + +def test_result_per_one_parser(): + assert mu.per("원/10㎡") == [(Decimal(10), "㎡")] + assert mu.per("원/1,000본") == [(Decimal(1000), "본")] + assert mu.per("원/㎡ · 원/㎥(단위 따름)") == [(Decimal(1), "㎡"), (Decimal(1), "㎥")] + assert mu.per("㎥/시간") == [] + assert mbs.result_per("원/ton") == [(Decimal(1), "ton")] From 98757431816a6c0919e5e6525e76314f09264093 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:04:02 +0900 Subject: [PATCH 15/30] =?UTF-8?q?feat(714-1):=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=EC=A4=84=20=EB=8D=A7=EC=B9=B8=20=E2=80=94=20?= =?UTF-8?q?=EC=93=B0=EB=8A=94=20=EC=9E=85=EB=A0=A5=20=C2=B7=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EA=B8=B0=EC=A4=80=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=C2=B7=20=ED=99=98=EC=82=B0=20=EA=B7=BC=EA=B1=B0=20=C2=B7=20M01?= =?UTF-8?q?=20/text=20=EC=99=80=20M02=20=EA=B3=84=EC=82=B0=EC=84=9C=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=A4=84=20=EA=BC=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 master_sheet_line.annotate — master_text.lines 답에 줄마다 덧칸(줄 차례 = 호표 _shares 비목 · 뒤 덧줄) · 머리 기준단위(결과단위 분모) - 쓰는입력 = 수량 식 · 요소 · 덧줄 식이 쓰는 로직 입력(중간값 거슬러 감) · 기준단위 = 줄이 찾는 표 기준(712-3 basis) · 환산근거 = 표 기준배 ↔ 결과단위 배 · 재료 품목 단위 ↔ 줄 단위(712-2 note) - M01 /text = 새 Store_Sheet 가 감쌈(Store.py · master_text.py 700 줄 근처라 안 늘림) · M02 /logic-lib/sheet 줄 칸에 같은 KEYS - 지킴 시험 test_714_1_sheet_line(두 길 같은 덧칸) Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Router.py | 3 +- M01_MasterData/M01_MasterData_Store_Sheet.py | 27 +++ M02_MasterTemplete/M02_Logic_Standard.py | 8 +- .../master_data/scripts/master_sheet_line.py | 161 ++++++++++++++++++ resources/tester/test_714_1_sheet_line.py | 62 +++++++ 5 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 M01_MasterData/M01_MasterData_Store_Sheet.py create mode 100644 resources/master_data/scripts/master_sheet_line.py create mode 100644 resources/tester/test_714_1_sheet_line.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 273f709ad..9a4be5281 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -16,6 +16,7 @@ from M01_MasterData import M01_MasterData_Store as store from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M01_MasterData import M01_MasterData_Store_Flow as flow from M01_MasterData import M01_MasterData_Store_Make as make +from M01_MasterData import M01_MasterData_Store_Sheet as sheet import master_basis as mbs # noqa: E402 (`store` 가 scripts 를 길에 둠) @@ -269,7 +270,7 @@ def post_text(body: CalcBody) -> dict: if got is not None: return got with store.cm.mf.mp.choosing(body.고름): - return _call(store.text, body.key, body.inputs, body.row, body.file) + return _call(sheet.text, body.key, body.inputs, body.row, body.file) # 줄 덧칸(714-1) @router.post("/save") diff --git a/M01_MasterData/M01_MasterData_Store_Sheet.py b/M01_MasterData/M01_MasterData_Store_Sheet.py new file mode 100644 index 000000000..7d893f62b --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_Sheet.py @@ -0,0 +1,27 @@ +"""M01 `/text` 계산서 줄 덧칸(PLAN 714-1) — `store.text` 답에 줄마다 쓰는 입력 · 묶음 · 기준단위 · +환산 근거(`master_sheet_line.annotate` — M02 `/logic-lib/sheet` 와 한 벌) · 서버 단독(③) · 저장 안 함. +`M01_MasterData_Store.py` 는 700 줄 근처라 여기서 감쌈.""" + +from __future__ import annotations + +from M01_MasterData import M01_MasterData_Store as store + +mf = store.cm.mf +import master_sheet_line as msl # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤 + + +def text(key: str, inputs: dict, row: dict | None = None, file: str | None = None) -> dict: + got = store.text(key, inputs, row, file) + if not isinstance(got, dict) or not got.get("ok"): + return got + given = {k: store._dec(v) for k, v in inputs.items()} + try: + if row is None: + master = store.loaded()[1] + else: # 저장 전 시험 — `store.text` 와 같은 메모리 사본 + files = store.cm.load(folder=store.FOLDER) + key = store._swap(files, str(row.get("키") or key), row, file) + master = mf.Master(files) + return msl.annotate(master, key, mf.fill_empty(master.logic(key), given), got) + except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + return got diff --git a/M02_MasterTemplete/M02_Logic_Standard.py b/M02_MasterTemplete/M02_Logic_Standard.py index df5b668b5..fd9959833 100644 --- a/M02_MasterTemplete/M02_Logic_Standard.py +++ b/M02_MasterTemplete/M02_Logic_Standard.py @@ -31,6 +31,9 @@ from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Logic_Settle as settle from M02_MasterTemplete import M02_Template_Layers as layers +mf = m01.cm.mf +import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) · scripts 길은 `m01` 이 둠 + SHAPE = 3 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 KIND = "m02_logic_standard" REGION = "지역" # 지역 = 자동값 한 벌(정함 8) @@ -331,7 +334,7 @@ def sheet(key: str, change: dict) -> dict: raise m01.StoreError(404, str(e)) from e given, ax = _given(whole, logic, change) filled = m01.cm.mf.fill_empty(logic, given) - got = m01.mtx.lines(files, key, filled) + got = msl.annotate(whole, key, filled, m01.mtx.lines(files, key, filled)) # 줄 덧칸(714-1) done = settle.settle(whole, key, filled) if "결과" not in done: # 돈 로직 — 줄마다 버림(B09 산근 규칙) settle.apply(got.get("groups") or [], m01.cm.mf.run(whole, key, filled), done) @@ -341,7 +344,7 @@ def sheet(key: str, change: dict) -> dict: "비목": g["비목"], "소계": g.get("소계"), "끝수": g.get("끝수") or "", - "줄": [{k: line.get(k) for k in ("이름", "글", "금액")} for line in g["줄"]], + "줄": [{k: line.get(k) for k in ("이름", "글", "금액", *msl.KEYS)} for line in g["줄"]], } for g in got.get("groups") or [] ] @@ -356,5 +359,6 @@ def sheet(key: str, change: dict) -> dict: "입력": given, "묶음": groups, "계": got.get("계"), + "기준단위": got.get("기준단위"), } ) diff --git a/resources/master_data/scripts/master_sheet_line.py b/resources/master_data/scripts/master_sheet_line.py new file mode 100644 index 000000000..1fe93a425 --- /dev/null +++ b/resources/master_data/scripts/master_sheet_line.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +"""계산서 줄 덧칸 — 「쓰는 입력」 · 묶음 · 기준단위 · 환산 근거(PLAN 714-1 · 요구서 4장 5 · 8). + +`master_text.lines` 답(비목 묶음 줄)에 줄마다 덧칸을 얹음 — M01 `/text` 와 M02 `/logic-lib/sheet` +두 길이 같은 함수(`annotate`)를 거쳐 같은 줄 꼴(`KEYS`)을 냄. 셈은 `master_formula.run` 한 벌. +- `쓰는입력` = 그 줄 값(수량 식 · 요소 · 덧줄 식)에 쓰인 로직 입력 이름 — 중간값을 거슬러 감 · + 덧줄이 부르는 비목 합 · 줄 금액은 거스르지 않음(율 식에 직접 쓴 입력만). +- `묶음` · `장비` = 712-1 `master_bundle`(엔진 줄 덧칸 그대로). +- `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「10㎡」 · 712-3 `master_basis.basis`) · 답 머리 + `기준단위` = 로직 결과단위 분모(「10㎡」 · 여럿이면 「㎡ · ㎥」). +- `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위 + (712-2 `master_unit.note`) · 없으면 「」. +줄 차례 맞춤 = `master_text.lines` 와 같은 차례(호표 줄마다 `_shares` 비목 · 뒤 덧줄). +""" + +from __future__ import annotations + +import re +from decimal import Decimal + +import master_basis as mbs +import master_bundle as mb +import master_formula as mf +import master_text as mtx +import master_unit as mu + +KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거") +_HOLE = re.compile(r"\{([^{}]+)\}") +_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) + + +def _names(expr) -> tuple[set[str], set[str]]: + """식 하나의 (이름 · 찾는 표 키) — `{입력}` 구멍 이름도 셈.""" + if isinstance(expr, dict): # 재료 고르기 조건 묶음 — 값 글의 구멍만 + return {h for v in expr.values() for h in _HOLE.findall(str(v))}, set() + if expr in (None, ""): + return set(), set() + try: + tree = mf.parse(expr) + except mf.FormulaError: + return set(_HOLE.findall(str(expr))), set() + names, tables = set(), set() + for node in mf.walk(tree): + if node[0] == "name": + names.add(node[1]) + elif node[0] in ("find", "logic", "ref"): + names |= set(_HOLE.findall(node[1])) + if node[0] == "find": + tables.add(node[1]) + return names, tables + + +def _closure(row: dict, exprs: list) -> tuple[list[str], list[str]]: + """식들이 쓰는 (입력 이름 · 표 키) — 중간값은 그 식으로 거슬러 감 · 입력 차례대로.""" + steps = {s["이름"]: s["식"] for s in row.get("중간", [])} + inputs = [s["이름"] for s in row.get("입력", [])] + seen, tables, todo = set(), [], list(exprs) + while todo: + names, found = _names(todo.pop()) + tables += [t for t in sorted(found) if t not in tables] + for name in names - seen: + seen.add(name) + if name in steps: + todo.append(steps[name]) + return [n for n in inputs if n in seen], tables + + +def _times(x: Decimal) -> str: + return format(x.normalize(), "f") + + +def _per_text(per: list[tuple[Decimal, str]]) -> str: + return " · ".join(dict.fromkeys(f"{_times(n) if n != 1 else ''}{u}" for n, u in per)) + + +def _basis(master, tables: list[str], env: dict) -> dict | None: + for ref in tables: + try: + got = mbs.basis(master.get(mf.fill(ref, env) if "{" in ref else ref)) + except (*_ERRORS, mf.EmptyInput): + continue + if got["갈래"] == "단위": + return got + return None + + +def _note(basis: dict | None, per: list, line: dict) -> str: + notes = [] + if basis and len(per) == 1: + times, one = per[0] + if mu.plain(basis["기준단위"]) == mu.plain(mbs.unit(one)) and basis["기준배"] != times: + ratio = basis["기준배"] / times + how = f"÷{_times(ratio)}" if ratio > 1 else f"×{_times(1 / ratio)}" + notes.append( + f"{_times(basis['기준배'])}{basis['기준단위']}당 → {_times(times)}{one}당 {how}" + ) + goods = line.get("품목") or {} + if goods.get("단위") and line.get("단위"): + notes.append(mu.note(goods["단위"], line["단위"])) + return " · ".join(n for n in notes if n) + + +def _fields(master, row, exprs, env, per, line: dict, bundle: tuple[str, bool] | None) -> dict: + uses, tables = _closure(row, exprs) + basis = _basis(master, tables, env) + out = { + "쓰는입력": uses, + "기준단위": f"{_times(basis['기준배']) if basis['기준배'] != 1 else ''}{basis['기준단위']}" + if basis + else None, + "환산근거": _note(basis, per, line), + } + if bundle: + out["묶음"] = bundle[0] + if bundle[1]: + out["장비"] = True + return out + + +def annotate(master, key: str, given: dict, answer: dict) -> dict: + """`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로.""" + if not answer.get("ok") or "groups" not in answer: + return answer + row = master.logic(key) + per = mbs.result_per(row.get("결과단위")) + answer["기준단위"] = _per_text(per) or None + try: + env = mtx._env_of(master, row, given) + except _ERRORS: + env = {} + if "결과" in row: + for line in answer["groups"][0]["줄"]: + line.update(_fields(master, row, [row["결과"]["식"]], env, per, {}, None)) + return answer + try: + result = mf.run(master, key, given) + except _ERRORS: + result = None + slots = {g["비목"]: iter(g["줄"]) for g in answer["groups"]} + items = row.get("호표") or [] + for n, item in enumerate(items): + line = result["줄"][n] if result else {} + if result: + parts = [p for p, _ in mtx._shares(line)] + elif item.get("종류") == "로직" and "비목" not in item: + parts = list(mf.COST_ITEMS) + else: + parts = [item.get("비목")] + exprs = [item.get("수량"), item.get("요소")] + got = _fields(master, row, exprs, env, per, line, mb.bundle(master, item, env)) + for part in parts: + target = next(slots.get(part, iter(())), None) + if target is not None: + target.update(got) + for extra in row.get("덧줄") or []: + target = next(slots.get(extra["비목"], iter(())), None) + if target is not None: + target.update( + _fields(master, row, [extra["식"]], env, per, {}, mb.bundle(master, extra)) + ) + return answer diff --git a/resources/tester/test_714_1_sheet_line.py b/resources/tester/test_714_1_sheet_line.py new file mode 100644 index 000000000..f2266a8e5 --- /dev/null +++ b/resources/tester/test_714_1_sheet_line.py @@ -0,0 +1,62 @@ +"""714-1 지킴 — 계산서 줄 덧칸(쓰는 입력 · 묶음 · 기준단위 · 환산 근거) · M01 `/text` 와 +M02 `/logic-lib/sheet` 가 같은 줄 꼴(`master_sheet_line`).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from M01_MasterData import M01_MasterData_Store as store # noqa: E402 +from M01_MasterData import M01_MasterData_Store_Sheet as sheet # noqa: E402 +from M02_MasterTemplete import M02_Logic_Standard as standard # noqa: E402 + +msl = sheet.msl + + +def _text(key: str) -> dict: + auto = {k: v for k, v in store.auto(key)["값"].items() if v is not None} + return sheet.text(key, auto) + + +def _lines(got: dict, groups: str = "groups") -> list[dict]: + return [line for g in got[groups] for line in g["줄"]] + + +def test_line_keys_and_bundle(): + got = _text("GF000103") # 구조물터파기 — 인력 + 굴착기 기계경비 줄 + assert got["ok"] and got["기준단위"] == "㎥" + lines = _lines(got) + assert all({"쓰는입력", "묶음", "기준단위", "환산근거"} <= set(line) for line in lines) + machine = [line for line in lines if line.get("장비")] + assert machine and all(line["묶음"] == "경비" for line in machine) + assert {"지역", "보정작업"} <= set(machine[0]["쓰는입력"]) + assert any(line["묶음"] == "인력" for line in lines) + + +def test_uses_follow_middle_values(): + """중간값을 거슬러 입력까지 — 돌쌓기 석공 품은 높이 · 쌓기 · 돌 입력을 씀.""" + lines = _lines(_text("GF000219")) + assert {"높이", "쌓기", "돌"} <= set(lines[0]["쓰는입력"]) + + +def test_basis_note_tenfold(): + """표 10㎡당 → 결과 원/㎡ 「÷10」 · 결과도 원/10㎡ 면 근거 없음.""" + one = _lines(_text("GC000037")) + assert one[0]["기준단위"] == "10㎡" and one[0]["환산근거"] == "10㎡당 → 1㎡당 ÷10" + ten = _text("GF000124") + assert ten["기준단위"] == "10㎡" and all(not x["환산근거"] for x in _lines(ten)) + + +def test_two_routes_same_shape(): + """M01 `/text` 와 M02 계산서 — 줄 차례 · 덧칸 값이 같음(금액 끝수만 다를 수 있음).""" + for key in ("GF000169", "GF000103", "GF000219"): + m01 = _lines(_text(key)) + m02 = _lines(standard.sheet(key, {}), "묶음") + assert len(m01) == len(m02) + for a, b in zip(m01, m02): + assert set(b) == {"이름", "글", "금액", *msl.KEYS} + assert a["이름"] == b["이름"] + assert [a.get(k) for k in msl.KEYS] == [b.get(k) for k in msl.KEYS] From b62a1d935646c9fd1f6011f5695b6ad24971e788 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:04:31 +0900 Subject: [PATCH 16/30] =?UTF-8?q?feat(715-3):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=A4=84=20=EB=8D=AE=EA=B0=9C(=ED=8C=A8?= =?UTF-8?q?=EC=B9=98)=20=C2=B7=20SK=20=EC=B0=B8=EC=A1=B0=20=EC=A4=84=20?= =?UTF-8?q?=C2=B7=20[=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5]=20=EA=B8=B8=20=C2=B7=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EB=8B=A8=EC=9C=84=20=EB=B9=88=EC=B9=B8=20?= =?UTF-8?q?=EB=A7=89=EA=B8=B0=20=C2=B7=20=E3=80=8C1=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=EB=8B=B9=E3=80=8D=20=EB=A8=B8=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Labor_Patch.py — 줄 칸 패치 = SK 꼴 {덮개, 뺀줄, 더한줄}(한 벌) · 로직 줄 · SK 참조 줄만 · 줄 고름 · 자재표시(차례)를 cid 로 바꿔 뺀줄 뒤 제자리 · 셈 = 이름 없는 SK 로 M02_Sangeun_Calc - M02_Labor_Doc — clean 이 패치 정리 · calc 로직 줄 · 참조 줄 패치 갈래(몇 줄 · 690 줄) - M02_Labor_Expand — 사전조합 안 줄 패치 갈래 · 머리 「1 ㎡ 당」 - M02_Labor_Ref — 사전조합 줄에 패치 남김 · 줄 있는 일위대가 단위 빈칸 400(빈 새 문서는 받음 — 화면 새로 단추가 단위 null) · 409 글 종류 이름 사전조합 → 일위대가 · labor → 구조물 조합 - POST /api/m02/layers/{층}/sangeun/from-line(층 쓰기 권한) — 새 SK + 바꿀 참조 줄 · 셈 답에 머리 - Labor_Calc.ts 짝은 그대로(settle 은 서버 기준을 받음 · 거울 시험 통과) - 낡은 시험 고침: test_m02_labor_ref · test_175_precombo_mats · test_204_2_b09_bill_library 의 줄 있는 사전조합에 단위(빈칸 막기) · 참조 줄 분모 = 그 사전조합 단위 - 새 시험 test_715_3_labor_patch.py(임시 storage) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Labor_Doc.py | 8 +- M02_MasterTemplete/M02_Labor_Expand.py | 26 +++- M02_MasterTemplete/M02_Labor_Patch.py | 123 ++++++++++++++++ M02_MasterTemplete/M02_Labor_Ref.py | 12 +- M02_MasterTemplete/M02_Sangeun_Calc.py | 24 +++- M02_MasterTemplete/M02_Sangeun_Router.py | 21 ++- resources/tester/test_175_precombo_mats.py | 14 +- .../tester/test_204_2_b09_bill_library.py | 2 +- resources/tester/test_715_3_labor_patch.py | 134 ++++++++++++++++++ resources/tester/test_715_sangeun_store.py | 9 +- resources/tester/test_m02_labor_ref.py | 12 +- 11 files changed, 359 insertions(+), 26 deletions(-) create mode 100644 M02_MasterTemplete/M02_Labor_Patch.py create mode 100644 resources/tester/test_715_3_labor_patch.py diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index dff01a191..71d496dd0 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -42,6 +42,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Labor_Patch as patch from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Labor_Spec as spec from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -127,6 +128,7 @@ def clean(doc: Any) -> dict[str, Any]: "별칭": alias, "메모": str(memo) if memo else None, "수량": _qty(one.get("수량"), where), + **patch.ref_clean(one, ref, where), # 그 줄 덮개(715-3) } ) continue @@ -155,6 +157,7 @@ def clean(doc: Any) -> dict[str, Any]: **({"켜쌓기율": rate} if rate is not None else {}), **({"고름": picks} if picks else {}), **({"승격": True} if one.get("승격") is True else {}), + **patch.clean(one, where, whole, logic), # 그 줄 덮개(715-3) **( {"자재표시": shown} if (shown := _mat_marks(one.get("자재표시"), logic)) else {} ), @@ -512,7 +515,7 @@ def calc( lines = [] for one in doc.get("담은로직") or []: if _is_ref(one): - lines.append(ref_line(one["참조"], expand(one["참조"]))) + lines.append(ref_line(one["참조"], patch.ref(one, expand))) continue if fixed.is_fixed(one): lines.append(fixed.line(whole, one[fixed.FIXED])) @@ -525,6 +528,9 @@ def calc( "분모": pill.get("단위"), "배": pill.get("배") or 1, } + if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 셈 · 별도계상까지 + lines.append({**line, **patch.run(whole, one, read)}) + continue got = run_line(whole, code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) lines.append({**line, **drop_apart(got, one.get("자재표시"))}) mats = [ diff --git a/M02_MasterTemplete/M02_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py index 35a73300c..10054f5c7 100644 --- a/M02_MasterTemplete/M02_Labor_Expand.py +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -11,6 +11,7 @@ from decimal import Decimal from typing import Any from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Labor_Patch as patch from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Sangeun_Calc as calc @@ -81,10 +82,13 @@ class Expand: from M02_MasterTemplete import M02_Labor_Doc as labor pill = pill or {} - got = labor.run_line( - self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") - ) - got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1) + if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지 + got = patch.run(self.whole, one, self.read) + else: + got = labor.run_line( + self.whole, one["로직"], one.get("입력값"), one.get("켜쌓기율"), one.get("고름") + ) + got = labor.drop_apart(got, one.get("자재표시")) # 별도계상 로직 자재 뺌(222-1) times = labor._number(pill.get("배")) or Decimal(1) base = got.get("기준") return { @@ -109,9 +113,10 @@ class Expand: inner, rows, why = [], [], None for one in lines: ref = one.get("참조") - if isinstance(ref, dict): - head, sub = {"참조": ref}, self(ref, trail) - elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음) + if isinstance(ref, dict): # 줄 덮개(715-3) 있는 SK 참조 = 그 줄만 다시 셈 + head = {"참조": ref} + sub = self(ref, trail) if not one.get(patch.FIELD) else patch.ref(one, self) + elif one.get("로직"): # 로직 줄(51-11) = 1 단위 값(기준 ÷ 배 · 승격과 같음) · 덮개 있음 head, sub = {"로직": one["로직"]}, self._logic(one, by_logic.get(one["로직"])) elif fixed.is_fixed(one): # 고정 줄(705-3) = 줄 단가 · 비율 줄 = 끝에(`ratio_row`) head, sub = {"고정": one["고정"]}, fixed.line(self.whole, one["고정"]) @@ -156,7 +161,14 @@ class Expand: return { "이름": doc.get("이름"), "단위": doc.get("단위"), + "머리": head_of(doc), "기준": None if why else labor._sum(rows), "까닭": why, "안": inner, } + + +def head_of(doc: dict) -> str | None: + """상세 머리 「1 ㎡ 당」(715-3 · 일위대가 = 기준단위 1 당) — 단위 빈 옛 문서는 None.""" + unit = str(doc.get("단위") or "").strip() + return f"1 {unit} 당" if unit else None diff --git a/M02_MasterTemplete/M02_Labor_Patch.py b/M02_MasterTemplete/M02_Labor_Patch.py new file mode 100644 index 000000000..aa0b3db09 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Patch.py @@ -0,0 +1,123 @@ +"""일위대가 · 사전조합 줄 덮개(PLAN 715-3 · 요구서 내역12 4장 16 · 4-1 확정 5). + +줄 칸 `패치` = 단가산출(SK) 문서 꼴 `{덮개, 뺀줄, 더한줄}`(한 벌 · `M02_Sangeun_Doc`) — 그 줄에만 · +로직 줄(`{로직, 입력값, 고름, 자재표시, 패치}`) · 단가산출 참조 줄(`{참조: SK, 패치}`)만 받음 · +줄 `고름` · `자재표시`(호표 차례 열쇠)는 바탕 줄 차례 그대로 — 셈 때 cid 로 바꿔 뺀줄 뒤에도 제자리. +더한줄은 인력 · 재료 · 기계 · 로직 · 고정만(참조는 일위대가에 참조 줄로). +셈 = 줄을 이름 없는 SK 문서로(`as_doc`) → `M02_Sangeun_Calc` 한 벌 · 로직 줄 기준 = 결과단위 값(배는 +알약 · `settle` 그대로) · 참조 줄 = 1 단위 값. [단가산출로 저장] = `as_doc` + 이름 · 단위 → 층 SK. +""" + +from __future__ import annotations + +from typing import Any + +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +FIELD = "패치" +KEEP = ("덮개", "뺀줄", "더한줄") +TEMP = "SK-000000" # 이름 없는 줄 단가산출 — 저장 안 함 + + +def _bad(text: str): + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + return store.StoreError(400, text) + + +def clean(one: dict, where: str, whole: Any, row: dict | None, opened: list | None = None) -> dict: + """줄 칸 `패치` 정리 — 없거나 비면 `{}`(칸 안 둠) · 틀리면 400.""" + raw = one.get(FIELD) + if not raw: + return {} + if not isinstance(raw, dict): + raise _bad(f"{where} · 패치는 묶음") + if any("참조" in x for x in raw.get("더한줄") or [] if isinstance(x, dict)): + raise _bad(f"{where} · 줄 더한줄엔 참조 못 넣음 — 일위대가에 참조 줄로") + body = {k: raw.get(k) for k in KEEP} + try: + got = sd.clean( + {"키": TEMP, "이름": "줄", "단위": "식", **body, "열린입력": opened or []}, row, whole + ) + except sd.DocError as e: + raise _bad(f"{where} · {e}") from e + out = {k: got[k] for k in KEEP if got[k]} + return {FIELD: out} if out else {} + + +def ref_clean(one: dict, ref: dict, where: str) -> dict: + """단가산출 참조 줄 패치 — 그 SK 의 열린입력이면 품 배율만.""" + from M02_MasterTemplete import M02_Labor_Ref as refs + + if not one.get(FIELD): + return {} + if refs.kind_of(ref["키"]) != sd.KIND: + raise _bad(f"{where} · 줄 덮개는 로직 줄 · 단가산출 참조 줄만") + from M01_MasterData import M01_MasterData_Store as m01 + + doc = refs.read_ref(ref) or {} + return clean(one, where, m01.loaded()[1], None, doc.get("열린입력")) + + +def _keyed(value: Any, ids: list[str]) -> dict: + """차례 열쇠 → cid 열쇠(바탕 줄 차례).""" + out = {} + for n, v in (value or {}).items() if isinstance(value, dict) else (): + at = int(n) if str(n).isdigit() else -1 + if 0 <= at < len(ids): + out[ids[at]] = v + return out + + +def as_doc(one: dict, whole: Any, name: str = "줄", unit: str = "식") -> dict[str, Any]: + """일위대가 줄 → SK 문서(바탕 = 그 로직 · 그 SK) — 고름 · 자재표시는 cid 로.""" + body = {k: (one.get(FIELD) or {}).get(k) or ([] if k != "덮개" else {}) for k in KEEP} + head = {"키": TEMP, "이름": name, "단위": unit, **body} + if "참조" in one: + return {**head, "바탕": {"참조": one["참조"]}} + try: + ids = sd.line_ids(whole.logic(one["로직"])) + except Exception: # noqa: BLE001 — 없는 로직 = 셈이 까닭 + ids = [] + extra = {"켜쌓기율": one["켜쌓기율"]} if one.get("켜쌓기율") is not None else {} + return { + **head, + **extra, + "바탕": {"로직": one["로직"]}, + "입력값": dict(one.get("입력값") or {}), + "고름": _keyed(one.get("고름"), ids), + "자재표시": _keyed(one.get("자재표시"), ids), + } + + +def run(whole: Any, one: dict, read: Any = None) -> dict[str, Any]: + """패치 있는 로직 줄 기준(`run_line` 꼴 · 별도계상 뺀 뒤 · 기준 = 결과단위 값).""" + from M02_MasterTemplete import M02_Labor_Ref as refs + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + try: + doc = sd.clean(as_doc(one, whole), None, whole) + return calc.run(doc, whole, None, read or refs.read_ref) + except sd.DocError as e: + return {"기준": None, "까닭": str(e)} + + +def ref(one: dict, expand: Any) -> dict[str, Any]: + """참조 줄 펼침 — 패치 있는 SK 참조는 그 줄만 다시 셈(캐시 안 씀) · 그 밖 `expand` 그대로.""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + if not one.get(FIELD): + return expand(one["참조"]) + doc = as_doc(one, expand.whole) + base = expand.read(one["참조"]) or {} + doc = {**doc, "이름": base.get("이름") or doc["이름"], "단위": base.get("단위") or doc["단위"]} + return calc.unit(doc, expand.whole, expand, ()) + + +def to_sangeun(one: dict, name: str, unit: str, whole: Any) -> dict[str, Any]: + """[단가산출로 저장] 문서 — 줄의 바탕 · 입력값 · 고름 · 덮개 → 이름 붙인 SK(키는 서버).""" + if "로직" not in one and "참조" not in one: + raise _bad("로직 줄 · 단가산출 참조 줄만 단가산출로 저장") + doc = as_doc(one, whole, name, unit) + doc.pop("키") + return doc diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index ae47ac4be..7bf6f9943 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -163,7 +163,10 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: out: dict[str, Any] = {"종류": TITLE[kind], "키": key} if not name: raise _bad("사전조합 이름이 빔") - out.update(이름=name, 단위=unit or None, 담은로직=_combo_lines(doc, key)) + lines = _combo_lines(doc, key) + if not unit and (lines or doc.get("자재")): # 일위대가 = 기준단위 1 당(요구 27 · 715-3) · + raise _bad("일위대가 단위가 빔 — 1 단위 값의 단위") # 빈 새 문서(이름만)는 받음 + out.update(이름=name, 단위=unit or None, 담은로직=lines) out["자재"] = _combo_mats(doc, out["담은로직"]) out.update({k: str(doc.get(k) or "").strip() for k in ("구분", "상세구분")}) out["본뜬"] = doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None @@ -226,9 +229,9 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: lines = [] for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]: - head = {k: one[k] for k in ("참조", "고정") if k in one} or {"로직": one["로직"]} + head = {k: one[k] for k in ("참조", "고정", "패치") if k in one} # 패치 = 줄 덮개(715-3) if "로직" in one: - head["입력값"] = one["입력값"] + head.update(로직=one["로직"], 입력값=one["입력값"]) head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one}) head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3) lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]}) @@ -318,7 +321,8 @@ def users(ref: Ref) -> list[str]: """`ref` 를 담은 일위대가 · 사전조합 · 단가산출(바탕 · 더한줄) — 「어디 이름」 목록(모든 층 훑음 · 프로젝트 뺌).""" rid, out = ref_id(ref), [] - for kind, what in (("labor", "일위대가"), ("precombo", "사전조합"), ("sangeun", "단가산출")): + # 「일위대가」 = 사전조합 새 이름(내역 14 확정 1) · 구조물 조합 일위대가 = 「구조물 조합」 + for kind, what in (("labor", "구조물 조합"), ("precombo", "일위대가"), ("sangeun", "단가산출")): for at, path in layers.layer_files(kind): try: doc = json.loads(path.read_bytes()) diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 0ea7b5e10..a6f0743fd 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -170,6 +170,10 @@ def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]: return {**head, "기준": got["1단위"], "까닭": why, "줄": lines, "짝잃음": got["짝잃음"]} +def head_of(doc: dict) -> str: + return f"1 {doc.get('단위')} 당" + + # ── 저장 ────────────────────────────────────────────────────────────── def _check_refs(doc: dict, here_: refs.Here) -> None: me = {"층": here_[0], "주인": here_[1], "키": doc["키"]} @@ -227,4 +231,22 @@ def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]: got = run(clean, whole, given, trail=(me,)) except sd.DocError as e: raise _fail(e) from e - return labor._json(got) + return labor._json({**got, "머리": head_of(clean)}) + + +def from_line(line: Any, name: str, unit: str) -> dict[str, Any]: + """[단가산출로 저장](715-3) — 일위대가 줄(바탕 · 입력값 · 고름 · 줄 덮개) → 지금 층 새 SK · + 답 = `{이름: 새 키, 줄: 그 SK 참조 줄}`(일위대가 문서는 화면 [저장] 때 바뀜).""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_Labor_Patch as patch + + if not isinstance(line, dict): + raise store.StoreError(400, "줄이 묶음 아님") + doc = patch.to_sangeun(line, name, unit, m01.loaded()[1]) + made = refs.create(sd.KIND, doc) + at = refs.here() + keep = {k: line[k] for k in ("별칭", "메모", "수량") if k in line} + return { + "이름": made["이름"], + "줄": {"참조": {"층": at[0], "주인": at[1], "키": made["이름"]}, **keep}, + } diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index bbc516045..72d0a415e 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -1,4 +1,5 @@ -"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc`. +"""M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc` · +[단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한). 저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만. 몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값). @@ -37,6 +38,24 @@ def _calc(body: SangeunCalcBody) -> dict[str, Any]: return calc.calc_doc(doc, body.입력값) +class FromLineBody(BaseModel): + 줄: dict[str, Any] + 이름: str + 단위: str + + +@router.post("/layers/{layer}/sangeun/from-line") +def sangeun_from_line( + layer: Layer, + body: FromLineBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[단가산출로 저장](715-3) — 일위대가 줄 → 그 층 새 단가산출 · 답 = 새 키 + 바꿀 참조 줄.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, calc.from_line, body.줄, body.이름, body.단위) + + @router.post("/layers/{layer}/sangeun/calc") def calc_sangeun( layer: Layer, diff --git a/resources/tester/test_175_precombo_mats.py b/resources/tester/test_175_precombo_mats.py index 248695cfd..3688f2103 100644 --- a/resources/tester/test_175_precombo_mats.py +++ b/resources/tester/test_175_precombo_mats.py @@ -30,7 +30,7 @@ SYS = {"층": "system", "주인": None} def _save(folder, doc): with store.at(folder): - key = refs.create("precombo", {"이름": "자재 조합", **doc})["이름"] + key = refs.create("precombo", {"이름": "자재 조합", "단위": "㎡", **doc})["이름"] return key, json.loads((folder / "precombo" / f"{key}.json").read_bytes()) @@ -82,9 +82,9 @@ def _world_combo(world): # noqa: F811 # MAT = 직접계산(222-1 · 기본 별도계상이면 셈에서 빠짐) mats = [{"코드": MAT, "수량": {"손": 2}, "별도계상": "직접계산"}, {"코드": WIRE, "수량": None}] with store.at(world["system"]): - key = refs.create("precombo", {"이름": "자재 조합", "담은로직": lines, "자재": mats})[ - "이름" - ] + key = refs.create( + "precombo", {"이름": "자재 조합", "단위": "㎡", "담은로직": lines, "자재": mats} + )["이름"] stone = Decimal(str(labor.run_line(m01.loaded()[1], STONE, {})["기준"]["계"])) return key, stone + _price(MAT) * 2 @@ -114,7 +114,11 @@ def test_B08_참조_줄_재료에_사전조합_자재_손_곱(world): # noqa: F with store.at(world["system"]): outer = refs.create( "precombo", - {"이름": "겹", "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}]}, + { + "이름": "겹", + "단위": "㎡", + "담은로직": [{"참조": {**SYS, "키": key}, "수량": {"손": 3}}], + }, )["이름"] got = _ref_mats(m01.loaded()[1], refs.read_ref, {**SYS, "키": outer}) mats = [(m["품목"]["키"], q) for m, q in got if m.get("자재")] diff --git a/resources/tester/test_204_2_b09_bill_library.py b/resources/tester/test_204_2_b09_bill_library.py index e0cfa1ebf..1a6a9a5c6 100644 --- a/resources/tester/test_204_2_b09_bill_library.py +++ b/resources/tester/test_204_2_b09_bill_library.py @@ -62,7 +62,7 @@ def world(tmp_path, monkeypatch): company = layers.company_dir(7) company.mkdir(parents=True) with store.at(company): - other = refs.create("precombo", {"이름": "회사 조합", "담은로직": lines[:1]})["이름"] + other = refs.create("precombo", {"이름": "회사 조합", "단위": "㎡", "담은로직": lines[:1]})["이름"] project = tmp_path / "storage" / "7" / "1" / "proj" project.mkdir(parents=True) return {"system": system, "key": key, "company": other, "root": project} diff --git a/resources/tester/test_715_3_labor_patch.py b/resources/tester/test_715_3_labor_patch.py new file mode 100644 index 000000000..23b87f316 --- /dev/null +++ b/resources/tester/test_715_3_labor_patch.py @@ -0,0 +1,134 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3 · `M02_Labor_Patch`) — 임시 storage. + +줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 · 줄 고름(차례)이 뺀줄 뒤 제자리 · +[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 일위대가 단위 빈칸 400 · 덮개 없는 줄 = 옛 값 그대로. +""" + +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" # 원/㎡ · 인력 둘(LB000036 · LB000002) + 「노무비 * 3 / 100」 +TWO_MATS = "GC000565" # 원/m · 재료 줄 둘 +COVER = {"덮개": {"labor:LB000036#0": {"qty": 0.5}}} + + +def _whole(): + return m01.loaded()[1] + + +def _sc(folder, lines, unit="㎡"): + with store.at(folder): + return refs.create("precombo", {"이름": "조합", "단위": unit, "담은로직": lines})["이름"] + + +def _value(folder, key): + with store.at(folder): + return refs.Expand(_whole())({**MINE, "키": key}) + + +def test_logic_line_cover_in_precombo(world): + """사전조합 로직 줄 덮개 = 같은 덮개 SK 값 · 손셈(품 0.5 × 단가 + 둘째 줄 · 3 % 덧줄).""" + bare = labor.run_line(_whole(), PCT, {}) + sc = _sc(world["personal"], [{"로직": PCT, "패치": COVER}]) + with store.at(world["personal"]): + kept = store.read("precombo", sc)["문서"]["담은로직"][0] + assert kept["패치"] == COVER + got = _value(world["personal"], sc)["기준"] + labor_sum = Decimal("0.5") * Decimal(str(bare["줄"][0]["단가"])) + Decimal( + str(bare["줄"][1]["금액"]) + ) + assert got["노무비"] == pytest.approx(labor_sum) + assert got["경비"] == pytest.approx(labor_sum * 3 / 100, abs=1) + + +def test_line_pick_by_order_stays_after_drop(world): + """줄 고름 = 바탕 호표 차례(2) · 패치 뺀줄로 첫 재료 줄을 빼도 그 자재가 그 줄에.""" + import master_pick as mp + + whole = _whole() + row = whole.logic(TWO_MATS) + item = row["호표"][2] + want = mp.search(whole, item["요소"], {}, item.get("단위"))[1] + one = { + "로직": TWO_MATS, + "고름": {"2": want}, + "자재표시": {"2": {"별도계상": "직접계산"}}, + "패치": {"뺀줄": ["material:" + row["호표"][1]["이름"] + "#0"]}, + } + with store.at(world["personal"]): + doc = labor.clean({"담은로직": [one]}) + got = labor.calc(doc, {})["줄"][0] + picked = [x for x in got["줄"] if x.get("품목")] + assert [x["품목"]["키"] for x in picked] == [want] + + +def test_ref_line_cover_only_that_line(world): + """SK 참조 줄 덮개 = 그 줄만 · SK 원본 · 다른 줄 값 그대로.""" + with store.at(world["personal"]): + sk = refs.create( + "sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []} + )["이름"] + before = store.read("sangeun", sk)["판"] + ref = {**MINE, "키": sk} + sc = _sc( + world["personal"], + [{"참조": ref, "수량": {"손": 1}, "패치": COVER}, {"로직": "GC001111", "수량": {"손": 1}}], + ) + inner = _value(world["personal"], sc)["안"] + plain = _value(world["personal"], _sc(world["personal"], [{"참조": ref}]))["기준"] + covered = calc.calc_doc( + {"키": "SK-000999", "이름": "x", "단위": "㎡", "바탕": {"로직": PCT}, **COVER} + ) + assert inner[0]["기준"]["계"] == pytest.approx(Decimal(str(covered["1단위"]["계"]))) + assert inner[0]["기준"]["계"] != plain["계"] + with store.at(world["personal"]): + assert store.read("sangeun", sk)["판"] == before + + +def test_save_as_sangeun(world): + """[단가산출로 저장] — 새 SK(바탕 · 입력값 · 고름 cid · 덮개) · 참조 줄 · 같은 1 단위 값.""" + one = {"로직": PCT, "패치": COVER, "수량": {"손": 2}, "별칭": "내 줄"} + with store.at(world["personal"]): + line_value = labor.calc(labor.clean({"담은로직": [one]}), {})["줄"][0]["기준"] + got = calc.from_line(one, "줄로 만든", "㎡") + doc = store.read("sangeun", got["이름"])["문서"] + value = calc.calc_doc(doc)["기준"] + assert got["줄"] == {"참조": {**MINE, "키": got["이름"]}, "수량": {"손": 2}, "별칭": "내 줄"} + assert doc["바탕"] == {"로직": PCT} and doc["덮개"]["labor:LB000036#0"]["qty"] == 0.5 + assert value["계"] == pytest.approx(line_value["계"]) + + +def test_refusals(world): + with store.at(world["personal"]): + with pytest.raises(store.StoreError, match="단위가 빔"): + refs.create("precombo", {"이름": "x", "단위": " ", "담은로직": [{"로직": PCT}]}) + assert refs.create("precombo", {"이름": "빈 새 문서", "단위": None, "담은로직": []})["이름"] + bad = {"로직": PCT, "패치": {"더한줄": [{"참조": {**MINE, "키": "SK-000001"}, "수량": 1}]}} + with pytest.raises(store.StoreError, match="참조 못 넣음"): + labor.clean({"담은로직": [bad]}) + with pytest.raises(store.StoreError, match="수가 아님"): + labor.clean( + {"담은로직": [{"로직": PCT, "패치": {"덮개": {"labor:LB000036#0": {"qty": "식"}}}}]} + ) + sc = _sc(world["personal"], [{"로직": PCT}]) + with pytest.raises(store.StoreError, match="줄 덮개는"): + labor.clean({"담은로직": [{"참조": {**MINE, "키": sc}, "패치": COVER}]}) + + +def test_no_patch_same_value(world): + """덮개 없는 줄 = run_line 값 그대로(옛 셈 길).""" + sc = _sc(world["personal"], [{"로직": PCT, "패치": {}}]) + with store.at(world["personal"]): + assert "패치" not in store.read("precombo", sc)["문서"]["담은로직"][0] + assert _value(world["personal"], sc)["기준"]["계"] == pytest.approx( + labor.run_line(_whole(), PCT, {})["기준"]["계"] + ) diff --git a/resources/tester/test_715_sangeun_store.py b/resources/tester/test_715_sangeun_store.py index 753028f25..0debcf8d0 100644 --- a/resources/tester/test_715_sangeun_store.py +++ b/resources/tester/test_715_sangeun_store.py @@ -1,3 +1,4 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 """M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage. 고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 · @@ -134,14 +135,18 @@ def test_precombo_holds_sangeun(world): with store.at(world["personal"]): sc = refs.create( "precombo", - {"이름": "조합", "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}]}, + { + "이름": "조합", + "단위": "㎡", + "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}], + }, )["이름"] got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {}) with pytest.raises(store.StoreError) as e: store.delete("sangeun", sk) one = _calc(world["personal"], sk)["1단위"]["계"] assert got["반영합"]["계"] == pytest.approx(one * 2, abs=1) - assert e.value.status == 409 and "사전조합 개인 7/42" in str(e.value.detail) + assert e.value.status == 409 and "일위대가 개인 7/42" in str(e.value.detail) def test_layer_copy_round_trip(world): diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index 7f96f4ad3..144178dde 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -70,7 +70,7 @@ def _promote(folder, code=TEN, values=None, **extra): def _combo(folder, lines, name="조합"): with store.at(folder): - return refs.create("precombo", {"이름": name, "담은로직": lines})["이름"] + return refs.create("precombo", {"이름": name, "단위": "㎡", "담은로직": lines})["이름"] def _read(folder, kind, key): @@ -225,11 +225,15 @@ def test_calc_는_참조를_펼침_승격은_기준_나누기_배_사전조합 one = got["줄"][0] want = Decimal(str(_base(TEN)["계"])) / 10 * 2 assert one["기준"]["계"] == pytest.approx(float(want)) - assert (one["분모"], one["배"], one["알약"]) == (None, 1, refs.ref_id(ref)) + assert (one["분모"], one["배"], one["알약"]) == ( + "㎡", + 1, + refs.ref_id(ref), + ) # 단위 = 그 사전조합 것 assert one["반영"]["계"] == pytest.approx(float(want * 5)) assert one["안"][0]["수량"] == 2 and one["안"][0]["참조"]["키"] == sp pills = labor.pills(doc) - assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "", "로직": [], "참조": ref}] + assert pills == [{"키": refs.ref_id(ref), "이름": "둘", "단위": "㎡", "로직": [], "참조": ref}] def test_승격_줄_상세는_원_로직_키_표_사전조합은_안_줄만(world): @@ -364,7 +368,7 @@ def test_길_새로_복사_권한(world): } client = TestClient(app) url = "/api/m02/layers/{}/library/{}" - body = {"문서": {"이름": "가", "담은로직": [{"로직": TEN}]}} + body = {"문서": {"이름": "가", "단위": "㎡", "담은로직": [{"로직": TEN}]}} got = client.post(url.format("personal", "precombo"), json=body).json() assert got["이름"] == "SC-000001" assert client.post(url.format("system", "precombo"), json=body).status_code == 403 From 458569046566408ac6ea4cd809fab25851ee4cc2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:04:57 +0900 Subject: [PATCH 17/30] =?UTF-8?q?style(715-3):=20715=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=20=EB=A8=B8=EB=A6=AC=20=EA=B8=80=20=EC=A4=84=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=C2=B7=20fixture=20=EB=B0=9B=EC=95=84=20=EC=94=80?= =?UTF-8?q?=20noqa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_715_3_labor_patch.py | 7 ++++--- resources/tester/test_715_sangeun_store.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/resources/tester/test_715_3_labor_patch.py b/resources/tester/test_715_3_labor_patch.py index 23b87f316..b6b4de67a 100644 --- a/resources/tester/test_715_3_labor_patch.py +++ b/resources/tester/test_715_3_labor_patch.py @@ -1,8 +1,9 @@ # ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 -"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3 · `M02_Labor_Patch`) — 임시 storage. +"""일위대가 · 사전조합 줄 덮개 · [단가산출로 저장] · 단위 빈칸 막기(PLAN 715-3) — 임시 storage. -줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 · 줄 고름(차례)이 뺀줄 뒤 제자리 · -[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 일위대가 단위 빈칸 400 · 덮개 없는 줄 = 옛 값 그대로. +줄 덮개 = 그 줄에만(단가산출 원본 그대로) · 값 = 같은 덮개 SK 값 = 손셈 · +줄 고름(차례)이 뺀줄 뒤 제자리 · +[단가산출로 저장] = 새 SK + 참조 줄 · 같은 값 · 단위 빈칸 400 · 덮개 없는 줄 = 옛 값. """ from decimal import Decimal diff --git a/resources/tester/test_715_sangeun_store.py b/resources/tester/test_715_sangeun_store.py index 0debcf8d0..05be42e2d 100644 --- a/resources/tester/test_715_sangeun_store.py +++ b/resources/tester/test_715_sangeun_store.py @@ -1,5 +1,5 @@ # ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 -"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2 · `M02_Sangeun_Calc` · `M02_Labor_Ref`) — 임시 storage. +"""M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2) — 임시 storage. 고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 · 순환 422 · 쓰는 곳 지우기 409 · 사전조합이 SK 를 담음 · 층 복사 왕복 같은 값. @@ -47,7 +47,7 @@ def _bare(code): def test_qty_cover_hand_and_pct_recomputed(world): - """품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄이 덮인 노무비에서 다시 셈 · 원값 = 서버가 그때 M01 값.""" + """품 덮개 = 손셈 · 「노무비 * 3 / 100」 덧줄 다시 셈 · 원값 = 서버가 셈.""" bare = _bare(PCT) first, second = bare["줄"][0], bare["줄"][1] key = _sk( From fdd781ca465315d323e55d5e9225d7576cdb9ee9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:04:58 +0900 Subject: [PATCH 18/30] auto: 2026-11-01 16:04 (ESD_LAPTOP) --- resources/tester/test_715_sangeun_engine.py | 84 +++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 resources/tester/test_715_sangeun_engine.py diff --git a/resources/tester/test_715_sangeun_engine.py b/resources/tester/test_715_sangeun_engine.py new file mode 100644 index 000000000..e0c01a8e3 --- /dev/null +++ b/resources/tester/test_715_sangeun_engine.py @@ -0,0 +1,84 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""M02 단가산출(SK) 엔진 입력 덧칸(PLAN 715-1b · `master_pick` `단가` · `값묶음`) — 고친 줄 값 = 손셈. + +줄 단가 덮개 = 손 단가 · 로직 줄 노 · 재 · 경 덮개 = 값묶음(일부면 엔진 값 위) · 덧줄 % 가 덮인 값에서 +다시 셈 · 더한줄 참조(SK · SC) · 고정 = 먼저 셈한 1 단위 묶음. 덧칸 없는 줄 = 엔진 값 그대로. +""" + +from decimal import Decimal + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」 +SUB = "GC000999" # 원/주 · 인력 둘 + 로직 줄(GC000275) + + +def _whole(): + return m01.loaded()[1] + + +def _run(**doc): + from M02_MasterTemplete import M02_Sangeun_Doc as sd + + whole = _whole() + clean = sd.clean({"키": "SK-000001", "이름": "시험", "단위": "㎡", **doc}, None, whole) + return calc.run(clean, whole) + + +def test_price_cover_is_hand_price_and_pct_follows(): + bare = labor.run_line(_whole(), PCT, {}) + first, second = bare["줄"][0], bare["줄"][1] + got = _run(바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"labor": 200000}}) + line = got["줄"][0] + assert line["단가"] == 200000 and line["출처"] == "손 단가" + want = Decimal(str(first["수량"])) * 200000 + Decimal(str(second["금액"])) + assert got["기준"]["노무비"] == pytest.approx(float(want)) + assert got["기준"]["경비"] == pytest.approx(float(want * 3 / 100), abs=1) + + +def test_bundle_cover_on_logic_line_partial(): + bare = labor.run_line(_whole(), SUB, {}) + sub = bare["줄"][2] + got = _run(바탕={"로직": SUB}, 덮개={"logic:GC000275#0": {"expense": 1000}}) + line = got["줄"][2] + qty = Decimal(str(sub["수량"])) + assert line["비목"]["경비"] == pytest.approx(float(qty * 1000)) + assert line["비목"]["노무비"] == pytest.approx(float(sub["비목"]["노무비"])) # 안 덮은 칸 = 엔진 값 + + +def test_untouched_lines_same_as_engine(): + bare = labor.run_line(_whole(), SUB, {}) + got = _run(바탕={"로직": SUB}) + assert got["기준"]["계"] == pytest.approx(float(bare["기준"]["계"])) + + +def test_added_ref_and_fixed_lines(world): + """더한줄 = SK 참조 2 · 고정 줄 1 — 값 = 2 × 그 SK 1 단위 + 고정 단가.""" + with store.at(world["personal"]): + sk = refs.create("sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []})[ + "이름" + ] + unit = calc.calc_doc(store.read("sangeun", sk)["문서"])["1단위"]["계"] + top = refs.create( + "sangeun", + { + "이름": "위", + "단위": "식", + "바탕": None, + "더한줄": [ + {"참조": {**MINE, "키": sk}, "수량": 2}, + {"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 5000}}, "수량": 1}, + ], + }, + )["이름"] + got = calc.calc_doc(store.read("sangeun", top)["문서"]) + assert got["기준"]["계"] == pytest.approx(unit * 2 + 5000, abs=1) + expense = labor.run_line(_whole(), PCT, {})["기준"]["경비"] + assert got["기준"]["경비"] == pytest.approx(5000 + 2 * float(expense), abs=1) From dfa72ac29248562e44beff3b369cecc8644865bb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:05:18 +0900 Subject: [PATCH 19/30] =?UTF-8?q?test(715-1b):=20auto=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=EC=97=90=20=EC=93=B8=EB=A0=A4=20=EC=98=AC=EB=9D=BC=EA=B0=84=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20=EB=8D=A7=EC=B9=B8=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=EC=9D=84=20=EB=BA=8C=20=E2=80=94=20=EC=97=94=EC=A7=84=20?= =?UTF-8?q?=EB=8D=A7=EC=B9=B8(sub3=20712=20=EB=92=A4)=20=EC=A0=84=EC=97=94?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=C2=B7=20715-1b=20=EB=95=8C=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=84=A3=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_715_sangeun_engine.py | 84 --------------------- 1 file changed, 84 deletions(-) delete mode 100644 resources/tester/test_715_sangeun_engine.py diff --git a/resources/tester/test_715_sangeun_engine.py b/resources/tester/test_715_sangeun_engine.py deleted file mode 100644 index e0c01a8e3..000000000 --- a/resources/tester/test_715_sangeun_engine.py +++ /dev/null @@ -1,84 +0,0 @@ -# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 -"""M02 단가산출(SK) 엔진 입력 덧칸(PLAN 715-1b · `master_pick` `단가` · `값묶음`) — 고친 줄 값 = 손셈. - -줄 단가 덮개 = 손 단가 · 로직 줄 노 · 재 · 경 덮개 = 값묶음(일부면 엔진 값 위) · 덧줄 % 가 덮인 값에서 -다시 셈 · 더한줄 참조(SK · SC) · 고정 = 먼저 셈한 1 단위 묶음. 덧칸 없는 줄 = 엔진 값 그대로. -""" - -from decimal import Decimal - -import pytest - -from M01_MasterData import M01_MasterData_Store as m01 -from M02_MasterTemplete import M02_Labor_Doc as labor -from M02_MasterTemplete import M02_Labor_Ref as refs -from M02_MasterTemplete import M02_MasterTemplete_Store as store -from M02_MasterTemplete import M02_Sangeun_Calc as calc -from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 - -PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」 -SUB = "GC000999" # 원/주 · 인력 둘 + 로직 줄(GC000275) - - -def _whole(): - return m01.loaded()[1] - - -def _run(**doc): - from M02_MasterTemplete import M02_Sangeun_Doc as sd - - whole = _whole() - clean = sd.clean({"키": "SK-000001", "이름": "시험", "단위": "㎡", **doc}, None, whole) - return calc.run(clean, whole) - - -def test_price_cover_is_hand_price_and_pct_follows(): - bare = labor.run_line(_whole(), PCT, {}) - first, second = bare["줄"][0], bare["줄"][1] - got = _run(바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"labor": 200000}}) - line = got["줄"][0] - assert line["단가"] == 200000 and line["출처"] == "손 단가" - want = Decimal(str(first["수량"])) * 200000 + Decimal(str(second["금액"])) - assert got["기준"]["노무비"] == pytest.approx(float(want)) - assert got["기준"]["경비"] == pytest.approx(float(want * 3 / 100), abs=1) - - -def test_bundle_cover_on_logic_line_partial(): - bare = labor.run_line(_whole(), SUB, {}) - sub = bare["줄"][2] - got = _run(바탕={"로직": SUB}, 덮개={"logic:GC000275#0": {"expense": 1000}}) - line = got["줄"][2] - qty = Decimal(str(sub["수량"])) - assert line["비목"]["경비"] == pytest.approx(float(qty * 1000)) - assert line["비목"]["노무비"] == pytest.approx(float(sub["비목"]["노무비"])) # 안 덮은 칸 = 엔진 값 - - -def test_untouched_lines_same_as_engine(): - bare = labor.run_line(_whole(), SUB, {}) - got = _run(바탕={"로직": SUB}) - assert got["기준"]["계"] == pytest.approx(float(bare["기준"]["계"])) - - -def test_added_ref_and_fixed_lines(world): - """더한줄 = SK 참조 2 · 고정 줄 1 — 값 = 2 × 그 SK 1 단위 + 고정 단가.""" - with store.at(world["personal"]): - sk = refs.create("sangeun", {"이름": "밑", "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []})[ - "이름" - ] - unit = calc.calc_doc(store.read("sangeun", sk)["문서"])["1단위"]["계"] - top = refs.create( - "sangeun", - { - "이름": "위", - "단위": "식", - "바탕": None, - "더한줄": [ - {"참조": {**MINE, "키": sk}, "수량": 2}, - {"고정": {"이름": "운반", "단위": "식", "단가": {"경비": 5000}}, "수량": 1}, - ], - }, - )["이름"] - got = calc.calc_doc(store.read("sangeun", top)["문서"]) - assert got["기준"]["계"] == pytest.approx(unit * 2 + 5000, abs=1) - expense = labor.run_line(_whole(), PCT, {})["기준"]["경비"] - assert got["기준"]["경비"] == pytest.approx(5000 + 2 * float(expense), abs=1) From ea1f749d992493c6fd9104d8c6abc8e343a7a535 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:05:26 +0900 Subject: [PATCH 20/30] =?UTF-8?q?test(717-2a):=20=EB=82=A1=EC=9D=80=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20339=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EB=81=9D=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EA=B3=A0=EC=B9=A8=EC=9D=B4=20Promise.all=20=EB=91=90?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=C2=B7=20=EC=83=81=EC=9D=80)=EC=9C=BC=EB=A1=9C=20=EB=B0=94?= =?UTF-8?q?=EB=80=90=20=EA=BC=B4=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- resources/tester/test_m02_hopyo_screen.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 4bc8efb8f..909b26079 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -443,7 +443,8 @@ def test_확인_창_손질_339(): assert make.index("document.dispatchEvent(new Event(HOPYO_BUILT));") < make.index("opt.onBuilt") side = _text("M02_MasterTemplete_UI_Side.ts") assert "document.addEventListener(HOPYO_BUILT, onBuilt);" in side - assert "void precombo.refresh()" in side.split("const onBuilt")[1].split(";")[0] + onb = side.split("const onBuilt")[1].split(";")[0] + assert "precombo.refresh()" in onb and "sangeun.refresh()" in onb # 717-2a — 둘 다 새로고침 sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts") assert "poll(file);" in sheet and "poll(next);" in sheet and "POLL_MS" in sheet assert "if (ok) document.dispatchEvent(new Event(HOPYO_BUILT));" in sheet From 9d72d80b5361a70c3221df0f247e3d7a206fca82 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:06:31 +0900 Subject: [PATCH 21/30] =?UTF-8?q?feat(717-4):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20[=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80]?= =?UTF-8?q?=20=E3=80=8C=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=E3=80=8D=20?= =?UTF-8?q?=ED=83=AD=20=C2=B7=20B09=20=EC=9E=AC=EB=A3=8C=20=EC=B0=BD?= =?UTF-8?q?=EA=B3=A0=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EC=B6=9C?= =?UTF-8?q?=EC=B2=98=20=C2=B7=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EB=8D=94=ED=95=9C=EC=A4=84=20=EA=B3=A0=EB=A5=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_MasterTemplete_Sangeun_Pick — listSangeun = 위 층 SK 목록(마스터 › 회사 › 개인 · 금액 = 층 목록 기준금액) · openSangeunLinePicker = SK 더한줄 고르기(M01 로직 · 일위대가 · 단가산출 · M01 재료 → 715 더한줄 꼴) - Labor_Pick — lib.sangeun 이면 탭 [기준 목록 | 단가산출] · 단가산출 탭 = 기준 목록 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = 참조 {층, 주인, SK 키} - withHopyo(일위대가 [로직 추가]) 에 sangeun 켬 · B09 단가연결 창은 그대로(SK 공사 연결 = 719-1 뒤) - B09 재료 창고 출처 탭 「단가산출」 — SK 목록 · 연결은 알림만 - locale_m2 키 넷 · 시험 test_717_4_sangeun_pick Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_UI_Store.ts | 30 ++++- .../M02_MasterTemplete_Hopyo_Pick.ts | 1 + .../M02_MasterTemplete_Labor_Pick.ts | 92 ++++++++++++++-- .../M02_MasterTemplete_Sangeun_Pick.ts | 104 ++++++++++++++++++ resources/tester/test_717_4_sangeun_pick.py | 72 ++++++++++++ ui_template/ui_template_locale_m2.ts | 4 + 6 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts create mode 100644 resources/tester/test_717_4_sangeun_pick.py diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts index 81c62c5c6..893fc0ffc 100644 --- a/B09_Estimation/B09_Estimation_UI_Store.ts +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B09_Estimation_UI_Store.ts - * B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 품셈 · 지난 공사). + * B09 설계내역서 오른쪽 재료 창고(PLAN 703-1) — 찾기 하나 + 출처 탭(이 공사 · 내 것 · 회사 · 단가산출 · 품셈 · 지난 공사). + * 단가산출(717-4) = 위 층 M02 SK 목록(`Sangeun_Pick` · 마스터 › 회사 › 개인) · 공사 연결은 아직 없음(목록만) * * 두 번 누름 · Enter = 고른 내역 줄(`…_Bill_Level` 고름)에 단가연결 · 끌어 놓기 = 놓은 줄에 단가연결 · * 우클릭 = [고른 줄에 연결] · 품셈 = [계산서 보기](704-2 `Labor_Sheet` · [담기] = 연결) @@ -31,17 +32,19 @@ import { } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; import { openStandardSheet } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Sheet"; import { ownOwner } from "../M02_MasterTemplete/M02_MasterTemplete_Layer"; +import { listSangeun } from "../M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick"; import type { Row } from "./B09_Estimation_Calc_Base"; import type { TabContext } from "./B09_Estimation_UI_Tabs"; import { applyAt, openMenuAt } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { askInputs, importLib, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; import { pickedKey } from "./B09_Estimation_UI_Tabs_Bill_Level"; -type Source = "here" | "mine" | "company" | "poomsem" | "past"; +type Source = "here" | "mine" | "company" | "sangeun" | "poomsem" | "past"; const SOURCES: Array<[Source, string]> = [ ["here", "이 공사"], ["mine", "내 것"], ["company", "회사"], + ["sangeun", "단가산출"], ["poomsem", "품셈"], ["past", "지난 공사"], ]; @@ -115,6 +118,28 @@ async function layerItems(layer: "personal" | "company"): Promise { ]; } +/** 단가산출(717-4) — 위 층 SK(마스터 › 회사 › 개인) · 공사 연결 길 없음 = 알림만 */ +async function sangeunItems(): Promise { + const user = await fetchSessionUser().catch(() => null); + const own = (["company", "personal"] as const) + .map((층) => ({ 층, 주인: ownOwner(층, user) })) + .filter((x) => x.주인); + const layerText: Record = { system: "마스터", company: "회사", personal: "개인" }; + return (await listSangeun([{ 층: "system", 주인: null }, ...own])).map((r) => { + const name = r.제목 || r.이름; + const sub = [r.구분, r.상세구분].filter(Boolean).join(" "); + const money = r.기준금액 === null ? "" : `${Math.round(r.기준금액).toLocaleString("ko-KR")}원`; + return { + tag: `단가산출 · ${layerText[r.ref.층] ?? r.ref.층}`, + name, + sub: [sub, money].filter(Boolean).join(" · "), + unit: r.단위 ?? "", + hit: has(`단가산출 ${name} ${sub}`), + go: () => showToast("단가산출은 아직 공사에 바로 연결하지 않음 — 목록 보기만", "info"), + }; + }); +} + /** 넣을 값 창 — 대표 규격 = 계산서(704-2)가 셈에 쓴 입력 전부(목록 `바꿈` 은 기본과 다른 것만 · * 기본을 다르게 보는 칸이 빠짐 — 석축헐기 뒷길이 60 · 0) · 못 받으면 `바꿈` */ function askStandard(apply: ApplyRef, r: StandardRow): void { @@ -200,6 +225,7 @@ async function pastItems(): Promise { const LOAD: Record, () => Promise> = { mine: () => layerItems("personal"), company: () => layerItems("company"), + sangeun: sangeunItems, poomsem: poomsemItems, past: pastItems, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts index a24be99b8..8d82f7339 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Pick.ts @@ -22,6 +22,7 @@ export function withHopyo(lib: LibraryPick): LibraryPick { const scope = { layer: lib.layer, owner: lib.owner }; return { ...lib, + sangeun: true, // 717-4 — 일위대가 [로직 추가] 「단가산출」 탭 kindFilter: lib.kindFilter ?? [ { value: "logic", text: tc("Kind_Logic") }, { value: "precombo", text: tc("Kind_Combo") }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 2b675b661..36381ec7c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -9,6 +9,8 @@ * 704-1 단가산출 줄 = 기준 목록 표(품명 · 규격 · 단위 · 금액 · 출처 · 대표 규격 펼침 · 공종 거름 · * 실무 낱말 = 서버가 별칭에 덧붙임) · 금액 = 서버 M01 엔진(`/logic-lib/standard`) · 오기 전엔 로직 줄만 · * 줄 누름 = 계산서 보기(704-2 · `Labor_Sheet`) → [담기] = 지금 담기 동작(`done(로직)`) + * 717-4 `lib.sangeun` = 탭 [기준 목록 | 단가산출] · 단가산출 = 위 층 SK(마스터 › 회사 › 개인 · `Sangeun_Pick`)를 + * 같은 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = `lib.pick` 참조(SK 키) * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; @@ -37,6 +39,8 @@ import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 import "./M02_MasterTemplete_Labor.css"; import { openModal } from "@ui/ui_template_modal"; import { createText } from "@ui/ui_template_text"; +import { createTabs } from "@ui/ui_template_tabs"; +import { listSangeun, type SangeunRow } from "./M02_MasterTemplete_Sangeun_Pick"; export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick"; @@ -71,6 +75,8 @@ export interface LibraryPick { more?: () => Promise; /** 종류 고르기 칸(251 · 값 logic · precombo · 덧붙인 줄 kind) */ kindFilter?: Array<{ value: string; text: string }>; + /** 717-4 「단가산출」 탭(M02 SK · 위 층 모두) — 고르면 `pick` 참조 */ + sangeun?: boolean; } /** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ @@ -107,6 +113,9 @@ export function openLogicPicker( const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") }); detail.select.disabled = true; let close = (): void => {}; + let rowPick: Array<() => void> = []; // 표 줄 누름 — 탭마다 다시 셈 + let tab: "std" | "sk" = "std"; + let skRows: SangeunRow[] | null = null; // 717-4 — 오기 전 = null /* 한 목록 — 구분(단가산출 · 일위조합) 칸 · 층 단추는 서고 줄(사전조합)의 층 */ const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } }); @@ -141,15 +150,7 @@ export function openLogicPicker( { key: "출처", label: tc("Std_Source"), width: 150 }, ], rows: [], - onRowClick: (i) => { - const row = listed[i]; - const logic = all.find((x) => x.키 === row?.키); - if (row && logic) - openStandardSheet(row, () => { - close(); - done(logic); - }); - }, + onRowClick: (i) => rowPick[i]?.(), }); /** 기준 목록이 오기 전 · 못 받으면 — 로직 하나 = 줄 하나(금액 빈칸) */ const plain = (x: LogicSummary): StandardRow => ({ @@ -170,6 +171,7 @@ export function openLogicPicker( const find = (): void => { const q = finder.search.value.trim().toLowerCase(); + if (tab === "sk") return findSangeun(q); const rows = std ?? all.map(plain); const found = rows.filter( (x) => @@ -200,6 +202,14 @@ export function openLogicPicker( ? `${shown} / ${total}` : tc("Pick_None"); listed = found; + rowPick = listed.map((row) => () => { + const logic = all.find((x) => x.키 === row.키); + if (logic) + openStandardSheet(row, () => { + close(); + done(logic); + }); + }); table.root.hidden = !!lib?.libOnly; table.setRows( found.map((x) => ({ @@ -240,6 +250,32 @@ export function openLogicPicker( })), ]); }; + /** 717-4 단가산출 탭 — 같은 표 꼴 · 찾기 = 이름 · 제목 · 구분 */ + const findSangeun = (q: string): void => { + const rows = (skRows ?? []).filter( + (x) => !q || [x.이름, x.제목, x.구분, x.상세구분].join(" ").toLowerCase().includes(q), + ); + finder.hint.textContent = !skRows + ? tc("Loading") + : rows.length + ? `${rows.length} / ${skRows.length}` + : tc("Pick_None"); + rowPick = rows.map((x) => () => { + close(); + lib?.pick(x.ref, x.제목 || x.이름); + }); + table.root.hidden = false; + table.setRows( + rows.map((x) => ({ + 품명: x.제목 || x.이름, + 규격: [x.구분, x.상세구분].filter(Boolean).join(" "), + 단위: x.단위 ?? "", + 금액: x.기준금액, + 출처: `${L("M02_PickSangeun")} · ${tc(LAYER_TEXT[x.ref.층 as keyof typeof LAYER_TEXT])}`, + })), + ); + finder.setRows([]); + }; const loadLib = async (): Promise => { if (!lib || !scope) return; const mine = ++seq; @@ -303,12 +339,46 @@ export function openLogicPicker( finder.search.addEventListener("input", find); layerBar.style.display = "none"; + const hint = createText(tc("Std_Hint")); + /** 탭 바꿈 — 단가산출 탭은 찾기 칸만(거름 · 층 단추 · 계산서 안내 숨김) */ + const tabs = lib?.sangeun + ? createTabs({ + tabs: [ + { key: "std", label: L("M02_PickStd") }, + { key: "sk", label: L("M02_PickSangeun") }, + ], + value: tab, + kind: "line", + onChange: (key) => { + tab = key === "sk" ? "sk" : "std"; + const std = tab === "std"; + for (const f of [kindSel?.root, work.root, sub.root, detail.root]) + if (f) f.style.display = std ? "" : "none"; + layerBar.style.display = std && scopes.length ? "" : "none"; + hint.hidden = !std; + find(); + }, + }) + : null; if (lib) void upper(lib.layer, lib.owner).then((got) => { scopes = got; + if (tabs) + listSangeun(got).then( + (rows) => { + skRows = rows; + tabs.setBadge("sk", rows.length); + find(); + }, + (error) => { + skRows = []; + find(); + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + }, + ); scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층 if (!got.length) return; - layerBar.style.display = ""; + layerBar.style.display = tab === "std" ? "" : "none"; drawLayers(); void loadLib(); void fetchStructureDef().then( @@ -331,7 +401,7 @@ export function openLogicPicker( body.append( el("div", { className: "m02-std-pick", - children: [finder.root, createText(tc("Std_Hint")), table.root], + children: [...(tabs ? [tabs.root] : []), finder.root, hint, table.root], }), ), })); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts new file mode 100644 index 000000000..39d004145 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts @@ -0,0 +1,104 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_Pick.ts + * M02 단가산출(SK) 고르기(PLAN 717-4) — 위 층 SK 목록 한 벌 · 단가산출 더한줄 고르기. + * · `listSangeun` = 층 목록 길(`/layers/{층}/templates` · 종류 `sangeun`)을 마스터 › 회사 › 개인 차례로 이음 · + * 금액 = 서버가 붙인 1 단위 `기준금액` · 일위대가 [로직 추가] 「단가산출」 탭 · B09 재료 창고 출처가 씀 + * · 고른 SK = 참조 `{층, 주인, 키: SK-…}` — 일위대가 줄은 옛 참조 줄 꼴(서버 `M02_Labor_Expand` 가 펼침) + * · `openSangeunLinePicker` = 단가산출 더한줄 고르기 — 같은 [로직 추가] 창(M01 로직 · 일위대가 · 단가산출 · + * M01 재료) → SK 문서 `더한줄` 한 줄(PLAN 715 이음 꼴) + * ========================================================================== */ + +import { t as L } from "@ui/ui_template_locale"; +import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { pickMaterial } from "./M02_Material_Pick"; + +export const SANGEUN_KIND = "sangeun"; +/** 층 차례 — 위에서 아래 */ +export const LAYER_ORDER = ["system", "company", "personal"] as const; + +/** SK 목록 한 줄 */ +export interface SangeunRow { + ref: LaborRef; + 이름: string; + 제목: string | null; + 단위: string | null; + 구분: string | null; + 상세구분: string | null; + 기준금액: number | null; +} + +interface ListRow { + 종류: string; + 이름: string; + 제목?: string | null; + 단위?: string | null; + 구분?: string | null; + 상세구분?: string | null; + 기준금액?: number | null; +} + +/** 위 층 SK — 층 차례(마스터 › 회사 › 개인) · 층 안은 서버 차례 */ +export async function listSangeun(scopes: Omit[]): Promise { + const rank = (x: Omit): number => + LAYER_ORDER.indexOf(x.층 as (typeof LAYER_ORDER)[number]); + const sorted = [...scopes].sort((a, b) => rank(a) - rank(b)); + const lists = await Promise.all( + sorted.map(async (at) => { + const q = at.주인 && at.층 !== "system" ? `?owner=${encodeURIComponent(at.주인)}` : ""; + const got = await call<{ 양식: ListRow[] }>(`/layers/${at.층}/templates${q}`); + return got.양식 + .filter((r) => r.종류 === SANGEUN_KIND) + .map((r) => ({ + ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 }, + 이름: r.이름, + 제목: r.제목 ?? null, + 단위: r.단위 ?? null, + 구분: r.구분 ?? null, + 상세구분: r.상세구분 ?? null, + 기준금액: r.기준금액 ?? null, + })); + }), + ); + return lists.flat(); +} + +/** 단가산출 더한줄 한 줄(PLAN 715 이음 꼴) */ +export type SangeunLine = + | { 종류: "로직"; 로직: string; 입력값: Record; 수량: number } + | { 참조: LaborRef; 수량: number } + | { 종류: "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number }; + +/** 단가산출 더한줄 고르기 — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */ +export function openSangeunLinePicker( + scope: { layer: Layer; owner: string | null }, + done: (line: SangeunLine) => void, +): void { + const element = L("M02_PickElement"); + openLogicPicker((x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 }), { + ...scope, + sangeun: true, + pick: (ref) => done({ 참조: ref, 수량: 1 }), + kindFilter: [ + { value: "logic", text: tc("Kind_Logic") }, + { value: "precombo", text: L("M02_PrecomboTitle") }, + { value: "element", text: element }, + ], + extra: [ + { + kind: "element", + key: "element:material", + tag: element, + name: L("M02_PickMaterial"), + sub: "", + unit: "", + onPick: () => + pickMaterial((m) => + done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 }), + ), + }, + ], + }); +} diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py new file mode 100644 index 000000000..858eb0d6b --- /dev/null +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -0,0 +1,72 @@ +"""717-4 단가산출(SK) 고르기 — SK 목록 층 차례 · 고른 SK 참조 꼴 · 일위대가 줄 금액.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import COMPANY, MINE, world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +PCT = "GC001109" # 원/㎡ · 인력 둘 + 덧줄 % + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_sk_list_layer_order_and_ref_shape(): + """목록 = 마스터 › 회사 › 개인 차례 · 종류 sangeun 만 · 고른 줄 = 참조 {층, 주인, 키}.""" + pick = _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts") + order = re.search(r"LAYER_ORDER = \[([^\]]+)\]", pick).group(1) + assert [x.strip().strip('"') for x in order.split(",")] == ["system", "company", "personal"] + assert "[...scopes].sort((a, b) => rank(a) - rank(b))" in pick + assert "r.종류 === SANGEUN_KIND" in pick and 'SANGEUN_KIND = "sangeun"' in pick + assert 'ref: { 층: at.층, 주인: at.층 === "system" ? null : at.주인, 키: r.이름 }' in pick + labor = _text(M02 / "M02_MasterTemplete_Labor_Pick.ts") + assert "lib?.pick(x.ref, x.제목 || x.이름);" in labor # SK 탭 줄 누름 = 참조 + assert 'if (tab === "sk") return findSangeun(q);' in labor + assert "sangeun: true," in _text( + M02 / "M02_MasterTemplete_Hopyo_Pick.ts" + ) # 일위대가 [로직 추가] + store_ts = _text(ROOT / "B09_Estimation" / "B09_Estimation_UI_Store.ts") + assert '["sangeun", "단가산출"],' in store_ts and "sangeun: sangeunItems," in store_ts + # 더한줄 고르기 — 715 이음 꼴 셋 + assert 'done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick + assert "done({ 참조: ref, 수량: 1 })" in pick + assert 'done({ 종류: "재료", 요소: m.코드, 이름: m.이름, 단위: m.단위, 수량: 1 })' in pick + + +def _sk(folder, name): + with store.at(folder): + return refs.create( + "sangeun", {"이름": name, "단위": "㎡", "바탕": {"로직": PCT}, "더한줄": []} + )["이름"] + + +def test_list_amount_and_labor_line_holds_upper_sk(world): # noqa: F811 + """층 목록 SK 줄에 1 단위 `기준금액` · 개인 일위대가 줄이 회사 SK 를 참조 = SK 1 단위 × 수량.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + sk = _sk(world["company"], "회사 단가산출") + with store.at(world["company"]): + rows = [r for r in store.list_all() if r["종류"] == "sangeun"] + refs.summaries(world["company"], rows) + one = calc.calc_doc(store.read("sangeun", sk)["문서"])["1단위"]["계"] + assert [r["이름"] for r in rows] == [sk] + assert rows[0]["제목"] == "회사 단가산출" and rows[0]["기준금액"] == pytest.approx(one, abs=1) + with store.at(world["personal"]): + sc = refs.create( + "precombo", + {"이름": "일위대가", "담은로직": [{"참조": {**COMPANY, "키": sk}, "수량": {"손": 3}}]}, + )["이름"] + saved = store.read("precombo", sc)["문서"]["담은로직"][0]["참조"] + got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {}) + assert saved == {**COMPANY, "키": sk} + assert got["반영합"]["계"] == pytest.approx(one * 3, abs=1) diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 6ae515608..771108fb6 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -191,4 +191,8 @@ export const ui_locales_m2 = { M02_HopyoImport: ["일위대가 불러오기", "Import unit-price sheets"], B01_Dashboard_CardPrecombo: ["일위대가", "Unit-cost items"], M02_PrecomboTitle: ["일위대가", "Unit-cost items"], + M02_PickStd: ["기준 목록", "Standard list"], + M02_PickSangeun: ["단가산출", "Unit-cost calcs"], + M02_PickElement: ["M01 요소", "M01 elements"], + M02_PickMaterial: ["재료 고르기", "Pick a material"], } as const; From ccabbad6fb1d8adab136f9777428d020ab47b78f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:09:53 +0900 Subject: [PATCH 22/30] =?UTF-8?q?feat(719-2):=20=EA=B3=B5=EC=82=AC=20?= =?UTF-8?q?=EC=AA=BD=20M02=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C(SK)=20?= =?UTF-8?q?=E2=80=94=20B09=20=EB=8C=80=EA=B0=80=20=EB=B8=94=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=EC=82=B0=EA=B7=BC=20=C2=B7=20[=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8]=20=C2=B7=20=EB=82=B4=20=EC=9E=90=EB=A3=8C?= =?UTF-8?q?=EC=A7=91=20SK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Labor_Source: 굳힌 SK = 품셈 꼴 대가 블록(사본 단가.json 굳힌 키 줄 · M01 안 읽음) · 단가산출 속 일위대가 · SK 줄 = 그 대가 블록 부르는 줄 · 고정 줄 = fixed · 단가 고친 줄 = fixed(M02 셈 단가) · cover = P:+SK 이름표(SK 고쳐도 B09 덮개 그대로) · 줄 sk · poom(품셈 값) · 부르는 줄 수량 ÷ 굳힌 줄 배 · 사전조합 알약 = 굳힌 알약 · 서고 sangeun 사본 - Sangeun: SK 하위 로직 = 산근 · 단가산출 속 참조 걸음 · 결과단위 배 = 사본 줄에서(_unit(row) · unit_of(whole, code)) - Linked: 굳힌 SK 자원 키(굳힌 키 로직 줄 · 고정 이음) - Refresh: 미리 보기 단가산출(바뀌는 SK 줄) · 서고 사본 원본판 다르면 다시 굳힘 - Bill_Library: 종류 sangeun(내 자료집 SK 불러오기) · changed · refresh_copy(같은 폴더 · 값 그대로 · 원본판 없는 옛 사본 그대로) - Daega_Personal: 품셈 블록(굳힌 SK 포함) → 개인 층 SK(B09 품 · 단가 덮개 = SK 덮개 · 더한줄 수량 · 단가) - M02_Item_Sangeun: 굳힌 SK 정체(굳힌 줄 차례 → 원본 cid) - 화면: 일위대가 펼침 SK 줄 되돌림 [단가산출 값으로] · 곁 글 품셈 값 · 내 자료집 단가산출 글 · 업데이트 미리 보기 SK 줄 · 단가연결 내 것 단가산출(Labor_Api fetchLibrary 종류 넓힘) - 시험 test_719_2_sk_project 5 통과 + 1 건너뜀(715-1b 엔진 단가 · 값묶음 뒤 자동 실행) - test_155_5: _unit 인자 = 로직 줄로 바뀜(사본 줄에서 배 읽음) — 부르는 줄 고침 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Bill_Library.py | 48 +++- .../B09_Estimation_Daega_Personal.py | 88 +++++- B09_Estimation/B09_Estimation_Labor_Source.py | 108 ++++++- B09_Estimation/B09_Estimation_Linked.py | 23 +- B09_Estimation/B09_Estimation_Refresh.py | 58 +++- B09_Estimation/B09_Estimation_Sangeun.py | 35 ++- B09_Estimation/B09_Estimation_UI_Check.ts | 6 +- B09_Estimation/B09_Estimation_UI_Store.ts | 15 +- .../B09_Estimation_UI_Tabs_Bill_Fold.ts | 24 +- .../B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts | 12 +- .../B09_Estimation_UI_Tabs_Bill_Link.ts | 4 +- M02_MasterTemplete/M02_Item_Sangeun.py | 4 +- .../M02_MasterTemplete_Labor_Api.ts | 2 +- resources/tester/test_155_5_sangeun_text.py | 3 +- resources/tester/test_719_2_sk_project.py | 267 ++++++++++++++++++ 15 files changed, 637 insertions(+), 60 deletions(-) create mode 100644 resources/tester/test_719_2_sk_project.py diff --git a/B09_Estimation/B09_Estimation_Bill_Library.py b/B09_Estimation/B09_Estimation_Bill_Library.py index 4b31102a2..93289d51e 100644 --- a/B09_Estimation/B09_Estimation_Bill_Library.py +++ b/B09_Estimation/B09_Estimation_Bill_Library.py @@ -6,6 +6,9 @@ 도면 · 면적 · 일위대가 · 단가 · 입력값 · 고른자재 첫 값) precombo 사전조합 = `일위대가.json` {줄: {담은로직: [{참조, 수량: {손: 1}}]}, 참조사본} · `단가.json`(`M02_Item_Source.price_bundle` — 사본 단가 묶음 한 벌) + sangeun 단가산출(SK · 내 자료집 · 719-2) = precombo 와 한 꼴(참조사본 = 풀어 굳힌 SK) +[업데이트](719-2) = 원본판이 다른 사본만 같은 폴더에 다시 굳힘(`changed` · `refresh_copy` · +값 그대로 · 원본판 없는 옛 사본 = 그대로). 사본 = 원본이 바뀌거나 지워져도 그대로(굳힘) · 대가 블록 = `Labor_Source.daega_inputs`(`libs` {폴더: 블록 id}) · 단가 수요 = `Linked.linked_resource_keys` · 줄 연결 = {tab: dg, key: 블록 id, lib: 폴더} · @@ -58,11 +61,11 @@ from M02_MasterTemplete.M02_Item_Source import ( ) LIBRARY = "library" -KINDS = ("combo", "precombo") +KINDS = ("combo", "precombo", "sangeun") #: 폴더 이름 — 종류 · 층(project = 불러온 공사 일위대가 · 707-7) · 주인 · #: 키(영문 · 숫자 · `-` `_`) · 값 해시(263-2 · 없을 수 있음) NAME = re.compile( - r"^(combo|precombo)@(system|company|personal|project)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$" + r"^(combo|precombo|sangeun)@(system|company|personal|project)@[0-9-]+@[A-Za-z0-9_-]{1,40}(~[0-9a-f]{8})?$" ) @@ -87,11 +90,11 @@ def folder_name( return name -def _precombo_files(layer: str, owner: str | None, key: str) -> dict[str, Any]: +def _precombo_files(layer: str, owner: str | None, key: str, kind: str = "precombo") -> dict: ref = {"층": layer, "주인": owner, "키": key} doc = refs.read_ref(ref) - if doc is None or doc.get("종류") != refs.TITLE["precombo"]: - raise LookupError(f"사전조합 {key} 이 {layer} 층에 없음") + if doc is None or doc.get("종류") != refs.TITLE[kind]: + raise LookupError(f"{refs.TITLE[kind]} {key} 이 {layer} 층에 없음") line = {"참조": ref, "수량": {"손": 1}} labor = labor_doc_rows({"이름": doc.get("이름"), "단위": doc.get("단위"), "담은로직": [line]}) return { @@ -116,10 +119,43 @@ def _build(kind: str, layer: str, owner: str | None, key: str) -> tuple[dict, di item = {"layer": layer, "owner": owner, "key": key} item["name"] = item_name(item) return build_snapshot(item), origin_of(item) - files = _precombo_files(layer, owner, key) + files = _precombo_files(layer, owner, key, kind) return files, files.pop("_원본") +def _source_of(manifest: dict[str, Any]) -> tuple[str, str, str | None, str]: + """manifest → (종류, 층, 주인, 키).""" + kind, origin = str(manifest.get("종류")), manifest.get("원본") or {} + if kind == "combo": + return kind, origin.get("layer"), origin.get("owner"), origin.get("key") + ref = origin.get("참조") or {} + return kind, ref.get("층"), ref.get("주인"), ref.get("키") + + +def changed(project_root: str | Path) -> list[tuple[Path, dict[str, Any], dict, dict]]: + """원본판이 지금 원본과 다른 사본 `(폴더, manifest, 새 파일들, 새 원본 표지)` — 원본판 없는 옛 + 사본 · 원본 없음 = 뺌(그대로).""" + out = [] + for folder, manifest in copies(project_root): + if not manifest.get("원본판"): + continue + try: + files, origin = _build(*_source_of(manifest)) + except (LookupError, ValueError, KeyError): + continue + if source_version(files) != manifest["원본판"]: + out.append((folder, manifest, files, origin)) + return out + + +def refresh_copy(project_root: str | Path, one: tuple) -> None: + """`changed` 한 줄 → 같은 폴더에 다시 굳힘(값 · 가져온이 그대로 · 원본판 새로).""" + folder, manifest, files, origin = one + keep = {k: v for k, v in manifest.items() if k not in ("값", "원값")} + fresh = {**keep, "원본": origin, "원본판": source_version(files), "가져온날": now_text()} + _write(project_root, folder.name, files, fresh, manifest.get("값") or {}) + + # ── 운반 값 사본(263-2) ──────────────────────────────────────────────── def _logic(code: str) -> dict[str, Any]: try: diff --git a/B09_Estimation/B09_Estimation_Daega_Personal.py b/B09_Estimation/B09_Estimation_Daega_Personal.py index 7aa7d7c9b..d35d2a962 100644 --- a/B09_Estimation/B09_Estimation_Daega_Personal.py +++ b/B09_Estimation/B09_Estimation_Daega_Personal.py @@ -9,6 +9,11 @@ 복사본 `K:`(703-2) = 원본까지 따라감(이름 · 덮개 = 복사본 것) 같은 이름 = 409 `{같은이름: 키, 이름}` → 부르는 쪽이 덮어쓰기(`overwrite` = 그 키) · 새 이름 · 쓰기 = 지금 층(`store.at` · 층 권한 · 폴더는 부르는 쪽). +품셈 블록(`P:` · 굳힌 SK 포함 · 719-2) = 개인 층 단가산출(SK · `sangeun`) — 바탕 = 그 M01 로직 · +원본 SK 문서(바탕 · 덮개 · 더한줄 · 뺀줄) · 넣을 값 · 고른 자재(cid) · B09 품 · 단가 덮개 = +SK 덮개(같은 cid · 굳힌 SK = `정체` 로 원본 cid · 더한줄 줄 = 그 더한줄 수량 · 단가) · +못 옮긴 칸 = 안담음 수 · +내 자료집 SK 불러오기 = B09 서고 사본 `sangeun`(`Bill_Library`). 불러온 공사 일위대가(707-7 · 참조 층 `project`) = 하위 호표 굳힌 문서를 문서 `_하위` 에 실어 보내고 쓸 때 지금 층에 같이 씀(새 키 · `본뜬.복사원본` = 그 참조 · 같은 하위는 한 번만 · 이미 있으면 그 키). @@ -16,11 +21,13 @@ from __future__ import annotations +import copy from pathlib import Path from typing import Any from B09_Estimation import B09_Estimation_Bill_Library as library -from B09_Estimation.B09_Estimation_Calc_Labor import COPY, with_copies +from B09_Estimation import B09_Estimation_Sangeun as sg +from B09_Estimation.B09_Estimation_Calc_Labor import COPY, COVER_KEYS, with_copies from B09_Estimation.B09_Estimation_Labor_Source import AREA_FILE, daega_inputs from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Item_Prices as item_prices @@ -35,6 +42,8 @@ from M02_MasterTemplete.M02_Item_Snapshot import ( from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json KIND = "precombo" +SK_KIND = "sangeun" +SK_KEEP = ("바탕", "입력값", "열린입력", "켜쌓기율", "고름", "자재표시", "덮개", "더한줄", "뺀줄") SUBS = "_하위" PRICE_KEYS = ("labor", "material", "expense") @@ -113,8 +122,10 @@ def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None = base = cover_key while base.startswith("K:"): # 복사본 → 원본 cover base = str(((cover.get(base) or {}).get(COPY) or {}).get("from") or "") - lines, mats = source(root, blocks.get(base, block), base) mine = cover.get(cover_key) or {} + if block["kind"] == "품셈": + return _sangeun(root, blocks.get(base, block), mine, name) + lines, mats = source(root, blocks.get(base, block), base) kept = {"수량": 0, "넣을값": sum(bool(ln.get("입력값")) for ln in lines)} kept["고른자재"] = sum(bool(ln.get("고름")) for ln in lines) skip = {"품": 0, "단가": 0} @@ -155,6 +166,70 @@ def build(root: Path, cover_key: str, cover: dict[str, Any], name: str | None = return {"문서": doc, "담음": kept, "안담음": skip} +def _sangeun(root: Path, block: dict, mine: dict, name: str | None) -> dict[str, Any]: + """품셈 블록 → 개인 층 SK 문서 `{문서, 담음, 안담음}` — 블록 줄 차례 = 엔진 줄 차례 = + cid 차례.""" + from M01_MasterData import M01_MasterData_Store as m01 + from M02_MasterTemplete import M02_Sangeun_Doc as sd + + origin = block.get("origin") or {} + rid = origin.get("단가산출") + if rid: # 굳힌 SK = 원본 문서 위에 · cid = 정체(굳힌 줄 차례 → 원본 cid) + frozen = _frozen(root, rid) + if frozen is None or not isinstance(frozen.get("원본"), dict): + raise LookupError(f"단가산출 원천을 못 찾음 「{rid}」") + doc = {k: copy.deepcopy(frozen["원본"][k]) for k in SK_KEEP if k in frozen["원본"]} + ids = list(frozen.get("정체") or []) + unit = str(frozen["원본"].get("단위") or "") + else: # M01 로직 한 줄 = 바탕 · 고름 차례 → cid + try: + row = m01.loaded()[1].logic(str(origin.get("로직"))) + except Exception as e: # noqa: BLE001 — 엔진 FormulaError + raise LookupError(f"로직을 못 찾음 「{origin.get('로직')}」") from e + ids, unit = sd.line_ids(row), sg._unit(row)[0] + chosen = (origin.get("고름") or {}).items() + picks = {ids[int(n)]: mt for n, mt in chosen if int(n) < len(ids)} + doc = {"바탕": {"로직": origin.get("로직")}, "입력값": dict(origin.get("입력값") or {})} + doc |= {"고름": picks} if picks else {} + if origin.get("켜쌓기율") is not None: + doc["켜쌓기율"] = origin["켜쌓기율"] + covers, added = doc.setdefault("덮개", {}), doc.setdefault("더한줄", []) + kept, skip = {"품": 0, "단가": 0}, {"품": 0, "단가": 0} + for n, line in enumerate(block["lines"]): + cell = {k: v for k, v in (mine.get(line.get("cid")) or {}).items() if k in COVER_KEYS} + cid = ids[n] if n < len(ids) else "" + if not cell: + continue + if cid.startswith("add:#"): # 더한줄 = 그 줄 수량 · 단가(한 비목 줄만) + one = added[int(cid[5:])] + if "qty" in cell: + one["수량"], kept["품"] = cell["qty"], kept["품"] + 1 + prices = [cell[k] for k in PRICE_KEYS if k in cell] + ok = len(prices) == 1 and "참조" not in one and "고정" not in one + if ok: + one["단가"], kept["단가"] = prices[0], kept["단가"] + 1 + skip["단가"] += len(prices) - (1 if ok else 0) + continue + if not cid or ("qty" in cell and doc.get("열린입력")): # 열어 둔 SK = 품 배율만 + skip["품"] += "qty" in cell + cell.pop("qty", None) + if not cell: + continue + if not cid: + skip["단가"] += len(cell) + continue + was = {k: v for k, v in (covers.get(cid) or {}).items() if k != "원값"} + if "qty" in cell: + was.pop("배율", None) + covers[cid] = {**was, **cell} + kept["품"] += "qty" in cell + kept["단가"] += sum(k in cell for k in PRICE_KEYS) + if not covers: + doc.pop("덮개") + head = {"종류": sd.TITLE, "이름": (name or block.get("name") or "").strip(), "단위": unit} + return {"문서": {**head, **doc}, "담음": kept, "안담음": skip} + + def _own(lines: Any) -> list[dict[str, Any]]: """불러온 공사 하위 호표 참조(층 `project` — 층에 없음 · 굳힌 문서뿐).""" return [ @@ -211,9 +286,10 @@ def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]: """지금 층(`store.at` 안)에 씀 — 같은 이름이면 409 · `overwrite` = 같은 이름 그 키를 덮음.""" subs = doc.get(SUBS) or {} doc = {k: v for k, v in doc.items() if k != SUBS} + kind = SK_KIND if doc.get("종류") == refs.TITLE[SK_KIND] else KIND same = [ path.stem - for path in sorted((store.root() / KIND).glob("SC-*.json")) + for path in sorted((store.root() / kind).glob(f"{refs.PREFIX[kind]}-*.json")) if (read_json(path) or {}).get("이름") == doc["이름"] ] if overwrite and overwrite not in same: @@ -223,6 +299,6 @@ def write(doc: dict[str, Any], overwrite: str | None = None) -> dict[str, Any]: if subs: doc = {**doc, "담은로직": _carry(doc["담은로직"], subs, {})} if overwrite: - old = store.read(KIND, overwrite) - return store.write(KIND, overwrite, old["판"], {**doc, "키": overwrite}) - return refs.create(KIND, doc) + old = store.read(kind, overwrite) + return store.write(kind, overwrite, old["판"], {**doc, "키": overwrite}) + return refs.create(kind, doc) diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 7520f426d..57da1420b 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -115,6 +115,14 @@ class Builder: # ── 품셈(로직 하나) ───────────────────────────────────────────────── def poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> str | None: + got = self._poomsem(whole, one, title) + if got is None: + return None + got.pop("_engine"), got.pop("_items") + return self.put(got) + + def _poomsem(self, whole: Any, one: dict[str, Any], title: str = "") -> dict | None: + """품셈 블록 꼴(넣기 전) — 엔진 줄 · 줄마다 `_n`(엔진 차례 · 단가산출 블록이 씀).""" code = str(one.get("로직") or "") given, rate, picks = one.get("입력값") or {}, one.get("켜쌓기율"), one.get("고름") try: @@ -142,18 +150,73 @@ class Builder: f"{k} {st._word(whole, k, v) if isinstance(v, str) else v}" for k, v in sorted(given.items()) ) - return self.put( - { - "kind": "품셈", - "name": title or logic.get("이름") or code, - "spec": shown, - "spec_raw": spec, - "unit": unit, - "cite": logic.get("출처") or "", - "origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks}, - "lines": lines, - } - ) + return { + "kind": "품셈", + "name": title or logic.get("이름") or code, + "spec": shown, + "spec_raw": spec, + "unit": unit, + "cite": logic.get("출처") or "", + "origin": {"로직": code, "입력값": given, "켜쌓기율": rate, "고름": picks}, + "lines": lines, + "_engine": engine, + "_items": items, + } + + # ── 단가산출(SK · 719-2) ──────────────────────────────────────────── + def sangeun(self, whole: Any, ref: dict, doc: dict, copies: dict, depth: int = 0) -> str | None: + """굳힌 SK(사본 `단가.json` 로직 줄 · 719-1) = 품셈 블록 꼴 대가 — 셈 = 굳힌 줄 그대로 · + 단가산출 속 일위대가 · SK 줄(참조) = 그 대가 블록을 부르는 줄(펼침 · B09 차례 그대로) · + 고정 줄 = `fixed` · 단가 고친 줄(덮개 · 더한줄 단가 · 값묶음) = `fixed`(줄 단가 = M02 셈) · + cover = `P:` + SK 이름표(SK 를 고쳐 [업데이트] 해도 B09 덮개 그대로) · + 줄 `sk` · `poom`(M02 에서 고친 줄의 품셈 값 = 원본 덮개 `원값`).""" + from M02_MasterTemplete import M02_Item_Sangeun as item_sk + from M02_MasterTemplete import M02_Sangeun_Calc as calc + from M02_MasterTemplete import M02_Sangeun_Doc as sd + from M02_MasterTemplete.M02_Labor_Expand import Expand + + rid = refs.ref_id(ref) + key = str((doc.get("바탕") or {}).get("로직") or "") + if item_sk.ROW not in doc or not key: + return None # 못 푼 SK(없는 바탕 · 순환) — 부르는 줄이 까닭 + + def read(r: dict) -> dict | None: + return copies.get(refs.ref_id(r)) + + try: + got = calc.resolve(doc, whole, read, (rid,)) + row = {**got["줄"], "키": key, "호표": [dict(x) for x in got["줄"]["호표"]]} + if calc._bundles(row, whole, Expand(whole, read), (rid,)): + return None + except (sd.DocError, mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): + return None + one = {"로직": key, "입력값": got["입력값"], "켜쌓기율": got["켜쌓기율"]} + block = self._poomsem(calc._Over(whole, key, row), {**one, "고름": got["고름"]}) + if block is None: + return None + ids = list(doc.get("정체") or []) + was = {c: v.get("원값") for c, v in ((doc.get("원본") or {}).get("덮개") or {}).items()} + lines = [] + for n, (line, x) in enumerate(zip(block["lines"], block.pop("_engine"))): + item = block["_items"][n] if n < len(block["_items"]) else {} + qty = _f(x.get("수량")) + head = {"name": line.get("name") or "", "spec": "", "unit": line.get("unit") or ""} + if isinstance(item.get("참조"), dict) and depth < refs.DEPTH: + line = {**self.line_of(whole, item, qty, 1, head["name"], copies, depth), **head} + elif isinstance(item.get("고정"), dict): + line = {**fixed_line(item["고정"], qty), **head} + elif qty and ("단가" in item or item.get("값묶음")): + own = {PART[k]: _f(v) / qty for k, v in (x.get("비목") or {}).items() if k in PART} + line = {**head, "kind": "fixed", "qty": qty, "own": own, "link": {}} + poom = was.get(ids[n]) if n < len(ids) else None + lines.append({**line, "sk": True, **({"poom": poom} if poom else {})}) + block.pop("_items") + name = doc.get("이름") or block["name"] + origin = {**block["origin"], "단가산출": rid} + cover = f"P:{_digest({'단가산출': rid})}" + return self.put({**block, "name": name, + "cite": f"단가산출 {ref.get('키')}", "origin": origin, + "lines": lines}, cover) # fmt: skip def _engine_line( self, item: dict | None, x: dict, logic: dict, extra: int, bases: dict, call: dict | None @@ -209,6 +272,17 @@ class Builder: "qty": each, "why": f"없는 참조 「{ref.get('키')}」", } + if refs.kind_of(ref.get("키")) == "sangeun": # 단가산출(719-2) = 굳힌 줄 대가 + bid = self.sangeun(whole, ref, doc, copies, depth + 1) + if bid is None: + why = "단가산출을 못 셈(바탕 없음 · 순환)" + return {"kind": "blank", "name": name, "qty": each, "why": why} + from M02_MasterTemplete.M02_Sangeun_Calc import times_of + + block = self.blocks[bid] # 블록 = 굳힌 줄 결과단위(배) 몫 · M02 참조 줄 = 1 단위 + per = float(times_of(whole.logic(doc["바탕"]["로직"]))) + return {"kind": "daega", "key": bid, "name": block["name"], "unit": block["unit"], + "qty": None if each is None else each / per} # fmt: skip if refs.kind_of(ref.get("키")) == "promote": # 옛 단가승격 참조 = 일위대가화 = 대가 first = (doc.get("담은로직") or [{}])[0] bid = self.poomsem(whole, first, doc.get("이름") or name) @@ -225,7 +299,7 @@ class Builder: return {"kind": "blank", "name": name or code, "qty": q, "why": "돈 아닌 로직"} key = sangeun_ref(code, one.get("입력값"), one.get("켜쌓기율"), one.get("고름")) return {"kind": "sangeun", "key": key, "name": name or code, - "unit": sg.unit_of(code), "qty": q} # fmt: skip + "unit": sg.unit_of(whole, code), "qty": q} # fmt: skip bid = self.poomsem(whole, one, str(one.get("별칭") or "") or name) if bid is None: return { @@ -243,9 +317,11 @@ class Builder: from M01_MasterData import M01_MasterData_Store_Combo as combo_store inner = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)] + kept = doc.get("알약") # 굳힌 알약(142-2 · 지금 M01 안 읽음 · 719-2) · 없으면 셈 pills = { code: pill - for pill in combo_store.combo_pills({"담은로직": inner})["물량"] + for pill in (kept if isinstance(kept, list) else None) + or combo_store.combo_pills({"담은로직": inner})["물량"] for code in pill["로직"] } lines = [] @@ -318,7 +394,7 @@ class Builder: def library(self, folder: Path, manifest: dict[str, Any]) -> str | None: """설계내역서 서고 사본(204-2) — 구조물조합 = 조합 블록 · 사전조합 = 겹 대가 블록 그대로.""" - if manifest.get("종류") != "precombo": + if manifest.get("종류") not in ("precombo", "sangeun"): return self.combo(folder, f"L:{folder.name}") labor = read_json(folder / LABOR_FILE) ref = (manifest.get("원본") or {}).get("참조") @@ -327,6 +403,8 @@ class Builder: if not isinstance(doc, dict): return None whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) + if manifest["종류"] == "sangeun": # 내 자료집 단가산출(719-2) + return self.sangeun(whole, ref, doc, copies, 1) return self.precombo(whole, ref, doc, copies, 1) diff --git a/B09_Estimation/B09_Estimation_Linked.py b/B09_Estimation/B09_Estimation_Linked.py index 2ea2e6a7f..8f33276ac 100644 --- a/B09_Estimation/B09_Estimation_Linked.py +++ b/B09_Estimation/B09_Estimation_Linked.py @@ -5,6 +5,7 @@ 실제로 부른 자원 줄(인력 LB · 기계 EQ · 자재 MT · 유가 MO)만 모음 — 단가 묶음 통째 (고르기 후보 전부)는 안 씀 · 값은 쓰지 않음(단가는 원가 한 벌 `price_book.json` 이 맡음) · 사본 일위대가 자재 · 굳힌 사전조합 자재 코드 · 고정 줄 이음 키도(183-1 · 705-1 · 로직을 안 돌아 `get` 에 안 걸림). +굳힌 SK(719-2) = 굳힌 키 로직 줄로 돌림(사본 `단가.json` · M01 안 읽음). 고르기가 훑은 후보 줄도 걸림 → 원가 길이 두 번 셈해 쓰는 줄만 남김(`Summary.used_materials`). 구조물 조합 사본 없으면 그 구조물은 없음 · 일위대가 없는 사본은 자원 없음. """ @@ -54,7 +55,9 @@ def used_keys(folder: Path) -> list[str]: # 굳힌 참조사본 안 로직까지 doc = item_prices.with_values(labor.get("줄") or {}, values, picks) inner = [d for d in (labor.get("참조사본") or {}).values() if isinstance(d, dict)] - for one in [*(doc.get("담은로직") or []), *(x for d in inner for x in d.get("담은로직") or [])]: + sks = [_sk_line(d) for d in inner if d.get("종류") == "단가산출" and "굳힌줄" in d] + lines = [*(doc.get("담은로직") or []), *(x for d in inner for x in d.get("담은로직") or [])] + for one in [*lines, *sks]: # 못 돈 로직 — 부른 데까지만(까닭은 안 봄) if isinstance(one, dict) and one.get("로직"): labor_doc.run_line( @@ -63,12 +66,30 @@ def used_keys(folder: Path) -> list[str]: mats = [*(doc.get("자재") or []), *(m for d in inner for m in d.get("자재") or [])] codes = [str(mat.get("코드") or "") if isinstance(mat, dict) else "" for mat in mats] codes += [k for d in [doc, *inner] for k in fixed.link_keys(d)] # 705-1 고정 줄 이음 + codes += [ # 굳힌 SK 고정 더한줄 이음(719-2) + k + for d in inner + for x in (d.get("굳힌줄") or {}).get("호표") or [] + if isinstance(x.get("고정"), dict) + for k in fixed.link_keys({"담은로직": [{"고정": x["고정"]}]}) + ] for code in codes: if code[:2] in RESOURCE_TABLES and code not in seen: seen.append(code) return seen +def _sk_line(doc: dict[str, Any]) -> dict[str, Any]: + """굳힌 SK(719-1) → 로직 줄 꼴 — 굳힌 키(사본 `단가.json` 로직) · 고름 cid → 차례.""" + from M02_MasterTemplete.M02_Sangeun_Doc import line_ids + + chosen = doc.get("고름") or {} + ids = line_ids(doc["굳힌줄"]) + picks = {str(n): chosen[cid] for n, cid in enumerate(ids) if cid in chosen} + one = {"로직": (doc.get("바탕") or {}).get("로직"), "입력값": doc.get("입력값")} + return {**one, "켜쌓기율": doc.get("켜쌓기율"), "고름": picks or None} + + def linked_resource_keys(project_root: str | Path, keep: set[str] | None = None) -> list[str]: """연결된 사본 전부가 부른 자원 키 — 구조물 id 차례 → 설계내역서 서고 사본(204-2) · 겹침 없음 · `keep` = 화면 캐시 줄이 잇는 서고 사본만(296 · 지운 줄의 사본 뺌).""" diff --git a/B09_Estimation/B09_Estimation_Refresh.py b/B09_Estimation/B09_Estimation_Refresh.py index c29a75999..af191bd16 100644 --- a/B09_Estimation/B09_Estimation_Refresh.py +++ b/B09_Estimation/B09_Estimation_Refresh.py @@ -5,6 +5,9 @@ 재적용 · 새 판 뒤에도 남음) · 노임 판(굳힌 판 → 지금 M01 판 · 옛 책 = 굳힌 날) 적용(`reapply_changed` → 부르는 쪽이 수요 다시 → `prices.refresh_book`) — 사본 먼저(수요가 바뀜) · 덮개(사용자 값)는 안 건드림 · 사본 하나가 막혀도 나머지는 함(막힌 것 = 알림). +M02 단가산출(SK · 719-2) — 구조물 · 서고 사본의 굳힌 SK 를 원본 층에서 다시 풀어 굳힘(같은 굳힌 키 · + 같은 cover → B09 덮개 · 입력값 · 고른 자재 그대로) · 서고 사본도 원본판이 다르면 다시 굳힘 · + 미리 보기 `단가산출` = 바뀌는 SK 줄 [{사본, 이름, 키, 판(새 · 바뀜 · 빠짐)}]. """ from __future__ import annotations @@ -12,10 +15,13 @@ from __future__ import annotations from pathlib import Path from typing import Any +from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Prices as prices from B09_Estimation.B09_Estimation_Calc_Labor import COPY from M02_MasterTemplete import M02_Item_Copy as item_copy -from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots +from M02_MasterTemplete import M02_Item_Sangeun as item_sk +from M02_MasterTemplete.M02_Item_Snapshot import MANIFEST_NAME, linked_snapshots, structures_dir +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, build_source, read_json def changed_copies(root: str | Path) -> list[dict[str, Any]]: @@ -28,6 +34,41 @@ def changed_copies(root: str | Path) -> list[dict[str, Any]]: ] +def _sks(labor: Any) -> dict[str, dict]: + copies = (labor or {}).get("참조사본") or {} + return {rid: doc for rid, doc in copies.items() if item_sk.is_sangeun(doc)} + + +def sk_lines(old: Any, new: Any, where: str) -> list[dict[str, str]]: + """사본 하나 전후 일위대가.json → 바뀌는 SK 줄(굳힌 줄 · 원본 SK 문서가 다름).""" + was, now = _sks(old), _sks(new) + out = [] + for rid in sorted(set(was) | set(now)): + a, b = was.get(rid) or {}, now.get(rid) or {} + if all(a.get(k) == b.get(k) for k in (item_sk.ROW, "원본")): + continue + mark = "새" if not a else "빠짐" if not b else "바뀜" + out.append({"사본": where, "이름": (b or a).get("이름") or "", "키": rid.rsplit("|")[-1], + "판": mark}) # fmt: skip + return out + + +def sk_changes(root: str | Path, copies: list[dict], libs: list[tuple]) -> list[dict[str, str]]: + """다시 가져올 구조물 사본(`changed_copies`) · 서고 사본(`library.changed`)의 바뀌는 SK 줄.""" + out = [] + for one in copies: + folder = structures_dir(root) / one["폴더"] + try: + fresh = build_source((read_json(folder / MANIFEST_NAME) or {})["원본"]) + except (LookupError, ValueError, KeyError): + continue + out += sk_lines(read_json(folder / LABOR_FILE), fresh.get(LABOR_FILE), one["이름"]) + for folder, manifest, files, origin in libs: + name = str(origin.get("name") or folder.name) + out += sk_lines(read_json(folder / LABOR_FILE), files.get(LABOR_FILE), name) + return out + + def cover_cells(cover: dict[str, Any] | None) -> int: """고친 칸 수 — 덮개 칸(품 · 수량 · 단가) · 복사본 표지 뺌.""" return sum( @@ -43,6 +84,8 @@ def preview( root: str | Path, book: dict[str, Any] | None, keys: list[str], cover: dict | None ) -> dict[str, Any]: diff = prices.book_diff(book, prices.refresh_book(keys, book)) + copies, libs = changed_copies(root), library.changed(root) + lib_names = [{"폴더": f.name, "이름": str(o.get("name") or f.name)} for f, _m, _f, o in libs] before = (book or {}).get("labor_edition") made = str((book or {}).get("made") or "")[:10] return { @@ -51,7 +94,8 @@ def preview( "바뀐줄": diff["changed"], "계수": diff["factor"], "환율": diff["fx"], - "사본": changed_copies(root), + "사본": copies + lib_names, + "단가산출": sk_changes(root, copies, libs), "고친칸": cover_cells(cover), "노임판": { "전": edition_text(before) if before else (f"굳힌 날 {made}" if made else "안 굳힘"), @@ -61,8 +105,16 @@ def preview( def reapply_changed(root: str | Path, who: dict[str, Any] | None = None) -> dict[str, list]: - """원본이 바뀐 연결 사본 재적용 — `{다시: [이름], 막힘: [이름 — 까닭]}`.""" + """원본이 바뀐 연결 사본 재적용 · 서고 사본 다시 굳힘 — + `{다시: [이름], 막힘: [이름 — 까닭]}`.""" done, stuck = [], [] + for one in library.changed(root): + name = str(one[3].get("name") or one[0].name) + try: + library.refresh_copy(root, one) + done.append(name) + except (LookupError, ValueError, KeyError, OSError) as error: + stuck.append(f"{name} — {error}") for one in changed_copies(root): try: item_copy.reapply(root, one["폴더"], who) diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 02549d389..95f159784 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -129,6 +129,17 @@ def rows_of(folder: Path, whole: Any = None) -> list[dict[str, Any]]: doc = copies.get(refs.ref_id(ref)) if not isinstance(doc, dict) or depth >= refs.DEPTH: continue + if refs.kind_of(ref.get("키")) == "sangeun": # 굳힌 SK(719-2) = 대가 · 하위 = 산근 + base = str((doc.get("바탕") or {}).get("로직") or "") + if base and "굳힌줄" in doc: + logic_line(base, doc.get("입력값"), doc.get("켜쌓기율"), None, True) + inner_refs = [ + {"참조": x["참조"]} + for x in doc["굳힌줄"].get("호표") or [] + if isinstance(x.get("참조"), dict) + ] + walk(inner_refs, True, depth + 1) # 단가산출 속 일위대가 · SK + continue if refs.kind_of(ref.get("키")) == "promote": # 옛 SP = 일위대가화 first = (doc.get("담은로직") or [None])[0] if isinstance(first, dict) and first.get("로직"): @@ -194,15 +205,23 @@ class _Prices: ) -def _unit(code: str) -> tuple[str, Decimal]: - """결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10).""" - pill = (combo_store.combo_pills({"담은로직": [{"로직": code}]})["물량"] or [{}])[0] - return str(pill.get("단위") or ""), Decimal(str(pill.get("배") or 1)) +def _unit(row: dict) -> tuple[str, Decimal]: + """결과단위 1 단위 — (단위, 배)(「원/10㎡」 = ㎡ · 10) · 알약 규칙(`combo_pills`) 그대로 · + 줄 = 사본 단가 묶음 것(M01 다시 안 읽음 · 719-2).""" + per = combo_store._PER.findall(str(row.get("결과단위") or "")) + if not per: + return "", Decimal(1) + times = per[0][0].replace(",", "") + unit = " · ".join(dict.fromkeys(u for _, u in per)) + return unit, Decimal(times) if len(per) == 1 and times else Decimal(1) -def unit_of(code: str) -> str: - """산근 1 단위 이름(「원/10㎡」 = ㎡).""" - return _unit(code)[0] +def unit_of(whole: Any, code: str) -> str: + """산근 1 단위 이름(「원/10㎡」 = ㎡) — `whole` = 사본 단가 묶음.""" + try: + return _unit(whole.logic(code))[0] + except mf.FormulaError: + return "" def _env(whole: Any, row: dict, given: dict) -> dict: @@ -343,7 +362,7 @@ class Solver: name = str(row.get("이름") or code) if isinstance(row.get("흐름"), dict) or "결과" in row: return self._stop(code, name, "돈 로직 아님(흐름 · 결과 로직) — 산근 풀이 꼴 없음") - unit, times = _unit(code) + unit, times = _unit(row) rate = Decimal(str(src["rate"])) / 100 if src.get("rate") not in (None, 100) else None lines: list[dict[str, Any]] = [] try: diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index 430a73b10..ed0d17dda 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -73,11 +73,15 @@ export function refreshTip(payload: Row | null): string { /** [업데이트] 미리 보기 글 줄(703-3) */ export function previewLines(got: Row): string[] { const copies: Row[] = got.사본 || []; + const sks: Row[] = got.단가산출 || []; const edition = got.노임판 || {}; return [ `바뀌는 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})` + `${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`, - `다시 가져올 구조물 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`, + `다시 가져올 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`, + ...(sks.length + ? [`M02 단가산출 바뀜 ${sks.length} — ${sks.map((s) => `${s.이름}(${s.판})`).join(" · ")}`] + : []), `고친 칸 ${got.고친칸 ?? 0} 지킴(일위대가 덮개 · 새 판 · 재적용 뒤에도 그대로)`, `노임 판 ${edition.전 || "—"} → ${edition.후 || "—"}`, ]; diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts index 81c62c5c6..c12b07ef6 100644 --- a/B09_Estimation/B09_Estimation_UI_Store.ts +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -80,11 +80,12 @@ function hereItems(ctx: TabContext): Item[] { return [...of("dg", ctx.sheets.daega), ...of("sg", ctx.sheets.sg_list)]; } -/** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) */ +/** 내 것 · 회사 — 구조물 조합(그 층) + 사전조합(그 층 일위대가) + 단가산출(SK · 719-2) */ async function layerItems(layer: "personal" | "company"): Promise { const owner = ownOwner(layer, await fetchSessionUser().catch(() => null)); const combos = (await loadRows().catch(() => [])).filter((r) => r.layer === layer); const pres = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "precombo") : []; + const sks = owner ? await fetchLibrary({ 층: layer, 주인: owner }, "sangeun") : []; return [ ...combos.map((r) => { const name = itemName(r); @@ -99,15 +100,19 @@ async function layerItems(layer: "personal" | "company"): Promise { void importLib({ apply, onPayload: onPayload(ctx) }, pick), }; }), - ...pres.map((r) => { + ...[ + ...pres.map((r) => ["precombo", r] as const), + ...sks.map((r) => ["sangeun", r] as const), + ].map(([kind, r]) => { const name = r.제목 || r.이름; - const pick = { kind: "precombo" as const, layer, owner, key: r.이름 }; + const tag = kind === "precombo" ? "일위대가" : "단가산출"; + const pick = { kind, layer, owner, key: r.이름 }; return { - tag: "일위대가", + tag, name, sub: [r.구분, r.상세구분].filter(Boolean).join(" "), unit: String(r.단위 || ""), - hit: has(`일위대가 ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`), + hit: has(`${tag} ${name} ${r.구분 ?? ""} ${r.상세구분 ?? ""}`), go: (ctx: TabContext, apply: ApplyRef) => void importLib({ apply, onPayload: onPayload(ctx) }, pick), }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts index 026839535..15dfbce47 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts @@ -8,6 +8,7 @@ * {블록 cover: {줄 cid: {qty · labor · material · expense}}} · 캐시에 바로 · 셈 짝이 바로 다시 셈 · * [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4) * 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림 + * M02 단가산출 블록 줄(`sk` · 719-2) = 되돌림 [단가산출 값으로](셈 값 = 단가산출 값) · 곁 글 = 품셈 값(`poom`) * 일위대가 연결(dg) 없는 줄 = 펼치지 않음 · 고른 줄 = 이 파일이 기억(다시 그려도 그대로) * 우클릭 덧메뉴(703-2) = 복사해 새 일위대가 · 내 자료집에 저장 · 어디 쓰였나(`…_Bill_Fold_Menu`) * 엑셀 붙여넣기(705-2) = 표를 누르고 Ctrl+V → 서버가 붙인 글을 읽어 지금 호표 끝 고정 줄(덮개 `@add` · 캐시 · @@ -59,11 +60,20 @@ let open: string | null = null; const plain = (v: unknown): string => typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸"; -/** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 */ +/** 되돌림 값 이름 — 단가산출 블록 줄 = 단가산출 값 · 그 밖 = 품셈 값 */ +const backName = (line: Row | undefined): string => (line?.sk ? "단가산출 값" : "품셈 값"); + +/** 단가산출에서 고친 줄의 품셈 값 곁 글 */ +const poomText = (line: Row): string => + line.poom ? `품셈 품 ${plain(line.poom.qty)} · 단가 ${plain(line.poom.단가)}` : ""; + +/** 품셈 값 칸 글 — 안 고침 = 「같음」 · 고친 칸 = 셈 값 · 단가산출 줄 = 품셈 값 곁 */ function baseText(line: Row): string { const covered: string[] = line.covered || []; - if (!covered.length) return "같음"; - return covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · "); + const poom = poomText(line); + if (!covered.length) return poom ? `단가산출 고침 — ${poom}` : "같음"; + const base = covered.map((k) => `${LABEL[k]} ${plain(line.base?.[k])}`).join(" · "); + return line.sk ? `단가산출 ${base}${poom ? ` (${poom})` : ""}` : base; } function linesOf(block: Row, blocks: Map, level: number, trail: string[]): FoldLine[] { @@ -184,7 +194,9 @@ function foldCard( const at = lines[Number(cell.parentElement?.dataset.i)]; const field = FIELD[key]; if (!at || !field || !covered(at, field)) return null; - return `고친 칸 — 품셈 값 ${plain(at.line.base?.[field])} · 우클릭 [품셈 값으로]`; + const poom = poomText(at.line); + const back = backName(at.line); + return `고친 칸 — ${back} ${plain(at.line.base?.[field])}${poom ? ` · ${poom}` : ""} · 우클릭 [${back}으로]`; }, "tr[data-i] td", ); @@ -192,7 +204,7 @@ function foldCard( const at = lines[i]; const undo: MapContextMenuItem[] = at && (at.line.covered || []).length - ? [["품셈 값으로", () => clearCovers(ctx, [at])], "-"] + ? [[`${backName(at.line)}으로`, () => clearCovers(ctx, [at])], "-"] : []; return [...undo, ...foldMenu(ctx, raw, row, block, at?.line, go)]; }); @@ -200,7 +212,7 @@ function foldCard( ...(edited.length ? [ createButton({ - label: `고친 칸 ${edited.length}줄 모두 품셈 값으로`, + label: `고친 칸 ${edited.length}줄 모두 ${backName((block.lines || []).find((l: Row) => l.sk))}으로`, variant: "ghost", size: "sm", onClick: () => clearCovers(ctx, edited), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts index c633edc0c..34c593700 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts @@ -87,11 +87,15 @@ async function postPersonal(body: Row): Promise<{ ok: boolean; status: number; g return { ok: response.ok, status: response.status, got }; } -/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 같은 이름 물음 */ +/** 내 자료집에 저장 — 개인 층 사전조합(덮개 · 넣을 값 · 고른 자재) · 품셈 블록 = 단가산출(SK · + * 품 · 단가 고친 칸 = SK 덮개 · 719-2) · 같은 이름 물음 */ async function savePersonal(ctx: TabContext, block: Row): Promise { + const sk = block.kind === "품셈"; let name = await askText({ title: "내 자료집에 저장", - message: "내 자료집 일위대가로 — 옵션 값 · 고른 자재 · 줄 수량 고친 칸까지", + message: sk + ? "내 자료집 단가산출로 — 옵션 값 · 고른 자재 · 품 · 단가 고친 칸까지" + : "내 자료집 일위대가로 — 옵션 값 · 고른 자재 · 줄 수량 고친 칸까지", value: block.name || "", okLabel: "저장", }); @@ -104,9 +108,9 @@ async function savePersonal(ctx: TabContext, block: Row): Promise { const skip: Row = got.안담음 || {}; const left = (skip.품 || 0) + (skip.단가 || 0); const tail = left - ? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김` + ? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가})${sk ? "" : " — 품은 고정 줄(PLAN 705) 뒤 담김"}` : ""; - showToast(`내 자료집에 저장 「${got.이름}」${tail}`, "success", 6000); + showToast(`내 자료집${sk ? " 단가산출" : ""}에 저장 「${got.이름}」${tail}`, "success", 6000); return; } if (status !== 409 || !same) { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts index 1e358ccf5..6054a8527 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link.ts @@ -66,7 +66,7 @@ export function askInputs(apply: ApplyRef, code: string, name: string, old?: Row /** 단가연결 목록에서 고른 것(263-1 · 「운반 단가」 모달이 받아 보임) · * lib = 있는 사본 폴더 · haul = 운반 값(263-2 · 손 값 {줄 id: {입력: 값}} · 덮은 칸 · 그 행 거리) */ export type LibPick = { - kind: "combo" | "precombo"; + kind: "combo" | "precombo" | "sangeun"; layer: string; owner: string | null; key: string; @@ -85,7 +85,7 @@ export interface LibLine { /** 사본 폴더 이름 → 서고 항목(`종류@층@주인@키~해시` · 짝 `Bill_Library.folder_name`) */ export function parseLib(lib: string): LibPick | null { const [kind, layer, owner, key] = lib.split("~")[0].split("@"); - if ((kind !== "combo" && kind !== "precombo") || !key) return null; + if ((kind !== "combo" && kind !== "precombo" && kind !== "sangeun") || !key) return null; return { kind, layer, owner: owner === "-" ? null : owner.replace("-", "/"), key, lib }; } diff --git a/M02_MasterTemplete/M02_Item_Sangeun.py b/M02_MasterTemplete/M02_Item_Sangeun.py index 0b5962009..487125d62 100644 --- a/M02_MasterTemplete/M02_Item_Sangeun.py +++ b/M02_MasterTemplete/M02_Item_Sangeun.py @@ -10,7 +10,8 @@ 고른자재가 짝을 안 잃음). 원본 층 문서는 읽기만. 사본 SK 문서 = {종류 · 키 · 이름 · 단위 · 구분 · 상세구분, 바탕 {로직: 굳힌 키}, 입력값(사슬 합), 열린입력, 켜쌓기율, 고름 · 자재표시(굳힌 줄 cid), 덮개 {}, 더한줄 [], 뺀줄 [], -굳힌줄(= 단가.json 줄), 원본(원래 SK 문서 · 「M02 에서 고침」 근거), 바탕판, 짝잃음}. +굳힌줄(= 단가.json 줄), 원본(원래 SK 문서 · 「M02 에서 고침」 근거), 정체(굳힌 줄 차례 → 원본 +cid · 더한줄 = `add:#k`), 바탕판, 짝잃음}. 단가산출 속 일위대가(SC) · SK 더한줄 = 참조 줄 그대로 → 사본 셈이 굳힌 참조사본으로 펼침. """ @@ -78,6 +79,7 @@ def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: "뺀줄": [], ROW: row, "원본": doc, + "정체": list(got["정체"]), # 굳힌 줄 차례 → 원본 cid(B09 덮개 · 내 자료집 · 719-2) "바탕판": got["바탕판"], "짝잃음": got["짝잃음"], } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index e18842fb5..6907c5790 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -342,7 +342,7 @@ export interface LibraryRow { /** 서고 목록 — 그 층(주인) 사전조합 `precombo` 문서 */ export const fetchLibrary = async ( ref: Omit, - kind: "precombo", + kind: "precombo" | "sangeun", ): Promise => { const q = ref.주인 && ref.층 !== "system" ? `?owner=${enc(ref.주인)}` : ""; const got = await call<{ 양식: (LibraryRow & { 종류: string })[] }>( diff --git a/resources/tester/test_155_5_sangeun_text.py b/resources/tester/test_155_5_sangeun_text.py index 6b780ec3e..e24c8438f 100644 --- a/resources/tester/test_155_5_sangeun_text.py +++ b/resources/tester/test_155_5_sangeun_text.py @@ -111,7 +111,8 @@ def test_글_속_금액은_산근_풀이_값과_같음(): whole = item_prices.load_prices(item_prices.to_json(item_prices.collect([code]))) sheets, price_of = _solver_prices(whole, code, given) block = sg.Solver(sheets).add(whole, {"code": code, "given": given}, "s1") - times = sg._unit(code)[1] # 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위 + # 결과단위 배(「원/1,000㎡」) — 산근 풀이와 같은 1 단위 + times = sg._unit(whole.logic(code))[1] got = st.render_sangeun_text(whole, code, given, price_of, times=times) parts = " + ".join(f"{st.PART_NAME[p]} {block[p]:,.0f}" for p in sg.PARTS) assert got[-1] == f"계 : {parts} = {block['sum']:,.0f}", code diff --git a/resources/tester/test_719_2_sk_project.py b/resources/tester/test_719_2_sk_project.py new file mode 100644 index 000000000..252707852 --- /dev/null +++ b/resources/tester/test_719_2_sk_project.py @@ -0,0 +1,267 @@ +"""719-2 공사 쪽 M02 단가산출(SK) — B09 대가 블록 · 산근 · [업데이트] · 내 자료집 SK(임시 storage · +실제 M01 읽기만). + +- 굳힌 SK = 품셈 꼴 대가 블록(`Labor_Source.sangeun`) · 금액 = M02 SK 셈 · 단가산출 속 일위대가 줄 = + 사전조합 블록을 부르는 줄 · 로직 더한줄의 하위 로직 = 산근 목록(`Sangeun.rows_of`) +- [업데이트] = SK 고친 뒤 다시 굳힘 — 굳힌 키 · cover · cid · 입력값 · 고른자재 · B09 덮개 그대로 · + 덮개 밑 값(base) = 새 단가산출 값 · 서고 사본(`sangeun`)도 원본판 다르면 다시 굳힘 · + 미리 보기 SK 줄 +- 내 자료집 = SK 블록 · 품셈 블록 → 개인 층 SK(B09 덮개 = SK 덮개) → 서고로 다시 불러와 같은 금액 +""" + +# ruff: noqa: F811 — 임시 층 fixture `world` 를 들여 씀 +import json + +import pytest + +from B09_Estimation import B09_Estimation_Bill_Library as library +from B09_Estimation import B09_Estimation_Daega_Personal as personal +from B09_Estimation import B09_Estimation_Prices as prices +from B09_Estimation import B09_Estimation_Refresh as refresh +from B09_Estimation import B09_Estimation_Sangeun as sangeun +from B09_Estimation.B09_Estimation_Calc_Base import compute_base +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs +from B09_Estimation.B09_Estimation_Linked import linked_resource_keys +from M02_MasterTemplete import M02_Item_Copy as item_copy +from M02_MasterTemplete import M02_Item_Snapshot as snapshots +from M02_MasterTemplete import M02_Item_Source as source +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_145_2_b09_labor_calc import STONE, _item, _project, _snap +from resources.tester.test_715_sangeun_store import PCT, _calc, _sk +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +TWO_MATS = "GC000565" # 재료 줄 둘(고른자재 열쇠) +COVER = {"labor:LB000036#0": {"qty": 0.5}} +PRICE = {"labor:LB000002#0": {"labor": 200000}} +OWNER = "7/42" + + +def _json(value): + return json.loads(json.dumps(value, ensure_ascii=False, default=str)) + + +def _sc(world, lines): + with store.at(world["personal"]): + doc = {"이름": "속 일위대가", "단위": "㎡", "담은로직": lines} + return refs.create("precombo", doc)["이름"] + + +def _mine(world, full=False): + """SK = 품 덮개 · 인력 더한줄 · 로직 더한줄(하위 산근) · full = 단가 덮개 + SC 더한줄.""" + added = [ + {"종류": "인력", "요소": "LB000002", "수량": 0.1}, + {"종류": "로직", "로직": PCT, "입력값": {}, "수량": 0.5}, + ] + cover = COVER + if full: + sc = _sc(world, [{"로직": PCT, "수량": {"손": 1}}]) + added.append({"참조": {**MINE, "키": sc}, "수량": 2}) # 단가산출 속 일위대가 + cover = {**COVER, **PRICE} + return _sk(world["personal"], 바탕={"로직": PCT}, 덮개=cover, 더한줄=added) + + +def _engine_ready(world): + """엔진 입력 덧칸(715-1b `단가` · `값묶음`)이 붙었나 — 아니면 M02 도 못 셈.""" + key = _sk( + world["personal"], 이름="엔진", 바탕={"로직": PCT}, 덮개={"labor:LB000002#0": {"labor": 1}} + ) + got = _calc(world["personal"], key) + return not got.get("까닭") and got["줄"][1]["단가"] == 1 + + +def _b09(root, cover=None): + keys = linked_resource_keys(root) + inputs = prices.build_inputs(None, keys) + inputs["daega"] = daega_inputs(root) + base = compute_base(inputs, {}) + sg = sangeun.build(root, base) + rows = {r["key"]: r for r in sg["sg_list"]} + out = compute_labor(inputs["daega"], base, rows, cover) + return inputs["daega"], {b["id"]: b for b in out["daega_blocks"]}, rows + + +def _lib(world, tmp_path, key, kind="sangeun"): + root = _project(tmp_path / "proj", []) + made = library.import_copy(root, kind, "personal", OWNER, key) + return root, made["폴더"] + + +def _set_cover(world, key, cover): + with store.at(world["personal"]): + now = store.read("sangeun", key) + store.write("sangeun", key, now["판"], {**now["문서"], "덮개": cover}) + + +def _same_money(block, live): + assert block["sum"] == pytest.approx(float(live["1단위"]["계"]), abs=5) # 끝수(내림)만 다름 + for part, name in (("labor", "노무비"), ("material", "재료비"), ("expense", "경비")): + assert block[part] == pytest.approx(float(live["1단위"][name]), abs=3) + + +def test_SK_대가_블록_금액_M02_같음_하위_산근(world, tmp_path): + key = _mine(world) + live = _calc(world["personal"], key) + assert not live.get("까닭") and live["1단위"]["계"] > 0 + root, folder = _lib(world, tmp_path, key) + daega, done, sg_rows = _b09(root) + block = done[daega["libs"][folder]] + assert block["cover"].startswith("P:") and block["kind"] == "품셈" + lines = block["lines"] + assert all(ln.get("sk") for ln in lines) + assert lines[0]["kind"] == "labor" and lines[0]["qty"] == 0.5 + assert lines[0]["poom"] == {"qty": 0.018, "단가": 233250} # M02 에서 고친 줄 = 품셈 값 곁 + assert "poom" not in lines[1] + sub = next(ln for ln in lines if ln["kind"] == "sangeun") + assert sub["key"] in sg_rows and sub["key"].startswith(f"SG:{PCT}") and sub["qty"] == 0.5 + _same_money(block, live) + + +def test_SK_단가_덮개_속_일위대가_펼침(world, tmp_path): + if not _engine_ready(world): + pytest.skip("715-1b 엔진 입력 덧칸(단가 · 값묶음) 뒤 — 지금은 M02 SK 셈도 못 함") + key = _mine(world, full=True) + live = _calc(world["personal"], key) + root, folder = _lib(world, tmp_path, key) + daega, done, _ = _b09(root) + block = done[daega["libs"][folder]] + lines = block["lines"] + assert lines[1]["kind"] == "fixed" and lines[1]["prices"]["labor"] == pytest.approx(200000) + sc_line = next(ln for ln in lines if ln["kind"] == "daega") + assert done[sc_line["key"]]["kind"] == "사전조합" and sc_line["qty"] == 2 + _same_money(block, live) + + +def test_구조물_사본_SK_줄_업데이트_덮개_입력값_고른자재_굳힌키_그대로( + world, tmp_path, monkeypatch +): + key = _mine(world) + ref = {**MINE, "키": key} + with store.at(world["personal"]): + from M02_MasterTemplete import M02_Labor_Doc as labor + + doc = labor.clean( + { + "열": "rv", + "담은로직": [ + {"참조": ref, "수량": {"손": 2}}, + {"로직": "GF000219", "입력값": {}, "수량": {"손": 1}}, + {"로직": TWO_MATS, "수량": {"손": 1}}, + ], + } + ) + + def build(_item_): # 원본 층 → 사본 파일(구조물 원본 대신 이 일위대가 문서) + rows = source.labor_doc_rows(doc) + return _json( + { + source.LABOR_FILE: rows, + source.PRICE_FILE: source.price_bundle(rows, world["personal"]), + snapshots.VALUES_FILE: {}, + } + ) + + monkeypatch.setattr(item_copy, "build_snapshot", build) + root = _project(tmp_path / "proj", [(100, "SS000901")]) + folder = snapshots.structures_dir(root) / snapshots.folder_name("pipe@100.0") + folder.mkdir(parents=True) + values = {"GF000219": {k: {"값": v} for k, v in STONE.items()}} + picks = {TWO_MATS: {"1": "MT000001"}} + for name, data in { + **build(None), + snapshots.VALUES_FILE: values, + snapshots.PICKS_FILE: picks, + snapshots.MANIFEST_NAME: {"원본": {**_item("SS000901"), "경로": ["x"]}}, + }.items(): + (folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + rid = refs.ref_id(ref) + frozen_key = json.loads((folder / source.LABOR_FILE).read_text("utf-8"))["참조사본"][rid] + frozen_key = frozen_key["바탕"]["로직"] + daega, _, _ = _b09(root) + combo = next(b for b in daega["blocks"] if b["id"] == daega["owners"]["pipe@100.0"]) + sk_block = next(b for b in daega["blocks"] if b["id"] == combo["lines"][0]["key"]) + cid = sk_block["lines"][0]["cid"] + cover = {sk_block["cover"]: {cid: {"qty": 0.9}}} + # SK 고침(품 0.5 → 0.7) → [재적용](업데이트) = 다시 풀어 굳힘 + _set_cover(world, key, {**COVER, "labor:LB000036#0": {"qty": 0.7}}) + item_copy.reapply(root, folder.name) + after = json.loads((folder / source.LABOR_FILE).read_text("utf-8"))["참조사본"][rid] + assert after["바탕"]["로직"] == frozen_key # 굳힌 키 그대로 + assert ( + json.loads((folder / snapshots.VALUES_FILE).read_text("utf-8"))["GF000219"] + == (values["GF000219"]) + ) + assert json.loads((folder / snapshots.PICKS_FILE).read_text("utf-8")) == picks + daega2, done, _ = _b09(root, cover) + block = next(b for b in done.values() if b["cover"] == sk_block["cover"]) + line = block["lines"][0] + assert line["cid"] == cid and line["covered"] == ["qty"] and line["qty"] == 0.9 + assert line["base"]["qty"] == 0.7 # 덮개 밑 = 새 단가산출 값(「단가산출 값으로」 되돌림 값) + assert daega2["owners"]["pipe@100.0"] in done + + +def test_서고_SK_업데이트_미리보기_다시_굳힘(world, tmp_path): + key = _mine(world) + root, folder = _lib(world, tmp_path, key) + assert library.changed(root) == [] + before = daega_inputs(root) + cover_key = next(b for b in before["blocks"] if b["id"] == before["libs"][folder])["cover"] + _set_cover(world, key, {**COVER, "labor:LB000036#0": {"qty": 0.7}}) + got = refresh.preview(root, None, [], None) + assert [(x["키"], x["판"]) for x in got["단가산출"]] == [(key, "바뀜")] + assert [c["폴더"] for c in got["사본"]] == [folder] + assert refresh.reapply_changed(root)["다시"] == ["내 단가산출"] + assert library.changed(root) == [] and [f.name for f, _ in library.copies(root)] == [folder] + after = daega_inputs(root) + block = next(b for b in after["blocks"] if b["id"] == after["libs"][folder]) + assert block["cover"] == cover_key and block["lines"][0]["qty"] == 0.7 + + +def test_내_자료집_SK_왕복_같은_금액(world, tmp_path): + key = _mine(world) + root, folder = _lib(world, tmp_path, key) + daega, _, _ = _b09(root) + block = next(b for b in daega["blocks"] if b["id"] == daega["libs"][folder]) + first, second = block["lines"][0]["cid"], block["lines"][1]["cid"] + priced = {block["cover"]: {first: {"qty": 0.9}, second: {"labor": 210000}}} + made = personal.build(root, block["cover"], priced, "내 SK 2") + doc = made["문서"] + assert doc["종류"] == "단가산출" and doc["바탕"] == {"로직": PCT} + assert doc["덮개"]["labor:LB000036#0"] == {"qty": 0.9} + assert doc["덮개"]["labor:LB000002#0"] == {"labor": 210000} + assert doc["더한줄"][0]["요소"] == "LB000002" + assert made["담음"] == {"품": 1, "단가": 1} and made["안담음"] == {"품": 0, "단가": 0} + third = block["lines"][2]["cid"] # 더한줄 인력 = 그 더한줄 수량 + cover = {block["cover"]: {first: {"qty": 0.9}, third: {"qty": 0.3}}} + doc = personal.build(root, block["cover"], cover, "내 SK 2")["문서"] + assert doc["더한줄"][0]["수량"] == 0.3 and doc["덮개"] == {"labor:LB000036#0": {"qty": 0.9}} + _, done, _ = _b09(root, cover) + want = done[daega["libs"][folder]]["sum"] + with store.at(world["personal"]): + new = personal.write(doc)["이름"] + with pytest.raises(store.StoreError) as same: + personal.write(doc) + assert same.value.status == 409 and same.value.detail["같은이름"] == new + assert new.startswith("SK-") and new != key + root2, folder2 = _lib(world, tmp_path / "b", new) + daega2, done2, _ = _b09(root2) + assert done2[daega2["libs"][folder2]]["sum"] == pytest.approx(want, abs=2) + + +def test_내_자료집_품셈_블록_SK(world, tmp_path): + root = _project(tmp_path / "proj", [(100, "SS000901")]) + _snap(root, "pipe@100.0", "SS000901", [{"로직": PCT, "승격": True, "수량": {"손": 1}}], {}) + daega = daega_inputs(root) + top = next(b for b in daega["blocks"] if b["id"] == daega["owners"]["pipe@100.0"]) + block = next(b for b in daega["blocks"] if b["id"] == top["lines"][0]["key"]) + assert block["kind"] == "품셈" and block["origin"]["로직"] == PCT + cover = {block["cover"]: {block["lines"][0]["cid"]: {"qty": 0.5}}} + doc = personal.build(root, block["cover"], cover, "내 해체")["문서"] + assert doc["바탕"] == {"로직": PCT} and doc["단위"] == "㎡" + assert doc["덮개"] == {"labor:LB000036#0": {"qty": 0.5}} + with store.at(world["personal"]): + new = personal.write(doc)["이름"] + plain = _sk(world["personal"], 이름="견줌", 바탕={"로직": PCT}, 덮개=doc["덮개"]) + a, b = _calc(world["personal"], new), _calc(world["personal"], plain) + assert a["1단위"]["계"] == b["1단위"]["계"] > 0 From 36a21591e264388e8b5f515d31da5367e04c2559 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:11:09 +0900 Subject: [PATCH 23/30] =?UTF-8?q?fix(715=20=C2=B7=20712-2=20=EC=9D=B4?= =?UTF-8?q?=EC=9D=8C):=20M02=5FSangeun=5FCalc.times=5Fof=20=E2=80=94=20?= =?UTF-8?q?=EC=98=9B=20Store=5FCombo.=5FPER=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=95=9C=20=EB=B2=8C=20master=5Funit.per(?= =?UTF-8?q?712-2=20=EA=B0=80=20=EC=98=AE=EA=B9=80)=20=C2=B7=20=ED=95=A9?= =?UTF-8?q?=EC=B9=9C=20=EB=92=A4=20=EC=8B=9C=ED=97=98=2063=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Sangeun_Calc.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index a6f0743fd..a45cd402c 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -47,9 +47,8 @@ def times_of(row: dict) -> Decimal: """결과단위 분모 앞 수(원/10㎡ → 10) — 없으면 1.""" from M01_MasterData import M01_MasterData_Store_Combo as combo_store - per = combo_store._PER.findall(str(row.get("결과단위") or "")) - head = per[0][0].replace(",", "") if len(per) == 1 else "" - return Decimal(head) if head else Decimal(1) + per = combo_store.mu.per(row.get("결과단위")) # 712-2 단위 한 벌 + return per[0][0] if len(per) == 1 else Decimal(1) def fingerprint(row: dict) -> str: From 5f36e85d7d9223cf26de4580a71253dffc6e9281 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:17:38 +0900 Subject: [PATCH 24/30] =?UTF-8?q?fix(=ED=95=A9=EC=B9=A8):=20ui=5Ftemplate?= =?UTF-8?q?=5Flocale=5Fm2=20=EC=B6=A9=EB=8F=8C=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EA=B1=B7=EC=9D=8C=20=E2=80=94=20717-2a=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=ED=82=A4=20=C2=B7=20717-4=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=ED=82=A4=20=EC=96=91=EC=AA=BD=20=EC=82=B4?= =?UTF-8?q?=EB=A6=BC(6561c90ab=20=ED=95=A9=EC=B9=A8=EC=97=90=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EA=B0=80=20=EB=82=A8=EC=95=98=EB=8D=98=20=EA=B2=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ui_template/ui_template_locale_m2.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index e90310eac..ad7f6ee6e 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -191,7 +191,6 @@ export const ui_locales_m2 = { M02_HopyoImport: ["일위대가 불러오기", "Import unit-price sheets"], B01_Dashboard_CardPrecombo: ["일위대가", "Unit-cost items"], M02_PrecomboTitle: ["일위대가", "Unit-cost items"], -<<<<<<< HEAD B01_Dashboard_CardSangeun: ["단가산출", "Unit-price analyses"], M02_SangeunTitle: ["단가산출", "Unit-price analyses"], M02_SangeunNone: ["단가산출 없음", "No unit-price analyses"], @@ -208,10 +207,8 @@ export const ui_locales_m2 = { "단가산출 상세 표 · 고침 — 준비 중(717-2b)", "Unit-price detail table and editing — coming next (717-2b)", ], -======= M02_PickStd: ["기준 목록", "Standard list"], M02_PickSangeun: ["단가산출", "Unit-cost calcs"], M02_PickElement: ["M01 요소", "M01 elements"], M02_PickMaterial: ["재료 고르기", "Pick a material"], ->>>>>>> origin/sub_laptop_7 } as const; From cf6e2cf725c61cb2d8121d2893e5ad4133291140 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:21:28 +0900 Subject: [PATCH 25/30] =?UTF-8?q?test(717-4):=20=EC=9D=B4=EC=9D=8C?= =?UTF-8?q?=EB=A7=A4=20=EC=8B=9C=ED=97=98=20=EC=9E=90=EB=A3=8C=EC=97=90=20?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=E3=80=8C=E3=8E=A1=E3=80=8D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 715-3 일위대가 단위 빈칸 막기와 맞물려 단위 없는 시험 자료가 실패하던 것 수정 Co-Authored-By: Claude Sonnet 5.5 --- resources/tester/test_717_4_sangeun_pick.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py index 858eb0d6b..c2c14859a 100644 --- a/resources/tester/test_717_4_sangeun_pick.py +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -64,7 +64,11 @@ def test_list_amount_and_labor_line_holds_upper_sk(world): # noqa: F811 with store.at(world["personal"]): sc = refs.create( "precombo", - {"이름": "일위대가", "담은로직": [{"참조": {**COMPANY, "키": sk}, "수량": {"손": 3}}]}, + { + "이름": "일위대가", + "단위": "㎡", + "담은로직": [{"참조": {**COMPANY, "키": sk}, "수량": {"손": 3}}], + }, )["이름"] saved = store.read("precombo", sc)["문서"]["담은로직"][0]["참조"] got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {}) From 702ab1daa843dcd2d69d6a532a70c9b1d92a4c20 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:30:33 +0900 Subject: [PATCH 26/30] =?UTF-8?q?feat(714-2):=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=84=9C=20=EB=AC=B6=EC=9D=8C=20=EA=BC=B4=20=E2=80=94=20?= =?UTF-8?q?=EC=9D=B8=EB=A0=A5=20=C2=B7=20=EC=9E=AC=EB=A3=8C=20=C2=B7=20?= =?UTF-8?q?=EA=B2=BD=EB=B9=84(=EC=9E=A5=EB=B9=84)=20=C2=B7=20=ED=95=98?= =?UTF-8?q?=EC=9C=84(714-1=20=EC=A4=84=20=EB=8D=A7=EC=B9=B8=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C)=20=C2=B7=20=EC=9E=A5=EB=B9=84=20=C2=B7=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=EB=A1=9C=EC=A7=81=20=3D=20=ED=95=9C=20=EC=A4=84=20?= =?UTF-8?q?+=20=EB=85=B8=20=C2=B7=20=EC=9E=AC=20=C2=B7=20=EA=B2=BD=20?= =?UTF-8?q?=EC=B9=B8=20=C2=B7=20=EB=88=84=EB=A5=B4=EB=A9=B4=20=EB=AA=AB=20?= =?UTF-8?q?=EC=8B=9D=20=EC=84=9D=20=EC=A4=84=20=ED=8E=BC=EC=B9=A8=20=C2=B7?= =?UTF-8?q?=20=EB=AC=B6=EC=9D=8C=20=EB=A8=B8=EB=A6=AC=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=C2=B7=20=ED=99=98=EC=82=B0=20=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20=C2=B7=20=EC=93=B0=EB=8A=94=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=B9=B8=20=C2=B7=20=EB=A7=A8=20=EC=95=84=EB=9E=98=20=EB=B9=84?= =?UTF-8?q?=EB=AA=A9=20=EC=86=8C=EA=B3=84=20=EC=85=8B=20+=20=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - calc_sheet.ts bundle 꼴 · bundleLines(순수 · 같은 이름 k 번째끼리 짝) · 새 calc_sheet.css(장비 줄 누름 · 몫 들여씀) - M01 「인력·재료·경비」 = bundle 꼴 · 변수 칸 = 서버 쓰는입력 - 714-3 텍스트 수식 단추도 서버 쓰는입력 먼저(조건 · 재료 고르기 입력까지) · 없으면 조각 - 704-2 M02 계산서 보기 = 비목 꼴 그대로(DOM 전후 같음) - 장비 펼침 넉 줄(손료 · 주연료 · 잡품 · 운전사) = sub3 「펼침」 덧칸 뒤 Co-Authored-By: Claude Opus 5.5 --- A00_Common/calc_sheet.css | 14 ++ A00_Common/calc_sheet.ts | 209 +++++++++++++++--- .../M01_MasterData_UI_LogicLab_Est.ts | 9 +- .../M01_MasterData_UI_LogicLab_Formula.ts | 12 +- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 6 + resources/tester/test_714_2_calc_sheet.py | 39 +++- 6 files changed, 246 insertions(+), 43 deletions(-) create mode 100644 A00_Common/calc_sheet.css diff --git a/A00_Common/calc_sheet.css b/A00_Common/calc_sheet.css new file mode 100644 index 000000000..9ddd4e44a --- /dev/null +++ b/A00_Common/calc_sheet.css @@ -0,0 +1,14 @@ +/* 공용 단가산출 계산서(714-2) — 장비 · 하위 로직 한 줄 = 누르면 펼침 */ +[data-sheet-equip] { + cursor: pointer; + border-radius: var(--radius-sm); +} + +[data-sheet-equip]:hover, +[data-sheet-equip]:focus-visible { + background: var(--color-hover); +} + +[data-sheet-part] .ui-calc-row__label { + padding-left: var(--spacing-16); +} diff --git a/A00_Common/calc_sheet.ts b/A00_Common/calc_sheet.ts index b8422bdc3..d5c3bad9a 100644 --- a/A00_Common/calc_sheet.ts +++ b/A00_Common/calc_sheet.ts @@ -1,22 +1,34 @@ /* ============================================================================= * calc_sheet.ts - * 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5) — 704-2 M02 계산서 보기를 올린 것. + * 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5 · 6) — 704-2 M02 계산서 보기를 올린 것. * 쓰는 곳 = M01 「인력·재료·경비」 보기 · M02 기준 목록 계산서 보기(704-2) · (뒤에) 일위대가 모달 · B09 단가산출근거 - * 꼴 = 묶음마다 [머리(변수 칸이 있을 때만) · 쓰는 입력 값 줄] · 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계 - * 값 = 서버 셈 그대로 찍음(화면에서 다시 셈 안 함) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈 - * 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 요소 · 페이지 CSS 없음 + * 꼴 둘 — + * 비목(기본 · 704-2) = 비목 묶음마다 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계 + * 묶음(`bundle`) = 인력 · 재료 · 경비(장비) · 하위 — 줄의 `묶음`(714-1 서버 덧칸) 으로 다시 묶음 · + * 장비 · 하위 로직 = 한 줄(노 · 재 · 경 칸 · 합) + 누르면 펼침(그 줄의 노 · 재 · 경 식) · + * 묶음 머리 = 이름 · 기준단위 · 환산 근거 · 그 묶음이 쓰는 입력 칸(`쓰는입력`) · 맨 아래 비목 소계 셋 + 계 + * 값 = 서버 셈 그대로(묶음 소계만 서버 줄 금액의 합) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈 + * 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 단추 · 표 · 페이지 CSS 없음 * ========================================================================== */ import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "@ui/ui_template_calc_row"; import { el } from "@ui/ui_template_elements"; import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; +import "./calc_sheet.css"; export interface SheetLine { 이름: string; /** 서버 값 식 「단가 * 품 = 금액」 — 「= 금액」 은 떼고 「*」 → 「×」 */ 글: string; 금액: number | null; + /** 714-1 서버 덧칸 — 그 줄 값이 쓰는 로직 입력 · 묶음 · 장비 · 찾는 표 기준 · 환산 근거 */ + 쓰는입력?: string[]; + 묶음?: string | null; + 장비?: boolean | null; + 기준단위?: string | null; + 환산근거?: string; } export interface SheetGroup { 비목: string; @@ -34,22 +46,103 @@ export interface SheetOptions { 계: number | null; /** 맨 아래 계 줄 이름(「계 (원/㎡)」) */ sumLabel: string; - /** 변수 칸 — 입력 꼴 · 지금 값 · 묶음이 쓰는 입력 이름 · 바꾼 뒤 할 일(없으면 변수 칸 없음) */ + /** 인력 · 재료 · 경비(장비) 묶음으로 다시 묶음(없으면 서버 비목 묶음 그대로 · 704-2) */ + bundle?: boolean; + /** 변수 칸 — 입력 꼴 · 지금 값 · 바꾼 뒤 할 일(없으면 변수 칸 없음) · 칸 = 묶음 줄들의 `쓰는입력` */ inputs?: SheetInput[]; values?: Record; - uses?: (group: SheetGroup, index: number) => string[]; onInput?: (name: string, value: string) => void; } +/* ── 순수 셈 ── */ +/** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */ +export const sheetFormula = (text: string): string => + text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × "); + +/** 장비 · 하위 로직 한 줄 — 비목마다 몫 줄(서버 줄 그대로) · 합 */ +export interface SheetMerged { + 이름: string; + 몫: { 비목: string; 줄: SheetLine }[]; + 금액: number | null; +} +export interface SheetBundle { + 묶음: string; + 줄: (SheetLine | SheetMerged)[]; + 소계: number; + 쓰는입력: string[]; + 기준단위: string[]; + 환산근거: string[]; +} + +const ORDER = ["인력", "재료", "경비", "하위"]; +const OF_COST: Record = { 노무비: "인력", 재료비: "재료", 경비: "경비" }; +const add = (a: number | null, b: number | null): number | null => + a === null && b === null ? null : (a ?? 0) + (b ?? 0); +const merges = (l: SheetLine): boolean => !!l.장비 || l.묶음 === "하위"; + +/** 비목 묶음 → 인력 · 재료 · 경비 · 하위 묶음 — 장비 · 하위 줄은 비목마다 갈린 몫을 한 줄로 + * (같은 이름 k 번째끼리 짝 — 서버 줄 차례 = 호표 차례 · 비목마다 같은 차례) */ +export function bundleLines(groups: SheetGroup[]): SheetBundle[] { + const bundles = new Map(); + const merged = new Map(); + const take = (name: string): SheetBundle => { + let b = bundles.get(name); + if (!b) { + b = { 묶음: name, 줄: [], 소계: 0, 쓰는입력: [], 기준단위: [], 환산근거: [] }; + bundles.set(name, b); + } + return b; + }; + for (const g of groups) { + const seen = new Map(); + for (const line of g.줄) { + const b = take(line.묶음 || OF_COST[g.비목] || g.비목); + b.소계 += line.금액 ?? 0; + for (const n of line.쓰는입력 ?? []) if (!b.쓰는입력.includes(n)) b.쓰는입력.push(n); + if (line.기준단위 && !b.기준단위.includes(line.기준단위)) b.기준단위.push(line.기준단위); + if (line.환산근거 && !b.환산근거.includes(line.환산근거)) b.환산근거.push(line.환산근거); + if (!merges(line)) { + b.줄.push(line); + continue; + } + const k = seen.get(line.이름) ?? 0; + seen.set(line.이름, k + 1); + const id = `${b.묶음}\n${line.이름}\n${k}`; + let m = merged.get(id); + if (!m) { + m = { 이름: line.이름, 몫: [], 금액: null }; + merged.set(id, m); + b.줄.push(m); + } + m.몫.push({ 비목: g.비목, 줄: line }); + m.금액 = add(m.금액, line.금액); + } + } + const rank = (x: string): number => (ORDER.includes(x) ? ORDER.indexOf(x) : ORDER.length); + return [...bundles.values()].sort((a, b) => rank(a.묶음) - rank(b.묶음)); +} +/* ── 순수 셈 끝 ── */ + +const TEXT = { + 인력: ["인력", "Labor"], + 재료: ["재료", "Material"], + 경비: ["경비(장비)", "Expense (equipment)"], + 하위: ["하위 로직", "Sub logic"], + Basis: ["기준", "Basis"], + Short: ["노 {a} · 재 {b} · 경 {c}", "L {a} · M {b} · E {c}"], + Fold: ["누르면 노 · 재 · 경 식 펼침", "Click to expand labor · material · expense"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; +const bundleName = (b: string): string => (b in TEXT ? t(b as keyof typeof TEXT) : b); + const won = (v: number | null, digits = 0): string => v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits }); const out = (v: number | null, key = false, digits = 0): HTMLElement => el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) }); -/** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */ -export const sheetFormula = (text: string): string => - text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × "); +const lineRow = (line: SheetLine, label = line.이름): HTMLElement => + createCalcFormulaRow(label, createCalcFormula(sheetFormula(line.글)), out(line.금액, false, 1)); /** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */ function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement { @@ -75,32 +168,86 @@ function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): return row; } +/** 장비 · 하위 한 줄 — 노 · 재 · 경 칸 + 합 · 누르면(Enter · Space) 몫 식 줄 펼침 */ +function mergedRows(m: SheetMerged): HTMLElement[] { + const share = (cost: string): number | null => + m.몫.filter((x) => x.비목 === cost).reduce((s, x) => add(s, x.줄.금액), null); + const short = t("Short") + .replace("{a}", won(share("노무비"), 1)) + .replace("{b}", won(share("재료비"), 1)) + .replace("{c}", won(share("경비"), 1)); + const head = createCalcFormulaRow(`▸ ${m.이름}`, createCalcFormula(short), out(m.금액, false, 1)); + const parts = m.몫.map((x) => lineRow(x.줄, `${x.비목}`)); + for (const p of parts) { + p.hidden = true; + p.dataset.sheetPart = m.이름; + } + head.dataset.sheetEquip = m.이름; + head.title = t("Fold"); + head.tabIndex = 0; + head.setAttribute("role", "button"); + head.setAttribute("aria-expanded", "false"); + const label = head.firstElementChild as HTMLElement; + const toggle = (): void => { + const open = head.getAttribute("aria-expanded") !== "true"; + head.setAttribute("aria-expanded", String(open)); + label.textContent = `${open ? "▾" : "▸"} ${m.이름}`; + parts.forEach((p) => (p.hidden = !open)); + }; + head.addEventListener("click", toggle); + head.addEventListener("keydown", (ev) => { + if (ev.key !== "Enter" && ev.key !== " ") return; + ev.preventDefault(); + toggle(); + }); + return [head, ...parts]; +} + +/** 묶음 머리 — 이름 · 기준 · 환산 근거 · 쓰는 입력 칸 */ +function headRows( + name: string, + uses: string[], + o: SheetOptions, + specs: Map, + basis: string[] = [], + notes: string[] = [], +): HTMLElement[] { + const vars = o.onInput ? uses.filter((n) => specs.has(n)) : []; + if (!vars.length && !o.bundle) return []; + const title = [name, basis.length ? `${t("Basis")} ${basis.join(" · ")}` : ""].filter(Boolean); + const head = createText(title.join(" — "), "muted", 14); + head.dataset.sheetHead = name; + const rows: HTMLElement[] = [head]; + if (notes.length) rows.push(createText(notes.join(" · "), "hint")); + const onInput = o.onInput; + if (onInput) + for (const n of vars) + rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v))); + return rows; +} + +const subtotal = (g: SheetGroup): HTMLElement => + createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)); + /** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */ export function calcSheetRows(o: SheetOptions): HTMLElement[] { const rows: HTMLElement[] = []; - const specs = new Map((o.onInput ? (o.inputs ?? []) : []).map((s) => [s.이름, s])); - o.묶음.forEach((g, gi) => { - const vars = (o.uses?.(g, gi) ?? []).filter((n) => specs.has(n)); - if (vars.length && o.onInput) { - const onInput = o.onInput; - const head = createText(g.비목, "muted", 14); - head.dataset.sheetHead = g.비목; - rows.push(head); - for (const n of vars) - rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v))); + const specs = new Map((o.inputs ?? []).map((s) => [s.이름, s])); + if (o.bundle) { + for (const b of bundleLines(o.묶음)) { + const name = bundleName(b.묶음); + rows.push(...headRows(name, b.쓰는입력, o, specs, b.기준단위, b.환산근거)); + for (const line of b.줄) rows.push(...("몫" in line ? mergedRows(line) : [lineRow(line)])); + rows.push(createCalcFormulaRow(name, null, out(b.소계, true, 1))); } - for (const line of g.줄) - rows.push( - createCalcFormulaRow( - line.이름, - createCalcFormula(sheetFormula(line.글)), - out(line.금액, false, 1), - ), - ); - rows.push( - createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)), - ); - }); + rows.push(...o.묶음.map(subtotal)); // 맨 아래 비목 소계(서버 끝수 그대로) — 장비 노무는 여기서 인력 노무와 더해짐 + } else { + for (const g of o.묶음) { + const uses = [...new Set(g.줄.flatMap((l) => l.쓰는입력 ?? []))]; + rows.push(...headRows(g.비목, uses, o, specs)); + rows.push(...g.줄.map((line) => lineRow(line)), subtotal(g)); + } + } rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true))); return rows; } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts index adef8655f..dc8dd6cf0 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Est.ts * 「인력·재료·경비」 보기(PLAN 714-2) — 공용 단가산출 계산서(`A00_Common/calc_sheet.ts`)에 시험 계산 읽는 식(`/text`)을 그대로. - * 묶음 머리 변수 칸 = 그 묶음 줄들이 쓰는 시험 입력(714-3 `usedInputs`) · 바꾸면 시험 계산 입력 고침 + 다시 셈(정본 안 바뀜) + * 인력 · 재료 · 경비(장비) 묶음(714-1 줄 덧칸 `묶음`) · 묶음 머리 변수 칸 = 줄들의 `쓰는입력` · 바꾸면 시험 계산 입력 고침 + 다시 셈(정본 안 바뀜) * ========================================================================== */ import { el } from "@ui/ui_template_elements"; @@ -9,7 +9,7 @@ import { createText } from "@ui/ui_template_text"; import { calcSheetRows } from "../A00_Common/calc_sheet"; import type { LogicInput } from "./M01_MasterData_UI_Logic_Api"; import { safeReason } from "./M01_MasterData_UI_Logic_Text"; -import { usedInputs, type PreviewTextAnswer } from "./M01_MasterData_UI_LogicLab_Formula"; +import type { PreviewTextAnswer } from "./M01_MasterData_UI_LogicLab_Formula"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; export interface EstContext { @@ -31,18 +31,15 @@ export function buildEst(ctx: EstContext): HTMLElement { host.append(createText(safeReason(text.reason), "error")); return host; } - const names = ctx.inputs.map((x) => x.이름); host.append( ...calcSheetRows({ 묶음: text.groups, 계: text.계, sumLabel: `${tl("Total")} (${ctx.unit || "원"})`, + bundle: true, inputs: ctx.inputs, values: ctx.values, onInput: ctx.onInput, - uses: (_, gi) => [ - ...new Set(text.groups[gi].줄.flatMap((line) => usedInputs(line, names).map(([n]) => n))), - ], }), ); return host; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index d14afae32..4cf179ae1 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -3,7 +3,7 @@ * 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음) * 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이 * 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게 - * 714-3 — 줄이 쓰는 입력(표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 → + * 714-3 — 줄이 쓰는 입력(서버 `쓰는입력` · 없으면 표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 → * 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8) * ========================================================================== */ @@ -123,10 +123,12 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { const rows: HTMLElement[] = []; const specs = ctx.onInput ? (ctx.inputs ?? []) : []; const vars = (line: TextLine): HTMLElement[] => { - const used = usedInputs( - line, - specs.map((x) => x.이름), - ); + const names = specs.map((x) => x.이름); + const seen = new Map(usedInputs(line, names)); + // 서버 「쓰는입력」(714-1 · 조건 · 재료 고르기 · 중간값까지) 이 있으면 그것 · 없으면 조각에서 + const used: [string, string][] = line.쓰는입력 + ? line.쓰는입력.filter((n) => names.includes(n)).map((n) => [n, seen.get(n) ?? ""]) + : [...seen]; if (!used.length || !ctx.onInput) return []; const onInput = ctx.onInput; return [ diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 6890e0432..7586b1b50 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -148,6 +148,12 @@ export interface TextLine { 짝?: number; 금액: number; 까닭?: string; + /** 714-1 계산서 줄 덧칸 — 쓰는 로직 입력 · 묶음(인력 · 재료 · 경비 · 하위) · 장비 · 찾는 표 기준 · 환산 근거 */ + 쓰는입력?: string[]; + 묶음?: string | null; + 장비?: boolean | null; + 기준단위?: string | null; + 환산근거?: string; } /** `POST /text` 답 — 서버가 만든 읽는 식 줄(화면은 그대로 찍음) */ export type TextAnswer = diff --git a/resources/tester/test_714_2_calc_sheet.py b/resources/tester/test_714_2_calc_sheet.py index b2c8772d5..5b2c4418e 100644 --- a/resources/tester/test_714_2_calc_sheet.py +++ b/resources/tester/test_714_2_calc_sheet.py @@ -36,7 +36,7 @@ def test_부품_상자만(): @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") def test_식_글(): s = _text(SHEET) - pure = s[s.index("export const sheetFormula") : s.index("/** 변수 칸 하나")] + pure = s[s.index("/* ── 순수 셈 ── */") : s.index("/* ── 순수 셈 끝 ── */")] body = 'console.log(JSON.stringify([sheetFormula("268,908 * 0.12 = 32,269"), sheetFormula("59,020 * 1 * 8 / 165 = 2,862"), sheetFormula("")]))' out = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", @@ -45,3 +45,40 @@ def test_식_글(): ) # fmt: skip assert out.returncode == 0, out.stderr assert json.loads(out.stdout) == ["268,908 × 0.12", "59,020 × 1 × 8 / 165", ""] + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_묶음_장비_한_줄(): + s = _text(SHEET) + pure = s[s.index("/* ── 순수 셈 ── */") : s.index("/* ── 순수 셈 끝 ── */")] + body = """ +const eq = (n, a, 쓰는입력 = []) => ({ 이름: n, 글: "", 금액: a, 묶음: "경비", 장비: true, 쓰는입력, 기준단위: "일" }); +const groups = [ + { 비목: "노무비", 소계: 0, 줄: [ + { 이름: "포설공", 글: "", 금액: 10, 묶음: "인력", 쓰는입력: [] }, + eq("굴착기", 100, ["지역"]), eq("굴착기", 7), + { 이름: "보통인부", 글: "", 금액: 5, 묶음: "인력", 쓰는입력: ["두께"], 기준단위: "10㎡", 환산근거: "10㎡당 → 1㎡당 ÷10" } ] }, + { 비목: "재료비", 소계: 0, 줄: [ eq("굴착기", 20), eq("굴착기", 2), { 이름: "모래", 글: "", 금액: 3 } ] }, + { 비목: "경비", 소계: 0, 줄: [ eq("굴착기", 30), eq("굴착기", null), { 이름: "공구손료", 글: "", 금액: 1, 묶음: "경비" } ] }, +]; +const got = bundleLines(groups).map((b) => ({ 묶음: b.묶음, 소계: b.소계, 쓰는입력: b.쓰는입력, 기준단위: b.기준단위, 환산근거: b.환산근거, + 줄: b.줄.map((l) => "몫" in l ? [l.이름, l.금액, l.몫.map((x) => x.비목)] : l.이름) })); +console.log(JSON.stringify(got)); +""" + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", pure + chr(10) + body], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + got = json.loads(out.stdout) + # 차례 = 인력 · 재료 · 경비 · 같은 이름 장비 둘은 k 번째끼리 한 줄(노 · 재 · 경 몫) · 비목 노무비 장비는 경비(장비) 묶음 + assert [b["묶음"] for b in got] == ["인력", "재료", "경비"] + assert got[0]["줄"] == ["포설공", "보통인부"] and got[0]["소계"] == 15 + assert got[0]["쓰는입력"] == ["두께"] and got[0]["기준단위"] == ["10㎡"] + assert got[0]["환산근거"] == ["10㎡당 → 1㎡당 ÷10"] + assert got[1]["줄"] == ["모래"] + eq = got[2] + assert eq["줄"][0] == ["굴착기", 150, ["노무비", "재료비", "경비"]] + assert eq["줄"][1] == ["굴착기", 9, ["노무비", "재료비", "경비"]] + assert eq["줄"][2] == "공구손료" and eq["소계"] == 160 and eq["쓰는입력"] == ["지역"] From dd4bbb3c6156e6c2a8942b86284b72335f5aa6c0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:31:57 +0900 Subject: [PATCH 27/30] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20554=20=EB=9D=BC=EC=9D=B4=EB=8B=A4=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=20=EB=8F=84=EA=B5=AC=20=EC=8B=9C=ED=97=98=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=E2=80=94=20=ED=8F=89=ED=99=9C=ED=95=B4?= =?UTF-8?q?=EB=8F=84=200.77=E2=86=920.75=20m=20=C2=B7=20=EC=97=85=EC=B2=B4?= =?UTF-8?q?=20=EB=B6=84=EB=A5=98+TIN=200.12=20m=20=C2=B7=20SMRF=200.27=20?= =?UTF-8?q?=C2=B7=20=EA=B6=8C=ED=95=A8=20=3D=20=EA=B2=A9=EC=9E=90=EB=A7=8C?= =?UTF-8?q?=20TIN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../_결과_553_라이다_외부도구시험.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/raw/요구_1101/_결과_553_라이다_외부도구시험.md diff --git a/docs/raw/요구_1101/_결과_553_라이다_외부도구시험.md b/docs/raw/요구_1101/_결과_553_라이다_외부도구시험.md new file mode 100644 index 000000000..05d9d3400 --- /dev/null +++ b/docs/raw/요구_1101/_결과_553_라이다_외부도구시험.md @@ -0,0 +1,93 @@ +# 553 결과 — 라이다 지형: 외부 무료 도구 시험 비교 + +시험만 · 앱 코드 · storage · git 손대지 않음. 자료 = 용화.las(4,900.7 만 점 · 업체 분류 1 · 2) · 검사점 = 업체 지면점 20 %(22.8 만 · 씨앗 553 · 비교 범위 안 20.2 만) · 모든 방법을 `dtm_classification.npz` 의 x · y(1 m)에 맞춰 잼. 차 = 검사점 − DEM(+ 이면 DEM 이 낮음). + +## ① 결론 다섯 줄 + +1. **평활 뒤에도 차이가 그대로 남음.** 지금 RMS 0.77 m → 평활 0.75 m(2 % 줄어듦) · DEM 이 0.60 m 낮은 치우침은 평활 전후 같음. 같은 업체 지면점을 TIN 으로만 지으면 0.12 m(약 6.5 배 나음). +2. 사용자 말(평활 · 평균에서 조정)은 **잔요철엔 일부 맞음** — 급경사 빨강 32 % → 27 % · 기울기 p90 120 → 107 %. 다만 TIN(빨강 16 % · p90 89 %)에는 못 미치고, **높이 치우침 · 깎기 비탈 1 m 넘는 오차는 평활로 안 풀림**(그림 숲 가장자리). +3. 흠의 주인은 **지면 가르기가 아니라 격자 짓기**(칸 최저점 · 반 칸 자리 · 가까운 값 메움). 이 사업 유효 칸의 49 % 가 지면점 없는 메운 칸. +4. 원자료로 지면을 다시 가르는 도구 가운데 이 숲(숲 98 % · 기울기 중앙 70 %)에 쓸 만한 것은 **PDAL SMRF(0.27 m)** · **WhiteboxTools 경사 필터(0.26 m)** 둘. CSF(PDAL · 원 라이브러리)와 **Whitebox Workflows NG 개선 필터는 급경사에서 실패**(절반 빔 · 또는 나무 위를 땅으로 잡아 RMS 3.7~4.4 m) — 553-B 의 「버금 = WbW NG」는 이 자료로는 맞지 않음. +5. **권함** — 업체 분류가 있는 LAS: 지금 지면 가르기 그대로 두고 **격자만 TIN** 으로(앱이 이미 가진 WhiteboxTools `LidarTINGridding` · 66 만 점 0.8 초 · 새 의존성 없음). 분류 없는 LAS: PDAL SMRF + TIN(값 맞추기 시험 뒤). + +## ② 수치 표 + +방법 — A 앱 저장 파일 그대로 · B PDAL 2.10.2 · C Whitebox · D CSF 원 라이브러리. TIN 은 모두 1 m · 긴 변 50 m 끊기. 「t」= 급경사용 값 덧 시험. + +### 높이 오차(검사점 대비 · m) · 평활 전 → 후 + +| 방법 | RMS | 평균 차 | 95 % 절대 오차 | 검사점 덮임 % | 유효 범위 안 빈칸 % | +| --- | --- | --- | --- | --- | --- | +| A0 지금 업체 분류(A1 = 평활본) | 0.767 → 0.751 | +0.601 → +0.602 | 1.371 → 1.337 | 97.8 | 0 (지면점 없어 가까운 값 메움 49.4) | +| A2 지금 CSF(A3 = 평활본) | 0.711 → 0.687 | +0.542 → +0.543 | 1.286 → 1.228 | 97.6 | 0 (메움 53.7) | +| **B1 업체 분류 + PDAL TIN** | **0.117 → 0.131** | −0.008 → −0.009 | 0.232 → 0.264 | 97.8 | 0.02 | +| B1w 업체 분류 + WBT TIN | 0.117 → 0.131 | −0.007 → −0.009 | 0.232 → 0.264 | 97.8 | 0.02 | +| B2 PDAL SMRF(기본값) | 0.265 → 0.272 | −0.108 → −0.121 | 0.492 → 0.513 | 97.8 | 0.03 | +| B3 PDAL CSF(기본값) | 0.944 → 0.932 | +0.304 → +0.301 | 1.695 → 1.686 | 50.6 | 47.4 | +| B3t PDAL CSF(무른 천 · 0.5 m) | 0.684 → 0.687 | −0.282 → −0.303 | 1.544 → 1.534 | 97.6 | 0.2 | +| C1 WbW NG 개선 필터(기본값 · 타일 2×2) | 4.416 → 4.532 | −0.934 → −1.020 | 7.86 → 7.81 | 47.8 | 51.6 | +| C1t WbW NG(경사 45° · 0.3 m) | 3.727 → 3.812 | −0.983 → −1.060 | 4.03 → 4.07 | 71.6 | 30.0 | +| C2 WBT 경사 필터(기본값) | 0.257 → 0.264 | +0.056 → +0.047 | 0.439 → 0.471 | 97.8 | 0.02 | +| D1 CSF 원 라이브러리(앱 CSF 와 같은 값) | 0.930 → 0.948 | +0.233 → +0.233 | 1.942 → 1.964 | 20.6 | 80.7 | + +### 지형 꼴 · 분류 · 비용 + +| 방법 | 빨강 %(83.9 % 이상) 전 → 후 | 기울기 p50 / p90 % 전 → 후 | 업체 지면 대비 맞음 · 놓침 · 잘못 넣음 % | 시간 s | 최대 메모리 GB | +| --- | --- | --- | --- | --- | --- | +| A0 | 32.0 → 26.8 | 69 / 120 → 68 / 107 | 100 · 0 · 0 | 앱 WF1 239(기록) | 기록 없음 | +| A2 | 35.2 → 29.4 | 70 / 129 → 69 / 113 | 55.1 · 44.9 · 12.2 | (같은 WF1 · CSF 4.1) | 기록 없음 | +| B1 | 16.4 → 14.8 | 70 / 89 → 69 / 88 | 100 · 0 · 0 | 20.3 | 3.6 | +| B1w | 16.3 → 14.8 | 70 / 89 → 69 / 88 | 100 · 0 · 0 | 4.1 | 1.8 | +| B2 | 22.5 → 20.1 | 70 / 98 → 69 / 95 | 94.6 · 5.4 · 53.4 | 57.8 | 6.7 | +| B3 | 2.7 → 2.2 ※ | 63 / 76 → 63 / 75 | 21.9 · 78.1 · 11.1 | 101.9 | 7.9 | +| B3t | 34.0 → 31.3 | 75 / 117 → 74 / 112 | 74.3 · 25.7 · 77.9 | 273.5 | 8.6 | +| C1 | 38.8 → 36.6 | 76 / 211 → 75 / 190 | 12.7 · 87.3 · 15.0 | 173.3 | 20.1 | +| C1t | 52.1 → 49.5 | 86 / 234 → 83 / 210 | 41.4 · 58.6 · 51.1 | 176.5 | 19.8 | +| C2 | 27.5 → 26.1 | 72 / 130 → 72 / 122 | 61.3 · 38.7 · 9.2 | 453.4 | 5.4 | +| D1 | 2.2 → 1.7 ※ | 57 / 71 → 56 / 70 | 8.8 · 91.2 · 7.4 | 25.1 | 6.0 | + +- ※ B3 · D1 은 급경사 절반 이상이 비어 남은 완경사만 잰 값 — 좋은 값 아님. +- 시간 = LAS 읽기 ~ 1 m 격자 GeoTIFF 쓰기까지(분류 LAS 쓰기 포함). 메모리 = 프로세스 나무 합. +- C1 은 통째(4,900 만 점)로 돌리면 16 GB 넘어 멈춤 → 2×2 타일(겹침 30 m)로 돌림 · 메모리 20 GB 중 약 5 GB 는 타일 나누는 laspy 몫. +- 「잘못 넣음」은 업체가 지면으로 안 본 점 — 진짜 오류인지 업체 누락인지 가르지 못함(SMRF 는 지도로 보면 덤불 · 그루터기 자리 +1 m 혹이 많음). +- 앱 평활 재현 확인 — 이 시험의 평활(가우스 σ 0.5 m + 쌍삼차 → 1.078 m 칸)로 A0 를 다시 평활하면 저장된 A1 과 최대 0.1 mm 차. +- 잘 지은 DEM(B1 · B2 · C2)에는 앱 평활이 오히려 오차를 조금 늘림(0.117 → 0.131). + +## ③ 그림(`figs/` · 100 m × 100 m · 각 구역 두 장) + +| 파일 | 보이는 것 | +| --- | --- | +| `steep_1_음영기복.png` · `steep_2_차이.png` | 가장 가파른 곳(평균 86 %). A0 는 1 m 계단 · 줄무늬가 음영 전체에 깔림 · A1 은 무늬가 흐려질 뿐 남음. B1 은 매끈한 비탈 · 골. 차이 지도에서 A0 · A1 은 거의 전면 파랑(B1 보다 0.5~1 m 낮음) · RMS 1.09 / 0.98 m. SMRF 는 군데군데 빨강 혹(덤불) · 나머지는 흰색(같음). | +| `edge_1_음영기복.png` · `edge_2_차이.png` | 빈터가 가장 많은 곳 — 임도 노면 · 깎기 비탈이 지나감. A0 · A1 은 **깎기 비탈 띠를 따라 1 m 넘게 낮음**(진한 파랑 띠) — 평활 뒤에도 그대로. SMRF 는 노면 · 비탈을 잘 따라감(RMS 0.45 m) · WBT 경사 필터는 비탈 끝을 깎아 먹음. | +| `flat_1_음영기복.png` · `flat_2_차이.png` | 가장 완만한 곳(그래도 평균 57 % · 이 사업엔 평지가 없음). A0 줄무늬 · 전면 파랑, A1 은 무늬만 흐려짐(RMS 0.66 → 0.54 m). SMRF 0.37 m · WBT 0.96 m(나무 둘레 혹). | + +## ④ 설치한 것(모두 `lidar_lab/` 안 · git 무시 · 시스템 · PATH · 레지스트리 손대지 않음) + +| 것 | 판 | 크기 | 걸린 시간 | +| --- | --- | --- | --- | +| micromamba(단일 실행 파일) | latest(26-11) | 4.7 MB 받음 | 수 초 | +| PDAL 환경 `env_pdal`(pdal · python-pdal · gdal · python 3.12 · conda-forge) | PDAL 2.10.2 | 4.0 GB + 받은 꾸러미 보관 1.3 GB(`mamba_root` · 지워도 됨) | 약 6 분 | +| 파이썬 venv(데스크탑 3.12) — whitebox-workflows · cloth-simulation-filter · laspy[lazrs] · numpy · scipy · rasterio · matplotlib · psutil | WbW 2.0.6 · CSF 1.1.7 · laspy 2.7.0 · lazrs 0.8.2 · rasterio 1.5.2 | 428 MB | 약 2 분 | +| WhiteboxTools Open Core | 2.4.0 | 받음 25 MB · 풀어 99 MB | 수 초 | +| QGIS | 설치 안 함(지면점 분류 = PDAL SMRF → B2 로 갈음) | — | — | + +- 시험 자료 `data/`(검사점 표시 LAS 1.6 GB · 분류 표) 2.0 GB — 다시 돌릴 일 없으면 지워도 됨. 시험 스크립트 `scripts/` · 원 기록 `runs/`(방법마다 시간 · 메모리 json · 로그 · `metrics.json`). + +## ⑤ 앱에 넣는다면(553-B ④ 와 맞춤) · 위험 + +| 길 | 이번 시험이 말하는 것 | +| --- | --- | +| **격자만 TIN 으로(553-A 경계 C · D)** — 업체 분류 LAS | 가장 큰 이득(0.77 → 0.12 m) · 새 도구 없이 지금 스택의 WhiteboxTools `LidarTINGridding`(MIT · 0.8 초)로 됨 · 553-B 길 A(Whitebox)와 같은 집안 | +| 553-B 길 B — PDAL 따로 실행 | 분류 없는 LAS 의 지면 가르기 = SMRF 가 가장 나음(0.27 m · 58 초) · 다만 환경 4 GB · 낮은 덤불 혹 → `slope` · `threshold` 값 시험 필요 | +| 553-B 길 A — WbW NG 개선 필터 | **이 숲에선 쓰지 않음**(RMS 3.7 m 넘음 · 4,900 만 점 통째 16 GB 넘어 멈춤) | +| 553-B 길 C — CSF 원 라이브러리 | **쓰지 않음** — 앱 자체 CSF(0.71 m)보다도 못함(급경사 80 % 빔) | +| WBT 경사 필터(Open Core) | 0.26 m 로 쓸 만하나 4,900 만 점 7.5 분 · 업체 지면 39 % 놓침 | + +위험 + +- **WhiteboxTools GeoTIFF 반 칸 어긋남** — Open Core 가 쓴 TIFF 를 rasterio 로 그대로 읽으면 반 칸(0.5 m) 밀려 같은 B1 이 0.12 → 0.39 m 로 나빠짐(지금 앱의 칸 모서리 흠과 같은 꼴). 넣을 때 칸 자리 대조 시험 필수. WbW NG(geotiff-rs)는 어긋남 없음. +- **값이 바뀜** — DEM 이 평균 약 0.6 m 오르고 깎기 비탈 · 노면 꼴이 바뀜 → 기존 프로젝트의 종단 · 토량 · 급경사 칸 % 가 달라짐. 새 프로젝트부터 또는 고르는 칸으로(사용자 협의). +- 앱 평활은 좋은 DEM 엔 오히려 오차를 조금 늘림 → TIN 으로 바꾸면 평활 기본값도 다시 볼 거리. +- TIN 긴 변 끊기(이번 50 m) 값에 따라 큰 빈터가 빔 — 이번엔 유효 범위 안 0.02 %. +- 이 시험은 업체 분류를 정답으로 둠 — SMRF 의 「잘못 넣음」 53 % 중 진짜 지면 몫은 가르지 못함. 숲 · 급경사 한 사업 하나만 잼(평지 · 미분류 LAS 는 따로 시험 필요). +- 두 PC 판 차이 · 도구 판 고정(553-B ⑤ 그대로). From f8338194c6f83cbc8df3d1dfd79a9b96bf054e42 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:32:32 +0900 Subject: [PATCH 28/30] =?UTF-8?q?refactor(717-3):=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=83=81=EC=84=B8=20=C2=B7=20=EC=A4=84=20?= =?UTF-8?q?=ED=91=9C=20=EB=82=98=EB=88=94=20=E2=80=94=20=EC=9E=90=EC=9E=AC?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=91=9C(Labor=5FMatTable)=20=C2=B7=20?= =?UTF-8?q?=EC=84=9C=EA=B3=A0=20=EA=B5=AC=EB=B6=84=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0(Labor=5FLibMeta)=20=EB=96=BC=EC=96=B4=20=EB=83=84=20?= =?UTF-8?q?=C2=B7=20=EB=8F=99=EC=9E=91=20=EA=B0=99=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Labor_Rows 671 → 566 줄 · Labor_Detail 700 → 650 줄(717-3 모달 단추 자리) - 낡은 시험 셋 고침: mat_marks · precombo_class = 옮긴 파일을 읽음 · hopyo_screen 의 Detail 「꼭 700 줄」 = 「700 줄 넘지 않음」(나눔으로 줄어듦) - 시험 158 통과 · 1 실패(test_175 B09 겹 대가 = Store_Combo._PER 없음 · 나눔 전부터 · 719-2 몫) Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Labor_Detail.ts | 64 +-------- .../M02_MasterTemplete_Labor_LibMeta.ts | 56 ++++++++ .../M02_MasterTemplete_Labor_MatTable.ts | 127 ++++++++++++++++++ .../M02_MasterTemplete_Labor_Rows.ts | 113 +--------------- .../M02_MasterTemplete_Labor_View.ts | 3 +- resources/tester/test_m02_hopyo_screen.py | 4 +- resources/tester/test_m02_labor_mat_marks.py | 2 +- resources/tester/test_m02_precombo_class.py | 2 +- 8 files changed, 199 insertions(+), 172 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_LibMeta.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_MatTable.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index c7286f964..d4707fb31 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -16,13 +16,7 @@ * · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`) * ========================================================================== */ -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; +import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; @@ -64,15 +58,15 @@ import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag"; import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick"; import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks"; -import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; +import { logicTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; +import { matTable } from "./M02_MasterTemplete_Labor_MatTable"; import { laborGrid } from "./M02_MasterTemplete_Labor_Grid"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model"; import { keepKnown } from "./M02_Labor_Values_Model"; import { addMaterialButton } from "./M02_Material_Pick"; import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; -import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; -import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo"; +import { libMeta } from "./M02_MasterTemplete_Labor_LibMeta"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; import "@ui/ui_template_general_layout.css"; @@ -574,52 +568,6 @@ export function mountLaborDetail( opt.onChanged(dirty()); }); - /** 서고 구분 · 상세구분(51-12 · 134-5) — 구조물군 › 종류 + 공통(`precomboClasses`)에서 고름 · - * 구분 바꾸면 상세구분 = 그 첫 줄(공통) · 빈 값 = 미분류 */ - const meta = cur as { 구분?: string | null; 상세구분?: string | null }; - const libMeta = (): HTMLElement[] => { - const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit }); - const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit }); - sub.root.dataset.laborField = "구분"; - detail.root.dataset.laborField = "상세구분"; - let subs: { name: string; details: string[] }[] = []; - // 목록에 없는 지금 값(빈 것 = 「—」 · 옛 문서)도 선택지로 둠 - const opts = (names: string[], now: string | null | undefined) => - [...new Set([now ?? "", ...names])] - .filter((v, i) => v || i === 0) - .map((v) => ({ value: v, text: v || "—" })); - const fill = (): void => { - sub.setOptions( - opts( - subs.map((s) => s.name), - meta.구분, - ), - meta.구분 ?? "", - ); - const details = subs.find((s) => s.name === meta.구분)?.details ?? []; - detail.setOptions(opts(details, meta.상세구분), meta.상세구분 ?? ""); - }; - sub.select.addEventListener("change", () => { - meta.구분 = sub.select.value; - meta.상세구분 = subs.find((s) => s.name === meta.구분)?.details[0] ?? null; - fill(); - opt.onChanged(dirty()); - }); - detail.select.addEventListener("change", () => { - meta.상세구분 = detail.select.value; - opt.onChanged(dirty()); - }); - fill(); - void fetchStructureDef().then( - (def) => { - subs = precomboClasses(def); - fill(); - }, - () => undefined, - ); - return [sub.root, detail.root]; - }; - const editor = (body: HTMLElement): HTMLElement => side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body; host.replaceChildren( @@ -637,7 +585,9 @@ export function mountLaborDetail( el("div", { className: "m01lab__info", children: [ - ...(lib ? [nameField.root, ...libMeta()] : []), + ...(lib + ? [nameField.root, ...libMeta(cur, edit, () => opt.onChanged(dirty()))] + : []), unit.root, origin.root, ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_LibMeta.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_LibMeta.ts new file mode 100644 index 000000000..d65966386 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_LibMeta.ts @@ -0,0 +1,56 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_LibMeta.ts + * 서고 모드(일위대가 · 사전조합) 정보 칸 구분 · 상세구분 고르기(51-12 · 134-5 · `Labor_Detail` 에서 뗌 · 717-3) + * 고침 = 문서 `구분` · `상세구분` 바로(화면 캐시) · 바꾼 뒤 `changed()`. + * ========================================================================== */ + +import { createSelectField } from "@ui/ui_template_elements"; +import { fetchStructureDef } from "./StructOptions/M02_SO_Api"; +import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; + +/** 서고 구분 · 상세구분(51-12 · 134-5) — 구조물군 › 종류 + 공통(`precomboClasses`)에서 고름 · + * 구분 바꾸면 상세구분 = 그 첫 줄(공통) · 빈 값 = 미분류 */ +export function libMeta(doc: unknown, edit: boolean, changed: () => void): HTMLElement[] { + const meta = doc as { 구분?: string | null; 상세구분?: string | null }; + const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit }); + const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit }); + sub.root.dataset.laborField = "구분"; + detail.root.dataset.laborField = "상세구분"; + let subs: { name: string; details: string[] }[] = []; + // 목록에 없는 지금 값(빈 것 = 「—」 · 옛 문서)도 선택지로 둠 + const opts = (names: string[], now: string | null | undefined) => + [...new Set([now ?? "", ...names])] + .filter((v, i) => v || i === 0) + .map((v) => ({ value: v, text: v || "—" })); + const fill = (): void => { + sub.setOptions( + opts( + subs.map((s) => s.name), + meta.구분, + ), + meta.구분 ?? "", + ); + const details = subs.find((s) => s.name === meta.구분)?.details ?? []; + detail.setOptions(opts(details, meta.상세구분), meta.상세구분 ?? ""); + }; + sub.select.addEventListener("change", () => { + meta.구분 = sub.select.value; + meta.상세구분 = subs.find((s) => s.name === meta.구분)?.details[0] ?? null; + fill(); + changed(); + }); + detail.select.addEventListener("change", () => { + meta.상세구분 = detail.select.value; + changed(); + }); + fill(); + void fetchStructureDef().then( + (def) => { + subs = precomboClasses(def); + fill(); + }, + () => undefined, + ); + return [sub.root, detail.root]; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_MatTable.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_MatTable.ts new file mode 100644 index 000000000..e4f785c45 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_MatTable.ts @@ -0,0 +1,127 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_MatTable.ts + * 일위대가 자재목록 표(43-3 · `Labor_Rows` 에서 뗌 · 717-3) — 상세 · 보기 갈래가 같이 씀. + * 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액. + * 금액은 calc 답(없으면 「…」) · 줄 누름 · [삭제]는 `on` 이 있을 때만. + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import type { + LaborCalc, + LaborCalcLine, + LaborCalcMat, + LaborMat, +} from "./M02_MasterTemplete_Labor_Api"; +import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; +import { + moneyCell, + PRICE_SLOTS, + qtyCell, + table, + titled, + type RowHandlers, +} from "./M02_MasterTemplete_Labor_Rows"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; + +export interface MatTableData { + mats: LaborMat[]; + /** 로직 자재 줄(103-1 · `Labor_LogicMats`) — 사용자 자재 앞 · 같은 칸 */ + lead?: HTMLElement[]; + calc: LaborCalc | null; + fail?: string | null; + cells?: ReadonlyMap; +} + +/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금 + * 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) · + * 보기 갈래는 `on` 없이 */ +export function matTable(data: MatTableData, on?: Omit): HTMLElement { + const wait = !data.calc && !data.fail; + const remove = on?.remove; + const show = (v: number | null | undefined): string => + wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); + const trs = data.mats.map((mat, i) => { + const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드); + // 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1 + const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine; + const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => { + const base = !!line?.기준칸 && line.기준칸 === slot; + return el("td", { + className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`, + text: show(line?.가격?.[slot]), + attrs: { + "data-label": slot, + "data-price-slot": slot, + ...(base ? { title: tc("Labor_BasePrice") } : {}), + }, + }); + }; + const tr = el("tr", { + className: on ? "m02-labrow__row is-open" : "m02-labrow__row", + attrs: { "data-labor-mat": mat.코드 }, + children: [ + el("td", { className: "m02-labrow__code", text: mat.코드 }), + titled( + el("td", { + className: "m02-labrow__name", + children: [ + el("strong", { text: mat.이름 ?? "" }), + ...(line?.까닭 + ? [el("small", { className: "ui-text ui-text--error", text: line.까닭 })] + : []), + ], + }), + ), + el("td", { + className: "m02-labrow__spec", + text: mat.규격 ?? "", + attrs: { title: mat.규격 ?? "" }, + }), + qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null), + el("td", { + className: "m02-labrow__unit", + text: mat.단위 ?? "", + attrs: { "data-label": tc("Labor_Unit") }, + }), + ...markCells(mat, on?.mark ? (k, v) => on.mark?.(i, k, v) : undefined), + ...PRICE_SLOTS.map(priceCell), + moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")), + ...(remove + ? [ + el("td", { + className: "m02-labrow__move", + children: [ + createButton({ + label: tc("Remove"), + variant: "danger", + onClick: (e) => { + e.stopPropagation(); + remove(i); + }, + }), + ], + }), + ] + : []), + ], + }); + if (on) tr.addEventListener("click", () => on.open(i)); + return tr; + }); + const heads = [ + tc("Labor_Code"), + tc("Col_Name"), + tc("Labor_Spec"), + tc("Labor_QtyCol"), + tc("Labor_Unit"), + ...markHeads(), + ...PRICE_SLOTS, + `${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`, + ...(remove ? [tc("Col_Move")] : []), + ]; + const lead = data.lead ?? []; + return data.mats.length || lead.length + ? table(heads, [...lead, ...trs], []) + : el("p", { className: "ui-text ui-text--muted", text: tc("Labor_NoMat") }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 84f9ca0fd..d3bc1ad1c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -5,7 +5,7 @@ * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · * 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 구분 ~ 별칭에 걸친 한 줄(나머지 칸은 위 줄과 두 줄 병합 · 113-1) · * 맨 아래 합계 줄(전체 합계 강조). - * 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3). + * 자재목록 표 = `Labor_MatTable`(717-3 나눔). * 금액은 calc 답(없으면 「…」). * 줄 누름 · 줄 단추는 `on` 이 있을 때만(보기 갈래 = 없음) — 순서 칸 2행(51-14 · 위 [위로] [아래로] / 아래 [옵션] 또는 [수정] · [삭제] · * 묶음 `ui-btn-group` · 순서 없는 카드 갈래 = 아래 줄만) · 일위조합 줄 = [수정](`ref` · 서고 원본 · 옛 단가승격 참조 줄 = 단추 없음) · @@ -20,14 +20,11 @@ import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; import type { LaborCalc, LaborCalcLine, - LaborCalcMat, LaborCost, - LaborMat, LaborRow, MatMarkKey, } from "./M02_MasterTemplete_Labor_Api"; import { lineId } from "./M02_MasterTemplete_Labor_Calc"; -import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "@ui/ui_template_general_layout.css"; @@ -124,7 +121,7 @@ export function qtyShort( /** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「수기 5 ㎥」 · 「12.5(B6)」 · 「—」) · * `open` 이 있으면 단추(줄 누름과 따로) */ -function qtyCell( +export function qtyCell( line: LaborCalcLine | undefined, cells: ReadonlyMap | undefined, open: (() => void) | null, @@ -180,12 +177,12 @@ export function moneyCell( } /** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */ -const titled = (td: HTMLElement): HTMLElement => { +export const titled = (td: HTMLElement): HTMLElement => { for (const c of td.children) c.setAttribute("title", c.textContent ?? ""); return td; }; -const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement => +export const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement => el("div", { className: "ui-general-block__table-wrap", children: [ @@ -565,107 +562,5 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement return out; } -export interface MatTableData { - mats: LaborMat[]; - /** 로직 자재 줄(103-1 · `Labor_LogicMats`) — 사용자 자재 앞 · 같은 칸 */ - lead?: HTMLElement[]; - calc: LaborCalc | null; - fail?: string | null; - cells?: ReadonlyMap; -} - /** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */ export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const; - -/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금 - * 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) · - * 보기 갈래는 `on` 없이 */ -export function matTable(data: MatTableData, on?: Omit): HTMLElement { - const wait = !data.calc && !data.fail; - const remove = on?.remove; - const show = (v: number | null | undefined): string => - wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); - const trs = data.mats.map((mat, i) => { - const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드); - // 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1 - const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine; - const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => { - const base = !!line?.기준칸 && line.기준칸 === slot; - return el("td", { - className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`, - text: show(line?.가격?.[slot]), - attrs: { - "data-label": slot, - "data-price-slot": slot, - ...(base ? { title: tc("Labor_BasePrice") } : {}), - }, - }); - }; - const tr = el("tr", { - className: on ? "m02-labrow__row is-open" : "m02-labrow__row", - attrs: { "data-labor-mat": mat.코드 }, - children: [ - el("td", { className: "m02-labrow__code", text: mat.코드 }), - titled( - el("td", { - className: "m02-labrow__name", - children: [ - el("strong", { text: mat.이름 ?? "" }), - ...(line?.까닭 - ? [el("small", { className: "ui-text ui-text--error", text: line.까닭 })] - : []), - ], - }), - ), - el("td", { - className: "m02-labrow__spec", - text: mat.규격 ?? "", - attrs: { title: mat.규격 ?? "" }, - }), - qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null), - el("td", { - className: "m02-labrow__unit", - text: mat.단위 ?? "", - attrs: { "data-label": tc("Labor_Unit") }, - }), - ...markCells(mat, on?.mark ? (k, v) => on.mark?.(i, k, v) : undefined), - ...PRICE_SLOTS.map(priceCell), - moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")), - ...(remove - ? [ - el("td", { - className: "m02-labrow__move", - children: [ - createButton({ - label: tc("Remove"), - variant: "danger", - onClick: (e) => { - e.stopPropagation(); - remove(i); - }, - }), - ], - }), - ] - : []), - ], - }); - if (on) tr.addEventListener("click", () => on.open(i)); - return tr; - }); - const heads = [ - tc("Labor_Code"), - tc("Col_Name"), - tc("Labor_Spec"), - tc("Labor_QtyCol"), - tc("Labor_Unit"), - ...markHeads(), - ...PRICE_SLOTS, - `${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`, - ...(remove ? [tc("Col_Move")] : []), - ]; - const lead = data.lead ?? []; - return data.mats.length || lead.length - ? table(heads, [...lead, ...trs], []) - : el("p", { className: "ui-text ui-text--muted", text: tc("Labor_NoMat") }); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts index 0fdbd43a0..aefc57fb2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts @@ -21,7 +21,8 @@ import { } from "./M02_MasterTemplete_Labor_Api"; import { lineId, settle } from "./M02_MasterTemplete_Labor_Calc"; import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; -import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; +import { logicTable } from "./M02_MasterTemplete_Labor_Rows"; +import { matTable } from "./M02_MasterTemplete_Labor_MatTable"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "./M02_MasterTemplete_Labor.css"; diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 909b26079..dfd6fc510 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -361,9 +361,7 @@ def test_좌측_판_자리와_권한(): def test_로직_추가_한_곳과_파일_규칙(): detail = _text("M02_MasterTemplete_Labor_Detail.ts") - assert ( - len(detail.splitlines()) == 700 - ) # 줄 수 그대로(361-2 전국 자재 부르는 줄 둘 · 553-5 보조 글 부품 -2 · 704-3 격자 +7) + assert len(detail.splitlines()) <= 700 # 700 줄 넘지 않음(717-3 나눔으로 줄어듦) assert "withHopyo(library)," in detail pick = _text("M02_MasterTemplete_Labor_Pick.ts") assert "more?: () => Promise;" in pick and "...later" in pick diff --git a/resources/tester/test_m02_labor_mat_marks.py b/resources/tester/test_m02_labor_mat_marks.py index e788663a8..556c47686 100644 --- a/resources/tester/test_m02_labor_mat_marks.py +++ b/resources/tester/test_m02_labor_mat_marks.py @@ -121,7 +121,7 @@ def test_화면_칸_부품_사용자_줄_로직_줄_같은_칸(): marks = _src("M02_MasterTemplete_Labor_MatMarks.ts") assert '"—"' not in marks # 「—」 항목 없음(162-1) assert "e.stopPropagation()" in marks # 줄 누름과 따로 - rows = _src("M02_MasterTemplete_Labor_Rows.ts") + rows = _src("M02_MasterTemplete_Labor_MatTable.ts") # 717-3 자재목록 표 나눔 assert "...markCells(mat, on?.mark" in rows and "...markHeads()," in rows assert "...markCells(" in _src("M02_MasterTemplete_Labor_LogicMats.ts") detail = _src("M02_MasterTemplete_Labor_Detail.ts") diff --git a/resources/tester/test_m02_precombo_class.py b/resources/tester/test_m02_precombo_class.py index 73eb21215..b80d9bc82 100644 --- a/resources/tester/test_m02_precombo_class.py +++ b/resources/tester/test_m02_precombo_class.py @@ -62,7 +62,7 @@ def test_옛_품셈_분류는_미분류_새_값은_그대로(): def test_화면_원천은_구조물_정의_새_문서는_미분류(): - detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8") + detail = (M02 / "M02_MasterTemplete_Labor_LibMeta.ts").read_text("utf-8") # 717-3 나눔 assert "subs = precomboClasses(def);" in detail and "fetchLibLogicSubs" not in detail assert '[...new Set([now ?? "", ...names])]' in detail # 지금 값도 선택지 그대로 lib = (M02 / "M02_MasterTemplete_Library_List.ts").read_text("utf-8") From 8c519c0ab2755d5a73ef60963568930e8a5995dc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:32:57 +0900 Subject: [PATCH 29/30] =?UTF-8?q?feat(714-1b):=20=EC=9E=A5=EB=B9=84=20?= =?UTF-8?q?=EC=A4=84=20=ED=8E=BC=EC=B9=A8=20=EB=8D=A7=EC=B9=B8=20=E2=80=94?= =?UTF-8?q?=20=EB=B6=80=EB=A5=B8=20=EA=B8=B0=EA=B3=84=EA=B2=BD=EB=B9=84=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B3=84=EC=82=B0=EC=84=9C=20=EC=A4=84=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9C=EA=B0=84=EB=8B=B9=20=EC=86=8C=EA=B3=84=20?= =?UTF-8?q?=C2=B7=20=EA=B3=84=20=C2=B7=20=ED=82=A4=20=C2=B7=20=EC=9D=B8?= =?UTF-8?q?=EC=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - master_sheet_line 장비(기계경비) 줄에 「펼침」 = {키, 이름, 단위, 입력, 줄[비목 · 이름 · 글 · 금액], 소계, 계} · 못 셈 = 까닭 - 셈 결과(master_text.lines)를 그대로 실음 — 조종원 환산 같은 규칙을 여기 두지 않음 - annotate 가 files 를 받음(펼침 계산서) · M01 /text · M02 /logic-lib/sheet 같은 KEYS - 시험 test_714_1_sheet_line 펼침 · 두 길 같은 수(JSON 수 규칙) Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Store_Sheet.py | 6 +-- M02_MasterTemplete/M02_Logic_Standard.py | 2 +- .../master_data/scripts/master_sheet_line.py | 37 ++++++++++++++++++- resources/tester/test_714_1_sheet_line.py | 15 +++++++- 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Store_Sheet.py b/M01_MasterData/M01_MasterData_Store_Sheet.py index 7d893f62b..57181e401 100644 --- a/M01_MasterData/M01_MasterData_Store_Sheet.py +++ b/M01_MasterData/M01_MasterData_Store_Sheet.py @@ -17,11 +17,11 @@ def text(key: str, inputs: dict, row: dict | None = None, file: str | None = Non given = {k: store._dec(v) for k, v in inputs.items()} try: if row is None: - master = store.loaded()[1] + files, whole = store.loaded() else: # 저장 전 시험 — `store.text` 와 같은 메모리 사본 files = store.cm.load(folder=store.FOLDER) key = store._swap(files, str(row.get("키") or key), row, file) - master = mf.Master(files) - return msl.annotate(master, key, mf.fill_empty(master.logic(key), given), got) + whole = mf.Master(files) + return msl.annotate(files, key, mf.fill_empty(whole.logic(key), given), got) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): return got diff --git a/M02_MasterTemplete/M02_Logic_Standard.py b/M02_MasterTemplete/M02_Logic_Standard.py index fd9959833..5ba2fc45a 100644 --- a/M02_MasterTemplete/M02_Logic_Standard.py +++ b/M02_MasterTemplete/M02_Logic_Standard.py @@ -334,7 +334,7 @@ def sheet(key: str, change: dict) -> dict: raise m01.StoreError(404, str(e)) from e given, ax = _given(whole, logic, change) filled = m01.cm.mf.fill_empty(logic, given) - got = msl.annotate(whole, key, filled, m01.mtx.lines(files, key, filled)) # 줄 덧칸(714-1) + got = msl.annotate(files, key, filled, m01.mtx.lines(files, key, filled)) # 줄 덧칸(714-1) done = settle.settle(whole, key, filled) if "결과" not in done: # 돈 로직 — 줄마다 버림(B09 산근 규칙) settle.apply(got.get("groups") or [], m01.cm.mf.run(whole, key, filled), done) diff --git a/resources/master_data/scripts/master_sheet_line.py b/resources/master_data/scripts/master_sheet_line.py index 1fe93a425..d567b5554 100644 --- a/resources/master_data/scripts/master_sheet_line.py +++ b/resources/master_data/scripts/master_sheet_line.py @@ -6,6 +6,8 @@ - `쓰는입력` = 그 줄 값(수량 식 · 요소 · 덧줄 식)에 쓰인 로직 입력 이름 — 중간값을 거슬러 감 · 덧줄이 부르는 비목 합 · 줄 금액은 거스르지 않음(율 식에 직접 쓴 입력만). - `묶음` · `장비` = 712-1 `master_bundle`(엔진 줄 덧칸 그대로). +- `펼침`(장비 줄만 · 714-1b) = 부른 기계경비 로직의 계산서 줄(조종원 · 주연료 · 잡재료비 · 기계손료 …) · + 시간당 비목 소계 · 계 · 부른 키 · 인자 — 셈 결과를 그대로 실음(환산 규칙을 여기 두지 않음). - `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「10㎡」 · 712-3 `master_basis.basis`) · 답 머리 `기준단위` = 로직 결과단위 분모(「10㎡」 · 여럿이면 「㎡ · ㎥」). - `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위 @@ -24,7 +26,7 @@ import master_formula as mf import master_text as mtx import master_unit as mu -KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거") +KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거", "펼침") _HOLE = re.compile(r"\{([^{}]+)\}") _ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) @@ -117,10 +119,39 @@ def _fields(master, row, exprs, env, per, line: dict, bundle: tuple[str, bool] | return out -def annotate(master, key: str, given: dict, answer: dict) -> dict: +def _spread(files: dict, master, item: dict, env: dict) -> dict: + """장비 줄이 부른 기계경비 로직 한 장 — `{키, 이름, 단위, 입력, 줄, 소계, 계}` · 못 셈 = 까닭.""" + node = mf.parse(item["요소"]) + key = node[1] + try: + key = mf.fill(key, env) if "{" in key else key + given = {k: mf.evaluate(v, env, master, 1) for k, v in node[2]} + got = mtx.lines(files, key, given) + except (*_ERRORS, mf.EmptyInput) as e: + return {"키": key, "까닭": str(e) or type(e).__name__} + if not got.get("ok"): + return {"키": key, "입력": given, "까닭": got.get("reason")} + groups = got.get("groups") or [] + return { + "키": key, + "이름": master.logic(key).get("이름") or "", + "단위": _per_text(mbs.result_per(master.logic(key).get("결과단위"))) or None, + "입력": given, + "줄": [ + {"비목": g["비목"], **{k: one.get(k) for k in ("이름", "글", "금액")}} + for g in groups + for one in g["줄"] + ], + "소계": {g["비목"]: g.get("소계") for g in groups}, + "계": got.get("계"), + } + + +def annotate(files: dict, key: str, given: dict, answer: dict) -> dict: """`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로.""" if not answer.get("ok") or "groups" not in answer: return answer + master = mf.Master(files) row = master.logic(key) per = mbs.result_per(row.get("결과단위")) answer["기준단위"] = _per_text(per) or None @@ -148,6 +179,8 @@ def annotate(master, key: str, given: dict, answer: dict) -> dict: parts = [item.get("비목")] exprs = [item.get("수량"), item.get("요소")] got = _fields(master, row, exprs, env, per, line, mb.bundle(master, item, env)) + if got.get("장비"): + got["펼침"] = _spread(files, master, item, env) for part in parts: target = next(slots.get(part, iter(())), None) if target is not None: diff --git a/resources/tester/test_714_1_sheet_line.py b/resources/tester/test_714_1_sheet_line.py index f2266a8e5..a317f93e4 100644 --- a/resources/tester/test_714_1_sheet_line.py +++ b/resources/tester/test_714_1_sheet_line.py @@ -14,6 +14,7 @@ from M01_MasterData import M01_MasterData_Store_Sheet as sheet # noqa: E402 from M02_MasterTemplete import M02_Logic_Standard as standard # noqa: E402 msl = sheet.msl +num = standard.labor_inputs._num # Decimal → JSON 수(두 길 같은 규칙) def _text(key: str) -> dict: @@ -36,6 +37,18 @@ def test_line_keys_and_bundle(): assert any(line["묶음"] == "인력" for line in lines) +def test_machine_spread(): + """714-1b — 장비 줄 「펼침」 = 부른 기계경비 로직 계산서(조종원 · 연료 · 손료) · 시간당 계.""" + lines = _lines(_text("GF000103")) + spread = next(line for line in lines if line.get("장비"))["펼침"] + assert spread["키"].startswith("GC0002") and spread["단위"] == "시간" + names = " ".join(one["이름"] for one in spread["줄"]) + assert "조종원" in names and "손료" in names + sums = [v for v in spread["소계"].values() if v is not None] + assert spread["계"] == sum(sums) and spread["계"] > 0 + assert not any(line.get("펼침") for line in lines if not line.get("장비")) + + def test_uses_follow_middle_values(): """중간값을 거슬러 입력까지 — 돌쌓기 석공 품은 높이 · 쌓기 · 돌 입력을 씀.""" lines = _lines(_text("GF000219")) @@ -59,4 +72,4 @@ def test_two_routes_same_shape(): for a, b in zip(m01, m02): assert set(b) == {"이름", "글", "금액", *msl.KEYS} assert a["이름"] == b["이름"] - assert [a.get(k) for k in msl.KEYS] == [b.get(k) for k in msl.KEYS] + assert num([a.get(k) for k in msl.KEYS]) == [b.get(k) for k in msl.KEYS] From 6126f5ee207accc5531b2ce5935186572c7d67a8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 16:33:17 +0900 Subject: [PATCH 30/30] =?UTF-8?q?fix(719-2):=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=92=A4=20=EC=84=9C=EA=B3=A0=20=EC=82=AC=EB=B3=B8?= =?UTF-8?q?=20=EC=A4=84=20=EC=97=B0=EA=B2=B0=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=9D=8C=20=C2=B7=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=B8=94=EB=A1=9D=20=EC=95=88=EB=82=B4=20=EA=B8=80?= =?UTF-8?q?=20=EB=90=98=EB=8F=8C=EB=A6=BC=20=EC=9D=B4=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shell: [업데이트]가 서고 사본을 다시 굳혀 블록 id 가 바뀌면 화면 캐시 줄 연결을 사본 폴더(lib)로 다시 이음(짝 Bill_Library.fix_keys) — 캐시가 옛 id 를 잡아 단가 빈칸 되던 것 - Bill_Fold: 단가산출 블록 안내 글 「우클릭 [단가산출 값으로]」 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_UI_Shell.ts | 7 +++++++ B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 6786947ae..62d8dcfb6 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -319,6 +319,13 @@ export function renderB09Estimation(root: HTMLElement): void { payload = next; const draft = projectId && !fromServer ? readState("b09-estimation", projectId) : null; user = draft ?? next.user ?? {}; + // 719-2 [업데이트]가 서고 사본을 다시 굳히면 블록 id 가 바뀜 → 사본 폴더(lib)로 다시 이음(짝 `Bill_Library.fix_keys`) + const libs: Row = next.inputs?.daega?.libs || {}; + const stale = ((user.bill?.rows || []) as Row[]).filter( + (r) => r?.ref?.tab === "dg" && libs[r.ref.lib] && r.ref.key !== libs[r.ref.lib], + ); + for (const r of stale) r.ref.key = libs[r.ref.lib]; + if (stale.length && draft && projectId) writeState("b09-estimation", user, projectId); recompute(); show(current); }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts index 15dfbce47..f253eab01 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold.ts @@ -228,7 +228,7 @@ function foldCard( body: [ table.root, createText( - "품 · 수량 · 단가 칸을 두 번 눌러 고침 · 색 칸 = 고친 칸(이 공사만 · 새 단가 판 · [재적용] 뒤에도 남음) · 우클릭 [품셈 값으로] · [저장] 때 서버가 다시 셈", + `품 · 수량 · 단가 칸을 두 번 눌러 고침 · 색 칸 = 고친 칸(이 공사만 · 새 단가 판 · [재적용] 뒤에도 남음) · 우클릭 [${backName((block.lines || []).find((l: Row) => l.sk))}으로] · [저장] 때 서버가 다시 셈`, "muted", ), ],