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

This commit is contained in:
2026-10-15 23:42:08 +09:00
38 changed files with 9472 additions and 781 deletions
+23 -7
View File
@@ -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)),
+300
View File
@@ -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]
+39 -4
View File
@@ -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["번"])):
+309
View File
@@ -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
+411
View File
@@ -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
+6 -4
View File
@@ -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}
+81 -1
View File
@@ -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]:
"""기억을 볼 층 폴더 — 같은 층 → 위 층(개인 → 회사 → 마스터)."""
+497
View File
@@ -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,
}
+364
View File
@@ -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
+177
View File
@@ -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["장비"]]
+264
View File
@@ -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},
}
+200
View File
@@ -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
+247
View File
@@ -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
+57 -10
View File
@@ -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
+184
View File
@@ -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);
});
+254
View File
@@ -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"
)
+11 -25
View File
@@ -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: {
+12 -10
View File
@@ -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));
+17
View File
@@ -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["글"])
+53 -1
View File
@@ -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))
+147
View File
@@ -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 # 노임 없으면 호표 통째 아님
+122
View File
@@ -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)
+20
View File
@@ -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())
+63
View File
@@ -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)) # 보통인부 하나뿐 = 우연 맞음
+77
View File
@@ -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
+279 -147
View File
@@ -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
+4 -6
View File
@@ -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 # 엑셀 직종 없는 줄(기계) = 조항 같음 + 단위
+217
View File
@@ -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 # 단산 줄은 블록 이음