From 736aa405cf61459309fcc87127fd1a6a5211be73 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 14 Oct 2026 22:15:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80?= =?UTF-8?q?=EC=8B=A4=EB=AC=B4):=20324-3=20=EC=8B=A4=EB=AC=B4=20=ED=98=B8?= =?UTF-8?q?=ED=91=9C=20=E2=86=92=20=EC=9A=B0=EB=A6=AC=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EA=B8=B0=20=C2=B7=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EC=A4=84=20=EB=B3=84=EC=B9=AD=20=EC=82=B4?= =?UTF-8?q?=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Hopyo_Build: 호표 = 사전조합 하나(이름 = 품명 + 규격 · 엑셀 단위 · 구분 실무자료 · 상세구분 파일 · 본뜬 실무자료 표시) · 로직 줄 {별칭 = 엑셀 품명 40 자 · 메모 「실무 0.14㎥ × 1 · 규격」 · 입력값 · 수량 손 = 엑셀 × 환산} · 자재 줄(값 없음 = 별도계상 · 아니면 직접계산 · 같은 자재 수량 더함) · 같은 로직 둘째 = 한 줄 사전조합으로 감쌈 · 일위N = 먼저 만들어 참조 · 순환 = 빠진 줄 · 같은 층 · 파일 · 호표 = 다시 씀 · 새로 = 그 키에 덮음 · 빠진 줄 + 엑셀 금액 · 만든 값 ↔ 엑셀 단가 - M02_Labor_Ref._combo_lines: 별칭 있으면 남김(한 줄) - 길 POST /layers/{층}/hopyo-build(이 층 쓰기 · 파일 층 읽기) - 시험 test_m02_hopyo_build 5 통과 · 곁 시험 84 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- M02_MasterTemplete/M02_Hopyo_Build.py | 204 +++++++++++++++++++++++ M02_MasterTemplete/M02_Labor_Ref.py | 1 + M02_MasterTemplete/M02_Labor_Router.py | 25 +++ resources/tester/test_m02_hopyo_build.py | 152 +++++++++++++++++ 4 files changed, 382 insertions(+) create mode 100644 M02_MasterTemplete/M02_Hopyo_Build.py create mode 100644 resources/tester/test_m02_hopyo_build.py diff --git a/M02_MasterTemplete/M02_Hopyo_Build.py b/M02_MasterTemplete/M02_Hopyo_Build.py new file mode 100644 index 000000000..c6997996a --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Build.py @@ -0,0 +1,204 @@ +"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3). + +호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 · +상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일} · +만드는 층 = 부르는 층(일위대가 문서 층). 들어가는 줄(`M02_Hopyo_Match` 의 `들어감`)만: + 로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값, + 수량 {손 = 엑셀 수량 × 환산}} · 같은 로직 둘째부터 = 그 줄 하나짜리 사전조합으로 감싸 참조 + (같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 별도계상 = 엑셀 값 없음 「별도계상」 · + 아니면 「직접계산」}(같은 자재 = 수량 더함) · 일위N = 그 호표 사전조합을 먼저 만들어 참조 + (순환 = 빠진 줄). +같은 층 · 같은 파일 · 같은 호표 = 있던 것을 다시 씀 · `새로` = 그 키에 다시 만들어 덮음(감싼 것도). +빠진 줄(% · 못 맞춤 · 빼기 · 환산 입력 · 추정) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가. +엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`). +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from pathlib import Path +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Match as match +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +ORIGIN = "실무자료" +WHY = { + "%": "% 줄 — 우리 로직 덧줄이 셈", + "빼기": "뺌(확정)", + "후보": "못 맞춤 — 후보 확정 필요", + "없음": "못 맞춤", +} + + +def mark(key: str, no: int, at: int | None = None) -> str: + """본뜬 키 — 호표 「HP-000001 제3호표」 · 감싼 줄 「… 2째 줄」.""" + head = f"{key} 제{no}호표" + return head if at is None else f"{head} {at + 1}째 줄" + + +def _found(origin: str) -> tuple[str, str] | None: + """지금 층 사전조합 가운데 본뜬 = 이 실무 표시 → (키, 판).""" + for path in sorted((store.root() / "precombo").glob("SC-*.json")): + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + got = doc.get("본뜬") if isinstance(doc, dict) else None + if isinstance(got, dict) and got.get("층") == ORIGIN and got.get("키") == origin: + return path.stem, store.version_of(path.read_bytes()) + return None + + +def _save(doc: dict[str, Any], fresh: bool) -> tuple[dict[str, Any], bool]: + """(참조, 다시씀) — 있으면 그대로(`fresh` 면 그 키에 덮음) · 없으면 새 키.""" + at = refs.here() + have = _found(doc["본뜬"]["키"]) + if have and not fresh: + return {"층": at[0], "주인": at[1], "키": have[0]}, True + if have: + store.write("precombo", have[0], have[1], doc) + key = have[0] + else: + key = refs.create("precombo", doc)["이름"] + return {"층": at[0], "주인": at[1], "키": key}, bool(have) + + +def _num(value: Decimal) -> int | float: + value = value.normalize() + return int(value) if value == value.to_integral_value() else float(value) + + +def _memo(line: dict[str, Any], times: Any) -> str: + qty = line.get("수량") + spec = str(line.get("규격") or "").strip() + head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {times}" + return f"{head} · {spec}" if spec else head + + +def _missing(line: dict[str, Any], why: str) -> dict[str, Any]: + keep = ("차례", "이름", "규격", "수량", "단위") + return { + **{k: line.get(k) for k in keep}, + "금액": (line.get("금액") or {}).get("계"), + "까닭": why, + } + + +class Builder: + """한 번 만들기 — 파일 · 층 기억 · 우리 쪽 목록을 한 번 읽고 호표마다 만든 참조를 기억.""" + + def __init__(self, source: Path, folder: Path, key: str, fresh: bool) -> None: + self.doc = hopyo_store.read(source, key) + self.key, self.fresh = key, fresh + self.memo, self.units = match.memories(folder), match.units_of(self.doc) + self.got = match.targets() + self.made: dict[int, dict[str, Any]] = {} + self.missing: dict[int, list[dict[str, Any]]] = {} + + def hopyo(self, no: int) -> dict[str, Any]: + found = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == int(no)), None) + if found is None: + raise store.StoreError(404, f"없는 호표 「제{no}호표」") + return found + + def build(self, no: int, trail: tuple[int, ...] = ()) -> dict[str, Any]: + """호표 → 사전조합 참조 — 일위N 은 먼저(순환은 빠진 줄).""" + if no in self.made: + return self.made[no] + hopyo = match.match_hopyo(self.hopyo(no), self.memo, self.units, True, self.got) + lines, mats, missing = [], {}, [] + used: set[str] = set() + for line in hopyo["줄"]: + got = line["맞춤"] + if not got["들어감"]: + conv = got.get("환산") or {} + why = WHY.get(got["상태"]) or ( + "추정 환산 — 확정 필요" if conv.get("갈래") == "추정" else "환산 입력 필요" + ) + missing.append(_missing(line, why)) + continue + times = got["환산"]["배"] + qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(times)) + target = got["대상"] + alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None + memo = _memo(line, times) + if "자재" in target: + code = target["자재"] + apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산" + have = mats.get(code) + hand = qty + Decimal(str(have["수량"]["손"])) if have else qty + mats[code] = {"코드": code, "수량": {"손": _num(hand)}, "별도계상": apart} + continue + if "호표" in target: + child = int(target["호표"]) + if child in (*trail, no): + missing.append(_missing(line, "순환 — 호표가 서로 담음")) + continue + ref = self.build(child, (*trail, no)) + lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) + continue + one = {"로직": target["로직"], "입력값": got.get("입력값") or {}, "메모": memo} + if target["로직"] not in used: + used.add(target["로직"]) + lines.append({**one, "별칭": alias, "수량": {"손": _num(qty)}}) + continue + wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈 + "이름": f"{hopyo['이름']} — {line.get('이름')}", + "단위": target.get("단위"), + "구분": ORIGIN, + "상세구분": self.doc.get("파일") or "", + "담은로직": [{**one, "별칭": alias, "수량": {"손": 1}}], + "자재": [], + "본뜬": self._origin(mark(self.key, no, line["차례"])), + } + ref, _ = _save(wrap, self.fresh) + lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}}) + name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x) + doc = { + "이름": name, + "단위": hopyo.get("단위") or None, + "구분": ORIGIN, + "상세구분": self.doc.get("파일") or "", + "담은로직": lines, + "자재": list(mats.values()), + "본뜬": self._origin(mark(self.key, no)), + } + ref, again = _save(doc, self.fresh) + self.made[no] = {**ref, "다시씀": again} + self.missing[no] = missing + return self.made[no] + + def _origin(self, origin: str) -> dict[str, Any]: + return {"층": ORIGIN, "주인": None, "키": origin, "파일": self.doc.get("파일")} + + +def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]: + """만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가.""" + got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")}) + base = got.get("기준") or {} + ours = float(base["계"]) if "계" in base else None + excel = (hopyo.get("금액") or {}).get("계") + gap = round(ours - excel, 1) if ours is not None and excel is not None else None + return {"엑셀": excel, "우리": ours, "차이": gap, "까닭": got.get("까닭")} + + +def build(folder: Path, source: Path, key: str, no: int, fresh: bool = False) -> dict[str, Any]: + """실무 호표(`source` 층 파일) → 지금 층(`folder`) 사전조합 — + `{참조, 이름, 다시씀, 빠진줄, 비교}`(부르는 쪽이 `store.at(folder)`).""" + maker = Builder(source, folder, key, fresh) + made = maker.build(int(no)) + hopyo = maker.hopyo(int(no)) + ref = {k: made[k] for k in ("층", "주인", "키")} + return { + "참조": ref, + "이름": hopyo.get("이름"), + "다시씀": made["다시씀"], + "빠진줄": maker.missing.get(int(no), []), + "비교": compare(ref, hopyo), + } diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 6e0a14bfb..722b87ef3 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -210,6 +210,7 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: if "로직" in one: head["입력값"] = one["입력값"] head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one}) + head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3) lines.append({**head, "수량": one["수량"] or {"손": 1}, "메모": one["메모"]}) at_ = here() me = {"층": at_[0], "주인": at_[1], "키": key} diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index 8d89ade41..ab7530fa1 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from pydantic import BaseModel from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store @@ -266,6 +267,30 @@ def hopyo_memory( return _call(folder, hopyo_match.confirm, folder, body.줄) +class BuildBody(BaseModel): + """실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` · `새로` = 있던 것을 덮음.""" + + 층: Layer + 주인: str | None = None + 키: str + 번: int + 새로: bool = False + + +@router.post("/layers/{layer}/hopyo-build") +def hopyo_make( + layer: Layer, + body: BuildBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """실무 호표 → 우리 일위대가 사전조합(이 층 쓰기 · 파일 층 읽기) — + `{참조, 이름, 다시씀, 빠진줄, 비교}`.""" + folder = _root(session, layer, owner, write=True) + source = _root(session, body.층, body.주인) + return _call(folder, hopyo_build.build, folder, source, body.키, body.번, body.새로) + + @router.delete("/layers/{layer}/hopyo/{key}") def hopyo_delete( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py new file mode 100644 index 000000000..1284a544c --- /dev/null +++ b/resources/tester/test_m02_hopyo_build.py @@ -0,0 +1,152 @@ +"""일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · `M02_Hopyo_Build`) — 별칭 남음 · +수량 = 엑셀 × 환산 · 자재 · 같은 로직 둘 = 감쌈 · 일위N 참조 · 순환 · +같은 호표 다시 = 같은 사전조합 · 새로 = 덮음 · +빠진 줄 · 값 비교 · 길 권한 · master_data 원본 그대로. 층은 임시 폴더. +""" + +import json + +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Template_Layers as layers + +# 올리기 길 시험의 `client` 마당을 같이 씀 +# ruff: noqa: F811 +from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_hash # noqa: F401 + +DIG = "구조물터파기(인력100%)|토사|m3" +FILL = "구조물되메우기(인력100%)||m3" +SOIL = "잔토처리(인력100%)||m3" +STONE = "임도표지석(받침,글새김포함)|900x600x200x150|m3" +MEMORY = "/api/m02/layers/{}/hopyo-memory" +BUILD = "/api/m02/layers/{}/hopyo-build" + + +def _remember(client, layer="personal", **lines): + res = client.post(MEMORY.format(layer), json={"줄": lines}) + assert res.status_code == 200, res.text + + +def _build(client, no, layer="personal", want=200, **extra): + body = {"층": "personal", "키": "HP-000001", "번": no, **extra} + res = client.post(BUILD.format(layer), json=body) + assert res.status_code == want, res.text + return res.json() + + +def _doc(layer_dir, key): + return json.loads((layer_dir / "precombo" / f"{key}.json").read_bytes()) + + +def test_만들기_별칭_수량_메모_빠진_줄_비교(client): + before = master_hash() + assert _post(client).status_code == 200 + _remember( + client, + **{ + DIG: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1}, + FILL: {"로직": "GF000113", "환산": 2}, + }, + ) + got = _build(client, 3) + mine = layers.personal_dir(7, 42) + doc = _doc(mine, got["참조"]["키"]) + assert (doc["이름"], doc["단위"], doc["구분"]) == ("임도표지석 910×600×150", "개소", "실무자료") + assert doc["상세구분"].endswith("(공통단가).xls") + assert doc["본뜬"]["층"] == "실무자료" and doc["본뜬"]["키"] == "HP-000001 제3호표" + assert doc["담은로직"] == [ + { + "로직": "GF000101", + "입력값": {"지역": "서울"}, + "별칭": "구조물터파기(인력100%)", + "수량": {"손": 0.14}, + "메모": "실무 0.14㎥ × 1 · 토사", + }, + { + "로직": "GF000113", + "입력값": {}, + "별칭": "구조물되메우기(인력100%)", + "수량": {"손": 0.1}, # 엑셀 0.05 × 환산 2 + "메모": "실무 0.05㎥ × 2", + }, + ] + assert [(x["이름"], x["금액"]) for x in got["빠진줄"]] == [ + ("임도표지석(받침,글새김포함)", 1400000), + ("잔토처리(인력100%)", 3097.1), + ] + assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0 + assert got["비교"]["차이"] == round(got["비교"]["우리"] - 1415169, 1) + assert master_hash() == before + + +def test_자재_같은_로직_감쌈_다시씀_새로(client): + assert _post(client).status_code == 200 + _remember( + client, + **{ + DIG: {"로직": "GF000101", "환산": 1}, + SOIL: {"로직": "GF000101", "환산": 1}, + STONE: {"자재": "MT004258", "환산": 1}, + }, + ) + got = _build(client, 3) + mine = layers.personal_dir(7, 42) + doc = _doc(mine, got["참조"]["키"]) + first, wrapped = doc["담은로직"] + assert first["로직"] == "GF000101" and first["별칭"] == "구조물터파기(인력100%)" + assert wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] == {"손": 0.09} + inner = _doc(mine, wrapped["참조"]["키"]) + assert inner["본뜬"]["키"] == "HP-000001 제3호표 4째 줄" + assert inner["담은로직"][0]["로직"] == "GF000101" and inner["담은로직"][0]["수량"] == {"손": 1} + assert [(m["코드"], m["수량"], m["별도계상"]) for m in doc["자재"]] == [ + ("MT004258", {"손": 1}, "직접계산") + ] + files = sorted(p.name for p in (mine / "precombo").glob("*.json")) + again = _build(client, 3) + assert again["다시씀"] is True and again["참조"] == got["참조"] + assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files + _remember(client, **{SOIL: {"빼기": True}}) + fresh = _build(client, 3, 새로=True) + assert fresh["참조"] == got["참조"] and fresh["다시씀"] is True + assert len(_doc(mine, got["참조"]["키"])["담은로직"]) == 1 # 덮음 + assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files + + +def test_일위N_참조_먼저_만듦(client): + assert _post(client).status_code == 200 + got = _build(client, 45) + mine = layers.personal_dir(7, 42) + lines = _doc(mine, got["참조"]["키"])["담은로직"] + ref = lines[0] + assert ref["별칭"] == "고화매트재료비" and ref["수량"] == {"손": 2.3} + child = _doc(mine, ref["참조"]["키"]) + assert child["이름"] == "고화매트재료비" and child["본뜬"]["키"] == "HP-000001 제46호표" + assert any(x["까닭"].startswith("% 줄") for x in _build(client, 46)["빠진줄"]) + + +def test_순환은_빠진_줄(client): + folder = layers.personal_dir(7, 42) + _post(client) # 층 뼈대 + line = {"차례": 0, "이름": "남", "규격": "", "수량": 1, "단위": "식", "갈래": "호표"} + money = {"계": 1, "노무비": 1, "재료비": None, "경비": None} + hopyos = [ + {"번": 1, "이름": "가", "규격": "", "단위": "식", "금액": money, "줄": [ + {**line, "참조": {"종류": "일위", "번": 2}, "금액": money}]}, + {"번": 2, "이름": "나", "규격": "", "단위": "식", "금액": money, "줄": [ + {**line, "참조": {"종류": "일위", "번": 1}, "금액": money}]}, + ] # fmt: skip + key = hs.save(folder, "loop.xls", {"시트": "일위대가", "호표": hopyos, "검산": {}}, None)["키"] + got = _build(client, 1, 키=key) + assert got["빠진줄"] == [] + inner = _doc(folder, _doc(folder, got["참조"]["키"])["담은로직"][0]["참조"]["키"]) + assert inner["담은로직"] == [] # 제2호표 안 제1호표 = 순환 → 빠짐 + + +def test_길_권한과_위_층_파일(client): + client.who.update(role="SYSTEM_ADMIN") + assert _post(client, "system").status_code == 200 + client.who.update(role="USER") + _build(client, 3, layer="system", 층="system", want=403) # 마스터 쓰기 = 시스템 관리자 + got = _build(client, 3, 층="system") # 마스터 파일 → 내 층 사전조합 + assert got["참조"]["층"] == "personal" and got["참조"]["주인"] == "7/42" + assert _build(client, 99, 층="system", want=404) + assert client.get(URL.format("personal")).json()["줄"] == [] # 파일은 마스터에만