From fd76c5b76e0bcee442008762268aa09d0328044f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 18:15:30 +0900 Subject: [PATCH 1/6] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=209=20=EC=9B=90=EB=AC=B8=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=E2=80=94=20=ED=98=84=ED=96=89=20=EB=B9=84=EB=AA=A9=20=EB=AC=BC?= =?UTF-8?q?=EC=9D=8C=20=C2=B7=20=EC=A0=80=EC=9E=A5=20=EC=86=8D=EB=8F=84=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EC=96=B4=EC=84=9C=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_내역서/사용자요구_내역서.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/raw/요구_내역서/사용자요구_내역서.md b/docs/raw/요구_내역서/사용자요구_내역서.md index 5bf99922f..f2415a372 100644 --- a/docs/raw/요구_내역서/사용자요구_내역서.md +++ b/docs/raw/요구_내역서/사용자요구_내역서.md @@ -44,3 +44,9 @@ 2. 보여주는 꼴은 익숙한게 좋을수 있음. 3. 전체 신규계획과 요구서를 작성하여 진행. 4. 조심해야될 부분은 우리 프로그램은 후발주자로서 다른 경쟁사의 프로그램 결과물을 불러오기가 되어야할것같음. 일단 현재도 일부는 되어 있지만 불편함. 매칭이 쉽지않음. + +## 내역 9 (10-31 · 사용자 · 통합 점검 아티팩트 보고 뒤) + +1. 원가계산서의 현행 비목의 요율은 다른 프로그램에서도 반영한거야? +2. 저장속도를 개선되기만 하면 좋지. +3. 브레인의 종료는 내가 정함. 이어서 진행 From 3c2c82c78e661feced15bcc7503ae6607f5b0141 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 18:19:09 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(M02=20=EA=B8=B0=EC=A4=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=C2=B7=EA=B3=84=EC=82=B0=EC=84=9C):=20=EB=81=9D?= =?UTF-8?q?=EC=88=98=20=3D=20B09=20=EC=82=B0=EA=B7=BC=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?(=EC=A4=84=EB=A7=88=EB=8B=A4=20=EB=B2=84=EB=A6=BC=20=C2=B7=20?= =?UTF-8?q?=EB=B9=84=EB=AA=A9=EB=A7=88=EB=8B=A4=20=EB=B2=84=EB=A6=BC)?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=A7=9E=EC=B6=A4=20=E2=80=94=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=84=9C=205,654=20=EB=8C=80=20B09=205,651=20?= =?UTF-8?q?=EC=96=B4=EA=B8=8B=EB=82=A8=20(PLAN=20704-2=20=EB=94=B8?= =?UTF-8?q?=EB=A6=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 `M02_Logic_Settle` — 엔진 구조 그대로 줄 ROUNDDOWN 1 · 비목 ROUNDDOWN 0 · 작업량 Q ROUND 2 · 하위 로직 같은 규칙 - 기준 목록 금액 · 계산서 줄 · 소계 · 계가 같은 값 · 목록 저장본 꼴 번호 올림 · 머리 글에 지식DB 단수처리 근거 - 시험: 거울 test_704_settle(B09 _money · block_totals) · 옛 목록 시험은 끝수 차 3 원 안으로 고침 Co-Authored-By: Claude Sonnet 5.5 --- M02_MasterTemplete/M02_Logic_Settle.py | 115 ++++++++++++++++++++ M02_MasterTemplete/M02_Logic_Standard.py | 17 ++- resources/tester/test_704_logic_standard.py | 5 +- resources/tester/test_704_settle.py | 75 +++++++++++++ 4 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 M02_MasterTemplete/M02_Logic_Settle.py create mode 100644 resources/tester/test_704_settle.py diff --git a/M02_MasterTemplete/M02_Logic_Settle.py b/M02_MasterTemplete/M02_Logic_Settle.py new file mode 100644 index 000000000..8900471e7 --- /dev/null +++ b/M02_MasterTemplete/M02_Logic_Settle.py @@ -0,0 +1,115 @@ +"""M02 기준 목록 · 계산서 끝수(PLAN 704-2 딸림) — B09 산근 끝수 규칙으로 셈(③ 서버 단독 · +`B09_Estimation_Sangeun` 과 규칙 짝 · 거울 시험 `test_704_settle`). + +줄 금액 = ROUNDDOWN(단가 × 수량, 1) 비목마다 · 비목 총계 = ROUNDDOWN(Σ 줄, 0) · 계 = 셋 합 · +작업량 Q(중간 이름 Q · Q1 …) = ROUND(식, 2) 뒤 쓰임 · % 덧줄 밑수 = 그때까지 줄 합(줄 끝수 뒤). +근거 = 지식DB `technical_info/01_임도/05_원가정보/단수처리_규칙.md`(행 금액 절사 · 관측 후보 규칙). +M01 엔진(`master_formula.run`)의 구조 그대로 — 끝수 자리만 다름(엔진은 줄 끝수 없이 계에서 한 번). +하위 로직(기계경비 포함)은 같은 규칙으로 다시 셈 · 흐름 · 결과 로직은 엔진 값 그대로. +""" + +from __future__ import annotations + +import re +from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 + +mf = m01.cm.mf +_Q = re.compile(r"Q\d*") + + +def _down(value: Any, places: int) -> Any: + return None if value is None else mf._round(Decimal(value), places, ROUND_DOWN) + + +def _bundle(whole: Any, item: dict, env: dict, depth: int) -> dict: + """로직 호표 줄이 부르는 하위 로직 — 같은 규칙으로 다시 셈(엔진 `logic` 마디 그대로).""" + node = mf.parse(item["요소"]) + if node[0] != "logic": + return mf.evaluate(node, env, whole, depth) + given = {k: mf.evaluate(v, env, whole, depth) for k, v in node[2]} + got = settle(whole, mf.fill(node[1], env), given, depth + 1) + return got["결과"] if "결과" in got else {k: got[k] for k in mf.BUNDLE} + + +def settle(whole: Any, ref: str, given: dict, depth: int = 0) -> dict: + """`master_formula.run` 과 같은 꼴 `{줄, 노무비, 재료비, 경비, 계, 중간}` — 끝수만 B09 규칙.""" + row = whole.logic(ref) + if isinstance(row.get("흐름"), dict) or "결과" in row: + return mf.run(whole, ref, given, depth) + env = mf._inputs(row, given) + for step in row.get("중간", []): + try: + value = mf.evaluate(mf.parse(step["식"]), env, whole, depth) + except mf.EmptyInput as e: + env[step["이름"]] = mf.Blank(str(e)) + continue + if _Q.fullmatch(step["이름"]) and isinstance(value, Decimal): + value = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + env[step["이름"]] = value + lines: list[dict] = [] + for n, item in enumerate(row.get("호표", [])): + try: + qty = mf.evaluate(mf.parse(item["수량"]), env, whole, depth) + if item["종류"] == "로직": + bundle = _bundle(whole, item, env, depth) + price = bundle["계"] + split = ( + {k: qty * bundle[k] for k in mf.COST_ITEMS} + if "비목" not in item + else {item["비목"]: qty * price} + ) + else: + ref_ = ( + { + k: mf.fill(v, env) if isinstance(v, str) else v + for k, v in item["요소"].items() + } + if isinstance(item["요소"], dict) + else mf.fill(item["요소"], env) + ) + if isinstance(ref_, str) and qty != 0: + whole.get(ref_) + price = mf.mp.priced(whole, item, ref_, env, n if depth == 0 else None)[0] + if price is None and qty == 0: + price = Decimal(0) + split = {item["비목"]: None if price is None else qty * price} + except mf.EmptyInput as e: + lines.append(mf._blank_line(item, str(e))) + continue + lines.append(_line(item["이름"], split)) + sums = {k: sum((x["비목"].get(k) or 0 for x in lines), Decimal(0)) for k in mf.COST_ITEMS} + env["줄"] = {x["이름"]: x["금액"] for x in lines if x["이름"] and x["금액"] is not None} + for extra in row.get("덧줄", []): + env.update(sums) + try: + value = mf.evaluate(mf.parse(extra["식"]), env, whole, depth) + env[extra["이름"]] = value + except mf.FormulaError: + value = None + sums[extra["비목"]] += _down(value, 1) or 0 + lines.append(_line(extra["이름"], {extra["비목"]: value})) + if value is not None: + env["줄"][extra["이름"]] = lines[-1]["금액"] + sums = {k: mf.cut(row, _down(v, 0), "계") for k, v in sums.items()} + return {"줄": lines, **sums, "계": sum(sums.values()), "중간": mf._shown(env)} + + +def _line(name: str, split: dict) -> dict: + cut = {k: _down(v, 1) for k, v in split.items()} + whole_sum = None if all(v is None for v in cut.values()) else sum(v or 0 for v in cut.values()) + return {"이름": name, "비목": cut, "금액": whole_sum} + + +def apply(groups: list[dict], engine: dict, done: dict) -> None: + """계산서 묶음의 줄 금액 · 소계를 `settle` 값으로 바꿈 — 줄 차례는 엔진과 같음.""" + seq: dict[str, list] = {k: [] for k in mf.COST_ITEMS} + for eng, mine in zip(engine["줄"], done["줄"]): + for part, _ in m01.mtx._shares(eng): + seq[part].append(mine["비목"].get(part)) + for group in groups: + for line, amount in zip(group["줄"], seq.get(group["비목"], [])): + line["금액"] = amount + group["소계"] = done[group["비목"]] diff --git a/M02_MasterTemplete/M02_Logic_Standard.py b/M02_MasterTemplete/M02_Logic_Standard.py index 71eaac2cb..bcfd69de3 100644 --- a/M02_MasterTemplete/M02_Logic_Standard.py +++ b/M02_MasterTemplete/M02_Logic_Standard.py @@ -5,12 +5,13 @@ 위 끝) · 그 밖 연속 값 입력은 견본 자동값(`master_auto.values`) 한 줄 · 지역 = 자동값(전국평균). 펼침 = 바탕(자동값 · 규격 축은 첫 대표 값) 한 줄 + 규격 축마다 값 하나씩 바꾼 줄(곱하지 않음 · 바꾼 줄이 0 원이면 표에 없는 조합으로 보고 뺌). -금액 = M01 엔진(`master_formula.run`) 「계」 — M01 시험 계산과 같은 셈 · 최신 판 노임(마스터 판). +금액 = M01 엔진 식 + B09 산근 끝수(줄마다 버림 · `M02_Logic_Settle` · 근거 지식DB +`technical_info/01_임도/05_원가정보/단수처리_규칙.md`) · 최신 판 노임(마스터 판). 목록은 서버가 뒤에서 한 번 셈(프로세스 `WORKERS` 개 · 수 분) → 프로세스 메모리 + 파생 저장본 (storage `_derived/m02_logic_standard/`) · 열쇠 = 마스터 내용 지문 + `SHAPE` · 같으면 안 셈 · 셈 도중 목록 = 빈 줄 `준비: false`(화면은 로직 줄 그대로 · 금액 빈칸). 실무 낱말(`WORDS`) = 별칭 칸에 덧붙여 찾기(공용 `matchLogic`)가 「석축」 → 돌쌓기를 찾음. -계산서 = `master_text.lines`(줄 「석공 | 268,908 * 0.1 = 26,891」 · 비목 소계 · 계 = 엔진 값). +계산서 = `master_text.lines`(줄 「석공 | 268,908 * 0.1 = 26,891」 · 비목 소계 · 계) + 같은 끝수 값. M01 은 읽기만 · 권한은 부르는 쪽 몫(로그인). """ @@ -27,9 +28,10 @@ from typing import Any from common_util.common_util_derived_store import derived_key, read_derived, write_derived from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs +from M02_MasterTemplete import M02_Logic_Settle as settle from M02_MasterTemplete import M02_Template_Layers as layers -SHAPE = 2 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 +SHAPE = 3 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 KIND = "m02_logic_standard" REGION = "지역" # 지역 = 자동값 한 벌(정함 8) QUIET = ("해당 없음", "없음") # 규격 글에서 뺄 값 @@ -168,7 +170,7 @@ def _money(whole: Any, logic: dict, given: dict) -> Decimal | None: if not str(logic.get("결과단위") or "").startswith("원"): return None try: - got = m01.cm.mf.run(whole, str(logic["키"]), m01.cm.mf.fill_empty(logic, given)) + got = settle.settle(whole, str(logic["키"]), m01.cm.mf.fill_empty(logic, given)) except (m01.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): return None return got.get("계", got.get("결과")) @@ -325,7 +327,12 @@ def sheet(key: str, change: dict) -> dict: except m01.cm.mf.FormulaError as e: raise m01.StoreError(404, str(e)) from e given, ax = _given(whole, logic, change) - got = m01.mtx.lines(files, key, m01.cm.mf.fill_empty(logic, given)) + filled = m01.cm.mf.fill_empty(logic, given) + got = m01.mtx.lines(files, key, filled) + done = settle.settle(whole, key, filled) + if "결과" not in done: # 돈 로직 — 줄마다 버림(B09 산근 규칙) + settle.apply(got.get("groups") or [], m01.cm.mf.run(whole, key, filled), done) + got["계"] = done["계"] groups = [ { "비목": g["비목"], diff --git a/resources/tester/test_704_logic_standard.py b/resources/tester/test_704_logic_standard.py index 2cd394958..97a5f2191 100644 --- a/resources/tester/test_704_logic_standard.py +++ b/resources/tester/test_704_logic_standard.py @@ -34,13 +34,14 @@ def _given(key: str, change: dict) -> dict: @pytest.mark.parametrize("key", THREE) -def test_줄_금액은_M01_시험_계산(key: str) -> None: +def test_줄_금액은_M01_시험_계산에_끝수만_버림(key: str) -> None: rows = _rows(key) assert rows for row in rows: got = m01.calc(key, _given(key, row["바꿈"])) assert got["ok"], got - assert row["금액"] == pytest.approx(float(got["sums"]["계"]), abs=0.01), row["규격"] + gap = float(got["sums"]["계"]) - row["금액"] # 줄 · 비목 버림(B09 산근 규칙) — 최대 3 원 + assert -0.01 <= gap < 3.2, row["규격"] def test_대표_규격_펼침() -> None: diff --git a/resources/tester/test_704_settle.py b/resources/tester/test_704_settle.py new file mode 100644 index 000000000..7e85217bd --- /dev/null +++ b/resources/tester/test_704_settle.py @@ -0,0 +1,75 @@ +"""704-2 딸림 — 기준 목록 · 계산서 끝수 = B09 산근 규칙(줄마다 ROUNDDOWN 1 · 비목마다 ROUNDDOWN 0). + +거울: 엔진 줄(수량 · 단가)에 B09 `_money` · `block_totals` 를 그대로 씌운 값 = `settle` 값. +""" + +import re +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402 +from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402 +from M02_MasterTemplete import M02_Logic_Settle as st # noqa: E402 +from M02_MasterTemplete import M02_Logic_Standard as ls # noqa: E402 + +mf = m01.cm.mf +PART_OF = {"노무비": "labor", "재료비": "material", "경비": "expense"} + + +def _plain(row: dict) -> bool: + """로직 줄 · 덧줄 · 작업량 Q 가 없는 돈 로직 — 엔진 줄만으로 B09 값을 낼 수 있음.""" + return ( + str(row.get("결과단위") or "").startswith("원") + and not row.get("덧줄") + and not row.get("끝수") + and not any(i.get("종류") == "로직" for i in row.get("호표") or []) + and not any(re.fullmatch(r"Q\d*", s["이름"]) for s in row.get("중간") or []) + ) + + +def test_줄마다_버림_비목마다_버림_B09와_같음(): + files, whole = m01.loaded() + checked = 0 + for _, data in m01._logic_files(files): + for row in data.get("줄") or []: + if not _plain(row) or checked >= 60: + continue + base, _ = ls.axes(whole, row) + given = mf.fill_empty(row, base) + got = mf.run(whole, row["키"], given) + lines = [] + for line in got["줄"]: + one = dict.fromkeys(PART_OF.values(), 0.0) + for part, money in line["비목"].items(): + one[PART_OF[part]] = sg._money(1.0, float(money)) if money is not None else None + lines.append(one) + if any(v is None for one in lines for v in one.values()): + continue + want = sg.block_totals(lines) + mine = st.settle(whole, row["키"], given) + for name, part in PART_OF.items(): + assert float(mine[name]) == want[part], (row["키"], name) + assert float(mine["계"]) == want["sum"], row["키"] + checked += 1 + assert checked >= 30 + + +def test_계산서_줄_소계_계가_맞물림(): + files, whole = m01.loaded() + key = "GF000089" + got = ls.sheet(key, {}) + assert got["묶음"], key + for group in got["묶음"]: + lines = [Decimal(str(x["금액"] or 0)) for x in group["줄"]] + assert all(v == v.quantize(Decimal("0.1")) for v in lines), group["비목"] + assert group["소계"] == int(group["소계"]) + assert group["소계"] <= sum(lines) < group["소계"] + 1 + Decimal("0.05"), group["비목"] + assert got["계"] == sum(g["소계"] for g in got["묶음"]) + assert got["계"] == ls._money( + whole, whole.logic(key), ls._given(whole, whole.logic(key), {})[0] + ) From 8c65a323ccc86a717cf0b6b0c032b73a556f9156 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 18:30:51 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix(=EB=B2=88=ED=98=B8=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?):=20=EB=B3=B5=EC=82=AC=20=EC=B0=BD=20=EA=B7=9C=EA=B2=A9=20?= =?UTF-8?q?=C2=B7=20=EC=9E=AC=EB=A3=8C=20=EC=B0=BD=EA=B3=A0=20=EA=B7=9C?= =?UTF-8?q?=EA=B2=A9=20=C2=B7=20B09=20=EC=A4=84=20=EB=B9=84=EA=B3=A0=20?= =?UTF-8?q?=C2=B7=20M02=20=EA=B2=A9=EC=9E=90=20=EB=A8=B8=EB=A6=AC=20=C2=B7?= =?UTF-8?q?=20=EB=B9=84=EA=B3=A0=20=3D=20=EC=82=AC=EB=9E=8C=20=EB=A7=90(?= =?UTF-8?q?=EC=B8=B5=20=EB=82=B1=EB=A7=90=20=C2=B7=20=EC=9D=B4=EB=A6=84)?= =?UTF-8?q?=20=C2=B7=20=EB=B2=88=ED=98=B8=EB=8A=94=20=ED=92=8D=EC=84=A0?= =?UTF-8?q?=EB=A7=8C=20(PLAN=20704=20=EB=94=B8=EB=A6=BC=20=E2=91=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사전조합 대가 블록 규격 = 「내 자료집 · 회사 · 마스터」 · 키는 spec_raw(풍선) · 복사해 새 일위대가 창 규격은 빈 칸 - B09 설계내역서 구조물 줄 비고 = 「층 낱말 · 조합 이름」(옛 「personal|1/3|SP3-000015」) - M02 일위대가 격자 머리 = 번호꼴 일위대가번이면 이름 · 「일위대가」 · 참조 줄 비고 = 층 낱말 · 참조 창 제목 · 이미 담음 알림에서 키 뺌 - 내 자료집 저장 알림 · 같은 이름 물음에서 키 뺌 - 시험: test_704_3 격자 · test_701_1 비고 고침 Co-Authored-By: Claude Sonnet 5.5 --- B09_Estimation/B09_Estimation_Bill_Items.py | 18 ++++++++++++++++- B09_Estimation/B09_Estimation_Labor_Source.py | 4 +++- .../B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts | 6 +++--- .../M02_MasterTemplete_Labor_Detail.ts | 6 +++--- .../M02_MasterTemplete_Labor_Grid.ts | 13 ++++++++++-- .../M02_MasterTemplete_Labor_RefEdit.ts | 2 +- .../M02_MasterTemplete_Labor_Text.ts | 2 ++ .../tester/test_701_1_b08_wall_summary.py | 2 +- resources/tester/test_704_3_hopyo_grid.py | 20 +++++++++++++++++-- 9 files changed, 59 insertions(+), 14 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Bill_Items.py b/B09_Estimation/B09_Estimation_Bill_Items.py index f9b4fa725..3bf6feba6 100644 --- a/B09_Estimation/B09_Estimation_Bill_Items.py +++ b/B09_Estimation/B09_Estimation_Bill_Items.py @@ -16,6 +16,7 @@ from typing import Any from B08_Quantity.B08_Quantity_Haul import bill_key, haul_groups from B08_Quantity.B08_Quantity_Material_Calc import _structure_row +from M02_MasterTemplete.M02_Item_Source import item_name WHY_EARTH = "조합 밖 토공 산근 — 연결 없음" WHY_READY = "준비공 대가 — 연결 없음" @@ -47,6 +48,20 @@ EARTH: list[tuple[str, str, str, tuple[str, str, str | None] | None]] = [ #: 구조물 집계표 열 id 머리 → 공종(그 밖 = 2.2 배수공) · 커플링 = 자재라 뺌 STRUCTURE_SECTION = {"pipe:revet|": "2.1", "pipe:chute|": "2.3", "pipe:wall|": "2.3"} SKIP_COLUMNS = ("pipe:cp|",) +#: 층 이름 — 화면 낱말(번호 · 영문 층 이름은 화면에 안 냄) +LAYER_WORD = {"system": "마스터", "company": "회사", "personal": "내 자료집", "project": "이 공사"} + + +def combo_text(combo: str, names: dict[str, str]) -> str: + """조합 id 「{층}|{주인}|{키}」 → 「내 자료집 · 조합 이름」(이름을 못 읽으면 층 낱말만).""" + layer, _, rest = combo.partition("|") + owner, _, key = rest.partition("|") + if combo not in names: + try: + names[combo] = item_name({"layer": layer, "owner": owner or None, "key": key}) + except (LookupError, KeyError, ValueError, OSError): + names[combo] = "" + return " · ".join(t for t in (LAYER_WORD.get(layer, ""), names[combo]) if t) def _same(row: dict[str, Any], want: tuple[str, str, str | None]) -> bool: @@ -80,13 +95,14 @@ def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]: columns = (summary or {}).get("columns") or [] total = (summary or {}).get("total") or {} out = [] + names: dict[str, str] = {} for column in columns: cid = str(column.get("id") or "") if cid.startswith(SKIP_COLUMNS): continue section = next((s for head, s in STRUCTURE_SECTION.items() if cid.startswith(head)), "2.2") name, spec, unit = _structure_row(column) - combos = [c for c in column.get("combos") or [] if c] + combos = [combo_text(c, names) for c in column.get("combos") or [] if c] out.append( { "key": f"st:{cid}", diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index ba3d269db..7520f426d 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -33,6 +33,7 @@ from typing import Any from B09_Estimation import B09_Estimation_Bill_Library as library from B09_Estimation import B09_Estimation_Sangeun as sg from B09_Estimation import B09_Estimation_Sangeun_Text as st +from B09_Estimation.B09_Estimation_Bill_Items import LAYER_WORD from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Fixed as fixed @@ -266,7 +267,8 @@ class Builder: { "kind": "사전조합", "name": doc.get("이름") or ref.get("키"), - "spec": str(ref.get("키") or ""), + "spec": LAYER_WORD.get(str(ref.get("층")), ""), # 번호(키)는 풍선 글로만 + "spec_raw": str(ref.get("키") or ""), "unit": doc.get("단위") or "", "cite": "", "origin": {"참조": refs.ref_id(ref)}, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts index 95b7fef78..c633edc0c 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Fold_Menu.ts @@ -38,7 +38,7 @@ async function copyBlock(ctx: TabContext, raw: DataRow[], row: Row, block: Row): note: `「${block.name}」 을 이 공사 안 새 호표로 — 고친 칸까지 복사`, fields: [ { key: "name", label: "이름", value: `${block.name} 복사`, required: true }, - { key: "spec", label: "규격", value: block.spec || "" }, + { key: "spec", label: "규격", value: block.kind === "사전조합" ? "" : block.spec || "" }, ], okLabel: "만들기", closeLabel: "닫기", @@ -106,7 +106,7 @@ async function savePersonal(ctx: TabContext, block: Row): Promise { const tail = left ? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김` : ""; - showToast(`내 자료집에 저장 「${got.이름}」(${got.키})${tail}`, "success", 6000); + showToast(`내 자료집에 저장 「${got.이름}」${tail}`, "success", 6000); return; } if (status !== 409 || !same) { @@ -116,7 +116,7 @@ async function savePersonal(ctx: TabContext, block: Row): Promise { } const over = await askConfirm({ title: "같은 이름", - message: `내 자료집에 「${name.trim()}」(${same}) 이 있음 — 덮어쓰기 · 새 이름`, + message: `내 자료집에 「${name.trim()}」 이 있음 — 덮어쓰기 · 새 이름`, okLabel: "덮어쓰기", cancelLabel: "새 이름", }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 280c3b2d6..c7286f964 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -455,7 +455,7 @@ export function mountLaborDetail( if (!ref) return; void openRefEdit({ ref, - title: `${ref.키} ${lineOf(lineId({ 참조: ref }) ?? "")?.이름 ?? ""}`.trim(), + title: lineOf(lineId({ 참조: ref }) ?? "")?.이름 ?? "", readOnly: !edit || ref.층 !== opt.layer, closed: () => alive && recalc(), }); @@ -466,7 +466,7 @@ export function mountLaborDetail( const row = cur.담은로직[i]; const line = lineOf(lineId(row) ?? ""); openQtyFor( - `${row.참조?.키 ?? row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(), + `${row.참조 ? "" : row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(), row, line, line?.분모 ?? "", @@ -491,7 +491,7 @@ export function mountLaborDetail( pick: (ref: LaborRef, name: string, 별칭: string | null = null) => { const id = lineId({ 참조: ref }); if (cur.담은로직.some((r) => lineId(r) === id)) - return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error"); + return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error"); const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6) cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량 }); changed(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts index b4f6bd348..8c05e65c5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts @@ -34,6 +34,13 @@ const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney => } : {}; +/** 층 낱말 — 번호(키)는 화면 글에 안 냄 */ +const layerWord = (layer: string): string => + layer === "personal" ? tc("Grid_Mine") : tc(layer === "company" ? "Lib_Company" : "Lib_System"); + +/** 일위대가번 — 번호꼴(SU-000011 · SU01010101)은 사람 글이 아니라 뺌 */ +const humanNo = (no?: string): string => (no && !/^S[A-Z]\d?-?\d{6,8}$/.test(no) ? no : ""); + /** 문서 + calc 답 → 호표 하나 — calc 없으면(계산 중) 금액 빈 칸 · 빈 줄 0 */ export function laborBlock( doc: LaborDoc, @@ -68,7 +75,9 @@ export function laborBlock( unit: line?.분모 ?? (row.참조 ? "" : (logic?.결과단위 ?? "")), prices: money(line?.기준, line?.배 || 1), amounts: money(line?.반영), - note: line?.까닭 ?? (calc && qty === null ? tc("Grid_NoQty") : (row.참조?.키 ?? row.로직)), + note: + line?.까닭 ?? + (calc && qty === null ? tc("Grid_NoQty") : row.참조 ? layerWord(row.참조.층) : row.로직), tag: HOPYO_TAG.pum, }; }); @@ -96,7 +105,7 @@ export function laborBlock( const lines = [...logicLines, ...matLines]; const block: HopyoBlock = { key: doc.열, - name: doc.일위대가번 || doc.열, + name: humanNo(doc.일위대가번) || (doc as { 이름?: string }).이름 || tc("Grid_Head"), unit: doc.단위 ?? "", total: money(calc?.반영합), lines, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts index bc08770ce..75916b241 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts @@ -15,7 +15,7 @@ import { tc } from "./M02_MasterTemplete_Labor_Text"; export interface RefEditOptions { ref: LaborRef; - /** 「SC-000001 돌붙임」 */ + /** 「돌붙임」 */ title: string; readOnly: boolean; closed: () => void; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 0d8541919..d64dbdfc9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -179,6 +179,8 @@ const TEXT = { Grid_Apart: ["별도계상 · 합계 밖", "Separate · not in total"], Grid_Linked: ["이 공사 단가에 이음", "Linked to project price"], Grid_Hand: ["손 단가(붙인 값)", "Hand price (pasted)"], + Grid_Head: ["일위대가", "Unit price"], + Grid_Mine: ["내 자료집", "My library"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/resources/tester/test_701_1_b08_wall_summary.py b/resources/tester/test_701_1_b08_wall_summary.py index c8723be4b..f651ca569 100644 --- a/resources/tester/test_701_1_b08_wall_summary.py +++ b/resources/tester/test_701_1_b08_wall_summary.py @@ -125,4 +125,4 @@ def test_B09_금액_길이_곱하기_대가(out): inputs = {"bill": {"items": structure_items(out)}} sheets = compute_estimation(inputs, {}, {"s-wall": price}.get) row = next(r for r in sheets["bill"]["rows"] if r["key"].endswith("#blk:w")) - assert (row["qty"], row["sum"], row["note"]) == (12.0, 1495128, "대가1 · system||SS000201") + assert (row["qty"], row["sum"], row["note"]) == (12.0, 1495128, "대가1 · 마스터") diff --git a/resources/tester/test_704_3_hopyo_grid.py b/resources/tester/test_704_3_hopyo_grid.py index 935a63bcd..96bc0d304 100644 --- a/resources/tester/test_704_3_hopyo_grid.py +++ b/resources/tester/test_704_3_hopyo_grid.py @@ -58,7 +58,7 @@ def test_머리_구성_합계_줄_꼬리표_빈_줄(): STUBS = r""" -const tc = (k) => ({ Grid_NoQty: "수량 없음", Grid_Apart: "별도계상 · 합계 밖", Labor_Layer: "켜쌓기 {n}%" })[k] ?? k; +const tc = (k) => ({ Grid_NoQty: "수량 없음", Grid_Apart: "별도계상 · 합계 밖", Labor_Layer: "켜쌓기 {n}%", Grid_Head: "일위대가", Grid_Mine: "내 자료집", Lib_Company: "회사", Lib_System: "마스터" })[k] ?? k; const valueLine = (inputs, values) => ({ text: Object.values(values ?? {}).join(" · "), empty: 0 }); """ @@ -108,7 +108,7 @@ def test_M02_문서_calc_를_격자_줄로(): "\nconsole.log(JSON.stringify({ b, foot: hopyoGrid([b]).rows.at(-1), wait: hopyoGrid([wait]).rows.at(-1) }));" ), STUBS) # fmt: skip b = out["b"] - assert b["name"] == "SU-000006" and b["unit"] == "m" and b["total"]["sum"] == 112559.55 + assert b["name"] == "일위대가" and b["unit"] == "m" and b["total"]["sum"] == 112559.55 one, two, mt1, mt2 = b["lines"] assert ( one["name"] == "돌쌓기 찰쌓기" @@ -141,3 +141,19 @@ def test_세_곳_같은_부품(): assert '"@ui/ui_template_hopyo_grid"' in m02 and '"@ui/ui_template_hopyo_grid"' in b09 kit = (ROOT / "ui_template/ui_template_ui_kit_parts.ts").read_text("utf-8") assert 'key: "hopyo-grid"' in kit # 부품 모음 견본 + + +def test_번호는_글에_안_냄_사람_글은_그대로(): + files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts", + "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip + ref = {"로직": "", "별칭": None, "입력값": {}, "수량": {"손": 1}, + "참조": {"층": "personal", "주인": "1/3", "키": "SC-000002"}} # fmt: skip + named = {**DOC, "일위대가번": "제5호표", "담은로직": [ref]} + out = _node(files, ( + f"\nconst a = laborBlock({json.dumps(named, ensure_ascii=False)}, null, new Map());" + f"\nconst c = laborBlock({json.dumps({**named, '일위대가번': 'SP3-000015'}, ensure_ascii=False)}, null, new Map());" + "\nconsole.log(JSON.stringify({ a, c }));" + ), STUBS) # fmt: skip + assert out["a"]["name"] == "제5호표" and out["c"]["name"] == "일위대가" + assert out["a"]["lines"][0]["note"] == "내 자료집" From 5dc014e9eedd96d4204513706f3704933d8a5787 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 18:34:47 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix(M02=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9):=20[=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=EC=9C=BC=EB=A1=9C]=20=EB=A7=8C=EB=93=A0=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=EC=9D=B4=20=EC=97=B4=EB=A6=B0=20=ED=91=9C(=ED=96=89?= =?UTF-8?q?=EB=A0=AC)=EC=97=90=20=EB=B0=94=EB=A1=9C=20=EB=B3=B4=EC=9E=84?= =?UTF-8?q?=20=E2=80=94=20[=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8]=20?= =?UTF-8?q?=EC=97=86=EC=9D=B4=20(PLAN=20704-4=20=EB=94=B8=EB=A6=BC=20?= =?UTF-8?q?=E2=91=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 만들기 알림이 오면 나무 · 옆 목록에 더해 구조물 조합 표가 열려 있으면 다시 읽음(저장 안 한 고침은 먼저 물음) Co-Authored-By: Claude Sonnet 5.5 --- M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 69d869319..17cb6cbc6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -157,11 +157,15 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void go("personal", next, open), }) : panel; - // 704-4 [구조물 조합으로] 만든 항목 → 나무 · 옆 목록 다시(페이지가 내려가면 듣기 끝) + // 704-4 [구조물 조합으로] 만든 항목 → 나무 · 옆 목록 · 열린 표(행렬)를 다시(페이지가 내려가면 듣기 끝) + const afterItem = async (): Promise => { + await Promise.all([side.refresh(), tree.refresh()]); + const now = main.current(); + const shown = now?.kind === "table" && now.name === STRUCTURE_TABLE; + if (shown && (await main.settle()) !== false) await main.openTable(layer, null, true); + }; const onItem = (): void => - tree.root.isConnected - ? void Promise.all([side.refresh(), tree.refresh()]) - : document.removeEventListener(ITEM_BUILT, onItem); + tree.root.isConnected ? void afterItem() : document.removeEventListener(ITEM_BUILT, onItem); document.addEventListener(ITEM_BUILT, onItem); const main = createMain( isAdmin, From 63fd282771f7d06c4df0fa5b4f3215f03d803808 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 18:34:48 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(M02=20=EC=98=AC=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD):=20=ED=9A=8C=EC=82=AC=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EB=8A=94=20=EC=96=B4=EB=8A=90=20=EC=B8=B5=EC=9D=84=20?= =?UTF-8?q?=EC=97=B4=EC=96=B4=EB=8F=84=20=E3=80=8C=EC=98=AC=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20=EC=9A=94=EC=B2=AD=20n=E3=80=8D=20=EB=9D=A0?= =?UTF-8?q?=EA=B0=80=20=EB=B3=B4=EC=9E=84=20(PLAN=20706-1=20=EB=94=B8?= =?UTF-8?q?=EB=A6=BC=20=E2=91=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 띠 조건에서 「회사 층일 때만」 뺌 — 회사를 고칠 수 있는 사람이면 개인 · 마스터 층에서도 보임 · 누르면 요청 목록 Co-Authored-By: Claude Sonnet 5.5 --- M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts b/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts index 086a735b3..133e4b0b7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ShareRequest.ts @@ -3,7 +3,7 @@ * 회사에 올리기 요청 · 승인(PLAN 706-1) — 서버 `M02_Share_Request.py` 짝(장부 = 회사 층 파일 하나). * 일반 사용자 = 자기 개인 층 구조물 조합 · 사전조합 나무 오른쪽 클릭 「회사에 올리기 요청」 → 글(선택) · * 같은 것 요청이 남아 있으면 「요청 중」(흐림) · 결과는 다음에 M02 열 때 위 띠(닫으면 봄) - * 회사 관리자 = 회사 층 위 띠 「올리기 요청 n」 → 목록(요청자 · 항목 · 날짜 · 글) · [미리 보기] · + * 회사 관리자 = M02 위 띠 「올리기 요청 n」(어느 층이든) → 목록(요청자 · 항목 · 날짜 · 글) · [미리 보기] · * [승인](폴더 고름 = 「회사로 복사…」와 같은 길 · 원본 그대로) · [거절](사유 한 줄) * 관리자(회사 층을 고칠 수 있는 사람)는 「회사로 복사…」가 있어 요청 메뉴를 안 둠. * ========================================================================== */ @@ -50,7 +50,7 @@ export interface ShareOptions { user: SessionUser | null; /** 볼 회사(시스템 관리자는 고른 회사) */ company: string | null; - /** 지금 페이지 층 — 회사 층이면 관리자 띠 */ + /** 지금 페이지 층 — 개인 층이면 요청 메뉴 */ layer: Layer; owner: string | null; showView: (label: string, node: HTMLElement) => void; @@ -77,10 +77,7 @@ export function createShare(opt: ShareOptions): ShareHandle { // 요청할 사람 = 회사가 있고 회사 층을 못 고침(고칠 수 있으면 「회사로 복사…」) const asker = !!ownOwner("personal", opt.user) && !accessFor("company", companyOwner, opt.user).canWrite; - const boss = - opt.layer === "company" && - !!companyOwner && - accessFor("company", companyOwner, opt.user).canWrite; + const boss = !!companyOwner && accessFor("company", companyOwner, opt.user).canWrite; const q = opt.company ? `?company=${encodeURIComponent(opt.company)}` : ""; let rows: ShareRow[] = []; let listHost: HTMLElement | null = null; From 51f6eeffb15a8129e6453f87987d88f8e2fef82c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 18:43:32 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(B09=20=ED=86=B5=ED=95=A9=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80):=20=EB=8B=A8=EA=B0=80=EC=97=B0=EA=B2=B0=20=EB=82=B1?= =?UTF-8?q?=EB=A7=90=20=EB=B3=84=EC=B9=AD=20=C2=B7=20=EC=A4=84=200=20?= =?UTF-8?q?=EA=B0=9C=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=3D=20?= =?UTF-8?q?=EB=B9=88=20=EC=A4=84=20=C2=B7=20=EB=8B=A8=EC=9C=84=20=EB=8B=A4?= =?UTF-8?q?=EB=A6=84=20=EC=95=8C=EB=A6=BC=20=C2=B7=20=EC=9E=8A=EC=9D=80=20?= =?UTF-8?q?=EC=A7=9D=20=EC=A7=9D=20=ED=91=9C=20=EC=A0=9C=EC=95=88=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=20(=ED=86=B5=ED=95=A9=20=EC=A0=90=EA=B2=80?= =?UTF-8?q?=20=ED=9D=A0=20=E2=91=A0=20=E2=91=A2=20=E2=91=A3=20+=20?= =?UTF-8?q?=EC=9E=8A=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기준 목록 별칭에 「터파기」→「파기」 낱말(측구파기 → 측구터파기) · 목록 모양 SHAPE 올림 - 줄 0 개 일위대가 = 빈 줄 하나 · 빈 줄 있는 대가를 이은 내역 줄 = 단가 모름(금액 0 아님) · py · ts 짝 + 거울 시험 - 단가연결 때 줄 단위 ≠ 일위대가 단위면 「단위 다름」 알림(연결은 됨) - 잊은 짝은 짝 표 제안에서도 안 나옴(다시 이으면 풀림) - 낡은 시험 여섯: 707-4 잇기 메뉴 묶음(UI_Link) 빠진 소스 목록 보충 Co-Authored-By: Claude Sonnet 5.5 --- B09_Estimation/B09_Estimation_Bill_Link.py | 2 +- B09_Estimation/B09_Estimation_Calc_Bill.py | 8 +++++- B09_Estimation/B09_Estimation_Calc_Bill.ts | 3 ++- B09_Estimation/B09_Estimation_Calc_Labor.py | 6 ++++- B09_Estimation/B09_Estimation_Calc_Labor.ts | 8 ++++-- .../B09_Estimation_UI_Tabs_Bill_Edit.ts | 8 ++++++ M02_MasterTemplete/M02_Logic_Standard.py | 7 ++++-- resources/tester/test_166_2_b09_bill_hand.py | 25 ++++++++++++++++++- resources/tester/test_182_b09_bill_level.py | 8 +++--- resources/tester/test_184_3_b09_bill_menu.py | 2 +- .../tester/test_200_2_b09_bill_level_bar.py | 2 +- resources/tester/test_204_1_b09_bill_drag.py | 2 +- .../tester/test_228_1_table_level_font.py | 2 +- resources/tester/test_235_1_b09_bill_tree.py | 4 +-- resources/tester/test_704_logic_standard.py | 11 ++++++++ resources/tester/test_707_4_bill_link.py | 14 +++++++++++ 16 files changed, 94 insertions(+), 18 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Bill_Link.py b/B09_Estimation/B09_Estimation_Bill_Link.py index 97920a1d7..bb30a4389 100644 --- a/B09_Estimation/B09_Estimation_Bill_Link.py +++ b/B09_Estimation/B09_Estimation_Bill_Link.py @@ -148,7 +148,7 @@ def suggest(rows: list[dict[str, Any]], memory: dict[str, dict[str, Any]]) -> li hit = _resolve(got, free) if got else None if hit: keys, tag = [hit], RECALL - elif row["kind"] == "item": + elif row["kind"] == "item" and not (memory.get(key_of(row)) or {}).get(FORGOT): keys, tag = [EARTH_KEY[n] for n in table(row["name"], row["spec"])], TABLE keys = [k for k in keys if k in free] else: diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index cbdff9d85..a8e72890c 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -171,7 +171,13 @@ def ref_price( if row is None: return None no = f"대가{row['no']}" if tab == "dg" else f"산근 {row['no']}호표" - return {"no": no, "tab": tab, "key": key, **{p: row.get(p) for p in (*PARTS, "sum")}} + blank = tab == "dg" and row.get("blank") # 빈 줄 있는 대가(줄 0 개 포함) = 단가 모름 + return { + "no": no, + "tab": tab, + "key": key, + **{p: None if blank else row.get(p) for p in (*PARTS, "sum")}, + } return hopyo.get(key) if key else None diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 9060fe51e..f18dde7d8 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -132,7 +132,8 @@ export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""): if (!row) return null; const no = tab === "dg" ? `대가${row.no}` : `산근 ${row.no}호표`; const out: Row = { no, tab, key }; - for (const p of [...PARTS, "sum"]) out[p] = row[p] ?? null; + const blank = tab === "dg" && row.blank; // 빈 줄 있는 대가(줄 0 개 포함) = 단가 모름 + for (const p of [...PARTS, "sum"]) out[p] = blank ? null : (row[p] ?? null); return out; } return key ? (hopyo[key] ?? null) : null; diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 7af6393b9..3b4ef0cd0 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -17,7 +17,8 @@ % 줄 = ROUNDDOWN(밑수 × % / 100, 1) · 관급 · 별도계상 자재 = 대가 안 0원 · 블록 비목 합 = ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합. 빈칸 = 단가 · 수량을 낸 원천이 없으면 None · 「연결 없음」 목록(gaps). -빈 줄(166-6) = 블록 합 = 아는 줄 합 + `blank` n · `blank_names` — 내역 단가 · 부른 대가로는 안 넘김. +빈 줄(166-6) = 블록 합 = 아는 줄 합 + `blank` n · `blank_names` — 내역 단가 · 부른 대가로는 안 넘김 · + 줄 0 개 블록 = 빈 줄 하나(`NO_LINES`). 고친 칸 덮개(702-2) = 사용자 값 `daega_cover` {블록 cover: {줄 cid: {qty · 노 · 재 · 경 단가}}} — 있는 칸만 셈 값 대신 · 줄 `base`(품셈 값 = 셈 수량 · 단가) · `covered`(고친 칸) · 짝 잃은 덮개(그 블록 · 줄 없음) = `daega_orphans` [{cover, cid}] — 지우지 않고 알림만. @@ -36,6 +37,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown, PARTS = ("labor", "material", "expense") COVER_KEYS = ("qty", *PARTS) TAB = "dg" +NO_LINES = "구성 줄 없음" # 줄 0 개 일위대가의 빈 줄 이름 COPY = "@copy" ADD = "@add" WHY = { @@ -158,6 +160,8 @@ def _block( known = [ln for ln in lines if ln["amount"] is not None] parts = {part: rounddown(sum(ln["amounts"][part] for ln in known), 0) for part in PARTS} blank_names = [ln.get("name") or "" for ln in lines if ln["amount"] is None] + if not lines: # 줄 0 개 일위대가 = 금액 0 이 아니라 모름(빈 줄) + blank_names = [NO_LINES] return { **{k: block.get(k) for k in ("id", "kind", "name", "spec", "unit", "cite", "cover")}, **({"spec_raw": block["spec_raw"]} if block.get("spec_raw") else {}), # 157-4 풍선 diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 4cb4bcd1d..e23923f07 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -14,7 +14,8 @@ * 줄 금액 ROUNDDOWN(단가 × 수량, 1) · 블록 비목 합 ROUNDDOWN(Σ, 0) · 합계 = 세 비목 합 * 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈) * 빈칸 = 원천이 없으면 null · 「연결 없음」 목록(gaps) - * 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김 + * 빈 줄(166-6) = 블록 합 = 아는 줄 합 + blank n · blank_names — 내역 단가 · 부른 대가로는 안 넘김 · + * 줄 0 개 블록 = 빈 줄 하나(NO_LINES) * 고친 칸 덮개(702-2) = 사용자 값 daega_cover {블록 cover: {줄 cid: {qty · labor · material · expense}}} — * 있는 칸만 셈 값 대신 · 줄 base(품셈 값) · covered(고친 칸) · 짝 잃은 덮개 = daega_orphans(알림만) * 복사본(703-2) = 덮개 K:… 의 @copy {from · name · spec} — 원본 cover 블록을 본뜬 새 호표 @@ -36,6 +37,7 @@ import { const PARTS = ["labor", "material", "expense"] as const; const COVER_KEYS = ["qty", ...PARTS] as const; const TAB = "dg"; +const NO_LINES = "구성 줄 없음"; // 줄 0 개 일위대가의 빈 줄 이름(파이썬 짝) export const COPY = "@copy"; /** 붙인 줄 덮개 열쇠(705-2) */ export const ADD = "@add"; @@ -153,7 +155,9 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[], own: known.reduce((acc, ln) => acc + ln.amounts[part], 0), 0, ); - const blankNames = lines.filter((ln) => ln.amount === null).map((ln) => ln.name || ""); + const blankNames = lines.length + ? lines.filter((ln) => ln.amount === null).map((ln) => ln.name || "") + : [NO_LINES]; // 줄 0 개 일위대가 = 금액 0 이 아니라 모름(빈 줄) return { id: b.id, key: b.id, diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts index d802bdf02..990451e69 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Edit.ts @@ -25,6 +25,7 @@ import { } from "@ui/ui_template_context_menu"; import type { Row } from "./B09_Estimation_Calc_Base"; import type { TabContext, TabTable } from "./B09_Estimation_UI_Tabs"; +import { showToast } from "@ui/ui_template_elements"; import { linkItems, type ApplyRef } from "./B09_Estimation_UI_Tabs_Bill_Link"; import { linkMenu } from "./B09_Estimation_UI_Link"; @@ -208,6 +209,9 @@ export function editCell( put(ctx, w.bill); } +/** 단위 비교 꼴 — 띄어쓰기 · 대소문자 · 호환 글자(㎥ = m3) 무시 */ +const unitKey = (u: string): string => u.normalize("NFKC").replace(/\s+/g, "").toLowerCase(); + /** 연결 적용 — 새 줄이 「새 줄」 · 빈 이름이면 고른 호표 명칭 · 규격 · 단위를 채움 */ function applyRef(ctx: TabContext, rows: DataRow[], i: number): ApplyRef { return (ref, from) => { @@ -222,6 +226,10 @@ function applyRef(ctx: TabContext, rows: DataRow[], i: number): ApplyRef { e.unit = e.unit || from.unit || ""; } put(ctx, w.bill); + const mine = String(e.unit ?? row.unit ?? ""); + const theirs = String(from?.unit ?? ""); + if (ref && unitKey(mine) && unitKey(theirs) && unitKey(mine) !== unitKey(theirs)) + showToast(`단위 다름 ${mine} ↔ ${theirs} — 연결은 됨 · 수량 단위 확인`, "warning"); }; } diff --git a/M02_MasterTemplete/M02_Logic_Standard.py b/M02_MasterTemplete/M02_Logic_Standard.py index 71eaac2cb..86c336f31 100644 --- a/M02_MasterTemplete/M02_Logic_Standard.py +++ b/M02_MasterTemplete/M02_Logic_Standard.py @@ -29,7 +29,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Template_Layers as layers -SHAPE = 2 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 +SHAPE = 3 # 줄 꼴이 바뀌면 올림 — 옛 저장본을 안 씀 KIND = "m02_logic_standard" REGION = "지역" # 지역 = 자동값 한 벌(정함 8) QUIET = ("해당 없음", "없음") # 규격 글에서 뺄 값 @@ -83,7 +83,10 @@ def kind_of(row: dict) -> str: def words_of(row: dict) -> list[str]: name = _sq(row.get("이름")) - return [w for w, targets in WORDS.items() if any(_sq(t) in name for t in targets)] + out = [w for w, targets in WORDS.items() if any(_sq(t) in name for t in targets)] + if "터파기" in name: # 내역서 낱말 「측구파기」 ↔ 로직 「측구터파기」 + out.append(str(row.get("이름")).replace("터파기", "파기")) + return out def _range_label(table: dict, low: Any, high: Any, unit: str) -> str: diff --git a/resources/tester/test_166_2_b09_bill_hand.py b/resources/tester/test_166_2_b09_bill_hand.py index ae0be8dbe..af32829b0 100644 --- a/resources/tester/test_166_2_b09_bill_hand.py +++ b/resources/tester/test_166_2_b09_bill_hand.py @@ -196,7 +196,7 @@ def test_새_산근_사본_뒤_번호_호표_탭_집계표(tmp_path): def test_우클릭_메뉴_항목_추가_줄_삭제_연결_TS(): # 184-3 — 모든 줄 메뉴 · 셈 출력 줄 = 계약 꼴(parent · auto · auto_{칸}) · 첫 손질 = v2 로 굳힘 - src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) + src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit"]) rows = [ {"kind": "title", "key": "sec:1", "parent": None, "auto": True}, {"kind": "title", "key": "sec:1.2", "parent": "sec:1", "auto": True}, @@ -243,3 +243,26 @@ console.log(JSON.stringify(out)); # 새 줄 지움 = 덮개에서 빠짐(gone 은 자동 줄만) assert [e["key"] for e in out["left"]["rows"]] == ["sec:1", "sec:1.2", "earth:4"] assert out["left"]["gone"] == [] + + +def test_줄_0개_일위대가_이으면_빈_줄(): + """703-1 가져오기 흠 — 줄 0 개 대가(금액 0)를 이은 내역 줄 = 금액 0 이 아니라 빈 줄.""" + empty = {"no": 4, "key": "DG:e", "labor": 0, "material": 0, "expense": 0, "sum": 0, + "blank": 1} # fmt: skip + books = {**BOOKS, "daega": [*BOOKS["daega"], empty]} + hand = {"key": "hand:e", "section": "1.1", "after": "earth:1", "name": "빈 대가", "spec": "", + "unit": "㎡", "qty": 3, "ref": {"tab": "dg", "key": "DG:e"}} # fmt: skip + user = {"bill": {"rows": [hand]}} + sheets = compute_estimation(INPUTS, user, None, books) + row = _rows(sheets)["hand:e"] + assert row["sum"] is None and row["note"] == "대가4" + ok = _rows(compute_estimation(INPUTS, {"bill": {"rows": [HAND[0]]}}, None, books)) + assert ok["hand:a"]["sum"] == 12300 # 빈 줄 없는 대가는 그대로 + calc = _ts(["Calc_Base", "Calc_Bill"]) + body = ( + f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};" + f"\nconst u = {json.dumps(user, ensure_ascii=False)};" + f"\nconst b = {json.dumps(books, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify(computeEstimation(i, u, null, b)));" + ) + assert json.loads(_node(calc + body)) == sheets # 거울 diff --git a/resources/tester/test_182_b09_bill_level.py b/resources/tester/test_182_b09_bill_level.py index 1feb0f283..f17745c78 100644 --- a/resources/tester/test_182_b09_bill_level.py +++ b/resources/tester/test_182_b09_bill_level.py @@ -218,7 +218,9 @@ def test_우클릭_레벨_메뉴_TS(): body = MENU_JS.replace("__ROWS__", json.dumps(rows, ensure_ascii=False)).replace( "__USER__", json.dumps(USER, ensure_ascii=False) ) - out = json.loads(_node(_ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"]) + body)) + out = json.loads( + _node(_ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit"]) + body) + ) level = {"묶음 줄로 바꾸기", "항목으로 바꾸기", "들여쓰기 →", "내어쓰기 ←"} pick = lambda key: [x for x in out[key] if x in level] # noqa: E731 assert [x for x in out["g1"] if "추가" in x] == ["항목 추가"] # 246-1 더함 하나 @@ -312,7 +314,7 @@ def test_연결_단추_칸_메뉴_고르기_창_TS(): .replace("__BILL__", json.dumps(sheets["bill"], ensure_ascii=False)) .replace("__DAEGA__", json.dumps(BOOKS["daega"], ensure_ascii=False)) ) - src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]) + src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]) out = json.loads(_node(src + body)) assert out["columns"] == ["connect", "note"] # 비고 앞 「연결」 칸 # 184-3 모든 항목 줄 · 새 줄 연결됨 = 호표 번호 · 자동 줄 연결됨 = 「연결됨」 · 묶음 = 없음 @@ -357,7 +359,7 @@ console.log(JSON.stringify(out)); def test_글_칸_위_우클릭도_줄_메뉴_TS(): - src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]) + src = _ts(["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"]) out = json.loads(_node(src + ROW_MENU_JS)) assert out["onInput"] is True and out["onCell"] is True assert out["opened"] == [[5, 6, ["줄 7"]], [5, 6, ["줄 7"]]] diff --git a/resources/tester/test_184_3_b09_bill_menu.py b/resources/tester/test_184_3_b09_bill_menu.py index 6c01f5658..83e0d1a6a 100644 --- a/resources/tester/test_184_3_b09_bill_menu.py +++ b/resources/tester/test_184_3_b09_bill_menu.py @@ -95,7 +95,7 @@ def _ts(names: list[str]) -> str: ) -SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit"] +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit"] def _menu(user, labels=(), runs=(), edits=(), rows=ROWS): diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index 1b01a8020..4523e0c1f 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -124,7 +124,7 @@ out.keyAct = [ console.log(JSON.stringify(out)); """ -SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", "UI_Tabs_Bill_Level"] # fmt: skip IN, OUT, UP, DOWN = "들여쓰기 →", "내어쓰기 ←", "위로 ↑", "아래로 ↓" diff --git a/resources/tester/test_204_1_b09_bill_drag.py b/resources/tester/test_204_1_b09_bill_drag.py index 185ffb4cf..4a26924a0 100644 --- a/resources/tester/test_204_1_b09_bill_drag.py +++ b/resources/tester/test_204_1_b09_bill_drag.py @@ -33,7 +33,7 @@ if str(ROOT) not in sys.path: from B09_Estimation import B09_Estimation_Store as store # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 -SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", "UI_Tabs_Bill_Level"] # fmt: skip diff --git a/resources/tester/test_228_1_table_level_font.py b/resources/tester/test_228_1_table_level_font.py index 56bab2b46..07eac586b 100644 --- a/resources/tester/test_228_1_table_level_font.py +++ b/resources/tester/test_228_1_table_level_font.py @@ -26,7 +26,7 @@ from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) TABLE = IMPORTS.sub("", (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")) -SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", "UI_Tabs_Bill_Level"] # fmt: skip IN, OUT = "들여쓰기 →", "내어쓰기 ←" diff --git a/resources/tester/test_235_1_b09_bill_tree.py b/resources/tester/test_235_1_b09_bill_tree.py index 72f567665..8de88d001 100644 --- a/resources/tester/test_235_1_b09_bill_tree.py +++ b/resources/tester/test_235_1_b09_bill_tree.py @@ -27,7 +27,7 @@ from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE) TABLE = IMPORTS.sub("", (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")) -SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"] +SRC = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill"] SUMS = ["sum", "labor", "material", "expense"] FOLDER_SVG, LEAF_SVG = "M1.5 3.5h4", "M3.5 1.5h6" @@ -303,7 +303,7 @@ def test_고른_줄_Enter_F2_입력_시작(): "bar = levelBar(root, table);", "bar = levelBar(root, table, (i) => edits.push(i));" ) body = (setup + KEYS_JS).replace("__ROWS__", json.dumps(FAKE_ROWS, ensure_ascii=False)) - names = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", + names = ["UI_Tabs", "UI_Tabs_Bill_Link", "UI_Link", "UI_Tabs_Bill_Edit", "UI_Tabs_Bill", "UI_Tabs_Bill_Drag", "UI_Tabs_Bill_Level"] # fmt: skip out = json.loads(_node(_ts(names) + body)) at = [r["key"] for r in FAKE_ROWS].index("earth:5") diff --git a/resources/tester/test_704_logic_standard.py b/resources/tester/test_704_logic_standard.py index 2cd394958..1ea587aaa 100644 --- a/resources/tester/test_704_logic_standard.py +++ b/resources/tester/test_704_logic_standard.py @@ -126,3 +126,14 @@ def test_화면_기준_목록_계산서_담기() -> None: router = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py").read_text("utf-8") assert '@router.get("/logic-lib/standard", dependencies=[Depends(verify_session)])' in router assert '@router.post("/logic-lib/sheet", dependencies=[Depends(verify_session)])' in router + + +def test_내역서_낱말_측구파기로_측구터파기를_찾음() -> None: + logic = next( + logic + for _, data in m01._logic_files(m01.loaded()[0]) + for logic in data.get("줄") or [] + if logic.get("키") == "GF000099" # 9-12-1 측구터파기 토사 + ) + row = {**logic, "별칭": ", ".join(st.words_of(logic))} + assert hit(row, "측구파기") diff --git a/resources/tester/test_707_4_bill_link.py b/resources/tester/test_707_4_bill_link.py index f6ce3ddf5..5f324d209 100644 --- a/resources/tester/test_707_4_bill_link.py +++ b/resources/tester/test_707_4_bill_link.py @@ -301,3 +301,17 @@ def test_잊은_짝_느슨한_기억에도_안_나옴(tmp_path, monkeypatch): assert lk.recall(memory, third) is None # 잊은 짝 곁에서는 느슨하게 안 좁힘 route.keep(user, {lk.key_of(row): live}) # 다시 이음 = 풀림 assert lk.recall(route.recalled(user), row) == live + + +def test_잊은_짝_짝_표_제안에도_안_나옴(geo): + _user, rows = _load(geo) + now = {s["key"]: s for s in lk.suggest(rows, {})} + line = next(r for r in rows if (r["name"], r.get("spec")) == ("토사절취", "백호0.7㎥")) + assert now[line["key"]]["꼬리표"] == lk.TABLE + memory = {lk.key_of(line): {lk.FORGOT: True}} + after = {s["key"]: s for s in lk.suggest(rows, memory)} + assert line["key"] not in after # 짝 표 제안도 잊음 + assert set(now) - set(after) == {line["key"]} # 다른 줄은 그대로 + memory[lk.key_of(line)] = {"확정": "earth:4", "이름": "절토(토사)"} # 다시 이음 = 풀림 + assert lk.suggest(rows, memory)[0] is not None + assert line["key"] in {s["key"] for s in lk.suggest(rows, memory)}