Merge remote-tracking branch 'origin/dev' into sub_laptop_3

This commit is contained in:
2026-11-05 17:05:57 +09:00
14 changed files with 2441 additions and 12 deletions
+5 -2
View File
@@ -35,6 +35,7 @@ from typing import Any
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Sangeun_Const as const
from M02_MasterTemplete import M02_Sangeun_Doc as sd
ADD = re.compile(r"^add:#(\d+)$")
@@ -72,6 +73,7 @@ def _read(ref: refs.Ref, read: Read, trail: tuple) -> tuple[dict, tuple]:
got = read(ref)
if not isinstance(got, dict):
raise sd.DocError(f"없는 참조 「{ref.get('키')}」")
const.no_flat(got) # 상수 · 표값 덮개 SK = 안 펼침(790-2)
return got, (*trail, rid)
@@ -256,6 +258,7 @@ def _cover_add(add: dict, cover: dict) -> dict:
def compose(under: dict, over: dict, whole: Any) -> dict:
"""펼친 바탕 SK `under` 위에 `over`(덮개 · 뺀줄 · 고름 · 자재표시 · 더한줄 · 입력값) → 한 벌 —
위 줄 정체를 아래 바탕 정체로 옮김(`patched` 의 `정체` 짝) · 짝 잃은 위 고침은 버림(값 없음)."""
const.no_flat(over) # 위 상수 · 표값 덮개 자리 = 아래 정체로 못 옮김(790-2)
base = under.get("바탕")
row = whole.logic(base["로직"]) if (base or {}).get("로직") else sd.empty_row(under)
got = sd.patched(row, under)
@@ -522,8 +525,8 @@ def reset(kind: str, doc: dict) -> tuple[dict, int]:
doc = flat_doc(kind, doc)
if kind == "sangeun":
adds, n = _reset_adds(doc.get("더한줄") or [])
n += len(doc.get("덮개") or {}) + len(doc.get("뺀줄") or [])
return {**doc, "덮개": {}, "뺀줄": [], "더한줄": adds}, n
n += const.covers_n(doc) # 790-2 상수 · 표값 덮개도 걷음(까닭 같이)
return {**const.restore(doc), "더한줄": adds}, n
return _reset_sc(doc)
+238
View File
@@ -0,0 +1,238 @@
"""경쟁사 산근 엑셀 읽기 — 블록 · 같은 책 단가표(PLAN 793-1 · 읽기만).
STmate(.xlsx) = 단가산출근거 시트 G 열 「BDCOD|D…」 머리 · POSS~POSE 몸(숨은 G 열 원문) ·
꼬리(계 · 계약단가 · 총계) + 목록표 단가(노무비 · 재료비 · 경비 = E 열 · 숨은 G 열 `COD` ·
중기 · 단가산출근거 = E~H 열 계 · 노 · 재 · 경 · 숨은 J 열 메타).
EST(.xls · .xlsx) = 단가산출 시트 블록 글(`M02_Hopyo_ReadEst.read`) + 노임단가 ·
자재단가(적용단가) · 기계경비총괄표 · 견적단가 · 단가산출총괄표(적용 칸 · 합이 안 맞으면 당초 칸).
"""
from __future__ import annotations
import io
import re
import warnings
from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_Hopyo_ReadEst as read_est
STM_SHEETS = (
"단가산출근거",
"단가산출근거목록표",
"노무비목록표",
"재료비목록표",
"경비목록표",
"중기목록표",
)
EST_SHEETS = ("단가산출총괄표", "단가산출", "기계경비총괄표", "자재단가", "노임단가", "견적단가")
_X7, _X5 = "_x0007_", "_x0005_"
_HEAD = re.compile(r"BDCOD\|([A-Z]\d+).*?POSS\|(\d+).*?POSE\|(\d+)")
def sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]:
"""책 바이트 → {시트: 줄} (두 꼴 시트만)."""
keep = STM_SHEETS + EST_SHEETS
if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"):
import openpyxl
with warnings.catch_warnings(): # 인쇄 영역 이름 경고
warnings.simplefilter("ignore")
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
try:
return {
ws.title.strip(): [list(r) for r in ws.iter_rows(values_only=True)]
for ws in wb.worksheets
if ws.title.strip() in keep
}
finally:
wb.close()
import xlrd
wb = xlrd.open_workbook(file_contents=data, logfile=io.StringIO())
return {
sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)]
for sh in wb.sheets()
if sh.name.strip() in keep
}
def kind_of(book: dict[str, list]) -> str | None:
if "단가산출근거" in book:
return "STmate"
return "EST" if "단가산출" in book else None
# ── STmate ─────────────────────────────────────────────────────────────
def _col(row: list[Any], letter: str) -> Any:
i = ord(letter) - 65
return row[i] if i < len(row) else None
def meta(text: Any) -> dict[str, str]:
"""숨은 메타 칸 「COD|L00003 ␅ UNT|L% …」 → dict."""
s = str(text or "").replace(_X7, "").replace(_X5, "\x05")
out = {}
for part in s.split("\x05"):
part = part.strip("`")
if "|" in part:
k, v = part.split("|", 1)
out[k] = v
return out
_BODY = "BCDEFGHIJKLMNO"
def stmate_blocks(book: dict[str, list]) -> list[dict[str, Any]]:
"""→ [{코드, 이름, 행, 머리 {A..F}, 몸 [{행, B, G, C~F …}], 꼬리 [{행, A, C~F, H}]}]."""
rows = book["단가산출근거"]
out = []
for i, r in enumerate(rows):
gv = _col(r, "G")
if not (isinstance(gv, str) and "BDCOD|" in gv):
continue
m = _HEAD.search(gv)
code, ps, pe = m.group(1), int(m.group(2)), int(m.group(3))
body = []
for rn in range(ps, pe + 1):
rr = rows[rn - 1]
one = {k: _col(rr, k) for k in _BODY}
one["G"] = str(one["G"] or "").replace(_X7, "")
body.append({"행": rn, **one})
tail = []
for rn in range(pe + 1, min(pe + 40, len(rows) + 1)):
rr = rows[rn - 1]
g2 = _col(rr, "G")
if isinstance(g2, str) and "BDCOD|" in g2:
break
a = str(_col(rr, "A") or "").replace(" ", "")
if a and isinstance(_col(rr, "C"), (int, float)):
tail.append({"행": rn, "A": a, **{k: _col(rr, k) for k in "CDEFH"}})
head = {k: _col(r, k) for k in "ABCDEF"}
name = _col(rows[i + 1], "B") if i + 1 < len(rows) else None
out.append(
{"코드": code, "이름": name, "행": i + 1, "머리": head, "몸": body, "꼬리": tail}
)
return out
def stmate_prices(book: dict[str, list]) -> dict[str, dict[str, Any]]:
"""코드 → {T, L, M, S, meta, 이름, 규격, 단위}."""
prices: dict[str, dict[str, Any]] = {}
for sheet, comp in (("노무비목록표", "L"), ("재료비목록표", "M"), ("경비목록표", "S")):
for r in book.get(sheet, [])[3:]:
m = meta(_col(r, "G")) if isinstance(_col(r, "G"), str) else {}
v = _col(r, "E")
if m.get("COD") and isinstance(v, (int, float)):
one = {c: (v if c == comp else 0) for c in "LMS"}
prices[m["COD"]] = {"T": v, **one, "meta": m} | _names(r)
for sheet in ("중기목록표", "단가산출근거목록표"):
for r in book.get(sheet, [])[3:]:
m = meta(_col(r, "J")) if isinstance(_col(r, "J"), str) else {}
if m.get("COD") and isinstance(_col(r, "E"), (int, float)):
one = {"T": _col(r, "E"), "L": _col(r, "F") or 0, "M": _col(r, "G") or 0}
prices[m["COD"]] = {**one, "S": _col(r, "H") or 0, "meta": m} | _names(r)
return prices
def _names(r: list[Any]) -> dict[str, Any]:
return {"이름": _col(r, "B"), "규격": _col(r, "C"), "단위": _col(r, "D")}
# ── EST ────────────────────────────────────────────────────────────────
def flat(s: Any) -> str:
return re.sub(r"\s+", "", str(s or ""))
def _hcol(rows: list[list[Any]], word: str, start: int = 2) -> int | None:
for r in rows[start : start + 2]:
for j, v in enumerate(r):
if flat(v) == word:
return j
return None
def _n(v: Any) -> float | None:
if isinstance(v, (int, float)):
return float(v)
try:
return float(str(v).replace(",", "").strip())
except ValueError:
return None
def applied(r: list[Any]) -> dict[str, float]:
"""총괄표 줄 → {T, L, M, S} — 적용 칸(5~8) 합이 맞으면 적용 · 아니면 당초(25~28)."""
a = {k: _n(r[j]) for k, j in (("T", 5), ("L", 6), ("M", 7), ("S", 8))}
if (
a["T"]
and all(a[k] is not None for k in "LMS")
and abs(a["L"] + a["M"] + a["S"] - a["T"]) < 0.5
and (a["L"] or a["M"] or a["S"])
):
return a
return {k: (_n(r[j]) or 0.0) for k, j in (("T", 25), ("L", 26), ("M", 27), ("S", 28))}
def est_prices(book: dict[str, list]) -> tuple[list[dict[str, Any]], dict[int, dict[str, Any]]]:
"""→ (단가 목록 [{종류, 이름, 규격, 비목, 값}], 산근 총괄 {번: {T, L, M, S, 이름, 규격}})."""
out: list[dict[str, Any]] = []
lab = book.get("노임단가", [])
c = _hcol(lab, "적용단가")
for r in lab[3:]:
if c is not None and len(r) > c and r[2] and isinstance(r[c], (int, float)):
out.append(
{"종류": "노임", "이름": flat(r[2]), "규격": "", "비목": "L", "값": float(r[c])}
)
mat = book.get("자재단가", [])
c = _hcol(mat, "적용단가")
for r in mat[4:]:
if c is not None and len(r) > c and r[2] and isinstance(r[c], (int, float)):
out.append(
{
"종류": "자재",
"이름": flat(r[2]),
"규격": flat(r[3]),
"비목": "M",
"값": float(r[c]),
}
)
for sheet, kind in (("기계경비총괄표", "기계"), ("견적단가", "견적")):
for r in book.get(sheet, [])[3:]:
if len(r) > 28 and r[2]:
for k, v in applied(r).items():
if isinstance(v, (int, float)) and (v or k != "T"):
one = {"종류": kind, "이름": flat(r[2]), "규격": flat(r[3])}
out.append({**one, "비목": k, "값": float(v or 0)})
san: dict[int, dict[str, Any]] = {}
for r in book.get("단가산출총괄표", [])[3:]:
if len(r) > 28 and str(r[1]).strip().isdigit():
n = int(str(r[1]).strip())
san[n] = {k: float(v or 0) for k, v in applied(r).items()}
san[n] |= {"이름": flat(r[2]), "규격": flat(r[3])}
for k in "TLMS":
if san[n][k] or k != "T":
one = {"종류": "산근", "이름": f"단산{n}", "규격": ""}
out.append({**one, "비목": k, "값": san[n][k]})
return out, san
def est_blocks(book: dict[str, list]) -> list[dict[str, Any]]:
"""→ [{번, 이름, 단위, 글 [줄], 글행 [행]}]."""
blocks = read_est.read(book.get(read_est.SHEET))
keep = ("번", "이름", "단위", "글", "글행")
return [{k: b[k] for k in keep} for b in blocks.values()]
def load(path: str | Path) -> dict[str, Any]:
"""파일 → {꼴, 블록, 단가(STmate dict | EST 목록), 총괄(EST)}."""
path = Path(path)
book = sheets(path.name, path.read_bytes())
kind = kind_of(book)
if kind == "STmate":
return {"꼴": kind, "블록": stmate_blocks(book), "단가": stmate_prices(book)}
if kind == "EST":
prices, san = est_prices(book)
return {"꼴": kind, "블록": est_blocks(book), "단가": prices, "총괄": san}
raise ValueError("STmate · EST 산근 시트 없음")
+318
View File
@@ -0,0 +1,318 @@
"""EST 산근 보이는 글 → 식 복원(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-A` 3 장 · eval 없음).
엑셀엔 식이 없고 인쇄 글뿐 — 「라벨 : 식 = 결과」 로 나눠 식을 되살림. 셈은 `M02_San_Eval.run_est`.
줄 꼴(`꼴`) — 빈 · 그림(분수 ─) · 글 · 글끝(끝이 결과 아님) · 식못읽음 · 변수 · 값 · 돈.
- 돈(`종류` 단가줄 · 참조줄 · 상수줄) — 라벨 · 비목(라벨 끝 노무비 · 재료비 · 경비) ·
값(보인 결과) · 식(`M02_San_Expr` 나무) ·
역할 {수 토큰 차례: 단가(후보 · 정체 · 모호) | 앞줄(같은 블록 앞 결과 줄 차례) | 상수} ·
규칙(보인 결과가 되는 줄 자리 속성 · 기본 버림1)
- 값(`종류` 값줄 「소 계 : 1,355.9」) — 숨은 = {갈래 복사 | 합, 줄 [차례], 규칙}(앞 결과 하나 또는
바로 앞 돈 · 값 줄 이음 합)
- 변수(`종류` 변수식 · 변수값 · 변수기호 · 변수글) — 값 · 규칙 = 얼림(금액엔 반올림된 값이 박힘)
"""
from __future__ import annotations
import collections
import re
from typing import Any
from M02_MasterTemplete import M02_San_Expr as ex
from M02_MasterTemplete.M02_San_Book import flat
COMPW = {"노무비": "L", "재료비": "M", "경비": "S"}
NUMRE = r"\d{1,3}(?:,\d{3})+(?:\.\d*)?|\d+(?:\.\d*)?|\.\d+"
EPS = ex.EPS
OPS = {"×": "*", "x": "*", "X": "*", "*": "*", "÷": "/", "/": "/", "+": "+", "-": "-"}
OPS |= {"-": "-", "−": "-"}
_UNIT = re.compile(r"\s*(?:[A-Za-z가-힣㎡㎥㎏ℓ㎝㎜°˚’'\"Øø]+(?:\s*/\s*[A-Za-z가-힣㎡㎥㎏ℓ]+)?)")
VARLINE = re.compile(r"^\s*(?:[○●◦·\-]\s*)?[A-Za-z][A-Za-z0-9_]*\s*(?:\([^)]*\))?\s*=")
RES = re.compile(
r"^\s*(?:[A-Z]\s+)?("
+ NUMRE
+ r")\s*\.?\s*(?:원)?\s*(?:[₩\\]?\s*/\s*\S+)?\s*(?:\([^()]*\))?\s*$"
)
_PLAIN = re.compile(r"[\s\d.,×÷+\-()A-Za-z가-힣㎡㎥]*")
_LABEL = re.compile(r"^(?P<l>[^::=]{0,40}?)\s*[::]\s*(?P<r>.*)$")
_HEADING = re.compile(r"^\s*(\d+|[가-하])\s*[.)]")
def num(s: str) -> float:
return float(s.replace(",", "").rstrip(".") or 0)
def tokens(seg: str) -> list[tuple] | None:
"""식 조각 → Expr 토큰 · 숫자 식이 아니면(기호 · 수 둘 잇달아 · 연산 없음) None."""
s = seg.replace("(", "(").replace(")", ")").replace("x", "x")
prev = None
while prev != s: # 수 없는 괄호 글 「(품할증)」
prev = s
s = re.sub(r"\([^()\d]*\)", " ", s)
out: list[tuple] = []
i, want, depth = 0, True, 0
while i < len(s):
ch = s[i]
if ch.isspace():
i += 1
continue
m = re.match(NUMRE, s[i:])
if m and (i == 0 or not (s[i - 1].isalpha() and s[i - 1].isascii())):
if not want:
return None
out.append(("수", num(m.group(0))))
i += len(m.group(0))
want = False
while i < len(s): # 단위 글 「인」 「㎡」 「본/일」
m2 = _UNIT.match(s[i:])
if m2 and m2.group(0).strip() and not re.fullmatch(r"\s*[xX]", m2.group(0)):
i += len(m2.group(0))
continue
break
continue
if ch == "%":
if want:
return None
out.append(("%",))
i += 1
continue
if ch in OPS:
if ch in "xX" and want:
return None
if ch == "/" and not re.match(r"\s*[\d(.]", s[i + 1 :]):
return None
if ch in "--−" and want:
out.append(("연산", "-"))
i += 1
continue
if want:
return None
out.append(("연산", OPS[ch]))
i += 1
want = True
continue
if ch == "(":
if not want:
return None
depth += 1
out.append(("(",))
i += 1
continue
if ch == ")":
depth -= 1
if depth < 0 or want:
return None
out.append((")",))
i += 1
continue
if ch.isalpha() or ch == "_":
return None
i += 1 # 장식(₩ 등)
if depth != 0 or want or not any(t[0] in ("연산", "%") for t in out):
return None
return out
def calc(node: tuple, sub: dict | None = None) -> float | None:
try:
return ex.value(node, {"V": {}, "sub": sub})
except ex.ExprError:
return None
def label_comp(label: str) -> str | None:
w = flat(label)
return next((v for k, v in COMPW.items() if w.endswith(k)), None)
def parse_line(text: str) -> dict[str, Any]:
"""보이는 글 한 줄 → 꼴 · 라벨 · 결과 · 식 나무(역할 전)."""
t = text.strip()
if not t:
return {"꼴": "빈"}
if "─" in t or _PLAIN.fullmatch(t) and "=" not in t and ":" not in t:
return {"꼴": "그림" if "─" in t else "글"}
label, rhs = "", t
mc = _LABEL.match(t)
if mc and not re.search(r"\d\s*$", mc.group("l")):
label, rhs = mc.group("l"), mc.group("r")
if VARLINE.match(rhs) or VARLINE.match(t):
return {"꼴": "변수", "조각": rhs.split("=")}
if "=" not in rhs:
m = re.match(r"^\s*(" + NUMRE + r")\s*\.?\s*$", rhs)
if label and m:
return {"꼴": "값", "라벨": label, "비목": label_comp(label), "값": num(m.group(1))}
return {"꼴": "글"}
segs = rhs.split("=")
mres = RES.match(segs[-1])
if not mres:
return {"꼴": "글끝"}
shown = num(mres.group(1))
toks = None
for seg in reversed(segs[:-1]):
toks = tokens(seg)
if toks is None: # 라벨 없는 줄 — 왼쪽 글을 깎아 가며
starts = {m.start() for m in re.finditer(NUMRE, seg)}
starts |= {m.start() for m in re.finditer(r"\(\s*\d", seg)}
for p in sorted(starts):
if p and (seg[p - 1].isdigit() or seg[p - 1] in ".,"):
continue
toks = tokens(seg[p:])
if toks:
label = label or seg[:p]
break
if toks:
break
if not toks:
return {"꼴": "식못읽음", "라벨": label, "값": shown}
return {"꼴": "돈", "라벨": label, "비목": label_comp(label), "값": shown, "식": ex.parse(toks)}
# ── 블록 풀이 ──────────────────────────────────────────────────────────
def _ident(p: dict[str, Any]) -> tuple:
return (p["종류"], p["이름"], p["규격"])
def _money(L: dict, prices_by_value: dict, results: list) -> None:
"""돈 줄 — 수 토큰 역할(단가 · 앞줄 · 상수) · 다시 셈 · 자리 규칙."""
comp = L["비목"]
nums = [(n[2], n[1]) for n in ex.numbers(L["식"])]
roles: dict[int, dict[str, Any]] = {}
price_at = None
for i, v in nums:
if v >= 1 and any(abs(r[1] - v) < EPS for r in results):
continue # 앞 결과와 같은 수 = 참조(단가표 우연 일치보다 먼저)
cands = [p for p in prices_by_value.get(v, []) if v >= 1 or (v == 0 and comp)]
if comp:
cands = [p for p in cands if p["비목"] == comp] or cands
if v == 0 and comp and not cands:
cands = [{"종류": "0", "이름": "-", "규격": "", "비목": comp, "값": 0.0}]
first = i == nums[0][0]
if cands and (v >= 100 or (first and v > 0)) or (v == 0 and cands):
price_at = i
roles[i] = {"갈래": "단가", "후보": cands}
break
for i, v in nums:
if i in roles:
continue
hit = [r for r in reversed(results) if abs(r[1] - v) < EPS]
roles[i] = {"갈래": "앞줄", "줄": hit[0][0]} if hit and v >= 1 else {"갈래": "상수"}
L["역할"] = roles
L["셈"] = calc(L["식"])
L["규칙"] = ex.fit(L["셈"], L["값"])
if price_at is not None:
L["종류"] = "단가줄"
pc = roles[price_at]["후보"]
L["비목"] = L["비목"] or (pc[0]["비목"] if len({p["비목"] for p in pc}) == 1 else None)
L["후보수"] = len({_ident(p) for p in pc})
elif any(r["갈래"] == "앞줄" for r in roles.values()):
L["종류"] = "참조줄"
else:
L["종류"] = "상수줄"
def _value(L: dict, lines: list, results: list) -> None:
"""값 줄 — 앞 결과 하나(자리 처리) 또는 바로 앞 돈 · 값 줄 이음 합."""
v, how = L["값"], None
for r in reversed(results):
rule = ex.fit(r[1], v)
if rule:
how = {"갈래": "복사", "줄": [r[0]], "규칙": rule}
break
if not how and flat(L["라벨"]) in ("계", "합계"):
got: dict[str, dict] = {}
for x in reversed(lines):
c = x.get("비목")
if c and x.get("꼴") in ("돈", "값") and c not in got:
got[c] = x
if len(got) == 3:
break
if got:
rule = ex.fit(sum(x["값"] for x in got.values()), v)
if rule:
how = {"갈래": "합", "줄": sorted(x["차례"] for x in got.values()), "규칙": rule}
if not how:
acc, used = 0.0, []
for x in reversed([x for x in lines if x.get("꼴") in ("돈", "값")]):
if len(used) >= 25:
break
acc += x["값"]
used.append(x["차례"])
rule = ex.fit(acc, v)
if rule and len(used) > 1:
how = {"갈래": "합", "줄": used[::-1], "규칙": rule}
break
L["종류"] = "값줄"
L["숨은"] = how
L["규칙"] = how["규칙"] if how else None
def _var(L: dict) -> None:
segs = L.pop("조각")
m = re.match(r"^\s*(" + NUMRE + r")", segs[-1]) if len(segs) > 1 else None
if not m:
L["종류"] = "변수글"
return
shown, toks = num(m.group(1)), None
for seg in reversed(segs[1:-1]):
toks = tokens(seg)
if toks:
break
L["값"] = shown
L["규칙"] = ex.fit(calc(ex.parse(toks)), shown) if toks else None
L["종류"] = "변수식" if toks else ("변수값" if len(segs) == 2 else "변수기호")
def parse_block(text: list[str], prices: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""블록 글 줄 → 복원 줄 목록(역할 · 숨은 합 · 자리 규칙 · 단가 정체)."""
by_value = collections.defaultdict(list)
for p in prices:
by_value[p["값"]].append(p)
lines: list[dict[str, Any]] = []
results: list[tuple] = [] # (차례, 값, 비목)
for k, raw in enumerate(text):
L = {"차례": k, "원글": raw, **parse_line(raw)}
if L["꼴"] == "돈":
_money(L, by_value, results)
results.append((k, L["값"], L["비목"]))
elif L["꼴"] == "값":
_value(L, lines, results)
results.append((k, L["값"], L["비목"]))
elif L["꼴"] == "변수":
_var(L)
lines.append(L)
identify(lines)
return lines
def identify(lines: list[dict[str, Any]]) -> None:
"""단가 토큰 정체 — 값 하나 · 기계 세 줄 겹침 · 문맥(라벨 · 머리 글 이름) · 남으면 모호 표지."""
head = ""
for L in lines:
if L["꼴"] in ("글", "글끝") or _HEADING.match(L["원글"]):
head = L["원글"]
if L.get("종류") != "단가줄":
continue
role = next(r for r in L["역할"].values() if r["갈래"] == "단가")
cands = role["후보"]
ids = {_ident(p) for p in cands}
how = "값"
if len(ids) > 1 and cands[0]["종류"] in ("기계", "산근"):
k0, inter = L["차례"], set(ids)
for M in lines[max(0, k0 - 3) : k0 + 4]:
if M is L or M.get("종류") != "단가줄":
continue
r2 = next(r for r in M["역할"].values() if r["갈래"] == "단가")
ids2 = {_ident(p) for p in r2["후보"]}
if ids2 & inter and len(ids2 & inter) < len(inter):
inter &= ids2
ids, how = inter, "기계 세 줄"
if len(ids) > 1:
ctx = flat(L["원글"]) + "|" + flat(head)
hit = {x for x in ids if x[1] and (x[1] in ctx or x[1][:3] in ctx)}
ids, how = ({x for x in hit if not x[2] or x[2] in ctx} or hit or ids), "문맥"
role["모호"] = len(ids) != 1
role["갈림"] = how
pick = sorted(ids)[0]
p = next(p for p in cands if _ident(p) == pick)
role["정체"] = {"종류": p["종류"], "이름": p["이름"], "규격": p["규격"], "비목": p["비목"]}
+293
View File
@@ -0,0 +1,293 @@
"""경쟁사 산근 참조 평가기(PLAN 793-1 · 요구서_652) — 줄 나무 + 같은 책 단가 →
줄 · 블록 금액 · 검산.
STmate 셈 규칙(`_분석_649-G2` 1 장) — 줄 금액 = 단가 × (계수 + 1e-13) → 소수 1 버림 ·
변수 `X = 식 =` = 소수 2 반올림 · 율 `&P..&` = 범위 비목 금액 × n % → 소수 1 버림 ·
`&R..&` = × n · 특수 % L% · M% = 처음부터 그 비목 누계 × n % ·
T% = 바로 앞 계표시 값 × 수량 · 계표시 = 아래 단 누계 · `(-=)` = 찍되 위 합에서 뺌 ·
`->` = 번지엔 쌓되 합에서 뺌 · 블록 끝전 두 갈래(비목 버림 / 계 버림 + 큰 나머지) ·
결과금액 조정(SBM = 계를 한 비목으로) · 계약단가 = 비목 × 낙찰률 원 버림.
EST 셈 규칙(같은 문서 2 장) — 줄 = 복원 식(단가 · 앞줄 결과 · 상수) → 줄 자리 속성 ·
값 줄 = 숨은 복사 · 합 → 자리 속성 · 블록 성분 = 그 값을 낸 마지막 줄.
단가 바뀜 전파 — STmate = 단가 dict 를 바꿔 다시 셈 · EST = `run_est(lines, {정체 열쇠: 새 값})`.
"""
from __future__ import annotations
import collections
import math
from typing import Any
from M02_MasterTemplete import M02_San_Expr as ex
COMPS = ("L", "M", "S")
SPECIAL = ("L%", "M%", "S%", "T%")
TOL_LINE = 0.05
def _zero() -> dict[str, float]:
return dict.fromkeys(COMPS, 0.0)
# ── STmate ─────────────────────────────────────────────────────────────
def _rate_amount(node: dict, coef: float, A: dict, T: dict, since: dict, total: dict) -> dict:
rate = node["율"]
area = rate["범위"]
if area == "A":
base = _zero()
for spot in rate["번지"]:
for c in COMPS:
base[c] += A[spot][c]
elif area == "T":
base = T.get(rate["번지"][0]) or _zero()
else:
base = since if area == "C" else total
pct = coef if rate["곱"] else coef / 100
src, dst = rate["비목"], rate["옮김"]
amt = _zero()
if src:
amt[dst or src] = ex.fl1(base[src] * pct)
elif dst:
amt[dst] = ex.fl1(sum(base.values()) * pct)
else:
for c in COMPS:
amt[c] = ex.fl1(base[c] * pct)
return amt
def _ref_amount(
node: dict, coef: float, prices: dict, total: dict, last_tot: Any, res: dict
) -> dict:
src_ = node["자원"]
code = src_["코드"]
if code not in prices:
raise ex.ExprError(f"단가 없음 {code}")
pr = prices[code]
src, dst = src_["비목"], src_["옮김"]
amt = _zero()
unt = (pr.get("meta") or {}).get("UNT")
res["단가"] = {c: pr[c] for c in COMPS}
if unt in SPECIAL:
dst2 = dst or {"L": "L", "M": "M", "S": "S"}.get(code[0])
if unt == "T%":
b0 = last_tot if last_tot is not None else sum(total.values())
amt[dst2] = ex.fl1(b0 * coef)
else:
b0 = total[unt[0]]
amt[dst2] = ex.fl1(b0 * coef / 100)
res["기준"] = b0
res["종류"] = "특수"
return amt
c_eps = coef + 1e-13
if src:
amt[dst or src] = ex.fl1(pr[src] * c_eps)
else:
for c in COMPS:
if pr[c]:
amt[c] = ex.fl1(pr[c] * c_eps)
return amt
def run_stmate(nodes: list[dict], prices: dict, dec: int = 2) -> tuple[list[dict], dict]:
"""줄 나무 → (줄 결과 [{종류, 값 {L, M, S}, 계수, 오류 …}], 블록 소수 합 {L, M, S})."""
V: dict[str, float] = {}
A: dict[str, dict] = collections.defaultdict(_zero) # 금액번지
T: dict[str, dict] = {} # 누계번지
acc = [_zero() for _ in range(6)] # 계 단 1~5 · 0 = 전체
since = _zero() # 마지막 계표시 뒤
last_tot = None
out = []
for node in nodes:
kind = node["종류"]
res: dict[str, Any] = {"행": node.get("행"), "종류": kind, "값": None, "오류": None}
try:
if kind == "계":
lvl = node["단"]
val = dict(acc[lvl])
res |= {"단": lvl, "제외": node["제외"], "값": val}
for k in range(1, lvl + 1):
acc[k] = _zero()
if node["제외"]:
for k in [*range(lvl + 1, 6), 0]:
for c in COMPS:
acc[k][c] -= val[c]
if node["누계번지"]:
T[node["누계번지"]] = val
last_tot = sum(val.values())
since = _zero()
elif kind in ("참조", "율"):
if node.get("오류"):
raise ex.ExprError(node["오류"])
coef = ex.value(node["식"], {"V": V, "R": 1.0})
if kind == "율":
amt = _rate_amount(node, coef, A, T, since, acc[0])
else:
amt = _ref_amount(node, coef, prices, acc[0], last_tot, res)
res |= {"값": amt, "계수": coef, "번지": node["번지"], "빼기": node["빼기"]}
addr = node["번지"]
if addr and addr.startswith("A"):
for c in COMPS:
A[addr][c] += amt[c]
if not node["빼기"]:
for k in range(6):
for c in COMPS:
acc[k][c] += amt[c]
for c in COMPS:
since[c] += amt[c]
elif kind == "대입":
for part in node["대입"]:
if part["이름"] is None:
try:
ex.value(part["식"], {"V": V, "R": 1.0})
except ex.ExprError:
pass
continue
v = ex.value(part["식"], {"V": V, "R": 1.0})
V[part["이름"]] = ex.rnd(v, dec) if part["찍기"] else v
except ex.ExprError as error:
res["오류"] = str(error)
out.append(res)
return out, acc[0]
def block_total(tot: dict, rule: str) -> dict[str, float]:
"""블록 끝전 — 비목버림(비목 원 버림 · 계 = 합) / 합버림_큰나머지(계 원 버림 · 나머지를 소수 큰
차례 경 › 재 › 노 로 1 원씩)."""
c = {k: math.floor(round(tot[k], 6)) for k in COMPS}
if rule == "비목버림":
c["T"] = c["L"] + c["M"] + c["S"]
return c
total = math.floor(round(sum(tot.values()), 6))
order = sorted(COMPS, key=lambda k: (-round(tot[k] - c[k], 6), "SML".index(k)))
for k in order[: total - sum(c.values())]:
c[k] += 1
c["T"] = total
return c
def stmate_block(block: dict, nodes: list[dict], prices: dict, rule: str) -> dict[str, Any]:
"""블록 → {줄, 계(끝전 뒤 · 계약 전), 금액(조정 · 계약 뒤), 낙찰}."""
lines, tot = run_stmate(nodes, prices)
meta = (prices.get(block["코드"]) or {}).get("meta", {})
kye = block_total(tot, rule)
sbm = (meta.get("SBM") or "").strip()
if meta.get("ADJ") == "T" and sbm in COMPS:
kye = {"L": 0, "M": 0, "S": 0, "T": kye["T"], sbm: kye["T"]}
nag = float(meta["NAG"]) if meta.get("UC") == "T" and meta.get("NAG") else None
fin = dict(kye)
if nag:
fin = {c: math.floor(round(kye[c] * nag / 100, 6)) for c in COMPS}
fin["T"] = fin["L"] + fin["M"] + fin["S"]
return {"줄": lines, "합": tot, "계": kye, "금액": fin, "낙찰": nag}
def stmate_check(block: dict, result: dict) -> dict[str, Any]:
"""줄마다 · 블록 검산 — 금액 줄(참조 · 율 · 특수) C~F 대조 · 계표시 줄 · 블록 머리 · 꼬리 계."""
bad, n_amt, n_tot, ok_tot = [], 0, 0, 0
for r, ln in zip(result["줄"], block["몸"], strict=True):
want = {"L": ln["D"] or 0, "M": ln["E"] or 0, "S": ln["F"] or 0}
if r["종류"] in ("참조", "율", "특수"):
n_amt += 1
if not r["값"] or any(abs(r["값"][c] - want[c]) >= TOL_LINE for c in COMPS):
bad.append({"행": ln["행"], "엑셀": want, "셈": r["값"], "오류": r["오류"]})
elif r["종류"] == "계" and ln["C"] is not None:
n_tot += 1
ok_tot += all(abs(r["값"][c] - want[c]) < 0.15 for c in COMPS)
head = block["머리"]
want = {"T": head["C"] or 0, "L": head["D"] or 0, "M": head["E"] or 0, "S": head["F"] or 0}
kye = next((t for t in block["꼬리"] if t["A"] == "계"), None)
want_k = {"T": kye["C"], "L": kye["D"], "M": kye["E"], "S": kye["F"]} if kye else None
same = lambda x, y: all(abs((x[k] or 0) - (y[k] or 0)) < 0.5 for k in "TLMS") # noqa: E731
ok = same(result["금액"], want) and (want_k is None or same(result["계"], want_k))
return {
"맞음": ok,
"엑셀": want,
"엑셀계": want_k,
"금액줄": n_amt,
"틀린줄": bad,
"계줄": n_tot,
"계줄맞음": ok_tot,
}
# ── EST ────────────────────────────────────────────────────────────────
def key_of(ident: dict[str, Any]) -> tuple:
"""EST 단가 정체 → 전파 열쇠 (종류, 이름, 규격, 비목)."""
return (ident["종류"], ident["이름"], ident["규격"], ident["비목"])
def run_est(lines: list[dict], newp: dict[tuple, float] | None = None) -> dict[int, float | None]:
"""복원 줄 → {차례: 다시 셈 값}(줄 자리 속성 그대로) · newp = {정체 열쇠: 새 단가}."""
newp = newp or {}
val: dict[int, float | None] = {}
for L in lines:
k = L["차례"]
if L.get("꼴") == "돈" and L.get("규칙"):
sub = {}
for i, r in L["역할"].items():
if r["갈래"] == "단가" and key_of(r["정체"]) in newp:
sub[i] = newp[key_of(r["정체"])]
elif r["갈래"] == "앞줄" and val.get(r["줄"]) is not None:
sub[i] = val[r["줄"]]
v = _est_calc(L["식"], sub)
val[k] = ex.RULE[L["규칙"]](v) if v is not None else None
elif L.get("종류") == "값줄" and L.get("숨은"):
how = L["숨은"]
s = sum(
(val.get(j) if val.get(j) is not None else lines[j]["값"]) or 0 for j in how["줄"]
)
val[k] = ex.RULE[how["규칙"]](s)
return val
def _est_calc(node: tuple, sub: dict) -> float | None:
try:
return ex.value(node, {"V": {}, "sub": sub})
except ex.ExprError:
return None
def designate(lines: list[dict], want: dict[str, float]) -> dict[str, Any]:
"""블록 성분(총괄표) → 그 값을 낸 줄(마지막 · 라벨 비목 우선) · 0 = 「영」 · 없음 = None."""
out: dict[str, Any] = {}
kinds = ("단가줄", "참조줄", "상수줄", "값줄")
for c in COMPS:
w = want[c]
if not w:
out[c] = "영"
continue
cand = [L for L in lines if L.get("종류") in kinds and abs(L["값"] - w) < ex.EPS]
pick = [L for L in cand if L.get("비목") == c] or cand
out[c] = pick[-1]["차례"] if pick else None
return out
def est_block(lines: list[dict], des: dict[str, Any], newp: dict | None = None) -> dict[str, Any]:
"""성분 줄 → 블록 금액(성분 원 버림 · 계 = 합) · 줄 값."""
val = run_est(lines, newp)
raw = {
c: (0.0 if k == "영" else (val.get(k) if k is not None else None)) for c, k in des.items()
}
if any(v is None for v in raw.values()):
return {"줄": val, "성분": raw, "금액": None}
fin = {c: math.floor(round(raw[c], 6)) for c in COMPS}
fin["T"] = fin["L"] + fin["M"] + fin["S"]
return {"줄": val, "성분": raw, "금액": fin}
def est_check(lines: list[dict], want: dict[str, float]) -> dict[str, Any]:
"""블록 검산 — 다시 셈 성분 = 총괄표 · 줄마다 보인 값 대조(돈 · 값 줄)."""
des = designate(lines, want)
got = est_block(lines, des)
val = got["줄"]
n, bad = 0, []
for L in lines:
if L.get("꼴") == "돈" or L.get("종류") == "값줄":
n += 1
v = val.get(L["차례"])
if v is None or abs(v - L["값"]) >= ex.EPS:
bad.append({"차례": L["차례"], "글": L["원글"].strip(), "보인": L["값"], "셈": v})
fin = got["금액"]
ok = fin is not None and all(abs(fin[c] - want[c]) < 0.5 for c in COMPS)
ok = ok and abs(want["L"] + want["M"] + want["S"] - want["T"]) < 0.5
return {"맞음": ok, "성분줄": des, "금액": fin, "줄수": n, "틀린줄": bad}
+260
View File
@@ -0,0 +1,260 @@
"""경쟁사 산근 글 식 — 셈 식 나무 · 평가 · 자리 규칙(PLAN 793-1 · 요구서_652 · eval 없음).
토큰 = `("수", 값)` · `("이름", 글)` · `("참조",)` · `("연산", + - * / ^)` · `("(",)` · `(")",)` ·
`(",",)` · `("%",)` · `("끼움", 이름 | None, 토큰 목록)`.
나무 마디 = `("수", 값, 토큰 차례)` · `("이름", 소문자)` · `("참조",)` ·
`("끼움", 이름 | None, 마디)` · `("함수", round | int, [마디])` · `("음", 마디)` ·
`("셈", 연산, 왼, 오른)` · `("오류", 까닭)`(읽기 실패 — 셀 때 오류).
우선순위 = 파이썬 식과 같음(`^` = 거듭제곱 · 오른쪽 결합 · 단항 `-` 보다 셈 · `%` = 「/ 100」 을
곱셈 단에서 왼쪽부터) — 측정 도구(`tmp/brain_649/G2`)의 파이썬 식 평가와 같은 부동소수 결과.
"""
from __future__ import annotations
import math
from typing import Any
FUNCS = ("round", "int")
MAX_DEPTH = 64
class ExprError(Exception):
"""식 읽기 · 셈 실패 — 줄 표지로 남김."""
# ── 자리 규칙 ──────────────────────────────────────────────────────────
def fl1(x: float) -> float:
"""소수 1 자리 버림(STmate 줄 금액)."""
return math.floor(round(x * 10, 6)) / 10
def rnd(x: float, n: int) -> float:
"""반올림(half up) — STmate 변수 표시 자리."""
q = 10**n
return math.floor(x * q + 0.5 + 1e-9) / q
def _cut(q: float) -> Any:
return lambda v: math.floor(round(v * q, 6)) / q
def _half(q: float) -> Any:
return lambda v: math.floor(v * q + 0.5 + 1e-9) / q
def _up(q: float) -> Any:
return lambda v: math.ceil(round(v * q, 6)) / q
# 줄 자리 속성 — 차례 = 되찾기 우선(EST 기본 = 버림1)
RULES: list[tuple[str, Any]] = [
("버림1", _cut(10)),
("버림0", _cut(1)),
("반올림1", _half(10)),
("반올림0", _half(1)),
("버림2", _cut(100)),
("반올림2", _half(100)),
("버림3", _cut(1000)),
("반올림3", _half(1000)),
("올림0", _up(1)),
("올림1", _up(10)),
("올림2", _up(100)),
("그대로", lambda v: v),
]
RULE = dict(RULES)
EPS = 1e-6
def fit(value: float | None, shown: float) -> str | None:
"""셈 값 → 보인 값이 되는 첫 자리 규칙 이름(없으면 None)."""
if value is None:
return None
return next((name for name, f in RULES if abs(f(value) - shown) < EPS), None)
# ── 읽기(재귀 하강) ────────────────────────────────────────────────────
class _Parser:
def __init__(self, toks: list[tuple]) -> None:
self.toks, self.at, self.depth = toks, 0, 0
def peek(self) -> tuple | None:
return self.toks[self.at] if self.at < len(self.toks) else None
def take(self) -> tuple:
tok = self.peek()
if tok is None:
raise ExprError("식이 덜 끝남")
self.at += 1
return tok
def is_op(self, *ops: str) -> bool:
tok = self.peek()
return tok is not None and tok[0] == "연산" and tok[1] in ops
def expr(self) -> tuple:
self.depth += 1
if self.depth > MAX_DEPTH:
raise ExprError("괄호가 너무 깊음")
node = self.term()
while self.is_op("+", "-"):
op = self.take()[1]
node = ("셈", op, node, self.term())
self.depth -= 1
return node
def term(self) -> tuple:
node = self.unary()
while True:
if self.is_op("*", "/"):
op = self.take()[1]
node = ("셈", op, node, self.unary())
elif (tok := self.peek()) is not None and tok[0] == "%":
self.take()
node = ("셈", "/", node, ("수", 100.0, None))
else:
return node
def unary(self) -> tuple:
if self.is_op("-"):
self.take()
return ("음", self.unary())
if self.is_op("+"):
self.take()
return self.unary()
return self.power()
def power(self) -> tuple:
node = self.atom()
if self.is_op("^"):
self.take()
return ("셈", "^", node, self.unary())
return node
def atom(self) -> tuple:
at = self.at
tok = self.take()
kind = tok[0]
if kind == "수":
return ("수", tok[1], at)
if kind == "참조":
return ("참조",)
if kind == "끼움":
return ("끼움", tok[1], parse(tok[2]))
if kind == "(":
node = self.expr()
if self.take()[0] != ")":
raise ExprError("괄호 짝 없음")
return node
if kind == "이름":
name = tok[1].lower()
if name in FUNCS and (nxt := self.peek()) is not None and nxt[0] == "(":
self.take()
args = [self.expr()]
while (nxt := self.peek()) is not None and nxt[0] == ",":
self.take()
args.append(self.expr())
if self.take()[0] != ")":
raise ExprError("함수 괄호 짝 없음")
return ("함수", name, args)
return ("이름", name)
raise ExprError(f"식 자리 아닌 토큰 {kind}")
def parse(toks: list[tuple]) -> tuple:
"""토큰 → 나무(남는 토큰 · 빈 식 = 오류)."""
if not toks:
raise ExprError("빈 식")
p = _Parser(toks)
node = p.expr()
if p.peek() is not None:
raise ExprError(f"식 뒤 남은 토큰 {p.peek()[0]}")
return node
# ── 셈 ─────────────────────────────────────────────────────────────────
def _brackets(node: tuple, env: dict, seen: dict) -> None:
"""끼움 `[A]` · `[Q=0.208]` 먼저(왼쪽부터 · 원문 치환 차례) — 값은 seen[id]."""
kind = node[0]
if kind == "끼움":
_brackets(node[2], env, seen)
value = calc(node[2], env, seen)
if node[1]:
env["V"][node[1].lower()] = value
seen[id(node)] = value
elif kind == "셈":
_brackets(node[2], env, seen)
_brackets(node[3], env, seen)
elif kind == "음":
_brackets(node[1], env, seen)
elif kind == "함수":
for arg in node[2]:
_brackets(arg, env, seen)
def calc(node: tuple, env: dict, seen: dict | None = None) -> float:
"""나무 → 값 · env = {"V": 변수(소문자), "R": 참조 값, "sub": {토큰 차례: 새 값}}."""
kind = node[0]
if kind == "오류":
raise ExprError(node[1])
if kind == "수":
sub = env.get("sub")
return sub[node[2]] if sub and node[2] in sub else node[1]
if kind == "이름":
if node[1] not in env["V"]:
raise ExprError(f"미정의 변수 {node[1]}")
return env["V"][node[1]]
if kind == "참조":
return env.get("R", 1.0)
if kind == "끼움":
if seen is not None and id(node) in seen:
return seen[id(node)]
return calc(node[2], env, seen)
if kind == "음":
return -calc(node[1], env, seen)
if kind == "함수":
args = [calc(a, env, seen) for a in node[2]]
if node[1] == "int":
return float(int(args[0]))
return float(round(args[0], int(args[1])) if len(args) > 1 else round(args[0]))
a, b = calc(node[2], env, seen), calc(node[3], env, seen)
op = node[1]
if op == "+":
return a + b
if op == "-":
return a - b
if op == "*":
return a * b
if op == "^":
try:
return float(a**b)
except (OverflowError, ZeroDivisionError) as error:
raise ExprError("거듭제곱 오류") from error
if b == 0:
raise ExprError("0 나누기")
return a / b
def value(node: tuple, env: dict) -> float:
"""끼움 먼저 · 그 다음 셈."""
seen: dict = {}
_brackets(node, env, seen)
out = calc(node, env, seen)
if isinstance(out, complex):
raise ExprError("복소수")
return out
def numbers(node: tuple) -> list[tuple]:
"""나무 속 수 마디(왼쪽부터)."""
kind = node[0]
if kind == "수":
return [node] if node[2] is not None else []
if kind == "셈":
return numbers(node[2]) + numbers(node[3])
if kind in ("음",):
return numbers(node[1])
if kind == "끼움":
return numbers(node[2])
if kind == "함수":
return [n for a in node[2] for n in numbers(a)]
return []
+191
View File
@@ -0,0 +1,191 @@
"""STmate 산근 글 식 파서(PLAN 793-1 · 요구서_652 · 문법 = `_분석_648-B` 3 장 · eval 없음).
단가산출근거 시트 숨은 G 열 원문 한 줄 → 줄 나무(dict). 셈은 `M02_San_Eval.run_stmate`.
줄 꼴(`종류`):
- 빈 · 글 — 셈 없음(`'글'` · 단위 조각 · `// 덧글` 뺀 뒤 빈 것)
- 계 — `(=)`~`(=====)` 단 1~5 · `(-=)` 제외(위 합에서 뺌) · `(=):T01` 누계번지
- 대입 — `X = 식` 전 자리 · `X = 식 =` 소수 2 자리 반올림 · 한 줄 여러 대입(`,`) ·
맨 식(끼움 `[A]` 만 먹음)
- 참조 — `&L..&` · `&M..&` · `&S..&` · `&X..L/M/S(:L/M/S)&` · `&D..&` · `&B..&` 단가 × 계수 식
(특수 % 코드 L% · M% · T% 는 목록표 UNT 로 셀 때 가림)
- 율 — `&P{A01|A0102..|T01|C_|S_}{L|M|S|}:{L|M|S|}& × n'%'` 범위 금액 × n % · `&R..&` × n
- 공통 칸 — 숨김(`;;`) · 번지(`:=A01` · `:=T01`) · 빼기(`->` = 번지엔 쌓되 합에서 뺌) ·
찍기(끝 `=` 또는 번지) · 식(계수 나무 · `("참조",)` = 단가 자리 1) · 오류(읽기 실패 까닭)
"""
from __future__ import annotations
import re
from typing import Any
from M02_MasterTemplete import M02_San_Expr as ex
REF = re.compile(r"&([A-Z]+)([^&]*)&")
_TOK = re.compile(r"\s*(?:(\d+\.?\d*|\.\d+)|([A-Za-z][A-Za-z0-9_]*)|(@R)|(.))")
_TOTAL = re.compile(r"^\((-?)(=+)\)(?::(T\d+))?")
_NAME_EQ = re.compile(r"^\s*[A-Za-z][A-Za-z0-9_]*\s*=(?!=)")
_ASSIGN = re.compile(r"^([A-Za-z][A-Za-z0-9_]*)\s*=(?!=)(.*)$")
_ADDR = re.compile(r":=\s*([AT]\d+)\s*$")
_RATE_TAIL = re.compile(r"^(\d*|_)([LMS]?)(?::([LMS]?))?$")
_CODE_TAIL = re.compile(r"^([LMS]?)(?::([LMS]))?$")
RATE_KINDS = ("PA", "PT", "PC", "PS", "RA", "RT", "RC", "RS")
OPS = "+-*/^"
def tokens(text: str) -> list[tuple]:
"""STmate 식 글 → 토큰(끼움 `[..]` 안은 다시 토큰) · 모르는 기호 = ExprError."""
out: list[tuple] = []
at = 0
while at < len(text):
if text[at].isspace():
at += 1
continue
if text[at] == "[":
end = text.find("]", at)
if end < 0:
raise ex.ExprError("끼움 괄호 짝 없음")
inner = text[at + 1 : end]
if "=" in inner:
name, body = inner.split("=", 1)
out.append(("끼움", name.strip(), tokens(body)))
else:
out.append(("끼움", None, tokens(inner)))
at = end + 1
continue
m = _TOK.match(text, at)
if not m or m.end() == at:
break
at = m.end()
num, name, ref, other = m.groups()
if num:
out.append(("수", float(num)))
elif name:
out.append(("이름", name))
elif ref:
out.append(("참조",))
elif other and other.strip():
if other in OPS:
out.append(("연산", other))
elif other in "(),":
out.append((other,))
else:
raise ex.ExprError(f"모르는 기호 {other!r}")
return out
def expr(text: str) -> tuple:
"""식 글 → 나무 · 실패 = `("오류", 까닭)`(셀 때 터짐 — 측정 도구와 같은 차례)."""
try:
return ex.parse(tokens(text))
except ex.ExprError as error:
return ("오류", str(error))
def split_top(text: str, sep: str = ",") -> list[str]:
"""괄호 밖 `sep` 로 나눔."""
out, depth, cur = [], 0, ""
for ch in text:
if ch in "([":
depth += 1
elif ch in ")]":
depth -= 1
if ch == sep and depth == 0:
out.append(cur)
cur = ""
else:
cur += ch
out.append(cur)
return out
def _clean(g: str) -> tuple[str, bool, str | None]:
"""원문 → (셈 글, 숨김, 덧글) — `//` 뒤 덧글 · `;;` 숨은 줄 · `'글'` 지움."""
raw, _, note = g.partition("//")
hidden = raw.lstrip().startswith(";;")
s = raw.lstrip()[2:] if hidden else raw
s = re.sub(r"'[^']*'", " ", s).replace("'", " ")
return s.strip(), hidden, (note.strip() or None) if _ else None
def _ref_line(st: str, m: re.Match) -> dict[str, Any]:
kind, tail = m.group(1), m.group(2)
pre = _NAME_EQ.sub(" ", st[: m.start()])
body = pre + " @R " + st[m.end() :]
out: dict[str, Any] = {"빼기": "->" in body}
body = body.replace("->", " ")
addr = _ADDR.search(body)
out["번지"] = addr.group(1) if addr else None
if addr:
body = body[: addr.start()]
out["찍기"] = body.rstrip().endswith("=") or bool(addr)
out["식"] = expr(body.rstrip().rstrip("="))
if kind in RATE_KINDS:
mt = _RATE_TAIL.match(tail)
if not mt:
return {**out, "종류": "율", "오류": f"율 꼬리 {tail}"}
num = mt.group(1)
area = kind[1]
if area == "A":
spots = ["A" + num[i : i + 2] for i in range(0, len(num), 2)]
elif area == "T":
spots = ["T" + num]
else:
spots = []
rate = {"곱": kind[0] == "R", "범위": area, "번지": spots}
rate |= {"비목": mt.group(2) or None, "옮김": mt.group(3) or None}
return {**out, "종류": "율", "율": rate}
digits = re.match(r"^\d*", tail).group(0)
mc = _CODE_TAIL.match(tail[len(digits) :])
if not mc:
return {**out, "종류": "참조", "오류": f"참조 꼬리 {tail[len(digits) :]}"}
src = {"코드": kind + digits, "비목": mc.group(1) or None, "옮김": mc.group(2)}
return {**out, "종류": "참조", "자원": src}
def _assign_line(st: str) -> dict[str, Any]:
parts: list[dict[str, Any]] = []
for part in split_top(st):
p = part.strip()
if not p:
continue
ma = _ASSIGN.match(p)
if ma:
body = ma.group(2).strip()
show = body.endswith("=")
body = body.rstrip("=").strip()
if body:
parts.append({"이름": ma.group(1).lower(), "식": expr(body), "찍기": show})
else:
node = expr(p.rstrip("=").strip())
if node[0] not in ("오류", "이름"): # 맨 낱말 · 못 읽는 글 = 셈 없음
parts.append({"이름": None, "식": node, "찍기": False})
if not parts:
return {"종류": "글"}
return {"종류": "대입", "대입": parts}
def parse_line(g: str | None) -> dict[str, Any]:
"""G 열 원문 한 줄 → 줄 나무."""
st, hidden, note = _clean(g or "")
head = {"숨김": hidden, "덧글": note}
tot = _TOTAL.match(st.replace(" ", ""))
if tot:
return {**head, "종류": "계", "단": len(tot.group(2)), "제외": tot.group(1) == "-"} | {
"누계번지": tot.group(3)
}
if not st:
return {**head, "종류": "빈"}
refs = list(REF.finditer(st))
if len(refs) > 1:
return {**head, "종류": "참조", "오류": "참조 둘 이상", "식": ("오류", "참조 둘 이상")}
if refs:
return {**head, **_ref_line(st, refs[0])}
return {**head, **_assign_line(st)}
def parse_block(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""[{행, G}] → 줄 나무 목록(행 · 원문 붙임)."""
return [
{"행": ln.get("행"), "글": ln.get("G") or "", **parse_line(ln.get("G"))} for ln in lines
]
+3 -3
View File
@@ -36,7 +36,7 @@ from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Const as const, M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Views as views
COSTS = labor.COSTS
@@ -536,7 +536,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]:
for cid, one in out["덮개"].items():
if cid in was:
one["원값"] = _was(was[cid])
out["바탕판"] = fingerprint(base["줄"])
out |= const.stamp(base["줄"]) # 바탕판 · M01판(790-2)
ratio_of(out, base["줄"], whole) # 단위 못 바꿈(계수 없음) = 저장 막음(728-2)
_stamp_adds(out, base, whole)
except sd.DocError as e:
@@ -579,7 +579,7 @@ def calc_doc(doc: Any, given: dict | None = None, seen: bool = True) -> dict[str
got = run(clean, whole, given, trail=(me,), detail=True, line=line, seen=seen)
except sd.DocError as e:
raise _fail(e) from e
return labor._json({**views.finish(per_unit(got, clean)), "머리": head_of(clean)})
return const.answer(got, clean, doc, given, (me,)) # 옛 답 + 원래 · 시트(790-2)
def from_line(
+404
View File
@@ -0,0 +1,404 @@
"""M02 단가산출(SK) 숫자 넓힘(PLAN 790-2 · 요구서_652 · _분석_650-H3 E2) — 새 덮개 갈래 · 시트 답.
짝 = `logic/M02_Sangeun_Const_Model.ts`(캐시 고치기 · M01 원래로 · 까닭 · 더한줄 열쇠 옮김 ·
같은 꼴) · 거울 시험 `resources/tester/test_790_2_sangeun_const.py`.
문서 덧칸(없으면 안 둠 — 옛 SK 그대로 · 지문 그대로):
상수덮개 {자리: {n: 값}} 자리 식의 n 번째 숫자 상수(0 부터 · 엔진 파서 차례 · 찾기 머리 · 키 ·
따옴표 글 안 셈) → 값 · 식 구조 그대로(옵션 바꾸면 나머지 따라감)
표값덮개 {자리: {n: 값}} 자리 식의 n 번째 표 찾기 값(`찾기(…).칸`) → 값 · 그 줄만 표 연결 끊김
글줄 [{자리, 글}] 셈 없는 글 줄(근거 · ※ 조건) · 자리 뒤에 찍힘("" = 맨 위)
까닭 {열쇠: 글} 덮개 · 더한줄 · 뺀줄 · 새 덮개마다(예정가격작성기준 제2조 ②)
M01판 {자리: 지문} 저장 때 서버가 바탕 줄마다 적음(`stamp` · `sd.clean` 은 버림 =
바탕판처럼) · 셈 때 받은 값과 지금 바탕이 다르면 「M01바뀜」
자리 = 바탕 줄 cid(호표 · 덧줄 `kind:키#n`) · 중간 줄 `mid:이름` · 열쇠 = 자리 · `add:#k` ·
`add:#k/세부 cid` · 식 칸 = 중간 「식」 · 호표 「수량」 · 덧줄 「식」.
셈 = `M02_Sangeun_Doc.patched` 첫머리 `formulas`(바탕 줄 식을 바꾼 사본) → 모든 셈 길(화면 · 저장
원값 · B09 · 펼침 겹침) 한 벌 · 펼침(`M02_Library_Flat._read`) = 새 덮개 SK 는 안 펼침
(옛 참조 그대로) · [초기화](`M02_Library_Flat.reset`) = `restore` 전체.
화면 셈 답(`answer`) 덧칸 `원래` {노무비, 재료비, 경비, 계}(1 단위) + `시트` {자리, 글줄, 짝잃음} ·
M01 원래 = 같은 옵션 · 덮개 · 뺀줄 · 더한줄 · 새 덮개 없는 셈.
"""
from __future__ import annotations
import functools
import hashlib
import json
import re
from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_Sangeun_Doc as sd
NEW = ("상수덮개", "표값덮개", "글줄", "까닭")
_SPOT = re.compile(r"^(mid:.+|(labor|material|machine|logic|pct):.*#\d+)$")
_KEY = re.compile(r"^(mid:.+|(add:#\d+/)?(labor|material|machine|logic|pct):.*#\d+|add:#\d+)$")
_ADD = re.compile(r"^add:#(\d+)(/.*)?$")
FIELD = {"중간": "식", "호표": "수량", "덧줄": "식"}
LONG = 500 # 글 · 까닭 길이 상한
MANY = 200 # 글줄 수 상한
# ── 식 속 자리 ────────────────────────────────────────────────────────
@functools.cache
def _parser() -> type:
"""엔진 파서(`master_formula._Parser`) + 숫자 · 표 찾기 글자 자리 기록(엔진과 같은 차례)."""
from M02_MasterTemplete import M02_Labor_Doc as labor
class Spots(labor.m01.cm.mf._Parser):
def __init__(self, text: str) -> None:
super().__init__(text)
self.spots: list[tuple[str, int, int]] = []
def primary(self): # noqa: ANN202
self.ws()
at = self.i
node = super().primary()
if node[0] == "num":
self.spots.append(("num", at, self.i))
return node
def unary(self): # noqa: ANN202
self.ws()
at = self.i
node = super().unary()
inner = node
while inner[0] == "field":
inner = inner[1]
if node[0] == "field" and inner[0] == "find":
self.spots.append(("find", at, self.i))
return node
return Spots
@functools.lru_cache(maxsize=4096)
def scan(text: str) -> dict[str, list[tuple[int, int]]]:
"""식 글 → {num: [(처음,끝)], find: [(처음, 끝)]}(왼쪽부터) · 못 읽는 식 = 빈 목록."""
p = _parser()(text)
try:
p.parse()
except Exception: # noqa: BLE001 — 엔진 FormulaError · 깊은 괄호
return {"num": [], "find": []}
spots = [(t, a, a + len(text[a:b].rstrip())) for t, a, b in p.spots] # 뒤 빈칸(`.` 엿봄) 뗌
return {k: sorted((a, b) for t, a, b in spots if t == k) for k in ("num", "find")}
def _lit(value: Any) -> str:
got = sd.text(value)
return f"({got})" if got.startswith("-") else got
def swap(text: str, nums: dict, finds: dict) -> str:
"""식 글의 n 번째 숫자 · 표 찾기를 값으로(원래 식 차례 · 바꾼 표 찾기 안 숫자는 버림)."""
spots = scan(text)
cuts = [(*spots["find"][int(n)], v) for n, v in finds.items() if int(n) < len(spots["find"])]
for n, v in nums.items():
if int(n) < len(spots["num"]):
a, b = spots["num"][int(n)]
if not any(x <= a and b <= y for x, y, _ in cuts):
cuts.append((a, b, v))
for a, b, v in sorted(cuts, reverse=True):
text = text[:a] + _lit(v) + text[b:]
return text
def places(row: dict) -> dict[str, tuple[str, int]]:
"""자리 → (갈래, 차례) — 중간 `mid:이름` · 호표 · 덧줄 cid."""
out = {f"mid:{s.get('이름')}": ("중간", at) for at, s in enumerate(row.get("중간") or [])}
n = len(row.get("호표") or [])
for at, cid in enumerate(sd.line_ids(row)):
out[cid] = ("호표", at) if at < n else ("덧줄", at - n)
return out
def formulas(row: dict, doc: dict) -> dict:
"""바탕 줄 + 상수 · 표값 덮개 → 식 바꾼 사본(덮개 없음 = 그 줄 그대로 · 순수)."""
nums, finds = doc.get("상수덮개") or {}, doc.get("표값덮개") or {}
if not nums and not finds:
return row
out = {**row, **{k: list(row.get(k) or []) for k in FIELD}}
for spot, (part, at) in places(row).items():
if spot in nums or spot in finds:
line = out[part][at] = dict(out[part][at])
field = FIELD[part]
line[field] = swap(str(line.get(field)), nums.get(spot) or {}, finds.get(spot) or {})
return out
def lost(row: dict, doc: dict) -> list[dict]:
"""짝 잃은 새 덮개 — 바탕에 없는 자리 · 없는 n 번째."""
where, out = places(row), []
for name, kind in (("상수덮개", "num"), ("표값덮개", "find")):
for spot, cover in (doc.get(name) or {}).items():
if spot not in where:
out.append({"cid": spot, "까닭": f"{name} — 바탕에 없는 줄"})
continue
part, at = where[spot]
have = len(scan(str((row[part][at] or {}).get(FIELD[part])))[kind])
out += [
{"cid": spot, "까닭": f"{name} — {int(n) + 1} 번째 없음"}
for n in cover
if int(n) >= have
]
return out
def no_flat(doc: Any) -> None:
"""펼침 막음 — 상수 · 표값 덮개 SK(펼친 줄에 못 옮김 · 옛 참조 그대로 = 값 그대로)."""
if isinstance(doc, dict) and (doc.get("상수덮개") or doc.get("표값덮개")):
raise sd.DocError("상수 · 표값 덮개 단가산출은 펼치지 않음(참조 그대로)")
def covers_n(doc: dict) -> int:
"""[초기화] 걷힐 고침 수 — 덮개 · 뺀줄 · 상수 · 표값 덮개 칸."""
keys = ("덮개", "뺀줄", "상수덮개", "표값덮개")
return sum(len(doc.get(k) or ()) for k in keys)
# ── 문서 꼴 ──────────────────────────────────────────────────────────
def _key(raw: Any, where: str, pat: re.Pattern = _SPOT) -> str:
if not isinstance(raw, str) or not pat.match(raw):
raise sd.DocError(f"{where} · 자리 꼴 아님 「{raw}」")
return raw
def _words(raw: Any, where: str) -> str:
got = str(raw or "").strip()
if len(got) > LONG:
raise sd.DocError(f"{where} · 글이 {LONG} 자를 넘음")
return got
def _covers(raw: Any, name: str) -> dict:
if raw in (None, {}):
return {}
if not isinstance(raw, dict):
raise sd.DocError(f"{name} · 묶음 아님")
out = {}
for spot, one in raw.items():
where = f"{name} 「{spot}」"
if not isinstance(one, dict):
raise sd.DocError(f"{where} · {{n: 값}} 아님")
got = {}
for n, v in one.items():
if not re.fullmatch(r"\d{1,3}", str(n)):
raise sd.DocError(f"{where} · n 은 0 부터 수 「{n}」")
got[str(int(n))] = sd.plain(sd._num(v, f"{where} {n}"))
if got:
out[_key(spot, name)] = got
return out
def clean_more(raw: Any, out: dict) -> dict:
"""`sd.clean` 끝 — 새 덮개 칸 검사 · 빈 칸은 안 둠(`M01판` · 원값은 서버 몫이라 버림)."""
raw = raw if isinstance(raw, dict) else {}
for name in ("상수덮개", "표값덮개"):
if got := _covers(raw.get(name), name):
out[name] = got
texts = raw.get("글줄") or []
if not isinstance(texts, list) or len(texts) > MANY:
raise sd.DocError(f"글줄 · {MANY} 줄까지 목록")
lines = []
for at, one in enumerate(texts, 1):
one = one if isinstance(one, dict) else {}
spot = str(one.get("자리") or "")
words = _words(one.get("글"), f"글줄 {at}째")
if words:
lines.append({"자리": spot and _key(spot, f"글줄 {at}째", _KEY), "글": words})
if lines:
out["글줄"] = lines
why = raw.get("까닭") or {}
if not isinstance(why, dict):
raise sd.DocError("까닭 · 묶음 아님")
why = {_key(k, "까닭", _KEY): _words(v, f"까닭 「{k}」") for k, v in why.items()}
if why := {k: v for k, v in why.items() if v}:
out["까닭"] = why
return out
def _print(one: Any) -> str:
raw = json.dumps(one, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
def stamp(row: dict) -> dict[str, Any]:
"""저장 때 서버 칸 — 바탕판(옛 칸 그대로) + M01판(자리마다 지문)."""
from M02_MasterTemplete import M02_Sangeun_Calc as calc
lines = [*(row.get("중간") or []), *(row.get("호표") or []), *(row.get("덧줄") or [])]
spots = [f"mid:{s.get('이름')}" for s in row.get("중간") or []] + sd.line_ids(row)
return {"바탕판": calc.fingerprint(row), "M01판": dict(zip(spots, map(_print, lines)))}
# ── 고치기(짝 = Const_Model.ts) ──────────────────────────────────────
def restore(doc: dict, key: str | None = None) -> dict:
"""[M01 원래로] — 전체(None) = 덮개 · 뺀줄 · 상수 · 표값 덮개 · 그 까닭 지움(더한줄 · 글줄 ·
옵션 남김) · 열쇠 하나 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(세부 `add:#k/cid` =
그 더한줄 세부)."""
out = {k: v for k, v in doc.items() if k not in ("상수덮개", "표값덮개", "까닭")}
why = dict(doc.get("까닭") or {})
if key is None:
out |= {"덮개": {}, "뺀줄": []}
why = {k: v for k, v in why.items() if _top_add(k)}
return out | ({"까닭": why} if why else {})
hit = _ADD.match(key)
if hit and hit[2]:
k, sub = int(hit[1]), hit[2][1:]
adds = [dict(x) for x in doc.get("더한줄") or []]
if k < len(adds):
one = {n: v for n, v in adds[k].items() if n not in ("덮개", "뺀줄")}
covers = {c: v for c, v in (adds[k].get("덮개") or {}).items() if c != sub}
drop = [c for c in adds[k].get("뺀줄") or [] if c != sub]
adds[k] = one | ({"덮개": covers} if covers else {}) | ({"뺀줄": drop} if drop else {})
out["더한줄"] = adds
else:
out["덮개"] = {c: v for c, v in (doc.get("덮개") or {}).items() if c != key}
out["뺀줄"] = [c for c in doc.get("뺀줄") or [] if c != key]
for name in ("상수덮개", "표값덮개"):
if got := {s: v for s, v in (doc.get(name) or {}).items() if s != key}:
out[name] = got
why.pop(key, None)
return out | ({"까닭": why} if why else {})
def _top_add(key: str) -> bool:
hit = _ADD.match(key)
return bool(hit) and not hit[2]
def drop_add_keys(doc: dict, k: int) -> dict:
"""더한줄 k 를 뺀 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j > k) 를 한 칸 당김 · k 는 지움."""
def move(key: str) -> str | None:
hit = _ADD.match(key)
if not hit:
return key
j = int(hit[1])
return None if j == k else f"add:#{j - 1 if j > k else j}{hit[2] or ''}"
out = dict(doc)
if "까닭" in doc:
why = {move(x): v for x, v in doc["까닭"].items() if move(x)}
out.pop("까닭")
out |= {"까닭": why} if why else {}
if "글줄" in doc:
out["글줄"] = [{**t, "자리": move(t["자리"]) or ""} for t in doc["글줄"]]
return out
def kinds(doc: dict, spot: str) -> list[str]:
"""자리의 덮개 갈래 — 품 · 단가 · 상수 · 표값 · 뺌(차례 그대로)."""
cover, out = (doc.get("덮개") or {}).get(spot) or {}, []
if "qty" in cover or "배율" in cover:
out.append("품")
if any(k in cover for k in sd.PARTS):
out.append("단가")
out += [
n for n, k in (("상수", "상수덮개"), ("표값", "표값덮개")) if spot in (doc.get(k) or {})
]
return out + (["뺌"] if spot in (doc.get("뺀줄") or []) else [])
# ── 화면 셈 답 ────────────────────────────────────────────────────────
def answer(got: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) -> dict:
"""`M02_Sangeun_Calc.calc_doc` 답 — 옛 답 + `원래` · `시트`."""
from M02_MasterTemplete import M02_Sangeun_Calc as calc
out = {**calc.views.finish(calc.per_unit(got, clean)), "머리": calc.head_of(clean)}
return calc.labor._json(out | sheet(out, clean, raw, given, trail))
def _bare(doc: dict) -> dict:
out = {k: v for k, v in doc.items() if k not in (*NEW, "뺀더한줄")}
return out | {"덮개": {}, "뺀줄": [], "더한줄": []}
def _mids(whole: Any, key: str, row: dict, values: dict) -> tuple[dict, dict]:
"""(중간 값, 엔진 입력) — 시험 계산과 같은 길(`M02_Sangeun_Views.text_of`) · 못 셈 = 빈."""
from M02_MasterTemplete import M02_Sangeun_Calc as calc
over = calc._Over(whole, key, {**row, "키": key})
m01 = calc.labor.m01
auto = {k: v for k, v in m01.mau.values(over, over.logic(key)).items() if v is not None}
given = {**auto, **m01._dec(values or {})}
try:
mid = calc.labor.mcut.engine(over, key, m01.cm.mf.run(over, key, given)).get("중간") or {}
except Exception: # noqa: BLE001 — 엔진 FormulaError · 셈 틀림 = 값 없음
return {}, given
return mid, given
def _finds(whole: Any, text: str, env: dict) -> list[dict]:
from M02_MasterTemplete import M02_Labor_Doc as labor
mf, out = labor.m01.cm.mf, []
for n, (a, b) in enumerate(scan(text)["find"]):
try:
value = mf.evaluate(mf.parse(text[a:b]), dict(env), whole)
except Exception: # noqa: BLE001
value = None
out.append({"n": n, "글": text[a:b], "값": value})
return out
def sheet(ans: dict, clean: dict, raw: Any, given: dict | None, trail: tuple) -> dict:
"""`원래` · `시트` — 자리마다 원래 · 지금 · 덮개 갈래 · 까닭 · M01바뀜 · 상수 · 표값."""
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Sangeun_Calc as calc
whole = calc.labor.m01.loaded()[1]
try:
base = calc._base_of(clean, whole, refs.read_ref, trail)["줄"]
bare = calc.per_unit(calc.run(_bare(clean), whole, given, trail=trail), clean)
except sd.DocError:
return {"원래": None, "시트": {"자리": {}, "글줄": clean.get("글줄") or [], "짝잃음": []}}
was = {x["cid"]: x for x in bare.get("줄") or [] if x.get("cid")}
now = {x["cid"]: x for x in ans.get("줄") or [] if x.get("cid")}
key = str(clean.get("키") or "SK-000000")
values = {**(clean.get("입력값") or {}), **(given or {})}
mids0, env0 = _mids(whole, key, base, values)
mids1, _ = _mids(whole, key, formulas(base, clean), values)
stamps = (raw.get("M01판") if isinstance(raw, dict) else None) or {}
fresh = stamp(base)["M01판"]
why = clean.get("까닭") or {}
nums, finds = clean.get("상수덮개") or {}, clean.get("표값덮개") or {}
spots: dict[str, dict] = {}
for spot, (part, at) in places(base).items():
line = base[part][at]
text = str(line.get(FIELD[part]))
consts = [
{"n": n, "값": nums.get(spot, {}).get(str(n), v), "원값": v}
for n, v in enumerate(Decimal(text[a:b]) for a, b in scan(text)["num"])
]
tables = [
{**t, "원값": t["값"], "값": finds.get(spot, {}).get(str(t["n"]), t["값"])}
for t in _finds(whole, text, {**env0, **mids0})
]
if part == "중간":
name = str(line.get("이름"))
old, cur = mids0.get(name), mids1.get(name)
else:
old, cur = (was.get(spot) or {}).get("금액"), (now.get(spot) or {}).get("금액")
spots[spot] = {
"갈래": part,
"이름": line.get("이름") or "",
"식": text,
"상수": consts,
"표값": tables,
"원래": old,
"지금": cur,
"덮개": kinds(clean, spot),
"까닭": why.get(spot),
"M01바뀜": spot in stamps and stamps[spot] != fresh.get(spot),
}
for cid, line in now.items():
if _ADD.match(cid) and cid not in spots:
spots[cid] = {"갈래": "더한줄", "이름": line.get("이름") or "", "원래": None}
spots[cid] |= {"지금": line.get("금액"), "덮개": [], "까닭": why.get(cid)}
gone = lost(base, clean) + [
{"cid": s, "까닭": "M01 바뀜 — 바탕에 없는 줄"} for s in stamps if s not in fresh
]
top = {"글줄": clean.get("글줄") or [], "짝잃음": gone}
return {"원래": bare.get("기준"), "시트": {"자리": spots, **top}}
+7 -7
View File
@@ -36,6 +36,8 @@ import re
from decimal import Decimal, InvalidOperation
from typing import Any
from M02_MasterTemplete import M02_Sangeun_Const as const
KIND = "sangeun"
TITLE = "단가산출"
PREFIX = "SK"
@@ -342,13 +344,11 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str
_added(one, f"더한줄 {at}째", whole) for at, one in enumerate(doc.get("더한줄") or [], 1)
]
_rate_keys(out["더한줄"])
drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []]
out["뺀줄"] = drop = [_cid(x, "뺀줄") for x in doc.get("뺀줄") or []]
if len(set(drop)) != len(drop):
raise DocError("뺀줄에 같은 줄 두 번")
out["뺀줄"] = drop
if gone := _gone_adds(doc.get("뺀더한줄"), whole):
out["뺀더한줄"] = gone
return out
out |= {"뺀더한줄": gone} if (gone := _gone_adds(doc.get("뺀더한줄"), whole)) else {}
return const.clean_more(doc, out) # 새 덮개 칸(790-2)
def is_rate(one: Any) -> bool:
@@ -460,11 +460,11 @@ def patched(
`picks` · `shown` = 바탕 쪽 차례 열쇠 고름 · 자재표시(바탕 SK 사슬) → 새 차례로 옮김.
돌려줌 = {줄, 고름 {차례: MT}, 자재표시 {차례: 칸}, 정체 [새 줄 cid · 더한줄 = `add:#k`],
짝잃음 [{cid, 까닭}]}."""
row = const.formulas(row, doc) # 상수 · 표값 덮개(790-2) = 식 바꾼 바탕
items, extras = list(row.get("호표") or []), list(row.get("덧줄") or [])
ids = line_ids(row)
covers, drop = doc.get("덮개") or {}, set(doc.get("뺀줄") or [])
mine = doc.get("고름") or {}
marks = doc.get("자재표시") or {}
mine, marks = doc.get("고름") or {}, doc.get("자재표시") or {}
lost = [
{"cid": c, "까닭": "뺀줄 — 바탕에 없는 줄"} for c in doc.get("뺀줄") or [] if c not in ids
]
@@ -0,0 +1,192 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_Sangeun_Const_Model.ts
* 단가산출(SK) 숫자 넓힘 캐시 고치기(PLAN 790-2 · 요구서_652 · _분석_650-H3 E2) —
* 짝 = 서버 `M02_MasterTemplete/M02_Sangeun_Const.py`(`restore` · `drop_add_keys` · `kinds` 같은 꼴)
* · 거울 시험 `resources/tester/test_790_2_sangeun_const.py`.
* 상수덮개 {자리: {n: 값}} 식 n 번째 숫자(0 부터 · 서버 답 `시트.자리[자리].상수` 의 n)
* 표값덮개 {자리: {n: 값}} 식 n 번째 `찾기(…).칸`(서버 답 `표값` 의 n)
* 글줄 [{자리, 글}] 셈 없는 글 줄 · 자리 뒤("" = 맨 위)
* 까닭 {열쇠: 글} 덮개 · 더한줄 · 뺀줄 · 새 덮개마다
* M01판 서버 몫 — 받은 그대로 둠(셈 때 「M01바뀜」 판정)
* 자리 = 바탕 cid · 중간 `mid:이름` · 열쇠 = 자리 · `add:#k` · `add:#k/세부 cid`.
* 금액은 늘 서버가 셈 — 여기는 문서 칸 고치기만(새 문서를 돌려줌 · 받은 문서 안 바꿈).
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
export type NumCovers = Record<string, Record<string, number>>;
export interface TextLine {
자리: string;
글: string;
}
export interface ConstDoc {
덮개?: Record<string, Record<string, unknown>>;
뺀줄?: string[];
더한줄?: Record<string, unknown>[];
상수덮개?: NumCovers;
표값덮개?: NumCovers;
글줄?: TextLine[];
까닭?: Record<string, string>;
[k: string]: unknown;
}
export type CoverKind = "품" | "단가" | "상수" | "표값" | "뺌";
const ADD = /^add:#(\d+)(\/.*)?$/;
const PARTS = ["labor", "material", "expense"];
/** 빈 묶음 · 목록 칸은 지움(서버 `clean_more` 처럼 없으면 안 둠). */
function put<T extends ConstDoc>(doc: T, name: string, value: unknown): T {
const out: ConstDoc = { ...doc };
const empty =
value == null ||
(Array.isArray(value)
? value.length === 0
: typeof value === "object" && Object.keys(value).length === 0);
if (empty) delete out[name];
else out[name] = value;
return out as T;
}
function setNum<T extends ConstDoc>(
doc: T,
name: "상수덮개" | "표값덮개",
spot: string,
n: number,
v: number | null,
): T {
const all: NumCovers = { ...(doc[name] ?? {}) };
const one = { ...(all[spot] ?? {}) };
if (v == null) delete one[String(n)];
else one[String(n)] = v;
if (Object.keys(one).length) all[spot] = one;
else delete all[spot];
return put(doc, name, all);
}
/** 식 속 n 번째 숫자 상수 → 값(null = 원래로). */
export const setConst = <T extends ConstDoc>(
doc: T,
spot: string,
n: number,
v: number | null,
): T => setNum(doc, "상수덮개", spot, n, v);
/** 식 속 n 번째 표 찾기 값 → 값(null = 표 연결 되살림). */
export const setTable = <T extends ConstDoc>(
doc: T,
spot: string,
n: number,
v: number | null,
): T => setNum(doc, "표값덮개", spot, n, v);
/** 글 줄 넣기 — `at` 차례(없으면 끝). */
export function addText<T extends ConstDoc>(doc: T, spot: string, text: string, at?: number): T {
const lines = [...(doc.글줄 ?? [])];
lines.splice(at ?? lines.length, 0, { 자리: spot, 글: text });
return put(doc, "글줄", lines);
}
export function editText<T extends ConstDoc>(doc: T, at: number, text: string): T {
const lines = (doc.글줄 ?? []).map((t, i) => (i === at ? { ...t, 글: text } : t));
return put(doc, "글줄", lines);
}
export function dropText<T extends ConstDoc>(doc: T, at: number): T {
return put(
doc,
"글줄",
(doc.글줄 ?? []).filter((_, i) => i !== at),
);
}
/** 까닭 — 빈 글 = 지움. */
export function setReason<T extends ConstDoc>(doc: T, key: string, text: string): T {
const why = { ...(doc.까닭 ?? {}) };
if (text.trim()) why[key] = text.trim();
else delete why[key];
return put(doc, "까닭", why);
}
const topAdd = (key: string): boolean => {
const hit = ADD.exec(key);
return !!hit && !hit[2];
};
/** [M01 원래로] — 열쇠 없음 = 전체(덮개 · 뺀줄 · 상수 · 표값 덮개 · 그 까닭 · 더한줄 · 글줄 · 옵션 남김) ·
* 열쇠 = 그 자리 덮개 · 뺀줄 · 새 덮개 · 까닭(`add:#k/cid` = 그 더한줄 세부) · 짝 `restore`. */
export function restoreM01<T extends ConstDoc>(doc: T, key?: string): T {
let out: ConstDoc = { ...doc };
delete out.상수덮개;
delete out.표값덮개;
delete out.까닭;
const why = { ...(doc.까닭 ?? {}) };
if (key == null) {
out = { ...out, 덮개: {}, 뺀줄: [] };
const kept = Object.fromEntries(Object.entries(why).filter(([k]) => topAdd(k)));
return put(out, "까닭", kept) as T;
}
const hit = ADD.exec(key);
if (hit && hit[2]) {
const k = Number(hit[1]);
const sub = hit[2].slice(1);
const adds = (doc.더한줄 ?? []).map((x) => ({ ...x }));
if (k < adds.length) {
const was = adds[k];
const one: Record<string, unknown> = { ...was };
delete one.덮개;
delete one.뺀줄;
const covers = Object.fromEntries(
Object.entries((was.덮개 as Record<string, unknown>) ?? {}).filter(([c]) => c !== sub),
);
const drop = ((was.뺀줄 as string[]) ?? []).filter((c) => c !== sub);
if (Object.keys(covers).length) one.덮개 = covers;
if (drop.length) one.뺀줄 = drop;
adds[k] = one;
}
out.더한줄 = adds;
} else {
out.덮개 = Object.fromEntries(Object.entries(doc.덮개 ?? {}).filter(([c]) => c !== key));
out.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== key);
for (const name of ["상수덮개", "표값덮개"] as const) {
const got = Object.fromEntries(Object.entries(doc[name] ?? {}).filter(([s]) => s !== key));
out = put(out, name, got);
}
}
delete why[key];
return put(out, "까닭", why) as T;
}
/** 더한줄 k 를 뺀 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j > k) 한 칸 당김 · k 는 지움 · 짝 `drop_add_keys`. */
export function dropAddKeys<T extends ConstDoc>(doc: T, k: number): T {
const move = (key: string): string | null => {
const hit = ADD.exec(key);
if (!hit) return key;
const j = Number(hit[1]);
return j === k ? null : `add:#${j > k ? j - 1 : j}${hit[2] ?? ""}`;
};
let out: ConstDoc = { ...doc };
if (doc.까닭) {
const why: Record<string, string> = {};
for (const [x, v] of Object.entries(doc.까닭)) {
const to = move(x);
if (to) why[to] = v;
}
out = put(out, "까닭", why);
}
if (doc.글줄) out.글줄 = doc.글줄.map((t) => ({ ...t, 자리: move(t.자리) ?? "" }));
return out as T;
}
/** 자리의 덮개 갈래(차례 = 품 · 단가 · 상수 · 표값 · 뺌) · 짝 `kinds`. */
export function coverKinds(doc: ConstDoc, spot: string): CoverKind[] {
const cover = doc.덮개?.[spot] ?? {};
const out: CoverKind[] = [];
if ("qty" in cover || "배율" in cover) out.push("품");
if (PARTS.some((k) => k in cover)) out.push("단가");
if (doc.상수덮개?.[spot]) out.push("상수");
if (doc.표값덮개?.[spot]) out.push("표값");
if ((doc.뺀줄 ?? []).includes(spot)) out.push("뺌");
return out;
}