Files
Aislo/M02_MasterTemplete/M02_Sangeun_QtyLinks.py
T
eomsangdonandClaude Sonnet 5.5 e171dda4e3 feat(797-2): 엑셀 불러오기 소요량 · 계수 이음 — 값 역색인 후보 + 사람 확인 · 고름 = 표 칸 참조 기록
- 새 `M02_Sangeun_QtyLinks` — 가져온 식 줄(호표 수량 · 덧줄 식)의 수를 M01 소요량(QC/QF) · 계수(CC/CF) 칸과 값 역색인(`San_Basis_Index.lookup`)으로 맞춤 · 같은 값 + 같은 단위 성격 + 공종 문맥(인력 = 직종 · 재료 = 칸 이름 추가) · 일반 수 · 비교 글자 · 중간 변수는 자리 아님 · 유일해도 자동 확정 없음
- 고름 = `가져온.연결` 기록(층 M01 · 표 · 줄 · 칸 · 값 · 꼴) · 엑셀 수 = 칸 값일 때만(다르면 400 까닭) · 버림 · 되돌림 · 기록만(셈 · 금액 · 단가 갱신 안 건드림) · 저장 거름(`clean`) = 자리 · 수 맞는 기록만
- 길 `…/sangeun/imported/{qty|qty-pick|qty-drop}` · 가져온 식 만들 때 표지 「참고 · 소요량 후보 N · 사람 확인」 · 「표 칸 없음」(못 옮김 표지 아님)
- 화면 — 가져온 식 모달 머리 판 한 줄(집계 · [이음 보기]) + 모달(줄마다 후보 · [고르기] · [풀기] · [버리기] · [되돌리기] · 부품 상자만)
- 시험 test_797_2_qty_coef_links 9 · 낡은 시험 없음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 15:23:12 +09:00

362 lines
15 KiB
Python

"""M02 가져온 식 소요량 · 계수 이음(PLAN 797-2 · 요구 655-3 · 요구서_655) — 서버 단독.
가져온 식 줄(엑셀 그대로)에 쓰인 수가 M01 소요량(QC/QF) · 계수(CC/CF) 표 칸의 값인지 **값 역색인**
(`M02_San_Basis_Index.lookup` 한 벌)으로 후보를 냄 — 같은 값 + 같은 단위 성격 + 이름 · 공종 문맥이
맞는 칸만 후보 · 유일해도 자동 확정 없음(사람이 고름) · 값을 지어 넣지 않음.
- 자리(slot) = `호표:i:n` | `덧줄:i:n` — 로직 줄 i 의 식(수량 | 식)의 n 번째 수(중간 변수는 단위 ·
이름 문맥이 없어 자리 아님). 일반 수(1 · 10 · 60 · 100 · 1000 · 8) · 비교 글자 · 찾기 · 로직 부름
인자는 안 셈.
- 고름 = 엑셀 수와 표 칸 값이 같을 때만(같은 값 · 율 ↔ 소수 · t ↔ kg) — 다르면 못 고름 + 까닭.
기록(`가져온.연결` = {고름 [..], 버림 [..]})만 — 셈 · 금액 · 엔진 안 건드림 · [단가 갱신]은 자원
단가만(요구 656-1) · [재생성]이 이 기록을 따라가는 길은 아직 없음(기록만).
- 고름 = {자리, 수, 층 「M01」, 표, 줄, 칸, 값, 꼴 eq|pct|kgt} · 로직이 바뀌어 자리의 수가 달라지면
그 기록은 버려진 것으로 보고 화면에 안 내놓음.
길(`M02_Sangeun_Router` `imported/{qty|qty-pick|qty-drop}`) · 표지(`flags` · 가져온 식 만들 때).
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_San_Basis_Index as bi
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_Sangeun_Doc as sd
from M02_MasterTemplete import M02_Sangeun_Imported as imp
KEY = "연결"
LAYER = "M01"
GENERAL = (0, 1, 8, 10, 60, 100, 1000) # 일반 수(793-3a N9)
SHOWN = 12 # 자리마다 화면에 내는 후보 상한(후보 수는 전부 셈)
FORMS = ("eq", "pct", "kgt")
FIELDS = (("호표", "수량"), ("덧줄", "식")) # 중간 변수 = 단위 · 이름 문맥 없음 → 자리 아님
_ctx: dict[int, dict[str, set[str]]] = {}
def _flat(s: Any) -> str:
return re.sub(r"[^가-힣A-Za-z0-9]", "", str(s or ""))
def _hangul(s: Any) -> str:
return re.sub(r"[^가-힣]", "", str(s or ""))
def _literals(node: Any, out: list[Decimal]) -> None:
"""식 나무 → 수 글자(차례대로) — 비교 글자 · 찾기 · 로직 부름 인자는 뺌."""
if not isinstance(node, tuple):
return
kind = node[0]
if kind == "num":
out.append(node[1])
elif kind == "bin":
cmp_ = node[1] in bi.mf._OPS
for side in node[2:]:
if not (cmp_ and isinstance(side, tuple) and side[0] == "num"):
_literals(side, out)
elif kind in ("neg", "field"):
_literals(node[1], out)
elif kind == "call":
for arg in node[2]:
_literals(arg, out)
def literals(text: Any) -> list[Decimal]:
out: list[Decimal] = []
try:
_literals(bi.mf.parse(str(text)), out)
except bi.mf.FormulaError:
pass
return out
def slots(row: dict) -> list[dict]:
"""가져온 로직 줄 → 소요량 · 계수 자리 `[{자리, 갈래, 줄, 이름, 단위, 수, d, 직종?}]`."""
out = []
for kind, field in FIELDS:
for i, one in enumerate(row.get(kind) or []):
if not isinstance(one, dict):
continue
labor = kind == "호표" and one.get("종류") == "인력"
for n, x in enumerate(literals(one.get(field))):
if float(x) in GENERAL:
continue
out.append(
{
"자리": f"{kind}:{i}:{n}",
"갈래": kind,
"종류": str(one.get("종류") or ""),
"줄": str(one.get("이름") or ""),
"단위": "인" if labor else str(one.get("단위") or ""),
"수": float(x),
"d": max(0, -x.as_tuple().exponent),
"직종": str(one.get("이름") or "") if labor else None,
}
)
return out
def _context(idx: bi.Index) -> dict[str, set[str]]:
"""표 → 이름 · 공종 · 구분 글(한글만) — 역색인 한 판마다 한 번."""
got = _ctx.get(id(idx))
if got is None:
_ctx.clear()
got = _ctx[id(idx)] = {
k: _tokens(
" ".join(
str(x or "")
for x in (t.get("이름"), (t.get("용도") or {}).get("공종"), t.get("상세구분"))
)
)
for k, t in idx.tables.items()
}
return got
STOP = re.compile(
"및|설치|해체|운반|조립|가공|제작|시공|공사|현장|작업|기계|인력|보통|일반|사용|적용|기준|표준|구간|공통"
)
def _tokens(s: str) -> set[str]:
return {t for t in re.split(r"[^가-힣]+", STOP.sub(" ", _hangul_keep(s))) if len(t) >= 2}
def _hangul_keep(s: Any) -> str:
"""한글 · 공백만 남김(낱말 가름 유지)."""
return re.sub(r"[^가-힣]+", " ", str(s or ""))
def _on_topic(title: set[str], table: set[str]) -> bool:
"""공종 맞음 — 블록 이름 낱말과 표 이름 · 공종 낱말이 같거나 한쪽이 다른 쪽에 들어 있음."""
return any(
a == b or min(len(a), len(b)) >= 3 and (a in b or b in a) for a in title for b in table
)
def _near(a: str, b: str) -> bool:
x, y = _flat(a), _flat(b)
return len(x) >= 2 and len(y) >= 2 and (x in y or y in x)
def _same(how: str, cell: float, v: float) -> bool:
"""엑셀 수와 칸 값이 같은 꼴(eq 같음 · pct 율 ↔ 소수 · kgt t ↔ kg)."""
scale = {"eq": (1,), "pct": (100, 0.01), "kgt": (1000, 0.001)}[how]
return any(abs(cell - v * m) <= 1e-9 * max(1.0, abs(cell)) for m in scale)
def _named(slot: dict, cell: dict, table: dict) -> bool:
"""줄 이름이 칸 이름(칸 · 같은 줄 글 칸)과 맞는가 — 인력 = 직종 맞음."""
name = slot["직종"] or slot["줄"]
if _near(name, str(cell["칸"]).split("/")[0]):
return True
row = (table.get("줄") or [{}])[cell["줄"]] if cell["줄"] < len(table.get("줄") or []) else {}
return any(isinstance(v, str) and _near(name, v) for v in row.values())
def find(idx: bi.Index, slot: dict, title: str) -> list[dict]:
"""자리 하나 → 후보 칸 `[{표, 줄, 칸, 값, 단위, 꼴, 맥락, 이름, 번호, 갈래}]`(이름 맥락 먼저)"""
k = {"v": slot["수"], "d": slot["d"], "단위": slot["단위"], "직종": slot["직종"]}
ctx, want = _context(idx), _tokens(title.split("(")[0]) # 이름 앞부분(규격 괄호 앞)
out, seen = [], set()
for cid, how in idx.lookup(k):
c = idx.cells[cid]
t = idx.tables.get(c["표"])
if t is None or c["종류"] in ("eq", "cond") or how not in FORMS:
continue
if not _same(how, c["v"], slot["수"]) or (c["표"], c["줄"], c["칸"]) in seen:
continue
if not _on_topic(want, ctx.get(c["표"], set())):
continue # 공종 문맥이 맞아야 후보
named = slot["직종"] is not None or _named(slot, c, t) # 인력 = lookup 이 직종 맞춤
if not named and slot["종류"] == "재료":
continue # 재료는 칸 이름도 맞아야
why = "이름 · 공종" if named else "공종"
seen.add((c["표"], c["줄"], c["칸"]))
out.append(
{
"표": c["표"],
"줄": c["줄"],
"칸": c["칸"],
"값": c["v"],
"단위": c["단위"],
"꼴": how,
"맥락": why,
"이름": t.get("이름"),
"번호": t.get("원문번호"),
"갈래": "소요량" if c["표"].startswith("Q") else "계수",
}
)
return sorted(out, key=lambda x: (x["맥락"] == "공종", x["표"], x["줄"]))
def scan(row: dict, idx: bi.Index | None = None) -> list[dict]:
"""로직 줄 → 자리마다 `후보`(전부) · 수 — 순수(저장 안 함)."""
idx = idx or bi.get()
title = str(row.get("이름") or "")
return [{**s, "후보": find(idx, s, title)} for s in slots(row)]
def flags(row: dict, idx: bi.Index | None = None) -> list[str]:
"""만들 때 표지(「참고 · …」) — 자리마다 한 줄 · 사람 확인 · 후보 0 = 표 칸 없음."""
out = []
for s in scan(row, idx):
n = len(s["후보"])
what = f"소요량 후보 {n} · 사람 확인" if n else "표 칸 없음"
out.append(f"{em.NOTE}{what} — {s['줄']} {s['수']:g}")
return out
def tally(scanned: list[dict]) -> dict[str, int]:
"""자리 수 · 줄 수 · 후보 유일 | 여럿 | 없음 자리 수."""
return {
"자리": len(scanned),
"줄": len({s["자리"].rsplit(":", 1)[0] for s in scanned}),
"유일": sum(len(s["후보"]) == 1 for s in scanned),
"여럿": sum(len(s["후보"]) > 1 for s in scanned),
"없음": sum(not s["후보"] for s in scanned),
}
# ── 고름 · 버림(문서 기록) ─────────────────────────────────────────────
def _need(doc: Any) -> dict:
if not imp.is_imported(doc):
raise sd.DocError("가져온 식 단가산출만")
return doc
def _state(doc: dict) -> dict[str, list]:
meta = doc[imp.KEY].get(KEY) or {}
return {"고름": list(meta.get("고름") or []), "버림": list(meta.get("버림") or [])}
def _with(doc: dict, state: dict) -> dict:
meta = {k: v for k, v in state.items() if v}
rest = {k: v for k, v in doc[imp.KEY].items() if k != KEY}
return {**doc, imp.KEY: rest | ({KEY: meta} if meta else {})}
def view(doc: Any, idx: bi.Index | None = None) -> dict[str, Any]:
"""화면 답 — 자리마다 {자리, 줄, 수, 단위, 후보수, 후보, 상태 후보|없음|고름|버림, 고른}."""
doc = _need(doc)
state, rows = _state(doc), []
scanned = scan(doc[imp.KEY]["로직"], idx)
picked = {x["자리"]: x for x in state["고름"] if x.get("수") is not None}
dropped = set(state["버림"])
for s in scanned:
mine = picked.get(s["자리"])
if mine and abs(mine["수"] - s["수"]) > 1e-12:
mine = None # 로직이 바뀜 — 옛 기록 안 씀
if mine: # M01 칸 값이 그새 바뀌었으면 알림(기록은 그대로)
now = next(
(
c["v"]
for c in (idx or bi.get()).cells_of_table.get(mine["표"], [])
if c["줄"] == mine["줄"] and c["칸"] == mine["칸"]
),
None,
)
mine = {
**mine,
"지금": now,
"같음": now is not None and _same(mine["꼴"], now, s["수"]),
}
rows.append(
{
**{k: s[k] for k in ("자리", "갈래", "줄", "단위", "수")},
"후보수": len(s["후보"]),
"후보": s["후보"][:SHOWN],
"고른": mine,
"상태": (
"고름"
if mine
else "버림"
if s["자리"] in dropped
else "후보"
if s["후보"]
else "없음"
),
}
)
count = tally(scanned) | {
"고름": sum(r["상태"] == "고름" for r in rows),
"버림": sum(r["상태"] == "버림" for r in rows),
}
return {"줄": rows, "집계": count}
def pick(doc: Any, spot: str, cell: dict, idx: bi.Index | None = None) -> dict[str, Any]:
"""후보 칸 고름 — 엑셀 수 = 칸 값일 때만(다르면 400 까닭) · 기록만 · 금액 그대로."""
doc = _need(doc)
idx = idx or bi.get()
slot = next((s for s in slots(doc[imp.KEY]["로직"]) if s["자리"] == spot), None)
if slot is None:
raise sd.DocError(f"없는 자리 「{spot}」")
table = idx.tables.get(str((cell or {}).get("표")))
rows = (table or {}).get("줄") or []
at, col = (cell or {}).get("줄"), (cell or {}).get("칸")
found = next(
(
c
for c in idx.cells_of_table.get(str((cell or {}).get("표")), [])
if c["줄"] == at and c["칸"] == col and c["종류"] not in ("eq", "cond")
),
None,
)
if table is None or found is None or not isinstance(at, int) or at >= len(rows):
raise sd.DocError("없는 표 칸")
how = next((h for h in FORMS if _same(h, found["v"], slot["수"])), None)
if how is None:
raise sd.DocError(
f"엑셀 수 {slot['수']:g} ≠ 표 칸 값 {found['v']:g} — 같은 값인 칸만 고를 수 있음"
)
one = {
"자리": spot,
"수": slot["수"],
"층": LAYER,
"표": found["표"],
"줄": at,
"칸": col,
"값": found["v"],
"꼴": how,
}
state = _state(doc)
state["고름"] = [x for x in state["고름"] if x.get("자리") != spot] + [one]
state["버림"] = [x for x in state["버림"] if x != spot]
return {"문서": _with(doc, state)}
def drop(doc: Any, spot: str, undo: bool = False) -> dict[str, Any]:
"""자리 후보 버림(또는 [되돌리기]) — 고른 것이 있으면 같이 풂 · 기록만."""
doc = _need(doc)
if not any(s["자리"] == spot for s in slots(doc[imp.KEY]["로직"])):
raise sd.DocError(f"없는 자리 「{spot}」")
state = _state(doc)
state["고름"] = [x for x in state["고름"] if x.get("자리") != spot]
state["버림"] = [x for x in state["버림"] if x != spot] + ([] if undo else [spot])
return {"문서": _with(doc, state)}
def tidy(raw: Any, row: dict) -> dict[str, list]:
"""저장 때 서버가 다시 거름(`M02_Sangeun_Imported.clean`) — 자리가 있고 수가 같은 기록만 ·
칸 값 대조는 고를 때(`pick`)만(M01 이 바뀌었으면 화면이 알림)."""
have = {s["자리"]: s["수"] for s in slots(row)}
raw = raw if isinstance(raw, dict) else {}
keep = ("자리", "수", "층", "표", "줄", "칸", "값", "꼴")
picked = []
for x in raw.get("고름") or []:
if not (isinstance(x, dict) and x.get("자리") in have and x.get("꼴") in FORMS):
continue
num = x.get("수")
if (
isinstance(num, (int, float))
and abs(num - have[x["자리"]]) <= 1e-12
and x.get("층") == LAYER
):
picked.append({k: x[k] for k in keep if k in x})
done = {p["자리"] for p in picked}
gone = [x for x in raw.get("버림") or [] if isinstance(x, str) and x in have and x not in done]
return {k: v for k, v in (("고름", picked), ("버림", gone)) if v}