diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 7ad6f00ee..e659114bb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -214,12 +214,23 @@ export function mountSangeunDetail( function drawNotes(): void { const lost = ans?.짝잃음 ?? []; + const gaps = ans?.빠진줄 ?? []; const revised = revisedBase(); + notes.replaceChildren( ...(fail || ans?.까닭 ? [createBanner({ tone: "danger", text: fail || String(ans?.까닭) })] : []), ...(revised ? [createBanner({ tone: "warning", text: L("M02_SangeunRevisedNote") })] : []), + ...(gaps.length + ? [ + createBanner({ + tone: "warning", + text: `${L("M02_SangeunGap")} — ${gaps.map((x) => x.이름 || x.cid).join(" · ")}`, + details: gaps.map((x) => `${x.이름 || x.cid} — ${x.까닭}`).join("\n"), + }), + ] + : []), ...(lost.length ? [ createBanner({ diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index a45cd402c..e6cbee6af 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -13,6 +13,7 @@ from __future__ import annotations import hashlib import json +import re from decimal import Decimal from typing import Any @@ -83,11 +84,20 @@ def _base_of(doc: dict, whole: Any, read: Any, trail: tuple[str, ...]) -> dict[s return {**up, "짝잃음": lost} -def resolve(doc: dict, whole: Any, read: Any = refs.read_ref, trail: tuple = ()) -> dict[str, Any]: +def resolve( + doc: dict, whole: Any, read: Any = refs.read_ref, trail: tuple = (), given: dict | None = None +) -> dict[str, Any]: """SK 문서 → 고친 줄 `{줄, 고름, 자재표시, 정체, 짝잃음, 입력값, 켜쌓기율, 배, 바탕판}` · - `trail` = 지나온 참조 이름표(순환 · 깊이 422).""" + `trail` = 지나온 참조 이름표(순환 · 깊이 422) · `given` = 공통 옵션(더한줄 로직 입력).""" base = _base_of(doc, whole, read, trail) + common = {**base["입력값"], **(doc.get("입력값") or {}), **(given or {})} + doc, lack = _fill(doc, whole, common) got = sd.patched(base["줄"], doc, base["고름"], base["자재표시"]) + items = got["줄"]["호표"] + for at, cid in enumerate(got["정체"][: len(items)]): + hit = re.match(r"^add:#(\d+)$", cid) + if hit and int(hit[1]) in lack: + items[at] = _gap(items[at], lack[int(hit[1])]) rate = doc.get("켜쌓기율", base["켜쌓기율"]) return { **got, @@ -99,18 +109,70 @@ def resolve(doc: dict, whole: Any, read: Any = refs.read_ref, trail: tuple = ()) } -def _bundles(row: dict, whole: Any, expand: Any, trail: tuple) -> str | None: - """더한줄 참조 · 고정 줄 `값묶음` = 1 단위 노 · 재 · 경 — 못 셈이면 까닭.""" - for item in row["호표"]: +# ── 못 셈 줄 = 그 줄만 빠짐(725-1 · 725-2) ───────────────────────────────── +NO_QTY = "수량 없음" # `M02_Labor_Expand.NO_QTY` 와 같은 글(가져오기 둘레 피함) +NO_QTY_WHY = "수량 없음 — 쓰는 자리에서 연결" + + +def _defaults(whole: Any, code: str) -> dict: + """로직 기본값 = M01 견본 입력(`/m01/logic/auto` 와 같은 길 · 흐름 예시값 › 옛 자동값).""" + from M01_MasterData import M01_MasterData_Store as m01 + from M01_MasterData import M01_MasterData_Store_Flow as flow + + try: + got = flow.auto(code) or {"값": m01.mau.values(whole, whole.logic(code))} + except Exception: # noqa: BLE001 — 없는 로직 · 자동값 못 셈 = 기본값 없음 + return {} + return got.get("값") or {} + + +def _fill(doc: dict, whole: Any, common: dict) -> tuple[dict, dict[int, str]]: + """더한줄 로직 입력 = 줄 값 › 공통 옵션 › 로직 기본값 — (채운 사본, {더한줄 차례: 까닭}).""" + adds, lack = [], {} + for k, one in enumerate(doc.get("더한줄") or []): + if one.get("종류") != "로직": + adds.append(one) + continue + try: + names = [str(s.get("이름")) for s in whole.logic(one["로직"]).get("입력") or []] + except Exception: # noqa: BLE001 — 없는 로직 = 검사(`sd.clean`)가 먼저 막음 + names = [] + mine = dict(one.get("입력값") or {}) + blank = [n for n in names if mine.get(n) is None or not str(mine[n]).strip()] + auto = _defaults(whole, one["로직"]) if blank else {} + for n in blank: + mine.pop(n, None) + for v in (common.get(n), auto.get(n)): + if v is not None and str(v).strip(): + mine[n] = v + break + miss = [n for n in names if n not in mine] + if miss: + lack[k] = f"입력 「{miss[0]}」 없음" + adds.append({**one, "입력값": mine}) + return {**doc, "더한줄": adds}, lack + + +def _gap(item: dict, why: str) -> dict: + """못 셈 줄 → 0 값묶음 줄(차례 그대로 · 계에 안 듦) + `빠짐` 까닭.""" + zero = dict.fromkeys(COSTS, Decimal(0)) + head = {k: item.get(k) or "" for k in ("이름", "단위")} + return {**head, "종류": "로직", "요소": None, "수량": item["수량"], "값묶음": zero, "빠짐": why} + + +def _bundles(row: dict, whole: Any, expand: Any, trail: tuple) -> None: + """더한줄 참조 · 고정 줄 `값묶음` = 1 단위 노 · 재 · 경 — 못 셈 줄 = `_gap`(나머지 셈 계속).""" + for at, item in enumerate(row["호표"]): if item.get("값묶음") is not None or not ("참조" in item or "고정" in item): continue sub = expand(item["참조"], trail) if "참조" in item else fixed.line(whole, item["고정"]) - if sub.get("기준") is None: - return f"「{sub.get('이름') or item.get('이름')}」 {sub.get('까닭')}" - item["값묶음"] = {k: Decimal(str(sub["기준"][k])) for k in COSTS} item["이름"] = item.get("이름") or sub.get("이름") or "" item["단위"] = item.get("단위") or sub.get("단위") or "" - return None + if sub.get("기준") is None: + why = str(sub.get("까닭") or "") + row["호표"][at] = _gap(item, NO_QTY_WHY if NO_QTY in why else why) + continue + item["값묶음"] = {k: Decimal(str(sub["기준"][k])) for k in COSTS} def run( @@ -125,28 +187,33 @@ def run( 고친 칸 `덮개`(칸 이름) · `원값`. 틀린 문서 · 순환 = `sd.DocError`.""" from M02_MasterTemplete.M02_Labor_Expand import Expand - got = resolve(doc, whole, read, trail) + got = resolve(doc, whole, read, trail, given) row = {**got["줄"], "호표": [dict(x) for x in got["줄"]["호표"]]} - why = _bundles(row, whole, expand or Expand(whole, read), trail) + _bundles(row, whole, expand or Expand(whole, read), trail) head = {k: got[k] for k in ("정체", "짝잃음", "바탕판")} | {"배": got["배"]} - if why: - return {**head, "기준": None, "1단위": None, "줄": [], "까닭": why} + gaps = {at: x for at, x in enumerate(row["호표"]) if x.get("빠짐")} key = str(doc.get("키") or "SK-000000") over = _Over(whole, key, {**row, "키": key}) values = {**got["입력값"], **(given or {})} out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None) covers = doc.get("덮개") or {} lines = [] - for cid, line in zip(got["정체"], out.get("줄") or []): + for at, (cid, line) in enumerate(zip(got["정체"], out.get("줄") or [])): mine = covers.get(cid) or {} mark = {"덮개": [k for k in sd.COVER_KEYS if k in mine]} if mine else {} + if at in gaps: # 못 셈 줄 = 금액 — · 까닭(725) + mark |= {"단가": None, "금액": None, "비목": {}, "까닭": gaps[at]["빠짐"]} lines.append( {**line, "cid": cid, **mark, **({"원값": mine["원값"]} if "원값" in mine else {})} ) out = labor.drop_apart({**out, "줄": lines}, got["자재표시"]) base = out.get("기준") unit = {k: Decimal(str(v)) / got["배"] for k, v in base.items()} if base else None - return {**out, **head, "1단위": unit} + gone = [ + {"cid": got["정체"][at], "이름": x.get("이름") or "", "까닭": x["빠짐"]} + for at, x in gaps.items() + ] + return {**out, **head, "1단위": unit, "빠진줄": gone} def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]: @@ -156,7 +223,9 @@ def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]: got = run(doc, whole, None, expand.read, expand, trail) except sd.DocError as e: return {**head, "기준": None, "까닭": str(e)} - why = got.get("까닭") or (None if got.get("1단위") else "돈 아닌 로직") + gone = got.get("빠진줄") or [] # 쓰는 자리 = 빠진 줄 있으면 통째 기준 없음(부분 값 안 넘김) + why = got.get("까닭") or next((f"「{x['이름']}」 {x['까닭']}" for x in gone), None) + why = why or (None if got.get("1단위") else "돈 아닌 로직") times = got["배"] lines = [ { @@ -166,7 +235,13 @@ def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]: } for x in got.get("줄") or [] ] - return {**head, "기준": got["1단위"], "까닭": why, "줄": lines, "짝잃음": got["짝잃음"]} + return { + **head, + "기준": None if gone else got["1단위"], + "까닭": why, + "줄": lines, + "짝잃음": got["짝잃음"], + } def head_of(doc: dict) -> str: diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index 5dfacef15..26d66fb2e 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -63,6 +63,8 @@ export interface SkLine { 묶음?: string | null; cid: string; 율?: number; + /** 못 셈 줄(725) — 금액 없음 · 계에 안 듦 */ + 까닭?: string; } export interface SkMat { @@ -79,6 +81,8 @@ export interface SkAnswer { 줄: SkLine[]; 재료?: SkMat[]; 까닭?: string | null; + /** 못 셈 줄(725 · 입력 없음 · 수량 없음) — 그 줄만 빠지고 나머지 셈 */ + 빠진줄?: { cid: string; 이름: string; 까닭: string }[]; 정체: string[]; 짝잃음: { cid: string; 까닭: string; 바탕?: string }[]; 바탕판: string; @@ -283,7 +287,7 @@ export function sheetGroups(rows: CoverRow[]): SheetGroup[] { const entries = Object.entries(r.parts); for (const [k, v] of entries.length ? entries : [["경비", 0] as [string, number]]) { const share = entries.length > 1 && r.금액 ? (r.단가 ?? 0) * (v / r.금액) : r.단가; - const amount = r.apart ? null : v; + const amount = r.apart || (!entries.length && r.금액 == null) ? null : v; const g = take(k); g.소계 = (g.소계 ?? 0) + (amount ?? 0); g.줄.push({ diff --git a/resources/tester/test_715_sangeun_store.py b/resources/tester/test_715_sangeun_store.py index 05be42e2d..b39938bec 100644 --- a/resources/tester/test_715_sangeun_store.py +++ b/resources/tester/test_715_sangeun_store.py @@ -141,7 +141,8 @@ def test_precombo_holds_sangeun(world): "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}], }, )["이름"] - got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, {}) + pill = {f"{refs.ref_id({**MINE, '키': sc})}#0": 2} # 723-1 안 줄 수량 = 쓰는 쪽 알약 + got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, pill) with pytest.raises(store.StoreError) as e: store.delete("sangeun", sk) one = _calc(world["personal"], sk)["1단위"]["계"] diff --git a/resources/tester/test_719_2_sk_project.py b/resources/tester/test_719_2_sk_project.py index f121cb5cf..56da848e5 100644 --- a/resources/tester/test_719_2_sk_project.py +++ b/resources/tester/test_719_2_sk_project.py @@ -122,8 +122,9 @@ def test_SK_단가_덮개_속_일위대가_펼침(world, tmp_path): if not _engine_ready(world): pytest.skip("715-1b 엔진 입력 덧칸(단가 · 값묶음) 뒤 — 지금은 M02 SK 셈도 못 함") key = _mine(world, full=True) - # 723-1 = 속 일위대가 안 줄 수량 저장 안 함 → M02 SK = 수량 없음 · B09 = 그 줄만 빈 줄(이 공사 입력) - assert "수량 없음" in _calc(world["personal"], key)["까닭"] + # 723-1 속 일위대가 안 줄 수량 없음 → M02 SK = 그 줄만 빠짐(725-2) · B09 = 그 줄만 빈 줄 + gone = _calc(world["personal"], key)["빠진줄"] + assert len(gone) == 1 and "수량 없음" in gone[0]["까닭"] root, folder = _lib(world, tmp_path, key) daega, done, _ = _b09(root) block = done[daega["libs"][folder]] diff --git a/resources/tester/test_725_1_sangeun_add_inputs.py b/resources/tester/test_725_1_sangeun_add_inputs.py new file mode 100644 index 000000000..b567402e9 --- /dev/null +++ b/resources/tester/test_725_1_sangeun_add_inputs.py @@ -0,0 +1,98 @@ +"""725-1 · 725-2 단가산출 [항목 추가] 못 셈 줄 — 더한줄 로직 입력 = 공통 옵션 › 로직 기본값 · +그래도 빈 입력 · 수량 없는 일위대가 줄 = 그 줄만 금액 없음 + 까닭 · 나머지 셈 계속(임시 storage).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +GF = "GF000099" # 측구터파기 토사 · 입력 지역 · 보정작업 +PCT = "GC001109" # 입력 없음 · 인력 둘 + 덧줄 % +GF_LINE = {"종류": "로직", "로직": GF, "입력값": {}, "수량": 1} +PCT_LINE = {"종류": "로직", "로직": PCT, "입력값": {}, "수량": 1} + + +def _sk(folder, **doc): + with store.at(folder): + made = refs.create("sangeun", {"이름": "sub7임시", "단위": "㎥", "더한줄": [], **doc}) + return store.read("sangeun", made["이름"])["문서"] + + +def _run(folder, doc, given=None): + with store.at(folder): + return calc.calc_doc(doc, given) + + +def _line(got, cid): + return next(x for x in got["줄"] if x["cid"] == cid) + + +def _m01(code, values): + return float(labor.run_line(m01.loaded()[1], code, values, None, None)["기준"]["계"]) + + +@pytest.mark.parametrize( + ("given", "want"), + [ + (None, {"지역": "전국평균", "보정작업": "해당 없음"}), # 로직 기본값 + ({"지역": "서울"}, {"지역": "서울", "보정작업": "해당 없음"}), # 공통 옵션 › 기본값 + ], +) +def test_added_logic_takes_common_then_default(world, given, want): # noqa: F811 + """입력 있는 로직 더한줄(입력값 {}) = 공통 옵션 넣은 M01 셈 · 계 = 바탕 + 그 줄.""" + doc = _sk(world["personal"], 바탕={"로직": GF}, 더한줄=[GF_LINE]) + plain = _sk(world["personal"], 바탕={"로직": GF}) + got, bare = _run(world["personal"], doc, given), _run(world["personal"], plain, given) + one = _m01(GF, want) + assert got["빠진줄"] == [] and not got.get("까닭") + assert _line(got, "add:#0")["금액"] == pytest.approx(one, rel=1e-9) + assert got["기준"]["계"] - bare["기준"]["계"] == pytest.approx(one, rel=1e-9) + assert doc["더한줄"][0]["입력값"] == {} # 문서에 안 적음 — 공통 옵션을 바꾸면 따라감 + + +def test_line_without_input_only_that_line_drops(world, monkeypatch): # noqa: F811 + """기본값도 없는 입력 = 그 줄만 금액 — · 까닭 · 계 = 나머지 · 쓰는 자리 = 기준 없음.""" + monkeypatch.setattr(calc, "_defaults", lambda whole, code: {}) + doc = _sk(world["personal"], 더한줄=[GF_LINE, PCT_LINE]) + rest = _sk(world["personal"], 더한줄=[PCT_LINE]) + got, want = _run(world["personal"], doc), _run(world["personal"], rest) + gap = _line(got, "add:#0") + assert gap["금액"] is None and gap["까닭"] == "입력 「지역」 없음" + assert got["빠진줄"] == [{"cid": "add:#0", "이름": gap["이름"], "까닭": "입력 「지역」 없음"}] + assert gap["이름"] # 이름 남음 — 화면 줄 빼기 자리 + assert got["기준"]["계"] == pytest.approx(float(want["기준"]["계"]), rel=1e-9) + assert _line(got, "add:#1")["금액"] == pytest.approx(float(want["기준"]["계"]), rel=1e-9) + with store.at(world["personal"]): + up = refs.Expand(m01.loaded()[1])({**MINE, "키": doc["키"]}) + assert up["기준"] is None and "입력 「지역」 없음" in up["까닭"] + + +def test_precombo_without_qty_only_that_line_drops(world): # noqa: F811 + """수량 없는 일위대가 줄(723-1) = 그 줄만 금액 — · 까닭 · 나머지 계 같음.""" + with store.at(world["personal"]): + sc = refs.create( + "precombo", {"이름": "sub7임시SC", "단위": "㎥", "담은로직": [{"로직": PCT}]} + ) + line = {"참조": {**MINE, "키": sc["이름"]}, "수량": 2} + doc = _sk(world["personal"], 더한줄=[line, PCT_LINE]) + rest = _sk(world["personal"], 더한줄=[PCT_LINE]) + got, want = _run(world["personal"], doc), _run(world["personal"], rest) + gap = _line(got, "add:#0") + assert gap["금액"] is None and gap["까닭"] == calc.NO_QTY_WHY and gap["이름"] == "sub7임시SC" + assert [x["cid"] for x in got["빠진줄"]] == ["add:#0"] + assert got["기준"]["계"] == pytest.approx(float(want["기준"]["계"]), rel=1e-9) + + +def test_screen_shows_dropped_lines(): + """화면 — 빠진 줄 띠(이름 · 까닭) · 금액 없는 줄 계산서 금액 —.""" + ts = (ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_Detail.ts").read_text("utf-8") + assert "ans?.빠진줄" in ts and 'L("M02_SangeunGap")' in ts diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index c005cecfa..965184cde 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -222,6 +222,10 @@ export const ui_locales_m2 = { "바탕에서 짝을 잃은 고침 — 지우지 않고 남겨 둠", "Edits that lost their base row — kept, not deleted", ], + M02_SangeunGap: [ + "못 셈 줄 — 금액 빼고 나머지로 셈 · 줄을 빼거나 입력을 채움", + "Lines not priced — total uses the rest · drop the line or fill its input", + ], M02_SangeunScaleOnly: ["열린 입력 — 품은 배율만", "Open inputs — quantity as multiplier only"], M02_SangeunBaseViews: [ "시험 계산 · 텍스트 수식 = 바탕 M01 로직 + 고친 품 · 단가(뺀 줄 · 더한 줄 빼고)",