diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index e14d61ff..b0236019 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -127,10 +127,16 @@ def get_pick(kind: str, q: str = "", limit: int = 50) -> dict: @router.get("/materials") def get_materials( - sub: str, detail: str = "", spec: str = "", region: str = "", limit: int = 50 + sub: str, + detail: str = "", + spec: str = "", + region: str = "", + unit: str = "", + limit: int = 50, ) -> dict: - """재료 고르기 조건 안 후보 — sub = 구분 · detail = 상세구분 · spec = 규격 · region = 자재지역.""" - return _call(store.materials, sub, detail, spec, region, limit) + """재료 고르기 조건 안 후보 — sub = 구분 · detail = 상세구분 · spec = 규격 · region = 자재지역 + · unit = 호표 줄 단위(주면 그 단위 줄만).""" + return _call(store.materials, sub, detail, spec, region, unit, limit) @router.post("/calc") diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 3ec32d1e..8ac97d31 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -347,11 +347,12 @@ def _brief(ref: str, row: dict) -> dict: } -def _pick_brief(whole, cond: dict) -> dict | None: - """고르기 조건 줄의 단가 미리보기 — 후보 수와 시험 계산이 쓸 줄(값 열 다섯 포함).""" +def _pick_brief(whole, cond: dict, unit: str | None = None) -> dict | None: + """고르기 조건 줄의 단가 미리보기 — 후보 수와 시험 계산이 쓸 줄(값 열 다섯 포함). + `unit` = 호표 줄 단위 — 후보를 같은 단위 줄로만 거름.""" try: - keys = cm.mf.candidates(whole, cond, {}) - brief = _market_brief(whole.get(cm.mf.pick(whole, cond, {}))) + keys = cm.mf.candidates(whole, cond, {}, unit) + brief = _market_brief(whole.get(cm.mf.pick(whole, cond, {}, unit))) except (cm.mf.FormulaError, KeyError, TypeError): return None return {**brief, "조건": cond, "후보": len(keys)} @@ -364,7 +365,7 @@ def _prices(whole, row: dict) -> dict: out = {} for item in row.get("호표") or []: if isinstance(item.get("요소"), dict): - out[cm.mf.cond_text(item["요소"])] = _pick_brief(whole, item["요소"]) + out[cm.mf.cond_text(item["요소"])] = _pick_brief(whole, item["요소"], item.get("단위")) continue ref = str(item.get("요소", "")) if item.get("종류") != "로직" and "{" not in ref: @@ -447,14 +448,15 @@ def _market_brief(row: dict) -> dict: } -def materials(sub: str, detail: str, spec: str, region: str, limit: int) -> dict: +def materials(sub: str, detail: str, spec: str, region: str, unit: str, limit: int) -> dict: """재료 고르기 조건 안 후보 목록 — 화면·테스트 컨테이너가 같이 씀. + `unit` = 호표 줄 단위 — 주면 그 단위 줄만(단위가 다르면 다른 물건). `기본` = 시험 계산이 쓸 줄(대표 줄이 없을 때의 첫 줄) · 그 줄을 `items` 맨 앞에 둠.""" 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) - first = cm.mf.pick(whole, cond, env) if keys else None + keys = cm.mf.candidates(whole, cond, env, unit or None) + first = cm.mf.pick(whole, cond, env, unit or None) if keys else None keys = [first] + [k for k in keys if k != first] if first else keys return { "total": len(keys), diff --git a/resources/master_data/scripts/check_master.py b/resources/master_data/scripts/check_master.py index 1b49e6ab..a7dcea23 100644 --- a/resources/master_data/scripts/check_master.py +++ b/resources/master_data/scripts/check_master.py @@ -337,6 +337,25 @@ def check_links(whole: mf.Master) -> list[str]: return out +def check_units(files: dict[str, dict], whole: mf.Master) -> list[str]: + """단위 경고 — 재료 고르기 조건의 후보 단위가 그 줄 단위와 다름(호표 줄 · 품셈재료 줄). + 데이터가 틀린 것이 아니라 조건을 더 좁혀야 하는 자리라 로직 검사와 따로 봄.""" + out = [] + for key, row in whole.index.get("MP", {}).items(): + cond = row.get("고르기") + if isinstance(cond, dict): + out += mf.check_unit(whole, whole.label(key) + " 고르기", cond, row.get("단위")) + for name, data in files.items(): + if data.get("그룹") != "로직": + continue + for row in data.get("줄", []): + for item in row.get("호표", []): + if isinstance(item.get("요소"), dict): + where = f"{name} · {row.get('키')} 호표 {item.get('이름', '')} 요소" + out += mf.check_unit(whole, where, item["요소"], item.get("단위")) + return out + + def _check_table(where: str, table: dict) -> list[str]: out, conds, cols = [], table.get("조건", {}), table.get("값칸", {}) allowed = set(conds) | set(cols) | {"단위", "짝"} | {c + "원문" for c in cols} @@ -613,6 +632,8 @@ def main(argv: list[str]) -> int: report["본문"] = check_body(files) if mode in ("로직", "전부"): report["로직"] = check_logics(files, whole) + if mode in ("단위", "전부"): + report["단위"] = check_units(files, whole) for title, found in report.items(): print(f"({title}) {len(found)}건") for line in found: diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index 8348e312..0d2a9649 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -481,7 +481,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: # 고르기 조건 묶음은 그대로 넘김 — `element` 가 대표 줄·첫 줄로 풂 ref = item["요소"] if isinstance(item["요소"], dict) else fill(item["요소"], env) try: - price, source = element(master, ref, env) + price, source = element(master, ref, env, item.get("단위")) except FormulaError: if qty != 0: raise @@ -703,6 +703,7 @@ from master_material import ( # noqa: E402, F401 TARIFF, candidates, check_pick, + check_unit, cond_text, element, pick, diff --git a/resources/master_data/scripts/master_material.py b/resources/master_data/scripts/master_material.py index 3d94b134..d66d3f59 100644 --- a/resources/master_data/scripts/master_material.py +++ b/resources/master_data/scripts/master_material.py @@ -111,35 +111,58 @@ def cond_text(cond: dict) -> str: return "|".join(str(cond.get(k) or "") for k in PICK_KEYS) -def candidates(master: mf.Master, cond: dict, env: dict | None = None) -> list[str]: +def candidates( + master: mf.Master, cond: dict, env: dict | None = None, unit: str | None = None +) -> list[str]: """고르기 조건 안 자재품목 키 — 구분 · 상세구분 · 규격 낱말이 모두 든 줄 · 파일 차례대로. + `unit` 을 주면 그 단위(호표 줄 단위)와 같은 줄만 — 단위가 다르면 다른 물건. 입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부).""" want = [plain_spec(w) for w in str(cond.get("규격") or "").split()] detail = cond.get("상세구분") + need = plain_unit(unit) if unit else "" 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) + and (not need or plain_unit(row.get("단위")) == need) ] 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: - """시험 계산이 쓸 줄 — 관리자가 정한 `대표`, 없으면 조건 안 값 있는 첫 줄.""" +def pick(master: mf.Master, cond: dict, env: dict, unit: str | None = None) -> str: + """시험 계산이 쓸 줄 — 관리자가 정한 `대표`, 없으면 조건 안 값 있는 첫 줄(단위 같은 줄만).""" if cond.get("대표"): return str(cond["대표"]) - keys = candidates(master, cond, env) + keys = candidates(master, cond, env, unit) 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]: +def check_unit(master: mf.Master, where: str, cond: dict, unit: str | None) -> list[str]: + """단위 경고 — 호표 줄·품셈재료 줄 단위와 맞는 후보가 없거나 대표 줄 단위가 다름. + 틀린 데이터가 아니라 좁혀야 할 조건이라 `check_pick` 과 따로 봄(`check_master 단위`).""" + rows = master.index.get(MARKET, {}) + rep = str(cond.get("대표") or "") + if not unit or set(cond) - set(PICK_KEYS) or not cond.get("구분"): + return [] + if rep in rows: + if plain_unit(rows[rep].get("단위")) != plain_unit(unit): + return [ + f"{where} · 대표 줄 단위 「{rows[rep].get('단위')}」 이 줄 단위 「{unit}」 과 다름" + ] + return [] + if candidates(master, cond) and not candidates(master, cond, None, unit): + return [f"{where} · 단위 「{unit}」 후보 0 — 조건 안 줄은 모두 다른 단위"] + return [] + + +def check_pick(master: mf.Master, where: str, cond: dict, unit: str | None = None) -> list[str]: """고르기 조건 검사 — 칸 이름 · 구분·상세구분이 자재품목에 있는지 · 대표 줄 · 후보 0.""" if set(cond) - set(PICK_KEYS) or not cond.get("구분"): return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — {' · '.join(PICK_KEYS)}"] @@ -157,11 +180,13 @@ def check_pick(master: mf.Master, where: str, cond: dict) -> list[str]: return out -def element(master: mf.Master, ref, env: dict) -> tuple[object, str | None]: +def element( + master: mf.Master, ref, env: dict, unit: str | None = None +) -> tuple[object, str | None]: """요소 값과 출처 — 자재품목은 제 줄, 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값`. - `ref` 가 고르기 조건 묶음이면 대표 줄(없으면 조건 안 첫 줄)로 풂.""" + `ref` 가 고르기 조건 묶음이면 대표 줄(없으면 단위 같은 조건 안 첫 줄)로 풂.""" if isinstance(ref, dict): - ref = pick(master, ref, env) + ref = pick(master, ref, env, unit) row = master.get(ref) if ref[:2] == MARKET: # 자재품목을 바로 가리키는 호표 줄 return _lowest(master, ref) diff --git a/resources/master_data/로직_건설품셈_02장_가설공사.json b/resources/master_data/로직_건설품셈_02장_가설공사.json index fa5d6499..62758443 100644 --- a/resources/master_data/로직_건설품셈_02장_가설공사.json +++ b/resources/master_data/로직_건설품셈_02장_가설공사.json @@ -63,7 +63,10 @@ }, { "종류": "재료", - "요소": "MP000097", + "요소": { + "구분": "건축마감", + "상세구분": "천장·바닥·마감재" + }, "이름": "외부PANEL(벽) 1,200×2,400㎜", "단위": "매", "수량": "찾기(QC000012, 구분='외부PANEL(벽)', 종류=주자재종류).수량 * 손율 / 100", @@ -71,7 +74,10 @@ }, { "종류": "재료", - "요소": "MP000098", + "요소": { + "구분": "건축마감", + "상세구분": "천장·바닥·마감재" + }, "이름": "외부PANEL (창문) 1,200×2,400㎜", "단위": "매", "수량": "찾기(QC000012, 구분='외부PANEL (창문)', 종류=주자재종류).수량 * 손율 / 100", @@ -79,7 +85,10 @@ }, { "종류": "재료", - "요소": "MP000099", + "요소": { + "구분": "건축마감", + "상세구분": "천장·바닥·마감재" + }, "이름": "외부PANEL (철재문) 1,200×2,400㎜", "단위": "매", "수량": "찾기(QC000012, 구분='외부PANEL (철재문)', 종류=주자재종류).수량 * 손율 / 100", @@ -87,7 +96,10 @@ }, { "종류": "재료", - "요소": "MP000100", + "요소": { + "구분": "건축마감", + "상세구분": "천장·바닥·마감재" + }, "이름": "내부PANEL(벽) 1,200×2,400㎜", "단위": "매", "수량": "만약(종류 == '사무실', 찾기(QC000012, 구분='내부PANEL(벽)', 종류=주자재종류).수량 * 손율 / 100, 0)", @@ -103,7 +115,10 @@ }, { "종류": "재료", - "요소": "MP000102", + "요소": { + "구분": "건축마감", + "상세구분": "천장·바닥·마감재" + }, "이름": "PANEL JOINT(AL-BAR) L=2,400㎜", "단위": "조", "수량": "찾기(QC000012, 구분='PANEL JOINT(AL-BAR)', 종류=주자재종류).수량 * 손율 / 100", @@ -111,7 +126,10 @@ }, { "종류": "재료", - "요소": "MP000103", + "요소": { + "구분": "건축마감", + "상세구분": "천장·바닥·마감재" + }, "이름": "CANOPY(출입구채양) 600×1,200㎜", "단위": "매", "수량": "찾기(QC000012, 구분='CANOPY(출입구채양)', 종류=주자재종류).수량 * 손율 / 100", @@ -119,7 +137,10 @@ }, { "종류": "재료", - "요소": "MP000104", + "요소": { + "구분": "건축마감", + "상세구분": "천장·바닥·마감재" + }, "이름": "박공PANEL", "단위": "매", "수량": "찾기(QC000012, 구분='박공PANEL', 종류=주자재종류).수량 * 손율 / 100", @@ -127,7 +148,11 @@ }, { "종류": "재료", - "요소": "MP000105", + "요소": { + "구분": "건축마감", + "상세구분": "천장·바닥·마감재", + "대표": "MT007262" + }, "이름": "ROOFSHEET 0.5㎜COLORSHEET", "단위": "㎡", "수량": "찾기(QC000012, 구분='ROOFSHEET', 종류=주자재종류).수량 * 손율 / 100", @@ -3974,7 +3999,10 @@ }, { "종류": "재료", - "요소": "MP000130", + "요소": { + "구분": "잡재료", + "상세구분": "소모품" + }, "이름": "방진망", "단위": "㎡", "수량": "찾기(QC000133, 구분='방진망').수량", diff --git a/resources/master_data/로직_건설품셈_05장_수장공사.json b/resources/master_data/로직_건설품셈_05장_수장공사.json index 13431eb1..5219505a 100644 --- a/resources/master_data/로직_건설품셈_05장_수장공사.json +++ b/resources/master_data/로직_건설품셈_05장_수장공사.json @@ -140,7 +140,10 @@ }, { "종류": "재료", - "요소": "MP000158", + "요소": { + "구분": "매트·토목섬유", + "상세구분": "부직포·토목섬유" + }, "이름": "펠트", "단위": "㎡", "수량": "찾기(QC001251, 구분='펠트').수량", @@ -1106,7 +1109,11 @@ }, { "종류": "재료", - "요소": "MP000168", + "요소": { + "구분": "건축마감", + "상세구분": "천장·바닥·마감재", + "대표": "MT008074" + }, "이름": "초배지", "단위": "㎡", "수량": "찾기(QC001269, 바탕면=바탕면).초배지", diff --git a/resources/master_data/로직_건설품셈_07장_지붕_및_홈통공사.json b/resources/master_data/로직_건설품셈_07장_지붕_및_홈통공사.json index 9de98336..d1beace1 100644 --- a/resources/master_data/로직_건설품셈_07장_지붕_및_홈통공사.json +++ b/resources/master_data/로직_건설품셈_07장_지붕_및_홈통공사.json @@ -407,7 +407,10 @@ }, { "종류": "재료", - "요소": "MP000186", + "요소": { + "구분": "건축마감", + "상세구분": "천장·바닥·마감재" + }, "이름": "폴리카보네이트", "단위": "㎡", "수량": "찾기(QC001319, 구분='폴리카보네이트').수량", diff --git a/resources/master_data/로직_건설품셈_13장_플랜트설비공사.json b/resources/master_data/로직_건설품셈_13장_플랜트설비공사.json index 7c162440..ffbf3ea8 100644 --- a/resources/master_data/로직_건설품셈_13장_플랜트설비공사.json +++ b/resources/master_data/로직_건설품셈_13장_플랜트설비공사.json @@ -3205,7 +3205,10 @@ }, { "종류": "재료", - "요소": "MP000251", + "요소": { + "구분": "잡재료", + "상세구분": "소모품" + }, "이름": "Sheet Metal Screw", "단위": "개", "수량": "만약(보온두께 == '30mm이하', 찾기(QC001650, 'Pipe Size mm'=관경, 구분='직관의물량').'Sheet Metal Screw', 만약(보온두께 == '31mm∼40mm', 찾기(QC001651, 'Pipe Size mm'=관경, 구분='직관의물량').'Sheet Metal Screw', 만약(보온두께 == '41mm∼60mm', 찾기(QC001652, 'Pipe Size mm'=관경, 구분='직관의물량').'Sheet Metal Screw', 만약(보온두께 == '61mm∼75mm', 찾기(QC001653, 'Pipe Size mm'=관경, 구분='직관의물량').'Sheet Metal Screw', 찾기(QC001654, 'Pipe Size mm'=관경, 구분='직관의물량').'Sheet Metal Screw')))) * 만약(부위 == '관(m당)', 1, 0)", @@ -8709,7 +8712,11 @@ }, { "종류": "재료", - "요소": "MP000289", + "요소": { + "구분": "잡재료", + "상세구분": "고철·기타금속", + "대표": "MT003499" + }, "이름": "함석", "단위": "매", "수량": "0.71", @@ -9009,7 +9016,11 @@ }, { "종류": "재료", - "요소": "MP000289", + "요소": { + "구분": "잡재료", + "상세구분": "고철·기타금속", + "대표": "MT003499" + }, "이름": "함석", "단위": "매", "수량": "0.62", @@ -10252,7 +10263,11 @@ }, { "종류": "재료", - "요소": "MP000292", + "요소": { + "구분": "잡재료", + "상세구분": "고철·기타금속", + "대표": "MT003499" + }, "이름": "함석", "단위": "대", "수량": "0.53", @@ -11595,7 +11610,11 @@ }, { "종류": "재료", - "요소": "MP000307", + "요소": { + "구분": "잡재료", + "상세구분": "고철·기타금속", + "대표": "MT003499" + }, "이름": "함석", "단위": "매", "수량": "1.9", @@ -12095,7 +12114,11 @@ }, { "종류": "재료", - "요소": "MP000307", + "요소": { + "구분": "잡재료", + "상세구분": "고철·기타금속", + "대표": "MT003499" + }, "이름": "함석", "단위": "매", "수량": "1.7", @@ -12516,7 +12539,11 @@ }, { "종류": "재료", - "요소": "MP000312", + "요소": { + "구분": "잡재료", + "상세구분": "고철·기타금속", + "대표": "MT003499" + }, "이름": "함석(Template)", "단위": "매", "수량": "0.53", @@ -12552,7 +12579,10 @@ }, { "종류": "재료", - "요소": "MP000316", + "요소": { + "구분": "기계·설비", + "상세구분": "기계류" + }, "이름": "Bite", "단위": "개", "수량": "2.5", @@ -12570,7 +12600,10 @@ }, { "종류": "재료", - "요소": "MP000316", + "요소": { + "구분": "기계·설비", + "상세구분": "기계류" + }, "이름": "Bite", "단위": "개", "수량": "3.2", @@ -12797,7 +12830,11 @@ }, { "종류": "재료", - "요소": "MP000307", + "요소": { + "구분": "잡재료", + "상세구분": "고철·기타금속", + "대표": "MT003499" + }, "이름": "함석", "단위": "매", "수량": "1.2", @@ -13044,7 +13081,11 @@ }, { "종류": "재료", - "요소": "MP000318", + "요소": { + "구분": "잡재료", + "상세구분": "고철·기타금속", + "대표": "MT003499" + }, "이름": "함석", "단위": "매", "수량": "0.71", @@ -19220,7 +19261,11 @@ }, { "종류": "재료", - "요소": "MP000339", + "요소": { + "구분": "잡재료", + "상세구분": "고철·기타금속", + "대표": "MT003499" + }, "이름": "함석", "단위": "매", "수량": "(만약(공사 == '기기', 0.05, 만약(공사 == '철골', 0.07, 만약(공사 == '배관', 0.05, 만약(공사 == 'Belt & Conveyor', 0.05, 만약(공사 == 'Heater & Tank', 0.05, 만약(공사 == 'Pump & Fan', 0.05, 만약(공사 == 'Crane 류', 0.07, 0))))))))", diff --git a/resources/tester/test_masterdata_check.py b/resources/tester/test_masterdata_check.py index 298e6867..9d67572e 100644 --- a/resources/tester/test_masterdata_check.py +++ b/resources/tester/test_masterdata_check.py @@ -913,8 +913,14 @@ def test_로직_일괄_시험계산(): """로직 전부를 시험 입력으로 돌림 — 멈춤은 데이터 결손(관리자 값·규격 미지정)만.""" files = cm.load() passed, stuck = cm.calc_all(files, mf.Master(files)) - assert passed >= 1270 - 남은_까닭 = ("관리자가 채울 값", "규격 선택 필요", "지역 값 없음", "맞는 줄") + assert passed >= 1272 + 남은_까닭 = ( + "관리자가 채울 값", + "규격 선택 필요", + "지역 값 없음", + "맞는 줄", + "고르기 후보 없음", + ) assert [x for x in stuck if not any(c in x for c in 남은_까닭)] == [] @@ -926,3 +932,23 @@ def test_시험입력이_기본값을_이김(): assert cm.시험입력(row) == {"지역": "전국평균", "깊이": 9} row["시험입력"] = {"지역": "포항"} assert cm.시험입력(row)["지역"] == "포항" + + +def test_고르기_후보는_줄_단위와_같은_것만(whole): + """호표 줄 단위와 다른 자재품목 줄은 후보에서 뺌 — 우드칩(㎥)에 원목후로링(㎡)이 섞이지 않음.""" + cond = {"구분": "목재"} + 전부 = mm.candidates(whole, cond) + 한단위 = mm.candidates(whole, cond, None, "㎥") + assert 0 < len(한단위) < len(전부) + assert {mm.plain_unit(whole.get(k)["단위"]) for k in 한단위} == {mm.plain_unit("㎥")} + 쓸줄 = mm.pick(whole, cond, {}, "㎥") + assert mm.plain_unit(whole.get(쓸줄)["단위"]) == mm.plain_unit("㎥") + + +def test_단위_경고는_로직_검사와_따로(): + """단위 경고는 조건을 좁히라는 알림 — 로직 검사(틀린 데이터)에 섞지 않음.""" + files = cm.load() + whole = mf.Master(files) + assert cm.check_logics(files, whole) == [] + warned = cm.check_units(files, whole) + assert warned and all("단위" in x for x in warned)