From 4c8a06559a588e95d37774e691e53103a567ba51 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 19:47:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(740-1):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EC=84=9C=EB=A1=9C=20=EB=8F=85=EB=A6=BD=20=E2=80=94=20=EB=84=A3?= =?UTF-8?q?=EA=B8=B0=20=ED=8E=BC=EC=B9=A8(M01=20=ED=82=A4=20=EC=A4=84=20+?= =?UTF-8?q?=20=EB=8D=AE=EA=B0=9C=20=EC=82=AC=EB=B3=B8=20=C2=B7=20=EB=B3=B8?= =?UTF-8?q?=EB=9C=AC)=20=C2=B7=20=EC=B4=88=EA=B8=B0=ED=99=94=20=EA=B8=B8?= =?UTF-8?q?=20=C2=B7=20=EC=98=AE=EA=B8=B0=EA=B8=B0=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=C2=B7=20B09=20=EB=8D=AE=EA=B0=9C=20=EC=A4=84=20=EA=B5=B3?= =?UTF-8?q?=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_Library_Flat: SK 에 SK = 더한줄 로직 한 줄에 원본 몸 통째(덮개 · 뺀줄 · 고름 · 더한줄 · % 덧줄까지 같은 금액) · SC 에 SK = 로직 줄 + 패치 · SK 에 SC = 줄마다 더한줄(수량 = 저장 수량.손 › 실무수량 › 1) · 바탕 SK · 패치 SK 참조 = 덮개 겹침 · 줄 본뜬 {층, 주인, 키, 이름, 때} - 저장 · 읽기 · 층 복사 때 서버가 펼침(못 펼친 줄 = 옛 참조 그대로) · SC 안 SC(하위 호표)는 저장 · 읽기에서 참조 그대로(엑셀 하위 호표 끝수 · B09 하위 대가) · 찾아 넣기 길만 펴 넣음 - 길: POST {kind}-flat(넣을 줄 · 못 펼침 400) · POST {kind}-reset(미리 보기 걷힐 수 · 씀) - 초기화 = 덮개 · 뺀줄 · 더한줄 단가 걷음(안쪽 넣은 몸까지) · 항목 · 수량 · 입력값 · 고름 남김 - 옮기기 = migrate(apply) · 전후 1 단위 금액 같을 때만 씀 · 공유 storage 는 브레인 - 둘째 기초 세부 셈 = 넣은 SK 몸이면 이름 없는 SK 로(Sangeun_Calc.sub_run) - B09: SC 사본 덮개 줄 = 굳힌 줄(굳힌 {키, 줄 …}) → 사본 셈 · 대가 블록이 덮개를 읽음(옛 산근 길은 덮개 무시 · 실측 6,451 대 M02 210,866) - 733 지문 = 옛 참조 줄 펼친 꼴로 대조 - 낡은 시험 고침(까닭 = 단가산출이 낀 참조가 이제 펼쳐 담김): 715 store(바탕 · 사전조합 SK · 층 복사) · 717-2a 목록 바탕 · 717-4 SC 안 SK · 719-1 원본판(원본 고쳐도 그대로) · 719-2 속 일위대가(빠진 줄 없음 · 쓰지 않는 _calc_given 지움) · 725-1 SK 안 SC · 729 SK 줄 본뜬 · 733-3 쓰는 곳 = 구조물 조합(SK 안 SK 참조 없음) · 572 지문 대조 대상 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Labor_Source.py | 40 ++ M02_MasterTemplete/M02_Item_Sangeun.py | 65 +- M02_MasterTemplete/M02_Labor_Expand.py | 9 +- M02_MasterTemplete/M02_Labor_Ref.py | 24 +- M02_MasterTemplete/M02_Library_Flat.py | 585 ++++++++++++++++++ M02_MasterTemplete/M02_Library_Same.py | 4 + M02_MasterTemplete/M02_Library_Same_Router.py | 53 ++ .../M02_MasterTemplete_Router_Layers.py | 2 + M02_MasterTemplete/M02_Sangeun_Calc.py | 8 + M02_MasterTemplete/M02_Sangeun_Doc.py | 32 +- resources/tester/test_572_library_same.py | 4 +- resources/tester/test_715_sangeun_store.py | 36 +- resources/tester/test_717_2a_sangeun_list.py | 6 +- resources/tester/test_717_4_sangeun_pick.py | 6 +- resources/tester/test_719_1_sk_freeze.py | 4 +- resources/tester/test_719_2_sk_project.py | 38 +- .../tester/test_725_1_sangeun_add_inputs.py | 11 +- resources/tester/test_729_hopyo_sk_link.py | 7 +- .../tester/test_733_3_library_same_merge.py | 63 +- resources/tester/test_740_1_library_flat.py | 366 +++++++++++ 20 files changed, 1259 insertions(+), 104 deletions(-) create mode 100644 M02_MasterTemplete/M02_Library_Flat.py create mode 100644 resources/tester/test_740_1_library_flat.py diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 621602eee..fc5ef01fe 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -308,6 +308,16 @@ class Builder: bid = self.precombo(whole, ref, doc, copies, depth + 1, use) return {"kind": "daega", "key": bid, "name": doc.get("이름") or name, "unit": doc.get("단위") or "", "qty": each} # fmt: skip + fz = one.get("굳힌") + if isinstance(fz, dict) and fz.get("키"): # 덮개 줄(740-1) = 굳힌 줄 단가산출 대가 + bid = self.sangeun( + whole, {"층": "project", "키": fz["키"]}, _sk_of(one), copies, depth + 1 + ) + if bid is None: + return {"kind": "blank", "name": name, "qty": each, "why": "덮개 줄을 못 셈"} + block = self.blocks[bid] # 블록 = 결과단위(배) 몫 · 수량 = 수량 ÷ 배 + return {"kind": "daega", "key": bid, "name": block["name"], "unit": block["unit"], + "qty": each} # fmt: skip code = str(one.get("로직") or "") if one.get("승격") is not True: # 단가산출 줄 = 산근 if not sg.is_money(whole, code): @@ -450,6 +460,36 @@ class Builder: return self.precombo(whole, ref, doc, copies, 1) +def _sk_of(one: dict) -> dict[str, Any]: + """굳힌 덮개 줄(740-1) → 굳힌 SK 꼴(`Builder.sangeun` 이 읽음) — 고름 · 자재표시 = 굳힌 줄 cid · + `원본.덮개` = 그 줄 패치 덮개(원값 = M02 에서 고친 줄의 품셈 값).""" + from M02_MasterTemplete import M02_Item_Sangeun as item_sk + from M02_MasterTemplete import M02_Sangeun_Doc as sd + + fz = one["굳힌"] + ids = sd.line_ids(fz["줄"]) + keyed = { + k: {ids[int(n)]: v for n, v in (fz.get(k) or {}).items() if int(n) < len(ids)} + for k in ("고름", "자재표시") + } + return { + "종류": "단가산출", + "키": fz["키"], + "이름": one.get("별칭") or fz["줄"].get("이름") or "", + "단위": "", + "바탕": {"로직": fz["키"]}, + "입력값": fz.get("입력값") or {}, + **({"켜쌓기율": fz["켜쌓기율"]} if fz.get("켜쌓기율") is not None else {}), + **keyed, + "덮개": {}, + "더한줄": [], + "뺀줄": [], + item_sk.ROW: fz["줄"], + "원본": {"덮개": (one.get("패치") or {}).get("덮개") or {}}, + "정체": list(fz.get("정체") or []), + } + + def _Loose(whole: Any, read: Any) -> Any: """B09 SK 속 펼침(723-4) — 사전조합 안 줄 수량 없음 = 0 값묶음(엔진 차례 · 수량만 씀 · 그 줄 값 = B09 사전조합 블록 · 이 공사에서 입력) · 그 밖 까닭은 그대로.""" diff --git a/M02_MasterTemplete/M02_Item_Sangeun.py b/M02_MasterTemplete/M02_Item_Sangeun.py index 487125d62..f84b1fd27 100644 --- a/M02_MasterTemplete/M02_Item_Sangeun.py +++ b/M02_MasterTemplete/M02_Item_Sangeun.py @@ -50,7 +50,7 @@ def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: from M02_MasterTemplete import M02_Sangeun_Calc as calc sks = sorted(rid for rid, doc in copies.items() if is_sangeun(doc)) - if not sks: + if not sks and not any(_patched(doc) for doc in copies.values()): return copies whole = m01.loaded()[1] @@ -86,19 +86,76 @@ def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: if got["켜쌓기율"] is not None: frozen["켜쌓기율"] = prices.to_json(got["켜쌓기율"]) out[rid] = frozen + for rid in sorted(rid for rid, doc in copies.items() if _patched(doc)): + out[rid] = _freeze_lines(rid, copies[rid], whole, read, taken) return out +def _patched(doc: Any) -> list[int]: + """일위대가(SC) 사본의 덮개 줄(로직 줄 + `패치` · 740-1 SK 넣기 꼴) 차례.""" + if not isinstance(doc, dict) or doc.get("종류") != refs.TITLE["precombo"]: + return [] + lines = doc.get("담은로직") or [] + return [ + n for n, x in enumerate(lines) if isinstance(x, dict) and x.get("로직") and x.get("패치") + ] + + +def _freeze_lines(rid: str, doc: dict, whole: Any, read: Any, taken: set[str]) -> dict: + """SC 사본 덮개 줄마다 고친 줄을 풀어 굳힘(740-1 · B09 대가 · 사본 셈이 덮개를 읽게) — + 줄은 그대로 두고 덧칸 `굳힌 {키, 줄, 입력값, 켜쌓기율, 고름, 자재표시, 정체}`(고름 · + 자재표시 = 굳힌 줄 차례) · 못 푼 줄 = 그대로(사본 셈이 옛 길).""" + from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Labor_Patch as patch + from M02_MasterTemplete import M02_Sangeun_Calc as calc + from M02_MasterTemplete.M02_Labor_Expand import Expand + + lines = list(doc.get("담은로직") or []) + for n in _patched(doc): + one = lines[n] + try: + got = calc.resolve(sd.clean(patch.as_doc(one, whole), None, whole), whole, read, (rid,)) + row = {**got["줄"], "호표": [dict(x) for x in got["줄"]["호표"]]} + calc._bundles(row, whole, Expand(whole, read), (rid,)) # 고정 더한줄 = 값묶음 + except sd.DocError: + continue + key = frozen_key(f"{rid}#{n}", taken) + fz = {"키": key, "줄": prices.to_json({**row, "키": key}), "정체": list(got["정체"])} + fz |= { + "입력값": prices.to_json(got["입력값"]), + "고름": got["고름"], + "자재표시": got["자재표시"], + } + if got["켜쌓기율"] is not None: + fz["켜쌓기율"] = prices.to_json(got["켜쌓기율"]) + lines[n] = {**one, FROZEN: fz} + return {**doc, "담은로직": lines} + + +FROZEN = "굳힌" + + def rows(copies: dict[str, Any]) -> list[dict[str, Any]]: - """굳힌 SK 줄 — 사본 `단가.json` `FILE` 에 넣음(이름표 차례).""" - return [doc[ROW] for _, doc in sorted(copies.items()) if is_sangeun(doc) and ROW in doc] + """굳힌 SK 줄 + SC 덮개 줄 굳힌 줄(740-1) — 사본 `단가.json` `FILE` 에 넣음(이름표 차례).""" + out = [doc[ROW] for _, doc in sorted(copies.items()) if is_sangeun(doc) and ROW in doc] + for _, doc in sorted(copies.items()): + out += [ + x[FROZEN]["줄"] + for x in doc.get("담은로직") or [] + if isinstance(x, dict) and x.get(FROZEN) + ] + return out def materials(copies: dict[str, Any]) -> list[str]: """굳힌 SK 고른 자재 키 — 단가 묶음에 담음.""" - return [ + out = [ str(mt) for doc in copies.values() if is_sangeun(doc) for mt in (doc.get("고름") or {}).values() ] + for doc in copies.values(): + lines = [x for x in doc.get("담은로직") or [] if isinstance(x, dict) and x.get(FROZEN)] + out += [str(mt) for x in lines for mt in (x[FROZEN].get("고름") or {}).values()] + return out diff --git a/M02_MasterTemplete/M02_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py index 0f18a8798..95e61acd2 100644 --- a/M02_MasterTemplete/M02_Labor_Expand.py +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -105,8 +105,13 @@ class Expand: """사전조합 안 로직 줄 — 일위대가 로직 줄과 같은 `run_line` · 배는 물량 알약 것.""" from M02_MasterTemplete import M02_Labor_Doc as labor - pill = pill or {} - if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지 + pill, fz = pill or {}, one.get("굳힌") + if isinstance(fz, dict) and fz.get("키"): # 사본 굳힌 덮개 줄(740-1 · `Item_Sangeun`) + got = labor.run_line( + self.whole, fz["키"], fz.get("입력값"), fz.get("켜쌓기율"), fz.get("고름") or None + ) + got = labor.drop_apart(got, fz.get("자재표시")) + elif one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지 got = patch.run(self.whole, one, self.read) else: got = labor.run_line( diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 3490e859b..6a3e39c10 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -155,6 +155,10 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: raise _bad("서고 문서가 묶음 아님") key = str(doc.get("키") or "") check_key(kind, key) + if here()[0] != "project": # 740-1 — M02 참조 줄 = M01 키 줄로 펼침(못 펼친 줄은 그대로) + from M02_MasterTemplete import M02_Library_Flat as flat + + doc = flat.flat_doc(kind, doc) name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip() if kind == "sangeun": from M02_MasterTemplete import M02_Sangeun_Calc as calc @@ -239,13 +243,14 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: 일위대가와 같은 길(`labor.clean` · 같은 로직 · 같은 참조 두 번 · 입력값) · 수량 기본 {손: 1}.""" from M02_MasterTemplete import M02_Labor_Doc as labor - lines = [] - for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]: + lines, raw = [], doc.get("담은로직") or [] + for one, sent in zip(labor.clean({"담은로직": raw})["담은로직"], raw): head = {k: one[k] for k in ("참조", "고정", "패치") if k in one} # 패치 = 줄 덮개(715-3) if "로직" in one: head.update(로직=one["로직"], 입력값=one["입력값"]) head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one}) head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3) + head.update(_mark(sent)) # 넣은 줄 출처(740-1 · 셈에 안 씀) lines.append({**head, "수량": one["수량"], "메모": one["메모"]}) at_ = here() me = {"층": at_[0], "주인": at_[1], "키": key} @@ -263,13 +268,20 @@ def _combo_mats(doc: dict, lines: list[dict[str, Any]]) -> list[dict[str, Any]]: 로직 자재와 같은 품목 400(`labor.no_twice`).""" from M02_MasterTemplete import M02_Labor_Doc as labor - mats = labor.clean({"자재": doc.get("자재") or []})["자재"] + raw = doc.get("자재") or [] + mats = [{**m, **_mark(s)} for m, s in zip(labor.clean({"자재": raw})["자재"], raw)] if mats: got = {"담은로직": lines, "자재": mats} labor.no_twice(got, labor.calc(got, {})) return mats +def _mark(sent: Any) -> dict[str, Any]: + """줄 `본뜬`(740-1 · 펼쳐 넣은 출처 {층, 주인, 키, 이름, 때}) — 묶음만 · 셈에 안 씀.""" + got = sent.get("본뜬") if isinstance(sent, dict) else None + return {"본뜬": got} if isinstance(got, dict) else {} + + def _no_loop(ref: Ref, trail: list[Ref]) -> None: """저장 때 순환 · 깊이 — 아래로 따라가 처음 문서를 다시 만나면 422 「A → B → A」.""" if len(trail) > DEPTH: @@ -406,8 +418,12 @@ def _reused(kind: str, doc: dict[str, Any], ref: Ref) -> str | None: def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]: """복사(원본 층 `source` → 지금 층) — 지금 층이 못 읽는 참조는 그 문서를 지금 층에 새 키로 같이 복사(`본뜬` = 원래 참조) · 참조를 새 키로 바꿈 · 같은 원본 같은 내용 사본이 이미 있으면 - 그 키(733-2). 원본 층 읽기 권한은 부르는 쪽 몫.""" + 그 키(733-2). 원본 층 읽기 권한은 부르는 쪽 몫 · 단가산출 · 일위대가 = 먼저 펼침(740-1).""" target, done = here(), {} + if doc.get("종류") in (TITLE["sangeun"], TITLE["precombo"]): + from M02_MasterTemplete import M02_Library_Flat as flat + + doc = flat.flat_doc("sangeun" if doc["종류"] == TITLE["sangeun"] else "precombo", doc) def move(ref: Ref, depth: int) -> Ref: if readable(target, ref): diff --git a/M02_MasterTemplete/M02_Library_Flat.py b/M02_MasterTemplete/M02_Library_Flat.py new file mode 100644 index 000000000..f8afd86a2 --- /dev/null +++ b/M02_MasterTemplete/M02_Library_Flat.py @@ -0,0 +1,585 @@ +"""M02 단가산출(SK) · 일위대가(SC) 펼침 · 초기화 · 옮기기(PLAN 740-1 · 요구 581 · 분석 579-2). + +서로 독립 — 연결 고리 = M01 키만. M02 문서를 넣으면(찾아 넣기 `-flat` · 저장 · 읽기 · 층 사이 +복사) 그 문서 몸을 M01 키 줄 + 그 문서 덮개 사본으로 펼쳐 담음 → 원본이 나중에 바뀌어도 +넣은 쪽 금액 그대로. 줄 `본뜬 {층, 주인, 키, 이름, 때}` = 출처 정보(셈에 안 씀). + SK 에 SK 더한줄 로직 한 줄 = 원본 몸 통째(바탕 기초 · 입력값 · 덮개 · 뺀줄 · 고름 · 자재표시 · + 켜쌓기율 · 더한줄 — `M02_Sangeun_Doc.BODY` · % 덧줄까지 같은 금액) · + 수량 = 넣은 수 ÷ 배(728 환산) · 바탕 없는 SK = 그 더한줄 × 넣은 수 + SK 에 SC SC 줄마다 더한줄(로직 줄 = 로직 한 줄 + 패치 몸 · 고정 · 자재 = 재료) · + 수량 = 넣은 수 × 줄 수량(저장 `수량.손` › 실무수량 › 1 · 742-1) ÷ 배(물량 알약 배) + SC 에 SK 로직 줄 하나 + 패치(= SK 몸) · 별칭 = SK 이름 · 같은 자리(안 줄 알약 키 그대로) · + 바탕 없는 SK · 단위 환산 SK = 못 넣음(값 단위가 달라짐) + SC 에 SC 찾아 넣기 길만 — 그 자리에 안 줄 · 자재를 펴 넣음 · 줄 수량 · 본뜬 실무수량 = + 바깥 × 안 · 같은 기초 · 고정 · 자재가 겹치면 못 넣음(일위대가 줄 짝 = 로직 키) · + 저장 · 읽기 · 옮기기는 하위 호표 참조 그대로(엑셀 하위 호표 끝수 · B09 하위 대가) + 바탕 SK 참조 · 패치 있는 SK 참조 줄 = 위 덮개를 아래 덮개에 겹쳐 한 벌(`compose`) +못 펼친 줄 = 저장 · 읽기 · 옮기기에선 옛 참조 그대로(값 그대로) · 넣기 길(`strict`)에선 400 까닭. +초기화(`reset`) = 덮개 · 뺀줄 · 더한줄 단가 걷음(안쪽 넣은 몸까지) · 항목 · 수량 · 입력값 · 고름 · +자재표시 · 켜쌓기율 · 고정 줄 · 기본정보 남김 → 값 = 연결된 M01 키 값. +옮기기(`migrate`) = 층 문서 펼쳐 다시 씀 — 전후 1 단위 금액 같을 때만(안 줄 수량 모두 1 로 대조) · +쓰는 구조물 조합이 있는 SC 펼쳐 넣기(차례 바뀜)는 안 옮김 · 공유 storage 는 브레인이 돌림. +""" + +from __future__ import annotations + +import json +import re +import time +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +ADD = re.compile(r"^add:#(\d+)$") +FAIL = (ArithmeticError, KeyError, TypeError, ValueError) # 못 펼침(DocError · FormulaError 포함) +EMPTY = (None, {}, [], "") +Read = Any + + +def _whole() -> Any: + from M01_MasterData import M01_MasterData_Store as m01 + + return m01.loaded()[1] + + +def _d(value: Any) -> Decimal: + return Decimal(str(value)) + + +def mark(ref: refs.Ref, doc: dict) -> dict[str, Any]: + """줄 출처 — 펼친 원본 {층, 주인, 키, 이름, 때(UTC)}.""" + when = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + return { + **{k: ref.get(k) for k in ("층", "주인", "키")}, + "이름": doc.get("이름") or "", + "때": when, + } + + +def _read(ref: refs.Ref, read: Read, trail: tuple) -> tuple[dict, tuple]: + rid = refs.ref_id(ref) + if rid in trail: + raise sd.DocError("순환 — " + " → ".join(t.rsplit("|", 1)[-1] for t in (*trail, rid)), 422) + if len(trail) >= refs.DEPTH: + raise sd.DocError(f"참조가 {refs.DEPTH}단을 넘음", 422) + got = read(ref) + if not isinstance(got, dict): + raise sd.DocError(f"없는 참조 「{ref.get('키')}」") + return got, (*trail, rid) + + +# ── SK ──────────────────────────────────────────────────────────────── +def flat_sk(doc: dict, read: Read, whole: Any, trail: tuple = (), strict: bool = True) -> dict: + """SK 문서 → M01 키만(더한줄 참조 펼침 · 바탕 참조 겹침) · `strict` False = 못 펼친 줄 + 그대로.""" + adds = [] + for one in doc.get("더한줄") or []: + ref = one.get("참조") if isinstance(one, dict) else None + if not isinstance(ref, dict): + adds.append(one) + continue + try: + adds += into_sk(ref, one.get("수량", 1), read, whole, trail) + except FAIL as e: + if strict: + raise sd.DocError(f"「{ref.get('키')}」 · {e}") from e + adds.append(one) + out = {**doc, "더한줄": adds} + base = doc.get("바탕") or {} + if isinstance(base.get("참조"), dict): + try: + under, path = _read(base["참조"], read, trail) + under = flat_sk(sd.clean(under, None, whole), read, whole, path) + out = compose(under, out, whole) + except FAIL: + if strict: + raise + return out + + +def into_sk(ref: refs.Ref, qty: Any, read: Read, whole: Any, trail: tuple = ()) -> list[dict]: + """SK 에 넣을 더한줄 — 넣는 문서(SK · SC)를 M01 키 줄로(`qty` = 그 문서 단위 수).""" + src, path = _read(ref, read, trail) + kind, q, tag = refs.kind_of(ref.get("키")), _d(qty), mark(ref, src) + src = sd.clean(src, None, whole) if kind == "sangeun" else src + if kind == "sangeun": + return sk_adds(flat_sk(src, read, whole, path), q, whole, tag) + if kind == "precombo": + return sc_adds(flat_sc(src, read, whole, path), q, whole, tag) + raise sd.DocError("옛 단가승격은 펼치지 않음") + + +def filled_adds(doc: dict, whole: Any) -> list[dict]: + """더한줄 로직 빈 입력 = 그 SK 입력값(셈 `_fill` 의 공통 옵션 몫) — 옮겨도 같은 옵션.""" + common, out = doc.get("입력값") or {}, [] + for one in doc.get("더한줄") or []: + if one.get("종류") == "로직": + try: + names = [str(s.get("이름")) for s in whole.logic(one["로직"]).get("입력") or []] + except Exception: # noqa: BLE001 — 없는 로직 = 검사 · 셈이 까닭 + names = [] + mine = dict(one.get("입력값") or {}) + for n in names: + if mine.get(n) in EMPTY and common.get(n) not in EMPTY: + mine[n] = common[n] + one = {**one, "입력값": mine} + out.append(one) + return out + + +def _scaled(one: dict, q: Decimal, tag: dict) -> dict: + return {**one, "수량": sd.plain(_d(one.get("수량") or 0) * q), "본뜬": tag} + + +def sk_adds(y: dict, q: Decimal, whole: Any, tag: dict) -> list[dict]: + """펼친 SK → 더한줄 — 바탕 기초 있으면 한 줄(몸 통째) · 없으면 그 더한줄 × q.""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + adds = filled_adds(y, whole) + code = (y.get("바탕") or {}).get("로직") + if not code: + return [_scaled(one, q, tag) for one in adds] + row = whole.logic(code) + names = {str(s.get("이름")) for s in row.get("입력") or []} + unit = calc.base_unit(row) + entry: dict[str, Any] = { + "종류": "로직", + "로직": code, + "이름": y.get("이름") or code, + "단위": unit[0] if unit else "", + "수량": sd.plain(q / calc.ratio_of(y, row, whole)), + "입력값": {k: v for k, v in (y.get("입력값") or {}).items() if k in names}, + } + entry |= { + k: y[k] for k in ("덮개", "뺀줄", "고름", "자재표시", "켜쌓기율") if y.get(k) not in EMPTY + } + return [{**entry, **({"더한줄": adds} if adds else {}), "본뜬": tag}] + + +def _hand(one: dict, kept: Any) -> Decimal: + """SC 안 줄 수량 — 저장 `수량.손`(742-1 사용자 입력) › 실무수량 › 1.""" + raw = one.get("수량") + got = raw.get("손") if isinstance(raw, dict) else None + return _d(got if got not in EMPTY else kept if kept not in EMPTY else 1) + + +def sc_adds(z: dict, q: Decimal, whole: Any, tag: dict) -> list[dict]: + """펼친 SC → SK 더한줄 — 줄 수량 = q × `_hand` · 로직 줄 ÷ 물량 알약 배.""" + from M02_MasterTemplete import M02_Labor_Patch as patch + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + hands = (z.get("본뜬") or {}).get("실무수량") or {} + lines, mats = hands.get("줄") or {}, hands.get("자재") or {} + out = [] + for at, one in enumerate(z.get("담은로직") or []): + n = q * _hand(one, lines.get(str(at))) + if one.get("로직"): + row = whole.logic(one["로직"]) + body = patch.as_doc(one, whole) + entry = { + "종류": "로직", + "로직": one["로직"], + "이름": one.get("별칭") or row.get("이름") or "", + } + entry |= {"수량": sd.plain(n / calc.times_of(row)), "입력값": body["입력값"]} + keep = ("덮개", "뺀줄", "고름", "자재표시", "켜쌓기율", "더한줄") + entry |= {k: body[k] for k in keep if body.get(k) not in EMPTY} + elif fixed.is_fixed(one) and not refs.is_rate(one): + entry = { + "고정": one["고정"], + "이름": one["고정"].get("이름") or "", + "수량": sd.plain(n), + } + else: + raise sd.DocError("비율 고정 줄 · 참조 줄은 단가산출에 못 담음") + out.append({**entry, "본뜬": tag}) + for mat in z.get("자재") or []: + code = str(mat.get("코드") or "") + if not code.startswith("MT"): + raise sd.DocError(f"개인 자재 「{code}」 는 단가산출에 못 담음") + n = q * _hand(mat, mats.get(code)) + one = { + "종류": "재료", + "요소": code, + "이름": mat.get("이름") or "", + "단위": mat.get("단위") or "", + } + apart = {"별도계상": True} if mat.get("별도계상") == "별도계상" else {} + out.append({**one, "수량": sd.plain(n), **apart, "본뜬": tag}) + return out + + +# ── 겹침(바탕 SK · 패치 SK 참조) ───────────────────────────────────────── +def _merge(a: dict | None, b: dict) -> dict: + """아래 덮개 a 위에 위 덮개 b — 품 qty 바꿈 · 배율 곱 · 단가 위 것.""" + if not a: + return dict(b) + out = {k: v for x in (a, b) for k, v in x.items() if k in sd.PARTS} + if "qty" in b: + out["qty"] = b["qty"] + elif "배율" in b and "qty" in a: + out["qty"] = sd.plain(_d(a["qty"]) * _d(b["배율"])) + elif "배율" in b: + out["배율"] = sd.plain(_d(a.get("배율", 1)) * _d(b["배율"])) + else: + out |= {k: a[k] for k in ("qty", "배율") if k in a} + return out | ({"원값": a["원값"]} if "원값" in a else {}) + + +def _cover_add(add: dict, cover: dict) -> dict: + """위 덮개를 아래 더한줄 항목에 — 수량 · 그 비목 단가만(그 밖 = 못 겹침).""" + out = dict(add) + if "qty" in cover: + out["수량"] = cover["qty"] + elif "배율" in cover: + out["수량"] = sd.plain(_d(add.get("수량") or 0) * _d(cover["배율"])) + prices = {sd.PARTS[k]: cover[k] for k in sd.PARTS if k in cover} + if prices and (add.get("종류") not in sd.ADD_PART or set(prices) != {add.get("비목")}): + raise sd.DocError("더한줄 단가 덮개를 못 겹침") + if prices: + out["단가"] = prices[add["비목"]] + return out + + +def compose(under: dict, over: dict, whole: Any) -> dict: + """펼친 바탕 SK `under` 위에 `over`(덮개 · 뺀줄 · 고름 · 자재표시 · 더한줄 · 입력값) → 한 벌 — + 위 줄 정체를 아래 바탕 정체로 옮김(`patched` 의 `정체` 짝) · 짝 잃은 위 고침은 버림(값 없음).""" + base = under.get("바탕") + row = whole.logic(base["로직"]) if (base or {}).get("로직") else sd.empty_row(under) + got = sd.patched(row, under) + where = dict(zip(sd.line_ids(got["줄"]), got["정체"])) + covers = {c: dict(v) for c, v in (under.get("덮개") or {}).items()} + drop, picks = list(under.get("뺀줄") or []), dict(under.get("고름") or {}) + marks, adds, gone = ( + dict(under.get("자재표시") or {}), + [dict(x) for x in under.get("더한줄") or []], + set(), + ) + + def to(cid: str) -> tuple[str, Any] | None: + o = where.get(cid) + hit = ADD.match(o) if o else None + return None if o is None else ("add", int(hit[1])) if hit else ("base", o) + + for cid, cover in (over.get("덮개") or {}).items(): + if t := to(cid): + if t[0] == "base": + covers[t[1]] = _merge(covers.get(t[1]), cover) + else: + adds[t[1]] = _cover_add(adds[t[1]], cover) + for cid in over.get("뺀줄") or []: + if (t := to(cid)) and t[0] == "add": + gone.add(t[1]) + elif t and t[1] not in drop: + drop.append(t[1]) + for name, mine, into in ( + ("고름", over.get("고름"), picks), + ("자재표시", over.get("자재표시"), marks), + ): + for cid, value in (mine or {}).items(): + if (t := to(cid)) and t[0] == "add": + raise sd.DocError(f"더한줄 {name}를 못 겹침") + if t: + into[t[1]] = value + rate = over.get("켜쌓기율", under.get("켜쌓기율")) + out = {**over, "바탕": base, "덮개": covers, "뺀줄": drop, "고름": picks, "자재표시": marks} + out["더한줄"] = [a for k, a in enumerate(adds) if k not in gone] + list( + over.get("더한줄") or [] + ) + out["입력값"] = {**(under.get("입력값") or {}), **(over.get("입력값") or {})} + return out | ({"켜쌓기율": rate} if rate is not None else {}) + + +# ── SC ──────────────────────────────────────────────────────────────── +def sk_line(y: dict, one: dict, whole: Any, tag: dict) -> dict: + """펼친 SK → 일위대가 로직 줄 하나(+ 패치) — 옛 참조 줄 `one` 의 별칭 · 메모 · 수량 · 패치 + 이어받음.""" + from M02_MasterTemplete import M02_Labor_Patch as patch + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + if one.get(patch.FIELD): + y = compose(y, {"입력값": {}, **one[patch.FIELD]}, whole) + code = (y.get("바탕") or {}).get("로직") + if not code: + raise sd.DocError("바탕 기초 없는 단가산출은 일위대가 줄로 못 담음 — 그 안 줄을 따로 넣음") + row = whole.logic(code) + if calc.ratio_of(y, row, whole) != calc.times_of(row): + raise sd.DocError("단위 환산 단가산출은 일위대가 줄로 못 담음(값 단위가 바뀜)") + ids = sd.line_ids(row) + names = {str(s.get("이름")) for s in row.get("입력") or []} + at = {c: str(n) for n, c in enumerate(ids)} + body = {k: y[k] for k in ("덮개", "뺀줄") if y.get(k) not in EMPTY} + body |= {"더한줄": adds} if (adds := filled_adds(y, whole)) else {} + line: dict[str, Any] = { + "로직": code, + "입력값": {k: v for k, v in (y.get("입력값") or {}).items() if k in names}, + "별칭": one.get("별칭") or y.get("이름"), + "메모": one.get("메모"), + "수량": one.get("수량"), + } + for name in ("고름", "자재표시"): + if got := {at[c]: v for c, v in (y.get(name) or {}).items() if c in at}: + line[name] = got + if y.get("켜쌓기율") is not None: + line["켜쌓기율"] = y["켜쌓기율"] + return {**line, **({patch.FIELD: body} if body else {}), "본뜬": tag} + + +def _ids(doc: dict) -> list[Any]: + from M02_MasterTemplete import M02_Labor_Doc as labor + + lines = [labor.line_id(x) for x in doc.get("담은로직") or [] if isinstance(x, dict)] + return lines + [f"자재|{m.get('코드')}" for m in doc.get("자재") or [] if isinstance(m, dict)] + + +def _twice(doc: dict) -> str | None: + seen: set[str] = set() + for one in _ids(doc): + key = json.dumps(one, ensure_ascii=False, sort_keys=True, default=str) + if key in seen: + return str(one) + seen.add(key) + return None + + +def flat_sc(doc: dict, read: Read, whole: Any, trail: tuple = (), strict: bool = True) -> dict: + """SC 문서 → M01 키만 — SK 참조 = 로직 줄 · SC 참조 = 안 줄 펴 넣음(`strict` 만 · 넣기 길) · + `strict` False(저장 · 읽기 · 옮기기) = SC 참조 그대로(하위 호표 · 엑셀 끝수 단계) · 못 펼친 + 줄 그대로.""" + hands = (doc.get("본뜬") or {}).get("실무수량") + old_q = dict((hands or {}).get("줄") or {}) + new_q, mat_q = {}, dict((hands or {}).get("자재") or {}) + lines, mats = [], list(doc.get("자재") or []) + for at, one in enumerate(doc.get("담은로직") or []): + ref = one.get("참조") if isinstance(one, dict) else None + kind = refs.kind_of(ref.get("키")) if isinstance(ref, dict) else None + here_q = old_q.get(str(at)) + if kind != "sangeun" and not (strict and kind == "precombo"): # 하위 호표 = 참조 그대로 + new_q |= {str(len(lines)): here_q} if here_q is not None else {} + lines.append(one) + continue + try: + src, path = _read(ref, read, trail) + tag = mark(ref, src) + if kind == "sangeun": + y = flat_sk(sd.clean(src, None, whole), read, whole, path) + put, put_m = [sk_line(y, one, whole, tag)], [] + put_q = {"0": here_q} if here_q is not None else {} + else: + z = flat_sc(src, read, whole, path) + put, put_q, put_m = _spread(z, one, here_q, tag) + rest = (doc.get("담은로직") or [])[at + 1 :] + trial = {"담은로직": [*lines, *put, *rest], "자재": [*mats, *put_m]} + if twice := _twice(trial): + raise sd.DocError(f"같은 줄이 겹침 「{twice}」 — 펼쳐 넣지 못함") + except FAIL as e: + if strict: + raise sd.DocError(f"「{ref.get('키')}」 · {e}") from e + new_q |= {str(len(lines)): here_q} if here_q is not None else {} + lines.append(one) + continue + new_q |= {str(len(lines) + int(n)): v for n, v in put_q.items()} + mat_q |= {m["코드"]: m.pop("_q") for m in put_m if "_q" in m} + lines, mats = [*lines, *put], [*mats, *put_m] + out = {**doc, "담은로직": lines, "자재": mats} + if hands is not None: + out["본뜬"] = {**doc["본뜬"], "실무수량": {**hands, "줄": new_q, "자재": mat_q}} + return out + + +def _spread(z: dict, one: dict, here_q: Any, tag: dict) -> tuple[list, dict, list]: + """안 SC 줄 · 자재 펴기 — 실무수량 = 바깥 × 안(둘 다 있을 때만) · 비율 고정 줄 있으면 못 폄 + (밑 = 그 SC 합 → 바깥 합으로 바뀜).""" + if any(refs.is_rate(x) for x in z.get("담은로직") or []): + raise sd.DocError("비율 고정 줄이 있는 일위대가는 펴 넣지 못함(밑 합이 바뀜)") + hands = (z.get("본뜬") or {}).get("실무수량") or {} + inner, inner_m = hands.get("줄") or {}, hands.get("자재") or {} + outer = (one.get("수량") or {}).get("손") if isinstance(one.get("수량"), dict) else None + + def times( + x: dict, + ) -> Any: # 줄 수량(742-1) = 바깥 × 안 · 둘 중 하나라도 없으면 쓰는 쪽 몫(null) + inner = (x.get("수량") or {}).get("손") if isinstance(x.get("수량"), dict) else None + both = outer not in EMPTY and inner not in EMPTY + return {"손": sd.plain(_d(outer) * _d(inner))} if both else None + + lines = [{**x, "수량": times(x), "본뜬": tag} for x in z.get("담은로직") or []] + if len(lines) == 1 and one.get("별칭"): + lines[0]["별칭"] = one["별칭"] + both = here_q is not None + qty = { + str(n): sd.plain(_d(here_q) * _d(inner[str(n)])) + for n in range(len(lines)) + if both and inner.get(str(n)) is not None + } + mats = [] + for m in z.get("자재") or []: + extra = ( + {"_q": sd.plain(_d(here_q) * _d(inner_m[m["코드"]]))} + if both and m.get("코드") in inner_m + else {} + ) + mats.append({**m, "본뜬": tag, **extra}) + return lines, qty, mats + + +# ── 문 ──────────────────────────────────────────────────────────────── +def flat_doc(kind: str, doc: Any, read: Read = refs.read_ref, strict: bool = False) -> Any: + """층 문서 하나 펼침(저장 · 읽기 · 복사 · 옮기기) — 참조 없으면 받은 그대로.""" + if not isinstance(doc, dict) or kind not in refs.PREFIX or not refs.refs_in(doc): + return doc + whole = _whole() + if kind == "sangeun": + return flat_sk(doc, read, whole, (), strict) + return flat_sc(doc, read, whole, (), strict) + + +def flat_lines(kind: str, ref: Any, qty: Any = 1, doc: Any = None) -> dict[str, Any]: + """찾아 넣기(`-flat` 길) — `kind` 문서에 `ref` 를 넣을 줄 · 못 펼침 = StoreError 400 · + SC = 지금 화면 문서 `doc` 과 겹치면 400.""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + at = refs.here() + try: + ref = refs.check_ref(ref, "넣을 문서", at) + whole = _whole() + if kind == "sangeun": + return {"더한줄": into_sk(ref, qty if qty not in EMPTY else 1, refs.read_ref, whole)} + got = flat_sc({"담은로직": [{"참조": ref, "수량": None}]}, refs.read_ref, whole) + host = doc if isinstance(doc, dict) else {} + trial = {k: [*(host.get(k) or []), *got[k]] for k in ("담은로직", "자재")} + if twice := _twice(trial): + raise sd.DocError(f"이미 담은 줄과 겹침 「{twice}」") + return {"담은로직": got["담은로직"], "자재": got["자재"]} + except FAIL as e: + raise store.StoreError(getattr(e, "status", 400), str(e)) from e + + +# ── 초기화 ──────────────────────────────────────────────────────────── +def _reset_adds(adds: list) -> tuple[list, int]: + out, n = [], 0 + for one in adds or []: + one = dict(one) if isinstance(one, dict) else one + if not isinstance(one, dict): + out.append(one) + continue + if "단가" in one: + one.pop("단가") + n += 1 + n += len(one.pop("덮개", None) or {}) + len(one.pop("뺀줄", None) or []) + one.pop("바탕판", None) + if one.get("더한줄"): + one["더한줄"], k = _reset_adds(one["더한줄"]) + n += k + out.append(one) + return out, n + + +def reset(kind: str, doc: dict) -> tuple[dict, int]: + """[초기화] — (걷은 문서, 걷힌 고침 수) · 먼저 펼침(옛 참조 줄도 M01 키로).""" + from M02_MasterTemplete import M02_Labor_Patch as patch + + doc = flat_doc(kind, doc) + if kind == "sangeun": + adds, n = _reset_adds(doc.get("더한줄") or []) + n += len(doc.get("덮개") or {}) + len(doc.get("뺀줄") or []) + return {**doc, "덮개": {}, "뺀줄": [], "더한줄": adds}, n + lines, n = [], 0 + for one in doc.get("담은로직") or []: + body = one.get(patch.FIELD) if isinstance(one, dict) else None + if not body: + lines.append(one) + continue + adds, k = _reset_adds(body.get("더한줄") or []) + n += k + len(body.get("덮개") or {}) + len(body.get("뺀줄") or []) + rest = {**({"더한줄": adds} if adds else {})} + line = {x: v for x, v in one.items() if x != patch.FIELD} + lines.append({**line, **({patch.FIELD: rest} if rest else {})}) + return {**doc, "담은로직": lines}, n + + +def reset_doc(kind: str, key: str | None, doc: Any, look: bool, version: str | None) -> dict: + """`-reset` 길 — 미리 보기 = 걷힐 수 · 아니면 지금 층에 씀(판이 오면 그 판일 때만).""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + if doc is None: + if not key: + raise store.StoreError(400, "문서 또는 키") + found = store.read(kind, key) + doc, version = found["문서"], version or found["판"] + if not isinstance(doc, dict): + raise store.StoreError(400, "문서가 묶음 아님") + got, n = reset(kind, doc) + if look: + return {"걷힘": n, "문서": got} + name = str(got.get("키") or key or "") + if not version: + version = store.read(kind, name)["판"] + store.write(kind, name, version, got) + return {"걷힘": n, "문서": store.read(kind, name)["문서"]} + + +# ── 옮기기 ──────────────────────────────────────────────────────────── +def _value(ref: refs.Ref, read: Read, whole: Any) -> Any: + from M02_MasterTemplete.M02_Labor_Expand import Expand, inner_pills + + given = {p["키"]: 1 for p in inner_pills(ref, read)} + got = Expand(whole, read, given)(ref).get("기준") + return None if got is None else {k: round(float(v), 6) for k, v in got.items()} + + +def migrate(apply: bool = False) -> list[dict[str, Any]]: + """저장소 층(마스터 · 회사 · 개인) SK · SC 의 M02 참조 줄을 펼쳐 다시 씀 — 줄마다 결과 + {어디, 키, 펼침, 같음, 씀, 까닭} · `apply` False = 보기만.""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + from M02_MasterTemplete import M02_Template_Layers as layers + + whole, out = _whole(), [] + for kind in ("sangeun", "precombo"): + for where_, path in layers.layer_files(kind): + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + if not refs.refs_in(doc): + continue + layer, owner = refs.where(path.parent.parent) + ref = {"층": layer, "주인": owner, "키": path.stem} + got = flat_doc(kind, doc) + left = [ + r.get("키") for r in refs.refs_in(got) if refs.kind_of(r.get("키")) != "promote" + ] + row = { + "어디": where_, + "키": path.stem, + "펼침": left == [], + "남은참조": left, + "씀": False, + } + + def mine(r: refs.Ref, _got: dict = got, _rid: str = refs.ref_id(ref)) -> dict | None: + return _got if refs.ref_id(r) == _rid else refs.read_ref(r) + + row["전"], row["후"] = _value(ref, refs.read_ref, whole), _value(ref, mine, whole) + row["같음"] = row["전"] == row["후"] + used = kind == "precombo" and len(got["담은로직"]) != len(doc.get("담은로직") or []) + if used and refs.users(ref): + row["까닭"] = "쓰는 곳 있는 일위대가 — 안 줄 차례가 바뀌어 안 옮김" + elif not row["같음"]: + row["까닭"] = "전후 금액 다름 — 안 옮김" + elif apply and got != doc: + with store.at(path.parent.parent): + store.write(kind, path.stem, store.read(kind, path.stem)["판"], got) + row["씀"] = True + out.append(row) + return out + + +if __name__ == "__main__": # 브레인 — `python -m M02_MasterTemplete.M02_Library_Flat [--apply]` + import sys + + for one in migrate("--apply" in sys.argv): + print(json.dumps(one, ensure_ascii=False, default=str)) diff --git a/M02_MasterTemplete/M02_Library_Same.py b/M02_MasterTemplete/M02_Library_Same.py index 991ed02b4..744ea5814 100644 --- a/M02_MasterTemplete/M02_Library_Same.py +++ b/M02_MasterTemplete/M02_Library_Same.py @@ -80,6 +80,10 @@ class Same: def finger(self, kind: str, doc: Any, trail: tuple = ()) -> str | None: if not isinstance(doc, dict): return None + if not trail: # 740-1 — 옛 참조 줄 = 펼친 꼴로 대조(펼쳐 담은 문서와 같은 지문) + from M02_MasterTemplete import M02_Library_Flat as flat + + doc = flat.flat_doc(kind, doc) body = self._sk(doc, trail) if kind == "sangeun" else self._sc(doc, trail) if kind == "sangeun" and not body.get("바탕") and not body.get("더한줄"): return None diff --git a/M02_MasterTemplete/M02_Library_Same_Router.py b/M02_MasterTemplete/M02_Library_Same_Router.py index e6ed4575f..390350545 100644 --- a/M02_MasterTemplete/M02_Library_Same_Router.py +++ b/M02_MasterTemplete/M02_Library_Same_Router.py @@ -11,6 +11,10 @@ POST …/undo {키} — `_합친것/` 에서 되돌림 · 옮긴 참조도 되 `M02_Library_Delete.undo`(문서 · 자리 · 고친 문서 앞판) POST /layers/{층}/{kind}-tree/delete-check {대상} — 나무 지우기 미리 보기(735-1) · 지울 문서 · 쓰는 줄 +POST /layers/{층}/{kind}-flat {참조, 수량?, 문서?} — 넣을 줄(740-1 · M02 문서 → M01 키 줄 + 덮개 + 사본 · 본뜬 · `M02_Library_Flat.flat_lines`) · 층 읽기 권한 · 못 펼침 400 +POST /layers/{층}/{kind}-reset {키 | 문서, 미리보기, 판?} — [초기화](740-1) · 걷힐 수 · 문서 · + 미리보기 false = 그 층에 씀(쓰기 권한) 지움 = 이 층 문서만 · 남김 = 이 층 또는 위 층(위 층과 같은 아래 층 문서 = 남김 = 위 층 키) · 판 = 이 층 서고 나무 판(`{종류}_폴더.json`) · 권한 = 그 층 쓰기 · 프로젝트 사본 = 굳힌 값이라 안 건드림 · 지문 = `M02_Library_Same`(733-1) 읽기만. @@ -31,6 +35,7 @@ from common_util.common_util_auth import verify_session from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Library_Delete as deleting +from M02_MasterTemplete import M02_Library_Flat as flat from M02_MasterTemplete import M02_Library_Same as same from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Precombo_Folders as pre_folders @@ -380,3 +385,51 @@ def library_same_undo( ) -> dict: """되돌림 — `_합친것/<키>` 문서 · 나무 자리 · 옮긴 참조.""" return _run(undo, session, _root(session, layer, owner, write=True), kind, body.키) + + +class FlatBody(BaseModel): + """`참조` = 넣을 문서 {층, 주인, 키} · `수량` = 그 문서 단위 수(SK 만 · 기본 1) · `문서` = 지금 + 화면 문서(SC 겹침 검사).""" + + 참조: dict[str, Any] + 수량: float | None = None + 문서: dict[str, Any] | None = None + + +class ResetBody(BaseModel): + 키: str | None = None + 문서: dict[str, Any] | None = None + 미리보기: bool = True + 판: str | None = None + + +def _in(folder: Path, fn, *args): + with store.at(folder): + return fn(*args) + + +@router.post("/layers/{layer}/{kind}-flat") +def library_flat( + layer: Layer, + kind: Kind, + body: FlatBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """찾아 넣기(740-1) — 넣을 줄(SK `더한줄` · SC `담은로직` + `자재`) · 저장 안 함.""" + folder = _root(session, layer, owner) + return _run(_in, folder, flat.flat_lines, kind, body.참조, body.수량, body.문서) + + +@router.post("/layers/{layer}/{kind}-reset") +def library_reset( + layer: Layer, + kind: Kind, + body: ResetBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[초기화](740-1) — 덮개 · 뺀줄 · 더한줄 단가 걷음 → M01 키 값 · `{걷힘, 문서}`.""" + folder = _root(session, layer, owner, write=not body.미리보기) + args = (kind, body.키, body.문서, body.미리보기, body.판) + return _run(_in, folder, flat.reset_doc, *args) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 03633c2ac..4c1df9130 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -27,6 +27,7 @@ from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool, run_with_connection from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_Library_Flat as flat from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -313,6 +314,7 @@ async def read_layer_template( found["문서"] = refs.filled(kind, found["문서"]) # 옛 서고 문서 구분 · 상세구분(51-12) if layer != "project": # 옛 단가승격 참조 줄 = 새 꼴(134-2 · 프로젝트 사본은 그대로) found["문서"] = await asyncio.to_thread(refs.unpromote, kind, found["문서"]) + found["문서"] = await asyncio.to_thread(flat.flat_doc, kind, found["문서"]) # 740-1 return found diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 307d5205f..4bd2ae5fd 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -216,6 +216,14 @@ def sub_run(one: dict, whole: Any, patched: bool = True) -> dict[str, Any]: row = whole.logic(code) except Exception as e: # noqa: BLE001 — 엔진 FormulaError return {"기준": None, "까닭": str(e), "정체": [], "짝잃음": [], "바탕판": ""} + if any(one.get(k) not in (None, {}, []) for k in sd.BODY): # 넣은 SK 몸(740-1) = 이름 없는 SK + keep = ("입력값", *sd.BODY, *(("덮개", "뺀줄") if patched else ())) + doc = {"키": "SK-000000", "이름": "줄", "단위": "식", "바탕": {"로직": code}} + doc |= {"덮개": {}, "뺀줄": [], "더한줄": [], **{k: one[k] for k in keep if k in one}} + try: + return {**run(doc, whole), "바탕판": fingerprint(row)} + except sd.DocError as e: + return {"기준": None, "까닭": str(e), "정체": [], "짝잃음": [], "바탕판": ""} got = sd.patched(row, one if patched else {}) over = _Over(whole, code, {**got["줄"], "키": code}) out = labor.run_line(over, code, one.get("입력값"), None, got["고름"] or None) diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index 477d07e35..5cdf81fbc 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -8,7 +8,8 @@ 덮개 {cid: {qty | 배율, labor, material, expense, 원값}}, 더한줄 [{종류: 인력|재료|기계, 요소, 이름, 단위, 수량, 비목?, 단가?, 별도계상?(재료 · 없음 = 계 안)} - | {종류: 로직, 로직, 입력값, 수량, 덮개?, 뺀줄?, 바탕판?} + | {종류: 로직, 로직, 입력값, 수량, 덮개?, 뺀줄?, 바탕판?, + 고름? · 자재표시? · 켜쌓기율? · 더한줄?(넣은 SK 몸 · 740-1) · 본뜬?(출처)} | {참조 | 고정, 이름?, 단위?, 수량}], 뺀줄 [cid], 바탕판, 환산?} SK 단위 환산(728-2) = `환산` {계수: 양수}(성격 다름 · 바탕 앞 수 뗀 1 단위 = SK 몇 단위) | @@ -178,7 +179,7 @@ def _added(raw: Any, where: str, whole: Any) -> dict: if row is not None: head["입력값"] = {k: v for k, v in given.items() if k in names} name = name or str(row.get("이름") or code) - head |= _sub_patch(raw, where) + head |= _sub_patch(raw, where) | _sub_body(raw, where, whole) elif raw.get("종류") in ADD_PART: key = str(raw.get("요소") or "") got = _element(whole, key, where) @@ -194,7 +195,8 @@ def _added(raw: Any, where: str, whole: Any) -> dict: unit = unit or str((got or {}).get("단위") or "") else: raise DocError(f"{where} · 종류는 인력 · 재료 · 기계 · 로직 · 참조 · 고정") - return {**head, "이름": name, "단위": unit, "수량": qty} + mark = {"본뜬": raw["본뜬"]} if isinstance(raw.get("본뜬"), dict) else {} # 출처(740-1) + return {**head, "이름": name, "단위": unit, "수량": qty, **mark} def _sub_patch(raw: dict, where: str) -> dict: @@ -210,9 +212,29 @@ def _sub_patch(raw: dict, where: str) -> dict: return {**({"덮개": covers} if covers else {}), **({"뺀줄": drop} if drop else {})} +BODY = ("고름", "자재표시", "켜쌓기율", "더한줄") # 넣은 SK 몸(740-1 · `M02_Library_Flat`) + + +def _sub_body(raw: dict, where: str, whole: Any) -> dict: + """넣은 SK 한 줄(740-1) — 그 SK 의 고름 · 자재표시 · 켜쌓기율 · 더한줄(M01 키만) · `본뜬`(출처 + 정보 · 셈에 안 씀) · 빈 칸은 안 둠.""" + out: dict[str, Any] = {} + if isinstance(raw.get("본뜬"), dict): + out["본뜬"] = raw["본뜬"] + if not any(raw.get(k) not in (None, {}, []) for k in BODY): + return out + body = {k: raw.get(k) for k in BODY if raw.get(k) not in (None, {}, [])} + doc = {"키": "SK-000000", "이름": "줄", "단위": "식", "바탕": {"로직": raw.get("로직")}, **body} + try: + got = clean(doc, None, whole) + except DocError as e: + raise DocError(f"{where} · {e}") from e + return out | {k: got[k] for k in BODY if got.get(k) not in (None, {}, [])} + + def sub_patched(one: dict) -> bool: - """고친 둘째 기초 — 더한줄 로직에 세부 덮개 · 뺀줄.""" - return one.get("종류") == "로직" and bool(one.get("덮개") or one.get("뺀줄")) + """고친 둘째 기초 — 더한줄 로직에 세부 덮개 · 뺀줄 · 넣은 SK 몸(740-1).""" + return one.get("종류") == "로직" and any(one.get(k) for k in ("덮개", "뺀줄", *BODY)) def _added_names(whole: Any, adds: Any) -> set[str]: diff --git a/resources/tester/test_572_library_same.py b/resources/tester/test_572_library_same.py index 1dcb59ee4..ab67d6105 100644 --- a/resources/tester/test_572_library_same.py +++ b/resources/tester/test_572_library_same.py @@ -106,8 +106,8 @@ def test_같은층_위층_찾기_참조_지문(client): {"층": "system", "주인": None, "키": "SK-000006"} ] assert same.find_same(me, "sangeun", {**SK, "바탕": None, "더한줄": []}) == [] - # 참조 = 가리킨 문서 지문 — 층 넘어 같은 내용 사본을 가리키면 같음 + # 참조 = 펼친 꼴(740-1) 지문 — 층 넘어 같은 내용 사본을 가리키면 같음 · 다른 내용이면 다름 a = {**SK, "바탕": {"참조": {"층": "company", "주인": "7", "키": "SK-000005"}}, "덮개": {}} b = {**a, "바탕": {"참조": {"층": "system", "주인": None, "키": "SK-000006"}}} - c = {**a, "바탕": {"참조": {"층": "personal", "주인": "7/42", "키": "SK-000003"}}} + c = {**a, "바탕": {"참조": {"층": "personal", "주인": "7/42", "키": "SK-000004"}}} assert same.finger("sangeun", a) == same.finger("sangeun", b) != same.finger("sangeun", c) diff --git a/resources/tester/test_715_sangeun_store.py b/resources/tester/test_715_sangeun_store.py index b39938bec..1f46efaf0 100644 --- a/resources/tester/test_715_sangeun_store.py +++ b/resources/tester/test_715_sangeun_store.py @@ -2,7 +2,7 @@ """M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2) — 임시 storage. 고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 · -순환 422 · 쓰는 곳 지우기 409 · 사전조합이 SK 를 담음 · 층 복사 왕복 같은 값. +바탕 · 사전조합에 SK 넣기 = 펼쳐 담음(740-1 · 원본 지워도 그대로) · 층 복사 왕복 같은 값. """ from decimal import Decimal @@ -106,32 +106,28 @@ def test_lost_cover_kept_and_listed(world): assert got["기준"]["계"] == pytest.approx(float(_bare(PCT)["기준"]["계"])) -def test_chain_loop_422_and_delete_409(world): - """바탕 사슬 A ← B · B 를 A 의 바탕으로 = 422 · 쓰는 곳 있는 A 지우기 = 409(이름).""" +def test_chain_base_flattened_delete_free(world): + """바탕 SK 넣기(740-1) = 펼쳐 담음 — 바탕 = M01 기초 · 위 덮개 겹침 · 값 같음 · + 원본 지워도 그대로.""" a = _sk(world["personal"], 바탕={"로직": PCT}) b = _sk( world["personal"], 바탕={"참조": {**MINE, "키": a}}, 덮개={"labor:LB000002#0": {"qty": 0}} ) + assert _doc(world["personal"], b)["바탕"] == {"로직": PCT} got = _calc(world["personal"], b) bare = _bare(PCT) assert got["기준"]["노무비"] == pytest.approx(float(bare["줄"][0]["금액"])) with store.at(world["personal"]): - doc = store.read("sangeun", a) - with pytest.raises(store.StoreError) as e: - store.write( - "sangeun", a, doc["판"], {**doc["문서"], "바탕": {"참조": {**MINE, "키": b}}} - ) - assert e.value.status == 422 - with pytest.raises(store.StoreError) as e: - store.delete("sangeun", a) - assert e.value.status == 409 and "단가산출 개인 7/42" in str(e.value.detail) + store.delete("sangeun", a) # 쓰는 곳 없음(서로 독립) + assert _calc(world["personal"], b)["기준"] == got["기준"] def test_precombo_holds_sangeun(world): - """사전조합 참조 줄이 SK 를 담음 — 1 단위 값 × 수량 · SK 지우기 막음.""" + """사전조합에 SK 넣기(740-1) = 로직 줄 + 패치(SK 몸) · 1 단위 값 × 수량 · SK 지워도 그대로.""" from M02_MasterTemplete import M02_Labor_Doc as labor sk = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}}) + one = _calc(world["personal"], sk)["1단위"]["계"] with store.at(world["personal"]): sc = refs.create( "precombo", @@ -141,17 +137,17 @@ def test_precombo_holds_sangeun(world): "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}], }, )["이름"] + line = store.read("precombo", sc)["문서"]["담은로직"][0] + assert line["로직"] == PCT and line["패치"]["덮개"] and line["본뜬"]["키"] == sk + store.delete("sangeun", sk) 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단위"]["계"] assert got["반영합"]["계"] == pytest.approx(one * 2, abs=1) - assert e.value.status == 409 and "일위대가 개인 7/42" in str(e.value.detail) def test_layer_copy_round_trip(world): - """개인 B(바탕 = 개인 A) → 회사로 복사 = A 도 같이(새 키 · 본뜬) → 개인으로 다시 = 같은 값.""" + """개인 B(바탕 = 개인 A) → 회사로 복사 = 펼친 몸(바탕 = 기초 · 본뜬) → 개인으로 다시 = + 같은 값.""" a = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}}) b = _sk(world["personal"], 바탕={"참조": {**MINE, "키": a}}, 뺀줄=["labor:LB000002#0"]) want = _calc(world["personal"], b)["기준"] @@ -160,15 +156,15 @@ def test_layer_copy_round_trip(world): made = refs.create( "sangeun", refs.carry(refs.origin_mark(src, {**MINE, "키": b}), ("personal", "7/42")) ) + assert not list((world["company"] / "sangeun").glob("*.json"))[1:] # A 따로 복사 안 함 copy = _doc(world["company"], made["이름"]) - assert copy["바탕"]["참조"]["층"] == "company" and copy["본뜬"]["키"] == b + assert copy["바탕"] == {"로직": PCT} and copy["본뜬"]["키"] == b assert _calc(world["company"], made["이름"])["기준"] == want with store.at(world["personal"]): back = refs.create( "sangeun", refs.carry(refs.origin_mark(copy, {**COMPANY, "키": made["이름"]}), ("company", "7")), ) - assert _doc(world["personal"], back["이름"])["바탕"]["참조"]["층"] == "company" assert _calc(world["personal"], back["이름"])["기준"] == want diff --git a/resources/tester/test_717_2a_sangeun_list.py b/resources/tester/test_717_2a_sangeun_list.py index ac19b209a..ca3db3416 100644 --- a/resources/tester/test_717_2a_sangeun_list.py +++ b/resources/tester/test_717_2a_sangeun_list.py @@ -32,15 +32,15 @@ def test_sangeun_row_base_edits_users(world): assert row["바탕"] == f"{PCT} {name}".strip() assert row["고친줄수"] == 2 and row["쓰는곳수"] == 0 assert row["제목"] == "목록 시험" and row["단위"] == "㎡" - # 바탕 참조 SK · 더한줄 하나 → 그 SK 쓰는 곳 1 + # 바탕 참조 SK(740-1) = 펼쳐 담음 → 바탕 = 기초 · 고친 줄 = 겹친 덮개 · 뺀줄 + 더한줄 · 쓰는 곳 0 b = _sk( mine, 바탕={"참조": {**MINE, "키": a}}, 더한줄=[{"종류": "인력", "요소": "LB000036", "이름": "보통인부", "단위": "인", "수량": 1}], ) row_b = _row(mine, "sangeun", b) - assert row_b["바탕"] == a and row_b["고친줄수"] == 1 - assert _row(mine, "sangeun", a)["쓰는곳수"] == 1 + assert row_b["바탕"] == f"{PCT} {name}".strip() and row_b["고친줄수"] == 3 + assert _row(mine, "sangeun", a)["쓰는곳수"] == 0 # 바탕 없음 c = _sk(mine) row_c = _row(mine, "sangeun", c) diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py index 37cde66d8..e2dcd5c53 100644 --- a/resources/tester/test_717_4_sangeun_pick.py +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -72,12 +72,14 @@ def test_list_amount_and_labor_line_holds_upper_sk(world): # noqa: F811 }, )["이름"] line = store.read("precombo", sc)["문서"]["담은로직"][0] - saved, inner = line["참조"], [line["수량"]] + saved, inner = line["본뜬"], [line["수량"]] outer = {"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]} got = labor.calc(outer, {}) fed = labor.calc(outer, {f"{refs.ref_id({**MINE, '키': sc})}#0": 3}) direct = labor.calc({"담은로직": [{"참조": {**COMPANY, "키": sk}, "수량": {"손": 3}}]}, {}) - assert saved == {**COMPANY, "키": sk} # 참조 = 회사 SK 그대로 + # 740-1 — 회사 SK = 로직 줄 + 패치로 펼쳐 담음 · 출처 = 본뜬 + assert {k: saved[k] for k in ("층", "주인", "키")} == {**COMPANY, "키": sk} + assert line["로직"] and "참조" not in line # 723-1 · 내역 17-1 — 일위대가 안 줄 수량은 저장 안 함(null) · 쓰는 자리(바깥 문서)에서 들어옴 assert inner == [None] assert got["반영합"]["계"] == 0 # 안 줄 수량 없음 = 금액 빠짐 diff --git a/resources/tester/test_719_1_sk_freeze.py b/resources/tester/test_719_1_sk_freeze.py index d0d1217b8..4779af727 100644 --- a/resources/tester/test_719_1_sk_freeze.py +++ b/resources/tester/test_719_1_sk_freeze.py @@ -117,10 +117,10 @@ def test_B09_서고_사본_값_같음_원본판(world, tmp_path): assert library.source_version(built) == mark # 다시 모아도 같은 판 again = library.import_copy(root, "precombo", "personal", "7/42", sc) assert again == {"폴더": made["폴더"], "만듦": False} - with store.at(world["personal"]): # SK 고침 → 원본판 다름 + with store.at(world["personal"]): # 740-1 — SK 고쳐도 SC 는 펼친 사본이라 원본판 그대로 now = store.read("sangeun", ref["ref"]["키"]) store.write("sangeun", ref["ref"]["키"], now["판"], {**now["문서"], "덮개": {}}) - assert library.source_version(library._build("precombo", "personal", "7/42", sc)[0]) != mark + assert library.source_version(library._build("precombo", "personal", "7/42", sc)[0]) == mark def test_SK_없는_사본_그대로(): diff --git a/resources/tester/test_719_2_sk_project.py b/resources/tester/test_719_2_sk_project.py index 56da848e5..c0f99cc86 100644 --- a/resources/tester/test_719_2_sk_project.py +++ b/resources/tester/test_719_2_sk_project.py @@ -21,7 +21,7 @@ from B09_Estimation import B09_Estimation_Refresh as refresh from B09_Estimation import B09_Estimation_Sangeun as sangeun from B09_Estimation.B09_Estimation_Calc_Base import compute_base from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor -from B09_Estimation.B09_Estimation_Labor_Source import ASK, daega_inputs +from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from M02_MasterTemplete import M02_Item_Copy as item_copy from M02_MasterTemplete import M02_Item_Snapshot as snapshots @@ -122,24 +122,18 @@ 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 = 그 줄만 빠짐(725-2) · B09 = 그 줄만 빈 줄 - gone = _calc(world["personal"], key)["빠진줄"] - assert len(gone) == 1 and "수량 없음" in gone[0]["까닭"] + # 740-1 — 속 일위대가 = 넣을 때 펼쳐 담음(SC 로직 줄 = 로직 더한줄 · 본뜬) → 빠진 줄 없음 + live = _calc(world["personal"], key) + added = _added(world, key) + assert live["빠진줄"] == [] and not any("참조" in x for x in added) + assert added[-1]["로직"] == PCT and added[-1]["본뜬"]["키"].startswith("SC-") root, folder = _lib(world, tmp_path, key) daega, done, _ = _b09(root) block = done[daega["libs"][folder]] lines = block["lines"] assert lines[1]["kind"] == "fixed" and lines[1]["prices"]["labor"] == pytest.approx(200000) - sc_line = next(ln for ln in lines if ln["kind"] == "daega") - sc = done[sc_line["key"]] - assert sc["kind"] == "사전조합" and sc_line["qty"] == 2 - assert sc["blank"] == 1 and sc["lines"][0]["why"] == ASK and block["blank"] >= 1 - # 이 공사에서 입력(702 덮개 qty 1) = 옛 손 1 과 같은 값 → M02 셈(알약 값 1) 과 같은 금액 - cover = {sc["cover"]: {sc["lines"][0]["cid"]: {"qty": 1}}} - block = _b09(root, cover)[1][daega["libs"][folder]] assert block["blank"] == 0 - sc_ref = next(x for x in _added(world, key) if "참조" in x)["참조"] - _same_money(block, _calc_given(world["personal"], key, {f"{refs.ref_id(sc_ref)}#0": 1})) + _same_money(block, live) def _added(world, key): @@ -147,24 +141,6 @@ def _added(world, key): return store.read("sangeun", key)["문서"]["더한줄"] -def _calc_given(folder, key, pills): - """M02 SK 셈 — 속 일위대가 안 줄 수량 = 알약 값(723-1 `Expand` given).""" - from M01_MasterData import M01_MasterData_Store as m01 - from M02_MasterTemplete import M02_Sangeun_Calc as calc - from M02_MasterTemplete import M02_Sangeun_Doc as sd - from M02_MasterTemplete.M02_Labor_Expand import Expand - - whole = m01.loaded()[1] - with store.at(folder): - clean = sd.clean(store.read("sangeun", key)["문서"], None, whole) - me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]}) - got = calc.run( - clean, whole, None, refs.read_ref, Expand(whole, refs.read_ref, pills), (me,) - ) - assert not got.get("까닭") - return got - - def test_구조물_사본_SK_줄_업데이트_덮개_입력값_고른자재_굳힌키_그대로( world, tmp_path, monkeypatch ): diff --git a/resources/tester/test_725_1_sangeun_add_inputs.py b/resources/tester/test_725_1_sangeun_add_inputs.py index 32eec4ca1..f442d2767 100644 --- a/resources/tester/test_725_1_sangeun_add_inputs.py +++ b/resources/tester/test_725_1_sangeun_add_inputs.py @@ -76,8 +76,8 @@ def test_line_without_input_only_that_line_drops(world, monkeypatch): # noqa: F assert up["기준"] is None and "입력 「지역」 없음" in up["까닭"] -def test_precombo_without_qty_only_that_line_drops(world): # noqa: F811 - """수량 없는 일위대가 줄(723-1) = 그 줄만 금액 — · 까닭 · 나머지 계 같음.""" +def test_precombo_in_sk_flattened_no_gap(world): # noqa: F811 + """740-1 — SK 에 넣은 일위대가 = 줄마다 더한줄로 펼쳐 담음(수량 = 넣은 수 × 1) · 빠진 줄 없음.""" with store.at(world["personal"]): sc = refs.create( "precombo", {"이름": "sub7임시SC", "단위": "㎥", "담은로직": [{"로직": PCT}]} @@ -86,10 +86,9 @@ def test_precombo_without_qty_only_that_line_drops(world): # noqa: F811 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) + assert doc["더한줄"][0]["로직"] == PCT and doc["더한줄"][0]["본뜬"]["키"] == sc["이름"] + assert got["빠진줄"] == [] + assert got["기준"]["계"] == pytest.approx(3 * float(want["기준"]["계"]), rel=1e-9) def test_screen_shows_dropped_lines(): diff --git a/resources/tester/test_729_hopyo_sk_link.py b/resources/tester/test_729_hopyo_sk_link.py index 1c8e7d7d6..60873446b 100644 --- a/resources/tester/test_729_hopyo_sk_link.py +++ b/resources/tester/test_729_hopyo_sk_link.py @@ -126,8 +126,8 @@ def test_실무_단가산출_기초_SK_고름대기_일위대가_막음(client): ok = client.post(BUILD, json={**body, "줄": picks}) assert ok.status_code == 200, ok.text doc = json.loads((folder / "precombo" / f"{ok.json()['참조']['키']}.json").read_bytes()) - refs_sk = [x for x in doc["담은로직"] if str((x.get("참조") or {}).get("키")).startswith("SK")] - assert refs_sk # 단가산출 줄 = SK 참조(고정 줄 대신) + refs_sk = [x for x in doc["담은로직"] if str((x.get("본뜬") or {}).get("키")).startswith("SK")] + assert refs_sk # 단가산출 줄 = SK 펼쳐 담음(740-1 · 로직 줄 + 패치 · 본뜬 = 그 SK) assert client.post(LEFT, json={**body, "줄": picks}).json()["수"] == 0 assert master_hash() == before @@ -223,7 +223,8 @@ def test_별칭_체크_줄_못옮김_SK_남은줄_그대로_SK_남은못옮김(c ok = client.post(BUILD, json={**body, "줄": [check, *picks]}) assert ok.status_code == 200, ok.text doc = json.loads((folder / "precombo" / f"{ok.json()['참조']['키']}.json").read_bytes()) - assert any((x.get("참조") or {}).get("키") == sk["키"] for x in doc["담은로직"]) # 차이 SK 참조 + # 차이 SK = 펼쳐 담음(740-1 · 본뜬 = 그 SK) + assert any((x.get("본뜬") or {}).get("키") == sk["키"] for x in doc["담은로직"]) assert master_hash() == before diff --git a/resources/tester/test_733_3_library_same_merge.py b/resources/tester/test_733_3_library_same_merge.py index e6b20aa28..56edcabe6 100644 --- a/resources/tester/test_733_3_library_same_merge.py +++ b/resources/tester/test_733_3_library_same_merge.py @@ -3,6 +3,7 @@ 묶음 = 이 층 + 위 층 같은 지문 · 합치기 = 쓰는 곳 참조 → 남김(금액 같음) · 지운 문서 `_합친것/` · 되돌림 = 문서 · 참조 되살림 · 지문 · 판 다르면 409 · 권한 밖 · 같은 참조 두 번 = 그 문서 남김 · +쓰는 곳 = 구조물 조합(740-1 — 단가산출 · 일위대가끼리는 펼쳐 담겨 참조 없음) · 위 층과 같은 아래 층 문서 = 남김 = 위 층 키 · 일반 사용자 회사 층 = 403. """ @@ -55,6 +56,27 @@ def _top(ref_keys, at=MINE): ] +def _user(folder, ref_keys, at=MINE, col="x1"): + """쓰는 곳 = 구조물 조합(740-1 — 단가산출 · 일위대가끼리는 펼쳐 담겨 참조 없음).""" + lines = [{**x, "수량": {"손": x["수량"]}} for x in _top(ref_keys, at)] + with store.at(folder): + store.write("labor", col, "", {"열": col, "담은로직": lines, "자재": []}) + return col + + +def _lines(folder, col): + with store.at(folder): + return store.read("labor", col)["문서"]["담은로직"] + + +def _sum(folder, col): + from M02_MasterTemplete import M02_Labor_Doc as labor + + with store.at(folder): + doc = labor.clean({**store.read("labor", col)["문서"], "열": col}) + return float(labor.calc(doc, {})["반영합"]["계"]) + + def _merge(api, keep, gone, 판, layer="personal"): body = {"남김": keep, "지움": gone, "판": 판} return api.post(URL.format(layer) + "/merge", json=body) @@ -64,14 +86,14 @@ def test_권한_밖이면_안_지움(api, world, monkeypatch): me = world["personal"] a = _make(me, 이름="가", 바탕={"로직": PCT}) c = _make(me, 이름="다", 바탕={"로직": PCT}) - top = _make(me, 이름="위", 단위="식", 바탕=None, 더한줄=_top([c])) + top = _user(me, [c]) monkeypatch.setattr(same_router, "_can_write", lambda session, at: False) 판 = api.get(URL.format("personal")).json()["판"] res = _merge(api, {**MINE, "키": a}, [c], 판) left = res.json()["남김"] assert res.status_code == 200 and left[0]["키"] == c and "권한 밖" in left[0]["쓰는곳"][0] assert res.json()["합침"] == [] and (me / "sangeun" / f"{c}.json").is_file() - assert _read(me, top)["더한줄"][0]["참조"]["키"] == c + assert _lines(me, top)[0]["참조"]["키"] == c def test_합치기_참조_옮김_금액_같음_되돌림(api, world): @@ -79,8 +101,8 @@ def test_합치기_참조_옮김_금액_같음_되돌림(api, world): a = _make(me, 이름="밑", 바탕={"로직": PCT}) b = _make(me, 이름="다른 이름 같은 내용", 바탕={"로직": PCT}) _make(me, 이름="다른 내용", 바탕={"로직": PCT}, 더한줄=_top([])[-1:]) - top = _make(me, 이름="위", 단위="식", 바탕=None, 더한줄=_top([b])) - before = _total(me, top) + top = _user(me, [b]) + before = _sum(me, top) got = api.get(URL.format("personal")) assert got.status_code == 200, got.text view = got.json() @@ -96,16 +118,16 @@ def test_합치기_참조_옮김_금액_같음_되돌림(api, world): assert out["묶음"] == [] and [g["키"] for g in out["합친것"]] == [b] assert not (me / "sangeun" / f"{b}.json").exists() assert (me / same_router.GONE / f"{b}.json").is_file() - assert _read(me, top)["더한줄"][0]["참조"]["키"] == a - assert _total(me, top) == pytest.approx(before) + assert _lines(me, top)[0]["참조"]["키"] == a + assert _sum(me, top) == pytest.approx(before) back = api.post(URL.format("personal") + "/undo", json={"키": b}) assert back.status_code == 200, back.text assert back.json()["되돌린곳수"] == 1 and back.json()["합친것"] == [] assert (me / "sangeun" / f"{b}.json").is_file() assert not (me / same_router.GONE / f"{b}.json").exists() - assert _read(me, top)["더한줄"][0]["참조"]["키"] == b - assert _total(me, top) == pytest.approx(before) + assert _lines(me, top)[0]["참조"]["키"] == b + assert _sum(me, top) == pytest.approx(before) assert api.post(URL.format("personal") + "/undo", json={"키": b}).status_code == 404 @@ -130,24 +152,25 @@ def test_권한_밖_남김_둘다_담은_문서(api, world, monkeypatch): a = _make(me, 이름="가", 바탕={"로직": PCT}) b = _make(me, 이름="나", 바탕={"로직": PCT}) c = _make(me, 이름="다", 바탕={"로직": PCT}) - both = _make(me, 이름="둘 다", 단위="식", 바탕=None, 더한줄=_top([a, b])) - before = _total(me, both) + both = _user(me, [a, b]) + before = _sum(me, both) 판 = api.get(URL.format("personal")).json()["판"] - res = _merge(api, {**MINE, "키": a}, [b, c], 판) # 둘 다 담은 문서 = 두 줄 모두 a · 금액 같음 + # 둘 다 담은 구조물 조합 = 같은 참조 두 번이 되어 못 옮김 → b 남김 · c 만 합침 · 금액 같음 + res = _merge(api, {**MINE, "키": a}, [b, c], 판) assert res.status_code == 200, res.text - assert [r["키"] for r in res.json()["합침"]] == [b, c] - assert [x["참조"]["키"] for x in _read(me, both)["더한줄"][:2]] == [a, a] - assert _total(me, both) == pytest.approx(before) - for key in (b, c): - assert api.post(URL.format("personal") + "/undo", json={"키": key}).status_code == 200 + assert [r["키"] for r in res.json()["합침"]] == [c] + assert [r["키"] for r in res.json()["남김"]] == [b] + assert [x["참조"]["키"] for x in _lines(me, both)[:2]] == [a, b] + assert _sum(me, both) == pytest.approx(before) + assert api.post(URL.format("personal") + "/undo", json={"키": c}).status_code == 200 def test_위층과_같으면_남김_위층_키(api, world): me, firm = world["personal"], world["company"] up = _make(firm, 이름="회사 것", 바탕={"로직": PCT}) mine = _make(me, 이름="내 것", 바탕={"로직": PCT}) - top = _make(me, 이름="위", 단위="식", 바탕=None, 더한줄=_top([mine])) - before = _total(me, top) + top = _user(me, [mine]) + before = _sum(me, top) view = api.get(URL.format("personal")).json() rows = view["묶음"][0]["문서"] assert [(r["층"], r["키"], r["위층"]) for r in rows] == [ @@ -156,8 +179,8 @@ def test_위층과_같으면_남김_위층_키(api, world): ] res = _merge(api, {**COMPANY, "키": up}, [mine], view["판"]) assert res.status_code == 200, res.text - assert _read(me, top)["더한줄"][0]["참조"] == {**COMPANY, "키": up} - assert _total(me, top) == pytest.approx(before) + assert _lines(me, top)[0]["참조"] == {**COMPANY, "키": up} + assert _sum(me, top) == pytest.approx(before) assert (firm / "sangeun" / f"{up}.json").is_file() # 위 층 문서는 지울 수 없음 · 일반 사용자는 회사 층 합치기 403 assert _merge(api, {**MINE, "키": mine}, [up], view["판"]).status_code in (403, 404) diff --git a/resources/tester/test_740_1_library_flat.py b/resources/tester/test_740_1_library_flat.py new file mode 100644 index 000000000..4eb4bb1ea --- /dev/null +++ b/resources/tester/test_740_1_library_flat.py @@ -0,0 +1,366 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""740-1 단가산출(SK) · 일위대가(SC) 서로 독립 · 연결 = M01 키 · 초기화(임시 storage · M01 읽기만). + +- 넣기 = 펼침(SK 에 SK = 몸 통째 한 줄 · SC 에 SK = 로직 줄 + 패치 · SK 에 SC = 줄마다 + 더한줄 · SC 에 SC = 넣기 길만 안 줄 펴 넣음 · 저장 · 읽기는 하위 호표 참조 그대로) · 줄 `본뜬` · + 펼치기 전후 금액 같음 · 원본 고쳐도 · 지워도 그대로 +- 분석 579-2 실측 E1(129,073 → 481,406) = 이제 안 일어남 +- 바탕 SK 참조 · 패치 SK 참조 = 덮개 겹침 · 못 펼침(겹침 · 바탕 없음 · 환산) = 넣기 400 · + 저장은 옛 참조 +- 초기화 = 덮개 · 뺀줄 · 더한줄 단가 걷음 → M01 값 · 옵션 · 항목 · 수량 남음 · 길(미리 보기 · 씀) +- 옮기기 함수 = 옛 참조 문서 펼쳐 씀(전후 같음 · 겹침 남김 · 두 번째 = 할 것 없음) · 733 지문 같음 +- B09 = SC 덮개 줄 굳힘 → 대가 블록 금액 = M02(옛 산근 길은 덮개를 안 읽었음) +""" + +import json + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +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_Library_Flat as flat +from M02_MasterTemplete import M02_Library_Same as same +from M02_MasterTemplete import M02_Library_Same_Router as same_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 + +GF = "GF000099" # 측구터파기 토사 · 원/㎥ +PCT = "GC001109" # 원/㎡ · 지붕잇기공 · 보통인부 · 「노무비 * 3 / 100」 +ADD = { + "종류": "인력", + "요소": "LB000002", + "비목": "노무비", + "이름": "보통인부", + "단위": "인", + "수량": 0.5, +} +USER = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False, "name": "갑"} + + +def _whole(): + return m01.loaded()[1] + + +def _raw(folder, kind, **doc): + """옛 꼴 문서(참조 줄 그대로) — 저장 길을 거치지 않고 파일로.""" + (folder / kind).mkdir(parents=True, exist_ok=True) + key = f"{refs.PREFIX[kind]}-{len(list((folder / kind).glob('*.json'))) + 1:06d}" + head = {"종류": refs.TITLE[kind], "키": key, "단위": "㎡"} + body = {"입력값": {}, "덮개": {}, "뺀줄": [], "고름": {}, "자재표시": {}, "더한줄": []} + full = {**head, **(body if kind == "sangeun" else {"자재": []}), **doc} + (folder / kind / f"{key}.json").write_text(json.dumps(full, ensure_ascii=False), "utf-8") + return key + + +def _make(folder, kind, **doc): + with store.at(folder): + extra = {"더한줄": []} if kind == "sangeun" else {} + return refs.create(kind, {"단위": "㎡", **extra, **doc})["이름"] + + +def _read(folder, kind, key): + with store.at(folder): + return store.read(kind, key)["문서"] + + +def _sk_total(folder, doc): + with store.at(folder): + return float(calc.run(sd.clean(doc, None, _whole()), _whole())["1단위"]["계"]) + + +def _sc_value(folder, key, read=None): + with store.at(folder): + got = flat._value({**MINE, "키": key}, read or refs.read_ref, _whole()) + assert got is not None + return got["계"] + + +def _y(folder): + """원본 SK — 바탕 PCT(% 덧줄) · 품 덮개 · 인력 더한줄(덧줄 밑에 듦).""" + return _make( + folder, + "sangeun", + 이름="원본", + 바탕={"로직": PCT}, + 덮개={"labor:LB000036#0": {"qty": 0.036}}, + 더한줄=[ADD], + ) + + +def test_SK_에_SK_몸_통째_금액_같음_원본_고쳐도_지워도_그대로(world): + me = world["personal"] + y = _y(me) + legacy = { + "이름": "밖", + "단위": "㎥", + "바탕": {"로직": GF}, + "더한줄": [{"참조": {**MINE, "키": y}, "수량": 2}], + } + before = _sk_total(me, {"키": "SK-000099", **legacy}) + x = _make(me, "sangeun", **legacy) + doc = _read(me, "sangeun", x) + entry = doc["더한줄"][0] + assert not refs.refs_in(doc) + assert entry["로직"] == PCT and entry["덮개"] and entry["더한줄"][0]["요소"] == "LB000002" + assert entry["본뜬"]["키"] == y and entry["본뜬"]["이름"] == "원본" and entry["본뜬"]["때"] + assert _sk_total(me, doc) == pytest.approx(before, rel=1e-9) + with store.at(me): # 원본 고침 · 지움 → 넣은 쪽 그대로 + now = store.read("sangeun", y) + store.write( + "sangeun", y, now["판"], {**now["문서"], "덮개": {"labor:LB000002#0": {"qty": 2}}} + ) + assert refs.users({**MINE, "키": y}) == [] + store.delete("sangeun", y) + assert _sk_total(me, _read(me, "sangeun", x)) == pytest.approx(before, rel=1e-9) + with store.at(me): # 화면 셈 길 = 넣은 줄 세부 펼침(안 더한줄 = add:#0/add:#0) + got = calc.calc_doc(_read(me, "sangeun", x)) + sub = next(r for r in got["줄"] if r["cid"] == "add:#0")["펼침"] + assert "add:#0/add:#0" in [r["cid"] for r in sub] + + +def test_SC_에_SK_패치_겹침_E1_원본_고쳐도_그대로(world): + """분석 579-2 E1 — 밖 SC(SK 참조 + 줄 패치 · 안 SC) 의 원본 SK 다른 줄을 고쳐도 밖 금액 + 그대로.""" + me = world["personal"] + sk = _make(me, "sangeun", 이름="밑", 바탕={"로직": PCT}, 더한줄=[]) + inner = _make(me, "precombo", 이름="안", 담은로직=[{"로직": "GC001111"}]) + lines = [ + {"참조": {**MINE, "키": sk}, "패치": {"덮개": {"labor:LB000036#0": {"qty": 0.5}}}}, + {"참조": {**MINE, "키": inner}}, + ] + old = _raw(me, "precombo", 이름="옛 밖", 담은로직=lines) + before = _sc_value(me, old) + host = _make(me, "precombo", 이름="밖", 담은로직=lines) + doc = _read(me, "precombo", host) + assert doc["담은로직"][0]["로직"] == PCT and doc["담은로직"][0]["본뜬"]["키"] == sk + assert doc["담은로직"][0]["패치"]["덮개"] == {"labor:LB000036#0": {"qty": 0.5}} + assert doc["담은로직"][1]["참조"]["키"] == inner # 하위 호표(SC 안 SC) = 참조 그대로 + assert _sc_value(me, host) == pytest.approx(before, rel=1e-9) + with store.at(me): # E1 — 원본 SK 의 덮지 않은 줄 품 2 + now = store.read("sangeun", sk) + store.write( + "sangeun", sk, now["판"], {**now["문서"], "덮개": {"labor:LB000002#0": {"qty": 2}}} + ) + assert _sc_value(me, old) > before * 3 # 옛 참조 = 따라감(129,073 → 481,406 꼴) + assert _sc_value(me, host) == pytest.approx(before, rel=1e-9) # 펼친 것 = 그대로 + + +def test_SC_에_SC_펴넣기_실무수량_겹침_넣기_400(world): + me = world["personal"] + hands = {"층": "실무자료", "실무수량": {"줄": {"0": 3}, "자재": {}}} + inner = _make(me, "precombo", 이름="안", 담은로직=[{"로직": PCT}], 본뜬=hands) + outer_hands = {"층": "실무자료", "실무수량": {"줄": {"0": 2, "1": 5}, "자재": {}}} + lines = [{"로직": GF}, {"참조": {**MINE, "키": inner}, "별칭": "안쪽"}] + old = _raw(me, "precombo", 이름="옛", 담은로직=lines, 본뜬=outer_hands) + assert flat.flat_doc("precombo", _read(me, "precombo", old))["담은로직"][1][ + "참조" + ] # 저장 · 읽기 + got = flat.flat_sc(_read(me, "precombo", old), refs.read_ref, _whole()) # 넣기 길 = 펴 넣음 + assert [x["로직"] for x in got["담은로직"]] == [GF, PCT] and got["담은로직"][1][ + "별칭" + ] == "안쪽" + assert got["본뜬"]["실무수량"]["줄"] == {"0": 2, "1": 15} # 바깥 5 × 안 3 + + def mine(r): + return got if r["키"] == old else refs.read_ref(r) + + assert _sc_value(me, old, mine) == pytest.approx(_sc_value(me, old), rel=1e-9) + # 같은 기초가 겹침 — 넣기 길 = 400 + dup = [{"로직": PCT}, {"참조": {**MINE, "키": inner}}] + kept = _make(me, "precombo", 이름="겹침", 담은로직=dup) + assert _read(me, "precombo", kept)["담은로직"][1]["참조"]["키"] == inner + with store.at(me), pytest.raises(store.StoreError) as e: + flat.flat_lines("precombo", {**MINE, "키": inner}, None, {"담은로직": [{"로직": PCT}]}) + assert e.value.status == 400 and "겹침" in str(e.value.detail) + + +def test_SK_에_SC_줄마다_더한줄(world): + me = world["personal"] + sc = _make(me, "precombo", 이름="조합", 담은로직=[{"로직": GF}]) + with store.at(me): + got = flat.flat_lines("sangeun", {**MINE, "키": sc}, 2)["더한줄"] + assert len(got) == 1 and got[0]["로직"] == GF and got[0]["수량"] == 2 # 실무수량 없음 = 1 × 2 + assert got[0]["본뜬"]["키"] == sc + # 742-1 저장 수량(수량.손)이 있으면 그 수 · 안 SC 펴기 = 바깥 × 안 + from decimal import Decimal + + hand = flat.sc_adds({"담은로직": [{"로직": GF, "수량": {"손": 3}}]}, Decimal(2), _whole(), {}) + assert hand[0]["수량"] == 6 + lines, _, _ = flat._spread( + {"담은로직": [{"로직": GF, "수량": {"손": 3}}]}, {"수량": {"손": 2}}, None, {} + ) + assert lines[0]["수량"] == {"손": 6} + doc = {"키": "SK-000099", "이름": "x", "단위": "㎥", "바탕": None, "더한줄": got} + one = float(labor.run_line(_whole(), GF, {})["기준"]["계"]) + assert _sk_total(me, doc) == pytest.approx(2 * one, rel=1e-9) + + +def test_바탕_SK_겹침_못_펼침_까닭(world): + me = world["personal"] + under = _make( + me, "sangeun", 이름="밑", 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.1}} + ) + over = {"이름": "위", "단위": "㎡", "바탕": {"참조": {**MINE, "키": under}}, "더한줄": [ADD]} + over |= {"덮개": {"labor:LB000036#0": {"배율": 2}}, "뺀줄": ["labor:LB000002#0"]} + before = _sk_total(me, {"키": "SK-000099", **over}) + got = flat.flat_doc("sangeun", {"키": "SK-000099", **over}) + assert got["바탕"] == {"로직": PCT} and got["덮개"]["labor:LB000036#0"]["qty"] == 0.2 + assert got["뺀줄"] == ["labor:LB000002#0"] + assert _sk_total(me, got) == pytest.approx(before, rel=1e-9) + # SC 에 바탕 없는 SK · 단위 환산 SK = 못 넣음 + bare = _make(me, "sangeun", 이름="바탕 없음", 바탕=None, 더한줄=[ADD]) + with store.at(me): + for key, why in ((bare, "바탕 기초 없는"),): + with pytest.raises(store.StoreError) as e: + flat.flat_lines("precombo", {**MINE, "키": key}) + assert e.value.status == 400 and why in str(e.value.detail) + + +def test_초기화_SK_SC_M01_값_옵션_남음(world): + me = world["personal"] + rock = {"보정작업": "암석작업(연암・보통암・경암)"} + entry = { + "종류": "로직", + "로직": PCT, + "입력값": {}, + "수량": 1, + "덮개": {"labor:LB000036#0": {"qty": 1}}, + } + priced = {**ADD, "단가": 1} + x = _make( + me, + "sangeun", + 이름="고친 것", + 단위="㎥", + 바탕={"로직": GF}, + 입력값=rock, + 덮개={"labor:LB000002#0": {"qty": 1}}, + 뺀줄=["logic:GC000267#0"], + 더한줄=[entry, priced], + ) + with store.at(me): + look = flat.reset_doc("sangeun", x, None, True, None) + assert look["걷힘"] == 4 and _read(me, "sangeun", x)["덮개"] # 미리 보기 = 안 씀 + done = flat.reset_doc("sangeun", x, None, False, None) + doc = _read(me, "sangeun", x) + assert doc["덮개"] == {} and doc["뺀줄"] == [] and doc["입력값"] == rock + assert [x.get("요소") or x.get("로직") for x in doc["더한줄"]] == [PCT, "LB000002"] + assert "덮개" not in doc["더한줄"][0] and "단가" not in doc["더한줄"][1] + assert done["걷힘"] == 4 + plain = {"키": "SK-000099", "이름": "x", "단위": "㎥", "바탕": {"로직": GF}, "입력값": rock} + plain["더한줄"] = [{**entry, "덮개": {}}, ADD] + assert _sk_total(me, doc) == pytest.approx(_sk_total(me, plain), rel=1e-9) + sc = _make( + me, + "precombo", + 이름="줄 덮개", + 담은로직=[ + {"로직": PCT, "패치": {"덮개": {"labor:LB000036#0": {"qty": 1}}, "더한줄": [priced]}} + ], + ) + with store.at(me): + got = flat.reset_doc("precombo", sc, None, False, None) + line = _read(me, "precombo", sc)["담은로직"][0] + assert got["걷힘"] == 2 and line["패치"] == {"더한줄": [{**ADD, "수량": 0.5}]} + + +def test_길_넣기_초기화_권한(world): + app = FastAPI() + app.include_router(same_router.router) + app.dependency_overrides[verify_session] = lambda: USER + client = TestClient(app) + me = world["personal"] + y = _y(me) + url = "/api/m02/layers/personal/{}-{}" + res = client.post(url.format("sangeun", "flat"), json={"참조": {**MINE, "키": y}, "수량": 1}) + assert res.status_code == 200, res.text + assert res.json()["더한줄"][0]["본뜬"]["키"] == y + bad = client.post(url.format("precombo", "flat"), json={"참조": {**MINE, "키": "SK-000777"}}) + assert bad.status_code == 400 + look = client.post(url.format("sangeun", "reset"), json={"키": y}) + assert look.status_code == 200 and look.json()["걷힘"] == 1 + firm = client.post("/api/m02/layers/company/sangeun-reset", json={"키": y, "미리보기": False}) + assert firm.status_code == 403 # 일반 사용자 회사 층 쓰기 막힘 + + +def test_옮기기_함수_전후_같음_겹침_남김(world): + me = world["personal"] + y = _y(me) + inner = _make(me, "precombo", 이름="안", 담은로직=[{"로직": GF}]) + sk = _raw( + me, + "sangeun", + 이름="옛 SK", + 단위="㎥", + 바탕={"로직": GF}, + 더한줄=[{"참조": {**MINE, "키": y}, "수량": 1}], + ) + sc = _raw( + me, + "precombo", + 이름="옛 SC", + 담은로직=[{"참조": {**MINE, "키": y}}, {"참조": {**MINE, "키": inner}}], + ) + dup = _raw( + me, "precombo", 이름="겹침", 담은로직=[{"로직": GF}, {"참조": {**MINE, "키": inner}}] + ) + before = {k: _sc_value(me, k) for k in (sc, dup)} | { + sk: _sk_total(me, _read(me, "sangeun", sk)) + } + look = {r["키"]: r for r in flat.migrate(False)} + assert look[sk]["같음"] and look[sc]["같음"] and not look[sc]["씀"] + assert look[sc]["남은참조"] == look[dup]["남은참조"] == [inner] # 하위 호표 = 그대로 + rows = {r["키"]: r for r in flat.migrate(True)} + assert rows[sk]["씀"] and rows[sc]["씀"] and not rows[dup]["씀"] + assert not refs.refs_in(json.loads((me / "sangeun" / f"{sk}.json").read_bytes())) + left = refs.refs_in(json.loads((me / "precombo" / f"{sc}.json").read_bytes())) + assert [r["키"] for r in left] == [inner] + assert _sk_total(me, _read(me, "sangeun", sk)) == pytest.approx(before[sk], rel=1e-9) + assert _sc_value(me, sc) == pytest.approx(before[sc], rel=1e-9) + assert _sc_value(me, dup) == pytest.approx(before[dup], rel=1e-9) + again = flat.migrate(False) # 두 번째 = 하위 호표 참조만 남음(바꿀 것 없음) + assert [r["키"] for r in again] == [sc, dup] and all(r["남은참조"] == [inner] for r in again) + + +def test_733_지문_옛_참조_펼친_것_같음(world): + me = world["personal"] + y = _y(me) + old = _raw(me, "precombo", 이름="옛", 담은로직=[{"참조": {**MINE, "키": y}}, {"로직": GF}]) + new = _make(me, "precombo", 이름="새", 담은로직=[{"참조": {**MINE, "키": y}}, {"로직": GF}]) + assert not refs.refs_in(_read(me, "precombo", new)) + fingers = {k: same.finger("precombo", _read(me, "precombo", k)) for k in (old, new)} + assert fingers[old] == fingers[new] + + +def test_B09_SC_덮개_줄_대가_금액_M02_같음(world, tmp_path): + """SC 덮개 줄(SK 넣기 꼴) — 옛 B09 = 산근(덮개 안 읽음) · 이제 굳힌 줄 대가 = M02 값.""" + from B09_Estimation import B09_Estimation_Bill_Library as library + from resources.tester.test_719_2_sk_project import _b09, _project + + me = world["personal"] + cover = {"labor:LB000036#0": {"qty": 0.5}} + sc = _make( + me, + "precombo", + 이름="덮은 줄", + 담은로직=[{"로직": PCT, "패치": {"덮개": cover, "더한줄": [ADD]}}], + ) + m02 = _sc_value(me, sc) + bare = float(labor.run_line(_whole(), PCT, {})["기준"]["계"]) + assert m02 > bare * 2 # 덮개 · 더한줄이 값에 듦(옛 B09 산근 = bare) + root = _project(tmp_path / "proj", []) + made = library.import_copy(root, "precombo", "personal", "7/42", sc) + daega, done, _ = _b09(root) + block = done[daega["libs"][made["폴더"]]] + line = block["lines"][0] + assert line["kind"] == "daega" and block["blank"] == 1 # 안 줄 수량 = 이 공사에서 입력(723-4) + cover_qty = {block["cover"]: {line["cid"]: {"qty": 1}}} + block = _b09(root, cover_qty)[1][daega["libs"][made["폴더"]]] + assert block["blank"] == 0 + assert block["sum"] == pytest.approx(m02, abs=5) # 끝수(내림)만 다름