Files
eomsangdonandClaude Opus 5.5 062fb07668 feat(822-2): 식 고름 한 곳(목록 › 꼴) · 까닭 글 · 새 기계 식 등록 · 쓰는 계산식 · 직접 값 · 전력 미리보기
- M02_Machine_Hourly.pick = 목록 › 그 기계 식 › 꼴(내 기계와 같은 규칙) · why = 값 모자란 까닭(가격 없음 · 범위 · 일 단위 손료 …)
- 데이터 흐름 · 사본 모으기 · M01 · 불러오기 · 줄 쌓기 모두 pick 을 씀(데이터 흐름 옛 짝 고름 지움)
- 내 기계 꼴: 전력 · 전력 + 조종원 · 직종 입력 · 손료계수 한 칸 · 직접(GC001199 — 줄 쌓기 회색 풀림) · 연료 + 전력 = 400
- M01 [새 기계] 저장 = 꼴 식 「기계」 목록에 등록 · M01 에서 만든 기계 지우면 목록에서 뺌 · 상세 「쓰는 계산식」(POST /machine/formula) · 목록 시간당 「—」 대신 까닭 글
- 미리보기 짝: 잡품 = (주연료 + 전력) × %(전력 기종 = 전력비의 %)
- 시험 test_822_2 새로 · test_804_2 갈래에 전력 줄 더함(손료만 식과 전력 식은 다른 갈래 — 모르타르 믹서)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 18:12:34 +09:00

551 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""단가산출 흐름도 입력 값(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 = "시간당 경비" # 기계 칸 이름 — 기계 시간당 경비 로직으로 셈(기종 → 로직 짝)
def _machine_hourly(key: str, inputs: dict) -> dict:
"""기계 한 시간 값 — 식 고름 = `M02_Machine_Hourly.pick`(목록 › 꼴 · 822-2) · 빈 입력은 첫 고르기."""
from M02_MasterTemplete import M02_Machine_Hourly as mh
row, why = _brief(key, "기계")
if row is None:
return {"ok": False, "reason": why}
code = str(row.get("원문번호") or "")
logic = mh.pick(row)
if lack := mh.why(row, logic):
return {"ok": False, "reason": f"「{row.get('이름')}」 — {lack}"}
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 if n.isidentifier() else repr(n)}='{v}'" for n, v in given.items())
mine = f", 기계='{code}'" if mh.takes_machine(logic) else ""
ref = f"로직({logic['키']}{mine}{args})"
line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"}
got = _money(_calc({}, _row(line, ())), "계")
return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got
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,
"blank": True,
"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,
"blank": True,
"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,
"blank": True,
"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(folder: Any, _old: Any = None) -> dict: # _old = 옛 고친 값 폴더(안 씀 · 옛 부름 꼴)
"""{unit, money, saved} — 표준 표 파일(`_standard/` · 고치면 바로 그 파일) · saved 늘 False."""
out: dict[str, Any] = {"saved": False}
for part, name in STANDARDS.items():
out[part] = json.loads((folder / name).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}」 표 모양이 아님 — 줄 목록이 있어야 함")