From 9d2b8e4021e35138cb416189a1bef5d3c91d4b6d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 22:32:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(750-1):=20SK=20=EC=85=88=20=EB=8B=B5=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EC=97=90=20=EB=B3=B4=EA=B8=B0=20=EB=84=B7=20?= =?UTF-8?q?=E2=80=94=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=8B=9D=20?= =?UTF-8?q?=EA=B8=80=20=C2=B7=20=EC=8B=9C=ED=97=98=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=A4=91=EA=B0=84=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=A4=84=20=EB=AA=A8=EB=8B=AC=20=EC=A4=84=EB=AA=A8?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 `M02_Sangeun_Views` — SK 고친 줄(`_Over`) · 같은 입력 · 같은 고름 위에서 `master_text.lines` + `annotate` = `글` · 줄마다 cid · 금액 · 소계 · 계 = SK 줄 · 기준(별도계상 뺌 · 단위 환산 ÷배 · 못 셈 까닭) · `중간` = 시험 계산 중간값 - `calc_doc` — 문서 `줄모드` true = 일위대가 그 줄 셈(`line` · 별도계상 기본) · 몸 `보기` false = 덧칸 안 지음(바탕 원값 셈) - `master_text.lines` · `master_sheet_line.annotate` — `files` 자리에 Master 도 받음(파일 묶음 길 그대로 · M01 `/text` 답 같음) - `lineDoc` — `줄모드: true`(일위대가 줄 모달 89,299.60 = 그 줄 금액) - 시험 `test_717_3` — lineDoc = as_doc 대조에서 `줄모드` 칸 따로 확인(서버 as_doc 은 `line` 인자로 줄 셈 · 문서 칸 없음) - 새 지킴 `test_750_1_sk_views` — 변형 일곱 · 참조 바탕 · 줄 모달 · 보기 false Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Sangeun_Calc.py | 23 +++-- M02_MasterTemplete/M02_Sangeun_Router.py | 6 +- M02_MasterTemplete/M02_Sangeun_Views.py | 83 ++++++++++++++++ .../logic/M02_Sangeun_Cover_Model.ts | 1 + .../master_data/scripts/master_sheet_line.py | 3 +- resources/master_data/scripts/master_text.py | 2 +- resources/tester/test_717_3_sangeun_modal.py | 1 + resources/tester/test_750_1_sk_views.py | 94 +++++++++++++++++++ 8 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 M02_MasterTemplete/M02_Sangeun_Views.py create mode 100644 resources/tester/test_750_1_sk_views.py diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 63de29f2d..809832716 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -17,6 +17,8 @@ SK 단위 환산(728-2) = 배 = 바탕 기준단위(「100본」) → SK 단위( 화면 셈 길 줄 · 계 = SK 1 단위 몫(`per_unit`). 바탕 재료(747-1) = 별도계상 빈 칸 = 직접계산(M01 기초처럼 재료비에 듦 · 「별도계상」 고른 줄만 밖) · 일위대가 덮개 줄(`line` · `M02_Labor_Patch.run`)은 일위대가 기본(222-1 · 162-1) 그대로. +보기 넷 한 답(750-1) = 화면 셈 길 답에 `글`(읽는 식) · `중간`(`M02_Sangeun_Views`) · 일위대가 줄 +모달 = 문서 `줄모드` true → `line`(일위대가 그 줄 셈과 같은 값). """ from __future__ import annotations @@ -32,6 +34,7 @@ from M02_MasterTemplete import M02_Labor_Fixed as fixed from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete import M02_Sangeun_Views as views COSTS = labor.COSTS Ref = dict[str, Any] @@ -328,10 +331,12 @@ def run( trail: tuple = (), detail: bool = False, line: bool = False, + seen: bool = True, ) -> dict[str, Any]: """SK 셈(저장 안 함) — `run_line` 답 + `{1단위, 배, 정체, 짝잃음, 바탕판}` · 줄마다 `cid` · 고친 칸 `덮개`(칸 이름) · `원값`. 틀린 문서 · 순환 = `sd.DocError` · - `detail` = 둘째 기초 세부 펼침(`_detail` · 화면 셈 길만) · `line` = 일위대가 덮개 줄.""" + `detail` = 둘째 기초 세부 펼침(`_detail` · 화면 셈 길만) · 그때 `seen` = 보기 덧칸(750-1) · + `line` = 일위대가 덮개 줄.""" from M02_MasterTemplete.M02_Labor_Expand import Expand got = resolve(doc, whole, read, trail, given, line) @@ -345,6 +350,10 @@ def run( names = {str(i.get("이름")) for i in row.get("입력") or []} values = {k: v for k, v in {**got["입력값"], **(given or {})}.items() if k in names} out = labor.run_line(over, key, values, got["켜쌓기율"], got["고름"] or None) + eng = out.get("줄") or [] + seen = ( + views.text_of(over, key, values, got["고름"], got["정체"], eng) if detail and seen else {} + ) covers = doc.get("덮개") or {} lines = [] for at, (cid, line) in enumerate(zip(got["정체"], out.get("줄") or [])): @@ -368,7 +377,7 @@ def run( ] base = doc.get("바탕") or {} tag = tag_of(whole, base["로직"]) if "로직" in base else (base.get("참조") or {}).get("키") - return {**out, **head, "1단위": unit, "빠진줄": gone, "꼬리표": tag} + return {**out, **head, **seen, "1단위": unit, "빠진줄": gone, "꼬리표": tag} def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]: @@ -495,8 +504,9 @@ def _was(line: dict) -> dict[str, Any]: return {k: sd.plain(Decimal(str(v))) for k, v in keep.items() if v is not None} -def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]: - """셈 길(`M02_Sangeun_Router`) — 지금 층 문서 · 저장 전 화면 문서 · 틀림 400 · 순환 422.""" +def calc_doc(doc: Any, given: dict | None = None, seen: bool = True) -> dict[str, Any]: + """셈 길(`M02_Sangeun_Router`) — 지금 층 문서 · 저장 전 화면 문서 · 틀림 400 · 순환 422 · + `seen` = 보기 덧칸(`글` · `중간`) 지음.""" from M01_MasterData import M01_MasterData_Store as m01 whole = m01.loaded()[1] @@ -504,10 +514,11 @@ def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]: clean = sd.clean(doc, None, whole) _check_refs(clean, refs.here()) # 저장 전 문서도 위 층만 · 있는 문서 · 순환 422 me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]}) - got = run(clean, whole, given, trail=(me,), detail=True) + line = isinstance(doc, dict) and doc.get("줄모드") is True # 일위대가 줄 모달(750-1) + got = run(clean, whole, given, trail=(me,), detail=True, line=line, seen=seen) except sd.DocError as e: raise _fail(e) from e - return labor._json({**per_unit(got, clean), "머리": head_of(clean)}) + return labor._json({**views.finish(per_unit(got, clean)), "머리": head_of(clean)}) def from_line( diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index c2c3769a0..cbbe03c3f 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -2,7 +2,8 @@ [단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한). 저장 안 함 · 로그인 사용자 · 층 읽기 권한(`Router_Items._root`) · 화면은 최신 응답만. -몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값). +몸 = `문서`(저장 전 화면 문서) 또는 `키`(그 층 저장본) + `입력값`(열린입력 · 시험 값) · +`보기` false = 보기 덧칸(`글` · `중간` · 750-1) 안 지음(바탕 원값 셈처럼 금액만 쓸 때). 문서 읽기 · 쓰기 · 새로 · 복사 · 지우기는 양식 길 · 서고 길 그대로(종류 `sangeun`). ⚠ 등록은 `main.py`(로그인만). """ @@ -27,6 +28,7 @@ class SangeunCalcBody(BaseModel): 문서: dict[str, Any] | None = None 키: str | None = None 입력값: dict[str, Any] | None = None + 보기: bool = True def _calc(body: SangeunCalcBody) -> dict[str, Any]: @@ -35,7 +37,7 @@ def _calc(body: SangeunCalcBody) -> dict[str, Any]: if not sd.KEY.match(str(body.키 or "")): raise store.StoreError(400, "문서 또는 키(SK-######)") doc = store.read(sd.KIND, str(body.키))["문서"] - return calc.calc_doc(doc, body.입력값) + return calc.calc_doc(doc, body.입력값, body.보기) class FromLineBody(BaseModel): diff --git a/M02_MasterTemplete/M02_Sangeun_Views.py b/M02_MasterTemplete/M02_Sangeun_Views.py new file mode 100644 index 000000000..bc7a8584f --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_Views.py @@ -0,0 +1,83 @@ +"""M02 단가산출(SK) 보기 넷 = 셈 답 하나(PLAN 750-1 · 요구 588-1) — 화면 셈 길 답 덧칸. + +- `글` = 읽는 식(M01 `/text` 와 같은 꼴 · `master_text.lines` + `master_sheet_line.annotate`) · + SK 고친 줄(`_Over`) · 같은 입력(자동값 + 입력값) · 같은 고름(`choosing`) 위에서 지음. + 줄마다 `cid` = SK 줄 cid(`annotate` 와 같은 차례 — 호표 줄 × `_shares` 비목 · 뒤 덧줄). + 금액 · 소계 · 계 = SK `줄` · `기준` 그대로(`finish` — 별도계상 뺌 · 켜쌓기율 · 단위 환산 ÷배 · + 못 셈 줄 까닭) · 식 글(`글` · `이름글`)은 바탕 단위 셈 그대로 + 머리 `나눔`(배 ≠ 1). +- `중간` = 시험 계산 중간값(수 · 글만) — 시험 계산 = {lines: `줄`, sums: `기준`, middle: `중간`}. +계산 자리 = 서버 단독(CLAUDE.md 5장 ③) · 화면은 그리기만. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Labor_Doc as labor + +mf = labor.m01.cm.mf +import master_sheet_line as msl # noqa: E402 — scripts 길은 `m01` 이 둠 +import master_text as mtx # noqa: E402 + +_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) + + +def text_of(over: Any, key: str, values: dict, picks: Any, ids: list, eng: list) -> dict: + """`{글, 중간}` — `eng` = `run_line` 줄(별도계상 빼기 전 · 차례 맞춤용) · 못 지음 = 까닭.""" + row = over.logic(key) + auto = {k: v for k, v in labor.m01.mau.values(over, row).items() if v is not None} + given = {**auto, **labor.m01._dec(values or {})} + try: + with mf.mp.choosing(picks or None): + text = msl.annotate(over, key, given, mtx.lines(over, key, given)) + mid = mf.run(over, key, given).get("중간") or {} + except _ERRORS as e: + return {"글": {"ok": False, "reason": str(e) or type(e).__name__}, "중간": {}} + if text.get("ok") and "groups" in text and "결과" not in row: + _tag(row, ids, eng, text) + keep = (Decimal, int, float, str, bool) + return {"글": text, "중간": {k: v for k, v in mid.items() if isinstance(v, keep)}} + + +def _tag(row: dict, ids: list, eng: list, text: dict) -> None: + """글 줄마다 `cid` — `annotate` 와 같은 차례(호표 줄 × 몫 비목 → 덧줄).""" + slots = {g["비목"]: iter(g["줄"]) for g in text["groups"]} + items, adds = row.get("호표") or [], row.get("덧줄") or [] + for n, line in enumerate(eng): + if n < len(items): + parts = [p for p, _ in mtx._shares(line)] if line.get("비목") else [] + else: + parts = [adds[n - len(items)]["비목"]] if n - len(items) < len(adds) else [] + for part in parts: + one = next(slots.get(part, iter(())), None) + if one is not None and n < len(ids): + one["cid"] = ids[n] + + +def finish(ans: dict) -> dict: + """화면 답(`per_unit` 뒤) — 글 금액 · 소계 · 계 = SK 줄 · 기준 · SK 에 없는 줄(별도계상) 뺌.""" + text = ans.get("글") or {} + base = ans.get("기준") + if not text.get("ok") or "groups" not in text or not base: + return ans + sk = {x["cid"]: x for x in ans.get("줄") or [] if x.get("cid")} + groups = [] + for g in text["groups"]: + kept = [] + for one in g["줄"]: + line = sk.get(one.get("cid")) + if line is None and one.get("cid"): + continue # SK 에서 빠진 줄(별도계상) + if line is not None: + parts = line.get("비목") or {} + money = parts.get(g["비목"]) if len(parts) > 1 else line.get("금액") + one = {**one, "금액": money} + if line.get("까닭"): + one["까닭"] = line["까닭"] + kept.append(one) + if kept: + groups.append({**g, "줄": kept, "소계": base.get(g["비목"])}) + times = ans.get("화면배") + head = {"나눔": times} if times not in (None, 1) else {} + return {**ans, "글": {**text, "groups": groups, "계": base.get("계"), **head}} diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index cd2e502f0..11a0c5887 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -632,6 +632,7 @@ export function lineDoc( 덮개: patch?.덮개 ?? {}, 뺀줄: patch?.뺀줄 ?? [], 더한줄: patch?.더한줄 ?? [], + 줄모드: true, // 셈 = 일위대가 그 줄 셈(별도계상 기본 · 750-1 · 서버 `calc_doc`) }; if (row.참조) return { ...head, 바탕: { 참조: row.참조 } }; return { diff --git a/resources/master_data/scripts/master_sheet_line.py b/resources/master_data/scripts/master_sheet_line.py index d567b5554..682a82411 100644 --- a/resources/master_data/scripts/master_sheet_line.py +++ b/resources/master_data/scripts/master_sheet_line.py @@ -13,6 +13,7 @@ - `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위 (712-2 `master_unit.note`) · 없으면 「」. 줄 차례 맞춤 = `master_text.lines` 와 같은 차례(호표 줄마다 `_shares` 비목 · 뒤 덧줄). +`files` 자리 = 파일 묶음 또는 Master(M02 SK 고친 줄 `_Over` · 750-1 · `_spread` 도 그대로 넘김). """ from __future__ import annotations @@ -151,7 +152,7 @@ def annotate(files: dict, key: str, given: dict, answer: dict) -> dict: """`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로.""" if not answer.get("ok") or "groups" not in answer: return answer - master = mf.Master(files) + master = files if hasattr(files, "logic") else mf.Master(files) row = master.logic(key) per = mbs.result_per(row.get("결과단위")) answer["기준단위"] = _per_text(per) or None diff --git a/resources/master_data/scripts/master_text.py b/resources/master_data/scripts/master_text.py index 0d8f340e9..b7f5bafa0 100644 --- a/resources/master_data/scripts/master_text.py +++ b/resources/master_data/scripts/master_text.py @@ -494,7 +494,7 @@ def lines(files: dict, key: str, given: dict) -> dict: """로직 한 줄의 읽는 식 — 비목 묶음마다 줄과 소계 · 돈 아닌 로직은 「결과」 묶음 하나. 호표 줄 계산이 막히면(재료 단가 없음 따위) `row` 구조만으로 이름 식·조각을 짓고 `글`·`금액`은 비움(입력 자체가 없는 경우는 지금처럼 그대로 막힘 — `_env_of` 에서 걸림).""" - master = mf.Master(files) + master = files if hasattr(files, "logic") else mf.Master(files) # Master 째(750-1) row = master.logic(key) env = _env_of(master, row, given) marks = mc.aliases(row, master) diff --git a/resources/tester/test_717_3_sangeun_modal.py b/resources/tester/test_717_3_sangeun_modal.py index a3d74c263..eefa8201d 100644 --- a/resources/tester/test_717_3_sangeun_modal.py +++ b/resources/tester/test_717_3_sangeun_modal.py @@ -93,6 +93,7 @@ def test_화면_문서_서버_as_doc_꼴(): "needsRef": ["needsIds", ref], } ) + assert got["logic"].pop("줄모드") is True and got["ref"].pop("줄모드") is True # 750-1 assert got["logic"] == patch.as_doc({**one, "패치": drop}, whole) assert got["ref"] == patch.as_doc({**ref, "패치": COVER}, whole) assert got["bare"]["고름"] == {} and got["bare"]["덮개"] == {} diff --git a/resources/tester/test_750_1_sk_views.py b/resources/tester/test_750_1_sk_views.py new file mode 100644 index 000000000..d1e2c440f --- /dev/null +++ b/resources/tester/test_750_1_sk_views.py @@ -0,0 +1,94 @@ +"""SK 보기 넷 = 셈 답 하나(PLAN 750-1 · 요구 588-1) — 화면 셈 답 `글`(텍스트 수식) · +시험 계산(`줄` · `기준` · `중간`) = SK 줄 · 계 · 변형 일곱(기본 · 줄 빼기 · 더하기 · 덧줄 · +별도계상 · 단위 환산 · 재료 고름) + 참조 바탕 · 일위대가 줄 모달(`줄모드`) = 일위대가 그 줄 셈 · +`보기` false = 덧칸 없음 · M01 엔진 · storage 안 씀.""" + +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_Sangeun_Calc as calc +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from M02_MasterTemplete import M02_Sangeun_Views as views + +FORM = "GC000211" # 6-3-1 합판거푸집 — 재료 일곱 · 소모자재 = 재료비 % 덧줄 +PLY = "material:합판(내수 12㎜)#0" +DOC = {"키": "SK-000002", "이름": "테스트1", "단위": "㎡", "바탕": {"로직": FORM}} +ADD = {"종류": "인력", "요소": "LB000002", "이름": "보통인부(더함)", "단위": "인", "수량": 0.05} +CASES = { + "기본": {}, + "줄 빼기": {"뺀줄": ["material:각재#0"]}, + "더하기": {"더한줄": [ADD]}, + "덧줄": {"덮개": {"pct:소모자재#0": {"qty": 5}}}, + "별도계상": {"자재표시": {PLY: {"별도계상": "별도계상"}}}, + "단위 환산": {"단위": "10㎡"}, + "재료 고름": {"고름": {PLY: "MT000020"}}, +} + + +def _whole(): + return m01.loaded()[1] + + +def _answer(doc: dict, read=None, line: bool = False) -> dict: + """`calc_doc` 와 같은 길(층 검사만 뺌).""" + whole = _whole() + clean = sd.clean(doc, None, whole) + got = calc.run(clean, whole, None, read or calc.refs.read_ref, detail=True, line=line) + return labor._json(views.finish(calc.per_unit(got, clean))) + + +def _same(a: dict) -> None: + text, base = a["글"], a["기준"] + assert text["ok"] is True + money: dict[str, float] = {} + for g in text["groups"]: + assert g["소계"] == pytest.approx(base[g["비목"]]) + for one in g["줄"]: + money[one["cid"]] = money.get(one["cid"], 0) + (one["금액"] or 0) + assert set(money) == {x["cid"] for x in a["줄"]} # 글 줄 = SK 줄(cid) + for x in a["줄"]: + assert money[x["cid"]] == pytest.approx(x["금액"] or 0) + assert text["계"] == pytest.approx(base["계"]) + assert sum(x["금액"] or 0 for x in a["줄"]) == pytest.approx(base["계"]) # 시험 계산 표 합 + assert a["중간"] and all(not isinstance(v, (dict, list)) for v in a["중간"].values()) + + +@pytest.mark.parametrize("name", list(CASES)) +def test_four_views_one_answer(name): + a = _answer({**DOC, **CASES[name]}) + _same(a) + if name == "별도계상": + assert all(one.get("cid") != PLY for g in a["글"]["groups"] for one in g["줄"]) + if name == "단위 환산": + assert a["글"]["나눔"] == pytest.approx(0.1) + plain = _answer(DOC) + assert a["기준"]["계"] == pytest.approx(plain["기준"]["계"] * 10) + if name == "재료 고름": + assert a["기준"]["재료비"] != pytest.approx(_answer(DOC)["기준"]["재료비"]) + + +def test_ref_base_same(): + """참조 바탕(SK 위 SK) — 글도 바탕 SK 고친 줄 위에서.""" + parent = {**DOC, **CASES["덧줄"], "뺀줄": ["material:각재#0"]} + ref = {"층": "personal", "주인": None, "키": "SK-000002"} + _same(_answer({**DOC, "키": "SK-000003", "바탕": {"참조": ref}}, read=lambda r: parent)) + + +def test_row_modal_is_hopyo_line(): + """일위대가 줄 모달(`줄모드`) = 일위대가 그 줄 셈(별도계상 기본 · 747 한쪽 흠).""" + whole = _whole() + line = labor._json(labor.drop_apart(labor.run_line(whole, FORM, None), {})) + got = _answer({**DOC, "단위": "식", "줄모드": True}, line=True) + assert got["기준"]["계"] == pytest.approx(line["기준"]["계"]) + assert [x["이름"] for x in got["줄"]] == [x["이름"] for x in line["줄"]] + _same(got) + + +def test_no_views_flag(): + """`보기` false(바탕 원값 셈) = 금액만 · 덧칸 없음 · 금액 같음.""" + whole = _whole() + clean = sd.clean(DOC, None, whole) + got = labor._json(calc.run(clean, whole, None, detail=True, seen=False)) + assert "글" not in got and "중간" not in got + assert got["기준"]["계"] == pytest.approx(_answer(DOC)["기준"]["계"])