From c5067aa2b659c973f407ecfe35e402a486ab0532 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 19:17:58 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(809-1a):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=3D=20?= =?UTF-8?q?=EA=B2=BD=EC=9F=81=EC=82=AC=20=EB=A1=9C=EC=A7=81=20=EC=9D=B8?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EC=9A=94=EC=86=8C=EB=A7=8C=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=C2=B7=20[=EC=88=98=EC=A0=95]=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20=3D=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=B6=80=ED=92=88=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EC=B4=88=20=ED=8C=90=EC=A0=95=20=EA=B1=B7=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [수정] 모달 = Labor_Table(읽기 · 덧칸 표지 · 엑셀 금액) · 줄 = 로직(단가산출 · 일위대가) / 요소(연결됨 · 후보 · 못 이음) · 단가산출 줄 = 가져온 식 SK 수식 탭 모달(Labor_SkEdit) · 서버 hopyo-view(M02_Hopyo_View) - 엑셀 보기 목록 · 칠 = 줄 표지(View.listing) · 단가산출 탭 = 블록 목록 + [가져온 식으로 열기] · [내 단가산출로] - 걷음(요구 703): 판정 체계 785(Judge · Solve · Bind · San_Basis 다섯 · Make.Move · Row 1차 판정 · 화면 Check · CheckRows · Rows · Block · JudgeApi · Picks · Judge.css) · [우리 기초로 바꾸기](to-base) · 근거 기초 제안 · Clause 기초 판정 - 시험 지움: 642_2(Bind · Solve) · 642_3(Row 1차 판정) · 642_5(옮기기 Move) · 642_6(판정 화면) · 786_2(옛 [수정] 모달) · 793_3a(근거 → 기초 제안) — 모두 걷은 기초 판정 길 몫 - 시험 고침: 642_1 · 794_2a2 · 794_2b(근거 제안 부분만 뺌) · 793_2 · 793_3b(기초 바꾸기 기대 → 없음) · 627 · 779_2 · 785_4 · 807_1 · m02_hopyo_excel · grid · stmate(judge 길 → view 길) - 새 시험 test_809_1_import_labor(기초 길 0 · 표지 1,350 줄 · 가져온 식 SK · 모달 = Labor_Table · 금액 = 엑셀) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Bind.py | 474 -------------- M02_MasterTemplete/M02_Hopyo_Build.py | 10 +- M02_MasterTemplete/M02_Hopyo_Clause.py | 256 +------- M02_MasterTemplete/M02_Hopyo_Fixed.py | 18 +- M02_MasterTemplete/M02_Hopyo_Judge.py | 364 ----------- M02_MasterTemplete/M02_Hopyo_Make.py | 297 +-------- M02_MasterTemplete/M02_Hopyo_Marks.py | 29 +- M02_MasterTemplete/M02_Hopyo_Router.py | 244 +------ M02_MasterTemplete/M02_Hopyo_Row.py | 302 +-------- M02_MasterTemplete/M02_Hopyo_Solve.py | 279 -------- M02_MasterTemplete/M02_Hopyo_View.py | 98 +++ .../M02_MasterTemplete_Hopyo.css | 77 +-- .../M02_MasterTemplete_Hopyo_Api.ts | 4 +- .../M02_MasterTemplete_Hopyo_Block.ts | 442 ------------- .../M02_MasterTemplete_Hopyo_Check.ts | 360 ----------- .../M02_MasterTemplete_Hopyo_CheckRows.ts | 331 ---------- .../M02_MasterTemplete_Hopyo_Import.ts | 16 +- .../M02_MasterTemplete_Hopyo_Imported.ts | 129 +--- .../M02_MasterTemplete_Hopyo_ImportedPanel.ts | 107 +--- .../M02_MasterTemplete_Hopyo_Judge.css | 33 - .../M02_MasterTemplete_Hopyo_JudgeApi.ts | 272 -------- .../M02_MasterTemplete_Hopyo_Rows.ts | 306 --------- .../M02_MasterTemplete_Hopyo_Sheet.ts | 98 +-- .../M02_MasterTemplete_Hopyo_SheetNav.ts | 4 +- .../M02_MasterTemplete_Hopyo_SheetSk.ts | 125 ++-- .../M02_MasterTemplete_Hopyo_View.ts | 235 +++++++ .../M02_MasterTemplete_Labor_Detail.ts | 11 + .../M02_MasterTemplete_Labor_Table.ts | 19 +- M02_MasterTemplete/M02_San_Basis.py | 602 ------------------ M02_MasterTemplete/M02_San_Basis_Check.py | 446 ------------- M02_MasterTemplete/M02_San_Basis_Const.py | 378 ----------- M02_MasterTemplete/M02_San_Basis_Index.py | 12 +- M02_MasterTemplete/M02_San_Basis_Map.py | 121 ---- M02_MasterTemplete/M02_San_Basis_Store.py | 151 ----- M02_MasterTemplete/M02_San_Basis_Value.py | 307 --------- M02_MasterTemplete/M02_Sangeun_FromHopyo.py | 11 +- M02_MasterTemplete/M02_Sangeun_Imported.py | 36 +- M02_MasterTemplete/M02_Sangeun_Router.py | 13 +- .../logic/M02_Hopyo_Imported_Model.ts | 88 +-- .../logic/M02_MasterTemplete_Hopyo_Picks.ts | 227 ------- .../M02_MasterTemplete_Hopyo_SheetMap.ts | 19 +- resources/tester/helper_642_6_picks.cjs | 34 - resources/tester/helper_778_5_sk_pick.cjs | 80 +-- resources/tester/test_627_sheet_sk.py | 36 +- resources/tester/test_642_1_clause_formula.py | 64 +- resources/tester/test_642_2_bind_solve.py | 194 ------ resources/tester/test_642_3_row.py | 296 --------- resources/tester/test_642_5_make.py | 210 ------ resources/tester/test_642_6_screen.py | 351 ---------- resources/tester/test_779_2_hopyo_marks.py | 21 +- resources/tester/test_785_4_import_screen.py | 23 +- resources/tester/test_786_2_check_modal.py | 123 ---- .../tester/test_793_2_sangeun_imported.py | 20 +- resources/tester/test_793_3a_san_basis.py | 259 -------- resources/tester/test_793_3b_import_screen.py | 103 +-- resources/tester/test_794_2a2_m01_add.py | 46 +- resources/tester/test_794_2b_edition.py | 105 +-- resources/tester/test_807_1_import_doc_one.py | 4 +- resources/tester/test_809_1_import_labor.py | 138 ++++ resources/tester/test_m02_hopyo_excel.py | 3 +- resources/tester/test_m02_hopyo_grid.py | 2 +- resources/tester/test_m02_hopyo_stmate.py | 4 +- 62 files changed, 769 insertions(+), 8698 deletions(-) delete mode 100644 M02_MasterTemplete/M02_Hopyo_Bind.py delete mode 100644 M02_MasterTemplete/M02_Hopyo_Judge.py delete mode 100644 M02_MasterTemplete/M02_Hopyo_Solve.py create mode 100644 M02_MasterTemplete/M02_Hopyo_View.py delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Judge.css delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_JudgeApi.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts delete mode 100644 M02_MasterTemplete/M02_San_Basis.py delete mode 100644 M02_MasterTemplete/M02_San_Basis_Check.py delete mode 100644 M02_MasterTemplete/M02_San_Basis_Const.py delete mode 100644 M02_MasterTemplete/M02_San_Basis_Map.py delete mode 100644 M02_MasterTemplete/M02_San_Basis_Store.py delete mode 100644 M02_MasterTemplete/M02_San_Basis_Value.py delete mode 100644 M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts delete mode 100644 resources/tester/helper_642_6_picks.cjs delete mode 100644 resources/tester/test_642_2_bind_solve.py delete mode 100644 resources/tester/test_642_3_row.py delete mode 100644 resources/tester/test_642_5_make.py delete mode 100644 resources/tester/test_642_6_screen.py delete mode 100644 resources/tester/test_786_2_check_modal.py delete mode 100644 resources/tester/test_793_3a_san_basis.py create mode 100644 resources/tester/test_809_1_import_labor.py diff --git a/M02_MasterTemplete/M02_Hopyo_Bind.py b/M02_MasterTemplete/M02_Hopyo_Bind.py deleted file mode 100644 index 9c8856c73..000000000 --- a/M02_MasterTemplete/M02_Hopyo_Bind.py +++ /dev/null @@ -1,474 +0,0 @@ -"""M02 일위대가 실무자료 옵션값 읽기(PLAN 785-2 · 요구 641 · 642 · 설계 642-A 2-5 ④-3) — -우리 기초 하나 + 엑셀 글감 → 입력값. 찾지 않고 읽음 · 지어 넣는 1 · 0 없음. - -길(앞이 먼저 · 두 길이 다른 값 = 엇갈림 · 앞 값 둠): - B1 부르는 호표 줄 규격 · 품명 괄호(`Match.spec_inputs`) - B6 같은 호표 다른 줄 — 레미콘 규격 「a-b-c」 → 굵은골재 · 강도 · 슬럼프 · 채움량 = 그 줄 수량 ÷ 이 줄 수량 - B2 선택값 줄(785-1 Formula) · 글에 없는 고르기 = 「아님 · 해당 없음」(글 · 줄에 칸 이름 · 다른 고름이 없을 때) - B3 상수 줄(기호표 `SYMBOLS` · 기호 = 입력 이름 · 「(90˚)」 = 선회각도 · 표 칸 이름 = 기호면 그 표 줄 조건값) - B4 대입식 — 우리 줄 수량 식을 중간까지 펴 분자 · 분모 자리 + 단위로 짝(하나뿐일 때만) - B5 표 거꾸로 — 못 읽은 찾기 조건 칸 = 엑셀 품과 같은 표 줄(`fit` 검산) · 「감률」 칸 = 품 배수 -못 읽은 칸 = 비움(지역 · 자재지역 · 조종원직종 = 프로젝트) · 입력 필요(빈 줄을 낳음 · 요소 글에 듦) · -안 씀(그 밖) · 기본 = `master_auto.defaults` 제안(저장 아님). 엔진 몫 `quantities` = 금액 없이 품만. -서버 단독 · 읽기만 · 짝 = `M02_Hopyo_Solve`. -""" - -from __future__ import annotations - -import re -from decimal import Decimal -from typing import Any, Callable - -from M01_MasterData import M01_MasterData_Store as m01 -from M02_MasterTemplete import M02_Hopyo_Match as hm - -mf = m01.cm.mf -mp = mf.mp -mm = mp.mm -import master_auto as ma # noqa: E402 — mf 를 불러 scripts 경로가 깔린 뒤 -import master_unit as mu # noqa: E402 - -SKIP = ("지역", "자재지역", "조종원직종") -NEG = ("아님", "해당 없음", "없음", "미적용", "사용 안 함") -# 상수 기호 → 입력 이름(앞이 먼저 · 기호 = 입력 이름이면 바로) -SYMBOLS = { - "q": ("버킷용량",), - "E": ("E입력",), - "Cm": ("싸이클입력",), - "L": ("운반거리", "거리"), - "n": ("운반회수",), - "T": ("실작업시간",), - "경사도": ("경사각도",), -} -_WORDS = re.compile(r"[\s::,/()※*·\[\]{}]+") -_SAME_UNIT = {"sec": "초", "s": "초", "min": "분", "hr": "시간", "h": "시간"} -_ANGLE = re.compile(r"\(\s*(\d+)\s*[˚°]\s*\)") -_MIX = re.compile(r"(? Decimal | None: - try: - return None if value in (None, "") else Decimal(str(value)) - except ArithmeticError: - return None - - -def plain(value: Any) -> Any: - """JSON 수 — Decimal → int · float(글은 그대로).""" - if isinstance(value, Decimal): - value = value.normalize() - return int(value) if value == value.to_integral_value() else float(value) - return value - - -def specs_of(row: dict) -> list[dict]: - return mf.inputs_of(row) if isinstance(row.get("흐름"), dict) else row.get("입력", []) - - -def _pick(spec: dict, value: Any) -> Any: - """고르기 칸이면 같은 수 · 같은 글의 고름(「080」 = 80) · 없으면 None · 고르기 아니면 수.""" - picks = spec.get("고르기") - if not picks: - return _num(value) - hit = next((o for o in picks if str(o) == str(value)), None) - n = _num(value) - if hit is None and n is not None: - hit = next((o for o in picks if _num(o) == n), None) - return hit - - -# ── 엔진 몫(품만) ───────────────────────────────────────────────────── -def _env(master: mf.Master, row: dict, given: dict) -> dict: - """입력 검사(고르기 · 범위 · 기계 키) — 빈 칸은 자리값으로 검사만 하고 None 으로 되돌림.""" - holes = {} - for spec in specs_of(row): - if given.get(spec["이름"]) is None: - span = spec.get("범위") - holes[spec["이름"]] = (spec.get("고르기") or [None])[0] or (span[0] if span else 0) - env = mf._inputs(row, {**{s["이름"]: None for s in specs_of(row)}, **given, **holes}, master) - return {**env, **dict.fromkeys(holes)} - - -def _rate(extra: dict, env: dict, master: mf.Master) -> Decimal | None: - """덧줄 율 — 식 꼴 「(기준) × 율 / 100」 의 율 ÷ 100 · 다른 꼴 = None.""" - node = mf.parse(extra["식"]) - if node[0] == "bin" and node[1] == "/" and node[3] == ("num", Decimal(100)): - left = node[2] - if left[0] == "bin" and left[1] == "*": - return mf.evaluate(left[3], env, master) / 100 - return None - - -def quantities(master: mf.Master, key: str, given: dict) -> list[dict]: - """로직 줄마다 품 `{이름, 종류, 단위, 요소, 품, 까닭?}` + 덧줄 율(종류 「율」) — 금액 안 셈 · - 빈 칸을 쓰는 줄만 품 None + 까닭(`mf.run` 과 같은 길).""" - row = master.logic(key) - if isinstance(row.get("흐름"), dict): # 흐름 로직 — 엔진 한 번(빈 칸 = 자리값 없음 → 못 셈) - try: - got = mf.run(master, key, given) - except mf.FormulaError as e: - return [{"이름": "", "종류": "", "단위": "", "품": None, "까닭": str(e)}] - return [{**x, "종류": "", "품": x.get("수량")} for x in got.get("줄", [])] - env = _env(master, row, given) - for step in row.get("중간", []): - try: - env[step["이름"]] = mf.evaluate(mf.parse(step["식"]), env, master) - except mf.EmptyInput as e: - env[step["이름"]] = mf.Blank(str(e)) - out = [] - for item in row.get("호표", []): - one = {k: item.get(k) for k in ("이름", "종류", "단위", "요소", "비목")} - try: - one["품"] = mf.evaluate(mf.parse(item["수량"]), env, master) - except mf.EmptyInput as e: - one |= {"품": None, "까닭": str(e)} - out.append(one) - for extra in row.get("덧줄", []): - one = {"이름": extra["이름"], "종류": "율", "단위": "%", "비목": extra.get("비목")} - try: - one["품"] = _rate(extra, env, master) - except mf.EmptyInput as e: - one |= {"품": None, "까닭": str(e)} - out.append(one) - return out - - -# ── 식 풀기 ─────────────────────────────────────────────────────────── -def _names(node) -> set: - return {p[1] for p in mf.walk(node) if p[0] == "name"} - - -def _mids(row: dict) -> dict: - return {s["이름"]: mf.parse(s["식"]) for s in row.get("중간", [])} - - -def deps(row: dict, text: str) -> set: - """식이 기대는 입력 이름 — 중간을 거쳐 폄(만약 갈래 모두 · 넉넉히).""" - mids, seen, todo = _mids(row), set(), _names(mf.parse(text)) - while todo: - name = todo.pop() - if name in seen: - continue - seen.add(name) - if name in mids: - todo |= _names(mids[name]) - return seen - - -def _place(node, side: str, mids: dict, out: dict, depth: int = 0) -> None: - """이름마다 분자 · 분모 자리 — 「/」 오른쪽이 뒤집힘 · 만약은 두 갈래만 · 찾기 · 함수 안은 안 봄.""" - kind = node[0] - if kind == "name": - if node[1] in mids and depth < 20: - _place(mids[node[1]], side, mids, out, depth + 1) - else: - out.setdefault(node[1], set()).add(side) - elif kind == "bin" and node[1] in ("*", "/", "+", "-"): - _place(node[2], side, mids, out, depth) - flip = {"분자": "분모", "분모": "분자"}[side] if node[1] == "/" else side - _place(node[3], flip, mids, out, depth) - elif kind == "call" and node[1] == "만약": - for arm in node[2][1:]: - _place(arm, side, mids, out, depth) - - -def _element_names(row: dict, element: Any) -> set: - """요소가 기대는 입력 — 글 = 식 · 묶음 = 「{이름}」 구멍.""" - if isinstance(element, str): - return deps(row, element) - if isinstance(element, dict): - return {n for v in element.values() for n in re.findall(r"\{([^{}]+)\}", str(v))} - return set() - - -def places(row: dict) -> dict: - """입력 이름 → {분자 | 분모} — 호표 줄 수량 식에서.""" - out: dict = {} - mids = _mids(row) - for item in row.get("호표", []): - _place(mf.parse(item["수량"]), "분자", mids, out) - return out - - -def finds(master: mf.Master, row: dict, depth: int = 0) -> dict: - """찾기 조건 칸으로 쓰인 입력 → [(표, 조건)] — 「로직(키, 칸=이름)」 한 겹 안도 이름을 옮겨 봄.""" - out = {k: list(v) for k, v in ma._uses(row)[0].items()} - if depth > 1: - return out - for text in ma._texts(row): - for part in mf.walk(mf.parse(text)): - if part[0] != "logic" or not isinstance(part[1], str) or "{" in part[1]: - continue - try: - inner = finds(master, master.logic(part[1]), depth + 1) - except mf.FormulaError: - continue - passed = {k: v[1] for k, v in part[2] if v[0] == "name"} - for name, uses in inner.items(): - if name in passed: - out.setdefault(passed[name], []).extend(uses) - return out - - -# ── 읽기 ───────────────────────────────────────────────────────────── -class _Got: - """읽은 값 · 출처 · 엇갈림.""" - - def __init__(self, specs: list[dict]) -> None: - self.specs = {s["이름"]: s for s in specs} - self.value: dict = {} - self.where: dict = {} - self.clash: dict = {} - - def put(self, name: str, value: Any, how: str, at: Any = None, text: Any = "") -> None: - spec = self.specs.get(name) - value = _pick(spec, value) if spec else None - if value is None or name in SKIP: - return - mine = {"값": plain(value), "길": how, "행": at} - if name not in self.value: - self.value[name], self.where[name] = value, {"길": how, "행": at, "글": str(text)[:120]} - elif self.value[name] != value: - first = { - "값": plain(self.value[name]), - **{k: self.where[name][k] for k in ("길", "행")}, - } - self.clash.setdefault(name, [first]) - if mine not in self.clash[name]: - self.clash[name].append(mine) - - -def _b1(got: _Got, line: dict | None, how: str = "B1") -> None: - if not line: - return - values, _ = hm.spec_inputs(line, list(got.specs.values())) - text = f"{line.get('이름') or ''} {line.get('규격') or ''}".strip() - for name, value in values.items(): - got.put(name, value, how, line.get("행"), text) - - -def _b6(got: _Got, line: dict | None, same: list[dict]) -> None: - for other in same or []: - text = f"{other.get('이름') or ''} {other.get('규격') or ''}" - mix = _MIX.search(text) - if not mix or not any(w in text for w in CONC): - continue - for name, value in zip(MIX_NAMES, mix.groups(), strict=True): - got.put(name, value, "B6", other.get("행"), text) - have, mine = _num(other.get("수량")), _num((line or {}).get("수량")) - for name, spec in got.specs.items(): - unit = mm.plain_unit(spec.get("단위")) - fill = "콘크리트" in name and unit.count("/") == 1 and have and mine - if fill and unit.split("/") == [ - mm.plain_unit(other.get("단위")), - mm.plain_unit(line.get("단위")), - ]: - got.put( - name, (have / mine).quantize(Decimal("0.0001")), "B6", other.get("행"), text - ) - - -def _b2(got: _Got, picks: list[dict], text: str, keys: dict) -> None: - """선택값 줄 · 글에 없는 켜기 칸 = 아님(표 줄을 고르는 찾기 조건 칸은 B5 몫 · 뺌).""" - for one in picks or []: # 고름 글 = 줄의 낱말 하나와 같음(「사용횟수」 ≠ 「사용」) - words = {mm.plain_spec(w) for w in _WORDS.split(str(one.get("글") or "")) if w} - for name, spec in got.specs.items(): - hits = [o for o in spec.get("고르기") or [] if mm.plain_spec(str(o)) in words] - if len(hits) == 1: - got.put(name, hits[0], "B2", one.get("행"), one.get("글")) - flat = mm.plain_spec(text) - for name, spec in got.specs.items(): - opts = spec.get("고르기") or [] - neg = next((o for o in opts if str(o) in NEG), None) - if neg is None or name in got.value or name in keys: - continue - words = [name, *(hm._PAREN.sub("", str(o)) for o in opts if o != neg)] - if not any(len(w) > 2 and mm.plain_spec(w) in flat for w in words): - got.put(name, neg, "B2", None, f"글에 「{name}」 없음") - - -def _unit_ok(have: Any, want: Any) -> bool: - """상수 단위 ↔ 입력 단위 — 한쪽이 없음 · 같음(「회/일」 = 회) · 같은 성격(길이 등).""" - a, b = (mm.plain_unit(str(u or "").split("/")[0]) for u in (have, want)) - a, b = _SAME_UNIT.get(a, a), _SAME_UNIT.get(b, b) - return not a or not b or a == b or mu.same_kind(a, b) - - -def _b3(got: _Got, consts: list[dict], master: mf.Master, uses: dict) -> None: - for c in consts or []: - sym, value, at, text = str(c.get("기호") or ""), c.get("값"), c.get("행"), c.get("글") or "" - angle = _ANGLE.search(f"({c.get('덧') or ''}) {c.get('단위') or ''} {text}") - if angle: - got.put("선회각도", angle.group(1), "B3", at, text) - names = [ - n - for n in (c.get("이름"), sym, *SYMBOLS.get(sym, ())) - if n in got.specs and _unit_ok(c.get("단위"), got.specs[n].get("단위")) - ] - if names: - unit = got.specs[names[0]].get("단위") - rate = mu.factor(c.get("단위"), unit) if c.get("단위") and unit else None - value = _num(value) * rate if rate and _num(value) is not None else value - got.put( - names[0], - value, - "B3", - at, - f"{sym} = {plain(_num(c.get('값')))}{c.get('단위') or ''}", - ) - continue - for name, refs in uses.items(): # 표 칸 이름 = 기호 → 그 줄 조건값(K = 0.55 → 버킷조건) - if name in got.value or name not in got.specs: - continue - for ref, cond in refs: - rows = [r for r in master.get(ref).get("줄", []) if _num(r.get(sym)) == _num(value)] - conds = {r.get(cond) for r in rows} - if len(conds) == 1 and None not in conds: - got.put(name, conds.pop(), "B5", at, f"{sym} = {plain(_num(value))} → {ref}") - - -def _b4(got: _Got, forms: list[dict], row: dict) -> None: - where = places(row) - for form in forms or []: - for side in ("분자", "분모"): - for term in form.get(side) or []: - unit = mm.plain_unit(str(term.get("단위") or "").split("/")[0]) - hits = [ - n - for n, s in got.specs.items() - if side in where.get(n, ()) and unit and mm.plain_unit(s.get("단위")) == unit - ] - value = term.get("수", term.get("값")) - if term.get("기호") is not None: - value = (form.get("대입") or {}).get(term["기호"]) - if len(hits) == 1 and _num(value) is not None: - got.put(hits[0], value, "B4", form.get("행"), form.get("글")) - - -def _ok(fit: Callable, given: dict) -> dict: - try: - return fit(given) or {} - except mf.FormulaError: - return {} - - -def _b5(got: _Got, master: mf.Master, row: dict, uses: dict, fit: Callable, need: set) -> None: - """못 읽은 찾기 조건 칸 — 표 줄마다 품 검산 · 줄이 갈리면 그 값(같은 값 여럿 = 엇갈림).""" - rate = [n for n in need if n.endswith("감률") and n not in got.value] - zero = dict.fromkeys(rate, Decimal(0)) - tables: dict = {} - for name in sorted(need): - for ref, cond in uses.get(name, []): - if name not in got.value and got.specs.get(name, {}).get("고르기"): - tables.setdefault(ref, []).append((name, cond)) - for ref, conds in tables.items(): - rows, combos = master.get(ref).get("줄", []), [] - bound = { - c: got.value[n] - for n, refs in uses.items() - if n in got.value - for r, c in refs - if r == ref - } - for r in rows: - if all(r.get(c) is None or str(r.get(c)) == str(v) for c, v in bound.items()): - combo = {n: r.get(c) for n, c in conds} - combos += [combo] if combo not in combos else [] - exact, scaled = [], [] - for combo in combos: - fill = {n: v if v is not None else got.specs[n]["고르기"][0] for n, v in combo.items()} - res = _ok(fit, {**got.value, **zero, **fill}) - if res.get("맞음"): - exact.append(combo) - elif rate and (res.get("배") or 0) > 1: - scaled.append(combo) - live = exact or scaled # 그대로 같은 줄이 감률 배 줄보다 먼저 - if not live or (len(live) == len(combos) > 1): - continue - for name, _ in conds: - seen = {str(c[name]) for c in live if c[name] is not None} - for v in [str(o) for o in got.specs[name]["고르기"] if str(o) in seen]: - got.put(name, v, "B5", None, f"{ref} 줄 = 엑셀 품") - if rate: # 감률 = 엑셀 품 ÷ 우리 품(감률 0)이 모든 노무 · 기계 줄에서 같은 배 - res = _ok(fit, {**got.value, **zero}) - k = res.get("배") - if k and k > 1: - cut = (Decimal(1) - Decimal(1) / Decimal(str(k))) * 100 - cut = ( - cut.quantize(Decimal("1")) - if abs(cut - cut.quantize(Decimal("1"))) < Decimal("0.5") - else cut - ) - if _ok(fit, {**got.value, rate[0]: cut}).get("맞음"): - got.put( - rate[0], - cut, - "B5", - None, - f"품 × {plain(Decimal(str(k)).quantize(Decimal('0.01')))}", - ) - - -def bind(master: mf.Master, key: str, src: dict, fit: Callable | None = None) -> dict: - """옵션값 읽기 → `{입력값, 출처, 엇갈림, 안씀, 입력필요, 비움, 기본}` — 설계 642-A 2-5 ④-3.""" - row = master.logic(key) - specs = specs_of(row) - got = _Got(specs) - line, block = src.get("줄"), src.get("블록") or {} - _b1(got, line) - if not line and block: - _b1(got, {"이름": block.get("이름"), "규격": "", "행": (block.get("행") or [None])[0]}) - _b6(got, line, src.get("같은줄") or []) - uses = finds(master, row) if not isinstance(row.get("흐름"), dict) else {} - _b3(got, src.get("상수") or [], master, uses) - if not isinstance(row.get("흐름"), dict): - _b4(got, src.get("식") or src.get("대입식") or [], row) - whole = " ".join( - [ - *(block.get("글") or []), - str((line or {}).get("이름") or ""), - str((line or {}).get("규격") or ""), - ] - ) - direct = {} if isinstance(row.get("흐름"), dict) else ma._uses(row)[0] - _b2(got, src.get("선택값") or [], whole, direct) - if fit: - _b5(got, master, row, uses, fit, _needed(master, key, row, got.value)) - need = _needed(master, key, row, got.value) - base = ma.defaults(master, row) - unread = [s["이름"] for s in specs if s["이름"] not in got.value] - skip = [n for n in unread if n in SKIP] - want = [n for n in unread if n in need and n not in SKIP and n not in got.clash] - unused = [n for n in unread if n not in skip and n not in want and n not in got.clash] - return { - "입력값": {k: plain(v) for k, v in got.value.items() if k not in got.clash}, - "출처": {k: v for k, v in got.where.items() if k not in got.clash}, - "엇갈림": got.clash, - "안씀": unused, - "입력필요": want, - "비움": skip, - "기본": {n: plain(base.get(n)) for n in [*want, *unused, *got.clash]}, - } - - -def _needed(master: mf.Master, key: str, row: dict, have: dict) -> set: - """못 읽은 칸 중 쓰이는 것 — 빈 품 줄이 기대는 칸(기본값으로 0 이 되는 줄 = 꺼진 줄 · 뺌) + - 품 있는 줄의 요소 글에 든 칸(기계 · 지역 등 단가 몫).""" - if isinstance(row.get("흐름"), dict): - return {s["이름"] for s in specs_of(row)} - set(have) - lines = quantities(master, key, have) - base = {k: v for k, v in ma.defaults(master, row).items() if v is not None and k not in have} - probe = ( - quantities(master, key, {**base, **have}) if any(x["품"] is None for x in lines) else lines - ) - items = [*row.get("호표", []), *({"수량": x["식"]} for x in row.get("덧줄", []))] - need: set = set() - for item, now, then in zip(items, lines, probe, strict=True): - if now.get("까닭") and then["품"] != 0: - need |= deps(row, item["수량"]) - elif now["품"] not in (None, 0): - need |= _element_names(row, item.get("요소")) - # ponytail: 0 이 되는 줄 = 꺼진 줄로 봄(기본값 하나로 판정) — 값마다 다른 줄이 나오면 칸마다 검산으로 - return need - set(have) diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py index 68a1b45cd..fca858b77 100644 --- a/M02_MasterTemplete/M02_Hopyo_Build.py +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -5,7 +5,7 @@ 몸이 비면 = 모두 고정(705-3 · [모두 고정으로 들여오기]). 서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 = 돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] · -호표 = 만들 층에 확인된 사전조합(확인 안 된 하위 = 409 · 하위 먼저 = 옮기기 `M02_Hopyo_Make` · 777-1). +호표 = 만들 층에 확인된 사전조합(확인 안 된 하위 = 409 · 하위 먼저 = [내 일위대가로] `build_all` · 777-1). 호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 · 상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인], 확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층). @@ -33,7 +33,7 @@ `{까닭, 남은줄}`(저장 안 함 · `save` 거짓 = 막지 않고 `남은줄` 만). 수량(742-1 · 요구 584-1) = 엑셀 수량을 줄 · 자재 저장 수량(`{손}`)으로 · 같은 값 `본뜬.실무수량 {줄: {차례: 수}, 자재: {코드: 수}}`(출처 기록 · 엑셀 대조 `M02_Hopyo_Compare` — 하위 호표 것까지 `excel_given`). -785-5 — 확인한 줄 몸 = `M02_Hopyo_Make.Lines`(옛 대상 꼴 `put` · 새 고른 것 꼴 `picks` = 몸 `{차례: {고른}}`) · +785-5 — 확인한 줄 몸 = `M02_Hopyo_Make.Lines`(대상 꼴 `put` · 옛 판정 고른 것 길은 809-1 에서 지움) · 여기는 모두 고정 길 · 줄 차례 · 쓰기(`Builder` · `_order` · `_origins` · `build_all` = B09 빌림). """ @@ -255,12 +255,6 @@ class Builder: book[ledger.hopyo_key(hopyo)] = self._row(hopyo, "짝", whole) for at, line in enumerate(rows): got = checked.get(at) - if got and "고른" in got: # 새 길 고른 것(785-5 · Make 가 검사 · 다시 셈) - poomsem.append(at) - body.picks(at, line, got["고른"]) - pairs = [hmake.ledger_pick(p) for p in got["고른"]] - book[ledger.key(line)] = self._row(line, "짝", pairs) - continue if whole: missing.append(_missing(line, WHOLE)) continue diff --git a/M02_MasterTemplete/M02_Hopyo_Clause.py b/M02_MasterTemplete/M02_Hopyo_Clause.py index 6cc098efd..c33ac48c0 100644 --- a/M02_MasterTemplete/M02_Hopyo_Clause.py +++ b/M02_MasterTemplete/M02_Hopyo_Clause.py @@ -1,19 +1,8 @@ -"""M02 일위대가 실무자료 단가산출 출처 풀이 · 조항 색인(PLAN 785-1 · 요구 642 · 설계 642-A ④-1) — -엑셀 「단가산출」 블록 품셈 번호 → M01 로직 후보(색인 · 순위 아님). +"""M02 일위대가 실무자료 단가산출 출처 풀이(PLAN 785-1 · 809-1 · 요구 642 · 702) — 엑셀 「단가산출」 블록 품셈 번호 풀이. -출처 풀이(`settle` · 781-1 · 옛 `M02_Hopyo_Basis` 에서 옮김) = 건설품셈(부문 모름) 번호 → -뜻 낱말로 부문 · 임도품셈(옛) → 대응표 현행 산림 번호 · -기초 없음 표지(`no_basis` · 781-2) = 출처 조항에 우리 로직 0 · 옛 번호. -조항 색인(`index`) = M01 로직 {(구분, 원문번호): [키]}. -블록 근거(`basis`) = 머리 줄(첫 절 앞) 출처 › 머리에 품셈 글이 없으면 몸 글 출처(절 제목 뺌) · - 절 제목 출처(「4. 기초지정[2025년 표준품셈3-4-8]」)는 그 절 후보 · - 색인: 같음 › 윗마디(엑셀 6-2 ⊃ 6-2-2 · 6-2-3) · 부문 모르는 건설품셈 = 건설 부문 모두 · - 옛 책 = 없음 · 윗마디 후보 여럿 = 일 절(`Formula.works`) 제목 ↔ 후보 이름 뜻 낱말 많이 - 겹친 것을 그 절에(안 맞는 후보 뺌) · - 결과단위 거름 = 후보 결과단위 성격(면적 · 체적 · 길이 · 무게 · 식) ≠ 블록 단위 성격이면 뺌 · - 표지 = 번호 없음(머리 · 몸에 번호 없음) › 준용(머리 글 「준용」 · 후보 참고만) › - 옛 번호 · 기초 없음(`no_basis`). -자동 확정 없음 · M01 · 원본 로직은 읽기만. +출처 풀이(`settle` · 781-1) = 건설품셈(부문 모름) 번호 → 뜻 낱말로 부문 · 임도품셈(옛) → 대응표 현행 산림 번호. +풀이 글 = 원문 패널 「엑셀 글의 품셈 정보」 카드(807-1) 몫 · 조항 → 우리 기초 판정(색인 · 블록 근거 · 기초 없음 표지)은 +809-1 에서 지움(경쟁사 로직 인정 · 요구 703). """ from __future__ import annotations @@ -25,9 +14,6 @@ from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Make as make -from M02_MasterTemplete import M02_Hopyo_Formula as formula -from M02_MasterTemplete import M02_Hopyo_ReadEst as est -from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs def _flat(value: Any) -> str: @@ -35,59 +21,6 @@ def _flat(value: Any) -> str: # ── 우리 쪽 조항 · 직종 ──────────────────────────────────────────────── -@lru_cache(maxsize=2) -def _ours(whole_id: int) -> dict[str, dict[str, Any]]: - """{로직 키: {책, 부문, 구분, 조항: {절 번호}, 직종: {이름}}} — 조항 = `sources()` 묶음 · - 직종 = 호표 인력(LB) 요소.""" - files, whole = m01.loaded() - lb = {str(row.get("키")): str(row.get("이름")) for row in whole.numbers.get("LB", {}).values()} - out = {} - for data in files.values(): - if data.get("그룹") != "로직": - continue - for row in data.get("줄") or []: - kind = str(row.get("구분") or "") - book, _, part = kind.partition(" ") - trades = { - lb[str(h.get("요소"))] - for h in row.get("호표") or [] - if isinstance(h, dict) and str(h.get("요소")) in lb - } - clauses = set(labor_inputs._NUMBER.findall(str(labor_inputs.sources(row) or ""))) - out[str(row.get("키"))] = { - "책": book, - "부문": part, - "구분": kind, - "조항": clauses, - "직종": trades, - } - return out - - -def ours() -> dict[str, dict[str, Any]]: - return _ours(id(m01.loaded()[1])) - - -def _head(number: str) -> str: - return number.rsplit("-", 1)[0] - - -def clause(theirs: list[list[str]], our: dict[str, Any]) -> tuple[float, list[str], str]: - """엑셀 인용 조항 `[[책, 번호]]` ↔ 우리 로직(`ours()` 한 줄) → (점수, 맞은 우리 조항 글, - 꼬리표) — 같은 책(부문을 알면 같은 구분 · 「(옛)」 = 없음) · 같음 1 · - 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 0.5.""" - nums = [n for book, n in theirs if book in (our["책"], our["구분"])] - same = sorted(n for n in nums if n in our["조항"]) - if same: - return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음" - kids = sorted({c for c in our["조항"] for n in nums if c.startswith(n + "-")}) - if kids: - return 0.9, [f"{our['구분']} {n}" for n in kids], "윗마디 같음" - heads = {_head(n) for n in nums} - near = sorted(n for n in our["조항"] if _head(n) in heads) - if near: - return 0.5, [f"{our['구분']} {n}" for n in near], "앞 마디 같음" - return 0.0, [], "" # ── 출처 풀이(781-1 · 781-2) — 건설 부문 · 임도 옛 번호 대응 · 기초 없음 ─────────── @@ -185,185 +118,4 @@ def settle(book: dict[str, Any]) -> dict[str, Any]: return book -def has_logic(kind: str, number: str) -> bool: - """그 구분 · 조항(윗마디 · 아랫마디 포함)에 M01 로직이 있나.""" - return _has(id(m01.loaded()[1]), kind, number) - - -@lru_cache(maxsize=512) -def _has(whole_id: int, kind: str, number: str) -> bool: - named = _named(whole_id) - parts = number.split("-") - ups = {"-".join(parts[:i]) for i in range(2, len(parts) + 1)} - return any(k == kind and (no in ups or no.startswith(number + "-")) for k, no in named) - - -def no_basis(theirs: list | None) -> dict[str, str] | None: - """출처 조항이 모두 우리 기초 밖이면 표지 `{판정, 글, 이름}` — 기초 없음(로직 0) › 옛 번호 - (「(옛)」 · 지금 원문에 없는 번호) · 로직 있는 조항 · 건설품셈(부문 모름)이 하나라도 - 있으면 None.""" - none, old = [], [] - for book, number in theirs or []: - base, _, part = book.partition(" ") - if book == "건설품셈": - return None - if book.endswith(OLD) or base not in make.BOOKS: - old.append((book.removesuffix(OLD), number)) - elif has_logic(book, number): - return None - elif (title := _sections(base).get((part, number))) is None: - old.append((book, number)) - else: - none.append((book, number, title)) - if none: - book, number, title = none[0] - where = f"{book.replace('품셈', '')} {number} {title}" - return { - "판정": "기초 없음", - "글": f"근거 조항 우리 기초에 없음 — {book} {number} {title}", - "이름": f"기초 없음 — {where}", - } - if old: - return { - "판정": "옛 번호", - "글": f"옛 번호 — 대응 미확인 — {old[0][0]} {old[0][1]}", - "이름": "옛 번호 — 근거 못 씀", - } - return None - - # ── 조항 색인 · 블록 근거(785-1) ────────────────────────────────────────── -_NATURE = { - **dict.fromkeys(("㎡", "m2", "ha"), "면적"), - **dict.fromkeys(("㎥", "m3", "공㎥"), "체적"), - **dict.fromkeys(("m", "km"), "길이"), - **dict.fromkeys(("ton", "t", "톤", "kg", "㎏"), "무게"), - "식": "식", -} - - -def nature(unit: Any) -> str | None: - """단위 → 성격(면적 · 체적 · 길이 · 무게 · 식) · 모름 = None · - 「원/10㎡」 = ㎡ · 여러 단위 = 모름.""" - text = str(unit or "").strip().removeprefix("원/") - if any(c in text for c in "·(또"): - return None - return _NATURE.get(re.sub(r"^[\d,]+", "", text).strip().lower()) - - -@lru_cache(maxsize=2) -def _index(whole_id: int) -> dict[tuple[str, str], list[dict[str, Any]]]: - out: dict[tuple[str, str], list[dict[str, Any]]] = {} - for data in m01.loaded()[0].values(): - if data.get("그룹") != "로직": - continue - for row in data.get("줄") or []: - key = (str(row.get("구분") or ""), str(row.get("원문번호") or "").split(" ")[0]) - one = {"키": str(row.get("키")), "이름": str(row.get("이름") or "")} - one |= {"구분": key[0], "원문번호": key[1], "결과단위": row.get("결과단위")} - out.setdefault(key, []).append(one) - return out - - -def index() -> dict[tuple[str, str], list[str]]: - """{(구분, 원문번호): [로직 키]} — M01 로직.""" - return {k: [o["키"] for o in v] for k, v in _index(id(m01.loaded()[1])).items()} - - -def lookup(book: str, number: str) -> list[dict[str, Any]]: - """[책, 번호] → 로직 줄 + 맞음 같음 · 윗마디 — 같은 것이 있으면 같음만.""" - table = _index(id(m01.loaded()[1])) - if book == "건설품셈": - kinds = {k for k, _ in table if k.startswith("건설품셈 ")} - elif book.endswith(OLD): - return [] - else: - kinds = {book} - same = [o for k in kinds for o in table.get((k, number), [])] - if same: - return [{**o, "맞음": "같음", "출처": [book, number]} for o in same] - return [ - {**o, "맞음": "윗마디", "출처": [book, number]} - for (k, no), rows in table.items() - if k in kinds and no.startswith(number + "-") - for o in rows - ] - - -def _paths(nodes: list[dict[str, Any]], up: str = "") -> list[tuple[str, dict[str, Any]]]: - out = [] - for one in nodes: - path = f"{up}.{one['번']}" if up else one["번"] - out += [(path, one), *_paths(one["아래"], path)] - return out - - -def _sources(lines: list[str], name: Any, whole: str) -> list[list[str]]: - raw: list[list[str]] = [] - for line in lines: - raw += [one for one in est.sources(line) if one not in raw] - return resolve(raw, name, whole) - - -def _split(found: list[dict[str, Any]], nodes: list[dict[str, Any]]) -> tuple[list, list]: - """윗마디 후보 여럿 → 일 절 제목과 뜻 낱말 가장 많이 겹친 후보를 그 절에 · 남은 후보는 뺌.""" - works = {id(w) for w in formula.works(nodes)} - picked: dict[str, str] = {} - for path, one in _paths(nodes): - if id(one) not in works: - continue - mine = _grams(_flat(one["제목"])) - score = {o["키"]: len(mine & _grams(_flat(o["이름"]))) for o in found} - best = max(score.values(), default=0) - for key, n in score.items(): - if best and n == best: - picked.setdefault(key, path) - if not picked: - return found, [] - keep = [{**o, "절": picked[o["키"]]} for o in found if o["키"] in picked] - drop = [{"키": o["키"], "까닭": "절 이름 안 맞음"} for o in found if o["키"] not in picked] - return keep, drop - - -def basis(block: dict[str, Any]) -> dict[str, Any]: - """Book 단산 블록 → {표지, 글, 출처, 후보 [{키, 이름, 구분, 원문번호, 결과단위, 맞음, - 출처, 절}], 뺌 [{키, 까닭}]}.""" - text = [str(t) for t in block.get("글") or []] - name, whole = block.get("이름"), " ".join(text) - nodes = block.get("절") or formula.tree(text, block.get("글행")) - top = [] - for line in text: - if formula._title(line): - break - top.append(line) - head = " ".join(top) - used = _sources(top, name, whole) - if not used and not re.search(r"품[셈줌]", head): - used = _sources([t for t in text if not formula._title(t)], name, whole) - found = [{**o, "절": None} for book, no in used for o in lookup(book, no)] - out: dict[str, Any] = {"표지": None, "글": "", "출처": list(used), "후보": [], "뺌": []} - if len(found) > 1 and any(o["맞음"] == "윗마디" for o in found): - found, out["뺌"] = _split(found, nodes) - for path, one in _paths(nodes): - for book, no in _sources([one["제목"]], one["제목"], whole): - out["출처"] += [[book, no]] if [book, no] not in out["출처"] else [] - found += [{**o, "절": path} for o in lookup(book, no)] - if not out["출처"]: - out["표지"], out["글"] = "번호 없음", "품셈 번호 없음 — 이름 후보로" - elif "준용" in head: - out["표지"], out["글"] = "준용", "준용 — 후보 참고만" - elif mark := no_basis(out["출처"]): - out["표지"], out["글"] = mark["판정"], mark["글"] - mine = nature(block.get("단위")) - for o in found: - theirs = nature(o["결과단위"]) - if any(o["키"] == c["키"] for c in out["후보"]): - continue - if mine and theirs and mine != theirs: - why = f"결과단위 다름 — {o['결과단위']} ≠ {block.get('단위')}" - out["뺌"].append({"키": o["키"], "까닭": why}) - else: - out["후보"].append(o) - if out["표지"] is None and not out["후보"]: - out["표지"], out["글"] = "기초 없음", "결과단위 맞는 우리 기초 없음" - return out diff --git a/M02_MasterTemplete/M02_Hopyo_Fixed.py b/M02_MasterTemplete/M02_Hopyo_Fixed.py index 90c589a1f..a11ed1ec0 100644 --- a/M02_MasterTemplete/M02_Hopyo_Fixed.py +++ b/M02_MasterTemplete/M02_Hopyo_Fixed.py @@ -116,8 +116,8 @@ class Linker: out["자재"] = self._same(self._mats(line), price["재료비"]) return out - def _machine(self, line: dict[str, Any]) -> str | None: - """기계 줄 → EQ 하나 — 이름 · 규격 글 같음 › 갈래 + 규격 수 · 괄호 낱말(하나일 때만).""" + def _eqs(self, line: dict[str, Any]) -> list[str]: + """기계 줄 → EQ 후보 — 이름 · 규격 글 같음 › 갈래 + 규격 수 · 괄호 낱말.""" name, spec = mm_plain(line.get("이름")), mm_plain(line.get("규격")) same = [c for c, _, _, n, s in self.machines if n == name and s == spec] if len(same) != 1: @@ -125,6 +125,20 @@ class Linker: kinds, nums = mtypes(text), set(specnums(text)) same = [c for c, t, s, _, _ in self.machines if kinds & t and nums and nums == s] same = elem.narrow(text, [(c, self.eq_names.get(c)) for c in same]) + return same + + def candidates(self, line: dict[str, Any]) -> list[str]: + """못 이은 줄의 이음 후보 키(809-1 [수정] 보기 「후보 N」) — 노임 같은 직종 · 기계 EQ · 같은 자재.""" + kind = kind_of(line) + if kind == "노임": + return [code for code, _ in self.labor.get(shares.trade(line.get("이름")) or "", [])] + if kind == "기계": + return self._eqs(line) + return [code for code, _ in self._mats(line)] if kind in hm.MATERIAL_KINDS else [] + + def _machine(self, line: dict[str, Any]) -> str | None: + """기계 줄 → EQ 하나(하나일 때만).""" + same = self._eqs(line) if len(same) == 1: return same[0] self.near += bool(same) diff --git a/M02_MasterTemplete/M02_Hopyo_Judge.py b/M02_MasterTemplete/M02_Hopyo_Judge.py deleted file mode 100644 index 7cf84a2dd..000000000 --- a/M02_MasterTemplete/M02_Hopyo_Judge.py +++ /dev/null @@ -1,364 +0,0 @@ -"""M02 엑셀 불러오기 판정 · 검산(PLAN 785-5 · 요구 642 · 설계 642-A 2-6 ⑤ · 2-9) — 서버 단독 · 읽기만. - -판정 = 1차 줄 판(`M02_Hopyo_Row.rows`) + 단산 줄 2차 블록 풀이(`Clause.basis` 후보 → `Solve.parts` → -`Solve.block`) · 목록(`listing`) · 호표 하나(`hopyo`) · 블록 하나(`block`). -블록 풀이 = 파생 저장본(HP 폴더 `_derived/hopyo_judge_{키}` · 입력 지문 = HP + M01 파일 내용 + 풀이 판 · -지문 다를 때만 다시 셈 · GET 은 안 지움 · 올림 뒤 한 번 뒤에서 `ensure` · 셈 중 = `셈중` · 판정 None) · -블록 판정 읽기 = `judged`(Marks 칠) · [끝내기] = `forget`. -줄 판 줄에 엑셀 칸(이름 · 규격 · 단위 · 단가) · 블록에 엑셀 금액 · 절에 몫(785-6 화면이 읽음). -검산(`check`) = 고른 것마다 — 기초 = 입력값(`inputs` · 엔진이 쓴 칸 모두 · 지역 · 자재지역 · 조종원직종 -비움) · 입력필요 · 엑셀 단위 줄과 품 대조(`Solve.pair`) · 금액(엔진 한 번 · 입력 필요면 셈 안 함) / -그 밖 = 「확인」 · 수량 = 엑셀 × 몫 × 배(`Row.quantity` · 사용자 배 우선). -저장 없음 · 지어 넣은 값 없음(입력 필요 칸은 비워 둠 · 안 쓰는 칸 = 기초 기본값). -""" - -from __future__ import annotations - -import shutil -import threading -from collections import Counter -from decimal import Decimal -from pathlib import Path -from typing import Any - -from common_util import common_util_derived_store as derived -from M02_MasterTemplete import M02_Hopyo_Bind as hb -from M02_MasterTemplete import M02_Hopyo_Build as hbuild -from M02_MasterTemplete import M02_Hopyo_Clause as clause -from M02_MasterTemplete import M02_Hopyo_Ledger as ledger -from M02_MasterTemplete import M02_Hopyo_Row as hrow -from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun -from M02_MasterTemplete import M02_Hopyo_Solve as solve -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 -from M02_MasterTemplete import M02_MasterTemplete_Store as store - -mf, ma = hb.mf, hb.ma -EXCEL = ("이름", "규격", "단위", "단가") -CHECKED = "확인" -SUGGEST = "제안 있음" # 1차 줄 판정(`M02_Hopyo_Row`) -KIND = "hopyo_judge_" # 파생 저장본 종류 = 이것 + HP 키(HP 폴더 `_derived/`) -VERSION = "785-5.1" # 풀이 꼴이 바뀌면 올림 -_LOCK, _RUNNING = threading.Lock(), set() - - -def master() -> mf.Master: - return hb.m01.cm.master() - - -def _bad(text: str, status: int = 400) -> store.StoreError: - return store.StoreError(status, text) - - -def _blocks(book: dict[str, Any]) -> dict[int, dict[str, Any]]: - return {int(b["번"]): b for b in book.get("단산") or [] if b.get("번") is not None} - - -def _unit(book: dict[str, Any], no: int) -> dict[str, Any]: - got = next((h for h in book.get("호표") or [] if int(h["번"]) == int(no)), None) - if got is None: - raise _bad(f"없는 호표 「제{no}호표」", 404) - return got - - -def _made(folder: Path, key: str, to: Path) -> tuple[dict[str, str], dict[str, str]]: - """(만든 SC {본뜬 키: SC}, 만든 SK {본뜬 키: SK}) — 만들 층 · 이 파일 층 것만.""" - source = refs.where(folder) - return hbuild.confirmed(to, source), sangeun.made(to, key, source) - - -# ── 기초 입력값 ─────────────────────────────────────────────────────── -def inputs(m: mf.Master, code: str, given: dict[str, Any] | None) -> tuple[dict, list[str]]: - """(저장 입력값, 입력필요) — 고른 값(그 기초 칸 · 빈 값 뺌) + 안 쓰는 칸 = 기초 기본값 · - 쓰는데 빈 칸 = 입력필요(비워 둠) · 지역 · 자재지역 · 조종원직종 = 비움(프로젝트가 정함).""" - row = m.logic(code) - names = [s["이름"] for s in hb.specs_of(row)] - have = { - k: v - for k, v in (given or {}).items() - if k in names and k not in hb.SKIP and v not in (None, "") - } - need = [n for n in names if n in hb._needed(m, code, row, have) and n not in hb.SKIP] - base = ma.defaults(m, row) - idle = [n for n in names if n not in have and n not in need and n not in hb.SKIP] - full = {n: hb.plain(base[n]) for n in idle if base.get(n) is not None} - return {**full, **{k: hb.plain(v) for k, v in have.items()}}, need - - -def money(m: mf.Master, code: str, given: dict[str, Any]) -> dict[str, Any]: - """기초 한 줄 셈(일위대가 로직 줄과 같은 길 `Labor_Doc.run_line` · 끝수 · 비운 지역 칸 = 자동값 · - 참고 셈만) → {노무비, 재료비, 경비, 계} · 못 셈 = {계 None, 까닭}.""" - got = labor.run_line(m, code, given) - if got.get("결과") is not None: - return {"결과": hb.plain(got["결과"])} - if got.get("기준") is None: - return {"계": None, "까닭": got.get("까닭")} - return {k: hb.plain(v) for k, v in got["기준"].items()} - - -# ── 판정 ───────────────────────────────────────────────────────────── -def _row(line: dict[str, Any], one: dict[str, Any]) -> dict[str, Any]: - return {**one, **{k: line.get(k) for k in EXCEL}} - - -def _solve(m: mf.Master, blk: dict, line: dict | None, same: list[dict]) -> dict[str, Any]: - """블록 풀이 — 조항 색인 후보 → 절 → 검산 · 판정 + 근거 · 엑셀 금액 · 절 몫.""" - basis = clause.basis(blk) - parts = solve.parts(blk, basis["후보"]) - got = solve.block(m, blk, parts, {"줄": line, "같은줄": same}) - for sec, part in zip(got["절"], parts, strict=True): - share = part.get("몫") - sec["몫"] = share.get("값") if isinstance(share, dict) else share - sec["번"] = part.get("번") - got["근거"] = {k: basis[k] for k in ("표지", "글", "출처", "후보", "뺌")} - got["금액"] = blk.get("금액") - return got - - -# ── 블록 풀이 파생 저장본(올림 뒤 한 번 · 뒤에서) ───────────────────────── -def _stamp(key: str, book: dict[str, Any]) -> str: - """입력 지문 — HP(지문 · 판 · 올린때) + M01 첫 층 파일 내용 + 풀이 판.""" - folder = Path(hb.m01.FOLDER) - files = { - p.name: derived.file_digest(p) - for p in sorted(folder.glob("*.json")) - if not p.name.startswith("_") - } - head = (VERSION, key, book.get("지문"), book.get("판"), book.get("올린때")) - return derived.derived_key(*head, files) - - -def _caller(book: dict[str, Any], no: int) -> tuple[dict | None, list[dict]]: - """블록을 부르는 첫 줄 · 그 호표 다른 줄(B1 · B6) — 일위대가 밖 = (None, []).""" - line = sangeun.calls(book)[1].get(no) - if not line: - return None, [] - unit = _unit(book, line["_호표"]) - same = [x for x in unit.get("줄") or [] if x.get("차례") != line.get("차례")] - return {k: v for k, v in line.items() if k != "_호표"}, same - - -def solved(folder: Path, key: str) -> dict[str, Any] | None: - """저장본 `{번 글: 풀이}` — 지문 다르거나 없으면 None(GET 은 안 지움).""" - book = hopyo_store.read(folder, key) - got = derived.read_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book)) - return got["value"] if got else None - - -def solve_all(folder: Path, key: str) -> dict[str, Any]: - """블록 모두 풀어 저장본으로(부르는 첫 줄 기준).""" - book = hopyo_store.read(folder, key) - m, out = master(), {} - for no, blk in sorted(_blocks(book).items()): - out[str(no)] = _solve(m, blk, *_caller(book, no)) - root = hopyo_store._dir(folder) - derived.write_derived(root, KIND + key, _stamp(key, book), out, keep=1) - return out - - -def _work(folder: Path, key: str) -> None: - try: - solve_all(folder, key) - except Exception: # noqa: BLE001 — 뒤 셈 실패 = 다음 읽기에서 다시(파생 저장본) - pass - finally: - with _LOCK: - _RUNNING.discard(f"{folder}|{key}") - - -def _start(folder: Path, key: str) -> None: - tag = f"{folder}|{key}" - with _LOCK: - if tag in _RUNNING: - return - _RUNNING.add(tag) - threading.Thread(target=_work, args=(Path(folder), key), daemon=True).start() - - -def ensure(folder: Path, key: str) -> bool: - """저장본 있음 = 참 · 없으면 뒤 셈 시작(화면 안 막음 · 같은 파일 한 번) = 거짓.""" - if solved(folder, key) is not None: - return True - _start(folder, key) - return False - - -def judged(folder: Path, key: str) -> dict[int, dict[str, Any]] | None: - """블록 판정 `{번: {판정, 고를수}}` — 셈 중 = None(뒤 셈 시작 · Marks 칠이 부름).""" - got = solved(folder, key) - if got is None: - _start(folder, key) - return None - return {int(no): {"판정": v["판정"], "고를수": v["고를수"]} for no, v in got.items()} - - -def forget(folder: Path, key: str) -> None: - """[끝내기] — 그 HP 저장본 지움.""" - shutil.rmtree(derived.derived_dir(hopyo_store._dir(folder), KIND + key), ignore_errors=True) - - -def listing(folder: Path, key: str, to: Path) -> dict[str, Any]: - """호표 목록(1차 판정 수 · 만든 SC) · 블록 목록(부름 · 표지 · 판정 · 고를수 · 만든 SK) · - `셈중` = 블록 풀이 뒤 셈 중(블록 판정 · 고를수 None).""" - book = hopyo_store.read(folder, key) - rows = hrow.rows(book, folder, ledger.doc_id(folder, key)) - sc, sk = _made(folder, key, to) - units = [] - for h in book.get("호표") or []: - no = int(h["번"]) - count = Counter(r["판정"] for r in rows.get(h["번"]) or []) - head = {"번": no, **{k: h.get(k) for k in ("이름", "규격", "단위", "단가")}} - units.append( - {**head, "줄수": len(h.get("줄") or []), "판정": dict(count), - "만든": sc.get(hbuild.mark(key, no))} - ) # fmt: skip - call, done = sangeun.callers(book), judged(folder, key) - blocks = [] - for no, b in sorted(_blocks(book).items()): - got = (done or {}).get(no) or {"판정": None, "고를수": None} - blocks.append( - {"번": no, "이름": b.get("이름"), "단위": b.get("단위"), "금액": b.get("금액"), - "부름": call.get(no, []), "표지": clause.basis(b)["표지"], **got, - "만든": sk.get(sangeun.mark(key, no))} - ) # fmt: skip - out = {"키": key, "파일": book.get("파일"), "셈중": done is None} - return {**out, "호표": units, "블록": blocks} - - -def view(folder: Path, key: str, to: Path) -> dict[str, Any]: - """엑셀 보기 머리 · 목록(옛 `Match.view` 꼴) — 파일 줄 + 검산 · 호표마다 `맞춤` [제안 있음, 줄 수] · - `줄상태 [[차례, 1차 판정, 제안 있음]]` · `만든` SC · `셈` = 블록 풀이 뒤 셈(`끝` 거짓 = 셈 중).""" - book = hopyo_store.read(folder, key) - rows = hrow.rows(book, folder, ledger.doc_id(folder, key)) - sc, _ = _made(folder, key, to) - units = [] - for h in book.get("호표") or []: - no = int(h["번"]) - state = [ - [i, r["판정"], r["판정"] == SUGGEST] for i, r in enumerate(rows.get(h["번"]) or []) - ] - head = {k: h.get(k) for k in ("번", "이름", "규격", "단위", "금액", "행") if k in h} - units.append( - {**head, "맞춤": [sum(x[2] for x in state), len(state)], "줄상태": state, - "만든": sc.get(hbuild.mark(key, no))} - ) # fmt: skip - ready = [sum(h["맞춤"][0] for h in units), sum(h["맞춤"][1] for h in units)] - calc = {"끝": judged(folder, key) is not None, "한": 0, "모두": None, "까닭": "블록 풀이"} - head = {**hopyo_store.brief(book), "검산": book.get("검산"), "셈": calc} - return {**head, "호표": units, "맞춤": ready} - - -def hopyo(folder: Path, key: str, no: int, to: Path) -> dict[str, Any]: - """호표 하나 — 줄 판(+ 엑셀 칸) · 단산 줄 `풀이`(부르는 첫 줄이면 저장본).""" - book = hopyo_store.read(folder, key) - unit = _unit(book, no) - rows = hrow.rows({**book, "호표": [unit]}, folder, ledger.doc_id(folder, key))[unit["번"]] - lines, blocks, m = unit.get("줄") or [], _blocks(book), None - cache, first = solved(folder, key) or {}, sangeun.calls(book)[1] - out = [] - for r, line in zip(rows, lines, strict=True): - r = _row(line, r) - sub = r.get("번") - if "단가산출" in r["갈래"] and sub is not None and int(sub) in blocks: - head = first.get(int(sub)) or {} - mine = head.get("_호표") == unit["번"] and head.get("차례") == line.get("차례") - if mine and str(sub) in cache: - r["풀이"] = cache[str(sub)] - else: - m = m or master() - others = [x for x in lines if x is not line] - r["풀이"] = _solve(m, blocks[int(sub)], line, others) - out.append(r) - sc, _ = _made(folder, key, to) - head = {k: unit.get(k) for k in ("번", "이름", "규격", "단위", "단가")} - return {**head, "만든": sc.get(hbuild.mark(key, no)), "줄": out} - - -def block(folder: Path, key: str, no: int, to: Path) -> dict[str, Any]: - """단산 블록 하나 — 풀이(저장본 › 셈) + 이름 · 단위 · 부름 · 만든(부르는 첫 줄로 B1 · B6).""" - book = hopyo_store.read(folder, key) - blocks = _blocks(book) - if no not in blocks: - raise _bad(f"없는 블록 「단산{no}」", 404) - line, same = _caller(book, no) - got = (solved(folder, key) or {}).get(str(no)) or _solve(master(), blocks[no], line, same) - _, sk = _made(folder, key, to) - blk = blocks[no] - head = {"이름": blk.get("이름"), "단위": blk.get("단위") or (line or {}).get("단위")} - head |= {"부름": sangeun.callers(book).get(no, []), "만든": sk.get(sangeun.mark(key, no))} - return {**got, **head} - - -# ── 검산 ───────────────────────────────────────────────────────────── -def _want(m: mf.Master, pick: dict[str, Any]) -> Any: - """고른 것 단위 — 기초 = 결과단위(「원/㎥」 → ㎥ · 「원/자재 단위」 → 자재) · 그 밖 = 고른 것 단위.""" - if pick.get("갈래") == "기초" and pick.get("키"): - unit = str(m.logic(pick["키"]).get("결과단위") or "") - unit = unit.split("/", 1)[1] if unit.startswith("원/") else unit - return unit.removesuffix(" 단위").strip() # 「원/자재 단위」 = 줄 단위 그대로 - return pick.get("단위") - - -def quantity(m: mf.Master, line: dict, pick: dict) -> dict[str, Any]: - """줄 수량 `{엑셀, 몫, 배, 갈래, 우리}` — 사용자 배(단위 환산 값)가 있으면 그것.""" - got = hrow.quantity(line, _want(m, pick), pick.get("몫")) - times = pick.get("배") - if times is not None and line.get("수량") is not None: - ours = Decimal(str(line["수량"])) * Decimal(str(pick.get("몫") or 1)) - got |= {"배": times, "갈래": "사용자", "우리": hrow._plain(ours * Decimal(str(times)))} - return got - - -def one_pick(m: mf.Master, pick: dict[str, Any], items: list[dict], share: Any) -> dict: - """고른 것 하나 검산.""" - out = {"갈래": pick["갈래"], "키": pick.get("키")} - if pick["갈래"] != "기초": - return {**out, "판정": CHECKED} - code = str(pick.get("키") or "") - try: - m.logic(code) - except Exception as e: # noqa: BLE001 — 없는 키 - raise _bad(f"없는 기초 「{code}」") from e - full, need = inputs(m, code, pick.get("입력값")) - rows = solve.pair(hb.quantities(m, code, full), solve._usable(items), share) if items else [] - if need: - verdict = solve.NEED - elif any(r["판정"] == solve.DIFF for r in rows): - verdict = solve.DIFF - else: - verdict = solve.SAME if any(r["판정"] == solve.SAME for r in rows) else CHECKED - got = money(m, code, full) if not need else {"계": None, "까닭": f"입력 필요 {len(need)}"} - return {**out, "입력값": full, "입력필요": need, "줄": rows, "판정": verdict, "금액": got} - - -def check( - folder: Path, key: str, hno: int | None, at: int | None, bno: int | None, picks: list[dict] -) -> dict[str, Any]: - """`{고른 [...], 수량}` — 호표 줄(`hno` · `at`) · 블록(`bno` · 줄이 단산이면 그 블록) · 고른 기초는 - 맡은 절(`Solve.parts`) 단위 줄 · 몫과 대조.""" - book = hopyo_store.read(folder, key) - blocks, line = _blocks(book), None - if hno is not None: - lines = _unit(book, hno).get("줄") or [] - if at is None or not 0 <= at < len(lines): - raise _bad(f"없는 줄 「제{hno}호표 {at}」") - line = lines[at] - ref = line.get("참조") or {} - if bno is None and ref.get("종류") == "단산" and ref.get("번") is not None: - bno = int(ref["번"]) - if bno is not None and bno not in blocks: - raise _bad(f"없는 블록 「단산{bno}」", 404) - blk = blocks[bno] if bno is not None else {} - parts = solve.parts(blk, clause.basis(blk)["후보"]) if blk else [] - m = master() - out = [] - for i, pick in enumerate(picks): - part = next((x for x in parts if pick.get("키") in x["후보"]), None) or {} - items = part.get("항목") # 그 기초가 맡은 절 단위 줄(없으면 블록 통째) - items = (blk.get("항목") or []) if items is None else items - share = part.get("몫") - share = pick.get("몫") or (share.get("값") if isinstance(share, dict) else share) or 1 - out.append({"차례": i, **one_pick(m, pick, items, share)}) - qty = quantity(m, line, picks[0]) if line is not None and picks else None - return {"고른": out, "수량": qty} diff --git a/M02_MasterTemplete/M02_Hopyo_Make.py b/M02_MasterTemplete/M02_Hopyo_Make.py index 8becc8fef..19c2c35dc 100644 --- a/M02_MasterTemplete/M02_Hopyo_Make.py +++ b/M02_MasterTemplete/M02_Hopyo_Make.py @@ -1,60 +1,23 @@ -"""M02 엑셀 불러오기 만들기(PLAN 785-5 · 요구 642 · 설계 642-A 2-7 ⑥) — 확인한 것 → 우리 SC · SK · 장부. +"""M02 일위대가 실무자료 → 우리 SC 줄 몸(PLAN 785-5 · 809-1) — `M02_Hopyo_Build.Builder` 가 줄 차례대로 부름. -옮기기 몸 = `{호표 {번: {차례: [고른 것]}}, 블록 {번: [고른 것]}}` · 고른 것 = 785-3 꼴 -`{갈래 기초|단가산출|일위대가|인력|재료|유가|기계|율|고정, 키, 입력값, 몫, 배?, 비목?, 기준?, 별도계상?, 층?, -주인?, 항목?}` · 서버가 다시 셈(브라우저 금액 · 품 안 받음 · 배 = `Row.factor` · 사용자 배 우선 · -입력값 = 엔진이 쓴 칸 모두 `Judge.inputs`) · 「입력 필요」 · 배 못 정함 · 없는 키 = 그 줄(블록)만 `막힘`. -SC(`M02_Hopyo_Build.Builder` 가 줄 차례 · 확인 안 한 줄 = 모두 고정 길 그대로) — 한 엑셀 줄 = 고른 것마다 -우리 줄(같은 별칭 = 엑셀 품명 · 줄 `본뜬 {호표, 차례, 행}`) · 수량 {손 = 엑셀 수량 × 몫 × 배} · - 기초 = 로직 줄(같은 로직 둘째 = 한 줄 SC 사본) · 재료 · 유가 = 자재 목록(별도계상) · 인력 · 기계 · 유가 이음 · - 별도계상 줄(`고정` + 별도계상 · 786-1) = 단가 0 고정 줄(합 밖 · 메모 「별도계상 · 품목은 프로젝트에서」) · - 율 = 고정 줄 `비율 {기준, 비목}` · 고정 = 엑셀 단가 고정 줄 · 단가산출 · 일위대가 = 참조 - (`단산N` = 같은 옮기기 블록 SK · `제N호표` = 같은 HP 호표 SC — 하위 먼저 · 확인 안 된 하위 = 모두 고정). -SK(블록) = 바탕 없음 + 더한줄 — 기초 `{종류 로직, 입력값, 수량 = 몫}` · 참조 · 단위 줄(`항목`)에 이은 - 인력 · 기계 · 재료 · 유가 · 율 = `SkUnit.adds` · 고정 = 엑셀 금액 · 쓰기 = `Sangeun.Maker.write`. -같은 내용 재사용(`Kids.save` · `Sangeun.Maker.write`) · 장부 = 고른 것 꼴 짝(`Ledger.check_pair`) · -엑셀 대조 = `Compare.compare`(참고 · 비목마다 1 원). -`Lines` = 확인한 줄 몸(옛 대상 꼴 `put` 은 옛 길 몫 · 785-7 지움). +`Lines.put` = 대상 하나(`{로직|자재|호표|전국|이음|참조, 입력값, 배, 몫}` · 붙여넣기 · 구조물 조합으로 길) → +우리 줄 · 자재(같은 로직 둘째 = 한 줄 SC 사본 · 재료 = 자재 목록 · 이음 = 고정 줄). +확인 안 한 줄 = Builder 가 고정 길(`Fixed.line_of`)로. 옛 판정 고른 것 · [옮기기](785-5 `Move`)는 809-1 에서 지움 — +불러오기 옮기기 = [내 일위대가로](`Build.build_all only` · 경쟁사 로직 인정 · 요구 703). """ from __future__ import annotations -import re from decimal import Decimal -from pathlib import Path from typing import Any from M02_MasterTemplete import M02_Hopyo_Build as hbuild from M02_MasterTemplete import M02_Hopyo_Fixed as hfix -from M02_MasterTemplete import M02_Hopyo_Judge as judge from M02_MasterTemplete import M02_Hopyo_Kids as hkids -from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Material as hmat from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun -from M02_MasterTemplete import M02_Hopyo_SkUnit as skunit -from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store -from M02_MasterTemplete import M02_Hopyo_Units as units from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs -from M02_MasterTemplete import M02_Library_Same as lsame -from M02_MasterTemplete import M02_MasterTemplete_Store as store -from M02_MasterTemplete import M02_Sangeun_Doc as sd -from M02_MasterTemplete.M02_Hopyo_Compare import compare - -_SUB = {"단가산출": re.compile(r"^단산(\d+)$"), "일위대가": re.compile(r"^제(\d+)호표$")} -LINK = {"인력": "노임", "기계": "기계", "유가": "유가"} -OLD_PICK = {"인력": "노임", "기계": "기계", "재료": "자재", "유가": "자재"} -NEED = "입력 필요" -APART_MEMO = "별도계상 · 품목은 프로젝트에서" -COVER = "이미 옮긴 것이 있음 — 덮으려면 [덮어 옮기기](덮음)" - - -class Stuck(Exception): - """그 줄(블록)만 못 옮김 — `{까닭, 칸?}`.""" - - def __init__(self, why: str, cells: list[str] | None = None) -> None: - super().__init__(why) - self.why, self.cells = why, cells or [] def _num(value: Decimal) -> int | float: @@ -157,253 +120,3 @@ class Lines: } ref = {"키": "SC-미리"} if b.dry else hkids.save(wrap, b.same)[0] # 미리 보기 = 쓰기 없음 self.lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) - - def apart(self, line: dict, alias: str | None, mark: dict) -> None: - """별도계상 줄(786-1) = 합 밖 고정 줄 — 단가 0(셈에 안 듦) · 품명 · 규격 · 단위 · 수량 그대로 · - 품목은 프로젝트에 복사할 때 정함(메모 표지).""" - zero = {**line, "단가": {}, "금액": {}} - self.put(zero, {"이음": dict.fromkeys(hfix.fixed.LINKS)}, alias, None, mark) - self.lines[-1]["메모"] = f"{APART_MEMO} · {self.lines[-1].get('메모') or ''}".strip(" ·") - - def picks(self, at: int, line: dict, picks: list[dict]) -> None: - """고른 것(검사 · 다시 셈 끝난 것) → 그 엑셀 줄의 우리 줄 · 자재.""" - alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None - mark = {"호표": self.b.no, "차례": at, "행": line.get("행")} - for p in picks: - kind, code = p["갈래"], p.get("키") - one = {"배": p["배"], "몫": p.get("몫")} - qty = p["수량"] - if kind == "기초": - self.put(line, {**one, "로직": code, "입력값": p["입력값"]}, alias, qty, mark) - elif kind in ("재료", "유가") and not p.get("이음"): - self.put( - line, {**one, "자재": code, "별도계상": p.get("별도계상")}, alias, qty, mark - ) - elif kind in ("단가산출", "일위대가"): - self.put(line, {**one, "참조": p["참조"]}, alias, qty, mark) - elif kind == "율": - self.put(line, {"이음": {"비율": p["비목"]}}, alias, None, mark) - self.lines[-1]["고정"]["비율"] = {"기준": p["기준"], "비목": p["비목"]} - elif kind == "고정" and p.get("별도계상"): - self.apart(line, alias, mark) - elif kind == "고정": - self.put(line, {"이음": dict.fromkeys(hfix.fixed.LINKS)}, alias, None, mark) - else: - self.put(line, {"이음": {LINK[kind]: code}}, alias, None, mark) - - -def ledger_pick(p: dict[str, Any]) -> dict[str, Any]: - """장부 짝(고른 것 꼴 · `Ledger.check_pair`) — 참조 = 만든 SK · SC 키.""" - keep = ("갈래", "키", "입력값", "배", "몫", "비목", "기준", "별도계상") - out = {k: p[k] for k in keep if p.get(k) is not None} - return out | ({"키": p["참조"]["키"]} if "참조" in p else {}) - - -# ── 고른 것 검사 · 다시 셈 ────────────────────────────────────────────── -class Move: - """한 번 옮기기 — 파일 · M01 · 만든 SC · SK 를 한 번 읽음(쓰기 = `store.root()`).""" - - def __init__(self, folder: Path, source: Path, key: str, who: Any) -> None: - self.folder, self.source, self.key, self.who = Path(folder), Path(source), key, who - self.hp = hopyo_store.read(source, key) - self.units = {int(h["번"]): h for h in self.hp.get("호표") or []} - self.m = judge.master() - self.known = ledger._known() - self.sk = sangeun.Maker(source, key, who) - self.same = lsame.Same() - self.out: dict[str, Any] = {"만듦": [], "단가산출": [], "막힘": [], "하위": []} - self.out["장부"] = {"씀": 0} - - def _ref(self, p: dict) -> dict[str, Any]: - """단가산출 · 일위대가 키 → 참조 칸(같은 HP 블록 · 호표 = 만든 것) · 없으면 막힘.""" - code, kind = str(p.get("키") or ""), p["갈래"] - hit = _SUB[kind].match(code) - here = refs.here() - if hit: - n = int(hit.group(1)) - made = ( - self.sk.keys.get(sangeun.mark(self.key, n)) - if kind == "단가산출" - else hbuild.confirmed(self.folder, refs.where(self.source)).get( - hbuild.mark(self.key, n) - ) - ) - if not made: - raise Stuck(f"{code} 먼저 옮김") - return {"층": here[0], "주인": here[1], "키": made} - head = "SK-" if kind == "단가산출" else "SC-" - ref = {"층": p.get("층") or here[0], "주인": p.get("주인"), "키": code} - if p.get("층") is None: - ref["주인"] = here[1] - if not code.startswith(head) or refs.read_ref(ref) is None: - raise Stuck(f"없는 {kind} 「{code}」") - return ref - - def pick(self, p: dict, line: dict | None, where: str) -> dict[str, Any]: - """고른 것 하나 재검사 · 다시 셈 → `{갈래, 키, 입력값, 배, 몫, 수량, 참조?, …}`.""" - kind = p["갈래"] - got = {k: p[k] for k in ("갈래", "키", "몫", "비목", "기준", "별도계상") if k in p} - if kind == "기초": - full, need = judge.inputs(self.m, str(p.get("키") or ""), p.get("입력값")) - if need: - raise Stuck(f"{NEED} {len(need)}", need) - got["입력값"] = full - if kind in _SUB: - got["참조"] = self._ref(p) - else: - plain = {k: v for k, v in got.items() if k in ledger.PICK_KEYS} - ledger.check_pair(plain, where, self.known) - if kind in ("재료", "유가") and not got.get("키"): - raise Stuck(f"{kind} 키 없음") - if line is not None: - qty = judge.quantity(self.m, line, p) - if qty["배"] is None: - raise Stuck("단위 환산 입력") - got["배"], got["수량"] = qty["배"], Decimal(str(qty["우리"] or 0)) - return got - - # ── SC ── - def hopyo(self, no: int, rows: dict[int, list[dict]]) -> None: - unit = self.units[no] - lines = unit.get("줄") or [] - checked = {} - for at, picks in sorted(rows.items()): - if not 0 <= at < len(lines): - raise store.StoreError(400, f"없는 줄 「제{no}호표 {at}」") - where = f"제{no}호표 {at + 1}째 줄" - if not picks: - continue - try: - checked[at] = {"고른": [self.pick(p, lines[at], where) for p in picks]} - except Stuck as e: - self.out["막힘"].append({"호표": no, "차례": at, "까닭": e.why, "칸": e.cells}) - b = hbuild.Builder(self.folder, self.source, self.key, no, self.who) - b.strict, b.same = False, self.same - made = b.build(checked, []) - ref = made["참조"] - head = {"번": no, "키": ref["키"], "이름": unit.get("이름")} - head |= {k: made[k] for k in ("다시씀", "재사용")} - self.out["만듦"].append({**head, "비교": compare(ref, unit)}) - self.out["장부"]["씀"] += made["장부"]["씀"] - - def kid(self, no: int) -> None: - """확인 안 된 하위 호표 — 모두 고정(705-3 길)으로 먼저.""" - b = hbuild.Builder(self.folder, self.source, self.key, no, self.who) - b.strict, b.same = False, self.same - if b.done.get(hbuild.mark(self.key, no)): - return - made = b.build({}, []) - self.out["하위"].append({"번": no, "키": made["참조"]["키"], "새로": True}) - - # ── SK ── - def block(self, no: int, picks: list[dict]) -> None: - blk = self.sk.blocks[no] - items = blk.get("항목") or [] - us = self.sk.units() - made: list[dict] = [] - rows: list[dict] = [] - for p in picks: - got = self.pick(p, None, f"단산{no}") - name = str(p.get("이름") or "").strip() or str(blk.get("이름") or f"단산{no}") - share = got.get("몫") or 1 - at = p.get("항목") - item = items[at] if isinstance(at, int) and 0 <= at < len(items) else None - if got["갈래"] == "기초": - made.append({"종류": "로직", "로직": got["키"], "입력값": got["입력값"], - "이름": name, "수량": share}) # fmt: skip - continue - if got["갈래"] in _SUB: - qty = float(item.get("품") or 1) if item else share - made.append({"참조": got["참조"], "이름": name, "수량": qty}) - continue - if item is None: - raise Stuck(f"{got['갈래']} — 블록 단위 줄(항목) 고름") - if got["갈래"] == "고정": - parts = hfix._parts(item.get("금액")) - fx = {"이름": name, "단가": {k: float(v) for k, v in parts.items()}, "이음": {}} - made.append({"고정": fx, "이름": name, "수량": 1}) - continue - old = {"기준": got.get("기준"), "비목": got.get("비목")} - if got["갈래"] in OLD_PICK: - old = {OLD_PICK[got["갈래"]]: got["키"], "별도계상": got.get("별도계상")} - unit = skunit._picked(item, old, us)["단위"] - rows.append({"차례": at, "행": item.get("행"), "상태": units.LINKED, "단위": unit}) - extra, _ = skunit.adds(blk, rows, us, self.sk.ref_of) - doc = {"키": "SK-000000", "이름": "옮김", "단위": blk.get("단위") or "식", "바탕": None} - try: - adds = sd.clean({**doc, "더한줄": made + extra}, None, self.sk.whole)["더한줄"] - except (ValueError, store.StoreError) as e: - raise Stuck(f"더한줄 틀림 — {e}") from e - plan = {"갈래": "고름", "바탕": None, "입력값": {}, "덮개": {}, "자재표시": {}, "뺀줄": []} - plan |= {"더한줄": adds, "못옮김": [], "놓음": []} - mark_ = sangeun.mark(self.key, no) - key = self.sk.write(no, plan, self.sk.keys.get(mark_)) - self.sk.keys[mark_] = key - self.out["단가산출"].append({"번": no, "키": key, "이름": blk.get("이름")}) - - def blocks(self, sure: dict[int, list[dict]]) -> None: - """하위 단산(블록 단위 줄 하위) 먼저.""" - done: set[int] = set() - - def visit(no: int, path: tuple = ()) -> None: - if no in done or no in path: - return - for one in self.sk.blocks[no].get("항목") or []: - sub = one.get("하위") or {} - if sub.get("종류") == "단산" and int(sub.get("번") or 0) in sure: - visit(int(sub["번"]), (*path, no)) - done.add(no) - try: - self.block(no, sure[no]) - except Stuck as e: - self.out["막힘"].append({"블록": no, "까닭": e.why, "칸": e.cells}) - - for no in sorted(sure): - visit(no) - - -def move( - folder: Path, - source: Path, - key: str, - units_: dict[int, dict[int, list[dict]]], - blocks: dict[int, list[dict]], - who: Any = None, - cover: bool = False, -) -> dict[str, Any]: - """[옮기기] → `{만듦, 단가산출, 막힘, 하위, 장부}` — 블록 SK 먼저 · 호표 하위 먼저 · - 이미 옮긴 호표 · 블록(같은 HP 출처 SC · SK)은 `cover`(덮음) 때만 덮음 · 아니면 409 아무것도 안 씀.""" - mv = Move(folder, source, key, who) - bad = [f"제{n}호표" for n in units_ if n not in mv.units] - bad += [f"단산{n}" for n in blocks if n not in mv.sk.blocks] - if bad: - raise store.StoreError(404, f"없는 {bad[0]}") - done = hbuild.confirmed(mv.folder, refs.where(mv.source)) - had = { - "호표": [ - {"번": n, "키": done[k]} for n in sorted(units_) if (k := hbuild.mark(key, n)) in done - ] - } - had["블록"] = [ - {"번": n, "키": mv.sk.keys[k]} - for n in sorted(blocks) - if (k := sangeun.mark(key, n)) in mv.sk.keys - ] - if not cover and (had["호표"] or had["블록"]): - raise store.StoreError(409, {"까닭": COVER, **had}) - mv.blocks(blocks) - wanted = { - int(hit.group(1)) - for rows in units_.values() - for picks in rows.values() - for p in picks - if p.get("갈래") == "일위대가" and (hit := _SUB["일위대가"].match(str(p.get("키") or ""))) - } - order = hbuild._order(mv.units) - for no in order: # 고른 것이 부르는 확인 안 된 하위 = 모두 고정으로 먼저 - if no in wanted and no not in units_: - mv.kid(no) - for no in order: - if no in units_: - mv.hopyo(no, units_[no]) - return mv.out diff --git a/M02_MasterTemplete/M02_Hopyo_Marks.py b/M02_MasterTemplete/M02_Hopyo_Marks.py index ec0b0addf..4bf1073c4 100644 --- a/M02_MasterTemplete/M02_Hopyo_Marks.py +++ b/M02_MasterTemplete/M02_Hopyo_Marks.py @@ -1,21 +1,13 @@ """M02 불러오기 엑셀 보기 하이라이트 재료(PLAN 779-2 · 요구서 621 3장 5) — 읽기만(파일 · storage 변화 0). -블록 `[{번, 판정, 고를수, SK, 갈래, 못옮김, 부름}]` = 단가산출 만들기 결과(`Sangeun.made`) + 블록 판정을 읽기만. -- `판정` = 블록 판정(785-6 · `M02_Hopyo_Judge.judged` 파생 저장본 = 기초로 풂 · 기초+덧 · 줄 조합 · 고를 것 · - `고를수`) · 안 불리거나 셈 전이면 None +블록 `[{번, 이름, 단위, 판정, SK, 갈래, 못옮김, 부름}]` = 단가산출 만들기 결과(`Sangeun.made`)를 읽기만. +- `판정` = 불리는 블록 「가져온 식」(809-1 · 경쟁사 로직 인정 · 기초 판정 없음) · 안 불리면 None - `부름` = 부르는 호표 번 [] = 일위대가 밖(780-1) · `어긋남` · `줄` = 모든 블록(밖 포함) - `SK` = 만든 SK 키 · 없으면 None / `갈래` · `못옮김` = 그 SK 본뜬(출처별) · 없으면 None · [] -줄 상태는 목록(`M02_Hopyo_Judge.view`) 호표마다 `줄상태 [[차례, 1차 판정, 제안 있음]]` · `만든`(SC 키)로 실려 감. +줄 상태는 목록(`M02_Hopyo_View.listing`) 호표마다 `줄상태 [[차례, 표지, 이어짐]]` · `만든`(SC 키)로 실려 감. -칠 꼴(화면 779-4 가 정함 · 상태 이름 맞춤표 — 「이어짐」 = 확인 창과 한 규칙 `체크`): -| 칠 | 줄 상태 (`줄상태`) | 블록 (`판정` · `SK` · `못옮김`) | -| 초록 이어짐 | 맞음 · 별칭 · 호표(`체크`) · 체크한 다른 점 | SK 있음 + 못옮김 [] (갈래 「기초」 포함) | -| 주황 엑셀과 다름 | 다른 점 + `체크` | SK 있음 + 갈래 「SK」(차이) | -| 파랑 고를 것 | 후보 · `체크` 아닌 다른 점 · 호표(`체크` 아님) | SK 없음 + 판정 후보 · 고름 대기 | -| 빨강 못 이음 | 없음 · 노임 · (안 이은 줄 = 마법사 몫) | SK 있음 + 못옮김 남음 | -| 흐림 밖 | % · 뺌 | 판정 None (안 불리는 블록) | -| 띠 내가 고름 | 화면 캐시(서버 아님) | — | -최종 판정은 화면(`SheetMap`)이 이 재료 + 캐시 고름으로 내림 — 여기는 상태 이름을 그대로 실음. +칠 꼴(화면 779-4 `SheetMap`) — 줄 = 이어짐(연결됨 · 로직) 초록 · 후보 파랑 · 못 이음 빨강 / 블록 = 만든 SK 갈래 +(못옮김 남음 빨강 · 차이 SK 주황 · 그 밖 초록) · 없으면 「가져온 식」 초록 · 안 불림 흐림. `어긋남`(778-5 D-6 · 요구 623 · 624) = 불리는 블록의 엑셀 셈 어긋남(엑셀 = 과거 값 · 사용자가 보고 판단 · 칠 · 판정에 안 씀) — `{줄합, 금액, 차, 덮음, 줄 [{행, 이름, 묶음, 셈, 금액, 차, 까닭}], 까닭}` · 없으면 None. @@ -31,7 +23,6 @@ import json from pathlib import Path from typing import Any -from M02_MasterTemplete import M02_Hopyo_Judge as hjudge from M02_MasterTemplete import M02_Hopyo_Sangeun as sangeun from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line @@ -39,6 +30,9 @@ from M02_MasterTemplete import M02_Hopyo_Units as hunits from M02_MasterTemplete import M02_Labor_Ref as refs +IMPORTED = "가져온 식" # 불리는 블록 = 경쟁사 로직 그대로(809-1) + + def _origin(to: Path, sk: str, mark: str, here: refs.Here) -> dict[str, Any]: try: doc = json.loads((to / sangeun.KIND / f"{sk}.json").read_bytes()) @@ -130,18 +124,17 @@ def blocks(folder: Path, key: str, to: Path | None = None) -> list[dict[str, Any all_ = sangeun.calls(hp)[0] units = hunits.Units() if all_ else None call = sangeun.callers(hp) - judge = hjudge.judged(folder, key) or {} # 셈 중 = None out = [] for no in sorted(all_): mark = sangeun.mark(key, no) sk = have.get(mark) origin = _origin(to, sk, mark, here) if sk else {} - new = judge.get(no) or {} out.append( { "번": no, - "판정": new.get("판정"), - "고를수": new.get("고를수"), + "이름": all_[no].get("이름"), + "단위": all_[no].get("단위"), + "판정": IMPORTED if call.get(no) else None, "SK": sk, "갈래": origin.get("갈래"), "못옮김": origin.get("못옮김") or [], diff --git a/M02_MasterTemplete/M02_Hopyo_Router.py b/M02_MasterTemplete/M02_Hopyo_Router.py index 9b364f12a..c0eff4bd6 100644 --- a/M02_MasterTemplete/M02_Hopyo_Router.py +++ b/M02_MasterTemplete/M02_Hopyo_Router.py @@ -1,12 +1,9 @@ """M02 일위대가 실무자료 길(PLAN 785-5 · 요구 642 · 설계 642-A 2-9) — `/api/m02/layers/{층}/hopyo…`. -새 길(글 먼저 · 결정적 매칭) — 판정 `hopyo-judge`(1차 `M02_Hopyo_Row` + 2차 `M02_Hopyo_Solve` · -`M02_Hopyo_Judge`) · 검산 `hopyo/{키}/check`(저장 없음) · 옮기기 `hopyo/{키}/move`(확인한 것 → -SC · SK · 장부 · `M02_Hopyo_Make`) · 경로 층 = 파일 층(판정 · 검산 · `to` = 만들 층) / 만들 층(옮기기). -올림 = Book + 격자 + 엑셀 사전(`Row.lexicon`) + 블록 풀이 뒤 셈(`Judge.ensure` · 파생 저장본) · -옛 맞춤 뒤 셈 · 시동 데움 없음 · [끝내기] = 저장본도 지움. -근거 제안(793-3a) = `hopyo-basis/{키}`(목록) · `hopyo-basis/{키}/{번}`(블록) · `M02_San_Basis_Store` -(올림 때 HP `숨은식` + 뒤 셈 · 파생 저장본 · 판정 길 `hopyo-judge` 칸은 그대로). +[수정] 보기 `hopyo-view/{키}/{번}`(809-1 · `M02_Hopyo_View` · 경쟁사 로직 인정 · 요소만 연결 · 읽기만) · +엑셀 보기 머리 · 목록 = `View.listing`(줄 표지) · 옮기기 = [내 일위대가로] `hopyo-build-all {번}`(807-1). +옛 판정 · 검산 · 옮기기 · 근거 제안 길(785-5 `hopyo-judge` · `check` · `move` · `price` · 793-3a `hopyo-basis`)은 +809-1 에서 지움(요구 703 — 기초로 바꾸기 없음). 그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 장부 · 모두 고정 · 붙여넣기 · 다시 잇기 · 구조물 조합 · 칠 = B09 · M01 이 씀) · 옛 맞춤 길(호표 하나 맞춤 · 미리 셈 · 실무 호표 · 확인 만들기 · 안 이은 줄 · 단가산출 만들기)은 785-7 에서 지움. @@ -19,7 +16,7 @@ from pathlib import Path from typing import Any, Literal from fastapi import APIRouter, Depends, File, HTTPException, UploadFile -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build @@ -27,239 +24,37 @@ from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel from M02_MasterTemplete import M02_Hopyo_Forget as hopyo_forget from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item -from M02_MasterTemplete import M02_Hopyo_Judge as hopyo_judge from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger -from M02_MasterTemplete import M02_Hopyo_Make as hopyo_maker from M02_MasterTemplete import M02_Hopyo_Marks as hopyo_marks +from M02_MasterTemplete import M02_Hopyo_View as hopyo_view_ 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_Row as hopyo_row from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store -from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo -from M02_MasterTemplete import M02_San_Basis_Store as san_basis from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root router = APIRouter(prefix="/api/m02", tags=["M02 Hopyo"]) -# ── 새 길(785-5) — 판정 · 검산 · 옮기기 ───────────────────────────────────── -def _to(session: Session, folder: Path, to: Layer | None, to_owner: str | None) -> Path: - return _root(session, to, to_owner) if to is not None else folder +# ── [수정] 보기(809-1) — 경쟁사 로직 인정 · 요소만 연결 · 읽기만 ───────────────── -@router.get("/layers/{layer}/hopyo-judge/{key}") -def hopyo_judge_list( - layer: Layer, - key: str, - owner: str | None = None, - to: Layer | None = None, - to_owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """호표 목록 + 1차 판정 수 · 블록 목록 `{키, 파일, 호표 [...], 블록 [...]}`(읽기만).""" - folder = _root(session, layer, owner) - target = _to(session, folder, to, to_owner) - return _call(folder, lambda: hopyo_judge.listing(folder, key, target)) - - -@router.get("/layers/{layer}/hopyo-judge/{key}/{no}") -def hopyo_judge_one( - layer: Layer, - key: str, - no: int, - owner: str | None = None, - to: Layer | None = None, - to_owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """호표 하나 — 줄 판(1차) · 단산 줄 `풀이`(2차 블록 풀이).""" - folder = _root(session, layer, owner) - target = _to(session, folder, to, to_owner) - return _call(folder, lambda: hopyo_judge.hopyo(folder, key, no, target)) - - -@router.get("/layers/{layer}/hopyo-judge/{key}/block/{no}") -def hopyo_judge_block( - layer: Layer, - key: str, - no: int, - owner: str | None = None, - to: Layer | None = None, - to_owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """단산 블록 하나 — 블록 풀이(`Solve.block`) + 이름 · 단위 · 부름 · 근거 · 만든.""" - folder = _root(session, layer, owner) - target = _to(session, folder, to, to_owner) - return _call(folder, lambda: hopyo_judge.block(folder, key, no, target)) - - -@router.get("/layers/{layer}/hopyo-basis/{key}") -def hopyo_basis_list( - layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) -) -> dict: - """근거 제안(793-3a) — `{셈중, 블록 {번: 제안}, 보강}` · 셈 중 = 뒤 셈 시작(읽기만).""" - folder = _root(session, layer, owner) - return _call(folder, lambda: san_basis.listing(folder, key)) - - -@router.get("/layers/{layer}/hopyo-basis/{key}/{no}") -def hopyo_basis_block( +@router.get("/layers/{layer}/hopyo-view/{key}/{no}") +def hopyo_view( layer: Layer, key: str, no: int, owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - """근거 제안 블록 하나 — 저장본 › 그 자리 셈(`M02_San_Basis.suggest` 꼴).""" + """호표 하나 → 일위대가 문서 미리보기(엑셀 숫자 그대로 고정 줄) + 줄 표지(로직 · 요소) — 저장 없음.""" folder = _root(session, layer, owner) - return _call(folder, lambda: san_basis.block(folder, key, no)) + return _call(folder, hopyo_view_.view, folder, key, no) -class Pick(BaseModel): - """고른 것(785-3 꼴) — 금액 · 품 칸 없음(서버가 셈) · 고정 단가 = 엑셀 줄 것.""" - - model_config = ConfigDict(extra="ignore") - 갈래: Literal["기초", "단가산출", "일위대가", "인력", "재료", "유가", "기계", "율", "고정"] - 키: str | None = Field(None, max_length=40) - 이름: str | None = Field(None, max_length=200) - 단위: str | None = Field(None, max_length=40) - 입력값: dict[str, Any] = Field(default_factory=dict) - 몫: float | None = None - 배: float | None = None - 비목: str | None = None - 기준: str | list[str] | None = None - 별도계상: bool | None = None - 층: Layer | None = None - 주인: str | None = None - 항목: int | None = None # 블록 고른 것 — 블록 단위 줄 차례(수량 = 그 줄 품) - - @model_validator(mode="before") - @classmethod - def _no_money(cls, raw: Any) -> Any: - """금액 · 품 · 수량 칸 = 400(서버가 셈 · 브라우저 값 안 받아 적음).""" - if isinstance(raw, dict) and {"금액", "품", "수량"} & set(raw): - raise ValueError("금액 · 품 · 수량은 서버가 셈") - return raw - - -class CheckBody(BaseModel): - """`호표` + `차례` = 일위대가 줄 · `블록` = 단산 블록(항목 줄과 대조) · `고른` = 고른 것.""" - - 호표: int | None = None - 차례: int | None = None - 블록: int | None = None - 고른: list[Pick] = Field(default_factory=list, max_length=20) - - -@router.post("/layers/{layer}/hopyo/{key}/check") -def hopyo_check( - layer: Layer, - key: str, - body: CheckBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """검산(저장 없음) — 고른 것마다 입력값(엔진이 쓴 칸 모두) · 입력필요 · 줄 대조 · 금액(서버 셈).""" - folder = _root(session, layer, owner) - picks = [p.model_dump(exclude_none=True) for p in body.고른] - args = (folder, key, body.호표, body.차례, body.블록, picks) - return _call(folder, hopyo_judge.check, *args) - - -class MoveBody(BaseModel): - """확인한 것 — 파일 층 `층` · `주인` · `호표 {번: {차례: [고른 것]}}` · `블록 {번: [고른 것]}`.""" - - model_config = ConfigDict(extra="forbid") - 층: Layer - 주인: str | None = None - 호표: dict[int, dict[int, list[Pick]]] = Field(default_factory=dict, max_length=500) - 블록: dict[int, list[Pick]] = Field(default_factory=dict, max_length=500) - 덮음: bool = False # 이미 옮긴 호표 · 블록(같은 HP 출처) 덮기 — 아니면 409 {까닭, 호표, 블록} - - -@router.post("/layers/{layer}/hopyo/{key}/move") -def hopyo_move( - layer: Layer, - key: str, - body: MoveBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """[옮기기] — 확인한 것 → 이 층 SC · SK · 장부(하위 먼저 · 서버가 다시 셈) → - `{만듦, 단가산출, 막힘, 하위, 장부}`.""" - folder = _root(session, layer, owner, write=True) - source = _root(session, body.층, body.주인) - dump = lambda ps: [p.model_dump(exclude_none=True) for p in ps] # noqa: E731 - units = {no: {at: dump(ps) for at, ps in rows.items()} for no, rows in body.호표.items()} - blocks = {no: dump(ps) for no, ps in body.블록.items()} - args = (folder, source, key, units, blocks, _who(session), body.덮음) - return _call(folder, hopyo_maker.move, *args) - - -class PriceBody(BaseModel): - """우리 금액 미리 셈(786-2) — 파일 층 `층` · `주인` · `호표` 번 · `줄 {차례: [고른 것]}`.""" - - model_config = ConfigDict(extra="forbid") - 층: Layer - 주인: str | None = None - 호표: int - 줄: dict[int, list[Pick]] = Field(default_factory=dict, max_length=300) - - -def _price(folder: Path, source: Path, key: str, no: int, rows: dict[int, list[dict]]) -> dict: - """줄마다 옮기기와 같은 길(`Make.Move.pick` 재검사 · `Make.Lines.picks` 줄 · 자재)로 - 한 줄 문서 → `Labor_Doc.calc`(정본 셈 · 저장 없음 · 같은 기초 둘째 = 미리 참조) · - 막힘 = 그 줄 까닭만.""" - mv = hopyo_maker.Move(folder, source, key, None) - if no not in mv.units: - raise store.StoreError(404, f"없는 제{no}호표") - lines = mv.units[no].get("줄") or [] - b = hopyo_build.Builder(folder, source, key, no, None) - b.dry, b.strict = True, False - where = {"파일": mv.hp.get("파일") or "", "호표": no} - out: dict[int, dict[str, Any]] = {} - for at, picks in rows.items(): - if not 0 <= at < len(lines) or not picks: - continue - try: - got = [mv.pick(p, lines[at], f"제{no}호표 {at + 1}째 줄") for p in picks] - except hopyo_maker.Stuck as e: - out[at] = {"금액": None, "까닭": [e.why], "칸": e.cells} - continue - body = hopyo_maker.Lines(b, lines, where, "", hopyo_maker.hfix.Linker(False)) - body.picks(at, lines[at], got) - doc = {"종류": "일위대가", "열": "hopyo-price", "단위": None, "담은로직": body.lines} - try: - calc = labor.calc(labor.clean({**doc, "자재": list(body.mats.values())}), {}) - except (ValueError, store.StoreError) as e: - out[at] = {"금액": None, "까닭": [str(getattr(e, "detail", e))], "칸": []} - continue - why = [x["까닭"] for x in [*calc.get("줄", []), *calc.get("자재", [])] if x.get("까닭")] - out[at] = {"금액": calc.get("반영합"), "자재": calc.get("자재", []), "까닭": why, "칸": []} - return {"줄": out} - - -@router.post("/layers/{layer}/hopyo/{key}/price") -def hopyo_price( - layer: Layer, - key: str, - body: PriceBody, - owner: str | None = None, - session: Session = Depends(verify_session), -) -> dict: - """우리 금액 미리 셈(786-2 [수정] 모달 · 저장 없음) — 경로 층 = 만들 층(참조 읽기) · - 몸 층 = 파일 층 → `{줄 {차례: {금액 {노무비 · 재료비 · 경비 · 계}, 자재, 까닭, 칸}}}`.""" - folder = _root(session, layer, owner) - source = _root(session, body.층, body.주인) - rows = {at: [p.model_dump(exclude_none=True) for p in ps] for at, ps in body.줄.items()} - return _call(folder, _price, folder, source, key, body.호표, rows) - - -# ── 옛 길(785-7 까지 · 옛 화면) · 그대로 쓰는 길 ───────────────────────────── +# ── 그대로 쓰는 길 ───────────────────────────────────────────────────── HOPYO_MAX_BYTES = 20 * 1024 * 1024 @@ -274,8 +69,6 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: cannot = None if not from_hopyo.has(store.root(), same["키"]): # 793-3b 전에 올린 HP cannot = from_hopyo.keep(store.root(), same["키"], name, data) - hopyo_judge.ensure(store.root(), same["키"]) - san_basis.ensure(store.root(), same["키"]) return {**same, "이어서": True, **({"읽기불가": cannot} if cannot else {})} try: parsed = hopyo_excel.read_book(name, data) @@ -285,15 +78,10 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: grid = hopyo_grid.read(name, data, parsed) except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404) grid = None - sheets, _ = hopyo_excel._open(name, data, hopyo_row.SHEETS) - parsed["사전"] = hopyo_row.lexicon(sheets) # 엑셀 사전(785-3 · 1차 줄 판정) - parsed["숨은식"] = san_basis.hidden(parsed, sheets) # STmate 숨은 G 식(793-3a 근거 제안) row = hopyo_store.save(store.root(), name, parsed, who, grid, mark) cannot = from_hopyo.keep( store.root(), row["키"], name, data ) # 산근 시트(793-3b 가져온 식 열기) - hopyo_judge.ensure(store.root(), row["키"]) # 블록 풀이 뒤 셈(파생 저장본 · 화면 안 기다림) - san_basis.ensure(store.root(), row["키"]) # 근거 제안 뒤 셈(793-3a) return ( {**row, "읽기불가": cannot} if cannot else row ) # 797-1 가져온 식 못 씀 사유 → 화면 토스트 @@ -328,10 +116,10 @@ def hopyo_upload( def hopyo_read( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: - """실무자료 하나(엑셀 보기 머리 · 목록) — 검산 · 호표마다 1차 판정 `맞춤` [제안 있음, 줄 수] · `줄상태` · - `만든` · 블록 풀이 `셈`(`M02_Hopyo_Judge.view`).""" + """실무자료 하나(엑셀 보기 머리 · 목록) — 검산 · 호표마다 `맞춤` [연결됨 + 로직 줄, 줄 수] · `줄상태` · + `만든`(`M02_Hopyo_View.listing` · 809-1).""" folder = _root(session, layer, owner) - return _call(folder, hopyo_judge.view, folder, key, folder) + return _call(folder, hopyo_view_.listing, folder, key, folder) @router.get("/layers/{layer}/hopyo-grid/{key}") @@ -456,8 +244,6 @@ def hopyo_delete( 만든 일위대가 · 단가산출은 엑셀 사본만 걷고 남음 · 별칭 장부 그대로 → `{지움, 만든}`.""" folder = _root(session, layer, owner, write=True) got = _call(folder, hopyo_forget.finish, folder, key) - hopyo_judge.forget(folder, key) # 블록 풀이 저장본(785-5) - san_basis.forget(folder, key) # 근거 제안 저장본(793-3a) return got diff --git a/M02_MasterTemplete/M02_Hopyo_Row.py b/M02_MasterTemplete/M02_Hopyo_Row.py index c139154b7..f71db2141 100644 --- a/M02_MasterTemplete/M02_Hopyo_Row.py +++ b/M02_MasterTemplete/M02_Hopyo_Row.py @@ -1,67 +1,31 @@ -"""M02 엑셀 불러오기 1차 줄 판정(PLAN 785-3 · 요구 641 · 642 · 설계 642-A 2-3 · 분석 642-C 5장). +"""M02 엑셀 줄 → M01 요소 후보 · 환산(PLAN 785-3 · 809-1) — 서버 단독 · 읽기만. -엑셀 사전 = 단가표 시트 → 갈래 + M01 키 후보(한 번 · `lexicon`) — JSON 꼴 `{꼴, 줄 [사전 줄]}` · - 사전 줄 = `{갈래, 이름, 규격, 단위, 단가, 시트, 행, 코드?, 후보 [고른 것]}`. - EST = 자재단가(재료 · 이름이 M01 유가전력이면 유가) · 노임단가(인력) · 기계경비총괄표(기계) · - 기계경비적용기준(기본재료비 = 유가 · 환율 = 환율 · 줄 꼴 없음 → 꼬리표만) · 단가산출총괄표 · - 일위대가총괄표 · 머리 줄 = 앞 열 줄 안 「품명」 칸(열은 머리 글로 찾음). - STmate = 목록표(`Stmate._lists`) · 줄 코드가 열쇠(L 인력 · X 기계 · M 재료 · D 단가산출 · - S · W 고정). -세부행 갈래(위가 먼저) = ① 비고 「단산N · 일위N참조」(이름으로 이은 줄 뺌) ② 품명 · 규격 - 「별도계상 · 별도계산」 → 재료 ③ 단위 % · 규격 「…비의」 → 율 · hr · 시간 → 기계 · 인 → 인력 - ④ 사전(코드 › 품명+규격 › 품명) · 갈래 여럿 = 금액 칸 꼴(재료비만 → 재료 · 유가)로 가름 · - 그 밖 = 고정. -후보 찾기 차례 = 장부(`Ledger.recall` · 고른 것 꼴) › M01 표마다(LB · MT · MO · EQ) › M02 층 - SK · SC 같은 이름(위 층까지 · 참고). -판정 = 제안 있음(장부 짝 · 후보 하나 · 별도계상 = 고정 줄 하나(`품목` 표지) · 하위 호표 · 율) · - 2차 필요(단가산출 줄 = - `Bind` · `Solve` 몫) · 후보 여럿 · 후보 없음(고정 금액 제안). -고른 것 = `{갈래 기초|단가산출|일위대가|인력|재료|유가|기계|율|고정, 키, 이름, 규격, 단위, 단가, - 입력값, 출처, 몫, 비목?, 기준?, 별도계상?, 층?, 주인?}`(설계 2-1 · 만들기 = `Make` 몫). -수량 = `{엑셀, 배, 갈래}` — 우리 수량 = 엑셀 수량 × 몫 × 배(`factor` · 옮김 · 같은 단위 = 1). -서버 단독 · 읽기만(M01 · 층 문서 · 장부). +`m01_picks` = 갈래(인력 · 재료 · 유가 · 기계) + 줄 이름 · 규격 · 단위 → M01 표마다(LB · MT · MO · EQ) 후보 +(가져온 식 이음 `M02_San_Emit` 이 부름) · `CURRENCY` = 환율 낱말 · `factor` · `quantity` = 단위 환산 배 · +우리 수량 = 엑셀 수량 × 몫 × 배. +옛 1차 줄 판정(엑셀 사전 · 제안 · 2차 필요 · 기초 후보)은 809-1 에서 지움(경쟁사 로직 인정 · 요구 703). """ from __future__ import annotations import copy -import json import re import unicodedata from decimal import Decimal from functools import lru_cache -from pathlib import Path from typing import Any from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_Elem as elem -from M02_MasterTemplete import M02_Hopyo_Ledger as ledger from M02_MasterTemplete import M02_Hopyo_Match as hm from M02_MasterTemplete import M02_Hopyo_Material as hmat from M02_MasterTemplete import M02_Hopyo_ReadEst as est -from M02_MasterTemplete import M02_Hopyo_Stmate as stmate -from M02_MasterTemplete import M02_Labor_Ref as refs mm, mp = hm.mm, hm.mp -KINDS = ("기초", "단가산출", "일위대가", "인력", "재료", "유가", "기계", "율", "고정") -VERDICTS = ("제안 있음", "2차 필요", "후보 여럿", "후보 없음") COSTS = ("노무비", "재료비", "경비") -SHEET_KIND = { - "자재단가": "재료", - "노임단가": "인력", - "기계경비총괄표": "기계", - "기계경비적용기준": "유가", - "단가산출총괄표": "단가산출", - "일위대가총괄표": "일위대가", -} -SHEETS = (*SHEET_KIND, *stmate.LISTS) -CODE_KIND = {"L": "인력", "X": "기계", "M": "재료", "D": "단가산출", "B": "일위대가"} -APART = ("별도계상", "별도계산") MACHINE_UNITS = ("hr", "시간", "h") -CURRENCY = (("달러", "USD"), ("$", "USD"), ("엔", "JPY"), ("유로", "EUR"), ("파운드", "GBP")) -_RATE_BASE = re.compile(r"(노무비|재료비|경비|인건비|자재비|노임|합계)\s*의") -_BASE_COST = {"노": "노무비", "인": "노무비", "품": "노무비", "재": "재료비", "자": "재료비"} TOP = 5 +CURRENCY = (("달러", "USD"), ("$", "USD"), ("엔", "JPY"), ("유로", "EUR"), ("파운드", "GBP")) def flat(value: Any) -> str: @@ -85,103 +49,6 @@ def _plain(value: Any) -> int | float | None: # ── 엑셀 사전 ─────────────────────────────────────────────────────────── -def _header(rows: list[list[Any]]) -> tuple[int, dict[str, int]] | None: - for at, row in enumerate(rows[:10]): - heads = [flat(v) for v in row] - if "품명" in heads: - return at, {h: i for i, h in reversed(list(enumerate(heads))) if h} - return None - - -def _list_rows(rows: list[list[Any]], sheet: str, kind: str) -> list[dict[str, Any]]: - """단가표 시트 줄 — No(수 · 「제N호표」) · 품명 있는 줄 · 단가 = 적용단가 › 합계 › 단가.""" - got = _header(rows) - if got is None: - return [] - at, cols = got - name = cols["품명"] - no = cols.get("no", cols.get("호표", name - 1)) - price = next((cols[h] for h in ("적용단가", "합계", "단가") if h in cols), None) - cell = lambda r, c: r[c] if c is not None and c < len(r) else None # noqa: E731 - out = [] - for i, row in enumerate(rows[at + 1 :], at + 2): - label = flat(cell(row, no)) - if not flat(cell(row, name)) or not (label.isdigit() or re.fullmatch(r"제\d+호표", label)): - continue - out.append( - { - "갈래": kind, - "이름": str(cell(row, name)).strip(), - "규격": str(cell(row, cols.get("규격")) or "").strip(), - "단위": str(cell(row, cols.get("단위")) or "").strip(), - "단가": _plain(_num(cell(row, price))), - "시트": sheet, - "행": i, - } - ) - return out - - -def _rates(rows: list[list[Any]], sheet: str) -> list[dict[str, Any]]: - """기계경비적용기준 환율 칸 — 「화폐」 머리 아래 (화폐, 환율) 줄.""" - for at, row in enumerate(rows[:10]): - heads = [flat(v) for v in row] - if "화폐" in heads: - c = heads.index("화폐") - return [ - { - "갈래": "환율", - "이름": str(r[c]).strip(), - "규격": "", - "단위": "원", - "단가": _plain(_num(r[c + 1])), - "시트": sheet, - "행": i, - } # fmt: skip - for i, r in enumerate(rows[at + 1 :], at + 2) - if c + 1 < len(r) and flat(r[c]) and _num(r[c + 1]) - ] - return [] - - -def lexicon(sheets: dict[str, list[list[Any]]], keys: bool = True) -> dict[str, Any]: - """시트 줄 → 엑셀 사전 `{꼴, 줄}` — `keys` = 줄마다 M01 키 후보(한 번).""" - if stmate.is_stmate(sheets): - rows = [ - { - "갈래": CODE_KIND.get(code[0], "고정"), - **one, - "단가": None, - "시트": "목록표", - "행": None, - "코드": code, - } # fmt: skip - for code, one in stmate._lists(sheets).items() - ] - shape = "STmate" - else: - rows = [] - for sheet, kind in SHEET_KIND.items(): - if sheets.get(sheet): - rows += _list_rows(sheets[sheet], sheet, kind) - rows += _rates(sheets.get("기계경비적용기준") or [], "기계경비적용기준") - shape = "EST" - for row in rows: - if row["갈래"] == "재료" and elem.oil_rows(row["이름"]): - row["갈래"] = "유가" - if keys: - row["후보"] = m01_picks(row["갈래"], row) - return {"꼴": shape, "줄": rows} - - -def _index(lex: dict[str, Any] | None) -> dict[str, list[dict[str, Any]]]: - out: dict[str, list[dict[str, Any]]] = {} - for row in (lex or {}).get("줄") or []: - if row.get("코드"): - out.setdefault("코드|" + row["코드"], []).append(row) - out.setdefault(f"{flat(row['이름'])}|{flat(row['규격'])}", []).append(row) - out.setdefault(flat(row["이름"]), []).append(row) - return out # ── M01 키 후보(표마다) ─────────────────────────────────────────────────── @@ -246,169 +113,12 @@ def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[ # ── M02 층 SK · SC(같은 이름 · 참고) ──────────────────────────────────────── -def layer_docs(folder: str | Path | None) -> dict[str, list[dict[str, Any]]]: - """`{정규 이름: [고른 것]}` — 같은 층 → 위 층 · 단가산출 SK · 일위대가 SC.""" - out: dict[str, list[dict[str, Any]]] = {} - if folder is None: - return out - for one in ledger.chain(folder): - layer, owner = refs.where(one) - for kind, sub in (("단가산출", "sangeun"), ("일위대가", "precombo")): - for path in sorted((Path(one) / sub).glob("S[CK]-*.json")): - doc = json.loads(path.read_bytes()) if path.is_file() else {} - name = doc.get("이름") or path.stem - got = pick(kind, path.stem, name, "", doc.get("단위"), 층=layer, 주인=owner) - out.setdefault(mm.plain(name), []).append(got) - return out # ── 줄 판정 ───────────────────────────────────────────────────────────── -def first(line: dict[str, Any]) -> tuple[str | None, str, int | None]: - """1차 ①~③ — (갈래, 근거, 번) · 못 가름 = (None, "", None).""" - ref = line.get("참조") or {} - if ref and line.get("이음") != "이름": - kind = "단가산출" if ref.get("종류") == "단산" else "일위대가" - return kind, f"비고 {line.get('비고') or line.get('코드') or ''}".strip(), ref.get("번") - text = flat(f"{line.get('이름')}{line.get('규격')}") - if any(w in text for w in APART) or str(line.get("비고") or "").startswith("별산"): - return "재료", "별도계상", None - unit = flat(line.get("단위")) - if unit == "%" or _RATE_BASE.search(str(line.get("규격") or "")): - return "율", "단위 %", None - if unit in MACHINE_UNITS: - return "기계", "단위 hr", None - if unit == "인": - return "인력", "단위 인", None - return None, "", None - - -def _shape(line: dict[str, Any]) -> tuple[str, ...]: - price = line.get("단가") or {} - return tuple(k for k in COSTS if price.get(k)) - - -def second(line: dict[str, Any], index: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]: - """④ 사전 — 코드 › 품명+규격 › 품명 · 갈래 여럿이면 금액 칸 꼴로 가름 → 사전 줄 목록.""" - code = str(line.get("코드") or "") - hits = index.get("코드|" + code) if code else None - hits = hits or index.get(f"{flat(line.get('이름'))}|{flat(line.get('규격'))}") - hits = hits or index.get(flat(line.get("이름"))) or [] - if len({h["갈래"] for h in hits}) > 1 and _shape(line) == ("재료비",): - hits = [h for h in hits if h["갈래"] in ("재료", "유가")] or hits - if not hits and code[:1] in CODE_KIND: - hits = [{"갈래": CODE_KIND[code[0]], "이름": line.get("이름"), "규격": line.get("규격")}] - return hits - - -def _rate_pick(line: dict[str, Any]) -> dict[str, Any]: - """율 줄 — 기준 = 규격 「…비의」 글(EST 노 · 품 · 인 → 노무비 · 재 · 자 → 재료비 · 경 → 경비 · - 합계 → 비목 셋 중 줄 금액 칸) · 비목 = 줄 단가가 든 칸(없으면 기준).""" - hit = _RATE_BASE.search(f"{line.get('규격')}{line.get('이름')}") - word = hit.group(1) if hit else "" - base = _BASE_COST.get(word[:1], "경비" if word.startswith("경") else None) - cost = next(iter(_shape(line)), None) or base or "재료비" - one = pick("율", None, line.get("이름"), line.get("규격"), "%", 비목=cost, 기준=base or cost) - return one # 별도계상 줄 표지 `품목` — 품목은 프로젝트에 복사할 때 정함(요구 645-2) -ITEM_LATER = "프로젝트에서" - - -def _fixed(line: dict[str, Any], apart: bool = False) -> dict[str, Any]: - price = {k: _plain((line.get("단가") or {}).get(k)) or 0 for k in COSTS} - more = {"별도계상": True, "품목": ITEM_LATER} if apart else {} - return pick( - "고정", None, line.get("이름"), line.get("규격"), line.get("단위"), 단가=price, **more - ) - - -def _from_ledger(row: dict[str, Any]) -> list[dict[str, Any]]: - """장부 짝 → 고른 것 — 새 꼴(`갈래`) 그대로 · 옛 꼴 로직 = 기초 · 자재 = 재료 · 유가.""" - out = [] - for p in row.get("짝") or []: - if p.get("갈래"): - got = pick(p["갈래"], p.get("키"), p.get("이름"), "", p.get("단위")) - got.update({k: p[k] for k in ("입력값", "몫", "비목", "기준", "별도계상") if k in p}) - elif p.get("로직"): - got = {**pick("기초", p["로직"]), "입력값": p.get("입력값") or {}, "몫": p.get("몫")} - else: - code = str(p.get("자재") or "") - got = pick("유가" if code.startswith("MO") else "재료", code) - out.append({**got, "출처": {"장부": row.get("층")}, "배": p.get("배")}) - return out - - -def judge( - line: dict[str, Any], - index: dict[str, list[dict[str, Any]]], - docs: dict[str, list[dict[str, Any]]] | None = None, - book: list | None = None, - doc: str | None = None, - folder: str | Path | None = None, -) -> dict[str, Any]: - """줄 하나 → 줄 판 `{차례, 행, 갈래 [..], 근거, 번?, 후보, 판정, 수량, 장부?}`.""" - kind, why, no = first(line) - hits = [] if kind else second(line, index) - kinds = [kind] if kind else list(dict.fromkeys(h["갈래"] for h in hits)) or ["고정"] - if not kind and hits: - h = hits[0] - why = f"사전 {h.get('시트')} {h.get('행') or h.get('코드') or ''}".strip() - out = {"차례": line.get("차례"), "행": line.get("행"), "갈래": kinds, "근거": why} - if no is not None: - out["번"] = no - qty = {"엑셀": line.get("수량"), "배": 1, "갈래": "같음"} - picks: list[dict[str, Any]] = [] - if why == "별도계상": # 모아서 한 번에 셈 — 품목 후보 · 장부 짝 안 찾음(고정 줄 하나 · 786-1) - return {**out, "후보": [_fixed(line, True)], "판정": "제안 있음", "수량": qty} - found = None - if folder is not None: - found = ledger.recall(line, folder, book, doc=doc) - if found and found.get("갈래") == "짝": - picks = _from_ledger(found) - for k in kinds if not picks else (): - if k == "일위대가" and no is not None: - picks.append(pick("일위대가", f"제{no}호표", line.get("이름"), line.get("규격"), - line.get("단위"), 출처={"엑셀": f"제{no}호표"})) # fmt: skip - elif k in ("단가산출", "일위대가"): - pass - elif k == "율": - picks.append(_rate_pick(line)) - elif any(h["갈래"] == k and "후보" in h for h in hits): # 사전이 붙인 M01 후보 - picks += [p for h in hits if h["갈래"] == k for p in h["후보"]] - else: - picks += m01_picks(k, line) - if not found: - picks += [p for p in (docs or {}).get(mm.plain(line.get("이름")), []) if p["갈래"] in kinds] - picks = list({(p["갈래"], p.get("키"), p.get("층")): p for p in picks}.values()) - if found and found.get("갈래") in ("짝", "우리에 없음", "안 씀"): - out["장부"] = {k: found.get(k) for k in ("갈래", "층", "제안", "규격다름")} - if "단가산출" in kinds: # 단산 줄 = 늘 2차 블록 풀이(장부 짝 = 1차 후보 힌트만 · 785-7) - verdict = "2차 필요" - elif ( - found and found.get("갈래") in ("짝", "안 씀", "우리에 없음") and not found.get("규격다름") - ): - verdict = "제안 있음" - elif kinds[0] in ("일위대가", "율") or len(picks) == 1: - verdict = "제안 있음" - elif picks: - verdict = "후보 여럿" - else: - verdict, picks = "후보 없음", [_fixed(line)] - return {**out, "후보": picks, "판정": verdict, "수량": qty} - - -def rows( - book: dict[str, Any], folder: str | Path | None = None, doc: str | None = None -) -> dict[int, list[dict[str, Any]]]: - """Book(+ `사전`) → `{호표 번: [줄 판]}` — 장부 · 층 문서는 한 번 읽어 넘김.""" - index = _index(book.get("사전")) - got = ledger.books(folder) if folder is not None else None - docs = layer_docs(folder) - return { - h["번"]: [judge(x, index, docs, got, doc, folder) for x in h.get("줄") or []] - for h in book.get("호표") or [] - } # ── 환산(`M02_Hopyo_Match.factor` 옮김) ───────────────────────────────────── diff --git a/M02_MasterTemplete/M02_Hopyo_Solve.py b/M02_MasterTemplete/M02_Hopyo_Solve.py deleted file mode 100644 index 33d5a5566..000000000 --- a/M02_MasterTemplete/M02_Hopyo_Solve.py +++ /dev/null @@ -1,279 +0,0 @@ -"""M02 일위대가 실무자료 블록 풀이 검산 · 판정(PLAN 785-2 · 요구 642 · 설계 642-A 2-5 ④-4 · ④-5) — -후보 기초마다 옵션값 읽기(`M02_Hopyo_Bind`) → 엔진 한 번(품 · 금액 참고) → 엑셀 단위 줄과 짝 · 판정. - -짝(같은 묶음끼리): L 노무 = 직종 이름 · X 기계 = 시간 단위 로직 줄 · M 자재 = 뜻 낱말(남은 하나끼리) · -R 율 = 덧줄 율(같은 율 › 남은 하나끼리) · E 값 · S 하위 = 엑셀만. -품 같음(`same`) = 엑셀 자릿수 반올림 폭 안(0.67 ↔ 0.6667) 또는 0.5 % · 금액은 참고(노임 · 단가 해가 다름). -줄 판정 = 품 같음 · 품 다름 · 입력 필요 · 엑셀만 · 우리만(우리 줄 품 0 = 꺼진 줄 · 안 보임). -절 판정 = 기초 풂(품 다름 없는 후보 하나) · 후보 여럿 · 기초 없음 / -블록 판정 = 기초로 풂 · 기초+덧(엑셀만 줄 → `Units` 잇기) · 줄 조합(기초 없음뿐) · 고를 것 n. -서버 단독 · 읽기만 · 자동 고름 없음(제안 · 사용자 확인). -""" - -from __future__ import annotations - -from decimal import Decimal -from statistics import median -from typing import Any, Callable - -from M02_MasterTemplete import M02_Hopyo_Bind as hb -from M02_MasterTemplete import M02_Hopyo_Match as hm - -mf, mm, ma = hb.mf, hb.mm, hb.ma -SAME, DIFF, NEED, EXCEL, OURS = "품 같음", "품 다름", "입력 필요", "엑셀만", "우리만" -SOLVED, SOME, NONE = "기초 풂", "후보 여럿", "기초 없음" -BY_BASE, BASE_PLUS, BY_LINES, PICK = "기초로 풂", "기초+덧", "줄 조합", "고를 것" -SHARE = Decimal("0.005") -HOURS = {"시간", "hr", "h", "시"} - - -def _d(value: Any) -> Decimal | None: - try: - return None if value is None else Decimal(str(round(float(value), 10))) - except (ArithmeticError, TypeError, ValueError): - return None - - -def same(excel: Any, ours: Any) -> bool: - """품 같음 — 엑셀 적힌 자릿수의 반올림 폭 안 또는 0.5 % 안.""" - e, o = _d(excel), _d(ours) - if e is None or o is None: - return False - gap = abs(e - o) - places = -e.normalize().as_tuple().exponent - width = Decimal(5) * Decimal(10) ** -(places + 1) if 0 <= places <= 4 else Decimal(0) - return gap <= width or gap <= abs(e) * SHARE - - -def _kind(line: dict) -> str | None: - kind = line.get("종류") - if kind in ("로직", "기계"): - return "X" if str(line.get("단위") or "").strip() in HOURS else "S" - return {"인력": "L", "재료": "M", "율": "R"}.get(kind) - - -def _strict(item: dict, line: dict) -> bool: - kind = item.get("묶음") - if kind == "L": - return hm.tidy(line.get("이름")) == hm.tidy(item.get("직종") or item.get("이름")) - if kind == "M": - return hm.near(hm.words(item.get("이름")), hm.words(line.get("이름"))) - if kind == "R": - return same(item.get("품"), line.get("품")) - return kind == "X" - - -def _excel(item: dict, share: Decimal) -> dict: - q = _d(item.get("품")) - return { - "묶음": item.get("묶음"), - "이름": item.get("이름"), - "품": hb.plain(q / share if q is not None and item.get("묶음") != "R" else q), - "행": item.get("행"), - **({"별도": True} if item.get("어떻게") == "별도" else {}), - } - - -def _row(item: dict | None, line: dict | None, share: Decimal) -> dict: - ex = _excel(item, share) if item else None - us = {k: hb.plain(line.get(k)) for k in ("이름", "종류", "단위", "품")} if line else None - if line and line.get("까닭"): - us["까닭"] = line["까닭"] - if ex is None: - verdict = NEED if line.get("까닭") else OURS - elif us is None: - verdict = EXCEL - elif us["품"] is None: - verdict = NEED if line.get("까닭") else DIFF - else: - verdict = SAME if same(ex["품"], us["품"]) else DIFF - out = {"엑셀": ex, "우리": us, "판정": verdict} - names = f"{(ex or {}).get('이름') or ''}|{(us or {}).get('이름') or ''}" - if item and line and item.get("묶음") == "X" and "타이어" in names and "무한궤도" in names: - out["표지"] = "기계 갈래 엇갈림" - return out - - -def pair(lines: list[dict], items: list[dict], share: Any = 1) -> list[dict]: - """엑셀 단위 줄 ↔ 우리 줄 짝 → 줄 판정(엑셀 차례 · 남은 우리 줄 뒤).""" - share = _d(share) or Decimal(1) - used: set = set() - got: dict = {} - late = [] - for i, item in enumerate(items): - pool = [j for j, ln in enumerate(lines) if j not in used and _kind(ln) == item.get("묶음")] - hits = [j for j in pool if _strict(item, lines[j])] - exact = [j for j in hits if lines[j].get("이름") == (item.get("직종") or item.get("이름"))] - if hits: - got[i] = (exact or hits)[0] - used.add(got[i]) - else: - late.append(i) - for i in late: # 남은 하나끼리(결속선 ↔ 어닐링철선) - kind = items[i].get("묶음") - pool = [j for j, ln in enumerate(lines) if j not in used and _kind(ln) == kind] - rest = [k for k in late if items[k].get("묶음") == kind and k not in got] - if len(pool) == 1 and len(rest) == 1 and kind in ("M", "R"): - got[i] = pool[0] - used.add(pool[0]) - rows = [_row(item, lines[got[i]] if i in got else None, share) for i, item in enumerate(items)] - for j, line in enumerate(lines): - if ( - j not in used - and line.get("품") != 0 - and (line.get("품") is not None or line.get("까닭")) - ): - rows.append(_row(None, line, share)) - return rows - - -def _usable(items: list[dict]) -> list[dict]: - return [x for x in items if not x.get("밖")] - - -def fitter(master: mf.Master, key: str, items: list[dict], share: Any = 1) -> Callable: - """입력값 → `{맞음, 배}` — 맞음 = 짝 하나 이상 · 모두 품 같음 / 배 = 노무 · 기계 줄이 모두 같은 배 - (자재 · 율 줄은 같음) — 표 거꾸로 · 감률 읽기(B5)가 씀.""" - items = _usable(items) - - def fit(given: dict) -> dict: - rows = pair(hb.quantities(master, key, given), items, share) - both = [r for r in rows if r["엑셀"] and r["우리"] and r["엑셀"]["품"] and r["우리"]["품"]] - ok = bool(both) and all(r["판정"] == SAME for r in both) - labor = [r for r in both if r["엑셀"]["묶음"] in ("L", "X")] - k = None - if labor: - k = median(_d(r["엑셀"]["품"]) / _d(r["우리"]["품"]) for r in labor) - flat = all(same(r["엑셀"]["품"], _d(r["우리"]["품"]) * k) for r in labor) - rest = all(r["판정"] == SAME for r in both if r not in labor) - k = float(k) if flat and rest else None - return {"맞음": ok, "배": k} - - return fit - - -def _money(master: mf.Master, key: str, bound: dict, given: dict) -> dict: - """금액 참고 — 입력 필요가 없을 때 엔진 한 번(안 씀 · 비움 = 기본 제안 · 저장 안 함).""" - if bound["입력필요"]: - return {"계": None, "까닭": f"입력 필요 {len(bound['입력필요'])}"} - row = master.logic(key) - base = ma.defaults(master, row) - fill = {n: base.get(n) for n in [*bound["안씀"], *bound["비움"]]} - try: - got = mf.run(master, key, mf.fill_empty(row, {**fill, **given})) - except mf.FormulaError as e: - return {"계": None, "까닭": str(e)} - if "결과" in got: - return {"결과": hb.plain(got["결과"])} - return {k: hb.plain(got.get(k)) for k in ("노무비", "재료비", "경비", "계")} - - -def candidate(master: mf.Master, key: str, src: dict, items: list[dict], share: Any = 1) -> dict: - """후보 기초 하나 — 바인딩 · 줄 대조 · 판정 · 금액 참고.""" - row = master.logic(key) - bound = hb.bind(master, key, src, fitter(master, key, items, share)) - given = {**bound["입력값"], **{n: v[0]["값"] for n, v in bound["엇갈림"].items()}} - idle = {n: bound["기본"][n] for n in bound["안씀"] if bound["기본"].get(n) is not None} - rows = pair(hb.quantities(master, key, {**idle, **given}), _usable(items), share) - count = {v: sum(r["판정"] == v for r in rows) for v in (SAME, DIFF, NEED, EXCEL, OURS)} - if count[DIFF]: - verdict = DIFF - elif bound["입력필요"] or count[NEED] or bound["엇갈림"]: - verdict = NEED - else: - verdict = SAME if count[SAME] else EXCEL - return { - "키": key, - "이름": row.get("이름"), - "바인딩": bound, - "줄": rows, - "셈": count, - "판정": verdict, - "금액": _money(master, key, bound, given), - } - - -def _section(master: mf.Master, block: dict, part: dict, src: dict) -> dict: - items = part.get("항목") - items = _usable(block.get("항목") or []) if items is None else items - mine = {**src, **{k: part[k] for k in ("선택값", "상수", "식", "대입식") if k in part}} - share = part.get("몫") - share = (share.get("값") if isinstance(share, dict) else share) or 1 - cands = [candidate(master, k, mine, items, share) for k in part.get("후보") or []] - good = [c for c in cands if c["셈"][DIFF] == 0 and (c["셈"][SAME] or len(cands) == 1)] - verdict = NONE if not cands else SOLVED if len(good) == 1 else SOME - pick = good[0]["키"] if verdict == SOLVED else None - return { - "제목": part.get("제목", ""), - "판정": verdict, - "고름": pick, - "후보": cands, - "항목": items, - } - - -def block( - master: mf.Master, blk: dict, parts: list[dict], src: dict | None = None, units: Any = None -) -> dict: - """블록 풀이 — 절마다 후보 검산 · 절 판정 · 덧 줄(엑셀만 → `Units`) · 블록 판정.""" - src = {**(src or {}), "블록": blk} - secs = [_section(master, blk, p, src) for p in parts] - extra, n = [], 0 - for sec in secs: - chosen = next((c for c in sec["후보"] if c["키"] == sec["고름"]), None) - if sec["판정"] == SOME: - n += 1 - if chosen: - b = chosen["바인딩"] - n += len(b["입력필요"]) + len(b["엇갈림"]) + chosen["셈"][DIFF] - alone = [r for r in chosen["줄"] if r["판정"] == EXCEL] - lone = [x for x in sec["항목"] if any(r["엑셀"]["행"] == x.get("행") for r in alone)] - else: - lone = sec["항목"] if sec["판정"] == NONE else [] - for item in lone: - if units is None: - from M02_MasterTemplete import M02_Hopyo_Units as hu - - units = hu.Units() - link = units.line(item) - extra.append( - {"절": sec["제목"], "행": item.get("행"), "이름": item.get("이름"), **link} - ) - n += link.get("상태") != "이어짐" - for sec in secs: - sec.pop("항목") - if secs and all(s["판정"] == NONE for s in secs): - verdict = BY_LINES - elif n: - verdict = PICK - else: - verdict = BASE_PLUS if extra else BY_BASE - return {"번": blk.get("번"), "판정": verdict, "고를수": n, "절": secs, "덧": extra} - - -def _gather(nodes: list[dict], key: str) -> list: - return [x for one in nodes for x in [*one.get(key, []), *_gather(one.get("아래", []), key)]] - - -def parts(blk: dict, cands: list[Any]) -> list[dict]: - """절 나무(785-1 `Formula.works`) + 후보(키 · `Clause` 후보 {키, 절}) → 풂 절 — 절 표 있는 - 후보가 있으면 일 절마다 그 절 후보 + 절 표 없는 후보 · 없으면 블록 통째 한 절 · - 단위 줄 · 상수 · 식 · 선택값(머리 것 더함) · 몫.""" - from M02_MasterTemplete import M02_Hopyo_Formula as fo - - nodes = blk.get("절") or fo.tree(blk.get("글") or [], blk.get("글행"), blk.get("항목") or []) - top = fo.head(blk.get("글") or [], blk.get("글행")) - cands = [c if isinstance(c, dict) else {"키": c} for c in cands] - items = blk.get("항목") or [] - work = fo.works(nodes) if any(c.get("절") for c in cands) else [] - if not work: # 블록 통째 한 일(절 표 있는 후보가 없으면 한 기초가 블록 전부) - whole = {"제목": blk.get("이름", ""), "후보": [c["키"] for c in cands], "항목": None} - return [whole | {k: top[k] + _gather(nodes, k) for k in ("상수", "식", "선택값")}] - out = [] - for one in work: - mine = [c["키"] for c in cands if c.get("절") in (None, one["번"])] - at = [i for i in _gather([one], "항목") if not items[i].get("밖")] - part = {"제목": one["제목"], "번": one["번"], "후보": mine, "항목": [items[i] for i in at]} - part |= {k: top[k] + _gather([one], k) for k in ("상수", "식", "선택값")} - out.append(part | {"몫": one.get("몫")}) - return out diff --git a/M02_MasterTemplete/M02_Hopyo_View.py b/M02_MasterTemplete/M02_Hopyo_View.py new file mode 100644 index 000000000..9ed8ba996 --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_View.py @@ -0,0 +1,98 @@ +"""M02 엑셀 불러오기 [수정] 보기(PLAN 809-1 · 요구 702 · 703) — 호표 하나 → 일위대가 문서 미리보기 + 줄 표지 · 읽기만. + +경쟁사 로직 인정 · 요소만 연결 — 문서 줄 = 엑셀 줄 하나씩 고정 줄(엑셀 숫자 그대로 · `Fixed.line_of` · +[내 일위대가로] 와 같은 이음 규칙 · 금액 = 엑셀). 줄 표지: + · 로직 줄 = 단산 N 참조 → 「단가산출」(번 · 가져온 식 SK 로 엶) · 일위 N 참조 → 「일위대가」(번 · 하위 호표 보기) + · 요소 줄 = 그 밖(인력 · 재료 · 기계 · 유가 · 율 · 고정 수) — 이음 있음 = 「연결됨」(이음) · + 후보 있음 = 「후보」(키 · 이름) · 없음 = 「못 이음」(엑셀 값 그대로) + · 엑셀 = 줄 금액(노무비 · 재료비 · 경비 · 계) +기초 판정 · 2차 풀이 없음 · 저장 없음. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Build as hbuild +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_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +LOGIC, ELEMENT = "로직", "요소" +SK, SC = "단가산출", "일위대가" +LINKED, NEAR, NONE = "연결됨", "후보", "못 이음" +MONEY = ("노무비", "재료비", "경비", "계") + + +def _name(linker: hfix.Linker, code: str) -> str: + row = (linker.whole.numbers.get(code[:2]) or {}).get(code) or {} + return str(row.get("이름") or "") + + +def mark(line: dict[str, Any], one: dict[str, Any], linker: hfix.Linker) -> dict[str, Any]: + """엑셀 줄 하나 표지 — 로직(단가산출 · 일위대가) / 요소(연결됨 · 후보 · 못 이음).""" + ref = line.get("참조") or {} + money = line.get("금액") or {} + out: dict[str, Any] = { + "차례": int(line.get("차례", 0)), + "엑셀": {k: money.get(k) for k in MONEY}, + } + if ref.get("종류") == "단산" and ref.get("번"): + return {**out, "갈래": LOGIC, "표지": SK, "번": int(ref["번"])} + if ref.get("종류") == "일위" or hfix.kind_of(line) == "호표": + return {**out, "갈래": LOGIC, "표지": SC, "번": int(ref.get("번") or 0) or None} + fx = one.get("고정") or {} + if hfix.linked(one): + link = {k: v for k, v in (fx.get("이음") or {}).items() if v} + rate = fx.get("비율") + return {**out, "갈래": ELEMENT, "표지": LINKED, "이음": link or {"비율": rate["기준"]}} + near = [{"키": c, "이름": _name(linker, c)} for c in linker.candidates(line)] + if near: + return {**out, "갈래": ELEMENT, "표지": NEAR, "후보": near} + return {**out, "갈래": ELEMENT, "표지": NONE} + + +def view(source: str | Path, key: str, no: int) -> dict[str, Any]: + """파일 층 HP 의 호표 하나 보기(`of`).""" + return of(hopyo_store.read(Path(source), key), no) + + +def of(book: dict[str, Any], no: int, linker: hfix.Linker | None = None) -> dict[str, Any]: + """호표 하나 → `{번, 이름, 규격, 단위, 금액, 문서 {이름, 단위, 담은로직, 자재}, 줄 [표지]}` — 문서 줄 차례 = 엑셀 줄 차례.""" + hopyo = next((h for h in book.get("호표") or [] if int(h["번"]) == int(no)), None) + if hopyo is None: + raise store.StoreError(404, f"없는 호표 「제{no}호표」") + rows = hopyo.get("줄") or [] + linker, taken = linker or hfix.Linker(True), set() + where = {"파일": book.get("파일") or "", "호표": int(no)} + lines, marks = [], [] + for line in rows: + one = hfix.line_of(line, [x for x in rows if x is not line], where, taken, linker) + lines.append(one) + marks.append(mark(line, one, linker)) + name = " ".join(x for x in (hopyo.get("이름"), hopyo.get("규격")) if x) + doc = {"이름": name, "단위": hopyo.get("단위") or None, "담은로직": lines, "자재": []} + head = {k: hopyo.get(k) for k in ("번", "이름", "규격", "단위", "금액")} + return {**head, "문서": doc, "줄": marks} + + +def listing(folder: str | Path, key: str, to: str | Path) -> dict[str, Any]: + """엑셀 보기 머리 · 목록 — 파일 줄 + 검산 · 호표마다 `맞춤` [연결됨 + 로직 줄, 줄 수] · + `줄상태 [[차례, 표지, 이어짐]]` · `만든` SC(만들 층 `to` · 이 파일 층 것만).""" + book = hopyo_store.read(Path(folder), key) + made = hbuild.confirmed(to, refs.where(Path(folder))) + linker, units = hfix.Linker(True), [] + for h in book.get("호표") or []: + no = int(h["번"]) + marks = of(book, no, linker)["줄"] + state = [ + [i, m["표지"], m["갈래"] == LOGIC or m["표지"] == LINKED] for i, m in enumerate(marks) + ] + head = {k: h.get(k) for k in ("번", "이름", "규격", "단위", "금액", "행") if k in h} + units.append({**head, "맞춤": [sum(x[2] for x in state), len(state)], "줄상태": state, + "만든": made.get(hbuild.mark(key, no))}) # fmt: skip + ready = [sum(h["맞춤"][0] for h in units), sum(h["맞춤"][1] for h in units)] + head = {**hopyo_store.brief(book), "검산": book.get("검산"), "셈": {"끝": True}} + return {**head, "호표": units, "맞춤": ready} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 0d12c6877..8e6ed5554 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -149,30 +149,6 @@ min-height: 8rem; } -.m02-hopyo-sheet__lines { - flex: 0 1 45%; - min-height: 0; - overflow: auto; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); -} - -.m02-hopyo-sheet__lines[hidden] { - display: none; -} - -/* 좁은 폭(785-7 · 390) — 줄 판이 열리면 위 칸(시트 + 줄 판)을 넓히고 줄 판 높이 바닥을 둠 */ -@media (max-width: 900px) { - .m02-hopyo-sheet:has(.m02-hopyo-sheet__lines:not([hidden])) { - grid-template-rows: minmax(0, 5fr) minmax(0, 1fr); - } - - .m02-hopyo-sheet__lines { - flex: 1 1 60%; - min-height: 16rem; - } -} - /* 세 보기 막대 · 범례(779-4) — 시트 위 한 줄씩 · 넘치면 감김 */ .m02-hopyo-sheet__nav { display: flex; @@ -286,54 +262,7 @@ margin-top: var(--spacing-12); } -/* [수정] 모달 「확인 — 제N호표」 줄 표(786-2 · 옛 359-3 꼴) — 일위대가 상세 꼴(m02-labrow) · 확인 · 행 · 수량 · 단위 = - 줄바꿈 없음 · 단추 = 세로로 쌓아 좁게 · 이름 · 별칭 · 품 비교가 남는 폭 */ -.m02-hopyo__table.m02-hopyo__lines :is(th, td) { - padding: var(--spacing-8); - vertical-align: top; -} - -.m02-hopyo__lines :is(td:first-child, td:nth-child(2), .m02-labrow__qty, .m02-labrow__unit) { - white-space: nowrap; -} - -.m02-hopyo__lines th > .ui-badge { - vertical-align: middle; -} - -.m02-hopyo__lines .m02-labrow__name { - max-width: none; - white-space: normal; -} - -.m02-hopyo__lines .m02-labrow__name > small { - display: block; -} - -.m02-hopyo__lines td.m02-hopyo__compare { - min-width: 7em; - white-space: pre-line; -} - -.m02-hopyo__lines td.m02-hopyo__acts { - width: 1%; -} - -.m02-hopyo__lines .m02-hopyo__acts > * { - display: flex; - width: 100%; - white-space: nowrap; -} - -.m02-hopyo__lines .m02-hopyo__acts > * + * { - margin: var(--spacing-4) 0 0; -} - -.m02-hopyo__solved > td { - background: var(--color-surface-raised); -} - -.m02-hopyo__subhead { - margin: var(--spacing-8) 0 0; - font-size: var(--text-body-sm); +/* 줄 바꿈 도구 막대(가져온 식 · 소요량 이음) */ +.m02-judge__wrap { + flex-wrap: wrap; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index 9e233140a..e94deb27f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -2,8 +2,8 @@ * M02_MasterTemplete_Hopyo_Api.ts * 일위대가 실무자료(PLAN 324 · 338) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) · * 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로) · - * 목록 · 올림 · 엑셀 보기 머리(1차 판정 줄상태) · 격자 · 지우기 · 별칭 장부 · 붙여넣기 · 다시 잇기 — - * 판정 · 검산 · 옮기기 길 = `Hopyo_JudgeApi` · 옛 맞춤 꼴(줄 맞춤 · 확인 만들기 · 실무 호표)은 785-7 에서 지움. + * 목록 · 올림 · 엑셀 보기 머리(809-1 줄 표지 줄상태) · 격자 · 지우기 · 별칭 장부 · 붙여넣기 · 다시 잇기 — + * [수정] 보기 = `Hopyo_View`(809-1) · 판정 · 검산 · 옮기기 길(785)은 809-1 에서 지움(요구 703). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts deleted file mode 100644 index 5ceb88cb0..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Block.ts +++ /dev/null @@ -1,442 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Hopyo_Block.ts - * 엑셀 불러오기 단산 블록 판(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-5 · 2-6) — 줄 판 단산 줄 펼침 · 단가산출 탭 줄 판 한 몸. - * · 블록 머리 = 블록 판정(기초로 풂 · 기초+덧 · 줄 조합 · 고를 것 n) · 근거 표지 · 금액 참고(우리 · 엑셀) - * · 절마다 = 맡은 절 · 기초 후보 칩(여럿 고름) · 고른 후보마다 입력값 칸(출처 꼬리표 B1~B6 · 사용자 · 기본 · - * 「입력 필요」 붉은 칸 · 엇갈림 고르기) · 안 씀 · 줄 대조(품 같음 · 품 다름 · 엑셀만 · 우리만 · 입력 필요) · 금액 - * · 덧 줄(엑셀만) = 후보 칩 → 재료 · 인력 · 기계 고른 것 - * · 머리 [가져온 식으로 열기](793-3b · 새) = `Hopyo_Imported` 모달(이 블록 → 가져온 식 SK · 시트 · 단가 · 기초로 바꾸기) - * · [검산] = 서버 셈(저장 없음 · 옛 요청 끊기) · 고름 · 입력은 캐시(`createJudgeState` = sessionStorage) — 옮김은 [옮기기] 뒤 서버 - * 판정 · 금액은 서버 · 여기는 보이기 · 고르기만. - * ========================================================================== */ - -import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; -import { t as L } from "@ui/ui_template_locale"; -import { createPanelHead } from "@ui/ui_template_panel_head"; -import { createTable, type Cell } from "@ui/ui_template_table"; -import { createText } from "@ui/ui_template_text"; -import { createToggle } from "@ui/ui_template_toggle"; -import { formatNumber, formatPrice } from "../common_util/common_util_money"; -import type { HopyoScope, Money } from "./M02_MasterTemplete_Hopyo_Api"; -import { - checkJudge, - readJudgeBlock, - type BaseCand, - type CandMoney, - type CheckOne, - type CheckResult, - type ExtraLine, - type PairLine, - type PickKind, - type Section, - type Solved, -} from "./M02_MasterTemplete_Hopyo_JudgeApi"; -import { - bare, - basePick, - blockPicks, - confirmable, - draftKey, - emptyDraft, - missing, - parseDraft, - putBlock, - samePick, - setInput, - togglePick, - type DraftPick, - type JudgeDraft, -} from "./logic/M02_MasterTemplete_Hopyo_Picks"; -import "./M02_MasterTemplete_Hopyo_Judge.css"; - -/** 고름 캐시 손잡이 — 파일 하나 · sessionStorage(포트별) · 바뀌면 알림 */ -export function createJudgeState(file: HopyoScope, key: string) { - const k = draftKey(file.layer, file.owner, key); - let d: JudgeDraft = parseDraft(sessionStorage.getItem(k)); - const subs = new Set<() => void>(); - return { - file, - key, - get: (): JudgeDraft => d, - set(next: JudgeDraft): void { - d = next; - sessionStorage.setItem(k, JSON.stringify(next)); - subs.forEach((f) => f()); - }, - clear(): void { - sessionStorage.removeItem(k); - d = emptyDraft(); - subs.forEach((f) => f()); - }, - on(f: () => void): () => void { - subs.add(f); - return () => subs.delete(f); - }, - }; -} -export type JudgeState = ReturnType; - -const TONE: Record = { - "기초로 풂": "success", - "기초+덧": "success", - "줄 조합": "info", - "고를 것": "warning", - "기초 풂": "success", - "후보 여럿": "warning", - "기초 없음": "danger", - "품 같음": "success", - "품 다름": "warning", - "입력 필요": "danger", - 엑셀만: "info", - 우리만: "neutral", - 확인: "success", -}; -const badge = (text: string, at?: string): HTMLElement => { - const b = createBadge(text, TONE[text] ?? "neutral"); - if (at) b.dataset[at] = text; - return b; -}; -const won = (v: number | null | undefined): string => - v === null || v === undefined ? "—" : formatPrice(Math.floor(v)); -const show = (v: unknown): string => - v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v); -const bar = (kids: HTMLElement[]): HTMLElement => - el("div", { className: "ui-toolbar m02-judge__wrap", children: kids }); - -/** 금액 한 줄 — 노무 · 재료 · 경비 · 계(없으면 까닭) */ -export function moneyText(m: CandMoney | Partial | null | undefined): string { - if (!m) return "—"; - if (m.계 === null && "까닭" in m) return String(m.까닭); - const x = m as Partial; - return `${won(x.노무비)} · ${won(x.재료비)} · ${won(x.경비)} = ${won(x.계)}`; -} - -export interface SolvedBodyOptions { - solved: Solved; - picks: DraftPick[]; - /** 고름 · 입력 바뀜 — 부르는 쪽이 캐시에 넣음 */ - onPicks: (list: DraftPick[]) => void; - /** 서버 검산(없으면 단추 안 보임) */ - onCheck?: (list: DraftPick[], signal: AbortSignal) => Promise; - /** 엑셀 보기 단가산출 상세 그 행 */ - onSheet?: (행: number) => void; - excel?: Partial | null; - readOnly?: boolean; -} - -/** 입력값 표 — 엔진 입력값 + 입력 필요 + 엇갈림 칸 */ -function inputTable( - opt: SolvedBodyOptions, - cand: BaseCand, - p: DraftPick, - put: (p: DraftPick) => void, -): HTMLElement { - const b = cand.바인딩; - const names = [...new Set([...Object.keys(p.입력값), ...b.입력필요, ...Object.keys(b.엇갈림)])]; - const need = new Set(missing(p)); - const rows: Cell[][] = names.map((name) => { - const src = p.출처[name]?.길; - const tags: HTMLElement[] = []; - if (src) { - const tag = createBadge(src === "사용자" ? L("M02_JudgeUser") : src === "기본" ? L("M02_JudgeDefault") : src, src.startsWith("B") ? "accent" : "info"); // prettier-ignore - tag.dataset.judgeSrc = src; - if (p.출처[name]?.글) tag.title = String(p.출처[name]?.글); - tags.push(tag); - } - if (need.has(name)) tags.push(badge(L("M02_JudgeNeed"), "judgeNeed")); - const split = b.엇갈림[name]; - if (split) tags.push(badge(L("M02_JudgeConflict"), "judgeConflict")); - let field: HTMLElement; - if (split && !opt.readOnly) { - const picked = split.findIndex((s) => s.값 === p.입력값[name]); - const sel = createSelectField({ - options: [{ value: "", text: "—" }].concat(split.map((s, i) => ({ value: String(i), text: `${show(s.값)} (${s.길})` }))), // prettier-ignore - value: picked < 0 ? "" : String(picked), - compact: true, - size: 24, - ariaLabel: name, - onChange: (v) => put(v === "" ? setInput(p, name, "") : setInput(p, name, split[+v].값, split[+v].길)), // prettier-ignore - }); - sel.select.dataset.judgeInput = name; - field = sel.root; - } else { - const f = createInputField({ compact: true, size: 24, value: show(p.입력값[name]) }); - f.input.dataset.judgeInput = name; - f.input.readOnly = !!opt.readOnly; - f.input.addEventListener("change", () => put(setInput(p, name, f.input.value))); - if (need.has(name)) f.setError(L("M02_JudgeNeed")); - field = f.root; - } - const dflt = b.기본[name]; - if (need.has(name) && dflt !== undefined && dflt !== null && !opt.readOnly) { - const use = createButton({ label: `${L("M02_JudgeDefault")} ${show(dflt)}`, size: 24, variant: "link", onClick: () => put(setInput(p, name, dflt, "기본")) }); // prettier-ignore - use.dataset.judgeDefault = name; - tags.push(use); - } - return [name, field, bar(tags)]; - }); - return createTable( - [{ label: L("M02_JudgeColInput"), width: "9em" }, { label: L("M02_JudgeColValue"), width: "12em" }, { label: L("M02_JudgeColSource") }], // prettier-ignore - rows, - { density: "data" }, - ); -} - -/** 줄 대조 표 */ -function pairTable(opt: SolvedBodyOptions, lines: PairLine[]): HTMLElement { - const rows: Cell[][] = lines.map((x) => { - const at = x.엑셀?.행; - const jump = - at && opt.onSheet - ? createButton({ label: L("M02_JudgeSheet"), size: 24, variant: "link", onClick: () => opt.onSheet!(at) }) // prettier-ignore - : ""; - return [ - x.엑셀?.묶음 ?? x.우리?.종류 ?? "", - x.엑셀 ? `${x.엑셀.이름} ${show(x.엑셀.품)}` : "—", - x.우리 ? `${x.우리.이름} ${show(x.우리.품)}${x.우리.까닭 ? ` · ${x.우리.까닭}` : ""}` : "—", - bar([badge(x.판정, "judgePair"), ...(x.표지 ? [createBadge(x.표지, "neutral")] : [])]), - jump, - ]; - }); - return createTable( - [ - { label: L("M02_JudgeColGroup"), width: "48px" }, - { label: L("M02_JudgeColExcel") }, - { label: L("M02_JudgeColOurs") }, - { label: L("M02_JudgeColPair") }, - { label: L("M02_JudgeSheet"), width: "52px" }, - ], - rows, - { density: "data" }, - ); -} - -/** 덧 줄 후보 → 고른 것 */ -function extraPick(x: ExtraLine, c: NonNullable[number]): DraftPick { - const 갈래: PickKind = c.자재 ? "재료" : c.노임 ? "인력" : "기계"; - const 키 = c.자재 ?? c.노임 ?? c.키 ?? null; - return { 갈래, 키, 이름: c.이름 ?? x.이름, 규격: c.규격 ?? "", 단위: c.단위 ?? "", 입력값: {}, 출처: {}, 몫: null }; // prettier-ignore -} - -/** 블록 풀이 몸 — 상태(고름 · 검산 결과)를 쥐고 바뀌면 다시 그림 */ -export function solvedBody(opt: SolvedBodyOptions): { - root: HTMLElement; - set: (picks: DraftPick[]) => void; -} { - const root = el("div", { - className: "m02-judge", - attrs: { "data-judge-solved": String(opt.solved.번) }, - }); - let picks = opt.picks; - let checked: Record = {}; - let ask: AbortController | null = null; - - const put = (list: DraftPick[]): void => { - picks = list; - checked = {}; - opt.onPicks(list); - draw(); - }; - const swap = (old: DraftPick, next: DraftPick): void => - put(picks.map((x) => (samePick(x, old) ? next : x))); - - async function check(): Promise { - if (!opt.onCheck) return; - ask?.abort(); - ask = new AbortController(); - try { - const got = await opt.onCheck(picks, ask.signal); - checked = Object.fromEntries(got.고른.map((c) => [String(c.키), c])); - draw(); - } catch (e) { - if ((e as Error).name !== "AbortError") showToast((e as Error).message, "error"); - } - } - - function candBox(cand: BaseCand): HTMLElement | null { - const p = picks.find((x) => x.갈래 === "기초" && x.키 === cand.키); - if (!p) return null; - const got = checked[cand.키]; - const box = el("div", { className: "m02-judge__cand", attrs: { "data-judge-cand": cand.키 } }); - const head = [createText(cand.이름, "muted"), badge(got?.판정 ?? cand.판정, "judgeVerdict")]; - box.append(bar(head)); - box.append(inputTable(opt, cand, p, (n) => swap(p, n))); - if (cand.바인딩.안씀.length) - box.append(createText(`${L("M02_JudgeUnused")} — ${cand.바인딩.안씀.join(" · ")}`, "hint")); - box.append(pairTable(opt, got?.줄 ?? cand.줄)); - const money = el("span", { text: `${L("M02_JudgeOursMoney")} ${moneyText(got?.금액 ?? cand.금액)}` }); // prettier-ignore - money.dataset.judgeMoney = cand.키; - box.append(bar([money])); - return box; - } - - function secBox(sec: Section, i: number): HTMLElement { - const box = el("div", { className: "m02-judge__sec", attrs: { "data-judge-sec": String(i) } }); - const title = `${L("M02_JudgeSection")} ${sec.번 ?? i + 1}. ${sec.제목}`; - const chips: HTMLElement[] = sec.후보.map((c) => { - const on = picks.some((x) => x.갈래 === "기초" && x.키 === c.키); - const chip = createToggle({ kind: "chip", label: `${c.키} ${c.이름}`, checked: on, disabled: opt.readOnly, onChange: () => put(on ? picks.filter((x) => !(x.갈래 === "기초" && x.키 === c.키)) : [...picks, basePick(c, sec)]) }); // prettier-ignore - chip.root.dataset.judgeBase = c.키; - return chip.root; - }); - if (!chips.length) chips.push(createText(L("M02_JudgeNoBase"), "warning")); - box.append(bar([createText(title, "muted", 14), badge(sec.판정, "judgeSec")]), bar(chips)); - for (const c of sec.후보) { - const cb = candBox(c); - if (cb) box.append(cb); - } - return box; - } - - function extraBox(): HTMLElement | null { - if (!opt.solved.덧.length) return null; - const rows: Cell[][] = opt.solved.덧.map((x) => { - const chips = (x.후보 ?? []).slice(0, 4).map((c) => { - const p = extraPick(x, c); - const on = picks.some((y) => samePick(y, p)); - const chip = createToggle({ kind: "chip", label: `${p.이름} ${p.규격 ?? ""}`.trim(), checked: on, disabled: opt.readOnly, onChange: () => put(togglePick(picks, p)) }); // prettier-ignore - chip.root.dataset.judgeExtra = String(p.키); - return chip.root; - }); - return [x.절, x.이름, x.방법 ?? "", bar(chips)]; - }); - const box = el("div", { className: "m02-judge__sec", attrs: { "data-judge-extra": "" } }); - box.append(createText(L("M02_JudgeExtra"), "muted", 14)); - box.append(createTable([{ label: L("M02_JudgeSection"), width: "8em" }, { label: L("M02_JudgeColExcel") }, { label: "" }, { label: L("M02_JudgeColPick") }], rows, { density: "data" })); // prettier-ignore - return box; - } - - function draw(): void { - const s = opt.solved; - const verdict = s.판정 === "고를 것" ? L("M02_JudgePickN").replace("{n}", String(s.고를수)) : s.판정; // prettier-ignore - const v = createBadge(verdict, TONE[s.판정] ?? "neutral"); - v.dataset.judgeBlockVerdict = s.판정; - const ours = picks.reduce( - (a, p) => { - const c = s.절.flatMap((x) => x.후보).find((x) => x.키 === p.키); - const m = (checked[String(p.키)]?.금액 ?? c?.금액) as Partial | undefined; - return a === null || !m || m.계 === null || m.계 === undefined ? null : a + m.계; - }, - picks.length ? 0 : (null as number | null), - ); - const head: HTMLElement[] = [v, el("span", { text: `${L("M02_JudgeOursMoney")} ${won(ours)}`, attrs: { "data-judge-ours": "" } })]; // prettier-ignore - if (opt.excel) head.push(el("span", { text: `${L("M02_JudgeExcelMoney")} ${moneyText(opt.excel)}`, attrs: { "data-judge-excel": "" } })); // prettier-ignore - if (opt.onCheck) { - const go = createButton({ label: L("M02_JudgeCheck"), size: 24, variant: "ghost", disabled: !picks.length, onClick: () => void check() }); // prettier-ignore - go.dataset.judgeCheck = ""; - head.push(go); - } - root.replaceChildren(bar(head), createText(L("M02_JudgeAmountNote"), "hint")); - s.절.forEach((sec, i) => root.append(secBox(sec, i))); - const extra = extraBox(); - if (extra) root.append(extra); - } - - draw(); - return { - root, - set(list: DraftPick[]): void { - picks = list; - draw(); - }, - }; -} - -export interface JudgeBlockOptions { - host: HTMLElement; - /** 파일 층 */ - file: HopyoScope; - key: string; - /** 만들 층 */ - target: HopyoScope; - state: JudgeState; - no: number; - onSheet?: (행: number) => void; - onClose?: () => void; - readOnly?: boolean; -} - -/** 단가산출 탭 줄 판 — 블록 하나 · [확인] = 그 블록 SK 로 옮길 것 */ -export function createJudgeBlock(opt: JudgeBlockOptions): { - root: HTMLElement; - destroy: () => void; -} { - const root = el("section", { - className: "m02-judge", - attrs: { "data-judge-block": String(opt.no) }, - }); - root.append(createText(L("M02_JudgeLoading"), "hint")); - opt.host.replaceChildren(root); - let alive = true; - - void readJudgeBlock(opt.file, opt.key, opt.no, opt.target) - .then((blk) => { - if (!alive) return; - const line = () => opt.state.get().블록[String(blk.번)]; - const ok = createToggle({ - kind: "chip", - label: L("M02_JudgeConfirm"), - checked: !!line()?.확인, - disabled: opt.readOnly, - onChange: (on) => { - const list = blockPicks(opt.state.get(), blk); - if (on && !confirmable(list)) { - ok.set(false); - showToast(`${L("M02_JudgeBlocked")} — ${list.flatMap(missing).join(" · ")}`, "warning"); - return; - } - opt.state.set(putBlock(opt.state.get(), blk.번, { 고른: list, 확인: on })); - }, - }); - ok.root.dataset.judgeOk = "block"; - const actions: HTMLElement[] = [ok.root]; - const open = createButton({ label: L("M02_CheckImported"), size: 24, variant: "ghost", disabled: opt.readOnly, onClick: () => void import("./M02_MasterTemplete_Hopyo_Imported").then((m) => m.openImported({ ...{ file: opt.file, key: opt.key, no: blk.번, target: opt.target, canWrite: !opt.readOnly } })) }); // prettier-ignore - open.dataset.judgeImported = String(blk.번); - // [내 단가산출로](807-1) — 늘 개인 층 · 만들 층이 개인이면 [가져온 식으로 열기]는 숨김(같은 일) - const mine = createButton({ label: L("M02_ToMySk"), size: 24, variant: "ghost", onClick: () => void import("./M02_MasterTemplete_Hopyo_Imported").then((m) => m.openImported({ ...{ file: opt.file, key: opt.key, no: blk.번, target: { layer: "personal", owner: null }, canWrite: true } })) }); // prettier-ignore - mine.dataset.judgeMine = String(blk.번); - open.hidden = opt.target.layer === "personal" && !opt.target.owner; - const fresh = createBadge(L("M02_CheckNew"), "accent", { size: 18 }); // 「새」(793-3b) — CheckRows.newBadge 꼴(돌림 import 피함) - fresh.dataset.checkNew = ""; - actions.push(open, mine, fresh); - if (opt.onClose) actions.push(createButton({ label: L("Common_Btn_Close"), size: 24, variant: "ghost", onClick: opt.onClose })); // prettier-ignore - const title = `${L("M02_JudgeBlock").replace("{n}", String(blk.번))} ${blk.이름} (${blk.단위})`; - const sub = [ - blk.근거?.표지, - blk.근거?.출처.map((x) => x.join(" ")).join(" · "), - blk.부름.length ? `${L("M02_JudgeCalls")} ${blk.부름.map((n) => L("M02_JudgeHopyo").replace("{n}", String(n))).join(" · ")}` : "", // prettier-ignore - blk.만든 ? `${L("M02_JudgeMade")} ${blk.만든}` : "", - ].filter(Boolean).join(" · "); // prettier-ignore - const body = solvedBody({ - solved: blk, - picks: blockPicks(opt.state.get(), blk), - excel: blk.금액, - readOnly: opt.readOnly, - onSheet: opt.onSheet, - onPicks: (list) => { - opt.state.set(putBlock(opt.state.get(), blk.번, { 고른: list, 확인: false })); - ok.set(false); - }, - onCheck: (list, signal) => checkJudge(opt.file, opt.key, { 블록: blk.번, 고른: list.map((p) => bare(p)) }, signal), // prettier-ignore - }); - root.replaceChildren(createPanelHead({ title, sub, actions, divider: true }).root, body.root); - }) - .catch((e: Error) => { - if (alive) root.replaceChildren(createText(e.message, "error")); - }); - - return { - root, - destroy(): void { - alive = false; - root.remove(); - }, - }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts deleted file mode 100644 index ec4e8fc5c..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ /dev/null @@ -1,360 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Hopyo_Check.ts - * 불러오기 [수정] 모달 「확인 — 제N호표」(PLAN 786-2 · 요구 644 · 645 · 785 전 옛 확인 창 꼴 되살림) — - * 엑셀 보기 오른쪽 호표 목록 [수정](`Hopyo_Sheet`)이 엶. - * · 머리 = 호표 · 엑셀 단가 · 제안 n / 줄 m · 확인 n · 만든 사전조합 · - * 단추 [호표 제안 모두 확인](새) · [사전조합 생성] · [구조물 조합으로] · [엑셀 보기] - * · 몸 = 로직 표 + 자재 표(구조물 조합 일위대가 상세 꼴 · `Hopyo_CheckRows`) · 줄 칸 부품 = `Hopyo_Rows.createRowKit` - * · 우리 금액 = 서버 `/hopyo/{키}/price`(옮기기와 같은 길로 한 줄 문서 → `Labor_Doc.calc` · 저장 없음 · - * 고름이 바뀐 줄만 · 묶어 한 번 · 줄마다 최신 응답만) - * · 고름 · 확인 = 판정 캐시(`createJudgeState` · 시트 칠 · 오른쪽 [옮기기 (n)] 과 한 벌) · 자동 확인 · 자동 옮김 없음 - * · [사전조합 생성] = 옮기기 길(`runMove`) · 이 호표의 확인한 줄만 · 서버가 정본으로 다시 셈 - * · [구조물 조합으로] = 이 호표가 사전조합으로 옮겨진 뒤만 · 구조물 조합 만들기 길(`Hopyo_ToItem`) - * · 단산 줄 [가져온 식으로 열기](793-3b) = `Hopyo_Imported` 모달(가져온 식 SK) · [이 줄에 잇기] = 그 줄 고른 것 = 그 SK - * · 행 번호 · [엑셀 보기] = 모달 닫고 시트 그 행(고름은 캐시로 남음 · 다시 [수정] 하면 그대로) - * · 옛 [맞음·별칭 일괄 체크] · [품셈으로 바꾸기] · [모두 고정으로] = 지운 서버 길 몫 — [호표 제안 모두 확인]이 갈음 - * ========================================================================== */ - -import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; -import { loadingError, showLoading } from "@ui/ui_template_loading"; -import { t as L } from "@ui/ui_template_locale"; -import { openModal } from "@ui/ui_template_modal"; -import { createText } from "@ui/ui_template_text"; -import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; -import { formatPrice } from "../common_util/common_util_money"; -import { call } from "./M02_MasterTemplete_Api_Fetch"; -import { at, HOPYO_BUILT, type HopyoBrief, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; -import { - checkJudge, - readJudgeBlock, - readJudgeOne, - type JudgeOne, - type JudgeRow, - type Pick, -} from "./M02_MasterTemplete_Hopyo_JudgeApi"; -import { - checkTables, - newBadge, - NO_LOGIC, - type LinePrice, - type PriceLine, -} from "./M02_MasterTemplete_Hopyo_CheckRows"; -import { - createRowKit, - runMove, - type JudgeState, - type SheetRole, -} from "./M02_MasterTemplete_Hopyo_Rows"; -import { openImported } from "./M02_MasterTemplete_Hopyo_Imported"; -import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem"; -import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api"; -import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { - bare, - confirmSuggested, - lineOf, - moveBody, - samePick, - setInput, - type DraftPick, -} from "./logic/M02_MasterTemplete_Hopyo_Picks"; -import "./M02_MasterTemplete_Hopyo.css"; - -const enc = encodeURIComponent; -const why = (e: unknown): string => (e instanceof Error ? e.message : String(e)); -const note = (text: string): HTMLElement => createText(text, "muted"); -const won = (v: number | null | undefined): string => (v === null || v === undefined ? "—" : formatPrice(v)); // prettier-ignore - -/** 우리 금액 미리 셈 — 경로 층 = 만들 층 · 몸 층 = 파일 층(저장 없음) */ -const priceJudge = ( - target: HopyoScope, - file: HopyoScope, - key: string, - no: number, - 줄: Record, -): Promise<{ 줄: Record }> => - call(at(target, `/hopyo/${enc(key)}/price`), { - method: "POST", - body: JSON.stringify({ 층: file.layer, ...(file.owner ? { 주인: file.owner } : {}), 호표: no, 줄 }), // prettier-ignore - }); - -export interface CheckOptions { - /** 실무자료 파일 층 */ - file: HopyoScope; - key: string; - /** 사전조합 · 구조물 조합을 만들 층 */ - target: HopyoScope; - state: JudgeState; - no: number; - /** 목록 한 줄(엑셀 단가 · 제안 n / 줄 m) */ - brief?: HopyoBrief; - canWrite: boolean; - /** 모달 닫은 뒤 엑셀 보기 그 행 · 그 호표 */ - onSheet: (행: number, role: SheetRole) => void; - onHopyo: () => void; - /** 옮긴 뒤(목록 · 칠 다시 읽음) */ - onMoved: () => void; -} - -/** [수정] 모달 */ -export function openHopyoCheck(opt: CheckOptions): { close: () => void } { - const readOnly = !opt.canWrite; - const no = opt.no; - const root = el("div", { className: "m02-hopyo m02-hopyo-check", attrs: { "data-hopyo-check": `${opt.key}|${no}` } }); // prettier-ignore - let one: JudgeOne | null = null; - let shut: () => void = () => {}; - let off: () => void = () => {}; - let busy = false; - const leave = (): void => { - off(); - shut(); - }; - - // ── 우리 금액(줄마다 최신 응답만 · 바뀐 줄만 묶어 한 번) ── - const priced = new Map(); - let queued: Record = {}; - let seq = 0; - let timer = 0; - let pending = false; - function soon(): void { - if (pending) return; - pending = true; - window.setTimeout(() => { - pending = false; - if (root.isConnected) draw(); - }, 0); - } - function flush(): void { - const ask = queued; - queued = {}; - const my = ++seq; - for (const k of Object.keys(ask)) { - const slot = priced.get(Number(k)); - if (slot) slot.seq = my; - } - priceJudge(opt.target, opt.file, opt.key, no, ask).then( - (got) => { - for (const k of Object.keys(ask)) { - const slot = priced.get(Number(k)); - const line = got.줄[k]; - if (slot?.seq === my) slot.val = line ? { got: line } : { fail: L("M02_CheckNoCalc") }; - } - soon(); - }, - (e: unknown) => { - for (const k of Object.keys(ask)) { - const slot = priced.get(Number(k)); - if (slot?.seq === my) slot.val = { fail: why(e) }; - } - soon(); - }, - ); - } - function price(row: JudgeRow): LinePrice | null { - const picks = kit.picksOf(row); - if (!picks.length) return null; - const 배 = lineOf(opt.state.get(), no, row.차례)?.배; - const body = picks.map((p) => bare(p, 배)); - const sig = JSON.stringify(body); - const have = priced.get(row.차례); - if (have?.sig === sig) return have.val; - priced.set(row.차례, { sig, seq: -1, val: { wait: true } }); - queued[String(row.차례)] = body; - window.clearTimeout(timer); - timer = window.setTimeout(flush, 250); - return { wait: true }; - } - - const kit = createRowKit({ - file: opt.file, - key: opt.key, - state: opt.state, - no, - readOnly, - redraw: () => draw(), - onSheet: (r, role) => { - leave(); - opt.onSheet(r, role); - }, - }); - - /** [원문 보기] — 엑셀 인용 조항(단산 줄 = 블록 근거) · 우리 로직 출처 */ - async function showSource(row: JudgeRow): Promise { - const blk = row.번 !== undefined ? await readJudgeBlock(opt.file, opt.key, row.번, opt.target).catch(() => null) : null; // prettier-ignore - const excel = (blk?.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean); - const codes = kit.picksOf(row).flatMap((p) => (p.갈래 === "기초" && p.키 ? [p.키] : [])); - const ours = (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) => - x.출처 ? [`${x.로직} — ${x.출처}`] : [], - ); - openModal({ - title: `${L("M02_CheckSource")} — ${row.이름 ?? ""}`, - closeLabel: L("Common_Btn_Close"), - wide: true, - dialogClass: "m02-modal m02-hopyo__src", - mount: (body) => { - if (row.근거) body.append(note(row.근거)); - const part = (title: string, list: string[], cut: (s: string) => string): void => { - body.append(el("h4", { text: title })); - if (!list.length) body.append(note(L("M02_CheckNone"))); - for (const s of list) { - const view = createSourceView({ path: "/m02/logic-lib/source", pdf: "/m02/logic-lib/source-pdf" }); // prettier-ignore - view.show(cut(s)); - body.append(note(s), view.root); - } - }; - part(L("M02_CheckSourceExcel"), excel, (s) => s); - part(L("M02_CheckSourceOurs"), ours, (s) => s.split(" — ").slice(1).join(" — ")); - }, - }); - } - - /** 기초 하나 바꿔 넣음(같은 것 자리) */ - const swap = (row: JudgeRow, from: DraftPick, to: DraftPick): void => - kit.edit( - row, - kit.picksOf(row).map((x) => (samePick(x, from) ? to : x)), - ); - - /** [옵션] — 그 기초 입력값 모달(구조물 조합 상세와 같은 창) · 바뀐 칸 = 출처 「사용자」 */ - function options(row: JudgeRow, p: DraftPick): void { - fetchLogicInputs([String(p.키)]).then( - (res) => - openLaborInput({ - title: `${p.키} ${p.이름}`.trim(), - logic: res.로직[0] ?? null, - values: p.입력값 as never, - memo: null, - readOnly, - done: (r) => { - let next = p; - const got = r.입력값 as Record; - for (const k of new Set([...Object.keys(got), ...Object.keys(p.입력값)])) - if (got[k] !== p.입력값[k]) next = setInput(next, k, got[k] ?? ""); - swap(row, p, next); - }, - }), - (e: unknown) => showToast(why(e), "error"), - ); - } - - /** [고르기] — 로직 찾기 창(구조물 조합 [로직 추가]와 같은 창) → 검산으로 입력값 · 입력 필요 받아 더함 */ - function pick(row: JudgeRow): void { - openLogicPicker((logic) => { - const raw: DraftPick = { 갈래: "기초", 키: logic.키, 이름: logic.이름, 입력값: {}, 출처: {}, 몫: null }; // prettier-ignore - checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: [bare(raw)] }).then( - (res) => { - const c = res.고른[0]; - const p: DraftPick = { ...raw, 입력값: c?.입력값 ?? {}, 필요: c?.입력필요 ?? [] }; - kit.edit(row, [...kit.picksOf(row).filter((x) => !samePick(x, p)), p]); - }, - (e: unknown) => showToast(why(e), "error"), - ); - }); - } - - /** [가져온 식으로 열기](793-3b) — 단산 블록 → 가져온 식 SK 모달 · [이 줄에 잇기] = 이 줄 고른 것 */ - const imported = (row: JudgeRow): void => - openImported({ - ...{ file: opt.file, key: opt.key, no: row.번!, target: opt.target, canWrite: opt.canWrite }, - onLink: (p) => kit.edit(row, [p]), - }); - - /** 추가(빈 값) = 제안으로 되돌림 · 로직없음 = 고정 줄(엑셀 숫자 그대로) */ - function out(row: JudgeRow, value: string): void { - if (value === NO_LOGIC) { - kit.edit(row, [{ 갈래: "고정", 키: null, 이름: row.이름 ?? "", 입력값: {}, 출처: {}, 몫: null }]); // prettier-ignore - return; - } - const d = opt.state.get(); - const lines = { ...(d.호표[String(no)] ?? {}) }; - delete lines[String(row.차례)]; - opt.state.set({ ...d, 호표: { ...d.호표, [String(no)]: lines } }); - } - - /** [사전조합 생성] — 이 호표의 확인한 줄만 옮김 */ - async function make(): Promise { - const all = moveBody(opt.state.get(), opt.file); - const mine = all.body.호표[String(no)] ?? {}; - const n = Object.keys(mine).length; - if (!n) return void showToast(L("M02_JudgeMoveNone"), "info"); - const ask = L("M02_CheckMakeAsk").replace("{a}", String(n)).replace("{b}", String((one?.줄.length ?? 0) - n)); // prettier-ignore - if (!(await showConfirmDialog(ask, L("M02_CheckMake")))) return; - busy = true; - draw(); - const stuck = all.막힘.filter((x) => x.호표 === no); - const r = await runMove(opt, { ...all.body, 호표: { [String(no)]: mine }, 블록: {} }, stuck); - busy = false; - if (r) { - document.dispatchEvent(new Event(HOPYO_BUILT)); - opt.onMoved(); - await load(true); - } else draw(); - } - - function draw(): void { - if (!one) return; - const h = one; - const done = h.줄.filter((r) => lineOf(opt.state.get(), no, r.차례)?.확인).length; - const [a, m] = opt.brief?.맞춤 ?? [h.줄.filter((r) => r.판정 === "제안 있음").length, h.줄.length]; // prettier-ignore - const all = createButton({ label: L("M02_JudgeConfirmAll"), size: "sm", variant: "ghost", disabled: readOnly, onClick: () => opt.state.set(confirmSuggested(opt.state.get(), no, h.줄)) }); // prettier-ignore - all.dataset.judgeAll = String(no); - const go = createButton({ label: L("M02_CheckMake"), size: "sm", variant: "filled", disabled: readOnly || busy || !done, onClick: () => void make() }); // prettier-ignore - go.dataset.hopyoMake = ""; - const item = createButton({ label: L("M02_CheckToItem"), size: "sm", disabled: readOnly || !h.만든, onClick: () => void toItem({ target: opt.target, sc: h.만든!, name: [h.이름, h.규격].filter(Boolean).join(" "), unit: h.단위 }) }); // prettier-ignore - item.dataset.hopyoToItem = ""; - item.title = h.만든 ? "" : L("M02_CheckToItemHint"); - const sheet = createButton({ label: L("M02_CheckSheet"), size: "sm", onClick: () => { leave(); opt.onHopyo(); } }); // prettier-ignore - sheet.dataset.hopyoSheet = ""; - const head = el("div", { - className: "ui-toolbar m02-hopyo__bar m02-judge__wrap", - children: [ - el("strong", { text: `${L("M02_JudgeHopyo").replace("{n}", String(no))} ${h.이름}` }), - note(h.규격), - note(h.단위), - note(`${L("M02_CheckExcelPrice")} ${won(opt.brief?.금액.계)}`), - note(L("M02_CheckMoneyHint")), - note(L("M02_CheckCount").replace("{a}", String(a)).replace("{m}", String(m)).replace("{c}", String(done))), // prettier-ignore - ...(h.만든 ? [note(`${L("M02_JudgeMade")} ${L("M02_PrecomboTitle")} ${h.만든}`)] : []), - newBadge(), - all, - go, - item, - sheet, - ], - }); - const legend = el("div", { className: "ui-toolbar m02-judge__wrap", children: [newBadge(), note(L("M02_CheckNewHint"))] }); // prettier-ignore - const ctx = { kit, readOnly, price, pick, source: (r: JudgeRow) => void showSource(r), options, out, imported }; // prettier-ignore - root.replaceChildren(head, legend, ...(h.줄.length ? checkTables(h.줄, ctx) : [note(L("M02_CheckNoLines"))])); // prettier-ignore - } - - async function load(quiet = false): Promise { - const stop = quiet ? () => {} : showLoading(root); - try { - one = await readJudgeOne(opt.file, opt.key, no, opt.target); - draw(); - } catch (e) { - if (!quiet) root.replaceChildren(loadingError(why(e), () => void load())); - } finally { - stop(); - } - } - - openModal({ - title: L("M02_CheckTitle").replace("{n}", String(no)), - closeLabel: L("Common_Btn_Close"), - wide: true, - dialogClass: "m02-modal m02-hopyo__check-modal", - beforeClose: () => { - off(); - return true; - }, - mount: (body, close) => { - shut = close; - body.append(root); - }, - }); - off = opt.state.on(() => draw()); - void load(); - return { close: leave }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts deleted file mode 100644 index 5e60de77f..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts +++ /dev/null @@ -1,331 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Hopyo_CheckRows.ts - * [수정] 모달 줄 표(PLAN 786-2 · 요구 644 · 옛 359-3 꼴 되살림) — 구조물 조합 일위대가 상세 꼴 - * (`Labor_Rows.moneyCell` · `PRICE_SLOTS` · `Labor_MatMarks` 빌려 씀 · 그 파일은 안 고침)에 새 판정 칸(785)을 얹음. - * · 로직 표 = [확인] · 행 · 구분(엑셀 갈래 + 1차 판정) · 이름(고름 칩 · 단산 [블록 풀이] · 셈 못 함 까닭) · - * 별칭(엑셀 품명) · 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배 · 단위 환산 칸) · 단위 · - * 노무비 · 재료비 · 경비 · 합계(위 엑셀 단가 / 아래 우리 = 서버 미리 셈 ÷ 엑셀 수량) · 품 비교 · 단추 - * · 자재 표 = [확인] · 행 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(글만) · 가격 다섯 · 엑셀 / 우리 합계 · 단추 - * · 단추 = [고르기] · [원문 보기] · [옵션](기초마다) · 추가 / 로직없음(= 고정 줄) — 옛 「사용안함」(뺌)은 새 옮기기 길에 없어 안 살림 - * · 「새」 배지(785 새 기능) = 머리 칸 [확인] · 구분(판정) · 이름(고름 · 블록 풀이) · 수량산출(단위 환산) - * · 단산 줄(793-3b) = 판정 칩 「2차 필요」 대신 결과 칩(가져온 식 있음 · 근거 확정 · 근접 · 사람 고름 · 입력 필요 n · - * SK 안 이었으면 블록 풀이 판정) · 단추 [가져온 식으로 열기](새 · `Hopyo_Imported` 모달) - * 줄 표 = `tableOf`(고른 것이 모두 재료 · 유가 · 없으면 엑셀 갈래 · 별도계상 줄 = 자재 표). - * ========================================================================== */ - -import { createBadge } from "@ui/ui_template_badge"; -import { createButton, createSelectField, el } from "@ui/ui_template_elements"; -import { t as L } from "@ui/ui_template_locale"; -import { formatPrice } from "../common_util/common_util_money"; -import type { JudgeRow } from "./M02_MasterTemplete_Hopyo_JudgeApi"; -import { apartRow, bar, show, type RowKit } from "./M02_MasterTemplete_Hopyo_Rows"; -import type { CalcMaterial } from "./M02_MasterTemplete_Labor_Calc"; -import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; -import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows"; -import { tc } from "./M02_MasterTemplete_Labor_Text"; -import { rowChips } from "./logic/M02_Hopyo_Imported_Model"; -import type { DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks"; - -type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number | null>; - -/** 서버 미리 셈 한 줄(`/hopyo/{키}/price`) */ -export interface PriceLine { - 금액: Money4 | null; - 자재?: CalcMaterial[]; - 까닭: string[]; - 칸: string[]; -} -/** 줄 금액 — 셈 중 = wait · 고른 것 없음 = null */ -export type LinePrice = { wait: true } | { got: PriceLine } | { fail: string }; - -export interface CheckCtx { - kit: RowKit; - readOnly: boolean; - price: (row: JudgeRow) => LinePrice | null; - pick: (row: JudgeRow) => void; - source: (row: JudgeRow) => void; - options: (row: JudgeRow, p: DraftPick) => void; - /** 추가(빈 값 = 제안으로 되돌림) · 로직없음(고정 줄) */ - out: (row: JudgeRow, value: string) => void; - /** 단산 줄 [가져온 식으로 열기](793-3b) */ - imported?: (row: JudgeRow) => void; -} - -const COSTS = ["노무비", "재료비", "경비", "계"] as const; -const COST_HEAD = { - 노무비: "Prev_Labor", - 재료비: "Prev_Material", - 경비: "Prev_Expense", - 계: "Prev_Sum", -} as const; -const MAT_KINDS = ["재료", "유가"]; -export const NO_LOGIC = "로직없음"; - -const small = (text: string, cls = "m02-labrow__note"): HTMLElement => - el("small", { className: cls, text, attrs: { title: text } }); - -/** 「새」 배지(785 새 기능 표지) */ -export function newBadge(): HTMLElement { - const b = createBadge(L("M02_CheckNew"), "accent", { size: 18 }); - b.dataset.checkNew = ""; - b.title = L("M02_CheckNewHint"); - return b; -} - -/** 줄이 들어갈 표 */ -export function tableOf(row: JudgeRow, picks: DraftPick[]): "logic" | "mat" { - if (apartRow(row)) return "mat"; - if (picks.length) return picks.every((p) => MAT_KINDS.includes(p.갈래)) ? "mat" : "logic"; - return row.갈래.length > 0 && row.갈래.every((k) => MAT_KINDS.includes(k)) ? "mat" : "logic"; -} - -/** 우리 금액 → 엑셀 1 단위(서버 반영합 ÷ 엑셀 수량 · 수량 없음 = 그대로) */ -function perUnit(v: number | null | undefined, row: JudgeRow): number | null { - if (v === null || v === undefined) return null; - const q = row.수량.엑셀; - return q ? v / q : v; -} - -const priced = (p: LinePrice | null): PriceLine | null => (p && "got" in p ? p.got : null); -const waiting = (p: LinePrice | null): boolean => !!p && "wait" in p; - -/** 이름 칸 덧글 — 셈 못 함 까닭 · 입력 필요 칸 */ -function notes(p: LinePrice | null): HTMLElement[] { - const got = priced(p); - const why = [...(p && "fail" in p ? [p.fail] : []), ...(got?.까닭 ?? [])]; - const out = why.map((t) => small(`${L("M02_CheckNoCalc")}: ${t}`, "ui-text ui-text--error")); - if (got?.칸.length) out.push(small(`${L("M02_JudgeNeed")}: ${got.칸.join(" · ")}`, "ui-text ui-text--error")); // prettier-ignore - return out; -} - -/** 품 비교 — 고른 기초의 줄 대조 판정 수(단산 줄 · 블록 풀이) */ -export function compareText(row: JudgeRow, picks: DraftPick[]): string { - const keys = new Set(picks.map((p) => p.키)); - const count = new Map(); - for (const sec of row.풀이?.절 ?? []) - for (const c of sec.후보) - if (keys.has(c.키)) for (const x of c.줄) count.set(x.판정, (count.get(x.판정) ?? 0) + 1); - return [...count].map(([k, n]) => `${k} ${n}`).join("\n"); -} - -/** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배) + 단위 환산 칸 */ -function qtyCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement { - const 배 = ctx.kit.timesOf(row); - const ex = row.수량.엑셀; - const ours = ex === null || 배 === null ? "—" : show(ex * 배); - const unit = ctx.kit.unitInput(row, picks); - return el("td", { - className: "m02-labrow__qty", - attrs: { "data-label": tc("Labor_QtyCol") }, - children: [ - el("small", { className: "m02-labrow__base", text: ex === null ? "—" : `${show(ex)} ${row.단위 ?? ""}`.trim(), attrs: { title: L("M02_JudgeColExcel") } }), // prettier-ignore - el("span", { className: "m02-labrow__applied", text: ours, attrs: { title: L("M02_JudgeColOurs") } }), // prettier-ignore - ...(unit ? [unit] : []), - ], - }); -} - -function actsCell(row: JudgeRow, ctx: CheckCtx, picks: DraftPick[]): HTMLElement { - const ghost = (label: string, run: () => void, at: string): HTMLElement => { - const b = createButton({ label, variant: "ghost", size: "sm", onClick: run }); - b.dataset.checkAct = at; - return b; - }; - const kids: HTMLElement[] = []; - if (!ctx.readOnly && !apartRow(row)) kids.push(ghost(L("M02_CheckPick"), () => ctx.pick(row), "pick")); // prettier-ignore - if (row.번 !== undefined && ctx.imported) { - const b = ghost(L("M02_CheckImported"), () => ctx.imported!(row), "imported"); - (b as HTMLButtonElement).disabled = ctx.readOnly; - kids.push(el("span", { className: "m02-judge__wrap", children: [b, newBadge()] })); - } - kids.push(ghost(L("M02_CheckSource"), () => ctx.source(row), "source")); - for (const p of picks.filter((x) => x.갈래 === "기초")) { - const b = ghost(L("M02_CheckOption"), () => ctx.options(row, p), "option"); - b.title = `${p.키} ${L("M02_CheckOption")}`; - kids.push(b); - } - if (!apartRow(row)) { - const fixed = picks.length === 1 && picks[0].갈래 === "고정"; - const out = createSelectField({ - options: [ - { value: "", text: L("M02_CheckAdd") }, - { value: NO_LOGIC, text: L("M02_CheckNoLogic") }, - ], - value: fixed ? NO_LOGIC : "", - compact: true, - ariaLabel: L("M02_CheckNoLogic"), - onChange: (v) => ctx.out(row, v), - }); - out.select.disabled = ctx.readOnly; - out.select.dataset.checkOut = String(row.차례); - out.root.title = L("M02_CheckNoLogicHint"); - kids.push(out.root); - } - return el("td", { className: "m02-hopyo__acts", children: kids }); -} - -const rowOf = ( - row: JudgeRow, - ctx: CheckCtx, - picks: DraftPick[], - cells: HTMLElement[], -): HTMLElement => - // prettier-ignore - el("tr", { - className: "m02-labrow__row m02-hopyo__line", - attrs: { "data-line": String(row.차례), "data-verdict": row.판정 }, - children: [ - el("td", { children: [ctx.kit.okChip(row, picks)] }), - el("td", { className: "m02-labrow__no", children: [ctx.kit.rowJump(row)] }), - ...cells, - actsCell(row, ctx, picks), - ], - }); - -/** 단산 줄 결과 칩(793-3b) — SK 고른 것 표지 · 없으면 블록 풀이 판정 · 단산 아니면 판정 칩 */ -function verdictOf(row: JudgeRow, ctx: CheckCtx): HTMLElement[] { - if (row.번 === undefined) return [ctx.kit.verdict(row)]; - const got = rowChips(ctx.kit.picksOf(row)); - const list = got ?? (row.풀이?.판정 ? [row.풀이.판정] : []); - if (!list.length) return [ctx.kit.verdict(row)]; - return list.map((text) => { - const tone = text.startsWith("입력 필요") ? "danger" : text.includes("확정") || text.startsWith("가져온") ? "success" : text.includes("근접") ? "warning" : "info"; // prettier-ignore - const b = createBadge(text, tone); - b.dataset.judgeResult = text; - return b; - }); -} - -const kindCell = (row: JudgeRow, ctx: CheckCtx): HTMLElement => - el("td", { - className: "m02-labrow__kind", - attrs: { title: row.근거 }, - children: [bar([...row.갈래.map((k) => createBadge(k, "neutral")), ...verdictOf(row, ctx)])], - }); - -function logicRow(row: JudgeRow, ctx: CheckCtx): HTMLElement[] { - const picks = ctx.kit.picksOf(row); - const p = ctx.price(row); - const got = priced(p); - const wait = waiting(p); - const unit = picks[0]?.단위 || row.단위 || ""; - const tr = rowOf(row, ctx, picks, [ - kindCell(row, ctx), - el("td", { className: "m02-labrow__name m02-hopyo__parts", children: [ctx.kit.chips(row, picks), ...notes(p)] }), // prettier-ignore - el("td", { - className: "m02-labrow__alias", - attrs: { title: `${row.이름 ?? ""} ${row.규격 ?? ""}`.trim() }, - children: [row.이름 ?? "", ...(row.규격 ? [small(row.규격)] : [])], - }), - qtyCell(row, ctx, picks), - el("td", { className: "m02-labrow__unit", text: unit }), - ...COSTS.map( - (c) => - moneyCell(row.단가?.[c] ?? null, got ? perUnit(got.금액?.[c], row) : null, wait, tc(COST_HEAD[c])), // prettier-ignore - ), - el("td", { className: "m02-hopyo__compare", text: compareText(row, picks) }), - ]); - const body = ctx.kit.solved(row); - if (!body) return [tr]; - const wide = el("td", { children: [body], attrs: { colspan: "15" } }); - return [tr, el("tr", { className: "m02-hopyo__solved", children: [wide] })]; -} - -function matRow(row: JudgeRow, ctx: CheckCtx): HTMLElement { - const picks = ctx.kit.picksOf(row); - const p = ctx.price(row); - const got = priced(p); - const wait = waiting(p); - const mats = got?.자재 ?? []; - const first = mats[0]; - const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null; - const cell = (v: number | null | undefined): string => - wait ? "…" : v === null || v === undefined ? "—" : formatPrice(v); - const pk = picks[0]; - const apart = apartRow(row) || !!pk?.별도계상; - return rowOf(row, ctx, picks, [ - el("td", { className: "m02-labrow__code", text: picks.map((x) => x.키 ?? "").filter(Boolean).join(" · ") || "—" }), // prettier-ignore - el("td", { - className: "m02-labrow__name m02-hopyo__parts", - children: [ctx.kit.chips(row, picks), small(`${L("M02_JudgeColExcel")} ${row.이름 ?? ""}`.trim()), ...notes(p)], // prettier-ignore - }), - el("td", { className: "m02-labrow__spec", text: pk?.규격 || row.규격 || "", attrs: { title: pk?.규격 || row.규격 || "" } }), // prettier-ignore - qtyCell(row, ctx, picks), - el("td", { className: "m02-labrow__unit", text: pk?.단위 || row.단위 || "" }), - ...markCells({ 별도계상: apart ? "별도계상" : "직접계산" }), - ...PRICE_SLOTS.map((slot) => - el("td", { - className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`, - text: cell(first?.가격?.[slot]), - attrs: { "data-label": slot, "data-price-slot": slot }, - }), - ), - moneyCell(row.단가?.계 ?? null, got ? perUnit(ours, row) : null, wait, tc("Labor_AppliedSum")), - ]); -} - -/** 머리 칸 — 글 · 새 기능이면 「새」 배지 */ -const head = (text: string, fresh = false): HTMLElement => - el("th", { children: fresh ? [text, " ", newBadge()] : [text] }); - -const grid = (heads: HTMLElement[], rows: HTMLElement[], kind: string): HTMLElement => - el("div", { - className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", - children: [ - el("table", { - className: "ui-general-block__table m02-labrow m02-hopyo__table m02-hopyo__lines", - attrs: { "data-hopyo-table": kind }, - children: [ - el("thead", { children: [el("tr", { children: heads })] }), - el("tbody", { children: rows }), - ], - }), - ], - }); - -/** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */ -export function checkTables(rows: JudgeRow[], ctx: CheckCtx): HTMLElement[] { - const logic = rows.filter((r) => tableOf(r, ctx.kit.picksOf(r)) === "logic"); - const mat = rows.filter((r) => !logic.includes(r)); - const first = (): HTMLElement[] => [head(L("M02_JudgeColOk"), true), head(L("M02_JudgeColRow"))]; // 표마다 새 노드 - const out: HTMLElement[] = []; - if (logic.length) - out.push( - grid( - [ - ...first(), - head(`${tc("Sub")} · ${L("M02_JudgeColVerdict")}`, true), - head(`${tc("Col_Name")} · ${L("M02_JudgeColPick")}`, true), - head(tc("Col_Alias")), - head(tc("Labor_QtyCol"), true), - head(tc("Col_Unit")), - ...COSTS.map((c) => head(tc(COST_HEAD[c]))), - head(L("M02_CheckCompare")), - head(""), - ], - logic.flatMap((r) => logicRow(r, ctx)), - "logic", - ), - ); - if (mat.length) - out.push( - el("h4", { className: "m02-hopyo__subhead", text: L("M02_CheckMat") }), - grid( - [ - ...first(), - head(tc("Labor_Code")), - head(`${tc("Col_Name")} · ${L("M02_JudgeColPick")}`, true), - head(tc("Labor_Spec")), - head(tc("Labor_QtyCol"), true), - head(tc("Labor_Unit")), - ...markHeads().map((t) => head(t)), - ...PRICE_SLOTS.map((t) => head(t)), - head(`${L("M02_JudgeColExcel")} / ${L("M02_JudgeColOurs")} ${tc("Labor_AppliedSum")}`), - head(""), - ], - mat.map((r) => matRow(r, ctx)), - "mat", - ), - ); - return out; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts index 4c4a2c52a..5d41aa753 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Import.ts @@ -2,9 +2,9 @@ * M02_MasterTemplete_Hopyo_Import.ts * 일위대가 불러오기 본 칸 보기(PLAN 785-4 · 요구 642-11 — 마법사 `Hopyo_Wizard` 갈음 · 가운데 상세 페이지 없음) — * 왼쪽 [불러오기] → 층 · 파일 고름 → 올림(`Hopyo_Side`) → 바로 이 보기: - * · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 제안)의 머리에 층 · [끝내기]를 덧붙임 - * · 아래 = 엑셀(탭 셋) + 오른쪽 호표 · 단가산출 목록(`Hopyo_Sheet` · `Hopyo_SheetSk`) · 만들기 = 목록 아래 [옮기기] - * · [끝내기] = 물음(확인하고 안 옮긴 수 · 안 옮긴 단가산출 알림) → 서버가 이번 HP · 격자 · 블록 풀이 저장본 지움 · + * · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 연결)의 머리에 층 · [끝내기]를 덧붙임 + * · 아래 = 엑셀(탭 셋) + 오른쪽 호표 · 단가산출 목록(`Hopyo_Sheet` · `Hopyo_SheetSk`) · 만들기 = 호표 줄 [내 일위대가로](807-1) + * · [끝내기] = 물음(안 만든 단가산출 알림) → 서버가 이번 HP · 격자 저장본 지움 · * 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음 · 옛 「안 이은 줄」 띠는 785-7 에서 지움(판정 「고를 것」) * · 작업 중 = 이 화면 동안만(743-1) — 화면 떠남(`leave` · 페이지 닫음) = 이 화면에서 올리거나 이은 파일 지움 · * 하루 지난 것은 서버가 지움 · 엑셀 자료는 보관하지 않음(필요하면 다시 올림) @@ -17,7 +17,6 @@ import { createText } from "@ui/ui_template_text"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { deleteHopyo, type HopyoFile, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; import type { HopyoSheetBody } from "./M02_MasterTemplete_Hopyo_Sheet"; -import { draftKey, moveBody, parseDraft } from "./logic/M02_MasterTemplete_Hopyo_Picks"; import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer"; import "./M02_MasterTemplete_Hopyo.css"; @@ -48,7 +47,7 @@ export interface HopyoImport { root: HTMLElement; /** 화면 떠남 — 이 화면에서 올리거나 이은 파일 지움(743-1) */ leave: () => void; - /** 확인하고 안 옮긴 판정 고름이 있음 — 다른 엑셀로 옮기기 전 물음 */ + /** 안 옮긴 고름 — 판정 고름 캐시 걷음(809-1) 뒤 늘 거짓 */ dirty: () => boolean; } @@ -76,17 +75,12 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport { const finish = createButton({ label: "끝내기", variant: "filled", size: 28, onClick: () => void end() }); // prettier-ignore finish.dataset.hopyoFinish = ""; const layerNote = createBadge(`${layerText(layer)} 층`, "neutral"); - /** 확인하고 안 옮긴 줄 · 블록 수(판정 고름 캐시 — 옮기면 캐시에서 빠짐) */ - const unmoved = (): number => - moveBody(parseDraft(sessionStorage.getItem(draftKey(layer, null, file.키))), scope).수; async function end(): Promise { finish.disabled = true; - const loose = unmoved(); const w = body?.skCount(); // 안 옮긴 단가산출(일위대가 밖 포함) — 묻기만 const ok = await showConfirmDialog( `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 블록 풀이)을 지움 · 만든 일위대가 · 단가산출(출처만 남김)과 별칭 장부는 남음 · 같은 엑셀은 필요하면 다시 올림` + - (loose ? ` · 확인하고 안 옮긴 줄 · 블록 ${loose} 개는 빠짐` : "") + (w?.블록 ? ` · ${L("M02_SkUnmoved").replace("{n}", String(w.블록)).replace("{m}", String(w.밖))}` : ""), // 780-2 막지 않음 @@ -127,6 +121,6 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport { return { root, leave, - dirty: () => unmoved() > 0, + dirty: () => false, }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts index 359c3ae8e..443a3770b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Imported.ts @@ -1,39 +1,27 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Imported.ts - * 불러오기 단산 줄 [가져온 식으로 열기] 모달(PLAN 793-3b · 요구 646 · 647 · 652 갈래 C) — - * [수정] 모달 단산 줄(`Hopyo_Check`) · 오른쪽 목록 단가산출 탭 블록(`Hopyo_Block`)이 엶. + * 불러오기 단산 블록 [가져온 식으로 열기] 모달(PLAN 793-3b · 809-1 · 요구 646 · 702) — + * 엑셀 보기 단가산출 탭 목록(`Hopyo_SheetSk`)이 엶 · 경쟁사 로직 그대로(기초로 바꾸기 없음 · 요구 703). * · 열기 = 서버 `…/sangeun/from-hopyo`(그 블록 → 가져온 식 SK · 만들 층 · 엑셀 단가 그대로 · 같은 내용 = 그 SK 다시 씀) * → 그 SK 를 읽어 우리 단가산출 화면(`Sangeun_Detail` 시트 · 고침 = 시트 그대로) + 머리 판(`Hopyo_ImportedPanel`) - * · [우리 단가로] · [엑셀 단가로] · [이음 고르기] · [우리 기초로 바꾸기] = 서버 `…/sangeun/imported/{price|link|to-base}` - * (화면 캐시 문서 고침 · 저장 없음) → 시트 다시 엶 · 기초로 바꿀 때 옵션 = 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본) → - * 블록 검산으로 입력 필요 · 단위 성격이 다르면 「환산 계수」도 입력 필요 + * · [우리 단가로] · [엑셀 단가로] · [이음 고르기] = 서버 `…/sangeun/imported/{price|link}` + * (화면 캐시 문서 고침 · 저장 없음) → 시트 다시 엶 * · [이음 보기](797-2) = 소요량 · 계수 후보 모달(`Hopyo_QtyLinks`) — 고르기 · 버리기 = 서버 `…/imported/{qty|qty-pick|qty-drop}`(기록만 · 금액 그대로) - * · [저장] = 그 SK 저장(서버가 다시 셈) · [이 줄에 잇기] = 저장 뒤 단산 줄 고른 것 = 그 SK(갈래 단가산출 · 키) — - * 확인 · [사전조합 생성]은 기존 길(자동 확인 · 옮김 없음) · 입력 필요가 비면 그 줄 확인 막힘 + * · [저장] = 그 SK 저장(서버가 다시 셈) * ========================================================================== */ import { createButton } from "@ui/ui_template_button"; import { el, showToast } from "@ui/ui_template_elements"; -import { createText } from "@ui/ui_template_text"; import { loadingError, showLoading } from "@ui/ui_template_loading"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { askConfirm, openModal } from "@ui/ui_template_modal"; import { call, readTemplate, saveTemplate } from "./M02_MasterTemplete_Api_Fetch"; import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; import { at, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; -import { checkJudge, readJudgeBlock } from "./M02_MasterTemplete_Hopyo_JudgeApi"; -import { importedPanel, tx, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel"; +import { importedPanel, type PanelState } from "./M02_MasterTemplete_Hopyo_ImportedPanel"; import { openQtyLinks, type QtyRow, type QtyTally } from "./M02_MasterTemplete_Hopyo_QtyLinks"; import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail"; -import { - marksOf, - skPick, - type BasisCand, - type ImportedDoc, - type Money4, - type Tier, -} from "./logic/M02_Hopyo_Imported_Model"; -import { bare, type DraftPick } from "./logic/M02_MasterTemplete_Hopyo_Picks"; +import { type ImportedDoc, type Money4 } from "./logic/M02_Hopyo_Imported_Model"; const TEXT = { Title: ["가져온 식 단가산출 — 단산{n}", "Imported build-up — block {n}"], @@ -41,8 +29,6 @@ const TEXT = { Made: ["{k} 만듦(엑셀 단가 그대로)", "{k} made (Excel prices)"], Save: ["저장", "Save"], Saved: ["{k} 저장함", "{k} saved"], - Link: ["이 줄에 잇기", "Use for this line"], - Linked: ["단산 줄 = {k} — [확인] 뒤 [사전조합 생성]", "Line uses {k} — confirm, then build"], Discard: ["저장 안 한 고침이 있음 — 버리고 닫을까?", "Discard unsaved edits?"], Close: ["닫기", "Close"], } as const satisfies Record; @@ -58,8 +44,6 @@ export interface ImportedOptions { /** SK 를 만들 층 */ target: HopyoScope; canWrite: boolean; - /** 단산 줄에서 열었을 때만 — [이 줄에 잇기] */ - onLink?: (pick: DraftPick) => void; } interface Made { @@ -73,24 +57,8 @@ const fromHopyo = (o: ImportedOptions): Promise => body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: o.no }), // prettier-ignore }); -/** 기초 옵션 — 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본 · 근거 옵션) → 블록 검산 입력 필요 + 엇갈림 */ -async function bind( - o: ImportedOptions, - code: string, - given: Record, -): Promise<{ 입력값: Record; 필요: string[] }> { - const blk = await readJudgeBlock(o.file, o.key, o.no, o.target).catch(() => null); - const b = blk?.절.flatMap((s) => s.후보).find((c) => c.키 === code)?.바인딩; - const values = { ...(b?.기본 ?? {}), ...(b?.입력값 ?? {}), ...given }; - const pick = bare({ 갈래: "기초", 키: code, 이름: code, 입력값: values, 출처: {}, 몫: null }); - const res = await checkJudge(o.file, o.key, { 블록: o.no, 고른: [pick] }); - const need = [...(res.고른[0]?.입력필요 ?? []), ...Object.keys(b?.엇갈림 ?? {})]; - return { 입력값: values, 필요: [...new Set(need)] }; -} - interface EditAnswer { 문서: ImportedDoc; - 필요?: string[]; 전?: Money4 | null; 후?: Money4 | null; 이음없음?: { 자리: string; 이름: string }[]; @@ -109,13 +77,9 @@ export function openImported(o: ImportedOptions): void { const host = el("div", { attrs: { "data-sk-body": "" } }); let sk = ""; let 판 = ""; - let tier: Tier | null = null; - /** 우리 기초로 바꾸기 전 가져온 식 · 고른 기초(환산 계수 넣을 때 다시 바꿈) */ - let based: { src: ImportedDoc; code: string } | null = null; let dirty = false; - let close = (): void => {}; let handle: ReturnType | null = null; - const st: PanelState = { doc: {}, unlinked: [], qty: null, price: null, need: [], busy: false, readOnly }; // prettier-ignore + const st: PanelState = { doc: {}, unlinked: [], qty: null, price: null, busy: false, readOnly }; // prettier-ignore const edit = (how: string, body: Record): Promise => call(at(o.target, `/sangeun/imported/${how}`), { method: "POST", body: JSON.stringify(body) }); @@ -131,19 +95,11 @@ export function openImported(o: ImportedOptions): void { }); draw(); } - /** 환산 계수 없이는 못 셈 — 시트 안 엶(셈 길 400 없음) · 머리 판 붉은 칸만 */ - function hold(doc: ImportedDoc): void { - handle?.destroy(); - handle = null; - st.doc = doc; - host.replaceChildren(createText(tx("NeedHint"), "warning", 14)); - } const now = (): ImportedDoc => (handle?.getDoc() as ImportedDoc | undefined) ?? st.doc; function draw(): void { - top.replaceChildren(importedPanel(st, { price, link, toBase, pickBase, fill, qty: openQty })); + top.replaceChildren(importedPanel(st, { price, link, qty: openQty })); saveBtn.disabled = readOnly || st.busy; - linkBtn.disabled = readOnly || st.busy; } /** 서버 고침 한 번(눌림 막음) → 시트 다시 엶 */ @@ -157,8 +113,7 @@ export function openImported(o: ImportedOptions): void { try { const got = await edit(how, { 문서: from, ...body }); dirty = true; - if (got.필요?.includes("환산")) hold(got.문서); - else mount(got.문서); + mount(got.문서); return got; } catch (e) { showToast(why(e), "error"); @@ -224,49 +179,6 @@ export function openImported(o: ImportedOptions): void { }); } - async function toBase(code: string, cand: BasisCand | null): Promise { - st.busy = true; - draw(); - let bound: Awaited>; - try { - bound = await bind(o, code, cand?.옵션 ?? {}); - } catch (e) { - st.busy = false; - draw(); - return void showToast(why(e), "error"); - } - st.busy = false; - const src = now(); - const got = await run("to-base", { 로직: code, 입력값: bound.입력값 }, src); - if (!got) return; - based = { src, code }; - tier = cand?.단 ?? null; - st.need = [...new Set([...bound.필요, ...(cand?.입력필요 ?? []), ...(got.필요 ?? [])])]; - st.unlinked = []; - st.qty = null; - draw(); - } - - function pickBase(): void { - openFindPicker({ layer: o.target.layer, owner: o.target.owner, kinds: ["logic"], logic: (x) => void toBase(x.키, null) }); // prettier-ignore - } - - /** 입력 필요 칸 — 옵션 = SK 입력값 · 「환산」 = SK 환산 계수 */ - function fill(name: string, value: string): void { - const doc = now(); - const v = value.trim(); - dirty = true; - if (name === "환산" && based) { - // 가져온 식에서 다시 바꿈(바꿈 전 · 후 = 서버 정본 · 옵션은 지금 값) - const swap = v && Number(v) > 0 ? { 계수: Number(v) } : null; - return void run("to-base", { 로직: based.code, 입력값: doc.입력값 ?? {}, 환산: swap }, based.src); // prettier-ignore - } - const kept = { ...(doc.입력값 ?? {}) }; - if (v) kept[name] = v; - else delete kept[name]; - mount({ ...doc, 입력값: kept }); - } - async function save(): Promise { try { const doc = now(); @@ -284,20 +196,7 @@ export function openImported(o: ImportedOptions): void { const saveBtn = createButton({ label: t("Save"), variant: "filled", onClick: () => void save() }); saveBtn.dataset.importedSave = ""; - const linkBtn = createButton({ - label: t("Link"), - variant: "ghost", - onClick: async () => { - if (dirty && !(await save())) return; - o.onLink!(skPick(sk, st.doc, st.need, marksOf(st.doc, tier))); - showToast(t("Linked").replace("{k}", sk), "info"); - dirty = false; - close(); - }, - }); - linkBtn.dataset.importedLinkRow = ""; - linkBtn.hidden = !o.onLink; - saveBtn.disabled = linkBtn.disabled = true; + saveBtn.disabled = true; async function load(): Promise { const stop = showLoading(root); @@ -318,13 +217,13 @@ export function openImported(o: ImportedOptions): void { } } - ({ close } = openModal({ + openModal({ title: t("Title").replace("{n}", String(o.no)), closeLabel: t("Close"), width: "1100px", wide: true, dialogClass: "m02-modal m02-hopyo__imported-modal", - actions: [saveBtn, linkBtn], + actions: [saveBtn], beforeClose: async () => { if (dirty && !readOnly && !(await askConfirm({ message: t("Discard"), danger: true }))) return false; @@ -332,6 +231,6 @@ export function openImported(o: ImportedOptions): void { return true; }, mount: (body) => body.append(root), - })); + }); void load(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts index 163a83e21..6b4409c90 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_ImportedPanel.ts @@ -5,42 +5,30 @@ * 바꾸기 전후 금액 · [원본 글 보기] * · 줄 2 = 이음 없는 자원 = 표지 + [이음 고르기] * · 줄 2-2 = 소요량 · 계수 이음(797-2 · 후보 집계 + [이음 보기] → `Hopyo_QtyLinks` 모달) - * · 줄 3 = 근거 제안 세 단(확정 · 근접(차이 · 까닭) · 사람 고름 · 793-3a) — 후보마다 [우리 기초로 바꾸기] · [기초 고르기] - * · 우리 기초로 바꾼 뒤 = 전후 차이 띠 · 「입력 필요」 붉은 칸(채우기 전엔 그 줄 확인 · 옮김 막힘) + * · 경쟁사 로직 그대로 — 기초로 바꾸기 · 근거 기초 제안 없음(809-1 · 요구 703) * 부품 = 단추 · 배지 · 글 · 입력 · 띠 · 표 · 모달(날 단추 · 입력 없음) · 서버 길은 부르는 쪽. * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; -import { createBanner } from "@ui/ui_template_banner"; import { createButton } from "@ui/ui_template_button"; import { el } from "@ui/ui_template_elements"; -import { createInputField } from "@ui/ui_template_input"; import { currentLanguageIndex } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; import { createTable } from "@ui/ui_template_table"; import { createText } from "@ui/ui_template_text"; import { createToolbar } from "@ui/ui_template_toolbar"; import { formatMoney } from "../common_util/common_util_money"; -import { newBadge } from "./M02_MasterTemplete_Hopyo_CheckRows"; import { tallyBadges, type QtyTally } from "./M02_MasterTemplete_Hopyo_QtyLinks"; import { gapText, isImported, - needValues, - reasonText, - stillNeed, - tiers, - TIERS, wrongLines, - type BasisCand, type ImportedDoc, type Money4, - type Tier, } from "./logic/M02_Hopyo_Imported_Model"; const TEXT = { Imported: ["가져온 식", "Imported formula"], - Based: ["우리 기초로 바꿈", "Converted to our basis"], Same: ["검산 같음", "Check matches"], Diff: ["검산 다름 {n}", "Check differs {n}"], NoCheck: ["검산 없음", "No check"], @@ -55,19 +43,7 @@ const TEXT = { Now: ["지금", "Now"], Unlinked: ["이음 없는 자원 {n}", "Unlinked resources {n}"], Link: ["이음 고르기", "Pick link"], - Basis3: ["근거 제안", "Basis suggestions"], - 확정: ["확정", "Confirmed"], - 근접: ["근접", "Near"], - 사람: ["사람 고름", "Human pick"], - None: ["없음", "None"], - Qty: ["품 {v}", "Qty {v}"], - NoCand: ["근거 제안 없음 — [기초 고르기]로 고름", "No suggestion — pick a basis"], - ToBase: ["우리 기초로 바꾸기", "Convert to our basis"], - PickBase: ["기초 고르기", "Pick basis"], Gap: ["바꾸기 전 {a} → 후 {b} ({d})", "Before {a} → after {b} ({d})"], - Need: ["입력 필요", "Input needed"], - Swap: ["환산 계수(기초 1 단위 = 이 단가산출 몇 단위)", "Unit factor (1 basis unit = n of this)"], - NeedHint: ["채워야 이 줄을 확인 · 옮김", "Fill to confirm and move this line"], QtyLinks: ["소요량 · 계수 이음 {n}", "Quantity / coefficient links {n}"], QtyOpen: ["이음 보기", "Show links"], Close: ["닫기", "Close"], @@ -83,8 +59,6 @@ export interface PanelState { qty: QtyTally | null; /** 마지막 단가 바꿈 전후 */ price: { 전: Money4 | null; 후: Money4 | null } | null; - /** 우리 기초로 바꾼 뒤 입력 필요 칸 */ - need: string[]; busy: boolean; readOnly: boolean; } @@ -92,9 +66,6 @@ export interface PanelState { export interface PanelActs { price: (basis: "엑셀" | "우리") => void; link: (spot: string, name: string) => void; - toBase: (code: string, cand: BasisCand | null) => void; - pickBase: () => void; - fill: (name: string, value: string) => void; qty: () => void; } @@ -144,59 +115,13 @@ function checkBadge(doc: ImportedDoc): HTMLElement { return b; } -function cand(c: BasisCand, s: PanelState, a: PanelActs): HTMLElement { - const opts = Object.entries(c.옵션 ?? {}) - .map(([k, v]) => `${k} ${String(v)}`) - .join(" · "); - const go = createButton({ label: tx("ToBase"), size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: () => a.toBase(c.기초, c) }); // prettier-ignore - go.dataset.importedToBase = c.기초; - return el("div", { - className: "ui-toolbar m02-judge__wrap", - attrs: { "data-basis-cand": c.기초, "data-basis-tier": c.단 }, - children: [ - createBadge(`${c.기초}${c.이름 ? ` ${c.이름}` : ""}`, "accent"), - ...(c.원문번호 ? [muted(c.원문번호)] : []), - ...(opts ? [muted(opts)] : []), - ...(c.품검산 ? [createBadge(tx("Qty").replace("{v}", c.품검산), c.품검산 === "같음" ? "success" : "warning")] : []), // prettier-ignore - ...(c.입력필요?.length ? [createBadge(`${tx("Need")} ${c.입력필요.length}`, "danger")] : []), - ...(reasonText(c) ? [muted(reasonText(c))] : []), - go, - ], - }); -} - -/** 근거 제안 세 단 */ -function basisPart(s: PanelState, a: PanelActs): HTMLElement { - const by = tiers(s.doc.가져온?.근거후보); - const any = TIERS.some((k) => by[k].length); - const pick = createButton({ label: tx("PickBase"), size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: a.pickBase }); // prettier-ignore - pick.dataset.importedPickBase = ""; - const tierRow = (k: Tier): HTMLElement => - el("div", { - className: "ui-toolbar m02-judge__wrap", - attrs: { "data-basis-tiers": k }, - children: [ - createBadge(`${tx(k)} ${by[k].length}`, k === "확정" ? "success" : k === "근접" ? "warning" : "neutral"), // prettier-ignore - ...(by[k].length ? by[k].map((c) => cand(c, s, a)) : [muted(tx("None"))]), - ], - }); - return el("div", { - attrs: { "data-imported-basis": "" }, - children: [ - createToolbar({ items: [newBadge(), createText(tx("Basis3"), "muted", 14), pick] }), - ...(any ? TIERS.map(tierRow) : [muted(tx("NoCand"))]), - ], - }); -} - /** 머리 판 */ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement { const meta = s.doc.가져온 ?? {}; const src = meta.원본; const here = isImported(s.doc); const head: (HTMLElement | "divider")[] = [ - newBadge(), - createBadge(here ? tx("Imported") : tx("Based"), here ? "accent" : "info"), + createBadge(tx("Imported"), "accent"), muted([src?.파일, src?.시트, src?.행?.join("~")].filter(Boolean).join(" · ")), checkBadge(s.doc), ]; @@ -237,33 +162,5 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement { const tag = createBadge(tx("QtyLinks").replace("{n}", String(s.qty.자리)), "info"); kids.push(createToolbar({ items: [tag, ...tallyBadges(s.qty), open] })); } - const gone = meta.바꿈; - if (!here && gone) { - const g = tx("Gap").replace("{a}", won(gone.전)).replace("{b}", won(gone.후)); - kids.push(createBanner({ tone: "info", text: g.replace("{d}", gapText(gone.전, gone.후)) })); // prettier-ignore - } - const values = needValues(s.doc); - const left = stillNeed(s.need, values); - if (s.need.length) { - const fields = s.need.map((k) => { - const label = k === "환산" ? tx("Swap") : k; - const f = createInputField({ label, size: 28, value: String(values[k] ?? "") }); - f.input.dataset.importedNeed = k; - f.input.disabled = s.readOnly || s.busy; - if (left.includes(k)) f.setError(tx("Need")); - f.input.addEventListener("change", () => a.fill(k, f.input.value)); - return f.root; - }); - kids.push( - el("div", { - attrs: { "data-imported-needs": String(left.length) }, - children: [ - createToolbar({ items: [createBadge(`${tx("Need")} ${left.length}`, left.length ? "danger" : "success"), muted(tx("NeedHint"))] }), // prettier-ignore - el("div", { className: "ui-toolbar m02-judge__wrap", children: fields }), - ], - }), - ); - } - kids.push(basisPart(s, a)); return el("div", { className: "m02-std-sheet__body", attrs: { "data-imported-panel": "" }, children: kids }); // prettier-ignore } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Judge.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Judge.css deleted file mode 100644 index 8c40d2f03..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Judge.css +++ /dev/null @@ -1,33 +0,0 @@ -/* 엑셀 불러오기 판정 화면(PLAN 785-6) — 줄 판 · 블록 판 자리 잡기만(단추 · 표 · 배지 · 칩은 부품 상자) */ -.m02-judge { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - min-width: 0; -} - -.m02-judge__sec { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface-raised); -} - -.m02-judge__cand { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding-left: var(--spacing-8); - border-left: 2px solid var(--color-accent-soft); -} - -.m02-judge__cand.is-off { - opacity: 0.55; -} - -.m02-judge__wrap { - flex-wrap: wrap; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_JudgeApi.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_JudgeApi.ts deleted file mode 100644 index 672d1ff05..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_JudgeApi.ts +++ /dev/null @@ -1,272 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Hopyo_JudgeApi.ts - * 엑셀 불러오기 새 판정 길(PLAN 785-5 · 785-6 · 요구 641 · 642 · 설계 642-A 2-9) — 타입 + 부름만. - * · GET `/layers/{파일 층}/hopyo-judge/{키}` = 호표 · 블록 목록(판정 수 · 만든) - * · GET `/layers/{파일 층}/hopyo-judge/{키}/{번}` = 호표 줄 판(785-3 줄 판 + 단산 줄 `풀이`) - * · GET `/layers/{파일 층}/hopyo-judge/{키}/block/{번}` = 블록 풀이(`Solve.block` 꼴 + 이름 · 근거 · 만든) - * · POST `/layers/{파일 층}/hopyo/{키}/check` = 고른 것 검산(저장 없음 · 서버 셈) - * · POST `/layers/{만들 층}/hopyo/{키}/move` = 확인한 것 옮김(서버가 다시 셈 · 금액 · 품 칸 안 보냄) - * 판정 · 금액은 서버 — 화면은 보이기 · 고르기만(옛 `Hopyo_Api.ts` 는 785-7 에서 걷음). - * ========================================================================== */ - -import { call } from "./M02_MasterTemplete_Api_Fetch"; -import { at, type HopyoScope, type Money } from "./M02_MasterTemplete_Hopyo_Api"; - -export type PickKind = - "기초" | "단가산출" | "일위대가" | "인력" | "재료" | "유가" | "기계" | "율" | "고정"; -export type RowVerdict = "제안 있음" | "2차 필요" | "후보 여럿" | "후보 없음"; -export type BlockVerdict = "기초로 풂" | "기초+덧" | "줄 조합" | "고를 것"; - -/** 값 출처 — 길 B1~B6 · 사용자 · 기본 */ -export interface PickSource { - 길: string; - 행?: number | null; - 글?: string; -} - -/** 고른 것(785-3 꼴 + 배) */ -export interface Pick { - 갈래: PickKind; - 키: string | null; - 이름: string; - 규격?: string; - 단위?: string; - 단가?: Partial | number | null; - 입력값: Record; - 출처: Record; - 몫: number | null; - 비목?: string; - 기준?: unknown; - 별도계상?: boolean; - 층?: string; - 주인?: string | null; - 배?: number | null; -} - -export interface Binding { - 입력값: Record; - 출처: Record; - 엇갈림: Record; - 안씀: string[]; - 입력필요: string[]; - 비움: string[]; - 기본: Record; -} - -export type PairVerdict = "품 같음" | "품 다름" | "입력 필요" | "엑셀만" | "우리만"; - -/** 줄 대조 — 엑셀 단위 줄 ↔ 우리 줄 */ -export interface PairLine { - 엑셀: { 묶음: string; 이름: string; 품: number | null; 행: number | null; 별도?: boolean } | null; - 우리: { 이름: string; 종류: string; 단위: string; 품: number | null; 까닭?: string } | null; - 판정: PairVerdict; - 표지?: string; -} - -export type CandMoney = Money | { 계: null; 까닭: string }; - -/** 절 기초 후보 하나 */ -export interface BaseCand { - 키: string; - 이름: string; - 바인딩: Binding; - 줄: PairLine[]; - 셈: Partial>; - 판정: string; - 금액: CandMoney; -} - -export interface Section { - 제목: string; - 번?: string | null; - 판정: "기초 풂" | "후보 여럿" | "기초 없음"; - 고름: string | null; - 후보: BaseCand[]; - /** 절 몫(곱수) — 수 · 옛 꼴 {값} */ - 몫?: number | { 값: number | null } | null; -} - -/** 덧 줄(엑셀만 줄 → 덧 줄 후보 · `Units` 잇기) */ -export interface ExtraLine { - 절: string; - 행: number | null; - 이름: string; - 방법?: string; - 후보?: { - 자재?: string; - 키?: string; - 노임?: string; - 이름?: string; - 규격?: string; - 단위?: string; - }[]; -} - -export interface Solved { - 번: number; - 판정: BlockVerdict; - 고를수: number; - 절: Section[]; - 덧: ExtraLine[]; -} - -export interface Basis { - 표지: string | null; - 글: string; - 출처: [string, string][]; -} - -export interface JudgeBlock extends Solved { - 이름: string; - 단위: string; - 부름: number[]; - 근거: Basis | null; - 만든: string | null; - /** 엑셀 블록 금액(참고 · 길이 실을 때만) */ - 금액?: Partial | null; -} - -/** 호표 줄 판 한 줄(785-3 + 엑셀 글 · 단산 줄 풀이) */ -export interface JudgeRow { - 차례: number; - 행: number | null; - 갈래: PickKind[]; - 근거: string; - 번?: number; - 후보: Pick[]; - 판정: RowVerdict; - 수량: { 엑셀: number | null; 배: number | null; 갈래: string }; - 이름?: string; - 규격?: string; - 단위?: string; - 단가?: Partial | null; - 풀이?: Solved | null; - /** 별칭 장부 짝(짝 = 지난번 고른 것이 후보 · 우리에 없음 · 안 씀) */ - 장부?: { 갈래: string; 층?: string | null; 제안?: unknown; 규격다름?: boolean } | null; -} - -export interface JudgeBrief { - 번: number; - 이름: string; - 규격: string; - 단위: string; - 줄수: number; - 판정: Partial>; - 만든: string | null; -} - -export interface JudgeBlockBrief { - 번: number; - 이름: string; - 단위: string; - 부름: number[]; - 표지: string | null; - 만든: string | null; - /** 블록 풀이 파생 저장본(셈 전 = 없음) */ - 판정?: BlockVerdict | null; - 고를수?: number | null; -} - -export interface JudgeList { - 키: string; - 파일: string; - /** 블록 풀이 뒤 셈 중(블록 판정 · 고를수 None) */ - 셈중?: boolean; - 호표: JudgeBrief[]; - 블록: JudgeBlockBrief[]; -} - -export interface JudgeOne { - 번: number; - 이름: string; - 규격: string; - 단위: string; - 만든: string | null; - 줄: JudgeRow[]; -} - -export interface CheckBody { - 호표?: number; - 차례?: number; - 블록?: number; - 고른: Pick[]; -} - -export interface CheckOne { - 차례: number; - 갈래: PickKind; - 키: string | null; - 입력값: Record; - 입력필요: string[]; - 줄?: PairLine[]; - 판정: string; - 금액: CandMoney; -} - -export interface CheckResult { - 고른: CheckOne[]; - 수량?: { 엑셀: number | null; 배: number | null; 몫: number | null }; -} - -export interface MoveBody { - 층: string; - 주인?: string | null; - 호표: Record>; - 블록: Record; - /** 이미 옮긴 호표 · 블록 덮기(아니면 409) */ - 덮음?: boolean; -} - -export interface MoveBlocked { - 호표?: number; - 블록?: number; - 차례?: number; - 까닭: string; - 칸: string[]; -} - -export interface MoveResult { - 만듦: { 번: number; 키: string; 이름: string; 다시씀?: boolean; 재사용?: boolean }[]; - 단가산출: { 번: number; 키: string; 이름: string }[]; - 막힘: MoveBlocked[]; - 하위: { 번: number; 키: string; 새로: boolean }[]; - 장부: { 씀: number }; -} - -const enc = encodeURIComponent; -const toQuery = (to?: HopyoScope): string => - to ? `?to=${to.layer}${to.owner ? `&to_owner=${enc(to.owner)}` : ""}` : ""; - -export const readJudge = (scope: HopyoScope, key: string, to?: HopyoScope): Promise => - call(at(scope, `/hopyo-judge/${enc(key)}${toQuery(to)}`)); - -export const readJudgeOne = ( - scope: HopyoScope, - key: string, - no: number, - to?: HopyoScope, -): Promise => call(at(scope, `/hopyo-judge/${enc(key)}/${no}${toQuery(to)}`)); - -export const readJudgeBlock = ( - scope: HopyoScope, - key: string, - no: number, - to?: HopyoScope, -): Promise => call(at(scope, `/hopyo-judge/${enc(key)}/block/${no}${toQuery(to)}`)); - -/** 검산(저장 없음) — `signal` = 옛 요청 끊기 */ -export const checkJudge = ( - scope: HopyoScope, - key: string, - body: CheckBody, - signal?: AbortSignal, -): Promise => - call(at(scope, `/hopyo/${enc(key)}/check`), { - method: "POST", - body: JSON.stringify(body), - signal, - }); - -/** 옮기기 — `target` = 만들 층 · 몸 `층` · `주인` = 파일 층 */ -export const moveJudge = (target: HopyoScope, key: string, body: MoveBody): Promise => - call(at(target, `/hopyo/${enc(key)}/move`), { method: "POST", body: JSON.stringify(body) }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts deleted file mode 100644 index fdc8d8948..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Rows.ts +++ /dev/null @@ -1,306 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Hopyo_Rows.ts - * 엑셀 불러오기 줄 칸 부품 · 옮기기(PLAN 785-6 · 786-2 · 요구 641 · 642 · 644 · 설계 642-A 2-3 · 2-4 · 2-6). - * · `createRowKit` = 호표 하나의 줄 칸 부품 — [수정] 모달 「확인 — 제N호표」(`Hopyo_Check` · `Hopyo_CheckRows`)가 - * 옛 확인 창 표 칸 안에 담음: 1차 판정 칩 · 후보 복수 고름 칩(기초 · 단가산출 · 일위대가 · 인력 · 재료 · 유가 · - * 기계 · 율 · 고정) · 단산 줄 [블록 풀이] 펼침(`Hopyo_Block.solvedBody` · 절마다 기초 · 입력값 칸 · 출처 꼬리표) + - * 「단가산출 통째」 칩(단산N → SK) · 단위 환산 칸(`logic/Hopyo_Unit` · 같은 단위 = 숨김) · [확인] - * · `createJudgeMove` = 오른쪽 판 아래 [옮기기 (n)] · `runMove` = 옮기기 한 번(모달 [사전조합 생성]도 이 길 · 이 호표만) - * · 이미 옮긴 것(409) = 물음 한 번 뒤 덮음 - * · 고름 · 확인 = 캐시(`createJudgeState`) · 자동 확인 · 자동 옮김 없음 · 옮김 = 서버가 정본으로 다시 셈 - * (785-6 시트 아래 줄 판 몸은 786-2 에서 모달로 옮김) - * ========================================================================== */ - -import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; -import { - askConfirm, - createButton, - createInputField, - el, - showToast, -} from "@ui/ui_template_elements"; -import { t as L } from "@ui/ui_template_locale"; -import { createText } from "@ui/ui_template_text"; -import { createToggle } from "@ui/ui_template_toggle"; -import { formatNumber } from "../common_util/common_util_money"; -import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; -import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; -import { createJudgeState, solvedBody, type JudgeState } from "./M02_MasterTemplete_Hopyo_Block"; -import { - checkJudge, - moveJudge, - type JudgeRow, - type MoveBody, - type MoveBlocked, - type MoveResult, -} from "./M02_MasterTemplete_Hopyo_JudgeApi"; -import { - apartPick, - bare, - confirmable, - editLine, - lineOf, - linePicks, - missing, - moveBody, - putLine, - samePick, - togglePick, - type DraftPick, -} from "./logic/M02_MasterTemplete_Hopyo_Picks"; -import { unitView } from "./logic/M02_MasterTemplete_Hopyo_Unit"; - -export { createJudgeState, type JudgeState }; - -const VERDICT: Record = { - "제안 있음": "success", - "2차 필요": "info", - "후보 여럿": "warning", - "후보 없음": "danger", -}; -export const show = (v: unknown): string => - v === null || v === undefined ? "" : typeof v === "number" ? formatNumber(v) : String(v); -export const bar = (kids: (HTMLElement | string)[]): HTMLElement => - el("div", { className: "ui-toolbar m02-judge__wrap", children: kids }); - -export type SheetRole = "일위대가표" | "단가산출상세"; - -export interface RowKitOptions { - /** 파일 층 */ - file: HopyoScope; - key: string; - state: JudgeState; - no: number; - /** 엑셀 보기 그 행(일위대가표 · 단가산출 상세) */ - onSheet?: (행: number, role: SheetRole) => void; - readOnly?: boolean; - /** [블록 풀이] 펼침 · 접음 — 부르는 쪽이 다시 그림 */ - redraw: () => void; -} - -/** 2차 줄 「단가산출 통째」 고른 것 — 단산N(같은 옮기기 블록 먼저 SK) */ -const wholeSk = (row: JudgeRow): DraftPick => ({ - 갈래: "단가산출", - 키: `단산${row.번}`, - 이름: row.이름 ?? `단산${row.번}`, - 규격: row.규격 ?? "", - 단위: row.단위 ?? "", - 입력값: {}, - 출처: {}, - 몫: null, -}); - -/** 별도계상 줄(786-1 · 서버 제안 = 고정 줄 하나 `apartPick`) — 고름 칩 없이 글만 */ -export const apartRow = (row: JudgeRow): boolean => row.후보.length === 1 && apartPick(row.후보[0]); - -/** 호표 하나 줄 칸 부품 — 판정 칩 · 고름 칩 · 수량(단위 환산) · [확인] · 블록 풀이 몸 */ -export function createRowKit(opt: RowKitOptions) { - const no = opt.no; - const open = new Set(); - const bodies = new Map>(); - const picksOf = (row: JudgeRow): DraftPick[] => linePicks(opt.state.get(), no, row); - const edit = (row: JudgeRow, list: DraftPick[]): void => - opt.state.set(editLine(opt.state.get(), no, row, list)); - - function verdict(row: JudgeRow): HTMLElement { - const v = createBadge(row.판정, VERDICT[row.판정] ?? "neutral"); - v.dataset.judgeVerdict = row.판정; - return v; - } - - function chips(row: JudgeRow, picks: DraftPick[]): HTMLElement { - const kids: HTMLElement[] = []; - if (apartRow(row)) return bar([createText(L("M02_CheckApart"), "muted")]); - if (row.번 !== undefined && row.풀이) { - const solve = createToggle({ kind: "chip", label: L("M02_JudgeSolve"), checked: open.has(row.차례), onChange: (on) => { if (on) open.add(row.차례); else open.delete(row.차례); opt.redraw(); } }); // prettier-ignore - solve.root.dataset.judgeSolve = String(row.번); - const sk = wholeSk(row); - const whole = createToggle({ kind: "chip", label: `${L("M02_JudgeWholeSk")} ${sk.키}`, checked: picks.some((p) => samePick(p, sk)), disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, sk)) }); // prettier-ignore - whole.root.dataset.judgeWhole = String(sk.키); - kids.push(solve.root, whole.root); - for (const p of picks.filter((x) => x.갈래 === "기초")) { - const b = createBadge(`${p.키}`, missing(p).length ? "danger" : "accent"); - b.dataset.judgePicked = String(p.키); - if (missing(p).length) b.title = `${L("M02_JudgeNeed")} ${missing(p).join(" · ")}`; - kids.push(b); - } - } else { - const seen = [...row.후보]; - for (const p of picks) if (!seen.some((x) => samePick(x, p))) seen.push(p); - for (const c of seen) { - const on = picks.some((p) => samePick(p, c)); - const label = `${c.갈래} ${c.이름 || c.키 || ""}${c.규격 ? ` ${c.규격}` : ""}`; - const chip = createToggle({ kind: "chip", label, checked: on, disabled: opt.readOnly, onChange: () => edit(row, togglePick(picks, { ...c })) }); // prettier-ignore - chip.root.dataset.judgeCand = `${c.갈래}:${c.키 ?? c.이름}`; - chip.root.title = c.키 ?? ""; - kids.push(chip.root); - const need = picks.find((p) => samePick(p, c)); - if (need && missing(need).length) { - const b = createBadge(`${L("M02_JudgeNeed")} ${missing(need).length}`, "danger"); - b.dataset.judgeNeed = missing(need).join(","); - kids.push(b); - } - } - if (!seen.length) kids.push(createText(L("M02_JudgeNoCand"), "warning")); - } - return bar(kids); - } - - /** 단위 환산 칸 — 같은 단위면 null */ - function unitInput(row: JudgeRow, picks: DraftPick[]): HTMLElement | null { - const line = lineOf(opt.state.get(), no, row.차례); - const 배 = line?.배 ?? row.수량.배; - const view = unitView({ 배, 환산: { 배: row.수량.배, 갈래: row.수량.갈래 } }, row.단위, picks[0]?.단위); // prettier-ignore - if (view.mode !== "input" || opt.readOnly) return null; - const f = createInputField({ compact: true, size: 24, value: show(배), unit: view.to, placeholder: L("M02_JudgeUnitHint").replace("{from}", view.from) }); // prettier-ignore - f.input.dataset.judgeTimes = String(row.차례); - f.input.addEventListener("change", () => { - const n = Number(f.input.value.replace(/,/g, "")); - const next = f.input.value.trim() === "" || !Number.isFinite(n) ? null : n; - opt.state.set(putLine(opt.state.get(), no, row.차례, { 고른: picks, 확인: false, 배: next })); - }); - if (배 === null) f.setError(L("M02_JudgeNeed")); - return bar([L("M02_JudgeUnitHint").replace("{from}", view.from), f.root]); - } - - /** 지금 배(사용자 단위 환산 › 서버) */ - const timesOf = (row: JudgeRow): number | null => - lineOf(opt.state.get(), no, row.차례)?.배 ?? row.수량.배; - - function okChip(row: JudgeRow, picks: DraftPick[]): HTMLElement { - const line = lineOf(opt.state.get(), no, row.차례); - const ok = createToggle({ - kind: "chip", - label: line?.확인 ? L("M02_JudgeConfirmed") : L("M02_JudgeConfirm"), - checked: !!line?.확인, - disabled: opt.readOnly, - onChange: (on) => { - if (on && !confirmable(picks)) { - ok.set(false); - const why = picks.length ? picks.flatMap(missing).join(" · ") : L("M02_JudgeNoCand"); - showToast(`${L("M02_JudgeBlocked")} — ${why}`, "warning"); - return; - } - opt.state.set(putLine(opt.state.get(), no, row.차례, { ...line, 고른: picks, 확인: on })); - }, - }); - ok.root.dataset.judgeOk = String(row.차례); - return ok.root; - } - - /** 행 번호 단추 — 엑셀 보기 그 행 */ - function rowJump(row: JudgeRow): HTMLElement | string { - if (row.행 === null || !opt.onSheet) return show(row.행); - const b = createButton({ label: String(row.행), size: 24, variant: "link", onClick: () => opt.onSheet!(row.행!, "일위대가표") }); // prettier-ignore - b.dataset.judgeRow = String(row.행); - return b; - } - - /** 블록 풀이 몸이 쥐는 고른 것 = 단가산출 통째 칩 빼고(기초 · 덧 줄) */ - const isSk = (p: DraftPick): boolean => p.갈래 === "단가산출"; - const inner = (row: JudgeRow): DraftPick[] => picksOf(row).filter((p) => !isSk(p)); - - /** 펼친 단산 줄의 블록 풀이 몸(접힘 = null) */ - function solved(row: JudgeRow): HTMLElement | null { - if (!row.풀이 || !open.has(row.차례)) return null; - let body = bodies.get(row.차례); - if (!body) { - body = solvedBody({ - solved: row.풀이, - picks: inner(row), - readOnly: opt.readOnly, - excel: row.단가, - onSheet: opt.onSheet ? (r) => opt.onSheet!(r, "단가산출상세") : undefined, - onPicks: (list) => edit(row, [...picksOf(row).filter(isSk), ...list]), - onCheck: (list, signal) => - checkJudge(opt.file, opt.key, { 호표: no, 차례: row.차례, 고른: list.map((p) => bare(p)) }, signal), // prettier-ignore - }); - bodies.set(row.차례, body); - } else body.set(inner(row)); - return body.root; - } - - return { picksOf, edit, verdict, chips, unitInput, timesOf, okChip, rowJump, solved }; -} -export type RowKit = ReturnType; - -export interface JudgeMoveOptions { - file: HopyoScope; - key: string; - target: HopyoScope; - state: JudgeState; - readOnly?: boolean; - onMoved?: (r: MoveResult) => void; -} - -/** 옮기기 한 번 — 409 = 물음 뒤 덮음 · 옮긴 것은 캐시에서 걷음 · 알림 · 실패 = null */ -export async function runMove( - opt: Pick, - body: MoveBody, - 막힘: MoveBlocked[], -): Promise { - try { - const r = await moveJudge(opt.target, opt.key, body).catch(async (e: unknown) => { - // 이미 옮긴 호표 · 블록 — 물음 한 번 뒤 덮음(아니면 그만) - if (!(e instanceof StaleError) || !(await askConfirm({ message: e.message }))) throw e; - return moveJudge(opt.target, opt.key, { ...body, 덮음: true }); - }); - const stuck = new Set(r.막힘.map((x) => `${x.호표 ?? "b" + x.블록}:${x.차례 ?? ""}`)); - let d = opt.state.get(); - for (const [h, lines] of Object.entries(body.호표)) - for (const at of Object.keys(lines)) - if (!stuck.has(`${h}:${at}`)) { - const { [at]: _gone, ...rest } = d.호표[h] ?? {}; - d = { ...d, 호표: { ...d.호표, [h]: rest } }; - } - for (const b of Object.keys(body.블록)) - if (!stuck.has(`b${b}:`)) { - const { [b]: _gone, ...rest } = d.블록; - d = { ...d, 블록: rest }; - } - opt.state.set(d); - const msg = L("M02_JudgeMoved") - .replace("{a}", String(r.만듦.length)) - .replace("{b}", String(r.단가산출.length)) - .replace("{c}", String(r.막힘.length + 막힘.length)); - showToast(msg, r.막힘.length + 막힘.length ? "warning" : "success"); - return r; - } catch (e) { - showToast((e as Error).message, "error"); - return null; - } -} - -/** 오른쪽 판 아래 [옮기기 (n)] — 확인한 줄 · 블록만 · 서버가 정본으로 다시 셈 · 옮긴 것은 캐시에서 걷음 */ -export function createJudgeMove(opt: JudgeMoveOptions): { root: HTMLElement; destroy: () => void } { - const btn = createButton({ label: L("M02_JudgeMove"), size: 28, variant: "filled", onClick: () => void go() }); // prettier-ignore - btn.dataset.judgeMove = ""; - const root = bar([btn]); - const count = (): void => { - const n = moveBody(opt.state.get(), opt.file).수; - btn.textContent = `${L("M02_JudgeMove")} (${n})`; - btn.disabled = !!opt.readOnly || n === 0; - }; - - async function go(): Promise { - const { body, 막힘, 수 } = moveBody(opt.state.get(), opt.file); - if (!수) { - showToast(L("M02_JudgeMoveNone"), "info"); - return; - } - btn.disabled = true; - const r = await runMove(opt, body, 막힘); - count(); - if (r) opt.onMoved?.(r); - } - - const off = opt.state.on(count); - count(); - return { - root, - destroy(): void { - off(); - root.remove(); - }, - }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts index f52e79a20..cee5e0942 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Sheet.ts @@ -1,17 +1,17 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Sheet.ts * 일위대가 실무자료 엑셀 보기 몸(PLAN 329 · 338-3 · 359-1 · 359-2 · 785-4) — 본 칸 불러오기 보기(`Hopyo_Import`)가 바로 담음 · - * 머리 막대 = 시트 · 호표 수 · 검산 · 제안 n / 줄 m(1차 판정) · 블록 풀이 셈 중 + 부르는 쪽 덧붙임(`headExtra` — 층 · [끝내기]) · + * 머리 막대 = 시트 · 호표 수 · 검산 · 연결 n / 줄 m(809-1 줄 표지 · 연결됨 + 로직 줄) + 부르는 쪽 덧붙임(`headExtra` — 층 · [끝내기]) · * 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거 스프레드시트 · 탭 셋 일위대가표 · 단가산출표 · 단가산출 상세 · * 칠 · 범례 · 뛰기 · 뒤로 = `Hopyo_SheetNav` · 779-4 · 같은 파일 격자 다시 씀 · 779-6) · - * 오른쪽 = 호표 목록(제N호표 [수정] · 찾기 · 제안 n/m — 다 제안이면 녹색 · 뒤 셈 중이면 3 초마다 다시) · + * 오른쪽 = 호표 목록(제N호표 [수정] · [내 일위대가로] · 찾기 · 연결 n/m — 다 연결이면 녹색) · * 단가산출표 · 상세 탭이면 단가산출 목록으로 바뀜(780-2 · `Hopyo_SheetSk`). * · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 머리 행이면 영역 고름 - * · [수정] = 모달 「확인 — 제N호표」(786-2 `Hopyo_Check` · 줄 누름과 따로) · 단가산출 탭 블록 = 시트 아래 블록 판 · - * 오른쪽 판 아래 [옮기기](확인한 줄 · 블록 · 고름 캐시 `createJudgeState` 한 벌) + * · [수정] = 일위대가 컨테이너 꼴 모달(809-1 `Hopyo_View` · 경쟁사 로직 인정 · 요소만 연결 · 줄 누름과 따로) · + * 옮기기 = [내 일위대가로](807-1) 하나 · 단가산출 탭 블록 = [가져온 식으로 열기](`Hopyo_SheetSk`) · 기초 판정 · [옮기기] 판 없음(요구 703) * · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록은 됨 * · `buildHopyoSheet` = 몸(머리 · 띠 · 시트 + 목록) · 옛 [저장] · [모두 고정으로 들여오기] · 모달 껍데기는 785-7 에서 지움 - * (만들기 = [옮기기] · 모두 고정 서버 길은 B09 · 붙여넣기가 씀) + * (모두 고정 서버 길은 B09 · 붙여넣기가 씀) * ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게). * ========================================================================== */ @@ -20,7 +20,6 @@ import { createInputField } from "@ui/ui_template_input"; import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import { - POLL_MS, readHopyo, type HopyoBrief, type HopyoGrid, @@ -28,10 +27,8 @@ import { type HopyoView, type HopyoScope, } from "./M02_MasterTemplete_Hopyo_Api"; -import { openHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check"; import { mineButton } from "./M02_MasterTemplete_Hopyo_Mine"; -import { createJudgeMove, createJudgeState } from "./M02_MasterTemplete_Hopyo_Rows"; -import { lineOf } from "./logic/M02_MasterTemplete_Hopyo_Picks"; +import { openHopyoView } from "./M02_MasterTemplete_Hopyo_View"; import * as Nav from "./M02_MasterTemplete_Hopyo_SheetNav"; import { createSheetSk, type SheetSk } from "./M02_MasterTemplete_Hopyo_SheetSk"; import "./M02_MasterTemplete_Hopyo.css"; @@ -45,7 +42,7 @@ export interface HopyoSheetOptions { scope: HopyoScope; key: string; 파일: string; - /** 만들 층 쓰기 권한 — 없으면 옮기기 없음(보기만) */ + /** 만들 층 쓰기 권한 — 없으면 단가산출 모달 읽기만 */ canWrite: boolean; /** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */ target?: HopyoScope; @@ -57,7 +54,7 @@ export interface HopyoSheetOptions { onClose?: () => void; /** 머리 막대 끝에 덧붙일 것(본 칸 불러오기 보기 — 층 · 안 이은 줄 · [끝내기]) */ headExtra?: HTMLElement[]; - /** 단가산출 판정 · 옮김 뒤 */ + /** 단가산출 목록 읽은 뒤 */ onSkChange?: () => void; /** 엑셀 결과를 읽었음 · 다시 읽었음(뒤 셈 중 3 초 다시 읽기 포함) — 호표 번 목록을 바로 넘김 */ onRead?: (file: HopyoView) => void; @@ -70,7 +67,7 @@ export interface HopyoSheetBody { destroy: () => void; /** 안 옮긴 단가산출 블록 수(판정 전 null) */ skCount: () => { 블록: number; 밖: number; 줄: number } | null; - /** 그 단가산출 블록으로 뜀 + 블록 판 엶 */ + /** 그 단가산출 블록으로 뜀 */ showSk: (번: number) => void; } @@ -130,9 +127,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { let current: number | null = opt.호표 ?? null; const items = new Map(); const target = opt.target ?? opt.scope; - const state = createJudgeState(opt.scope, opt.key); // 판정 고름 캐시(785-6) - let panel: { destroy: () => void } | null = null; // 시트 아래 블록 판 몸 - const offPaint = state.on(() => nav?.repaint()); // 확인 바뀜 = 다시 칠 const head = el("div", { className: "ui-toolbar m02-hopyo__bar", @@ -147,22 +141,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { className: "m02-hopyo-sheet__grid", children: [note("엑셀을 읽는 중…")], }); - const lines = el("div", { - className: "m02-hopyo-sheet__lines", - attrs: { "data-hopyo-lines": "" }, - }); // 시트 아래 블록 판 자리(단가산출 탭 · SheetSk) - lines.hidden = true; - const closeLines = (): void => { - panel?.destroy(); - panel = null; - lines.hidden = true; - }; - const openLines = (make: (host: HTMLElement) => { destroy: () => void }): void => { - panel?.destroy(); - lines.hidden = false; - panel = make(lines); - }; - const col = el("div", { className: "m02-hopyo-sheet__col", children: [left, lines] }); + const col = el("div", { className: "m02-hopyo-sheet__col", children: [left] }); const searchField = createInputField({ type: "search", placeholder: "호표 · 품명 · 규격 찾기", @@ -172,14 +151,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { const search = searchField.input; const list = el("div", { className: "m02-hopyo-sheet__list" }); const count = note(""); - const move = createJudgeMove({ - file: opt.scope, - key: opt.key, - target, - state, - readOnly: !opt.canWrite, - onMoved: () => void refresh(), - }); const right = el("div", { className: "m02-hopyo-sheet__side", children: [ @@ -189,16 +160,12 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { children: [count], }), list, - move.root, ], }); const finish = (): void => { if (closed) return; closed = true; - closeLines(); - offPaint(); - move.destroy(); sk?.destroy(); nav?.destroy(); sheet?.destroy(); @@ -208,13 +175,11 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { /** 머리 막대 — 시트 · 호표 수 · 검산 · 제안 · 셈 중 + 덧붙임 */ function paintHead(file: HopyoView): void { const wrong = file.검산.어긋남; - const busy = !!file.셈 && !file.셈.끝; head.replaceChildren( note(`시트 ${file.시트}`), note(`호표 ${file.호표수}`), note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"), - note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`), - ...(busy ? [note(`${file.셈?.까닭 ?? "블록 풀이"} 셈 중`)] : []), + note(`연결 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`), ...(opt.headExtra ?? []), ); opt.onRead?.(file); @@ -240,7 +205,7 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { if (at.시트 !== ilwi || at.범위 !== text) sheet.select(text, ilwi); } - /** [수정] — 그 호표 자리 + 모달 「확인 — 제N호표」(786-2 · 줄 누름과 따로) */ + /** [수정] — 그 호표 자리 + 일위대가 컨테이너 꼴 모달(809-1 · 줄 누름과 따로) */ const editButton = (h: HopyoBrief): HTMLElement => { const b = createButton({ label: "수정", @@ -249,17 +214,14 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { onClick: (e) => { e.stopPropagation(); focus(h.번); - openHopyoCheck({ + openHopyoView({ file: opt.scope, key: opt.key, target, - state, no: h.번, brief: h, canWrite: opt.canWrite, - onSheet: (r, role) => nav?.jumpTo(role === "일위대가표" ? ilwi : detail, r), - onHopyo: () => focus(h.번), - onMoved: () => void refresh(), + onSheet: () => focus(h.번), }); }, }); @@ -312,11 +274,10 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { left.replaceChildren(); const lastOf = (sid: string): string => lastColumn(book.시트.find((x) => x.id === sid) ?? first); - const judged = (no: number, at: number): boolean => !!lineOf(state.get(), no, at)?.확인; - const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left, judged }; + const more = { ...opt, target, grid, last: lastOf, sheet: () => sheet, host: left }; nav = ids.일위대가표 ? Nav.createSheetNav(more) : null; if (nav) warn.after(nav.root); - sk = nav ? createSheetSk({ ...opt, target, nav, state, side: right, openLines, closeLines, onMoved: () => void refresh(), ...(opt.onSkChange ? { onChange: opt.onSkChange } : {}) }) : null; // prettier-ignore + sk = nav ? createSheetSk({ ...opt, target, nav, side: right, ...(opt.onSkChange ? { onChange: opt.onSkChange } : {}) }) : null; // prettier-ignore sheet = createSpreadsheet(left, book, { readOnly: true, 칠: nav?.fill, @@ -339,32 +300,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { }); } - /** 뒤 숫자 판정 셈 중이면 3 초 뒤 목록 「제안 n/m」 · 머리 다시 읽음 — 셈 끝 · 창 닫음 · 못 읽음이면 멈춤 */ - function poll(file: HopyoView): void { - if (!file.셈 || file.셈.끝) return; - window.setTimeout(async () => { - const next = closed ? null : await readHopyo(opt.scope, opt.key).catch(() => null); - if (!next || closed) return; - briefs = next.호표; - paintHead(next); - paintList(); - nav?.marks(briefs); - poll(next); - }, POLL_MS); - } - - /** 옮긴 뒤 — 목록 · 칠 · 단가산출 목록 다시 읽음 */ - async function refresh(): Promise { - const next = await readHopyo(opt.scope, opt.key).catch(() => null); - if (!next || closed) return; - briefs = next.호표; - paintHead(next); - paintList(); - nav?.marks(briefs); - sk?.reload(); - opt.onSkChange?.(); - } - async function load(): Promise { try { const [file, grid] = await Promise.all([ @@ -376,7 +311,6 @@ export function buildHopyoSheet(opt: HopyoSheetOptions): HopyoSheetBody { spans = grid instanceof Error ? [] : grid.호표; paintHead(file); paintList(); - poll(file); if (grid instanceof Error) return void left.replaceChildren(note(grid.message)); await mountSheet(grid); const rows = opt.단산행 === undefined ? null : rowsText(opt.단산행); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetNav.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetNav.ts index 77b14dbc5..ae1217795 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetNav.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetNav.ts @@ -87,8 +87,6 @@ export interface SheetNavOptions { sheet: () => SpreadsheetHandle | null; /** 두 번 누름을 들을 자리(시트 칸) */ host: HTMLElement; - /** 판정 화면에서 확인한 줄(785-6 고름 캐시) */ - judged?: (no: number, at: number) => boolean; } const toCol = (letters: string): number => @@ -208,7 +206,7 @@ export function createSheetNav(opt: SheetNavOptions) { } function repaint(): void { - paint = paintOf(ix, briefs, blocks, () => null, opt.judged); // 옛 확인 창 캐시 없음(785-7) + paint = paintOf(ix, briefs, blocks, () => null); // 줄 표지 = 서버(809-1) opt.sheet()?.repaint(); paintBar(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts index fe49c5f1c..39195b8e3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_SheetSk.ts @@ -1,24 +1,23 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_SheetSk.ts - * 엑셀 보기 창 오른쪽 판 바꾸기(PLAN 780-2 · 785-6 · 요구 627 · 642) — 탭 따라: + * 엑셀 보기 창 오른쪽 판 바꾸기(PLAN 780-2 · 809-1 · 요구 627 · 702) — 탭 따라: * · 일위대가표 = 호표 목록(`Hopyo_Sheet` 판 그대로) · 단가산출표 · 단가산출 상세 = 단가산출 목록 - * (새 블록 판정 `hopyo-judge` 목록 블록 줄 = 판정 · 고를 것 n · 표지 · 부름 · 만든 SK · 캐시 확인) - * · 목록 블록 줄 누름 → 지금 탭의 그 블록 자리 + 시트 아래 줄 판 = 블록 판(`Hopyo_Block.createJudgeBlock` · [확인]) - * · 옮김 = 이 판 아래 [옮기기](`Hopyo_Rows.createJudgeMove` · 호표 목록 판 것과 같은 캐시 · 한 몸) + * (블록 줄 = 단산 N 이름 · 단위 · 부름 수 / 일위대가 밖 · 만든 가져온 식 SK · 재료 = `hopyo-marks`) + * · 블록 줄 누름 → 지금 탭의 그 블록 자리 · 줄 끝 [가져온 식으로 열기](만들 층) · [내 단가산출로](개인 층 · 807-1) = `Hopyo_Imported` 모달 + * (경쟁사 로직 그대로 · 엑셀 단가 · 요소 이음 · 소요량 이음) * · 시트 칸 고름 → 그 행 블록을 목록에서 강조(`SheetNav.blockAt`) - * · 목록은 처음 단가산출 탭에 올 때 읽음 · 만들 층 ≠ 파일 층 · 쓰기 권한 없음 = 보기만 + * · 목록은 처음 단가산출 탭에 올 때 읽음 · 기초 판정 · 블록 판 · [옮기기] 없음(요구 703) * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; -import { el } from "@ui/ui_template_elements"; +import { createButton, el } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { createListRow } from "@ui/ui_template_list_row"; import { createText } from "@ui/ui_template_text"; -import { POLL_MS, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; -import { createJudgeBlock, type JudgeState } from "./M02_MasterTemplete_Hopyo_Block"; -import { createJudgeMove } from "./M02_MasterTemplete_Hopyo_Rows"; -import { readJudge, type JudgeBlockBrief } from "./M02_MasterTemplete_Hopyo_JudgeApi"; -import type { SheetNav } from "./M02_MasterTemplete_Hopyo_SheetNav"; +import type { HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; +import { openImported } from "./M02_MasterTemplete_Hopyo_Imported"; +import { readHopyoMarks, type SheetNav } from "./M02_MasterTemplete_Hopyo_SheetNav"; +import type { BlockMark } from "./logic/M02_MasterTemplete_Hopyo_SheetMap"; export interface SheetSkOptions { scope: HopyoScope; @@ -26,71 +25,46 @@ export interface SheetSkOptions { target: HopyoScope; canWrite: boolean; nav: SheetNav; - state: JudgeState; /** 호표 목록 판 — 단가산출 탭이면 숨김 · 그 뒤에 단가산출 판을 붙임 */ side: HTMLElement; - /** 시트 아래 줄 판 자리 열기 · 닫기(`Hopyo_Sheet` 가 쥠) */ - openLines: (make: (host: HTMLElement) => { destroy: () => void }) => void; - closeLines: () => void; - /** 판정 · 옮김 뒤(부르는 쪽 끝내기 글) */ + /** 목록 읽은 뒤(부르는 쪽 끝내기 글) */ onChange?: () => void; - /** 옮긴 뒤(목록 · 칠 다시 읽음) */ - onMoved?: () => void; } -const sameLayer = (a: HopyoScope, b: HopyoScope): boolean => - a.layer === b.layer && (a.owner ?? null) === (b.owner ?? null); -const STRIPE = { - done: "var(--color-success)", - ready: "var(--color-info)", - pick: "var(--color-warning)", -} as const; +const STRIPE = { done: "var(--color-success)", ready: "var(--color-info)", out: "var(--color-border)" } as const; // prettier-ignore export function createSheetSk(opt: SheetSkOptions) { const list = el("div", { className: "m02-hopyo-sheet__list" }); - const readOnly = !opt.canWrite || !sameLayer(opt.scope, opt.target); - const move = createJudgeMove({ - file: opt.scope, - key: opt.key, - target: opt.target, - state: opt.state, - readOnly, - ...(opt.onMoved ? { onMoved: opt.onMoved } : {}), - }); - const root = el("div", { - className: "m02-hopyo-sheet__side m02-hopyo-sheet__sk", - children: [list, move.root], - }); + const root = el("div", { className: "m02-hopyo-sheet__side m02-hopyo-sheet__sk", children: [list] }); // prettier-ignore root.setAttribute("data-hopyo-sk-side", ""); root.hidden = true; opt.side.after(root); - let blocks: JudgeBlockBrief[] | null = null; + let blocks: BlockMark[] | null = null; let asked = false; let sheet = ""; let current: number | null = null; + const mineAt = { layer: "personal", owner: null } as const; // [내 단가산출로](807-1) = 늘 개인 층 + const open = (no: number, mine: boolean): void => + openImported({ file: opt.scope, key: opt.key, no, target: mine ? mineAt : opt.target, canWrite: mine || opt.canWrite }); // prettier-ignore + function draw(): void { if (!blocks) return; - const d = opt.state.get(); list.replaceChildren( ...blocks.map((b) => { - const ok = !!d.블록[String(b.번)]?.확인; - const sub = [ - b.단위, - b.판정 ? `${b.판정}${b.판정 === "고를 것" && b.고를수 ? ` ${b.고를수}` : ""}` : "", - b.표지 ?? "", - b.부름.length ? `${L("M02_JudgeCalls")} ${b.부름.length}` : L("M02_JudgeOutside"), - ].filter(Boolean).join(" · "); // prettier-ignore - const tail = b.만든 - ? createBadge(`${L("M02_JudgeMade")} ${b.만든}`, "success") - : ok - ? createBadge(L("M02_JudgeConfirmed"), "accent") - : ""; + const calls = b.부름?.length ?? 0; + const sub = [b.단위 ?? "", calls ? `${L("M02_JudgeCalls")} ${calls}` : L("M02_JudgeOutside")].filter(Boolean).join(" · "); // prettier-ignore + const go = createButton({ label: L("M02_CheckImported"), size: 24, variant: "ghost", onClick: (e) => { e.stopPropagation(); open(b.번, false); } }); // prettier-ignore + go.dataset.skOpen = String(b.번); + go.hidden = opt.target.layer === "personal" && !opt.target.owner; // 만들 층 = 개인이면 [내 단가산출로]와 같은 일 + const mine = createButton({ label: L("M02_ToMySk"), size: 24, variant: "ghost", onClick: (e) => { e.stopPropagation(); open(b.번, true); } }); // prettier-ignore + mine.dataset.skMine = String(b.번); + const tail = el("span", { className: "ui-toolbar", children: [...(b.SK ? [createBadge(`${L("M02_JudgeMade")} ${b.SK}`, "success")] : []), go, mine] }); // prettier-ignore const row = createListRow({ - label: `${L("M02_JudgeBlock").replace("{n}", String(b.번))} ${b.이름}`, + label: `${L("M02_JudgeBlock").replace("{n}", String(b.번))} ${b.이름 ?? ""}`, sub, trailing: tail, - stripe: b.만든 || ok ? STRIPE.done : b.판정 === "고를 것" ? STRIPE.pick : STRIPE.ready, + stripe: b.SK ? STRIPE.done : calls ? STRIPE.ready : STRIPE.out, active: b.번 === current, size: 36, onClick: () => pick(b.번), @@ -106,18 +80,16 @@ export function createSheetSk(opt: SheetSkOptions) { if (asked) return; asked = true; if (!blocks) list.replaceChildren(createText(L("M02_JudgeLoading"), "hint")); - void readJudge(opt.scope, opt.key, opt.target) + void readHopyoMarks(opt.scope, opt.key, opt.target) .then((got) => { - blocks = got.블록; + blocks = got; draw(); opt.onChange?.(); - // 블록 풀이 뒤 셈 중 — 끝나면 판정 · 고를 것이 실림(3 초마다 · 판이 붙어 있는 동안) - if (got.셈중) setTimeout(() => list.isConnected && ((asked = false), ensure()), POLL_MS); }) .catch((e: Error) => list.replaceChildren(createText(e.message, "error"))); } - /** 블록 고름 — 그 자리 뜀 + 줄 판 = 블록 판 */ + /** 블록 고름 — 그 자리 뜀 */ function pick(no: number): void { current = no; draw(); @@ -126,43 +98,21 @@ export function createSheetSk(opt: SheetSkOptions) { ? (opt.nav.spotOf(no, "단가산출표") ?? opt.nav.spotOf(no, "단가산출상세")) : (opt.nav.spotOf(no, "단가산출상세") ?? opt.nav.spotOf(no, "단가산출표")); if (spot) opt.nav.jumpTo(spot.시트, spot.행); - const detail = opt.nav.spotOf(no, "단가산출상세")?.시트; - opt.openLines((host) => - createJudgeBlock({ - host, - file: opt.scope, - key: opt.key, - target: opt.target, - state: opt.state, - no, - readOnly, - onClose: opt.closeLines, - ...(detail ? { onSheet: (r: number) => opt.nav.jumpTo(detail, r) } : {}), - }), - ); } - const off = opt.state.on(draw); - return { root, - /** 아직 안 옮긴 블록 수 · 그중 일위대가 밖 · 고를 것 합 — 목록 전 null */ + /** 아직 SK 안 만든 블록 수 · 그중 일위대가 밖 — 목록 전 null */ count: (): { 블록: number; 밖: number; 줄: number } | null => { if (!blocks) return null; - const left = blocks.filter((b) => !b.만든); - const 밖 = left.filter((b) => !b.부름.length).length; - return { 블록: left.length, 밖, 줄: left.reduce((a, b) => a + (b.고를수 ?? 0), 0) }; + const left = blocks.filter((b) => !b.SK); + return { 블록: left.length, 밖: left.filter((b) => !b.부름?.length).length, 줄: 0 }; }, - /** 그 블록으로 뜀 + 블록 판 */ + /** 그 블록으로 뜀 */ show(번: number): void { ensure(); pick(번); }, - /** 옮긴 뒤 — 목록 다시 읽음(만든 SK) */ - reload(): void { - asked = false; - if (!root.hidden) ensure(); - }, /** 시트 고름 · 탭 바꿈 — 판 바꾸기 · 강조 */ at(sel: { 시트: string; 칸: string }): void { sheet = sel.시트; @@ -179,10 +129,7 @@ export function createSheetSk(opt: SheetSkOptions) { draw(); } }, - destroy(): void { - off(); - move.destroy(); - }, + destroy(): void {}, }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts new file mode 100644 index 000000000..dd80fa9a6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_View.ts @@ -0,0 +1,235 @@ +/* ============================================================================= + * M02_MasterTemplete_Hopyo_View.ts + * 엑셀 불러오기 [수정] 모달(PLAN 809-1 · 요구 702 · 703) — 엑셀 보기 오른쪽 호표 목록 [수정](`Hopyo_Sheet`)이 엶. + * 경쟁사 로직 인정 · 요소만 연결 — 몸 = 일위대가 컨테이너 부품(`Labor_Table` · 806-1 조합과 한 벌 · 읽기만) + 덧칸 둘: + * · 표지 = 로직 줄 「단가산출 N」 · 「일위대가 N」 칩 / 요소 줄 「✓ 연결됨」 · 「후보 n」 · 「못 이음」(엑셀 값 그대로) + * · 엑셀 금액 = 그 줄 엑셀 금액 계 + * 줄 = 서버 `GET …/hopyo-view/{키}/{번}`(`M02_Hopyo_View` · 엑셀 숫자 그대로 고정 줄 · 저장 없음 · 금액 = 엑셀). + * · 단가산출 줄 누름 = 그 블록 가져온 식 SK(서버 `…/sangeun/from-hopyo` · 만들 층 · 같은 내용 = 있는 것) → + * 단가산출 「수식」 탭 모달(`Labor_SkEdit` 부르기만 · [엑셀 단가 그대로 | 우리 단가로]) + * · 일위대가 줄 누름 = 그 하위 호표 [수정] 모달 · 후보 줄 누름 = 후보 목록(고르기 = [내 일위대가로] 뒤 일위대가 [연결]) + * · 머리 = 표지 수 · 엑셀 단가 · [내 일위대가로](807-1 `Hopyo_Mine`) · [구조물 조합으로](만든 SC 있을 때 · `Hopyo_ToItem`) · [엑셀 보기] + * 기초 판정 · 「우리 기초로 바꾸기」 없음(요구 703). + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { loadingError, showLoading } from "@ui/ui_template_loading"; +import { currentLanguageIndex, t as L } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { createTable } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { formatPrice } from "../common_util/common_util_money"; +import { call } from "./M02_MasterTemplete_Api_Fetch"; +import { at, type HopyoBrief, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api"; +import { mineButton } from "./M02_MasterTemplete_Hopyo_Mine"; +import { toItem } from "./M02_MasterTemplete_Hopyo_ToItem"; +import type { LaborDetailHandle } from "./M02_MasterTemplete_Labor_Detail"; +import { openSkEdit } from "./M02_MasterTemplete_Labor_SkEdit"; +import { mountLaborTable } from "./M02_MasterTemplete_Labor_Table"; + +const TEXT = { + Title: ["제{n}호표 — 엑셀 불러오기", "Unit cost #{n} — Excel import"], + Mark: ["표지", "Status"], + Excel: ["엑셀 금액", "Excel amount"], + Linked: ["✓ 연결됨", "✓ Linked"], + Near: ["후보 {n}", "{n} candidates"], + None: ["못 이음", "Not linked"], + Logic: ["로직 {n}", "Logic {n}"], + ExcelPrice: ["엑셀 단가", "Excel price"], + Hint: [ + "경쟁사 로직 그대로 · 요소만 연결 — 단가산출 줄을 누르면 수식 탭 · 금액 = 엑셀", + "Competitor logic kept · elements linked — click a build-up line for its formula", + ], + Sheet: ["엑셀 보기", "Show in Excel"], + NearTitle: ["이음 후보 — {name}", "Link candidates — {name}"], + NearHint: [ + "고르기 = [내 일위대가로] 뒤 내 일위대가의 [연결]", + "Pick after [To my unit costs] with [Link]", + ], + Key: ["키", "Key"], + Name: ["이름", "Name"], + Close: ["닫기", "Close"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; +const why = (e: unknown): string => (e instanceof Error ? e.message : String(e)); +const enc = encodeURIComponent; + +type Money = Record<"노무비" | "재료비" | "경비" | "계", number | null>; +/** 줄 표지(서버 `M02_Hopyo_View.mark`) */ +export interface ViewMark { + 차례: number; + 갈래: "로직" | "요소"; + 표지: "단가산출" | "일위대가" | "연결됨" | "후보" | "못 이음"; + 번?: number | null; + 이음?: Record; + 후보?: { 키: string; 이름: string }[]; + 엑셀: Money; +} +export interface ViewReply { + 번: number; + 이름: string; + 규격: string | null; + 단위: string | null; + 금액: Money | null; + 문서: unknown; + 줄: ViewMark[]; +} + +export const readHopyoView = (file: HopyoScope, key: string, no: number): Promise => + call(at(file, `/hopyo-view/${enc(key)}/${no}`)); + +/** 표지 칸 글 */ +export function markText(m: ViewMark): string { + if (m.갈래 === "로직") return `${m.표지}${m.번 ? ` ${m.번}` : ""}`; + if (m.표지 === "연결됨") return t("Linked"); + if (m.표지 === "후보") return t("Near").replace("{n}", String(m.후보?.length ?? 0)); + return t("None"); +} + +/** 표지 칸 꼴 — 로직 = 칩 · 연결됨 초록 · 후보 주황 · 못 이음 빨강 */ +const markTone = (m: ViewMark): string => + m.갈래 === "로직" + ? "ui-hopyo-grid__tag is-pum" + : m.표지 === "연결됨" + ? "ui-text--success" + : m.표지 === "후보" + ? "ui-text--warning" + : "ui-text--error"; + +export interface ViewOptions { + /** 실무자료 파일 층 */ + file: HopyoScope; + key: string; + /** 가져온 식 SK 를 만들 층 */ + target: HopyoScope; + no: number; + /** 목록 한 줄([내 일위대가로] · 엑셀 단가) */ + brief?: HopyoBrief; + canWrite: boolean; + /** 모달 닫고 엑셀 보기 그 호표 */ + onSheet?: () => void; +} + +/** 후보 줄 — 후보 목록 */ +function showNear(name: string, m: ViewMark): void { + openModal({ + title: t("NearTitle").replace("{name}", name), + closeLabel: t("Close"), + dialogClass: "m02-modal", + mount: (body) => { + const rows = (m.후보 ?? []).map((c) => [c.키, c.이름]); + body.append(createTable([{ label: t("Key") }, { label: t("Name") }], rows, { density: "data" })); // prettier-ignore + body.append(createText(t("NearHint"), "hint")); + }, + }); +} + +/** [구조물 조합으로] — 만든 SC(이 파일 층에서 옮긴 것)로 구조물 조합 만들기 */ +const itemButton = (target: HopyoScope, sc: string, name: string, unit: string): HTMLElement => + createButton({ label: L("M02_CheckToItem"), size: 28, variant: "ghost", onClick: () => toItem({ target, sc, name, unit }) }); // prettier-ignore + +/** [수정] 모달 */ +export function openHopyoView(o: ViewOptions): void { + const root = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": `${o.key}|${o.no}` } }); // prettier-ignore + const host = el("div", { attrs: { "data-hopyo-view-body": "" } }); + let table: LaborDetailHandle | null = null; + let close = (): void => {}; + + /** 단가산출 줄 — 가져온 식 SK(만들 층) → 수식 탭 모달 */ + async function openSk(m: ViewMark, name: string, head: string): Promise { + try { + const made = await call<{ 이름: string }>(at(o.target, "/sangeun/from-hopyo"), { + method: "POST", + body: JSON.stringify({ 층: o.file.layer, ...(o.file.owner ? { 주인: o.file.owner } : {}), 키: o.key, 번: m.번 }), // prettier-ignore + }); + const ref = { 층: o.target.layer, 주인: o.target.owner, 키: made.이름 }; + void openSkEdit({ ref, title: `${name} — ${made.이름}`, head, readOnly: !o.canWrite, closed: () => {} }); // prettier-ignore + } catch (e) { + showToast(why(e), "error"); + } + } + + function draw(r: ViewReply): void { + const count = (k: ViewMark["표지"]): number => r.줄.filter((m) => m.표지 === k).length; + const logic = r.줄.filter((m) => m.갈래 === "로직").length; + const name = [r.이름, r.규격].filter(Boolean).join(" "); + const head = [name, r.단위 ? `1 ${r.단위} 당` : ""].filter(Boolean).join(" · "); + const badges = [ + createBadge(t("Logic").replace("{n}", String(logic)), "accent"), + createBadge(`${t("Linked")} ${count("연결됨")}`, "success"), + createBadge(t("Near").replace("{n}", String(count("후보"))), "warning"), + createBadge(`${t("None")} ${count("못 이음")}`, "danger"), + ]; + badges.forEach((b, i) => (b.dataset.viewCount = ["로직", "연결됨", "후보", "못 이음"][i])); + const sheet = createButton({ label: t("Sheet"), size: 28, variant: "ghost", onClick: () => { close(); o.onSheet?.(); } }); // prettier-ignore + const items: (HTMLElement | "divider")[] = [ + el("strong", { text: `제${r.번}호표 ${name}` }), + ...badges, + "divider", + createText(`${t("ExcelPrice")} ${r.금액?.계 == null ? "—" : formatPrice(r.금액.계)}`, "muted", 14), // prettier-ignore + ...(o.brief ? [mineButton(o.file, o.key, o.brief)] : []), + ...(o.brief?.만든 && o.canWrite + ? [itemButton(o.target, o.brief.만든, name, r.단위 ?? "")] + : []), + ...(o.onSheet ? [sheet] : []), + ]; + root.replaceChildren(createToolbar({ items }), createText(t("Hint"), "hint"), host); + table?.destroy(); + table = mountLaborTable(host, r.문서, { + layer: o.target.layer, + owner: o.target.owner, + column: `제${r.번}호표`, + mode: "precombo", + readOnly: true, + onChanged: () => {}, + view: { + cols: [ + { key: "x_mark", label: t("Mark"), width: 96 }, + { key: "x_excel", label: t("Excel"), kind: "number", decimals: 1, trim: true, width: 96 }, + ], + cell: (n, key) => { + const m = r.줄[n]; + if (!m) return null; + return key === "x_mark" ? markText(m) : m.엑셀.계; + }, + tone: (n, key) => (key === "x_mark" && r.줄[n] ? markTone(r.줄[n]) : null), + onRow: (n) => { + const m = r.줄[n]; + const line = (r.문서 as { 담은로직?: { 별칭?: string | null }[] }).담은로직?.[n]; + const label = line?.별칭 ?? name; + if (m?.표지 === "단가산출" && m.번) return (void openSk(m, label, head), true); + if (m?.표지 === "일위대가" && m.번) return void openHopyoView({ ...o, no: m.번, brief: undefined }), true; // prettier-ignore + if (m?.표지 === "후보") return (void showNear(label, m), true); + return true; // 요소 줄 = 엑셀 숫자 그대로(모달 없음) + }, + }, + }); + } + + async function load(): Promise { + const stop = showLoading(root); + try { + draw(await readHopyoView(o.file, o.key, o.no)); + } catch (e) { + root.replaceChildren(loadingError(why(e), () => void load())); + } finally { + stop(); + } + } + + ({ close } = openModal({ + title: t("Title").replace("{n}", String(o.no)), + closeLabel: t("Close"), + wide: true, + width: "1100px", + dialogClass: "m02-modal m02-hopyo__view-modal", + beforeClose: () => { + table?.destroy(); + return true; + }, + mount: (body) => body.append(root), + })); + void load(); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 8cbab866d..1a563f3e1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -9,6 +9,7 @@ * 양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main. * ========================================================================== */ +import type { DataColumn } from "@ui/ui_template_data_table"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import type { LaborCalc, LaborDoc } from "./M02_MasterTemplete_Labor_Api"; import { mountLaborTable } from "./M02_MasterTemplete_Labor_Table"; @@ -24,6 +25,8 @@ export interface LaborDetailOptions { card?: boolean; /** 일위대가 사전조합 서고(50-3) — 사전조합 문서(`column` = 키) */ mode?: "precombo"; + /** 덧칸(809-1 불러오기 [수정] 보기 · 읽기만) — 줄 표지 · 엑셀 금액 열 · 줄 누름 가로챔 */ + view?: LaborView; /** 그 층 주인(134-3 · 사전조합 층 · null = 자기 층) — 없으면 페이지 층 것 */ owner?: string | null; /** 셈 · 면적 자리 주입(142-1 · 고르기 모달 = 프로젝트 복사본) — 없으면 층 길 */ @@ -31,6 +34,14 @@ export interface LaborDetailOptions { readArea?: () => Promise<{ 문서: unknown; 판?: string | null }>; } +/** 덧칸 — `cell(n, 열)` = 문서 줄 n 의 값 · `onRow(n)` 참 = 기본 줄 모달 안 엶 */ +export interface LaborView { + cols: DataColumn[]; + cell: (n: number, key: string) => string | number | null; + tone?: (n: number, key: string) => string | null; + onRow?: (n: number) => boolean; +} + export interface LaborDetailHandle { getDoc: () => LaborDoc; /** [엑셀에서 고르기]로 바뀐 면적산출 문서를 저장(일위대가 [저장] 앞) · 저장했으면 참(없으면 거짓) — diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts index 4622bd325..ef9539cad 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts @@ -154,7 +154,11 @@ export function mountLaborTable( const grid = createHopyoGrid( [block], { - columns: g.columns.map((c) => (label[c.key] ? { ...c, label: label[c.key] } : c)), + columns: [ + ...g.columns.map((c) => (label[c.key] ? { ...c, label: label[c.key] } : c)), + ...(opt.view?.cols ?? []), + ], + ...(opt.view ? { rows: viewRows(g) } : {}), // 809-1 덧칸 값 // 조합 갈래 — 수량 칸 누름 = 수량산출 모달(줄 모달은 안 엶) onCellClick: combo ? (i, key) => { @@ -167,6 +171,7 @@ export function mountLaborTable( onRowClick: (i) => { if (qtyHit) return void (qtyHit = false); const n = at(i); + if (n !== null && opt.view?.onRow?.(n)) return; if (n !== null) openAt(n); }, // 줄 끝 ⋯(731-1) — 우클릭 · 길게 누름이 없어도 같은 메뉴(위로 · 아래로 · 별칭 · 삭제) @@ -191,7 +196,7 @@ export function mountLaborTable( cellClass: (i, key) => { const n = at(i); if (key === "note" && n !== null && empties.has(n)) return "ui-hopyo-grid__blank"; - return hopyoCellClass(g.rows, i, key); + return (n !== null && opt.view?.tone?.(n, key)) || hopyoCellClass(g.rows, i, key); }, }, edit ? (text) => void pasteInto(cur, text, afterPaste) : undefined, @@ -205,7 +210,7 @@ export function mountLaborTable( requestAnimationFrame(() => requestAnimationFrame(() => openTreeMenu(ev, menuAt(n)))); }); tipBorn(grid.root, at, (n) => (cur.담은로직[n] ?? cur.자재[n - cur.담은로직.length])?.본뜬); // 740-2 - const bar = unlinkedBar(cur, edit && ((qtyOnly) => changed(qtyOnly))); // 729-3 안 이은 줄 띠 + const bar = opt.view ? null : unlinkedBar(cur, edit && ((qtyOnly) => changed(qtyOnly))); // 729-3 안 이은 줄 띠 · 덧칸 = 표지 열이 대신 const specs = combo && edit ? [specBar(cur, calc ?? last, (qtyOnly) => changed(qtyOnly))] : []; gridHost.replaceChildren(...(bar ? [bar] : []), ...specs, grid.root); dg?.draw(cur, logics, calc ?? last, openAt); @@ -213,6 +218,14 @@ export function mountLaborTable( for (const fn of watchers) fn(calc ?? last); }; + /** 덧칸 값(809-1) — 구성 줄만 */ + const viewRows = (g: ReturnType) => + g.rows.map((r, i) => { + const n = g.at.get(i)?.[1]; + const v = opt.view; + return n === undefined || !v ? r : { ...r, ...Object.fromEntries(v.cols.map((c) => [c.key, v.cell(n, c.key)])) }; // prettier-ignore + }); + /** 서버 다시 셈 — 늦게 온 답은 버림 */ const recalc = (): void => { window.clearTimeout(timer); diff --git a/M02_MasterTemplete/M02_San_Basis.py b/M02_MasterTemplete/M02_San_Basis.py deleted file mode 100644 index 1020809b8..000000000 --- a/M02_MasterTemplete/M02_San_Basis.py +++ /dev/null @@ -1,602 +0,0 @@ -"""근거 제안 엔진(PLAN 793-3a · 요구 648 · 649 · 652 갈래 C · 분석 648-F H1~H5 · 648-D2) — 서버 -단독 · 읽기만. - -산근 블록(HP `단산` 꼴) → 절마다 번호 후보(`M02_Hopyo_Clause.basis`) ∩ 값 -후보(`M02_San_Basis_Value` · -역색인 · 유의 4) ∩ 제목 후보(이름 두 글자 묶음 Dice ≥ 0.35 · 같은 말 묶음) · 기계 작업량 절(q · K -· f · E · Cm -둘+)은 일 갈래(번호 · 제목) 먼저 · 차례 = 분석 648-D2 1 장 · 후보 2~6 이면 엔진 품 대조로 하나만 -맞으면 확정 · -같은 기초 절은 묶어 검산(`M02_San_Basis_Check`) · 절 2+ 블록은 블록 통째 길도 셈해 나은 꼴. -꼴(648-D2) = 확정·전부 · 확정·일부 · 확정·일부(절 미확정) · 후보 2~3 · 없음. -단(근거후보 · 화면 · 793-2 SK) = 확정(기초 확정 · 엔진 품 = 엑셀 품 · 품 다름 0) · 근접(기초 확정 -· 품 다름 · -품 대조 못 함 · 까닭 표지) · 사람(후보 2~3) · 없음(M01 없음 의심 → `보강` = 794-2 근거). -자동 바꿈 없음 · 지어 넣는 값 없음(못 읽은 칸 · 글에 없는 감률 · 할증 = 입력필요). -794-2c — 같은 말 묶음(SYN) · 옛 번호 → 현행 절(`old_cands` · `M02_San_Basis_Map.OLD`) 번호 후보 · -Q 식 절은 줄 글에서 기호 끝값을 더 읽고(`_fill`) 값 · 제목 길이 품 검산으로 안 받쳐지면 그 기계 -「작업량당 기계경비」 로직(`by_machine` · 차례 「기계」). -""" - -from __future__ import annotations - -import re -from typing import Any - -from M02_MasterTemplete import M02_Hopyo_Bind as hb -from M02_MasterTemplete import M02_Hopyo_Clause as clause -from M02_MasterTemplete import M02_Hopyo_Formula as formula -from M02_MasterTemplete import M02_Hopyo_Solve as solve -from M02_MasterTemplete import M02_San_Basis_Check as chk -from M02_MasterTemplete import M02_San_Basis_Const as bc -from M02_MasterTemplete import M02_San_Basis_Index as bi -from M02_MasterTemplete import M02_San_Basis_Map as bm -from M02_MasterTemplete import M02_San_Basis_Value as bv - -TITLE_MIN, TITLE_GAP, TITLE_TOP = 0.35, 0.15, 5 -SYN = [ - ("B/H", "굴착기"), - ("BACK-HOE", "굴착기"), - ("BACKHOE", "굴착기"), - ("백호", "굴착기"), - ("굴삭", "굴착"), - ("D/Z", "불도저"), - ("도자", "불도저"), - ("불도우저", "불도저"), - ("D/P", "덤프"), - ("브레카", "브레이커"), - ("브레이카", "브레이커"), - ("깍기", "깎기"), - ("뒷채움", "뒤채움"), - ("로울러", "롤러"), - ("콤펙터", "콤팩터"), - ("레미콘", "레디믹스트콘크리트"), - ("컨테이너", "콘테이너"), - ("휠라", "채움재"), - ("평떼", "평잔디"), - ("육샹", "육상"), -] -ORDER = ["확정·전부", "확정·일부", "확정·일부(절 미확정)", "후보 2~3", "없음"] -NONE = {"확정": [], "동점": [], "약함": []} - - -class Ctx: - """역색인 + M01 Master + 로직 이름 묶음(역색인 지문마다 한 벌).""" - - def __init__(self, idx: bi.Index): - self.idx, self.m = idx, hb.m01.cm.master() - self.logic = { - **self.m.index.get("GC", {}), - **self.m.index.get("GF", {}), - **self.m.index.get("GX", {}), - } - self.grams = {k: grams(r.get("이름")) for k, r in self.logic.items()} - self.mach = bm.machines(self.logic) - - -_CTX: list = [None, None] - - -def ctx() -> Ctx: - idx = bi.get() - if _CTX[0] is not idx: - _CTX[:] = [idx, Ctx(idx)] - return _CTX[1] - - -# ── 제목 → 로직 이름 후보 ─────────────────────────────────────────── -def syn(text: Any) -> str: - t = str(text or "") - for a, b in SYN: - t = re.sub(re.escape(a), b, t, flags=re.I) - return t - - -def grams(text: Any) -> set: - return clause._grams(re.sub(r"\d", " ", syn(text))) - - -def title_cands(c: Ctx, text: str, unit: Any) -> list[tuple[str, float]]: - """제목 ↔ 로직 이름 Dice ≥ 0.35 · 1등과 0.15 안 · 다섯까지 · 결과단위 성격 다르면 뺌.""" - mine = grams(text) - if not mine: - return [] - nat, scored = clause.nature(unit), [] - for k, gs in c.grams.items(): - theirs = clause.nature(c.logic[k].get("결과단위")) - if not gs or nat and theirs and nat != theirs: - continue - s = 2 * len(mine & gs) / (len(mine) + len(gs)) - if s >= TITLE_MIN: - scored.append((k, round(s, 3))) - scored.sort(key=lambda x: (-x[1], x[0])) - return [x for x in scored if x[1] >= scored[0][1] - TITLE_GAP][:TITLE_TOP] if scored else [] - - -def dice(c: Ctx, key: str, text: str) -> float: - a, b = grams(text), c.grams.get(key, set()) - return 2 * len(a & b) / (len(a) + len(b)) if a and b else 0.0 - - -# ── 블록 · 절 ─────────────────────────────────────────────────────── -def _flat(t: Any) -> str: - return re.sub(r"[\s.()\[\]:·,]", "", str(t or "")) - - -def parts_of(blk: dict) -> tuple[list[dict], dict, list]: - """일 절(`Formula.works`)마다 {제목, 번, 길, 항목, 상수, 식, 선택값, 몫} · 블록 통째 상수 · 쓸 - 항목. - """ - nodes = blk.get("절") or formula.tree( - blk.get("글") or [], blk.get("글행"), blk.get("항목") or [] - ) - top = formula.head(blk.get("글") or [], blk.get("글행")) - works = formula.works(nodes) - paths = {id(n): p for p, n in clause._paths(nodes)} - items = blk.get("항목") or [] - usable = solve._usable(items) - lines = [str(t) for t in blk.get("글") or []] - whole = {k: top[k] + solve._gather(nodes, k) for k in ("상수", "식", "선택값")} - _fill(whole, lines) - if len(works) < 2: - one = {"제목": "(블록 통째)", "번": None, "길": None, "항목": usable, "글줄": lines} - one |= whole - one["몫"] = works[0].get("몫") if works else None - return [one], whole, usable - out = [] - for w in works: - at = [i for i in solve._gather([w], "항목") if not items[i].get("밖")] - part = { - "제목": f"{w['번']}. {w['제목']}", - "번": w["번"], - "길": paths.get(id(w)), - "항목": [items[i] for i in at], - "글줄": lines[w["글"][0] : _end(w) + 1] if w.get("글") else [], - } - part |= {k: top[k] + solve._gather([w], k) for k in ("상수", "식", "선택값")} - _fill(part, part["글줄"]) - part["몫"] = w.get("몫") - out.append(part) - if not any(p["항목"] for p in out): # STmate — 글행 없음 → 항목 절 이름으로 붙임 - for it in usable: - name = _flat(it.get("윗절") or it.get("절")) - hit = next( - ( - p - for p in out - if name and (_flat(p["제목"]).endswith(name) or name in _flat(p["제목"])) - ), - None, - ) - if hit: - hit["항목"].append(it) - return out, whole, usable - - -def _end(node: dict) -> int: - return max([node["글"][1], *(_end(n) for n in node.get("아래") or [] if n.get("글"))]) - - -def _fill(part: dict, lines: list[str]) -> None: - """기계 작업량 절(Q 식 줄) — 못 읽은 기호(q · K · f · E · Cm)를 줄 글 끝값으로 더함 · 고르는 - 차례(`is_machine`)는 채우기 전 상수로.""" - if bm.has_q(lines): - part["기계식"] = is_machine(part) - have = {chk.base_sym(x.get("기호") or "") for x in part["상수"]} - part["상수"] = part["상수"] + [ - x for x in bm.consts(lines) if chk.base_sym(x["기호"]) not in have - ] - - -def old_cands(c: Ctx, basis: dict, title: str, unit: Any) -> list[dict]: - """옛 번호 → 현행 절 로직(`M02_San_Basis_Map`) · 건설품셈(옛) 같은 번호는 같은 절 · 제목과 뜻 - 겹침만 · 결과단위 성격 거름.""" - mine, have, out = clause.nature(unit), {x["키"] for x in basis["후보"]}, [] - for book, no in basis["출처"]: - if not book.endswith(clause.OLD): - continue - rows = bm.old_rows(book, no) - if not rows and book == "건설품셈" + clause.OLD: - rows = [ - o - for o in clause.lookup("건설품셈", no) - if o["맞음"] == "같음" and dice(c, o["키"], title) > 0 - ] - for o in rows: - theirs = clause.nature(o["결과단위"]) - if o["키"] not in have and not (mine and theirs and mine != theirs): - have.add(o["키"]) - out.append({**o, "절": None}) - return out - - -def value_ev(rec: dict | None, parts: list[dict]) -> tuple[dict, list[dict], dict]: - """값 길 — 블록 {확정, 동점, 약함} · 절마다 · 로직별 옵션.""" - if rec is None: - return dict(NONE), [dict(NONE) for _ in parts], {} - opts: dict = {} - for b in rec.get("기초", []) + rec.get("동점", []): - opts.setdefault(b["로직"], {}).update(b.get("옵션") or {}) - blk = { - "확정": [b["로직"] for b in rec.get("기초", [])] if rec["판정"] in ("U", "P") else [], - "동점": [b["로직"] for b in rec.get("동점", [])] if rec["판정"] == "A" else [], - "약함": [b["로직"] for b in rec.get("약한후보", [])[:1]], - } - secs, out = rec.get("절") or [], [] - for i, p in enumerate(parts): - s = None - if len(parts) > 1: - head = lambda x: _flat(x["절"]).split(".")[-1][:6] # noqa: E731 - s = ( - secs[i] - if len(secs) == len(parts) - else next((x for x in secs if head(x) and head(x) in _flat(p["제목"])), None) - ) - one = {"확정": [], "동점": [], "약함": []} - if s and s["판정"] in ("U", "P") and s.get("기초"): - one["확정"] = [s["기초"]["로직"]] - opts.setdefault(s["기초"]["로직"], {}).update(s["기초"].get("옵션") or {}) - elif s and s["판정"] == "A": - one["동점"] = list(s.get("동점") or []) - out.append(one) - return blk, out, opts - - -def is_machine(part: dict) -> bool: - syms = {chk.base_sym(c.get("기호") or "") for c in part["상수"]} | { - chk.base_sym(f.get("기호") or "") for f in part["식"] - } - return len(syms - {""}) >= 2 - - -def pick( - c: Ctx, N: list, T: list, Vb: dict, Vs: dict, machine: bool, single: bool, title: str -) -> tuple[list, str]: - """일반: 번호∩값 › 값 › 번호∩제목 › 값동점∩제목 › 번호 › 제목 / - 기계 절: 번호∩제목 › 번호∩값 › 제목∩값 › 번호 › 제목 › 값.""" - Tk = [k for k, _ in T] - sure = [ - k - for k in (Vs["확정"] or (Vb["확정"] if single else [])) - if not title or dice(c, k, title) >= 0.2 - ] - anyv = list(dict.fromkeys(Vs["확정"] + Vs["동점"] + Vb["확정"] + Vb["동점"] + Vb["약함"])) - - def inter(a, b): - return [x for x in a if x in set(b)] - - if machine: - steps = [ - ("번호∩제목", inter(N, Tk)), - ("번호∩값", inter(N, anyv)), - ("제목∩값", inter(Tk, anyv)), - ("번호", N), - ("제목", Tk), - ("값", sure), - ] - else: - steps = [ - ("번호∩값", inter(N, anyv)), - ("값", sure), - ("번호∩제목", inter(N, Tk)), - ("값동점∩제목", inter(Vs["동점"] + Vb["동점"], Tk)), - ("번호", N), - ("제목", Tk), - ] - for name, s in steps: - if s: - return list(dict.fromkeys(s)), name - return [], "없음" - - -def _good(v: dict) -> bool: - return "오류" not in v and not v["셈"][solve.DIFF] and bool(v["셈"][solve.SAME]) - - -def resolve(S: list, how: str, check) -> tuple[list, str]: - """후보 2~6 → 검산(품 같음 있고 품 다름 없는 후보 하나)으로 거름.""" - if 2 <= len(S) <= 6: - good = [k for k in S if _good(check(k))] - if len(good) == 1: - return good, how + "+검산" - return S, how - - -def by_machine(c: Ctx, p: dict, S: list, how: str, title: str, check) -> tuple[list, str]: - """기계 작업량 절(Q 식) — 번호 길이 아니고 고른 기초가 품 검산으로 안 받쳐지면 그 기계 - 「작업량당 기계경비」 로직(품 같음 · 또는 고른 것 없고 품 짝 있음).""" - if how.startswith("번호") or not bm.has_q(p["글줄"]) or not is_machine(p): - return S, how - names = " ".join(str(i.get("이름") or "") for i in p["항목"]) - mk = bm.machine_key(c.mach, syn(f"{p['제목']} {title} {names}")) - if not mk or mk in S or (len(S) == 1 and _good(check(S[0]))): - return S, how - v = check(mk) - cnt = v.get("셈") or {} - if _good(v) or ("오류" not in v and (cnt.get(solve.SAME) or cnt.get(solve.DIFF))): - return [mk], "기계" - return S, how - - -def kind_of(S: list) -> str: - return "확정" if len(S) == 1 else "후보 2~3" if 2 <= len(S) <= 3 else "없음" - - -def _found(c: Ctx, key: str, v: dict, mc: list, rec: dict | None, how: str) -> dict: - kind, missing, differ = chk.judge_one(v, mc) - return { - "꼴": kind, - "기초": key, - "기초이름": c.logic.get(key, {}).get("이름", ""), - "차례": how, - "옵션": v.get("입력값"), - "옵션길": v.get("길"), - "입력필요": v.get("입력필요"), - "0간주": v.get("0간주"), - "글에없음": missing, - "값다름": differ, - "기계상수": mc, - "셈": v.get("셈"), - "다름까닭": chk.causes(v, mc, rec) if differ else [], - } - - -def run_block(c: Ctx, blk: dict, line: dict | None, same: list, rec: dict | None) -> dict: - """블록 하나 합친 길 — {꼴, 블록길, 절 [...], 통째, 기초 [키]} (648-D2 꼴).""" - basis = clause.basis(blk) - parts, whole, usable = parts_of(blk) - title_b = f"{blk.get('이름') or ''} {blk.get('규격') or ''}".strip() - basis["후보"] += old_cands(c, basis, title_b, blk.get("단위")) - src = {"줄": line, "같은줄": same, "블록": blk} - src["제목줄"] = { - "글": re.sub(r"[-_/()\[\],~]+", " ", f"{blk.get('이름') or ''} {blk.get('규격') or ''}"), - "행": None, - } - Vb, Vsecs, vopts = value_ev(rec, parts) - allN = list(dict.fromkeys(x["키"] for x in basis["후보"])) - memo: dict = {} - - def check(key, items, cons, share): - tag = (key, id(items), share, repr(cons)) # 값에 items 를 쥐어 id 를 다시 안 씀 - if tag not in memo: - memo[tag] = (items, chk.verify(c.m, key, items, cons, src, share, vopts.get(key, {}))) - return memo[tag][1] - - def share_of(p): - s = p.get("몫") - return (s.get("값") if isinstance(s, dict) else s) or 1 - - secs = [] - for p, Vs in zip(parts, Vsecs, strict=True): - if p["길"]: - N = [ - x["키"] - for x in basis["후보"] - if x.get("절") in (None, p["길"]) or str(p["길"]).startswith(f"{x.get('절')}.") - ] - text = f"{p['제목']} {title_b}" - else: - N, text = allN, title_b - N, mach = list(dict.fromkeys(N)), p.get("기계식", is_machine(p)) - T = title_cands(c, text, blk.get("단위")) - S, how = pick(c, N, T, Vb, Vs, mach, len(parts) == 1, text) - cons = {x: p[x] for x in ("상수", "식", "선택값")} - S, how = resolve(S, how, lambda k, p=p, cons=cons: check(k, p["항목"], cons, share_of(p))) - S, how = by_machine( - c, p, S, how, title_b, lambda k, p=p, cons=cons: check(k, p["항목"], cons, share_of(p)) - ) - secs.append( - { - "절": p["제목"], - "길": p["길"], - "항목수": len(p["항목"]), - "기계식": mach, - "번호": N, - "제목후보": [k for k, _ in T], - "차례": how, - "후보": S, - "_p": p, - } - ) - chosen = {i: s["후보"][0] for i, s in enumerate(secs) if len(s["후보"]) == 1} - bases = list(dict.fromkeys(chosen.values())) - group = {} - for b in bases: # 같은 기초 절끼리 묶어 검산(기초 하나뿐이면 블록 통째) - if len(bases) == 1: - items, cons, ps = usable, whole, list(parts) - else: - ps = [secs[i]["_p"] for i, k in chosen.items() if k == b] - items = [x for p in ps for x in p["항목"]] - cons = {x: [y for p in ps for y in p[x]] for x in ("상수", "식", "선택값")} - v = check(b, items, cons, share_of(ps[0]) if len(ps) == 1 else 1) - mc = ( - chk.mach_check(c.idx, b, chk.mach_consts(cons)) - if any(s["기계식"] for s in secs) - else [] - ) - group[b] = (v, mc) - for i, s in enumerate(secs): - s.pop("_p") - if ( - i in chosen - and s["차례"] == "제목" - and not _good({"셈": group[chosen[i]][0].get("셈") or {solve.DIFF: 0, solve.SAME: 0}}) - ): - s |= {"꼴": "후보 2~3", "제목단독": True} # 제목 하나뿐 · 품 대조로 못 받침 = 사람 확인 - elif i in chosen: - s |= _found(c, chosen[i], *group[chosen[i]], rec, s["차례"]) - else: - s["꼴"] = kind_of(s["후보"]) - kinds = [s["꼴"] for s in ([s for s in secs if s["항목수"]] or secs)] - if all(k == "확정·전부" for k in kinds): - bk = "확정·전부" - elif all(k.startswith("확정") for k in kinds): - bk = "확정·일부" - elif any(k.startswith("확정") for k in kinds): - bk = "확정·일부(절 미확정)" - else: - bk = "후보 2~3" if "후보 2~3" in kinds else "없음" - out = {"절": secs, "표지": basis["표지"], "출처": basis["출처"], "번호후보": allN} - wb = None - if len(parts) > 1: # 블록 통째 길 — 블록 제목 · 번호 · 블록 값으로 기초 하나 → 블록 전부로 검산 - mach = any(s["기계식"] for s in secs) - S, how = pick( - c, allN, title_cands(c, title_b, blk.get("단위")), Vb, dict(NONE), mach, True, title_b - ) - S, how = resolve(S, how, lambda k: check(k, usable, whole, 1)) - if len(S) == 1: - v = check(S[0], usable, whole, 1) - mc = chk.mach_check(c.idx, S[0], chk.mach_consts(whole)) if mach else [] - wb = _found(c, S[0], v, mc, rec, how) - if how == "제목" and not _good({"셈": v.get("셈") or {solve.DIFF: 0, solve.SAME: 0}}): - wb["꼴"] = "후보 2~3" - else: - wb = {"꼴": kind_of(S), "후보": S, "차례": how} - if wb and ORDER.index(wb["꼴"]) < ORDER.index(bk) and (wb.get("셈") or {}).get(solve.SAME): - out |= {"꼴": wb["꼴"], "블록길": "통째", "통째": wb, "기초": [wb["기초"]]} - else: - out |= {"꼴": bk, "블록길": "절 모음" if len(parts) > 1 else "통째(절 하나)", "통째": wb} - out["기초"] = list(dict.fromkeys(s["기초"] for s in secs if s.get("기초"))) - return out - - -# ── 세 단 · 근거후보 ──────────────────────────────────────────────── -def chosen_units(r: dict) -> list[dict]: - """블록 판정에 쓰인 단위 — 통째 길이면 통째 하나 · 아니면 기초마다 첫 절.""" - if r["블록길"] == "통째" and r.get("통째"): - return [r["통째"]] - out, seen = [], set() - for s in r["절"]: - if s.get("기초") and s["기초"] not in seen: - seen.add(s["기초"]) - out.append(s) - return out - - -def checked(s: dict) -> str: - """품 검산 — 다름 · 같음 · 짝 없음.""" - cnt = s.get("셈") or {} - return "다름" if cnt.get(solve.DIFF) else "같음" if cnt.get(solve.SAME) else "짝 없음" - - -def _tier(s: dict) -> str: - return "확정" if checked(s) == "같음" and not s["값다름"] else "근접" - - -def cands(c: Ctx, r: dict) -> list[dict]: - """근거후보 [{기초, 이름, 원문번호, 옵션, 옵션길, 입력필요, 품검산, 단, 까닭, 글에없음, - 값다름}] · - 사람 = 후보 2~3(각 후보 · 옵션 없음) · 없음 = [].""" - if r["꼴"].startswith("확정"): - out = [] - for s in chosen_units(r): - opts = {k: v for k, v in (s.get("옵션") or {}).items()} - row = c.logic.get(s["기초"], {}) - out.append( - { - "기초": s["기초"], - "이름": row.get("이름", ""), - "원문번호": row.get("원문번호"), - "차례": s["차례"], - "옵션": opts, - "옵션길": s.get("옵션길") or {}, - "입력필요": list(s["입력필요"] or []) + list(s["0간주"] or []), - "품검산": checked(s), - "단": _tier(s), - "까닭": [[chk.CAUSE[t], x] for t, x in s["다름까닭"]], - "글에없음": s["글에없음"], - "값다름": s["값다름"], - } - ) - return out - if r["꼴"] == "후보 2~3": - keys = list( - dict.fromkeys( - k for s in r["절"] if s["꼴"] == "후보 2~3" for k in (s.get("후보") or []) - ) - ) - return [ - { - "기초": k, - "이름": c.logic.get(k, {}).get("이름", ""), - "원문번호": c.logic.get(k, {}).get("원문번호"), - "옵션": {}, - "입력필요": [], - "품검산": None, - "단": "사람", - "까닭": [], - } - for k in keys[:6] - ] - return [] - - -def lack(blk: dict, r: dict) -> dict: - """없음 까닭 — a 기초 없음(번호 확인) · a′ 품목 없음 의심 · f 후보 4+ · 증거 흩어짐 · 794-2 - 보강 근거. - """ - if r["표지"] == "기초 없음": - why = "a 기초 없음(번호 확인)" - elif any(s.get("후보") for s in r["절"]): - why = "f 후보 4+ · 증거 흩어짐" - else: - why = "a′ 품목 없음 의심" - name = re.sub(r"\(.*?\)|\[.*?\]|[^가-힣]", "", f"{blk.get('이름') or ''}")[:8] - return {"까닭": why, "이름": name, "품셈": [f"{b} {n}" for b, n in r["출처"]]} - - -def suggest( - blk: dict, - kind: str, - line: dict | None = None, - same: list | None = None, - g: list[str] | None = None, -) -> dict: - """산근 블록 하나 → 근거 제안 {단, 꼴, 근거후보, 절, 보강?, 값판정}. - `kind` = 책 꼴(STmate · 엑셀꼴) · `line` · `same` = 부르는 첫 줄 · 그 호표 다른 줄(B1 · B6) · - `g` = STmate 숨은 G 식(없으면 보이는 글).""" - c = ctx() - pr = bc.prep(blk, kind, g) - rec = bv.value(c.idx, pr) - r = run_block(c, blk, line, same or [], rec) - out = {**r, "값판정": rec["판정"], "근거후보": cands(c, r)} - if r["꼴"].startswith("확정"): - tiers = {x["단"] for x in out["근거후보"]} - outside = [ - m[6:] for x in out["근거후보"] for m in x["글에없음"] if m.startswith("기초 밖 줄 ") - ] - out["까닭"] = [["기초 밖 줄", ", ".join(outside)]] if outside else [] # 엑셀에만 있는 줄 - sure = tiers == {"확정"} and r["꼴"] != "확정·일부(절 미확정)" - out["단"] = "확정" if sure else "근접" - else: - out["단"] = {"후보 2~3": "사람"}.get(r["꼴"], "없음") - weak = (rec.get("약한후보") or [None])[0] - title = f"{blk.get('이름') or ''} {blk.get('규격') or ''}" - if ( - weak - and weak["로직"] not in {x["기초"] for x in out["근거후보"]} - and dice(c, weak["로직"], title) >= 0.2 - ): - row = c.logic.get( - weak["로직"], {} - ) # 값 길 약한 1 등(유의 4 미만) — 제목과 뜻 겹치면 사람 후보 맨 앞 - opts = {k: v for k, v in weak["옵션"].items()} - out["근거후보"].insert( - 0, - { - "기초": weak["로직"], - "이름": row.get("이름", ""), - "원문번호": row.get("원문번호"), - "차례": "값약함", - "옵션": opts, - "옵션길": dict.fromkeys(opts, "V"), - "입력필요": [], - "품검산": None, - "단": "사람", - "까닭": [], - }, - ) - out["단"] = "사람" - if out["단"] == "없음": - out["보강"] = lack(blk, r) - return out diff --git a/M02_MasterTemplete/M02_San_Basis_Check.py b/M02_MasterTemplete/M02_San_Basis_Check.py deleted file mode 100644 index db01dc81a..000000000 --- a/M02_MasterTemplete/M02_San_Basis_Check.py +++ /dev/null @@ -1,446 +0,0 @@ -"""근거 제안 4 — 기초 하나 검산 · 옵션 · 까닭(PLAN 793-3a · 분석 648-D2 1 장) · 읽기만 · 지어 넣는 -값 없음. - -옵션 = 기존 `M02_Hopyo_Bind.bind`(B1 부르는 줄 규격 · B2 선택값 + 블록 제목 낱말 · B3 기호표 · B4 -대입식 · -B5 표 거꾸로 · B6 레미콘 규격) → 못 읽은 칸 = 값 길 옵션(「V」) → 그래도 고르기 칸이면 고르기 -값마다 넣어 -엑셀 품과 맞댐(조합 60 까지 · 다 같음 하나 = 「검산」 · ±15 % 근접 하나 = 「근접」) → 남은 칸 = -입력 필요. -감률 · 할증 · 가산 · 보정 · 구멍땜 수 칸은 셈 때만 0(검산 몫) · 옵션으로 안 냄(입력 필요 · 요구 -647-1). -품 대조 = `M02_Hopyo_Solve.pair`(엑셀 표시 자릿수 반올림 폭) · 기계 상수(q · K · f · E · E0 · Cm) = -그 로직(하위 포함)이 닿는 표의 같은 기호 칸 · 같은 기호 줄 값과 대조(`mach_check`). -까닭(`causes`) = b 판 차이 · c 현장 값 · d 감률 · 할증 배수 · e 단위 기준 차이 · f 그 밖(분석 -648-D2 5-2 규칙). -""" - -from __future__ import annotations - -import itertools -import math -import re -from decimal import Decimal -from typing import Any - -from M02_MasterTemplete import M02_Hopyo_Bind as hb -from M02_MasterTemplete import M02_Hopyo_Solve as solve -from M02_MasterTemplete import M02_San_Basis_Index as bi - -RATIOS = { - 8: "일↔시간 ×8", - 1 / 8: "일↔시간 ÷8", - 100: "% ×100", - 0.01: "% ÷100", - 1000: "t↔kg ×1000", - 0.001: "t↔kg ÷1000", - 10: "×10", - 0.1: "÷10", - 60: "분↔시간 ×60", - 1 / 60: "분↔시간 ÷60", -} -CAUSE = { - "b": "판 차이", - "c": "현장 값", - "d": "감률 · 할증 배수", - "e": "단위 기준 차이", - "f": "그 밖", -} -ZERO = re.compile(r"감률|할증|가산|구멍땜|보정|증가|추가") - - -# ── 기계 상수 ↔ 그 로직 표 ────────────────────────────────────────── -def _norm(s: Any) -> str: - s = re.sub(r"\(.*?\)", "", str(s or "").replace("㎝", "cm").lower()) - return re.sub(r"[^a-z0-9]", "", s) - - -def base_sym(s: str) -> str: - m = re.match(r"(cm|e0|q|k|f|e)\d*$", _norm(s)) - return m.group(1) if m else "" - - -def mach_consts(cons: dict) -> list[dict]: - out = list(cons.get("상수", [])) - for f in cons.get("식", []): # 식 끝값(Cm = t1+…+t4 = 10.7)은 셈 결과 — 대입 수만 - out += [{"기호": k, "값": v} for k, v in (f.get("대입") or {}).items()] - return out - - -def mach_check(idx: bi.Index, key: str, consts: list[dict]) -> list[dict]: - out, seen = [], set() - tables = idx.item_tables.get(key, set()) - for c in consts: - sym, v = base_sym(c.get("기호") or ""), c.get("값") - if not sym or not isinstance(v, (int, float)) or (c.get("기호"), v) in seen: - continue - seen.add((c.get("기호"), v)) - pool = [] - for t in tables: - tb = idx.tables[t] - for row in tb.get("줄", []): - row_sym = any(isinstance(x, str) and base_sym(x) == sym for x in row.values()) - for col in tb.get("값칸", {}): - if base_sym(col) == sym or row_sym: - pool += bi.nums_of(row.get(col)) - if not pool: - out.append({"기호": c["기호"], "값": v, "대조": "칸 없음"}) - continue - d = len(str(v).split(".")[1]) if "." in str(v) else 0 - near = min(pool, key=lambda x: abs(x - v)) - hit = abs(near - v) <= max(0.5 * 10 ** (-d), abs(v) * 0.005) - out.append( - { - "기호": c["기호"], - "값": v, - "대조": "표에 있음" if hit else "표에 없음", - "가까운": near, - } - ) - return out - - -# ── 옵션 · 검산 ───────────────────────────────────────────────────── -def zeroable(m: Any, key: str, name: str) -> bool: - """글에 없으면 0(없음)으로 볼 만한 수 칸 — 고르기 없음 · 이름이 감률 · 할증 · 가산 · 보정 류.""" - spec = next((x for x in hb.specs_of(m.logic(key)) if x["이름"] == name), {}) - return not spec.get("고르기") and bool(ZERO.search(name)) - - -def _conv(v: Any) -> Any: - if isinstance(v, list): - ends = [x for x in v if isinstance(x, (int, float))] - return sum(ends) / len(ends) if ends else None - return v - - -_RANGE = re.compile(r"^\s*([\d.]+)\s*(?:[∼~]\s*([\d.]+))?\s*$") -_SIZE = re.compile(r"용량|규격|톤|출력") - - -def _ranges(opts: list, base: dict) -> list: - """고르기가 모두 범위(「0.6∼0.8」 · 수 하나)면 이미 든 용량 · 규격 값이 드는 범위만(794-2c · - 버킷용량 → 규격구분) · 하나로 안 좁혀지면 그대로.""" - spans = [_RANGE.match(str(o)) for o in opts] - if not all(spans): - return opts - nums = [ - float(v) - for n, v in base.items() - if _SIZE.search(n) and isinstance(v, (int, float, Decimal)) - ] - hit = [ - o for o, m in zip(opts, spans) if any(float(m[1]) <= x <= float(m[2] or m[1]) for x in nums) - ] - return hit if len(hit) == 1 else opts - - -def probe( - m: Any, key: str, specs: dict, hard: list, base: dict, items: list, share: Any -) -> dict | None: - """못 읽은 고르기 칸을 고르기 값마다 넣어 엑셀 품과 맞댐 — 「검산」 · 「근접」 옵션.""" - if not hard or not all(specs.get(n, {}).get("고르기") for n in hard): - return None - opts = [_ranges(specs[n]["고르기"], base) for n in hard] - if math.prod(len(o) for o in opts) > 60: - return None - exact, close = [], [] - - def sig(rr): - return tuple(round(r["우리"]["품"] or 0, 6) for r in rr if r.get("우리")) - - for combo in itertools.product(*opts): - g2 = dict(zip(hard, combo)) - try: - rr = solve.pair(hb.quantities(m, key, {**base, **g2}), solve._usable(items), share) - except Exception: # noqa: BLE001 — 그 조합은 셈 못 함 - continue - same = sum(r["판정"] == solve.SAME for r in rr) - if any(r["판정"] == solve.NEED for r in rr) or not same: - continue - ratios = [ - r["엑셀"]["품"] / r["우리"]["품"] - for r in rr - if r["판정"] == solve.DIFF and r["엑셀"]["품"] and r["우리"]["품"] - ] - if not ratios: - exact.append((g2, rr)) - elif all(abs(x - 1) <= 0.15 for x in ratios): - close.append((same, -len(ratios), g2, rr)) - if len(exact) == 1: - return {"값": exact[0][0], "줄": exact[0][1], "길": "검산"} - if len(exact) > 1 and len({sig(rr) for _, rr in exact}) == 1: - return {"값": exact[0][0], "줄": exact[0][1], "길": "검산(같은 품 여럿)"} - if not exact and close: - close.sort(key=lambda x: (-x[0], -x[1])) - top = [c for c in close if (c[0], c[1]) == (close[0][0], close[0][1])] - if len(top) == 1 or len({sig(c[3]) for c in top}) == 1: - return { - "값": close[0][2], - "줄": close[0][3], - "길": "근접" + ("" if len(top) == 1 else "(같은 품 여럿)"), - } - return None - - -def verify(m: Any, key: str, items: list, consts: dict, src: dict, share: Any, vopts: dict) -> dict: - """기초 하나 검산 — bind(B1~B6) · 값 길 옵션 · 고르기 맞댐 · 엑셀 품 대조.""" - mine = {**src, **consts} - if src.get("제목줄"): - mine["선택값"] = list(consts.get("선택값") or []) + [src["제목줄"]] - try: - b = hb.bind(m, key, mine, solve.fitter(m, key, items, share)) - specs = {s["이름"]: s for s in hb.specs_of(m.logic(key))} - given = {**b["입력값"], **{n: v[0]["값"] for n, v in b["엇갈림"].items()}} - fromv = { - n: _conv(vopts[n]) - for n in b["입력필요"] + b["안씀"] - if n in vopts and _conv(vopts[n]) is not None - } - need = [n for n in b["입력필요"] if n not in fromv] - zero = {n: 0 for n in need if zeroable(m, key, n)} - idle = { - n: b["기본"][n] for n in b["안씀"] if b["기본"].get(n) is not None and n not in fromv - } - base = {**idle, **given, **fromv, **zero} - rows = solve.pair(hb.quantities(m, key, base), solve._usable(items), share) - got = probe(m, key, specs, [n for n in need if n not in zero], base, items, share) - if got: - fromv |= got["값"] - need = [n for n in need if n not in got["값"]] - rows = got["줄"] - same_q = [] # 엇갈림인데 어느 값이든 품이 같음 = 문제 아님 - for n, alts in b["엇갈림"].items(): - sigs = set() - for alt in alts: - q = hb.quantities(m, key, {**idle, **given, **fromv, **zero, n: alt["값"]}) - sigs.add( - tuple(round(float(x["품"]), 6) if x.get("품") is not None else None for x in q) - ) - if len(sigs) == 1: - same_q.append(n) - except Exception as e: # noqa: BLE001 — 검산 못 함 = 오류 표지(확정 안 함) - return {"키": key, "오류": repr(e)[:160]} - blk = src["블록"] - text = " ".join( - [str(blk.get("이름") or ""), str(blk.get("규격") or "")] - + [str(t) for t in blk.get("글") or []] - ) - words = {"소량": "소량|소형|소규모", "높이": "높이|수직고"} - absorbed = [ - [n, hb.plain(given[n])] - for n, w in b["출처"].items() - if w["길"] == "B5" - and n.endswith("감률") - and n in given - and not re.search(words.get(n.replace("감률", ""), n.replace("감률", "") or "소량"), text) - ] - cnt = { - v: sum(r["판정"] == v for r in rows) - for v in (solve.SAME, solve.DIFF, solve.NEED, solve.EXCEL, solve.OURS) - } - diff = [] - for r in rows: - if r["판정"] == solve.DIFF: - ex, us = r["엑셀"]["품"], r["우리"]["품"] - diff.append( - { - "줄": r["엑셀"].get("이름") or r["우리"].get("이름"), - "엑셀": ex, - "우리": us, - "배": round(ex / us, 4) if ex and us else None, - "묶음": r["엑셀"].get("묶음"), - } - ) - outside = [ - r["엑셀"].get("이름") - for r in rows - if r["판정"] == solve.EXCEL and r["엑셀"] and r["엑셀"].get("품") - ] - way = {n: alts[0]["길"] for n, alts in b["엇갈림"].items()} # 엇갈림 = 첫 값 - way |= {k: v["길"] for k, v in b["출처"].items()} - way |= {k: "V" for k in fromv if not got or k not in got["값"]} - way |= {k: got["길"] for k in got["값"]} if got else {} - return { - "키": key, - "셈": cnt, - "기초밖": outside, - "감률흡수": absorbed, - "입력값": { - **{k: hb.plain(v) for k, v in given.items()}, - **{k: hb.plain(v) for k, v in fromv.items()}, - }, - "길": way, - "입력필요": [n for n in need if n not in zero], - "0간주": list(zero), - "엇갈림": { - k: [hb.plain(x["값"]) for x in v] for k, v in b["엇갈림"].items() if k not in same_q - }, - "품다름": diff, - } - - -def judge_one(v: dict, mach: list[dict]) -> tuple[str, list, list]: - """(확정·전부 | 확정·일부, 글에 없음, 값이 M01 과 다름).""" - if "오류" in v: - return "확정·일부", [], [f"검산 오류 {v['오류'][:60]}"] - missing = list(v["입력필요"]) + [f"옵션 여럿 {k} {vals}" for k, vals in v["엇갈림"].items()] - missing += [f"기초 밖 줄 {x}" for x in v.get("기초밖", [])] - differ = [ - f"품 {d['줄']} 엑셀 {d['엑셀']:.4g} ≠ 우리 {d['우리']:.4g}" - for d in v["품다름"] - if d["엑셀"] and d["우리"] - ] - differ += [ - f"기계 {x['기호']}={x['값']} 표에 없음(가까운 {x['가까운']:g})" - for x in mach - if x["대조"] == "표에 없음" - ] - differ += [f"감률 {n}={x} 로 흡수(글에 그 말 없음) — 품 배수" for n, x in v.get("감률흡수", [])] - differ += [ - f"근접 옵션 {k}={v['입력값'].get(k)}" - for k, w in (v.get("길") or {}).items() - if str(w).startswith("근접") - ] - if not v["셈"].get(solve.SAME) and not v["셈"].get(solve.DIFF): - missing.append("(품 짝 없음)") - return ("확정·전부" if not missing and not differ else "확정·일부"), missing, differ - - -def causes(v: dict, mach: list[dict], rec: dict | None) -> list[list[str]]: - """값 다름 → [갈래 b~f, 글] 【추정 규칙 · 648-D2 5-2】.""" - out = [] - user = bool(rec) and ( - bool(rec.get("입력")) - or any( - "kg/" in str(k.get("단위", "")).lower() or "㎏/" in str(k.get("단위", "")) - for k in rec.get("K", []) - ) - ) - labor = [d["배"] for d in v.get("품다름", []) if d["배"] and d["묶음"] in ("L", "X")] - uniform = len(labor) >= 2 and all(abs(x / labor[0] - 1) <= 0.02 for x in labor) - for d in v.get("품다름", []): - r = d["배"] - if not r: - out.append(["f", f"{d['줄']} 품 0"]) - continue - norm = next((name for k, name in RATIOS.items() if abs(r / k - 1) <= 0.01), None) - if norm: - out.append(["e", f"{d['줄']} {norm}"]) - elif uniform and abs(r - 1) > 0.15: - out.append(["d", f"{d['줄']} 같은 배 ×{r:g}"]) - elif abs(r - 1) <= 0.15: - out.append(["c" if user else "b", f"{d['줄']} ×{r:g}"]) - else: - out.append(["c" if user else "f", f"{d['줄']} ×{r:g}"]) - for n, x in v.get("감률흡수", []): - out.append(["b" if isinstance(x, (int, float)) and x < 15 else "d", f"감률 {n}={x} 흡수"]) - for x in mach: - if x["대조"] == "표에 없음": - out.append( - [ - "b" if x["가까운"] and abs(x["값"] / x["가까운"] - 1) <= 0.15 else "c", - f"기계 {x['기호']}={x['값']}", - ] - ) - return out - - -# ── 판 표지(PLAN 794-2b · 분석 652-794-2a 5 장) — 근거후보 까닭 「판 차이」에 판 번호 ────────── -STRONG = ("판 차이", "현장 값", "그 밖") # 둘 이상 파일 같은 배면 「판 차이 강함」 으로 올림 -_TEXT_YEAR = re.compile(r"(? int | None: - y = int(m.group(1)) % 100 if m else None - return None if y is None else y + (2000 if y < 50 else 1900) - - -def file_year(name: Any) -> int | None: - """파일 이름 연도 — 「2024년 …」 · 「00.25년_…」 · 「20250527 2025년 …」.""" - s = str(name or "") - return _year(re.search(r"(20\d\d)\s*년", s) or re.search(r"(? int | None: - """블록 글 속 판 — 「2017년 품셈」 · 「24년 건설표준품셈」(가장 옛 것).""" - text = " ".join(str(t) for t in [blk.get("이름"), blk.get("규격"), *(blk.get("글") or [])]) - found = [_year(m) for m in _TEXT_YEAR.finditer(text)] - return min(found) if found else None - - -def edition_tag(key: str, rev: list, year: int, by: str) -> str: - """기초 절 개정 가운데 블록 판 뒤 것 — 「'24 보완 · 파일 2024」 · 없으면 빈 글. - - 글 속 판 해 = 그 해 개정을 담음(뒤 것만) · 파일 해 = 같은 해 개정이 앞일 수도(같은 해 포함).""" - later = [int(y) for y in rev or [] if int(y) > year or (by == "파일" and int(y) == year)] - if not later: - return "" - word = "제정" if key.startswith("GF") else "보완" - return f"{' · '.join(f'{chr(39)}{y % 100:02d}' for y in later)} {word} · {by} {year}" - - -def edition(out: dict, file_name: Any, blk: dict) -> dict: - """제안 근거후보마다 판 표지 `판`(덧칸) · 까닭 「판 차이」 → 「판 차이(판 표지)」. - - 셈 · 단 · 꼴 안 바뀜(표지 글만).""" - by_text, by_file = text_year(blk), file_year(file_name) - if by_text and (not by_file or by_text <= by_file): - year, by = by_text, "글" - else: - year, by = by_file, "파일" - if not year: - return out - index = hb.m01.cm.master().index - for c in out.get("근거후보") or []: - key = str(c.get("기초") or "") - tag = edition_tag(key, (index.get(key[:2], {}).get(key) or {}).get("개정"), year, by) - if tag: - c["판"] = tag - c["까닭"] = [ - [f"판 차이({tag})" if lab == "판 차이" else lab, txt] for lab, txt in c["까닭"] - ] - return out - - -def _ratios(c: dict) -> list[float]: - return [ - float(m.group(1)) - for lab, txt in c.get("까닭") or [] - if lab.startswith(STRONG) and (m := _RATIO.search(str(txt))) - ] - - -def strong(got: dict, others: list[dict]) -> dict: - """같은 기초 · 같은 배(±2 %)가 다른 파일에도 판 표지와 함께 있으면 「판 차이 강함(…)」.""" - seen = [ - (c["기초"], r) - for o in others - for p in o.values() - for c in p.get("근거후보") or [] - if c.get("판") - for r in _ratios(c) - ] - if not seen: - return got - - def hit(key: str, lab: str, text: Any) -> bool: - m = _RATIO.search(str(text)) if lab.startswith(STRONG) else None - return bool(m) and any( - k == key and r and abs(float(m.group(1)) / r - 1) <= 0.02 for k, r in seen - ) - - out = {} - for no, p in got.items(): - cs = [] - for c in p.get("근거후보") or []: - if c.get("판"): - why = [ - [f"판 차이 강함({c['판']})", t] if hit(c["기초"], lab, t) else [lab, t] - for lab, t in c["까닭"] - ] - c = {**c, "까닭": why} - cs.append(c) - out[no] = {**p, "근거후보": cs} - return out diff --git a/M02_MasterTemplete/M02_San_Basis_Const.py b/M02_MasterTemplete/M02_San_Basis_Const.py deleted file mode 100644 index daf790620..000000000 --- a/M02_MasterTemplete/M02_San_Basis_Const.py +++ /dev/null @@ -1,378 +0,0 @@ -"""근거 제안 2 — 산근 블록 글 → 상수 지문(PLAN 793-3a · 분석 648-D 1-2 뽑기) · 읽기만. - -뽑기 = 엑셀꼴(EST · STmate 보이는 글): 콜론 앞 이름 · 단가 앞 규격 · 쉼표 금액 · 항목 단가 · 「=」 -뒤 결과 -(앞 마디가 셈일 때) · 소계 줄 · 절 제목 줄 · 품셈 근거 줄 · 품셈 번호 · 연도 뺌 · 정수 끝 점 지움 / -STmate 숨은 G 식: 따옴표 글 뺌 · 수 바로 뒤 짧은 따옴표 = 단위 · `&…&` 참조 뺌. -N9 일반 수(1 · 100 · 1000 · 60 분 · 8 시간 = 「일반」 표지) · N10 입력(L · 거리 · km) 뺌 · -인력 줄 몫(「a 인 / b」 · 「a / Q」 = 블록 안 대입)에 직종 표지. -""" - -from __future__ import annotations - -import re -import unicodedata -from functools import lru_cache -from typing import Any - -from M02_MasterTemplete import M02_Hopyo_Formula as formula -from M02_MasterTemplete import M02_Hopyo_ReadEst as est -from M02_MasterTemplete import M02_San_Basis_Index as bi - -fam = bi.fam -UNIT = ( - r"(㎡|㎥|㎏|kg|Kg|KG|ton|Ton|TON|톤|m3|M3|m2|M2|㎝|cm|㎜|mm|km|Km|KM|ℓ|hr|Hr|HR" - r"|시간|분|초|sec|min|일|인|회|본|개|매|조|대|%|EA|ea|t(?![a-zA-Z])|m(?![a-zA-Z²³])|M(?![a-zA-Z]))" -) -NUMTOK = re.compile( - r"(? str: - s = unicodedata.normalize("NFKC", str(s or "")) - return s.replace("×", "*").replace("÷", "/").replace("=", "=").replace(":", ":") - - -def _f(s: Any) -> float | None: - try: - return float(str(s).replace(",", "").rstrip(".")) - except ValueError: - return None - - -def g_text(g: str) -> tuple[str, str]: - """STmate G 식 → (절 나누기 글, 수 뽑기 글) — 따옴표 글은 이름 · 수 뒤 짧은 따옴표 = 단위.""" - parts = g.split("'") - out: list[str] = [] - for i, p in enumerate(parts): - if i % 2 == 0: - out.append(p) - elif re.search(r"\d\s*$", parts[i - 1]) and len(p) <= 8 and not re.search(r"\d", p): - out[-1] = parts[i - 1].rstrip() - out.append(f"⟨{p}⟩") - else: - out.append(" ") - s = re.sub(r"&[^&]*&", " ¤ ", "".join(out)) # 단가 · 금액 범위 참조 - s = re.sub(r":=\s*A\d+|:\s*T\d+|->|\(-?=+\)|\[[A-Za-z]\w*\]", " ", s) - return g.replace("'", ""), s - - -def result_spans(s: str) -> list[tuple[int, int]]: - """「=」 마디 중 바로 앞 마디가 셈인 마디의 첫 수 = 결과 — 여러 대입 줄(「q = 0.7 K = - 0.9」)은 결과 아님. - """ - out, at = [], 0 - segs = s.split("=") - for i, seg in enumerate(segs): - last = i == len(segs) - 1 - if ( - i - and OPER.search(segs[i - 1].split(",")[-1]) - and (last or not OPER.search(seg.split(",")[0])) - ): - m = re.search(r"\d[\d,]*(?:\.\d+)?", seg) - if m and not seg[: m.start()].strip(" ({"): - out.append((at + m.start(), at + m.end())) - at += len(seg) + 1 - return out - - -def extract(text: str, prices: set, results: set, g: bool) -> tuple[list[dict], list[dict], str]: - """줄 하나 → (상수, 입력, 수 글) — 결과 · 단가 · 금액 · 이름 · 번호 뺌.""" - s = nk(text) - if not g and "=" not in s and (formula._title(s.strip()) or re.search(r"품\s*셈", s)): - return [], [], "" # 절 제목 · 품셈 근거 줄 — 규격 · 번호뿐 - for r in DROP: - s = r.sub(" ", s) - s = HEADNO.sub(" ", s) - s = re.sub(r"(?<=\d)\.(?!\d)", "", s) # 엑셀꼴 정수 끝 점(「5. 인 / 45. ㎡」) - if not g: - if ":" in s and "=" not in s.split(":")[0]: - s = s.split(":", 1)[1] - else: # 콜론 없는 재료 · 인력 줄 — 단가 앞(이름 · 규격) 자름 - for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", s): - x = _f(m.group()) - if ( - x is not None - and ("," in m.group() or x in prices) - and "=" not in s[: m.start()] - ): - s = s[m.start() :] - break - skip = [] if g else result_spans(s) - consts, inputs = [], [] - for m in NUMTOK.finditer(s): - if any(a <= m.start(1) < z for a, z in skip): - continue # 셈 결과 - raw = m.group(1) - if "," in raw.strip(","): - continue # 금액 · 단가 - raw = raw.rstrip(".,") - x = _f(raw) - if x is None or x == 0: - continue - unit = m.group(2) if m.group(2) is not None else (m.group(3) or "") - if not unit and re.match(r"[가-힣]", s[m.end() : m.end() + 1]): - continue # 「2차선」 · 「3층」 같은 낱말 속 수 - sym_hit = SYM.search(s[: m.start()]) - sym = sym_hit.group(1) if sym_hit else "" - prev = s[: m.start()].rstrip()[-1:] - one = { - "v": x, - "d": len(raw.split(".")[1]) if "." in raw else 0, - "단위": unit, - "기호": sym, - "글": text.strip()[:90], - } - if x in prices or (not g and x in results): - continue - speed = re.match(r"\s*/\s*h", s[m.end() : m.end() + 4], re.I) - if ( - (sym and INPUT_SYM.match(sym)) - or fam(unit) == "길이" - and unit.lower() in ("km",) - and not speed - ): - inputs.append(one) - continue - if ( - (x in (1, 100, 1000) and unit != "%") - or (x == 60 and fam(unit) in (None, "시간")) - or ( - x == 8 - and (prev == "/" or fam(unit) == "시간" or re.search(r"hr|시간", s, re.I)) - and fam(unit) in (None, "시간") - ) - ): - one["일반"] = True - consts.append(one) - return consts, inputs, s - - -def quotients(body: str, labor: bool, syms: dict) -> list[dict]: - """인력 줄 「3 / 4.5」 · 「5인 / 45」 · 「2인 * 1일 / 50」 · 「5인 / Q」 → 몫 상수(인/양).""" - out = [] - for m in QUO.finditer(body): - a, bt = _f(m.group(1)), m.group(2) - b = syms.get(bt) if re.match(r"[A-Za-z]", bt) else _f(bt) - unit_p = bool(re.search(r"⟨인⟩|인|명", body[m.start() : m.start(1) + len(m.group(1)) + 4])) - if not a or not b or not (labor or unit_p) or b in (8, 60, 100): - continue - out.append( - { - "v": a / b, - "d": 9, - "단위": "인", - "기호": "", - "몫": f"{a:g}/{b:g}", - "글": body.strip()[:90], - } - ) - return out - - -def trade_of(text: str) -> str | None: - """인력 줄 이름 → 우리 인력 이름(모르면 None).""" - m = est._COLON.match(nk(text)) - if not m: - return None - name = re.sub(r"^[0-9가-힣]\.|^[○◇□※·\-*ㅇ\s]+", "", re.sub(r"\s+", "", m.group("name"))) - return bi.trade(name) - - -# ── 인력 줄 가름(엑셀꼴) ───────────────────────────────────────────── -_SWAP = [ - ("m3", "㎥"), - ("M3", "㎥"), - ("m2", "㎡"), - ("M2", "㎡"), - ("m", "m"), - ("Ton", "톤"), - ("TON", "톤"), - ("ton", "톤"), - ("굴착기", "굴삭기"), - ("백호", "굴삭기"), - ("B/H", "굴삭기"), - ("BACK-HOE", "굴삭기"), - ("브레카", "브레이커"), - ("브레이카", "브레이커"), - ("뒤채움", "뒷채움"), - ("Ⅰ", "I"), - ("Ⅱ", "II"), - ("ll", "II"), - ("=", "="), - (":", ":"), -] -_TOTAL = re.compile(r"^(소계|계|합계|총계|인부소계|자재소계|주자재소계|인건비소계)[:]?#?$") - - -def _fk(s: Any) -> str: - s = unicodedata.normalize("NFKC", str(s or "")) - for a, b in _SWAP: - s = s.replace(a, b) - return s - - -def _skel(line: str) -> str: - s = re.sub(r"(?<=[\d\s)])[xX×](?=[\s\d(])", "*", _fk(line)).replace("×", "*").replace("÷", "/") - s = re.sub(r"(?<=\d)\.(?=[*/=%+\-)\s]|$)", "", re.sub(r"\s+", "", s)) - return re.sub(r"\d[\d,]*(?:\.\d+)?", "#", s) - - -@lru_cache(maxsize=1) -def _names() -> dict: - return est.trade_names() - - -def labor(line: str) -> bool: - """인력 줄(「보통인부 : …」) — 소계 · 품셈 근거 줄 뺌.""" - s = _fk(line).strip() - f, sk = re.sub(r"\s+", "", s), _skel(s) - if ( - not f - or _TOTAL.match(sk) - or re.match(r"^(\d+\.)?(합계|소계|계)[:]?", f) - and "*" not in sk - and "/" not in sk - ): - return False - if re.search(r"품[셈줌]", s) and "=" not in s: - return False - cm = est._COLON.match(s) - nm = ( - re.sub( - r"^[○◇□※·\-*]+", "", re.sub(r"^[0-9가-힣]\.", "", re.sub(r"\s+", "", cm.group("name"))) - ) - if cm - else "" - ) - return bool(cm and (_names().get(nm) or bi.trade(nm))) - - -# ── 블록 → 줄 · 절 · 상수 ──────────────────────────────────────────── -def results_of(lines: list[str], items: list[dict]) -> tuple[set, set]: - prices, results = set(), set() - for it in items or []: - prices |= { - float(v) for v in (it.get("단가") or {}).values() if isinstance(v, (int, float)) and v - } - if isinstance(it.get("금액"), (int, float)): - results.add(float(it["금액"])) - if isinstance(it.get("기준값"), (int, float)): - results.add(round(float(it["기준값"]), 1)) - for t in lines: - s = nk(t) - results |= {_f(s[a:z]) for a, z in result_spans(s)} - if re.search(r"(소\s*계|합\s*계|총\s*계|^\s*계\s*:)", s): - results |= {_f(m.group()) for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", s)} - results.discard(None) - return prices, results - - -def block_lines(blk: dict, kind: str, g: list[str] | None) -> list[dict]: - """[{글(절 나누기), 수글, g}] — STmate + 숨은 G 식이 있으면 G · 그 밖 보이는 글.""" - out = [] - if kind == "STmate" and g: - for gl in g: - tree, s = g_text(gl) - if tree.strip() and not re.fullmatch(r"\s*\(-?=+\)\s*", tree): - out.append({"글": tree, "수글": s, "g": True}) - else: - out = [{"글": t, "수글": t, "g": False} for t in blk.get("글") or [] if t and t.strip()] - return out - - -def sections(lines: list[dict]) -> list[tuple[str, list[int]]]: - texts = [ln["글"] for ln in lines] - works = formula.works(formula.tree(texts, list(range(len(texts))))) - if not works: - return [("(블록 통째)", list(range(len(texts))))] - return [ - (f"{w['번']}. {w['제목']}"[:40], list(range(w["글"][0], w["글"][1] + 1))) for w in works - ] - - -def prep(blk: dict, kind: str, g: list[str] | None = None) -> dict: - """블록 → 줄 · 줄별 (상수, 입력) · 절.""" - lines = block_lines(blk, kind, g) - prices, results = results_of(blk.get("글") or [], blk.get("항목") or []) - per_line, bodies = [], [] - for ln in lines: - cs, ins, body = extract(ln["수글"], prices, results, ln["g"]) - per_line.append([cs, ins]) - bodies.append(body) - syms: dict = {} - for cs, _ in per_line: - for c in cs: - if c["기호"] and c["기호"] not in syms: - syms[c["기호"]] = c["v"] - for i, ln in enumerate(lines): - lab = re.search(r"&L\d", ln["글"]) if ln["g"] else labor(ln["글"]) - tr = trade_of(ln["글"]) if lab else None - qs = quotients(bodies[i], bool(lab), syms) - if tr: - tops = {q["몫"].split("/")[0] for q in qs} - for c in per_line[i][0]: - if fam(c["단위"]) == "사람" or f"{c['v']:g}" in tops: - c["직종"] = tr - for q in qs: - q["직종"] = tr - per_line[i][0] += qs - in_vals = {round(c["v"], 9) for _, ins in per_line for c in ins if c.get("기호")} - for cs, _ in per_line: - cs[:] = [c for c in cs if round(c["v"], 9) not in in_vals or c.get("몫")] - return {"lines": lines, "per_line": per_line, "secs": sections(lines) if lines else []} - - -def gather(per_line: list, ids: Any) -> tuple[list[dict], list[dict]]: - """줄 묶음 → (상수 K, 입력) — 같은 수 · 율과 소수 쌍(2 % · 0.02)은 하나(단위 있는 쪽).""" - K: list[dict] = [] - seen, ins, at = set(), [], {} - for i in ids: - cs, ii = per_line[i] - ins += ii - for c in cs: - vk = (round(c["v"], 9), c.get("직종"), bool(c.get("몫"))) - if vk in at and fam(c["단위"]) != "율" and fam(K[at[vk]]["단위"]) != "율": - if not K[at[vk]]["단위"] and c["단위"]: - K[at[vk]] = {**c, "줄": i} - continue - key = (round(c["v"], 9), fam(c["단위"]), c.get("직종")) - twin = ( - (round(c["v"] * 100, 9), "율", None) - if fam(c["단위"]) is None and c["v"] < 1 - else ((round(c["v"] / 100, 9), None, None) if fam(c["단위"]) == "율" else None) - ) - if key in seen or twin in seen: - continue - seen.add(key) - at.setdefault(vk, len(K)) - K.append({**c, "줄": i}) - return K, ins - - -def corpus(preps: list[dict]) -> list[list]: - """보정 자료 — 블록들 상수(일반 뺌 · 같은 꼴 하나) [[v, d, 단위, 직종, 몫]].""" - keys: dict = {} - for pr in preps: - for c in gather(pr["per_line"], range(len(pr["lines"])))[0]: - if not c.get("일반"): - keys[bi.kkey(c)] = None - return [list(k) for k in keys] diff --git a/M02_MasterTemplete/M02_San_Basis_Index.py b/M02_MasterTemplete/M02_San_Basis_Index.py index 65d3eeeb7..d132d75c4 100644 --- a/M02_MasterTemplete/M02_San_Basis_Index.py +++ b/M02_MasterTemplete/M02_San_Basis_Index.py @@ -8,8 +8,8 @@ (하위 로직 3 겹까지 펼침 · 인자 이름 바꿈) · 식 속 수(`inline`) · 호표 수량 속 수(`inline_trade` · 직종 표지). 정규화(`lookup`) — N1 표시 자릿수 반올림 · N2 % ↔ 소수 · N3 t ↔ kg · N4 인/양 몫 · N5 직종 묶음 · -N6 표 기준 · N7 식 속 수 · N8 단위 성격 · N9 일반 수 · N10 입력 뺌(N9 · N10 = -`M02_San_Basis_Const`). +N6 표 기준 · N7 식 속 수 · N8 단위 성격(N9 · N10 옛 근거 제안 몫은 809-1 에서 지움 · +쓰는 곳 = 소요량 · 계수 이음 `M02_Sangeun_QtyLinks`). 우연 보정(`q_of` · `pb_sig`) = 보정 자료(실무 여덟 파일 상수 · `M02_San_Basis_corpus.json`) 몫 · 포아송 이항 꼬리 −log10. 짓기 = M01 폴더 지문(`stamp`)마다 한 번(`get` · 프로세스 안 캐시 · 지문 바뀌면 다시). @@ -31,10 +31,12 @@ from pathlib import Path from typing import Any from common_util import common_util_derived_store as derived -from M02_MasterTemplete import M02_Hopyo_Bind as hb +from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Hopyo_ReadEst as est -mf, ma = hb.mf, hb.ma +mf = m01.cm.mf +import master_auto as ma # noqa: E402 — mf 를 불러 scripts 경로가 깔린 뒤 + CORPUS = Path(__file__).with_name("M02_San_Basis_corpus.json") INLINE_MAX = 20 # 식 속 수가 이보다 많은 로직(표를 식으로 펼친 것)은 식 속 수 맞음을 안 셈 PERSON = ("인",) @@ -135,7 +137,7 @@ def same_trade(a: str, b: str) -> bool: # ── M01 읽기 · 로직 훑기 ───────────────────────────────────────────── def folder() -> Path: - return Path(hb.m01.FOLDER) + return Path(m01.FOLDER) def stamp() -> str: diff --git a/M02_MasterTemplete/M02_San_Basis_Map.py b/M02_MasterTemplete/M02_San_Basis_Map.py deleted file mode 100644 index 2ed29b1ef..000000000 --- a/M02_MasterTemplete/M02_San_Basis_Map.py +++ /dev/null @@ -1,121 +0,0 @@ -"""근거 제안 5 — 옛 번호 대조표 · 기계 작업량 읽기(PLAN 794-2c · 요구 653 · 분석 652-794-2a 3 장) · -읽기만 · 지어 넣는 값 없음. - -옛 번호 → 현행 절 = 대조표 `OLD`(근거 = 원문 표 값 · 실무 글) › 사방품셈(옛) = 지식DB 판 대응표 -「2018 통합 산림사업 표준품셈」 확정 한 대응 › 건설품셈(옛) 같은 번호 = 현행 절 로직 이름과 블록 -제목(같은 말 묶음 뒤)이 뜻 낱말을 나눌 때만(엔진 몫). -기계 작업량 절 = 글에 「3600 … Cm」 Q 식 · 기호(q · K · f · E · Cm) 끝값을 줄 글에서 읽음(여러 대입 -한 줄 · 「q (버킷용량) = 0.7」 꼴) · 기계 낱말 → 그 기계 「작업량당 기계경비」 로직(이름에서 뽑음). -""" - -from __future__ import annotations - -import csv -import re -from functools import lru_cache -from typing import Any - -from M02_MasterTemplete import M02_Hopyo_Clause as clause - -# (옛 책, 옛 번호) → (현행 구분, 현행 번호, 근거) -OLD = { - ("건설품셈(옛)", "11-3"): ( - "건설품셈 공통", - "8-2-3", - "실무 글 「건설표준품셈 11-3(굴삭기)」(봉화 · 울진대흥 2024) = 현행 8-2-3 굴착기", - ), - ("건설품셈(옛)", "3-7"): ( - "건설품셈 공통", - "3-9-1", - "현행 3-9-1 벌목 표(5m미만 벌목부 2.14 · 보통인부 0.51 · 굴착기+집게 2.71) = 블록 글" - " 「건설표준품셈 3-7」(동부청영월 2024)", - ), - ("건설품셈(옛)", "8-2-35"): ( - "건설품셈 공통", - "8-2-32", - "현행 8-2-32 이동식 임목파쇄기 93.25㎾ Q 6.0 · 발전기 250㎾ · 보통인부 2인 · 굴착기 0.7㎥" - "+집게 = 블록 글 「표준품셈 8-2-35」(동부청영월 2024)", - ), -} -_SABANG = "2018 통합 산림사업 표준품셈" - - -@lru_cache(maxsize=1) -def sabang_map() -> dict[str, str]: - """사방품셈(옛) 번호 → 현행 산림 번호 — 판 대응표 「2018 통합」 판 · 확정 · 한 대응만.""" - with open(clause._ROAD_MAP, encoding="utf-8-sig", newline="") as f: - rows = list(csv.DictReader(f)) - out = {} - for row in rows: - old, new = clause._OLD_NO.match(row["과거_절"].strip()), row["현행_절"].strip() - if row["STmate_인용판"] != _SABANG or row["판정"] != "확정" or not old: - continue - if clause._OLD_NO.fullmatch(new) and " " not in new: - out[old.group(1)] = new - return out - - -def old_rows(book: str, number: str) -> list[dict]: - """옛 출처 하나 → 현행 로직 줄(`clause.lookup` 꼴) · 대조 없음 = [].""" - if (book, number) in OLD: - return clause.lookup(*OLD[(book, number)][:2]) - if book == "사방품셈" + clause.OLD and number in sabang_map(): - return clause.lookup("산림품셈", sabang_map()[number]) - return [] - - -# ── 기계 작업량 절 ────────────────────────────────────────────────── -_Q = re.compile(r"3,?600\.?\s*[×x*]") -_SYM = re.compile( - r"(? bool: - """Q = 3600 × q × K × f × E / Cm 식 줄이 있나.""" - text = " ".join(lines) - return bool(_Q.search(text)) and "Cm" in text - - -def consts(lines: list[str]) -> list[dict[str, Any]]: - """줄 글 → [{기호, 값, 덧?}] — 기호 마디마다 괄호 글 뺀 마지막 수(「f = 1/1.3 = 0.77」 = 0.77) - · Cm 괄호 각도 = 덧 · 첫 값만(같은 기호 둘째부터 뺌).""" - out, seen = [], set() - for line in lines: - hits = list(_SYM.finditer(line)) - for i, m in enumerate(hits): - body = line[m.end() : hits[i + 1].start() if i + 1 < len(hits) else len(line)] - plain = re.sub(r"\((?:[^()]|\([^()]*\))*\)", " ", body) - nums = [float(x.replace(",", "")) for x in _NUM.findall(plain.split("sec")[0])] - sym = _CANON.get(m.group(1), m.group(1)) - if not nums or sym in seen: - continue - ratio = _RATIO.fullmatch(plain) # 「f = 1/1.25」 끝값 없는 나눗셈 - one: dict[str, Any] = { - "기호": sym, - "값": round(nums[0] / nums[1], 6) if ratio else nums[-1], - } - if sym == "Cm" and (deg := re.search(r"\(\s*(\d+)\s*˚?\s*\)", body)): - one["덧"] = f"{deg.group(1)}˚" - seen.add(sym) - out.append(one) - return out - - -def machines(logic: dict[str, dict]) -> dict[str, str]: - """{기계 낱말: 「작업량당 기계경비」 로직 키} — 로직 이름 첫 낱말(괄호 앞).""" - out = {} - for k, r in sorted(logic.items()): - name = str(r.get("이름") or "") - if "작업량당 기계경비" in name: - out.setdefault(re.split(r"[\s(]", name)[0], k) - return out - - -def machine_key(table: dict[str, str], text: str) -> str | None: - """글(같은 말 묶음 뒤)에 기계 낱말이 하나뿐이면 그 로직 키.""" - found = {k for w, k in table.items() if w in text} - return found.pop() if len(found) == 1 else None diff --git a/M02_MasterTemplete/M02_San_Basis_Store.py b/M02_MasterTemplete/M02_San_Basis_Store.py deleted file mode 100644 index 77841697e..000000000 --- a/M02_MasterTemplete/M02_San_Basis_Store.py +++ /dev/null @@ -1,151 +0,0 @@ -"""근거 제안 길 · 저장본(PLAN 793-3a) — 서버 단독 · 읽기만(HP 는 안 고침). - -올림 = HP 에 `숨은식 {번: [G 식]}`(STmate 단가산출근거 숨은 G 열 · `hidden`) + 뒤 셈(`ensure`). -저장본 = HP 폴더 `_derived/san_basis_{키}` · 입력 지문 = HP(지문 · 판 · 올린때) + M01 역색인 지문 -+ 판 · -지문 다를 때만 다시 셈 · GET 은 안 지움 · [끝내기] = `forget`. -답 꼴 = `listing` {셈중, 블록 {번: 제안}, 보강 [{이름, 품셈, 까닭, 블록 [번]}]} · `block` = 제안 -하나 -(`M02_San_Basis.suggest` 꼴 — 단 · 꼴 · 근거후보 · 절 · 보강?). -""" - -from __future__ import annotations - -import shutil -import threading -from pathlib import Path -from typing import Any - -from common_util import common_util_derived_store as derived -from M02_MasterTemplete import M02_Hopyo_Judge as judge -from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store -from M02_MasterTemplete import M02_San_Basis as basis -from M02_MasterTemplete import M02_San_Basis_Check as chk -from M02_MasterTemplete import M02_San_Basis_Index as bi - -KIND = "san_basis_" -VERSION = "794-2b.1" # 제안 꼴이 바뀌면 올림 -SHEET = "단가산출근거" -_LOCK, _RUNNING = threading.Lock(), set() - - -def hidden(book: dict[str, Any], sheets: dict[str, list[list[Any]]]) -> dict[str, list[str]]: - """STmate 블록마다 숨은 G 열 원문 식(「_x0007_」 머리 줄만) · 그 밖 = {}.""" - rows = sheets.get(SHEET) or [] - if book.get("꼴") != "STmate" or not rows: - return {} - out = {} - for blk in book.get("단산") or []: - start, end = blk["행"] - got = [] - for r in range(start, end + 1): - row = rows[r - 1] if 0 < r <= len(rows) else [] - v = str(row[6]) if len(row) > 6 and row[6] is not None else "" - if v.startswith("_x0007_") and v != "_x0007_": - got.append(v.replace("_x0007_", "").strip()) - out[str(blk["번"])] = got - return out - - -def one(book: dict[str, Any], no: int) -> dict[str, Any]: - blk = next(b for b in book.get("단산") or [] if int(b["번"]) == int(no)) - line, same = judge._caller(book, int(no)) - g = (book.get("숨은식") or {}).get(str(no)) - out = basis.suggest(blk, book.get("꼴") or "", line, same, g) - return chk.edition(out, book.get("파일"), blk) # 794-2b 판 표지 - - -def _stamp(key: str, book: dict[str, Any]) -> str: - return derived.derived_key( - VERSION, key, book.get("지문"), book.get("판"), book.get("올린때"), bi.stamp() - ) - - -def solved(folder: Path, key: str) -> dict[str, Any] | None: - book = hopyo_store.read(folder, key) - got = derived.read_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book)) - return got["value"] if got else None - - -def solve_all(folder: Path, key: str) -> dict[str, Any]: - book = hopyo_store.read(folder, key) - out = { - str(b["번"]): one(book, int(b["번"])) - for b in book.get("단산") or [] - if b.get("번") is not None - } - derived.write_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book), out, keep=1) - return out - - -def _work(folder: Path, key: str) -> None: - try: - solve_all(folder, key) - except Exception: # noqa: BLE001 — 뒤 셈 실패 = 다음 읽기에서 다시(파생 저장본) - pass - finally: - with _LOCK: - _RUNNING.discard(f"{folder}|{key}") - - -def ensure(folder: Path, key: str) -> bool: - """저장본 있음 = 참 · 없으면 뒤 셈 시작(같은 파일 한 번) = 거짓.""" - if solved(folder, key) is not None: - return True - tag = f"{folder}|{key}" - with _LOCK: - if tag in _RUNNING: - return False - _RUNNING.add(tag) - threading.Thread(target=_work, args=(Path(folder), key), daemon=True).start() - return False - - -def shortfall(got: dict[str, Any]) -> list[dict[str, Any]]: - """없음 블록 → M01 보강 목록(794-2 근거) — 이름 · 품셈 묶음마다 블록 번.""" - out: dict[tuple, dict] = {} - for no, r in got.items(): - if r.get("보강"): - b = r["보강"] - one_ = out.setdefault((b["이름"], tuple(b["품셈"])), {**b, "블록": []}) - one_["블록"].append(int(no)) - return sorted(out.values(), key=lambda x: (-len(x["블록"]), x["이름"])) - - -def _others(folder: Path, key: str) -> list[dict[str, Any]]: - """같은 폴더 다른 HP 저장본(셈 끝난 것만) — 판 차이 강함(794-2b) 대조.""" - out = [] - for r in hopyo_store.rows(folder): - if r.get("키") and r["키"] != key: - try: - got = solved(folder, r["키"]) - except Exception: # noqa: BLE001 — 못 읽는 HP = 대조에서 뺌 - got = None - if got: - out.append(got) - return out - - -def listing(folder: Path, key: str) -> dict[str, Any]: - """{셈중, 블록 {번: 제안}, 보강} — 셈 중 = 블록 {} · 뒤 셈 시작.""" - got = solved(folder, key) - if got is None: - ensure(folder, key) - return {"키": key, "셈중": True, "블록": {}, "보강": []} - got = chk.strong(got, _others(folder, key)) - return {"키": key, "셈중": False, "블록": got, "보강": shortfall(got)} - - -def block(folder: Path, key: str, no: int) -> dict[str, Any]: - """블록 하나 — 저장본 › 그 자리 셈.""" - got = (solved(folder, key) or {}).get(str(no)) - if got is not None: - return chk.strong({str(no): got}, _others(folder, key))[str(no)] - book = hopyo_store.read(folder, key) - if not any(b.get("번") is not None and int(b["번"]) == int(no) for b in book.get("단산") or []): - raise judge._bad(f"없는 블록 「단산{no}」", 404) - return one(book, no) - - -def forget(folder: Path, key: str) -> None: - shutil.rmtree(derived.derived_dir(hopyo_store._dir(folder), KIND + key), ignore_errors=True) diff --git a/M02_MasterTemplete/M02_San_Basis_Value.py b/M02_MasterTemplete/M02_San_Basis_Value.py deleted file mode 100644 index eed555c7e..000000000 --- a/M02_MasterTemplete/M02_San_Basis_Value.py +++ /dev/null @@ -1,307 +0,0 @@ -"""근거 제안 3 — 값 길: 상수 지문 → M01 로직 후보(PLAN 793-3a · 분석 648-D 1-3) · 읽기만. - -로직마다 입력 값 조합(400 넘으면 입력마다 혼자 가장 많이 덮는 값) → 찾기마다 줄 하나(새로 덮는 -상수가 -가장 많은 줄부터) → 덮은 상수 · 우연 보정 유의(−log10 꼬리 확률) · 판정 U 유일 · 다 설명 · 옵션 -하나 / -P 유일 / A 동점(유의 1 안) / N 유의 4 미만 / N0 상수 없음 · 동점이 모두 한 하위 로직을 부르면 그 -하위(핵). -블록 = 일 절 2+ · 상수 2+ 주 절이 있으면 주 절 판정 모음 + 절 밖 상수 · 그 밖 블록 통째 + 덧 기초. -""" - -from __future__ import annotations - -import itertools -import json -import math -from collections import defaultdict -from typing import Any - -from M02_MasterTemplete import M02_San_Basis_Const as bc -from M02_MasterTemplete import M02_San_Basis_Index as bi - -TIE = 1.0 # 동점 폭(유의) -SIG_MIN = 4.0 # 1등 유의가 이 아래면 N — 로직 1,515 중 우연 1등 ≈ 10^-3.2 -fam = bi.fam - - -def solve(hits: list[tuple]) -> dict: - """hits = [(로직, 부름id, 줄, 묶음)] · 식 속 수 = 부름id None → 덮음 · 옵션 · 옵션여럿.""" - inl = {h[0] for h in hits if h[1] is None} - bound = [h for h in hits if h[1] is not None] - inputs = sorted({i for h in bound for i in h[3] if not i.startswith("§")}) - vals = {i: sorted({h[3][i] for h in bound if i in h[3]}) for i in inputs} - by_call: dict = defaultdict(lambda: defaultdict(set)) - row_bind = {} - for k, call, row, b in bound: - by_call[call][row].add(k) - row_bind[(call, row)] = b - order = sorted(by_call, key=lambda c: -max(len(x) for x in by_call[c].values())) - - def run(combo: dict): - cov, fixed, pick = set(inl), dict(combo), {} - while True: - best = None - for call in order: - if call in pick: - continue - for r, ks in by_call[call].items(): - gain = len(ks - cov) - if ( - gain - and (best is None or gain > best[0]) - and all( - fixed.get(i, v) == v if i.startswith("§") else combo.get(i) == v - for i, v in row_bind[(call, r)].items() - ) - ): - best = (gain, call, r, ks) - if best is None: - break - _, call, r, ks = best - cov |= ks - pick[call] = r - fixed |= {i: v for i, v in row_bind[(call, r)].items() if i.startswith("§")} - return cov, pick, -len(pick) - - if (math.prod(len(v) for v in vals.values()) if vals else 1) <= 400: - combos = [dict(zip(inputs, c)) for c in itertools.product(*[vals[i] for i in inputs])] or [ - {} - ] - else: # ponytail: 입력 값 조합이 많으면 입력마다 혼자 가장 많이 덮는 값 — 상호작용 놓칠 수 있음 - cnt = defaultdict(set) - for h in bound: - for i, v in h[3].items(): - cnt[(i, v)].add(h[0]) - combos = [{i: max(vals[i], key=lambda v: len(cnt[(i, v)])) for i in inputs}] - best, outs = (-1, -1), [] - for combo in combos: - cov, pick, ev = run(combo) - score = (len(cov), ev) - if score > best: - best, outs = score, [(combo, cov, pick)] - elif score == best: - outs.append((combo, cov, pick)) - used = { - i - for _, _, pick in outs - for call, r in pick.items() - for i in row_bind[(call, r)] - if not i.startswith("§") - } - resolved, opt_amb = {}, {} - for i in sorted(used): - seen = sorted({o[0].get(i) for o in outs if o[0].get(i) is not None}) - if len(seen) == 1: - resolved[i] = json.loads(seen[0]) - elif seen: - opt_amb[i] = [json.loads(x) for x in seen] - same_cov = len({frozenset(o[1]) for o in outs}) == 1 - return {"덮음": outs[0][1], "옵션": resolved, "옵션여럿": opt_amb, "조합같은덮음": same_cov} - - -def rank(idx: bi.Index, K: list[dict]) -> list[dict]: - """상수 목록 → 로직 후보(유의 차례).""" - per_item, anchored, how_of = defaultdict(list), set(), defaultdict(dict) - for ki, k in enumerate(K): - for item, call, row, bd, how, anc in idx.explain(k): - per_item[item].append((ki, call, row, bd)) - how_of[item].setdefault(ki, how) - if anc: - anchored.add(item) - real = sorted(i for i, k in enumerate(K) if not k.get("일반")) - ub = [] - for it, hs in per_item.items(): - if it in anchored: - c = len({h[0] for h in hs} & set(real)) - ub.append((bi.pb_sig([idx.q_of(it, K[i]) for i in real], c), c, it)) - ub.sort(key=lambda x: (-x[0], -x[1], x[2])) - out, best = [], 0.0 - for s_ub, c, item in ub: - if c == 0 or s_ub < best - TIE or len(out) >= 200: - break - got = solve(per_item[item]) - got_real = got["덮음"] & set(real) - s_val = bi.pb_sig([idx.q_of(item, K[i]) for i in real], len(got_real)) - best = max(best, s_val) - out.append({"로직": item, **got, "꼴": how_of[item], "실수": len(got_real), "유의": s_val}) - out.sort(key=lambda o: (-o["유의"], -o["실수"], o["로직"])) - return out - - -def collapse(idx: bi.Index, tied: list[dict]) -> tuple[list[dict], str]: - """동점 정리 — 다른 동점이 모두 그 로직을 부르면 그 하위(핵) · 위 로직 하나면 그 위 로직 · - 그 밖 = 동점 위 로직이 덮는 하위는 뺌.""" - for c in tied: - others = [o for o in tied if o is not c] - if others and all(o["로직"] in idx.callers.get(c["로직"], set()) for o in others): - return ( - (others, "위 로직 하나") - if len(others) == 1 - else ([c], f"핵 하위 로직(위 로직 {len(others)})") - ) - keep = [] - for o in tied: - if not [ - p - for p in tied - if p is not o - and p["로직"] in idx.callers.get(o["로직"], set()) - and o["덮음"] <= p["덮음"] - ]: - keep.append(o) - return keep, "" - - -def judge(idx: bi.Index, K: list[dict]) -> dict: - """한 단위(절 · 블록) 판정 — U · P · A · N · N0.""" - real = {i for i, k in enumerate(K) if not k.get("일반")} - if not real: - return {"판정": "N0", "까닭": "지문 없음(상수 0)", "후보": [], "K": K} - cands = [o for o in rank(idx, K) if o["실수"]] - if not cands: - return {"판정": "N", "까닭": "맞는 표 없음", "후보": [], "K": K} - top = cands[0] - if top["유의"] < SIG_MIN: - return { - "판정": "N", - "까닭": f"유의 못 미침(1등 {top['실수']}/{len(real)} · 유의 {top['유의']})", - "후보": cands[:5], - "K": K, - } - tied, note = collapse(idx, [o for o in cands if o["유의"] >= top["유의"] - TIE]) - if len(tied) >= 2: - return { - "판정": "A", - "까닭": f"동점 {len(tied)}", - "후보": tied[:12], - "n": len(tied), - "K": K, - "남음": sorted(real - top["덮음"]), - } - top = tied[0] - left = sorted(real - top["덮음"]) - full = not left and not top["옵션여럿"] and top["조합같은덮음"] - rest = [o for o in cands if o is not top] - return { - "판정": "U" if full else "P", - "까닭": note, - "후보": [top] + rest[:4], - "K": K, - "남음": left, - } - - -def cover(idx: bi.Index, K: list[dict], first: dict) -> list[dict]: - """블록 덮기 — 첫 기초 뒤 남은 상수로 다음 기초(새 상수 2+ · 유일 · 넷까지).""" - picks, done = [first], set(first["덮음"]) - for _ in range(3): - rest = [k if i not in done else {**k, "일반": True} for i, k in enumerate(K)] - if all(k.get("일반") for k in rest): - break - got = judge(idx, rest) - if got["판정"] not in ("U", "P"): - break - o = got["후보"][0] - if len(o["덮음"] - done) < 2: - break - picks.append({**o, "동점": 1}) - done |= o["덮음"] - return picks - - -def assemble(idx: bi.Index, bK: list[dict], mains: list) -> dict: - """절 2+ 블록 — 주 절 판정 모음 + 절 밖 상수(머리 · 비목 절)는 고른 기초가 덮는지 · - 남으면 따로.""" - picks, verdicts, covered = [], [], set() - - def key(c): - return (round(c["v"], 9), fam(c["단위"]), c.get("직종")) - - for _, _, K, sj in mains: - verdicts.append(sj["판정"]) - if sj["판정"] in ("U", "P"): - o = sj["후보"][0] - mine = {key(K[j]) for j in o["덮음"]} - picks.append({**o, "덮음": {i for i, c in enumerate(bK) if key(c) in mine}}) - covered |= {key(c) for c in K} - rest = [c if key(c) not in covered else {**c, "일반": True} for c in bK] - left = {i for i, c in enumerate(rest) if not c.get("일반")} - if left and picks: - chosen = {p["로직"] for p in picks} - for o in rank(idx, rest): - if o["로직"] not in chosen: - continue - got = o["덮음"] & left - for p in picks: - if p["로직"] != o["로직"]: - continue - p["덮음"] = p["덮음"] | got - if got: # 절 밖 상수가 정한 옵션도 얹음(같은 입력 다른 값 = 여럿) - for kk, vv in o["옵션"].items(): - if kk in p["옵션"] and p["옵션"][kk] != vv: - p["옵션여럿"] = {**p["옵션여럿"], kk: [p["옵션"][kk], vv]} - else: - p["옵션"] = {**p["옵션"], kk: vv} - left -= got - if len(left) >= 2: - extra = judge(idx, [c if i in left else {**c, "일반": True} for i, c in enumerate(bK)]) - if extra["판정"] in ("U", "P"): - picks.append(extra["후보"][0]) - left -= extra["후보"][0]["덮음"] - verdicts.append(extra["판정"]) - if not picks: - out = { - "판정": "A" if "A" in verdicts else ("N" if "N" in verdicts else "N0"), - "후보": [], - "K": bK, - } - a = next((x[3] for x in mains if x[3]["판정"] == "A"), None) - return out | ({"후보": a["후보"], "n": a["n"], "K": a["K"]} if a else {}) - allu = not left and all(not p["옵션여럿"] for p in picks) - return { - "판정": "U" if allu else "P", - "후보": picks, - "K": bK, - "기초목록": picks, - "남음": sorted(left), - } - - -def _show(o: dict) -> dict: - return {"로직": o["로직"], "옵션": o["옵션"], "옵션여럿": o["옵션여럿"]} - - -def value(idx: bi.Index, pr: dict) -> dict: - """블록 준비(`Const.prep`) → 값 판정 {판정 U|P|A|N|N0, 기초 · 동점 · 약한후보 [{로직, 옵션}], - 절 [{절, 판정, 기초?, 동점?}], K, 입력}.""" - lines, per_line, secs = pr["lines"], pr["per_line"], pr["secs"] - bK, bIns = bc.gather(per_line, range(len(lines))) - sec_res = [] - for title, ids in secs: - K, _ = bc.gather(per_line, ids) - sec_res.append((title, ids, K, judge(idx, K))) - mains = [x for x in sec_res if len([k for k in x[2] if not k.get("일반")]) >= 2] - multi = len(secs) >= 2 and len(mains) >= 1 - bj = assemble(idx, bK, mains) if multi else judge(idx, bK) - rec: dict[str, Any] = { - "판정": bj["판정"], - "K": [{"단위": c["단위"]} for c in bK], - "입력": [c["v"] for c in bIns], - } - if bj["판정"] in ("U", "P"): - rec["기초"] = [_show(p) for p in (bj.get("기초목록") or cover(idx, bK, bj["후보"][0]))] - elif bj["판정"] == "A": - rec["동점"] = [_show(o) for o in bj["후보"]] - rec["n"] = bj["n"] - elif bj["판정"] == "N" and bj.get("후보"): - rec["약한후보"] = [_show(o) for o in bj["후보"][:3]] - rec["절"] = [] - for title, _, _, sj in sec_res: - one = {"절": title, "판정": sj["판정"]} - if sj["판정"] in ("U", "P"): - one["기초"] = _show(sj["후보"][0]) - elif sj["판정"] == "A": - one["동점"] = [o["로직"] for o in sj["후보"]] - rec["절"].append(one) - return rec diff --git a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py index 2bbe189b9..68abc23e0 100644 --- a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -4,8 +4,8 @@ `실무자료/산근/.json` 에 둠 (원본 엑셀은 안 둠 · HP 지울 때 같이 지움) → [가져온 식으로 열기] = 그 블록을 793-2a 내보내기 (`M02_San_Emit` · 이음 = 1차 줄 판정) → 793-2 만들기(`M02_Sangeun_Imported.create` · 개인 층 · -엑셀 단가 그대로 · `가져온.근거후보` = 793-3a 근거 제안 `M02_San_Basis_Store.block` 근거후보) · -같은 내용 SK 가 있으면 그것(다시씀 · 근거후보 그대로). +엑셀 단가 그대로 · 경쟁사 로직 인정 — 근거 기초 제안 없음 · 809-1 · 요구 703) · +같은 내용 SK 가 있으면 그것(다시씀). 블록 번 = 판정 블록 번(STmate = 코드 「D00102」 숫자 · EST = 단가산출 블록 번). STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — 참조 평가기로 둘 다 셈해 엑셀 계와 맞는 것. @@ -168,7 +168,6 @@ def _link_inner(source: Path, key: str, no: int, out: dict, trail: tuple) -> Non def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) -> dict[str, Any]: """지금 층(만들 층)에 가져온 식 SK — 같은 내용이 있으면 그 SK(`다시씀`) · 안쪽 산근 블록은 형제 SK 를 먼저 만들어 참조(797-1).""" - from M02_MasterTemplete import M02_San_Basis_Store as san_basis from M02_MasterTemplete import M02_Sangeun_Imported as imp out = emit(source, key, no) @@ -182,11 +181,7 @@ def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) -> pass title = str(name or "").strip() or str(out["로직"].get("이름") or f"단산{no}") try: - basis = san_basis.block(source, key, no).get("근거후보") or None - except Exception: # noqa: BLE001 — 근거 제안 못 셈 = 빈 칸(가져온 식은 만듦) - basis = None - try: - got = imp.create(out["로직"], out["원본"], title, basis=basis) + got = imp.create(out["로직"], out["원본"], title) return {**got, "다시씀": False} except store.StoreError as e: same = (e.detail or {}).get("같은것") if isinstance(e.detail, dict) else None diff --git a/M02_MasterTemplete/M02_Sangeun_Imported.py b/M02_MasterTemplete/M02_Sangeun_Imported.py index 1fd41a664..287f08d8e 100644 --- a/M02_MasterTemplete/M02_Sangeun_Imported.py +++ b/M02_MasterTemplete/M02_Sangeun_Imported.py @@ -17,8 +17,7 @@ 글줄 [{행, 글, 자리?(셈 줄 cid | mid:이름), 값?(그 줄 엑셀 금액)}]}. SK 문서 덧칸 `가져온` = {로직(엑셀 단가 그대로 · 고치지 않음), 단가기준 엑셀|우리, 원본, 검산 {같음, 까닭, 셈 {노..계}, 엑셀 {노..계}, 줄 [{자리, 행, 셈, 엑셀, 같음, 까닭?}]}, - 근거후보 [{기초, 옵션, 품검산, 단 확정|근접|사람}](793-3 이 채움), - 바꿈?(우리 기초로 바꾼 뒤 = 그때 로직 · 단가기준 · 전 · 후 · 차이)} · + 근거후보 · 바꿈(옛 문서 읽기만 · 809-1 부터 안 씀)} · 바탕 = null · 가져온 식 SK = `가져온.로직` 있음 · 검산 = 저장 때 서버가 다시. 단가기준 엑셀 = 줄 단가 · 값묶음 그대로 / 우리 = 이음 있는 줄만 단가 · 값묶음 떼고 M01(B09 = 사본 단가) 지금 값 · @@ -28,9 +27,7 @@ SK 문서 덧칸 `가져온` = {로직(엑셀 단가 그대로 · 고치지 않 → {이름(SK 키), 검산, 단가기준}(서버가 다시 셈해 엑셀과 맞댐 · 같은 내용 409 = 기존 길) POST …/sangeun/imported/price 몸 {문서, 기준} → {문서, 전, 후, 이음없음 [{자리, 이름}]} POST …/sangeun/imported/link 몸 {문서, 자리, 이음} → {문서}(이음 고름 · 덮개 열쇠 옮김) - POST …/sangeun/imported/to-base 몸 {문서, 로직(M01 GC), 입력값, 환산?} → {문서, 전, 후, 차이, - 필요?}(기초 결과단위 성격이 SK 단위와 달라 환산 계수가 없으면 후 = None · - 필요 ["환산"] · 793-3b) + (우리 기초로 바꾸기 `to-base` 는 809-1 에서 지움 — 경쟁사 로직 인정 · 요구 703) 셈 답(`…/sangeun/calc`) = 옛 꼴 그대로 · `원래` = 엑셀 금액(「M01 원래」 자리) · 덧칸 `가져온` {단가기준, 파일, 시트, 행, 갈래, 엑셀금액, 글줄 [{행, 글, 자리, 엑셀, 지금, 같음}], 이음없음}. """ @@ -503,35 +500,10 @@ def _rate_rekey(one: Any, move: dict) -> Any: return {**one, "고정": {**one["고정"], "비율": {**rate, "기준": keys}}} -def to_base(doc: Any, code: str, given: dict | None = None, swap: Any = None) -> dict[str, Any]: - """[우리 기초로 바꾸기] — 가져온 식 SK → M01 기초(바탕 로직) + 옵션 SK · 고침 걷힘 · - 원본 · 검산 · 근거후보 보존 + `바꿈` {로직, 단가기준, 전, 후, 차이}(1 단위) · - `swap` = SK 단위 환산 {계수} · 없는데 단위 성격이 다르면 후 = None · `필요` ["환산"](793-3b).""" - doc = _need(doc) - whole = _whole() - before = _money(_run(doc, whole)) - head = {k: doc[k] for k in ("키", "이름", "단위", "구분", "상세구분") if k in doc} - new = {**head, "바탕": {"로직": str(code or "").strip()}, "입력값": dict(given or {})} - new |= {"열린입력": [], "고름": {}, "자재표시": {}, "덮개": {}, "뺀줄": [], "더한줄": []} - new |= {"환산": swap} if swap else {} - need: list[str] = [] - try: - after = _money(_run(new, whole)) - except sd.DocError as e: - if swap or not str(e).startswith("단가산출 단위"): - raise - after, need = None, ["환산"] - gap = {k: after[k] - before[k] for k in after} if before and after else None - keep = {k: doc[KEY][k] for k in ("원본", "검산", "근거후보") if doc[KEY].get(k)} - was = {k: doc[KEY].get(k) for k in ("로직", "단가기준")} - new[KEY] = {**keep, "바꿈": {**was, "전": before, "후": after, "차이": gap}} - return {"문서": new, "전": before, "후": after, "차이": gap} | ({"필요": need} if need else {}) - - # ── 만들기(개인 층) ─────────────────────────────────────────────────── def create( logic: Any, source: Any, name: str = "", unit: str = "", head: dict | None = None, - new: bool = False, use: str | None = None, basis: list | None = None, + new: bool = False, use: str | None = None, ) -> dict[str, Any]: # fmt: skip """엔진 로직 줄 + 원본 → 지금 층 새 가져온 식 SK — 서버가 다시 셈해 엑셀과 맞댐(검산 · 다르면 `같음` False 로 표지 · 막지 않음) · 같은 층 같은 내용 = 409(`ask` · 733-2).""" @@ -547,8 +519,6 @@ def create( meta = {"로직": row, "단가기준": "엑셀"} if source not in (None, {}): meta["원본"] = _meta(source, "원본") - if basis: # 근거 제안(793-3a `제안.근거후보` 그대로 · 793-3b) - meta["근거후보"] = _meta(basis, "근거후보") doc[KEY] = meta got = check(sd.clean(doc, None, whole), whole) key = ask.ask(sd.KIND, doc, new, use) or refs.create(sd.KIND, doc)["이름"] diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index e87d765db..103631a31 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -2,7 +2,7 @@ [단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한) · 식 칸 검사(792-1) `POST /api/m02/layers/{층}/sangeun/formula-check`(셈 없음 · 층 읽기 권한) · 가져온 식(793-2 · `M02_Sangeun_Imported`) `…/sangeun/imported`(만들기 · 층 쓰기) · `/price` · -`/link` · `/to-base`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기) · +`/link`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기 · 기초로 바꾸기 없음 · 809-1) · 불러오기 단산 블록 → 가져온 식(793-3b · `M02_Sangeun_FromHopyo`) `…/sangeun/from-hopyo` (만들 층 쓰기). 로직 사본(795-1 · `M02_Sangeun_Copy`) `…/sangeun/copy/price` [단가 갱신] · `/regen` [재생성] @@ -160,9 +160,6 @@ class ImportedEditBody(BaseModel): 기준: str | None = None # price 자리: str | None = None # link 이음: str | None = None # link - 로직: str | None = None # to-base - 입력값: dict[str, Any] | None = None # to-base - 환산: dict[str, Any] | None = None # to-base(793-3b · SK 단위 환산 계수) 칸: dict[str, Any] | None = None # qty-pick(797-2 · 고른 M01 표 칸 {표, 줄, 칸}) 되돌리기: bool = False # qty-drop(797-2 · 버림 풀기) @@ -182,7 +179,6 @@ def _sd(fn: Any) -> Any: _EDIT = { "price": lambda b: (imp.to_price, b.문서, b.기준), "link": lambda b: (imp.link, b.문서, b.자리, b.이음), - "to-base": lambda b: (imp.to_base, b.문서, b.로직, b.입력값, b.환산), "qty": lambda b: (qty.view, b.문서), "qty-pick": lambda b: (qty.pick, b.문서, b.자리, b.칸), "qty-drop": lambda b: (qty.drop, b.문서, b.자리, b.되돌리기), @@ -197,12 +193,9 @@ def sangeun_imported_edit( owner: str | None = None, session: Session = Depends(verify_session), ) -> dict: - """가져온 식 고치기(793-2) — price [우리 · 엑셀 단가로] · link 이음 고름 · to-base [우리 기초로 - 바꾸기] · 답 = 고친 문서(화면 캐시 · 저장 없음) + 전 · 후 금액.""" + """가져온 식 고치기(793-2) — price [우리 · 엑셀 단가로] · link 이음 고름 · qty 소요량 이음 · 답 = 고친 문서(화면 캐시 · 저장 없음) + 전 · 후 금액.""" if how not in _EDIT: - raise HTTPException( - status_code=404, detail="price · link · to-base · qty · qty-pick · qty-drop" - ) + raise HTTPException(status_code=404, detail="price · link · qty · qty-pick · qty-drop") fn, *args = _EDIT[how](body) return _call(_root(session, layer, owner), _sd(fn), *args) diff --git a/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts b/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts index 3084d4cb0..cde16bbac 100644 --- a/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts +++ b/M02_MasterTemplete/logic/M02_Hopyo_Imported_Model.ts @@ -1,34 +1,11 @@ /* ============================================================================= * logic/M02_Hopyo_Imported_Model.ts - * 불러오기 단산 줄 = 가져온 식 단가산출(PLAN 793-3b · 요구 646 · 647) — 꼴 · 읽기 · 바꾸기만. - * · SK `가져온` 덧칸 꼴(793-2 `M02_Sangeun_Imported.py` 머리) · 근거 제안 세 단(확정 · 근접 · 사람 고름 · 793-3a 가 채움) - * · 단산 줄 고른 것 = {갈래 단가산출, 키 SK-…, 필요(입력 필요), 표지(결과 칩)} — 층 = 옮길 층 — 필요가 비어야 확인 · 옮김 - * · 줄 결과 칩 = 가져온 식 있음 · 근거 확정 · 근접 · 사람 고름 · 입력 필요 n(「2차 필요」 대신) + * 불러오기 단산 블록 = 가져온 식 단가산출(PLAN 793-3b · 809-1 · 요구 646 · 702) — 꼴 · 읽기만. + * · SK `가져온` 덧칸 꼴(793-2 `M02_Sangeun_Imported.py` 머리) · 경쟁사 로직 그대로(기초로 바꾸기 없음 · 요구 703) * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ -import type { DraftPick } from "./M02_MasterTemplete_Hopyo_Picks"; - export type Money4 = Record<"노무비" | "재료비" | "경비" | "계", number>; -export type Tier = "확정" | "근접" | "사람"; -export const TIERS: Tier[] = ["확정", "근접", "사람"]; - -/** 근거 후보 하나(793-3a `제안.근거후보`) — 까닭 = [[표지(판 차이 · 현장 값 · 감률 · 할증 배수 · 단위 기준 차이 · 그 밖), 글]] */ -export interface BasisCand { - 기초: string; - 이름?: string; - 원문번호?: string; - 옵션?: Record; - /** 글에 없는 칸(감률 · 할증 포함) — 사용자 입력 */ - 입력필요?: string[]; - 품검산?: "같음" | "다름" | "짝 없음" | null; - 단: Tier; - 까닭?: [string, string][]; -} - -/** 근거 후보 까닭 글(「표지 글」 · 줄마다) */ -export const reasonText = (c: BasisCand): string => - (c.까닭 ?? []).map(([tag, text]) => `${tag} ${text}`.trim()).join(" · "); export interface CheckLine { 자리: string; @@ -58,82 +35,23 @@ export interface ImportedMeta { 엑셀?: Money4; 줄: CheckLine[]; }; - 근거후보?: BasisCand[]; - 바꿈?: { 전?: Money4 | null; 후?: Money4 | null; 차이?: Money4 | null }; } export interface ImportedDoc { 이름?: string; 단위?: string; - 바탕?: { 로직?: string } | null; 입력값?: Record; - /** SK 단위 환산(기초 결과단위 성격이 다를 때 · 728-2) */ - 환산?: { 계수?: number | string }; 가져온?: ImportedMeta; [k: string]: unknown; } -/** 가져온 식(아직 우리 기초로 안 바꿈) */ +/** 가져온 식(경쟁사 로직이 든 SK) */ export const isImported = (d: ImportedDoc | null | undefined): boolean => !!d?.가져온?.로직; -/** 근거 후보 세 단 */ -export function tiers(list: BasisCand[] | undefined): Record { - const out: Record = { 확정: [], 근접: [], 사람: [] }; - for (const c of list ?? []) if (TIERS.includes(c.단)) out[c.단].push(c); - return out; -} - /** 검산 다른 줄 */ export const wrongLines = (d: ImportedDoc): CheckLine[] => (d.가져온?.검산?.줄 ?? []).filter((x) => !x.같음); -const blank = (v: unknown): boolean => v === undefined || v === null || String(v).trim() === ""; - -/** 입력 필요 칸 값 — 입력값 + 「환산」 = 환산 계수 */ -export const needValues = (d: ImportedDoc): Record => ({ - ...(d.입력값 ?? {}), - 환산: d.환산?.계수, -}); - -/** 입력 필요 중 아직 빈 칸 */ -export const stillNeed = (need: string[], values: Record | undefined): string[] => - need.filter((k) => blank(values?.[k])); - -/** 단산 줄 고른 것 — 그 SK(필요 = 빈 입력 필요 칸 · `missing` 이 막음) · 층 · 주인 안 둠 = 옮길 층(SK 를 만든 층)의 SK */ -export function skPick( - sk: string, - doc: ImportedDoc, - need: string[], - marks: string[], -): DraftPick & { 표지: string[] } { - const values = needValues(doc); - return { - 갈래: "단가산출", - 키: sk, - 이름: String(doc.이름 ?? sk), - 단위: String(doc.단위 ?? ""), - 입력값: Object.fromEntries(need.map((k) => [k, values[k] ?? ""])), - 출처: {}, - 몫: null, - 필요: need, - 표지: marks, - }; -} - -/** 결과 표지 — 가져온 식 있음 | 우리 기초(+ 근거 단) */ -export function marksOf(doc: ImportedDoc, tier: Tier | null): string[] { - if (isImported(doc)) return ["가져온 식 있음"]; - return tier ? [`근거 ${tier}`] : ["사람 고름"]; -} - -/** 단산 줄 결과 칩 글 — SK 고른 것이 있으면 그 표지 + 입력 필요 n · 없으면 null(옛 판정 칩) */ -export function rowChips(picks: (DraftPick & { 표지?: string[] })[]): string[] | null { - const sk = picks.find((p) => p.갈래 === "단가산출" && /^SK-/.test(String(p.키 ?? ""))); - if (!sk) return null; - const need = stillNeed(sk.필요 ?? [], sk.입력값); - return [...(sk.표지 ?? ["가져온 식 있음"]), ...(need.length ? [`입력 필요 ${need.length}`] : [])]; -} - /** 금액 차이 글(후 − 전) */ export function gapText( before: Money4 | null | undefined, diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts deleted file mode 100644 index cc53a6caa..000000000 --- a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts +++ /dev/null @@ -1,227 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Picks.ts - * 엑셀 불러오기 판정 화면 고름 캐시(PLAN 785-6 · 요구 641 · 642 · 설계 642-A 2-6) — 순수 셈. - * · 캐시 꼴 `{호표 {번: {차례: 고름}}, 블록 {번: 고름}}` · 고름 = `{고른 [고른 것 + 필요?], 확인, 배?}` — - * sessionStorage 에 넣고 빼는 것은 화면(`Hopyo_Rows`) · 여기는 꼴 · 읽기 · 바꾸기만 - * · 제안 = 미리 체크로만(캐시에 없으면 서버 제안) · 확인 = 사용자 [확인] · [호표 제안 모두 확인] 누름만(자동 없음) - * · 기초 고른 것 `필요` = 엔진이 쓰는데 못 읽은 칸(입력 필요) + 엇갈림 칸 — 값이 비면 그 줄 확인 · 옮김 막음 - * · 옮기기 몸 = 확인한 줄 · 블록만 · `필요` 걷음 · 줄 `배`(단위 환산 사용자 값) 얹음 · 금액 · 품 칸 안 보냄(서버가 다시 셈) - * · 별도계상 줄 = 서버 제안이 고정 줄 하나(`apartPick`) — 고름 없이 [확인] · [옮기기] 됨(막는 칸 없음) - * · 칠 = 확인함 success · 품 다름 warning · 고를 것 info · 후보 없음 danger - * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). - * ========================================================================== */ - -import type { - BaseCand, - JudgeRow, - MoveBody, - MoveBlocked, - Pick, - Section, - Solved, -} from "../M02_MasterTemplete_Hopyo_JudgeApi"; -import type { Tone } from "./M02_MasterTemplete_Hopyo_SheetMap"; - -/** 캐시 안 고른 것 — `필요` = 비면 막는 칸(화면만 · 몸에선 걷음) */ -export type DraftPick = Pick & { 필요?: string[] }; - -export interface LineDraft { - 고른: DraftPick[]; - 확인: boolean; - /** 단위 환산 사용자 값(엑셀 1 단위 = 우리 배) */ - 배?: number | null; -} - -export interface JudgeDraft { - 호표: Record>; - 블록: Record; -} - -export const draftKey = (layer: string, owner: string | null, key: string): string => - `m02-hopyo-judge:${layer}:${owner ?? ""}:${key}`; - -export const emptyDraft = (): JudgeDraft => ({ 호표: {}, 블록: {} }); - -export function parseDraft(text: string | null): JudgeDraft { - try { - const d = JSON.parse(text ?? "") as Partial; - if (d && typeof d === "object" && d.호표 && d.블록) return { 호표: d.호표, 블록: d.블록 }; - } catch { - /* 빈 · 깨진 캐시 = 빈 캐시 */ - } - return emptyDraft(); -} - -const blank = (v: unknown): boolean => v === undefined || v === null || String(v).trim() === ""; - -/** 절 기초 후보 → 고른 것(엔진 입력값 · 출처 · 몫 · 필요 = 입력 필요 + 엇갈림) */ -export function basePick(cand: BaseCand, sec: Section): DraftPick { - const b = cand.바인딩; - return { - 갈래: "기초", - 키: cand.키, - 이름: cand.이름, - 입력값: { ...b.입력값 }, - 출처: { ...b.출처 }, - 몫: typeof sec.몫 === "number" ? sec.몫 : (sec.몫?.값 ?? null), - 필요: [...new Set([...b.입력필요, ...Object.keys(b.엇갈림)])], - }; -} - -/** 블록 풀이 제안 — 절마다 고름 기초 하나 */ -export function solvedPicks(s: Solved | null | undefined): DraftPick[] { - const out: DraftPick[] = []; - for (const sec of s?.절 ?? []) { - const c = sec.후보.find((x) => x.키 === sec.고름); - if (c) out.push(basePick(c, sec)); - } - return out; -} - -/** 줄 제안(캐시 없을 때) — 장부 짝 = 고른 것 모두 · 제안 있음 · 후보 없음 = 첫 후보 · 2차 = 블록 풀이 · 후보 여럿 = 빈 */ -export function rowDefaults(row: JudgeRow): DraftPick[] { - if (row.장부?.갈래 === "짝") return row.후보.map((p) => ({ ...p })); - if (row.판정 === "2차 필요") return solvedPicks(row.풀이); - if (row.판정 === "후보 여럿") return []; - return row.후보.slice(0, 1).map((p) => ({ ...p })); -} - -export const lineOf = (d: JudgeDraft, no: number, at: number): LineDraft | undefined => - d.호표[String(no)]?.[String(at)]; - -/** 지금 고른 것 — 캐시 › 제안 */ -export const linePicks = (d: JudgeDraft, no: number, row: JudgeRow): DraftPick[] => - lineOf(d, no, row.차례)?.고른 ?? rowDefaults(row); - -export const blockPicks = (d: JudgeDraft, s: Solved): DraftPick[] => - d.블록[String(s.번)]?.고른 ?? solvedPicks(s); - -/** 별도계상 줄(786-1) — 고정 줄 하나 · 품목은 프로젝트에 복사할 때 정함 · 고름 없이 확인 · 옮김 */ -export const apartPick = (p: Pick): boolean => p.갈래 === "고정" && p.별도계상 === true; - -export const samePick = (a: Pick, b: Pick): boolean => - a.갈래 === b.갈래 && a.키 === b.키 && a.이름 === b.이름 && (a.규격 ?? "") === (b.규격 ?? ""); - -/** 복수 고름 — 있으면 뺌 · 없으면 더함 */ -export function togglePick(list: DraftPick[], p: DraftPick): DraftPick[] { - return list.some((x) => samePick(x, p)) ? list.filter((x) => !samePick(x, p)) : [...list, p]; -} - -/** 입력값 한 칸 — 출처 「사용자」(엇갈림에서 고르면 그 길) · 빈 글 = 칸 지움 */ -export function setInput(p: DraftPick, name: string, value: unknown, 길 = "사용자"): DraftPick { - const 입력값 = { ...p.입력값 }; - const 출처 = { ...p.출처 }; - if (blank(value)) { - delete 입력값[name]; - delete 출처[name]; - } else { - const n = typeof value === "string" ? Number(value.replace(/,/g, "")) : NaN; - 입력값[name] = typeof value === "string" && value.trim() !== "" && Number.isFinite(n) ? n : value; // prettier-ignore - 출처[name] = { 길 }; - } - return { ...p, 입력값, 출처 }; -} - -/** 아직 빈 막는 칸 */ -export const missing = (p: DraftPick): string[] => (p.필요 ?? []).filter((k) => blank(p.입력값[k])); - -/** 줄 막힘 — 고른 것 없음 · 빈 막는 칸 */ -export function blockedOf(list: DraftPick[]): string[] { - return list.length ? [...new Set(list.flatMap(missing))] : ["고른 것 없음"]; -} - -export const confirmable = (list: DraftPick[]): boolean => - list.length > 0 && list.every((p) => missing(p).length === 0); - -/** 호표 줄 고름 넣기(새 캐시) */ -export function putLine(d: JudgeDraft, no: number, at: number, line: LineDraft): JudgeDraft { - const h = { ...(d.호표[String(no)] ?? {}), [String(at)]: line }; - return { ...d, 호표: { ...d.호표, [String(no)]: h } }; -} - -export function putBlock(d: JudgeDraft, no: number, line: LineDraft): JudgeDraft { - return { ...d, 블록: { ...d.블록, [String(no)]: line } }; -} - -/** 줄 하나 바꿈 — 고른 것이 바뀌면 확인 풀림 */ -export function editLine(d: JudgeDraft, no: number, row: JudgeRow, 고른: DraftPick[]): JudgeDraft { - const was = lineOf(d, no, row.차례); - return putLine(d, no, row.차례, { ...was, 고른, 확인: false }); -} - -/** 제안이 다 풀린 줄 — 제안 있음(후보 하나) · 2차 기초로 풂 · 기초+덧(막는 칸 0) */ -export function suggestReady(row: JudgeRow): boolean { - if (row.판정 === "제안 있음") return row.후보.length > 0; - if (row.판정 !== "2차 필요" || !row.풀이) return false; - const ok = row.풀이.판정 === "기초로 풂" || row.풀이.판정 === "기초+덧"; - return ok && confirmable(solvedPicks(row.풀이)); -} - -/** [호표 제안 모두 확인] — 다 풀린 제안 줄만 확인 켬(사용자 누름 · 이미 손댄 줄은 그대로) */ -export function confirmSuggested(d: JudgeDraft, no: number, rows: JudgeRow[]): JudgeDraft { - let out = d; - for (const row of rows) { - if (lineOf(out, no, row.차례) || !suggestReady(row)) continue; - out = putLine(out, no, row.차례, { 고른: rowDefaults(row), 확인: true }); - } - return out; -} - -const PAIR_BAD = new Set(["품 다름"]); -const pairBad = (s: Solved | null | undefined, keys: Set): boolean => - (s?.절 ?? []).some((sec) => - sec.후보.some((c) => keys.has(c.키) && c.줄.some((x) => PAIR_BAD.has(x.판정))), - ); - -/** 줄 칠 — 확인 = success(품 다름 = warning) · 후보 없음 = danger · 그 밖 = info */ -export function rowTone(row: JudgeRow, line: LineDraft | undefined): Tone { - if (line?.확인) { - const keys = new Set(line.고른.map((p) => p.키)); - return pairBad(row.풀이, keys) ? "warning" : "success"; - } - return row.판정 === "후보 없음" ? "danger" : "info"; -} - -/** 블록 칠 — 만든 SK · 확인 = success · 고를 것 = info · 줄 조합 = info · 후보 0 = danger */ -export function blockTone(s: Solved, line: LineDraft | undefined, made: string | null): Tone { - if (line?.확인 || made) return "success"; - return s.절.every((sec) => sec.후보.length === 0) && s.판정 !== "줄 조합" ? "danger" : "info"; -} - -/** 몸 꼴 — `필요` 걷음 · 배 얹음 */ -export const bare = (p: DraftPick, 배?: number | null): Pick => { - const { 필요: _need, ...rest } = p; - return 배 === undefined || 배 === null ? rest : { ...rest, 배 }; -}; - -/** 옮기기 몸 — 확인한 것만 · 막는 칸 남은 줄 = 막힘(몸에서 뺌) */ -export function moveBody( - d: JudgeDraft, - file: { layer: string; owner: string | null }, -): { body: MoveBody; 막힘: MoveBlocked[]; 수: number } { - const body: MoveBody = { 층: file.layer, 호표: {}, 블록: {} }; - if (file.owner) body.주인 = file.owner; - const 막힘: MoveBlocked[] = []; - let 수 = 0; - for (const [no, lines] of Object.entries(d.호표)) { - for (const [at, line] of Object.entries(lines)) { - if (!line.확인) continue; - const 칸 = line.고른.length ? line.고른.flatMap(missing) : ["고른 것 없음"]; - if (칸.length) { - 막힘.push({ 호표: Number(no), 차례: Number(at), 까닭: "입력 필요", 칸 }); - continue; - } - (body.호표[no] ??= {})[at] = line.고른.map((p) => bare(p, line.배)); - 수 += 1; - } - } - for (const [no, line] of Object.entries(d.블록)) { - if (!line.확인) continue; - const 칸 = blockedOf(line.고른); - if (line.고른.length && 칸.length === 0) { - body.블록[no] = line.고른.map((p) => bare(p)); - 수 += 1; - } else 막힘.push({ 블록: Number(no), 까닭: "입력 필요", 칸 }); - } - return { body, 막힘, 수 }; -} diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts index cc48246df..589d07cef 100644 --- a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts +++ b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts @@ -1,10 +1,10 @@ /* ============================================================================= * M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_SheetMap.ts * 엑셀 보기 창 세 보기 칠 · 뛸 곳(PLAN 779-4 · 요구서 621 3장 4 · 5) — 순수 셈. - * · 재료 = 서버 격자 `보기 · 목록 · 줄 · 호표 · 단산`(779-1 `M02_Hopyo_Grid`) · 목록 `줄상태 · 만든`(785-7 `Judge.view` 1차 판정) · + * · 재료 = 서버 격자 `보기 · 목록 · 줄 · 호표 · 단산`(779-1 `M02_Hopyo_Grid`) · 목록 `줄상태 · 만든`(809-1 `Hopyo_View.listing` 줄 표지) · * 블록 `[{번, 판정, SK, 갈래, 못옮김}]`(779-2 `M02_Hopyo_Marks` — 칠 꼴 표가 그 머리) · 확인 창 캐시 고름 * · 칠 = success 이어짐 · warning 이어짐 · 엑셀과 다름 · info 고를 것 · danger 못 이음 · muted 밖 + mine(내가 고름 띠) - * · 「이어짐」 = 1차 판정 「제안 있음」 · 판정 화면 확인함 · 만든 SC(옛 확인 창 캐시 `draftOf` 는 785-7 부터 늘 null) + * · 「이어짐」 = 줄 표지 연결됨 · 로직 줄(809-1 `Hopyo_View` · 경쟁사 로직 인정) · 만든 SC · 못 이음 = 빨강 · 후보 = 파랑 * · 호표 머리 행 = 그 호표 줄 칠 중 가장 나쁜 것 · 블록 = 만든 SK 갈래 › 없으면 부르는 첫 줄 판정 「예상」 · 안 불림 = 밖 * · 블록 안 줄(779-5) = 블록 `줄 [[행, 상태, 차]]`(Marks · D-2 줄 잇기 상태) → 그 출처 행만 칠 · 머리 행 = 줄 가장 나쁜 칠 · * 「엑셀과 다름」 = 이어졌는데 금액 참고 차만 있을 때 · 줄 재료 없으면 옛 블록 칠(범위 통째) @@ -42,10 +42,12 @@ export interface BriefMarks { } export interface BlockMark { 번: number; - /** 새 블록 판정(785-6 · 기초로 풂 · 기초+덧 · 줄 조합 · 고를 것) › 옛 부르는 첫 줄 맞춤셈 상태 */ + /** 블록 판정(809-1 · 불리는 블록 = 「가져온 식」 · 안 불림 = null) */ 판정: string | null; - 고를수?: number | null; 부름?: number[]; + /** 블록 이름 · 단위(809-1 단가산출 목록) */ + 이름?: string | null; + 단위?: string | null; SK: string | null; 갈래: string | null; 못옮김: unknown[]; @@ -110,15 +112,15 @@ export function lineTone( const on = (d?.체크[at] ?? check) && ok; if (state === "%") return { tone: on ? "success" : "muted", mine }; if (on) return { tone: state === "다른 점" && !parts ? "warning" : "success", mine }; - return { tone: state === "없음" || state === "노임" ? "danger" : "info", mine }; + return { tone: state === "못 이음" || state === "없음" || state === "노임" ? "danger" : "info", mine }; // prettier-ignore } /** 가장 나쁜 칠(밖만이면 밖) */ export const worst = (tones: Tone[]): Tone => tones.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), "muted"); -/** 새 블록 판정 칠(785-6) — 확인 전이라 제안 칠만(확인함 = 만든 SK) */ -const BLOCK_TONE: Record = { "기초로 풂": "success", "기초+덧": "success", "줄 조합": "info", "고를 것": "info" }; // prettier-ignore +/** 블록 판정 칠(809-1) — 불리는 블록 = 가져온 식(경쟁사 로직 인정) · 만든 SK 전 제안 칠 */ +const BLOCK_TONE: Record = { "가져온 식": "success" }; /** 블록 칠 — 만든 SK 갈래 › 없으면 부르는 첫 줄 판정 「예상」 · 판정 없음 = 「일위대가 밖」 */ export function blockTone(m: BlockMark | undefined): { tone: Tone; 글: string } { @@ -131,9 +133,8 @@ export function blockTone(m: BlockMark | undefined): { tone: Tone; 글: string } if (!m.판정) return { tone: "muted", 글: "일위대가 밖" }; // 부르는 일위대가 줄 없음(780-2) const fresh = BLOCK_TONE[m.판정]; if (fresh) { - const n = m.판정 === "고를 것" && m.고를수 ? ` ${m.고를수}` : ""; const out = m.부름 && !m.부름.length ? " · 일위대가 밖" : ""; - return { tone: fresh, 글: `제안 — ${m.판정}${n}${out}` }; + return { tone: fresh, 글: `${m.판정}${out}` }; } const tone: Tone = m.판정 === "맞음" ? "success" : m.판정 === "다른 점" ? "warning" : "info"; return { tone, 글: `예상 — 부르는 첫 줄 ${m.판정}` }; diff --git a/resources/tester/helper_642_6_picks.cjs b/resources/tester/helper_642_6_picks.cjs deleted file mode 100644 index 64d09304e..000000000 --- a/resources/tester/helper_642_6_picks.cjs +++ /dev/null @@ -1,34 +0,0 @@ -/* 642-6(PLAN 785-6) — `logic/M02_MasterTemplete_Hopyo_Picks.ts`(판정 화면 고름 캐시 순수 셈)를 트랜스파일해 Node 로 돌림. - * stdin = {steps: [{fn, args, mod?}]} — args 안 `{"$": i}` = i 번째 걸음 결과 · `{"$none": 1}` = () => null · - * `{"$set": [[번, 차례]]}` = (번, 차례) => 그 짝 있음 · mod "SheetMap" = 칠 셈 · stdout = 걸음 결과 배열 JSON(Map → 객체). */ -const fs = require("fs"); -const path = require("path"); -const { makeLoader } = require("./helper_ts_stub_loader.cjs"); - -const ROOT = path.join(__dirname, "..", ".."); -const { loadTs } = makeLoader({ root: ROOT }); -const picks = loadTs( - path.join(ROOT, "M02_MasterTemplete", "logic", "M02_MasterTemplete_Hopyo_Picks.ts"), -); -const map = loadTs( - path.join(ROOT, "M02_MasterTemplete", "logic", "M02_MasterTemplete_Hopyo_SheetMap.ts"), -); -const input = JSON.parse(fs.readFileSync(0, "utf8")); -const out = []; -const ref = (v) => - v && typeof v === "object" - ? "$none" in v - ? () => null - : "$set" in v - ? (a, b) => v.$set.some(([x, y]) => x === a && y === b) - : "$" in v && Object.keys(v).length === 1 - ? out[v.$] - : Array.isArray(v) - ? v.map(ref) - : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, ref(x)])) - : v; -for (const s of input.steps) - out.push((s.mod === "SheetMap" ? map : picks)[s.fn](...ref(s.args)) ?? null); -process.stdout.write( - JSON.stringify(out, (_k, v) => (v instanceof Map ? Object.fromEntries(v) : v)), -); diff --git a/resources/tester/helper_778_5_sk_pick.cjs b/resources/tester/helper_778_5_sk_pick.cjs index a433c596b..7b0cd8e60 100644 --- a/resources/tester/helper_778_5_sk_pick.cjs +++ b/resources/tester/helper_778_5_sk_pick.cjs @@ -1,4 +1,4 @@ -/* 780-2 · 785-6 엑셀 보기 오른쪽 판 바꾸기 헬퍼 — stdin `{sheetSk: {...}}` = `Hopyo_SheetSk` 를 트랜스파일해 Node 로 돌림 · +/* 780-2 · 809-1 엑셀 보기 오른쪽 판 바꾸기 헬퍼 — stdin `{sheetSk: {...}}` = `Hopyo_SheetSk` 를 트랜스파일해 Node 로 돌림 · * 단가산출 칸 · 시트 막대 · 판은 가짜 · stdout = JSON. 옛 마법사 줄 고름(`SkPick` · `SkApi`)은 785-7 에서 지움. */ const fs = require("fs"); const path = require("path"); @@ -18,57 +18,31 @@ const fakeEl = () => ({ }); const list = []; // 그린 목록 줄 벌 const reads = []; // 목록 읽음 +const opened = []; // [가져온 식으로 열기] const BRIEFS = [ - { - 번: 8, - 이름: "거푸집", - 단위: "㎡", - 부름: [1], - 표지: null, - 만든: null, - 판정: "고를 것", - 고를수: 2, - }, - { - 번: 9, - 이름: "잡석", - 단위: "㎥", - 부름: [], - 표지: "준용", - 만든: null, - 판정: "줄 조합", - 고를수: 0, - }, - { - 번: 10, - 이름: "운반", - 단위: "㎡", - 부름: [2], - 표지: null, - 만든: "SK-000001", - 판정: "기초로 풂", - 고를수: 0, - }, + { 번: 8, 이름: "거푸집", 단위: "㎡", 부름: [1], SK: null, 판정: "가져온 식" }, + { 번: 9, 이름: "잡석", 단위: "㎥", 부름: [], SK: null, 판정: null }, + { 번: 10, 이름: "운반", 단위: "㎡", 부름: [2], SK: "SK-000001", 판정: "가져온 식" }, ]; const STUBS = { "@ui/ui_template_locale": { t: (k) => (k === "M02_JudgeBlock" ? "단산{n}" : k) }, "@ui/ui_template_badge": { createBadge: (text, tone) => `${tone}:${text}` }, "@ui/ui_template_text": { createText: (text) => text }, "@ui/ui_template_list_row": { createListRow: (opts) => ({ opts, dataset: {} }) }, - "./M02_MasterTemplete_Hopyo_JudgeApi": { - readJudge: async (scope, key, to) => { + "./M02_MasterTemplete_Hopyo_SheetNav": { + readHopyoMarks: async (scope, key, to) => { reads.push({ key, to }); - return { 키: key, 파일: "a.xls", 호표: [], 블록: BRIEFS }; + return BRIEFS; }, }, - "./M02_MasterTemplete_Hopyo_Block": { createJudgeBlock: (o) => ({ ...o, destroy() {} }) }, - "./M02_MasterTemplete_Hopyo_Api": { POLL_MS: 3000 }, - "./M02_MasterTemplete_Hopyo_Rows": { createJudgeMove: () => ({ root: fakeEl(), destroy() {} }) }, + "./M02_MasterTemplete_Hopyo_Imported": { openImported: (o) => opened.push(o) }, "@config/config_frontend": { API_BASE_URL: "/api", ROUTES: {} }, "@ui/ui_template_elements": { + createButton: (o) => ({ label: o.label, dataset: {}, hidden: false, click: () => o.onClick({ stopPropagation() {} }) }), el: (tag, o = {}) => ({ ...fakeEl(), tag, + kids: o.children ?? [], className: o.className, replaceChildren(...kids) { this.kids = kids; @@ -97,7 +71,7 @@ global.sessionStorage = { getItem: () => null, setItem: () => {}, removeItem: () process.exit(1); }); -/** 780-2 · 785-6 판 바꾸기 — 탭(역할) 차례로 고름 · 판 숨김 · 목록 읽음 한 번 · 강조 · 줄 누름 = 뛰기 + 블록 판 */ +/** 780-2 · 809-1 판 바꾸기 — 탭(역할) 차례로 고름 · 판 숨김 · 목록 읽음 한 번 · 강조 · 줄 누름 = 뛰기 · 줄 끝 = 가져온 식 모달 */ async function sheetSk({ same, canWrite, picks }) { const M02 = path.join(ROOT, "M02_MasterTemplete"); const { createSheetSk } = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_SheetSk.ts")); @@ -118,22 +92,7 @@ async function sheetSk({ same, canWrite, picks }) { }; const scope = { layer: "personal", owner: null }; const target = same ? scope : { layer: "company", owner: null }; - const state = { - get: () => ({ 호표: {}, 블록: { 8: { 고른: [], 확인: true } } }), - on: () => () => {}, - }; - const opened = []; - const p = createSheetSk({ - scope, - key: "HP-000001", - target, - canWrite, - nav, - side, - state, - openLines: (make) => opened.push(make("host")), - closeLines: () => {}, - }); + const p = createSheetSk({ scope, key: "HP-000001", target, canWrite, nav, side }); const seen = []; const rows = () => list.at(-1) ?? []; for (const [sheet, cell] of picks) { @@ -146,22 +105,23 @@ async function sheetSk({ same, canWrite, picks }) { 읽음: reads.length, 강조: active, }); - if (sheet !== "s1") - rows() - .find((r) => r.opts.label.startsWith("단산8 ")) - ?.opts.onClick(); + if (sheet !== "s1") { + const row = rows().find((r) => r.opts.label.startsWith("단산8 ")); + row?.opts.onClick(); + row?.opts.trailing.kids.at(-2).click(); // [가져온 식으로 열기](만들 층) + } } return { seen, toggled, jumps, reads, - blocks: opened.map((o) => ({ no: o.no, readOnly: o.readOnly, onSheet: !!o.onSheet })), + opened: opened.map((o) => ({ no: o.no, canWrite: o.canWrite, to: o.target.layer })), rows: rows().map((r) => ({ label: r.opts.label, sub: r.opts.sub, stripe: r.opts.stripe, - tail: r.opts.trailing, + tail: r.opts.trailing.kids.filter((k) => !k.hidden).map((k) => (typeof k === "string" ? k : k.label)), })), count: p.count(), }; diff --git a/resources/tester/test_627_sheet_sk.py b/resources/tester/test_627_sheet_sk.py index a96abb845..f4cb260ff 100644 --- a/resources/tester/test_627_sheet_sk.py +++ b/resources/tester/test_627_sheet_sk.py @@ -1,7 +1,7 @@ """780-2 지킴 — 엑셀 보기 단가산출표 · 상세 탭 = 오른쪽 단가산출 목록(요구 627). 서버 없이 잡음: 탭(역할) 따라 판 바꿈(일위대가표 = 호표 목록 · 단가산출 탭 = 단가산출 목록 · 칸은 처음 한 번만 지음) · -시트 행 → 목록 강조 · 목록 줄 → 지금 탭의 블록 자리 · 만들 층 ≠ 파일 층 = 보기만 · 거르기(전체 · 안 · 밖 · 고를 줄 있음) · +시트 행 → 목록 강조 · 목록 줄 → 지금 탭의 블록 자리 · 줄 끝 [가져온 식으로 열기](809-1 · 판정 블록 판 걷음) · [끝내기] = 막지 않고 물음창 글만 · 화면 = 부품 상자만 · 700 줄. """ @@ -34,7 +34,7 @@ PICKS = [["s1", "B5"], ["s2", "B12"], ["s1", "C3"], ["s3", "A85"], ["s3", "A2"]] def test_판_바꿈_목록_지음_강조_뛰기(): got = _node({"sheetSk": {"same": True, "canWrite": True, "picks": PICKS}}) - # 785-6 — 단가산출 목록 = 새 블록 판정(hopyo-judge 목록) · 처음 단가산출 탭에 올 때 한 번 읽음 + # 809-1 — 단가산출 목록 = 블록 칠 재료(hopyo-marks) · 처음 단가산출 탭에 올 때 한 번 읽음 hide = [(s["호표판숨김"], s["단가판숨김"], s["읽음"]) for s in got["seen"]] assert hide == [ (False, True, 0), @@ -53,21 +53,22 @@ def test_판_바꿈_목록_지음_강조_뛰기(): "단산8 거푸집", None, ] - # 목록 줄 누름 = 지금 탭의 자리(단가산출표 = 목록 행 · 상세 = 블록 범위) + 시트 아래 블록 판 + # 목록 줄 누름 = 지금 탭의 자리(단가산출표 = 목록 행 · 상세 = 블록 범위) · 줄 끝 = 가져온 식 모달 assert got["jumps"] == [["s2", [8, 9]], ["s3", [8, 9]], ["s3", [8, 9]]] - assert got["blocks"] == [{"no": 8, "readOnly": False, "onSheet": True}] * 3 + assert got["opened"] == [{"no": 8, "canWrite": True, "to": "personal"}] * 3 rows = {r["label"]: r for r in got["rows"]} - assert rows["단산8 거푸집"]["sub"] == "㎡ · 고를 것 2 · M02_JudgeCalls 1" - assert rows["단산8 거푸집"]["tail"] == "accent:M02_JudgeConfirmed" # 캐시 확인 - assert rows["단산9 잡석"]["sub"] == "㎥ · 줄 조합 · 준용 · M02_JudgeOutside" - assert rows["단산10 운반"]["tail"] == "success:M02_JudgeMade SK-000001" - assert got["count"] == {"블록": 2, "밖": 1, "줄": 2} # 만든 SK 뺌 + assert rows["단산8 거푸집"]["sub"] == "㎡ · M02_JudgeCalls 1" + assert rows["단산8 거푸집"]["tail"] == [ + "M02_ToMySk" + ] # 만들 층 = 개인 → [가져온 식으로 열기] 숨김 + assert rows["단산9 잡석"]["sub"] == "㎥ · M02_JudgeOutside" + assert rows["단산10 운반"]["tail"] == ["success:M02_JudgeMade SK-000001", "M02_ToMySk"] + assert got["count"] == {"블록": 2, "밖": 1, "줄": 0} # 만든 SK 뺌 -def test_다른_층_권한_없음_보기만(): - for same, can in ((False, True), (True, False)): - got = _node({"sheetSk": {"same": same, "canWrite": can, "picks": [["s2", "B5"]]}}) - assert got["blocks"][0]["readOnly"] is True +def test_권한_그대로_넘김(): + got = _node({"sheetSk": {"same": False, "canWrite": False, "picks": [["s2", "B5"]]}}) + assert got["opened"][0] == {"no": 8, "canWrite": False, "to": "company"} def _src(name: str) -> str: @@ -78,11 +79,16 @@ def test_캐시_공유_알림_칠_다시(): sheet = _src("Sheet") assert "createSheetSk(" in sheet and "sk?.at(s);" in sheet side = _src("SheetSk") - assert "readJudge(opt.scope, opt.key, opt.target)" in side # 785-6 새 블록 판정 목록 + assert "readHopyoMarks(opt.scope, opt.key, opt.target)" in side # 809-1 블록 칠 재료 목록 + assert ( + "createJudgeBlock" not in side and "createJudgeMove" not in side + ) # 판정 블록 판 · 옮기기 걷음 nav = _src("SheetNav") assert "blockAt(sheet: string, row: number)" in nav and "spotOf(no: number, role: Role)" in nav wizard = _src("Import") - assert 'L("M02_SkUnmoved")' in wizard and "leftCount" not in wizard # 밖 = 안 막음 · 옛 남은 줄 막음 걷음 + assert ( + 'L("M02_SkUnmoved")' in wizard and "leftCount" not in wizard + ) # 밖 = 안 막음 · 옛 남은 줄 막음 걷음 tone = (M02 / "logic" / "M02_MasterTemplete_Hopyo_SheetMap.ts").read_text(encoding="utf-8") assert '글: "일위대가 밖"' in tone diff --git a/resources/tester/test_642_1_clause_formula.py b/resources/tester/test_642_1_clause_formula.py index 583969fed..f90e7b353 100644 --- a/resources/tester/test_642_1_clause_formula.py +++ b/resources/tester/test_642_1_clause_formula.py @@ -1,5 +1,5 @@ -"""785-1(642-1) 지킴 — 출처 풀이 · 조항 색인(`M02_Hopyo_Clause`) · 블록 글 → 절 나무 · 상수 · -대입식 · 절 몫(`M02_Hopyo_Formula`) · 북부청 견본 엑셀(설계 642-A 5-3 첫 줄 기대). +"""785-1(642-1) 지킴 — 블록 글 → 절 나무 · 상수 · 대입식 · 절 몫(`M02_Hopyo_Formula`) · 북부청 견본 엑셀. +조항 색인 → 기초 후보(`Clause.basis` · `index`) 시험은 809-1 에서 지움(경쟁사 로직 인정 · 요구 703). 읽기만 · 서버 없음. """ @@ -16,7 +16,6 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from M02_MasterTemplete import M02_Hopyo_Clause as clause # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402 from M02_MasterTemplete import M02_Hopyo_Formula as fm # noqa: E402 @@ -36,60 +35,19 @@ def blocks() -> dict[int, dict]: return {b["번"]: b for b in book["단산"]} -@lru_cache(maxsize=None) -def got(no: int) -> dict: - return clause.basis(blocks()[no]) - - -def keys(no: int) -> set[str]: - return {o["키"] for o in got(no)["후보"]} - - -# ── 조항 색인 → 후보 ────────────────────────────────────────────────── -@pytest.mark.parametrize( - "no, want", - [ - (10, {"GC001140", "GC001141", "GC001142"}), - (46, {"GC000198"}), - (24, {"GC000211"}), - (47, {"GC000206", "GC000207"}), - (39, {"GF000224", "GF000225"}), - ], -) -def test_후보(no, want): - assert keys(no) == want - assert got(no)["표지"] is None - - -def test_윗마디_절마다(): - """47 「6-2」 → 절 「1. 현장가공」 GC000206 · 「2. 조립」 GC000207 · 공장가공은 뺌.""" - by = {o["키"]: (o["맞음"], o["절"]) for o in got(47)["후보"]} - assert by == {"GC000206": ("윗마디", "1"), "GC000207": ("윗마디", "2")} - assert {o["키"] for o in got(47)["뺌"]} >= {"GC000208"} - - -@pytest.mark.parametrize("no, mark", [(33, "준용"), (6, "옛 번호"), (3, "번호 없음")]) -def test_표지(no, mark): - assert got(no)["표지"] == mark - - -def test_준용_결과단위_거름_절_번호(): - """33 「산림 12-12 준용」 → GF000177(원/식) 뺌 · 절 4 「표준품셈3-4-8」 → GC000110.""" - r = got(33) - assert {o["키"]: o["까닭"][:7] for o in r["뺌"]} == {"GF000177": "결과단위 다름"} - assert [(o["키"], o["절"]) for o in r["후보"]] == [("GC000110", "4")] - - -def test_색인(): - assert set(clause.index()[("건설품셈 공통", "1-2-7")]) == {"GC001140", "GC001141", "GC001142"} - assert clause.nature("원/10㎡") == "면적" and clause.nature("원/자재 단위") is None - - # ── 절 나무 ─────────────────────────────────────────────────────────── def _works(no: int) -> list[str]: nodes = blocks()[no]["절"] ids = {id(w) for w in fm.works(nodes)} - return [p for p, one in clause._paths(nodes) if id(one) in ids] + return [p for p, one in _paths(nodes) if id(one) in ids] + + +def _paths(nodes: list[dict], up: str = "") -> list[tuple[str, dict]]: + out = [] + for one in nodes: + path = f"{up}.{one['번']}" if up else one["번"] + out += [(path, one), *_paths(one["아래"], path)] + return out def test_절_나무(): diff --git a/resources/tester/test_642_2_bind_solve.py b/resources/tester/test_642_2_bind_solve.py deleted file mode 100644 index af46b81fa..000000000 --- a/resources/tester/test_642_2_bind_solve.py +++ /dev/null @@ -1,194 +0,0 @@ -# -*- coding: utf-8 -*- -"""642-2(PLAN 785-2) 옵션값 읽기 · 검산 · 판정 — 북부청 임도 예시 1 · 2 · 3 + 단산46 · 39 기대표 -(설계 642-A 2-10 · 5-3) · 출처 꼬리표 · 지어 넣은 값 0 · 입력 필요 · 품 같음 · master_auto.defaults. -읽기만(엑셀 · M01) · storage 안 씀.""" - -from decimal import Decimal -from pathlib import Path - -import pytest - -from M02_MasterTemplete import M02_Hopyo_Bind as hb -from M02_MasterTemplete import M02_Hopyo_Excel as hx -from M02_MasterTemplete import M02_Hopyo_Solve as hs - -NORTH = ( - Path(__file__).resolve().parents[1] - / "knowledge/original/실무문서/2026_실무일위대가 모음" - / "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls" -) -PATHS = {"B1", "B2", "B3", "B4", "B5", "B6"} -# 후보 = 785-1 Clause 몫(조항 색인) — 여기선 설계 2-10 후보 열 그대로 -CANDS = { - 10: ["GC001140", "GC001141", "GC001142"], - 24: ["GC000211"], - 46: ["GC000198"], - 47: [{"키": "GC000206", "절": "1"}, {"키": "GC000207", "절": "2"}], - 39: ["GF000224", "GF000225"], -} - - -@pytest.fixture(scope="module") -def master(): - return hb.m01.cm.master() - - -@pytest.fixture(scope="module") -def book(): - return hx.read_book(NORTH.name, NORTH.read_bytes()) - - -@pytest.fixture(scope="module") -def solved(master, book): - blocks = {b["번"]: b for b in book["단산"]} - out = {} - for no, cands in CANDS.items(): - hopyo = next( - h for h in book["호표"] if any((x.get("참조") or {}).get("번") == no for x in h["줄"]) - ) - line = next(x for x in hopyo["줄"] if (x.get("참조") or {}).get("번") == no) - same = [x for x in hopyo["줄"] if x is not line] - blk = blocks[no] - out[no] = hs.block(master, blk, hs.parts(blk, cands), {"줄": line, "같은줄": same}) - return out - - -def _cand(solved, no, key, sec=0): - return next(c for c in solved[no]["절"][sec]["후보"] if c["키"] == key) - - -def _where(cand): - return {k: v["길"] for k, v in cand["바인딩"]["출처"].items()} - - -def test_예시1_단산10_소운반(solved): - got = solved[10] - assert got["판정"] == "기초로 풂" and got["고를수"] == 0 - assert got["절"][0]["고름"] == "GC001140" - c = _cand(solved, 10, "GC001140") - b = c["바인딩"] - assert b["입력값"] == { - "운반무게": 575, - "회당운반량": 25, - "운반거리": 6, - "경사각도": 35, - "운반회수": 42, - } - assert _where(c) == { - "운반무게": "B4", - "회당운반량": "B4", - "운반거리": "B3", - "경사각도": "B3", - "운반회수": "B3", - } - assert b["입력필요"] == [] and b["엇갈림"] == {} - assert set(b["안씀"]) == {"재료종별", "재료형상", "재료갈래", "운반로", "실작업시간"} - (row,) = c["줄"] - assert row["판정"] == "품 같음" - assert Decimal(str(row["우리"]["품"])).quantize(Decimal("0.000001")) == Decimal("0.547619") - assert round(c["금액"]["계"], 1) == 94227.7 - - -def test_예시2_단산47_절_둘(solved): - got = solved[47] - one, two = got["절"] - assert (one["고름"], two["고름"]) == ("GC000206", "GC000207") - a = _cand(solved, 47, "GC000206", 0)["바인딩"] - assert a["입력값"] == {"Type": "Type-Ⅰ"} and a["출처"]["Type"]["길"] == "B1" - c = _cand(solved, 47, "GC000207", 1) - b = c["바인딩"] - assert b["입력값"] == {"Type": "Type-Ⅰ", "호": "Ⅰ-1", "소량감률": 50} - assert _where(c) == {"Type": "B1", "호": "B5", "소량감률": "B5"} - assert [x["값"] for x in b["엇갈림"]["구분"]] == ["토목", "건축"] - assert b["기본"]["구분"] == "토목" - assert [r["판정"] for r in c["줄"]] == ["품 같음"] * 4 - assert got["판정"] == "고를 것" and got["고를수"] == 1 # 구분 하나만 사용자 - - -def test_예시3_단산24_합판거푸집(solved, master, book): - c = _cand(solved, 24, "GC000211") - b = c["바인딩"] - assert b["입력값"] == { - "사용횟수": "4회", - "유형": "보통", - "지붕슬래브": "아님", - "폼타이": "아님", - } - assert _where(c) == {"사용횟수": "B1", "유형": "B2", "지붕슬래브": "B2", "폼타이": "B2"} - assert b["입력필요"] == ["높이감률", "구멍땜"] - assert b["기본"]["높이감률"] is None and b["기본"]["구멍땜"] is None # 규칙에 맞는 칸 없음 - assert set(b["안씀"]) == {"폼타이규격", "측압"} and b["비움"] == ["자재지역"] - assert solved[24]["판정"] == "고를 것" and solved[24]["고를수"] == 2 - # 사용자가 0 넣음 → 노무 줄 품 같음 · 노무비 38,291 = 엑셀 - given = {**b["입력값"], "높이감률": 0, "구멍땜": 0} - items = [x for x in next(b for b in book["단산"] if b["번"] == 24)["항목"] if not x.get("밖")] - rows = hs.pair(hb.quantities(master, "GC000211", given), items) - assert {r["판정"] for r in rows if r["엑셀"]} == {"품 같음"} - fill = {n: b["기본"][n] for n in b["안씀"]} | {"자재지역": None} - money = hb.mf.run(master, "GC000211", {**fill, **given}) - assert round(money["노무비"]) == 38291 - - -def test_단산46_레미콘타설(solved): - got = solved[46] - c = _cand(solved, 46, "GC000198") - b = c["바인딩"] - assert b["입력값"] == {"타설방법": "장비사용타설", "구조물": "철근구조물", "소량감률": 50} - assert _where(c) == {"타설방법": "B1", "구조물": "B1", "소량감률": "B5"} - assert b["입력필요"] == ["굴착기"] and b["비움"] == ["지역"] # 굴착기 규격 = B3 · 사용자 - mach = next(r for r in c["줄"] if r["엑셀"] and r["엑셀"]["묶음"] == "X") - assert mach["판정"] == "품 같음" and mach["표지"] == "기계 갈래 엇갈림" - assert [d["이름"] for d in got["덧"]] == ["레미콘"] # 별도계상 = 덧 줄 - - -def test_단산39_찰붙임(solved): - sec = solved[39]["절"][0] - assert sec["고름"] == "GF000225" and solved[39]["판정"] == "기초로 풂" - c = _cand(solved, 39, "GF000225") - assert c["바인딩"]["입력값"] == { - "뒷길이": 35, - "굵은골재최대치수": 25, - "강도": 21, - "슬럼프": "120", - "채움콘크리트량": 0.12, - "보정작업": "해당 없음", - } - assert _where(c)["뒷길이"] == "B1" and _where(c)["채움콘크리트량"] == "B6" - assert _cand(solved, 39, "GF000224")["판정"] == "품 다름" - - -def test_지어_넣은_값_없음(solved): - for got in solved.values(): - for sec in got["절"]: - for c in sec["후보"]: - b = c["바인딩"] - assert set(b["입력값"]) == set(b["출처"]) # 출처 없는 값 0 - assert {v["길"] for v in b["출처"].values()} <= PATHS - - -@pytest.mark.parametrize( - "excel, ours, want", - [ - (0.67, 0.6667, True), - (1.18, 1.1765, True), - (3.53, 3.5294, True), - (0.1111111111, 0.11111, True), - (0.67, 0.69, False), - (0.547619, 0.5476190476, True), - (None, 1, False), - ], -) -def test_품_같음(excel, ours, want): - assert hs.same(excel, ours) is want - - -def test_defaults_규칙(master): - ma = hb.ma - jige = ma.defaults(master, master.logic("GC001140")) - assert jige["실작업시간"] == 0 and jige["운반무게"] == 0 # 가드 - assert jige["경사각도"] == 0 and jige["운반거리"] is None # 고르기 첫째 · 못 정함 - pour = ma.defaults(master, master.logic("GC000198")) - assert pour["소량감률"] == 0 and pour["타설방법"] == "인력운반타설" # 0 을 품은 범위 - form = ma.defaults(master, master.logic("GC000211")) - assert form["높이감률"] is None and form["구멍땜"] is None - assert 1 not in [v for v in jige.values() if not isinstance(v, str)] # 지어 넣는 1 없음 diff --git a/resources/tester/test_642_3_row.py b/resources/tester/test_642_3_row.py deleted file mode 100644 index b40e1997e..000000000 --- a/resources/tester/test_642_3_row.py +++ /dev/null @@ -1,296 +0,0 @@ -"""642-3 1차 줄 판정 · 엑셀 사전 · 유가전력 MO 지킴 시험(PLAN 785-3 · 설계 642-A 5-3 셋째 줄). - -- 북부청 임도 415 줄 1차 갈래 수 = 단산 268 · 일위 1 · 별도계상 64 · 재료(사전) 78 · 기계 3 · 율 1 - (분석 642-C 3-1) · 판정 2차 필요 268 · 같은 EST 꼴 사방 201 · 거창 68 = 모든 줄 갈래 하나(100 %) -- 엑셀 사전 = 단가표 시트 갈래 수 · 노임 19 M01 LB 하나씩 · 경유 → MO -- 장부 짝 고른 것 꼴(새) · 옛 꼴 그대로 · 틀린 키 400 · 환산 옮김(Match.factor = Row.factor) -- 유가전력 MO — 고정 줄 이음 셈(재료비 = MO 값 · 묶음 재료 → 율 밑) · 자재 목록 MO · TS 짝 거울 -엑셀은 읽기만 · 층 · storage 안 씀. -""" - -import collections -import json -import os -import subprocess -from pathlib import Path - -import pytest - -from M01_MasterData import M01_MasterData_Store as m01 -from M02_MasterTemplete import M02_Hopyo_Elem as elem -from M02_MasterTemplete import M02_Hopyo_Excel as ex -from M02_MasterTemplete import M02_Hopyo_Ledger as ledger -from M02_MasterTemplete import M02_Hopyo_Match as hm -from M02_MasterTemplete import M02_Hopyo_Row as row -from M02_MasterTemplete import M02_Labor_Doc as labor -from M02_MasterTemplete import M02_Labor_Fixed as fixed - -HERE = Path(__file__).resolve().parent -DOCS = HERE.parent / "knowledge" / "original" / "실무문서" -BUKBU = DOCS / "2026_실무일위대가 모음" / "2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls" -SABANG = DOCS / "2026_실무일위대가 모음" / "2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls" -GEOCHANG = ( - DOCS - / "01.산불거창(거창북상병곡)" - / "01.내역서" - / "00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx" -) -OIL = "MO000002" # 자동차용경유 전국평균 - - -def _read(path: Path, keys: bool) -> dict: - data = path.read_bytes() - book = ex.read_book(path.name, data) - sheets, _ = ex._open(path.name, data, row.SHEETS) - return {**book, "사전": row.lexicon(sheets, keys)} - - -def _kinds(book: dict) -> collections.Counter: - """줄마다 (갈래, 근거 첫 낱말) — 1차 ①~③ · ④ 사전.""" - index = row._index(book["사전"]) - out = collections.Counter() - for h in book["호표"]: - for line in h["줄"]: - kind, why, _ = row.first(line) - if kind is None: - hits = row.second(line, index) - kinds = sorted({x["갈래"] for x in hits}) or ["없음"] - kind, why = "/".join(kinds), "사전" - out[(kind, why.split(" ")[0])] += 1 - return out - - -@pytest.fixture(scope="module") -def bukbu() -> dict: - return _read(BUKBU, True) - - -def test_북부청_415_줄_1차_갈래(bukbu): - got = _kinds(bukbu) - assert sum(got.values()) == 415 - assert got == { - ("단가산출", "비고"): 268, - ("일위대가", "비고"): 1, - ("재료", "별도계상"): 64, - ("재료", "사전"): 78, - ("기계", "단위"): 3, - ("율", "단위"): 1, - } - - -def test_북부청_줄_판_판정과_후보(bukbu): - out = row.rows(bukbu) - lines = [x for v in out.values() for x in v] - assert len(out) == 68 and len(lines) == 415 - verdict = collections.Counter(x["판정"] for x in lines) - assert verdict["2차 필요"] == 268 and set(verdict) <= set(row.VERDICTS) - assert all(set(x["갈래"]) <= set(row.KINDS) and x["후보"] is not None for x in lines) - first = {x["행"]: x for x in out[1]} # 제1호표(설계 2-10 기대표) - assert first[6]["갈래"] == ["재료"] and first[6]["근거"] == "별도계상" - assert first[6]["판정"] == "제안 있음" and len(first[6]["후보"]) == 1 - apart = [x for x in lines if x["근거"] == "별도계상"] # 786-1 별도계상 64 줄 = 고정 줄 하나 - assert len(apart) == 64 and all( - x["갈래"] == ["재료"] and x["판정"] == "제안 있음" for x in apart - ) - for x in apart: - (p,) = x["후보"] - assert (p["갈래"], p["키"], p["별도계상"], p["품목"]) == ( - "고정", - None, - True, - "프로젝트에서", - ) - assert p["단위"] and p["단가"] == {"노무비": 0, "재료비": 0, "경비": 0} and "장부" not in x - assert all( - p["갈래"] != "고정" or "품목" not in p for x in lines for p in x["후보"] if x not in apart - ) - assert first[7]["번"] == 46 and first[7]["판정"] == "2차 필요" - assert first[10]["갈래"] == ["재료"] and first[10]["근거"].startswith("사전 자재단가") - rate = next(x for x in lines if x["갈래"] == ["율"]) - assert rate["판정"] == "제안 있음" - assert ( - rate["후보"][0]["비목"] == "재료비" and rate["후보"][0]["기준"] == "재료비" - ) # 재료비의 3 % - sub = next(x for x in lines if x["갈래"] == ["일위대가"]) - assert sub["번"] == 46 and sub["후보"][0]["키"] == "제46호표" and sub["판정"] == "제안 있음" - for x in lines: # 고른 것 꼴(설계 2-1) - for p in x["후보"]: - assert {"갈래", "키", "이름", "단위", "입력값", "출처", "몫"} <= set(p) - - -def test_엑셀_사전_단가표_시트(bukbu): - lex = bukbu["사전"] - assert lex["꼴"] == "EST" - count = collections.Counter(x["갈래"] for x in lex["줄"]) - assert count == { - "단가산출": 95, - "재료": 91, - "일위대가": 68, - "기계": 32, - "인력": 19, - "유가": 4, # 자재단가 경유 · 휘발유 + 기본재료비 둘 - "환율": 3, - } - people = [x for x in lex["줄"] if x["갈래"] == "인력"] - assert all(len(x["후보"]) == 1 and x["후보"][0]["키"].startswith("LB") for x in people) - oil = next(x for x in lex["줄"] if x["갈래"] == "유가" and x["시트"] == "자재단가") - assert oil["후보"][0]["키"] == OIL - json.dumps(lex, ensure_ascii=False) # HP 에 둘 수 있는 꼴 - - -@pytest.mark.parametrize("path, total", [(SABANG, 201), (GEOCHANG, 68)]) -def test_같은_EST_꼴_모든_줄_갈래_하나(path, total): - got = _kinds(_read(path, False)) - assert sum(got.values()) == total - assert not [k for k in got if "/" in k[0] or k[0] == "없음"] - - -# ── 장부 짝 고른 것 꼴 ───────────────────────────────────────────────────── -def test_장부_짝_고른_것_꼴과_옛_꼴(): - known = ledger._known() - mat = sorted(known[1])[0] - new = ledger.check_pair( - {"갈래": "유가", "키": OIL, "배": 1, "이름": "경유", "단위": "L"}, "시험", known - ) - assert new == {"갈래": "유가", "키": OIL, "입력값": {}, "배": 1, "몫": None, "이름": "경유", - "단위": "L"} # fmt: skip - rate = {"갈래": "율", "비목": "재료비", "기준": "재료", "배": 1} - assert ledger.check_pair(rate, "시험", known)["기준"] == "재료" - assert ledger.check_pair({"자재": mat, "배": 2}, "시험", known) == { - "자재": mat, "입력값": {}, "배": 2, "몫": None} # fmt: skip - for bad in ( - {"갈래": "유가", "키": "MT000008"}, - {"갈래": "고정", "키": "MO000002"}, - {"갈래": "인력", "키": "LB999999"}, - {"갈래": "재료", "키": mat, "입력값": {"x": 1}}, - {"갈래": "율", "비목": "재료비"}, - {"갈래": "없는갈래"}, - ): - with pytest.raises(ledger.store.StoreError) as got: - ledger.check_pair(bad, "시험", known) - assert got.value.status == 400 - line = {"이름": "경유", "규격": "", "단위": "ℓ", "수량": 3, "단가": {"재료비": 1376}} - book = [("personal", "1/2", {ledger.key(line): {"갈래": "짝", "짝": [new]}})] - out = row.judge(line, {}, {}, book, folder="x") - assert out["판정"] == "제안 있음" and out["후보"][0]["키"] == OIL - assert out["후보"][0]["출처"] == {"장부": "personal"} - # 단산 줄 = 장부 짝이 있어도 늘 2차 블록 풀이(장부 = 1차 후보 힌트만 · 785-7) - sk = { - "이름": "야면석소운반", - "규격": "", - "단위": "㎡", - "수량": 1, - "참조": {"종류": "단산", "번": 10}, - } - base = {"갈래": "기초", "키": "GC001140", "입력값": {}, "배": 1, "몫": None} - book = [("personal", "1/2", {ledger.key(sk): {"갈래": "짝", "짝": [base]}})] - out = row.judge(sk, {}, {}, book, folder="x") - assert out["판정"] == "2차 필요" and out["후보"][0]["키"] == "GC001140" - - -def test_환산_옮김(): - for have, want, spec in ( - ("㎡", "㎡", ""), - ("cm", "m", ""), - ("㎡", "㎥", "T=20cm"), - ("개", "㎥", ""), - ): - assert hm.factor(have, want, spec) == row.factor(have, want, spec) - assert row.factor("cm", "m")["배"] == 0.01 - q = row.quantity({"수량": 2.5, "단위": "㎡", "규격": "T=20cm"}, "㎥", 0.5) - assert q["배"] == 0.2 and q["우리"] == 0.25 - - -# ── 유가전력 MO ───────────────────────────────────────────────────────── -def _oil_price() -> float: - return float(m01.loaded()[1].get(OIL)["값"]) - - -def test_유가_후보(): - keys = [x["키"] for x in elem.oils("경유")] - assert keys[0] == OIL and all(k.startswith("MO") for k in keys) - assert elem.lost({"갈래": "유가", "이름": "경유"})[0]["키"] == OIL - - -OIL_DOC = { - "열": "rv", - "담은로직": [ - {"로직": "GC000421", "수량": {"손": 2}}, - { - "로직": "", - "고정": { - "이름": "경유", - "단위": "L", - "단가": {"재료비": 1376}, - "이음": {"유가": OIL}, - }, # fmt: skip - "수량": {"손": 3}, - }, - { - "로직": "", - "고정": { - "이름": "잡재료", - "단위": "%", - "단가": {}, - "비율": {"기준": "재료", "비목": "재료비"}, - }, # fmt: skip - "수량": {"손": 10}, - }, - ], - "자재": [{"코드": OIL, "수량": {"손": 2}, "별도계상": "직접계산"}], # 기본 = 별도계상(합 밖) -} - - -def test_고정_줄_유가_이음_셈(): - doc = labor.clean(OIL_DOC) - fx = doc["담은로직"][1]["고정"] - assert fx["이음"] == {"노임": None, "자재": None, "기계": None, "유가": OIL} - assert fixed.group_of(fx) == "재료" - assert doc["자재"][0]["코드"] == OIL # 자재 목록 MO - out = labor.calc(doc, {}) - line = out["줄"][1] - assert line["기준"]["재료비"] == pytest.approx(_oil_price()) and line["이은"] == ["재료비"] - assert out["자재"][0]["기준단가"] == pytest.approx(_oil_price()) - with pytest.raises(labor.store.StoreError): # 머리 틀림 = 400 - labor.clean({**OIL_DOC, "담은로직": [{**OIL_DOC["담은로직"][1], "고정": { - **OIL_DOC["담은로직"][1]["고정"], "이음": {"유가": "MT000008"}}}]}) # fmt: skip - old = labor.clean({"담은로직": [{"로직": "", "고정": {"이름": "a", "단가": {}}}]}) - assert old["담은로직"][0]["고정"]["이음"] == {"노임": None, "자재": None, "기계": None} - - -def _ts(answer: dict, doc: dict, values: dict) -> dict: - proc = subprocess.run( - ["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")], - input=json.dumps({"답": answer, "문서": doc, "값": values}, ensure_ascii=False), - capture_output=True, - text=True, - encoding="utf-8", - check=True, - ) - return json.loads(proc.stdout) - - -def _same(a, b) -> None: - if isinstance(a, dict): - assert set(a) == set(b) - for k in a: - _same(a[k], b[k]) - elif isinstance(a, list): - assert len(a) == len(b) - for x, y in zip(a, b): - _same(x, y) - elif isinstance(a, (int, float)) and not isinstance(a, bool): - assert b == pytest.approx(a, rel=1e-9) - else: - assert a == b - - -def test_유가_율_고정_줄_TS_거울(): - doc = labor.clean(OIL_DOC) - answer = labor.calc(doc, {}) - py = labor.settle(answer, doc, {}) - _same(py, _ts(answer, doc, {})) - rate = py["줄"][2] - oil = py["줄"][1]["반영"]["재료비"] - mats = py["자재"][0]["반영"] or 0 - assert rate["기준"]["재료비"] == pytest.approx(oil + mats) # 묶음 재료 = 유가 줄 + 자재 diff --git a/resources/tester/test_642_5_make.py b/resources/tester/test_642_5_make.py deleted file mode 100644 index e9d96cf21..000000000 --- a/resources/tester/test_642_5_make.py +++ /dev/null @@ -1,210 +0,0 @@ -# -*- coding: utf-8 -*- -"""642-5(PLAN 785-5) 만들기 · 길 지킴 시험(설계 642-A 5-3 넷째 줄) — 임시 storage(`client` 마당). - -- 판정 길(목록 · 호표 · 블록) · 검산 길(입력값 = 엔진이 쓴 칸 모두 · 지역 · 자재지역 · 조종원직종 비움) -- 옮기기 → SC 줄(고른 것마다 · 같은 별칭 · 본뜬 {호표, 차례, 행} · 수량 = 엑셀 × 몫) · 같은 기초 둘째 = 사본 · - 별도계상 자재 · 입력 필요 = 그 줄만 막힘(고정 줄로 남음) · SK 더한줄(바탕 없음) · 장부 · 다시 옮김 = SC 안 늚 -- 금액 · 품 칸 막음 · M01 원본 그대로 -""" - -import json - -from M02_MasterTemplete import M02_Hopyo_Bind as hb -from M02_MasterTemplete import M02_Hopyo_Build as hbuild -from M02_MasterTemplete import M02_Hopyo_Judge as judge -from M02_MasterTemplete import M02_Hopyo_Ledger as ledger -from M02_MasterTemplete import M02_Labor_Doc as labor -from M02_MasterTemplete import M02_Template_Layers as layers - -# 올리기 길 시험의 `client` 마당을 같이 씀 -# ruff: noqa: F811 -from resources.tester.test_m02_hopyo_excel import _post, client, master_hash # noqa: F401 - -API = "/api/m02/layers/personal" -KEY = "HP-000001" - - -def _mine(): - return layers.personal_dir(7, 42) - - -def _doc(kind, key): - return json.loads((_mine() / kind / f"{key}.json").read_bytes()) - - -def _at(unit, no): - """호표 줄 판에서 단산 N 을 부르는 줄.""" - return next(r for r in unit["줄"] if r.get("번") == no and "단가산출" in r["갈래"]) - - -def _base(row, sec=0): - """그 줄 풀이 절의 고른 기초 · 입력값.""" - part = row["풀이"]["절"][sec] - cand = next(c for c in part["후보"] if c["키"] == part["고름"]) - return {"갈래": "기초", "키": cand["키"], "입력값": cand["바인딩"]["입력값"]} - - -def _specs(code): - m = hb.m01.cm.master() - return {s["이름"] for s in hb.specs_of(m.logic(code))} - set(hb.SKIP) - - -def test_판정_검산_옮기기(client): - before = master_hash() - assert _post(client).status_code == 200 - got = client.get(f"{API}/hopyo-judge/{KEY}").json() - assert got["셈중"] is True and got["블록"][9]["판정"] is None # 뒤 셈 전 - assert judge.judged(_mine(), KEY) is None - judge.solve_all(_mine(), KEY) # 올림 뒤 뒤 셈(시험은 바로) - got = client.get(f"{API}/hopyo-judge/{KEY}").json() - assert got["셈중"] is False and len(got["호표"]) == 68 and len(got["블록"]) == 95 - assert got["블록"][9]["번"] == 10 and got["블록"][9]["판정"] == "기초로 풂" - assert judge.judged(_mine(), KEY)[10] == {"판정": "기초로 풂", "고를수": 0} - assert got["호표"][0]["판정"] == {"제안 있음": 2, "2차 필요": 4} - - two = client.get(f"{API}/hopyo-judge/{KEY}/2").json() - ten = _at(two, 10) # 예시 1 야면석소운반 단산10 - assert ten["이름"] and "단가" in ten and ten["풀이"]["판정"] == "기초로 풂" - ex1 = _base(ten) - assert ex1["키"] == "GC001140" and ex1["입력값"]["운반무게"] == 575 - blk = client.get(f"{API}/hopyo-judge/{KEY}/block/10").json() - assert blk["부름"] and "몫" in blk["절"][0] and "금액" in blk - - check = client.post( - f"{API}/hopyo/{KEY}/check", json={"호표": 2, "차례": ten["차례"], "고른": [ex1]} - ).json() - one = check["고른"][0] - assert one["판정"] == "품 같음" and one["입력필요"] == [] - assert set(one["입력값"]) == _specs("GC001140") - assert not set(one["입력값"]) & set(hb.SKIP) # 지역 · 자재지역 · 조종원직종 비움 - assert int(one["금액"]["계"]) == 94227 # 엑셀 94,227 (원 미만 버림) - assert check["수량"]["우리"] == ten["수량"]["엑셀"] - - first = client.get(f"{API}/hopyo-judge/{KEY}/1").json() - rebar, form = _at(first, 47), _at(first, 24) # 예시 2 철근 · 예시 3 거푸집 - a, b = _base(rebar, 0), _base(rebar, 1) - b["입력값"] = {**b["입력값"], "구분": "토목"} # 엇갈림 = 사용자 고름 - a["몫"] = 0.5 - mat = first["줄"][0] # 레미콘 별도계상 — 786-1 후보 = 고정 줄 하나(품목은 프로젝트에서) - assert mat["판정"] == "제안 있음" and len(mat["후보"]) == 1 - cement = mat["후보"][0] - assert (cement["갈래"], cement["키"], cement["별도계상"]) == ("고정", None, True) - assert cement["품목"] == "프로젝트에서" - stuck = {"갈래": "기초", "키": "GC000211", "입력값": {"유형": "보통"}} - ex3 = _base(form) # 예시 3 GC000211 보통 · 4회 · 높이감률 · 구멍땜 = 입력 필요 - url = f"{API}/hopyo/{KEY}/check" - need = client.post(url, json={"호표": 1, "차례": form["차례"], "고른": [ex3]}).json() - assert ( - need["고른"][0]["입력필요"] == ["높이감률", "구멍땜"] - and need["고른"][0]["금액"]["계"] is None - ) - ex3["입력값"] |= {"높이감률": 0, "구멍땜": 0} - got3 = client.post(url, json={"호표": 1, "차례": form["차례"], "고른": [ex3]}).json()["고른"][0] - assert ( - got3["판정"] == "품 같음" and abs(got3["금액"]["노무비"] - 38291) <= 1 - ) # 엑셀 노무 38,291 - other = next(r for r in two["줄"] if r["차례"] > ten["차례"]) # 같은 기초 둘째 줄 - twice = {**ex1, "입력값": {**ex1["입력값"], "운반거리": 12}} - body = { - "층": "personal", - "호표": { - "1": { - str(rebar["차례"]): [a, b], - str(form["차례"]): [stuck], - "0": [cement], - }, - "2": {str(ten["차례"]): [ex1], str(other["차례"]): [twice]}, - }, - "블록": {"10": [{**ex1, "몫": 1}]}, - } - res = client.post(f"{API}/hopyo/{KEY}/move", json=body) - assert res.status_code == 200, res.text - out = res.json() - assert [x["번"] for x in out["만듦"]] == [1, 2] and out["장부"]["씀"] >= 4 - assert out["막힘"] == [ - { - "호표": 1, - "차례": form["차례"], - "까닭": out["막힘"][0]["까닭"], - "칸": out["막힘"][0]["칸"], - } - ] - assert out["막힘"][0]["까닭"].startswith("입력 필요") and out["막힘"][0]["칸"] - - sc2 = _doc("precombo", out["만듦"][1]["키"]) - lines = sc2["담은로직"] - mine = next(x for x in lines if x.get("로직") == "GC001140") - assert mine["별칭"] == ten["이름"][: len(mine["별칭"])] - assert mine["본뜬"] == {"호표": 2, "차례": ten["차례"], "행": ten["행"]} - assert mine["수량"]["손"] == ten["수량"]["엑셀"] - assert set(mine["입력값"]) == _specs("GC001140") - wrap = next(x for x in lines if x.get("별칭") == other["이름"][: len(x.get("별칭") or "")]) - inner = wrap["참조"]["몸"]["담은로직"][ - 0 - ] # 같은 기초 둘째 = 한 줄 SC 사본(본뜬 = 사본 출처 740-1) - assert inner["로직"] == "GC001140" and inner["입력값"]["운반거리"] == 12 - assert len(lines) == len(two["줄"]) # 확인 안 한 줄 = 고정(빠짐 0) - - sc1 = _doc("precombo", out["만듦"][0]["키"]) - pair = [x for x in sc1["담은로직"] if (x.get("본뜬") or {}).get("차례") == rebar["차례"]] - assert [x["로직"] for x in pair] == ["GC000206", "GC000207"] - assert pair[0]["별칭"] == pair[1]["별칭"] # 한 엑셀 줄 = 우리 두 줄 · 같은 별칭 - excel = rebar["수량"]["엑셀"] - assert pair[0]["수량"]["손"] == excel * 0.5 and pair[1]["수량"]["손"] == excel - assert pair[1]["입력값"]["구분"] == "토목" - held = next( - x - for x in sc1["담은로직"] - if (x.get("고정") or {}).get("출처", {}).get("줄") == form["차례"] - ) - assert "본뜬" not in held # 막힌 줄 = 고정 줄(엑셀 숫자) - held_apart = next( - x for x in sc1["담은로직"] if (x.get("본뜬") or {}).get("차례") == mat["차례"] - ) - fx = held_apart["고정"] # 별도계상 줄 = 합 밖 고정 줄(단가 0 · 자재 목록에 안 넣음) - assert fx["이름"] == mat["이름"] and fx["규격"] == "별도계상" and not any(fx["단가"].values()) - assert held_apart["수량"]["손"] == mat["수량"]["엑셀"] - assert held_apart["메모"].startswith("별도계상 · 품목은 프로젝트에서") - without = {**sc1, "담은로직": [x for x in sc1["담은로직"] if x is not held_apart]} - got_sum, base_sum = labor.server(sc1)["계산값"], labor.server(without)["계산값"] - assert got_sum["반영합"] == base_sum["반영합"] # 서버 정본 셈 — 별도계상 줄은 합에 안 듦 - - sk = _doc("sangeun", out["단가산출"][0]["키"]) - assert sk["바탕"] is None and [x["로직"] for x in sk["더한줄"]] == ["GC001140"] - assert sk["더한줄"][0]["입력값"]["운반무게"] == 575 - - book = ledger.read(_mine()) - assert any( - p.get("갈래") == "기초" and p.get("키") == "GC000207" - for row in book.values() - for p in row.get("짝") or [] - ) - scs = sorted(p.stem for p in (_mine() / "precombo").glob("SC-*.json")) - stop = client.post(f"{API}/hopyo/{KEY}/move", json=body) # 이미 옮긴 것 = 덮음 없이 409 - assert stop.status_code == 409 and [x["번"] for x in stop.json()["detail"]["호표"]] == [1, 2] - assert sorted(p.stem for p in (_mine() / "precombo").glob("SC-*.json")) == scs - again = client.post(f"{API}/hopyo/{KEY}/move", json={**body, "덮음": True}) - assert again.status_code == 200, again.text - again = again.json() - assert [x["키"] for x in again["만듦"]] == [x["키"] for x in out["만듦"]] - assert sorted(p.stem for p in (_mine() / "precombo").glob("SC-*.json")) == scs - assert hbuild.confirmed(_mine())[hbuild.mark(KEY, 2)] == out["만듦"][1]["키"] - assert client.delete(f"{API}/hopyo/{KEY}").status_code == 200 # [끝내기] = 저장본도 지움 - assert not list((_mine() / "실무자료" / "_derived").glob(f"*{KEY}*")) - assert master_hash() == before - - -def test_막는_길(client): - assert _post(client).status_code == 200 - pick = {"갈래": "기초", "키": "GC001140", "입력값": {}, "금액": 1} - bad = client.post( - f"{API}/hopyo/{KEY}/move", json={"층": "personal", "호표": {"2": {"0": [pick]}}} - ) - assert bad.status_code == 422 # 금액 · 품 칸 = 서버가 셈 - none = client.post(f"{API}/hopyo/{KEY}/move", json={"층": "personal", "호표": {"99": {}}}) - assert none.status_code == 404 - wrong = {"갈래": "인력", "키": "LB999999"} - res = client.post( - f"{API}/hopyo/{KEY}/move", json={"층": "personal", "호표": {"2": {"0": [wrong]}}} - ) - assert res.status_code == 400 diff --git a/resources/tester/test_642_6_screen.py b/resources/tester/test_642_6_screen.py deleted file mode 100644 index fe5775e0f..000000000 --- a/resources/tester/test_642_6_screen.py +++ /dev/null @@ -1,351 +0,0 @@ -# -*- coding: utf-8 -*- -"""642-6(PLAN 785-6) 판정 화면 지킴 — 줄 판 · 블록 판 · 고름 캐시(설계 642-A 5-3 다섯째 줄). - -- 부품 상자만(날 `