Files
Aislo/resources/master_data/scripts/master_doc_unit.py
T
eomsangdonandClaude Opus 5.5 54d49c1a82 feat(845-1): 수식 문서 변수 단위 채움 — 원본 · 식 셈 · 금액 줄 역산 규칙 한 벌(변환 끝 · 금액 무관)
- 새 규칙 `master_doc_unit.fill` — 변환(`master_doc_convert.convert`) 끝에서 부름 · 빌더 · 실행 중 변환(SK 사본) 한 벌 · 보이는 변수 `단위` 칸만(연결 안 단위 · 셈 안 건드림)
- 근거: 표 값칸 단위 · 「줄 따름」 류 = 고를 수 있는 행 단위 한 가지 · 기계 칸 = 기계 표 단위(천원 · 10^-7/시간 · ℓ/hr · ㎾h/hr · % · 인/일) · 노임 원/일 · 재료 · 유가 원/단위 · 부른 문서 결과단위 · 식 셈(천원 × 1000 · % ÷ 100 · 2 배 안 보정 계수) · 금액 줄 역산(줄 단위 = 단가 단위일 때 더하기로만 이은 변수)
- 저장본 다시 지음: 1,520 문서 중 1,486 문서의 변수 단위 칸만 바뀜(채움 7,816 · 자리표시 「구분 따름」 · 「수」 · 나열 고침 982 · 표기 25) · 금액 대조 기본 1,520 같음 · 옵션 표본 18,873 중 다름 3 = 채택 1 원 셋 그대로
- 짐작은 안 채움(목록 tmp/845_1 · 시간환산 1/8 · 측량 직종 칸 · 식 속 수 상수 등)
- 지킴 시험 test_845_1: 다시 채워도 바뀔 단위 0 · 자리표시 0 · 금액 줄 단위 = 단가 단위 · 사용자 그림 문서 · 단위 셈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 22:32:39 +09:00

566 lines
24 KiB
Python
Raw 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.
# -*- coding: utf-8 -*-
"""수식 문서 변수 단위 채움 · 단위 셈 (PLAN 845-1 · 요구 880).
보이는 변수 `단위` 칸만 고침 — 셈 · 연결(연결 안 `단위` = 재료 환산) 안 건드림.
빌더(`build_수식문서_802_2.py`)가 변환 뒤 문서마다 `fill` 을 부름 — 빈 단위만 채우고 적힌 단위는
그대로(자리표시 「줄 따름」 류 · 「수」 만 고침). 채움 근거는 원본 · 셈만(지어내지 않음):
- 표 칸 = 표 `값칸` 단위 · 「줄 따름」 류 = 고를 수 있는 행이 모두 같은 `단위` 칸일 때 그 단위.
- 요소 = 기계 칸 단위(`기계/*.json` 단위) · 노임 · 재료 · 유가 = 「원/요소 단위」.
- 단가산출 = 부른 문서 결과단위 · 수 고르기 = 이름 끝에 적힌 단위(「두께mm이하」).
- 식 = 식 속 변수 단위로 셈(`infer`) — 2 배 밖 수(× 8 · ÷ 15)가 끼면 단위를 품을 수 있어 안 채움.
- 금액 줄 역산 = 줄 단위가 단가 단위와 맞을 때 수량을 더하기로만 이은 변수 = 줄 단위.
무차원(계수 · 비 · 글 고르기)은 빈 단위가 맞음 — `dimensionless` 가 가름.
"""
from __future__ import annotations
import re
from fractions import Fraction
import master_doc as md
import master_unit_text as ut
# 행마다 단위가 다른 표 칸 — 행의 `단위` 칸이 참값
ROW_UNIT = (
"줄 따름",
"구분 따름",
"단위 따름",
"단위 칸 따름",
"줄 단위",
"단위 칸",
"수(단위는 줄의 단위 칸 따름)",
)
PLAIN_NUMBER = (
"수",
"배",
"수(또는 원문 비율표기)",
"수 또는 글자",
"수 또는 글",
) # 원본 「수」 칸 = 무차원
NOT_UNIT = (
"수",
"수(또는 원문 비율표기)",
"수 또는 글자",
"수 또는 글",
) # 단위 칸에 적힌 「수」 = 단위 아님 → 빈 단위
ROW = "줄 따름" # 행마다 다른 단위 한 표기(화면이 안 그림 · `common_util_line_stack_pick`)
TEXT = ("글", "글자")
# 비교 · 셈 꼴 — 같은 단위의 다른 표기
ALIAS = {
"hr": "시간", "h": "시간", "HR": "시간", "Hr": "시간", "min": "분", "sec": "초",
"L": "ℓ", "l": "ℓ", "kg": "㎏", "KG": "㎏", "m3": "㎥", "M3": "㎥", "m2": "㎡", "M2": "㎡",
"km": "㎞", "mm": "㎜", "cm": "㎝", "t": "ton", "TON": "ton", "Ton": "ton", "톤": "ton",
"EA": "개", "ea": "개", "kWh": "㎾h", "kW": "㎾", "SET": "조", "set": "조", "세트": "조",
"명": "인",
} # fmt: skip
SCALE = {"천원": (Fraction(1000), "원"), "만㎡": (Fraction(10000), "㎡")}
# 기계 문서 칸 → 기계 자료 단위 칸(`master_machine_crew` 옛 칸 이름)
EQ_COLUMN = {"잡재료율": "잡품", "조종원": "인력"}
EQ_TEXT = ("조종원직종", "주연료종류", "주연료2종류")
CODE = re.compile(r"직종$|^기계$") # 고르는 값 = 노임 · 기계 번호(수 꼴 글)
NAMED = re.compile(
r"(mm|㎜|cm|㎝|ℓ)(?:이하|이상)?$"
) # 이름 끝에 단위를 적은 수 고르기(「두께mm이하」)
_NUM = re.compile(r"^(\d[\d,]*(?:\.\d+)?)(.+)$")
_POW = re.compile(r"^×?10\^(-?\d+)(.*)$")
def show(unit) -> str:
"""보이는 꼴 한 벌 — 원문 꼴(`master_unit_text.canon`) + ℓ · ㎾h(원문 8-4 · 13-1 꼴)."""
text = ut.canon(unit)
text = re.sub(r"(?<![A-Za-z])L(?![A-Za-z])", "ℓ", text)
return re.sub(r"(?<![A-Za-z])kWh(?![A-Za-z])", "㎾h", text)
# ── 단위 글 → (배, 차원) ─────────────────────────────────────────────
def _token(text: str):
"""낱말 하나 → (배, {단위: 지수}) · 못 읽음 None."""
t = text.strip()
if not t:
return Fraction(1), {}
m = _POW.match(t)
if m:
rest = _token(m.group(2))
return None if rest is None else (Fraction(10) ** int(m.group(1)) * rest[0], rest[1])
if t in SCALE:
n, u = SCALE[t]
return n, {u: 1}
m = _NUM.match(t)
if m and not m.group(2)[:1].isdigit():
rest = _token(m.group(2))
return None if rest is None else (Fraction(m.group(1).replace(",", "")) * rest[0], rest[1])
if t.startswith("%"):
return Fraction(1, 100), {}
if t in PLAIN_NUMBER:
return Fraction(1), {}
if re.search(r"\s", t):
return None
return Fraction(1), {ALIAS.get(t, t): 1}
def parse(unit):
"""단위 글 → (배, 차원) · 자리표시 · 여럿 나열(㎥·㎏·본) · 글 = None."""
text = str(unit or "").strip()
if not text:
return Fraction(1), {}
if text in ROW_UNIT or text in TEXT or "따름" in text:
return None
if (
text[:1] != "(" and "(" in text
): # 「m(블레이드의 길이 3.6m)」 · 「%(범위 내·인력품 대비)」 = 단위 + 덧글
text = re.sub(r"\([^()]*\)", "", text).strip() or text
head, *tails = text.split("/")
out_scale, out = Fraction(1), {}
parts = [(p, 1) for p in head.split("·")] + [
(p, -1) for tail in tails for p in re.split(r"[·,]", tail)
]
if len(head.split("·")) > 2:
return None # 「㎥·㎏·본」 = 줄마다 다른 단위 나열
for p, sign in parts:
got = _token(p)
if got is None:
return None
scale, dims = got
if not scale:
return None
out_scale *= scale**sign
for k, n in dims.items():
out[k] = out.get(k, 0) + n * sign
return out_scale, {k: n for k, n in out.items() if n}
def text_of(dims: dict) -> str:
"""차원 → 단위 글(보이는 꼴) · 노임 「원/일」 × 「인」 = 원(1 인 1 일)."""
if dims.get("원") and dims.get("인", 0) > 0 and dims.get("일", 0) < 0:
n = min(dims["인"], -dims["일"])
dims = {k: v for k, v in {**dims, "인": dims["인"] - n, "일": dims["일"] + n}.items() if v}
up = [k if n == 1 else f"{k}^{n}" for k, n in dims.items() if n > 0]
down = [k if n == -1 else f"{k}^{-n}" for k, n in dims.items() if n < 0]
text = "·".join(up) or ("1" if down else "")
return show(text + "".join("/" + d for d in down))
def same(a, b) -> bool:
pa, pb = parse(a), parse(b)
return pa is not None and pa == pb
# ── 식 단위 셈 ───────────────────────────────────────────────────────
class Unknown(Exception):
"""단위를 못 셈 — 까닭 글."""
_TEXTV = ("글",)
_ZERO = ("영",)
CAP = 64 # 배 갈래 수 윗끝(만약 갈래가 곱으로 불어남)
FACTOR = (Fraction(1, 2), Fraction(2)) # 식 속 수가 곱한 정도 — 이 안 = 보정 계수(무차원)
def _val(node, units: dict):
"""마디 → (배 모음, 차원) · 글 = _TEXTV · 수 0 = _ZERO(아무 단위와 맞음).
배 = 식 값 × 배 → 차원의 기준 값(천원 × 1000 · % ÷ 100 = 1) · 만약 갈래마다 배가 달라 모음."""
kind = node[0]
if kind == "num":
return _ZERO if node[1] == 0 else (frozenset([1 / Fraction(str(node[1]))]), {})
if kind == "str":
return _TEXTV
if kind == "var":
if node[1] not in units:
raise Unknown(f"「{node[1]}」 단위 모름")
got = units[node[1]]
if got is None:
raise Unknown(f"「{node[1]}」 단위가 행마다 다름")
return got if got == _TEXTV else (frozenset([got[0]]), got[1])
if kind == "pct":
inner = _val(node[1], units)
return inner if inner in (_ZERO, _TEXTV) else (_times(inner[0], [Fraction(100)]), inner[1])
if kind == "neg":
inner = _val(node[1], units)
return inner if inner in (_ZERO, _TEXTV) else (frozenset(-f for f in inner[0]), inner[1])
if kind == "bin":
op = node[1]
if op not in ("+", "-", "*", "/"): # 비교 · 그리고 · 또는 = 참거짓
return _TEXTV
a, b = _val(node[2], units), _val(node[3], units)
if op in ("*", "/"):
if _TEXTV in (a, b):
raise Unknown("글을 곱함")
if a == _ZERO or (b == _ZERO and op == "*"):
return _ZERO
if b == _ZERO:
raise Unknown("0 으로 나눔")
sign = 1 if op == "*" else -1
dims = dict(a[1])
for k, n in b[1].items():
dims[k] = dims.get(k, 0) + n * sign
return _times(a[0], [f**sign for f in b[0]]), {k: n for k, n in dims.items() if n}
return _same(a, b, "더하기 · 빼기")
if kind == "call":
if node[1] == "만약": # 조건은 단위와 무관
return _same(_val(node[2][1], units), _val(node[2][2], units), "만약 두 갈래")
args = [_val(x, units) for x in node[2]]
if node[1] in ("올림", "버림", "반올림"):
return args[0]
if node[1] in ("최소", "최대"):
out = args[0]
for x in args[1:]:
out = _same(out, x, node[1])
return out
raise Unknown(f"함수 「{node[1]}」")
if kind == "field":
base = node[1]
if base[0] == "var" and base[1] in units:
return _val(base, units)
raise Unknown("칸 읽기")
raise Unknown(f"마디 「{kind}」")
def _times(fs, gs) -> frozenset:
out = frozenset(f * g for f in fs for g in gs)
if len(out) > CAP:
raise Unknown("갈래가 너무 많음")
return out
def _same(a, b, where: str):
if a == _ZERO:
return b
if b == _ZERO:
return a
if a == _TEXTV and b == _TEXTV:
return _TEXTV
if _TEXTV in (a, b) or a[1] != b[1]:
raise Unknown(f"{where} 단위 다름")
if not a[1]: # 무차원끼리(1 - 8 ÷ 100 · 만약(…, 0.85, 1)) — 2 배 안 수는 보정 계수 하나로
a, b = (_small(a[0]), a[1]), (_small(b[0]), b[1])
return a[0] | b[0], a[1]
def _small(fs: frozenset) -> frozenset:
return frozenset([Fraction(1)]) if all(abs(1 / f) <= FACTOR[1] for f in fs) else fs
def infer(text, units: dict, named: dict | None = None) -> tuple[str | None, str]:
"""식 글 → (단위 글 | None, 까닭) — 「」 = 무차원 · 「글」 = 글 값 · None = 못 셈(까닭)."""
try:
got = _val(md.parse(text), units)
except (Unknown, md.DocError, ArithmeticError) as e:
return None, str(e)
if got == _TEXTV:
return "글", ""
if got == _ZERO:
return None, "값 0 뿐"
fs, dims = got
if any(abs(n) > 1 for n in dims.values()):
return None, "제곱 단위"
if fs == {1}:
return text_of(dims), ""
if len(fs) == 1:
hit = (named or {}).get((next(iter(fs)), tuple(sorted(dims.items()))))
if hit:
return hit, ""
if not dims and fs == {Fraction(1, 100)}:
return "%", ""
if not any(dims.values()) and all(f != 1 for f in fs):
return None, "변수 없이 수만 — 수가 단위를 품을 수 있음"
if all(FACTOR[0] <= abs(1 / f) <= FACTOR[1] for f in fs):
return text_of(dims), "" # 0.85 · 1.1 · 1.3 = 보정 계수
return None, "단위 없는 수가 단위를 품을 수 있음(× " + " · ".join(
sorted({str(1 / f) for f in fs})[:3]
) + ")"
# ── 원본 단위 ───────────────────────────────────────────────────────
def _rows_of(master, ref: str, choices: dict | None = None) -> list[dict]:
"""키 · 「ID:원문번호」 · 「ID:{빈칸}…」 → 맞을 수 있는 원본 줄들(빈칸 = 고를 수 있는 값만)."""
if "{" not in ref:
try:
return [master.get(ref)]
except Exception: # noqa: BLE001 — 없는 키 = 근거 없음
return []
tid, _, number = ref.partition(":")
def hole(m) -> str:
vals = (choices or {}).get(m.group(1))
return "(?:" + "|".join(re.escape(str(v)) for v in vals) + ")" if vals else ".+"
parts = re.split(r"(\{[^{}]+\})", number)
pat = "".join(md.HOLE.sub(hole, x) if x.startswith("{") else re.escape(x) for x in parts)
pat = re.compile("^" + pat + "$")
return [r for n, r in (master.numbers.get(tid) or {}).items() if pat.match(n)]
def choices_of(master, doc: dict) -> dict:
"""기호 → 고를 수 있는 값 — 선택 목록 · 기계 글 칸(주연료종류 …) = 고를 수 있는 기계의 그 칸."""
out = {
md.var_id(v): {str(x) for x in (v["연결"].get("목록") or [])}
for v in doc.get("변수") or []
if (v.get("연결") or {}).get("갈래") == "선택"
}
for v in doc.get("변수") or []:
link = v.get("연결") or {}
if link.get("갈래") == "요소" and link.get("칸") in EQ_TEXT:
rows = _rows_of(master, str(link.get("키") or ""), out)
vals = {str((r.get("값") or {}).get(link["칸"])) for r in rows}
if rows and "None" not in vals:
out[md.var_id(v)] = vals
return out
def _one(units) -> str | None:
"""모두 같은 단위 하나 — 빈 것 · 단위로 못 읽는 글(「1일 1대당」)이 끼면 None."""
got = {show(u) for u in units if u not in (None, "")}
if len(got) != 1 or any(u in (None, "") for u in units):
return None
one = got.pop()
return one if parse(one) is not None else None
def element_unit(master, link: dict, choices: dict | None = None) -> tuple[str | None, str]:
"""요소 연결 → (보일 단위 | None, 근거) — 「글」 = 글 값."""
if link.get("재료") is not None:
unit = str(link.get("단위") or "")
return (f"원/{show(unit)}", "재료 고르기 단위") if unit else (None, "재료 단위 없음")
ref, col = str(link.get("키") or ""), link.get("칸")
rows = _rows_of(master, ref, choices)
if not rows:
return None, f"요소 「{ref}」 없음"
tid = ref[:2]
if tid == "EQ":
if col in EQ_TEXT:
return "글", f"기계 「{col}」 = 글"
if col is None:
return None, "기계 칸 없음"
units = [(r.get("단위") or {}).get(EQ_COLUMN.get(col, col)) for r in rows]
units = [u for u, r in zip(units, rows) if EQ_COLUMN.get(col, col) in (r.get("값") or {})]
got = _one(units)
return got, f"기계 단위 「{EQ_COLUMN.get(col, col)}」"
if col is not None:
return None, f"요소 칸 「{col}」"
if tid == "MT":
got = _one([r.get("단위") for r in rows])
return (f"원/{got}" if got else None), "자재 단위"
if tid in ("LB", "MO"):
return _one([r.get("단위") for r in rows]), "노임 · 유가 단위"
return None, f"요소 「{tid}」"
def _row_unit(master, link: dict, choices: dict) -> tuple[str | None, str]:
"""표 칸 단위 — 값칸 단위 · 자리표시면 고를 수 있는 행이 모두 같은 `단위` 칸."""
rows = _rows_of(master, str(link.get("표") or ""))
if len(rows) != 1:
return None, "표 하나로 안 정해짐"
table, col = rows[0], link.get("칸")
head = (table.get("값칸") or {}).get(col)
if head is None:
return None, f"칸 「{col}」 없음"
if head in TEXT:
return "글", "값칸 = 글"
picked = table.get("줄") or []
if head not in ROW_UNIT and not (parse(head) is None and any("단위" in r for r in picked)):
return show(head), "값칸 단위"
for k, cond in (link.get("조건") or {}).items():
cond = str(cond).strip()
if cond[:1] == "'" and cond[-1:] == "'":
picked = [r for r in picked if str(r.get(k)) == cond[1:-1]]
elif cond in choices:
picked = [r for r in picked if str(r.get(k)) in choices[cond]]
got = _one([r.get("단위") for r in picked]) if picked else None
return got, ("고를 수 있는 행 단위 한 가지" if got else "행마다 단위 다름")
def dimensionless(var: dict) -> bool:
"""빈 단위가 맞는 변수 — 글 고르기 · 글 값 · 원본이 「수」 로 적은 칸은 `fill` 이 「」 로 둠."""
link = var.get("연결") or {}
if CODE.search(str(var.get("이름") or "")):
return link.get("갈래") in ("선택", "입력")
if link.get("갈래") == "선택":
return not all(_is_num(x) for x in link.get("목록") or [])
return link.get("갈래") == "입력" and not _is_num(link.get("값"))
def _is_num(x) -> bool:
try:
float(str(x).replace(",", ""))
return True
except ValueError:
return False
def source_unit(master, doc: dict, var: dict) -> tuple[str | None, str]:
"""변수 하나의 원본 단위 — (단위 | 「글」 | 「」 무차원 | None 모름, 근거)."""
link = var.get("연결") or {}
kind = link.get("갈래")
choices = choices_of(master, doc)
if kind == "표":
return _row_unit(master, link, choices)
if kind == "요소":
return element_unit(master, link, choices)
if kind == "단가산출":
try:
unit = master.logic(str(link.get("키") or "")).get("결과단위") or ""
except Exception: # noqa: BLE001
return None, "부른 문서 없음"
return (show(unit), "부른 문서 결과단위") if unit else (None, "부른 문서 결과단위 없음")
if dimensionless(var):
return "", "글 고르기 · 글 값"
return None, ""
def decide(master, doc: dict, v: dict) -> tuple[str | None, str]:
"""변수 하나 — (바꿀 단위 | 「글」 | None 그대로, 근거). 식 변수는 `fill` 이 식 셈으로."""
now = str(v.get("단위") or "")
kind = (v.get("연결") or {}).get("갈래")
if kind in ("선택", "입력") and not now and not dimensionless(v):
hit = NAMED.search(str(v.get("이름") or ""))
return (show(hit.group(1)), "이름에 적힌 단위") if hit else (None, "")
if kind not in ("표", "요소", "단가산출"):
return None, ""
src, why = source_unit(master, doc, v)
if src == "글":
return ("글", why) if not now else (None, why)
if kind == "표" and now in NOT_UNIT:
return "", "단위 칸 「수」 = 단위 아님"
if not now or (kind == "표" and (now in ROW_UNIT or parse(now) is None)):
if src and src not in PLAIN_NUMBER and src != now and parse(src) is not None:
return src, why
if now and now != ROW and why == "행마다 단위 다름":
return ROW, "행마다 다른 단위 표기 하나로"
return None, why
if kind == "표" and src and show(now) != now and same(now, src):
return show(now), "표기 한 벌"
return None, why
def fill(doc: dict, master) -> list[tuple[str, str, str, str]]:
"""빈 단위(· 자리표시)를 원본 · 식 셈 · 금액 줄 역산으로 채움 — [(기호, 전, 후, 근거)]."""
changed, texts = [], set()
by_id = {md.var_id(v): v for v in doc.get("변수") or []}
for v in doc.get("변수") or []:
got, why = decide(master, doc, v)
if got == "글":
texts.add(md.var_id(v))
elif got is not None:
changed.append((md.var_id(v), str(v.get("단위") or ""), got, why))
v["단위"] = got
steps = {line["기호"]: line["식"] for line in doc.get("식") or []}
_forward(doc, by_id, steps, texts, changed)
for vid, (unit, why) in _back(doc, by_id, steps, known(doc, texts)).items():
changed.append((vid, "", unit, why))
by_id[vid]["단위"] = unit
_forward(doc, by_id, steps, texts, changed)
return changed
def _forward(doc, by_id, steps, texts, changed) -> None:
for _ in range(len(steps) + 1): # 식이 뒤 식을 부름 — 안 바뀔 때까지
units, more = known(doc, texts), False
for sid, text in steps.items():
v = by_id.get(sid)
if v is None or v.get("단위") or sid in texts:
continue
got, _ = infer(text, units, _texts(doc))
if got == "글":
texts.add(sid)
more = True
elif got:
changed.append((sid, "", got, f"식 셈 — {text}"))
v["단위"], more = got, True
if not more:
return
def line_ok(line: dict, units: dict) -> bool:
"""금액 줄 단위가 단가 단위(원/X)의 X 와 같음 — 노임 「원/일」 은 「인」(1 인 1 일)."""
price = units.get(str(line.get("단가") or "").strip())
mine = parse(line.get("단위"))
if price in (None, _TEXTV) or mine is None or price[1].get("원") != 1:
return False
per = {k: -n for k, n in price[1].items() if k != "원"}
return price[0] == 1 and (mine[1] == per or (per == {"일": 1} and mine[1] == {"인": 1}))
def _back(doc, by_id, steps, units) -> dict:
"""금액 줄 역산 — 단가와 맞는 줄 단위 U 의 수량을 더하기(· 보정 계수 곱 · 만약 갈래)로만
이은 표 · 식 변수 = U.
한 변수에 두 단위가 나오면 안 채움."""
got: dict[str, set] = {}
def walk(node, unit, line, depth=0):
kind = node[0]
if kind == "var":
v = by_id.get(node[1])
if v is None or v.get("단위"):
return
link = (v.get("연결") or {}).get("갈래")
if link == "표" or link == "식":
got.setdefault(node[1], set()).add((unit, line))
if link == "식" and node[1] in steps and depth < 8:
walk(md.parse(steps[node[1]]), unit, line, depth + 1)
elif kind == "bin" and node[1] in ("+", "-"):
walk(node[2], unit, line, depth)
walk(node[3], unit, line, depth)
elif kind == "bin" and node[1] in ("*", "/"):
for me, other in ((node[2], node[3]), (node[3], node[2])):
if (node[1] == "*" or other is node[3]) and _factor(other):
walk(me, unit, line, depth)
elif kind == "call" and node[1] == "만약":
for x in node[2][1:]:
walk(x, unit, line, depth)
elif kind == "neg":
walk(node[1], unit, line, depth)
for line in doc.get("금액") or []:
if line.get("수량") and str(line.get("단위") or "") != "식" and line_ok(line, units):
try:
walk(md.parse(line["수량"]), show(line["단위"]), str(line.get("이름") or ""))
except md.DocError:
continue
out = {}
for vid, hits in got.items():
kinds = {u for u, _ in hits}
if len(kinds) == 1:
unit = kinds.pop()
names = " · ".join(sorted({n for _, n in hits})[:3])
out[vid] = (
unit,
f"금액 줄 「{names}」 단위 {unit} 역산(단가 단위와 맞음 · 더하기로 이음)",
)
return out
def _factor(node) -> bool:
"""변수 없는 수 · 만약(…, 수, 수) — 2 배 안 보정 계수."""
try:
got = _val(node, {})
except (Unknown, ArithmeticError):
return False
if got in (_ZERO, _TEXTV) or got[1]:
return False
return all(FACTOR[0] <= abs(1 / f) <= FACTOR[1] for f in got[0])
def _texts(doc: dict) -> dict:
"""(배, 차원) → 그 단위를 적은 변수 단위 글 — 식이 변수 하나를 옮길 때(「천원」 그대로)."""
out = {}
for v in doc.get("변수") or []:
got = parse(v.get("단위")) if v.get("단위") else None
if got is not None:
out.setdefault((got[0], tuple(sorted(got[1].items()))), show(v["단위"]))
return out
def known(doc: dict, texts=()) -> dict:
"""기호 → (배, 차원) | 글 | None(행마다 다름) — 식 셈 재료.
빈 단위 식 변수 · 수 입력 · 원본 칸이 빈 표는 모름(안 넣음)."""
out = {}
for v in doc.get("변수") or []:
vid, unit = md.var_id(v), str(v.get("단위") or "")
if vid in texts or (not unit and dimensionless(v)):
out[vid] = _TEXTV
elif unit:
out[vid] = parse(unit)
for k in md.COSTS:
out.setdefault(k, None)
return out