Merge remote-tracking branch 'origin/dev' into sub_laptop_8
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
출처 = 「…품셈」(오타 「품줌」) 뒤 20 글자 안 절 번호 — 앞 글에 산림 · 임도 = 산림품셈 ·
|
||||
그 밖 = 건설품셈 ·
|
||||
직종 = 「이름 : 수」 줄의 빈칸 뺀 이름이 우리 인력(LB) 이름 · 옛이름일 때만(지금 이름으로) ·
|
||||
글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료).
|
||||
글(338) = 머리 뺀 몸 줄 `GLINES` 줄 · 줄마다 `GCHARS` 자 안(맞춤 지문 재료) · 산출근거 열이 빈 줄은
|
||||
그 오른쪽 ~ 돈 머리(맨 오른쪽 묶음) 앞 칸을 빈칸으로 이어 붙임(거창 꼴 · 돈 머리 없으면 `WIDE` 열).
|
||||
견줌(`candidates`) = 같은 책 조항 같음 1.0 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) ·
|
||||
앞 마디 같음 0.5(조항이 안 맞으면 후보 아님 · `clause`) · 조항 오타 「13-114」 = 「13-11-4」 ·
|
||||
건설 공통 · 토목 덤 0.1 · 직종 겹침(보통인부 뺌) 0.5 · 단위 0.15 · 직종도 뜻 낱말도 안 겹치면 버림 ·
|
||||
@@ -34,7 +35,7 @@ _HEAD = re.compile(r"^(\d+)\.\s*(.*?)\s*\[([^\[\]]*)\]\s*$")
|
||||
_BOOK = re.compile(r"품[셈줌]")
|
||||
_TRADE = re.compile(r"^[\s*·\-]*([가-힣][가-힣\s]*?)\s*:\s*[\d,]")
|
||||
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 「13-114」 = 13-11-4
|
||||
GLINES, GCHARS = 200, 300
|
||||
GLINES, GCHARS, WIDE = 200, 300, 140
|
||||
_TYPOS = (("깍기", "깎기"),)
|
||||
_PLAIN_WORDS = frozenset({"인력", "기계", "장비"}) # 방법 낱말 — 「터널굴착 1발파당 인력」
|
||||
_MONEY = {"합계": "계", "노무비": "노무비", "재료비": "재료비", "경비": "경비"}
|
||||
@@ -45,6 +46,12 @@ def _flat(value: Any) -> str:
|
||||
|
||||
|
||||
# ── 엑셀 블록 읽기 ─────────────────────────────────────────────────────
|
||||
def _cell_text(value: Any) -> str:
|
||||
if isinstance(value, float) and value == int(value):
|
||||
value = int(value)
|
||||
return str(value).strip() if value is not None else ""
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _trade_names(whole_id: int) -> dict[str, str]:
|
||||
"""{빈칸 뺀 이름 · 옛이름: 지금 이름} — 우리 인력(LB)."""
|
||||
@@ -78,11 +85,15 @@ def sources(text: str) -> list[list[str]]:
|
||||
|
||||
|
||||
def _columns(rows: list[list[Any]]) -> tuple[int, int, dict[str, int]]:
|
||||
"""(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {})."""
|
||||
"""(자료 첫 줄, 산출근거 열, {계 · 노무비 · 재료비 · 경비: 열}) — 머리 없으면 (0, 1, {}) ·
|
||||
돈 머리가 둘 묶음이면 맨 오른쪽(거창 꼴 · 값이 든 쪽)."""
|
||||
for at, row in enumerate(rows[:10]):
|
||||
flat = [_flat(v) for v in row]
|
||||
if "산출근거" in flat:
|
||||
money = {key: flat.index(head) for head, key in _MONEY.items() if head in flat}
|
||||
last = len(flat) - 1
|
||||
money = {
|
||||
key: last - flat[::-1].index(head) for head, key in _MONEY.items() if head in flat
|
||||
}
|
||||
return at + 1, flat.index("산출근거"), money
|
||||
return 0, 1, {}
|
||||
|
||||
@@ -93,14 +104,19 @@ def read(rows: list[list[Any]] | None) -> dict[int, dict[str, Any]]:
|
||||
if not rows:
|
||||
return {}
|
||||
start, col, money = _columns(rows)
|
||||
end = min(money.values()) if money and min(money.values()) > col + 1 else WIDE
|
||||
names = trade_names()
|
||||
blocks: dict[int, dict[str, Any]] = {}
|
||||
block: dict[str, Any] | None = None
|
||||
for at, row in enumerate(rows[start:], start + 1):
|
||||
text = row[col] if col < len(row) else None
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
continue
|
||||
head = None if text[:1].isspace() else _HEAD.match(text)
|
||||
head = None
|
||||
if isinstance(text, str) and text.strip():
|
||||
head = None if text[:1].isspace() else _HEAD.match(text)
|
||||
else:
|
||||
text = " ".join(_cell_text(v) for v in row[col + 1 : end] if _cell_text(v))
|
||||
if not text:
|
||||
continue
|
||||
if head:
|
||||
block = blocks.setdefault(
|
||||
int(head.group(1)),
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"""M02 일위대가 맞춤 — 조합 풀이(PLAN 338-1 · 설계 2-7): 블록 몫(절마다 노임 · 장비 벡터)을 우리
|
||||
로직 단위당 벡터 조합으로 풂 · 섞인 줄은 인력 몫 로직이 1순위(몫 먼저).
|
||||
|
||||
맞음 = 차원(직종 · 장비 갈래)마다 작은/큰 가중 평균(보통인부 0.5 · 장비 있음 0.5 + 시간 0.5 ·
|
||||
크기 다르면 × 0.8 · 갈래 일부만 같으면 × 0.8) · 몫 x(0.1 … 0.9) × 0.95 · 제목 몫 수와 같은 x 면
|
||||
+0.1.
|
||||
덧 로직 = 1순위가 못 덮는(< COVER) 절은 절 1위(≥ 0.75) · 아니면 몫마다(≥ 0.6) · 장비만 든 몫 =
|
||||
「기계 시간당 기계경비」(체크 안 함 · 시간 = 엑셀 값).
|
||||
순위 덤 = 앞 차례 0.3 · 인용 조항 같음 0.3 · 같은 책 0.1 · 후보 풀 = 앞 차례 15 + 드문 직종 · 장비
|
||||
색인
|
||||
(40 개 이하) + 절 제목 이름 후보 8. 우리 벡터 = `M02_Hopyo_Ours` 지문 변형 L · X(따로 안 셈).
|
||||
규칙 · 문턱은 336 실험(C5 `c03_vec` · `c05_solve` · C7 `solve`) 그대로 · 문턱은 `M02_Hopyo_Fit`
|
||||
머리.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
|
||||
COMMON = "보통인부"
|
||||
MACHINE = {"굴착기": "GC000267"} # 굴착기(무한궤도) 시간당 기계경비 · 그 밖 장비 = GC000268
|
||||
MACHINE_ANY = "GC000268"
|
||||
MACHINE_SET = {"GC000267", "GC000268", "GC000269", "GC000275"}
|
||||
_SHARE = re.compile(r"(\d{1,3})\s*%")
|
||||
_RATIO = re.compile(r"(\d)\s*:\s*(\d)")
|
||||
_STATE: dict[str, Any] = {"판": None}
|
||||
|
||||
|
||||
# ── 우리 벡터 · 색인 ───────────────────────────────────────────────────
|
||||
def _vec(v: dict[str, Any]) -> dict[str, Any]:
|
||||
eq = [(frozenset(a), frozenset(b), q) for a, b, q in v.get("X") or [] if a and q]
|
||||
return {"노임": {t: q for t, q in (v.get("L") or {}).items() if q}, "장비": eq}
|
||||
|
||||
|
||||
def _load() -> dict[str, Any]:
|
||||
"""{변형: {로직: [(입력, 벡터)]}, 색인: {차원: {로직}}} — 우리 지문 판마다 한 번."""
|
||||
table = ours.table() or []
|
||||
ver = ours.version()
|
||||
if _STATE["판"] != (ver, id(table)):
|
||||
var: dict[str, list] = {}
|
||||
idx: dict[str, set] = {}
|
||||
for o in table:
|
||||
vs = [(x.get("in") or {}, _vec(x)) for x in o.get("변형") or []]
|
||||
var[o["로직"]] = [(g, v) for g, v in vs if v["노임"] or v["장비"]]
|
||||
if var[o["로직"]]:
|
||||
for d in dims(var[o["로직"]][0][1]):
|
||||
idx.setdefault(d, set()).add(o["로직"])
|
||||
_STATE.update(판=(ver, id(table)), 변형=var, 색인=idx)
|
||||
return _STATE
|
||||
|
||||
|
||||
def variants(code: str) -> list[tuple[dict, dict]]:
|
||||
return _load()["변형"].get(code) or []
|
||||
|
||||
|
||||
def _kinds(key: str) -> tuple[frozenset, str]:
|
||||
kind, _, size = key.partition("|")
|
||||
return frozenset(kind.split("+")), size
|
||||
|
||||
|
||||
def dims(E: dict[str, Any]) -> set[str]:
|
||||
out = {t for t in E.get("노임") or {} if t != COMMON}
|
||||
eq = E.get("장비") or {}
|
||||
if isinstance(eq, dict):
|
||||
return out | {"+".join(sorted(_kinds(k)[0])) for k in eq}
|
||||
return out | {"+".join(sorted(a)) for a, _, _ in eq}
|
||||
|
||||
|
||||
def sim(E: dict[str, Any], ov: dict[str, Any], x: float = 1.0) -> float:
|
||||
"""E = 엑셀 몫 {노임, 장비: {「굴착기|0.6」: 시간}} · ov = 우리 벡터 — 0..1."""
|
||||
num = den = 0.0
|
||||
et, ot = E.get("노임") or {}, ov.get("노임") or {}
|
||||
for t in set(et) | set(ot):
|
||||
w = 0.5 if t == COMMON else 1.0
|
||||
a, b = et.get(t, 0.0), ot.get(t, 0.0) * x
|
||||
den += w
|
||||
num += w * min(a, b) / max(a, b) if a > 0 and b > 0 else 0
|
||||
oe = ov.get("장비") or []
|
||||
used = set()
|
||||
for k, a in (E.get("장비") or {}).items():
|
||||
kinds, size = _kinds(k)
|
||||
best, pick = 0.0, None
|
||||
for at, (kinds2, sizes2, b) in enumerate(oe):
|
||||
if not kinds & kinds2:
|
||||
continue
|
||||
f = 1.0 if (not size or not sizes2 or float(size) in sizes2) else 0.8
|
||||
f *= 1.0 if kinds == kinds2 else 0.8 # 굴착기 ↔ 굴착기+집게
|
||||
b *= x
|
||||
s = f * (0.5 + 0.5 * (min(a, b) / max(a, b) if a > 0 and b > 0 else 0))
|
||||
if s > best:
|
||||
best, pick = s, at
|
||||
den += 1.0
|
||||
num += best
|
||||
if pick is not None:
|
||||
used.add(pick)
|
||||
den += sum(1.0 for at in range(len(oe)) if at not in used)
|
||||
return num / den if den else 0.0
|
||||
|
||||
|
||||
def fit_one(E: dict[str, Any], code: str, share: bool) -> tuple[float, dict, float]:
|
||||
"""(맞음, 입력, 몫 x) — 변형마다 x = 1 과 몫 x 중 가장 좋은 것."""
|
||||
top = (0.0, {}, 1.0)
|
||||
for given, ov in variants(code):
|
||||
s = sim(E, ov)
|
||||
if s > top[0]:
|
||||
top = (s, given, 1.0)
|
||||
if share:
|
||||
for x in fit.SHARES:
|
||||
s2 = 0.95 * sim(E, ov, x)
|
||||
if s2 > top[0]:
|
||||
top = (s2, given, x)
|
||||
return top
|
||||
|
||||
|
||||
def cover(E: dict[str, Any], code: str) -> float:
|
||||
"""E 의 차원을 로직 벡터가 얼마나 덮나(로직의 남는 차원은 안 셈)."""
|
||||
top = 0.0
|
||||
for _, ov in variants(code):
|
||||
num = den = 0.0
|
||||
for t, a in (E.get("노임") or {}).items():
|
||||
w = 0.5 if t == COMMON else 1.0
|
||||
b = (ov.get("노임") or {}).get(t, 0.0)
|
||||
den += w
|
||||
num += w * (min(a, b) / max(a, b) if a > 0 and b > 0 else 0)
|
||||
for k in E.get("장비") or {}:
|
||||
den += 1
|
||||
num += any(_kinds(k)[0] & a for a, _, _ in ov.get("장비") or [])
|
||||
top = max(top, num / den if den else 0)
|
||||
return top
|
||||
|
||||
|
||||
# ── 후보 풀 · 앞선 믿음 ────────────────────────────────────────────────
|
||||
def title_cands(title: str, unit: str, got: dict, n: int = 8) -> list[str]:
|
||||
text = re.sub(r"\s+", "", re.sub(r"[\d.·:()\[\]%]+", " ", str(title or "")))
|
||||
if len(text) < 2:
|
||||
return []
|
||||
found = hopyo_match.candidates({"이름": text, "단위": unit}, "로직", got, n)
|
||||
return [c["로직"] for c in found if c.get("로직")]
|
||||
|
||||
|
||||
class Prior:
|
||||
"""앞선 믿음 — 앞 차례 · 줄 근거 조항(윗마디 포함) · 같은 책."""
|
||||
|
||||
def __init__(self, line: dict[str, Any], ranked: list[str]):
|
||||
self.rank = {c: k for k, c in enumerate(ranked)}
|
||||
self.n = max(len(ranked), 1)
|
||||
self.theirs = [list(x) for x in ((line.get("근거") or {}).get("출처") or [])]
|
||||
self.books = {b for b, _ in self.theirs}
|
||||
self.table = hopyo_basis.ours()
|
||||
|
||||
def __call__(self, code: str) -> float:
|
||||
p = 0.3 * (1 - self.rank[code] / self.n) if code in self.rank else 0.0
|
||||
our = self.table.get(code)
|
||||
if our and self.theirs:
|
||||
p += 0.3 if hopyo_match.clause(self.theirs, our)[0] >= 0.9 else 0
|
||||
p += 0.1 if our["책"] in self.books else 0
|
||||
return p
|
||||
|
||||
|
||||
def _sum(parts: list[dict]) -> dict[str, Any]:
|
||||
out: dict[str, dict] = {"노임": {}, "장비": {}}
|
||||
for s in parts:
|
||||
for k in ("노임", "장비"):
|
||||
for d, q in (s.get(k) or {}).items():
|
||||
out[k][d] = out[k].get(d, 0) + q
|
||||
return out
|
||||
|
||||
|
||||
def pool_for(E: dict[str, Any], base: list[str], extra: list[str]) -> list[str]:
|
||||
idx = _load()["색인"]
|
||||
rare: set[str] = set()
|
||||
for d in dims(E):
|
||||
if len(idx.get(d, ())) <= 40:
|
||||
rare |= idx.get(d, set())
|
||||
return list(dict.fromkeys([*base, *extra, *sorted(rare)]))
|
||||
|
||||
|
||||
def _hint(title: Any) -> float | None:
|
||||
m = _SHARE.search(str(title or ""))
|
||||
return int(m.group(1)) / 100 if m and 0 < int(m.group(1)) < 100 else None
|
||||
|
||||
|
||||
def ranked(E: dict, pool: list[str], prior: Prior, share: bool, hint: float | None) -> list[tuple]:
|
||||
"""[(순위 점수, 맞음, 몫 x, 로직, 입력)] — 순위 차례 · 제목 몫과 같은 x 면 +0.1."""
|
||||
out = []
|
||||
for c in pool:
|
||||
f, given, x = fit_one(E, c, share)
|
||||
if f <= 0:
|
||||
continue
|
||||
score = f + prior(c)
|
||||
if hint is not None:
|
||||
score += 0.1 if abs(x - hint) < 1e-9 else -0.1
|
||||
out.append((score, f, x, c, given))
|
||||
out.sort(key=lambda z: -z[0])
|
||||
return out
|
||||
|
||||
|
||||
def is_mixed(parts: list[dict], line: dict[str, Any]) -> bool:
|
||||
"""섞인 줄 — 제목 · 이름 · 규격에 몫 표시 + 노임만 든 몫 + 장비만 든 몫."""
|
||||
text = " ".join(
|
||||
[str(p.get("제목")) for p in parts] + [str(line.get(k)) for k in ("이름", "규격")]
|
||||
)
|
||||
marked = bool(_SHARE.search(text) or _RATIO.search(text))
|
||||
man = any(p.get("노임") and not p.get("장비") for p in parts)
|
||||
mach = any(p.get("장비") and not p.get("노임") for p in parts)
|
||||
return marked and man and mach
|
||||
|
||||
|
||||
def machine_of(E: dict[str, Any]) -> str:
|
||||
kinds = {frozenset(_kinds(k)[0]) for k in E.get("장비") or {}}
|
||||
return MACHINE["굴착기"] if kinds == {frozenset({"굴착기"})} else MACHINE_ANY
|
||||
|
||||
|
||||
# ── 풀이 ───────────────────────────────────────────────────────────────
|
||||
def combine(
|
||||
primary: str | None, parts: list[dict], line: dict, got: dict, base: list[str]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""[{로직, 어디, 맞음, 몫, 단계, 입력}] — 1순위 + 1순위가 못 덮는 절 · 몫의 로직."""
|
||||
one = lambda c, w, f, x, step, g=None: { # noqa: E731
|
||||
"로직": c,
|
||||
"어디": w,
|
||||
"맞음": f,
|
||||
"몫": x,
|
||||
"단계": step,
|
||||
"입력": g or {},
|
||||
}
|
||||
chosen = [one(primary, "1순위", None, 1.0, "1순위")] if primary else []
|
||||
if not parts:
|
||||
return chosen
|
||||
prior, unit = Prior(line, base), str(line.get("단위") or "")
|
||||
mixed = is_mixed(parts, line)
|
||||
groups: dict[str, list] = {}
|
||||
for p in parts:
|
||||
groups.setdefault(str(p.get("절") or p.get("제목") or ""), []).append(p)
|
||||
have = {primary} if primary else set()
|
||||
|
||||
def machine(E: dict, where: str) -> None:
|
||||
if mixed or len(groups) > 1:
|
||||
m = machine_of(E)
|
||||
chosen.append(one(m, where, None, 1.0, "장비 몫"))
|
||||
have.add(m)
|
||||
|
||||
for title, ps in groups.items():
|
||||
E = _sum(ps)
|
||||
if primary and cover(E, primary) >= fit.COVER:
|
||||
continue
|
||||
if E["장비"] and not E["노임"]: # 장비만 든 절 = 기계 시간 로직(닫힌 세계 규칙)
|
||||
machine(E, title)
|
||||
continue
|
||||
share = any(_hint(p.get("제목")) for p in ps) or _hint(title) is not None
|
||||
pool = pool_for(E, base, title_cands(title, unit, got))
|
||||
cand = ranked(E, pool, prior, share, _hint(title))
|
||||
if cand and cand[0][1] >= fit.COMBO[1] and cand[0][3] not in have:
|
||||
chosen.append(
|
||||
one(cand[0][3], title, round(cand[0][1], 3), cand[0][2], "절", cand[0][4])
|
||||
)
|
||||
have.add(cand[0][3])
|
||||
continue
|
||||
for p in ps:
|
||||
if primary and cover(p, primary) >= fit.COVER:
|
||||
continue
|
||||
if p.get("장비") and not p.get("노임"):
|
||||
machine(p, p.get("제목"))
|
||||
continue
|
||||
hint = _hint(p.get("제목"))
|
||||
pool = pool_for(p, base, title_cands(p.get("제목"), unit, got))
|
||||
cand = ranked(p, pool, prior, hint is not None or share, hint)
|
||||
if cand and cand[0][1] >= fit.COMBO[2] and cand[0][3] not in have:
|
||||
f, x, c, g = round(cand[0][1], 3), cand[0][2], cand[0][3], cand[0][4]
|
||||
chosen.append(one(c, p.get("제목"), f, x, "몫", g))
|
||||
have.add(c)
|
||||
return chosen
|
||||
|
||||
|
||||
def solve(
|
||||
top: list[str], parts: list[dict], line: dict, got: dict, base: list[str]
|
||||
) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
"""(새 차례, 조합 대상) — 섞인 줄이고 1순위가 블록 합을 MIXED_CUT 못 덮으면 1순위를 인력 몫
|
||||
로직으로 바꿈(몫 먼저) · 조합이 하나뿐이면 대상 빈 것."""
|
||||
chosen = combine((top or [None])[0], parts, line, got, base)
|
||||
codes = list(dict.fromkeys(c["로직"] for c in chosen if c["로직"]))
|
||||
if top and len(codes) > 1 and parts and is_mixed(parts, line):
|
||||
if cover(_sum(parts), top[0]) < fit.MIXED_CUT:
|
||||
labor = [c for c in chosen if c["로직"] != top[0] and c["단계"] in ("몫", "절")]
|
||||
if labor:
|
||||
chosen = [c for c in chosen if c["로직"] != top[0]]
|
||||
top = [labor[0]["로직"]] + [c for c in top if c != labor[0]["로직"]][:4]
|
||||
seen: set[str] = set()
|
||||
chosen = [
|
||||
c for c in chosen if not (c["로직"] in seen or seen.add(c["로직"]))
|
||||
] # 같은 로직은 처음 것
|
||||
if len(chosen) < 2:
|
||||
return top, []
|
||||
return top, [{**c, "체크": c["로직"] not in MACHINE_SET or c["로직"] == top[0]} for c in chosen]
|
||||
@@ -7,7 +7,12 @@
|
||||
호표 줄 = 「제N호표」 · 호표 1 단위 값 = 금액 열 · 구성 줄 = 호표 칸 빔 · 품명 있음 ·
|
||||
끝 = 다음 호표.
|
||||
갈래 = % · 호표(비고 일위N참조) · 단가산출(단산N참조) · 기계(단위 hr) · 별도계상(값 없음) ·
|
||||
자재(재료비만) · 기타 — 규격은 원문 그대로 둠.
|
||||
자재(재료비만) · 노임(단위 인 + 우리 인력 이름) · 기타 — 규격은 원문 그대로 둠.
|
||||
이음(338) = 비고 「단산 · 산근 · 단가산출 N(참조 · 호표)」 → 단산 N · 「일위 · 대가 · 일위대가 N」 →
|
||||
호표 N(빈칸 무시) · 비고 빈 줄(노무비 · 경비 단가 있음)은 품명 + 단위가 같은 블록이 하나뿐이면
|
||||
이음(줄 `이음` = 「이름」) · 하이퍼링크 안 읽음.
|
||||
STmate 꼴(「일위대가표」 · 「단가산출근거」 시트) = `M02_Hopyo_Stmate.read` ·
|
||||
문서 보안(DRM) 파일 = 400 글.
|
||||
검산 = 줄 금액 floor(단가 × 수량 × 10) / 10 · 합계 단가 = 셋 합 ·
|
||||
% 줄 = floor(바탕 × 율 / 100 × 10) / 10 · 호표 비목 = floor(줄 금액 합) · 총괄표 합계 —
|
||||
어긋남은 글 목록(막지 않음).
|
||||
@@ -25,6 +30,7 @@ from decimal import ROUND_FLOOR, Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
|
||||
|
||||
SHEET = "일위대가"
|
||||
SUMMARY = "일위대가총괄표"
|
||||
@@ -32,7 +38,9 @@ COSTS = ("노무비", "재료비", "경비")
|
||||
HEADS = ("호표", "품명", "규격", "수량", "단위", "비고")
|
||||
NO_SHEET = "일위대가 시트 없음"
|
||||
_HOPYO = re.compile(r"제\s*(\d+)\s*호표")
|
||||
_REF = re.compile(r"^(단산|일위)\s*(\d+)\s*참조$")
|
||||
_REF = re.compile(r"^(?:(단산|산근|단가산출)|(일위대가|일위|대가))(\d+)(?:참조|호표)?$")
|
||||
DRM = "문서 보안(DRM)으로 잠긴 파일 — 보안을 푼 사본으로 저장해 올림"
|
||||
_OLE = b"\xd0\xcf\x11\xe0"
|
||||
_MACHINE_UNITS = ("hr", "시간", "h")
|
||||
|
||||
|
||||
@@ -77,11 +85,15 @@ def _sheets(name: str, data: bytes) -> dict[str, list[list[Any]]]:
|
||||
wanted = (SHEET, SUMMARY, hopyo_basis.SHEET)
|
||||
if not data:
|
||||
raise ExcelError("빈 파일")
|
||||
if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", _OLE):
|
||||
raise ExcelError(DRM)
|
||||
try:
|
||||
if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"):
|
||||
import openpyxl
|
||||
|
||||
book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||||
if hopyo_stmate.is_stmate([t.strip() for t in book.sheetnames]):
|
||||
wanted = hopyo_stmate.SHEETS
|
||||
out = {
|
||||
ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)]
|
||||
for ws in book.worksheets
|
||||
@@ -143,6 +155,8 @@ def kind_of(line: dict[str, Any]) -> str:
|
||||
return "단가산출"
|
||||
if unit in _MACHINE_UNITS:
|
||||
return "기계"
|
||||
if unit == "인" and _flat(line.get("이름")) in hopyo_basis.trade_names():
|
||||
return "노임"
|
||||
if not any(price.get(k) for k in ("계", *COSTS)):
|
||||
return "별도계상"
|
||||
if price.get("재료비") and not price.get("노무비") and not price.get("경비"):
|
||||
@@ -208,10 +222,28 @@ def _summary(rows: list[list[Any]] | None) -> dict[int, Decimal]:
|
||||
return out
|
||||
|
||||
|
||||
def _by_name(raw: list[list[dict[str, Any]]], blocks: dict[int, dict[str, Any]]) -> None:
|
||||
"""비고 빈 줄(노무비 · 경비 단가 있음) → 품명 + 단위가 같은 블록이 하나뿐이면 단산 이음."""
|
||||
index: dict[tuple[str, str], list[int]] = {}
|
||||
for no, block in blocks.items():
|
||||
index.setdefault((_flat(block["이름"]), _flat(block["단위"]).lower()), []).append(no)
|
||||
for line in (one for lines in raw for one in lines):
|
||||
if line["참조"] or line["비고"] or line["갈래"] == "%":
|
||||
continue
|
||||
if not (line["단가"].get("노무비") or line["단가"].get("경비")):
|
||||
continue
|
||||
hit = index.get((_flat(line["이름"]), _flat(line["단위"]).lower())) or []
|
||||
if len(hit) == 1:
|
||||
line["참조"], line["이음"] = {"종류": "단산", "번": hit[0]}, "이름"
|
||||
line["갈래"] = kind_of(line)
|
||||
|
||||
|
||||
# ── 읽기 ──────────────────────────────────────────────────────────────
|
||||
def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
"""엑셀 → `{시트, 호표: [...], 검산: {어긋남: [글 …], 총괄표: 맞춘 수}}` — 값은 JSON 수."""
|
||||
sheets = _sheets(name, data)
|
||||
if hopyo_stmate.is_stmate(sheets):
|
||||
return hopyo_stmate.read(sheets)
|
||||
rows = sheets.get(SHEET)
|
||||
if rows is None:
|
||||
raise ExcelError(NO_SHEET)
|
||||
@@ -238,7 +270,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
if head or not name_ or not hopyos:
|
||||
continue
|
||||
note = _text(_cell(row, cols.get("비고")))
|
||||
ref = _REF.match(note.replace(" ", ""))
|
||||
ref = _REF.match(_flat(note))
|
||||
line = {
|
||||
"차례": len(raw[-1]),
|
||||
"이름": name_,
|
||||
@@ -248,7 +280,9 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
"단가": _money(row, cols, 0),
|
||||
"금액": _money(row, cols, 1),
|
||||
"비고": note,
|
||||
"참조": {"종류": ref.group(1), "번": int(ref.group(2))} if ref else None,
|
||||
"참조": {"종류": "단산" if ref.group(1) else "일위", "번": int(ref.group(3))}
|
||||
if ref
|
||||
else None,
|
||||
}
|
||||
line["갈래"] = kind_of(line)
|
||||
line["행"] = hopyos[-1]["행"][1] = at
|
||||
@@ -256,6 +290,7 @@ def read_book(name: str, data: bytes) -> dict[str, Any]:
|
||||
if not hopyos:
|
||||
raise ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음")
|
||||
blocks = hopyo_basis.read(sheets.get(hopyo_basis.SHEET))
|
||||
_by_name(raw, blocks)
|
||||
for line in (one for lines in raw for one in lines):
|
||||
ref = line["참조"] or {}
|
||||
if ref.get("종류") == "단산" and (block := blocks.get(ref["번"])):
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""M02 일위대가 실무자료 엑셀 지문(PLAN 338-1 · 설계 2-2) — 호표 줄 + 이어진 단가산출 블록 →
|
||||
직종 품 · 장비 시간 · 자재 수량 · 율 · 계수 · 조건 · 조항 · 몫. 금액 안 씀(식 맨 앞 단가는 뗌).
|
||||
|
||||
엑셀꼴 = 블록 `글`(머리 뺀 몸 줄)을 줄마다 풂 — 절 `^\\d+\\.` · 작은 절 `^[가-하]\\.` ·
|
||||
직종 줄 「직종 : 단가 × 식 = 금액」(우리 인력 이름 · 옛이름) · 장비 = 「노무비 · 경비 · 재료비 :
|
||||
단가 ÷ Q」
|
||||
+ 지금 장비(노무비 > 경비 > 재료비) · 자재 = 「이름 단가 × 수량 = 금액」 · 「: 별도계상」 ·
|
||||
못 풀면 금액 ÷ 단가(「역산」 · 품 되찾기만).
|
||||
STmate = 블록 `자원`(L · X · M · S) 품이 곧 품(노무비 · 재료비 · 경비 세 줄 같은 품은 한 번) ·
|
||||
섞음 = 이름 · 규격 몫 둘(작은 쪽 인력 · 큰 쪽 장비).
|
||||
몫(조합용 · 설계 2-7) = 맨 윗 절마다 노임 · 장비 벡터(`M02_Hopyo_Shares`).
|
||||
블록 없는 기계(hr) 줄 = `machine` · 노임 · 장비를 바로 적은 호표 = `direct`.
|
||||
값 · 규칙은 336 실험(C4 `s1_excel_fp` · K5 `stmate`) 그대로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Shares as shares
|
||||
from M02_MasterTemplete.M02_Hopyo_Shares import trade
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, parse_rhs, specnums
|
||||
|
||||
COST = {"노무비", "재료비", "경비"}
|
||||
SKIPW = {
|
||||
"소계",
|
||||
"계",
|
||||
"합계",
|
||||
"인부소계",
|
||||
"자재소계",
|
||||
"주자재소계",
|
||||
"운반",
|
||||
"상차",
|
||||
"기계",
|
||||
"다짐",
|
||||
"적사",
|
||||
"살수",
|
||||
"집토",
|
||||
"암절취",
|
||||
"채집",
|
||||
"인력",
|
||||
"장비",
|
||||
"작업조건",
|
||||
"1일시공량",
|
||||
"작업면적",
|
||||
"1시간작업량",
|
||||
"기계채집",
|
||||
"구조물터파기",
|
||||
}
|
||||
_COLON = re.compile(
|
||||
r"^\s*(?:\d+\)|[0-9가-힣]\.)?\s*(?P<name>[^::=]{1,40}?)\s*[::]\s*(?P<rhs>.*)$"
|
||||
)
|
||||
_MAT = re.compile(
|
||||
r"^\s*(?P<name>[^\d\s::=][^::=]*?[A-Za-z가-힣)\]])\s+(?P<rhs>[\d,]+(?:\.\d+)?\s*[×xX*÷/].*=.*)$"
|
||||
)
|
||||
_PCT = re.compile(r"([가-힣]+)의\s*([\d.]+)\s*%")
|
||||
_COEF = re.compile(
|
||||
r"(?<![A-Za-z가-힣])(q|qs|q1|K|k|f|f1|f2|E|Es|Cm|L|V1|V2|T|rt|n|t1|t2|t3|t4)\s*=\s*"
|
||||
r"(\d+(?:\.\d+)?)(?![\d.]*\s*[×x*÷/])"
|
||||
)
|
||||
_HEAD = re.compile(r"^\s*(\d+)\.\s*(.*)$")
|
||||
_SUB = re.compile(r"^\s*([가-하])\.\s*(.*)$")
|
||||
OLD = re.compile(
|
||||
r"((?<![\d,.])20(0\d|1\d|20)(?![\d,.])|(?<!\d)(1\d|20)년|임도품셈|임도표준품셈|사방품셈|사방사업표준품셈)"
|
||||
)
|
||||
_LASTNUM = re.compile(r"=\s*([\d,]+(?:\.\d+)?)\s*$")
|
||||
_HOURS = re.compile(r"(hr|시간)", re.I)
|
||||
TRADE_UNITS = ("인",)
|
||||
|
||||
|
||||
def _flat(s: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(s or ""))
|
||||
|
||||
|
||||
def _ratio(rhs: str) -> float | None:
|
||||
"""식을 못 풀 때만 — 금액 ÷ 단가(품 되찾기용)."""
|
||||
m = re.match(r"^\s*([\d,]+(?:\.\d+)?)", rhs)
|
||||
n = _LASTNUM.search(rhs)
|
||||
if not m or not n:
|
||||
return None
|
||||
p, a = float(m.group(1).replace(",", "")), float(n.group(1).replace(",", ""))
|
||||
return a / p if p else None
|
||||
|
||||
|
||||
def rates_in(text: Any) -> set[float]:
|
||||
"""글 속 섞음 비율 후보 — (N%) · 8:2 · 9:1."""
|
||||
t = str(text or "")
|
||||
out = {round(float(n) / 100, 4) for n in re.findall(r"(\d+(?:\.\d+)?)\s*%", t)}
|
||||
out = {v for v in out if 0 < v < 1}
|
||||
for a, b in re.findall(r"(\d+)\s*:\s*(\d+)", t):
|
||||
a, b = float(a), float(b)
|
||||
if a and b and a + b <= 20:
|
||||
out |= {round(a / (a + b), 4), round(b / (a + b), 4)}
|
||||
return out
|
||||
|
||||
|
||||
def empty(line: dict[str, Any], block: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
block = block or {}
|
||||
basis = line.get("근거") or {}
|
||||
name, spec = str(line.get("이름") or ""), str(line.get("규격") or "")
|
||||
return {
|
||||
"이름": name,
|
||||
"규격": spec,
|
||||
"단위": str(line.get("단위") or ""),
|
||||
"출처": [list(x) for x in block.get("출처") or basis.get("출처") or []],
|
||||
"인력": {}, # 직종: {품, 밑품, 율, 어떻게}
|
||||
"장비": [], # {갈래, 규격, 시간, 밑시간, 율, 절, 글}
|
||||
"자재": [], # {이름, 품, 밑품}
|
||||
"율": [], # [바탕, %]
|
||||
"계수": {},
|
||||
"조건": {},
|
||||
"이름조건": {k: sorted(v) for k, v in conds(f"{name} {spec}").items()},
|
||||
"옛판": False,
|
||||
"몫": [],
|
||||
}
|
||||
|
||||
|
||||
def _labor(fp: dict, name: str, q: float, base: float, rates: list, how: str) -> None:
|
||||
one = fp["인력"].setdefault(name, {"품": 0.0, "밑품": 0.0, "율": [], "어떻게": []})
|
||||
one["품"] += q
|
||||
one["밑품"] += base
|
||||
one["율"] += rates
|
||||
one["어떻게"].append(how)
|
||||
|
||||
|
||||
def _round(fp: dict[str, Any]) -> dict[str, Any]:
|
||||
for one in fp["인력"].values():
|
||||
one["품"], one["밑품"] = round(one["품"], 6), round(one["밑품"], 6)
|
||||
return fp
|
||||
|
||||
|
||||
def excel(block: dict[str, Any] | None, line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""HP 블록(설계 3-1) + 그 줄(이름 · 규격 · 단위) → 지문 · 블록이 없거나 글 · 자원이 없으면
|
||||
기계(hr) 줄 지문(장비 1 시간) 또는 빈 지문."""
|
||||
if block and block.get("자원"):
|
||||
return _round(_stmate(block, line))
|
||||
if block and block.get("글"):
|
||||
return _round(_text(block, line))
|
||||
return machine(line)
|
||||
|
||||
|
||||
def machine(line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""블록 없는 기계(hr) 줄 → 장비 [{갈래, 규격, 시간 1}]."""
|
||||
fp = empty(line)
|
||||
text = f"{fp['이름']} {fp['규격']}"
|
||||
types = mtypes(text)
|
||||
if types:
|
||||
hours = 1.0 if _HOURS.search(fp["단위"]) else None
|
||||
one = {"갈래": sorted(types), "규격": sorted(specnums(text)), "시간": hours}
|
||||
fp["장비"] = [{**one, "밑시간": None, "율": [], "절": "0", "글": fp["이름"]}]
|
||||
fp["조건"] = fp["이름조건"]
|
||||
return fp
|
||||
|
||||
|
||||
def _text(block: dict[str, Any], line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""엑셀꼴 — 블록 글 줄마다 풂(C4 `north`)."""
|
||||
fp = empty(line, block)
|
||||
head = f"{block.get('번', '')}.{block.get('이름', '')} [{block.get('단위', '')}]"
|
||||
text = [head, *[str(t) for t in block.get("글") or []]]
|
||||
front = [t for t in text[:5]] + [t for t in text if "※" in t or "작업조건" in t]
|
||||
head_text = " ".join([fp["이름"], fp["규격"], *front])
|
||||
fp["조건"] = {k: sorted(v) for k, v in conds(head_text).items()}
|
||||
fp["옛판"] = bool(OLD.search(" ".join(text[:6])))
|
||||
sec, sub, mach, label = 0, "", None, ""
|
||||
got_cost: dict[tuple, tuple] = {}
|
||||
item_rates = rates_in(f"{fp['이름']} {fp['규격']}")
|
||||
sec_rates: set[float] = set()
|
||||
|
||||
def unmix(got: dict | None) -> dict | None:
|
||||
"""율 메모가 없는데 맨 인자가 섞음 비율 후보와 같으면 율로 가름."""
|
||||
if got and not got["율"]:
|
||||
for v in got.get("인자") or []:
|
||||
if round(v, 4) in item_rates | sec_rates and 0 < v < 1:
|
||||
return {**got, "밑품": got["밑품"] / v, "율": [[round(v, 4), "섞음"]]}
|
||||
return got
|
||||
|
||||
for raw in text[1:]:
|
||||
row = raw.rstrip()
|
||||
h, s = _HEAD.match(row), _SUB.match(row)
|
||||
if h or s:
|
||||
if h:
|
||||
sec, sub = int(h.group(1)), ""
|
||||
sec_rates = rates_in(h.group(2))
|
||||
else:
|
||||
sub = s.group(1)
|
||||
sec_rates = sec_rates | rates_in(s.group(2))
|
||||
title = (h or s).group(2)
|
||||
ts = mtypes(title)
|
||||
mach = (ts, specnums(title)) if ts else None
|
||||
label = title.strip() if ts else ""
|
||||
for k, v in _PCT.findall(row):
|
||||
fp["율"].append([k, float(v)])
|
||||
cm = _COLON.match(row)
|
||||
name = re.sub(r"^[0-9가-힣]\.", "", _flat(cm.group("name"))) if cm else ""
|
||||
who = hopyo_basis.trade_names().get(name) if cm else None
|
||||
if who:
|
||||
rhs = cm.group("rhs")
|
||||
got = unmix(parse_rhs(rhs))
|
||||
if got:
|
||||
_labor(fp, who, got["품"], got["밑품"], got["율"], "식")
|
||||
elif r := _ratio(rhs):
|
||||
_labor(fp, who, r, r, [], "역산")
|
||||
continue
|
||||
if cm and name in COST:
|
||||
got = unmix(parse_rhs(cm.group("rhs")))
|
||||
if got and mach:
|
||||
key = (sec, sub, label)
|
||||
prio = 0 if (name == "노무비" and got["단가"] > 0) else 1 if name == "경비" else 2
|
||||
if key not in got_cost or prio < got_cost[key][0]:
|
||||
got_cost[key] = (prio, mach, got)
|
||||
continue
|
||||
if cm and name not in SKIPW and re.search(r"(별도|별산)", cm.group("rhs")):
|
||||
fp["자재"].append({"이름": cm.group("name").strip(), "품": None, "밑품": None})
|
||||
continue
|
||||
mm = _MAT.match(row)
|
||||
if mm and not cm and (got := parse_rhs(mm.group("rhs"))):
|
||||
fp["자재"].append(
|
||||
{"이름": mm.group("name").strip(), "품": got["품"], "밑품": got["밑품"]}
|
||||
)
|
||||
continue
|
||||
if not (h or s):
|
||||
ts = mtypes(row)
|
||||
if ts and "%" not in row and not cm:
|
||||
mach, label = (ts, specnums(row)), row.strip()
|
||||
for k, v in _COEF.findall(row):
|
||||
fp["계수"].setdefault(k, float(v))
|
||||
for (sec_, sub_, lab), (_, (ts, sp), got) in got_cost.items():
|
||||
where = f"{sec_}.{sub_}" if sub_ else str(sec_)
|
||||
one = {"갈래": sorted(ts), "규격": sorted(sp), "시간": got["품"], "밑시간": got["밑품"]}
|
||||
fp["장비"].append({**one, "율": got["율"], "절": where, "글": lab})
|
||||
fp["몫"] = shares.sections(text[1:])
|
||||
return fp
|
||||
|
||||
|
||||
def _stmate(block: dict[str, Any], line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""STmate — 자원 품이 곧 품(K5 `stmate`)."""
|
||||
fp = empty(line, block)
|
||||
text = [str(t) for t in block.get("글") or []][:60]
|
||||
fp["조건"] = {
|
||||
k: sorted(v) for k, v in conds(" ".join([fp["이름"], fp["규격"], *text[:4]])).items()
|
||||
}
|
||||
fp["옛판"] = bool(OLD.search(" ".join(text[:4])))
|
||||
for t in text:
|
||||
for k, v in _COEF.findall(t):
|
||||
fp["계수"].setdefault(k, float(v))
|
||||
for k, v in _PCT.findall(t):
|
||||
fp["율"].append([k, float(v)])
|
||||
rates = rates_in(f"{fp['이름']} {fp['규격']}")
|
||||
lab_rate = min(rates) if len(rates) >= 2 else None
|
||||
mach_rate = max(rates) if len(rates) >= 2 else None
|
||||
seen: Counter = Counter()
|
||||
for x in block.get("자원") or []:
|
||||
kind, q = x.get("갈래"), x.get("품")
|
||||
nm, sp = str(x.get("이름") or ""), str(x.get("규격") or "")
|
||||
if not q:
|
||||
continue
|
||||
seen[(x.get("코드"), round(float(q), 9))] += 1
|
||||
if seen[(x.get("코드"), round(float(q), 9))] > 1: # 노무비 · 재료비 · 경비 세 줄 같은 품
|
||||
continue
|
||||
q = float(q)
|
||||
ts = mtypes(f"{nm} {sp}")
|
||||
if kind == "L":
|
||||
if who := trade(nm):
|
||||
base = q / lab_rate if lab_rate else q
|
||||
_labor(fp, who, q, base, [[lab_rate, "섞음"]] if lab_rate else [], "QTY")
|
||||
elif kind == "X" or (kind == "S" and ts):
|
||||
one = {"갈래": sorted(ts), "규격": sorted(specnums(sp) | specnums(nm)), "시간": q}
|
||||
base = q / mach_rate if mach_rate else q
|
||||
fp["장비"].append({**one, "밑시간": base, "율": [], "절": "0", "글": f"{nm} {sp}"})
|
||||
elif kind in ("M", "S"):
|
||||
fp["자재"].append({"이름": f"{nm} {sp}".strip(), "품": q, "밑품": q})
|
||||
fp["몫"] = shares.sections_stm(block.get("자원") or [], text)
|
||||
return fp
|
||||
|
||||
|
||||
def direct(hopyo: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""노임 · 장비를 바로 적은 호표 → 줄 수량 합 지문(호표 1 단위) · 근거글 출처 — 없으면 None."""
|
||||
lines = hopyo.get("줄") or []
|
||||
fp = empty({k: hopyo.get(k) for k in ("이름", "규격", "단위")})
|
||||
texts = [str(t) for t in hopyo.get("근거글") or []]
|
||||
texts += [f"{x.get('이름')} {x.get('규격')}" for x in lines if x.get("갈래") == "근거글"]
|
||||
fp["출처"] = hopyo_basis.sources(" ".join(texts))
|
||||
hit = False
|
||||
for x in lines:
|
||||
q = x.get("수량")
|
||||
if not isinstance(q, (int, float)) or q <= 0:
|
||||
continue
|
||||
if kind_of(x) == "노임" and (who := trade(x.get("이름"))):
|
||||
_labor(fp, who, float(q), float(q), [], "QTY")
|
||||
hit = True
|
||||
elif kind_of(x) == "기계":
|
||||
text = f"{x.get('이름')} {x.get('규격')}"
|
||||
if ts := mtypes(text):
|
||||
one = {"갈래": sorted(ts), "규격": sorted(specnums(text)), "시간": float(q)}
|
||||
fp["장비"].append({**one, "밑시간": float(q), "율": [], "절": "0", "글": text})
|
||||
hit = True
|
||||
return _round(fp) if hit and fp["인력"] else None
|
||||
|
||||
|
||||
def kind_of(line: dict[str, Any]) -> str:
|
||||
"""줄 갈래 + 「노임」(단위 인 + 우리 직종 · 나 창 읽기 전에도)."""
|
||||
kind = str(line.get("갈래") or "")
|
||||
unit = _flat(line.get("단위"))
|
||||
if kind in ("기타", "자재", "노임") and unit in TRADE_UNITS and trade(line.get("이름")):
|
||||
return "노임"
|
||||
return kind
|
||||
@@ -0,0 +1,411 @@
|
||||
"""M02 일위대가 맞춤 — 순위 · 판정 · 블록 셈 · 뒤 셈 · 맞춤셈 캐시(PLAN 338-1 · 설계 2-4 · 2-6 ·
|
||||
2-10).
|
||||
|
||||
순위 = 로직마다 가장 닮은 변형의 지문 점수(인력 · 장비 · 자재 · 율 · 단위 · 조건 · 조항 · 이름 ·
|
||||
금액 없음)
|
||||
→ 지문 위 POOL ∪ 이름 · 근거 차례(`Match.b_order`) 위 다섯 → 점수 + BETA × 차례 덤 → 위 다섯 ·
|
||||
같음(이름 같음 · 씨앗) 1위 고정 — 지문 「딱 맞음」이 다른 로직이면 지문 · 섞인 줄은 이름 · 근거
|
||||
차례로
|
||||
몫 풀이(`Combine.solve`).
|
||||
판정(1위) = 맞음(옵션 수준 ≤ 1 · 엑셀 몫을 다 덮음 · 숫자 하나뿐 줄 아님) · 다른 점(옵션 수준 2 또는
|
||||
직종 묶음 같음 + 인용 조항 같음) · 후보 — 글은 숫자 사실만(엑셀 · 우리 품).
|
||||
뒤 셈 = 올리기 · 보기가 `ensure` → 일감 줄 하나 · 블록 차례로 셈 · 20 블록마다 캐시 덮음 ·
|
||||
캐시 = `tmp/m02_hopyo/맞춤_<HP16>_<판16>.json`. 서버 단독 · 원본 읽기만.
|
||||
손잡이(아래 상수)는 336 · K5 실험 값 그대로 — 한 파일로 다시 맞추지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Finger as finger
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import close, cond_score, cosine, jacc, nfkc
|
||||
|
||||
# ── 손잡이(한 곳) ──────────────────────────────────────────────────────
|
||||
W = {
|
||||
"인력": 3.0,
|
||||
"장비": 2.0,
|
||||
"자재": 1.0,
|
||||
"율": 0.3,
|
||||
"단위": 0.5,
|
||||
"조건": 0.6,
|
||||
"조항": 1.5,
|
||||
"이름": 0.6,
|
||||
}
|
||||
BETA = 1.0
|
||||
RB = (1, 0.6, 0.45, 0.35, 0.25)
|
||||
POOL = 40
|
||||
TOP_OPT = 5
|
||||
CAP = 400
|
||||
TOL = (0.005, "R", 0.03)
|
||||
SURE = (0.03, 0.3)
|
||||
MIXED_CUT = 0.35
|
||||
COMBO = (0.85, 0.75, 0.6)
|
||||
COVER = 0.6
|
||||
SHARES = (0.1, 0.2, 0.3, 0.5, 0.7, 0.8, 0.9)
|
||||
EVERY = 20 # 블록 몇 개마다 캐시 덮음
|
||||
COUNT = {"개소", "개", "본", "ea", "조", "매", "대", "기", "공"}
|
||||
HOUR = {"hr", "시간", "h"}
|
||||
NO_NUMBER = "숫자 판정 없음"
|
||||
_STATE: dict[str, Any] = {"판": None}
|
||||
|
||||
|
||||
# ── 우리 지문 정리(점수용) ─────────────────────────────────────────────
|
||||
def mat_words(name: Any) -> set[str]:
|
||||
t = nfkc(name).lower().replace(".", "").replace(" ", "")
|
||||
out = set(hopyo_match.words(name)) | ({"pvc"} if "pvc" in t else set())
|
||||
return {w for w in out if len(w) >= 2}
|
||||
|
||||
|
||||
def _prep(o: dict[str, Any], grams: dict[str, set]) -> dict[str, Any]:
|
||||
"""기계경비 로직은 조종원 뺌 · 모두 0 인 변형 뺌 · 섞기 전 변형 덧 · 고를 수 있는 장비."""
|
||||
vs = []
|
||||
for v in o["변형"]:
|
||||
L = {} if o["기계경비"] else {t: q for t, q in v["L"].items() if q > 0}
|
||||
X = [(set(a), set(b), q) for a, b, q in v["X"] if q > 0]
|
||||
M = {k: q for k, q in v["M"].items() if q > 0}
|
||||
if not (L or X or M) and (v["L"] or v["X"] or v["M"]):
|
||||
continue
|
||||
vs.append({"in": v["in"], "L": L, "X": X, "M": M})
|
||||
ri, rm = (v.get("mix") or {}).get("인력비율"), (v.get("mix") or {}).get("기계비율")
|
||||
if (ri and 0 < ri < 100) or (rm and 0 < rm < 100): # 섞기 전 품(인력 · 기계 100%)
|
||||
Lu = {t: q * 100 / ri for t, q in L.items()} if ri and 0 < ri < 100 else L
|
||||
Xu = [(a, b, q * 100 / rm) for a, b, q in X] if rm and 0 < rm < 100 else X
|
||||
vs.append({"in": {**v["in"], "_섞기전": "100%"}, "L": Lu, "X": Xu, "M": M})
|
||||
if not vs: # 엔진이 못 돈 로직 — 직종 이름만(품 모름)
|
||||
X0 = [(set(a), set(b), None) for a, b in o["장비"] if a]
|
||||
vs = [{"in": None, "L": {t: None for t in o["직종"]}, "X": X0, "M": {}}]
|
||||
opt = []
|
||||
if o["기계경비"] and (o["장비고름"] or o["장비"]):
|
||||
ts = set().union(*[set(a) for a, _ in o["장비고름"] + o["장비"]])
|
||||
sp = set().union(*[set(b) for _, b in o["장비고름"]]) if o["장비고름"] else set()
|
||||
opt = [(ts, sp)]
|
||||
mats = set().union(*[mat_words(n) for n in o["자재"]], set())
|
||||
for v in vs:
|
||||
for n in v["M"]:
|
||||
mats |= mat_words(n)
|
||||
return {
|
||||
"로직": o["로직"],
|
||||
"단위": o["단위"],
|
||||
"vs": vs,
|
||||
"opt": opt,
|
||||
"matw": mats,
|
||||
"pctv": {round(p, 3) for _, p in o["덧줄율"]},
|
||||
"nameg": grams.get(o["로직"], set()),
|
||||
"nc": {k: set(v) for k, v in o["이름조건"].items()},
|
||||
"oc": {k: set(v) for k, v in o["고름조건"].items()},
|
||||
}
|
||||
|
||||
|
||||
def prepared() -> list[dict[str, Any]] | None:
|
||||
"""점수용 우리 지문 — 우리 지문 준비 전 None."""
|
||||
table = ours.table()
|
||||
if table is None:
|
||||
return None
|
||||
if _STATE["판"] != id(table):
|
||||
grams = {x["로직"]: x["grams"] for x in hopyo_match.targets()["로직"]}
|
||||
_STATE.update(판=id(table), 표=[_prep(o, grams) for o in table])
|
||||
return _STATE["표"]
|
||||
|
||||
|
||||
# ── 엑셀 지문 정리 · 점수 ──────────────────────────────────────────────
|
||||
def ex_prep(efp: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
**efp,
|
||||
"E": {t: o["품"] for t, o in efp["인력"].items() if o["품"]},
|
||||
"Eb": {t: o["밑품"] for t, o in efp["인력"].items() if o["밑품"]},
|
||||
"matl": [w for w in (mat_words(m["이름"]) for m in efp["자재"]) if w],
|
||||
"pctv": {round(v, 3) for _, v in efp["율"]},
|
||||
"nameg": hopyo_match._grams(hopyo_match.tidy(efp["이름"])),
|
||||
"theirs": [list(x) for x in efp["출처"]],
|
||||
"조건s": {k: set(v) for k, v in efp["조건"].items()},
|
||||
"이름조건s": {k: set(v) for k, v in efp["이름조건"].items()},
|
||||
}
|
||||
|
||||
|
||||
def unit_fit(a: Any, b: Any) -> bool:
|
||||
"""단위 맞음 + 별칭(개소 = 개 = 본 · hr = 시간) — 품 크기 견줌에만."""
|
||||
if hopyo_match.fits(a, b):
|
||||
return True
|
||||
na = "".join(str(a or "").split()).replace("/", "").lower()
|
||||
for nb in ("".join(x.split()).replace("/", "").lower() for x in str(b or "").split("·")):
|
||||
if na == nb or (na in COUNT and nb in COUNT) or (na in HOUR and nb in HOUR):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def lab_sim(E: dict, Eb: dict, ov: dict, unit_ok: bool) -> tuple[float, float | None]:
|
||||
if not ov:
|
||||
return 0.0, None
|
||||
keys = set(E) | set(ov)
|
||||
w = lambda t: 0.5 if t == "보통인부" else 1.0 # noqa: E731
|
||||
st = sum(w(t) for t in set(E) & set(ov)) / sum(w(t) for t in keys)
|
||||
if any(q is None for q in ov.values()): # 품 모름 — 직종 겹침만
|
||||
return 0.6 * st, None
|
||||
shape = max(cosine(E, ov), cosine(Eb, ov))
|
||||
if not unit_ok:
|
||||
return 0.35 * st + 0.25 * shape, None
|
||||
mag = sum(max(close(E.get(t), ov.get(t)), close(Eb.get(t), ov.get(t))) for t in keys) / len(
|
||||
keys
|
||||
)
|
||||
return 0.35 * st + 0.25 * shape + 0.40 * mag, mag
|
||||
|
||||
|
||||
def mach_sim(EM: list, OX: list, opt: list, unit_ok: bool) -> float:
|
||||
if not OX and not opt:
|
||||
return 0.0
|
||||
got = []
|
||||
for m in EM:
|
||||
mt, ms = set(m["갈래"]), set(m["규격"])
|
||||
best = 0.0
|
||||
for ot, osp, oh in OX:
|
||||
j = jacc(mt, ot)
|
||||
if not j:
|
||||
continue
|
||||
sp = (1.0 if ms & osp else 0.0) if ms and osp else 0.5
|
||||
hc = max(close(m.get("시간"), oh), close(m.get("밑시간"), oh)) if unit_ok and oh else 0
|
||||
best = max(best, 0.6 * j + 0.2 * sp + 0.2 * hc)
|
||||
for ot, osp in opt:
|
||||
if mt and mt <= ot:
|
||||
sp = (1.0 if ms & osp else 0.0) if ms and osp else 0.5
|
||||
best = max(best, 0.6 + 0.2 * sp + 0.2 * (1.0 if unit_ok else 0.0))
|
||||
got.append(best)
|
||||
et = set().union(*[set(m["갈래"]) for m in EM])
|
||||
ot_all = set().union(*[t for t, _, _ in OX]) if OX else set()
|
||||
extra = len(ot_all - et) / max(1, len(ot_all | et)) if ot_all else 0.0
|
||||
return sum(got) / len(got) * (1 - 0.5 * extra)
|
||||
|
||||
|
||||
def score_pair(e: dict, o: dict, table: dict | None = None) -> tuple[float, dict, dict | None]:
|
||||
"""(점수, 채널, 고른 변형) — 로직마다 가장 닮은 변형 · `table` = `Basis.ours()`(한 번 읽어
|
||||
넘김)."""
|
||||
unit_ok = unit_fit(e["단위"], o["단위"])
|
||||
ch = {"단위": 1.0 if unit_ok else 0.0}
|
||||
g1, g2 = e["nameg"], o["nameg"]
|
||||
ch["이름"] = 2 * len(g1 & g2) / (len(g1) + len(g2)) if g1 and g2 else 0.0
|
||||
our = (table if table is not None else hopyo_basis.ours()).get(o["로직"])
|
||||
c = hopyo_match.clause(e["theirs"], our)[0] if (our and e["theirs"]) else 0.0
|
||||
ch["조항"] = c * (0.5 if e["옛판"] else 1.0)
|
||||
hit, _ = cond_score(e["조건s"], o["nc"], o["oc"])
|
||||
_, clash = cond_score(e["이름조건s"], o["nc"], {})
|
||||
ch["조건"] = hit - clash
|
||||
if e["matl"]:
|
||||
near = lambda ws: any(a in b or b in a for a in ws for b in o["matw"]) # noqa: E731
|
||||
ch["자재"] = sum(1 for ws in e["matl"] if near(ws)) / len(e["matl"])
|
||||
ch["율"] = 1.0 if (e["pctv"] and o["pctv"] and e["pctv"] & o["pctv"]) else 0.0
|
||||
base = sum(W.get(k, 0) * v for k, v in ch.items())
|
||||
best, bestv, bestch = None, None, {}
|
||||
for v in o["vs"]:
|
||||
s, vch = base, {}
|
||||
if e["E"]:
|
||||
ls, mag = lab_sim(e["E"], e["Eb"], v["L"], unit_ok)
|
||||
vch["인력"], vch["품"] = ls, mag
|
||||
s += W["인력"] * ls
|
||||
elif v["L"]:
|
||||
s -= 0.6
|
||||
if e["장비"]:
|
||||
vch["장비"] = mach_sim(e["장비"], v["X"], o["opt"], unit_ok)
|
||||
s += W["장비"] * vch["장비"]
|
||||
elif v["X"] or o["opt"]:
|
||||
s -= 0.4
|
||||
if best is None or s > best:
|
||||
best, bestv, bestch = s, v, vch
|
||||
return best, {**ch, **bestch}, bestv
|
||||
|
||||
|
||||
def rank(e: dict, n: int = POOL) -> list[tuple]:
|
||||
"""[(점수, 로직, 채널, 변형)] — 위 n."""
|
||||
table = hopyo_basis.ours()
|
||||
out = [(*_swap(score_pair(e, o, table), o["로직"]),) for o in prepared() or []]
|
||||
out.sort(key=lambda x: -x[0])
|
||||
return out[:n]
|
||||
|
||||
|
||||
def _swap(got: tuple, code: str) -> tuple:
|
||||
return got[0], code, got[1], got[2]
|
||||
|
||||
|
||||
def sure(e: dict, pool: list[tuple]) -> bool:
|
||||
"""딱 맞음 — 1위 직종 · 품(3% 안)이 다 맞음(보통인부 하나뿐 줄 아님) 또는 장비 갈래 · 시간 맞음
|
||||
·
|
||||
2위와 점수 차 SURE[1] 넘음."""
|
||||
if not pool:
|
||||
return False
|
||||
s1, _, ch, _ = pool[0]
|
||||
gap = s1 - pool[1][0] if len(pool) > 1 else 9
|
||||
if e["E"]:
|
||||
rich = len(e["E"]) >= 2 or set(e["E"]) != {"보통인부"} or bool(e["장비"])
|
||||
ok = rich and (ch.get("품") or 0) >= 1 - SURE[0] and ch.get("인력", 0) >= 1 - SURE[0]
|
||||
else:
|
||||
ok = bool(e["장비"]) and ch.get("장비", 0) >= 0.95
|
||||
return ok and gap > SURE[1]
|
||||
|
||||
|
||||
def fuse(e: dict, pool: list[tuple], border: list[str]) -> list[str]:
|
||||
"""지문 점수 + BETA × 이름 · 근거 차례 덤 → 위 다섯."""
|
||||
bonus = {k: RB[i] for i, k in enumerate(border[:5])}
|
||||
cand = {k: s for s, k, _, _ in pool}
|
||||
by = {o["로직"]: o for o in prepared() or []}
|
||||
table = hopyo_basis.ours()
|
||||
for k in bonus:
|
||||
if k not in cand and k in by:
|
||||
cand[k] = score_pair(e, by[k], table)[0]
|
||||
return sorted(cand, key=lambda k: -(cand[k] + BETA * bonus.get(k, 0)))[:TOP_OPT]
|
||||
|
||||
|
||||
def single(e: dict) -> bool:
|
||||
"""숫자 하나뿐 줄 — 직종 하나 또는 장비 시간 하나(기계 1 시간 줄 뺌) · 같은 숫자 로직이 많아
|
||||
우연 맞음."""
|
||||
E = [k for k, x in e["인력"].items() if x.get("품")]
|
||||
X = [m for m in e["장비"] if m.get("시간")]
|
||||
return len(E) + len(X) == 1 and not (X and abs(X[0]["시간"] - 1.0) < 1e-9)
|
||||
|
||||
|
||||
def has_numbers(efp: dict) -> bool:
|
||||
return bool(efp["인력"] or any(m.get("시간") for m in efp["장비"]) or efp["자재"])
|
||||
|
||||
|
||||
# ── 블록 셈 ────────────────────────────────────────────────────────────
|
||||
def compute(block: dict | None, line: dict, got: dict | None = None) -> dict[str, Any]:
|
||||
"""블록 + 줄 하나 → 맞춤셈 한 칸(설계 3-2 꼴)."""
|
||||
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # 판정이 이 파일을 부름
|
||||
|
||||
return verdict.block(block, line, got or hopyo_match.targets())
|
||||
|
||||
|
||||
# ── 뒤 셈 · 캐시 ───────────────────────────────────────────────────────
|
||||
_JOBS: queue.Queue = queue.Queue()
|
||||
_PENDING: set[str] = set()
|
||||
_WORKER: dict[str, Any] = {"스레드": None}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _hp_path(folder: Path, key: str) -> Path:
|
||||
return hopyo_store._path(folder, key)
|
||||
|
||||
|
||||
def cache_path(folder: Path, key: str) -> Path:
|
||||
hp = hashlib.sha256(_hp_path(folder, key).read_bytes()).hexdigest()[:16]
|
||||
return ours.folder() / f"맞춤_{hp}_{ours.version()}.json"
|
||||
|
||||
|
||||
def units(doc: dict[str, Any]) -> list[tuple[str, dict | None, dict]]:
|
||||
"""셈 단위 [(열쇠, 블록, 줄)] — 블록 + 줄 열쇠 한 번 · 블록 없는 기계 줄 · 직접 줄 호표."""
|
||||
blocks = {int(b["번"]): b for b in doc.get("단산") or [] if b.get("번") is not None}
|
||||
out, seen = [], set()
|
||||
for h in doc.get("호표") or []:
|
||||
for line in h.get("줄") or []:
|
||||
ref = line.get("참조") or {}
|
||||
kind = finger.kind_of(line)
|
||||
if ref.get("종류") == "단산":
|
||||
key = unit_key(line)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append((key, blocks.get(int(ref.get("번") or 0)), line))
|
||||
elif kind == "기계":
|
||||
key = unit_key(line)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append((key, None, line))
|
||||
if (fp := finger.direct(h)) is not None and f"호표:{h['번']}" not in seen:
|
||||
seen.add(f"호표:{h['번']}")
|
||||
out.append(
|
||||
(f"호표:{h['번']}", {"_지문": fp}, {k: h.get(k) for k in ("이름", "규격", "단위")})
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def unit_key(line: dict[str, Any]) -> str:
|
||||
ref = line.get("참조") or {}
|
||||
if ref.get("종류") == "단산":
|
||||
return f"단산:{ref.get('번')}|{hopyo_match.line_key(line)}"
|
||||
return f"줄:{hopyo_match.line_key(line)}"
|
||||
|
||||
|
||||
def _read(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
doc = json.loads(path.read_bytes())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return doc if isinstance(doc, dict) else None
|
||||
|
||||
|
||||
def result(folder: Path, key: str) -> dict[str, Any] | None:
|
||||
"""맞춤셈 캐시(이 HP 파일 · 지금 판) — 없으면 None."""
|
||||
try:
|
||||
return _read(cache_path(folder, key))
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def status(folder: Path, key: str) -> dict[str, Any]:
|
||||
"""{끝, 한, 모두, 까닭?}."""
|
||||
got = result(folder, key)
|
||||
if got and got.get("끝"):
|
||||
return {"끝": True, "한": got["셈"][0], "모두": got["셈"][1]}
|
||||
why = None if ours.ready() else "우리 로직 준비 중"
|
||||
done, total = got["셈"] if got else (0, None)
|
||||
return {"끝": False, "한": done, "모두": total, **({"까닭": why} if why else {})}
|
||||
|
||||
|
||||
def run_job(folder: Path, key: str) -> dict[str, Any]:
|
||||
"""HP 파일 하나를 끝까지 셈(뒤 스레드 · 시험이 바로 부름) → 캐시."""
|
||||
if ours.table() is None:
|
||||
thread = ours.warm(build_missing=True)
|
||||
if thread is not None:
|
||||
thread.join()
|
||||
path = cache_path(folder, key)
|
||||
doc = hopyo_store.read(folder, key)
|
||||
todo = units(doc)
|
||||
got = _read(path) or {}
|
||||
done = got.get("블록") if got.get("판") == ours.version() else None
|
||||
out = {"판": ours.version(), "지문판": ours.FP_VERSION, "셈": [0, len(todo)], "끝": False}
|
||||
out["블록"] = dict(done or {})
|
||||
targets = hopyo_match.targets()
|
||||
for at, (unit, block, line) in enumerate(todo, 1):
|
||||
if unit not in out["블록"]:
|
||||
out["블록"][unit] = compute(block, line, targets)
|
||||
out["셈"] = [at, len(todo)]
|
||||
if at % EVERY == 0:
|
||||
atomic_write_json(path, out)
|
||||
out["끝"] = True
|
||||
atomic_write_json(path, out)
|
||||
return out
|
||||
|
||||
|
||||
def _work() -> None:
|
||||
while True:
|
||||
folder, key = _JOBS.get()
|
||||
try:
|
||||
run_job(folder, key)
|
||||
except Exception: # noqa: BLE001 — 한 파일이 깨져도 일감 줄은 계속(보기에서 「셈 중」 그대로)
|
||||
pass
|
||||
finally:
|
||||
with _LOCK:
|
||||
_PENDING.discard(f"{folder}|{key}")
|
||||
|
||||
|
||||
def ensure(folder: Path, key: str) -> dict[str, Any]:
|
||||
"""캐시 있으면 끝 · 없으면 일감 줄에 넣음(같은 파일은 한 번) → {끝, 한, 모두, 까닭?}."""
|
||||
now = status(folder, key)
|
||||
if now["끝"]:
|
||||
return now
|
||||
with _LOCK:
|
||||
tag = f"{folder}|{key}"
|
||||
if tag not in _PENDING:
|
||||
_PENDING.add(tag)
|
||||
_JOBS.put((Path(folder), key))
|
||||
if _WORKER["스레드"] is None or not _WORKER["스레드"].is_alive():
|
||||
_WORKER["스레드"] = threading.Thread(target=_work, name="m02-hopyo-fit", daemon=True)
|
||||
_WORKER["스레드"].start()
|
||||
return now
|
||||
@@ -1,11 +1,12 @@
|
||||
"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트 →
|
||||
"""M02 일위대가 실무자료 엑셀꼴 격자(PLAN 329-1) — 올린 엑셀 「일위대가」 · 「단가산출」 시트
|
||||
(STmate = 「일위대가표」 · 「단가산출근거」 · 338) →
|
||||
A00 산출근거 스프레드시트 Workbook(`A00_Common/spreadsheet/spreadsheet_types.ts` 꼴 ·
|
||||
읽기 전용 보기).
|
||||
|
||||
xls = xlrd `formatting_info` — 값 · 병합 · 열 폭(÷256 글자) ·
|
||||
행 높이(÷20 pt · 시트 기본과 다른 것만) · 서식 표(글꼴 · 굵게 · 맞춤 · 줄바꿈 · 테두리 · 채움 ·
|
||||
숫자 형식) · 틀고정(파일 값 · 없으면 머리 4행).
|
||||
xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지.
|
||||
xlsx = 값만 · 넓은 시트는 머리 글(앞 4 줄) 마지막 열까지 · STmate 숨은 칸 글(「_x0005_`BDCOD…」) 뺌.
|
||||
결과 `{책, 호표: [{번, 이름, 행}], 단산: [{번, 이름, 행}]}` — 행 = 엑셀 1부터 [시작, 끝].
|
||||
"""
|
||||
|
||||
@@ -18,8 +19,9 @@ from openpyxl.utils import get_column_letter
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
|
||||
from M02_MasterTemplete import M02_Hopyo_Stmate as hopyo_stmate
|
||||
|
||||
SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET)
|
||||
SHEETS = (hopyo_excel.SHEET, hopyo_basis.SHEET, hopyo_stmate.SHEET, hopyo_stmate.CALC)
|
||||
HEAD_ROWS = 4
|
||||
_HALIGN = {1: "left", 2: "center", 3: "right", 6: "centerContinuous", 7: "distributed"}
|
||||
_VALIGN = {0: "top", 1: "center"}
|
||||
@@ -192,7 +194,7 @@ def _xlsx(name: str, data: bytes) -> dict[str, Any]:
|
||||
}
|
||||
for r, row in enumerate(rows)
|
||||
for c, v in enumerate(row[:width])
|
||||
if v not in (None, "")
|
||||
if v not in (None, "") and not str(v).startswith("_x000") # STmate 숨은 칸 글
|
||||
}
|
||||
sheets.append({"id": f"s{at}", "이름": title, "칸": cells, "틀고정": {"행": HEAD_ROWS}})
|
||||
return {"서식": [{}], "시트": sheets}
|
||||
|
||||
@@ -319,7 +319,7 @@ def candidates(
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
out = []
|
||||
for score, one in scored:
|
||||
label = (one["이름"], one.get("규격"), one["단위"])
|
||||
label = one.get("로직") or (one["이름"], one.get("규격"), one["단위"]) # 로직은 접지 않음
|
||||
if label in seen:
|
||||
continue
|
||||
seen.add(label)
|
||||
@@ -329,6 +329,86 @@ def candidates(
|
||||
return out
|
||||
|
||||
|
||||
# ── 인용 조항 · 이름 · 근거 차례(338 · 설계 2-4 B 차례) ─────────────────────
|
||||
_OLD_TEXT = re.compile(r"(20(1\d|2[0-4])|1\d년|2[0-4]년|임도품셈|임도표준품셈|사방품셈)")
|
||||
_TYPO_CLAUSE = re.compile(r"^(\d+)-(\d)(\d)(\d)$") # 13-114 → 13-11-4
|
||||
_GENERIC = frozenset(
|
||||
"인력 기계 장비 소계 합계 노무비 재료비 경비 적용 기준 별도계상 별도 인부 보통인부 특별인부 "
|
||||
"설치 해체 운반 작업 시공 사용 포함 공구손료 경장비 손료 기계경비 재료 인건비 품셈 표준품셈 "
|
||||
"건설표준품셈 이하 이상".split()
|
||||
)
|
||||
|
||||
|
||||
def fix_typo(theirs: list) -> list[list[str]]:
|
||||
"""엑셀 인용 조항 오타 — 「13-114」 → 「13-11-4」."""
|
||||
out = []
|
||||
for book, n in theirs or []:
|
||||
m = _TYPO_CLAUSE.match(str(n))
|
||||
out.append([book, f"{m.group(1)}-{m.group(2)}{m.group(3)}-{m.group(4)}" if m else n])
|
||||
return out
|
||||
|
||||
|
||||
def clause(theirs: list, our: dict[str, Any]) -> tuple[float, list[str], str]:
|
||||
"""(점수, 맞은 우리 조항 글, 꼬리표) — 같음 1 · 윗마디 0.9(엑셀 6-2 ↔ 우리 6-2-2) · 앞 마디 0.5
|
||||
·
|
||||
나 창 `Basis.clause` 가 있으면 그것."""
|
||||
if hasattr(hopyo_basis, "clause"):
|
||||
return hopyo_basis.clause(theirs, our)
|
||||
nums = [n for book, n in theirs if book == our["책"]]
|
||||
same = sorted(n for n in nums if n in our["조항"])
|
||||
if same:
|
||||
return 1.0, [f"{our['구분']} {n}" for n in same], "조항 같음"
|
||||
kids = sorted(c for c in our["조항"] for n in nums if c.startswith(n + "-"))
|
||||
if kids:
|
||||
return 0.9, [f"{our['구분']} {n}" for n in kids], "조항 같음"
|
||||
heads = {n.rsplit("-", 1)[0] for n in nums}
|
||||
near_ = sorted(n for n in our["조항"] if n.rsplit("-", 1)[0] in heads)
|
||||
if near_:
|
||||
return 0.5, [f"{our['구분']} {n}" for n in near_], "앞 마디 같음"
|
||||
return 0.0, [], ""
|
||||
|
||||
|
||||
def _strong_basis(line: dict[str, Any], cand: dict[str, Any]) -> bool:
|
||||
if cand["근거"]["직종"]:
|
||||
return True
|
||||
mine = {w for w in hopyo_basis._loose_words(f"{line.get('이름')}") if w not in _GENERIC}
|
||||
theirs = {w for w in hopyo_basis._loose_words(cand["이름"]) if w not in _GENERIC}
|
||||
return any(a in b or b in a for a in mine for b in theirs)
|
||||
|
||||
|
||||
def b_order(line: dict[str, Any], got: dict[str, Any] | None = None, text: Any = ()) -> dict:
|
||||
"""이름 · 근거 차례 `{상태: 같음 | 후보 | 없음, top: [로직 5], 합침: [로직 15]}` — 이름 후보 +
|
||||
근거 후보 합침(접힘 없음) · 「같음」도 근거 검사(인용 조항이 다른 로직을 가리키면 후보) ·
|
||||
옛 판 글이면 같음 그대로 · 씨앗은 이름 같음 자리."""
|
||||
got = got or targets()
|
||||
basis_ = dict(line.get("근거") or {})
|
||||
if basis_.get("출처"):
|
||||
line = {**line, "근거": {**basis_, "출처": fix_typo(basis_["출처"])}}
|
||||
basis = hopyo_basis.candidates(line, got)
|
||||
named = candidates(line, "로직", got, None)
|
||||
merged = [c.get("로직") for c in hopyo_basis.merge(basis, named) if c.get("로직")]
|
||||
found = _exact(line, "로직", got)
|
||||
if len(found) == 1:
|
||||
tgt = found[0]["로직"]
|
||||
theirs = (line.get("근거") or {}).get("출처") or []
|
||||
our = hopyo_basis.ours().get(tgt)
|
||||
if _OLD_TEXT.search(" ".join(str(t) for t in list(text)[:5])) or not theirs:
|
||||
return {"상태": "같음", "top": [tgt], "합침": merged[:15]}
|
||||
if our and clause(theirs, our)[0] >= 0.9:
|
||||
return {"상태": "같음", "top": [tgt], "합침": merged[:15]}
|
||||
strong = [c for c in basis if c["근거"]["까닭"].startswith("조항 같음")]
|
||||
strong = [c for c in strong if _strong_basis(line, c)]
|
||||
if not strong:
|
||||
return {"상태": "같음", "top": [tgt], "합침": merged[:15]}
|
||||
top = list(dict.fromkeys([strong[0]["로직"], tgt] + [c["로직"] for c in strong[1:]]))
|
||||
return {"상태": "후보", "top": top[:5], "합침": merged[:15]}
|
||||
top = [x["로직"] for x in found]
|
||||
rest = [c for c in hopyo_basis.merge(basis, named) if c.get("로직") not in top]
|
||||
first = next((c.get("로직") for c in named if c.get("로직") not in top), None)
|
||||
top += [c["로직"] for c in hopyo_basis.pick(rest, first if basis else None, 5 - len(top))]
|
||||
return {"상태": "후보" if top else "없음", "top": top[:5], "합침": merged[:15]}
|
||||
|
||||
|
||||
# ── 층 기억 ───────────────────────────────────────────────────────────
|
||||
def chain(folder: Path) -> list[Path]:
|
||||
"""기억을 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터)."""
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
"""M02 일위대가 맞춤 — 옵션 거꾸로 찾기(PLAN 338-1 · 설계 2-5): 고른 우리 로직의 입력 칸마다
|
||||
후보(옵션 공간)를 엔진으로 돌려 엑셀 품(직종 품 · 장비 시간 · 자재 수량)과 같은 입력을 찾음.
|
||||
|
||||
공간 = 고르기 모두 · 기계(EQ) = 엑셀 규격 수와 맞는 기종(없으면 모두) · 범위 = 두 끝 + 범위 안 엑셀
|
||||
수 ·
|
||||
표 찾기 칸 = 그 표 줄 값 · 그 밖 수 = 자동값 + 같은 이름 계수 + 단위 붙은 엑셀 수(% 칸 = 0 덧 ·
|
||||
「품」 칸 = 엑셀 품 × 결과단위) · 뺌 = 지역 · 보정작업 · 조종원직종.
|
||||
조합 = 곱 ≤ CAP 모두(안 닿는 칸 · 같은 품 값 묶음 셈) · 넘으면 엑셀 조건 낱말로 고르기 좁힘 ·
|
||||
그래도 넘으면 기준점 둘에서 하나씩 + 고르기 둘씩.
|
||||
수준 = 0(0.5%) · 1(R = 0.5% + 엑셀 값 자릿수 반올림 폭) · 2(3%) · 9(없음) — 「딱 맞음」(핵심 + 엑셀
|
||||
몫을
|
||||
다 덮음)만 셈 · 같은 수준이면 거리 작은 것. 규칙 · 문턱은 336 K5(`k5_lib` · `stage_b`) 그대로.
|
||||
엔진 = `M02_Hopyo_Ours.run`(읽기만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit # 손잡이(CAP · TOL) — 부를 때만 씀
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import nfkc, specnums
|
||||
|
||||
FIXED = ("지역", "보정작업", "조종원직종")
|
||||
_NUM = re.compile(r"(?<![\d,.])(\d+(?:\.\d+)?)(?![\d,])")
|
||||
UNITS = {
|
||||
"%": ("%",),
|
||||
"㎝": ("㎝", "cm"),
|
||||
"cm": ("㎝", "cm"),
|
||||
"㎞": ("㎞", "km"),
|
||||
"km": ("㎞", "km"),
|
||||
"㎜": ("㎜", "mm"),
|
||||
"mm": ("㎜", "mm"),
|
||||
"㎥": ("㎥", "m3"),
|
||||
"㎡": ("㎡", "m2"),
|
||||
"m": ("m",),
|
||||
"회": ("회",),
|
||||
"개월": ("개월",),
|
||||
"층": ("층",),
|
||||
"t": ("t", "ton", "톤"),
|
||||
"ton": ("t", "ton", "톤"),
|
||||
"톤": ("t", "ton", "톤"),
|
||||
"분": ("분",),
|
||||
"초": ("초", "sec"),
|
||||
"kg": ("kg", "㎏"),
|
||||
"㎏": ("kg", "㎏"),
|
||||
}
|
||||
UNITW = {"mm", "cm", "kg", "ton", "sec", "kgf", "mpa"}
|
||||
|
||||
|
||||
# ── 엑셀 쪽 수 ─────────────────────────────────────────────────────────
|
||||
def ex_of(efp: dict[str, Any], text: list[str] | None = None) -> dict[str, Any]:
|
||||
"""엑셀 지문 → 옵션 공간 재료(글 · 글머리 · 계수 · 규격 수 · 조건 · 품 값)."""
|
||||
texts = [efp["이름"], efp.get("규격") or ""] + [str(t) for t in text or []]
|
||||
sp: set[float] = set()
|
||||
for m in efp["장비"]:
|
||||
sp |= set(m.get("규격") or [])
|
||||
sp |= specnums(f"{efp['이름']} {efp.get('규격') or ''}")
|
||||
return {
|
||||
"글": texts,
|
||||
"글머리": texts[:8],
|
||||
"계수": efp.get("계수") or {},
|
||||
"규격수": sp,
|
||||
"조건": efp.get("조건") or {},
|
||||
"품값": {Decimal(str(x["품"])) for x in efp["인력"].values() if x.get("품")},
|
||||
}
|
||||
|
||||
|
||||
def excel_numbers(texts: list[str], spec: dict[str, Any] | None = None) -> set[Decimal]:
|
||||
"""글의 수 — spec 이 있으면 그 칸 단위가 뒤에 붙거나 칸 이름이 앞에 붙은 수만(쉼표 단가 뺌)."""
|
||||
out: set[Decimal] = set()
|
||||
flat = [nfkc(t) for t in texts]
|
||||
if spec is None:
|
||||
pats = [_NUM]
|
||||
else:
|
||||
u = nfkc(spec.get("단위")).strip().lower()
|
||||
vs = UNITS.get(u, (u,) if u and len(u) <= 3 and "/" not in u else ())
|
||||
vs = () if u == "%" else vs # 「노무비의 2%」 같은 율이 감률 칸 후보로 드는 것을 막음
|
||||
num = r"(\d+(?:\.\d+)?)"
|
||||
pats = []
|
||||
if vs:
|
||||
alt = "|".join(re.escape(x) for x in vs)
|
||||
pats.append(
|
||||
re.compile(r"(?<![\d,.])" + num + r"\s*(?:" + alt + r")(?![A-Za-z가-힣])", re.I)
|
||||
)
|
||||
pats.append(
|
||||
re.compile(re.escape(nfkc(spec["이름"])) + r"\s*[:=(]?\s*" + num + r"(?![\d,])")
|
||||
)
|
||||
for t in flat:
|
||||
for pat in pats:
|
||||
for n in pat.findall(t):
|
||||
v = Decimal(n)
|
||||
if 0 < v <= 2000:
|
||||
out.add(v.normalize() if v == v.to_integral() else v)
|
||||
return out
|
||||
|
||||
|
||||
def eq_numbers(code: str) -> set[float]:
|
||||
"""기계 규격 수 — 단위 붙은 수 + 맨 수(「41 [80∼95]」 → 41 · 80 · 95)."""
|
||||
one = ours.whole().numbers.get("EQ", {}).get(str(code)) or {}
|
||||
bare = {float(n) for n in re.findall(r"\d+(?:\.\d+)?", nfkc(one.get("규격")))}
|
||||
return ours.eq_of(code)[1] | bare
|
||||
|
||||
|
||||
def _uses(row: dict) -> tuple[dict, set]:
|
||||
try:
|
||||
return m01.mau._uses(row)
|
||||
except Exception: # noqa: BLE001 — 식을 못 읽는 로직은 찾기 칸 없음
|
||||
return {}, set()
|
||||
|
||||
|
||||
def space(code: str, ex: dict[str, Any] | None = None) -> list[tuple[str, list, str]]:
|
||||
"""[(입력 이름, 후보, 갈래)] — 갈래 = 기계 · 고르기 · 범위 · 표 · 수."""
|
||||
whole = ours.whole()
|
||||
row = whole.logic(code)
|
||||
auto = m01.mau.values(whole, row)
|
||||
finds, _ = _uses(row)
|
||||
ex = ex or {}
|
||||
texts = ex.get("글") or []
|
||||
coef = {k.lower(): Decimal(str(v)) for k, v in (ex.get("계수") or {}).items()}
|
||||
spn = ex.get("규격수") or set()
|
||||
times = Decimal(str(ours.times_of(row)))
|
||||
out = []
|
||||
for s in row.get("입력") or []:
|
||||
name, opts = s["이름"], s.get("고르기") or []
|
||||
if name in FIXED:
|
||||
continue
|
||||
if opts and all(isinstance(v, str) and ours.EQ_CODE.match(v) for v in opts):
|
||||
hits = {v: len(eq_numbers(v) & spn) for v in opts} if spn else {}
|
||||
most = max(hits.values(), default=0)
|
||||
keep = [
|
||||
v for v in opts if most and hits[v] == most
|
||||
] # 엑셀 규격 수가 가장 많이 겹치는 기종
|
||||
out.append((name, keep or list(opts), "기계"))
|
||||
elif opts:
|
||||
out.append((name, list(opts), "고르기"))
|
||||
elif isinstance(s.get("범위"), list) and len(s["범위"]) == 2:
|
||||
lo, hi = (Decimal(str(x)) for x in s["범위"])
|
||||
vals = {lo, hi} | {v for v in excel_numbers(texts, s) if lo <= v <= hi}
|
||||
out.append((name, sorted(vals), "범위"))
|
||||
elif name in finds:
|
||||
rows = set()
|
||||
for ref, cond in finds[name]:
|
||||
rows |= set(m01.mau._fits(whole, ref, cond))
|
||||
out.append((name, sorted(rows) or [auto.get(name)], "표"))
|
||||
else:
|
||||
base = auto.get(name)
|
||||
vals = {base} if base is not None else set()
|
||||
if name.lower() in coef:
|
||||
vals.add(coef[name.lower()])
|
||||
vals |= excel_numbers(texts, s) if texts else set()
|
||||
if str(s.get("단위") or "").strip() == "%":
|
||||
vals.add(Decimal(0))
|
||||
if name == "품":
|
||||
vals |= {v * times for v in ex.get("품값", set())}
|
||||
vals.discard(None)
|
||||
out.append((name, sorted(vals, key=lambda v: (v != base, v)) or [base], "수"))
|
||||
return out
|
||||
|
||||
|
||||
def _product(sp: list) -> list[dict]:
|
||||
names = [n for n, _, _ in sp]
|
||||
return [dict(zip(names, vals)) for vals in itertools.product(*[o for _, o, _ in sp])]
|
||||
|
||||
|
||||
def narrow(sp: list, ex: dict[str, Any] | None) -> list:
|
||||
"""고르기 후보를 엑셀 조건 낱말로 좁힘 — 낱말이 든 후보가 있으면 그것만."""
|
||||
words = {w for ws in ((ex or {}).get("조건") or {}).values() for w in ws}
|
||||
flat = re.sub(r"\s+", "", nfkc(" ".join((ex or {}).get("글머리") or [])))
|
||||
out = []
|
||||
for name, opts, kind in sp:
|
||||
if kind == "고르기" and len(opts) > 1:
|
||||
hit = [
|
||||
o
|
||||
for o in opts
|
||||
if any(w in str(o) for w in words) or (len(str(o)) >= 2 and str(o) in flat)
|
||||
]
|
||||
if hit:
|
||||
out.append((name, hit, kind))
|
||||
continue
|
||||
out.append((name, opts, kind))
|
||||
return out
|
||||
|
||||
|
||||
def modes() -> tuple[tuple[str, float, bool], ...]:
|
||||
"""허용 셋 — 0.5% · R(0.5% + 엑셀 값 자릿수 반올림 폭) · 3%."""
|
||||
return (("0.5%", fit.TOL[0], False), ("R", fit.TOL[0], True), ("3%", fit.TOL[2], False))
|
||||
|
||||
|
||||
def combos(
|
||||
sp: list, ex: dict[str, Any] | None = None, cap: int | None = None
|
||||
) -> tuple[list[dict], str]:
|
||||
"""(조합, 어떻게 — 모두 · 좁힘 · 하나씩)."""
|
||||
cap = cap or fit.CAP
|
||||
if (math.prod(len(o) for _, o, _ in sp) if sp else 1) <= cap:
|
||||
return _product(sp), "모두"
|
||||
sp2 = narrow(sp, ex)
|
||||
if math.prod(len(o) for _, o, _ in sp2) <= cap:
|
||||
return _product(sp2), "좁힘"
|
||||
base = {n: o[0] for n, o, _ in sp2}
|
||||
out = [dict(base)] + [{**base, n: v} for n, o, _ in sp2 for v in o[1:]]
|
||||
small = [(n, o) for n, o, k in sp2 if k != "수"]
|
||||
for (n1, o1), (n2, o2) in itertools.combinations(small, 2):
|
||||
if len(o1) * len(o2) <= 400:
|
||||
out += [{**base, n1: a, n2: b} for a in o1 for b in o2]
|
||||
seen, uniq = set(), []
|
||||
for c in out:
|
||||
k = json.dumps(c, sort_keys=True, default=str, ensure_ascii=False)
|
||||
if k not in seen:
|
||||
seen.add(k)
|
||||
uniq.append(c)
|
||||
return uniq[:cap], "하나씩"
|
||||
|
||||
|
||||
def sig(v: dict | None, nd: int = 4) -> str | None:
|
||||
"""품 꼴 서명 — 직종 · 장비(갈래 · 시간) · 자재 · 딸린 로직(유효 nd 자리)."""
|
||||
if v is None:
|
||||
return None
|
||||
r = lambda x: float(f"{x:.{nd}g}") # noqa: E731
|
||||
return json.dumps(
|
||||
[
|
||||
sorted((k, r(q)) for k, q in v["L"].items() if q),
|
||||
sorted((tuple(a), tuple(b), r(q)) for a, b, q in v["X"] if q),
|
||||
sorted((k, r(q)) for k, q in v["M"].items() if q),
|
||||
sorted((k, r(q)) for k, q in v["S"].items() if q),
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def plan(code: str, ex: dict[str, Any] | None = None) -> tuple[list[dict], str, list, list, dict]:
|
||||
"""(조합, 어떻게, 움직이는 칸, 안 닿는 칸, 값 묶음)."""
|
||||
sp = space(code, ex)
|
||||
if (math.prod(len(o) for _, o, _ in sp) if sp else 1) <= fit.CAP:
|
||||
cb = _product(sp)
|
||||
sg = [sig(ours.run(code, c)) for c in cb]
|
||||
active, inert, cls = [], [], {}
|
||||
for n, o, k in sp:
|
||||
if len(o) < 2:
|
||||
continue
|
||||
ctx: dict[str, dict] = {}
|
||||
for c, s_ in zip(cb, sg, strict=True):
|
||||
rest = {x: str(y) for x, y in c.items() if x != n}
|
||||
ctx.setdefault(json.dumps(rest, sort_keys=True, ensure_ascii=False), {})[
|
||||
str(c[n])
|
||||
] = s_
|
||||
keys = sorted(ctx)
|
||||
prof = {str(v): tuple(ctx[g].get(str(v)) for g in keys) for v in o}
|
||||
if len(set(prof.values())) == 1:
|
||||
inert.append(n)
|
||||
continue
|
||||
active.append(n)
|
||||
if k != "고르기":
|
||||
ids: dict = {}
|
||||
cls[n] = {v: ids.setdefault(pf, len(ids)) for v, pf in prof.items()}
|
||||
return cb, "모두", active, inert, cls
|
||||
base = {n: o[0] for n, o, _ in sp}
|
||||
alt = {n: o[-1] for n, o, _ in sp}
|
||||
act, inert = [], []
|
||||
for n, o, k in sp:
|
||||
if len(o) == 1:
|
||||
continue
|
||||
s0 = {sig(ours.run(code, {**base, n: v})) for v in o}
|
||||
s1 = {sig(ours.run(code, {**alt, n: v})) for v in o} if len(s0) == 1 else s0
|
||||
if len(s0) == 1 and len(s1) == 1:
|
||||
inert.append(n)
|
||||
continue
|
||||
if k != "고르기": # 같은 표 줄에 드는 수는 한 값으로 — 두 기준점 품이 같으면 같은 값
|
||||
keep, seen = [], set()
|
||||
for v in o:
|
||||
pair = (sig(ours.run(code, {**base, n: v})), sig(ours.run(code, {**alt, n: v})))
|
||||
if pair not in seen:
|
||||
seen.add(pair)
|
||||
keep.append(v)
|
||||
o = keep
|
||||
act.append((n, o, k))
|
||||
cb, how = combos(act, ex)
|
||||
return [{**base, **c} for c in cb], how, [n for n, _, _ in act], inert, {}
|
||||
|
||||
|
||||
def vary(hits: list[dict], act: list[str], cls: dict) -> list[str]:
|
||||
"""맞은 조합들 사이에서 값이 갈리는 칸 — 수 칸은 같은 값 묶음이면 한 값."""
|
||||
return [
|
||||
n for n in act if len({cls.get(n, {}).get(str(g.get(n)), str(g.get(n))) for g in hits}) > 1
|
||||
]
|
||||
|
||||
|
||||
# ── 맞대기 ──────────────────────────────────────────────────────────────
|
||||
def near(a: Any, b: Any, t: float, r: float = 0.0) -> bool:
|
||||
"""a(엑셀) ≈ b(우리) — 비 t 안 · r = 엑셀 값 자릿수 반올림 폭."""
|
||||
return bool(a and b and a > 0 and b > 0 and abs(a - b) <= max(t * max(a, b), r))
|
||||
|
||||
|
||||
def ru(x: float) -> float:
|
||||
"""엑셀 값 반올림 폭 — 소수 둘째~넷째 자리로 적힌 값만 · 값의 4% 안."""
|
||||
s = repr(float(x))
|
||||
if "e" in s or "." not in s:
|
||||
return 0.0
|
||||
d = len(s.split(".")[1].rstrip("0"))
|
||||
return min(0.5 * 10 ** (-d), 0.04 * float(x)) if 2 <= d <= 4 else 0.0
|
||||
|
||||
|
||||
def mat_words(name: Any) -> set[str]:
|
||||
"""자재 이름 낱말 — 한글 두 자 넘는 덩이 · 영문 세 자 넘는 덩이 · 단위 낱말 뺌."""
|
||||
t = nfkc(name).lower().replace(".", "")
|
||||
return {w for w in re.findall(r"[가-힣]{2,}|[a-z]{3,}", t) if w not in UNITW}
|
||||
|
||||
|
||||
def _named(ws: set[str], k: str) -> bool:
|
||||
w = mat_words(k)
|
||||
return bool(ws & w) or any(a in b or b in a for a in ws for b in w)
|
||||
|
||||
|
||||
def compare(
|
||||
e: dict, v: dict | None, t: float, base: bool = False, rnd: bool = False
|
||||
) -> dict | None:
|
||||
"""엑셀 지문 ↔ 우리 셈 → {직종, 장비, 자재, 핵심, 직종만, 덧} · base = 엑셀 밑품으로."""
|
||||
if v is None:
|
||||
return None
|
||||
tol = lambda x: ru(x) if rnd else 0 # noqa: E731
|
||||
E = {k: (x["밑품"] if base else x["품"]) for k, x in e["인력"].items() if x.get("품")}
|
||||
ov = {k: q for k, q in v["L"].items() if q and q > 0}
|
||||
out: dict[str, Any] = {"덧": []}
|
||||
if E:
|
||||
same = set(E) == set(ov)
|
||||
ok = same and all(near(E[k], ov[k], t, tol(E[k])) for k in E)
|
||||
out["직종"] = "같음" if ok else "직종 같음" if same else "다름"
|
||||
else:
|
||||
out["직종"] = "우리만" if ov else "없음"
|
||||
EX = [m for m in e["장비"] if m.get("시간")]
|
||||
OX = [(set(a), q) for a, _, q in v["X"] if q]
|
||||
if EX and OX:
|
||||
ok = []
|
||||
for m in EX:
|
||||
mine = [q for a, q in OX if a & set(m["갈래"])]
|
||||
if not mine:
|
||||
out["덧"].append("엑셀만 장비 " + "·".join(sorted(m["갈래"])))
|
||||
continue
|
||||
h = m["밑시간"] if base and m.get("밑시간") else m["시간"]
|
||||
hit = any(near(h, q, t, tol(h)) for q in mine) or near(h, sum(mine), t, tol(h))
|
||||
ok.append(hit)
|
||||
et = set().union(*[set(m["갈래"]) for m in e["장비"]])
|
||||
out["덧"] += ["우리만 장비 " + "·".join(sorted(a)) for a, _ in OX if not a & et]
|
||||
out["장비"] = ("같음" if all(ok) else "다름") if ok else "갈래 안 겹침"
|
||||
else:
|
||||
out["장비"] = "엑셀만" if EX else "우리만" if OX else "없음"
|
||||
EM = [m for m in e["자재"] if m.get("품")]
|
||||
OM = {k: q for k, q in v["M"].items() if q}
|
||||
if EM and OM:
|
||||
ok = []
|
||||
for m in EM:
|
||||
mine = [q for k, q in OM.items() if _named(mat_words(m["이름"]), k)]
|
||||
if mine:
|
||||
x = m["밑품"] if base and m.get("밑품") else m["품"]
|
||||
ok.append(any(near(x, q, t, tol(x)) for q in mine))
|
||||
out["자재"] = ("같음" if all(ok) else "다름") if ok else "이름 안 겹침"
|
||||
else:
|
||||
out["자재"] = "엑셀만" if EM else "없음"
|
||||
core = [out["직종"] == "같음"] if E else [False] if ov else []
|
||||
core += [out[k] == "같음" for k in ("장비", "자재") if out[k] in ("같음", "다름")]
|
||||
out["핵심"] = bool(core) and all(core)
|
||||
out["직종만"] = bool(E) and out["직종"] == "같음"
|
||||
return out
|
||||
|
||||
|
||||
def strong(e: dict, c: dict | None) -> bool:
|
||||
"""딱 맞음 — 핵심 + 엑셀 몫을 다 덮음(엑셀만 · 우리만 장비 없음 · 자재 다름 없음 ·
|
||||
「보통인부 하나뿐 · 장비 없음」 줄 아님)."""
|
||||
if not c or not c.get("핵심"):
|
||||
return False
|
||||
if c.get("장비") in ("엑셀만", "우리만", "갈래 안 겹침"):
|
||||
return False
|
||||
if any("엑셀만 장비" in d or "우리만 장비" in d for d in c.get("덧") or []):
|
||||
return False
|
||||
if c.get("자재") == "다름":
|
||||
return False
|
||||
labor = [k for k, x in e["인력"].items() if x.get("품")]
|
||||
return not (labor == ["보통인부"] and not any(m.get("시간") for m in e["장비"]))
|
||||
|
||||
|
||||
def gap(e: dict, v: dict | None, base: bool = False) -> float:
|
||||
"""닮음 거리 — 직종 묶음 어긋남 + 품 |ln 비| 평균 + 장비 시간 반 무게."""
|
||||
if v is None:
|
||||
return 99.0
|
||||
E = {k: (x["밑품"] if base else x["품"]) for k, x in e["인력"].items() if x.get("품")}
|
||||
ov = {k: q for k, q in v["L"].items() if q and q > 0}
|
||||
d = 0.0
|
||||
if E or ov:
|
||||
d += len(set(E) ^ set(ov)) / len(set(E) | set(ov))
|
||||
both = [min(1.0, abs(math.log(E[k] / ov[k]))) for k in set(E) & set(ov)]
|
||||
d += sum(both) / len(both) if both else 1.0
|
||||
EX = [m for m in e["장비"] if m.get("시간")]
|
||||
OX = [(set(a), q) for a, _, q in v["X"] if q]
|
||||
if EX and OX:
|
||||
hs = []
|
||||
for m in EX:
|
||||
mine = [q for a, q in OX if a & set(m["갈래"])]
|
||||
h = m["밑시간"] if base and m.get("밑시간") else m["시간"]
|
||||
hs.append(min(1.0, min(abs(math.log(h / q)) for q in mine)) if mine else 1.0)
|
||||
d += 0.5 * sum(hs) / len(hs)
|
||||
return d
|
||||
|
||||
|
||||
def csig(e: dict, v: dict | None, nd: int = 4) -> str | None:
|
||||
"""엑셀이 보이는 몫만의 품 서명 — 직종 · 엑셀 갈래와 겹치는 장비 · 엑셀 이름과 겹치는 자재."""
|
||||
if v is None:
|
||||
return None
|
||||
r = lambda x: float(f"{x:.{nd}g}") # noqa: E731
|
||||
et = set().union(*[set(m["갈래"]) for m in e["장비"]]) if e["장비"] else set()
|
||||
em = [mat_words(m["이름"]) for m in e["자재"] if m.get("품")]
|
||||
return json.dumps(
|
||||
[
|
||||
sorted((k, r(q)) for k, q in v["L"].items() if q),
|
||||
sorted((tuple(a), r(q)) for a, _, q in v["X"] if q and set(a) & et),
|
||||
sorted((k, r(q)) for k, q in v["M"].items() if q and any(_named(ws, k) for ws in em)),
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def ours_qty(v: dict | None) -> dict[str, Any]:
|
||||
"""우리 품(화면 꼴) — {인력: {직종: 품}, 장비: [{갈래, 시간}], 자재: [{이름, 수량}]}."""
|
||||
if v is None:
|
||||
return {"인력": {}, "장비": [], "자재": []}
|
||||
return {
|
||||
"인력": {k: round(q, 6) for k, q in v["L"].items() if q},
|
||||
"장비": [{"갈래": list(a), "시간": round(q, 6)} for a, _, q in v["X"] if q],
|
||||
"자재": [{"이름": k, "수량": round(q, 6)} for k, q in v["M"].items() if q],
|
||||
}
|
||||
|
||||
|
||||
def _plain(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
value = value.normalize()
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
return value
|
||||
|
||||
|
||||
def find(code: str, efp: dict[str, Any], text: list[str] | None = None) -> dict[str, Any]:
|
||||
"""로직 하나의 옵션 거꾸로 찾기 → {수준, 판정, 입력, 갈리는칸, 견줌, 우리품, 거리, 섞기전}."""
|
||||
none = {"수준": 9, "판정": "없음", "입력": {}, "갈리는칸": [], "견줌": None}
|
||||
none.update(우리품=ours_qty(None), 거리=99.0, 섞기전=False, 직종같음=False)
|
||||
try:
|
||||
ex = ex_of(efp, text)
|
||||
cb, how, act, _, cls = plan(code, ex)
|
||||
except Exception: # noqa: BLE001 — 로직 없음 · 식 못 읽음
|
||||
return {**none, "판정": "못 셈"}
|
||||
best = (9, 9, 99.0, None, None)
|
||||
hits: dict[int, list] = {0: [], 1: [], 2: []}
|
||||
for g in cb:
|
||||
v = ours.run(code, g)
|
||||
if v is None or not (v["L"] or v["X"] or v["M"]):
|
||||
continue
|
||||
lvl = weak = 9
|
||||
for i, (_, t, rnd) in enumerate(modes()):
|
||||
c = compare(efp, v, t, rnd=rnd) or {}
|
||||
if c.get("핵심"):
|
||||
weak = min(weak, i)
|
||||
if strong(efp, c):
|
||||
hits[i].append((g, csig(efp, v)))
|
||||
lvl = min(lvl, i)
|
||||
d = gap(efp, v)
|
||||
if (lvl, weak, d) < best[:3]:
|
||||
best = (lvl, weak, d, g, v)
|
||||
lvl, _, d, g, v = best
|
||||
if g is None:
|
||||
return none
|
||||
got = [h for h in hits.get(lvl, [])] if lvl < 9 else []
|
||||
split = vary([h for h, _ in got], act, cls)
|
||||
if not got:
|
||||
verdict = "없음"
|
||||
elif len({s for _, s in got}) > 1:
|
||||
verdict = "여럿(다른 품)"
|
||||
else:
|
||||
verdict = "여럿(같은 품)" if split else "하나만"
|
||||
auto = m01.mau.values(ours.whole(), ours.whole().logic(code)) # 움직인 칸 + 자동값과 다른 칸
|
||||
base = compare(efp, v, fit.TOL[2], base=True) or {}
|
||||
labor = {k for k, x in efp["인력"].items() if x.get("품")}
|
||||
return {
|
||||
"수준": lvl,
|
||||
"판정": verdict,
|
||||
"입력": {n: _plain(x) for n, x in g.items() if n in act or str(x) != str(auto.get(n))},
|
||||
"모든입력": {n: _plain(x) for n, x in g.items()},
|
||||
"갈리는칸": split,
|
||||
"견줌": compare(efp, v, fit.TOL[2]),
|
||||
"직종같음": bool(v["L"]) and set(k for k, q in v["L"].items() if q) == labor,
|
||||
"우리품": ours_qty(v),
|
||||
"거리": round(d, 4),
|
||||
"섞기전": lvl == 9 and bool(base.get("핵심")),
|
||||
"어떻게": how,
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"""M02 일위대가 맞춤 — 우리 돈 로직 지문 · 마스터 판 열쇠 · 디스크 캐시 · 엔진 셈 캐시
|
||||
(PLAN 338-1 · 설계 2-3).
|
||||
|
||||
지문 = 정적(호표 인력 → 직종 · 「로직(기계경비 …)」 → 장비 갈래 · 규격 · 재료 이름 · 덧줄 % ·
|
||||
이름 · 고르기 글 조건 낱말 · 기계경비 로직) + 변형(고르기 입력 곱 64 이하 모두 · 넘으면 하나씩 ·
|
||||
지역 · 조종원직종 · 보정작업 · 기종 목록 칸 뺌)마다 단위당 L(직종 인) · X(장비 시간) · M(자재) ·
|
||||
S(딸린 로직) · mix(인력비율 · 기계비율).
|
||||
캐시 = `tmp/m02_hopyo/우리지문_<판>.json`(환경 `AISLO_HOPYO_CACHE` 로 바꿈) · 판 = 마스터 첫 층 JSON
|
||||
sha256 묶음 · 처음 셈은 뒤 스레드(≈ 2 분) · 다음은 읽기만.
|
||||
엔진 = `Labor_Doc.run_line` 과 같은 길(자동값 + 입력 → `mf.run`) · 셈 잠금 하나(뒤 셈 · 요청 겹침) ·
|
||||
메모리 캐시. 원본(`resources/master_data`) 읽기만. 규칙은 336 실험(C4 `s2_ours_fp` · K5 `run`)
|
||||
그대로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Cache as m01_cache
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, specnums
|
||||
|
||||
FP_VERSION = 1 # 지문 꼴이 바뀌면 올림 — 옛 캐시 버림
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
_PER = re.compile(r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?")
|
||||
_SUB = re.compile(r"^\s*로직\(\s*(\w+)\s*(?:,(.*))?\)\s*$")
|
||||
EQ_CODE = re.compile(r"^\d{4}-\d{4}$")
|
||||
_PCT = re.compile(r"\*\s*([\d.]+)\s*/\s*100")
|
||||
SKIP_IN = ("지역", "조종원직종", "보정작업")
|
||||
FIXED_MACH = {
|
||||
"GC000267": {"굴착기"},
|
||||
"GC000270": {"굴착기", "집게"},
|
||||
"GC000271": {"굴착기", "브레이커"},
|
||||
"GF000145": {"덤프트럭"},
|
||||
}
|
||||
RUN_CAP = 50_000 # ponytail: 엔진 셈 캐시 칸 수 — 넘으면 통째 비움(LRU 는 필요해지면)
|
||||
_ENGINE = threading.RLock() # ponytail: 셈 잠금 하나 — 엔진을 뒤 · 요청 스레드가 같이 부를 때
|
||||
_STATE: dict[str, Any] = {"판": None, "자국": None, "표": None, "스레드": None}
|
||||
_RUN: dict[tuple, Any] = {}
|
||||
_LOCK = threading.Lock()
|
||||
_NO_PRICE = {str(i): "-" for i in range(400)} # 고른 자재 = 없는 키 → 값 없음 · 찾기 안 함
|
||||
WHOLE_TTL = 2.0 # ponytail: 마스터 색인을 2 초 붙듦 — 줄마다 파일 자국을 다시 안 봄
|
||||
|
||||
|
||||
def folder() -> Path:
|
||||
return Path(os.environ.get("AISLO_HOPYO_CACHE") or ROOT / "tmp" / "m02_hopyo")
|
||||
|
||||
|
||||
# ── 마스터 판 열쇠 ─────────────────────────────────────────────────────
|
||||
def version() -> str:
|
||||
"""마스터 판 = 첫 층 *.json(밑줄 뺌) 이름 + sha256 16 묶음의 sha256 앞 16 — 자국이 같으면 다시
|
||||
안 셈."""
|
||||
stamps = m01_cache._stamps(Path(m01.FOLDER))
|
||||
if _STATE["자국"] != stamps:
|
||||
parts = [
|
||||
f"{name}:{m01.version_of((Path(m01.FOLDER) / name).read_bytes())}" for name in stamps
|
||||
]
|
||||
_STATE.update(자국=stamps, 판=hashlib.sha256("\n".join(parts).encode()).hexdigest()[:16])
|
||||
return _STATE["판"]
|
||||
|
||||
|
||||
def path_of(ver: str | None = None) -> Path:
|
||||
return folder() / f"우리지문_{ver or version()}.json"
|
||||
|
||||
|
||||
# ── 엔진 한 번 ─────────────────────────────────────────────────────────
|
||||
def whole() -> Any:
|
||||
now = time.monotonic()
|
||||
if _STATE.get("색인") is None or now - _STATE.get("색인때", 0) > WHOLE_TTL:
|
||||
_STATE.update(색인=m01.loaded()[1], 색인때=now)
|
||||
return _STATE["색인"]
|
||||
|
||||
|
||||
def is_mach_logic(row: dict[str, Any]) -> bool:
|
||||
return "시간당" in str(row.get("이름") or "")
|
||||
|
||||
|
||||
def eq_of(code: Any) -> tuple[set[str], set[float]]:
|
||||
one = whole().numbers.get("EQ", {}).get(str(code)) or {}
|
||||
if not one:
|
||||
return set(), set()
|
||||
return mtypes(f"{one.get('상세구분')} {one.get('이름')}"), specnums(one.get("규격"))
|
||||
|
||||
|
||||
def times_of(row: dict[str, Any]) -> float:
|
||||
per = _PER.findall(str(row.get("결과단위") or ""))
|
||||
return float(per[0].replace(",", "")) if per and per[0] else 1.0
|
||||
|
||||
|
||||
def _args(text: str | None) -> dict[str, str]:
|
||||
out = {}
|
||||
for part in re.split(r",(?![^()]*\))", text or ""):
|
||||
if "=" in part:
|
||||
k, v = part.split("=", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
|
||||
|
||||
def _labor_names() -> dict[str, str]:
|
||||
w = whole()
|
||||
if _STATE.get("LB판") is not w:
|
||||
lb = {str(r.get("키")): str(r.get("이름")) for r in w.numbers.get("LB", {}).values()}
|
||||
_STATE.update(LB판=w, LB=lb)
|
||||
return _STATE["LB"]
|
||||
|
||||
|
||||
def _engine(code: str, given: dict[str, Any]) -> dict[str, Any]:
|
||||
"""{L, X, M, S, mix} — 줄 수량 ÷ 결과단위 수(단위당)."""
|
||||
w = whole()
|
||||
row = w.logic(code)
|
||||
times = times_of(row)
|
||||
by_name: dict[Any, dict] = {}
|
||||
for h in row.get("호표") or []:
|
||||
by_name.setdefault(h.get("이름"), h)
|
||||
auto = {k: v for k, v in m01.mau.values(w, row).items() if v is not None}
|
||||
with _ENGINE, m01.cm.mf.mp.choosing(_NO_PRICE): # 품만 씀 — 자재 값 찾기 건너뜀(수량 같음)
|
||||
got = m01.cm.mf.run(w, code, {**auto, **m01._dec(given)})
|
||||
mid, lb = got.get("중간") or {}, _labor_names()
|
||||
L: dict[str, float] = {}
|
||||
X: list[list] = []
|
||||
M: dict[str, float] = {}
|
||||
S: dict[str, float] = {}
|
||||
for line in got.get("줄") or []:
|
||||
h = by_name.get(line.get("이름"))
|
||||
if h is None:
|
||||
continue
|
||||
try:
|
||||
q = float(Decimal(str(line.get("수량") or 0))) / times
|
||||
except ArithmeticError:
|
||||
continue
|
||||
kind = h.get("종류")
|
||||
if kind == "인력":
|
||||
t = lb.get(str(h.get("요소")), str(h.get("이름")))
|
||||
L[t] = L.get(t, 0.0) + q
|
||||
elif kind == "로직":
|
||||
m = _SUB.match(str(h.get("요소") or ""))
|
||||
sub = m.group(1) if m else ""
|
||||
sub_row = w.logic(sub) if sub else {}
|
||||
if sub_row and is_mach_logic(sub_row):
|
||||
ts = set(FIXED_MACH.get(sub, set())) | mtypes(h.get("이름"))
|
||||
sp = specnums(h.get("이름"))
|
||||
for v in _args(m.group(2)).values():
|
||||
lit = v.strip("'\"")
|
||||
if not EQ_CODE.match(lit):
|
||||
lit = str(mid.get(lit) or given.get(lit) or auto.get(lit) or "")
|
||||
if EQ_CODE.match(lit):
|
||||
a, b = eq_of(lit)
|
||||
ts |= a
|
||||
sp |= b
|
||||
if q:
|
||||
X.append([sorted(ts), sorted(sp), q])
|
||||
elif q:
|
||||
S[sub] = S.get(sub, 0.0) + q
|
||||
elif kind == "재료" and q:
|
||||
M[str(h.get("이름"))] = M.get(str(h.get("이름")), 0.0) + q
|
||||
mix = {}
|
||||
for k in ("인력비율", "기계비율"):
|
||||
try:
|
||||
if mid.get(k) not in (None, ""):
|
||||
mix[k] = float(Decimal(str(mid.get(k))))
|
||||
except ArithmeticError:
|
||||
pass
|
||||
return {"L": L, "X": X, "M": M, "S": S, "mix": mix}
|
||||
|
||||
|
||||
def _mach_view(code: str, row: dict, given: dict, v: dict) -> dict:
|
||||
"""「기계 시간당 기계경비」 = 고른 기계(EQ 칸)의 갈래 · 규격 1 시간(조종원은 기계경비 속)."""
|
||||
auto = m01.mau.values(whole(), row)
|
||||
X = []
|
||||
for s in row.get("입력") or []:
|
||||
opts = s.get("고르기") or []
|
||||
if opts and all(isinstance(o, str) and EQ_CODE.match(o) for o in opts):
|
||||
ts, sp = eq_of(given.get(s["이름"], auto.get(s["이름"])))
|
||||
X.append([sorted(ts | FIXED_MACH.get(code, set())), sorted(sp), 1.0])
|
||||
if not X and FIXED_MACH.get(code):
|
||||
X = [[sorted(FIXED_MACH[code]), [], 1.0]]
|
||||
return {**v, "L": {}, "X": X}
|
||||
|
||||
|
||||
def run(code: str, given: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
||||
"""엔진 한 번 → {L, X, M, S, mix} · 못 돌면 None(메모리 캐시)."""
|
||||
given = dict(given or {})
|
||||
key = (code, json.dumps(given, sort_keys=True, default=str, ensure_ascii=False))
|
||||
if key not in _RUN:
|
||||
try:
|
||||
v = _engine(code, given)
|
||||
row = whole().logic(code)
|
||||
v = _mach_view(code, row, given, v) if is_mach_logic(row) else v
|
||||
except Exception: # noqa: BLE001 — 못 도는 조합은 없음으로(엔진 예외가 여럿)
|
||||
v = None
|
||||
if len(_RUN) >= RUN_CAP:
|
||||
_RUN.clear()
|
||||
_RUN[key] = v
|
||||
return _RUN[key]
|
||||
|
||||
|
||||
# ── 지문 ───────────────────────────────────────────────────────────────
|
||||
def _static(code: str, row: dict[str, Any], unit: str) -> dict[str, Any]:
|
||||
picks = [s for s in row.get("입력") or [] if s.get("고르기")]
|
||||
labels, opt_mach = [], []
|
||||
for s in picks:
|
||||
names = s.get("고르기이름") or {}
|
||||
for v in s.get("고르기") or []:
|
||||
if isinstance(v, str) and EQ_CODE.match(v):
|
||||
ts, sp = eq_of(v)
|
||||
if ts:
|
||||
opt_mach.append([sorted(ts), sorted(sp)])
|
||||
else:
|
||||
labels.append(str(names.get(v) or v))
|
||||
lb = _labor_names()
|
||||
trades, mach, mats = set(), [], []
|
||||
for h in row.get("호표") or []:
|
||||
kind = h.get("종류")
|
||||
if kind == "인력":
|
||||
trades.add(lb.get(str(h.get("요소")), str(h.get("이름"))))
|
||||
elif kind == "로직":
|
||||
m = _SUB.match(str(h.get("요소") or ""))
|
||||
ts = set(FIXED_MACH.get(m.group(1) if m else "", set())) | mtypes(h.get("이름"))
|
||||
for v in _args(m.group(2) if m else "").values():
|
||||
if EQ_CODE.match(v.strip("'\"")):
|
||||
ts |= eq_of(v.strip("'\""))[0]
|
||||
if ts:
|
||||
mach.append([sorted(ts), sorted(specnums(h.get("이름")))])
|
||||
elif kind == "재료":
|
||||
el = h.get("요소")
|
||||
words = (
|
||||
(el.get("검색어") or el.get("이름") or el.get("상세구분"))
|
||||
if isinstance(el, dict)
|
||||
else ""
|
||||
)
|
||||
mats.append(f"{h.get('이름')} {words or ''}".strip())
|
||||
pct = [
|
||||
[str(d.get("이름")), float(v)]
|
||||
for d in row.get("덧줄") or []
|
||||
for v in _PCT.findall(str(d.get("식") or ""))
|
||||
]
|
||||
if is_mach_logic(row):
|
||||
mach.append([sorted(FIXED_MACH.get(code, set())), []])
|
||||
pick_text = " ".join(labels + [str(s.get("이름")) for s in picks])
|
||||
return {
|
||||
"로직": code,
|
||||
"이름": str(row.get("이름") or ""),
|
||||
"단위": unit,
|
||||
"직종": sorted(trades),
|
||||
"장비": mach,
|
||||
"장비고름": opt_mach,
|
||||
"자재": mats,
|
||||
"덧줄율": pct,
|
||||
"이름조건": {k: sorted(v) for k, v in conds(row.get("이름")).items()},
|
||||
"고름조건": {k: sorted(v) for k, v in conds(pick_text).items()},
|
||||
"기계경비": is_mach_logic(row),
|
||||
}
|
||||
|
||||
|
||||
def combos(row: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""변형 입력 — 고르기 곱 64 이하 모두 · 넘으면 하나씩(기본 + 칸마다)."""
|
||||
picks = []
|
||||
for s in row.get("입력") or []:
|
||||
opts = s.get("고르기") or []
|
||||
if not opts or any(w in str(s.get("이름")) for w in SKIP_IN):
|
||||
continue
|
||||
if any(isinstance(v, str) and EQ_CODE.match(v) for v in opts):
|
||||
continue
|
||||
picks.append((str(s["이름"]), list(opts)))
|
||||
if not picks:
|
||||
return [{}]
|
||||
if math.prod(len(o) for _, o in picks) <= 64:
|
||||
names = [n for n, _ in picks]
|
||||
return [dict(zip(names, vals)) for vals in itertools.product(*[o for _, o in picks])]
|
||||
return [{}] + [{n: v} for n, opts in picks for v in opts]
|
||||
|
||||
|
||||
def _sig(v: dict) -> str:
|
||||
r = lambda x: float(f"{x:.4g}") # noqa: E731
|
||||
return json.dumps(
|
||||
[
|
||||
sorted((k, r(q)) for k, q in v["L"].items()),
|
||||
sorted((tuple(a), tuple(b), r(q)) for a, b, q in v["X"]),
|
||||
sorted((k, r(q)) for k, q in v["M"].items()),
|
||||
],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def fingerprint(code: str, unit: str) -> dict[str, Any]:
|
||||
"""로직 하나 → 정적 지문 + 변형(서로 다른 품 꼴만)."""
|
||||
row = whole().logic(code)
|
||||
fp = _static(code, row, unit)
|
||||
seen, variants, errors = set(), [], 0
|
||||
for given in combos(row) if not fp["기계경비"] else [{}]:
|
||||
try:
|
||||
v = _engine(code, given) # 기계경비 로직도 엔진 그대로(맞춤이 고르기 장비로 봄)
|
||||
except Exception: # noqa: BLE001 — 못 도는 조합은 셈만
|
||||
errors += 1
|
||||
continue
|
||||
if (k := _sig(v)) not in seen:
|
||||
seen.add(k)
|
||||
variants.append({"in": {n: str(x) for n, x in given.items()}, **v})
|
||||
return {**fp, "갈래수": len(variants), "오류": errors, "변형": variants}
|
||||
|
||||
|
||||
# ── 표 · 캐시 · 데움 ───────────────────────────────────────────────────
|
||||
def _read(ver: str) -> list[dict] | None:
|
||||
try:
|
||||
doc = json.loads(path_of(ver).read_bytes())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
ok = isinstance(doc, dict) and doc.get("판") == ver and doc.get("지문판") == FP_VERSION
|
||||
return doc.get("표") if ok else None
|
||||
|
||||
|
||||
def build(ver: str | None = None) -> list[dict]:
|
||||
"""1,438 돈 로직 지문을 셈 → 디스크 캐시(같은 판이면 덮음)."""
|
||||
ver = ver or version()
|
||||
table = [fingerprint(one["로직"], one["단위"]) for one in hopyo_match.targets()["로직"]]
|
||||
atomic_write_json(path_of(ver), {"판": ver, "지문판": FP_VERSION, "표": table})
|
||||
return table
|
||||
|
||||
|
||||
def table() -> list[dict] | None:
|
||||
"""우리 지문(판이 같은 것) · 준비 전 None — 디스크에 있으면 읽어 둠."""
|
||||
ver = version()
|
||||
with _LOCK:
|
||||
if _STATE.get("표판") != ver:
|
||||
_STATE.update(표=_read(ver), 표판=ver)
|
||||
return _STATE["표"]
|
||||
|
||||
|
||||
def ready() -> bool:
|
||||
return table() is not None
|
||||
|
||||
|
||||
def warm(build_missing: bool = False) -> threading.Thread | None:
|
||||
"""디스크 캐시 읽기(뒤) · `build_missing` 이면 없을 때 뒤 스레드로 셈 — 셈 중이면 그 스레드."""
|
||||
running = _STATE.get("스레드")
|
||||
if running is not None and running.is_alive():
|
||||
return running
|
||||
|
||||
def work() -> None:
|
||||
try:
|
||||
if table() is None and build_missing:
|
||||
ver = version()
|
||||
got = build(ver)
|
||||
with _LOCK:
|
||||
_STATE.update(표=got, 표판=ver)
|
||||
except Exception: # noqa: BLE001 — 마스터가 깨져도 서버를 막지 않음(보기에서 「준비 중」)
|
||||
pass
|
||||
|
||||
thread = threading.Thread(target=work, name="m02-hopyo-ours", daemon=True)
|
||||
_STATE["스레드"] = thread
|
||||
thread.start()
|
||||
return thread
|
||||
@@ -0,0 +1,177 @@
|
||||
"""M02 일위대가 맞춤 몫(PLAN 338-1 · 설계 2-2 · 2-7) — 단가산출 블록을 맨 윗 절(1. 2. 3.)
|
||||
· 작은 절(가. 나.)로 갈라 몫마다 노임(직종 → 인) · 장비(「굴착기|0.6」 → 시간) 벡터.
|
||||
|
||||
엑셀꼴 = 「직종 : 노임 × 식 = 금액」(식을 못 풀면 금액 ÷ 노임) · 장비 이름 줄 뒤 「노무비 : 단가 ÷ Q
|
||||
× 몫」 ·
|
||||
소계 · 합계 절 뺌. STmate = 자원 L · X(절 = 자원 `절` · 없으면 한 몫). 금액 안 씀.
|
||||
규칙은 336 실험(C5 `c01_trees.sections` · `sections_stm`) 그대로 · 장비 갈래는
|
||||
`M02_Hopyo_Words.mtypes`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import operator
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, nfkc
|
||||
|
||||
_SIZE = re.compile(r"(\d+(?:\.\d+)?)\s*(㎥|m3|톤|ton|t\b|kw|hp)", re.I)
|
||||
_OPS = {
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
}
|
||||
_TRADE = re.compile(
|
||||
r"^[\s*·\-○◇]*([가-힣][가-힣\s]*?)\s*:\s*([\d,]+(?:\.\d+)?)\s*(.*?)(?:=\s*([\d,]+(?:\.\d+)?))?\s*$"
|
||||
)
|
||||
_SECTION = re.compile(r"^\s*(\d+)\s*\.\s*(.*)$")
|
||||
_EQ_COST = re.compile(r"^\s*노\s*무\s*비\s*:\s*([\d,]+(?:\.\d+)?)\s*(.*?)(?:=\s*[\d,.]+)?\s*$")
|
||||
_SKIP_SEC = re.compile(r"^(합\s*계|소\s*계|계)")
|
||||
_SUBLABEL = re.compile(r"^\s*[가-하]\s*\.\s*(.*)$")
|
||||
_COSTLINE = re.compile(r"^\s*(노\s*무\s*비|재\s*료\s*비|경\s*비)\s*:")
|
||||
|
||||
|
||||
def trade(name: Any) -> str | None:
|
||||
"""노임 이름 → 우리 인력 이름(빈칸 · 옛이름 · 괄호 글 풀이) · 아니면 None."""
|
||||
names = hopyo_basis.trade_names()
|
||||
flat = re.sub(r"\s+", "", str(name or ""))
|
||||
return names.get(flat) or names.get(re.sub(r"\(.*?\)", "", flat))
|
||||
|
||||
|
||||
def eq_key(text: Any) -> str | None:
|
||||
"""장비 글 → 「굴착기|0.6」(갈래 + 로 묶음 · 크기 없으면 「굴착기|」) · 장비 아니면 None."""
|
||||
kinds = mtypes(text)
|
||||
if not kinds:
|
||||
return None
|
||||
m = _SIZE.search(nfkc(text).replace(",", ""))
|
||||
size = (
|
||||
m.group(1).rstrip("0").rstrip(".") if m and "." in m.group(1) else m.group(1) if m else ""
|
||||
)
|
||||
return f"{'+'.join(sorted(kinds))}|{size}"
|
||||
|
||||
|
||||
def _eval(node: ast.AST) -> float:
|
||||
if isinstance(node, ast.Expression):
|
||||
return _eval(node.body)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return float(node.value)
|
||||
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
|
||||
return -_eval(node.operand)
|
||||
raise ValueError("식 아님")
|
||||
|
||||
|
||||
def qty_of(expr: str) -> float | None:
|
||||
"""「273,540 × 3인 ÷ 55㎥ × 2」 → 3/55×2 · 「59,020 ÷ 23.1 × 90%」 → 1/23.1×0.9."""
|
||||
s = re.sub(r"\([^()0-9]*\)", "", str(expr)).replace(",", "")
|
||||
s = re.sub(r"(\d+(?:\.\d+)?)\s*%", r"(\1/100)", s)
|
||||
s = s.replace("×", "*").replace("÷", "/").replace("X", "*").replace("x", "*")
|
||||
s = re.sub(r"/\s*(?=[^\d(\s.])", " ", s) # 「인/㎥」 의 / 는 단위
|
||||
s = re.sub(r"[^\d.+\-*/() ]", " ", s)
|
||||
s = re.sub(r"(?<=\d)\.(?!\d)", "", s) # 「4.」 → 4
|
||||
s = re.sub(r"\(\s*\)", " ", s).strip()
|
||||
m = re.match(r"\s*\d+(?:\.\d+)?", s)
|
||||
if not m:
|
||||
return None
|
||||
s = "1" + s[m.end() :]
|
||||
s = re.sub(r"(?<=[\d)])\s+(?=[\d(])", "*", s) # 빈칸으로 붙은 수는 곱
|
||||
try:
|
||||
v = _eval(ast.parse(s, mode="eval"))
|
||||
except (SyntaxError, ValueError, ZeroDivisionError):
|
||||
return None
|
||||
return v if 0 < v < 1e4 else None
|
||||
|
||||
|
||||
def _part(title: str, parent: str) -> dict[str, Any]:
|
||||
return {"제목": title, "절": parent, "노임": {}, "장비": {}}
|
||||
|
||||
|
||||
def sections(texts: list[str]) -> list[dict[str, Any]]:
|
||||
"""블록 몸 글 → 몫마다 {제목, 절, 노임, 장비} — 노임 · 장비가 하나도 없는 몫은 뺌."""
|
||||
out: list[dict[str, Any]] = []
|
||||
cur: dict[str, Any] | None = None
|
||||
eq, parent = None, ""
|
||||
for raw in texts:
|
||||
line = str(raw).rstrip()
|
||||
sec = _SECTION.match(line)
|
||||
if sec and not re.match(r"^\s*\d+\.\d", line):
|
||||
title = sec.group(2).strip()
|
||||
if _SKIP_SEC.match(title.replace(" ", "")):
|
||||
cur = None
|
||||
continue
|
||||
parent = title
|
||||
cur = _part(title, title)
|
||||
out.append(cur)
|
||||
eq = eq_key(title)
|
||||
continue
|
||||
sub = _SUBLABEL.match(line) if cur is not None else None
|
||||
if sub and not _SKIP_SEC.match(sub.group(1).replace(" ", "")):
|
||||
cur = _part(f"{parent} · {sub.group(1).strip()}", parent)
|
||||
out.append(cur)
|
||||
if cur is None:
|
||||
if out or not line.strip():
|
||||
continue
|
||||
cur = _part("", "")
|
||||
out.append(cur)
|
||||
if re.search(r"(소계|합계|인부소계)", line.replace(" ", "")):
|
||||
eq = None
|
||||
continue
|
||||
sub = _SUBLABEL.match(line)
|
||||
if sub:
|
||||
eq = eq_key(sub.group(1))
|
||||
elif not _COSTLINE.match(line) and (k := eq_key(line)):
|
||||
eq = k
|
||||
m = _EQ_COST.match(line)
|
||||
if m and eq:
|
||||
q = qty_of(f"{m.group(1)} {m.group(2)}")
|
||||
if q and q < 50:
|
||||
cur["장비"][eq] = cur["장비"].get(eq, 0) + q
|
||||
continue
|
||||
t = _TRADE.match(line)
|
||||
if t and (who := trade(t.group(1))):
|
||||
q = qty_of(f"{t.group(2)} {t.group(3)}")
|
||||
if q is None and t.group(4): # 식을 못 풀면 금액 ÷ 노임(품 되찾기만)
|
||||
q = float(t.group(4).replace(",", "")) / float(t.group(2).replace(",", ""))
|
||||
if q:
|
||||
cur["노임"][who] = cur["노임"].get(who, 0) + q
|
||||
return [s for s in out if s["노임"] or s["장비"]]
|
||||
|
||||
|
||||
def sections_stm(res: list[dict[str, Any]], texts: list[str]) -> list[dict[str, Any]]:
|
||||
"""STmate 자원(L · X) → 몫(자원 `절` = 글 줄 차례면 그 앞 맨 윗 절 · 글이면 그 제목 · 없으면 한
|
||||
몫)."""
|
||||
marks = []
|
||||
for at, t in enumerate(texts):
|
||||
sec = _SECTION.match(t)
|
||||
if sec and not re.match(r"^\s*\d+\.\d", t):
|
||||
if not _SKIP_SEC.match(sec.group(2).replace(" ", "")):
|
||||
marks.append((at, sec.group(2).strip()))
|
||||
|
||||
def which(where: Any) -> str:
|
||||
if isinstance(where, str):
|
||||
return where
|
||||
if isinstance(where, int) and not isinstance(where, bool):
|
||||
got = [m for m in marks if m[0] <= where]
|
||||
return got[-1][1] if got else ""
|
||||
return ""
|
||||
|
||||
secs: dict[str, dict[str, Any]] = {}
|
||||
seen = set()
|
||||
for x in res:
|
||||
q, kind = x.get("품"), x.get("갈래")
|
||||
if not q or kind not in ("L", "X") or (x.get("코드"), q, x.get("절")) in seen:
|
||||
continue
|
||||
seen.add((x.get("코드"), q, x.get("절")))
|
||||
title = which(x.get("절"))
|
||||
s = secs.setdefault(title, _part(title, title))
|
||||
if kind == "L" and (who := trade(x.get("이름"))):
|
||||
s["노임"][who] = s["노임"].get(who, 0) + float(q)
|
||||
elif kind == "X":
|
||||
k = eq_key(f"{x.get('이름') or ''} {x.get('규격') or ''}") or f"?{x.get('코드')}"
|
||||
s["장비"][k] = s["장비"].get(k, 0) + float(q)
|
||||
return [s for s in secs.values() if s["노임"] or s["장비"]]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""M02 일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b) — 「일위대가표」 · 「단가산출근거」 시트 +
|
||||
목록표(노무비 · 중기 · 재료비 · 경비 · 단가산출근거) → 엑셀꼴과 같은 HP 꼴
|
||||
(`M02_Hopyo_Excel.read_book`).
|
||||
|
||||
일위대가표 = A 열 「제N호표」 줄(B~Z 열 「BDCOD|B…」 = 호표 코드) → 다음 줄 A 이름 · B 규격 ·
|
||||
D 단위 → 구성 줄(A 이름 · B 규격 · C 수량 · D 단위 · E 계 · G 노무비 · I 재료비 · K 경비 단가 ·
|
||||
다음 열 금액 · M 비고) → 「합계」 줄에서 끝.
|
||||
줄 코드 = M~Z 열에서 `_CODE` 꼴 첫 것(판마다 열 수가 달라도 읽힘 · 하이퍼링크 안 읽음) ·
|
||||
코드 없는 줄 = 호표 `근거글`.
|
||||
갈래 = D 단가산출(참조 단산 · 번 = 코드 숫자) · B 호표(참조 일위 · 번 = 호표 코드 표) · L 노임 ·
|
||||
X 기계 · M 자재(비고 「별산」 · 규격 「별도계상」 · 값 없음 = 별도계상) · S · W 별도계상 ·
|
||||
PR · 단위 % = % · 그 밖 기타.
|
||||
단가산출근거 = 「BDCOD|D…」 머리 → 다음 머리 · 「총계」 앞까지 한 블록 · 글 = B 열(머리 이름 줄 ·
|
||||
소계 · 계 뺌) · 자원 = M 열 「X00000_n」 줄(품 = H 열 · 이름 · 규격 = X 열 「이름 / 규격」 ·
|
||||
비면 목록표 · 기계 셋 줄(노무비 · 재료비 · 경비)은 하나로 · 절 = 그 앞 번호 절 제목) ·
|
||||
머리 이름 · 규격 · 단위 = 단가산출근거목록표 · 출처 = 블록 글 `Basis.sources` ·
|
||||
직종 = 자원 L 이름(우리 인력 · 옛이름 = 지금 이름).
|
||||
검산 안 함 — `검산 {어긋남: [], 총괄표: 0}`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel # 서로 부름 — 부를 때만 씀
|
||||
|
||||
SHEET = "일위대가표"
|
||||
CALC = "단가산출근거"
|
||||
LISTS = ("단가산출근거목록표", "노무비목록표", "중기목록표", "재료비목록표", "경비목록표")
|
||||
SHEETS = (SHEET, CALC, *LISTS)
|
||||
_CODE = re.compile(r"^(?:COD\|)?([A-Z]{1,2}\d{5}|PR[A-Z]?\d+|[TC]\d+)(?:_\d+)?(?: →)?$")
|
||||
_META = re.compile(r"(?<![A-Z])COD\|([A-Z]{1,2}\d{5})") # 숨은 칸 「`COD|L00001」
|
||||
_HEAD_B = re.compile(r"BDCOD\|(B\d{5})")
|
||||
_HEAD_D = re.compile(r"BDCOD\|(D\d{5})")
|
||||
_RES = re.compile(r"^([LMXS])(\d{5})_\d+$")
|
||||
_RES_A = re.compile(r"^([LMXS]\d{5})[LMS]$") # A 열 「L00002L」
|
||||
_RES_G = re.compile(r"&([LMXS]\d{5})[LMS]&") # G 열 식 「&L00002L&」
|
||||
_SECTION = re.compile(r"^\s*(?:\d+\s*[.)]|[①-⑩])\s*(.*\S)")
|
||||
_COLS = {"합계": 4, "노무비": 6, "재료비": 8, "경비": 10}
|
||||
NOTE, CODE_COLS, RES_COL, QTY_COL, NAME_COL = 12, range(12, 26), 12, 7, 23
|
||||
_KIND = {"D": "단가산출", "B": "호표", "L": "노임", "X": "기계", "S": "별도계상", "W": "별도계상"}
|
||||
|
||||
|
||||
def is_stmate(names: Any) -> bool:
|
||||
return SHEET in names and CALC in names
|
||||
|
||||
|
||||
def _cell(row: list[Any], col: int) -> Any:
|
||||
return row[col] if col < len(row) else None
|
||||
|
||||
|
||||
def _s(value: Any) -> str:
|
||||
return hopyo_excel._text(value)
|
||||
|
||||
|
||||
def _flat(value: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(value or ""))
|
||||
|
||||
|
||||
def _code(row: list[Any], cols: Any = CODE_COLS) -> str | None:
|
||||
for col in cols:
|
||||
hit = _CODE.match(_s(_cell(row, col)))
|
||||
if hit:
|
||||
return hit.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _lists(sheets: dict[str, list[list[Any]]]) -> dict[str, dict[str, str]]:
|
||||
"""목록표 → {코드: {이름, 규격, 단위}} — B 이름 · C 규격 · D 단위 · 코드 = 숨은 칸
|
||||
「COD|…」 · 없으면 코드 꼴 첫 칸(판마다 A 열 · 뒤 열)."""
|
||||
out: dict[str, dict[str, str]] = {}
|
||||
for title in LISTS:
|
||||
for row in sheets.get(title) or []:
|
||||
meta = _META.search(" ".join(_s(v) for v in row))
|
||||
code = meta.group(1) if meta else _code(row, range(len(row)))
|
||||
if code and _s(_cell(row, 1)):
|
||||
out[code] = {
|
||||
"이름": _s(row[1]),
|
||||
"규격": _s(_cell(row, 2)),
|
||||
"단위": _s(_cell(row, 3)),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
# ── 단가산출근거 블록 ──────────────────────────────────────────────────
|
||||
def _blocks(rows: list[list[Any]], table: dict[str, dict[str, str]]) -> dict[str, dict[str, Any]]:
|
||||
"""{D 코드: {번, 코드, 이름, 규격, 단위, 행, 출처, 직종, 글, 자원}}."""
|
||||
names = hopyo_basis.trade_names()
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
block: dict[str, Any] | None = None
|
||||
title = ""
|
||||
for at, row in enumerate(rows, 1):
|
||||
head = next(
|
||||
(hit for v in row[:8] if (hit := _HEAD_D.search(_s(v)))), None
|
||||
) # 머리 = G 열(판마다 B 열 글이 다름)
|
||||
if head:
|
||||
code = head.group(1)
|
||||
info = table.get(code) or {}
|
||||
block = out.setdefault(
|
||||
code,
|
||||
{
|
||||
"번": int(code[1:]),
|
||||
"코드": code,
|
||||
"이름": info.get("이름", ""),
|
||||
"규격": info.get("규격", ""),
|
||||
"단위": info.get("단위", ""),
|
||||
"행": [at, at],
|
||||
"글": [],
|
||||
"자원": [],
|
||||
},
|
||||
)
|
||||
title = ""
|
||||
continue
|
||||
if block is None:
|
||||
continue
|
||||
first = _flat(_cell(row, 0))
|
||||
if first.startswith("총계"):
|
||||
block["행"][1] = at
|
||||
block = None
|
||||
continue
|
||||
text = _s(_cell(row, 1))
|
||||
res = _RES.match(_s(_cell(row, RES_COL)))
|
||||
code = res.group(1) + res.group(2) if res else None
|
||||
if not code:
|
||||
alt = _RES_A.match(first) or _RES_G.search(_s(_cell(row, 6)))
|
||||
code = alt.group(1) if alt else None
|
||||
if not text and not code:
|
||||
continue
|
||||
block["행"][1] = at
|
||||
if _s(_cell(row, RES_COL)) == block["코드"]: # 머리 이름 줄 「이름 규격 / 단위」
|
||||
if not block["이름"]:
|
||||
name, _, unit = text.rpartition("/")
|
||||
block["이름"], block["단위"] = name.strip() or text, unit.strip()
|
||||
continue
|
||||
if text and _flat(text) not in ("소계", "계"):
|
||||
if len(block["글"]) < hopyo_basis.GLINES:
|
||||
block["글"].append(text[: hopyo_basis.GCHARS])
|
||||
if (sec := _SECTION.match(text)) and not re.match(r"^\s*\d+\.\d", text):
|
||||
title = sec.group(1).strip()
|
||||
if not code:
|
||||
continue
|
||||
last = block["자원"][-1] if block["자원"] else None
|
||||
if code[0] == "X" and last and last["코드"] == code and last["절"] == title:
|
||||
continue # 기계 노무비 · 재료비 · 경비 셋 줄 = 하나
|
||||
name, _, spec = _s(_cell(row, NAME_COL)).partition("/")
|
||||
info = table.get(code) or {}
|
||||
qty = hopyo_excel._num(_cell(row, QTY_COL))
|
||||
block["자원"].append(
|
||||
{
|
||||
"갈래": code[0],
|
||||
"코드": code,
|
||||
"이름": name.strip() or info.get("이름", ""),
|
||||
"규격": spec.strip() if name.strip() else info.get("규격", ""),
|
||||
"품": float(qty) if qty is not None else None,
|
||||
"절": title,
|
||||
}
|
||||
)
|
||||
for block in out.values():
|
||||
block["출처"] = hopyo_basis.sources(" ".join(block["글"]))
|
||||
trades = [names.get(_flat(r["이름"])) for r in block["자원"] if r["갈래"] == "L"]
|
||||
block["직종"] = list(dict.fromkeys(t for t in trades if t))
|
||||
return out
|
||||
|
||||
|
||||
# ── 일위대가표 ────────────────────────────────────────────────────────
|
||||
def _kind(code: str, line: dict[str, Any]) -> str:
|
||||
if code.startswith("PR") or _flat(line["단위"]) == "%":
|
||||
return "%"
|
||||
if code[0] == "M":
|
||||
apart = line["비고"].startswith("별산") or line["규격"] == "별도계상"
|
||||
return "별도계상" if apart or not any(line["단가"].values()) else "자재"
|
||||
return _KIND.get(code[0], "기타")
|
||||
|
||||
|
||||
def _hopyos(rows: list[list[Any]]) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]:
|
||||
hopyos: list[dict[str, Any]] = []
|
||||
raw: list[list[dict[str, Any]]] = []
|
||||
want_head = done = False
|
||||
for at, row in enumerate(rows, 1):
|
||||
first = _s(_cell(row, 0))
|
||||
hit = hopyo_excel._HOPYO.fullmatch(_flat(first))
|
||||
if hit:
|
||||
code = next((m.group(1) for v in row[1:26] if (m := _HEAD_B.search(_s(v)))), None)
|
||||
hopyos.append({"번": int(hit.group(1)), "코드": code, "행": [at, at], "근거글": []})
|
||||
raw.append([])
|
||||
want_head, done = True, False
|
||||
continue
|
||||
if not hopyos or done:
|
||||
continue
|
||||
hopyo = hopyos[-1]
|
||||
if want_head:
|
||||
hopyo.update(이름=first, 규격=_s(_cell(row, 1)), 단위=_s(_cell(row, 3)))
|
||||
hopyo["금액"] = {}
|
||||
want_head = False
|
||||
continue
|
||||
if _flat(first).startswith("합계") or _flat(first) == "계":
|
||||
hopyo["금액"] = hopyo_excel._money(row, _COLS, 1)
|
||||
done = True # 끝 — 다음 호표까지 줄 안 받음
|
||||
continue
|
||||
if not first:
|
||||
continue
|
||||
code = _code(row)
|
||||
hopyo["행"][1] = at
|
||||
if not code:
|
||||
if re.search(r"[0-9A-Za-z가-힣]", first):
|
||||
hopyo["근거글"].append(f"{first} {_s(_cell(row, 1))}".strip())
|
||||
continue
|
||||
if code[0] in "TC" and code[1].isdigit():
|
||||
continue # 계 · 소계 · 계약단가
|
||||
line = {
|
||||
"차례": len(raw[-1]),
|
||||
"이름": first,
|
||||
"규격": _s(_cell(row, 1)),
|
||||
"수량": hopyo_excel._num(_cell(row, 2)),
|
||||
"단위": _s(_cell(row, 3)),
|
||||
"단가": hopyo_excel._money(row, _COLS, 0),
|
||||
"금액": hopyo_excel._money(row, _COLS, 1),
|
||||
"비고": _s(_cell(row, NOTE)),
|
||||
"코드": code,
|
||||
"행": at,
|
||||
}
|
||||
line["갈래"] = _kind(code, line)
|
||||
line["참조"] = (
|
||||
{"종류": "단산", "번": int(code[1:]), "코드": code}
|
||||
if code[0] == "D"
|
||||
else {"종류": "일위", "번": None, "코드": code}
|
||||
if code[0] == "B"
|
||||
else None
|
||||
)
|
||||
raw[-1].append(line)
|
||||
return hopyos, raw
|
||||
|
||||
|
||||
def read(sheets: dict[str, list[list[Any]]]) -> dict[str, Any]:
|
||||
"""STmate 시트 → HP 꼴 `{꼴, 시트, 호표, 단산, 검산}`(값은 JSON 수)."""
|
||||
hopyos, raw = _hopyos(sheets.get(SHEET) or [])
|
||||
if not hopyos:
|
||||
raise hopyo_excel.ExcelError(f"{SHEET} 시트에 호표(제N호표)가 없음")
|
||||
table = _lists(sheets)
|
||||
blocks = _blocks(sheets.get(CALC) or [], table)
|
||||
by_code = {h["코드"]: h["번"] for h in hopyos if h["코드"]}
|
||||
for line in (one for lines in raw for one in lines):
|
||||
ref = line["참조"] or {}
|
||||
if ref.get("종류") == "일위":
|
||||
ref["번"] = by_code.get(ref["코드"])
|
||||
elif ref.get("종류") == "단산" and (block := blocks.get(ref["코드"])):
|
||||
line["근거"] = {k: block[k] for k in ("출처", "직종", "행")}
|
||||
out = []
|
||||
for hopyo, lines in zip(hopyos, raw, strict=True):
|
||||
for key in ("이름", "규격", "단위"):
|
||||
hopyo.setdefault(key, "")
|
||||
if not hopyo["근거글"]:
|
||||
del hopyo["근거글"]
|
||||
out.append({**hopyo_excel._json(hopyo), "줄": [hopyo_excel._json(x) for x in lines]})
|
||||
keep = ("번", "코드", "이름", "규격", "단위", "행", "출처", "직종", "글", "자원")
|
||||
return {
|
||||
"꼴": "STmate",
|
||||
"시트": SHEET,
|
||||
"호표": out,
|
||||
"단산": [{k: b[k] for k in keep} for b in blocks.values()],
|
||||
"검산": {"어긋남": [], "총괄표": 0},
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"""M02 일위대가 맞춤 — 블록 하나 셈 · 판정(PLAN 338-1 · 설계 2-1 · 2-6 · 3-2).
|
||||
|
||||
블록 + 줄 → 엑셀 지문(`Finger`) → 이름 · 근거 차례(`Match.b_order`) → 지문 순위 섞기(`Fit`) ·
|
||||
같음 1위 고정(딱 맞음이 이김) → 섞인 줄 몫 먼저 · 조합(`Combine.solve`) → 위 다섯 옵션
|
||||
거꾸로(`Option`) →
|
||||
판정 맞음 · 다른 점 · 후보 + 후보 다섯(로직마다 옵션 판정 꼬리표).
|
||||
글 · 꼬리표는 숫자 사실만(엑셀 · 우리 품) — 「옛 판」 같은 까닭은 짓지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hopyo_basis
|
||||
from M02_MasterTemplete import M02_Hopyo_Combine as combine
|
||||
from M02_MasterTemplete import M02_Hopyo_Finger as finger
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
|
||||
from M02_MasterTemplete import M02_Hopyo_Option as option
|
||||
|
||||
|
||||
def _num(x: float) -> str:
|
||||
return f"{x:.4g}"
|
||||
|
||||
|
||||
def excel_qty(efp: dict[str, Any]) -> dict[str, Any]:
|
||||
"""엑셀 품(화면 꼴) — {인력: {직종: 품}, 장비: [{갈래, 시간}], 자재: [{이름, 수량}]}."""
|
||||
return {
|
||||
"인력": {k: round(x["품"], 6) for k, x in efp["인력"].items() if x.get("품")},
|
||||
"장비": [{"갈래": m["갈래"], "시간": m["시간"]} for m in efp["장비"] if m.get("시간")],
|
||||
"자재": [{"이름": m["이름"], "수량": m["품"]} for m in efp["자재"] if m.get("품")],
|
||||
}
|
||||
|
||||
|
||||
def _public(efp: dict[str, Any]) -> dict[str, Any]:
|
||||
keep = ("인력", "장비", "자재", "출처", "조건", "옛판", "율", "계수")
|
||||
return {k: efp.get(k) for k in keep}
|
||||
|
||||
|
||||
def _label(code: str, values: dict[str, Any]) -> str:
|
||||
"""옵션 요약 — 고르기 글은 그대로 · 수 칸은 「칸 값」 · 기계 코드는 고르기이름."""
|
||||
specs = {s.get("이름"): s for s in hopyo_match._logic_specs(code)}
|
||||
out = []
|
||||
for name, v in values.items():
|
||||
names = (specs.get(name) or {}).get("고르기이름") or {}
|
||||
out.append(str(names.get(v) or v) if isinstance(v, str) else f"{name} {v}")
|
||||
return " · ".join(out)
|
||||
|
||||
|
||||
def _part_fp(part: dict[str, Any], x: float, line: dict[str, Any]) -> dict[str, Any]:
|
||||
"""조합 몫 → 옵션 찾기용 지문(몫 x 를 풀어 100% 품)."""
|
||||
fp = finger.empty(line)
|
||||
k = x or 1.0
|
||||
for t, q in (part.get("노임") or {}).items():
|
||||
fp["인력"][t] = {"품": q / k, "밑품": q / k, "율": [], "어떻게": ["몫"]}
|
||||
return fp
|
||||
|
||||
|
||||
def _diffs(efp: dict, found: dict) -> list[str]:
|
||||
"""숫자 사실 꼬리표 — 직종 품 다름 · 섞음 · 율 · 엑셀만 · 우리만 장비."""
|
||||
tags = []
|
||||
ours_l = (found.get("우리품") or {}).get("인력") or {}
|
||||
for t, x in efp["인력"].items():
|
||||
a, b = x.get("품"), ours_l.get(t)
|
||||
if a and b and not option.near(a, b, fit.TOL[2]):
|
||||
tags.append(f"{t} 엑셀 {_num(a)} · 우리 {_num(b)}")
|
||||
elif a and not b:
|
||||
tags.append(f"{t} 엑셀만 {_num(a)}")
|
||||
tags += [f"{t} 우리만 {_num(q)}" for t, q in ours_l.items() if t not in efp["인력"]]
|
||||
for x in efp["인력"].values():
|
||||
for v, note in x.get("율") or []:
|
||||
tag = f"섞음 — 엑셀이 × {_num(v)}" if note == "섞음" else f"율 {note} × {_num(v)}"
|
||||
if v and tag not in tags:
|
||||
tags.append(tag)
|
||||
tags += [d for d in ((found.get("견줌") or {}).get("덧") or []) if d not in tags]
|
||||
return tags
|
||||
|
||||
|
||||
def _state(efp: dict, code: str, found: dict | None, theirs: list) -> str:
|
||||
lvl = 9 if (found is None or fit.single(efp)) else found["수준"]
|
||||
if lvl <= 1:
|
||||
return "맞음"
|
||||
our = hopyo_basis.ours().get(code)
|
||||
clause_ok = bool(our and theirs and hopyo_match.clause(theirs, our)[0] >= 0.9)
|
||||
if lvl == 2 or (found and found.get("직종같음") and clause_ok):
|
||||
return "다른 점"
|
||||
return "후보"
|
||||
|
||||
|
||||
def _no_numbers(entry: dict, top: list[str], why: str) -> dict[str, Any]:
|
||||
entry["순위"] = [{"로직": k, "점수": None, "B": i} for i, k in enumerate(top[: fit.TOP_OPT])]
|
||||
entry["차례"] = top[: fit.TOP_OPT]
|
||||
entry["판정"] = {"상태": "후보", "글": why, "꼬리표": [why]}
|
||||
return entry
|
||||
|
||||
|
||||
def block(block_: dict | None, line: dict[str, Any], got: dict[str, Any]) -> dict[str, Any]:
|
||||
"""블록(또는 `{_지문}` 직접 줄 호표) + 줄 → 맞춤셈 한 칸(설계 3-2)."""
|
||||
if block_ and "_지문" in block_:
|
||||
efp, text = block_["_지문"], []
|
||||
else:
|
||||
efp, text = finger.excel(block_, line), (block_ or {}).get("글") or []
|
||||
border = hopyo_match.b_order(line, got, text)
|
||||
entry: dict[str, Any] = {"지문": _public(efp), "품": excel_qty(efp), "B": border["상태"]}
|
||||
entry.update(옵션={}, 조합=None)
|
||||
if not fit.has_numbers(efp):
|
||||
return _no_numbers(entry, border["top"], fit.NO_NUMBER)
|
||||
if fit.prepared() is None:
|
||||
return _no_numbers(entry, border["top"], "우리 로직 준비 중")
|
||||
e = fit.ex_prep(efp)
|
||||
pool = fit.rank(e)
|
||||
btop = border["top"]
|
||||
scores = {k: s for s, k, _, _ in pool}
|
||||
fused = fit.fuse(e, pool, btop)
|
||||
if border["상태"] == "같음": # 같음 1위 고정 — 지문 딱 맞음이 다른 로직이면 지문 1위
|
||||
first = pool[0][1] if fit.sure(e, pool) and pool[0][1] != btop[0] else btop[0]
|
||||
top = [first] + [k for k in fused if k != first][:4]
|
||||
else:
|
||||
top = fused
|
||||
parts = efp.get("몫") or []
|
||||
if parts and btop and combine.is_mixed(parts, line): # 섞인 줄 — 이름 · 근거 차례로 몫 먼저
|
||||
top = btop[: fit.TOP_OPT]
|
||||
top, combo = combine.solve(top, parts, line, got, border["합침"])
|
||||
opts = {k: option.find(k, efp, text) for k in top[: fit.TOP_OPT]}
|
||||
for c in combo:
|
||||
if c["로직"] not in opts and c["체크"]:
|
||||
part = next((p for p in parts if p.get("제목") == c["어디"]), None)
|
||||
pfp = _part_fp(part, c["몫"], line) if part else efp
|
||||
opts[c["로직"]] = option.find(c["로직"], pfp, text) if pfp["인력"] else None
|
||||
theirs = efp["출처"]
|
||||
chans = {k: ch for _, k, ch, _ in pool}
|
||||
entry["순위"] = [
|
||||
{
|
||||
"로직": k,
|
||||
"점수": round(scores[k], 3) if k in scores else None,
|
||||
"채널": {a: round(b, 3) for a, b in (chans.get(k) or {}).items() if b is not None},
|
||||
"B": btop.index(k) if k in btop else None,
|
||||
}
|
||||
for k in top[: fit.TOP_OPT]
|
||||
]
|
||||
entry["차례"] = top[: fit.TOP_OPT]
|
||||
entry["옵션"] = opts
|
||||
entry["조합"] = combo or None
|
||||
entry["판정"] = judge(efp, top, opts, theirs, got)
|
||||
entry["후보"] = candidates(efp, top, opts, theirs, got, scores)
|
||||
return entry
|
||||
|
||||
|
||||
def judge(efp: dict, top: list[str], opts: dict, theirs: list, got: dict) -> dict[str, Any]:
|
||||
"""1위 판정 {상태, 글, 꼬리표}."""
|
||||
if not top:
|
||||
return {"상태": "후보", "글": "후보 없음", "꼬리표": []}
|
||||
names = {x["로직"]: x["이름"] for x in got["로직"]}
|
||||
t1, found = top[0], opts.get(top[0]) or {}
|
||||
state = _state(efp, t1, opts.get(t1), theirs)
|
||||
exact = [k for k in top[: fit.TOP_OPT] if _state(efp, k, opts.get(k), theirs) == "맞음"]
|
||||
tags = []
|
||||
if state == "맞음":
|
||||
label = _label(t1, found.get("입력") or {})
|
||||
text = " · ".join(x for x in ("맞음 — " + t1, names.get(t1, ""), label) if x)
|
||||
if len(exact) > 1:
|
||||
tags.append("맞음 둘")
|
||||
if found.get("갈리는칸"):
|
||||
tags.append(f"{' · '.join(found['갈리는칸'])} 못 가름")
|
||||
return {"상태": state, "글": text, "꼬리표": tags}
|
||||
if state == "다른 점":
|
||||
tags = _diffs(efp, found)
|
||||
return {"상태": state, "글": " · ".join(["다른 점 — " + t1, *tags[:2]]), "꼬리표": tags}
|
||||
return {"상태": "후보", "글": f"후보 {len(top[: fit.TOP_OPT])}", "꼬리표": []}
|
||||
|
||||
|
||||
def candidates(
|
||||
efp: dict, top: list[str], opts: dict, theirs: list, got: dict, scores: dict
|
||||
) -> list[dict[str, Any]]:
|
||||
"""후보 다섯 — 로직마다 옵션 판정 꼬리표 · 「맞음 둘」이면 둘째 맞음이 맨 앞."""
|
||||
by = {x["로직"]: x for x in got["로직"]}
|
||||
table = hopyo_basis.ours()
|
||||
out = []
|
||||
for k in top[: fit.TOP_OPT]:
|
||||
found = opts.get(k) or {}
|
||||
state = _state(efp, k, opts.get(k), theirs)
|
||||
our = table.get(k)
|
||||
sc, clauses, why = hopyo_match.clause(theirs, our) if (our and theirs) else (0, [], "")
|
||||
one = by.get(k) or {"로직": k, "이름": "", "단위": ""}
|
||||
text = state if state != "다른 점" else " · ".join(["다른 점", *_diffs(efp, found)[:1]])
|
||||
out.append(
|
||||
{
|
||||
"로직": k,
|
||||
"이름": one["이름"],
|
||||
"단위": one["단위"],
|
||||
"점수": round(scores[k], 3) if k in scores else None,
|
||||
"판정": state,
|
||||
"글": text,
|
||||
"근거": {"조항": clauses, "까닭": why},
|
||||
}
|
||||
)
|
||||
first = [c for c in out[1:] if c["판정"] == "맞음"][:1]
|
||||
if out and out[0]["판정"] == "맞음" and first:
|
||||
out = [out[0], *first, *[c for c in out[1:] if c is not first[0]]]
|
||||
return out
|
||||
@@ -0,0 +1,247 @@
|
||||
"""M02 일위대가 맞춤 낱말 · 식 도우미(PLAN 338-1 · 설계 2-2) — 엑셀 지문 · 우리 지문이 같이 씀.
|
||||
|
||||
장비 갈래(`mtypes`) · 규격 수(`specnums`) · 조건 낱말(`conds` · `cond_score`) ·
|
||||
식 풀기(`parse_rhs` — 「단가 × 식 = 금액」 → 품 · 맨 앞 단가는 뗌 · 금액 안 씀) · 닮음(`close` ·
|
||||
`cosine` ·
|
||||
`jacc`). 값 · 문턱은 336 실험(C4 `fp_common`) 그대로.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
# ── 장비 갈래(엑셀 글 · 우리 EQ 상세구분 공통) ─────────────────────────────
|
||||
MACH = (
|
||||
("굴착기", ("굴착기", "굴삭기", "백호", "b/h", "backhoe", "back-hoe", "포크레인")),
|
||||
("덤프트럭", ("덤프",)),
|
||||
("크레인", ("크레인",)),
|
||||
("브레이커", ("브레이커", "브레이카")),
|
||||
("콘크리트펌프", ("펌프차", "펌프카", "콘크리트펌프", "콘크리트 펌프")),
|
||||
("롤러", ("롤러", "로울러")),
|
||||
("불도저", ("불도저", "불도우저")),
|
||||
("집게", ("집게",)),
|
||||
("살수차", ("살수", "물탱크")),
|
||||
("모터그레이더", ("그레이더",)),
|
||||
("래머", ("래머", "램머", "래 머")),
|
||||
("콤팩터", ("콤팩터", "컴팩터")),
|
||||
("진동기", ("진동기",)),
|
||||
("발전기", ("발전기",)),
|
||||
("공기압축기", ("공기압축기", "컴프레셔", "콤프레샤")),
|
||||
("드릴", ("드릴", "천공기")),
|
||||
("커터", ("커터",)),
|
||||
("화물차", ("화물자동차", "화물차", "카고")),
|
||||
("로더", ("로더",)),
|
||||
("지게차", ("지게차",)),
|
||||
("체인톱", ("체인톱",)),
|
||||
("트랙터", ("트랙터",)),
|
||||
("믹서", ("믹서",)),
|
||||
("분무기", ("분무기",)),
|
||||
)
|
||||
_SPECNUM = re.compile(r"(\d+(?:\.\d+)?)\s*(㎥|m3|톤|ton|t\b|m\b|㎾|kw|ℓ|mm|hp)", re.I)
|
||||
|
||||
|
||||
def nfkc(text: Any) -> str:
|
||||
return unicodedata.normalize("NFKC", str(text or ""))
|
||||
|
||||
|
||||
def mtypes(text: Any) -> set[str]:
|
||||
t = nfkc(text).lower()
|
||||
return {k for k, ws in MACH if any(w in t for w in ws)}
|
||||
|
||||
|
||||
def specnums(text: Any) -> set[float]:
|
||||
return {float(a) for a, _ in _SPECNUM.findall(nfkc(text))}
|
||||
|
||||
|
||||
# ── 조건 낱말(축) ──────────────────────────────────────────────────────
|
||||
SOIL = ("보통토사", "경질토사", "혼합토사", "자갈섞인토사", "사질토", "점질토", "토사")
|
||||
ROCK = ("리핑암", "발파암", "풍화암", "연암", "보통암", "경암", "극경암")
|
||||
AXES = {
|
||||
"토질": SOIL + ROCK,
|
||||
"메찰": ("메쌓기", "찰쌓기", "메붙임", "찰붙임", "메붙이기", "찰붙이기"),
|
||||
"형": ("원형", "사각", "타원형"),
|
||||
"근": ("무근", "철근"),
|
||||
"난이": ("제물치장", "매우복잡", "복잡", "보통", "간단"),
|
||||
"규모": ("소량", "소형", "소규모", "중규모", "대규모", "대형"),
|
||||
"방법": ("인력운반", "인력", "기계", "장비", "펌프"),
|
||||
}
|
||||
CLASH = ("토질", "메찰", "형") # 이름에 박힌 값이 엑셀과 어긋나면 깎는 축
|
||||
_ROCKISH = re.compile(r"(\(암\)|암\)|[^가-힣]암$|^암$|암절취|\b암\b)")
|
||||
|
||||
|
||||
def soil_family(word: str) -> str | None:
|
||||
if word in ROCK or word == "암":
|
||||
return "암"
|
||||
return "토사" if word in SOIL else None
|
||||
|
||||
|
||||
def conds(text: Any) -> dict[str, set[str]]:
|
||||
"""{축: {낱말}} + 횟수 · 개월 · Type."""
|
||||
t = re.sub(r"\s+", "", nfkc(text))
|
||||
out: dict[str, set[str]] = {}
|
||||
for ax, ws in AXES.items():
|
||||
hit, rest = set(), t
|
||||
for w in sorted(ws, key=len, reverse=True):
|
||||
if w in rest:
|
||||
hit.add(w)
|
||||
rest = rest.replace(w, " ")
|
||||
if ax == "토질" and _ROCKISH.search(t):
|
||||
hit.add("암")
|
||||
if hit:
|
||||
out[ax] = hit
|
||||
if n := re.findall(r"(\d)회", t):
|
||||
out["횟수"] = {f"{x}회" for x in n}
|
||||
if m := re.findall(r"(\d+)개월", t):
|
||||
out["개월"] = {f"{x}개월" for x in m}
|
||||
if ty := re.findall(r"type[-\s]*(ⅰ|ⅱ|ii|i)\b", t.lower()):
|
||||
out["type"] = {"Ⅱ" if x in ("ⅱ", "ii") else "Ⅰ" for x in ty}
|
||||
return out
|
||||
|
||||
|
||||
def cond_score(excel: dict, name_c: dict, opt_c: dict) -> tuple[float, int]:
|
||||
"""(맞음 비율, 어긋남) — 엑셀 조건 낱말이 우리 이름 · 고르기 글에 있나 · 이름 축 어긋남."""
|
||||
words = [(ax, w) for ax, ws in excel.items() for w in ws]
|
||||
if not words:
|
||||
return 0.0, 0
|
||||
hit = 0
|
||||
for ax, w in words:
|
||||
pool = name_c.get(ax, set()) | opt_c.get(ax, set())
|
||||
fams = {soil_family(x) for x in pool}
|
||||
if w in pool or (ax == "토질" and soil_family(w) in fams):
|
||||
hit += 1
|
||||
clash = 0
|
||||
for ax in CLASH:
|
||||
if ax in excel and ax in name_c:
|
||||
if ax == "토질":
|
||||
ef = {soil_family(w) for w in excel[ax]}
|
||||
of = {soil_family(w) for w in name_c[ax]}
|
||||
clash |= bool(ef and of and not ef & of)
|
||||
elif not excel[ax] & name_c[ax]:
|
||||
clash = 1
|
||||
return hit / len(words), clash
|
||||
|
||||
|
||||
# ── 식 풀기 — 「단가 × 식 = 금액」 → 품(단가로 나눈 몫 · 단가는 버림) ─────────
|
||||
_UNITFRAC = re.compile(
|
||||
r"(인|개|본|공|EA|ea|hr|ton|Ton|TON|kg|㎥|㎡|m3|m2|m|M|조|회)\s*/\s*"
|
||||
r"(EA|ea|㎥|㎡|m3|m2|m|M|ton|Ton|TON|t|hr|일|개|본|조|공㎥|㎥)"
|
||||
)
|
||||
_UNITTAIL = re.compile(
|
||||
r"(?<=[\d.)])\s*(hr|HR|EA|ea|kg|km|ton|Ton|TON|sec|m3|m2|mm|cm|M|m|t)(?![A-Za-z])"
|
||||
)
|
||||
_NOTE = re.compile(r"\(([^()]*[가-힣][^()]*)\)")
|
||||
_LEAD = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([*/])\s*(.+)$")
|
||||
_SAFE = re.compile(r"^[\d.+\-*/() ]+$")
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
s = nfkc(s).replace("×", "*").replace("✕", "*").replace("÷", "/")
|
||||
s = s.replace("x", "*").replace("X", "*")
|
||||
s = re.sub(r"(?<=\d),(?=\d{3})", "", s)
|
||||
return _UNITFRAC.sub("", s)
|
||||
|
||||
|
||||
def _split(rest: str, first_op: str) -> list[tuple[str, str]]:
|
||||
"""맨 바깥 * / 로 쪼갬 → [(연산, 조각)]."""
|
||||
out, depth, cur, op = [], 0, "", first_op
|
||||
for ch in rest:
|
||||
depth += (ch == "(") - (ch == ")")
|
||||
if depth == 0 and ch in "*/":
|
||||
out.append((op, cur))
|
||||
op, cur = ch, ""
|
||||
continue
|
||||
cur += ch
|
||||
out.append((op, cur))
|
||||
return out
|
||||
|
||||
|
||||
def _factor(text: str) -> tuple[float, bool, str] | None:
|
||||
"""조각 → (값, 율 표시, 메모) · 변수(P · Q …)면 None."""
|
||||
notes = _NOTE.findall(text)
|
||||
t = _NOTE.sub("", text)
|
||||
pct = "%" in t
|
||||
if re.search(r"\d\s*회", t): # 「× 2회」 — 횟수 곱(율로 가름)
|
||||
notes.append("회")
|
||||
t = re.sub(r"[가-힣㎥㎡㎝㎜℃˚°'\"]", "", t).replace("%", "")
|
||||
t = _UNITTAIL.sub("", t).strip()
|
||||
if not t or re.search(r"[A-Za-z]", t) or not _SAFE.match(t):
|
||||
return None
|
||||
try:
|
||||
v = eval(t, {"__builtins__": {}}, {}) # noqa: S307 — 숫자 · 연산 글만(_SAFE)
|
||||
except Exception: # noqa: BLE001 — 0 나눔 · 괄호 짝 틀림
|
||||
return None
|
||||
return float(v) / (100 if pct else 1), pct or bool(notes), " ".join(notes)
|
||||
|
||||
|
||||
def _rates_any(parts: list[str]) -> list[tuple[float, str]]:
|
||||
"""모든 조각의 율(메모 · %) — 풀 수 있는 조각만."""
|
||||
out: list[tuple[float, str]] = []
|
||||
for seg in parts:
|
||||
m = _LEAD.match(seg)
|
||||
if not m:
|
||||
continue
|
||||
for op, piece in _split(m.group(3), m.group(2)):
|
||||
f = _factor(piece)
|
||||
if f and f[1] and f[0] and all(abs(f[0] - v) > 1e-9 for v, _ in out):
|
||||
out.append((f[0] if op == "*" else 1 / f[0], f[2] or "%"))
|
||||
return out
|
||||
|
||||
|
||||
def parse_rhs(rhs: str) -> dict[str, Any] | None:
|
||||
"""「단가 × 식 [= 바꿔 쓴 식] = 금액」 → {단가, 품, 밑품, 율, 인자} · 못 읽으면 None."""
|
||||
parts = _norm(rhs).split("=")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
every = _rates_any(parts[:-1])
|
||||
for seg in parts[:-1]:
|
||||
m = _LEAD.match(seg)
|
||||
if not m:
|
||||
continue
|
||||
facs = []
|
||||
for op, piece in _split(m.group(3), m.group(2)):
|
||||
f = _factor(piece)
|
||||
if f is None:
|
||||
break
|
||||
facs.append((op, *f))
|
||||
else:
|
||||
if not facs or any(v == 0 and op == "/" for op, v, _, _ in facs):
|
||||
continue
|
||||
full = base = 1.0
|
||||
rates = []
|
||||
for op, v, rate, note in facs:
|
||||
full = full * v if op == "*" else full / v
|
||||
if rate:
|
||||
rates.append([round(v if op == "*" else 1 / v, 6), note or "%"])
|
||||
else:
|
||||
base = base * v if op == "*" else base / v
|
||||
if not rates and every: # 바꿔 쓴 식엔 메모가 빠짐 — 앞 조각의 율로 밑품
|
||||
for v, _ in every:
|
||||
base /= v or 1
|
||||
rates = [[round(v, 6), note] for v, note in every]
|
||||
plain = [v if op == "*" else 1 / v for op, v, rate, _ in facs if not rate and v]
|
||||
return {"단가": float(m.group(1)), "품": full, "밑품": base, "율": rates, "인자": plain}
|
||||
return None
|
||||
|
||||
|
||||
# ── 닮음 ───────────────────────────────────────────────────────────────
|
||||
def close(a: Any, b: Any, tol: float = 0.15) -> float:
|
||||
"""두 양수 가까움 0~1 — ln 비 3% 안 = 1 · 그 밖은 떨어짐."""
|
||||
if not a or not b or a <= 0 or b <= 0:
|
||||
return 0.0
|
||||
d = abs(math.log(a / b))
|
||||
return 1.0 if d <= 0.03 else max(0.0, 1 - (d - 0.03) / (tol * 4))
|
||||
|
||||
|
||||
def cosine(a: dict, b: dict) -> float:
|
||||
num = sum(a.get(k, 0) * b.get(k, 0) for k in set(a) | set(b))
|
||||
da = math.sqrt(sum(v * v for v in a.values()))
|
||||
db = math.sqrt(sum(v * v for v in b.values()))
|
||||
return num / (da * db) if da and db else 0.0
|
||||
|
||||
|
||||
def jacc(a: Any, b: Any) -> float:
|
||||
a, b = set(a), set(b)
|
||||
return len(a & b) / len(a | b) if a | b else 0.0
|
||||
@@ -49,8 +49,8 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 줄 표 — 갈래 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 입력값 · 환산 · 단추 = 줄바꿈 없음(글자가 세로로 안
|
||||
갈라짐 · 폭 = 글 폭) · 품명 · 규격 · 대상이 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
|
||||
/* 확인 창 줄 표(338) — 체크 · 갈래 · 수량 · 단위 · 상태 · 단추 = 줄바꿈 없음(글자가 세로로 안 갈라짐 ·
|
||||
폭 = 글 폭) · 품명 · 규격 · 우리 로직 · 옵션 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
|
||||
.m02-hopyo__table.m02-hopyo__lines :is(th, td) {
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
@@ -58,19 +58,17 @@
|
||||
.m02-hopyo__table.m02-hopyo__lines
|
||||
:is(th, td):is(
|
||||
:nth-child(1),
|
||||
:nth-child(4),
|
||||
:nth-child(2),
|
||||
:nth-child(5),
|
||||
:nth-child(6),
|
||||
:nth-child(7),
|
||||
:nth-child(9),
|
||||
:nth-child(10),
|
||||
:nth-child(11)
|
||||
) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 입력값 — 길면 줄임표(전체 글은 칸 풍선 도움말) */
|
||||
.m02-hopyo__table.m02-hopyo__lines td:nth-child(9) {
|
||||
/* 옵션 · 품 비교 — 길면 줄임표(전체 글은 칸 풍선 도움말) */
|
||||
.m02-hopyo__table.m02-hopyo__lines td:is(:nth-child(9), :nth-child(10)) {
|
||||
max-width: 24em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -89,23 +87,56 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-hopyo__state.is-기억,
|
||||
.m02-hopyo__state.is-같음,
|
||||
/* 상태 색(338) — 맞음 초록 · 별칭 파랑 · 다른 점 주황 · 후보 회색(테두리 기본) · 없음 빨강 */
|
||||
.m02-hopyo__state.is-맞음,
|
||||
.m02-hopyo__state.is-호표 {
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.m02-hopyo__state.is-후보 {
|
||||
.m02-hopyo__state.is-별칭 {
|
||||
border-color: var(--color-info);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.m02-hopyo__state.is-다른점 {
|
||||
border-color: var(--color-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.m02-hopyo__state.is-후보,
|
||||
.m02-hopyo__state.is-뺌,
|
||||
.m02-hopyo__state.is-노임 {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m02-hopyo__state.is-없음 {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* 우리 로직 칸 — 대상 여럿 = 칸 안 여러 줄(이름 · 키 · 몫 · 배 칸 · [옵션]) */
|
||||
.m02-hopyo__parts {
|
||||
min-width: 14em;
|
||||
}
|
||||
|
||||
.m02-hopyo__part {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-hopyo__part + .m02-hopyo__part {
|
||||
margin-top: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m02-hopyo__whole {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.m02-hopyo__times {
|
||||
width: 6em;
|
||||
}
|
||||
@@ -150,6 +181,22 @@
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-hopyo__cand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 별칭 장부(338) — 위 층 줄 = 회색 읽기만 · 고친 줄 = 왼쪽 띠 */
|
||||
.m02-hopyo-ledger__row.is-upper {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.m02-hopyo-ledger__row.is-edited > td:first-child {
|
||||
box-shadow: inset 3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
/* 엑셀 보기 창(329) — 왼쪽 읽기 전용 시트 · 오른쪽 호표 목록 · 불러오기 결과 */
|
||||
.m02-hopyo-sheet__dialog > .ui-modal__body {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Api.ts
|
||||
* 일위대가 실무자료(PLAN 324) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) ·
|
||||
* 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로).
|
||||
* 일위대가 실무자료(PLAN 324 · 338) 서버 길 — `/api/m02/layers/{층}/hopyo…` · 층 = 화면 층(`Api_Fetch` 지금 층) ·
|
||||
* 올리기 = FormData(숨은 file 입력) · 그 밖 = 공용 `call`(서버 까닭 글 그대로) ·
|
||||
* 맞춤 응답 꼴 = 설계 3-3(상태 · 체크 제안 · 대상 목록 · 품 비교) · 만들기 몸 = 확인한 줄만(3-6) · 별칭 장부(3-5).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -33,7 +34,7 @@ export interface HopyoBrief {
|
||||
규격: string;
|
||||
단위: string;
|
||||
금액: Money;
|
||||
/** [들어감 수, 줄 수] */
|
||||
/** [체크 제안 수, 줄 수] */
|
||||
맞춤: [number, number];
|
||||
/** 일위대가 시트 행 [시작, 끝](엑셀 1부터 · 329 · 옛 파일 없음) */
|
||||
행?: [number, number];
|
||||
@@ -75,7 +76,7 @@ export interface HopyoLineBasis {
|
||||
행: [number, number];
|
||||
}
|
||||
|
||||
/** 맞춤 대상 — 로직 · 자재 · 다른 호표 */
|
||||
/** 후보 · 고른 대상 글 — 로직 · 자재 · 다른 호표 */
|
||||
export interface HopyoTarget {
|
||||
로직?: string;
|
||||
자재?: string;
|
||||
@@ -86,17 +87,55 @@ export interface HopyoTarget {
|
||||
점수?: number;
|
||||
/** 근거 맞춤(330 · 옛 파일 · 근거 없는 줄은 없음) */
|
||||
근거?: HopyoBasisHit;
|
||||
/** 후보 판정(338) — 맞음 · 다른 점 · 후보 */
|
||||
판정?: "맞음" | "다른 점" | "후보";
|
||||
글?: string;
|
||||
}
|
||||
|
||||
/** 맞춤 대상 하나(338 · 로직 여럿 가능) — 배 = 환산 × 몫 · 우리 수량 = 엑셀 수량 × 배 */
|
||||
export interface HopyoPart extends HopyoTarget {
|
||||
입력값: Record<string, unknown>;
|
||||
못읽음?: string[];
|
||||
/** 칸마다 값 출처 — 별칭 · 블록 · 규격 · 자동값 */
|
||||
출처?: Record<string, string>;
|
||||
환산?: { 배: number | null; 갈래: string } | null;
|
||||
몫?: number | null;
|
||||
배: number | null;
|
||||
/** 블록 절 제목(조합 덧 로직) */
|
||||
어디?: string;
|
||||
}
|
||||
|
||||
/** 품 묶음 — 직종 품 · 장비 시간 · 자재 수량 */
|
||||
export interface HopyoQty {
|
||||
인력?: Record<string, number>;
|
||||
장비?: { 갈래: string | string[]; 시간: number }[];
|
||||
자재?: { 이름: string; 수량: number }[];
|
||||
}
|
||||
|
||||
export type HopyoState =
|
||||
"맞음" | "별칭" | "다른 점" | "후보" | "없음" | "뺌" | "호표" | "%" | "노임";
|
||||
|
||||
export interface HopyoMatch {
|
||||
열쇠: string;
|
||||
상태: "기억" | "같음" | "후보" | "없음" | "빼기" | "호표" | "%";
|
||||
대상: HopyoTarget | null;
|
||||
입력값: Record<string, unknown>;
|
||||
못읽음: string[];
|
||||
환산: { 배: number | null; 갈래: string } | null;
|
||||
들어감: boolean;
|
||||
상태: HopyoState;
|
||||
/** 처음 체크 제안(확인 대기 — 들어감 아님) */
|
||||
체크: boolean;
|
||||
대상: HopyoPart[];
|
||||
후보?: HopyoTarget[];
|
||||
글?: string;
|
||||
꼬리표?: string[];
|
||||
품?: { 엑셀: HopyoQty; 우리: HopyoQty; 같음: Record<string, boolean> };
|
||||
갈리는칸?: string[];
|
||||
별칭?: { 층: Layer; 주인: string | null; 규격다름?: boolean; 근거다름?: boolean };
|
||||
뺌?: string;
|
||||
}
|
||||
|
||||
/** 숫자 판정 셈 — 끝 · 한 블록 · 모두 · 까닭(우리 로직 준비 중 등) */
|
||||
export interface HopyoCalc {
|
||||
끝: boolean;
|
||||
한: number;
|
||||
모두: number;
|
||||
까닭?: string;
|
||||
}
|
||||
|
||||
export interface HopyoLine {
|
||||
@@ -115,14 +154,38 @@ export interface HopyoLine {
|
||||
}
|
||||
|
||||
export interface HopyoOne extends HopyoFile {
|
||||
호표: HopyoBrief & { 줄: HopyoLine[] };
|
||||
호표: HopyoBrief & {
|
||||
줄: HopyoLine[];
|
||||
/** 직접 줄 호표 통째 제안(노임 · 장비를 바로 적은 호표) */
|
||||
통째?: { 대상: HopyoPart[]; 글?: string; 체크: boolean } | null;
|
||||
셈?: HopyoCalc;
|
||||
/** 만들 층에 이 호표로 만든 사전조합 */
|
||||
만든?: { 키: string; 확인: boolean } | null;
|
||||
};
|
||||
}
|
||||
|
||||
/** 미리 셈(저장 안 함) — 고른 대상 · 옵션으로 우리 품 */
|
||||
export interface HopyoGuess {
|
||||
대상: HopyoTarget;
|
||||
입력값: Record<string, unknown>;
|
||||
못읽음: string[];
|
||||
환산: { 배: number | null; 갈래: string };
|
||||
대상: HopyoPart[];
|
||||
품?: HopyoMatch["품"];
|
||||
글?: string;
|
||||
}
|
||||
|
||||
/** 만들기 몸 대상 하나 — 로직 · 자재 · 호표 중 하나(금액 · 품 칸 없음 — 서버가 셈) */
|
||||
export interface BuildTarget {
|
||||
로직?: string;
|
||||
자재?: string;
|
||||
호표?: number;
|
||||
입력값?: Record<string, unknown>;
|
||||
배: number;
|
||||
몫?: number | null;
|
||||
}
|
||||
|
||||
/** 확인한 줄 — 대상 목록 또는 뺌 까닭 */
|
||||
export interface BuildLine {
|
||||
차례: number;
|
||||
대상?: BuildTarget[];
|
||||
뺌?: "우리에 없음" | "안 씀";
|
||||
}
|
||||
|
||||
export interface HopyoMissing {
|
||||
@@ -141,22 +204,45 @@ export interface HopyoBuilt {
|
||||
다시씀: boolean;
|
||||
빠진줄: HopyoMissing[];
|
||||
비교: { 엑셀: number | null; 우리: number | null; 차이: number | null; 까닭: string | null };
|
||||
장부?: { 씀: number };
|
||||
}
|
||||
|
||||
/** [로직 추가] 「실무 호표」 줄 하나 */
|
||||
/** [로직 추가] 「실무 호표」 줄 하나 — 확인 = 문서 층에 확인된 사전조합 */
|
||||
export interface HopyoPickRow extends Omit<HopyoBrief, "금액"> {
|
||||
층: Layer;
|
||||
주인: string | null;
|
||||
키: string;
|
||||
파일: string;
|
||||
확인?: LaborRef | null;
|
||||
}
|
||||
|
||||
/** 확정 한 줄 — 로직 · 자재 · 빼기 · null(지움) */
|
||||
export type HopyoMemory =
|
||||
| { 로직: string; 입력값: Record<string, unknown>; 환산: number }
|
||||
| { 자재: string; 입력값: Record<string, unknown>; 환산: number }
|
||||
| { 빼기: true }
|
||||
| null;
|
||||
/** 별칭 장부 짝 하나 */
|
||||
export interface LedgerPair {
|
||||
로직?: string;
|
||||
자재?: string;
|
||||
입력값: Record<string, unknown>;
|
||||
배: number;
|
||||
몫: number | null;
|
||||
}
|
||||
|
||||
/** 별칭 장부 한 줄(쓰기 꼴) */
|
||||
export interface LedgerEntry {
|
||||
원래: { 품명: string; 규격: string; 단위: string };
|
||||
갈래: "짝" | "우리에 없음" | "안 씀";
|
||||
짝: LedgerPair[];
|
||||
엑셀조항?: unknown[];
|
||||
}
|
||||
|
||||
/** 별칭 장부 보기 한 줄 — 층 · 고침(이 층 · 쓰기 권한) */
|
||||
export interface LedgerRow extends LedgerEntry {
|
||||
열쇠: string;
|
||||
누가: { id: number; 이름: string | null } | null;
|
||||
때: string | null;
|
||||
처음파일: string | null;
|
||||
층: Layer;
|
||||
주인: string | null;
|
||||
고침: boolean;
|
||||
}
|
||||
|
||||
const enc = encodeURIComponent;
|
||||
/** 지금 화면 층(구조물 조합 층) */
|
||||
@@ -177,39 +263,58 @@ export const readHopyo = (scope: HopyoScope, key: string): Promise<HopyoView> =>
|
||||
export const readHopyoGrid = (scope: HopyoScope, key: string): Promise<HopyoGrid> =>
|
||||
call(at(scope, `/hopyo-grid/${enc(key)}`));
|
||||
|
||||
export const readHopyoOne = (scope: HopyoScope, key: string, no: number): Promise<HopyoOne> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}/${no}`));
|
||||
/** 호표 하나 — `to` = 만들 층(호표 참조 줄 체크 · 만든 · 없으면 파일 층) */
|
||||
export const readHopyoOne = (
|
||||
scope: HopyoScope,
|
||||
key: string,
|
||||
no: number,
|
||||
to?: HopyoScope,
|
||||
): Promise<HopyoOne> => {
|
||||
const want = to ? `?to=${to.layer}${to.owner ? `&to_owner=${enc(to.owner)}` : ""}` : "";
|
||||
return call(at(scope, `/hopyo/${enc(key)}/${no}${want}`));
|
||||
};
|
||||
|
||||
/** 미리 셈(저장 안 함) — 고른 대상 목록 · 옵션 → 우리 품 */
|
||||
export const guessHopyo = (
|
||||
scope: HopyoScope,
|
||||
key: string,
|
||||
no: number,
|
||||
차례: number,
|
||||
대상: HopyoTarget,
|
||||
대상: BuildTarget[],
|
||||
): Promise<HopyoGuess> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}/${no}/guess`), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 차례, 대상 }),
|
||||
});
|
||||
|
||||
export const saveHopyoMemory = (
|
||||
export const readLedger = async (scope: HopyoScope): Promise<LedgerRow[]> =>
|
||||
(await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"))).줄;
|
||||
|
||||
/** 별칭 장부 [저장] — 고친 줄 · null(지움) · 이 층만 */
|
||||
export const saveLedger = async (
|
||||
scope: HopyoScope,
|
||||
줄: Record<string, HopyoMemory>,
|
||||
): Promise<unknown> =>
|
||||
call(at(scope, "/hopyo-memory"), { method: "POST", body: JSON.stringify({ 줄 }) });
|
||||
줄: Record<string, LedgerEntry | null>,
|
||||
): Promise<LedgerRow[]> =>
|
||||
(
|
||||
await call<{ 줄: LedgerRow[] }>(at(scope, "/hopyo-ledger"), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ 줄 }),
|
||||
})
|
||||
).줄;
|
||||
|
||||
export const deleteHopyo = (scope: HopyoScope, key: string): Promise<unknown> =>
|
||||
call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE" });
|
||||
|
||||
/** 실무 호표 → `target` 층 사전조합(파일 층 = `from`) */
|
||||
/** [확인하고 사전조합 만들기] — 확인한 줄만 · 직접 줄 호표 = `통째` → `target` 층 사전조합(파일 층 = `from`) */
|
||||
export const buildHopyo = (
|
||||
target: HopyoScope,
|
||||
from: { 층: Layer; 주인: string | null; 키: string; 번: number },
|
||||
새로 = false,
|
||||
줄: BuildLine[],
|
||||
통째?: { 대상: BuildTarget[] } | null,
|
||||
): Promise<HopyoBuilt> =>
|
||||
call(at(target, "/hopyo-build"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...from, 새로 }),
|
||||
body: JSON.stringify(통째 ? { ...from, 통째 } : { ...from, 줄 }),
|
||||
});
|
||||
|
||||
export const listHopyoPicks = async (scope: HopyoScope): Promise<HopyoPickRow[]> =>
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Check.ts
|
||||
* 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 ·
|
||||
* 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [확인하고 사전조합 만들기]로 서버에 보냄.
|
||||
* · 자리 = 실무자료 본 칸(호표 누름) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침 [확인 창 열기]
|
||||
* · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로)
|
||||
* · 줄 = [✓] · 갈래 · 품명 · 규격 · 수량 · 단위 · 상태(맞음 · 별칭 · 다른 점 · 후보) · 우리 로직(여럿 · 몫 · 배) ·
|
||||
* 옵션(칸=값 · 출처 · 못 가름) · 품 비교(엑셀 → 우리 ✓ / ≠) · [고르기] [원문 보기] [엑셀에서 보기] · 뺌 까닭
|
||||
* · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈
|
||||
* · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음
|
||||
* · 화면 잠금은 [확인하고 사전조합 만들기] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
|
||||
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) — 화면은 보이기 · 체크 · 고름만.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
createButton,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
|
||||
import {
|
||||
buildHopyo,
|
||||
guessHopyo,
|
||||
readHopyoOne,
|
||||
type BuildLine,
|
||||
type BuildTarget,
|
||||
type HopyoBuilt,
|
||||
type HopyoLine,
|
||||
type HopyoMatch,
|
||||
type HopyoOne,
|
||||
type HopyoPart,
|
||||
type HopyoScope,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import {
|
||||
builtWord,
|
||||
inputsText,
|
||||
openHopyoChoose,
|
||||
sourceText,
|
||||
targetText,
|
||||
} from "./M02_MasterTemplete_Hopyo_Choose";
|
||||
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export const POLL_MS = 3000;
|
||||
const OUT = ["우리에 없음", "안 씀"] as const;
|
||||
|
||||
/** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */
|
||||
export interface CheckDraft {
|
||||
체크: Record<number, boolean>;
|
||||
대상: Record<number, HopyoPart[]>;
|
||||
뺌: Record<number, string>;
|
||||
품: Record<number, HopyoMatch["품"]>;
|
||||
통째?: boolean;
|
||||
}
|
||||
|
||||
const empty = (): CheckDraft => ({ 체크: {}, 대상: {}, 뺌: {}, 품: {} });
|
||||
export const draftKey = (scope: HopyoScope, key: string, no: number): string =>
|
||||
`m02-hopyo-check:${scope.layer}:${scope.owner ?? ""}:${key}:${no}`;
|
||||
|
||||
export function loadDraft(k: string): CheckDraft {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(k);
|
||||
return raw ? { ...empty(), ...(JSON.parse(raw) as Partial<CheckDraft>) } : empty();
|
||||
} catch {
|
||||
return empty();
|
||||
}
|
||||
}
|
||||
export function saveDraft(k: string, d: CheckDraft): void {
|
||||
try {
|
||||
sessionStorage.setItem(k, JSON.stringify(d));
|
||||
} catch {
|
||||
/* 캐시 못 씀 = 이 화면에만 남음 */
|
||||
}
|
||||
}
|
||||
export function dropDraft(k: string): void {
|
||||
try {
|
||||
sessionStorage.removeItem(k);
|
||||
} catch {
|
||||
/* 없음 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 줄의 지금 대상 — 사용자가 고른 것(캐시) · 없으면 서버 제안 */
|
||||
export const partsOf = (line: HopyoLine, d: CheckDraft): HopyoPart[] => {
|
||||
const got = d.대상[line.차례] ?? line.맞춤.대상;
|
||||
return Array.isArray(got) ? got : []; // 옛 응답(대상 하나) = 대상 없음
|
||||
};
|
||||
/** 체크 — 사용자가 만진 줄은 캐시 · 아니면 서버 제안 */
|
||||
export const isChecked = (line: HopyoLine, d: CheckDraft): boolean =>
|
||||
d.체크[line.차례] ?? !!line.맞춤.체크;
|
||||
/** 체크할 수 있음 — % 줄 아님 · 뺌 까닭 또는 대상(배 0 넘음) */
|
||||
export function checkable(line: HopyoLine, d: CheckDraft): boolean {
|
||||
if (line.맞춤.상태 === "%") return false;
|
||||
if (d.뺌[line.차례]) return true;
|
||||
const parts = partsOf(line, d);
|
||||
return parts.length > 0 && parts.every((p) => p.배 !== null && p.배 > 0);
|
||||
}
|
||||
|
||||
/** 대상 → 만들기 몸 대상(이름 · 품 같은 보이기 칸은 안 보냄) */
|
||||
export function toTarget(p: HopyoPart): BuildTarget {
|
||||
if (p.호표 !== undefined) return { 호표: p.호표, 배: p.배 ?? 1 };
|
||||
const head = p.로직 ? { 로직: p.로직, 입력값: p.입력값 ?? {} } : { 자재: p.자재 };
|
||||
return { ...head, 배: p.배 ?? 1, ...(p.몫 != null ? { 몫: p.몫 } : {}) };
|
||||
}
|
||||
|
||||
/** 체크한 줄만 → 만들기 몸 `줄` */
|
||||
export const bodyOf = (lines: HopyoLine[], d: CheckDraft): BuildLine[] =>
|
||||
lines
|
||||
.filter((l) => isChecked(l, d) && checkable(l, d))
|
||||
.map((l) =>
|
||||
d.뺌[l.차례]
|
||||
? { 차례: l.차례, 뺌: d.뺌[l.차례] as BuildLine["뺌"] }
|
||||
: { 차례: l.차례, 대상: partsOf(l, d).map(toTarget) },
|
||||
);
|
||||
|
||||
/** 빠질 줄 까닭별 수 — 확인 안 함 · 뺌 · % 줄 */
|
||||
export function leftOut(lines: HopyoLine[], d: CheckDraft): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const l of lines) {
|
||||
const why =
|
||||
l.맞춤.상태 === "%"
|
||||
? "% 줄"
|
||||
: !isChecked(l, d) || !checkable(l, d)
|
||||
? "확인 안 함"
|
||||
: d.뺌[l.차례]
|
||||
? "뺌"
|
||||
: null;
|
||||
if (why) out[why] = (out[why] ?? 0) + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const n4 = (v: number | undefined): string =>
|
||||
v === undefined ? "–" : String(Math.round(v * 10000) / 10000);
|
||||
|
||||
/** 품 비교 글 — 「콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠」 */
|
||||
export function compareText(q: HopyoMatch["품"] | undefined): string {
|
||||
if (!q) return "";
|
||||
const ex = q.엑셀.인력 ?? {};
|
||||
const ours = q.우리.인력 ?? {};
|
||||
const mark = (k: string): string => (q.같음[k] ? "✓" : "≠");
|
||||
const out = [...new Set([...Object.keys(ex), ...Object.keys(ours)])].map(
|
||||
(k) => `${k} ${n4(ex[k])} → ${n4(ours[k])} ${mark(k)}`,
|
||||
);
|
||||
const name = (g: string | string[]): string => [g].flat().join("+");
|
||||
for (const x of q.엑셀.장비 ?? []) {
|
||||
const o = (q.우리.장비 ?? []).find((y) => name(y.갈래) === name(x.갈래));
|
||||
out.push(`${name(x.갈래)} ${n4(x.시간)} → ${n4(o?.시간)} ${mark([x.갈래].flat()[0])}`);
|
||||
}
|
||||
return out.join(" · ");
|
||||
}
|
||||
|
||||
/** 줄마다 최신 요청만 — `ask(줄)` 이 돌려준 함수가 그 뒤 같은 줄 요청이 없을 때만 true */
|
||||
export function newest(): (key: number) => () => boolean {
|
||||
const last = new Map<number, number>();
|
||||
return (key) => {
|
||||
const my = (last.get(key) ?? 0) + 1;
|
||||
last.set(key, my);
|
||||
return () => last.get(key) === my;
|
||||
};
|
||||
}
|
||||
|
||||
/** [맞음 · 별칭 모두 체크] 대상 줄 */
|
||||
export const sureLines = (lines: HopyoLine[], d: CheckDraft): HopyoLine[] =>
|
||||
lines.filter((l) => ["맞음", "별칭"].includes(l.맞춤.상태) && checkable(l, d));
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
|
||||
const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "error");
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
const td = (text: string, cls = ""): HTMLElement =>
|
||||
el("td", { text, className: cls, attrs: { title: text } });
|
||||
const openSheet = (o: HopyoSheetOptions): void =>
|
||||
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
|
||||
|
||||
export interface CheckOptions {
|
||||
/** 실무자료 파일 층 */
|
||||
scope: HopyoScope;
|
||||
key: string;
|
||||
no: number;
|
||||
/** 사전조합을 만들 층(없으면 파일 층) */
|
||||
target?: HopyoScope;
|
||||
/** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */
|
||||
canWrite: boolean;
|
||||
/** [← 호표 목록](본 칸) */
|
||||
onBack?: () => void;
|
||||
/** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */
|
||||
onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void;
|
||||
}
|
||||
|
||||
/** 확인 창 몸 — 본 칸 · 모달이 같이 씀 */
|
||||
export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
|
||||
const root = el("div", {
|
||||
className: "m02-hopyo m02-hopyo-check",
|
||||
attrs: { "data-hopyo-check": `${opt.key}|${opt.no}` },
|
||||
});
|
||||
const target = opt.target ?? opt.scope;
|
||||
const k = draftKey(opt.scope, opt.key, opt.no);
|
||||
let d = loadDraft(k);
|
||||
let got: HopyoOne | null = null;
|
||||
let built: HopyoBuilt | null = null;
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
const ask = newest(); // 줄마다 최신 미리 셈만
|
||||
const keep = (): void => saveDraft(k, d);
|
||||
|
||||
async function load(quiet = false): Promise<void> {
|
||||
const my = ++seq;
|
||||
const stop = quiet ? () => {} : showLoading(root);
|
||||
try {
|
||||
const one = await readHopyoOne(opt.scope, opt.key, opt.no, opt.target);
|
||||
if (my !== seq) return;
|
||||
got = one;
|
||||
draw();
|
||||
} catch (error) {
|
||||
if (my === seq && !quiet) root.replaceChildren(loadingError(why(error), () => void load()));
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
|
||||
/** 대상 바꿈 → 캐시 · 체크 · 서버 미리 셈(최신 응답만) */
|
||||
function setParts(line: HopyoLine, parts: HopyoPart[]): void {
|
||||
d.대상[line.차례] = parts;
|
||||
d.체크[line.차례] = true;
|
||||
delete d.뺌[line.차례];
|
||||
keep();
|
||||
draw();
|
||||
const mine = ask(line.차례);
|
||||
guessHopyo(opt.scope, opt.key, opt.no, line.차례, parts.map(toTarget)).then((g) => {
|
||||
if (!mine()) return;
|
||||
if (Array.isArray(g.대상)) d.대상[line.차례] = g.대상;
|
||||
if (g.품) d.품[line.차례] = g.품;
|
||||
keep();
|
||||
draw();
|
||||
}, fail);
|
||||
}
|
||||
|
||||
function editOptions(line: HopyoLine, at: number): void {
|
||||
const parts = partsOf(line, d);
|
||||
const p = parts[at];
|
||||
if (!p?.로직) return;
|
||||
fetchLogicInputs([p.로직]).then(
|
||||
(res) =>
|
||||
openLaborInput({
|
||||
title: `${p.로직} ${p.이름 ?? ""}`.trim(),
|
||||
logic: res.로직[0] ?? null,
|
||||
values: (p.입력값 ?? {}) as never,
|
||||
memo: null,
|
||||
readOnly: !opt.canWrite,
|
||||
done: (r) =>
|
||||
setParts(
|
||||
line,
|
||||
parts.map((x, i) => (i === at ? { ...x, 입력값: r.입력값 } : x)),
|
||||
),
|
||||
}),
|
||||
fail,
|
||||
);
|
||||
}
|
||||
|
||||
/** [원문 보기] — 엑셀 인용 조항 · 우리 로직 출처 두 갈래 */
|
||||
async function showSource(line: HopyoLine): Promise<void> {
|
||||
const excel = (line.근거?.출처 ?? []).map((x) => x.join(" ").trim()).filter(Boolean);
|
||||
const codes = partsOf(line, d).flatMap((p) => (p.로직 ? [p.로직] : []));
|
||||
const ours = codes.length
|
||||
? (await fetchLogicInputs(codes).catch(() => ({ 로직: [] }))).로직.flatMap((x) =>
|
||||
x.출처 ? [`${x.로직} — ${x.출처}`] : [],
|
||||
)
|
||||
: [];
|
||||
openModal({
|
||||
title: `원문 — ${line.이름}`,
|
||||
closeLabel: "닫기",
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo__src",
|
||||
mount: (body) => {
|
||||
const part = (head: string, list: string[], cut: (s: string) => string): void => {
|
||||
body.append(el("h4", { text: head }));
|
||||
if (!list.length) body.append(note("없음"));
|
||||
for (const s of list) {
|
||||
const view = createSourceView({
|
||||
path: "/m02/logic-lib/source",
|
||||
pdf: "/m02/logic-lib/source-pdf",
|
||||
});
|
||||
view.show(cut(s));
|
||||
body.append(note(s), view.root);
|
||||
}
|
||||
};
|
||||
part("엑셀 인용 조항", excel, (s) => s);
|
||||
part("우리 로직 출처", ours, (s) => s.split(" — ").slice(1).join(" — "));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function partCell(line: HopyoLine): HTMLElement {
|
||||
const parts = partsOf(line, d);
|
||||
return el("td", {
|
||||
className: "m02-hopyo__parts",
|
||||
children: parts.map((p, at) => {
|
||||
const times = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) },
|
||||
}) as HTMLInputElement;
|
||||
times.title = "배 — 우리 수량 = 엑셀 수량 × 배";
|
||||
times.disabled = !opt.canWrite;
|
||||
times.addEventListener("change", () => {
|
||||
const v = times.value === "" ? null : Number(times.value);
|
||||
d.대상[line.차례] = parts.map((x, i) => (i === at ? { ...x, 배: v } : x));
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
const share = p.몫 != null ? ` · 몫 ${p.몫}` : "";
|
||||
return el("div", {
|
||||
className: "m02-hopyo__part",
|
||||
children: [
|
||||
el("span", {
|
||||
text: `${targetText(p)} ${p.로직 ?? p.자재 ?? ""}${share}`.trim(),
|
||||
attrs: { title: p.어디 ?? "" },
|
||||
}),
|
||||
times,
|
||||
...(p.로직
|
||||
? [
|
||||
createButton({
|
||||
label: "옵션",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => editOptions(line, at),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function lineRow(line: HopyoLine): HTMLElement {
|
||||
const m = line.맞춤;
|
||||
const parts = partsOf(line, d);
|
||||
const box = el("input", { attrs: { type: "checkbox", "data-check": String(line.차례) } });
|
||||
const input = box as HTMLInputElement;
|
||||
input.checked = isChecked(line, d) && checkable(line, d);
|
||||
input.disabled = !opt.canWrite || !checkable(line, d);
|
||||
input.addEventListener("change", () => {
|
||||
d.체크[line.차례] = input.checked;
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
const out = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
attrs: { title: "뺌 — 사전조합에 안 넣고 장부에 남김" },
|
||||
children: [
|
||||
el("option", { text: "넣음", attrs: { value: "" } }),
|
||||
...OUT.map((x) => el("option", { text: `뺌 — ${x}`, attrs: { value: x } })),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
out.value = d.뺌[line.차례] ?? "";
|
||||
out.disabled = !opt.canWrite || m.상태 === "%";
|
||||
out.addEventListener("change", () => {
|
||||
if (out.value) d.뺌[line.차례] = out.value;
|
||||
else delete d.뺌[line.차례];
|
||||
d.체크[line.차례] = !!out.value || d.체크[line.차례];
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
const sheet = (행: [number, number]): void =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
파일: got?.파일 ?? "",
|
||||
canWrite: opt.canWrite,
|
||||
target,
|
||||
단산행: 행,
|
||||
});
|
||||
const acts = [
|
||||
...(opt.canWrite && m.상태 !== "%"
|
||||
? [
|
||||
createButton({
|
||||
label: "고르기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openHopyoChoose({
|
||||
line,
|
||||
openSheet: sheet,
|
||||
done: (ts) =>
|
||||
setParts(
|
||||
line,
|
||||
ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
createButton({
|
||||
label: "원문 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void showSource(line),
|
||||
}),
|
||||
...(line.근거
|
||||
? [
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => sheet(line.근거!.행),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
out,
|
||||
];
|
||||
const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n");
|
||||
const opts = parts
|
||||
.filter((p) => p.로직)
|
||||
.map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처))
|
||||
.filter(Boolean);
|
||||
if (m.갈리는칸?.length) opts.push(`⚠ 못 가름: ${m.갈리는칸.join(", ")}`);
|
||||
const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : "";
|
||||
return el("tr", {
|
||||
className: `m02-hopyo__line is-${input.checked ? "in" : "out"}`,
|
||||
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
|
||||
children: [
|
||||
el("td", { children: [box] }),
|
||||
td(line.갈래),
|
||||
td(line.이름),
|
||||
td(line.규격),
|
||||
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
|
||||
td(line.단위),
|
||||
el("td", {
|
||||
attrs: { title: tags },
|
||||
children: [
|
||||
el("span", {
|
||||
className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`,
|
||||
text: `${m.상태}${alias}`,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
partCell(line),
|
||||
td(opts.join(" / ")),
|
||||
td(compareText(d.품[line.차례] ?? m.품)),
|
||||
el("td", { className: "m02-hopyo__acts", children: acts }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function make(h: HopyoOne["호표"]): Promise<void> {
|
||||
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
|
||||
const lines = whole ? [] : bodyOf(h.줄, d);
|
||||
const out = leftOut(h.줄, d);
|
||||
const put = lines.filter((x) => x.대상).length;
|
||||
const outs = lines.length - put;
|
||||
const ask = whole
|
||||
? `호표 통째로 만듦 — ${h.통째!.대상.map(targetText).join(" · ")}`
|
||||
: `넣을 줄 ${put} · 뺌 ${outs} · 빠질 줄 ${Object.values(out).reduce((a, b) => a + b, 0)}` +
|
||||
(Object.keys(out).length
|
||||
? `(${Object.entries(out)
|
||||
.map(([a, b]) => `${a} ${b}`)
|
||||
.join(" · ")})`
|
||||
: "") +
|
||||
" — 확인하고 사전조합을 만듦";
|
||||
if (!(await showConfirmDialog(ask, "확인하고 만들기"))) return;
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no };
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const res = await buildHopyo(
|
||||
target,
|
||||
from,
|
||||
lines,
|
||||
whole ? { 대상: h.통째!.대상.map(toTarget) } : null,
|
||||
);
|
||||
dropDraft(k);
|
||||
d = empty();
|
||||
built = res;
|
||||
const miss = res.빠진줄.length;
|
||||
showToast(`${res.참조.키} ${builtWord(res)}${miss ? ` — 빠진 줄 ${miss}` : ""}`, "success");
|
||||
opt.onBuilt?.(res, h);
|
||||
await load(true);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function builtBlock(res: HopyoBuilt): HTMLElement {
|
||||
const c = res.비교;
|
||||
const rows = res.빠진줄.map((x) =>
|
||||
el("tr", {
|
||||
children: [
|
||||
td(x.이름),
|
||||
td(x.규격),
|
||||
td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"),
|
||||
td(x.단위),
|
||||
td(won(x.금액), "m02-hopyo__num"),
|
||||
td(x.까닭),
|
||||
],
|
||||
}),
|
||||
);
|
||||
return el("div", {
|
||||
className: "m02-hopyo__block m02-hopyo__built",
|
||||
attrs: { "data-built": res.참조.키 },
|
||||
children: [
|
||||
note(res.다시씀 ? `있던 ${res.참조.키} 를 확인한 내용으로 덮음` : `${res.참조.키} 만듦`),
|
||||
...(rows.length
|
||||
? [
|
||||
el("h4", { text: `빠진 줄 ${rows.length}` }),
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: ["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"].map((t) =>
|
||||
el("th", { text: t }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
note(
|
||||
`값 비교(참고) — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` +
|
||||
` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
if (!got) return;
|
||||
const h = got.호표;
|
||||
const checked = h.줄.filter((l) => isChecked(l, d) && checkable(l, d)).length;
|
||||
const calc = h.셈;
|
||||
window.clearTimeout(timer);
|
||||
if (calc && !calc.끝)
|
||||
timer = window.setTimeout(() => root.isConnected && void load(true), POLL_MS);
|
||||
const tools: HTMLElement[] = [];
|
||||
if (opt.canWrite) {
|
||||
const sure = sureLines(h.줄, d);
|
||||
const all = createButton({ label: `맞음 · 별칭 모두 체크 (${sure.length})`, size: "sm" });
|
||||
all.disabled = !sure.length;
|
||||
all.addEventListener("click", () => {
|
||||
for (const l of sure) d.체크[l.차례] = true;
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
|
||||
const go = createButton({ label: "확인하고 사전조합 만들기", variant: "filled", size: "sm" });
|
||||
go.disabled = !whole && checked < 1;
|
||||
go.setAttribute("data-hopyo-make", "");
|
||||
go.addEventListener("click", () => void make(h));
|
||||
tools.push(all, go);
|
||||
}
|
||||
const back = opt.onBack
|
||||
? [createButton({ label: "← 호표 목록", variant: "ghost", size: "sm", onClick: opt.onBack })]
|
||||
: [];
|
||||
const head = el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
...back,
|
||||
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
|
||||
note(h.규격),
|
||||
note(h.단위),
|
||||
note(`엑셀 단가 ${won(h.금액.계)}`),
|
||||
note(`제안 ${h.맞춤[0]} / 줄 ${h.맞춤[1]} · 체크 ${checked}`),
|
||||
...(calc && !calc.끝
|
||||
? [note(`숫자 판정 셈 중 ${calc.한}/${calc.모두}${calc.까닭 ? ` · ${calc.까닭}` : ""}`)]
|
||||
: []),
|
||||
...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []),
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
파일: got!.파일,
|
||||
canWrite: opt.canWrite,
|
||||
target,
|
||||
호표: opt.no,
|
||||
}),
|
||||
}),
|
||||
...tools,
|
||||
],
|
||||
});
|
||||
const whole = h.통째
|
||||
? (() => {
|
||||
const box = el("input", {
|
||||
attrs: { type: "checkbox", "data-hopyo-whole": "" },
|
||||
}) as HTMLInputElement;
|
||||
box.checked = d.통째 ?? h.통째.체크;
|
||||
box.disabled = !opt.canWrite;
|
||||
box.addEventListener("change", () => {
|
||||
d.통째 = box.checked;
|
||||
keep();
|
||||
draw();
|
||||
});
|
||||
return [
|
||||
el("label", {
|
||||
className: "m02-hopyo__bar m02-hopyo__whole",
|
||||
children: [
|
||||
box,
|
||||
note(
|
||||
`통째 = ${h.통째.대상.map(targetText).join(" · ")}${h.통째.글 ? ` — ${h.통째.글}` : ""}`,
|
||||
),
|
||||
],
|
||||
}),
|
||||
];
|
||||
})()
|
||||
: [];
|
||||
const heads = [
|
||||
"",
|
||||
"갈래",
|
||||
"품명",
|
||||
"규격",
|
||||
"수량",
|
||||
"단위",
|
||||
"상태",
|
||||
"우리 로직 · 배",
|
||||
"옵션",
|
||||
"품 비교",
|
||||
"",
|
||||
];
|
||||
root.replaceChildren(
|
||||
head,
|
||||
...whole,
|
||||
h.줄.length
|
||||
? el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table m02-hopyo__lines",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: h.줄.map(lineRow) }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
: note("구성 줄 없음"),
|
||||
...(built ? [builtBlock(built)] : []),
|
||||
);
|
||||
}
|
||||
|
||||
void load();
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 [확인 창 열기] · 만들면 닫음 */
|
||||
export function openHopyoCheck(opt: CheckOptions & { title?: string }): void {
|
||||
openModal({
|
||||
title: opt.title ?? `확인 — 제${opt.no}호표`,
|
||||
closeLabel: "닫기",
|
||||
wide: true,
|
||||
dialogClass: "m02-modal m02-hopyo__check-modal",
|
||||
mount: (body, close) =>
|
||||
body.append(
|
||||
buildHopyoCheck({
|
||||
...opt,
|
||||
onBuilt: (got, h) => {
|
||||
opt.onBuilt?.(got, h);
|
||||
close();
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Choose.ts
|
||||
* 일위대가 실무자료 고르기 창(PLAN 338-3 · 설계 4-2 · 옛 `Hopyo_View.choose` 를 옮김) + 글 도우미.
|
||||
* · 위 = 엑셀 블록 품(직종 · 장비 · 자재) · 인용 조항 · [엑셀에서 보기](단가산출 그 행)
|
||||
* · 후보 다섯 = 이름 · 키 · 판정 꼬리표(맞음 / 다른 점 / 후보) · 글 · 근거 꼬리표(근거 맞음 초록) · 금액 비 없음
|
||||
* · 고름 = [고름](한 로직) · 또는 체크 + 몫 칸 → [고른 n개 함께](조합) · 옵션은 부르는 쪽이 서버 `guess` 로 채움
|
||||
* · [다른 로직 찾기] = `openLogicPicker` 에 찾기 글 미리 넣음(엑셀 인용 조항 번호 → 없으면 품명 첫 뜻 낱말)
|
||||
* 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type {
|
||||
BuildTarget,
|
||||
HopyoBasisHit,
|
||||
HopyoLine,
|
||||
HopyoLineBasis,
|
||||
HopyoQty,
|
||||
HopyoTarget,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
const MATERIAL = new Set(["자재", "별도계상"]);
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
const num = (v: number): string => String(Math.round(v * 10000) / 10000);
|
||||
|
||||
/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */
|
||||
export const targetText = (t: HopyoTarget | null | undefined): string => {
|
||||
if (!t) return "";
|
||||
if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim();
|
||||
const spec = t.규격 ? ` ${t.규격}` : "";
|
||||
return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`;
|
||||
};
|
||||
|
||||
/** 입력값 글 — 「이름=값」 · 칸 출처(블록 · 규격 · 별칭) · 못 가르는 칸 표시 */
|
||||
export const inputsText = (
|
||||
values: Record<string, unknown>,
|
||||
unread: string[],
|
||||
from: Record<string, string> = {},
|
||||
): string =>
|
||||
[
|
||||
Object.entries(values)
|
||||
.map(([k, v]) => `${k}=${String(v)}${from[k] && from[k] !== "자동값" ? `(${from[k]})` : ""}`)
|
||||
.join(", "),
|
||||
unread.length ? `⚠ 못 가름: ${unread.join(", ")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
/** 만들기 결과 낱말 — 있던 것을 덮음 · 새로 만듦 */
|
||||
export const builtWord = (got: { 다시씀: boolean }): string =>
|
||||
got.다시씀 ? "있던 것을 확인한 내용으로 덮음" : "만듦";
|
||||
|
||||
/** 근거 맞음 — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침
|
||||
* (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */
|
||||
export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean =>
|
||||
!!b &&
|
||||
b.까닭.startsWith("조항 같음") &&
|
||||
b.단위 &&
|
||||
(b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부"));
|
||||
|
||||
/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */
|
||||
export const basisTag = (b: HopyoBasisHit | undefined): string =>
|
||||
b
|
||||
? [
|
||||
b.조항.length ? `조항 ${b.조항.join(", ")}` : "",
|
||||
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
|
||||
b.단위 ? "단위 같음" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "";
|
||||
|
||||
/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */
|
||||
export const sourceText = (b: HopyoLineBasis | undefined): string =>
|
||||
b
|
||||
? [
|
||||
b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "",
|
||||
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
|
||||
`단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "";
|
||||
|
||||
/** 품 글 — 「콘크리트공 0.1091 · 굴착기 0.29hr · 레미콘 1.02」 */
|
||||
export const qtyText = (q: HopyoQty | undefined): string =>
|
||||
q
|
||||
? [
|
||||
...Object.entries(q.인력 ?? {}).map(([k, v]) => `${k} ${num(v)}`),
|
||||
...(q.장비 ?? []).map((x) => `${[x.갈래].flat().join("+")} ${num(x.시간)}hr`),
|
||||
...(q.자재 ?? []).map((x) => `${x.이름} ${num(x.수량)}`),
|
||||
].join(" · ")
|
||||
: "";
|
||||
|
||||
/** [다른 로직 찾기] 미리 넣을 글 — 엑셀 인용 조항 번호 → 없으면 품명 괄호 밖 첫 뜻 낱말 */
|
||||
export function searchHint(line: Pick<HopyoLine, "이름" | "근거">): string {
|
||||
const clause = line.근거?.출처?.find((x) => x[1])?.[1];
|
||||
if (clause) return clause;
|
||||
return /[가-힣A-Za-z]{2,}/.exec(line.이름.replace(/\([^()]*\)/g, " "))?.[0] ?? "";
|
||||
}
|
||||
|
||||
/** 고른 후보 → 만들기 대상(배 1 · 몫 있으면 배 = 몫) */
|
||||
export const pickedTargets = (picked: { c: HopyoTarget; 몫: number | null }[]): BuildTarget[] =>
|
||||
picked.map(({ c, 몫 }) => ({
|
||||
...(c.로직 ? { 로직: c.로직 } : { 자재: c.자재 }),
|
||||
입력값: {},
|
||||
배: 몫 ?? 1,
|
||||
몫,
|
||||
}));
|
||||
|
||||
export interface ChooseOptions {
|
||||
line: HopyoLine;
|
||||
/** [엑셀에서 보기] — 단가산출 그 행 */
|
||||
openSheet?: (행: [number, number]) => void;
|
||||
done: (targets: BuildTarget[]) => void;
|
||||
}
|
||||
|
||||
/** 고르기 창 — 엑셀 품 · 인용 조항 · 후보 다섯(하나 또는 여럿 · 몫) · 다른 로직 · 자재 찾기 */
|
||||
export function openHopyoChoose(opt: ChooseOptions): void {
|
||||
const { line } = opt;
|
||||
const material = MATERIAL.has(line.갈래);
|
||||
openModal({
|
||||
title: `고르기 — ${line.이름} ${line.규격}`.trim(),
|
||||
closeLabel: "닫기",
|
||||
dialogClass: "m02-modal m02-hopyo__choose",
|
||||
mount: (body, close) => {
|
||||
const finish = (targets: BuildTarget[]): void => {
|
||||
close();
|
||||
opt.done(targets);
|
||||
};
|
||||
const picked = new Map<HopyoTarget, HTMLInputElement>();
|
||||
const together = createButton({ label: "고른 0개 함께", size: "sm", disabled: true });
|
||||
const paint = (): void => {
|
||||
together.querySelector(".ui-btn__label")!.textContent = `고른 ${picked.size}개 함께`;
|
||||
together.disabled = picked.size < 2;
|
||||
};
|
||||
together.addEventListener("click", () =>
|
||||
finish(
|
||||
pickedTargets(
|
||||
[...picked].map(([c, box]) => ({ c, 몫: box.value === "" ? null : Number(box.value) })),
|
||||
),
|
||||
),
|
||||
);
|
||||
const list = (line.맞춤.후보 ?? []).map((c) => {
|
||||
const share = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: { type: "number", step: "0.05", min: "0", max: "1", placeholder: "몫" },
|
||||
}) as HTMLInputElement;
|
||||
const box = el("input", { attrs: { type: "checkbox", title: "함께 고름(조합)" } });
|
||||
box.addEventListener("change", () => {
|
||||
if ((box as HTMLInputElement).checked) picked.set(c, share);
|
||||
else picked.delete(c);
|
||||
paint();
|
||||
});
|
||||
const tag = basisTag(c.근거);
|
||||
return el("div", {
|
||||
className: "m02-hopyo__cand",
|
||||
attrs: { "data-cand": c.로직 ?? c.자재 ?? "" },
|
||||
children: [
|
||||
box,
|
||||
createButton({
|
||||
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => finish(pickedTargets([{ c, 몫: null }])),
|
||||
}),
|
||||
...(c.판정
|
||||
? [
|
||||
el("span", {
|
||||
className: `m02-hopyo__state is-${c.판정.replace(" ", "")}`,
|
||||
text: c.판정,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(c.글 ? [note(c.글)] : []),
|
||||
...(tag && c.근거
|
||||
? [
|
||||
el("span", {
|
||||
className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`,
|
||||
text: tag,
|
||||
attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` },
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
share,
|
||||
],
|
||||
});
|
||||
});
|
||||
const excel = qtyText(line.맞춤.품?.엑셀);
|
||||
const basis = line.근거;
|
||||
const head = el("div", {
|
||||
className: "m02-hopyo__bar m02-hopyo__source",
|
||||
children: [
|
||||
...(excel ? [note(`엑셀 품 — ${excel}`)] : []),
|
||||
...(basis ? [note(`엑셀 근거 — ${sourceText(basis)}`)] : []),
|
||||
...(basis && opt.openSheet
|
||||
? [
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => opt.openSheet?.(basis.행),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
const more = createButton({
|
||||
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
if (material) pickMaterial((x) => opt.done([{ 자재: x.코드, 배: 1 }]));
|
||||
else
|
||||
openLogicPicker((x) => opt.done([{ 로직: x.키, 입력값: {}, 배: 1 }]), undefined, {
|
||||
query: searchHint(line),
|
||||
});
|
||||
},
|
||||
});
|
||||
body.append(
|
||||
...(head.childElementCount ? [head] : []),
|
||||
note(list.length ? "후보 — 하나 고름 · 여럿은 체크(몫 칸) 뒤 [함께]" : "가까운 것 없음"),
|
||||
el("div", { className: "m02-hopyo__cands", children: list }),
|
||||
el("div", { className: "m02-hopyo__bar", children: [together, more] }),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Ledger.ts
|
||||
* 일위대가 실무자료 별칭 장부 화면(PLAN 338-3 · 설계 4-4) — 본 칸 · 좌측 판 [별칭 장부] 로 엶.
|
||||
* · 표 = 엑셀 품명 · 규격 · 단위 | 갈래 | 우리 로직(여럿) · 옵션 · 배 · 몫 | 누가 · 때 · 처음 파일 | 층(위 층 회색 읽기만)
|
||||
* · 할 일 = 찾기 · 갈래 거름 · 갈래 바꿈 · [로직 바꿈] · [로직 더함] · 배 · 몫 · [옵션] · [지우기] ·
|
||||
* 위 층 줄 [내 층으로 고쳐 씀]
|
||||
* · 고친 것 = sessionStorage `m02-hopyo-ledger:{층}:{주인}` → [저장] 때만 `PUT`(서버가 짝을 다시 검사)
|
||||
* · 안 둠 = 발주처 칸 · 자동 올림 · 자동 합침 · 쓰인 횟수 · 회사로 올리기.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import {
|
||||
readLedger,
|
||||
saveLedger,
|
||||
type HopyoScope,
|
||||
type LedgerEntry,
|
||||
type LedgerPair,
|
||||
type LedgerRow,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export const LEDGER_TITLE = "별칭 장부";
|
||||
const KINDS = ["짝", "우리에 없음", "안 씀"] as const;
|
||||
const LAYER_TEXT: Record<string, string> = { system: "마스터", company: "회사", personal: "개인" };
|
||||
type Edits = Record<string, LedgerEntry | null>;
|
||||
/** 화면 한 줄 — 서버 줄 위에 고친 것 */
|
||||
export type LedgerView = LedgerRow & { 고친?: boolean };
|
||||
|
||||
export const ledgerKey = (scope: HopyoScope): string =>
|
||||
`m02-hopyo-ledger:${scope.layer}:${scope.owner ?? ""}`;
|
||||
function loadEdits(k: string): Edits {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(k) ?? "{}") as Edits;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
function saveEdits(k: string, edits: Edits): void {
|
||||
try {
|
||||
if (Object.keys(edits).length) sessionStorage.setItem(k, JSON.stringify(edits));
|
||||
else sessionStorage.removeItem(k);
|
||||
} catch {
|
||||
/* 캐시 못 씀 = 이 화면에만 남음 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 쓰기 꼴 — 보기 칸(층 · 누가 · 때 …) 뺌 */
|
||||
export const entryOf = (row: LedgerEntry): LedgerEntry => ({
|
||||
원래: row.원래,
|
||||
갈래: row.갈래,
|
||||
짝:
|
||||
row.갈래 === "짝"
|
||||
? row.짝.map((p) => ({ ...p, 입력값: p.입력값 ?? {}, 몫: p.몫 ?? null }))
|
||||
: [],
|
||||
엑셀조항: row.엑셀조항 ?? [],
|
||||
});
|
||||
|
||||
/** 서버 줄 + 고친 것 → 화면 줄 — 이 층 줄은 고친 값 · 지운 것 뺌 · 위 층에서 옮긴 줄은 이 층 줄로 더함 */
|
||||
export function mergeRows(rows: LedgerRow[], edits: Edits, scope: HopyoScope): LedgerView[] {
|
||||
const mine = rows.filter((r) => r.고침);
|
||||
const out: LedgerView[] = [];
|
||||
for (const r of mine) {
|
||||
if (!(r.열쇠 in edits)) out.push(r);
|
||||
else if (edits[r.열쇠]) out.push({ ...r, ...edits[r.열쇠]!, 고친: true });
|
||||
}
|
||||
for (const [key, entry] of Object.entries(edits)) {
|
||||
if (!entry || mine.some((r) => r.열쇠 === key)) continue;
|
||||
out.push({
|
||||
...entry,
|
||||
열쇠: key,
|
||||
누가: null,
|
||||
때: null,
|
||||
처음파일: null,
|
||||
층: scope.layer,
|
||||
주인: scope.owner,
|
||||
고침: true,
|
||||
고친: true,
|
||||
});
|
||||
}
|
||||
return [...out, ...rows.filter((r) => !r.고침)];
|
||||
}
|
||||
|
||||
/** 짝 글 — 「GC000198 × 1 · 몫 0.1 · 소량감률=50」 */
|
||||
export const pairText = (p: LedgerPair): string =>
|
||||
[
|
||||
`${p.로직 ?? p.자재 ?? ""} × ${p.배}`,
|
||||
p.몫 != null ? `몫 ${p.몫}` : "",
|
||||
Object.entries(p.입력값 ?? {})
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join(", "),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
/** 찾기 · 갈래 거름 */
|
||||
export const filterRows = (rows: LedgerView[], q: string, kind: string): LedgerView[] => {
|
||||
const want = q.trim().toLowerCase();
|
||||
return rows.filter(
|
||||
(r) =>
|
||||
(!kind || r.갈래 === kind) &&
|
||||
(!want ||
|
||||
`${r.원래.품명} ${r.원래.규격} ${r.짝.map(pairText).join(" ")}`
|
||||
.toLowerCase()
|
||||
.includes(want)),
|
||||
);
|
||||
};
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const td = (text: string, cls = ""): HTMLElement =>
|
||||
el("td", { text, className: cls, attrs: { title: text } });
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
|
||||
export interface LedgerOptions {
|
||||
scope: HopyoScope;
|
||||
/** 이 층 쓰기 권한 — 없으면 보기만 */
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export function buildHopyoLedger(opt: LedgerOptions): HTMLElement {
|
||||
const root = el("div", {
|
||||
className: "m02-hopyo m02-hopyo-ledger",
|
||||
attrs: { "data-hopyo-ledger": "" },
|
||||
});
|
||||
const k = ledgerKey(opt.scope);
|
||||
let edits = loadEdits(k);
|
||||
let rows: LedgerRow[] = [];
|
||||
const search = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__search",
|
||||
attrs: { type: "search", placeholder: "품명 · 규격 · 로직 찾기" },
|
||||
}) as HTMLInputElement;
|
||||
const kind = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
children: [
|
||||
el("option", { text: "갈래 전체", attrs: { value: "" } }),
|
||||
...KINDS.map((x) => el("option", { text: x, attrs: { value: x } })),
|
||||
],
|
||||
}) as HTMLSelectElement;
|
||||
const save = createButton({ label: "저장", variant: "filled", size: "sm" });
|
||||
const drop = createButton({ label: "고친 것 버림", variant: "ghost", size: "sm" });
|
||||
const body = el("tbody");
|
||||
const keep = (): void => {
|
||||
saveEdits(k, edits);
|
||||
paint();
|
||||
};
|
||||
const edit = (r: LedgerView, next: Partial<LedgerEntry>): void => {
|
||||
edits[r.열쇠] = entryOf({ ...entryOf(r), ...next });
|
||||
keep();
|
||||
};
|
||||
|
||||
function pairCell(r: LedgerView, mine: boolean): HTMLElement {
|
||||
const pairs: HTMLElement[] = r.짝.map((p, at) => {
|
||||
const parts: HTMLElement[] = [el("span", { text: pairText(p) })];
|
||||
if (mine) {
|
||||
const times = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: { type: "number", step: "any", min: "0", value: String(p.배), title: "배" },
|
||||
}) as HTMLInputElement;
|
||||
times.addEventListener("change", () =>
|
||||
edit(r, { 짝: r.짝.map((x, i) => (i === at ? { ...x, 배: Number(times.value) } : x)) }),
|
||||
);
|
||||
parts.push(times);
|
||||
if (p.로직)
|
||||
parts.push(
|
||||
createButton({
|
||||
label: "옵션",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
void fetchLogicInputs([p.로직!]).then(
|
||||
(got) =>
|
||||
openLaborInput({
|
||||
title: p.로직!,
|
||||
logic: got.로직[0] ?? null,
|
||||
values: p.입력값 as never,
|
||||
memo: null,
|
||||
readOnly: false,
|
||||
done: (res) =>
|
||||
edit(r, {
|
||||
짝: r.짝.map((x, i) => (i === at ? { ...x, 입력값: res.입력값 } : x)),
|
||||
}),
|
||||
}),
|
||||
(error) => showToast(why(error), "error"),
|
||||
),
|
||||
}),
|
||||
createButton({
|
||||
label: "×",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => edit(r, { 짝: r.짝.filter((_, i) => i !== at) }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("div", { className: "m02-hopyo__part", children: parts });
|
||||
});
|
||||
if (mine && r.갈래 === "짝")
|
||||
pairs.push(
|
||||
createButton({
|
||||
label: "로직 더함",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openLogicPicker((x) =>
|
||||
edit(r, { 짝: [...r.짝, { 로직: x.키, 입력값: {}, 배: 1, 몫: null }] }),
|
||||
),
|
||||
}),
|
||||
);
|
||||
return el("td", { className: "m02-hopyo__parts", children: pairs });
|
||||
}
|
||||
|
||||
function rowOf(r: LedgerView): HTMLElement {
|
||||
const mine = opt.canWrite && r.고침;
|
||||
const kindCell = mine
|
||||
? (() => {
|
||||
const s = el("select", {
|
||||
className: "ui-input ui-input--sm",
|
||||
children: KINDS.map((x) => el("option", { text: x, attrs: { value: x } })),
|
||||
}) as HTMLSelectElement;
|
||||
s.value = r.갈래;
|
||||
s.addEventListener("change", () =>
|
||||
edit(r, { 갈래: s.value as LedgerEntry["갈래"], 짝: s.value === "짝" ? r.짝 : [] }),
|
||||
);
|
||||
return el("td", { children: [s] });
|
||||
})()
|
||||
: td(r.갈래);
|
||||
const acts: HTMLElement[] = [];
|
||||
if (mine)
|
||||
acts.push(
|
||||
createButton({
|
||||
label: "지우기",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
edits[r.열쇠] = null;
|
||||
keep();
|
||||
},
|
||||
}),
|
||||
);
|
||||
else if (opt.canWrite && !rows.some((x) => x.고침 && x.열쇠 === r.열쇠) && !edits[r.열쇠])
|
||||
acts.push(
|
||||
createButton({
|
||||
label: "내 층으로 고쳐 씀",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
edits[r.열쇠] = entryOf(r);
|
||||
keep();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const when = [r.누가?.이름, r.때?.replace("T", " ").slice(0, 16), r.처음파일]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return el("tr", {
|
||||
className: `m02-hopyo-ledger__row${r.고침 ? "" : " is-upper"}${r.고친 ? " is-edited" : ""}`,
|
||||
attrs: { "data-ledger": r.열쇠 },
|
||||
children: [
|
||||
td(r.원래.품명),
|
||||
td(r.원래.규격),
|
||||
td(r.원래.단위),
|
||||
kindCell,
|
||||
pairCell(r, mine),
|
||||
td(when),
|
||||
td(r.고침 ? "내 층" : (LAYER_TEXT[r.층] ?? r.층)),
|
||||
el("td", { className: "m02-hopyo__acts", children: acts }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function paint(): void {
|
||||
const all = mergeRows(rows, edits, opt.scope);
|
||||
const shown = filterRows(all, search.value, kind.value);
|
||||
const n = Object.keys(edits).length;
|
||||
save.querySelector(".ui-btn__label")!.textContent = n ? `저장 (${n})` : "저장";
|
||||
save.disabled = drop.disabled = !n;
|
||||
body.replaceChildren(
|
||||
...(shown.length
|
||||
? shown.map(rowOf)
|
||||
: [el("tr", { children: [el("td", { text: "장부 줄 없음", attrs: { colspan: "8" } })] })]),
|
||||
);
|
||||
}
|
||||
search.addEventListener("input", paint);
|
||||
kind.addEventListener("change", paint);
|
||||
drop.addEventListener("click", () => {
|
||||
edits = {};
|
||||
keep();
|
||||
});
|
||||
save.addEventListener("click", async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
rows = await saveLedger(opt.scope, edits);
|
||||
edits = {};
|
||||
showToast("장부 저장", "success");
|
||||
keep();
|
||||
} catch (error) {
|
||||
showToast(`저장 못 함 — ${why(error)}`, "error");
|
||||
paint();
|
||||
}
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const stop = showLoading(root);
|
||||
try {
|
||||
rows = await readLedger(opt.scope);
|
||||
} catch (error) {
|
||||
root.replaceChildren(loadingError(why(error), () => void load()));
|
||||
return;
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
root.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
el("strong", { text: LEDGER_TITLE }),
|
||||
note("엑셀 이름 → 우리 로직 + 옵션 + 배 · 확인하고 만들 때 남음 · 아래 층이 이김"),
|
||||
search,
|
||||
kind,
|
||||
...(opt.canWrite ? [save, drop] : []),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
"엑셀 품명",
|
||||
"규격",
|
||||
"단위",
|
||||
"갈래",
|
||||
"우리 로직 · 배 · 몫 · 옵션",
|
||||
"누가 · 때 · 처음 파일",
|
||||
"층",
|
||||
"",
|
||||
].map((t) => el("th", { text: t })),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
body,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
paint();
|
||||
}
|
||||
|
||||
void load();
|
||||
return root;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Pick.ts
|
||||
* [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(맞춤 n/m) ·
|
||||
* 고르면 서버가 그 호표를 문서 층 사전조합으로 만들고(`/hopyo-build` · 있던 것은 다시 씀) `lib.pick` 으로
|
||||
* 참조 줄(별칭 = 호표 품명) · 빠진 줄이 있으면 알림 · 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 ·
|
||||
* 고른 호표마다 참조 줄). 프로젝트 층엔 없음.
|
||||
* [로직 추가] 창의 「실무 호표」 줄(PLAN 324-4 · 338-3) — 일위대가 문서 층 · 위 층 실무자료의 호표마다 한 줄(제안 n/m) ·
|
||||
* 고름 = 문서 층에 확인된 사전조합이 있으면 그것을 바로 참조 · 없으면 확인 창 모달(`Hopyo_Check` · 늦게 불러옴) →
|
||||
* [확인하고 사전조합 만들기] → `lib.pick` 참조 줄(별칭 = 호표 품명) · 확인 없이 만들지 않음 ·
|
||||
* 파일마다 「엑셀로 보기 · 여러 호표」 줄(329 엑셀 보기 창 · 고른 호표마다 참조 줄). 프로젝트 층엔 없음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { buildHopyo, listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { listHopyoPicks, type HopyoPickRow } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import type { LibraryPick, PickExtra } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
|
||||
@@ -73,21 +73,29 @@ export function hopyoExtra(row: HopyoPickRow, lib: LibraryPick): PickExtra {
|
||||
tag: "실무 호표",
|
||||
name: [row.이름, row.규격].filter(Boolean).join(" "),
|
||||
sub: `${row.파일} · 제${row.번}호표 · ${LAYER_TEXT[row.층] ?? row.층}`,
|
||||
unit: `${row.단위} · 맞춤 ${row.맞춤[0]}/${row.맞춤[1]}`,
|
||||
unit: `${row.단위} · ${row.확인 ? `확인 ${row.확인.키}` : `제안 ${row.맞춤[0]}/${row.맞춤[1]}`}`,
|
||||
onPick: () => void pickHopyo(row, lib),
|
||||
};
|
||||
}
|
||||
|
||||
/** 고름 — 서버가 사전조합을 만들고(다시 씀) 참조 줄을 더함 */
|
||||
/** 고름 — 확인된 사전조합이면 바로 참조 · 아니면 확인 창 모달 → 만든 뒤 참조 */
|
||||
export async function pickHopyo(row: HopyoPickRow, lib: LibraryPick): Promise<void> {
|
||||
const alias = row.이름.slice(0, ALIAS_MAX).trim() || null;
|
||||
if (row.확인) {
|
||||
showToast(`「${row.이름}」 → 확인한 사전조합 ${row.확인.키}`, "success");
|
||||
return lib.pick(row.확인, row.이름, alias);
|
||||
}
|
||||
try {
|
||||
const from = { 층: row.층, 주인: row.주인, 키: row.키, 번: row.번 };
|
||||
const got = await buildHopyo({ layer: lib.layer, owner: lib.owner }, from);
|
||||
const miss = got.빠진줄.length;
|
||||
const head = `「${row.이름}」 → 사전조합 ${got.참조.키}${got.다시씀 ? "(있던 것)" : ""}`;
|
||||
if (miss) showToast(`${head} · 빠진 줄 ${miss} — 실무자료 화면에서 맞춤`, "warning", 6000);
|
||||
else showToast(head, "success");
|
||||
lib.pick(got.참조, got.이름, row.이름.slice(0, ALIAS_MAX).trim() || null);
|
||||
const check = await import("./M02_MasterTemplete_Hopyo_Check");
|
||||
check.openHopyoCheck({
|
||||
title: `확인 — ${row.파일} 제${row.번}호표 ${row.이름}`,
|
||||
scope: { layer: row.층, owner: row.주인 },
|
||||
key: row.키,
|
||||
no: row.번,
|
||||
target: { layer: lib.layer, owner: lib.owner },
|
||||
canWrite: true,
|
||||
onBuilt: (got) => lib.pick(got.참조, got.이름, alias),
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), "error");
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_Sheet.ts
|
||||
* 일위대가 실무자료 엑셀 보기 창(PLAN 329) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거
|
||||
* 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 맞춤 n/m) + [고른 n개 불러오기].
|
||||
* 일위대가 실무자료 엑셀 보기 창(PLAN 329 · 338-3) — 큰 모달 · 왼쪽 = 올린 엑셀을 연 것처럼 읽기 전용 시트(A00 산출근거
|
||||
* 스프레드시트 · 탭 일위대가 · 단가산출) · 오른쪽 = 호표 목록(체크 · 찾기 · 제안 n/m) + [고른 n개 확인하고 만들기].
|
||||
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
|
||||
* · 불러오기 = 고른 호표마다 차례로 사전조합 만들기(`/hopyo-build`) → 결과 표(사전조합 키 · 빠진 줄 · 값 차이) ·
|
||||
* · ① [고른 n개 확인하고 만들기] = 고른 호표마다 서버 보기 → 펼침 표(넣을 줄 = 체크 제안 · 빠질 줄 · 셈 중 ·
|
||||
* [확인 창 열기]) ② [이대로 n개 만들기] = 하위 호표부터 차례로 `/hopyo-build`(몸 = 그 호표 체크 줄) —
|
||||
* 펼쳐 보이고 한 번 누름 = 사용자 확인 · 결과 표(사전조합 키 · 빠진 줄 · 값 차이) ·
|
||||
* `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 불러오기는 됨
|
||||
* · 격자 = 서버 `/hopyo-grid/{키}` · 옛 파일(격자 없음)은 시트 자리에 까닭 글 · 목록 · 확인하고 만들기는 됨
|
||||
* ⚠ 부르는 쪽은 `import()` 로 엶 · 스프레드시트도 열 때 불러옴(다른 화면 번들에 안 섞이게).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createButton,
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
@@ -17,13 +25,24 @@ import {
|
||||
buildHopyo,
|
||||
readHopyo,
|
||||
readHopyoGrid,
|
||||
readHopyoOne,
|
||||
type HopyoBrief,
|
||||
type HopyoBuilt,
|
||||
type HopyoGrid,
|
||||
type HopyoOne,
|
||||
type HopyoScope,
|
||||
type HopyoSpan,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { builtWord } from "./M02_MasterTemplete_Hopyo_View";
|
||||
import {
|
||||
bodyOf,
|
||||
draftKey,
|
||||
leftOut,
|
||||
loadDraft,
|
||||
openHopyoCheck,
|
||||
partsOf,
|
||||
toTarget,
|
||||
} from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export interface HopyoSheetOptions {
|
||||
@@ -31,7 +50,7 @@ export interface HopyoSheetOptions {
|
||||
scope: HopyoScope;
|
||||
key: string;
|
||||
파일: string;
|
||||
/** 만들 층 쓰기 권한 — 없으면 체크 · 불러오기 없음(보기만) */
|
||||
/** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */
|
||||
canWrite: boolean;
|
||||
/** 사전조합을 만들 층(없으면 `scope` · [로직 추가] = 문서 층) */
|
||||
target?: HopyoScope;
|
||||
@@ -68,6 +87,19 @@ export function lastColumn(sheet: Pick<Sheet, "칸" | "열">): string {
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 만들 차례 — 하위 호표(호표 참조 줄이 가리키는 고른 호표)부터 · 순환은 남은 차례대로 */
|
||||
export function buildOrder(ones: { 번: number; 아래: number[] }[]): number[] {
|
||||
const left = new Map(ones.map((x) => [x.번, x.아래.filter((n) => n !== x.번)]));
|
||||
const out: number[] = [];
|
||||
while (left.size) {
|
||||
const ready = [...left].find(([, kids]) => kids.every((n) => !left.has(n)));
|
||||
const no = ready ? ready[0] : [...left.keys()][0];
|
||||
out.push(no);
|
||||
left.delete(no);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 단가산출 행 → 행 전체 범위 A1(`4:9`) */
|
||||
export const rowsText = (행: number | [number, number]): string =>
|
||||
typeof 행 === "number" ? `${행}:${행}` : `${행[0]}:${행[1]}`;
|
||||
@@ -94,7 +126,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
}) as HTMLInputElement;
|
||||
const list = el("div", { className: "m02-hopyo-sheet__list" });
|
||||
const count = note("");
|
||||
const go = createButton({ label: "고른 0개 불러오기", variant: "filled", size: "sm" });
|
||||
const go = createButton({ label: "고른 0개 확인하고 만들기", variant: "filled", size: "sm" });
|
||||
go.disabled = true;
|
||||
const result = el("div", { className: "m02-hopyo-sheet__result" });
|
||||
const right = el("div", {
|
||||
@@ -122,7 +154,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
});
|
||||
|
||||
const paintGo = (): void => {
|
||||
go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 불러오기`;
|
||||
go.querySelector(".ui-btn__label")!.textContent = `고른 ${checked.size}개 확인하고 만들기`;
|
||||
go.disabled = !checked.size;
|
||||
};
|
||||
|
||||
@@ -226,57 +258,147 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function importPicked(): Promise<void> {
|
||||
const nos = [...checked].sort((a, b) => a - b);
|
||||
const target = opt.target ?? opt.scope;
|
||||
const rows: HTMLElement[] = [];
|
||||
let ok = 0;
|
||||
go.disabled = true;
|
||||
for (const no of nos) {
|
||||
const h = briefs.find((x) => x.번 === no);
|
||||
if (!h || closed) continue;
|
||||
const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() });
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
|
||||
const got = await buildHopyo(target, from);
|
||||
ok += 1;
|
||||
opt.onImport?.(got, h);
|
||||
rows.push(
|
||||
el("tr", {
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
head,
|
||||
el("td", { text: `${got.참조.키} · ${builtWord(got, false)}` }),
|
||||
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
|
||||
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] }));
|
||||
}
|
||||
result.replaceChildren(
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: ["호표", "사전조합", "빠진 줄", "값 차이"].map((t) =>
|
||||
el("th", { text: t }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
);
|
||||
el("tbody", { children: rows }),
|
||||
],
|
||||
});
|
||||
|
||||
/** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */
|
||||
function bodyFor(one: HopyoOne): Parameters<typeof buildHopyo>[2] {
|
||||
const h = one.호표;
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d);
|
||||
}
|
||||
function wholeFor(one: HopyoOne): Parameters<typeof buildHopyo>[3] {
|
||||
const h = one.호표;
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null;
|
||||
}
|
||||
|
||||
/** ① 고른 호표마다 서버 보기 → 펼침 표 */
|
||||
async function review(): Promise<void> {
|
||||
const target = opt.target ?? opt.scope;
|
||||
const nos = [...checked].sort((a, b) => a - b);
|
||||
go.disabled = true;
|
||||
const ones: HopyoOne[] = [];
|
||||
for (const no of nos) {
|
||||
if (closed) return;
|
||||
try {
|
||||
ones.push(await readHopyoOne(opt.scope, opt.key, no, target));
|
||||
} catch (error) {
|
||||
showToast(`제${no}호표 — ${why(error)}`, "error");
|
||||
}
|
||||
}
|
||||
paintGo();
|
||||
showToast(`불러옴 ${ok} / ${nos.length}`, ok === nos.length ? "success" : "warning");
|
||||
const rows = ones.map((one) => {
|
||||
const h = one.호표;
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
|
||||
const out = Object.entries(leftOut(h.줄, d));
|
||||
const calc = h.셈 && !h.셈.끝 ? `셈 중 ${h.셈.한}/${h.셈.모두}` : "";
|
||||
const brief = briefs.find((x) => x.번 === h.번) ?? h;
|
||||
return el("tr", {
|
||||
attrs: { "data-review": String(h.번) },
|
||||
children: [
|
||||
el("td", { text: `제${h.번}호표 ${h.이름}`.trim() }),
|
||||
el("td", {
|
||||
text: wholeFor(one) ? "통째" : String(bodyFor(one).filter((x) => x.대상).length),
|
||||
className: "m02-hopyo__num",
|
||||
}),
|
||||
el("td", { text: out.map(([a, b]) => `${a} ${b}`).join(" · ") }),
|
||||
el("td", { text: calc }),
|
||||
el("td", {
|
||||
children: [
|
||||
createButton({
|
||||
label: "확인 창 열기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openHopyoCheck({
|
||||
scope: opt.scope,
|
||||
key: opt.key,
|
||||
no: h.번,
|
||||
target,
|
||||
canWrite: opt.canWrite,
|
||||
onBuilt: (got) => opt.onImport?.(got, brief),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one));
|
||||
const all = createButton({
|
||||
label: `이대로 ${ready.length}개 만들기`,
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
disabled: !ready.length,
|
||||
onClick: () => void makeAll(ready),
|
||||
});
|
||||
all.setAttribute("data-hopyo-make-all", "");
|
||||
result.replaceChildren(
|
||||
table(["호표", "넣을 줄", "빠질 줄", "숫자 판정", ""], rows),
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [all, note("넣을 줄 = 체크 제안 · 확인 창에서 고친 것")],
|
||||
}),
|
||||
);
|
||||
}
|
||||
go.addEventListener("click", () => void importPicked());
|
||||
|
||||
/** ② 한 번 누름 = 사용자 확인 — 하위 호표부터 차례로 만들기 */
|
||||
async function makeAll(ones: HopyoOne[]): Promise<void> {
|
||||
const target = opt.target ?? opt.scope;
|
||||
const by = new Map(ones.map((one) => [one.호표.번, one]));
|
||||
const order = buildOrder(
|
||||
ones.map((one) => {
|
||||
const d = loadDraft(draftKey(opt.scope, opt.key, one.호표.번));
|
||||
const kids = one.호표.줄.flatMap((l) =>
|
||||
partsOf(l, d).flatMap((p) => (p.호표 !== undefined ? [p.호표] : [])),
|
||||
);
|
||||
return { 번: one.호표.번, 아래: kids };
|
||||
}),
|
||||
);
|
||||
const rows: HTMLElement[] = [];
|
||||
let ok = 0;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
for (const no of order) {
|
||||
const one = by.get(no)!;
|
||||
const h = briefs.find((x) => x.번 === no) ?? one.호표;
|
||||
const head = el("td", { text: `제${no}호표 ${h.이름}`.trim() });
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
|
||||
const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one));
|
||||
ok += 1;
|
||||
opt.onImport?.(got, h);
|
||||
rows.push(
|
||||
el("tr", {
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
head,
|
||||
el("td", { text: `${got.참조.키} · ${builtWord(got)}` }),
|
||||
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
|
||||
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
rows.push(el("tr", { children: [head, el("td", { text: `실패 — ${why(error)}` })] }));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
result.replaceChildren(table(["호표", "사전조합", "빠진 줄", "값 차이(참고)"], rows));
|
||||
showToast(`만듦 ${ok} / ${order.length}`, ok === order.length ? "success" : "warning");
|
||||
}
|
||||
go.addEventListener("click", () => void review());
|
||||
|
||||
void load();
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
* 좌측 「일위대가 실무자료」 컨테이너(PLAN 324-4 · 일위대가 사전조합 아래) — 층 = 구조물 조합 층(화면 층) ·
|
||||
* [엑셀 올리기](숨은 file 입력 .xls · .xlsx · 쓰기 권한 없으면 꺼짐) + 파일 줄 → 누르면 본 화면
|
||||
* (`M02_MasterTemplete_Hopyo_View`) · 고름 표시 = 본 화면이 메인 칸에 붙어 있는 동안만 ·
|
||||
* 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶.
|
||||
* 올린 직후 엑셀 보기 창(329 · `M02_MasterTemplete_Hopyo_Sheet` 늦게 불러옴)을 자동으로 엶 ·
|
||||
* 파일 목록 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { listHopyo, pageScope, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoLedger, LEDGER_TITLE } from "./M02_MasterTemplete_Hopyo_Ledger";
|
||||
import { buildHopyoView } from "./M02_MasterTemplete_Hopyo_View";
|
||||
|
||||
export const HOPYO_TITLE = "일위대가 실무자료";
|
||||
@@ -48,9 +50,19 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
onClick: () => input.click(),
|
||||
});
|
||||
upload.title = opt.canWrite ? "설계내역서 엑셀(일위대가 시트)" : "이 층 쓰기 권한 없음";
|
||||
const ledger = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
text: LEDGER_TITLE,
|
||||
attrs: {
|
||||
type: "button",
|
||||
title: "엑셀 이름 → 우리 로직 짝 보기 · 고치기",
|
||||
"data-hopyo-ledger-open": "",
|
||||
},
|
||||
});
|
||||
ledger.addEventListener("click", () => void showLedger());
|
||||
const body = el("div", {
|
||||
className: "m02-hopyo-side",
|
||||
children: [el("div", { className: "m02-side__row", children: [upload, input] }), list],
|
||||
children: [el("div", { className: "m02-side__row", children: [upload, input] }), list, ledger],
|
||||
});
|
||||
let files: HopyoFile[] = [];
|
||||
let open: { key: string; view: HTMLElement } | null = null;
|
||||
@@ -75,6 +87,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
});
|
||||
|
||||
function paintList(): void {
|
||||
ledger.classList.toggle("is-active", open?.key === LEDGER_TITLE);
|
||||
list.replaceChildren(
|
||||
...(files.length
|
||||
? files.map((f) => {
|
||||
@@ -107,6 +120,16 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
|
||||
paintList();
|
||||
}
|
||||
|
||||
async function showLedger(): Promise<void> {
|
||||
if (open?.key === LEDGER_TITLE && open.view.isConnected) return;
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
const view = buildHopyoLedger({ scope, canWrite: opt.canWrite });
|
||||
opt.show(`${HOPYO_TITLE} — ${LEDGER_TITLE}`, view);
|
||||
opt.onOpen();
|
||||
open = { key: LEDGER_TITLE, view };
|
||||
paintList();
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const mine = ++seq;
|
||||
try {
|
||||
|
||||
@@ -1,58 +1,31 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Hopyo_View.ts
|
||||
* 일위대가 실무자료 본 화면(PLAN 324-4) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 ·
|
||||
* [지우기] / 호표 하나: 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산 ·
|
||||
* [고르기] [확정] [빼기]) · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기] ·
|
||||
* [엑셀로 보기] · [엑셀에서 보기](329 엑셀 보기 창 · 늦게 불러옴 · 호표 화면은 그 호표 고른 채) ·
|
||||
* 고르기 창 후보 근거 꼬리표 · 엑셀 근거 줄 + [엑셀에서 보기](단가산출 그 행) · [근거 맞음 모두 확정](330).
|
||||
* 일위대가 실무자료 본 화면(PLAN 324-4 · 338-3) — 파일 하나: 파일 · 시트 · 호표 수 · 검산 · 제안 n/m · 호표 표 + 찾기 ·
|
||||
* [지우기] · [엑셀로 보기](329 엑셀 보기 창 · 늦게 불러옴) / 호표 하나 = 확인 창(`M02_MasterTemplete_Hopyo_Check` ·
|
||||
* 체크한 줄만 [확인하고 사전조합 만들기]) — 즉시 확정 · 바로 만들기 단추 없음.
|
||||
* 맞춤 · 만들기는 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) · 쓰기 단추는 층 쓰기 권한만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { loadingError, showLoading } from "@ui/ui_template_loading";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import {
|
||||
buildHopyo,
|
||||
deleteHopyo,
|
||||
guessHopyo,
|
||||
readHopyo,
|
||||
readHopyoOne,
|
||||
saveHopyoMemory,
|
||||
type HopyoBasisHit,
|
||||
type HopyoBuilt,
|
||||
type HopyoGuess,
|
||||
type HopyoLine,
|
||||
type HopyoLineBasis,
|
||||
type HopyoMemory,
|
||||
type HopyoOne,
|
||||
type HopyoScope,
|
||||
type HopyoTarget,
|
||||
type HopyoView,
|
||||
} from "./M02_MasterTemplete_Hopyo_Api";
|
||||
import { buildHopyoCheck } from "./M02_MasterTemplete_Hopyo_Check";
|
||||
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { pickMaterial } from "./M02_Material_Pick";
|
||||
import "./M02_MasterTemplete_Hopyo.css";
|
||||
|
||||
export interface HopyoViewOptions {
|
||||
scope: HopyoScope;
|
||||
/** 층 쓰기 권한 — 없으면 확정 · 만들기 · 지우기 단추 없음 */
|
||||
/** 층 쓰기 권한 — 없으면 체크 · 만들기 · 지우기 단추 없음 */
|
||||
canWrite: boolean;
|
||||
/** 파일을 지웠음 — 좌측 목록 다시 */
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
const STATE: Record<string, string> = {
|
||||
기억: "기억",
|
||||
같음: "같음",
|
||||
후보: "후보",
|
||||
없음: "없음",
|
||||
빼기: "뺌",
|
||||
호표: "호표",
|
||||
"%": "% 줄",
|
||||
};
|
||||
const MATERIAL = new Set(["자재", "별도계상"]);
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
const won = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? "" : v.toLocaleString("ko-KR");
|
||||
@@ -61,12 +34,12 @@ const fail = (error: unknown): void => showToast(`실패 — ${why(error)}`, "er
|
||||
const openSheet = (o: HopyoSheetOptions): void =>
|
||||
void import("./M02_MasterTemplete_Hopyo_Sheet").then((m) => m.openHopyoSheet(o), fail);
|
||||
|
||||
const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
|
||||
const grid = (heads: string[], body: HTMLElement): HTMLElement =>
|
||||
el("div", {
|
||||
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
|
||||
children: [
|
||||
el("table", {
|
||||
className: `ui-general-block__table m02-hopyo__table ${cls}`.trim(),
|
||||
className: "ui-general-block__table m02-hopyo__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
@@ -76,87 +49,10 @@ const grid = (heads: string[], body: HTMLElement, cls = ""): HTMLElement =>
|
||||
}),
|
||||
],
|
||||
});
|
||||
const table = (heads: string[], rows: HTMLElement[]): HTMLElement =>
|
||||
grid(heads, el("tbody", { children: rows }));
|
||||
const td = (text: string, cls = ""): HTMLElement =>
|
||||
el("td", { text, className: cls, attrs: { title: text } });
|
||||
const note = (text: string): HTMLElement => el("span", { className: "m02-hopyo__note", text });
|
||||
|
||||
/** 대상 글 — 「이름 [단위]」 · 자재는 규격도 · 호표 = 「제N호표」 */
|
||||
export const targetText = (t: HopyoTarget | null | undefined): string => {
|
||||
if (!t) return "";
|
||||
if (t.호표 !== undefined) return `제${t.호표}호표 ${t.단위 ? `[${t.단위}]` : ""}`.trim();
|
||||
const spec = t.규격 ? ` ${t.규격}` : "";
|
||||
return `${t.이름 ?? t.로직 ?? t.자재 ?? ""}${spec}${t.단위 ? ` [${t.단위}]` : ""}`;
|
||||
};
|
||||
|
||||
/** 입력값 글 — 「이름=값」 · 못 읽은 입력 표시 */
|
||||
export const inputsText = (values: Record<string, unknown>, unread: string[]): string =>
|
||||
[
|
||||
Object.entries(values)
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join(", "),
|
||||
unread.length ? `⚠ 못 읽음: ${unread.join(", ")}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
/** 만들기 결과 낱말 — [새로 만들기] = 「새로 만들어 덮음」 · 있던 것 = 「다시 씀」 · 그 밖 「만듦」 */
|
||||
export const builtWord = (got: Pick<HopyoBuilt, "다시씀">, fresh: boolean): string =>
|
||||
fresh ? "새로 만들어 덮음" : got.다시씀 ? "다시 씀" : "만듦";
|
||||
|
||||
/** 확정 한 줄 몸 — 대상 · 입력값 · 환산(0 넘는 수) · 없으면 null */
|
||||
export function memoryOf(
|
||||
target: HopyoTarget | null,
|
||||
values: Record<string, unknown>,
|
||||
times: number | null,
|
||||
): HopyoMemory {
|
||||
if (!target || times === null || !(times > 0)) return null;
|
||||
if (target.로직) return { 로직: target.로직, 입력값: values, 환산: times };
|
||||
if (target.자재) return { 자재: target.자재, 입력값: values, 환산: times };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 근거 맞음(330 [근거 맞음 모두 확정]) — 조항 같음(앞 마디 아님) · 단위 맞음 · 직종 겹침
|
||||
* (줄 근거 직종이 보통인부뿐이거나 없으면 — 기계 등 — 직종은 안 봄) */
|
||||
export const basisOk = (b: HopyoBasisHit | undefined, line?: HopyoLineBasis): boolean =>
|
||||
!!b &&
|
||||
b.까닭.startsWith("조항 같음") &&
|
||||
b.단위 &&
|
||||
(b.직종.length > 0 || !(line?.직종 ?? []).some((x) => x !== "보통인부"));
|
||||
|
||||
/** 후보 근거 꼬리표 — 「조항 건설 공통 3-2-1 · 직종 특별인부 · 단위 같음」 · 근거 없으면 "" */
|
||||
export const basisTag = (b: HopyoBasisHit | undefined): string =>
|
||||
b
|
||||
? [
|
||||
b.조항.length ? `조항 ${b.조항.join(", ")}` : "",
|
||||
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
|
||||
b.단위 ? "단위 같음" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "";
|
||||
|
||||
/** 엑셀 근거 줄 — 「출처 건설표준품셈 3-2-1 · 직종 특별인부 · 단가산출 93–101행」 · 없으면 "" */
|
||||
export const sourceText = (b: HopyoLineBasis | undefined): string =>
|
||||
b
|
||||
? [
|
||||
b.출처.length ? `출처 ${b.출처.map((x) => x.join(" ").trim()).join(", ")}` : "",
|
||||
b.직종.length ? `직종 ${b.직종.join(", ")}` : "",
|
||||
`단가산출 ${b.행[0] === b.행[1] ? b.행[0] : `${b.행[0]}–${b.행[1]}`}행`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "";
|
||||
|
||||
/** [근거 맞음 모두 확정] 대상 — 「후보」 줄의 지금 대상이 근거 맞으면 그것 · 아니면 근거 맞는 첫 후보 · 없으면 null */
|
||||
export function basisPick(line: HopyoLine): HopyoTarget | null {
|
||||
const m = line.맞춤;
|
||||
if (m.상태 !== "후보") return null;
|
||||
if (m.대상 && basisOk(m.대상.근거, line.근거)) return m.대상;
|
||||
return (m.후보 ?? []).find((c) => basisOk(c.근거, line.근거)) ?? null;
|
||||
}
|
||||
|
||||
export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement {
|
||||
const view = el("div", { className: "m02-hopyo", attrs: { "data-hopyo-view": key } });
|
||||
let nav = 0; // 늦게 온 응답 버림
|
||||
@@ -223,7 +119,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
note(`시트 ${file.시트}`),
|
||||
note(`호표 ${file.호표수}`),
|
||||
note(wrong.length ? `검산 어긋남 ${wrong.length}` : "검산 맞음"),
|
||||
note(`맞춤 ${file.맞춤[0]} / ${file.맞춤[1]}`),
|
||||
note(`제안 ${file.맞춤[0]} / 줄 ${file.맞춤[1]}`),
|
||||
createButton({
|
||||
label: "엑셀로 보기",
|
||||
size: "sm",
|
||||
@@ -235,7 +131,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
}),
|
||||
...(wrong.length ? [el("p", { className: "m02-hopyo__warn", text: wrong.join("\n") })] : []),
|
||||
search,
|
||||
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "맞춤"], body),
|
||||
grid(["호표", "품명", "규격", "단위", "엑셀 단가", "제안 / 줄"], body),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,7 +141,7 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 맞춤 기억은 그대로`;
|
||||
const ask = `실무자료 「${file.파일}」을 지움 — 만든 사전조합 · 별칭 장부는 그대로`;
|
||||
if (!(await showConfirmDialog(ask, "지우기"))) return;
|
||||
try {
|
||||
await deleteHopyo(opt.scope, key);
|
||||
@@ -257,358 +153,20 @@ export function buildHopyoView(key: string, opt: HopyoViewOptions): HTMLElement
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 호표 하나 — 줄 맞춤 ────────────────────────────────────── */
|
||||
async function drawOne(no: number, built?: HopyoBuilt & { 새로: boolean }): Promise<void> {
|
||||
const got = await load(
|
||||
() => readHopyoOne(opt.scope, key, no),
|
||||
() => void drawOne(no),
|
||||
);
|
||||
if (!got) return;
|
||||
const h = got.호표;
|
||||
const picked = new Map<number, HopyoGuess>(); // 고른 대상(확정 전)
|
||||
const back = createButton({ label: "← 호표 목록", variant: "ghost", size: "sm" });
|
||||
back.addEventListener("click", () => void drawFile());
|
||||
const rows = el("tbody");
|
||||
const paintRows = (): void => {
|
||||
rows.replaceChildren(...h.줄.map((line) => lineRow(got, line, picked, paintRows)));
|
||||
};
|
||||
paintRows();
|
||||
const same = h.줄.filter((x) => x.맞춤.상태 === "같음" && x.맞춤.환산?.배 != null);
|
||||
const tools: HTMLElement[] = [];
|
||||
if (opt.canWrite) {
|
||||
const all = createButton({ label: `같음 모두 확정 (${same.length})`, size: "sm" });
|
||||
all.disabled = !same.length;
|
||||
all.addEventListener("click", () => void confirmAll(no, same));
|
||||
tools.push(all);
|
||||
// 근거(330) — 옛 파일 · 근거 없는 호표면 단추 없음
|
||||
if (h.줄.some((x) => x.근거 || x.맞춤.후보?.some((c) => c.근거))) {
|
||||
const fit = h.줄.filter((x) => basisPick(x));
|
||||
const ok = createButton({ label: `근거 맞음 모두 확정 (${fit.length})`, size: "sm" });
|
||||
ok.title = "조항 · 직종 · 단위가 모두 맞는 후보만";
|
||||
ok.disabled = !fit.length;
|
||||
ok.addEventListener("click", () => void confirmBasis(no, fit));
|
||||
tools.push(ok);
|
||||
}
|
||||
const make = createButton({ label: "사전조합 만들기", variant: "filled", size: "sm" });
|
||||
make.addEventListener("click", () => void build(no, false));
|
||||
tools.push(make);
|
||||
}
|
||||
const missing = h.줄.filter((x) => !x.맞춤.들어감);
|
||||
/* ── 호표 하나 — 확인 창 ────────────────────────────────────── */
|
||||
function drawOne(no: number): void {
|
||||
nav += 1; // 파일 표 늦은 응답 버림
|
||||
view.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar",
|
||||
children: [
|
||||
back,
|
||||
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
|
||||
note(h.규격),
|
||||
note(h.단위),
|
||||
note(`엑셀 단가 ${won(h.금액.계)}`),
|
||||
note(`맞춤 ${h.맞춤[0]} / ${h.맞춤[1]}`),
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key,
|
||||
파일: got.파일,
|
||||
canWrite: opt.canWrite,
|
||||
호표: no,
|
||||
}),
|
||||
}),
|
||||
...tools,
|
||||
],
|
||||
buildHopyoCheck({
|
||||
scope: opt.scope,
|
||||
key,
|
||||
no,
|
||||
canWrite: opt.canWrite,
|
||||
onBack: () => void drawFile(),
|
||||
}),
|
||||
h.줄.length
|
||||
? grid(
|
||||
[
|
||||
"갈래",
|
||||
"품명",
|
||||
"규격",
|
||||
"수량",
|
||||
"단위",
|
||||
"엑셀 단가",
|
||||
"맞춤",
|
||||
"대상",
|
||||
"입력값",
|
||||
"환산",
|
||||
"",
|
||||
],
|
||||
rows,
|
||||
"m02-hopyo__lines",
|
||||
)
|
||||
: el("p", { className: "m02-hopyo__note", text: "구성 줄 없음" }),
|
||||
...(missing.length ? [missingBlock(missing)] : []),
|
||||
...(built ? [builtBlock(no, built)] : []),
|
||||
);
|
||||
}
|
||||
|
||||
function lineRow(
|
||||
got: HopyoOne,
|
||||
line: HopyoLine,
|
||||
picked: Map<number, HopyoGuess>,
|
||||
repaint: () => void,
|
||||
): HTMLElement {
|
||||
const m = line.맞춤;
|
||||
const mine = picked.get(line.차례);
|
||||
const target = mine?.대상 ?? m.대상;
|
||||
const values = mine?.입력값 ?? m.입력값;
|
||||
const conv = mine?.환산 ?? m.환산;
|
||||
const fixed = ["%", "호표", "빼기"].includes(m.상태);
|
||||
const times = el("input", {
|
||||
className: "ui-input ui-input--sm m02-hopyo__times",
|
||||
attrs: {
|
||||
type: "number",
|
||||
step: "any",
|
||||
min: "0",
|
||||
value: conv?.배 != null ? String(conv.배) : "",
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
times.disabled = !opt.canWrite || fixed || !target;
|
||||
const state = mine ? "고름" : (STATE[m.상태] ?? m.상태);
|
||||
const actions: HTMLElement[] = [];
|
||||
if (opt.canWrite && m.상태 === "빼기") {
|
||||
actions.push(
|
||||
createButton({
|
||||
label: "되살림",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void remember(got.호표.번, { [m.열쇠]: null }),
|
||||
}),
|
||||
);
|
||||
} else if (opt.canWrite && !["%", "호표"].includes(m.상태)) {
|
||||
actions.push(
|
||||
createButton({
|
||||
label: "고르기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
choose(line, got.파일, (t) =>
|
||||
guessHopyo(opt.scope, key, got.호표.번, line.차례, t).then(
|
||||
(g) => (picked.set(line.차례, g), repaint()),
|
||||
fail,
|
||||
),
|
||||
),
|
||||
}),
|
||||
createButton({
|
||||
label: "확정",
|
||||
size: "sm",
|
||||
disabled: !target,
|
||||
onClick: () => {
|
||||
const body = memoryOf(target, values, times.value === "" ? null : Number(times.value));
|
||||
if (!body) return showToast("대상과 0 넘는 환산을 넣어야 확정", "error");
|
||||
void remember(got.호표.번, { [m.열쇠]: body });
|
||||
},
|
||||
}),
|
||||
createButton({
|
||||
label: "빼기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => void remember(got.호표.번, { [m.열쇠]: { 빼기: true } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return el("tr", {
|
||||
className: `m02-hopyo__line is-${m.들어감 ? "in" : "out"}`,
|
||||
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
|
||||
children: [
|
||||
td(line.갈래),
|
||||
td(line.이름),
|
||||
td(line.규격),
|
||||
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
|
||||
td(line.단위),
|
||||
td(won(line.단가.계), "m02-hopyo__num"),
|
||||
el("td", {
|
||||
children: [el("span", { className: `m02-hopyo__state is-${m.상태}`, text: state })],
|
||||
}),
|
||||
td(targetText(target)),
|
||||
td(inputsText(values, mine?.못읽음 ?? m.못읽음)),
|
||||
el("td", {
|
||||
children: [times, note(conv?.갈래 ? ` ${conv.갈래}` : "")],
|
||||
}),
|
||||
el("td", { className: "m02-hopyo__acts", children: actions }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 대상 고르기 — 엑셀 근거 줄 · 후보 5(근거 꼬리표) + 다른 로직 · 자재 찾기 */
|
||||
function choose(line: HopyoLine, 파일: string, done: (t: HopyoTarget) => void): void {
|
||||
const material = MATERIAL.has(line.갈래);
|
||||
openModal({
|
||||
title: `고르기 — ${line.이름} ${line.규격}`.trim(),
|
||||
closeLabel: "닫기",
|
||||
dialogClass: "m02-modal m02-hopyo__choose",
|
||||
mount: (body, close) => {
|
||||
const pick = (t: HopyoTarget): void => {
|
||||
close();
|
||||
done(t);
|
||||
};
|
||||
const list = (line.맞춤.후보 ?? []).map((c) => {
|
||||
const button = createButton({
|
||||
label: `${targetText(c)} · ${c.로직 ?? c.자재 ?? ""}`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => pick(c),
|
||||
});
|
||||
const tag = basisTag(c.근거);
|
||||
if (tag && c.근거)
|
||||
button.append(
|
||||
el("span", {
|
||||
className: `m02-hopyo__basis${basisOk(c.근거, line.근거) ? " is-ok" : ""}`,
|
||||
text: tag,
|
||||
attrs: { title: `${c.근거.까닭} · 점수 ${c.근거.점수}` },
|
||||
}),
|
||||
);
|
||||
return button;
|
||||
});
|
||||
const basis = line.근거;
|
||||
const source = basis
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-hopyo__bar m02-hopyo__source",
|
||||
children: [
|
||||
note(`엑셀 근거 — ${sourceText(basis)}`),
|
||||
createButton({
|
||||
label: "엑셀에서 보기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openSheet({
|
||||
scope: opt.scope,
|
||||
key,
|
||||
파일,
|
||||
canWrite: opt.canWrite,
|
||||
단산행: basis.행,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const more = createButton({
|
||||
label: material ? "다른 자재 찾기…" : "다른 로직 찾기…",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
if (material) pickMaterial((x) => done({ 자재: x.코드 }));
|
||||
else openLogicPicker((x) => done({ 로직: x.키 }));
|
||||
},
|
||||
});
|
||||
body.append(
|
||||
...source,
|
||||
el("p", { className: "m02-hopyo__note", text: list.length ? "후보" : "가까운 것 없음" }),
|
||||
el("div", { className: "m02-hopyo__cands", children: list }),
|
||||
more,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function remember(no: number, 줄: Record<string, HopyoMemory>): Promise<void> {
|
||||
try {
|
||||
await saveHopyoMemory(opt.scope, 줄);
|
||||
await drawOne(no);
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAll(no: number, lines: HopyoLine[]): Promise<void> {
|
||||
const body: Record<string, HopyoMemory> = {};
|
||||
for (const x of lines) {
|
||||
const one = memoryOf(x.맞춤.대상, x.맞춤.입력값, x.맞춤.환산?.배 ?? null);
|
||||
if (one) body[x.맞춤.열쇠] = one;
|
||||
}
|
||||
await remember(no, body);
|
||||
}
|
||||
|
||||
/** 근거 맞는 후보로 확정 — 지금 대상이 아니면 입력값 · 환산을 서버에서 다시 읽음 */
|
||||
async function confirmBasis(no: number, lines: HopyoLine[]): Promise<void> {
|
||||
const body: Record<string, HopyoMemory> = {};
|
||||
for (const x of lines) {
|
||||
const c = basisPick(x);
|
||||
const m = x.맞춤;
|
||||
if (!c) continue;
|
||||
const g =
|
||||
c === m.대상
|
||||
? { 대상: c, 입력값: m.입력값, 환산: m.환산 }
|
||||
: await guessHopyo(opt.scope, key, no, x.차례, c).catch(() => null);
|
||||
const one = g && memoryOf(g.대상, g.입력값, g.환산?.배 ?? null);
|
||||
if (one) body[m.열쇠] = one;
|
||||
}
|
||||
if (!Object.keys(body).length) return showToast("확정할 줄 없음 — 환산을 못 읽음", "warning");
|
||||
await remember(no, body);
|
||||
}
|
||||
|
||||
async function build(no: number, fresh: boolean): Promise<void> {
|
||||
try {
|
||||
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: key, 번: no };
|
||||
const got = await buildHopyo(opt.scope, from, fresh);
|
||||
const word = builtWord(got, fresh);
|
||||
const miss = got.빠진줄.length;
|
||||
showToast(miss ? `${word} — 빠진 줄 ${miss}` : word, miss ? "warning" : "success");
|
||||
await drawOne(no, { ...got, 새로: fresh });
|
||||
} catch (error) {
|
||||
fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
const missingBlock = (lines: HopyoLine[]): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-hopyo__block",
|
||||
children: [
|
||||
el("h4", { text: `빠진 줄 ${lines.length} — 사전조합에 안 들어감` }),
|
||||
table(
|
||||
["품명", "규격", "수량", "단위", "엑셀 금액", "까닭"],
|
||||
lines.map((x) =>
|
||||
el("tr", {
|
||||
children: [
|
||||
td(x.이름),
|
||||
td(x.규격),
|
||||
td(x.수량 === null ? "" : String(x.수량), "m02-hopyo__num"),
|
||||
td(x.단위),
|
||||
td(won(x.금액.계), "m02-hopyo__num"),
|
||||
td(STATE[x.맞춤.상태] ?? x.맞춤.상태),
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
function builtBlock(no: number, got: HopyoBuilt & { 새로: boolean }): HTMLElement {
|
||||
const c = got.비교;
|
||||
const again = got.다시씀
|
||||
? [
|
||||
note(
|
||||
got.새로
|
||||
? `사전조합 ${got.참조.키} 를 새로 만들어 덮음`
|
||||
: `있던 사전조합 ${got.참조.키} 를 다시 씀`,
|
||||
),
|
||||
createButton({
|
||||
label: "새로 만들기",
|
||||
variant: "danger",
|
||||
size: "sm",
|
||||
onClick: async () => {
|
||||
const ask = `사전조합 ${got.참조.키} 를 지금 맞춤으로 다시 만들어 덮음`;
|
||||
if (await showConfirmDialog(ask, "새로 만들기")) await build(no, true);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [note(`새 사전조합 ${got.참조.키}`)];
|
||||
return el("div", {
|
||||
className: "m02-hopyo__block m02-hopyo__built",
|
||||
attrs: { "data-built": got.참조.키 },
|
||||
children: [
|
||||
el("div", { className: "m02-hopyo__bar", children: again }),
|
||||
note(
|
||||
`값 비교 — 엑셀 ${won(c.엑셀)} · 우리 ${won(c.우리 === null ? null : Math.round(c.우리))}` +
|
||||
` · 차이 ${won(c.차이)}${c.까닭 ? ` · ${c.까닭}` : ""}`,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
void drawFile();
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,12 @@ const LAYER_TEXT = {
|
||||
personal: "Lib_Personal",
|
||||
} as const;
|
||||
|
||||
export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: LibraryPick): void {
|
||||
/** `pre.query` = 찾기 칸에 미리 넣을 글(338 실무자료 고르기 창 · 인용 조항 번호 · 품명 낱말) */
|
||||
export function openLogicPicker(
|
||||
done: (logic: LogicSummary) => void,
|
||||
lib?: LibraryPick,
|
||||
pre?: { query?: string },
|
||||
): void {
|
||||
const subs: SubBrief[] = [];
|
||||
let classes: ReturnType<typeof precomboClasses> = []; // 사전조합 새 분류(149-2) — 서고 줄이 있을 때만
|
||||
let all: LogicSummary[] = [];
|
||||
@@ -112,6 +117,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra
|
||||
filters: [...(kindSel ? [kindSel.root] : []), sub.root, detail.root],
|
||||
extra: [layerBar],
|
||||
});
|
||||
if (pre?.query) finder.search.value = pre.query;
|
||||
|
||||
const find = (): void => {
|
||||
const q = finder.search.value.trim().toLowerCase();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
/* M02 일위대가 실무자료 확인 창(338-3) 헬퍼 — `Hopyo_Check` · `Hopyo_Choose` · `Hopyo_Ledger` · `Hopyo_Sheet`
|
||||
* 도우미와 `Hopyo_Api` 길을 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · sessionStorage 대역 ·
|
||||
* stdin = {lines, rows, ledger} · stdout = {check, choose, ledger, sheet, calls, pick} JSON. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
|
||||
// DOM · 모듈 메타를 쓰는 창은 대역(도우미 · 길만 시험)
|
||||
const STUB_FILES = [
|
||||
"M02_MasterTemplete_Labor_Pick",
|
||||
"M02_Material_Pick",
|
||||
"M02_MasterTemplete_Labor_Api",
|
||||
"M02_MasterTemplete_Labor_Input",
|
||||
"M01_MasterData_UI_LogicLab_Source",
|
||||
];
|
||||
const opened = [];
|
||||
const stub = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_t, k) =>
|
||||
k === "currentLanguageIndex"
|
||||
? () => 0
|
||||
: k === "openModal"
|
||||
? (o) => opened.push(o.title)
|
||||
: () => {},
|
||||
},
|
||||
);
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (STUB_FILES.some((name) => request.endsWith(name))) return stub;
|
||||
if (request.startsWith(".") && !request.endsWith(".css"))
|
||||
return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
|
||||
return stub;
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const store = new Map();
|
||||
global.sessionStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const calls = [];
|
||||
global.fetch = async (url, init = {}) => {
|
||||
calls.push({
|
||||
url,
|
||||
method: init.method ?? "GET",
|
||||
body: init.body ? JSON.parse(init.body) : null,
|
||||
});
|
||||
const body = url.includes("/hopyo-picks") || url.includes("/hopyo-ledger") ? { 줄: [] } : {};
|
||||
return { status: 200, ok: true, json: async () => body };
|
||||
};
|
||||
const M02 = path.join(ROOT, "M02_MasterTemplete");
|
||||
const check = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Check.ts"));
|
||||
const choose = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts"));
|
||||
const ledger = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Ledger.ts"));
|
||||
const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts"));
|
||||
const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts"));
|
||||
const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts"));
|
||||
const { lines } = input;
|
||||
const scope = { layer: "personal", owner: null };
|
||||
const key = check.draftKey(scope, "HP-000001", 3);
|
||||
|
||||
// 서버 제안 그대로 → 사용자가 만짐(체크 끔 · 뺌 · 고른 대상) → 캐시 다시 읽음
|
||||
const fresh = check.loadDraft(key);
|
||||
const suggested = check.bodyOf(lines, fresh);
|
||||
const d = check.loadDraft(key);
|
||||
d.체크[1] = false; // 맞음 줄 체크 끔
|
||||
d.뺌[3] = "우리에 없음";
|
||||
d.체크[3] = true;
|
||||
d.대상[4] = [{ 로직: "GF000211", 이름: "고임돌", 입력값: { 유형: "보통" }, 배: 0.1, 몫: 0.1 }];
|
||||
d.체크[4] = true;
|
||||
check.saveDraft(key, d);
|
||||
const again = check.loadDraft(key);
|
||||
const ask = check.newest();
|
||||
const a = ask(1);
|
||||
const b = ask(1);
|
||||
const c = ask(2);
|
||||
const out = {
|
||||
check: {
|
||||
key,
|
||||
suggested,
|
||||
edited: check.bodyOf(lines, again),
|
||||
leftOut: check.leftOut(lines, again),
|
||||
checkable: lines.map((l) => check.checkable(l, fresh)),
|
||||
sure: check.sureLines(lines, fresh).map((l) => l.차례),
|
||||
compare: check.compareText(lines[1].맞춤.품),
|
||||
toTarget: [
|
||||
check.toTarget({ 호표: 46, 이름: "고화매트", 입력값: {}, 배: 2.3 }),
|
||||
check.toTarget({ 자재: "MT1", 이름: "레미콘", 입력값: {}, 배: 1, 몫: null }),
|
||||
],
|
||||
newest: [a(), b(), c()],
|
||||
dropped: (check.dropDraft(key), store.has(key)),
|
||||
},
|
||||
choose: {
|
||||
hint: [
|
||||
choose.searchHint({
|
||||
이름: "레미콘타설(장비사용)",
|
||||
근거: { 출처: [["건설품셈", "6-1-1"]], 직종: [], 행: [1, 1] },
|
||||
}),
|
||||
choose.searchHint({ 이름: "(소)고임돌 채집 및 운반" }),
|
||||
],
|
||||
picked: choose.pickedTargets([
|
||||
{ c: { 로직: "GC000206" }, 몫: null },
|
||||
{ c: { 로직: "GF000211" }, 몫: 0.1 },
|
||||
]),
|
||||
inputs: choose.inputsText({ 소량감률: 50, 지역: "서울" }, ["측압"], {
|
||||
소량감률: "블록",
|
||||
지역: "자동값",
|
||||
}),
|
||||
word: [choose.builtWord({ 다시씀: false }), choose.builtWord({ 다시씀: true })],
|
||||
qty: choose.qtyText(lines[1].맞춤.품.엑셀),
|
||||
target: choose.targetText({ 호표: 46, 단위: "㎡" }),
|
||||
},
|
||||
ledger: (() => {
|
||||
const rows = input.ledger;
|
||||
const edits = {
|
||||
"가|b|m3": null, // 지움
|
||||
"나||m3": { 원래: { 품명: "나", 규격: "", 단위: "m3" }, 갈래: "안 씀", 짝: [] },
|
||||
"다||개": ledger.entryOf(rows[2]), // 위 층 → 내 층
|
||||
};
|
||||
const merged = ledger.mergeRows(rows, edits, { layer: "personal", owner: "7/42" });
|
||||
return {
|
||||
key: ledger.ledgerKey({ layer: "company", owner: "7" }),
|
||||
merged: merged.map((r) => [r.열쇠, r.층, r.고침, !!r.고친, r.갈래]),
|
||||
filter: ledger.filterRows(merged, "MT1", "").map((r) => r.열쇠),
|
||||
kind: ledger.filterRows(merged, "", "안 씀").map((r) => r.열쇠),
|
||||
pair: ledger.pairText({ 로직: "GC000198", 입력값: { 소량감률: 50 }, 배: 1, 몫: 0.1 }),
|
||||
entry: ledger.entryOf({ ...rows[0], 갈래: "우리에 없음" }),
|
||||
};
|
||||
})(),
|
||||
sheet: {
|
||||
order: sheet.buildOrder([
|
||||
{ 번: 45, 아래: [46] },
|
||||
{ 번: 46, 아래: [] },
|
||||
{ 번: 3, 아래: [] },
|
||||
{ 번: 7, 아래: [8] },
|
||||
{ 번: 8, 아래: [7] },
|
||||
]),
|
||||
},
|
||||
};
|
||||
// 길 — 만들기(줄 · 통째) · 호표 하나(만들 층) · 미리 셈 · 장부
|
||||
const from = { 층: "system", 주인: null, 키: "HP-000001", 번: 3 };
|
||||
const target = { layer: "company", owner: "7" };
|
||||
await api.buildHopyo(target, from, suggested);
|
||||
await api.buildHopyo(target, from, [], { 대상: [{ 로직: "GF000225", 배: 1 }] });
|
||||
await api.readHopyoOne({ layer: "system", owner: null }, "HP-000001", 3, target);
|
||||
await api.guessHopyo(scope, "HP-000001", 3, 4, [
|
||||
{ 로직: "GF000211", 입력값: {}, 배: 0.1, 몫: 0.1 },
|
||||
]);
|
||||
await api.readLedger(target);
|
||||
await api.saveLedger(target, { "가|b|m3": null });
|
||||
out.calls = calls.splice(0);
|
||||
// [로직 추가] 실무 호표 — 확인된 것 = 바로 참조 · 아닌 것 = 확인 창(만들기 길 안 부름)
|
||||
const picked = [];
|
||||
const lib = {
|
||||
layer: "company",
|
||||
owner: "7",
|
||||
pick: (ref, name, alias) => picked.push({ ref, name, alias }),
|
||||
};
|
||||
for (const row of input.rows) await pick.pickHopyo(row, lib);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
out.pick = { picked, calls: calls.splice(0), opened };
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error?.stack ?? error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
"""338 골든 도우미 — 실물 엑셀(`resources/knowledge/original/실무문서`)을 지금 읽기로 읽어 정답 표
|
||||
(`fixtures/m02_hopyo_golden.json`)와 맞대고 지표(설계 7-3)를 셈. 읽기만.
|
||||
|
||||
블록 글 = HP 블록에 `글` 이 있으면 그것(나 창 2a 뒤) · 없으면 단가산출 시트 산출근거 열 글(엑셀꼴) ·
|
||||
거창 = 줄 칸을 이어 붙임. STmate 파일은 읽기(`M02_Hopyo_Excel.read_book`)가 되면 들어감 · 아니면 뺌.
|
||||
python resources/tester/helper_m02_hopyo_golden.py → 지표 찍음 · tmp/m02_338/golden.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
|
||||
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # noqa: E402
|
||||
|
||||
DOCS = ROOT / "resources/knowledge/original/실무문서"
|
||||
FIXTURE = Path(__file__).resolve().parent / "fixtures/m02_hopyo_golden.json"
|
||||
FILES = {
|
||||
"북부청임도": DOCS
|
||||
/ "2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls",
|
||||
"북부청사방": DOCS
|
||||
/ "2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls",
|
||||
"거창": DOCS
|
||||
/ "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
|
||||
"봉화": DOCS
|
||||
/ "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
|
||||
"동부청영월": DOCS / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
|
||||
"울진대흥": DOCS
|
||||
/ "2024년 간선임도(기번3-울진.대흥)(공통)"
|
||||
/ "2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx",
|
||||
"영덕계류": DOCS
|
||||
/ "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료"
|
||||
/ "2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx",
|
||||
"울진소광": DOCS
|
||||
/ "25년 산불진화임도(기번8)(울진소광)"
|
||||
/ "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
|
||||
}
|
||||
LABELED = ("북부청임도", "북부청사방", "동부청영월")
|
||||
|
||||
|
||||
def labels() -> list[dict[str, Any]]:
|
||||
return json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _row_text(row: list, start: int) -> str:
|
||||
cells = []
|
||||
for v in row[start:140]:
|
||||
if v is None or v == "":
|
||||
continue
|
||||
cells.append(str(int(v) if isinstance(v, float) and v == int(v) else v).strip())
|
||||
return " ".join(cells)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def read(tag: str) -> dict[str, Any] | None:
|
||||
"""파일 → HP 꼴(블록 `글` 붙임) · 못 읽으면 None(STmate 읽기 전)."""
|
||||
path = FILES[tag]
|
||||
data = path.read_bytes()
|
||||
try:
|
||||
book = hx.read_book(path.name, data)
|
||||
except hx.ExcelError:
|
||||
return None
|
||||
if any(b.get("글") or b.get("자원") for b in book.get("단산") or []):
|
||||
return book
|
||||
calc = hx._sheets(path.name, data).get(hb.SHEET) or []
|
||||
_, col, _ = hb._columns(calc)
|
||||
join = tag == "거창"
|
||||
for b in book.get("단산") or []:
|
||||
a, z = b["행"]
|
||||
rows = calc[a:z]
|
||||
text = [_row_text(r, col) if join else (r[col] if col < len(r) else "") for r in rows]
|
||||
b["글"] = [str(t).rstrip() for t in text if isinstance(t, str) and t.strip()]
|
||||
return book
|
||||
|
||||
|
||||
def _key(line: dict[str, Any]) -> tuple:
|
||||
ref = line.get("참조") or {}
|
||||
no = ref.get("번") if ref.get("종류") == "단산" else None
|
||||
code = str(ref.get("코드") or line.get("코드") or "")
|
||||
return line.get("이름"), line.get("규격") or "", line.get("단위") or "", no, code[:6]
|
||||
|
||||
|
||||
def _lab_key(lab: dict[str, Any]) -> tuple:
|
||||
return lab["이름"], lab["규격"], lab["단위"], lab.get("단산"), str(lab.get("코드") or "")[:6]
|
||||
|
||||
|
||||
def line_of(lid: str) -> tuple[dict | None, dict[str, Any]]:
|
||||
"""정답 표 id → (블록, 줄) — 시험이 실물 줄 하나를 꺼냄."""
|
||||
lab = next(x for x in labels() if x["id"] == lid)
|
||||
book = read(lab["파일"])
|
||||
blocks = {int(b["번"]): b for b in book.get("단산") or []}
|
||||
for h in book["호표"]:
|
||||
for line in h["줄"]:
|
||||
if _key(line) == _lab_key(lab) or (
|
||||
not line.get("참조") and _key(line)[:3] == _lab_key(lab)[:3]
|
||||
):
|
||||
ref = line.get("참조") or {}
|
||||
return (blocks.get(int(ref["번"])) if ref.get("종류") == "단산" else None), line
|
||||
raise KeyError(lid)
|
||||
|
||||
|
||||
def evaluate(tags: tuple[str, ...] | None = None) -> dict[str, Any]:
|
||||
"""{줄: {id: 결과}, 호표: {(파일, 번): (ids, refs)}, 읽음: [파일], 시간}."""
|
||||
labs = labels()
|
||||
tags = tags or tuple(dict.fromkeys(lab["파일"] for lab in labs))
|
||||
got = hm.targets()
|
||||
out: dict[str, Any] = {"줄": {}, "호표": {}, "읽음": [], "못읽음": [], "시간": {}}
|
||||
for tag in tags:
|
||||
book = read(tag)
|
||||
if book is None:
|
||||
out["못읽음"].append(tag)
|
||||
continue
|
||||
out["읽음"].append(tag)
|
||||
want = {_lab_key(lab): lab for lab in labs if lab["파일"] == tag}
|
||||
loose = {k[:3]: lab for k, lab in want.items()}
|
||||
blocks = {int(b["번"]): b for b in book.get("단산") or []}
|
||||
t0 = time.time()
|
||||
for h in book["호표"]:
|
||||
ids, refs = [], []
|
||||
for line in h["줄"]:
|
||||
ref = line.get("참조") or {}
|
||||
if ref.get("종류") == "일위" and ref.get("번"):
|
||||
refs.append(int(ref["번"]))
|
||||
lab = want.get(_key(line)) or (loose.get(_key(line)[:3]) if not ref else None)
|
||||
if lab is None:
|
||||
continue
|
||||
ids.append(lab["id"])
|
||||
if lab["id"] in out["줄"]:
|
||||
continue
|
||||
block = blocks.get(int(ref["번"])) if ref.get("종류") == "단산" else None
|
||||
out["줄"][lab["id"]] = {"lab": lab, "셈": verdict.block(block, line, got)}
|
||||
out["호표"][f"{tag}|{h['번']}"] = (list(dict.fromkeys(ids)), refs)
|
||||
out["시간"][tag] = round(time.time() - t0, 1)
|
||||
return out
|
||||
|
||||
|
||||
def chosen_of(entry: dict[str, Any]) -> set[str]:
|
||||
"""줄이 고른 로직(T1) — 조합이면 체크한 것 · 아니면 1위."""
|
||||
combo = entry.get("조합") or []
|
||||
top = (entry.get("차례") or [None])[0]
|
||||
codes = {c["로직"] for c in combo if c["로직"] and c.get("체크")}
|
||||
return codes or ({top} if top else set())
|
||||
|
||||
|
||||
def _gather(res: dict, key: str, seen: tuple = ()) -> list[str]:
|
||||
tag = key.split("|")[0]
|
||||
if key not in res["호표"] or key in seen:
|
||||
return []
|
||||
ids, refs = res["호표"][key]
|
||||
out = list(ids)
|
||||
for no in refs:
|
||||
out += _gather(res, f"{tag}|{no}", (*seen, key))
|
||||
return list(dict.fromkeys(out))
|
||||
|
||||
|
||||
def metrics(res: dict[str, Any], group: set[str] | None = None) -> dict[str, Any]:
|
||||
"""설계 7-3 지표 — 사람 정답 줄 · 새 파일 · 조합 맞음(T1 · 덮을 수 있는 호표)."""
|
||||
rows = [r for r in res["줄"].values() if group is None or r["lab"]["무리"] in group]
|
||||
have = [r for r in rows if r["lab"]["정답"]]
|
||||
ok1 = lambda r: (r["셈"]["차례"] or [None])[0] in set(r["lab"]["정답"]) # noqa: E731
|
||||
state = lambda r: r["셈"]["판정"]["상태"] # noqa: E731
|
||||
opt_ok = 0
|
||||
for r in have:
|
||||
top = (r["셈"]["차례"] or [None])[0]
|
||||
found = (r["셈"]["옵션"] or {}).get(top) or {}
|
||||
if ok1(r) and found.get("수준", 9) <= 1 and state(r) == "맞음":
|
||||
opt_ok += 1
|
||||
out = {
|
||||
"줄": len(have),
|
||||
"1순위": sum(ok1(r) for r in have),
|
||||
"다섯 안": sum(bool(set(r["셈"]["차례"][:5]) & set(r["lab"]["정답"])) for r in have),
|
||||
"맞음": sum(state(r) == "맞음" for r in rows),
|
||||
"맞음 틀림": sum(state(r) == "맞음" and not ok1(r) for r in rows),
|
||||
"다른 점": sum(state(r) == "다른 점" for r in rows),
|
||||
"다른 점 틀림": sum(state(r) == "다른 점" and not ok1(r) for r in rows),
|
||||
"옵션까지": opt_ok,
|
||||
}
|
||||
keys = [k for k in res["호표"] if k.split("|")[0] in LABELED]
|
||||
whole = cover = 0
|
||||
by = res["줄"]
|
||||
for key in keys:
|
||||
ids = [
|
||||
i
|
||||
for i in _gather(res, key)
|
||||
if i in by and (group is None or by[i]["lab"]["무리"] in group)
|
||||
]
|
||||
if not ids:
|
||||
continue
|
||||
slots, chosen = [], set()
|
||||
for i in ids:
|
||||
lab = by[i]["lab"]
|
||||
if not lab["정답"]:
|
||||
slots.append(set())
|
||||
elif lab.get("복합") and lab["id"] != "S062":
|
||||
slots += [{x} for x in lab["복합"]]
|
||||
else:
|
||||
slots.append(set(lab["정답"]))
|
||||
chosen |= chosen_of(by[i]["셈"])
|
||||
if any(not s for s in slots):
|
||||
continue
|
||||
cover += 1
|
||||
alts = set().union(*slots)
|
||||
whole += all(chosen & s for s in slots) and not (chosen - alts)
|
||||
out["조합 호표"], out["조합 맞음"] = cover, whole
|
||||
return out
|
||||
|
||||
|
||||
def show(res: dict[str, Any]) -> dict[str, Any]:
|
||||
table = {
|
||||
"사람": metrics(res, {"사람"}),
|
||||
"새 파일": metrics(res, {"자동", "씨앗", "자동+씨앗"}),
|
||||
**{
|
||||
tag: metrics(
|
||||
{**res, "줄": {i: r for i, r in res["줄"].items() if r["lab"]["파일"] == tag}},
|
||||
{"사람"},
|
||||
)
|
||||
for tag in LABELED
|
||||
if tag in res["읽음"]
|
||||
},
|
||||
}
|
||||
return {"읽음": res["읽음"], "못읽음": res["못읽음"], "시간": res["시간"], "지표": table}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
t = time.time()
|
||||
res = evaluate(tuple(sys.argv[1:]) or None)
|
||||
summary = show(res)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=1))
|
||||
print("전체", round(time.time() - t, 1), "s")
|
||||
dump = {
|
||||
i: {
|
||||
"lab": r["lab"],
|
||||
"차례": r["셈"]["차례"],
|
||||
"판정": r["셈"]["판정"],
|
||||
"조합": r["셈"]["조합"],
|
||||
"B": r["셈"]["B"],
|
||||
}
|
||||
for i, r in res["줄"].items()
|
||||
}
|
||||
(ROOT / "tmp/m02_338").mkdir(parents=True, exist_ok=True)
|
||||
(ROOT / "tmp/m02_338/golden.json").write_text(
|
||||
json.dumps(dump, ensure_ascii=False, indent=1, default=str), "utf-8"
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
/* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) ·
|
||||
* `M02_MasterTemplete_Hopyo_View.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를
|
||||
/* M02 일위대가 실무자료(324-4 · 338-3) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) ·
|
||||
* `M02_MasterTemplete_Hopyo_Choose.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를
|
||||
* 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 ·
|
||||
* stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view, sheet} JSON. */
|
||||
const fs = require("fs");
|
||||
@@ -8,8 +8,14 @@ const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "t
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
|
||||
// 본 화면이 부르는 고르기 창 · 자재 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험)
|
||||
const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"];
|
||||
// 고르기 창 · 자재 창 · 입력 창 · 원문 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험)
|
||||
const STUB_FILES = [
|
||||
"M02_MasterTemplete_Labor_Pick",
|
||||
"M02_Material_Pick",
|
||||
"M02_MasterTemplete_Labor_Api",
|
||||
"M02_MasterTemplete_Labor_Input",
|
||||
"M01_MasterData_UI_LogicLab_Source",
|
||||
];
|
||||
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) });
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
@@ -55,7 +61,7 @@ function loadTs(file) {
|
||||
const rows = (await wrapped.more()).map(({ onPick: _, ...r }) => r);
|
||||
await pick.pickHopyo(input.rows[0], lib);
|
||||
const project = pick.withHopyo({ ...lib, layer: "project" });
|
||||
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts"));
|
||||
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts"));
|
||||
const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts"));
|
||||
const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts"));
|
||||
await api.readHopyoGrid({ layer: "company", owner: "7" }, "HP-000001");
|
||||
@@ -76,17 +82,6 @@ function loadTs(file) {
|
||||
view.targetText({ 호표: 46, 단위: "㎡" }),
|
||||
],
|
||||
inputs: view.inputsText({ 횟수: "4회사용시", 지역: "서울" }, ["보정작업"]),
|
||||
memory: [
|
||||
view.memoryOf({ 로직: "GF000101" }, { 지역: "서울" }, 1),
|
||||
view.memoryOf({ 자재: "MT1" }, {}, 0.5),
|
||||
view.memoryOf({ 로직: "GF000101" }, {}, 0),
|
||||
view.memoryOf(null, {}, 1),
|
||||
],
|
||||
word: [
|
||||
view.builtWord({ 다시씀: false }, false),
|
||||
view.builtWord({ 다시씀: true }, false),
|
||||
view.builtWord({ 다시씀: true }, true),
|
||||
],
|
||||
},
|
||||
basis: (() => {
|
||||
const hit = {
|
||||
@@ -99,9 +94,6 @@ function loadTs(file) {
|
||||
const half = { ...hit, 직종: [] };
|
||||
const trade = { 출처: [["건설품셈", "3-2-1"]], 직종: ["특별인부", "보통인부"], 행: [1, 1] };
|
||||
const plain = { ...trade, 직종: ["보통인부"] };
|
||||
const line = (상태, 대상, 후보) => ({ 근거: trade, 맞춤: { 상태, 대상, 후보 } });
|
||||
const a = { 로직: "GF000101", 근거: half };
|
||||
const b = { 로직: "GC000083", 근거: hit };
|
||||
return {
|
||||
ok: [hit, half, { ...hit, 단위: false }, { ...hit, 까닭: "앞 마디 같음" }, undefined].map(
|
||||
(b) => view.basisOk(b, trade),
|
||||
@@ -114,12 +106,6 @@ function loadTs(file) {
|
||||
view.sourceText({ 출처: [], 직종: [], 행: [7, 7] }),
|
||||
view.sourceText(undefined),
|
||||
],
|
||||
pick: [
|
||||
line("후보", a, [a, b]),
|
||||
line("후보", b, [b]),
|
||||
line("같음", b, [b]),
|
||||
line("후보", a, [a]),
|
||||
].map((x) => view.basisPick(x)?.로직 ?? null),
|
||||
};
|
||||
})(),
|
||||
sheet: {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
/* M02 일위대가 실무자료 이음매(329 · 330) 헬퍼 — 실제 서버 응답(격자 · 호표 맞춤)을
|
||||
* `M02_MasterTemplete_Hopyo_Sheet.ts`(행 → 호표 · 영역 A1) · `M02_MasterTemplete_Hopyo_View.ts`
|
||||
* (근거 꼬리표 · 엑셀 근거 줄 · 근거 맞음 대상)에 먹임 ·
|
||||
* stdin = {grid, lines, all} · stdout = {at, span, rows, lines: [{이름, source, tag, ok, pick}],
|
||||
* count: {top, pick, half}} JSON — count = 「후보」 줄 1순위 근거 맞음 · 근거 맞음 대상 · 그 가운데 앞 마디. */
|
||||
* `M02_MasterTemplete_Hopyo_Sheet.ts`(행 → 호표 · 영역 A1) · `M02_MasterTemplete_Hopyo_Choose.ts`
|
||||
* (근거 꼬리표 · 엑셀 근거 줄 · 근거 맞음)에 먹임 ·
|
||||
* stdin = {grid, lines, all} · stdout = {at, span, rows, lines: [{이름, source, tag, ok}],
|
||||
* count: {top}} JSON — count = 「후보」 줄 1순위 근거 맞음. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
|
||||
const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"];
|
||||
const STUB_FILES = [
|
||||
"M02_MasterTemplete_Labor_Pick",
|
||||
"M02_Material_Pick",
|
||||
"M02_MasterTemplete_Labor_Api",
|
||||
"M02_MasterTemplete_Labor_Input",
|
||||
"M01_MasterData_UI_LogicLab_Source",
|
||||
];
|
||||
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) });
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
@@ -34,7 +40,7 @@ function loadTs(file) {
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const M02 = path.join(ROOT, "M02_MasterTemplete");
|
||||
const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts"));
|
||||
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts"));
|
||||
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts"));
|
||||
const { grid } = input;
|
||||
const ilwi = grid.책.시트.find((s) => s.이름 === "일위대가");
|
||||
const three = grid.호표.find((h) => h.번 === 3);
|
||||
@@ -49,15 +55,11 @@ const out = {
|
||||
source: view.sourceText(line.근거),
|
||||
tag: view.basisTag(top?.근거),
|
||||
ok: view.basisOk(top?.근거, line.근거),
|
||||
pick: view.basisPick(line)?.로직 ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
const open = (input.all ?? []).filter((line) => line.맞춤.상태 === "후보");
|
||||
const picks = open.map((line) => view.basisPick(line)).filter(Boolean);
|
||||
out.count = {
|
||||
top: open.filter((line) => view.basisOk((line.맞춤.후보 ?? [])[0]?.근거, line.근거)).length,
|
||||
pick: picks.length,
|
||||
half: picks.filter((c) => !c.근거.까닭.startsWith("조항 같음")).length,
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
후보 93 → GC000083 · 21 → GC000198 · 64 → GC000143 위 둘 · 49 → GC000085 ·
|
||||
잔토처리에 철골재 철거 없음 · 근거 없는 줄 그대로 · 상태 「후보」(자동 확정 없음) ·
|
||||
블록 글 HP(338-2a · 268 · 139 이음 그대로) · `clause` 윗마디 0.9 · 조항 오타 13-114 ·
|
||||
거창 꼴 흩어진 칸 이어 붙임(단산 줄 21 · 출처 17 · 직종 21 · 338-2b) ·
|
||||
master_data 그대로.
|
||||
"""
|
||||
|
||||
@@ -152,3 +153,19 @@ def test_조항_윗마디_오타(north):
|
||||
assert hb.sources("[산림사업표준품셈 9-14]") == [["산림품셈", "9-14"]]
|
||||
stone = next(b for b in north["단산"] if b["번"] == 63)
|
||||
assert stone["출처"] == [["산림품셈", "13-11-4"]]
|
||||
|
||||
|
||||
def test_거창_흩어진_칸():
|
||||
path = (
|
||||
base.ROOT
|
||||
/ "resources/knowledge/original/실무문서/01.산불거창(거창북상병곡)/01.내역서"
|
||||
/ "00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx"
|
||||
)
|
||||
book = hx.read_book(path.name, path.read_bytes())
|
||||
lines = [x for h in book["호표"] for x in h["줄"] if x["갈래"] == "단가산출"]
|
||||
assert len(lines) == 21 and all(x.get("근거") for x in lines)
|
||||
assert sum(1 for x in lines if x["근거"]["출처"]) == 17
|
||||
assert sum(1 for x in lines if x["근거"]["직종"]) == 21
|
||||
first = book["단산"][0]
|
||||
assert first["이름"] == "측구터파기(토사)" and first["직종"] == ["보통인부"]
|
||||
assert any(t.startswith("보통인부 : 172,068") for t in first["글"])
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
견본 = `resources/knowledge/original/실무문서/2026_실무일위대가 모음/`(북부청 임도 · 사방) ·
|
||||
검산 어긋남 0 · 호표 값 · 시트 없음 400 · xlsx 길 · 역할 표 · 엉터리 · 20 MB · 지우기 ·
|
||||
양식 목록에 안 섞임 ·
|
||||
양식 목록에 안 섞임 · 이음 넓힘(산근 · 대가 · 호표 · 이름으로 이음) · 노임 갈래 ·
|
||||
문서 보안 400(338) ·
|
||||
master_data 원본 그대로(sha256). 층은 임시 폴더.
|
||||
"""
|
||||
|
||||
@@ -207,3 +208,54 @@ def test_마스터_층은_마스터_폴더(client):
|
||||
client.who.update(role="USER")
|
||||
assert client.get(URL.format("system")).json()["줄"][0]["키"] == "HP-000001" # 읽기 = 누구나
|
||||
assert client.delete(URL.format("system") + "/HP-000001").status_code == 403
|
||||
|
||||
|
||||
def _linked_xlsx() -> bytes:
|
||||
"""338 — 비고 「산근 2 호표」 · 「대가1」 · 빈 비고 + 이름 같은 블록 · 노임 줄."""
|
||||
import openpyxl
|
||||
|
||||
book = openpyxl.Workbook()
|
||||
ws = book.active
|
||||
ws.title = "일위대가"
|
||||
ws.append([None, "호표", "품명", "규격", "수량", "단위", "합계", None, "노무비", None, "비고"])
|
||||
ws.append([None, None, None, None, None, None, "단가", "금액", "단가", "금액"])
|
||||
ws.append([None, "제1호표", "표지석", "", None, "개소", None, 1000, None, 1000])
|
||||
ws.append([None, None, "터파기", "토사", 1, "㎥", 100, 100, 100, 100, "산근 2 호표"])
|
||||
ws.append([None, None, "되메우기", "", 1, "㎥", 100, 100, 100, 100, None])
|
||||
ws.append([None, None, "보통인부", "", 1, "인", 100, 100, 100, 100, None])
|
||||
ws.append([None, None, "호표 줄", "", 1, "개소", 100, 100, 100, 100, "대가1"])
|
||||
calc = book.create_sheet("단가산출")
|
||||
calc.append([None, "산출근거", "합계", "노무비"])
|
||||
calc.append([None, "2.터파기[㎥]"])
|
||||
calc.append([None, " [건설공사표준품셈 3-2-1]"])
|
||||
calc.append([None, "3.되메우기[㎥]"])
|
||||
calc.append([None, " 보통인부 : 100 × 1 = 100"])
|
||||
out = io.BytesIO()
|
||||
book.save(out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def test_이음_넓힘_노임():
|
||||
got = hx.read_book("a.xlsx", _linked_xlsx())
|
||||
lines = {x["이름"]: x for x in got["호표"][0]["줄"]}
|
||||
assert lines["터파기"]["참조"] == {"종류": "단산", "번": 2} and "이음" not in lines["터파기"]
|
||||
assert lines["터파기"]["근거"]["출처"] == [["건설품셈", "3-2-1"]]
|
||||
assert lines["되메우기"]["참조"] == {"종류": "단산", "번": 3} # 빈 비고 · 이름 같은 블록 하나
|
||||
assert lines["되메우기"]["이음"] == "이름" and lines["되메우기"]["갈래"] == "단가산출"
|
||||
assert lines["되메우기"]["근거"]["직종"] == ["보통인부"]
|
||||
assert lines["보통인부"]["갈래"] == "노임" and "이음" not in lines["보통인부"]
|
||||
assert lines["호표 줄"]["참조"] == {"종류": "일위", "번": 1}
|
||||
for note, want in (
|
||||
("단산12참조", ("단산", 12)),
|
||||
("단가산출 3", ("단산", 3)),
|
||||
("일위대가4호표", ("일위", 4)),
|
||||
("일위 5 참조", ("일위", 5)),
|
||||
):
|
||||
hit = hx._REF.match(hx._flat(note))
|
||||
assert ("단산" if hit.group(1) else "일위", int(hit.group(3))) == want
|
||||
assert hx._REF.match("단산참조") is None and hx._REF.match("D96") is None
|
||||
|
||||
|
||||
def test_문서_보안_400():
|
||||
with pytest.raises(hx.ExcelError, match="문서 보안"):
|
||||
hx.read_book("a.xlsx", bytes([0x9B]) + b" DRMONE This Document is secured" + bytes(64))
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""338-1 엑셀 지문(`M02_Hopyo_Finger` · `_Words` · `_Shares`) — 식 풀기 일곱 꼴 · 합판 지문 · 고임돌
|
||||
섞음 ·
|
||||
몫 · STmate 자원 · 기계 줄 · 직접 줄 호표. 실물 = 북부청 임도(읽기만)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Finger as finger
|
||||
from M02_MasterTemplete import M02_Hopyo_Shares as shares
|
||||
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, parse_rhs
|
||||
from resources.tester.helper_m02_hopyo_golden import line_of
|
||||
|
||||
approx = pytest.approx
|
||||
|
||||
|
||||
# ── 식 풀기 ────────────────────────────────────────────────────────────
|
||||
def test_식_단가_떼고_품():
|
||||
got = parse_rhs("273,540 × 3인 ÷ 55㎥ × 2 = 29,840.7")
|
||||
assert got["단가"] == 273540 and got["품"] == approx(0.10909, abs=1e-5) and got["율"] == []
|
||||
|
||||
|
||||
def test_식_퍼센트_율():
|
||||
got = parse_rhs("59,020 ÷ 23.1 × 90% = 2,299.5")
|
||||
assert got["품"] == approx(0.03896, abs=1e-5)
|
||||
assert got["밑품"] == approx(1 / 23.1) and got["율"] == [[0.9, "%"]]
|
||||
|
||||
|
||||
def test_식_인당_단위_섞음():
|
||||
got = parse_rhs("172,068 × 0.3인/㎥ × 10% = 5,162")
|
||||
assert got["품"] == approx(0.03) and got["밑품"] == approx(0.3)
|
||||
|
||||
|
||||
def test_식_바꿔_쓴_식은_앞_율을_이어받음():
|
||||
got = parse_rhs("215,907 × 4인 ÷ P × 1.2(할증) = 215,907 × 0.104 × 1.2 = 26,945")
|
||||
assert got["품"] == approx(0.1248) and got["밑품"] == approx(0.104)
|
||||
assert got["율"] == [[1.2, "할증"]]
|
||||
|
||||
|
||||
def test_식_횟수_품할증():
|
||||
got = parse_rhs("172,068 × 0.05인 × 2회 = 17,206")
|
||||
assert got["품"] == approx(0.1) and got["밑품"] == approx(0.05) and got["율"] == [[2.0, "회"]]
|
||||
got = parse_rhs("273,540 × 0.1 × 1.2(품할증) = 32,824")
|
||||
assert got["밑품"] == approx(0.1) and got["율"] == [[1.2, "품할증"]]
|
||||
|
||||
|
||||
def test_식_못_풀면_역산():
|
||||
assert parse_rhs("273,540 × P = 30,000") is None
|
||||
block = {"번": 1, "이름": "시험", "단위": "㎥", "글": [" 특별인부 : 273,540 × P = 27,354"]}
|
||||
fp = finger.excel(block, {"이름": "시험", "규격": "", "단위": "㎥"})
|
||||
assert fp["인력"]["특별인부"]["품"] == approx(0.1) and fp["인력"]["특별인부"]["어떻게"] == [
|
||||
"역산"
|
||||
]
|
||||
|
||||
|
||||
def test_장비_갈래_조건_낱말():
|
||||
assert mtypes("유압식 백호우 0.7㎥") == {"굴착기"} and mtypes("펌프카 41m") == {"콘크리트펌프"}
|
||||
assert conds("합판거푸집 4회 보통")["횟수"] == {"4회"}
|
||||
assert conds("구조물터파기(암)")["토질"] == {"암"}
|
||||
|
||||
|
||||
# ── 실물 지문 ──────────────────────────────────────────────────────────
|
||||
def test_합판_4회_지문():
|
||||
block, line = line_of("N001")
|
||||
fp = finger.excel(block, line)
|
||||
assert fp["인력"]["형틀목공"]["품"] == approx(0.1111, abs=1e-4)
|
||||
assert fp["인력"]["보통인부"]["품"] == approx(0.0444, abs=1e-4)
|
||||
assert "4회" in fp["조건"]["횟수"] and fp["출처"] == [["건설품셈", "6-3-1"]]
|
||||
assert {m["이름"][:4] for m in fp["자재"] if m["품"]} >= {"내수합판"}
|
||||
|
||||
|
||||
def test_레미콘_소량_지문_장비_시간():
|
||||
block, line = line_of("N000")
|
||||
fp = finger.excel(block, line)
|
||||
assert fp["인력"]["콘크리트공"]["품"] == approx(0.10909, abs=1e-5)
|
||||
assert [(m["갈래"], round(m["시간"], 2)) for m in fp["장비"]] == [(["굴착기"], 0.29)]
|
||||
|
||||
|
||||
def test_고임돌_섞음_율과_몫():
|
||||
block, line = line_of("N005")
|
||||
fp = finger.excel(block, line)
|
||||
labor = fp["인력"]["보통인부"]
|
||||
assert labor["밑품"] == approx(0.3, abs=1e-3) and labor["품"] == approx(0.03, abs=1e-3)
|
||||
assert any(note in ("%", "섞음") and v == approx(0.1) for v, note in labor["율"])
|
||||
parts = fp["몫"]
|
||||
assert any(p["노임"] and not p["장비"] for p in parts) # 인력 몫
|
||||
assert any(p["장비"] and not p["노임"] for p in parts) # 장비 몫
|
||||
|
||||
|
||||
def test_몫_절마다_노임_장비():
|
||||
texts = [
|
||||
" 1. 굴착기 0.6㎥ 채집(90%)",
|
||||
" 노무비 : 59,020 ÷ 23.1 × 90% = 2,299",
|
||||
" 2. 인력채집(10%)",
|
||||
" 보통인부 : 172,068 × 0.3인/㎥ × 10% = 5,162",
|
||||
" 3. 합 계",
|
||||
" 보통인부 : 172,068 × 1 = 172,068",
|
||||
]
|
||||
parts = shares.sections(texts)
|
||||
assert [p["제목"] for p in parts] == ["굴착기 0.6㎥ 채집(90%)", "인력채집(10%)"]
|
||||
assert parts[0]["장비"] == {"굴착기|0.6": approx(0.9 / 23.1)}
|
||||
assert parts[1]["노임"] == {"보통인부": approx(0.03)}
|
||||
|
||||
|
||||
def test_STmate_자원_지문():
|
||||
block = {
|
||||
"글": ["잔토처리 (기계100%) 건설표준품셈9-3", "q = 0.7 , K = 0.9"],
|
||||
"자원": [
|
||||
{"갈래": "L", "코드": "L00002", "이름": "보통인부", "규격": "", "품": 0.2},
|
||||
*[
|
||||
{
|
||||
"갈래": "X",
|
||||
"코드": "X00205",
|
||||
"이름": "굴삭기(무한궤도)",
|
||||
"규격": "0.7㎥",
|
||||
"품": 0.0229,
|
||||
}
|
||||
]
|
||||
* 3,
|
||||
{"갈래": "M", "코드": "M00001", "이름": "모래", "규격": "", "품": 1.2},
|
||||
],
|
||||
}
|
||||
fp = finger.excel(block, {"이름": "잔토처리", "규격": "토사", "단위": "M3"})
|
||||
assert fp["인력"] == {"보통인부": {"품": 0.2, "밑품": 0.2, "율": [], "어떻게": ["QTY"]}}
|
||||
assert [(m["갈래"], m["규격"], m["시간"]) for m in fp["장비"]] == [(["굴착기"], [0.7], 0.0229)]
|
||||
assert fp["자재"] == [{"이름": "모래", "품": 1.2, "밑품": 1.2}] and fp["계수"]["K"] == 0.9
|
||||
|
||||
|
||||
def test_기계_줄_블록_없음():
|
||||
fp = finger.excel(None, {"이름": "콘크리트 펌프차", "규격": "41m,80~95㎥/hr", "단위": "hr"})
|
||||
assert [(m["갈래"], m["시간"]) for m in fp["장비"]] == [(["콘크리트펌프"], 1.0)]
|
||||
assert 41.0 in fp["장비"][0]["규격"] and fp["인력"] == {}
|
||||
|
||||
|
||||
def test_직접_줄_호표():
|
||||
hopyo = {
|
||||
"이름": "돌붙임",
|
||||
"규격": "",
|
||||
"단위": "㎡",
|
||||
"줄": [
|
||||
{"이름": "석 공", "규격": "", "수량": 0.1, "단위": "인", "갈래": "기타"},
|
||||
{"이름": "보통인부", "규격": "", "수량": 0.05, "단위": "인", "갈래": "기타"},
|
||||
{"이름": "굴착기", "규격": "0.6㎥", "수량": 0.2, "단위": "hr", "갈래": "기계"},
|
||||
],
|
||||
}
|
||||
fp = finger.direct(hopyo)
|
||||
assert fp["인력"]["석공"]["품"] == 0.1 and fp["인력"]["보통인부"]["품"] == 0.05
|
||||
assert [(m["갈래"], m["시간"]) for m in fp["장비"]] == [(["굴착기"], 0.2)]
|
||||
assert finger.direct({**hopyo, "줄": hopyo["줄"][2:]}) is None # 노임 없으면 호표 통째 아님
|
||||
@@ -0,0 +1,122 @@
|
||||
"""338-1 순위 · 판정 · 조합 · 뒤 셈(`M02_Hopyo_Fit` · `_Verdict` · `_Combine`) — 같음 규칙 · 딱
|
||||
맞음이
|
||||
같음을 이김 · 맞음 · 다른 점 · 후보 · 철근 둘 · 고임돌 인력 10% · 맞춤셈 캐시 · 「셈 중」.
|
||||
실물 = 북부청 임도 · 사방(읽기만) · 층 · 캐시 폴더는 임시."""
|
||||
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||||
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict
|
||||
from resources.tester.helper_m02_hopyo_golden import line_of, read
|
||||
from resources.tester.test_m02_hopyo_excel import master_hash
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got():
|
||||
if not ours.ready():
|
||||
ours.build()
|
||||
return hm.targets()
|
||||
|
||||
|
||||
def _block(lid: str, got: dict, **change) -> dict:
|
||||
block, line = line_of(lid)
|
||||
if change:
|
||||
line = {**line, "근거": {}}
|
||||
block = {**block, "출처": []}
|
||||
return verdict.block(block, line, got)
|
||||
|
||||
|
||||
def test_같음_규칙_이름_같음_1위_고정(got):
|
||||
r = _block("N014", got)
|
||||
assert r["B"] == "같음" and r["차례"][0] == "GC000214" and r["판정"]["상태"] == "맞음"
|
||||
|
||||
|
||||
def test_딱_맞음이_같음을_이김(got):
|
||||
r = _block("N001", got, 근거=None) # 인용 조항을 지우면 이름 같음 = 산림 GF000160
|
||||
assert r["B"] == "같음" and hm.b_order(line_of("N001")[1], got)["상태"] == "후보"
|
||||
assert r["차례"][0] == "GC000211" and r["판정"]["상태"] == "맞음"
|
||||
|
||||
|
||||
def test_판정_셋(got):
|
||||
a = _block("N000", got)
|
||||
assert a["판정"]["상태"] == "맞음" and "소량감률 50" in a["판정"]["글"]
|
||||
assert a["후보"][0]["판정"] == "맞음" and a["후보"][0]["근거"]["까닭"] == "조항 같음"
|
||||
b = _block("N008", got)
|
||||
assert b["판정"]["상태"] == "다른 점" and b["차례"][0] == "GC000083"
|
||||
assert "특별인부 엑셀 0.3542 · 우리 0.3704" in b["판정"]["꼬리표"]
|
||||
c = _block("S084", got)
|
||||
assert c["판정"]["상태"] == "후보" and len(c["후보"]) == 5
|
||||
|
||||
|
||||
def test_합판_맞음_못_가름_꼬리표(got):
|
||||
r = _block("N001", got)
|
||||
assert r["판정"]["상태"] == "맞음"
|
||||
assert any(
|
||||
t.endswith("못 가름") and {"측압", "폼타이규격"} <= set(t.split())
|
||||
for t in r["판정"]["꼬리표"]
|
||||
)
|
||||
|
||||
|
||||
def test_조합_철근_가공_조립(got):
|
||||
r = _block("N003", got)
|
||||
assert {c["로직"] for c in r["조합"] if c["체크"]} == {"GC000206", "GC000207"}
|
||||
|
||||
|
||||
def test_조합_고임돌_인력_몫_먼저(got):
|
||||
r = _block("N005", got)
|
||||
assert r["차례"][0] == "GF000211"
|
||||
labor = next(c for c in r["조합"] if c["로직"] == "GF000211")
|
||||
assert labor["몫"] == 0.1 and labor["체크"]
|
||||
assert all(not c["체크"] for c in r["조합"] if c["로직"] in ("GC000267", "GC000268"))
|
||||
|
||||
|
||||
def test_숫자_없음_옛_HP(got):
|
||||
block, line = line_of("N000")
|
||||
r = verdict.block({**block, "글": []}, line, got)
|
||||
assert r["판정"]["상태"] == "후보" and r["판정"]["글"] == fit.NO_NUMBER
|
||||
assert r["차례"][0] == "GC000198" # 이름 · 근거 차례 그대로
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def layer(tmp_path, monkeypatch, got):
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
shutil.copy(ours.path_of(), cache / ours.path_of().name)
|
||||
monkeypatch.setenv("AISLO_HOPYO_CACHE", str(cache))
|
||||
book = read("북부청임도")
|
||||
small = {**book, "호표": book["호표"][:3]}
|
||||
brief = hopyo_store.save(tmp_path, "북부청.xls", small, {"id": "t"})
|
||||
return tmp_path, brief["키"], cache
|
||||
|
||||
|
||||
def test_뒤_셈_캐시_셈_중(layer):
|
||||
folder, key, cache = layer
|
||||
before = master_hash()
|
||||
assert fit.result(folder, key) is None
|
||||
first = fit.ensure(folder, key)
|
||||
assert first["끝"] is False and first["한"] == 0
|
||||
for _ in range(300):
|
||||
if fit.status(folder, key)["끝"]:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
done = fit.status(folder, key)
|
||||
assert done["끝"] and done["한"] == done["모두"] > 0
|
||||
got = fit.result(folder, key)
|
||||
assert got["판"] == ours.version() and len(got["블록"]) == done["모두"]
|
||||
assert fit.cache_path(folder, key).parent == cache
|
||||
assert fit.ensure(folder, key)["끝"] # 두 번째는 캐시만 봄
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def test_셈_단위_열쇠(got):
|
||||
book = read("북부청임도")
|
||||
units = fit.units({**book, "호표": book["호표"][:3]})
|
||||
keys = [k for k, _, _ in units]
|
||||
assert len(keys) == len(set(keys)) and keys[0].startswith("단산:")
|
||||
assert all(k.startswith(("단산:", "줄:", "호표:")) for k in keys)
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
북부청 임도 xls — 병합 11 · 15열 · B20 「제3호표」 · `#,##0` · 호표 68 행 범위 안 겹침 ·
|
||||
제3호표 [20, 24] · 올리면 격자도 · 200 / 옛 파일 404 · 지우면 격자도 · 역할 · xlsx 값만 ·
|
||||
STmate 격자(일위대가표 · 단가산출근거 · 숨은 칸 글 뺌 · 338) ·
|
||||
master_data 그대로.
|
||||
"""
|
||||
|
||||
@@ -131,3 +132,22 @@ def test_xlsx_값만():
|
||||
assert sheet["칸"]["B5"] == {"값": "제 1 호표"} and sheet["칸"]["E6"] == {"값": 0.5}
|
||||
assert "K6" not in sheet["칸"] # 머리 글(앞 4 줄) 마지막 열 J 까지
|
||||
assert grid["호표"] == [{"번": 1, "이름": "표지석", "행": [5, 6]}] and grid["단산"] == []
|
||||
|
||||
|
||||
def test_STmate_격자():
|
||||
from resources.tester.test_m02_hopyo_stmate import _path
|
||||
|
||||
path = _path("영월")
|
||||
data = path.read_bytes()
|
||||
parsed = hx.read_book(path.name, data)
|
||||
grid = hg.read(path.name, data, parsed)
|
||||
assert [s["이름"] for s in grid["책"]["시트"]] == ["일위대가표", "단가산출근거"]
|
||||
cells = _sheet(grid, "일위대가표")["칸"]
|
||||
one = grid["호표"][0]
|
||||
assert one == {"번": 1, "이름": "녹생토(종자배합)", "행": [5, 10]}
|
||||
assert cells[f"A{one['행'][0]}"]["값"].strip().startswith("제")
|
||||
assert cells["A10"]["값"] == "보통인부"
|
||||
calc = _sheet(grid, "단가산출근거")["칸"]
|
||||
block = next(b for b in grid["단산"] if b["번"] == 2)
|
||||
assert calc[f"B{block['행'][0] + 1}"]["값"].startswith("구조물터파기(인력)")
|
||||
assert not any(str(c.get("값")).startswith("_x000") for c in {**cells, **calc}.values())
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""338-1 옵션 거꾸로 찾기(`M02_Hopyo_Option`) — 레미콘 소량 「하나만」 · 합판 갈리는 칸 · 무근 R
|
||||
맞음 ·
|
||||
구조물터파기 없음(다른 점) · 펌프차 기계 칸 · 견줌 · 반올림 폭. 실물 = 북부청 임도 ·
|
||||
사방(읽기만)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Finger as finger
|
||||
from M02_MasterTemplete import M02_Hopyo_Option as option
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from resources.tester.helper_m02_hopyo_golden import line_of
|
||||
|
||||
|
||||
def _find(lid: str, code: str) -> dict:
|
||||
block, line = line_of(lid)
|
||||
return option.find(code, finger.excel(block, line), (block or {}).get("글"))
|
||||
|
||||
|
||||
def test_레미콘_소량_하나만():
|
||||
got = _find("N000", "GC000198")
|
||||
assert got["수준"] == 0 and got["판정"] == "하나만"
|
||||
assert got["입력"] == {"타설방법": "장비사용타설", "구조물": "철근구조물", "소량감률": 50}
|
||||
assert got["우리품"]["인력"] == {"콘크리트공": 0.109091, "보통인부": 0.036364}
|
||||
|
||||
|
||||
def test_합판_보통_갈리는_칸():
|
||||
got = _find("N001", "GC000211")
|
||||
assert got["수준"] == 0 and got["판정"] == "여럿(같은 품)"
|
||||
assert got["입력"]["유형"] == "보통" and got["입력"]["사용횟수"] == "4회"
|
||||
assert set(got["갈리는칸"]) == {"측압", "폼타이규격"}
|
||||
|
||||
|
||||
def test_무근_반올림_맞음():
|
||||
got = _find("N013", "GC000198")
|
||||
assert got["수준"] <= 1 and got["입력"]["구조물"] == "무근구조물"
|
||||
|
||||
|
||||
def test_구조물터파기_없음_가까운_옵션():
|
||||
got = _find("N008", "GC000083")
|
||||
assert got["수준"] == 9 and got["판정"] == "없음" and got["직종같음"]
|
||||
assert got["견줌"]["직종"] == "직종 같음" and got["거리"] < 0.1
|
||||
|
||||
|
||||
def test_펌프차_기계_칸():
|
||||
efp = finger.machine({"이름": "콘크리트 펌프차", "규격": "41m,80~95㎥/hr", "단위": "hr"})
|
||||
got = option.find("GC000268", efp)
|
||||
assert got["수준"] == 0
|
||||
code = got["입력"]["기계"]
|
||||
assert ours.eq_of(code)[0] == {"콘크리트펌프"} and 41.0 in option.eq_numbers(code)
|
||||
|
||||
|
||||
def test_없는_로직_못_셈():
|
||||
efp = finger.machine({"이름": "굴착기", "규격": "0.6㎥", "단위": "hr"})
|
||||
assert option.find("없는로직", efp)["판정"] == "못 셈"
|
||||
|
||||
|
||||
def test_반올림_폭_견줌():
|
||||
assert option.ru(0.15) == pytest.approx(0.005) and option.ru(0.1) == 0.0
|
||||
assert option.ru(0.109091) == 0.0
|
||||
e = {"인력": {"보통인부": {"품": 0.15, "밑품": 0.15}}, "장비": [], "자재": []}
|
||||
v = {"L": {"보통인부": 0.1545}, "X": [], "M": {}, "S": {}}
|
||||
assert not option.compare(e, v, 0.005)["핵심"] and option.compare(e, v, 0.005, rnd=True)["핵심"]
|
||||
assert not option.strong(e, option.compare(e, v, 0.03)) # 보통인부 하나뿐 = 우연 맞음
|
||||
@@ -0,0 +1,77 @@
|
||||
"""338-1 우리 로직 지문(`M02_Hopyo_Ours`) — 판 열쇠 · 캐시 쓰기 · 읽기 · 준비 전 None · 합판 품 ·
|
||||
기계경비 로직 · 섞기 전 변형 · 엔진 셈 캐시 · master_data 원본 그대로. 캐시 폴더는 임시."""
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Fit as fit
|
||||
from M02_MasterTemplete import M02_Hopyo_Match as hm
|
||||
from M02_MasterTemplete import M02_Hopyo_Ours as ours
|
||||
from resources.tester.test_m02_hopyo_excel import master_hash
|
||||
|
||||
approx = pytest.approx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache(tmp_path, monkeypatch):
|
||||
"""임시 캐시 폴더 + 로직 셋만 셈(1,438 대신) — 끝나면 메모리 표를 비움."""
|
||||
monkeypatch.setenv("AISLO_HOPYO_CACHE", str(tmp_path))
|
||||
few = [x for x in hm.targets()["로직"] if x["로직"] in ("GC000211", "GC000198", "GC000268")]
|
||||
monkeypatch.setattr(hm, "targets", lambda: {**hm._targets(id(ours.whole())), "로직": few})
|
||||
monkeypatch.setitem(ours._STATE, "표판", None)
|
||||
monkeypatch.setitem(ours._STATE, "표", None)
|
||||
yield tmp_path
|
||||
ours._STATE.update(표판=None, 표=None)
|
||||
|
||||
|
||||
def test_판_열쇠_같음():
|
||||
a, b = ours.version(), ours.version()
|
||||
assert a == b and len(a) == 16 and ours.path_of(a).name == f"우리지문_{a}.json"
|
||||
|
||||
|
||||
def test_캐시_쓰기_읽기_준비_전_none(cache):
|
||||
before = master_hash()
|
||||
assert ours.table() is None and not ours.ready()
|
||||
built = ours.build()
|
||||
assert [o["로직"] for o in built] == ["GC000198", "GC000211", "GC000268"]
|
||||
assert ours.path_of().parent == cache and ours.path_of().is_file()
|
||||
ours._STATE.update(표판=None, 표=None)
|
||||
assert [o["로직"] for o in ours.table()] == ["GC000198", "GC000211", "GC000268"]
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def test_데움_뒤_스레드(cache):
|
||||
thread = ours.warm(build_missing=True)
|
||||
thread.join(timeout=120)
|
||||
assert ours.ready() and len(ours.table()) == 3
|
||||
|
||||
|
||||
def test_합판_보통_형틀목공():
|
||||
fp = ours.fingerprint("GC000211", "㎡")
|
||||
plain = next(v for v in fp["변형"] if v["in"].get("유형") == "보통")
|
||||
assert plain["L"]["형틀목공"] == approx(0.112, abs=1e-3)
|
||||
assert plain["L"]["보통인부"] == approx(0.045, abs=1e-3)
|
||||
assert fp["직종"] == ["보통인부", "형틀목공"] and not fp["기계경비"]
|
||||
assert fp["고름조건"]["횟수"] # 고르기 글 「4회사용시」 낱말
|
||||
|
||||
|
||||
def test_기계경비_로직은_고른_기계_1시간():
|
||||
v = ours.run("GC000268", {})
|
||||
assert v["L"] == {} and len(v["X"]) == 1 and v["X"][0][2] == 1.0
|
||||
assert ours.fingerprint("GC000268", "hr")["기계경비"]
|
||||
|
||||
|
||||
def test_섞기_전_변형():
|
||||
table = ours.table() or ours.build()
|
||||
mixed = next(
|
||||
o
|
||||
for o in table
|
||||
if any(0 < ((v.get("mix") or {}).get("인력비율") or 0) < 100 for v in o["변형"])
|
||||
)
|
||||
prep = fit._prep(mixed, {})
|
||||
assert any(v["in"] and v["in"].get("_섞기전") for v in prep["vs"])
|
||||
|
||||
|
||||
def test_엔진_셈_캐시():
|
||||
a = ours.run("GC000198", {"소량감률": 50})
|
||||
assert ours.run("GC000198", {"소량감률": 50}) is a # 같은 입력은 다시 안 셈
|
||||
assert ours.run("없는로직", {}) is None
|
||||
@@ -1,12 +1,15 @@
|
||||
"""일위대가 실무자료 화면(PLAN 324-4) — 좌측 판 · 본 화면 · [로직 추가] 「실무 호표」 줄.
|
||||
"""일위대가 실무자료 화면(PLAN 324-4 · 338-3) — 좌측 판 · 본 화면 · 확인 창 · 고르기 창 ·
|
||||
별칭 장부 · [로직 추가] 「실무 호표」 줄 · 엑셀 보기 창.
|
||||
|
||||
- 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 ·
|
||||
고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 ·
|
||||
본 화면 글 도우미
|
||||
- 고르기 창 근거(330-3): 꼬리표 · 엑셀 근거 줄 · [근거 맞음 모두 확정] 대상
|
||||
- 엑셀 보기 창(329): 행 → 호표 · 호표 영역 A1 · 격자 길 · 여는 자리 넷 · 늦게 불러옴
|
||||
- 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 ·
|
||||
숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰
|
||||
- 확인 창 도우미(Node · fetch · sessionStorage 대역 · `helper_m02_hopyo_check.cjs`):
|
||||
체크 → 몸(체크한 줄만 · 뺌 · 고른 대상 · 몫 · 배 · 호표) · 빠질 줄 까닭 · 캐시 키 ·
|
||||
최신 응답만 · 품 비교 · 만들기 · 통째 · 만들 층 · 미리 셈 · 장부 길 · 장부 화면 합침 ·
|
||||
하위 호표 먼저 · 확인된 것 = 바로 참조
|
||||
- 고르기 창 덧줄(`helper_m02_hopyo_pick.cjs`): 이 층 · 위 층 호표 줄 · 종류 고르기 ·
|
||||
프로젝트 층엔 없음 · 글 도우미 · 근거 꼬리표 · 엑셀 보기 창 행 → 호표
|
||||
- 소스: 옛 즉시 확정 · 바로 만들기 없음(saveHopyoMemory · [확정] · [같음 모두 확정] ·
|
||||
[근거 맞음 모두 확정] · [사전조합 만들기]) · `buildHopyo(` 는 확인 창 · 엑셀 보기 창 펼침에서만 ·
|
||||
좌측 판 자리 · 700 줄 · 색 = 공용 토큰
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -17,45 +20,181 @@ from pathlib import Path
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
|
||||
NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View", "Hopyo_Sheet")
|
||||
NEW = (
|
||||
"Hopyo_Api",
|
||||
"Hopyo_Pick",
|
||||
"Hopyo_Side",
|
||||
"Hopyo_View",
|
||||
"Hopyo_Sheet",
|
||||
"Hopyo_Check",
|
||||
"Hopyo_Choose",
|
||||
"Hopyo_Ledger",
|
||||
)
|
||||
ROW = {
|
||||
"층": "system",
|
||||
"주인": None,
|
||||
"키": "HP-000001",
|
||||
"파일": "북부청.xls",
|
||||
"번": 3,
|
||||
"이름": "임도표지석",
|
||||
"규격": "910×600×150",
|
||||
"단위": "개소",
|
||||
"맞춤": [2, 4],
|
||||
}
|
||||
SURE = {"로직": "GC000198", "이름": "장비사용타설", "입력값": {"소량감률": 50}, "배": 1, "몫": None}
|
||||
QTY = {
|
||||
"엑셀": {"인력": {"콘크리트공": 0.1091}, "장비": [{"갈래": "굴착기", "시간": 0.29}]},
|
||||
"우리": {"인력": {"콘크리트공": 0.1091}, "장비": [{"갈래": "굴착기", "시간": 0.31}]},
|
||||
"같음": {"콘크리트공": True, "굴착기": False},
|
||||
}
|
||||
|
||||
|
||||
def _text(name: str) -> str:
|
||||
return (M02 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _run() -> dict:
|
||||
row = {
|
||||
"층": "system",
|
||||
"주인": None,
|
||||
"키": "HP-000001",
|
||||
"파일": "북부청.xls",
|
||||
"번": 3,
|
||||
"이름": "임도표지석",
|
||||
"규격": "910×600×150",
|
||||
"단위": "개소",
|
||||
"맞춤": [2, 4],
|
||||
}
|
||||
built = {
|
||||
"참조": {"층": "company", "주인": "7", "키": "SC-000009"},
|
||||
"이름": "임도표지석",
|
||||
"다시씀": False,
|
||||
"빠진줄": [{"차례": 0}],
|
||||
"비교": {},
|
||||
}
|
||||
def _node(helper: str, payload: dict) -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_hopyo_pick.cjs")],
|
||||
input=json.dumps({"rows": [row], "built": built}, ensure_ascii=False),
|
||||
["node", os.path.join(HERE, helper)],
|
||||
input=json.dumps(payload, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_고르기_창_실무_호표_줄과_고름():
|
||||
got = _run()
|
||||
def _line(no, state, check, parts, **extra):
|
||||
match = {"열쇠": f"줄{no}||m3", "상태": state, "체크": check, "대상": parts, **extra}
|
||||
return {"차례": no, "이름": f"줄{no}", "규격": "", "수량": 1, "단위": "㎥", "갈래": "단가산출",
|
||||
"맞춤": match} # fmt: skip
|
||||
|
||||
|
||||
def _check() -> dict:
|
||||
lines = [
|
||||
_line(0, "후보", False, [{"자재": "MT004258", "입력값": {}, "배": 1}]),
|
||||
_line(1, "맞음", True, [SURE], 품=QTY),
|
||||
_line(2, "별칭", True, [{"로직": "GF000101", "입력값": {}, "배": 1}]),
|
||||
_line(3, "다른 점", False, [{"로직": "GF000103", "입력값": {}, "배": 1}]),
|
||||
_line(4, "후보", False, []),
|
||||
_line(5, "%", False, []),
|
||||
_line(6, "호표", True, [{"호표": 46, "입력값": {}, "배": 2.3}]),
|
||||
_line(7, "노임", False, [{"로직": "GF000300", "입력값": {}, "배": None}]),
|
||||
]
|
||||
ledger = [
|
||||
{"열쇠": "가|b|m3", "원래": {"품명": "가", "규격": "b", "단위": "m3"}, "갈래": "짝",
|
||||
"짝": [{"로직": "GC000198", "입력값": {}, "배": 1, "몫": None}], "층": "personal",
|
||||
"주인": "7/42", "고침": True},
|
||||
{"열쇠": "나||m3", "원래": {"품명": "나", "규격": "", "단위": "m3"}, "갈래": "짝",
|
||||
"짝": [{"로직": "GF000101", "입력값": {}, "배": 1, "몫": None}], "층": "personal",
|
||||
"주인": "7/42", "고침": True},
|
||||
{"열쇠": "다||개", "원래": {"품명": "다", "규격": "", "단위": "개"}, "갈래": "짝",
|
||||
"짝": [{"자재": "MT1", "입력값": {}, "배": 1, "몫": None}], "층": "system",
|
||||
"주인": None, "고침": False},
|
||||
] # fmt: skip
|
||||
rows = [
|
||||
{**ROW, "확인": {"층": "company", "주인": "7", "키": "SC-000009"}},
|
||||
{**ROW, "번": 4, "이름": "배수관", "확인": None},
|
||||
]
|
||||
return _node("helper_m02_hopyo_check.cjs", {"lines": lines, "ledger": ledger, "rows": rows})
|
||||
|
||||
|
||||
def test_확인_창_체크한_줄만_몸():
|
||||
got = _check()["check"]
|
||||
assert got["key"] == "m02-hopyo-check:personal::HP-000001:3"
|
||||
assert got["suggested"] == [ # 서버 체크 제안 그대로 · 배 없는 줄 · % 줄 · 후보는 안 감
|
||||
{"차례": 1, "대상": [{"로직": "GC000198", "입력값": {"소량감률": 50}, "배": 1}]},
|
||||
{"차례": 2, "대상": [{"로직": "GF000101", "입력값": {}, "배": 1}]},
|
||||
{"차례": 6, "대상": [{"호표": 46, "배": 2.3}]},
|
||||
]
|
||||
assert got["edited"] == [ # 캐시 — 맞음 줄 체크 끔 · 뺌 · 고른 대상(몫 · 배)
|
||||
{"차례": 2, "대상": [{"로직": "GF000101", "입력값": {}, "배": 1}]},
|
||||
{"차례": 3, "뺌": "우리에 없음"},
|
||||
{
|
||||
"차례": 4,
|
||||
"대상": [{"로직": "GF000211", "입력값": {"유형": "보통"}, "배": 0.1, "몫": 0.1}],
|
||||
},
|
||||
{"차례": 6, "대상": [{"호표": 46, "배": 2.3}]},
|
||||
]
|
||||
assert got["leftOut"] == {"확인 안 함": 3, "뺌": 1, "% 줄": 1}
|
||||
assert got["checkable"] == [True, True, True, True, False, False, True, False]
|
||||
assert got["sure"] == [1, 2] # [맞음 · 별칭 모두 체크]
|
||||
assert got["compare"] == "콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠"
|
||||
assert got["toTarget"] == [{"호표": 46, "배": 2.3}, {"자재": "MT1", "배": 1}]
|
||||
assert got["newest"] == [False, True, True] # 같은 줄 늦은 응답 버림 · 다른 줄은 따로
|
||||
assert got["dropped"] is False # 만들기 성공 = 캐시 지움
|
||||
|
||||
|
||||
def test_고르기_창_도우미():
|
||||
got = _check()["choose"]
|
||||
assert got["hint"] == ["6-1-1", "고임돌"] # 인용 조항 번호 → 없으면 품명 첫 뜻 낱말
|
||||
assert got["picked"] == [
|
||||
{"로직": "GC000206", "입력값": {}, "배": 1, "몫": None},
|
||||
{"로직": "GF000211", "입력값": {}, "배": 0.1, "몫": 0.1},
|
||||
]
|
||||
assert got["inputs"] == "소량감률=50(블록), 지역=서울 · ⚠ 못 가름: 측압"
|
||||
assert got["word"] == ["만듦", "있던 것을 확인한 내용으로 덮음"]
|
||||
assert got["qty"] == "콘크리트공 0.1091 · 굴착기 0.29hr"
|
||||
assert got["target"] == "제46호표 [㎡]"
|
||||
|
||||
|
||||
def test_별칭_장부_화면_합침():
|
||||
got = _check()["ledger"]
|
||||
assert got["key"] == "m02-hopyo-ledger:company:7"
|
||||
assert got["merged"] == [
|
||||
["나||m3", "personal", True, True, "안 씀"], # 고친 줄
|
||||
["다||개", "personal", True, True, "짝"], # 위 층 → 내 층으로 고쳐 씀
|
||||
["다||개", "system", False, False, "짝"], # 위 층 = 그대로 읽기만
|
||||
] # 지운 줄(가) 없음
|
||||
assert got["filter"] == ["다||개", "다||개"] and got["kind"] == ["나||m3"]
|
||||
assert got["pair"] == "GC000198 × 1 · 몫 0.1 · 소량감률=50"
|
||||
assert got["entry"]["짝"] == [] and "층" not in got["entry"]
|
||||
|
||||
|
||||
def test_길_만들기_통째_만들_층_미리_셈_장부():
|
||||
calls = _check()["calls"]
|
||||
build = "/api/m02/layers/company/hopyo-build?owner=7"
|
||||
head = {"층": "system", "주인": None, "키": "HP-000001", "번": 3}
|
||||
assert calls[0]["url"] == build and calls[0]["body"]["줄"][0]["차례"] == 1
|
||||
assert {k: calls[0]["body"][k] for k in head} == head and "새로" not in calls[0]["body"]
|
||||
assert calls[1]["body"] == {**head, "통째": {"대상": [{"로직": "GF000225", "배": 1}]}}
|
||||
assert calls[2] == {
|
||||
"url": "/api/m02/layers/system/hopyo/HP-000001/3?to=company&to_owner=7",
|
||||
"method": "GET",
|
||||
"body": None,
|
||||
}
|
||||
assert calls[3]["url"] == "/api/m02/layers/personal/hopyo/HP-000001/3/guess"
|
||||
assert calls[3]["body"]["대상"][0]["몫"] == 0.1
|
||||
assert calls[4]["url"] == "/api/m02/layers/company/hopyo-ledger?owner=7"
|
||||
assert calls[5]["method"] == "PUT" and calls[5]["body"] == {"줄": {"가|b|m3": None}}
|
||||
|
||||
|
||||
def test_로직_추가_확인된_것은_바로_참조_아니면_확인_창():
|
||||
got = _check()["pick"]
|
||||
assert got["picked"] == [
|
||||
{
|
||||
"ref": {"층": "company", "주인": "7", "키": "SC-000009"},
|
||||
"name": "임도표지석",
|
||||
"alias": "임도표지석",
|
||||
}
|
||||
]
|
||||
assert got["calls"] == [] # 확인 없이 만들지 않음
|
||||
assert got["opened"] == ["확인 — 북부청.xls 제4호표 배수관"]
|
||||
|
||||
|
||||
def test_엑셀_보기_창_하위_호표_먼저():
|
||||
assert _check()["sheet"]["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례
|
||||
|
||||
|
||||
def _pick() -> dict:
|
||||
row = {**ROW, "확인": {"층": "company", "주인": "7", "키": "SC-000009"}}
|
||||
return _node("helper_m02_hopyo_pick.cjs", {"rows": [row], "built": {}})
|
||||
|
||||
|
||||
def test_고르기_창_실무_호표_줄():
|
||||
got = _pick()
|
||||
assert got["calls"][0] == {
|
||||
"url": "/api/m02/layers/company/hopyo-picks?owner=7",
|
||||
"method": "GET",
|
||||
@@ -69,7 +208,7 @@ def test_고르기_창_실무_호표_줄과_고름():
|
||||
"tag": "실무 호표",
|
||||
"name": "임도표지석 910×600×150",
|
||||
"sub": "북부청.xls · 제3호표 · 마스터",
|
||||
"unit": "개소 · 맞춤 2/4",
|
||||
"unit": "개소 · 확인 SC-000009",
|
||||
},
|
||||
{
|
||||
"kind": "hopyo",
|
||||
@@ -80,119 +219,16 @@ def test_고르기_창_실무_호표_줄과_고름():
|
||||
"unit": "호표 1",
|
||||
},
|
||||
]
|
||||
build = got["calls"][1]
|
||||
assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층
|
||||
assert build["body"] == {
|
||||
"층": "system",
|
||||
"주인": None,
|
||||
"키": "HP-000001",
|
||||
"번": 3,
|
||||
"새로": False,
|
||||
}
|
||||
assert got["picked"] == [
|
||||
{
|
||||
"ref": {"층": "company", "주인": "7", "키": "SC-000009"},
|
||||
"name": "임도표지석",
|
||||
"alias": "임도표지석",
|
||||
}
|
||||
]
|
||||
assert got["picked"][0]["ref"]["키"] == "SC-000009"
|
||||
assert got["project"] is True # 프로젝트 층 = 덧줄 없음
|
||||
|
||||
|
||||
def test_본_화면_글_도우미():
|
||||
view = _run()["view"]
|
||||
def test_글_도우미와_근거_꼬리표():
|
||||
got = _pick()
|
||||
view = got["view"]
|
||||
assert view["target"] == ["구조물터파기 [㎥]", "레미콘 25-21-120 [㎥]", "제46호표 [㎡]"]
|
||||
assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 읽음: 보정작업"
|
||||
assert view["memory"] == [
|
||||
{"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1},
|
||||
{"자재": "MT1", "입력값": {}, "환산": 0.5},
|
||||
None, # 환산 0 = 확정 못 함
|
||||
None, # 대상 없음
|
||||
]
|
||||
assert view["word"] == ["만듦", "다시 씀", "새로 만들어 덮음"] # 알림 · 판 글
|
||||
|
||||
|
||||
def test_줄_표_줄바꿈_없음과_고르기_창_머리():
|
||||
css = _text("M02_MasterTemplete_Hopyo.css")
|
||||
rule = css.split(".m02-hopyo__table.m02-hopyo__lines\n :is(th, td):is(")[1].split("}")[0]
|
||||
cols = [int(n) for n in re.findall(r":nth-child\((\d+)\)", rule)]
|
||||
assert cols == [1, 4, 5, 6, 7, 9, 10, 11] and "white-space: nowrap;" in rule
|
||||
heads = ["갈래", "품명", "규격", "수량", "단위", "엑셀 단가", "맞춤", "대상", "입력값", "환산"]
|
||||
view = _text("M02_MasterTemplete_Hopyo_View.ts")
|
||||
flat = re.sub(r"\s+", "", view)
|
||||
assert re.sub(r"\s+", "", ",".join(f'"{h}"' for h in heads)) in flat # 열 차례 그대로
|
||||
top = css.split(".m02-hopyo__choose .ui-modal__top > .ui-btn {")[1].split("}")[0]
|
||||
assert "flex-shrink: 0;" in top and "white-space: nowrap;" in top
|
||||
assert '"가까운 것 없음"' in view and "builtWord(got, fresh)" in view
|
||||
|
||||
|
||||
def test_좌측_판_자리와_권한():
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side
|
||||
host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1]
|
||||
assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),")
|
||||
assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?")
|
||||
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
|
||||
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
|
||||
names = set(re.findall(r"\b\w*[Pp]ractice\w*\b", side))
|
||||
assert names <= {"buildPracticeSection", "practice", "M02_MasterTemplete_Practice"} # 옛 이름만
|
||||
assert "LaborSection" not in side and "labor?." not in side and "picked" not in side
|
||||
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
|
||||
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
|
||||
|
||||
|
||||
def test_로직_추가_한_곳과_파일_규칙():
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert len(detail.splitlines()) == 693 # 줄 수 그대로(제자리 고침)
|
||||
assert "withHopyo(library)," in detail
|
||||
assert 'cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null' in detail
|
||||
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
|
||||
assert "more?: () => Promise<PickExtra[]>;" in pick and "...later" in pick
|
||||
for name in NEW:
|
||||
text = _text(f"M02_MasterTemplete_{name}.ts")
|
||||
assert len(text.splitlines()) <= 700, name
|
||||
css = _text("M02_MasterTemplete_Hopyo.css")
|
||||
assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css)
|
||||
assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts")
|
||||
|
||||
|
||||
def test_엑셀_보기_창_행과_호표():
|
||||
got = _run()
|
||||
assert got["calls"][2] == {
|
||||
"url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7",
|
||||
"method": "GET",
|
||||
"body": None,
|
||||
}
|
||||
sheet = got["sheet"]
|
||||
assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음
|
||||
assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O)
|
||||
assert sheet["last"] == ["AA", "A"]
|
||||
assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체
|
||||
|
||||
|
||||
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
|
||||
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
|
||||
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동
|
||||
view = _text("M02_MasterTemplete_Hopyo_View.ts")
|
||||
assert lazy in view and '"엑셀로 보기"' in view and '"엑셀에서 보기"' in view
|
||||
assert "호표: no" in view # 호표 화면 = 그 호표 고른 채
|
||||
pick = _text("M02_MasterTemplete_Hopyo_Pick.ts")
|
||||
assert lazy in pick and "lib.pick(got.참조" in pick
|
||||
static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"'
|
||||
for text in (side, view, pick):
|
||||
assert not re.search(static, text, re.M) # 정적 import 없음(타입만)
|
||||
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
|
||||
assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet
|
||||
assert not re.search(
|
||||
r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M
|
||||
)
|
||||
assert "readOnly: true" in sheet and "wide: true" in sheet
|
||||
|
||||
|
||||
def test_고르기_창_근거_꼬리표와_근거_맞음():
|
||||
basis = _run()["basis"]
|
||||
assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 가름: 보정작업"
|
||||
basis = got["basis"]
|
||||
assert basis["ok"] == [True, False, False, False, False] # 조항 같음 · 직종 · 단위 모두
|
||||
assert basis["plain"] == [True, True] # 줄 직종이 보통인부뿐 · 없음 = 직종 안 봄
|
||||
assert basis["tag"] == [
|
||||
@@ -205,8 +241,104 @@ def test_고르기_창_근거_꼬리표와_근거_맞음():
|
||||
"단가산출 7행",
|
||||
"",
|
||||
]
|
||||
# 지금 대상이 근거 안 맞으면 근거 맞는 후보 · 「후보」 줄만 · 근거 맞는 것 없으면 없음
|
||||
assert basis["pick"] == ["GC000083", "GC000083", None, None]
|
||||
|
||||
|
||||
def test_엑셀_보기_창_행과_호표():
|
||||
got = _pick()
|
||||
assert got["calls"][1] == {
|
||||
"url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7",
|
||||
"method": "GET",
|
||||
"body": None,
|
||||
}
|
||||
sheet = got["sheet"]
|
||||
assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음
|
||||
assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O)
|
||||
assert sheet["last"] == ["AA", "A"]
|
||||
assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체
|
||||
|
||||
|
||||
# ── 소스 ──────────────────────────────────────────────────────────────
|
||||
def test_옛_즉시_확정과_바로_만들기_없음():
|
||||
texts = {name: _text(f"M02_MasterTemplete_{name}.ts") for name in NEW}
|
||||
every = "\n".join(texts.values())
|
||||
for gone in ("saveHopyoMemory", "hopyo-memory", "HopyoMemory", "memoryOf", "basisPick"):
|
||||
assert gone not in every, gone
|
||||
for label in (
|
||||
"같음 모두 확정",
|
||||
"근거 맞음 모두 확정",
|
||||
'"확정"',
|
||||
'"사전조합 만들기"',
|
||||
"불러오기",
|
||||
):
|
||||
assert label not in every, label
|
||||
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
|
||||
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 펼침 뒤 한 번
|
||||
check = texts["Hopyo_Check"]
|
||||
assert '"확인하고 사전조합 만들기"' in check and "맞음 · 별칭 모두 체크" in check
|
||||
assert "showLoadingOverlay()" in check and "dropDraft(k)" in check
|
||||
assert "m02-hopyo-check:" in check and "POLL_MS" in check
|
||||
sheet = texts["Hopyo_Sheet"]
|
||||
assert "확인하고 만들기" in sheet and "이대로 ${ready.length}개 만들기" in sheet
|
||||
assert "확인 창 열기" in sheet and "buildOrder(" in sheet
|
||||
pick = texts["Hopyo_Pick"]
|
||||
assert "if (row.확인)" in pick and 'import("./M02_MasterTemplete_Hopyo_Check")' in pick
|
||||
view = texts["Hopyo_View"]
|
||||
assert "buildHopyoCheck(" in view and "choose(" not in view
|
||||
ledger = texts["Hopyo_Ledger"]
|
||||
assert "m02-hopyo-ledger:" in ledger and "saveLedger(" in ledger
|
||||
for gone in ("발주처", "회사로 올리기", "쓰인 횟수"):
|
||||
assert gone not in ledger.split("*/", 1)[1] # 머리 글 「안 둠」 밖
|
||||
|
||||
|
||||
def test_좌측_판_자리와_권한():
|
||||
side = _text("M02_MasterTemplete_UI_Side.ts")
|
||||
assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side
|
||||
host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1]
|
||||
assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),")
|
||||
assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?")
|
||||
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
|
||||
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
|
||||
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
|
||||
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
|
||||
assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부]
|
||||
assert hopyo_side.index("list, ledger]") > 0
|
||||
|
||||
|
||||
def test_로직_추가_한_곳과_파일_규칙():
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert len(detail.splitlines()) == 693 # 줄 수 그대로
|
||||
assert "withHopyo(library)," in detail
|
||||
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
|
||||
assert "more?: () => Promise<PickExtra[]>;" in pick and "...later" in pick
|
||||
assert "if (pre?.query) finder.search.value = pre.query;" in pick # 찾기 글 미리 넣음
|
||||
for name in NEW:
|
||||
text = _text(f"M02_MasterTemplete_{name}.ts")
|
||||
assert len(text.splitlines()) <= 700, name
|
||||
css = _text("M02_MasterTemplete_Hopyo.css")
|
||||
assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css)
|
||||
for state in ("맞음", "별칭", "다른점", "후보"):
|
||||
assert f".m02-hopyo__state.is-{state}" in css
|
||||
assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_Check.ts")
|
||||
|
||||
|
||||
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
|
||||
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
|
||||
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
|
||||
assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동
|
||||
view = _text("M02_MasterTemplete_Hopyo_View.ts")
|
||||
assert "단산행: basis.행" in view and "근거 맞음 모두 확정" in view
|
||||
assert "x.근거 || x.맞춤.후보?.some((c) => c.근거)" in view # 옛 파일 = 단추 없음
|
||||
assert lazy in view and '"엑셀로 보기"' in view
|
||||
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
|
||||
assert lazy in check and '"엑셀에서 보기"' in check and "호표: opt.no" in check
|
||||
assert "단산행: 행" in check
|
||||
pick = _text("M02_MasterTemplete_Hopyo_Pick.ts")
|
||||
assert lazy in pick
|
||||
static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"'
|
||||
for text in (side, view, pick, check):
|
||||
assert not re.search(static, text, re.M) # 정적 import 없음(타입만)
|
||||
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
|
||||
assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet
|
||||
assert not re.search(
|
||||
r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M
|
||||
)
|
||||
assert "readOnly: true" in sheet and "wide: true" in sheet
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet · Hopyo_View)에 먹임.
|
||||
|
||||
행 22 → 제3호표 · 25 빈 줄 · 26 → 제4호표 · 영역 B20:O24 · 단산93 행 4229:4244 ·
|
||||
엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 대상 · 북부청 「후보」 줄 1순위 근거 맞음 = 서버 기준 123 ·
|
||||
앞 마디 같음 0(330 덧고침 2).
|
||||
엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 · 북부청 「후보」 줄 1순위 근거 맞음 = 서버 기준 123
|
||||
(338 — 근거 맞음 모두 확정 단추 없앰 · 도우미는 고르기 창 `Hopyo_Choose`).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -42,14 +42,12 @@ def test_실제_응답_화면_도우미(client):
|
||||
res = client.get(f"{URL.format('personal')}/HP-000001/{one['번']}").json()
|
||||
every += [x for x in res["호표"]["줄"] if x["갈래"] == "단가산출"]
|
||||
got = _node({"grid": grid, "lines": lines, "all": every})
|
||||
assert got["count"]["top"] == 123 and got["count"]["half"] == 0
|
||||
assert got["count"]["pick"] >= 123
|
||||
assert got["count"]["top"] == 123
|
||||
assert got["at"] == [None, 3, 3, 3, None, 4] # 25행 = 호표 사이 빈 줄
|
||||
assert got["span"] == "B20:O24" and got["rows"] == "4229:4244"
|
||||
by = {x["이름"]: x for x in got["lines"]}
|
||||
dig = by["구조물터파기(인력100%)"]
|
||||
assert dig["source"] == "출처 건설품셈 3-2-1 · 직종 특별인부 · 단가산출 4229–4244행"
|
||||
assert dig["tag"] == "조항 건설품셈 공통 3-2-1 · 직종 특별인부 · 단위 같음"
|
||||
assert dig["ok"] is True and dig["pick"] == "GC000083"
|
||||
assert by["비탈면 보호공"]["pick"] == "GC000143"
|
||||
assert dig["ok"] is True
|
||||
assert by["흙깍기(토사)"]["ok"] is True # 엑셀 직종 없는 줄(기계) = 조항 같음 + 단위
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b · `M02_Hopyo_Stmate`).
|
||||
|
||||
여섯 파일 올라감(봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표) ·
|
||||
봉화 줄 216(구성 209 + 근거글 7) · 단산 103 · 노임 30 · 호표 22 · 줄 코드 갈래 ·
|
||||
노임 이름 어긋남 0 · 블록 자원 품 · 이음 열쇠(M~Z 열 코드 꼴 첫 것) ·
|
||||
문서 보안(장수 · 진안) 400 글 · 원본 엑셀 · master_data 그대로 ·
|
||||
영월 정답 자리(`y_ids` — 338 실험 C1 `labels_y` Y000~Y035 = D · X 고유 줄 차례 · 골든 시험이 씀).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Hopyo_Basis as hb
|
||||
from M02_MasterTemplete import M02_Hopyo_Excel as hx
|
||||
from M02_MasterTemplete import M02_Hopyo_Stmate as st
|
||||
from M02_MasterTemplete import M02_Hopyo_Store as hs
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester import test_m02_hopyo_excel as base
|
||||
from resources.tester.test_m02_hopyo_excel import ROOT, _post, master_hash
|
||||
|
||||
client = base.client # 공용 고정물(임시 층)
|
||||
DOCS = ROOT / "resources" / "knowledge" / "original" / "실무문서"
|
||||
FILES = {
|
||||
"봉화": "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
|
||||
"영월": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
|
||||
"대흥설계": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/"
|
||||
"2024년 간선임도사업(기번3).xlsx",
|
||||
"대흥착공": "2024년 간선임도(기번3-울진.대흥)(공통)/"
|
||||
"2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx",
|
||||
"영덕": "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/"
|
||||
"2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx",
|
||||
"소광": "25년 산불진화임도(기번8)(울진소광)/"
|
||||
"2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
|
||||
}
|
||||
HOPYOS = {"봉화": 45, "영월": 23, "대흥설계": 34, "대흥착공": 34, "영덕": 9, "소광": 25}
|
||||
LOCKED = {
|
||||
"장수": "02.산불 장수/01.내역서/"
|
||||
"00.단가변경_26년_산불_무주(장수,장수,덕산,산54-1)내역+수량.xlsx",
|
||||
"진안": "03.산불진안/01.내역서/00.단가변경_26년_산불_무주(진안,백운,백암,산1-2)내역+수량.xlsx",
|
||||
}
|
||||
|
||||
|
||||
def _path(tag: str) -> Path:
|
||||
return DOCS / {**FILES, **LOCKED}[tag]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def books() -> dict[str, dict]:
|
||||
return {tag: hx.read_book(_path(tag).name, _path(tag).read_bytes()) for tag in FILES}
|
||||
|
||||
|
||||
def _lines(book: dict) -> list[dict]:
|
||||
return [x for h in book["호표"] for x in h["줄"]]
|
||||
|
||||
|
||||
def test_여섯_파일_호표_수(books):
|
||||
assert {tag: len(b["호표"]) for tag, b in books.items()} == HOPYOS
|
||||
for book in books.values():
|
||||
assert (book["꼴"], book["시트"]) == ("STmate", "일위대가표")
|
||||
assert book["검산"] == {"어긋남": [], "총괄표": 0}
|
||||
assert len(_lines(books["대흥설계"])) == len(_lines(books["대흥착공"])) == 171 # 같은 설계
|
||||
|
||||
|
||||
def test_봉화_줄_갈래(books):
|
||||
bong = books["봉화"]
|
||||
lines = _lines(bong)
|
||||
kinds = {k: sum(1 for x in lines if x["갈래"] == k) for k in ("단가산출", "노임", "호표", "%")}
|
||||
assert kinds == {"단가산출": 103, "노임": 30, "호표": 22, "%": 7}
|
||||
assert len(lines) + sum(len(h.get("근거글") or []) for h in bong["호표"]) == 216
|
||||
assert all(x["코드"] for x in lines)
|
||||
for book in books.values():
|
||||
for x in _lines(book):
|
||||
if x["갈래"] == "호표":
|
||||
assert x["참조"]["번"] in {h["번"] for h in book["호표"]}
|
||||
if x["갈래"] == "단가산출":
|
||||
assert x["참조"] == {"종류": "단산", "번": int(x["코드"][1:]), "코드": x["코드"]}
|
||||
assert "근거" in x # 블록 모두 이어짐
|
||||
by = {h["번"]: h for h in bong["호표"]}
|
||||
assert by[13]["근거글"] == ["건설표준품셈 7-2-2(찰붙임)"] # 코드 없는 줄 = 호표 근거글
|
||||
assert hb.sources(" ".join(by[13]["근거글"])) == [["건설품셈", "7-2-2"]]
|
||||
|
||||
|
||||
def test_영월_줄과_블록(books):
|
||||
yw = books["영월"]
|
||||
one = yw["호표"][0]
|
||||
assert (one["번"], one["코드"], one["이름"], one["단위"]) == (
|
||||
1,
|
||||
"B00260",
|
||||
"녹생토(종자배합)",
|
||||
"M3",
|
||||
)
|
||||
assert one["행"] == [5, 10] and one["금액"]["계"] == 155184
|
||||
assert [(x["이름"], x["코드"], x["갈래"], x["수량"]) for x in one["줄"]] == [
|
||||
("산림종자", "M00024", "자재", 0.25),
|
||||
("산림종자", "M00211", "자재", 0.25),
|
||||
("인공토양(녹생토)", "M00832", "자재", 1.1),
|
||||
("보통인부", "L00002", "노임", 0.43),
|
||||
]
|
||||
two = {x["이름"]: x for x in yw["호표"][1]["줄"]}
|
||||
assert two["국가지점번호판 검증수수료"]["갈래"] == "별도계상" # 비고 「별산」
|
||||
assert two["구조물터파기(인력)"]["근거"] == {
|
||||
"출처": [["건설품셈", "3-1-3"]],
|
||||
"직종": ["보통인부"],
|
||||
"행": [5, 54],
|
||||
}
|
||||
block = next(b for b in yw["단산"] if b["코드"] == "D00002")
|
||||
assert (block["번"], block["이름"], block["규격"], block["단위"]) == (
|
||||
2,
|
||||
"구조물터파기(인력)",
|
||||
"경질토사",
|
||||
"M3",
|
||||
)
|
||||
assert block["자원"] == [
|
||||
{"갈래": "L", "코드": "L00002", "이름": "보통인부", "규격": "", "품": 0.26, "절": ""}
|
||||
]
|
||||
assert any("건설표준품셈3-1-3" in t for t in block["글"])
|
||||
|
||||
|
||||
def test_기계_자원_하나로(books):
|
||||
block = next(b for b in books["봉화"]["단산"] if b["코드"] == "D00002")
|
||||
machines = [r for r in block["자원"] if r["갈래"] == "X"]
|
||||
assert len(machines) == 1 # 노무비 · 재료비 · 경비 셋 줄
|
||||
assert machines[0]["이름"] == "굴삭기(무한궤도)" and machines[0]["규격"] == "0.2㎥"
|
||||
assert machines[0]["품"] == pytest.approx(0.0636537237529)
|
||||
|
||||
|
||||
def test_노임_이름_어긋남_0(books):
|
||||
names = hb.trade_names()
|
||||
for tag in FILES:
|
||||
sheets = hx._sheets(_path(tag).name, _path(tag).read_bytes())
|
||||
table = st._lists(sheets)
|
||||
res = [r for b in books[tag]["단산"] for r in b["자원"] if r["갈래"] == "L" and r["이름"]]
|
||||
assert res
|
||||
assert sum(1 for r in res if r["코드"] in table) >= len(res) // 2
|
||||
for r in res:
|
||||
if r["코드"] in table: # 목록표에 있는 직종 — X 열 이름과 같음
|
||||
assert st._flat(r["이름"]) == st._flat(table[r["코드"]]["이름"])
|
||||
if table.get(r["코드"], {}).get("단위") == "인": # L 코드 % 줄(기구손료) 뺌
|
||||
assert st._flat(r["이름"]) in names, (tag, r)
|
||||
labor = [x for x in _lines(books[tag]) if x["갈래"] == "노임"]
|
||||
assert all(st._flat(x["이름"]) in names for x in labor)
|
||||
|
||||
|
||||
def test_이음_열쇠_코드_칸():
|
||||
row = [None] * 26
|
||||
row[12], row[14], row[15] = "별산M00985", "M00985_1", "T7_1"
|
||||
assert st._code(row) == "M00985"
|
||||
row = [None] * 26
|
||||
row[25] = "D01352 →" # O 열 없는 판
|
||||
assert st._code(row) == "D01352"
|
||||
row = [None] * 47
|
||||
row[14], row[13] = "PRA01_1", "_x0007_`COD|PR_x0005_`EXI"
|
||||
assert st._code(row) == "PRA01"
|
||||
assert (
|
||||
st._code(["계", None, None, None, None, None, None, None] + [None] * 6 + ["T7_1"]) == "T7"
|
||||
)
|
||||
assert st._code([None] * 12 + ["D96"]) is None # 비고 글은 코드 아님
|
||||
|
||||
|
||||
def test_문서_보안_400(client):
|
||||
for tag in LOCKED:
|
||||
data = _path(tag).read_bytes()
|
||||
with pytest.raises(hx.ExcelError, match="문서 보안"):
|
||||
hx.read_book(_path(tag).name, data)
|
||||
res = _post(client, data=data, name=_path(tag).name)
|
||||
assert res.status_code == 400 and res.json()["detail"] == hx.DRM
|
||||
|
||||
|
||||
def test_여섯_파일_올라감(client):
|
||||
before = master_hash()
|
||||
sources = {tag: hashlib.sha256(_path(tag).read_bytes()).hexdigest() for tag in FILES}
|
||||
for at, tag in enumerate(FILES, 1):
|
||||
res = _post(client, data=_path(tag).read_bytes(), name=_path(tag).name)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["호표수"] == HOPYOS[tag]
|
||||
doc = hs.read(layers.personal_dir(7, 42), f"HP-{at:06d}")
|
||||
assert doc["꼴"] == "STmate"
|
||||
assert sum(1 for b in doc["단산"] if not b["글"]) <= 3 # 봉화 무대처리 · 흙 쌓 기 = 값 0
|
||||
assert any(b["자원"] for b in doc["단산"])
|
||||
first = doc["호표"][0]["번"]
|
||||
view = client.get(f"{base.URL.format('personal')}/HP-{at:06d}/{first}")
|
||||
assert view.status_code == 200, view.text # 맞춤 보기가 STmate 줄을 받음
|
||||
assert {tag: hashlib.sha256(_path(tag).read_bytes()).hexdigest() for tag in FILES} == sources
|
||||
assert master_hash() == before
|
||||
|
||||
|
||||
def y_ids(book: dict) -> dict[str, dict]:
|
||||
"""영월 정답 자리 — D · X 줄을 (이름, 규격, 단위, 코드) 고유로 처음 나온 차례 →
|
||||
`{"Y000": {호표, 차례, 열쇠}}`(338 실험 C1 `s09_stmate` 와 같은 번)."""
|
||||
out: dict[str, dict] = {}
|
||||
seen: set[tuple] = set()
|
||||
for hopyo in book["호표"]:
|
||||
for line in hopyo["줄"]:
|
||||
key = (line["이름"], line["규격"], line["단위"], line["코드"])
|
||||
if line["코드"][0] in "DX" and key not in seen:
|
||||
seen.add(key)
|
||||
out[f"Y{len(out):03d}"] = {"호표": hopyo["번"], "차례": line["차례"], "열쇠": key}
|
||||
return out
|
||||
|
||||
|
||||
def test_영월_정답_자리(books):
|
||||
got = y_ids(books["영월"])
|
||||
assert len(got) == 36 # 정답 34 + 우리에 없음 둘(Y004 · Y007)
|
||||
assert got["Y000"] == {
|
||||
"호표": 2,
|
||||
"차례": 1,
|
||||
"열쇠": ("무근콘크리트타설", "인력운반타설", "M3", "D02058"),
|
||||
}
|
||||
assert got["Y005"]["열쇠"] == ("크레인(타이어)", "10톤", "시간", "X02104")
|
||||
assert got["Y010"]["열쇠"][3] == "D00060" and got["Y035"]["열쇠"][3] == "D01810"
|
||||
by = {h["번"]: h for h in books["영월"]["호표"]}
|
||||
for one in got.values():
|
||||
line = by[one["호표"]]["줄"][one["차례"]]
|
||||
assert (line["이름"], line["규격"], line["단위"], line["코드"]) == one["열쇠"]
|
||||
assert line["갈래"] == "기계" or "근거" in line # 단산 줄은 블록 이음
|
||||
Reference in New Issue
Block a user