feat(816-2): 후보 여럿 기계를 같은 파일 안 구성 대조로 가름

- 잣대 = 같은 파일 · 같은 때의 구성 줄(손료 계수 · 가격 · 주연료 · 조종원) — 우리 기계표 값과 맞대지 않음(요구 773-3)
- M02_Machine_Find.peers · narrow — 구성이 같은 단독 기계 이름 · 규격으로 다시 찾아 후보 가운데 키 하나면 이음 · 여럿 · 없음 = 그대로
- EST 원파일 MachMainDat 묶기(M02_San_Estx.makes) · EST 엑셀 「기계경비」 시트(M02_San_Xlsx.makes) → 자원 「같은구성」
- 저장 묶음(M02_Doc_FromNative) · 줄 만들기(M02_Doc_NativeEst · M02_San_Emit) 가 「같은구성」 을 넘김
- 앞서 짠 우리 표 값 대조는 걷음 · STC = 구성 표(BDQTY) 난독화로 없음 · STmate 엑셀 중기사용료 = 같은 파일 단독 짝 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 19:46:23 +09:00
co-authored by Claude Opus 5.5
parent 20300af829
commit ff19138727
9 changed files with 261 additions and 9 deletions
+2
View File
@@ -90,6 +90,8 @@ def _est_line(ln: dict) -> dict:
one = {k: ln.get(k) for k in _EST_KEYS if ln.get(k) not in (None, [], False, "")}
if one.get("자원"):
one["자원"] = {k: ln["자원"].get(k) for k in _RES_KEYS}
if ln["자원"].get("같은구성"): # 816-2 같은 파일에서 구성이 같은 단독 기계
one["자원"]["같은구성"] = ln["자원"]["같은구성"]
return one
+4 -2
View File
@@ -320,7 +320,8 @@ class Block:
r |= {"단가": prices}
if quoted := self.quote(r)[1]: # 815-1 K5 — 세 줄 견적 = 값묶음 그대로 · 표지 한 번
self.mark(rows[0], quoted.removeprefix(NOTE))
parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
make = (rows[0].get("자원") or {}).get("같은구성") # 816-2 같은 파일 구성 대조
parts = em.machine_parts(r, make) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
r |= {"찾기": parts[0]} if parts else {}
r |= {"조합첫": True} if len(parts) > 1 else {}
head = next(
@@ -366,7 +367,8 @@ class Block:
price = (ln.get("연결단가") or [ln["결과"][-1]])[0]
r = self.res(ln, ln["비목"], price if got else ln["결과"][-1])
name = self.o.names.item(f"{ln.get('품명') or r.get('이름') or '줄'} {COMP[ln['비목']]}")
parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
make = (ln.get("자원") or {}).get("같은구성") # 816-2 같은 파일 구성 대조
parts = em.machine_parts(r, make) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
r |= {"찾기": parts[0]} if parts else {}
r |= {"조합첫": True} if len(parts) > 1 else {}
link, many = (parts[0]["키"], None) if parts else self.quote(r)
+42
View File
@@ -305,6 +305,48 @@ def find_all(name: Any, spec: Any = "") -> list[dict[str, Any]]:
return out
# ── 같은 파일 안 구성 대조로 가름(816-2 · 요구 773-3) ───────────────────────────
MADE = "같은 파일 구성 같음"
def peers(machines: dict[Any, tuple[str, str, dict]]) -> dict[Any, list[list[str]]]:
"""같은 파일 기계 {코드: (이름, 규격, 구성 {손료 [[계수, 가격]], 주연료, 조종원})} → {코드: 구성이 같은
단독 기계(손료 줄 하나) [이름, 규격] 들} — 낱 기계 = 구성 통째 같음 · 조합 = 손료 줄마다 그 줄 하나뿐인
단독 기계. 같은 파일 · 같은 때의 값끼리(우리 표 값과 안 맞댐)."""
alone = {c: m for c, m in machines.items() if len(m[2].get("손료") or []) == 1}
out: dict[Any, list[list[str]]] = {}
for c, (_, _, make) in machines.items():
loss = make.get("손료") or []
if len(loss) == 1:
got = [o for o, m in alone.items() if o != c and m[2] == make]
else:
got = [o for o, m in alone.items() if o != c and m[2]["손료"][0] in loss]
if got:
out[c] = [[alone[o][0], alone[o][1]] for o in got]
return out
def narrow(parts: list[dict[str, Any]], same: list | None) -> list[dict[str, Any]]:
"""「후보 여럿」 부분 → 같은 파일에서 구성이 같은 단독 기계 이름 · 규격으로 다시 찾아(`find`) 그 후보
가운데 키 하나로 모이면 그 줄 · 여럿 · 없음 · 대조 없음 = 그대로(우리 값으로 넘겨짚지 않음)."""
if not same:
return parts
found = [(n, s, find(n, s)) for n, s in same]
out = []
for p in parts:
if p["판정"] == MANY:
mine = {c["키"] for c in p["후보"]}
keys = {
g["키"] for _, _, g in found if g["판정"] in (LINKED, ALIAS) and g["키"] in mine
}
if len(keys) == 1:
key = keys.pop()
n, s = next((n, s) for n, s, g in found if g["키"] == key)
p = {**p, **_ans(LINKED, [table()[2][key]], n, f"{MADE} — {n} {s}".strip())}
out.append(p)
return out
def mark(got: dict[str, Any], name: Any, spec: Any) -> str | None:
"""못 이은 기계 표지 글(「참고 · 」 머리) — 이음 · 별칭 = None."""
said = f"「{' '.join(x for x in (str(name or ''), str(spec or '')) if x)}」"
+9 -1
View File
@@ -22,7 +22,15 @@ STM_SHEETS = (
"경비목록표",
"중기목록표",
)
EST_SHEETS = ("단가산출총괄표", "단가산출", "기계경비총괄표", "자재단가", "노임단가", "견적단가")
EST_SHEETS = (
"단가산출총괄표",
"단가산출",
"기계경비총괄표",
"기계경비",
"자재단가",
"노임단가",
"견적단가",
)
_X7, _X5 = "_x0007_", "_x0005_"
_HEAD = re.compile(r"BDCOD\|([A-Z]\d+).*?POSS\|(\d+).*?POSE\|(\d+)")
+4 -3
View File
@@ -178,13 +178,14 @@ def reprice(logic: dict, where: dict, price: dict[str, float]) -> int:
# ── 기계 부분(812-9 · 조합 기계 = 줄 둘) ─────────────────────────────────
def machine_parts(src: dict) -> list[dict]:
"""기계 자원 → 부분 찾기(`M02_Machine_Find.find_all` · 조합 = 둘 이상) · 기계 아님 = []."""
def machine_parts(src: dict, same: list | None = None) -> list[dict]:
"""기계 자원 → 부분 찾기(`M02_Machine_Find.find_all` · 조합 = 둘 이상) · 기계 아님 = [] ·
same = 같은 파일에서 구성이 같은 단독 기계 [[이름, 규격]](816-2 · 있으면 「후보 여럿」을 가름)."""
if src.get("갈래") != "기계":
return []
from M02_MasterTemplete import M02_Machine_Find as mfind
return mfind.find_all(src.get("이름"), src.get("규격"))
return mfind.narrow(mfind.find_all(src.get("이름"), src.get("규격")), same)
def first_spec(one: dict, parts: list[dict]) -> dict:
+33 -2
View File
@@ -228,9 +228,36 @@ def marks(text: str) -> dict[str, Any]:
return out
def makes(t: dict[str, list[dict[str, Any]]]) -> dict[str, dict[str, Any]]:
"""기계 구성(816-2 · MachMainDat — TitleCode 머리 다음 줄부터 빈 줄까지) → {코드: {손료 [[계수, 가격]
(Index -3 GN · TU)] · 주연료 ℓ(-2 「주연료」 GN) · 조종원(-1 GN > 0)}} — 손료 줄 없는 기계는 뺌."""
out: dict[str, dict[str, Any]] = {}
cur = None
for r in t.get("MachMainDat", []):
if r.get("TitleCode"):
cur = out.setdefault(r["TitleCode"], {"손료": [], "주연료": None, "조종원": False})
elif not r.get("Name"):
cur = None
elif cur is not None:
i, gn = r.get("Index"), r.get("GN")
if i == -3 and gn:
cur["손료"].append([gn, r.get("TU")])
elif i == -2 and cur["주연료"] is None and re.sub(r"\s", "", r["Name"]) == "주연료":
cur["주연료"] = gn
elif i == -1 and (gn or 0) > 0:
cur["조종원"] = True
return {k: v for k, v in out.items() if v["손료"]}
def resources(t: dict[str, list[dict[str, Any]]]) -> dict[tuple[int, str], dict[str, Any]]:
"""(LinkIndex, 코드) → 자원 머리(표 · 이름 · 규격 · 단위 · 저장 금액)."""
"""(LinkIndex, 코드) → 자원 머리(표 · 이름 · 규격 · 단위 · 저장 금액 · 기계 = 같은 파일에서 구성이
같은 단독 기계 `같은구성` [[이름, 규격]] · 816-2)."""
from M02_MasterTemplete import M02_Machine_Find as mfind
out = {}
heads = {r.get("Code"): r for r in t.get("MachTitleDat", [])}
made = {c: (heads[c].get("Name"), heads[c].get("Size"), m) for c, m in makes(t).items() if c in heads} # fmt: skip
same = mfind.peers(made)
for link, name in SOURCE.items():
for r in t.get(name, []):
if r.get("Code"):
@@ -240,7 +267,11 @@ def resources(t: dict[str, list[dict[str, Any]]]) -> dict[tuple[int, str], dict[
"규격": r.get("Size"),
"단위": r.get("Unite"),
"저장": _amounts(r, AMT),
}
} | (
{"같은구성": same[r["Code"]]}
if name == "MachTitleDat" and r["Code"] in same
else {}
)
return out
+33
View File
@@ -145,9 +145,42 @@ def resources(sheets: dict[str, list]) -> dict[str, list[dict[str, Any]]]:
vals = {k: float(v or 0) for k, v in vals.items()}
got.append({**_drop(one), "표": sheet, "값": vals} | ({"칸": note} if note else {}))
out[kind] = got
from M02_MasterTemplete import M02_Machine_Find as mfind
same = mfind.peers(makes(sheets.get("기계경비") or []))
for one in out["기계"]: # 816-2 같은 파일에서 구성이 같은 단독 기계
if got := same.get((flat(one["이름"]), flat(one["규격"]))):
one["같은구성"] = got
return out
def makes(rows: list[list[Any]]) -> dict[tuple[str, str], dict[str, Any]]:
"""EST 엑셀 「기계경비」 시트(816-2) — 「N. 이름 · 규격」 머리 아래 빈 줄까지: 「손료 가격 ₩× 계수 ×
10(-7)」 · 「주연료 종류 ℓ」 · 「직종 1 인」 → {(이름, 규격) 붙임: (이름, 규격, {손료 [[계수, 가격]] ·
주연료 ℓ · 조종원})}."""
out: dict[tuple[str, str], tuple[str, str, dict[str, Any]]] = {}
cur = None
for row in rows:
v = [c for c in row if c not in (None, "")]
head = re.match(r"\d+\.\s*(.+)", str(v[0])) if v else None
if head:
spec = v[1] if len(v) > 1 and isinstance(v[1], str) else ""
name = head.group(1).strip()
make = {"손료": [], "주연료": None, "조종원": False}
cur = out.setdefault((flat(name), flat(spec)), (name, spec, make))[2]
elif not v:
cur = None
elif cur is not None:
word = flat(v[0])
if word.startswith("손료") and "₩×" in v and (at := v.index("₩×") + 1) < len(v):
cur["손료"].append([number(v[at]), number(v[1])])
elif word == "주연료" and len(v) > 2 and cur["주연료"] is None:
cur["주연료"] = number(v[2])
elif len(v) > 2 and v[2] == "인" and (number(v[1]) or 0) > 0:
cur["조종원"] = True
return {k: m for k, m in out.items() if m[2]["손료"] and None not in m[2]["손료"][0]}
def _drop(one: dict) -> dict:
return {k: v for k, v in one.items() if k != "줄"}
+5 -1
View File
@@ -154,7 +154,11 @@ class Lines:
f"단산{one['번']}" if kind == "산근" else f"{one['표']}#{one['번']}"
) # 원 코드 없음 = 표 줄 번
out = {"코드": code, "종류": kind, "표": one["표"]}
return out | {k: one[k] for k in ("이름", "규격", "단위")} | {"저장": dict(one["값"])}
out |= {
k: one[k]
for k in ("이름", "규격", "단위") + (("같은구성",) if "같은구성" in one else ())
}
return out | {"저장": dict(one["값"])}
def link(self, ln: dict, kind: str, one: dict | None) -> None:
ln["연결"] = LINK[kind] if kind in LINK else 62
@@ -0,0 +1,129 @@
"""816-2 지킴 — 「후보 여럿」 기계를 같은 파일 안 구성 대조로 가름(요구 770-2 · 773-3 · 방향 장부 N1).
잣대 = 같은 파일 · 같은 때의 값끼리(손료 계수 · 가격 · 주연료 · 조종원) — 우리 기계표 값과 맞대지 않음.
(a) EST 원파일 MachMainDat 묶기 — main 기계 1,049 대 모두 구성
(b) 견본 셋 — 대형브레이카 +굴착기(1.0㎥) → 같은 파일 굴착기(무한궤도) 1.0㎥ → EQ000022 · 크레인(트럭) 10Ton →
크레인(타이어) 10Ton → EQ000128 · 굴삭기(유압식백호) 0.6㎥ → EQ000019
(c) 파일 값이 우리 표와 달라도 같은 파일 대조로 가름 · 우리 표 값과 같다는 것만으로는 안 이음 · 같은 구성
없음 · 여럿 = 그대로
(d) EST 엑셀 「기계경비」 시트 길 — 산출근거 줄 → 같은 대조 · 2016 사방 엑셀 기계 12 대 구성 읽음
"""
from __future__ import annotations
import sys
from functools import lru_cache
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
for p in (ROOT, HERE):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
from M02_MasterTemplete import M02_Machine_Find as mf # noqa: E402
from M02_MasterTemplete import M02_San_Book as book # noqa: E402
from M02_MasterTemplete import M02_San_Emit as em # noqa: E402
from M02_MasterTemplete import M02_San_Estx as estx # noqa: E402
from M02_MasterTemplete import M02_San_Xlsx as sx # noqa: E402
MAIN = ROOT / "resources/knowledge/original/경쟁사 프로그램/EST Plus 2/다산소프트/2026-07-main.estx"
COMBO = ("대형브레이카", "+굴착기(1.0㎥)")
@lru_cache(maxsize=1)
def _tables() -> dict:
return estx.tables(MAIN.read_bytes())
@lru_cache(maxsize=1)
def _main() -> dict:
res = estx.resources(_tables()).values()
return {(r["이름"], r["규격"]): r for r in res if r["표"] == "MachTitleDat"}
def _keys(name: str, spec: str, same: list | None) -> list:
return [(p["판정"], p["키"]) for p in em.machine_parts({"갈래": "기계", "이름": name, "규격": spec}, same)] # fmt: skip
def test_main_makes() -> None:
made = estx.makes(_tables())
assert len(made) == 1049 and all(m["손료"] for m in made.values())
code = next(r["Code"] for r in _tables()["MachTitleDat"] if (r["Name"], r["Size"]) == COMBO)
assert made[code] == {"손료": [[2085.0, 142069000.0], [6601.0, 28692000.0]], "주연료": 19.5, "조종원": True} # fmt: skip
assert ["굴착기(무한궤도)", "1.0㎥"] in _main()[COMBO]["같은구성"]
def test_samples() -> None:
for (name, spec), want in (
(COMBO, "EQ000022"),
(("크레인(트럭)", "10Ton"), "EQ000128"),
(("굴삭기(유압식백호)", "0.6㎥"), "EQ000019"),
):
same = _main()[(name, spec)]["같은구성"]
assert any(v == mf.MANY for v, _ in _keys(name, spec, None)), name
after = _keys(name, spec, same)
assert (mf.LINKED, want) in after and all(v != mf.MANY for v, _ in after), name
got = em.machine_parts({"갈래": "기계", "이름": "크레인(트럭)", "규격": "10Ton"}, _main()[("크레인(트럭)", "10Ton")]["같은구성"]) # fmt: skip
assert got[0]["까닭"].startswith(mf.MADE) and "크레인(타이어)" in got[0]["까닭"]
def test_file_values_only() -> None:
"""파일 값이 우리 표와 달라도 같은 파일 대조로 가름 · 우리 표 값과 같아도 같은 파일에 짝 없으면 그대로."""
odd = {"손료": [[9999.0, 1.0]], "주연료": 1.0, "조종원": True} # 우리 표에 없는 값
files = {
"a": ("굴착기", "1.0㎥", odd),
"b": ("굴착기(무한궤도)", "1.0㎥", odd),
"c": ("굴착기(타이어)", "1.0㎥", {**odd, "주연료": 2.0}),
}
same = mf.peers(files)
assert same["a"] == [["굴착기(무한궤도)", "1.0㎥"]]
assert _keys("굴착기", "1.0㎥", same["a"]) == [(mf.LINKED, "EQ000022")]
ours = {
"손료": [[2085.0, 142069000.0]],
"주연료": 19.5,
"조종원": True,
} # 우리 표 무한궤도 값 그대로
alone = mf.peers({"a": ("굴착기", "1.0㎥", ours), "c": ("굴착기(타이어)", "1.0㎥", odd)})
assert "a" not in alone and _keys("굴착기", "1.0㎥", alone.get("a")) == [(mf.MANY, None)]
both = mf.peers(
{**files, "c": ("굴착기(타이어)", "1.0㎥", odd)}
) # 같은 구성 둘(형식 다름) = 여럿
assert _keys("굴착기", "1.0㎥", both["a"])[0][0] == mf.MANY
def test_keep_when_unsure() -> None:
same = _main()[("굴삭기(유압식백호)", "1.0㎥;암보정")].get(
"같은구성"
) # 같은 파일 무한궤도(발파) 2,405
assert ["굴착기(무한궤도)", "1.0㎥(발파)"] in same
assert _keys("굴삭기(유압식백호)", "1.0㎥;암보정", same) == [(mf.LINKED, "EQ000022")]
assert _keys("크레인(트럭)", "10Ton", None) == [(mf.MANY, None)]
def test_excel_sheet() -> None:
rows = [
["기계경비"],
["명 칭", "규 격", "산 출 근 거"],
["1. 굴삭기", "1.0㎥", 0.0, 28259.0],
["손 료", 100000000.0, "₩×", 2038.0, "×", "10(-7)", 20380.0],
["주연료", "경유", 19.5, "ℓ", "×", 993.0, 19363.5],
["건설기계운전사", 1.0, "인", "×", 135644.0],
[],
["2. 굴삭기(무한궤도)", "1.0㎥", 0.0, 28259.0],
["손 료", 100000000.0, "₩×", 2038.0, "×", "10(-7)", 20380.0],
["주연료", "경유", 19.5, "ℓ", "×", 993.0, 19363.5],
["건설기계운전사", 1.0, "인", "×", 135644.0],
[],
]
made = sx.makes(rows)
assert made[("굴삭기", "1.0㎥")][2] == {"손료": [[2038.0, 100000000.0]], "주연료": 19.5, "조종원": True} # fmt: skip
same = mf.peers(made)[("굴삭기", "1.0㎥")]
assert same == [["굴삭기(무한궤도)", "1.0㎥"]]
assert _keys("굴삭기", "1.0㎥", same) == [(mf.LINKED, "EQ000022")]
import test_797_1a_import_flags as practice
sad = practice.FILES.get("2016사방")
if sad:
sheets = book.sheets(sad.name, sad.read_bytes())
assert len(sx.makes(sheets["기계경비"])) == 12