"""단가산출 흐름도 입력 값(PLAN 35-9 · 정함 5 · 6 · 11) — 서버 길은 `M02_DF_Router.py`. · 그룹 요소 값 — 인력 노임 · 재료(키 · 검색어 묶음) · 유가전력 · 환율 · 요율 · 기계(값 칸 · 시간당 경비) · 소요량 / 계수 표(블록 · 한 줄) · 사용자 요소 — 단가는 기존 `/calc`(`M01_MasterData_Store.calc`)와 같은 셈 · 재료 자리 셋(고정 · 선택 · 조건 찾기) · 자재 찾기(띄어쓰기 AND · 쪽) · 단위 · 금액 표준 표 마스터 파일은 읽기만. """ from __future__ import annotations import json import re from contextvars import ContextVar from typing import Any from M01_MasterData import M01_MasterData_Store as store cm = store.cm _UKEY = re.compile(r"^UE-(\d{4,})$") RANGE_LOW, RANGE_HIGH = "·아래", "·위" RANGE_RULE = "·범위규칙" # 표 블록 끝 칸 — 줄마다 경계 말(옛 find 와 같음 · 없으면 초과·이하) FIELDS = (*cm.mf.COST_ITEMS, "계") # 단가 자리 — `mf.run` 을 부른 쪽이 넘긴 Master(프로젝트 사본이면 그 단가) · 없으면 마스터 # ponytail: 단가 · 값 · 표만 이 자리 · 찾기 목록(재료 조건 찾기 · 그룹 찾기 · 기계 짝)은 마스터에서 SOURCE: ContextVar = ContextVar("df_price_source", default=None) _DRAFT = "GX999998" # 단가 자리에 잠깐 얹는 견본 로직 키 def _whole(): return SOURCE.get() or store.loaded()[1] def _calc(inputs: dict, row: dict) -> dict: """`Store.calc("", inputs, row)` 와 같은 꼴 — 단가 자리가 넘겨졌으면 그 Master 로 셈.""" whole = SOURCE.get() if whole is None: return store.calc("", inputs, row, None) slot = whole.index.setdefault(_DRAFT[:2], {}) slot[_DRAFT] = {**store._dec(row), "키": _DRAFT} try: given = cm.mf.fill_empty(slot[_DRAFT], {k: store._dec(v) for k, v in inputs.items()}) got = cm.mf.run(whole, _DRAFT, given) except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: return {"ok": False, "reason": str(e) or type(e).__name__} finally: slot.pop(_DRAFT, None) if "결과" in got: return {"ok": True, "result": got["결과"], "middle": got["중간"]} sums = {k: got[k] for k in cm.mf.BUNDLE} return {"ok": True, "lines": got["줄"], "sums": sums, "middle": got["중간"]} # ── 입력 값 ──────────────────────────────────────────────────────────── def _row(line: dict, names) -> dict: """단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직.""" return { "키": "", "원문번호": "", "이름": "입력 값", "결과단위": "원/식", "출처": "자체", "입력": [{"이름": n} for n in names], "중간": [], "호표": [line], "덧줄": [], "끝수": None, } def _money(got: dict, field: str | None) -> dict: if not got["ok"]: return got if "result" in got: return {"ok": True, "value": got["result"]} pick = field or "계" if pick not in FIELDS: return {"ok": False, "reason": f"모르는 칸 「{pick}」"} return {"ok": True, "value": got["sums"][pick]} def _pick(key: Any) -> dict | None: """재료 검색어 묶음 `{구분, 상세구분, 검색어, 규격?}` — 묶음 · 그 JSON 글 · 아니면 None.""" if isinstance(key, dict): return key if isinstance(key, str) and key.lstrip().startswith("{"): try: got = json.loads(key) except ValueError: return None return got if isinstance(got, dict) else None return None def _material(cond: dict, unit: str, inputs: dict | None = None) -> dict: """검색어 묶음 재료 단가 — 기존 로직 재료 줄과 같은 길(검색어 · 자동값 차례 · 단위 환산) · inputs = 로직 입력(자재지역 · 계약종별 · 검색어 {자리}) — 옛 셈이 읽는 그대로.""" inputs = inputs or {} line = {"종류": "재료", "요소": cond, "이름": cond.get("검색어") or "재료", "수량": "1"} got = _calc(inputs, _row({**line, "단위": unit, "비목": "재료비"}, inputs)) if not got["ok"]: return got one = got["lines"][0] if one["단가"] is None: word = cond.get("검색어") or "" empty = "값 없음" in str(one.get("까닭") or "") why = ( "단가가 자료에 비어 있음 — 단가를 채우거나 다른 품목을 고름" if empty else "맞는 재료 없음" ) return {"ok": False, "blank": True, "reason": f"「{word}」 {why} — {one.get('까닭') or ''}"} picked = one.get("품목") or {} return {"ok": True, "value": one["단가"], "name": picked.get("이름"), "unit": unit or None} def _brief(key: str, want: str) -> tuple[dict | None, str | None]: """마스터 줄 하나 + 그룹 확인 — (줄, 까닭) · 유가전력은 재료 그룹 안 MO 키.""" try: row = _whole().get(key) except cm.mf.FormulaError as e: return None, str(e) group = store.mk.GROUP_OF.get(store.mk.id_of(key)) if not (group == want or (want == "유가전력" and key.startswith("MO"))): return None, f"「{key}」 는 {want} 아님" return row, None def _price(group: str, key: str, unit: str = "", inputs: dict | None = None) -> dict: """인력 노임 · 재료 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로.""" row, why = _brief(key, group) if row is None: return {"ok": False, "reason": why} kind, cost = {"인력": ("인력", "노무비"), "재료": ("재료", "재료비")}[group] line = {"종류": kind, "요소": key, "이름": row.get("이름") or key, "수량": "1"} line = {**line, "단위": unit or row.get("단위") or "", "비목": cost} inputs = inputs or {} got = _calc(inputs, _row(line, inputs)) if not got["ok"]: return got one = got["lines"][0] out = {"ok": one["단가"] is not None, "value": one["단가"], "name": row.get("이름")} out |= {"blank": True} if one["단가"] is None else {} # 옛 셈은 그 줄만 비우고 계속 return {**out, "unit": row.get("단위"), **({"reason": one["까닭"]} if one.get("까닭") else {})} def _plain_value(group: str, key: str, field: str | None) -> dict: """유가전력 · 환율 · 요율 · 기계 값 칸 — 그 줄 값(묶음이면 field · 요율 기본 rate_percent).""" row, why = _brief(key, group) if row is None: return {"ok": False, "reason": why} value, unit = row.get("값"), row.get("단위") if isinstance(value, dict): pick = field or ("rate_percent" if group == "요율" else "") if pick not in value: return { "ok": False, "reason": f"「{row.get('이름')}」 에서 쓸 칸을 고름 — {', '.join(value)}", } value = value[pick] unit = ( unit.get(pick) if isinstance(unit, dict) else ("%" if pick == "rate_percent" else unit) ) if value is None: return {"ok": False, "reason": f"「{row.get('이름')}」 값이 자료에 비어 있음"} return {"ok": True, "value": value, "name": row.get("이름"), "unit": unit} def _table(key: str, at: Any = None) -> dict: """소요량 · 계수 표 → 머리 줄 + 줄(조건 칸 → 값 칸 · 범위가 있는 칸은 「·아래」 「·위」 두 칸 · 그 칸에 수 하나면 아래 · 위 같은 수) · at = 줄 차례(0부터)면 머리 줄 + 그 줄 하나.""" if SOURCE.get() is not None: # 단가 자리(프로젝트 사본)의 표 try: table = SOURCE.get().get(key) except cm.mf.FormulaError: table = None return _table_of(table, key, at) files = store.loaded()[0] table = next( ( t for d in files.values() if d.get("그룹") in cm.mf.TABLE_GROUPS for t in d.get("표") or [] if str(t.get("키")) == key ), None, ) return _table_of(table, key, at) def _table_of(table: dict | None, key: str, at: Any) -> dict: if table is None: return {"ok": False, "reason": f"없는 표 「{key}」"} conds, lines = table.get("조건") or {}, table.get("줄") or [] names = [*conds, *(table.get("값칸") or {})] split = { n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines) } head = [h for n in names for h in ((n + RANGE_LOW, n + RANGE_HIGH) if n in split else (n,))] if at is not None: if not isinstance(at, int) or not 0 <= at < len(lines): return {"ok": False, "reason": f"「{table.get('이름')}」 에 {at} 번째 줄 없음"} lines = [lines[at]] rows, worth = [], set(table.get("값칸") or {}) for r in lines: line = [] for n in names: v = _arith(r.get(n)) if n in worth else r.get(n) if n in split: line += list(v) if isinstance(v, list) else [v, v] else: line.append(None if v == "" else v if not isinstance(v, dict) else str(v)) rows.append(line) if "범위" in conds.values(): # 끝 칸 「·범위규칙」 = 줄마다 그 줄 경계 말(lookup 이 읽음) head, rows = [*head, RANGE_RULE], [[*r, _bound(table, x)] for r, x in zip(rows, lines)] return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")} def _bound(table: dict, row: dict) -> str: """줄 경계 말 — 옛 `_match` 와 같음 · 첫 낱말 · 「이하 포함」 이면 맨 윗줄만 이상·이하.""" rule, *more = str(table.get("범위규칙") or "초과·이하").split(" · ") rule = rule.split(" (")[0] if rule != "이상·미만" or not any("이하 포함" in x for x in more): return rule for c, kind in (table.get("조건") or {}).items(): # 옛 _match — 그 범위 칸 위 끝이 가장 큰 줄 ups = [ r[c][1] for r in table.get("줄") or [] if isinstance(r.get(c), list) and r[c][1] is not None ] if kind == "범위" and ups and isinstance(row.get(c), list) and row[c][1] == max(ups): return "이상·이하" return rule def _arith(v: Any) -> Any: """값 칸 수 식 글(「(0.05)」 「1/1.30」) — 옛 셈처럼 수로(사칙연산 · 괄호만 · 옛 엔진 파서).""" if not isinstance(v, str) or not cm.mf._ARITH.fullmatch(v): return v try: return cm.mf.evaluate(cm.mf.parse(v), {}, None, 0) except (cm.mf.FormulaError, ArithmeticError): return v def _user(uid: str, local: list[dict]) -> dict: """사용자 요소 값 — local = 다이어그램 안 것 + 공용 목록(부르는 쪽이 모음).""" one = next((r for r in local if r.get("id") == uid), None) if one is None: return {"ok": False, "reason": f"없는 사용자 요소 「{uid}」"} if one.get("value") is None: return {"ok": False, "reason": f"「{one.get('name')}」 값이 비어 있음"} return {"ok": True, "value": one["value"], "name": one.get("name"), "unit": one.get("unit")} MACHINE_SEP = "#" # 입력 판(sub3) 기계 요소 ref 「EQ키#칸」 HOURLY = "시간당 경비" # 기계 칸 이름 — 기계 시간당 경비 로직으로 셈(기종 → 로직 짝) _MACHINE_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용 def _machine_family() -> list[dict]: """기계 시간당 경비 로직 — 「건설기계」 장 파일 안 · 입력 「기계」 고르기가 있는 돈 로직.""" files = store.loaded()[0] return [ row for name, data in files.items() if data.get("그룹") == "로직" and "건설기계" in name for row in data.get("줄") or [] if str(row.get("결과단위") or "").startswith("원") and any(i.get("이름") == "기계" and i.get("고르기") for i in row.get("입력") or []) ] def _machine_rank(row: dict) -> tuple: """짝 차례(옛 EasyCalc `pickMachine` 와 같음) — 부착 전용 · 손료만 · 보정 · 기종 수 · 키.""" names = [i.get("이름") for i in row.get("입력") or []] codes = next(i.get("고르기") for i in row["입력"] if i.get("이름") == "기계") attach = any(n not in _MACHINE_PLAIN for n in names) loss = "지역" not in names return (attach, loss, loss and "보정작업" in names, len(codes), str(row.get("키"))) def _machine_hourly(key: str, inputs: dict) -> dict: """기계 한 시간 값 — 기종을 받는 기계경비 로직 가운데 짝 차례 첫째 · 빈 입력은 첫 고르기.""" row, why = _brief(key, "기계") if row is None: return {"ok": False, "reason": why} code = str(row.get("원문번호") or "") fit = [r for r in _machine_family() if code in [str(c) for c in _codes(r)]] if not fit: return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"} logic = min(fit, key=_machine_rank) given = {} for spec in logic.get("입력") or []: name = spec.get("이름") if name != "기계": given[name] = inputs.get(name, (spec.get("고르기") or [None])[0]) args = "".join(f", {n}='{v}'" for n, v in given.items()) ref = f"로직({logic['키']}, 기계='{code}'{args})" line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"} got = _money(_calc({}, _row(line, ())), "계") return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got def _codes(row: dict) -> list: return next( (i.get("고르기") or [] for i in row.get("입력") or [] if i.get("이름") == "기계"), [] ) def one_value(item: dict, local: list[dict] | None = None) -> dict: """물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`. 기계 ref 「EQ키#칸」(입력 판 요소 ref)도 받음 · 칸 「시간당 경비」 = 기계경비 로직으로 셈. """ group, ref = item.get("group"), item.get("ref") table, field = item.get("table") or {}, item.get("field") if group == "기계" and isinstance(ref, str) and MACHINE_SEP in ref: ref, _, field = ref.partition(MACHINE_SEP) try: if ( isinstance(item.get("slot"), dict) and group in GROUP_RULE ): # 인력 · 기계 · 유가 조건 찾기 got = group_find(str(group), item["slot"], item.get("value")) elif isinstance(item.get("slot"), dict): # 재료 자리(고정 · 선택 · 조건 찾기) got = slot_price(item["slot"], item.get("value"), item.get("pick")) elif isinstance(ref, str) and _UKEY.match(ref): got = _user(ref, local or []) elif group in ("소요량", "계수"): got = _table(str(table.get("key") or ref or ""), table.get("row")) elif group == "재료" and _pick(ref) is not None: got = _material(_pick(ref), str(item.get("unit") or ""), item.get("inputs")) elif group in ("인력", "재료"): got = _price(group, str(ref or ""), str(item.get("unit") or ""), item.get("inputs")) elif group == "기계" and str(ref or "").startswith("로직("): inputs = item.get("inputs") or {} line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"} got = _money(_calc(inputs, _row(line, inputs)), field) elif group == "기계" and field == HOURLY: got = _machine_hourly(str(ref or ""), item.get("inputs") or {}) elif group in ("기계", "유가전력", "환율", "요율"): got = _plain_value(group, str(ref or ""), field) else: got = {"ok": False, "reason": f"모르는 그룹 「{group}」"} except store.StoreError as e: got = {"ok": False, "reason": str(e.detail)} return {"id": item.get("id") or "", **got} def values(items: list[dict], local: list[dict]) -> dict: # ponytail: 하나씩 셈(단가 하나 0.3초쯤) — 입력 값이 수십이면 마스터 사본 한 벌로 묶어 셈 return {"values": [one_value(i, local) for i in items]} # ── 자재 찾기 · 재료 자리 ────────────────────────────────────────────── MATERIALS = "재료_자재품목.json" _LEDGER: dict[str, Any] = {} def _numbers() -> dict[str, str]: """자재품목 키 → 원문번호(품번) — 줄에 없고 키대장에만 있음 · 파일이 바뀌면 다시 읽음.""" path = store.FOLDER / store.mk.BOOK.name stamp = (str(path), path.stat().st_mtime if path.is_file() else 0) if _LEDGER.get("stamp") != stamp: book = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {"키": {}} keys = book.get("키") or {} found = {k: str(v.get("원문번호") or "") for k, v in keys.items() if k.startswith("MT")} _LEDGER.update(stamp=stamp, map=found) return _LEDGER["map"] def _squash(text: Any) -> str: return re.sub(r"\s+", "", str(text or "")).lower() def _found(sub: str, detail: str, q: str) -> list[dict]: """범위(구분 · 상세구분) 안 · 띄어쓰기로 나눈 말을 모두(AND) 이름 · 규격 · 키 · 원문번호에서. 품목 글 · 찾는 낱말 모두 띄어쓰기를 지우고 비교 — 「흄 관(B형)」 도 「흄관」 으로 걸림. """ words = [_squash(w) for w in str(q or "").split()] numbers = _numbers() rows = (store.loaded()[0].get(MATERIALS) or {}).get("줄") or [] out = [] for r in rows: if (sub and r.get("구분") != sub) or (detail and r.get("상세구분") != detail): continue fields = (r.get("이름"), r.get("규격"), r.get("키"), numbers.get(r.get("키"))) hay = "|".join(_squash(x) for x in fields) if all(w in hay for w in words): out.append(r) return out def _brief_row(r: dict) -> dict: return { "ref": r.get("키"), "name": r.get("이름"), "spec": r.get("규격") or "", "unit": r.get("단위") or "", "price": store._lowest(r), "구분": r.get("구분"), "상세구분": r.get("상세구분"), } def materials(q: str, sub: str, detail: str, page: int, size: int) -> dict: """자재 찾기 — {total, page, size, items: [{ref, name, spec, unit, price, 구분, 상세구분}]}.""" hits = _found(sub, detail, q) page, size = max(page, 1), min(max(size, 1), 200) part = hits[(page - 1) * size : page * size] return {"total": len(hits), "page": page, "size": size, "items": [_brief_row(r) for r in part]} FIELDS_RULE, OPS_RULE = ("규격", "이름", "키", "검색어"), ("포함", "같음") # Model CfMaterialRule _HOLE = re.compile(r"\{[^{}]+\}") def _filled(template: str, value: Any) -> str: """검색어 틀의 {자리} — 알약 값이 이미 채운 글이면 그대로 · 아니면 자리마다 그 값.""" text = str(value) pattern = "".join( ".+" if _HOLE.fullmatch(p) else re.escape(p) for p in re.split(r"(\{[^{}]+\})", template) ) return text if re.fullmatch(pattern, text) else _HOLE.sub(lambda _: text, template) def _search(slot: dict, value: Any) -> dict: """조건 찾기 field 검색어 — 틀(요소 ref 묶음)의 {자리} 를 알약 값으로 채워 옛 찾기 그대로.""" if value in (None, ""): return {"ok": False, "reason": "찾을 값이 비어 있음 — 이어진 알약을 확인해 주세요"} cond = _pick(slot.get("ref")) # 요소 ref = 옛 검색어 묶음(규격 · 지역 칸도 그대로) if cond is None: # 묶음이 없으면 범위(구분 · 상세구분) + 채운 검색어 scope = slot.get("scope") or {} cond = {k: scope[k] for k in ("구분", "상세구분") if scope.get(k)} | {"검색어": ""} fill = {k: _filled(v, value) for k, v in cond.items() if isinstance(v, str) and _HOLE.search(v)} cond = {**cond, **fill} if fill else {**cond, "검색어": str(value)} return _material(cond, str(slot.get("unit") or ""), slot.get("inputs")) def slot_price(slot: dict, value: Any = None, pick: str | None = None) -> dict: """재료 자리 단가 — 고정 = 품목 · 선택 = 고른 품목(없으면 기본) · 조건 찾기 = 범위 + 규칙. slot = Model CfElementRef 꼴 {mode: fixed | choice | rule, ref?, default?, scope?: {구분?, 상세구분?, 찾는말?}, field?: 규격 | 이름, op?: 포함 | 같음} 조건 찾기(value = 이어진 알약 값): 하나면 그 단가 · 0 이면 맞는 재료 없음 · 여럿이면 후보 다섯. """ mode, inputs = slot.get("mode") or "fixed", slot.get("inputs") if mode == "fixed": return _price("재료", str(slot.get("ref") or ""), "", inputs) if mode == "choice": ref = pick or slot.get("default") if not ref: return {"ok": False, "reason": "고른 품목도 기본 품목도 없음 — 품목을 골라 주세요"} return _price("재료", str(ref), "", inputs) if mode != "rule": return {"ok": False, "reason": f"모르는 재료 방식 「{mode}」"} field, op, scope = ( slot.get("field") or "규격", slot.get("op") or "포함", slot.get("scope") or {}, ) if field not in FIELDS_RULE or op not in OPS_RULE: return {"ok": False, "reason": f"모르는 찾기 규칙 「{field} {op}」"} if field == "검색어": return _search(slot, value) if value in (None, ""): return {"ok": False, "reason": "찾을 값이 비어 있음 — 이어진 알약을 확인해 주세요"} want = _squash(value) sub, detail = str(scope.get("구분") or ""), str(scope.get("상세구분") or "") rows = _found(sub, detail, scope.get("찾는말") or "") numbers = _numbers() if field == "키" else {} # 키 = 자재 키 또는 품번(원문번호) def pool(r: dict) -> list[str]: got = [r.get("키"), numbers.get(r.get("키"))] if field == "키" else [r.get(field)] return [_squash(x) for x in got if x] hits = [r for r in rows if any(want == x if op == "같음" else want in x for x in pool(r))] if not hits: where = " · ".join(x for x in (sub, detail) if x) or "전체" return {"ok": False, "reason": f"맞는 재료 없음 — {where} 에서 {field}에 「{value}」 {op}"} if len(hits) > 1: names = ", ".join(f"{r.get('이름')} {r.get('규격') or ''}".strip() for r in hits[:5]) return { "ok": False, "reason": f"맞는 재료 {len(hits)}개 — 범위나 규칙을 좁혀 주세요({names} …)", "candidates": [_brief_row(r) for r in hits[:5]], } got = _price("재료", str(hits[0].get("키")), "", inputs) return {**got, "ref": hits[0].get("키"), "name": hits[0].get("이름")} GROUP_RULE = ( "인력", "기계", "유가전력", ) # 조건 찾기를 재료 밖에서도(옛 LB:{직종} · EQ:{기계} · MO:…) def _group_rows(group: str) -> list[dict]: """그룹 줄 — 인력 · 기계 = 그 파일 · 유가전력 = 재료 안 MO 키.""" files = store.loaded()[0] want = "재료" if group == "유가전력" else group return [ r for d in files.values() if d.get("그룹") == want for r in d.get(store.items_key(d)) or [] if group != "유가전력" or str(r.get("키")).startswith("MO") ] def group_find(group: str, slot: dict, value: Any) -> dict: """인력 · 기계 · 유가전력 조건 찾기 — 알약 값이 이름 · 키(원문번호) 와 같음(포함)인 줄 하나. slot = {field?: 이름 | 키, op?: 같음 | 포함, scope?: {찾는말?}, ref?: 「키#칸」 = 칸 이어 씀} · field 가 없으면 키(원문번호) 또는 이름이 같은 줄 · 하나면 그 값(`one_value`). """ if value in (None, ""): return {"ok": False, "reason": "찾을 값이 비어 있음 — 이어진 알약을 확인해 주세요"} field, op, want = slot.get("field"), slot.get("op") or "같음", _squash(value) words = str((slot.get("scope") or {}).get("찾는말") or "").split() def hit(r: dict) -> bool: hay = _squash(f"{r.get('이름')}{r.get('규격') or ''}") if not all(_squash(w) in hay for w in words): return False keys = [_squash(r.get("키")), _squash(r.get("원문번호"))] names = [_squash(r.get("이름"))] pool = keys if field == "키" else names if field == "이름" else keys + names return any(want == x if op == "같음" else want in x for x in pool if x) hits = [r for r in _group_rows(group) if hit(r)] if len(hits) != 1: if not hits: return {"ok": False, "reason": f"맞는 {group} 없음 — 「{value}」"} names = ", ".join(f"{r.get('이름')} {r.get('규격') or ''}".strip() for r in hits[:5]) return {"ok": False, "reason": f"맞는 {group} {len(hits)}개 — 좁혀 주세요({names} …)"} ref = str(hits[0].get("키")) suffix = str(slot.get("ref") or "").partition(MACHINE_SEP)[2] got = one_value({"group": group, "ref": f"{ref}{MACHINE_SEP}{suffix}" if suffix else ref, "unit": slot.get("unit") or ""}) # fmt: skip return {**got, "ref": ref, "name": hits[0].get("이름")} # ── 단위 · 금액 표준 표 ──────────────────────────────────────────────── STANDARDS = {"unit": "단위표준.json", "money": "금액단위표준.json"} def standards(first: Any, mine: Any) -> dict: """{unit, money, saved} — 고친 것(`_data`)이 있으면 그것 · 없으면 처음 값(`_standard`).""" out: dict[str, Any] = {"saved": False} for part, name in STANDARDS.items(): path = mine / name if (mine / name).is_file() else first / name out["saved"] = out["saved"] or path.parent == mine out[part] = json.loads(path.read_text(encoding="utf-8")) return out def standards_check(body: dict) -> None: for part in STANDARDS: table = body.get(part) if not isinstance(table, dict) or not isinstance(table.get("줄"), list): raise store.StoreError(400, f"「{part}」 표 모양이 아님 — 줄 목록이 있어야 함")