diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 86e59128..0c9c1a5a 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -124,6 +124,14 @@ def get_pick(kind: str, q: str = "", limit: int = 50) -> dict: return _call(store.pick, kind, q, limit) +@router.get("/materials") +def get_materials( + sub: str, detail: str = "", spec: str = "", region: str = "", limit: int = 50 +) -> dict: + """재료 고르기 조건 안 후보 — sub = 구분 · detail = 상세구분 · spec = 규격 · region = 자재지역.""" + return _call(store.materials, sub, detail, spec, region, limit) + + @router.post("/calc") def post_calc(body: CalcBody) -> dict: return _call(store.calc, body.key, body.inputs, body.row, body.file) diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index eacfe9f7..a4a80e5a 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -412,6 +412,29 @@ def pick(kind: str, q: str, limit: int) -> dict: return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]} +def _market_brief(row: dict) -> dict: + return { + "ref": str(row.get("키")), + **{k: row.get(k) for k in ("구분", "상세구분", "이름", "규격", "단위")}, + "값": _lowest(row), + "관급": row.get("관급") is not None, + } + + +def materials(sub: str, detail: str, spec: str, region: str, limit: int) -> dict: + """재료 고르기 조건 안 후보 목록 — 화면·테스트 컨테이너가 같이 씀. + `기본` = 시험 계산이 쓸 줄(대표 줄이 없을 때의 첫 줄).""" + whole = cm.master(folder=FOLDER) + cond = {"구분": sub, "상세구분": detail or None, "규격": spec or None} + env = {cm.mf.REGION: region} if region else {} + keys = cm.mf.candidates(whole, cond, env) + return { + "total": len(keys), + "기본": cm.mf.pick(whole, cond, env) if keys else None, + "items": [_market_brief(whole.get(k)) for k in keys[: min(max(limit, 1), 200)]], + } + + # ── 시험 계산 ────────────────────────────────────────────────────────── def _swap(files: dict, key: str, row: dict, file: str | None) -> str: """저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함) · 돌려받는 것 = 계산할 키.""" diff --git a/resources/master_data/_틀.md b/resources/master_data/_틀.md index 8d0b6199..4c47c8b5 100644 --- a/resources/master_data/_틀.md +++ b/resources/master_data/_틀.md @@ -192,6 +192,28 @@ - `비목` = 노무비 · 재료비 · 경비. `덧줄` 식에서 비목 합을 이름으로 씀. - `결과단위` 가 돈이 아닌 로직(㎥/시간)은 `호표` 없이 `중간` + `결과` 식. - `종류` 가 로직이고 `비목` 칸이 없는 호표 줄은 불러온 결과를 비목별로 나눠 얹음. +재료 고르기 — 호표 재료 줄의 `요소` 를 키 대신 조건 묶음으로 적음: + +```json +{ + "종류": "재료", + "요소": { "구분": "관·수로", "상세구분": "흄관", "규격": "D300", "대표": "MT012345" }, + "이름": "흄관", + "단위": "m", + "수량": "1", + "비목": "재료비", + "출처": "산림품셈 13-6-1" +} +``` + +- 조건 칸 = `구분`(반드시) · `상세구분` · `규격` · `대표` · 그 밖의 칸은 두지 않음. `종류` 는 「재료」만. +- 구분·상세구분 = 자재품목 줄의 같은 이름 칸과 똑같이 적음(묶음 목록은 `ref/_묶음_자재품목.md`). +- 규격 조건 = 원문이 규격을 밝힌 것만 좁힘 — 낱말이 **모두** 든 줄이 후보(「D300」 · 「D300 B종」) · 대소문자·빈칸·`φ` 는 안 가림. 원문이 규격을 안 밝히면 칸을 두지 않음. +- 대표 줄 = 관리자가 고른 자재품목 키 — 시험 계산이 그 줄의 값을 씀. 조건 밖 키는 검사가 잡음. +- 대표가 없으면 시험 계산은 **조건 안 값 있는 첫 줄** · 로직 입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄부터(레미콘·골재처럼 지역이 갈리는 품목). +- 실제 줄 고르기는 프로젝트 몫 — 후보 목록은 `GET /api/m01/materials`(화면·테스트 컨테이너가 같이 씀). +- 옛 방식(품셈재료 키 `MP…` · 자재품목 키 `MT…`)도 그대로 돎 — 로직 일괄 변환 전까지 함께 씀. + - 근거가 덜 선 줄은 `비고` 칸에 적음 — 「환산 근거 미확인」. - 입력에 원문 범위가 있으면 `범위: [아래, 위]` · 기본값 두지 않음. - `소유` = 공용 · 개인(뒤 단계 자리). diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index 34002383..4f0b2b99 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -6,7 +6,7 @@ (1) 틀 — 파일 이름 · 머리 칸 · 줄 칸 · 출처 모양 · 키 모양·겹침 · 대장 (2) 본문 — 표형 요소의 출처 절을 본문 md 에서 찾아 수를 맞댐 · 결손(본문 표에 있고 요소에 없음)·허구(요소에 있고 본문 절에 없음) -(3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 돌고 도는 참조 · 끊긴 키 +(3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 재료 고르기 조건 · 돌고 도는 참조 · 끊긴 키 (4) 계산 — 로직 키와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`) 수 뽑기는 `resources/knowledge/original/_pipeline/pum_md_tool.py` 의 것을 씀. """ diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 17c1744d..d520f9f2 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -260,8 +260,10 @@ class Master: raise FormulaError(f"없는 요소 「{ref}」") return row - def label(self, ref: str) -> str: - """알림 글 — 키 옆에 이름.""" + def label(self, ref) -> str: + """알림 글 — 키 옆에 이름 · 고르기 조건은 조건 글.""" + if isinstance(ref, dict): + return "재료 고르기 " + " · ".join(str(v) for v in ref.values() if v) try: row = self.get(ref) except FormulaError: @@ -476,7 +478,8 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: else {item["비목"]: qty * price} ) else: - ref = fill(item["요소"], env) + # 고르기 조건 묶음은 그대로 넘김 — `element` 가 대표 줄·첫 줄로 풂 + ref = item["요소"] if isinstance(item["요소"], dict) else fill(item["요소"], env) try: price, source = element(master, ref, env) except FormulaError: @@ -654,6 +657,11 @@ def check_logic(master: Master, row: dict) -> tuple[list[str], set]: item.get("종류") != "로직" and "비목" not in item ): out.append(f"{where} · 비목 「{item.get('비목')}」") + if isinstance(item["요소"], dict): # 재료 고르기 조건 + if item.get("종류") != "재료": + out.append(f"{where} · 고르기 조건은 종류 「재료」 만") + out += check_pick(master, where + " 요소", item["요소"]) + continue one(item["요소"], where + " 요소", known) group = mk.GROUP_OF.get(mk.id_of(str(item["요소"]))) if item.get("종류") != "로직" and group != item.get("종류"): @@ -687,4 +695,14 @@ def find_loops(graph: dict[str, set]) -> list[list[str]]: # 품셈재료 단가 — 서로 부르는 모듈이라 끝에서 읽음 -from master_material import LINKED, REGION, SLOTS, TARIFF, element # noqa: E402, F401 +from master_material import ( # noqa: E402, F401 + LINKED, + PICK_KEYS, + REGION, + SLOTS, + TARIFF, + candidates, + check_pick, + element, + pick, +) diff --git a/resources/master_data/scripts/master_material.py b/resources/master_data/scripts/master_material.py index da45efd4..0ed21a4a 100644 --- a/resources/master_data/scripts/master_material.py +++ b/resources/master_data/scripts/master_material.py @@ -16,6 +16,7 @@ LINKED = "MP" # 품셈재료 테이블ID MARKET = "MT" # 자재품목 테이블ID PRICED = "MO" # 유가전력 테이블ID — 값 한 칸 SLOTS = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재품목 값 열 +PICK_KEYS = ("구분", "상세구분", "규격", "대표") # 재료 고르기 조건 칸 (`_틀.md` 7장) REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀 TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음 _UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"} @@ -104,8 +105,58 @@ def _candidates(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]: return [key for _, key in hits] -def element(master: mf.Master, ref: str, env: dict) -> tuple[object, str | None]: - """요소 값과 출처 — 자재품목은 제 줄, 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값`.""" +# ── 재료 고르기 ──────────────────────────────────────────────────────── +def candidates(master: mf.Master, cond: dict, env: dict | None = None) -> list[str]: + """고르기 조건 안 자재품목 키 — 구분 · 상세구분 · 규격 낱말이 모두 든 줄 · 파일 차례대로. + 입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부).""" + want = [plain_spec(w) for w in str(cond.get("규격") or "").split()] + detail = cond.get("상세구분") + hits = [ + key + for key, row in master.index.get(MARKET, {}).items() + if row.get("구분") == cond.get("구분") + and (not detail or row.get("상세구분") == detail) + and all(w in plain_spec(row.get("규격")) for w in want) + ] + region = plain((env or {}).get(REGION, "")) + near = [k for k in hits if region in plain(master.get(k).get("이름"))] if region else [] + return near or hits + + +def pick(master: mf.Master, cond: dict, env: dict) -> str: + """시험 계산이 쓸 줄 — 관리자가 정한 `대표`, 없으면 조건 안 값 있는 첫 줄.""" + if cond.get("대표"): + return str(cond["대표"]) + keys = candidates(master, cond, env) + keys = [k for k in keys if _offers(master, k)] or keys + if not keys: + raise mf.FormulaError(f"재료 고르기 후보 없음 — {cond}") + return keys[0] + + +def check_pick(master: mf.Master, where: str, cond: dict) -> list[str]: + """고르기 조건 검사 — 칸 이름 · 구분·상세구분이 자재품목에 있는지 · 대표 줄 · 후보 0.""" + if set(cond) - set(PICK_KEYS) or not cond.get("구분"): + return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — {' · '.join(PICK_KEYS)}"] + out, rows = [], master.index.get(MARKET, {}) + for slot in ("구분", "상세구분"): + if cond.get(slot) and not any(r.get(slot) == cond[slot] for r in rows.values()): + out.append(f"{where} · 자재품목에 없는 {slot} 「{cond[slot]}」") + rep = cond.get("대표") + if rep and str(rep) not in rows: + out.append(f"{where} · 대표 줄 「{rep}」 이 자재품목에 없음") + elif rep and str(rep) not in candidates(master, cond): + out.append(f"{where} · 대표 줄 「{rep}」 이 고르기 조건 밖") + if not out and not candidates(master, cond): + out.append(f"{where} · 재료 고르기 후보 0") + return out + + +def element(master: mf.Master, ref, env: dict) -> tuple[object, str | None]: + """요소 값과 출처 — 자재품목은 제 줄, 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값`. + `ref` 가 고르기 조건 묶음이면 대표 줄(없으면 조건 안 첫 줄)로 풂.""" + if isinstance(ref, dict): + ref = pick(master, ref, env) row = master.get(ref) if ref[:2] == MARKET: # 자재품목을 바로 가리키는 호표 줄 return _lowest(master, ref) diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index f97ecd2a..0e270fc1 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -427,3 +427,32 @@ def test_유가전력_구분_상세구분_거름(client: TestClient) -> None: detail="주택용 저압", ) assert hit["total"] > 0 and {r["상세구분"] for r in hit["rows"]} == {"주택용 저압"} + + +def test_재료_고르기_후보_목록(client: TestClient) -> None: + """구분·상세구분·규격으로 좁힌 후보 · `기본` = 시험 계산이 쓸 줄 · 자재지역.""" + market = store.FOLDER / "재료_자재품목.json" + data = json.loads(market.read_text(encoding="utf-8")) + for row, sub, detail, name, spec in ( + (data["줄"][0], "관·수로", "흄관", "흄관-경상북도(울진군)", "D300 B종"), + (data["줄"][1], "관·수로", "흄관", "흄관-경상북도(포항시)", "D300 B종"), + (data["줄"][2], "관·수로", "파형강관", "파형강관", "D300"), + ): + row.update({"구분": sub, "상세구분": detail, "이름": name, "규격": spec}) + market.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + + assert client.get("/api/m01/materials").status_code == 422 # 구분은 반드시 + whole = _get(client, "/api/m01/materials", sub="관·수로") + assert whole["total"] == 3 and whole["기본"] == whole["items"][0]["ref"] + pipes = _get(client, "/api/m01/materials", sub="관·수로", detail="흄관") + assert [i["ref"] for i in pipes["items"]] == [r["키"] for r in data["줄"][:2]] + assert pipes["items"][0]["단위"] and pipes["items"][0]["값"] is not None + spec = _get(client, "/api/m01/materials", sub="관·수로", spec="D300 B종") + assert spec["total"] == 2 + near = _get(client, "/api/m01/materials", sub="관·수로", detail="흄관", region="울진") + assert [i["ref"] for i in near["items"]] == [data["줄"][0]["키"]] + assert _get(client, "/api/m01/materials", sub="없는구분") == { + "total": 0, + "기본": None, + "items": [], + } diff --git a/resources/tester/test_masterdata_check.py b/resources/tester/test_masterdata_check.py index ac9f56fa..7c955803 100644 --- a/resources/tester/test_masterdata_check.py +++ b/resources/tester/test_masterdata_check.py @@ -724,3 +724,145 @@ def test_품셈재료_자재지역_후보(whole): mf.element(whole, "MP:레미콘", {"자재지역": "울진"}) value, source = mf.element(whole, "MP:모래", {"자재지역": "수원"}) assert source.startswith("MT") and value > 0 + + +# ── 재료 고르기 조건 (`_틀.md` 7장) ──────────────────────────────────── +def _pipes() -> mf.Master: + """고르기 견본 — 자재품목 넉 줄 · 로직 한 줄(호표 요소가 고르기 조건).""" + rows = [ + { + "키": "MT900001", + "구분": "관·수로", + "상세구분": "흄관", + "이름": "흄관", + "규격": "D300 B종", + "단위": "m", + "물가자료": None, + "관급": None, + }, + { + "키": "MT900002", + "구분": "관·수로", + "상세구분": "흄관", + "이름": "흄관", + "규격": "D 300 B종", + "단위": "m", + "물가자료": Decimal(7000), + "관급": Decimal(6500), + }, + { + "키": "MT900003", + "구분": "관·수로", + "상세구분": "흄관", + "이름": "흄관", + "규격": "D600 B종", + "단위": "m", + "물가자료": Decimal(12000), + "관급": None, + }, + { + "키": "MT900004", + "구분": "관·수로", + "상세구분": "파형강관", + "이름": "파형강관", + "규격": "D300", + "단위": "m", + "물가자료": Decimal(9000), + "관급": None, + }, + ] + logic = { + "키": "GX900001", + "이름": "흄관 부설", + "입력": [], + "호표": [ + { + "종류": "재료", + "요소": {"구분": "관·수로", "상세구분": "흄관", "규격": "D300"}, + "이름": "흄관", + "단위": "m", + "수량": "2", + "비목": "재료비", + } + ], + } + return mf.Master( + { + "재료_자재품목.json": {"그룹": "재료", "원문": "자재품목", "줄": rows}, + "로직_자체.json": {"그룹": "로직", "원문": "자체", "줄": [logic]}, + } + ) + + +def test_재료_고르기_조건이_후보를_좁힘(): + """구분 → 상세구분 → 규격 낱말(빈칸·대소문자 안 가림) 차례로 좁아짐.""" + whole = _pipes() + assert len(mm.candidates(whole, {"구분": "관·수로"})) == 4 + assert len(mm.candidates(whole, {"구분": "관·수로", "상세구분": "흄관"})) == 3 + narrow = mm.candidates(whole, {"구분": "관·수로", "상세구분": "흄관", "규격": "D300 B종"}) + assert narrow == ["MT900001", "MT900002"] # 「D 300 B종」 도 같은 줄로 봄 + assert mm.candidates(whole, {"구분": "없는구분"}) == [] + + +def test_재료_고르기_시험계산은_대표_줄_없으면_값_있는_첫_줄(): + whole = _pipes() + cond = {"구분": "관·수로", "상세구분": "흄관", "규격": "D300"} + assert mm.pick(whole, cond, {}) == "MT900002" # MT900001 은 값이 없어 건너뜀 + value, source = mf.element(whole, cond, {}) + assert value == 6500 and source == "MT900002.관급" # 값 열 다섯 가운데 낮은 값 + assert mm.pick(whole, {**cond, "대표": "MT900001"}, {}) == "MT900001" + with pytest.raises(mf.FormulaError, match="후보 없음"): + mm.pick(whole, {"구분": "없는구분"}, {}) + + +def test_재료_고르기_호표_줄이_돎(): + """호표 `요소` 가 조건 묶음이어도 옛 방식과 같은 길로 금액이 섬.""" + got = mf.run(_pipes(), "GX900001", {}) + assert got["재료비"] == 13000 and got["줄"][0]["단가"] == 6500 + + +def test_재료_고르기_조건_검사(): + whole = _pipes() + ok = {"구분": "관·수로", "상세구분": "흄관"} + assert mm.check_pick(whole, "어디", ok) == [] + assert "조건 모양" in mm.check_pick(whole, "어디", {**ok, "지역": "울진"})[0] + assert "조건 모양" in mm.check_pick(whole, "어디", {"상세구분": "흄관"})[0] + assert "없는 구분" in mm.check_pick(whole, "어디", {"구분": "없는구분"})[0] + assert "없는 상세구분" in mm.check_pick(whole, "어디", {**ok, "상세구분": "없음"})[0] + assert "조건 밖" in mm.check_pick(whole, "어디", {**ok, "대표": "MT900004"})[0] + assert "자재품목에 없음" in mm.check_pick(whole, "어디", {**ok, "대표": "MT000001"})[0] + # 호표 줄 검사에도 같이 실림 — 종류가 재료가 아니면 잡음 + row = dict(whole.get("GX900001")) + row["호표"] = [{**row["호표"][0], "종류": "기계"}] + assert any("종류 「재료」" in x for x in mf.check_logic(whole, row)[0]) + + +def test_재료_고르기_자재지역(): + """지역이 갈리는 품목 — 이름에 그 지역이 든 줄부터 · 그런 줄이 없으면 조건 안 전부.""" + rows = [ + { + "키": "MT900011", + "구분": "레미콘", + "상세구분": None, + "이름": "레미콘-경상북도(포항시)", + "규격": "25-24-15", + "단위": "㎥", + "물가자료": Decimal(80000), + "관급": None, + }, + { + "키": "MT900012", + "구분": "레미콘", + "상세구분": None, + "이름": "레미콘-경상북도(울진군)", + "규격": "25-24-15", + "단위": "㎥", + "물가자료": Decimal(90000), + "관급": None, + }, + ] + whole = mf.Master({"재료_자재품목.json": {"그룹": "재료", "원문": "자재품목", "줄": rows}}) + cond = {"구분": "레미콘"} + assert mm.candidates(whole, cond, {mm.REGION: "울진"}) == ["MT900012"] + assert len(mm.candidates(whole, cond, {mm.REGION: "제주"})) == 2 # 없으면 조건 안 전부 + assert mf.element(whole, cond, {mm.REGION: "울진"})[0] == 90000