fix(812-2): 연결 찾기 로직 대조 다듬음 — 원파일 기계경비 묶음 ↔ 우리 기계 = 이름 종류 짝 · 율 대상 = (자원 갈래, 비목) · 번지 합 · 비목 이름 몫

- 기계경비 숫자 묶음(코드 없음) ↔ 우리 기계 = 이름 머리로 같은 기계(로울러 = 롤러 · 물탱크 = 살수차 등) · 시간당 단가 · 구성 = 참고
- 로직 같은 짝을 먼저 지음(같은 수량 줄 엇갈림 막음) · 한쪽 단가 없음(0) 비목 = 값
- 율 = 번지 합이면 핵심 줄 전부 · 「노무비 × n %」 = 그 비목 몫(기계 운전 노무비 포함) · 대상 = (갈래, 비목) 묶음
- test_812_2 — 기계 이름 짝 · 율 대상 범위 시험 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 09:38:19 +09:00
co-authored by Claude Opus 5.5
parent 1e4ce036c6
commit f1abfc2fb1
3 changed files with 124 additions and 28 deletions
+8 -1
View File
@@ -440,7 +440,14 @@ def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]:
)
)
same += len(exact)
for a, b in take(lambda a, b: core(a, b) and cls(a, b) and a["수량"] == b["수량"]):
def twin(a: dict, b: dict) -> bool:
return core(a, b) and cls(a, b) and a["수량"] == b["수량"]
for a, b in take(lambda a, b: twin(a, b) and not note.pair(a, b)[0]): # 로직 같은 짝 먼저
same += 1
notes += note.pair(a, b)[1]
for a, b in take(twin):
bad, ref = note.pair(a, b)
same, paired = (same + 1, paired) if not bad else (same, paired + 1)
diffs += bad
+57 -24
View File
@@ -3,7 +3,7 @@
확정을 막는 「다른 항」 = 로직 차이만 — 자원 갈래 · 기계 규격 · 노임 종류 · 자재 이름 종류 · 비목 · 산출 단가 ·
율 구조(있고 없음 · 대상 비목 · 율 = 비율) · 수량(옵션 풀이 뒤 · 원 단위 같아야 함).
「참고」 = 값 차이 — 인력 · 재료 · 기계 시간당 단가 · 끝수(합만 다름) · 그로 인한 비목 금액 · 계.
율 = 율 줄 셈값 ÷ 대상 줄 금액 합 — 대상 = 율 식이 (거쳐서) 부르는 금액 줄 · 비목 이름이면 그 비목 줄 ·
율 = 율 줄 셈값 ÷ 대상 금액 합 — 대상 = 율 식이 (거쳐서) 부르는 금액 줄 · 비목 이름이면 그 비목 몫 · 번지 합 = 핵심 줄 전부 ·
대상 금액 끝수 자리 차이만 지움(유효 6 자리 · 두 바탕 중 짧은 꼴) — 지어 맞추기 아님.
"""
@@ -57,7 +57,7 @@ def _short(r: Decimal) -> Decimal:
def rates(doc: dict, got: dict, facts: list[dict]) -> None:
"""율 사실에 `율`(비율 · 못 셈 None) · `대상`(대상 비목 묶음)을 채움(제자리)."""
"""율 사실에 `율`(비율 · 못 셈 None) · `대상`({(자원 갈래, 비목)})을 채움(제자리)."""
money, rows = doc.get("금액") or [], got.get("줄") or []
direct = _reach(doc)
core = [f for f in facts if f["갈래"] != "율" and not f["합밖"]]
@@ -67,33 +67,37 @@ def rates(doc: dict, got: dict, facts: list[dict]) -> None:
line, row = money[f["번"]], rows[f["번"]]
reach = _close(_names(line.get("수량")) | _names(line.get("단가")), direct)
costs = reach & set(md.COSTS)
hits = []
for c in core:
mine = money[c["번"]]
parts = _names(mine.get("수량")) | _names(mine.get("단가"))
if (
(mine.get("기호") and str(mine["기호"]) in reach)
or (parts and parts <= reach)
or (set(c["비목"]) & costs)
):
hits.append(c)
f["대상"] = frozenset(k for c in hits for k in c["비목"])
hits = [c for c in core if _hit(money[c["번"]], reach)]
if hits: # 줄 기호 · 줄 수량 · 단가 이름 = 그 줄 금액 전부
parts = [(c, tuple(c["비목"])) for c in hits]
elif costs: # 비목 이름(「노무비 × 4 %」) = 그 비목 몫만
parts = [(c, tuple(k for k in c["비목"] if k in costs)) for c in core]
else: # 번지 합 · 못 찾음 = 블록 핵심 줄 전부
parts = [(c, tuple(c["비목"])) for c in core]
parts = [(c, ks) for c, ks in parts if ks]
f["대상"] = frozenset((_cls(c["갈래"]), k) for c, ks in parts for k in ks)
f["율"] = None
amount = _mul(row.get("수량"), row.get("단가"))
if amount is None:
continue
bases = [
sum((_num(rows[c["번"]].get("금액")) for c in hits), Decimal(0)),
sum(
(_mul(rows[c["번"]].get("수량"), rows[c["번"]].get("단가")) or 0 for c in hits),
Decimal(0),
),
]
bases = [sum((_num(c["비목"][k]) for c, ks in parts for k in ks), Decimal(0))]
if hits:
each = (_mul(rows[c["번"]].get("수량"), rows[c["번"]].get("단가")) for c in hits)
bases.append(sum((x or 0 for x in each), Decimal(0)))
found = [_short(amount / b) for b in bases if b]
if found:
f["율"] = min(found, key=lambda r: len(r.as_tuple().digits))
def _hit(line: dict, reach: set[str]) -> bool:
parts = _names(line.get("수량")) | _names(line.get("단가"))
return bool(line.get("기호") and str(line["기호"]) in reach) or bool(parts and parts <= reach)
def _cls(kind: str) -> str:
return "기계" if kind == "산출" else kind # 기계경비 묶음(코드 없음) = 기계
def _num(v: Any) -> Decimal:
if isinstance(v, dict):
return sum((_num(v.get(k)) for k in md.COSTS), Decimal(0))
@@ -126,28 +130,57 @@ def txt(v: Any) -> str:
return str(v)
def _grams(text: str) -> set[str]:
_ALIAS = (("로울러", "롤러"), ("굴삭", "굴착"), ("브레이카", "브레이커"), ("로우더", "로더"))
_ALIAS += (("물탱크", "살수차"), ("아스팔트페이버", "아스팔트피니셔")) # 같은 기계 다른 이름
def _plain(text: str) -> str:
t = re.sub(r"\s", "", text)
for old, new in _ALIAS:
t = t.replace(old, new)
return t
def _head(text: str) -> str:
"""기계 이름 머리(괄호 · 규격 앞 한글) — 「머캐덤로울러(자주식)」 → 「머캐덤롤러」."""
m = re.match(r"[가-힣]+", _plain(text))
return m.group(0) if m else ""
def _same_machine(a: str, b: str) -> bool:
x, y = _head(a), _head(b)
return bool(x and y) and (x in y or y in x)
def _grams(text: str) -> set[str]:
t = _plain(text)
return {run[i : i + 2] for run in re.findall(r"[가-힣]+", t) for i in range(len(run) - 1)}
def pair(a: dict, b: dict) -> tuple[list[dict], list[dict]]:
"""수량 같은 짝 줄 → (다른 항, 참고) — 단가만 다르면 참고 · 자원 · 비목이 다르면 다른 항."""
diffs: list[dict] = []
kinds = {a["갈래"], b["갈래"]}
# 기계경비 숫자 묶음(코드 없음 · 원파일 값) ↔ 우리 기계 = 이름 종류로 같은 기계인지
loose = kinds == {"기계", "산출"} and not (a if a["갈래"] == "산출" else b)["코드"]
if a["갈래"] == b["갈래"] == "기계" and a["코드"] != b["코드"]:
diffs.append({"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']}"})
elif loose and not _same_machine(a["이름"], b["이름"]):
diffs.append({"항": "기계 규격", "글": f"{a['이름']} ↔ {b['이름']} {b['코드']}"})
elif a["갈래"] == b["갈래"] == "노임" and a["코드"] != b["코드"]:
diffs.append({"항": "자원", "글": f"{a['이름']} {a['코드']} ↔ {b['이름']} {b['코드']}"})
elif a["갈래"] == b["갈래"] == "재료" and not _same_stuff(a, b):
diffs.append({"항": "자원", "글": f"{a['이름']} ↔ {b['이름']}"})
if set(a["비목"]) != set(b["비목"]):
machine = a["갈래"] == b["갈래"] == "기계" or loose # 시간당 단가 구성(운전 노무비 등) = 값
priced = a["비목"] and b["비목"] # 한쪽 단가 없음(0) = 값
if priced and not machine and set(a["비목"]) != set(b["비목"]):
diffs.append(
{"항": "비목", "글": f"{a['이름']} 비목 {'·'.join(a['비목'])} ↔ {'·'.join(b['비목'])}"}
)
if a["단가"] == b["단가"]:
return diffs, []
say = f"{a['이름']} 단가 {txt(a['단가'])} ↔ {txt(b['단가'])}"
if a["갈래"] == b["갈래"] and a["갈래"] in PRICED:
if (a["갈래"] == b["갈래"] and a["갈래"] in PRICED) or loose:
return diffs, [{"항": "단가 차이", "글": say}]
return [*diffs, {"항": "산출 단가", "글": say}], []
@@ -175,7 +208,7 @@ def rate_pairs(left: list[dict], right: list[dict], take: Any) -> list[dict]:
return f"{a['이름']} {pct(a.get('율'))} ↔ {b['이름']} {pct(b.get('율'))}"
def target(x: dict) -> str:
return "·".join(sorted(x.get("대상") or ())) or "없음"
return "·".join(f"{k} {c}" for k, c in sorted(x.get("대상") or ())) or "없음"
take(lambda a, b: same(a, b, "율", "대상", "비목")) # 로직 같음
for a, b in take(lambda a, b: same(a, b, "율", "대상")):