From 54fc156f1870df90a363fd11561cddf5e8ae578d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:21:17 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(713-3):=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=8D=A7=EC=B9=B8=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=E2=80=94=20/logics=20=EC=A4=84=EB=A7=88=EB=8B=A4=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C(=EA=B0=99=EC=9D=80=20=EC=A0=88)=20=C2=B7=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=EB=9F=89=20=EB=B6=80=EB=AA=A8=20=C2=B7=20=EB=B6=80?= =?UTF-8?q?=EB=A5=B4=EB=8A=94/=EB=B6=88=EB=A6=AC=EB=8A=94=20=EC=88=98=20?= =?UTF-8?q?=C2=B7=20=EA=B4=80=EB=A0=A8(=EB=B9=84=EA=B3=A0=20=EC=A0=88=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8)=20=C2=B7=20=EC=A0=95=EB=B3=B8=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=C2=B7=20=EC=83=88=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?Store=5FList=20=C2=B7=20=EC=9D=B8=EC=9E=AC=EA=B2=BD=20=EC=A4=84?= =?UTF-8?q?=20=EC=88=98=EB=8A=94=20712-1=20=EB=92=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Router.py | 4 +- M01_MasterData/M01_MasterData_Store_List.py | 96 +++++++++++++++++++++ resources/tester/test_713_3_list.py | 31 +++++++ 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 M01_MasterData/M01_MasterData_Store_List.py create mode 100644 resources/tester/test_713_3_list.py diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index d3d083af4..02b30265a 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -15,6 +15,7 @@ from pydantic import BaseModel 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_List as store_list from M01_MasterData import M01_MasterData_Store_Make as make from M01_MasterData import M01_MasterData_Store_Revise as revise @@ -193,7 +194,8 @@ def get_sections( def get_logics( sub: str = "", detail: str = "", q: str = "", blocked: int | None = None, owner: str = "" ) -> dict: - return {"logics": _call(store.logics, sub, detail, q, blocked, owner)} + """줄마다 덧칸(묶음 · 작업량 · 부르는/불리는 수 · 관련 · 713-3) — 읽기만.""" + return {"logics": store_list.attach(_call(store.logics, sub, detail, q, blocked, owner))} @router.get("/logic") diff --git a/M01_MasterData/M01_MasterData_Store_List.py b/M01_MasterData/M01_MasterData_Store_List.py new file mode 100644 index 000000000..18a0ba796 --- /dev/null +++ b/M01_MasterData/M01_MasterData_Store_List.py @@ -0,0 +1,96 @@ +"""M01 로직 목록 덧칸 (PLAN 713-3 · 요구서 내역12 4장 7) — 정본은 그대로 · 목록에서만 묶음. + +`/logics` 줄마다 붙이는 읽기 칸(파일에 안 씀): +- `묶음` = 같은 파일 · 같은 원문번호 로직이 둘 넘으면 그 번호 · `묶음수` = 몇 개. +- `작업량` = 돈 아닌 로직(`결과` 칸) · `부모` = 그 작업량을 부르는 로직(목록에서 그 밑으로 접음). +- `부르는수` · `불리는수` = 로직끼리 부르기(기계경비 포함 · `Store_Cache.counted`). +- `관련` = 비고 글의 절 번호(「1-5-6(주③)」 · 「별도」 참조)가 가리키는 로직 — 계산 밖 링크. +""" + +from __future__ import annotations + +import re +from collections import Counter, defaultdict + +from M01_MasterData import M01_MasterData_Store as store +from M01_MasterData import M01_MasterData_Store_Cache as cache + +_REF = re.compile( + r"(?:(산림품셈|건설품셈)\s*(공통|토목|건축|기계설비|유지관리)?\s*)?(? tuple[str, str]: + return str(data.get("원문") or ""), str(data.get("부문") or "") + + +def _related(row: dict, book: tuple[str, str], by_section: dict) -> list[str]: + """비고 글 절 번호 → 같은 원문(책 이름이 앞에 있으면 그 책) 로직 키 · 자기 빼고 · 차례대로.""" + out: dict[str, None] = {} + clauses = [c for c in str(row.get("비고") or "").split(" · ") if not c.startswith("해석")] + for name, division, number in _REF.findall(" · ".join(clauses)): + where = (name, division or ("" if name == "산림품셈" else book[1])) if name else book + keys = by_section.get((*where, number), []) + if len(keys) > _WIDE: # 장 · 큰 절 통째(「1-1」)는 링크 아님 + continue + out |= dict.fromkeys(k for k in keys if k != str(row.get("키"))) + return list(out) + + +def extras() -> dict[str, dict]: + """로직 키 → 덧칸 — 목록 전체로 셈(거른 목록이어도 묶음 수 · 부르는 수는 전체 기준).""" + files, _ = store.loaded() + rows = [ + (name, data, row) + for name, data in files.items() + if data.get("그룹") == "로직" + for row in data.get("줄") or [] + ] + calls = {key: got[1] for key, got in cache.counted(store.FOLDER).items()} + called = Counter(k for got in calls.values() for k in got) + parents: dict[str, list[str]] = defaultdict(list) + for key, got in calls.items(): + for k in got: + parents[k].append(key) + groups = Counter((name, str(row.get("원문번호") or "")) for name, _, row in rows) + by_section: dict[tuple, list[str]] = defaultdict(list) + for _, data, row in rows: + number = str(row.get("원문번호") or "") + for at in range(1, number.count("-") + 1): # 「1-5-6」 은 「1-5」 로도 찾힘 + by_section[(*_book(data), "-".join(number.split("-")[: at + 1]))].append( + str(row.get("키")) + ) + out = {} + for name, data, row in rows: + key, number = str(row.get("키")), str(row.get("원문번호") or "") + many = groups[(name, number)] if number else 1 + work = "결과" in row + out[key] = { + "묶음": number if many > 1 else "", + "묶음수": many if many > 1 else 0, + "작업량": work, + "부모": sorted(parents.get(key, [])) if work else [], + "부르는수": len(calls.get(key, ())), + "불리는수": called.get(key, 0), + "관련": _related(row, _book(data), by_section), + } + return out + + +def attach(logics: list[dict]) -> list[dict]: + """`/logics` 줄에 덧칸을 붙임(없는 키 = 저장 안 한 초안 — 빈 칸).""" + more = extras() + blank = { + "묶음": "", + "묶음수": 0, + "작업량": False, + "부모": [], + "부르는수": 0, + "불리는수": 0, + "관련": [], + } + return [{**one, **more.get(str(one.get("키")), blank)} for one in logics] diff --git a/resources/tester/test_713_3_list.py b/resources/tester/test_713_3_list.py new file mode 100644 index 000000000..6631933f3 --- /dev/null +++ b/resources/tester/test_713_3_list.py @@ -0,0 +1,31 @@ +"""713-3 지킴 — 로직 목록 덧칸(묶음 · 작업량 부모 · 부르는/불리는 수 · 관련) · 정본 그대로 · 줄 수 그대로.""" + +from __future__ import annotations + +from M01_MasterData import M01_MasterData_Router as router +from M01_MasterData import M01_MasterData_Store_List as store_list + +COLS = {"묶음", "묶음수", "작업량", "부모", "부르는수", "불리는수", "관련"} + + +def test_목록_줄_수_그대로_덧칸_다_있음() -> None: + got = router.get_logics()["logics"] + assert len(got) == 1469 # 묶음은 화면 접기만 — 목록 줄은 그대로 + assert all(COLS <= set(x) for x in got) + + +def test_묶음_작업량_부르기() -> None: + more = store_list.extras() + assert sum(1 for v in more.values() if v["묶음"]) == 323 # 한 절에 로직 둘 넘음(조사 12-B ㉯) + work = {k for k, v in more.items() if v["작업량"]} + assert len(work) == 27 + assert more["GF000102"]["부모"] == ["GF000103"] # 구조물터파기 작업량 → 단가 로직 밑 + assert more["GC000268"]["불리는수"] >= 300 # 기계경비 로직 + assert sum(1 for v in more.values() if v["부르는수"]) == 506 + assert more["GC000294"]["관련"] == ["GC000292"] # 비고 「1-5-6(주③)」 + + +def test_관련은_해석_글과_넓은_절을_뺌() -> None: + more = store_list.extras() + assert all(len(v["관련"]) <= store_list._WIDE for v in more.values()) + assert not any(k in v["관련"] for k, v in more.items()) # 자기 자신 없음 From cc588889f6cac223a064b5b77446fb48043b80a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:22:27 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(718-1):=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=B0=EA=B3=BC=20=E3=80=8C=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=E3=80=8D=20=EC=BC=AC=20=E2=80=94=20=EC=82=B0?= =?UTF-8?q?=EA=B7=BC=20=EB=B8=94=EB=A1=9D=EB=A7=88=EB=8B=A4=20=EB=B0=94?= =?UTF-8?q?=ED=83=95=20=EC=97=86=EB=8A=94=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=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 3/4] =?UTF-8?q?feat(711-2):=20M01=20=EC=9E=AC=EB=A3=8C=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=ED=95=98=EB=82=98=20=E2=80=94=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=91=9C=20=EC=A4=84=20=C2=B7=20=EC=8B=9C=ED=97=98?= =?UTF-8?q?=20=EA=B3=84=EC=82=B0=20=EC=9E=90=EC=9E=AC=20=EC=B9=B8=20?= =?UTF-8?q?=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 4/4] =?UTF-8?q?feat(719-1):=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=82=AC=EB=B3=B8=20SK=20=ED=92=80=EC=96=B4=20?= =?UTF-8?q?=EA=B5=B3=ED=9E=98=20=E2=80=94=20=EA=B3=A0=EC=B9=9C=20=EC=A4=84?= =?UTF-8?q?=20=3D=20=EC=82=AC=EB=B3=B8=20=EB=8B=A8=EA=B0=80.json=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A4=84=20=C2=B7=20=EA=B5=B3=ED=9E=8C=20?= =?UTF-8?q?=ED=82=A4=20GX9#####=20(SK=20=EC=9D=B4=EB=A6=84=ED=91=9C=20?= =?UTF-8?q?=ED=95=B4=EC=8B=9C=20=C2=B7=20=EA=B0=99=EC=9D=80=20SK=20=3D=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=ED=82=A4)=20=C2=B7=20B09=20=EC=84=9C?= =?UTF-8?q?=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) == []