diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 8036a0d01..60be6edbe 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -125,6 +125,28 @@ def clean(doc: Any) -> dict[str, Any]: return out +def from_combo(key: str) -> dict[str, Any]: + """옛 일위대가 조합(마스터 UA = 실무문서 · 회사 UC · 개인 UP) → 일위대가 문서 몸 — + 담은로직 · 단위 복사 · 입력값 {} · 수량 null · 자재 [] · `본뜬` = 그 조합(층 · 주인 · 키). + 없는 조합은 404.""" + from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos + + row = combos.find(key) + if row is None: + raise store.StoreError(404, f"없는 일위대가 조합 「{key}」") + layer, owner = combos.where(key) or (None, None) + return { + "단위": row.get("단위"), + "본뜬": {"층": layer, "주인": owner, "키": key}, + "담은로직": [ + {"로직": one.get("로직"), "메모": one.get("메모"), "입력값": {}, "수량": None} + for one in row.get("담은로직") or [] + if isinstance(one, dict) + ], + "자재": [], + } + + # ── 알약 · 면적 값 ──────────────────────────────────────────────────── def material_key(code: str) -> str: return f"{MATERIAL_HEAD}{code}" diff --git a/M02_MasterTemplete/M02_Labor_Migrate.py b/M02_MasterTemplete/M02_Labor_Migrate.py new file mode 100644 index 000000000..37527b31b --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_Migrate.py @@ -0,0 +1,90 @@ +"""옛 일위대가 조합 → 구조물 열마다 일위대가 문서 — 한 번 돌리는 도구(PLAN 39-1b · 설계_정함 1장). + +층마다 구조물 열마다 `labor/<열id>.json`: + - 열 `일위대가` 키가 있으면 그 조합의 담은로직 · 단위 복사(`본뜬.키` = 옛 키 · 입력값 {} · + 수량 null · 자재 []) — 여러 열이 쓰던 조합도 열마다 따로 복사 + - 키 없는 열 = 빈 일위대가 · 번호 SU 는 저장소가 줌(도번과 같은 뒷자리) + - 몇 번 돌려도 같음 — 이미 그 키를 본뜬 문서 · 사람이 채운 문서(담은로직 · 자재가 있음)는 그대로 +옛 조합 파일 · 집계표 열 `일위대가` 칸은 손대지 않음(실무문서 · 은퇴는 39-1b B9). + + ./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py # 시스템 층 + ./venv/Scripts/python.exe M02_MasterTemplete/M02_Labor_Migrate.py --storage # 회사 · 개인 층 + [--dry-run] +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from M02_MasterTemplete import M02_Labor_Doc as labor # noqa: E402 +from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402 +from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402 + + +def _filled(doc: dict) -> bool: + return bool(doc.get("담은로직") or doc.get("자재")) + + +def migrate(folder: Path, dry: bool = False) -> dict[str, list[str]]: + """`folder` 층 하나 — `{만듦, 옮김, 그대로, 못함}`(열 id · 못함은 까닭 붙임).""" + out: dict[str, list[str]] = {"만듦": [], "옮김": [], "그대로": [], "못함": []} + with store.at(folder): + for col in store._structure_columns(): + cid, key = col["id"], str(col.get("일위대가") or "") + path = store._path("labor", cid) + have = json.loads(path.read_bytes()) if path.is_file() else None + done = have is not None and (have.get("본뜬") or {}).get("키") == key + if have is not None and (_filled(have) or not key or done): + out["그대로"].append(cid) + continue + if not key: + out["만듦"].append(cid) + if not dry: + store._ensure("labor", cid) + continue + try: + body = labor.from_combo(key) + except store.StoreError as e: + out["못함"].append(f"{cid} · {e.detail}") + continue + out["옮김"].append(f"{cid} ← {key}") + if dry: + continue + store._ensure("labor", cid) # 번호(SU · 같은 뒷자리) + got = store.read("labor", cid) + store.write("labor", cid, got["판"], {**got["문서"], **body}) + return out + + +def layer_folders(storage: bool) -> list[Path]: + """시스템 층 하나 · 또는 storage 회사 · 개인 층(구조물집계표가 있는 것 · 프로젝트 빼고).""" + if not storage: + return [layers.system_dir()] + root = layers.storage_root() + table = Path("table") / f"{store.STRUCTURE_TABLE}.json" + found = [ + *root.glob(f"*/{layers.TEMPLATES_DIRNAME}"), + *root.glob(f"*/*/{layers.TEMPLATES_DIRNAME}"), + ] + return sorted(p for p in found if (p / table).is_file()) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--storage", action="store_true", help="회사 · 개인 층(storage)") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + report: dict[str, Any] = {} + for folder in layer_folders(args.storage): + report[str(folder)] = migrate(folder, args.dry_run) + print(json.dumps(report, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/resources/master_template/labor/item1.json b/resources/master_template/labor/item1.json new file mode 100644 index 000000000..b9889e9e3 --- /dev/null +++ b/resources/master_template/labor/item1.json @@ -0,0 +1,340 @@ +{ + "종류": "일위대가", + "판": 1, + "열": "item1", + "단위": "개소", + "본뜬": { + "층": "system", + "주인": null, + "키": "UA000008" + }, + "담은로직": [ + { + "로직": "GC000198", + "메모": "레미콘타설(장비) 무근,소형", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000214", + "메모": "유로폼 설치 및 해체 보통", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000206", + "메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000207", + "메모": "철근가공 및 조립 Type-Ⅰ-1,현장가공조립", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000218", + "메모": "기초다짐 및 뒤채움 소형 장비사용 시공 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000225", + "메모": "깬잡석찰붙임 L3=30cm내외", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000156", + "메모": "콘크리트(기계비빔) 무근구조물 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000226", + "메모": "큰돌메쌓기 뒷길이 0.6~0.8m", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000214", + "메모": "야면석채집 L3=45cm내외 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000222", + "메모": "찰쌓기(켜쌓기) L3=55cm이하 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000677", + "메모": "모르터바르기 1:3", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000221", + "메모": "돌쌓기 찰(켜)쌓기,L3=55cm이하 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + }, + { + "로직": "GC000088", + "메모": "구조물터파기(토사) 굴착기0.6m3", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000113", + "메모": "구조물되메우기 굴착기0.6m3 (실무 수는 옛 판 값)", + "입력값": {}, + "수량": null + } + ], + "자재": [], + "일위대가번": "SU-000001", + "계산값": { + "줄": [ + { + "로직": "GC000198", + "알약": "레디믹스트콘크리트 타설|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 58122.782608695656, + "재료비": 0, + "경비": 1162.455652173913, + "계": 59285.23826086956 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000214", + "알약": "유로폼 설치 및 해체|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 51524.36363636364, + "재료비": 0, + "경비": 1545.730909090909, + "계": 53070.09454545454 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000206", + "알약": "철근 현장가공|ton", + "분모": "ton", + "배": 1, + "기준": { + "노무비": 217028.66666666666, + "재료비": 0, + "경비": 19532.58, + "계": 236561.24666666667 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000207", + "알약": "철근 현장조립|ton", + "분모": "ton", + "배": 1, + "기준": { + "노무비": 574487.6470588235, + "재료비": 11765, + "경비": 11489.75294117647, + "계": 597742.4 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000218", + "알약": "기초다짐 및 뒷채움|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 12869.246375, + "재료비": 1530.2328689, + "경비": 1315.382, + "계": 15714.8612439 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000225", + "알약": "돌붙임 찰붙임|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 49447.04583333333, + "재료비": 4806.2270256, + "경비": 6425.98, + "계": 60679.25285893333 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000156", + "알약": "기계비빔타설|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 144348.25222222222, + "재료비": 1647.113, + "경비": 378, + "계": 146373.36522222223 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000226", + "알약": "큰돌쌓기 메쌓기|㎡", + "분모": "㎡", + "배": 10, + "기준": { + "노무비": 819958.01, + "재료비": 132344.5084416, + "경비": 111948.7001, + "계": 1064251.2185416 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000214", + "알약": "야면석 채집|㎡ · ㎥", + "분모": "㎡ · ㎥", + "배": 1, + "기준": { + "노무비": 18927.48, + "재료비": 0, + "경비": 0, + "계": 18927.48 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000222", + "알약": "돌쌓기 찰쌓기|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 51101.38458333333, + "재료비": 6772.4108088, + "경비": 9054.79, + "계": 66928.58539213333 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000677", + "알약": "모르타르 배합|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 113564.88, + "재료비": 166136, + "경비": 0, + "계": 279700.88 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000221", + "알약": "돌쌓기 찰쌓기|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 52917.12, + "재료비": 1469.88, + "경비": 0, + "계": 54387 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GC000088", + "알약": "터파기(기계) 보통토사|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 843.1458333333334, + "재료비": 627.50883, + "경비": 423.15714285714284, + "계": 1893.811806190476 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000113", + "알약": "되메우기|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 2660.6419100706057, + "재료비": 416.1508405797101, + "경비": 391.04953017996496, + "계": 3467.8422808302807 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + } + ], + "자재": [], + "기준합": { + "노무비": 2167800.6667278423, + "재료비": 327515.0318154797, + "경비": 163667.5782754784, + "계": 2658983.2768188003 + }, + "반영합": { + "노무비": 0, + "재료비": 0, + "경비": 0, + "계": 0 + } + } +} diff --git a/resources/master_template/labor/item2.json b/resources/master_template/labor/item2.json new file mode 100644 index 000000000..a59dd165c --- /dev/null +++ b/resources/master_template/labor/item2.json @@ -0,0 +1,11 @@ +{ + "종류": "일위대가", + "판": 1, + "열": "item2", + "단위": null, + "본뜬": null, + "담은로직": [], + "자재": [], + "계산값": {}, + "일위대가번": "SU-000002" +} diff --git a/resources/master_template/labor/item3.json b/resources/master_template/labor/item3.json new file mode 100644 index 000000000..b512db52a --- /dev/null +++ b/resources/master_template/labor/item3.json @@ -0,0 +1,98 @@ +{ + "종류": "일위대가", + "판": 1, + "열": "item3", + "단위": "식", + "본뜬": { + "층": "system", + "주인": null, + "키": "UA000001" + }, + "담은로직": [ + { + "로직": "GF000219", + "메모": "메쌓기 — 돌쌓기 인력", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000119", + "메모": "노체포설 — 굴착기 0.7㎥", + "입력값": {}, + "수량": null + }, + { + "로직": "GF000137", + "메모": "운반 — 중기운반 트레일러(20ton)", + "입력값": {}, + "수량": null + } + ], + "자재": [], + "일위대가번": "SU-000003", + "계산값": { + "줄": [ + { + "로직": "GF000219", + "알약": "돌쌓기 메쌓기|㎡", + "분모": "㎡", + "배": 1, + "기준": { + "노무비": 79279.668, + "재료비": 0, + "경비": 0, + "계": 79279.668 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000119", + "알약": "노체포설|㎥", + "분모": "㎥", + "배": 1, + "기준": { + "노무비": 2433.321308321308, + "재료비": 1077.3082366522367, + "경비": 1012.3273551844981, + "계": 4522.956900158043 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "로직": "GF000137", + "알약": "중기운반 트레일러|회", + "분모": "회", + "배": 1, + "기준": { + "노무비": 38181.82530864197, + "재료비": 32481.682622277778, + "경비": 13280.479259259258, + "계": 83943.98719017902 + }, + "까닭": null, + "수량": null, + "수량출처": "없음", + "반영": null + } + ], + "자재": [], + "기준합": { + "노무비": 119894.81461696328, + "재료비": 33558.99085893002, + "경비": 14292.806614443756, + "계": 167746.61209033706 + }, + "반영합": { + "노무비": 0, + "재료비": 0, + "경비": 0, + "계": 0 + } + } +} diff --git a/resources/tester/test_m02_labor_migrate.py b/resources/tester/test_m02_labor_migrate.py new file mode 100644 index 000000000..1098822af --- /dev/null +++ b/resources/tester/test_m02_labor_migrate.py @@ -0,0 +1,119 @@ +"""옛 일위대가 조합 → 열마다 일위대가 문서 옮기기(`M02_Labor_Migrate` · PLAN 39-1b). + +시스템 층 = 견본 표(임시 폴더) · 조합은 M01 마스터(읽기만) · 개인 층 = 임시 storage. +""" + +import json +from pathlib import Path + +import pytest + +from config import config_system +from M02_MasterTemplete import M02_Labor_Migrate as migrate +from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +SHARED = "UP42-000001" + + +def _point(folder: Path, keys: dict[str, str]) -> None: + path = folder / "table" / f"{store.STRUCTURE_TABLE}.json" + doc = json.loads(path.read_bytes()) + for col in doc["열"]: + if col["id"] in keys: + col["일위대가"] = keys[col["id"]] + path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + + +@pytest.fixture() +def world(tmp_path, monkeypatch): + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + storage = tmp_path / "storage" + storage.mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + _point(system, {"rv": "UA000008", "c1": "UA000008", "pv_b": "UA999999"}) + personal = layers.personal_dir(7, 42) + items_store.ensure(personal) + with store.at(personal): + got = store.read("table", store.STRUCTURE_TABLE) + doc = got["문서"] + doc["열"] = [ + {"id": "a", "머리": ["돌", "가"], "꼴": "수", "일위대가": SHARED}, + {"id": "b", "머리": ["돌", "나"], "꼴": "수", "일위대가": SHARED}, + ] + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + combo = { + "그룹": "일위대가조합", + "다음": 2, + "줄": [ + { + "키": SHARED, + "이름": "개인 조합", + "단위": "개소", + "담은로직": [{"로직": "GF000219", "메모": "메쌓기"}], + } + ], + } + (personal / combos.FILE).write_text(json.dumps(combo, ensure_ascii=False), encoding="utf-8") + return {"system": system, "personal": personal, "storage": storage} + + +def _labor(folder: Path, cid: str) -> dict: + return json.loads((folder / "labor" / f"{cid}.json").read_bytes()) + + +def test_시스템_층_열마다_복사_같은_조합도_따로_없는_조합은_못함(world): + system = world["system"] + got = migrate.migrate(system) + assert got["옮김"] == ["rv ← UA000008", "c1 ← UA000008"] + assert got["못함"] == ["pv_b · 없는 일위대가 조합 「UA999999」"] + rv, c1 = _labor(system, "rv"), _labor(system, "c1") + assert rv["본뜬"] == {"층": "system", "주인": None, "키": "UA000008"} + assert rv["담은로직"] == c1["담은로직"] and len(rv["담은로직"]) == 14 + assert all(one["입력값"] == {} and one["수량"] is None for one in rv["담은로직"]) + assert rv["일위대가번"] != c1["일위대가번"] # 열마다 자기 번호 + with store.at(system): # 나중에 만든 도면도 같은 뒷자리 + store.create_structure("rv") + doc = json.loads((system / "structure" / "rv.json").read_bytes()) + assert doc["도번"][2:] == rv["일위대가번"][2:] + assert rv["계산값"]["줄"] # 서버가 계산값까지 + assert _labor(system, "ms")["담은로직"] == [] # 키 없는 열 = 빈 일위대가 + + +def test_개인_층_여러_열이_쓰던_조합은_열마다_본뜬은_그_층(world): + personal = world["personal"] + assert migrate.layer_folders(True) == [personal] + got = migrate.migrate(personal) + assert got["옮김"] == [f"a ← {SHARED}", f"b ← {SHARED}"] + a, b = _labor(personal, "a"), _labor(personal, "b") + assert a["본뜬"] == b["본뜬"] == {"층": "personal", "주인": "7/42", "키": SHARED} + assert (a["단위"], a["담은로직"][0]["메모"]) == ("개소", "메쌓기") + assert a["일위대가번"] != b["일위대가번"] + + +def test_다시_돌려도_같음_사람이_고친_문서는_그대로(world): + system = world["system"] + migrate.migrate(system) + before = {p.name: p.read_bytes() for p in (system / "labor").glob("*.json")} + assert migrate.migrate(system)["옮김"] == [] + after = {p.name: p.read_bytes() for p in (system / "labor").glob("*.json")} + assert before == after + with store.at(system): # 사람이 비운 문서도 다시 안 채움 + got = store.read("labor", "rv") + store.write("labor", "rv", got["판"], {**got["문서"], "담은로직": []}) + assert "rv" in migrate.migrate(system)["그대로"] + assert _labor(system, "rv")["담은로직"] == [] + + +def test_미리_보기는_파일을_안_씀(world): + system = world["system"] + got = migrate.migrate(system, dry=True) + assert "rv ← UA000008" in got["옮김"] + assert not list((system / "labor").glob("*.json"))