diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index 771d4616..97424138 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -27,6 +27,9 @@ class CalcBody(BaseModel): inputs: dict[str, Any] = {} row: dict[str, Any] | None = None # 저장 전 고친 로직 줄(없으면 저장된 파일로) file: str | None = None # 새 로직이면 더할 로직 파일 + 고름: dict[ + str, str + ] = {} # {호표 줄 차례(0부터): 자재 키} — 그 줄만 이 품목으로 셈 · 저장 안 함 class Change(BaseModel): @@ -244,13 +247,15 @@ def get_materials( @router.post("/calc") def post_calc(body: CalcBody) -> dict: - return _call(store.calc, body.key, body.inputs, body.row, body.file) + with store.cm.mf.mp.choosing(body.고름): + return _call(store.calc, body.key, body.inputs, body.row, body.file) @router.post("/text") def post_text(body: CalcBody) -> dict: """읽는 식 줄 — 몸은 `/calc` 와 같음.""" - return _call(store.text, body.key, body.inputs, body.row, body.file) + with store.cm.mf.mp.choosing(body.고름): + return _call(store.text, body.key, body.inputs, body.row, body.file) @router.post("/save") diff --git a/resources/master_data/scripts/master_formula.py b/resources/master_data/scripts/master_formula.py index f33c88eb..926396b6 100644 --- a/resources/master_data/scripts/master_formula.py +++ b/resources/master_data/scripts/master_formula.py @@ -267,6 +267,7 @@ class Master: if number in slot: self.twice.add((key[:2], number)) slot.setdefault(number, row) + mp.EQ.set(self.numbers.get("EQ", {})) # 기계 입력이 목록 밖 원문 분류번호도 받게 def get(self, ref: str) -> dict: tid, _, number = ref.partition(":") @@ -502,7 +503,7 @@ def _inputs(row: dict, given: dict) -> dict: pass if isinstance(value, (int, float)): value = Decimal(str(value)) - if "고르기" in spec and value not in spec["고르기"]: + if "고르기" in spec and value not in spec["고르기"] and not mp.machine(spec, value): raise FormulaError(f"입력 「{name}」={value} 고르기 밖") if "범위" in spec: low, high = spec["범위"] @@ -547,8 +548,8 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: return {"결과": cut(row, value, "결과"), "중간": _shown(env)} sums = dict.fromkeys(COST_ITEMS, Decimal(0)) lines = [] - for item in row.get("호표", []): - source = why = None + for n, item in enumerate(row.get("호표", [])): + source = why = warn = None try: qty = evaluate(parse(item["수량"]), env, master, depth) except EmptyInput as e: # 비어 있는 입력을 쓰는 줄만 비움 @@ -579,18 +580,10 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: lines.append(_blank_line(item, str(e))) continue # 후보 0 · 값 빈 품목은 그 줄만 단가·금액을 비우고 까닭을 닮 — 로직은 계속 · 합에서 뺌 - # (없는 키는 틀린 로직 — 지금처럼 멈춤) + # (없는 키는 틀린 로직 — 지금처럼 멈춤) · 검색어 · 고름 · 단위 환산은 `master_pick` if isinstance(ref, str) and qty != 0: master.get(ref) - try: - price, source = element(master, ref, env, item.get("단위")) - why = ( - None - if price is not None - else f"「{master.label(ref)}」 값 없음 — 관리자가 채울 값" - ) - except FormulaError as e: - price, why = None, str(e) + price, source, why, warn = mp.priced(master, item, ref, env, n if depth == 0 else None) if price is None and qty == 0: price, why = Decimal(0), None # 안 쓰는 재료 줄 — 값이 비어도 금액 0 split = {item["비목"]: None if price is None else qty * price} @@ -606,6 +599,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict: "비목": split, **({"출처": source} if source else {}), **({"까닭": why} if why else {}), + **({"단위경고": warn} if warn else {}), } ) # 덧줄이 호표 줄 금액 하나를 빼고 더할 수 있게 — `줄.'호표 줄 이름'`(빈 줄은 뺌) @@ -853,3 +847,4 @@ from master_material import ( # noqa: E402, F401 element, pick, ) +import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위 diff --git a/resources/master_data/scripts/master_material.py b/resources/master_data/scripts/master_material.py index 4a13ecb6..6d6237ac 100644 --- a/resources/master_data/scripts/master_material.py +++ b/resources/master_data/scripts/master_material.py @@ -120,7 +120,9 @@ def static(cond: dict) -> dict: def cond_text(cond: dict) -> str: """고르기 조건 한 글자 열쇠 — 「구분|상세구분|규격|대표」 · 화면 단가 미리보기가 줄을 찾는 이름.""" - return "|".join(str(cond.get(k) or "") for k in PICK_KEYS) + return "|".join(str(cond.get(k) or "") for k in PICK_KEYS) + ( + f"|{cond['검색어']}" if cond.get("검색어") else "" + ) def by_class(master: mf.Master) -> dict[str, dict]: @@ -140,7 +142,10 @@ def candidates( ) -> list[str]: """고르기 조건 안 자재품목 키 — 구분 · 상세구분 · 이름 · 규격 낱말이 모두 든 줄 · 파일 차례대로. `unit` 을 주면 그 단위(호표 줄 단위)와 같은 줄만 — 단위가 다르면 다른 물건. - 입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부).""" + 입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부). + 조건에 `검색어` 가 있으면 기본 검색어 찾기(`master_pick`).""" + if cond.get("검색어"): + return mp.search(master, cond, env, unit) want = [plain_spec(w) for w in str(cond.get("규격") or "").split()] want_name = [plain(w) for w in str(cond.get("이름") or "").split()] detail = cond.get("상세구분") @@ -160,6 +165,8 @@ def candidates( def pick(master: mf.Master, cond: dict, env: dict, unit: str | None = None) -> str: """시험 계산이 쓸 줄 — 관리자가 정한 `대표`, 없으면 조건 안 값 있는 첫 줄(단위 같은 줄만).""" + if cond.get("검색어"): + return mp.pick(master, cond, env, unit) if cond.get("대표"): return str(cond["대표"]) keys = candidates(master, cond, env, unit) @@ -173,6 +180,8 @@ def pick(master: mf.Master, cond: dict, env: dict, unit: str | None = None) -> s def check_unit(master: mf.Master, where: str, cond: dict, unit: str | None) -> list[str]: """단위 경고 — 호표 줄·품셈재료 줄 단위와 맞는 후보가 없거나 대표 줄 단위가 다름. 틀린 데이터가 아니라 좁혀야 할 조건이라 `check_pick` 과 따로 봄(`check_master 단위`).""" + if cond.get("검색어"): + return mp.check_warn(master, where, cond, unit) cond = static(cond) rows = master.index.get(MARKET, {}) rep = str(cond.get("대표") or "") @@ -194,6 +203,8 @@ def check_pick(master: mf.Master, where: str, cond: dict, unit: str | None = Non 값에 `{입력}` 이 든 칸(관경 등)은 계산 때 채워지므로 검사에서 뺌. 지역·계약종별 조건은 구분 없이 이름 하나로 자재품목·유가전력을 넓게 찾음(_region · _tariff) — 여기서는 이름 칸만 있는지 봄(후보 0 은 계산 때 판단).""" + if cond.get("검색어") is not None and not set(cond) - {*PICK_KEYS, "검색어"}: + return mp.check_zero(master, where, cond) if set(cond) - set(PICK_KEYS): return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — {' · '.join(PICK_KEYS)}"] if ("지역" in cond and cond["지역"] != "입력") or ( @@ -257,3 +268,4 @@ def element( import master_formula as mf # noqa: E402 — 서로 부름 · 이 모듈 이름이 다 선 뒤에 읽음 +import master_pick as mp # noqa: E402 — 검색어 찾기 diff --git a/resources/master_data/scripts/master_pick.py b/resources/master_data/scripts/master_pick.py new file mode 100644 index 00000000..f3f28ebf --- /dev/null +++ b/resources/master_data/scripts/master_pick.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +"""재료 줄의 기본 검색어 찾기 · 고른 품목 · 단위 환산 · 단위 경고 (PLAN 8-1 계약). + +`master_formula.run` 이 재료 줄 단가를 풀 때 부름 — 요소 객체에 `검색어` 가 있으면 여기서 찾고, +없으면 옛 길(`master_material` 조건 · 대표). 서로 부르는 모듈이라 `master_formula` 는 끝에서 읽음. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from decimal import Decimal + +import master_material as mm + +# 「같은 성격」 단위 — {성격: {정규 단위: 기준 단위 몇 배}} (키는 `plain_unit` 을 거친 꼴) +UNITS = { + "무게": {"g": 1, "kg": 1000, "톤": 1000000}, + "길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000}, + "넓이": {"cm2": 1, "m2": 10000}, + "부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000}, +} +_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()} +REGIONS = ( + "전국평균 전국 서울 경기 강원 충북 충남 전북 전남 경북 경남 부산 대구 인천 대전 울산 세종 제주 광주" + " 전남광주" +).split() + +CHOSEN: ContextVar[dict | None] = ContextVar("고름", default=None) # {호표 줄 차례(0부터): 자재 키} +EQ: ContextVar[dict | None] = ContextVar("기계번호", default=None) # 기계 마스터 {원문번호: 줄} + + +@contextmanager +def choosing(picks: dict | None): + """`/calc` · `/text` 몸의 `고름` — 그 요청 동안만 · 저장 안 함.""" + token = CHOSEN.set({str(k): str(v) for k, v in (picks or {}).items()}) + try: + yield + finally: + CHOSEN.reset(token) + + +def machine(spec: dict, value) -> bool: + """기계 입력 — 고르기 목록 밖 원문 분류번호도 기계 마스터에 있으면 받음.""" + known = EQ.get() or {} + picks = spec.get("고르기") or [] + return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks) + + +# ── 단위 ─────────────────────────────────────────────────────────────── +def kind_of(unit) -> str | None: + got = _SCALE.get(mm.plain_unit(unit)) + return got[0] if got else None + + +def same_kind(a, b) -> bool: + """단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피).""" + pa, pb = mm.plain_unit(a), mm.plain_unit(b) + return pa == pb or (kind_of(a) is not None and kind_of(a) == kind_of(b)) + + +def convert(price, have, want): + """`have` 단위 단가 → `want` 단위 단가 — 같은 성격이 아니면 그대로.""" + if price is None or not want or mm.plain_unit(have) == mm.plain_unit(want): + return price + if not same_kind(have, want) or kind_of(have) is None: + return price + return price * Decimal(_SCALE[mm.plain_unit(want)][1]) / Decimal(_SCALE[mm.plain_unit(have)][1]) + + +# ── 찾기 ─────────────────────────────────────────────────────────────── +def _words(term) -> tuple[list[str], list[str]]: + """(이름·규격에 든 낱말, 지역 낱말) — 지역 낱말은 고르는 차례에만 쓰임.""" + every = [mm.plain_spec(w) for w in str(term or "").split()] + region = [w for w in every if w in REGIONS] + return [w for w in every if w not in REGIONS], region + + +def search(master, cond: dict, env: dict | None = None, unit: str | None = None) -> list[str]: + """검색어 안 자재품목 키 — 낱말이 모두 이름 또는 규격에 든 줄(요소의 구분 · 상세구분 안에서) · + 단위는 같은 성격까지(하나도 없으면 다 — 경고가 붙음) · 고르는 차례: + 값 있는 줄 → 줄 단위와 똑같은 단위 → 지역 낱말(입력 `자재지역` 포함)과 맞는 줄 → 키 차례.""" + words, region = _words(cond.get("검색어")) + region += [mm.plain(env[mm.REGION])] if (env or {}).get(mm.REGION) else [] + rows = master.index.get(mm.MARKET, {}) + hits = [ + key + for _, name, spec, _, key in mm._market_index(master) + if all(w in name.replace("×", "x") or w in spec for w in words) + and (not cond.get("구분") or rows[key].get("구분") == cond["구분"]) + and (not cond.get("상세구분") or rows[key].get("상세구분") == cond["상세구분"]) + ] + if unit: + kin = [k for k in hits if same_kind(rows[k].get("단위"), unit)] + hits = kin or hits + want = mm.plain_unit(unit) if unit else None + + def rank(key): + one = rows[key] + name = mm.plain(one.get("이름")) + return ( + not mm._offers(master, key), + bool(want) and mm.plain_unit(one.get("단위")) != want, + not (region and all(r in name for r in region)), + ) + + return sorted(hits, key=rank) # 정렬은 안정 — 남는 차례는 파일 차례 + + +def pick(master, cond: dict, env: dict, unit: str | None = None) -> str: + keys = search(master, cond, env, unit) + if not keys: + raise mm.mf.FormulaError(f"재료 검색어 후보 없음 — {cond.get('검색어')}") + return keys[0] + + +def priced(master, item: dict, ref, env: dict, index: int | None): + """재료 줄 단가 — (단가, 출처, 까닭, 단위경고). 고른 품목(`고름`)이 있으면 그것 · 검색어가 있으면 찾기 · + 없으면 옛 길. 자재 단가는 줄 단위로 환산 · 성격이 다르면 그대로 두고 경고.""" + unit = item.get("단위") + picked = (CHOSEN.get() or {}).get(str(index)) if index is not None else None + try: + if picked or (isinstance(ref, dict) and ref.get("검색어")): + ref = picked or pick(master, ref, env, unit) + have = master.get(ref).get("단위") if ref[:2] == mm.MARKET else None + price, source = mm.element(master, ref, env, None) + warn = None + if have and unit and not same_kind(have, unit): + warn = f"줄 {unit} · 자재 {have}" + price = convert(price, have, unit) if have and unit else price + else: + price, source = mm.element(master, ref, env, unit) + warn = None + except mm.mf.FormulaError as e: + return None, None, str(e), None + why = None if price is not None else f"「{master.label(ref)}」 값 없음 — 관리자가 채울 값" + return price, source, why, warn + + +# ── 검사 ─────────────────────────────────────────────────────────────── +def check_zero(master, where: str, cond: dict) -> list[str]: + """검색어 후보 0 — `{입력}` 이 든 검색어는 계산 때 채워져 여기서 못 봄.""" + term = str(cond.get("검색어") or "") + if not term.strip() or "{" in term or any("{" in str(v) for v in cond.values()): + return [] if term.strip() else [f"{where} · 검색어 비어 있음"] + return [] if search(master, cond) else [f"{where} · 검색어 「{term}」 후보 0"] + + +def check_warn(master, where: str, cond: dict, unit: str | None) -> list[str]: + """단위 경고 줄 — 검색어 후보가 있으나 줄 단위와 같은 성격이 하나도 없음.""" + term = str(cond.get("검색어") or "") + if not unit or "{" in term or "{" in str(cond.get("구분") or ""): + return [] + keys = search(master, cond) + rows = master.index.get(mm.MARKET, {}) + if keys and not any(same_kind(rows[k].get("단위"), unit) for k in keys): + return [f"{where} · 검색어 「{term}」 후보 단위가 줄 단위 「{unit}」 과 성격이 다름"] + return [] + + +import master_formula as mf # noqa: E402, F401 — 서로 부름 · 이 모듈 이름이 다 선 뒤에 읽음 diff --git a/resources/tester/test_m01_pick.py b/resources/tester/test_m01_pick.py new file mode 100644 index 00000000..1e6215da --- /dev/null +++ b/resources/tester/test_m01_pick.py @@ -0,0 +1,114 @@ +"""M01 재료 기본 검색어 · 고름 · 단위 환산 · 단위 경고 — PLAN 8-1 · 8-2 계약. + +검색어 든 견본 로직 줄을 시험 안에서 만들어(`POST /calc` 의 `row` — 저장 안 함) 돌림. 정본 폴더는 읽기만. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store + +mf = store.cm.mf +mp = mf.mp + + +@pytest.fixture(scope="module") +def client() -> TestClient: + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _sample(term: str, unit: str, **cond) -> dict: + """재료 줄 하나짜리 견본 로직 — 검색어 줄 하나 · 수량 1.""" + return { + "이름": "검색어 견본", + "구분": "자체", + "입력": [], + "호표": [ + { + "종류": "재료", + "요소": {"검색어": term, **cond}, + "이름": "견본 재료", + "단위": unit, + "수량": "1", + "비목": "재료비", + } + ], + } + + +def _line(client: TestClient, row: dict, picks: dict | None = None, path: str = "calc") -> dict: + body = {"key": "GF999999", "inputs": {}, "row": row, **({"고름": picks} if picks else {})} + got = client.post(f"/api/m01/{path}", json=body).json() + assert got.get("ok", True) is True, got + return got["lines"][0] if path == "calc" else got + + +def test_검색어로_찾고_지역_낱말이_맞는_줄이_먼저(client) -> None: + seoul = _line(client, _sample("합판(내수) 12t 서울", "㎡")) + assert seoul["출처"].startswith("MT000018") and seoul["단가"] == 11354 # 값 열 가운데 낮은 값 + busan = _line(client, _sample("합판(내수) 12t 부산", "㎡")) + assert busan["출처"].startswith("MT000020") and busan["단가"] == 11488 + assert "단위경고" not in seoul + + +def test_고름은_그_줄만_그_품목으로_셈(client) -> None: + row = _sample("합판(내수) 12t 서울", "㎡") + got = _line(client, row, {"0": "MT000019"}) + assert got["출처"].startswith("MT000019") and got["단가"] == 11556 + assert _line(client, row)["단가"] == 11354 # 요청마다 — 저장 안 함 + text = _line(client, row, {"0": "MT000019"}, "text") + assert text["ok"] is True # 읽는 식도 같은 몸으로 돎 + + +def test_같은_성격_단위는_줄_단위로_환산(client) -> None: + got = _line(client, _sample("어닐링철선 서울 4.0mm", "톤")) # 자재 kg → 줄 톤 + assert got["단가"] == 1610000 and "단위경고" not in got + got = _line(client, _sample("어닐링철선 서울 4.0mm", "g")) + assert got["단가"] == pytest.approx(1.61) + + +def test_성격이_다르면_계산은_하고_단위경고(client) -> None: + got = _line(client, _sample("어닐링철선 서울 4.0mm", "m")) + assert got["단가"] == 1610 and got["단위경고"] == "줄 m · 자재 kg" + assert got["금액"] == 1610 + + +def test_검색어_후보가_없으면_그_줄만_비움(client) -> None: + got = client.post( + "/api/m01/calc", json={"key": "GF999999", "inputs": {}, "row": _sample("없는물건zzz", "개")} + ).json() + assert got["ok"] is True and got["lines"][0]["금액"] is None + assert "후보 없음" in got["lines"][0]["까닭"] + + +def test_기계_입력은_목록_밖_원문번호도_받음(client) -> None: + key = "GC000267" + inputs = {**store.auto(key)["값"], "기계": "0101-0007"} # 고르기 목록 밖 · 기계 마스터엔 있음 + got = client.post("/api/m01/calc", json={"key": key, "inputs": inputs}).json() + assert got["ok"] is True, got + bad = client.post("/api/m01/calc", json={"key": key, "inputs": {**inputs, "기계": "9999-9999"}}) + assert bad.json()["ok"] is False # 기계 마스터에 없는 번호는 그대로 막힘 + + +def test_단위_환산_표() -> None: + assert mp.convert(Decimal(1610), "kg", "톤") == 1610000 + assert mp.convert(Decimal(5), "Ton", "kg") == Decimal("0.005") + assert mp.convert(Decimal(100), "㎡", "㎠") == Decimal("0.01") + assert mp.convert(Decimal(7), "kg", "m") == 7 # 성격이 다르면 그대로 + assert mp.same_kind("ℓ", "㎥") and not mp.same_kind("kg", "m") + + +def test_check_master_검색어_후보0_단위경고(client) -> None: + whole = store.loaded()[1] + assert mf.check_pick(whole, "줄", {"검색어": "합판(내수) 12t 서울"}) == [] + assert "후보 0" in mf.check_pick(whole, "줄", {"검색어": "없는물건zzz"})[0] + assert mf.check_unit(whole, "줄", {"검색어": "어닐링철선 서울 4.0mm"}, "kg") == [] + assert "성격이 다름" in mf.check_unit(whole, "줄", {"검색어": "어닐링철선 서울 4.0mm"}, "m")[0]