refactor(815-2): 연결 찾기(기초 찾기) 전체 걷기 — 요구 762-1 · 763-1 · 방향 G1
- 서버 M02_Doc_Match · M02_Doc_Match_Note · M02_Doc_Match_Value · M02_Doc_MatchSk 지움 · 길 POST …/sangeun/{키}/match 지움
- 화면 판정 띠(M02_MasterTemplete_Sangeun_MatchBand · logic/M02_Sangeun_Match) · [연결 찾기] · [바꾸기] · [되돌리기] 단추 · 원문 카드 판정 글 지움
- 옛 SK 기록 `가져온.원본.연결후보` · 바꾼 SK `가져온보관` = 읽기만 그대로(카드 · 깨지지 않음 · 지우지 않음)
- 시험 지움: test_810_4_match · test_812_2_match_logic · test_812_4_rate_value · test_812_11_match_values · helper_810_4_sweep(걷은 모듈 지킴) · test_814_1 의 꼬리 뗀 맞대기 · 공구손료 비목 · 띠 글 · 도우미 줄(연결 찾기 몫)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,696 +0,0 @@
|
||||
"""가져온 식 ↔ 우리 M01 기초 연결 찾기(PLAN 810-4 · 요구 707 · 709 · 703) — 서버 단독.
|
||||
|
||||
가져온 식 문서(원파일 확정 정보 · 우리 단가로 셈) 하나를 M01 수식 문서 전부와 맞대어
|
||||
「확정」 · 「근접」 · 「없음」 을 냄 — 지어 맞추기 없음(값 보정 · 허용 오차 없음 · 원 단위 같아야 확정).
|
||||
① 후보 = 결과단위 같음 + 노임 키 ⊂ M01 노임 키 + 재료 · 기계 있으면 M01 에도 ·
|
||||
품셈 절 번호(가져온 「추정」 글 · M01 `출처`)가 겹치는 문서가 있으면 그것만.
|
||||
② 옵션 풀이 = 그 문서의 선택 · 입력 변수 값을 줄마다 좁힘(줄 수량 · 단가가 의존하는 변수만 돌림 ·
|
||||
값이 가져온 줄 수량 · 단가 묶음에 있거나 0(꺼진 줄)인 값만 남김) → 남은 조합(상한 `CAP_ALL`)을
|
||||
문서 셈 · 입력 변수 후보 = 기본값 · 0 · 가져온 식에 나온 수 · 선택은 가져온 글에 든 값부터.
|
||||
③ 판정 = 줄 대조 = 로직(812-2 · 요구 712 — `M02_Doc_Match_Note`): 다른 항 = 자원 · 기종 · 단위 환산 ·
|
||||
비목 · 율 구조(대상 · 비목) · 줄 없음 · 이름 · 참고 = 단가 · 끝수 · 율 값 % · 같은 기종 규격 · 수량
|
||||
(값 — 확정을 안 막음 · 812-11 `M02_Doc_Match_Value`) — 다른 항 0 = 「확정」 · 같은 줄이 하나 넘고
|
||||
핵심 줄 반 넘게 짝 = 「근접」(다른 항 목록) · 그 밖 「없음」 · 값이 맞는 조합 없으면 기본 옵션 + 「옵션 확인」.
|
||||
⓪ 기계 한 묶음 + 작업량 식 수량 = 「기계 작업」(우리 기초에 안 붙임 · 요구 728 · 735-4).
|
||||
결과 `{판정, 키, 이름, 옵션, 다른, 참고, 바꿈, 금액 {가져온, 우리}, 후보수}` = 가져온 식 원본 `연결후보` ·
|
||||
`바꿈` = [바꾸기] 때 갈아 끼울 기계 줄 [{기호(우리 단가 기호), 기계(가져온 원문번호), 이름, 규격}].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
from M02_MasterTemplete import M02_Doc_Match_Note as note
|
||||
from M02_MasterTemplete import M02_Doc_Match_Value as val
|
||||
|
||||
md, mc, ml = dc.md, dc.mc, dc.ml
|
||||
if not hasattr(
|
||||
md.parse, "cache_info"
|
||||
): # 식 글 → 나무 = 순수 함수 · 조합마다 같은 글을 다시 풀지 않음
|
||||
md.parse = functools.lru_cache(maxsize=50000)(md.parse)
|
||||
|
||||
KEY = "연결후보"
|
||||
SURE, NEAR, NONE = "확정", "근접", "없음"
|
||||
CAP_LINE = 3000 # 줄 하나 좁힐 때 돌리는 조합 상한(넘으면 그 줄은 좁힘에 안 씀)
|
||||
CAP_ALL = 192 # 문서 하나 끝 셈 조합 상한
|
||||
CAP_INPUT = 12 # 입력 변수 후보 수 상한
|
||||
NICE = (2, 3, 4, 5, 6, 8, 10, 12, 100, 1000) # 단위 환산으로 보는 배
|
||||
_SEC = re.compile(r"(?<![\d-])(\d{1,2}(?:-\d{1,2}){1,3})(?![\d-])")
|
||||
_NUMS = re.compile(r"\d+(?:\.\d+)?")
|
||||
_UNIT = {"m3": "㎥", "m2": "㎡", "m": "m", "ea": "개", "톤": "ton", "t": "ton", "hr": "시간"}
|
||||
|
||||
|
||||
# ── 수 ────────────────────────────────────────────────────────────────
|
||||
def _key(v: Any) -> Any:
|
||||
"""비교 열쇠 — 수 = 유효 12 자리(Decimal 셈 자리 차이만 지움) · 묶음 = 비목 튜플."""
|
||||
if isinstance(v, dict):
|
||||
return tuple(_key(v.get(k)) for k in md.COSTS)
|
||||
if isinstance(v, Decimal):
|
||||
try:
|
||||
return v.normalize() if v == 0 else Decimal(f"{v:.12g}").normalize()
|
||||
except InvalidOperation:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def unit_of(text: Any) -> str:
|
||||
"""결과단위 「원/㎡」 → 「㎡」(소문자 · m3 → ㎥ 등) · 돈 아닌 단위 = ""."""
|
||||
text = str(text or "").strip()
|
||||
if not text.startswith("원/"):
|
||||
return ""
|
||||
unit = text[2:].strip().lower().replace(" ", "")
|
||||
return _UNIT.get(unit, unit)
|
||||
|
||||
|
||||
def sections(texts: list[str]) -> set[str]:
|
||||
"""품셈 절 번호(「6-3-1」) — 앞 둘 · 셋 마디 모두."""
|
||||
out = set()
|
||||
for t in texts:
|
||||
for s in _SEC.findall(str(t or "")):
|
||||
parts = s.split("-")
|
||||
out |= {"-".join(parts[:n]) for n in range(2, len(parts) + 1)}
|
||||
return out
|
||||
|
||||
|
||||
# ── 줄 사실 ───────────────────────────────────────────────────────────
|
||||
def _single(text: Any) -> str | None:
|
||||
try:
|
||||
node = md.parse(text)
|
||||
except md.DocError:
|
||||
return None
|
||||
return node[1] if node[0] == "var" else None
|
||||
|
||||
|
||||
def _what(doc: dict, line: dict) -> tuple[str, str]:
|
||||
"""금액 줄 자원 → (갈래, 코드) · 갈래 = 노임 · 재료 · 기계 · 산출 · 율 · 수."""
|
||||
sym = _single(line.get("단가"))
|
||||
var = next((v for v in doc.get("변수") or [] if sym and md.var_id(v) == sym), None)
|
||||
if var is None:
|
||||
return "율", ""
|
||||
link = var.get("연결") or {}
|
||||
kind, key = link.get("갈래"), str(link.get("키") or "")
|
||||
if kind == "요소":
|
||||
if key.startswith("LB"):
|
||||
return "노임", key
|
||||
if key.startswith("EQ"):
|
||||
return "기계", key
|
||||
return "재료", key
|
||||
if kind == "단가산출":
|
||||
given = link.get("입력") or {}
|
||||
code = str(given.get("기계") or "").strip("'")
|
||||
return ("기계", code) if "기계" in given else ("산출", key)
|
||||
if kind == "숫자" and isinstance(link.get("값"), dict):
|
||||
return "산출", ""
|
||||
if kind == "식":
|
||||
return "율", ""
|
||||
return "수", ""
|
||||
|
||||
|
||||
def facts(doc: dict, got: dict) -> list[dict]:
|
||||
"""셈 답 줄 → 대조 사실 — 소계 보임(비목 없는 합 밖) · 꺼진 줄(수량 0 · 금액 0) 뺌."""
|
||||
out = []
|
||||
money = doc.get("금액") or []
|
||||
for i, (line, row) in enumerate(zip(money, got.get("줄") or [])):
|
||||
if line.get("합밖") and not line.get("비목"):
|
||||
continue
|
||||
if row.get("금액") is not None and row["금액"] == 0 and row.get("수량") == 0:
|
||||
continue
|
||||
kind, code = _what(doc, line)
|
||||
if kind == "기계" and code in (got.get("값") or {}): # 옵션 기호 = 그 값(기계 코드)
|
||||
code = mc.plain_text(got["값"][code])
|
||||
if kind == "율" or (kind == "수" and line.get("수량") in (None, "")):
|
||||
kind = "율"
|
||||
out.append(
|
||||
{
|
||||
"번": i,
|
||||
"이름": str(line.get("이름") or ""),
|
||||
"갈래": kind,
|
||||
"코드": code,
|
||||
"수량": _key(row.get("수량")) if kind != "율" else None,
|
||||
"단가": _key(row.get("단가")) if kind != "율" else None,
|
||||
"비목": {k: _key(v) for k, v in (row.get("비목") or {}).items() if v},
|
||||
"금액": _key(row.get("금액")),
|
||||
"합밖": bool(row.get("합밖")),
|
||||
"기호": _single(line.get("단가")),
|
||||
"규격": str(line.get("규격") or ""),
|
||||
"수량글": str(line.get("수량") or ""),
|
||||
}
|
||||
)
|
||||
val.mark_combo(doc, out)
|
||||
note.rates(doc, got, out)
|
||||
return out
|
||||
|
||||
|
||||
def resources(doc: dict) -> dict[str, set]:
|
||||
"""문서가 쓸 수 있는 자원(옵션 무관) — {노임 키, 재료, 기계}."""
|
||||
out: dict[str, set] = {"노임": set(), "재료": set(), "기계": set()}
|
||||
for line in doc.get("금액") or []:
|
||||
kind, code = _what(doc, line)
|
||||
if kind == "노임":
|
||||
out["노임"].add(code)
|
||||
elif kind == "재료":
|
||||
out["재료"].add(code or "고름")
|
||||
elif kind in ("기계", "산출"):
|
||||
out["기계"].add(code or kind)
|
||||
return out
|
||||
|
||||
|
||||
# ── 연결 기억 ─────────────────────────────────────────────────────────
|
||||
class Memo:
|
||||
"""M01 연결 + 셈 기억(요소 · 표 · 부른 단가산출) — 같은 값을 조합마다 다시 찾지 않음."""
|
||||
|
||||
def __init__(self, links: Any):
|
||||
self.links, self.hits, self.calls = links, {}, {}
|
||||
|
||||
def _hit(self, tag: str, p: dict, fn: Any) -> Any:
|
||||
key = (tag, json.dumps(p, sort_keys=True, ensure_ascii=False, default=str))
|
||||
if key not in self.hits:
|
||||
try:
|
||||
self.hits[key] = (True, fn(p))
|
||||
except (mc.Gap, mc.Broken) as e:
|
||||
self.hits[key] = (False, e)
|
||||
ok, got = self.hits[key]
|
||||
if not ok:
|
||||
raise got
|
||||
return got
|
||||
|
||||
def element(self, p: dict) -> Any:
|
||||
return self._hit("요소", p, self.links.element)
|
||||
|
||||
def table(self, p: dict) -> Any:
|
||||
return self._hit("표", p, self.links.table)
|
||||
|
||||
def doc(self, key: str) -> dict | None:
|
||||
return self.links.doc(key)
|
||||
|
||||
|
||||
# ── ① 후보 ────────────────────────────────────────────────────────────
|
||||
class Index:
|
||||
"""M01 수식 문서 색인(결과단위 · 자원 · 절) — 프로세스 안 한 번."""
|
||||
|
||||
def __init__(self, docs: dict[str, dict]):
|
||||
self.docs = docs
|
||||
self.by_unit: dict[str, list[str]] = {}
|
||||
self.res: dict[str, dict[str, set]] = {}
|
||||
self.secs: dict[str, set[str]] = {}
|
||||
self.regions: set[str] = set()
|
||||
for key, doc in docs.items():
|
||||
for v in doc.get("변수") or []:
|
||||
if str(v.get("열쇠") or "").endswith("지역"):
|
||||
self.regions |= {str(x) for x in (v.get("연결") or {}).get("목록") or []}
|
||||
unit = unit_of(doc.get("결과단위"))
|
||||
if not unit or not doc.get("금액"):
|
||||
continue
|
||||
self.by_unit.setdefault(unit, []).append(key)
|
||||
self.res[key] = resources(doc)
|
||||
self.secs[key] = sections([doc.get("출처")])
|
||||
|
||||
def candidates(self, doc: dict) -> list[str]:
|
||||
mine = resources(doc)
|
||||
keys = []
|
||||
for key in self.by_unit.get(unit_of(doc.get("결과단위")), []):
|
||||
have = self.res[key]
|
||||
if not mine["노임"] <= have["노임"]:
|
||||
continue
|
||||
if mine["재료"] and not have["재료"]:
|
||||
continue
|
||||
if mine["기계"] and not have["기계"]:
|
||||
continue
|
||||
if not (mine["노임"] or mine["재료"] or mine["기계"]):
|
||||
continue
|
||||
keys.append(key)
|
||||
secs = sections([x.get("글") for x in (doc.get("가져온") or {}).get("추정") or []])
|
||||
hit = [k for k in keys if secs & self.secs[k]]
|
||||
return hit or keys
|
||||
|
||||
|
||||
# ── ② 옵션 풀이 ───────────────────────────────────────────────────────
|
||||
def _closure(doc: dict) -> dict[str, set[str]]:
|
||||
"""기호 → 그 기호가 (거쳐서) 부르는 이름 전부."""
|
||||
direct: dict[str, set[str]] = {}
|
||||
for v in doc.get("변수") or []:
|
||||
direct[md.var_id(v)] = set(md.link_names(v.get("연결") or {}))
|
||||
for line in doc.get("식") or []:
|
||||
try:
|
||||
direct[str(line.get("기호"))] = set(md.names(md.parse(line.get("식"))))
|
||||
except md.DocError:
|
||||
direct[str(line.get("기호"))] = set()
|
||||
memo: dict[str, set[str]] = {}
|
||||
|
||||
def reach(name: str, seen: tuple = ()) -> set[str]:
|
||||
if name in memo:
|
||||
return memo[name]
|
||||
out = set()
|
||||
for n in direct.get(name, ()):
|
||||
if n not in seen:
|
||||
out |= {n} | reach(n, (*seen, name))
|
||||
memo[name] = out
|
||||
return out
|
||||
|
||||
return {n: reach(n) for n in direct}
|
||||
|
||||
|
||||
def _uses(text: Any, close: dict) -> set[str] | None:
|
||||
try:
|
||||
names = md.names(md.parse(text)) if text not in (None, "") else []
|
||||
except md.DocError:
|
||||
return None
|
||||
out = set(names)
|
||||
for n in names:
|
||||
out |= close.get(n, set())
|
||||
return out
|
||||
|
||||
|
||||
def numbers_of(doc: dict) -> list[Decimal]:
|
||||
"""가져온 식에 나온 수(숫자 변수 · 식 · 수량 글 · 규격 · 이름)."""
|
||||
got: list[Decimal] = []
|
||||
texts = [doc.get("이름"), (doc.get("가져온") or {}).get("규격")]
|
||||
for v in doc.get("변수") or []:
|
||||
val = (v.get("연결") or {}).get("값")
|
||||
if not isinstance(val, dict):
|
||||
texts.append(val)
|
||||
texts += [x.get("식") for x in doc.get("식") or []]
|
||||
texts += [x.get("수량") for x in doc.get("금액") or []]
|
||||
texts += [x.get("규격") for x in doc.get("금액") or []]
|
||||
for t in texts:
|
||||
for s in _NUMS.findall(str(t or "")):
|
||||
d = Decimal(s).normalize()
|
||||
if d not in got:
|
||||
got.append(d)
|
||||
return got
|
||||
|
||||
|
||||
def _words(doc: dict) -> str:
|
||||
"""가져온 식 글(이름 · 규격 · 줄 규격 · 부름 입력 값 — 줄 이름 뺌) — 선택 값의 글 근거."""
|
||||
bits = [doc.get("이름"), (doc.get("가져온") or {}).get("규격")]
|
||||
bits += [x.get("규격") for x in doc.get("금액") or []]
|
||||
for v in doc.get("변수") or []:
|
||||
bits += [str(x) for x in ((v.get("연결") or {}).get("입력") or {}).values()]
|
||||
return " ".join(str(b or "") for b in bits).replace(" ", "").replace(",", "")
|
||||
|
||||
|
||||
def _bare(o: Any) -> str:
|
||||
return str(o).replace(" ", "").replace(",", "") # 「D1,800」 = 글 「D1800」
|
||||
|
||||
|
||||
def domains(
|
||||
doc: dict, imported: dict, regions: set[str] = frozenset()
|
||||
) -> tuple[dict[str, list], dict[str, set]]:
|
||||
"""선택 · 입력 변수 → (돌릴 값, 글 근거) — 기본 먼저 · 가져온 글에 든 선택 값(근거) 먼저 ·
|
||||
글 입력 지역 = 가져온 글에 든 지역 이름."""
|
||||
words, nums = _words(imported), numbers_of(imported)
|
||||
out: dict[str, list] = {}
|
||||
ev: dict[str, set] = {}
|
||||
for v in doc.get("변수") or []:
|
||||
link = v.get("연결") or {}
|
||||
kind, base = link.get("갈래"), link.get("값")
|
||||
if kind == "선택":
|
||||
opts = list(link.get("목록") or [])
|
||||
seen = [o for o in opts if _bare(o) in words and len(str(o)) > 1]
|
||||
seen.sort(key=lambda o: -len(str(o)))
|
||||
if seen:
|
||||
ev[md.var_id(v)] = set(seen)
|
||||
rest = [o for o in opts if o not in seen]
|
||||
first = [base] if base in rest else []
|
||||
out[md.var_id(v)] = seen + first + [o for o in rest if o != base]
|
||||
elif kind == "입력":
|
||||
try:
|
||||
d = Decimal(str(base).replace(",", "")).normalize()
|
||||
except (InvalidOperation, ValueError):
|
||||
if str(v.get("열쇠") or "").endswith("지역"):
|
||||
seen = sorted((r for r in regions if len(r) > 1 and r in words), key=len)
|
||||
out[md.var_id(v)] = list(dict.fromkeys([base, *seen[::-1]]))
|
||||
continue # 그 밖 글 입력 = 기본 그대로
|
||||
low, high = (mc.number(x) for x in (link.get("범위") or [None, None]))
|
||||
vals = [d, Decimal(0)] + nums
|
||||
vals = [x for x in vals if (low is None or x >= low) and (high is None or x <= high)]
|
||||
out[md.var_id(v)] = list(dict.fromkeys(vals))[:CAP_INPUT]
|
||||
return out, ev
|
||||
|
||||
|
||||
def _given(doc: dict, pick: dict) -> dict:
|
||||
ids = {md.var_id(v): v.get("열쇠") or md.var_id(v) for v in doc.get("변수") or []}
|
||||
return {ids[k]: v for k, v in pick.items()}
|
||||
|
||||
|
||||
def _eval(doc: dict, links: Any, pick: dict, text: Any, memo: dict) -> Any:
|
||||
run = mc._Run(doc, links, _given(doc, pick), (str(doc.get("키")),), links.calls)
|
||||
got = run.expr(text, "줄")
|
||||
if isinstance(got, Decimal) and got == 0:
|
||||
return 0
|
||||
return _key(got) if isinstance(got, (Decimal, dict)) else None
|
||||
|
||||
|
||||
def narrow(
|
||||
doc: dict, links: Any, dom: dict, want: dict[str, set], ev: dict[str, set] | None = None
|
||||
) -> dict[str, list]:
|
||||
"""줄마다 의존 변수만 돌려 가져온 값(수량 · 단가) · 0 에 닿는 값만 남김(두 바퀴) ·
|
||||
글 근거 값(`ev`)은 안 지움(값이 달라도 그 조합을 셈 — 다른 항으로 드러냄)."""
|
||||
ev = ev or {}
|
||||
close = _closure(doc)
|
||||
memo: dict = {}
|
||||
base = {k: vs[0] for k, vs in dom.items()}
|
||||
jobs = []
|
||||
for line in doc.get("금액") or []:
|
||||
for slot, field in (("수량", "수량"), ("단가", "단가")):
|
||||
uses = _uses(line.get(field), close)
|
||||
if uses is None or uses & (set(md.MONEY_NAMES) | _money_syms(doc)):
|
||||
continue
|
||||
free = sorted(u for u in uses if u in dom)
|
||||
if free:
|
||||
jobs.append((line.get(field), free, want[slot]))
|
||||
for _ in range(2):
|
||||
for text, free, targets in jobs:
|
||||
space = [dom[f] for f in free]
|
||||
if _size(space) > CAP_LINE:
|
||||
continue
|
||||
ok: dict[str, set] = {f: set() for f in free}
|
||||
for combo in itertools.product(*space):
|
||||
pick = {**base, **dict(zip(free, combo))}
|
||||
got = _eval(doc, links, pick, text, memo)
|
||||
if got == 0 or got in targets:
|
||||
for f, c in zip(free, combo):
|
||||
ok[f].add(c)
|
||||
if all(ok.values()):
|
||||
for f in free:
|
||||
ok[f] |= ev.get(f, set())
|
||||
dom[f] = [x for x in dom[f] if x in ok[f]]
|
||||
base[f] = dom[f][0]
|
||||
return dom
|
||||
|
||||
|
||||
def _money_syms(doc: dict) -> set[str]:
|
||||
return {str(x["기호"]) for x in doc.get("금액") or [] if x.get("기호")}
|
||||
|
||||
|
||||
def _size(space: list[list]) -> int:
|
||||
n = 1
|
||||
for s in space:
|
||||
n *= max(len(s), 1)
|
||||
return n
|
||||
|
||||
|
||||
def combos(doc: dict, dom: dict) -> list[dict]:
|
||||
"""좁힌 값 조합(기본 쪽 먼저 · 상한 `CAP_ALL`) — 한 값뿐인 변수는 그 값."""
|
||||
keys = [k for k, v in dom.items() if v]
|
||||
out = []
|
||||
for combo in itertools.product(*(dom[k] for k in keys)):
|
||||
out.append(dict(zip(keys, combo)))
|
||||
if len(out) >= CAP_ALL:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# ── ③ 줄 대조 ─────────────────────────────────────────────────────────
|
||||
def _ratio(a: Any, b: Any) -> str | None:
|
||||
if not isinstance(a, Decimal) or not isinstance(b, Decimal) or not a or not b:
|
||||
return None
|
||||
for n in NICE:
|
||||
if _key(a * n) == b:
|
||||
return f"× {n}"
|
||||
if _key(b * n) == a:
|
||||
return f"÷ {n}"
|
||||
return None
|
||||
|
||||
|
||||
def compare(mine: list[dict], ours: list[dict]) -> dict[str, Any]:
|
||||
"""가져온 줄(mine) ↔ M01 줄(ours) → {같음(로직 같은 줄), 짝, 핵심, 다른 [{항, 글}], 참고 [{항, 글}]}."""
|
||||
left, right = list(mine), list(ours)
|
||||
same = paired = 0
|
||||
diffs: list[dict] = []
|
||||
|
||||
def take(test) -> list[tuple[dict, dict]]:
|
||||
hits = []
|
||||
for a in list(left):
|
||||
b = next((x for x in right if test(a, x)), None)
|
||||
if b is not None:
|
||||
left.remove(a)
|
||||
right.remove(b)
|
||||
hits.append((a, b))
|
||||
return hits
|
||||
|
||||
def core(a: dict, b: dict) -> bool:
|
||||
return a["갈래"] != "율" and b["갈래"] != "율"
|
||||
|
||||
def cls(a: dict, b: dict) -> bool:
|
||||
return a["갈래"] == b["갈래"] or {a["갈래"], b["갈래"]} <= {"기계", "산출"}
|
||||
|
||||
notes: list[dict] = []
|
||||
exact = take(
|
||||
lambda a, b: (
|
||||
core(a, b)
|
||||
and a["수량"] == b["수량"]
|
||||
and a["단가"] == b["단가"]
|
||||
and a["비목"] == b["비목"]
|
||||
and (a["코드"] == b["코드"] or not (a["코드"] and b["코드"])) # 코드 다름 = 짝 대조
|
||||
and val.same_combo(a, b)
|
||||
)
|
||||
)
|
||||
same += len(exact)
|
||||
|
||||
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
|
||||
notes += ref
|
||||
bad, ref, n = val.pairs(take, core, cls, note.pair, _ratio) # 같은 자원 수량 다름 = 값(812-11)
|
||||
same, paired, diffs, notes = same + n, paired + len(bad), diffs + bad, notes + ref
|
||||
for a, b in take(lambda a, b: core(a, b) and cls(a, b) and a["단가"] == b["단가"]):
|
||||
paired += 1
|
||||
how = _ratio(a["수량"], b["수량"])
|
||||
what = "단위 환산" if how else "수량"
|
||||
say = {"항": what, "글": f"{a['이름']} 수량 {a['수량']} ↔ {b['수량']} {how or ''}"}
|
||||
diffs += note.pair(a, b)[0] or [say] # 같은 자원 아님 = 그 로직 까닭(비목 · 자원)
|
||||
for a, b in take(lambda a, b: core(a, b) and a["갈래"] == b["갈래"] == "기계"):
|
||||
paired += 1
|
||||
diffs.append(
|
||||
{"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']} · 수량 다름"}
|
||||
)
|
||||
rate_diffs, rate_notes = note.rate_pairs(left, right, take)
|
||||
diffs += rate_diffs
|
||||
notes += rate_notes
|
||||
for side, rows in (("가져온 쪽만", left), ("우리 쪽만", right)):
|
||||
for x in rows:
|
||||
if x["갈래"] == "율":
|
||||
what = "공구손료 유무" if "공구" in x["이름"] else "율 줄"
|
||||
else:
|
||||
what = "줄 없음"
|
||||
diffs.append({"항": what, "글": f"{side} — {x['이름']}"})
|
||||
n_core = sum(1 for a in mine if a["갈래"] != "율")
|
||||
swaps = [x.pop("바꿈") for x in notes if "바꿈" in x]
|
||||
return {
|
||||
"같음": same,
|
||||
"짝": same + paired,
|
||||
"핵심": n_core,
|
||||
"다른": diffs,
|
||||
"참고": notes,
|
||||
"바꿈": swaps,
|
||||
}
|
||||
|
||||
|
||||
def _totals(got: dict) -> dict:
|
||||
return {k: _key(mc.number(got.get(k))) for k in md.BUNDLE}
|
||||
|
||||
|
||||
EXPLAINED = (
|
||||
"이름 다름",
|
||||
"자원",
|
||||
"산출 단가",
|
||||
"율 대상",
|
||||
"단위 환산",
|
||||
"기계 규격",
|
||||
"비목",
|
||||
"율 줄",
|
||||
"공구손료 유무",
|
||||
)
|
||||
|
||||
|
||||
def verdict(
|
||||
mine: list[dict], ours: list[dict], got_a: dict, got_b: dict, off: list[dict] = ()
|
||||
) -> dict:
|
||||
"""확정 = 다른 항 0(옵션 글 근거까지 맞음 · 참고는 있어도 됨) · 근접 = 핵심 줄 반 넘게 짝 + (같은 줄
|
||||
있음 또는 다른 항이 모두 까닭 있는 갈래 — 자원 · 단위 환산 · 기계 규격 · 비목 · 율) · 그 밖 없음 ·
|
||||
끝수 = 줄 로직 · 단가 모두 같은데 합만 다름(참고)."""
|
||||
res = compare(mine, ours)
|
||||
res["다른"] = [*off, *res["다른"]]
|
||||
ta, tb = _totals(got_a), _totals(got_b)
|
||||
if not res["다른"] and not res["참고"] and ta != tb:
|
||||
res["참고"].append(
|
||||
{"항": "끝수", "글": f"줄은 같고 합이 다름 {_sum_txt(ta)} ↔ {_sum_txt(tb)}"}
|
||||
)
|
||||
if not res["다른"] and res["같음"]:
|
||||
res["판정"] = SURE
|
||||
elif res["짝"] * 2 >= res["핵심"] + 1 and (
|
||||
res["같음"] or all(d["항"] in EXPLAINED for d in res["다른"])
|
||||
):
|
||||
res["판정"] = NEAR
|
||||
else:
|
||||
res["판정"] = NONE
|
||||
return res
|
||||
|
||||
|
||||
def _sum_txt(t: dict) -> str:
|
||||
return " · ".join(f"{k} {note.txt(v)}" for k, v in t.items() if v is not None)
|
||||
|
||||
|
||||
# ── 한 문서 ───────────────────────────────────────────────────────────
|
||||
def _calc(doc: dict, links: Any, given: dict) -> dict | None:
|
||||
try:
|
||||
got = mc.calc(doc, links, given, (), getattr(links, "calls", None))
|
||||
except Exception: # noqa: BLE001 — 못 셈 = 그 조합 버림
|
||||
return None
|
||||
return got if got.get("셈됨") else None
|
||||
|
||||
|
||||
def try_doc(
|
||||
base: dict, links: Any, imported: dict, got_imp: dict, mine: list[dict], regions: set[str]
|
||||
) -> dict:
|
||||
"""M01 문서 하나 — 옵션 풀이 · 가장 나은 조합 판정(이름 · 절 근거 없으면 「이름 다름」 = 확정 아님)."""
|
||||
want = {
|
||||
"수량": {x["수량"] for x in mine if x["수량"] is not None},
|
||||
"단가": {x["단가"] for x in mine if x["단가"] is not None},
|
||||
}
|
||||
dom, ev = domains(base, imported, regions)
|
||||
dom = narrow(base, links, dom, want, ev)
|
||||
named = [] if same_name(imported, base) else [_unnamed(imported, base)]
|
||||
best: dict | None = None
|
||||
ties: list[tuple] = []
|
||||
for pick in combos(base, dom) or [{}]:
|
||||
got = _calc(base, links, _given(base, pick))
|
||||
if got is None:
|
||||
continue
|
||||
off = [_off(base, k, pick[k], ev[k]) for k in ev if k in pick and pick[k] not in ev[k]]
|
||||
off += named
|
||||
res = verdict(mine, facts(base, got), got_imp, got, off)
|
||||
values = sum(1 for x in res["참고"] if x["항"] in val.VALUE) if res["판정"] == SURE else 0
|
||||
rank = (
|
||||
res["판정"] == SURE,
|
||||
-len(off),
|
||||
-values, # 값 참고(규격 · 수량)가 적은 조합 먼저(812-11)
|
||||
res["같음"],
|
||||
res["짝"],
|
||||
-len(res["다른"]),
|
||||
-len(res["참고"]),
|
||||
)
|
||||
if best is None or rank > best["순위"]:
|
||||
best = {**res, "순위": rank, "옵션": pick, "금액": _totals(got)}
|
||||
ties.append((rank, pick))
|
||||
if res["판정"] == SURE and not res["참고"]:
|
||||
break
|
||||
if best and best["판정"] == SURE and any(x["항"] in val.VALUE[1:] for x in best["참고"]):
|
||||
same = [p for r, p in ties if r == best["순위"]] # 값으로 못 가른 조합(요구 733 ㉢)
|
||||
best["참고"] += val.option_check(base, best["옵션"], same, _qty_vars(base))
|
||||
return best or {
|
||||
"판정": NONE,
|
||||
"같음": 0,
|
||||
"짝": 0,
|
||||
"다른": [],
|
||||
"참고": [],
|
||||
"순위": (False, -9, 0, 0, 0, 0, 0),
|
||||
}
|
||||
|
||||
|
||||
def _qty_vars(doc: dict) -> set[str]:
|
||||
"""금액 줄 수량이 (거쳐서) 부르는 변수."""
|
||||
close = _closure(doc)
|
||||
return set().union(*(_uses(x.get("수량"), close) or set() for x in doc.get("금액") or []))
|
||||
|
||||
|
||||
def _off(doc: dict, vid: str, value: Any, seen: set) -> dict:
|
||||
"""옵션 값이 가져온 글 근거와 다름 — 다른 항 「옵션 글」."""
|
||||
var = next((v for v in doc.get("변수") or [] if md.var_id(v) == vid), {})
|
||||
name = var.get("이름") or vid
|
||||
return {"항": "옵션 글", "글": f"{name} 글 {'·'.join(map(str, seen))} ↔ {value}"}
|
||||
|
||||
|
||||
def match(imported: dict, links_imp: Any, index: Index, links: Any) -> dict[str, Any]:
|
||||
"""가져온 식 문서 → 연결후보 `{판정, 키, 이름, 옵션, 다른, 참고, 금액, 후보수}`."""
|
||||
links = links if isinstance(links, Memo) else Memo(links)
|
||||
imported = note.bare(imported) # 엑셀 이름 규격 · 단위 꼬리 뗌(814-1 · 맞대기만)
|
||||
got_imp = _calc(imported, links_imp, {})
|
||||
if got_imp is None:
|
||||
return {"판정": NONE, "까닭": "가져온 식을 우리 단가로 못 셈", "후보수": 0}
|
||||
mine = facts(imported, got_imp)
|
||||
alone = val.work(imported, mine, note._names)
|
||||
keys = index.candidates(imported)
|
||||
best, best_key = None, None
|
||||
for key in keys:
|
||||
res = try_doc(index.docs[key], links, imported, got_imp, mine, index.regions)
|
||||
if best is None or res["순위"] > best["순위"]:
|
||||
best, best_key = res, key
|
||||
if res["판정"] == SURE and not res["참고"]:
|
||||
break
|
||||
exact = best and best["판정"] == SURE and not {x["항"] for x in best["참고"]} & set(val.CHECK)
|
||||
if alone and not exact: # 기계 작업 — 우리 기초와 수량 · 규격까지 같을 때만 확정으로 붙임
|
||||
return alone
|
||||
if best is None:
|
||||
return {"판정": NONE, "후보수": 0}
|
||||
base = index.docs[best_key]
|
||||
if best["판정"] == NONE: # 가까운 후보(보이기만 — 단추 없음)
|
||||
near = {"키": best_key, "이름": base.get("이름"), "다른": best["다른"][:12]}
|
||||
return {"판정": NONE, "후보수": len(keys), "가까움": near}
|
||||
return {
|
||||
"판정": best["판정"],
|
||||
"키": best_key,
|
||||
"이름": base.get("이름"),
|
||||
"옵션": _shown(base, best["옵션"]),
|
||||
"다른": best["다른"],
|
||||
"참고": best["참고"],
|
||||
"바꿈": best.get("바꿈") or [],
|
||||
"금액": {"가져온": _plain(_totals(got_imp)), "우리": _plain(best["금액"])},
|
||||
"후보수": len(keys),
|
||||
}
|
||||
|
||||
|
||||
def _shown(doc: dict, pick: dict) -> dict[str, Any]:
|
||||
"""고른 옵션(기본과 다른 것) — {열쇠: 값}(수 = 수 · 선택 목록의 수 값과 같은 꼴 · SK 입력값)."""
|
||||
out = {}
|
||||
for v in doc.get("변수") or []:
|
||||
vid = md.var_id(v)
|
||||
if vid in pick and mc.plain_text(mc.number(pick[vid])) != mc.plain_text(
|
||||
mc.number((v.get("연결") or {}).get("값"))
|
||||
):
|
||||
out[str(v.get("열쇠") or vid)] = ml._norm(mc.number(pick[vid]))
|
||||
return out
|
||||
|
||||
|
||||
_STOP = {"설치", "해체", "인력", "기계", "작업", "운반", "타설", "제작"}
|
||||
|
||||
|
||||
def _grams(texts: list[Any]) -> set[str]:
|
||||
"""이름 낱말 — 한글 두 글자 묶음 · 영문 · 수 낱말(일반 낱말 뺌)."""
|
||||
out: set[str] = set()
|
||||
for t in texts:
|
||||
t = re.sub(r"\s|및", "", str(t or ""))
|
||||
for run in re.findall(r"[가-힣]+", t):
|
||||
out |= {run[i : i + 2] for i in range(len(run) - 1)}
|
||||
out |= {w.lower() for w in re.findall(r"[A-Za-z]{2,}", t)}
|
||||
return out - _STOP
|
||||
|
||||
|
||||
def same_name(imported: dict, base: dict) -> bool:
|
||||
"""뜻 근거 — 품셈 절 번호가 같거나 이름 낱말이 겹침(값만 같은 남의 기초를 확정으로 안 묶음)."""
|
||||
secs = sections([x.get("글") for x in (imported.get("가져온") or {}).get("추정") or []])
|
||||
if secs & sections([base.get("출처")]):
|
||||
return True
|
||||
return bool(_grams([imported.get("이름")]) & _grams([base.get("이름")]))
|
||||
|
||||
|
||||
def _unnamed(imported: dict, base: dict) -> dict:
|
||||
return {"항": "이름 다름", "글": f"{imported.get('이름')} ↔ {base.get('이름')}"}
|
||||
|
||||
|
||||
def _plain(d: dict) -> dict:
|
||||
return {k: (None if v is None else float(v)) for k, v in d.items()}
|
||||
@@ -1,150 +0,0 @@
|
||||
"""가져온 식 SK ↔ 우리 M01 기초 — 연결 찾기 기록 · 「확정」 때만 우리 기초 사본으로 바꾸기 · 되돌리기
|
||||
(PLAN 810-4 · 요구 707 · 708 · 709 · 703) — 서버 단독.
|
||||
|
||||
찾기(`find`) = 가져온 식 문서(우리 단가로 셈) → `M02_Doc_Match.match` → SK `가져온.원본.연결후보` 에 적음.
|
||||
바꾸기(`swap`) = 서버가 다시 찾아 「확정」 일 때만 — 같은 SK 키에 M01 사본 SK(바탕 = 그 기초 ·
|
||||
입력값 = 찾은 옵션 · 802-4a 꼴 · 수식 사본은 저장 때 서버가 찍음)를 씀 · 원래 가져온 식 SK 몸은
|
||||
`가져온보관` 칸에 그대로 둠 · 일위대가 줄 참조 = 키 그대로(SK 내용만 바뀜 → 금액 다시 셈).
|
||||
+ 찾은 `바꿈`(같은 기종 규격만 다른 기계 줄 · 812-11 · 요구 733-2) = 사본 문서 그 줄 기계 입력(원문번호)을
|
||||
가져온 규격의 우리 기계로 갈아 끼움(수식 길 저장 = 따로) · 품 · 율 · 단가 · 조건 값은 우리 값 그대로 ·
|
||||
기계가 옵션 기호 · 식이면 안 바꿈.
|
||||
되돌리기(`back`) = `가져온보관` 몸을 그 키에 다시 씀(가져온 식 금액 그대로 · 수식 사본도 보관 몸 그대로).
|
||||
우리 M01 기초는 어느 갈래에서도 안 바뀜(708) · 근접 · 없음 · 기계 작업 = 바꾸기 409(703).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy as cp
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_time import utc_now_text
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
from M02_MasterTemplete import M02_Doc_Import as dim
|
||||
from M02_MasterTemplete import M02_Doc_Match as dm
|
||||
from M02_MasterTemplete import M02_Doc_Sk as dsk
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
||||
|
||||
KEEP = "가져온보관" # 바꾼 SK 의 원래 가져온 식 몸(되돌리기)
|
||||
_INDEX: dict[str, Any] = {"문서": None, "색인": None}
|
||||
|
||||
|
||||
def _bad(why: str, status: int = 400) -> store.StoreError:
|
||||
return store.StoreError(status, why)
|
||||
|
||||
|
||||
def index() -> dm.Index:
|
||||
"""M01 수식 문서 색인 — 폴더가 바뀌면(M01 관리자 고침) 다시."""
|
||||
docs = dc.folder()
|
||||
if _INDEX["문서"] is not docs:
|
||||
aims = {k: d for k, d in docs.items() if not d.get("부품")} # 부품 문서 = 과녁 아님(813-2)
|
||||
_INDEX["문서"], _INDEX["색인"] = docs, dm.Index(aims)
|
||||
return _INDEX["색인"]
|
||||
|
||||
|
||||
def _read(key: str) -> dict[str, Any]:
|
||||
if not sd.KEY.match(str(key or "")):
|
||||
raise _bad("키(SK-######)")
|
||||
return store.read(sd.KIND, key)
|
||||
|
||||
|
||||
def judge(sk: dict) -> dict[str, Any]:
|
||||
"""가져온 식 SK → 연결후보(저장 없음)."""
|
||||
whole = imp._whole()
|
||||
links_imp = dim.Links(whole, "우리")
|
||||
doc = dim.of(sk)
|
||||
links_imp.learn(doc)
|
||||
got = dm.match(doc, links_imp, index(), dm.ml.Links(whole, dc.folder()))
|
||||
from M02_MasterTemplete import M02_San_Link as san_link # 우리 정보로 셈 줄 수(814-1 띠)
|
||||
|
||||
return {**got, "우리정보": san_link.tally(sk[imp.KEY].get("로직") or {}), "때": utc_now_text()}
|
||||
|
||||
|
||||
def find(key: str, version: str) -> dict[str, Any]:
|
||||
"""연결 찾기 → SK `가져온.원본.연결후보` 에 적음 → {연결후보, 판}."""
|
||||
got = _read(key)
|
||||
sk = got["문서"]
|
||||
if not dim.is_imported(sk):
|
||||
raise _bad("가져온 식 단가산출만 연결을 찾음")
|
||||
found = judge(sk)
|
||||
meta = sk[imp.KEY]
|
||||
new = {**sk, imp.KEY: {**meta, "원본": {**(meta.get("원본") or {}), dm.KEY: found}}}
|
||||
info = store.write(sd.KIND, key, version or got["판"], new)
|
||||
return {dm.KEY: found, "판": info.get("판")}
|
||||
|
||||
|
||||
def copy_body(sk: dict, found: dict) -> dict[str, Any]:
|
||||
"""M01 사본 SK 몸(이름 · 단위 · 구분 그대로 · 바탕 = 찾은 기초 · 입력값 = 찾은 옵션)."""
|
||||
keep = {k: sk.get(k) for k in ("키", "이름", "단위", "구분", "상세구분") if sk.get(k)}
|
||||
old = {k: v for k, v in sk.items() if k not in ("키", "종류", KEEP)}
|
||||
body = {
|
||||
"덮개": {},
|
||||
"뺀줄": [],
|
||||
"더한줄": [],
|
||||
"고름": {},
|
||||
"자재표시": {},
|
||||
} # M01 그대로(고침 없음)
|
||||
base = {"바탕": {"로직": found["키"]}, "입력값": dict(found.get("옵션") or {})}
|
||||
return {**keep, **body, **base, KEEP: old}
|
||||
|
||||
|
||||
def swap(key: str, version: str) -> dict[str, Any]:
|
||||
"""「확정」 일 때만 우리 기초 사본으로 — 서버가 다시 찾음(화면 판정을 믿지 않음)."""
|
||||
got = _read(key)
|
||||
sk = got["문서"]
|
||||
if not dim.is_imported(sk):
|
||||
raise _bad("가져온 식 단가산출만 바꿈")
|
||||
found = judge(sk)
|
||||
if found.get("판정") != dm.SURE:
|
||||
raise _bad(f"우리 기초와 「확정」 일치가 아님({found.get('판정')}) — 바꾸지 않음", 409)
|
||||
info = store.write(sd.KIND, key, version or got["판"], copy_body(sk, found))
|
||||
done = refit(key, found.get("바꿈") or [])
|
||||
return {dm.KEY: found, "판": done[0] or info.get("판"), "바꾼기계": done[1]}
|
||||
|
||||
|
||||
def refit(key: str, swaps: list[dict]) -> tuple[str | None, list[dict]]:
|
||||
"""사본 문서 기계 줄 → 가져온 규격 우리 기계(그 줄 단가 기호 연결의 입력 「기계」 · 줄 이름 · 규격) →
|
||||
수식 길 저장 · (판, 바꾼 것)."""
|
||||
from M02_MasterTemplete import M02_Doc_Sk_Path as path # 수식 길 — 늦게(돌아 부름 피함)
|
||||
|
||||
if not swaps:
|
||||
return None, []
|
||||
got = path.read(key)
|
||||
doc = cp.deepcopy(got["문서"])
|
||||
done = []
|
||||
for one in swaps:
|
||||
var = next((v for v in doc.get("변수") or [] if dm.md.var_id(v) == one["기호"]), None)
|
||||
link = (var or {}).get("연결") or {}
|
||||
given = link.get("입력") or {}
|
||||
if link.get("갈래") != "단가산출" or not re.fullmatch(r"'[^']+'", str(given.get("기계"))):
|
||||
continue
|
||||
link["입력"] = {**given, "기계": f"'{one['기계']}'"}
|
||||
for line in doc.get("금액") or []:
|
||||
if dm._single(line.get("단가")) == one["기호"]:
|
||||
if line.get("묶음") == line.get("이름"): # 그 줄 이름을 단 묶음 제목도
|
||||
line["묶음"] = f"{one['이름']} {one['규격']}".strip()
|
||||
line["이름"], line["규격"] = one["이름"], one["규격"]
|
||||
done.append(one)
|
||||
if not done:
|
||||
return None, []
|
||||
return path.save(key, {"문서": doc, "판": got["판"]})["판"], done
|
||||
|
||||
|
||||
def back(key: str, version: str) -> dict[str, Any]:
|
||||
"""우리 기초 사본 → 원래 가져온 식(보관 몸 그대로)."""
|
||||
got = _read(key)
|
||||
old = got["문서"].get(KEEP)
|
||||
if not isinstance(old, dict) or not old.get(imp.KEY):
|
||||
raise _bad("되돌릴 가져온 식 없음")
|
||||
keep = (
|
||||
old.get(dc.KEY) if dc.has(old) else None
|
||||
) # 보관 몸 수식 사본 그대로(바꾼 기계 = 따로 버림)
|
||||
token = dsk.SAVING.set({"키": key, "사본": keep}) if keep else None
|
||||
try:
|
||||
info = store.write(sd.KIND, key, version or got["판"], {**old, "키": key})
|
||||
finally:
|
||||
if token is not None:
|
||||
dsk.SAVING.reset(token)
|
||||
return {"판": info.get("판")}
|
||||
@@ -1,256 +0,0 @@
|
||||
"""연결 찾기 — 로직 차이(다른 항) 대 값 차이(참고) 가르기(PLAN 812-2 · 요구 712) — `M02_Doc_Match` 가 부름.
|
||||
|
||||
확정을 막는 「다른 항」 = 로직 차이만 — 자원 갈래 · 기종 · 노임 종류 · 자재 이름 종류 · 비목 · 산출 단가 ·
|
||||
율 구조(있고 없음 · 대상 비목 — 율 값 % 는 참고) · 단위 환산(`M02_Doc_Match_Value`).
|
||||
「참고」 = 값 차이 — 인력 · 재료 · 기계 시간당 단가 · 끝수(합만 다름) · 율 값(%) · 같은 기종 규격 · 수량(812-11) ·
|
||||
그로 인한 비목 금액 · 계.
|
||||
율 = 율 줄 셈값 ÷ 대상 금액 합 — 대상 = 율 식이 (거쳐서) 부르는 금액 줄 · 비목 이름이면 그 비목 몫 · 번지 합 = 핵심 줄 전부 ·
|
||||
대상 금액 끝수 자리 차이만 지움(유효 6 자리 · 두 바탕 중 짧은 꼴) — 지어 맞추기 아님.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
from M02_MasterTemplete import M02_Doc_Match_Value as val
|
||||
from M02_MasterTemplete import M02_Machine_Find as mfind
|
||||
|
||||
md = dc.md
|
||||
PRICED = ("노임", "재료", "기계") # 시간당 · 단위 단가 = 값(참고)
|
||||
|
||||
|
||||
# ── 율 구조 ───────────────────────────────────────────────────────────
|
||||
def _names(text: Any) -> set[str]:
|
||||
if text in (None, ""):
|
||||
return set()
|
||||
try:
|
||||
return set(md.names(md.parse(text)))
|
||||
except md.DocError:
|
||||
return set()
|
||||
|
||||
|
||||
def _reach(doc: dict) -> dict[str, set[str]]:
|
||||
"""기호 → 바로 부르는 이름(변수 연결 · 식 줄 · 금액 줄 수량 · 단가)."""
|
||||
direct: dict[str, set[str]] = {}
|
||||
for v in doc.get("변수") or []:
|
||||
direct[md.var_id(v)] = set(md.link_names(v.get("연결") or {}))
|
||||
for line in doc.get("식") or []:
|
||||
direct[str(line.get("기호"))] = _names(line.get("식"))
|
||||
for line in doc.get("금액") or []:
|
||||
if line.get("기호"):
|
||||
direct[str(line["기호"])] = _names(line.get("수량")) | _names(line.get("단가"))
|
||||
return direct
|
||||
|
||||
|
||||
def _close(start: set[str], direct: dict[str, set[str]]) -> set[str]:
|
||||
seen, todo = set(start), list(start)
|
||||
while todo:
|
||||
for n in direct.get(todo.pop(), ()):
|
||||
if n not in seen:
|
||||
seen.add(n)
|
||||
todo.append(n)
|
||||
return seen
|
||||
|
||||
|
||||
def _short(r: Decimal) -> Decimal:
|
||||
return Decimal(f"{r:.6g}").normalize()
|
||||
|
||||
|
||||
def rates(doc: dict, got: dict, facts: list[dict]) -> None:
|
||||
"""율 사실에 `율`(비율 · 못 셈 None) · `대상`({(자원 갈래, 비목)})을 채움(제자리)."""
|
||||
money, rows = doc.get("금액") or [], got.get("줄") or []
|
||||
direct = _reach(doc)
|
||||
core = [f for f in facts if f["갈래"] != "율" and not f["합밖"]]
|
||||
for f in facts:
|
||||
if f["갈래"] != "율":
|
||||
continue
|
||||
line, row = money[f["번"]], rows[f["번"]]
|
||||
reach = _close(_names(line.get("수량")) | _names(line.get("단가")), direct)
|
||||
costs = reach & set(md.COSTS)
|
||||
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(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))
|
||||
try:
|
||||
return Decimal(str(v)) if v is not None else Decimal(0)
|
||||
except InvalidOperation:
|
||||
return Decimal(0)
|
||||
|
||||
|
||||
def _mul(q: Any, p: Any) -> Decimal | None:
|
||||
if q is None or p is None:
|
||||
return None
|
||||
return _num(q) * _num(p)
|
||||
|
||||
|
||||
def pct(r: Any) -> str:
|
||||
"""비율 → 「9 %」."""
|
||||
if not isinstance(r, Decimal):
|
||||
return "?"
|
||||
return f"{(r * 100).normalize():f} %"
|
||||
|
||||
|
||||
# ── 짝 줄 ─────────────────────────────────────────────────────────────
|
||||
def txt(v: Any) -> str:
|
||||
"""단가 글 — 수 = 원 단위 쉼표 · 기계 묶음 = 노 · 재 · 경."""
|
||||
if isinstance(v, tuple):
|
||||
return " · ".join(f"{k[0]} {txt(x)}" for k, x in zip(md.COSTS, v) if x)
|
||||
if isinstance(v, Decimal):
|
||||
return f"{v.normalize():,f}"
|
||||
return str(v)
|
||||
|
||||
|
||||
def _plain(text: str) -> str:
|
||||
"""표기 정규화(소리 표기 · 빈칸) — 기계 찾기와 같은 규칙(812-9 `M02_Machine_Find.norm`)."""
|
||||
return mfind.norm(text)
|
||||
|
||||
|
||||
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:
|
||||
"""같은 기종 — 기계표 · 별칭 장부로 둘 다 기종을 찾으면 그 판정(812-9 · 박힌 짝 없음) ·
|
||||
못 찾으면 이름 머리 품음."""
|
||||
got = mfind.same_kind(a, b)
|
||||
if got is not None:
|
||||
return got
|
||||
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] = []
|
||||
spec: list[dict] = [] # 같은 기종 규격만 다름 = 값(812-11 · 요구 733-2)
|
||||
kinds = {a["갈래"], b["갈래"]}
|
||||
# 기계경비 숫자 묶음(코드 없음 · 원파일 값) ↔ 우리 기계 = 이름 종류로 같은 기계인지
|
||||
loose = kinds == {"기계", "산출"} and not (a if a["갈래"] == "산출" else b)["코드"]
|
||||
combo = not val.same_combo(a, b) # 조합 기계 ↔ 낱 기계 = 다른 기종
|
||||
if a["갈래"] == b["갈래"] == "기계" and (a["코드"] != b["코드"] or combo):
|
||||
if not combo and val.same_machine(a, b):
|
||||
spec.append(val.spec_note(a, b))
|
||||
else:
|
||||
diffs.append(
|
||||
{"항": "기계 규격", "글": f"{a['이름']} {a['코드']} ↔ {b['코드']} — 다른 기종"}
|
||||
)
|
||||
elif loose and not _same_machine(a["이름"], b["이름"]):
|
||||
diffs.append({"항": "기계 규격", "글": f"{a['이름']} ↔ {b['이름']} {b['코드']}"})
|
||||
elif loose:
|
||||
spec += [x for x in (val.loose_note(a, b),) if x]
|
||||
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['이름']}"})
|
||||
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, spec
|
||||
say = f"{a['이름']} 단가 {txt(a['단가'])} ↔ {txt(b['단가'])}"
|
||||
if (a["갈래"] == b["갈래"] and a["갈래"] in PRICED) or loose:
|
||||
return diffs, [*spec, {"항": "단가 차이", "글": say}]
|
||||
return [*diffs, {"항": "산출 단가", "글": say}], spec
|
||||
|
||||
|
||||
def _same_stuff(a: dict, b: dict) -> bool:
|
||||
"""자재 종류 같음 — 두 코드가 있으면 코드 · 없으면 이름 낱말(한글 두 글자) 겹침."""
|
||||
if a["코드"] and b["코드"] and a["코드"] == b["코드"]:
|
||||
return True
|
||||
return bool(_grams(a["이름"]) & _grams(b["이름"]))
|
||||
|
||||
|
||||
def rate_pairs(left: list[dict], right: list[dict], take: Any) -> tuple[list[dict], list[dict]]:
|
||||
"""율 줄 짝 → (다른 항, 참고) — 구조 = 대상 · 비목 · 같으면 로직 같음(율 값 % 다름 = 참고 · 값은 업데이트됨)."""
|
||||
diffs: list[dict] = []
|
||||
notes: list[dict] = []
|
||||
|
||||
def is_rate(a: dict, b: dict) -> bool:
|
||||
return a["갈래"] == b["갈래"] == "율"
|
||||
|
||||
def same(a: dict, b: dict, *keys: str) -> bool:
|
||||
return is_rate(a, b) and all(
|
||||
(set(a[k]) == set(b[k])) if k == "비목" else a.get(k) == b.get(k) for k in keys
|
||||
)
|
||||
|
||||
def name(a: dict, b: dict) -> str:
|
||||
return f"{a['이름']} {pct(a.get('율'))} ↔ {b['이름']} {pct(b.get('율'))}"
|
||||
|
||||
def target(x: dict) -> str:
|
||||
return "·".join(f"{k} {c}" for k, c in sorted(x.get("대상") or ())) or "없음"
|
||||
|
||||
def value(a: dict, b: dict) -> None:
|
||||
if a.get("율") != b.get("율"):
|
||||
notes.append({"항": "율 값", "글": f"{name(a, b)} — 바꾸면 우리 값"})
|
||||
|
||||
take(lambda a, b: same(a, b, "율", "대상", "비목")) # 로직 · 값 같음
|
||||
for a, b in take(lambda a, b: same(a, b, "대상", "비목")): # 로직 같음 · 율 값만 다름
|
||||
value(a, b)
|
||||
for a, b in take(lambda a, b: same(a, b, "대상")):
|
||||
tool = val.tool_cost(a, b) # 공구손료 비목만 다름 = 값(814-1)
|
||||
(notes if tool else diffs).append(
|
||||
tool or {"항": "비목", "글": f"율 줄 {name(a, b)} 비목 다름"}
|
||||
)
|
||||
value(a, b)
|
||||
for a, b in take(lambda a, b: same(a, b, "율")):
|
||||
diffs.append({"항": "율 대상", "글": f"{name(a, b)} 대상 {target(a)} ↔ {target(b)}"})
|
||||
for a, b in take(is_rate):
|
||||
diffs.append({"항": "율 대상", "글": f"{name(a, b)} 대상 {target(a)} ↔ {target(b)}"})
|
||||
return diffs, notes
|
||||
|
||||
|
||||
# ── 이름 꼬리(814-1 · 요구 743-3) ─────────────────────────────────────────
|
||||
def bare(doc: dict) -> dict:
|
||||
"""엑셀 단가산출 이름의 규격 · 단위 꼬리(「구조물터파기(인력) 경질토사 / M3」) 뗀 맞댈 문서 —
|
||||
보이는 · 저장 이름은 그대로(연결 찾기만 이 사본을 씀)."""
|
||||
name = str(doc.get("이름") or "")
|
||||
unit = re.sub(r"^원\s*/\s*", "", str(doc.get("결과단위") or "")).strip()
|
||||
spec = str((doc.get("가져온") or {}).get("규격") or "").strip()
|
||||
got = re.sub(rf"\s*/\s*{re.escape(unit)}\s*$", "", name) if unit else name
|
||||
if got != name and spec and got.rstrip().endswith(spec) and got.rstrip() != spec:
|
||||
got = got.rstrip()[: -len(spec)]
|
||||
got = got.strip()
|
||||
return doc if not got or got == name else {**doc, "이름": got}
|
||||
@@ -1,233 +0,0 @@
|
||||
"""연결 찾기 값 기준 넓힘(PLAN 812-11 · 요구 714 · 733 · 734 · 735) — `M02_Doc_Match` 가 부름.
|
||||
|
||||
값(참고 · 확정을 안 막음 · 바꾸면 우리 값):
|
||||
같은 기종 규격만 다름(굴착기 0.7㎥ ↔ 1.0㎥ — 기종 = 기계표 이름 머리 + 괄호 낱말 · 무한궤도 대 타이어 = 다른 기종 ·
|
||||
조합 기계는 구성 줄마다) = 「규격」(바꾸면 가져온 규격 — [바꾸기] 때 그 줄 기계를 갈아 끼움 `바꿈`) ·
|
||||
수량 차이(같은 자원 줄) = 「수량 끝자리」(1 % 안) · 「품 숫자」(가져온 수량 = 수 하나) · 「수량」(조건 값 · 비율 · 표 값).
|
||||
로직(다른 항): 자원 줄 구성(갈래 · 기종 · 노임 직종 · 재료 종류 · 비목) · 줄 없음 · 율 구조 · 단위 환산 =
|
||||
같은 배(× 6 등)가 두 줄 넘게 같이 걸림(결과 단위 기준이 다름) — 한 줄만 배가 맞는 것은 수량 값.
|
||||
기계 작업(`work`) = 가져온 문서가 기계 한 묶음(한 기계 줄 · 조합 기계면 그 구성 줄들)뿐 · 수량이 작업량 식(Q) =
|
||||
우리 기초에 안 붙임(요구 728 · 735-4 — 수량 · 규격까지 같은 우리 기초가 있을 때만 확정) · 공식 꼴이 그 기종
|
||||
「작업량 틀」(813-2 부품)과 같으면 참고에 틀 이름.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Machine_Find as mfind
|
||||
|
||||
WORK = "기계 작업"
|
||||
SPEC, QTY, TAIL, POOM = "규격", "수량", "수량 끝자리", "품 숫자"
|
||||
VALUE = (SPEC, QTY, TAIL, POOM) # 확정 순위 — 값 참고가 적은 조합 · 기초 먼저
|
||||
CHECK = (*VALUE, "옵션 확인") # 기계 작업 꼴이 확정으로 붙지 않는 참고
|
||||
_NUMBER = re.compile(r"^\s*\d+(?:\.\d+)?\s*$")
|
||||
|
||||
|
||||
# ── 공구손료 율 비목(814-1 · 요구 744 C1) ─────────────────────────────────
|
||||
TOOL = "공구손료 비목"
|
||||
|
||||
|
||||
def tool_cost(a: dict, b: dict) -> dict | None:
|
||||
"""공구손료 율 줄 짝 — 대상은 같고 비목만 다름(실무 재료비 ↔ 우리 경비) = 값(참고 · 확정 안 막음 ·
|
||||
[바꾸기] = 우리 기초 사본 = 우리 비목) · 공구손료 아님 = None."""
|
||||
if "공구" not in str(a.get("이름")) + str(b.get("이름")):
|
||||
return None
|
||||
say = f"{a['이름']} 비목 {'·'.join(a['비목'])} ↔ {'·'.join(b['비목'])} — 바꾸면 우리 비목"
|
||||
return {"항": TOOL, "글": say}
|
||||
|
||||
|
||||
# ── 기종 ──────────────────────────────────────────────────────────────
|
||||
def row_of(code: Any) -> Any:
|
||||
"""원문번호 → 기계표 줄(`M02_Machine_Find.Row`) · 없음 None."""
|
||||
key = mfind.by_code(code)
|
||||
return mfind.table()[2].get(key) if key else None
|
||||
|
||||
|
||||
def same_kind(a: Any, b: Any) -> bool:
|
||||
"""두 원문번호 = 같은 기종 — 이름 머리 + 괄호 낱말 같음(규격만 다름)."""
|
||||
x, y = row_of(a), row_of(b)
|
||||
return bool(x and y) and x.base == y.base and x.words == y.words
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _combo(name: str) -> tuple | None:
|
||||
"""조합 기계 이름 → 구성 기종 묶음(정렬) · 낱 기계 = None."""
|
||||
got = mfind.parts(name)
|
||||
return tuple(sorted(mfind.split_name(n)[0] for n, _ in got)) if len(got) > 1 else None
|
||||
|
||||
|
||||
def mark_combo(doc: dict, facts: list[dict]) -> None:
|
||||
"""기계 사실에 `조합`(구성 묶음 · 낱 = None) · `갈라짐`(가져온 조합을 둘로 가른 줄 = 낱 기계로도 맞음)."""
|
||||
head = _machines(doc)
|
||||
for f in facts:
|
||||
if f["갈래"] in ("기계", "산출"):
|
||||
f["조합"] = _combo(f["이름"])
|
||||
f["갈라짐"] = any(head.get(str(f.get("기호")), {}).get(k) for k in ("조합", "조합첫"))
|
||||
|
||||
|
||||
def same_combo(a: dict, b: dict) -> bool:
|
||||
"""조합 여부 같음 — 구성 묶음 같음 · 가른 줄 ↔ 낱 기계도 같음(「굴착기+부착용 집게」 ↔ 굴착기 하나 = 다름)."""
|
||||
x, y = a.get("조합"), b.get("조합")
|
||||
return x == y or (a.get("갈라짐") and y is None) or (b.get("갈라짐") and x is None)
|
||||
|
||||
|
||||
def same_machine(a: dict, b: dict) -> bool:
|
||||
"""같은 기종 기계 줄 — 원문번호 기종 같음 + 조합 구성 같음."""
|
||||
return same_kind(a["코드"], b["코드"]) and same_combo(a, b)
|
||||
|
||||
|
||||
def _eq(code: Any) -> str:
|
||||
r = row_of(code)
|
||||
return f"{r.raw.get('이름')} {r.raw.get('규격') or ''}".strip() if r else str(code)
|
||||
|
||||
|
||||
def spec_note(a: dict, b: dict) -> dict:
|
||||
"""같은 기종 규격 다름 → 참고 「규격」 + 바꿈(우리 줄 단가 기호 → 가져온 원문번호)."""
|
||||
ra = row_of(a["코드"])
|
||||
say = f"규격 다름 {_eq(a['코드'])} ↔ {(row_of(b['코드']).raw.get('규격') or '')}"
|
||||
swap = {"기호": b.get("기호"), "기계": a["코드"], "이름": ra.raw.get("이름"),
|
||||
"규격": ra.raw.get("규격") or ""} # fmt: skip
|
||||
return {"항": SPEC, "글": f"{say} — 바꾸면 가져온 규격", "바꿈": swap}
|
||||
|
||||
|
||||
def loose_note(a: dict, b: dict) -> dict | None:
|
||||
"""못 이은 가져온 기계(원파일 묶음 값) ↔ 우리 기계 — 규격 글이 다르면 「안 바꿈」 참고."""
|
||||
loose, ours = (a, b) if a["갈래"] == "산출" else (b, a)
|
||||
row = row_of(ours["코드"])
|
||||
if not row or not loose.get("규격"):
|
||||
return None
|
||||
if mfind.spec_fit(mfind.tokens(loose["규격"]), row.spec) == "같음":
|
||||
return None
|
||||
say = f"규격 다름 {loose['이름']} {loose['규격']} ↔ {_eq(ours['코드'])}"
|
||||
return {
|
||||
"항": SPEC,
|
||||
"글": f"{say} — 가져온 기계를 우리 표에서 못 찾음 · 바꾸면 우리 기계 그대로",
|
||||
}
|
||||
|
||||
|
||||
# ── 수량 ──────────────────────────────────────────────────────────────
|
||||
def _show(v: Any) -> str:
|
||||
return f"{v:.4g}" if isinstance(v, Decimal) else str(v)
|
||||
|
||||
|
||||
def qty_note(a: dict, b: dict) -> dict:
|
||||
"""같은 자원 줄 수량 다름 → 참고(끝자리 · 품 숫자 · 수량)."""
|
||||
x, y = a["수량"], b["수량"]
|
||||
if isinstance(x, Decimal) and isinstance(y, Decimal) and y and abs(x - y) <= abs(y) / 100:
|
||||
what = TAIL
|
||||
elif _NUMBER.match(str(a.get("수량글") or "")):
|
||||
what = POOM
|
||||
else:
|
||||
what = QTY
|
||||
return {"항": what, "글": f"{a['이름']} 수량 다름 {_show(x)} ↔ {_show(y)} — 바꾸면 우리 값"}
|
||||
|
||||
|
||||
def pairs(
|
||||
take: Any, core: Any, cls: Any, pair: Any, ratio: Any
|
||||
) -> tuple[list[dict], list[dict], int]:
|
||||
"""수량 다른 같은 자원 짝(로직 같음 · `pair` 다른 항 0) → (다른 항 = 단위 환산 무리, 참고, 같은 줄 수) ·
|
||||
`ratio` = 단위 환산 배 글(「× 6」) · 없음 None."""
|
||||
got = take(
|
||||
lambda a, b: core(a, b) and cls(a, b) and a["수량"] != b["수량"] and not pair(a, b)[0]
|
||||
)
|
||||
hows = [ratio(a["수량"], b["수량"]) for a, b in got]
|
||||
many = {
|
||||
h for h in hows if h and hows.count(h) >= 2
|
||||
} # 같은 배가 두 줄 넘게 = 결과 단위 기준 다름
|
||||
diffs: list[dict] = []
|
||||
notes: list[dict] = []
|
||||
for (a, b), how in zip(got, hows):
|
||||
if how in many:
|
||||
diffs.append(
|
||||
{"항": "단위 환산", "글": f"{a['이름']} 수량 {a['수량']} ↔ {b['수량']} {how}"}
|
||||
)
|
||||
else:
|
||||
notes.append(qty_note(a, b))
|
||||
notes += pair(a, b)[1]
|
||||
return diffs, notes, len(got) - sum(1 for h in hows if h in many)
|
||||
|
||||
|
||||
# ── 기계 작업 ─────────────────────────────────────────────────────────
|
||||
def _machines(doc: dict) -> dict[str, dict]:
|
||||
head = (doc.get("가져온") or {}).get("기계")
|
||||
return {str(x.get("기호")): x for x in head or [] if isinstance(x, dict)}
|
||||
|
||||
|
||||
def _one_group(rows: list[dict]) -> bool:
|
||||
if len(rows) == 1:
|
||||
return True
|
||||
return sum(1 for x in rows if x.get("조합첫")) == 1 and all(
|
||||
x.get("조합") or x.get("조합첫") for x in rows
|
||||
)
|
||||
|
||||
|
||||
def _form(text: Any) -> str:
|
||||
"""작업량 식 꼴 — 끝수 함수 벗김 · 빈칸 뺌 · 작은 글자(Cm = cm)."""
|
||||
t = re.sub(r"\s", "", str(text or ""))
|
||||
m = re.match(r"^(?:반올림|버림|올림)\((.*),\d+\)$", t)
|
||||
return (m.group(1) if m else t).lower()
|
||||
|
||||
|
||||
def _q_form(doc: dict, names: set[str]) -> str:
|
||||
for line in doc.get("식") or []:
|
||||
if str(line.get("기호")) in names and str(line.get("기호")).lower().startswith("q"):
|
||||
return _form(line.get("식"))
|
||||
return ""
|
||||
|
||||
|
||||
def work(doc: dict, mine: list[dict], names: Any) -> dict | None:
|
||||
"""가져온 문서가 기계 한 묶음 + 작업량 식 수량이면 {판정 기계 작업, 참고} · 아니면 None
|
||||
(`names` = 식 글 → 쓰는 이름 · 인력 · 재료 · 율 줄이 섞이면 보통 길)."""
|
||||
heads = _machines(doc)
|
||||
if not mine or not heads or any(f["갈래"] == "율" for f in mine):
|
||||
return None
|
||||
rows = [f for f in mine if not f["합밖"]]
|
||||
if not rows or any(
|
||||
str(f.get("기호")) not in heads or f["갈래"] not in ("기계", "산출") for f in rows
|
||||
):
|
||||
return None
|
||||
if not _one_group([heads[str(f["기호"])] for f in rows]):
|
||||
return None
|
||||
used = [names(f.get("수량글")) for f in rows]
|
||||
if not all(used): # 수량이 수뿐 = 작업량 식 아님
|
||||
return None
|
||||
notes = []
|
||||
form = _q_form(doc, set().union(*used))
|
||||
from M01_MasterData import M01_MasterData_Parts as parts # M02 가 M01 을 부름 — 늦게
|
||||
|
||||
for f in rows:
|
||||
if not form or not row_of(f["코드"]):
|
||||
continue
|
||||
got = parts.of_machine(str(f["코드"]))
|
||||
for t in got["틀"]:
|
||||
if any(
|
||||
_form(x.get("식")) == form
|
||||
for x in t["문서"].get("식") or []
|
||||
if x.get("기호") == "Q"
|
||||
):
|
||||
say = f"{got['기계']['이름']} — 작업량 공식 꼴 = {t['키']} {t['이름']}"
|
||||
notes.append({"항": "작업량 틀", "글": say})
|
||||
break
|
||||
return {"판정": WORK, "참고": notes, "후보수": 0}
|
||||
|
||||
|
||||
# ── 옵션 확인 ─────────────────────────────────────────────────────────
|
||||
def option_check(doc: dict, pick: dict, same: list[dict], qty_vars: set[str]) -> list[dict]:
|
||||
"""값이 정확히 맞는 조합이 없고 수량을 정하는 선택이 값으로 안 갈림(같은 순위 조합에 값 여럿) =
|
||||
고른 값(기본 먼저) + 참고 「옵션 확인」."""
|
||||
from M02_MasterTemplete.M02_Doc_Copy import md
|
||||
|
||||
out = []
|
||||
for v in doc.get("변수") or []:
|
||||
vid, link = md.var_id(v), v.get("연결") or {}
|
||||
if link.get("갈래") != "선택" or vid not in qty_vars:
|
||||
continue
|
||||
if len({str(p.get(vid)) for p in same}) > 1:
|
||||
how = "기본값" if pick.get(vid) == link.get("값") else "고른 값"
|
||||
say = f"옵션 확인 — {v.get('이름') or vid} = {pick.get(vid)}({how} · 값으로 못 가름)"
|
||||
out.append({"항": "옵션 확인", "글": say})
|
||||
return out
|
||||
@@ -4,9 +4,7 @@
|
||||
POST /api/m02/layers/{층}/sangeun/{키}/doc/preview 미리 셈(층 쓰기) {문서?, 끊음?, 할일?}
|
||||
→ {…, 전, 후, 바뀐자원?, 버림?}
|
||||
POST /api/m02/layers/{층}/sangeun/{키}/doc/save 저장(층 쓰기) {문서?, 끊음?, 할일?, 판} → 읽기 꼴
|
||||
· 가져온 기계 줄에 고른 EQ = 이 층 별칭 장부(812-9)
|
||||
POST /api/m02/layers/{층}/sangeun/{키}/match {할일 찾기|바꾸기|되돌리기, 판?}(층 쓰기 · 810-4)
|
||||
→ {연결후보?, 판}(바꾸기 = 확정만 · 아니면 409)
|
||||
옛 별칭 장부 쓰기(812-9) · 연결 찾기 길(810-4)은 걷음(815-2 · 요구 762-1 · 765).
|
||||
⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서).
|
||||
"""
|
||||
|
||||
@@ -19,7 +17,6 @@ from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Doc_Sk_Path as path
|
||||
from M02_MasterTemplete import M02_Machine_Find as mfind
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Sangeun Doc"])
|
||||
@@ -52,31 +49,6 @@ def preview_doc(
|
||||
return _call(folder, path.preview, key, body.model_dump(exclude_none=True))
|
||||
|
||||
|
||||
class MatchBody(BaseModel):
|
||||
할일: str = "찾기" # 찾기 | 바꾸기 | 되돌리기(810-4)
|
||||
판: str | None = None
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/{key}/match")
|
||||
def match_doc(
|
||||
layer: Layer,
|
||||
key: str,
|
||||
body: MatchBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""가져온 식 ↔ 우리 기초(810-4) — 찾기(연결후보 적음) · 바꾸기(확정만 · M01 사본 SK) · 되돌리기."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_MatchSk as msk
|
||||
|
||||
jobs = {"찾기": msk.find, "바꾸기": msk.swap, "되돌리기": msk.back}
|
||||
if body.할일 not in jobs:
|
||||
raise HTTPException(status_code=400, detail="찾기 · 바꾸기 · 되돌리기")
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
return _call(folder, jobs[body.할일], key, body.판 or "")
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/sangeun/{key}/doc/save")
|
||||
def save_doc(
|
||||
layer: Layer,
|
||||
@@ -86,8 +58,4 @@ def save_doc(
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
folder = _root(session, layer, owner, write=True)
|
||||
got = _call(folder, path.save, key, body.model_dump(exclude_none=True))
|
||||
if body.문서 is not None: # 812-9 — 가져온 기계 줄에 고른 EQ = 이 층 별칭 장부 줄
|
||||
who = {"id": session.get("user_id"), "이름": session.get("name")}
|
||||
_call(folder, mfind.learn, body.문서, who)
|
||||
return got
|
||||
return _call(folder, path.save, key, body.model_dump(exclude_none=True))
|
||||
|
||||
@@ -69,7 +69,6 @@ export interface SangeunDetailOptions {
|
||||
/** 수식 탭 저장 뒤 그 SK 새 판(페이지 [저장] 409 막음 · 802-4b-2) */
|
||||
onVersion?: (version: string) => void;
|
||||
slot?: HTMLElement; // 페이지 머리 줄 빈 자리(804-8) — 보기 고르기를 M01 처럼 머리 줄에
|
||||
match?: Parameters<typeof createSangeunSide>[0]; // 원문 패널 판정 띠 단추(810-4)
|
||||
}
|
||||
|
||||
const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" };
|
||||
@@ -104,7 +103,7 @@ export function mountSangeunDetail(
|
||||
const inputsOf = new Map<string, LogicInput[]>();
|
||||
const infoOf = new Map<string, SourceTab>(); // 더한줄 로직 → 원문 탭(752-1)
|
||||
let extraKeys: string[] = [];
|
||||
const side = opt.source ? createSangeunSide(opt.match) : null;
|
||||
const side = opt.source ? createSangeunSide() : null;
|
||||
let seq = 0;
|
||||
let alive = true;
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_MatchBand.ts
|
||||
* 가져온 식 ↔ 우리 기초 판정 띠(PLAN 810-4 · 요구 707 · 709 · 703) — 원문 패널 「어디서 왔나」 카드 맨 위.
|
||||
* 글 · 단추 = `logic/M02_Sangeun_Match.ts` `matchBand` · 길 POST `/m02/layers/{층}/sangeun/{키}/match`
|
||||
* {할일 찾기 | 바꾸기 | 되돌리기} — 서버가 다시 찾아 「확정」 일 때만 바꿈(화면 판정을 믿지 않음) ·
|
||||
* 바꾸기 · 되돌리기 = 확인창 · 저장 안 한 고침이 있으면 막음(먼저 [저장]) · 끝나면 `done(판)` = 그 SK 를 다시 엶
|
||||
* 층 쓰기 권한 없음(읽기만) = 단추 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createButton } from "@ui/ui_template_button";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { showToast } from "@ui/ui_template_toast";
|
||||
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { matchBand, type MatchAction } from "./logic/M02_Sangeun_Match";
|
||||
|
||||
export interface MatchCtx {
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
key: string;
|
||||
readOnly: boolean;
|
||||
/** 저장 안 한 고침 있음 */
|
||||
dirty: () => boolean;
|
||||
/** 서버가 SK 를 고친 뒤(새 판) — 그 SK 다시 엶 */
|
||||
done: (version: string) => void;
|
||||
}
|
||||
|
||||
const LABEL: Record<Exclude<MatchAction, null>, string> = {
|
||||
찾기: "우리 기초와 연결 찾기",
|
||||
바꾸기: "우리 기초 사본으로 바꾸기",
|
||||
되돌리기: "가져온 식으로 되돌리기",
|
||||
};
|
||||
const ASK: Partial<Record<Exclude<MatchAction, null>, string>> = {
|
||||
바꾸기:
|
||||
"이 단가산출을 우리 기초 사본(찾은 옵션 · 우리 값)으로 바꿈 — 규격만 다른 기계 줄은 가져온 규격의 우리 기계 · 키 그대로 · 일위대가 금액이 다시 셈됨 · 원래 가져온 식은 보관",
|
||||
되돌리기: "우리 기초 사본을 원래 가져온 식으로 되돌림 — 가져온 식 금액 그대로",
|
||||
};
|
||||
|
||||
/** 판정 띠 — 가져온 식 · 바꾼 SK 가 아니면 null */
|
||||
export function createMatchBand(sk: unknown, ctx: MatchCtx | undefined): HTMLElement | null {
|
||||
const band = matchBand(sk as Parameters<typeof matchBand>[0]);
|
||||
if (!band) return null;
|
||||
const act = band.action;
|
||||
const actions =
|
||||
act && ctx && !ctx.readOnly && ctx.key
|
||||
? [
|
||||
createButton({
|
||||
label: LABEL[act],
|
||||
variant: act === "바꾸기" ? "filled" : "ghost",
|
||||
size: 24,
|
||||
onClick: (ev) => void run(act, ctx, ev.currentTarget as HTMLButtonElement),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const details = band.details.length
|
||||
? el("div", { children: band.details.map((x) => createText(x, "muted", 12)) })
|
||||
: undefined;
|
||||
const root = createBanner({ tone: band.tone, text: band.text, details, actions });
|
||||
root.setAttribute("data-sk-match", act ?? "");
|
||||
return root;
|
||||
}
|
||||
|
||||
async function run(act: Exclude<MatchAction, null>, ctx: MatchCtx, btn: HTMLButtonElement) {
|
||||
if (ctx.dirty()) return void showToast("저장 안 한 고침이 있음 — 먼저 [저장]", "warning");
|
||||
const ask = ASK[act];
|
||||
if (ask && !(await askConfirm({ message: ask, okLabel: LABEL[act] }))) return;
|
||||
const label = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = act === "찾기" ? "찾는 중…" : "바꾸는 중…";
|
||||
try {
|
||||
const owner = ctx.owner ? `?owner=${encodeURIComponent(ctx.owner)}` : "";
|
||||
const path = `/layers/${ctx.layer}/sangeun/${encodeURIComponent(ctx.key)}/match${owner}`;
|
||||
const got = await call<{ 판?: string }>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 할일: act }),
|
||||
});
|
||||
ctx.done(got.판 ?? "");
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : String(e), "error");
|
||||
btn.disabled = false;
|
||||
btn.textContent = label;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
* 맨 위 「단가산출 기초 찾기」(807-1 · M01 원문 패널과 같은 `LogicLab_SourceFind`) — 고르면 그 기초 원문(보기만 · 기억 안 함) ·
|
||||
* 지우면 처음 화면 · 처음 화면 = 우리 SK 는 기초 원문 · 가져온 식 SK 는 「어디서 왔나」 카드
|
||||
* (프로그램 · 파일 · 블록 · 행 · 가져온 때 · 표지 요약 + 엑셀 글의 품셈 절 글만 — 찾기 · 잇기 없음)
|
||||
* 카드 맨 위 = 우리 기초 판정 띠(810-4 · `Sangeun_MatchBand`) · 우리 기초 사본으로 바꾼 SK = 보관한 가져온 식 카드
|
||||
* 옛 우리 기초 판정 띠(810-4)는 걷음(815-2) · 옛 바꾼 SK(`가져온보관`) = 보관한 가져온 식 카드(읽기만)
|
||||
* 일위대가 줄 모달 안 상세는 안 만듦(부르는 쪽이 `source` 를 안 켬).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -23,7 +23,6 @@ import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_S
|
||||
import { createSourceFind } from "../M01_MasterData/M01_MasterData_UI_LogicLab_SourceFind";
|
||||
import { originOf, type Origin, type OriginMeta } from "./logic/M02_Sangeun_Origin";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import { createMatchBand, type MatchCtx } from "./M02_MasterTemplete_Sangeun_MatchBand";
|
||||
|
||||
export interface SourceTab {
|
||||
label: string;
|
||||
@@ -63,7 +62,7 @@ const tx = (k: keyof typeof TEXT, n = 0): string =>
|
||||
TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n));
|
||||
|
||||
/** 「어디서 왔나」 카드 — 표지는 셋까지(더 = 단추로 펼침) */
|
||||
function originCard(o: Origin, band: HTMLElement | null): HTMLElement {
|
||||
function originCard(o: Origin): HTMLElement {
|
||||
const marks = el("div");
|
||||
const showMarks = (all: boolean): void =>
|
||||
marks.replaceChildren(
|
||||
@@ -82,7 +81,6 @@ function originCard(o: Origin, band: HTMLElement | null): HTMLElement {
|
||||
title: tx("Origin"),
|
||||
pad: 12,
|
||||
body: [
|
||||
...(band ? [band] : []),
|
||||
...(o.요약 ? [el("strong", { text: o.요약, attrs: { "data-origin-native": "" } })] : []),
|
||||
createKv([
|
||||
{ label: tx("Program"), value: o.프로그램 },
|
||||
@@ -103,9 +101,9 @@ function originCard(o: Origin, band: HTMLElement | null): HTMLElement {
|
||||
return card;
|
||||
}
|
||||
|
||||
export function createSangeunSide(match?: MatchCtx): {
|
||||
export function createSangeunSide(): {
|
||||
wrap: (main: HTMLElement) => HTMLElement;
|
||||
/** 원문 탭 · SK 문서(`가져온` · 바꾼 SK `가져온보관` — 있으면 첫 화면 = 카드 + 판정 띠) */
|
||||
/** 원문 탭 · SK 문서(`가져온` · 옛 바꾼 SK `가져온보관` — 있으면 첫 화면 = 카드) */
|
||||
set: (tabs: SourceTab[], sk?: unknown) => void;
|
||||
} {
|
||||
const view = createSourceView({
|
||||
@@ -140,8 +138,7 @@ export function createSangeunSide(match?: MatchCtx): {
|
||||
view.show(found);
|
||||
return void content.replaceChildren(view.root);
|
||||
}
|
||||
if (origin)
|
||||
return void content.replaceChildren(originCard(origin, createMatchBand(skDoc, match)));
|
||||
if (origin) return void content.replaceChildren(originCard(origin));
|
||||
if (!tabs.length)
|
||||
return void content.replaceChildren(createText(L("M02_SangeunSourceNone"), "muted"));
|
||||
at = Math.min(at, tabs.length - 1);
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
* 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄).
|
||||
* 717-2b = 상세 · 고침(`Sangeun_Detail.ts` mountSangeunDetail) · [저장]은 그 손잡이 `getDoc` · 고침 알림 `opt.onChanged`.
|
||||
* 804-8 = 페이지 [저장] 한 번에 수식도(`flush` 먼저 · 새 판) · 보기 고르기는 머리 줄 `slot`.
|
||||
* 810-4 = 원문 패널 판정 띠 [바꾸기] · [되돌리기] · [연결 찾기] 뒤 그 SK 를 다시 읽어 상세를 다시 엶(새 판).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readTemplate } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
@@ -25,37 +23,9 @@ export function mountSangeunView(
|
||||
doc: unknown,
|
||||
opt: SangeunViewOptions,
|
||||
): { getDoc: () => Promise<unknown>; destroy: () => void } {
|
||||
let dirty = false;
|
||||
const onChanged = (d: boolean): void => ((dirty = d), opt.onChanged(d));
|
||||
/** 판정 띠 단추(810-4) — 서버가 SK 를 고친 뒤 새 판 · 그 SK 를 다시 읽어 상세를 다시 엶 */
|
||||
const match = {
|
||||
layer: opt.layer,
|
||||
owner: opt.owner ?? null,
|
||||
key: opt.name,
|
||||
readOnly: opt.readOnly,
|
||||
dirty: () => dirty,
|
||||
done: (version: string): void => {
|
||||
opt.onVersion?.(version);
|
||||
void readTemplate(opt.layer, "sangeun", opt.name, null, opt.owner ?? null).then((got) => {
|
||||
if (!alive) return;
|
||||
one.destroy();
|
||||
one = mount(got.문서);
|
||||
onChanged(false);
|
||||
});
|
||||
},
|
||||
};
|
||||
const mount = (d: unknown) =>
|
||||
mountSangeunDetail(host, d, {
|
||||
...opt,
|
||||
onChanged,
|
||||
owner: opt.owner ?? null,
|
||||
source: true,
|
||||
match,
|
||||
}); // 752-1
|
||||
let alive = true;
|
||||
let one = mount(doc);
|
||||
const one = mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null, source: true }); // 752-1
|
||||
return {
|
||||
getDoc: () => one.flush().then(() => one.getDoc()),
|
||||
destroy: () => ((alive = false), one.destroy()),
|
||||
destroy: () => one.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_Sangeun_Match.ts
|
||||
* 가져온 식 ↔ 우리 M01 기초 연결 판정 띠 자료(PLAN 810-4 · 요구 707 · 709 · 703) — 원문 패널 카드 위.
|
||||
* SK `가져온.원본.연결후보`(서버 `M02_Doc_Match` 가 적음) → 띠 글 · 다른 항 · 단추 하나:
|
||||
* 확정 = [우리 기초 사본으로 바꾸기] · 근접 · 없음 = 단추 없음(703) · 안 찾음 = [연결 찾기] ·
|
||||
* 다른 항 = 로직 차이(확정을 막음) · 참고 = 단가 · 끝수 · 율 값 %(값 차이 — 「우리 단가로 셈 ○○ 원」 · 812-2 · 812-4) ·
|
||||
* 바꾼 SK(`가져온보관` 있음) = 「우리 기초 사본 ○○」 + [가져온 식으로 되돌리기] ·
|
||||
* 같은 기종 규격 · 수량 차이 = 참고(812-11) · 기계 작업 = 기계 한 줄 단가산출(우리 기초에 안 붙임 · 단추 없음)
|
||||
* 화면 · DOM · fetch 없음
|
||||
* ========================================================================== */
|
||||
|
||||
export type MatchVerdict = "확정" | "근접" | "없음" | "기계 작업";
|
||||
|
||||
export interface MatchFound {
|
||||
판정?: MatchVerdict;
|
||||
키?: string;
|
||||
이름?: string;
|
||||
옵션?: Record<string, string>;
|
||||
다른?: { 항: string; 글: string }[];
|
||||
/** 값 차이(단가 · 끝수 · 율 값 · 규격 · 수량) — 확정을 안 막음 */
|
||||
참고?: { 항: string; 글: string }[];
|
||||
금액?: { 가져온?: Record<string, number | null>; 우리?: Record<string, number | null> };
|
||||
후보수?: number;
|
||||
가까움?: { 키?: string; 이름?: string; 다른?: { 항: string; 글: string }[] };
|
||||
/** 우리 정보로 셈(814-1) — 자원 줄 이음 수 · 형제 단가산출 부름뿐 = 조합 */
|
||||
우리정보?: { 됨?: number; 줄?: number; 조합?: boolean };
|
||||
}
|
||||
|
||||
export type MatchAction = "찾기" | "바꾸기" | "되돌리기" | null;
|
||||
|
||||
export interface MatchBand {
|
||||
tone: "success" | "info" | "warning";
|
||||
text: string;
|
||||
/** 다른 항 글(근접 · 없음의 가까운 후보) */
|
||||
details: string[];
|
||||
action: MatchAction;
|
||||
}
|
||||
|
||||
interface SkLike {
|
||||
바탕?: { 로직?: string } | null;
|
||||
입력값?: Record<string, unknown>;
|
||||
가져온?: { 로직?: unknown; 원본?: { 연결후보?: MatchFound } } | null;
|
||||
가져온보관?: { 가져온?: { 원본?: { 연결후보?: MatchFound } } } | null;
|
||||
}
|
||||
|
||||
const opts = (o?: Record<string, unknown>): string => {
|
||||
const list = Object.entries(o ?? {}).map(([k, v]) => `${k} ${String(v)}`);
|
||||
return list.length ? `(${list.join(" · ")})` : "(기본 옵션)";
|
||||
};
|
||||
const lines = (d?: { 항: string; 글: string }[]): string[] =>
|
||||
(d ?? []).map((x) => `${x.항} — ${x.글}`);
|
||||
|
||||
const won = (n?: number | null): string =>
|
||||
n == null ? "" : `${Math.round(n).toLocaleString("ko-KR")} 원`;
|
||||
const QTY = ["수량", "수량 끝자리", "품 숫자"];
|
||||
/** 참고 요약 — 「참고: 단가 다름 3 줄 · 율 값 다름 1 줄 · 우리 단가로 셈 53,218 원」 · 참고 없음 = "" */
|
||||
const noteText = (f: MatchFound): string => {
|
||||
const n = f.참고 ?? [];
|
||||
if (!n.length) return "";
|
||||
const price = n.filter((x) => x.항 === "단가 차이").length;
|
||||
const rate = n.filter((x) => x.항 === "율 값").length;
|
||||
const spec = n.filter((x) => x.항 === "규격").length;
|
||||
const qty = n.filter((x) => QTY.includes(x.항)).length;
|
||||
const bits = [
|
||||
spec ? `규격 다름 ${spec} 줄` : "",
|
||||
qty ? `수량 다름 ${qty} 줄` : "",
|
||||
price ? `단가 다름 ${price} 줄` : "",
|
||||
rate ? `율 값 다름 ${rate} 줄` : "",
|
||||
n.some((x) => x.항 === "옵션 확인") ? "옵션 확인" : "",
|
||||
n.some((x) => x.항 === "끝수") ? "끝수 다름" : "",
|
||||
].filter(Boolean);
|
||||
const ours = won(f.금액?.우리?.계);
|
||||
return ` · 참고: ${bits.join(" · ")}${ours ? ` · 우리 단가로 셈 ${ours}` : ""}`;
|
||||
};
|
||||
|
||||
/** SK → 띠 자료 · 가져온 식도 · 바꾼 SK 도 아니면 null */
|
||||
export function matchBand(sk: SkLike | null | undefined): MatchBand | null {
|
||||
const kept = sk?.가져온보관;
|
||||
if (kept && sk?.바탕?.로직) {
|
||||
return {
|
||||
tone: "info",
|
||||
text: `우리 기초 사본 ${sk.바탕.로직} ${opts(sk.입력값)} — 원래 가져온 식은 보관`,
|
||||
details: [],
|
||||
action: "되돌리기",
|
||||
};
|
||||
}
|
||||
if (!sk?.가져온?.로직) return null;
|
||||
const f = sk.가져온.원본?.연결후보;
|
||||
if (!f?.판정) {
|
||||
return { tone: "info", text: "우리 기초와 연결을 아직 안 찾음", details: [], action: "찾기" };
|
||||
}
|
||||
if (f.판정 === "기계 작업") {
|
||||
return {
|
||||
tone: "info",
|
||||
text: "기계 한 줄 단가산출 — 그대로 씀 · 값은 [단가 갱신]으로 우리 값",
|
||||
details: lines(f.참고),
|
||||
action: null,
|
||||
};
|
||||
}
|
||||
const base = `${f.키 ?? ""} ${f.이름 ?? ""} ${opts(f.옵션)}`.trim();
|
||||
if (f.판정 === "확정") {
|
||||
return {
|
||||
tone: "success",
|
||||
text: `확정 — 로직 같음 · 우리 기초 ${base}${noteText(f)}`,
|
||||
details: lines(f.참고),
|
||||
action: "바꾸기",
|
||||
};
|
||||
}
|
||||
if (f.판정 === "근접") {
|
||||
const kinds = [...new Set((f.다른 ?? []).map((x) => x.항))].join(" · ");
|
||||
return {
|
||||
tone: "warning",
|
||||
text: `근접 — ${base} · 다른 항: ${kinds}${noteText(f)}`,
|
||||
details: [...lines(f.다른), ...lines(f.참고).map((x) => `참고 ${x}`)],
|
||||
action: null,
|
||||
};
|
||||
}
|
||||
const near = f.가까움;
|
||||
const mine = f.우리정보; // 「없음」 = 흠 아님(814-1 · 요구 745) — 우리 정보로 다시 만든 정도
|
||||
const made = mine?.줄 ? ` — 우리 정보로 셈 ${mine.됨 ?? 0}/${mine.줄} 줄` : "";
|
||||
return {
|
||||
tone: "info",
|
||||
text: mine?.조합
|
||||
? `조합 — 다른 단가산출 ${mine.줄 ?? 0} 개 묶음(안의 단가산출로 셈)`
|
||||
: `우리 기초 없음${made}`,
|
||||
details: near ? [`가장 가까운 ${near.키 ?? ""} ${near.이름 ?? ""}`, ...lines(near.다른)] : [],
|
||||
action: null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user