feat(814-1 3·4·6·7·8·9·10): 불러오기 결정 반영 — 세 줄 묶기 · 꼬리 뗀 맞대기 · 유가 · 재료 · 까닭 · 가벼움

- 3 연결 찾기 = 엑셀 이름 규격 · 단위 꼬리 뗀 이름으로 맞댐(`M02_Doc_Match_Note.bare` · 보이는 이름 그대로) · STmate 블록 규격도 가져온 칸에
- 4 기계 세 줄 = 기계 한 줄 — STmate X 코드 노 · 재 · 경 세 참조 줄(같은 수량 · 번지) 묶음(묶어서 셈 못 하는 세 줄만 그대로) ·
  문서 단계 묶기 넓힘(차례 아무렇게 · 시간당 경비 이음 없으면 엑셀 값 묶음 · 번지 같을 때) · 조합 다음 줄 = 세 줄 뒤 · 같은 기계 한 항목
- 6 공구손료 율 비목 차이(재료비 ↔ 경비) = 참고(`M02_Doc_Match_Value.tool_cost`) — 확정 안 막음
- 7 못 이은 줄 까닭(`M02_San_Link.why` — 자재 없음 · 운임표 없음 · 기계 표에 없음 · 별칭 대기 · 후보 여럿 · 시간당 경비 없음 · 조합) =
  문서 설명 · 이음 없는 자원 띠 · 연결 「없음」 띠 = 「우리 기초 없음 — 우리 정보로 셈 n/m 줄」 · 형제만 = 「조합」
- 8 유가 = 전국평균 줄 + 지역 옵션(`MO:경유:{S_지역}`) · 전력 = 일반용(갑)Ⅰ 저압 + 계약종별 옵션 · 엑셀 값 = 검산 칸
- 9 재료 = 지역 꼬리 뗀 이름 · 규격 정확 일치면 이음(지역 여럿 = 줄 지역 › 서울)
- 10 조합 첫 줄 규격 = 그 기계 · 얼린 줄도 소계 식에(북부청임도 33) · 기계 줄 찾기 판정 늘 붙음 · 올린 직후 409(HP 보이기 전 산근 보관) ·
  엑셀 블록 「엑셀 읽기 확정」 글 · 전수 도우미 str() 뗌 · 줄 쌓기 이름에 규격이 들면 한 번만
- M02_San_Link 새 파일(San_Emit 700 줄 막음 — 요소 잇기 · 시간당 · 조합 · 까닭)
- 시험: 새 test_814_1 · 812-2 · 810-4 거푸집 41 = 근접 → 확정(공구손료 비목 = 값 · 요구 744 C1) · 바꾸기 거절 견본 = 19 ·
  812-9 조합 첫 줄 규격 「0.7+브레이카」 → 「0.7」(칩 글 · 요구 738 흠 12)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-10 14:47:08 +09:00
co-authored by Claude Opus 5.5
parent 8914b21bad
commit bed35e3bc3
26 changed files with 786 additions and 127 deletions
+3
View File
@@ -121,6 +121,9 @@ def emit(saved: dict, no: int, keys: bool = True) -> dict[str, Any]:
marks = sum(1 for one in out["원본"]["글줄"] if one.get("표지"))
sure = len(out["로직"]["호표"]) + len(out["로직"].get("덧줄") or []) + len(out["로직"]["중간"])
out["원본"] |= {"원파일": True, "갈래": "STmate", "확정": {"확정": sure, "표지": marks}}
out["원본"] |= (
{"규격": blk["규격"]} if blk.get("규격") else {}
) # 연결 찾기 근거 · 이름 꼬리(814-1)
return _excel(saved, out, blk)
+57
View File
@@ -226,6 +226,8 @@ def _link_of(item: dict) -> tuple[dict | None, str]:
}
return link | ({"칸": item["참조비목"]} if item.get("참조비목") else {}), ""
key = str(item.get("이음") or "")
if not key and (item.get("자원") or {}).get("조합식"):
return None, f"우리 조합 식 {item['자원']['조합식']} 에 듦(첫 줄) — 이 줄 값 0"
if not key:
return None, ""
if item.get("종류") == "로직":
@@ -233,12 +235,59 @@ def _link_of(item: dict) -> tuple[dict | None, str]:
if key.startswith("EQ"):
got = hourly_link(key, str(item.get("비목") or ""))
return got, "" if got else f"기계 {key} 시간당 경비 자료 없음 — 엑셀 단가 그대로"
if key.startswith("MO") and (got := _oil(key)):
return got, ""
link = {"갈래": "요소", "키": key}
if key[:2] == dc.cv.mm.LINKED: # 품셈재료 = 줄 단위로 값이 갈림(M01 변환과 같음)
link["단위"] = str(item.get("단위") or "")
return link, ""
OPTION = "_옵션" # 연결이 부르는 옵션 변수(문서에 없으면 `_attach` 가 더함)
def _oil(key: str) -> dict | None:
"""유가 · 전력 이음(814-1 · 요구 748) — 우리 기초 꼴: 유가 = `MO:경유:{S_지역}` + 지역 옵션(기본
전국평균) · 전력 = 전력량요금 계약종별 고르기 + 계약종별 옵션(기본 일반용(갑)Ⅰ 저압)."""
whole = imp._whole()
try:
row = whole.get(key)
except Exception: # noqa: BLE001 — 없는 키 = 보통 요소 연결
return None
rows = list(whole.index.get("MO", {}).values())
if row.get("구분") == "전력":
kinds = list(dict.fromkeys(str(r.get("상세구분")) for r in rows if r.get("구분") == "전력" and r.get("이름") == row.get("이름"))) # fmt: skip
var = _choice("전력 계약종별", "전력계약종별", kinds, str(row.get("상세구분")))
link = {
"갈래": "요소",
"재료": {"이름": row.get("이름"), "계약종별": "입력"},
"단위": "㎾h",
}
return link | {"계약종별": dc.md.var_id(var), OPTION: var}
head = str(row.get("원문번호") or "").split(":")[0]
if not head or row.get("구분") != "유류":
return None
places = [
str(r.get("상세구분")) for r in rows if str(r.get("원문번호") or "").split(":")[0] == head
]
places.sort(key=lambda x: x != "전국평균")
var = _choice("지역", "지역", places, str(row.get("상세구분")))
return {"갈래": "요소", "키": f"MO:{head}:{{{dc.md.var_id(var)}}}", OPTION: var}
def _choice(key: str, below: str, items: list[str], value: str) -> dict:
link = {"갈래": "선택", "목록": items, "값": value}
return {
"열쇠": key,
"기호": "S",
"아래": below,
"위": "",
"이름": key,
"단위": "",
"연결": link,
}
MACHINES = "_기계" # 문서 셈 동안 잠깐 — `derive` 가 머리 `가져온.기계` 로 옮김
@@ -280,7 +329,15 @@ def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None:
if note:
var["설명"] = note
if link is None:
if not note and not item.get("이음"): # 못 이은 까닭(814-1 — 흠 아님 · 우리 정보 없음)
from M02_MasterTemplete import M02_San_Link as san_link
var["설명"] = f"이음 없음 — {san_link.why(item)} · 엑셀 단가 그대로"
continue
if (opt := link.pop(OPTION, None)) and opt["열쇠"] not in {
v.get("열쇠") for v in doc["변수"]
}:
doc["변수"].append(opt) # 유가 지역 · 전력 계약종별 옵션(문서에 하나)
if item.get("참조비목") and isinstance(excel, dict):
excel = excel[item["참조비목"]]
line["단가"], line["비목"] = dc.md.var_id(var), item["참조비목"]
+77 -23
View File
@@ -68,20 +68,26 @@ def _rename_steps(doc: dict, swap: dict[str, str]) -> None:
st["줄"] = got
def _bundle_of(doc: dict, at: int) -> list[int] | None:
"""at 에서 시작하는 기계 세 줄(노무비 · 재료비 · 경비) — 같은 기계 이음 · 같은 수량."""
def _bundle_of(doc: dict, at: int, who: dict[str, str] | None = None) -> list[int] | None:
"""at 에서 시작하는 기계 세 줄(노무비 · 재료비 · 경비 · 차례 아무렇게) — 같은 기계 이음 · 같은 수량 ·
시간당 경비 이음 없음(숫자)이면 같은 기계 자원(`who` 줄 이름 → 자원 · 814-1)."""
money = doc.get("금액") or []
three = money[at : at + 3]
if len(three) < 3 or [x.get("비목") for x in three] != list(COSTS):
if len(three) < 3 or sorted(str(x.get("비목")) for x in three) != sorted(COSTS):
return None
vars_ = {v.get("열쇠"): v for v in doc.get("변수") or []}
links = [(vars_.get(str(x.get("단가"))) or {}).get("연결") or {} for x in three]
if any(x.get("갈래") != "단가산출" or "기계" not in (x.get("입력") or {}) for x in links):
if all(x.get("갈래") == "숫자" and not isinstance(x.get("값"), dict) for x in links):
mine = {(who or {}).get(str(x.get("이름"))) for x in three}
if len(mine) != 1 or None in mine:
return None
elif any(x.get("갈래") != "단가산출" or "기계" not in (x.get("입력") or {}) for x in links):
return None
same = {(x.get("키"), str(x.get("입력"))) for x in links}
if len(same) != 1 or [x.get("칸") for x in links] != list(COSTS):
elif len({(x.get("키"), str(x.get("입력"))) for x in links}) != 1 or [
x.get("칸") for x in links
] != [x.get("비목") for x in three]:
return None
for k in ("수량", "규격", "자리", "합밖"):
for k in ("수량", "규격", "자리", "합밖", "번지"):
if len({str(x.get(k) or "") for x in three}) != 1:
return None
names = {str(x.get("이름")) for x in three}
@@ -113,30 +119,70 @@ def _rewrite(text: Any, swap: dict[str, str]) -> Any:
return pat.sub(lambda m: swap[m.group(0)], text)
def machines(doc: dict, title: dict[str, str]) -> None:
"""② EST 기계 세 줄 → 한 기계 줄(비목 묶음) · 부르던 식 = 비목 칸. `title` = {줄 이름: 자원 이름}."""
AFTER = "_뒤" # 옮긴 조합 다음 줄 — 차림이 원본 행 차례로 다시 섞지 않게(다듬기 사이 임시)
def regroup(doc: dict, who: dict[str, str], parts: set[str]) -> None:
"""814-1 — 조합 기계 비목 세 줄 사이에 낀 다음 줄(값 0)을 세 줄 뒤로 · 같은 기계끼리 모음
(첫 줄 · 다음 줄이 엇갈려 항목이 겹치지 않게 · 값 0 줄이라 셈 그대로). `parts` = 조합 다음 줄 이름."""
money = doc.get("금액") or []
at = 0
while at < len(money):
head = who.get(str(money[at].get("이름")))
if head is None or str(money[at].get("이름")) in parts:
at += 1
continue
end, mains, rest = at, [], []
while end < len(money):
name = str(money[end].get("이름"))
if name in parts:
rest.append(money[end])
elif who.get(name) == head and len(mains) < 3:
mains.append(money[end])
else:
break
end += 1
if len(mains) > 1 and rest:
seen = list(dict.fromkeys(who.get(str(x.get("이름"))) for x in rest))
rest.sort(key=lambda x: seen.index(who.get(str(x.get("이름"))))) # 같은 기계끼리
for x in rest:
x[AFTER] = True # 차림(`NativeLayout.apply`) 원본 행 = 앞 줄 행 뒤
money[at:end] = mains + rest
at = max(end, at + 1)
def machines(doc: dict, title: dict[str, str], who: dict[str, str] | None = None) -> None:
"""② 기계 세 줄 → 한 기계 줄(비목 묶음) · 부르던 식 = 비목 칸. `title` = {줄 이름: 자원 이름} ·
`who` = {줄 이름: 기계 자원 열쇠}(시간당 경비 이음 없는 세 줄 · 814-1)."""
money = doc.get("금액") or []
vars_ = {v.get("열쇠"): v for v in doc.get("변수") or []}
out, swap, at, used = [], {}, 0, set()
while at < len(money):
idx = _bundle_of(doc, at)
idx = _bundle_of(doc, at, who)
if idx is None:
out.append(money[at])
at += 1
continue
three = [money[i] for i in idx]
first = vars_[str(three[0]["단가"])]
link = {k: v for k, v in first["연결"].items() if k not in ("칸", "엑셀")}
excel = {
c: Decimal(str((vars_[str(x["단가"])]["연결"]).get("엑셀") or 0))
for c, x in zip(COSTS, three)
}
link["엑셀"] = excel
name = title.get(str(three[0].get("이름"))) or str(three[0].get("이름"))
three = sorted((money[i] for i in idx), key=lambda x: COSTS.index(x["비목"]))
first = vars_[str(money[idx[0]]["단가"])]
if first["연결"].get("갈래") == "숫자": # 시간당 경비 이음 없음 = 엑셀 값 묶음
link = {
"갈래": "숫자",
"값": {c: (vars_[str(x["단가"])]["연결"]).get("값") for c, x in zip(COSTS, three)},
}
else:
link = {k: v for k, v in first["연결"].items() if k not in ("칸", "엑셀")}
link["엑셀"] = {
c: Decimal(str((vars_[str(x["단가"])]["연결"]).get("엑셀") or 0))
for c, x in zip(COSTS, three)
}
lead = money[idx[0]]
name = title.get(str(lead.get("이름"))) or str(lead.get("이름"))
first["연결"], first["이름"] = link, f"{name} 단가"
if name in {str(x.get("이름")) for x in money} - {str(x.get("이름")) for x in three}:
name = f"{name} ({at + 1})" # 같은 이름 줄이 있으면 겹치지 않게
line = {k: v for k, v in three[0].items() if k != "기호"} | {"이름": name, "비목": "묶음"}
line = {k: v for k, v in lead.items() if k != "기호"} | {"이름": name, "비목": "묶음"}
line["단가"] = lead["단가"]
_rename_steps(doc, {str(x.get("이름")): name for x in three})
if any(x.get("기호") for x in three): # 부르던 식 = 번지 칸(엔진 `번지.X.노무비`)
place = _place(doc, name, used)
@@ -147,7 +193,8 @@ def machines(doc: dict, title: dict[str, str]) -> None:
doc["변수"] = [
v
for v in doc["변수"]
if v is first or v.get("열쇠") not in {str(x["단가"]) for x in three[1:]}
if v is first
or v.get("열쇠") not in {str(x["단가"]) for x in three} - {str(lead["단가"])}
]
out.append(line)
at += 3
@@ -166,11 +213,18 @@ def shape(doc: dict, meta: dict) -> dict:
src = meta.get("원본") or {}
lay.tag(doc, meta) # 812-3 원파일 차림 — 금액 줄 ↔ 로직 줄(끝에 `apply`)
names(doc, list(src.get("글줄") or []))
title = {}
title, who, parts = {}, {}, set()
for item in (meta.get("로직") or {}).get("호표") or []:
res = item.get("자원") or {}
if res.get("갈래") == "기계" and res.get("이름"):
title[str(item.get("이름"))] = " ".join(x for x in (res["이름"], res.get("규격")) if x)
machines(doc, title)
key = (res.get("코드"), res.get("이름"), res.get("규격"))
who[str(item.get("이름"))] = repr(key)
if res.get("조합"):
parts.add(str(item.get("이름")))
regroup(doc, who, parts)
machines(doc, title, who)
lay.apply(doc, meta)
for line in doc.get("금액") or []:
line.pop(AFTER, None)
return doc
+1
View File
@@ -618,6 +618,7 @@ def _off(doc: dict, vid: str, value: Any, seen: set) -> dict:
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}
+3 -1
View File
@@ -57,7 +57,9 @@ def judge(sk: dict) -> dict[str, Any]:
doc = dim.of(sk)
links_imp.learn(doc)
got = dm.match(doc, links_imp, index(), dm.ml.Links(whole, dc.folder()))
return {**got, "때": utc_now_text()}
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]:
+18 -1
View File
@@ -230,10 +230,27 @@ def rate_pairs(left: list[dict], right: list[dict], take: Any) -> tuple[list[dic
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, "대상")):
diffs.append({"항": "비목", "글": f"율 줄 {name(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}
+13
View File
@@ -27,6 +27,19 @@ 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."""
+23 -6
View File
@@ -299,7 +299,7 @@ class Block:
self.got["확정"] += bool(got)
self.register(ln, f"줄.'{name}'")
def triple(self, rows: list[dict], total: dict | None) -> None:
def triple(self, rows: list[dict], total: dict | None, back: int = 0) -> None:
"""기계 · 산근 · 견적 세 줄 → 로직 값묶음 한 줄(수량 식 · 자리 같을 때) · 아니면 줄마다."""
got = [self.money(ln) for ln in rows]
parts = [ln["비목"] for ln in rows]
@@ -307,8 +307,8 @@ class Block:
if not same:
for ln, g in zip(rows, got):
self.machine_line(ln, g)
if total:
self.subtotal(total, [call for _, call, _ in self.done[-len(rows) :]])
if total: # 소계 = 세 줄 + 바로 앞 같은 기계 낱 줄(`back`)
self.subtotal(total, [call for _, call, _ in self.done[-(len(rows) + back) :]])
return
qty, place = got[0]
prices = {ln["비목"]: (ln.get("연결단가") or [0])[0] for ln in rows}
@@ -325,8 +325,10 @@ class Block:
title = head.get("품명") or r.get("원이름") or r.get("이름") or "줄"
name = self.o.names.item(title)
one = {"이름": name, "규격": r.get("규격") or "", "단위": r.get("단위") or "", "수량": qty}
link = em.hourly_text(parts[0]["키"]) if parts else None
one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": link, "자원": r}
one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": None, "자원": r}
em.first_spec(one, parts)
if parts: # 814-1 조합(굴착기 + 브레이커 · 집게) = 우리 조합 식 한 줄
one["이음"] = em.combo_link(one, parts) or em.hourly_text(parts[0]["키"])
one |= {"자리": place} if place else {}
value = total["결과"][-1] if total and total.get("결과") else None
for ln in rows: # 노 · 재 · 경 글 = 펼침(맞댈 값은 한 줄 계 — 소계 글 · 없으면 첫 줄)
@@ -362,6 +364,7 @@ class Block:
r |= {"찾기": parts[0]} if parts else {}
r |= {"조합첫": True} if len(parts) > 1 else {}
one = em.resource(name, r.get("단위"), qty, r, parts[0]["키"] if parts else None)
em.first_spec(one, parts)
one |= {"자리": place} if place else {}
self.o.add_line(one, True, ln["행"], ln.get("보임") or "", ln["결과"][-1])
self.got["확정"] += bool(got)
@@ -548,8 +551,22 @@ class Block:
rows.append(lines[i + len(rows)])
nxt = lines[i + len(rows)] if i + len(rows) < len(lines) else None
total = nxt if nxt and nxt.get("연결") == link and nxt.get("비목") == "T" else None
back, seen = (
0,
{r.get("비목") for r in rows},
) # 바로 앞 같은 기계 낱 줄(얼린 줄 · 814-1)
while i - back > 0 and len(seen) < 3:
b = lines[i - back - 1]
if (
(b.get("자원") or {}).get("코드") != code
or b.get("비목") in seen
or not b.get("결과")
):
break
seen.add(b.get("비목"))
back += 1
if all(r.get("결과") for r in rows):
self.triple(rows, total)
self.triple(rows, total, back)
else:
for r in rows:
self.mark(r, "결과 없는 세 줄")
+5 -1
View File
@@ -85,6 +85,9 @@ def apply(doc: dict, meta: dict) -> None:
if j is not None:
at[j] = min(at.get(j, t["행"]), t["행"])
rows = _fill([at.get(j) for j in range(len(money))])
for j in range(1, len(rows)): # 조합 다음 줄을 세 줄 뒤로 옮긴 것(814-1 `Import_Shape.regroup`)
if money[j].get("_뒤"):
rows[j] = max(rows[j], rows[j - 1])
order = sorted(range(len(money)), key=lambda j: (rows[j], j))
if order != list(range(len(money))) and _safe(doc, order):
doc["금액"] = money = [money[j] for j in order]
@@ -193,8 +196,9 @@ def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dic
cut = False
else:
one = mach.get(price_sym(money[val]) or "") if kind == "금액" else None
if one and one.get("조합"):
if one and one.get("조합") and not (cur and cur.get("_조합") == _mach_title(one)):
cur = new(_mach_title(one), bool(cur and cur["가나다"]))
cur["_조합"] = _mach_title(one) # 같은 기계 다음 줄(비목 셋)은 한 항목
elif one and cur is not None and "+" in cur["제목"]:
cur["제목"] = _mach_title(one)
cur = cur if cur is not None else new()
+3 -7
View File
@@ -82,13 +82,9 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
grid = hopyo_grid.read(name, data, parsed)
except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404)
grid = None
row = hopyo_store.save(store.root(), name, parsed, who, grid, mark)
cannot = from_hopyo.keep(
store.root(), row["키"], name, data
) # 산근 시트(793-3b 가져온 식 열기)
return (
{**row, "읽기불가": cannot} if cannot else row
) # 797-1 가져온 식 못 씀 사유 → 화면 토스트
# 산근 시트(793-3b 가져온 식 열기) = HP 를 목록에 보이기 전에 보관(814-1) · 못 씀 사유 → 화면 토스트(797-1)
keep = lambda key: from_hopyo.keep(store.root(), key, name, data) # noqa: E731
return hopyo_store.save(store.root(), name, parsed, who, grid, mark, keep)
@router.get("/layers/{layer}/hopyo")
+9 -2
View File
@@ -124,8 +124,10 @@ def save(
who: Any,
grid: dict[str, Any] | None = None,
mark: str | None = None,
first: Any = None,
) -> dict[str, Any]:
"""읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄 · `mark` = 올린 엑셀 지문."""
"""읽은 결과(+ 격자) → 새 키로 씀 → 목록 한 줄 · `mark` = 올린 엑셀 지문 ·
`first(키)` = HP 를 목록에 보이기 전에 할 일(산근 보관 — 올린 직후 409 막음 · 814-1) · 돌려준 값 = 답 `읽기불가`."""
with _LOCK:
have = [
int(m.group(1)) for p in _dir(folder).glob("HP-*.json") if (m := _KEY.match(p.stem))
@@ -145,9 +147,14 @@ def save(
if grid is not None:
book = {**grid["책"], "열": key}
atomic_write_json(_grid_path(folder, key), {**grid, "책": book})
cannot = first(key) if first else None
atomic_write_json(_path(folder, key), doc)
_put_done(folder, log)
return {**brief(doc), **({"지문": mark} if mark else {})}
return {
**brief(doc),
**({"지문": mark} if mark else {}),
**({"읽기불가": cannot} if cannot else {}),
}
def grid(folder: str | Path, key: str) -> dict[str, Any]:
@@ -61,7 +61,7 @@ interface EditAnswer {
문서: ImportedDoc;
전?: Money4 | null;
후?: Money4 | null;
이음없음?: { 자리: string; 이름: string }[];
이음없음?: { 자리: string; 이름: string; 까닭?: string }[];
/** qty 답(797-2) */
줄?: QtyRow[];
집계?: QtyTally;
@@ -54,7 +54,7 @@ export const tx = (k: keyof typeof TEXT): string =>
export interface PanelState {
doc: ImportedDoc;
/** 이음 없는 자원(서버 price 답) */
unlinked: { 자리: string; 이름: string }[];
unlinked: { 자리: string; 이름: string; 까닭?: string }[];
/** 소요량 · 계수 이음 집계(서버 qty 답 · 797-2) */
qty: QtyTally | null;
/** 마지막 단가 바꿈 전후 */
@@ -149,7 +149,8 @@ export function importedPanel(s: PanelState, a: PanelActs): HTMLElement {
const kids: HTMLElement[] = [createToolbar({ items: head })];
if (here && s.unlinked.length) {
const items = s.unlinked.map((u) => {
const b = createButton({ label: `${u.이름} · ${tx("Link")}`, size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: () => a.link(u.자리, u.이름) }); // prettier-ignore
const label = `${u.이름}${u.까닭 ? ` (${u.까닭})` : ""} · ${tx("Link")}`; // 못 이은 까닭(814-1)
const b = createButton({ label, size: 24, variant: "ghost", disabled: s.readOnly || s.busy, onClick: () => a.link(u.자리, u.이름) }); // prettier-ignore
b.dataset.importedLink = u.자리;
return b;
});
@@ -91,7 +91,7 @@ function originCard(o: Origin, band: HTMLElement | null): HTMLElement {
{ label: tx("Rows"), value: o.행 },
{ label: tx("When"), value: o.때 },
{ label: tx("Marks"), value: tx("MarksN", o.표지.length) },
...(o.확정 ? [{ label: tx("Sure"), value: o.확정 }] : []),
...(o.확정 ? [{ label: o.확정이름 ?? tx("Sure"), value: o.확정 }] : []),
]),
marks,
el("strong", { text: tx("Clauses") }),
+93 -61
View File
@@ -28,6 +28,8 @@ from typing import Any
from M02_MasterTemplete import M02_San_Eval as ev
from M02_MasterTemplete import M02_San_Expr as ex
from M02_MasterTemplete.M02_San_Link import combo_link, hourly_text # noqa: F401 — 밖에서 em. 으로 부름
from M02_MasterTemplete.M02_San_Link import m01 as _m01
COMP = {"L": "노무비", "M": "재료비", "S": "경비"}
_CODE = {v: k for k, v in COMP.items()}
@@ -175,61 +177,6 @@ def reprice(logic: dict, where: dict, price: dict[str, float]) -> int:
return n
def _m01(kind: str, src: dict) -> tuple[str | None, str | None]:
"""1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 ·
환율만 — 재료 · 인력은 조용히 이음 없음) · 기계 = 기계 찾기(812-9) 못 이으면 늘 표지. 재료 줄 이름이 M01 유가전력(MO)이면
유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님)."""
if kind not in ("인력", "재료", "기계"):
return None, None
from M02_MasterTemplete import M02_Hopyo_Elem as elem
from M02_MasterTemplete import M02_Hopyo_Row as hrow
line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")}
if (
kind == "기계"
): # 812-9 — 하나 = 이음 · 아니면 표지(후보 여럿 · 못 찾음 · 조용히 넘기지 않음)
from M02_MasterTemplete import M02_Machine_Find as mfind
got = mfind.find(line["이름"], line["규격"])
return got["키"], mfind.mark(got, line["이름"], line["규격"])
if kind == "재료":
if elem.oil_rows(line["이름"]):
kind = "유가"
elif line["이름"].strip().upper() in {x.upper() for pair in hrow.CURRENCY for x in pair}:
kind = "환율"
if kind == "유가": # MO 전부 → 줄 규격이 상세구분(전국 · 지역)이면 그것만
got = [
{"키": r["키"], "규격": str(r.get("상세구분") or "")}
for r in elem.oil_rows(line["이름"])
]
spec = line["규격"].replace(" ", "")
got = (
[x for x in got if x["규격"] == spec]
or [x for x in got if spec and spec in x["규격"]]
or got
)
else:
got = hrow.m01_picks(kind, line)
keys = {x.get("키") for x in got if x.get("키")}
if len(keys) == 1:
return keys.pop(), None
if len(keys) > 1 and kind in ("유가", "환율"):
return None, f"{NOTE}{kind} 후보 여럿({len(keys)}) · 사람 확인"
return None, None
def hourly_text(key: str | None) -> str | None:
"""기계 EQ → 시간당 기계경비 로직 부름 글(797-1 짝 고름) · 키 · 자료 없음 = None."""
if not key:
return None
from M02_MasterTemplete import M02_Doc_Import as dim
got = dim.hourly_link(key, "노무비")
if not got:
return None
return f"로직({got['키']}{''.join(f', {k}={v}' for k, v in got['입력'].items())})"
# ── 기계 부분(812-9 · 조합 기계 = 줄 둘) ─────────────────────────────────
def machine_parts(src: dict) -> list[dict]:
"""기계 자원 → 부분 찾기(`M02_Machine_Find.find_all` · 조합 = 둘 이상) · 기계 아님 = []."""
@@ -240,6 +187,13 @@ def machine_parts(src: dict) -> list[dict]:
return mfind.find_all(src.get("이름"), src.get("규격"))
def first_spec(one: dict, parts: list[dict]) -> dict:
"""조합 기계 첫 줄 규격 = 그 기계 규격(칩 글 · 「0.7+브레이카」 아님 · 814-1) — 자원 원문은 그대로."""
if len(parts) > 1 and parts[0].get("규격") is not None:
one["규격"] = parts[0]["규격"]
return one
def part_line(main: dict, part: dict, names: "Names") -> dict:
"""조합 기계 다음 줄 — 같은 수량 식 · 값 0(파일 경비 = 한 덩이 = 첫 줄) · 제 이음."""
res = {**main["자원"], "이름": part["이름"], "규격": part["규격"], "찾기": part, "조합": True}
@@ -248,8 +202,9 @@ def part_line(main: dict, part: dict, names: "Names") -> dict:
title = part["후보"][0]["이름"] if part["키"] else part["이름"]
one = {"이름": names.item(str(title)), "규격": part["규격"], "단위": main.get("단위") or ""}
one |= {"수량": main["수량"], "자원": res}
if main.get("종류") == "로직":
one |= {"종류": "로직", "값묶음": bundle(res), "이음": hourly_text(part["키"])}
if main.get("종류") == "로직": # 우리 조합 식(814-1 `조합식`)에 든 다음 줄 = 이음 없음 · 값 0
link = None if res.get("조합식") else hourly_text(part["키"])
one |= {"종류": "로직", "값묶음": bundle(res), "이음": link}
else:
one |= {"종류": main["종류"], "비목": main["비목"], "단가": Decimal(0), "이음": part["키"]}
return one | ({"자리": main["자리"]} if main.get("자리") else {})
@@ -439,6 +394,8 @@ class _Stm:
unt = (pr.get("meta") or {}).get("UNT")
one_, to = node["자원"]["비목"], node["자원"]["옮김"]
name = _label(node) or str(pr.get("이름") or code)
if node.get(_MERGED): # 814-1 — 기계 세 줄(노 · 재 · 경) = 기계 한 줄 · 이름 = 기계
name = str(pr.get("이름") or code)
if unt in ev.SPECIAL: # 특수 % — 처음부터 비목 누계 · 앞 계표시
dst = to or {"L": "L", "M": "M", "S": "S"}.get(code[0])
if unt == "T%":
@@ -463,11 +420,13 @@ class _Stm:
res |= {"조합첫": True} if len(parts) > 1 else {}
else:
link, many = _m01(res["갈래"], res) if self.keys else (None, None)
one = resource(o.names.item(name), pr.get("단위"), cf, res, link)
one = first_spec(resource(o.names.item(name), pr.get("단위"), cf, res, link), parts)
if parts and one["종류"] == "로직":
one["이음"] = hourly_text(link)
one["이음"] = combo_link(one, parts) or hourly_text(link)
self.line(one, node, True, src)
bare = {k: v for k, v in node.items() if k != "번지"}
for at, glyph in node.get(_MERGED) or []: # 묶은 다른 두 줄 원문 = 같은 자리
o.glyph(at, glyph, one["이름"], self.rows.get(at, {}).get("C"))
bare = {k: v for k, v in node.items() if k not in ("번지", _MERGED)}
for p in parts[1:]:
self.line(part_line(one, p, o.names), bare, True, src)
seen = (res.get("이름"), res.get("규격"))
@@ -552,11 +511,84 @@ def _ratios(meta: dict) -> str:
return " · ".join(f"{n} {v}" for n, v in pairs if v is not None and abs(_num(v) - 1) > 1e-9)
_MERGED = "_묶음줄" # 세 줄을 묶은 줄 나무 — 나머지 두 줄 [(행, 글)]
_BAD = ("셈 못 함", "엔진 꼴 없음")
def triples(nodes: list[dict]) -> list[list[int]]:
"""814-1 — 같은 기계(X 코드)의 노 · 재 · 경 세 참조 줄(같은 수량 식 · 번지 · 빼기 · 옮김 없음) 자리
[[i, j, k], …] — 사이 빈 줄은 건너뜀."""
out, k = [], 0
while k < len(nodes):
at, j = [k], k + 1
while len(at) < 3 and j < len(nodes):
if nodes[j].get("종류") == "빈" and not str(nodes[j].get("글") or "").strip():
j += 1
continue
at.append(j)
j += 1
if len(at) == 3 and _same_machine([nodes[i] for i in at]):
out.append(at)
k = j
else:
k += 1
return out
def merge(nodes: list[dict], groups: list[list[int]]) -> list[dict]:
"""세 줄 자리 → 비목 없는 한 줄(값묶음 · 조합이면 그 뒤 기계마다 줄) · 나머지 두 줄 원문은 같은 자리."""
first = {g[0]: g for g in groups}
drop = {i for g in groups for i in g[1:]}
out = []
for i, node in enumerate(nodes):
if i in first:
res = {**node["자원"], "비목": None, "옮김": None}
rest = [(nodes[j].get("행"), nodes[j].get("글") or "") for j in first[i][1:]]
out.append({**node, "자원": res, _MERGED: rest})
elif i not in drop:
out.append(node)
return out
def _same_machine(three: list[dict]) -> bool:
if any(x.get("종류") != "참조" or x.get("오류") or not x.get("자원") for x in three):
return False
res = [x["자원"] for x in three]
if not str(res[0].get("코드") or "").startswith("X") or len({r["코드"] for r in res}) != 1:
return False
if sorted(str(r.get("비목")) for r in res) != ["L", "M", "S"] or any(
r.get("옮김") for r in res
):
return False
return all(len({repr(x.get(k)) for x in three}) == 1 for k in ("식", "번지", "빼기"))
def stmate(
block: dict, nodes: list[dict], prices: dict, rule: str, keys: bool = False, file: str = ""
) -> dict:
"""STmate 블록(`M02_San_Book.stmate_blocks` 한 개) + 줄 나무 + 같은 책 단가 → {로직, 원본}.
rule = 블록 끝전(비목버림 | 합버림_큰나머지 · 파일 설정) · keys = 자원 M01 키(이음) 찾기."""
rule = 블록 끝전(비목버림 | 합버림_큰나머지 · 파일 설정) · keys = 자원 M01 키(이음) 찾기 ·
기계 세 줄 = 한 줄(`triples` · 814-1) — 묶어서 셈 못 하는 줄이 생기면 묶지 않은 꼴."""
groups = triples(nodes)
out = _stmate(block, merge(nodes, groups), prices, rule, keys, file)
bad = _bad(out)
if not groups or not bad:
return out
took: list[
list[int]
] = [] # 묶어서 셈 못 하는 세 줄만 그대로(율 범위 C · 누계 % 가 비목 하나를 부름)
base = _bad(_stmate(block, nodes, prices, rule, keys, file))
for g in groups:
if _bad(_stmate(block, merge(nodes, [*took, g]), prices, rule, keys, file)) <= base:
took.append(g)
return _stmate(block, merge(nodes, took), prices, rule, keys, file)
def _bad(out: dict) -> int:
return sum(1 for m in out["원본"]["표지"] if any(b in m for b in _BAD))
def _stmate(block: dict, nodes: list[dict], prices: dict, rule: str, keys: bool, file: str) -> dict:
me = prices.get(block["코드"]) or {}
meta = me.get("meta") or {}
o = Out(block.get("이름"), me.get("단위"), "STmate", block["코드"])
+167
View File
@@ -0,0 +1,167 @@
"""경쟁사 산근 줄 → M01 요소 이음(PLAN 814-1 · `M02_San_Emit` 에서 뗌 — 셈 꼴 글 · 표지 그대로).
`m01` = 1차 줄 판정 유일 짝(인력 · 재료 · 기계 · 유가 · 환율) · `hourly_text` = 기계 시간당 경비 부름 글 ·
`combo_link` = 조합 기계(굴착기 + 브레이커 · 집게) 우리 조합 식 부름 글.
"""
from __future__ import annotations
import re
import unicodedata
NOTE = "참고 · " # `M02_San_Emit.NOTE` 와 같음(사람 확인 표지 머리)
OIL_BASE = ("전국평균", "일반용(갑)Ⅰ 저압") # 유가 · 전력 기본(요구 748 · 우리 기초 기본값)
BASE_REGION = "서울" # 같은 이름 · 규격 자재가 지역마다 있을 때 기본(우리 기초 검색어 꼴)
_REGION = re.compile(r"\s*,\s*([가-힣]{2,4})\s*$") # 자재 이름 지역 꼬리 「, 서울」 · 「,서울」
def m01(kind: str, src: dict) -> tuple[str | None, str | None]:
"""1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 ·
환율만 — 재료 · 인력은 조용히 이음 없음) · 기계 = 기계 찾기(812-9) 못 이으면 늘 표지. 재료 줄 이름이 M01 유가전력(MO)이면
유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님)."""
if kind not in ("인력", "재료", "기계"):
return None, None
from M02_MasterTemplete import M02_Hopyo_Elem as elem
from M02_MasterTemplete import M02_Hopyo_Row as hrow
line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")}
if (
kind == "기계"
): # 812-9 — 하나 = 이음 · 아니면 표지(후보 여럿 · 못 찾음 · 조용히 넘기지 않음)
from M02_MasterTemplete import M02_Machine_Find as mfind
got = mfind.find(line["이름"], line["규격"])
# 찾기 판정 없는 기계 줄 막음(814-1 · 줄 쌓기 표지 · 별칭)
src.setdefault("찾기", {**got, "이름": got.get("이름") or line["이름"], "규격": got.get("규격") or line["규격"]}) # fmt: skip
return got["키"], mfind.mark(got, line["이름"], line["규격"])
if kind == "재료":
if elem.oil_rows(line["이름"]):
kind = "유가"
elif line["이름"].strip().upper() in {x.upper() for pair in hrow.CURRENCY for x in pair}:
kind = "환율"
if kind == "유가": # MO 전부 → 줄 규격이 상세구분(전국 · 지역)이면 그것만
got = [
{"키": r["키"], "규격": str(r.get("상세구분") or "")}
for r in elem.oil_rows(line["이름"])
]
spec = line["규격"].replace(" ", "")
got = (
[x for x in got if x["규격"] == spec]
or [x for x in got if spec and spec in x["규격"]]
or got
)
else:
got = hrow.m01_picks(kind, line)
keys = {x.get("키") for x in got if x.get("키")}
if (
len(keys) > 1 and kind == "유가"
): # 814-1 — 전국평균 · 전력 = 일반용(갑)Ⅰ 저압(지역 · 계약종별은 옵션)
keys = {x["키"] for x in got if x["규격"] in OIL_BASE} or keys
elif len(keys) > 1 and kind == "재료": # 814-1 — 지역 꼬리 뗀 이름 · 규격 정확 일치
keys = exact(line, got) or keys
if len(keys) == 1:
return keys.pop(), None
if len(keys) > 1 and kind in ("유가", "환율"):
return None, f"{NOTE}{kind} 후보 여럿({len(keys)}) · 사람 확인"
return None, None
def _flat(text: object) -> str:
return re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(text or ""))).lower()
def region_of(name: object) -> tuple[str, str]:
"""자재 이름 → (지역 꼬리 뗀 이름, 지역 | 빈 글)."""
text = str(name or "")
m = _REGION.search(text)
return (text[: m.start()], m.group(1)) if m else (text, "")
def exact(line: dict, got: list[dict]) -> set[str]:
"""재료 후보 가운데 지역 꼬리 뗀 이름 · 규격이 줄과 정확히 같은 것(빈칸 · 전각 · 대소문자 정규화) —
지역마다 여럿이면 줄 지역 › 서울 › 같은 단위 › 키 차례 첫째 하나 · 없음 = 빈 모음(사람 확인)."""
name, mine = region_of(line.get("이름"))
want = (_flat(name), _flat(line.get("규격")))
hits = [x for x in got if x.get("키") and (_flat(region_of(x["이름"])[0]), _flat(x.get("규격"))) == want] # fmt: skip
if not hits:
return set()
unit = _flat(line.get("단위"))
def rank(x: dict) -> tuple:
where = region_of(x["이름"])[1]
return (where != mine if mine else True, where != BASE_REGION, _flat(x.get("단위")) != unit, str(x["키"])) # fmt: skip
return {min(hits, key=rank)["키"]}
def hourly_text(key: str | None) -> str | None:
"""기계 EQ → 시간당 기계경비 로직 부름 글(797-1 짝 고름) · 키 · 자료 없음 = None."""
if not key:
return None
from M02_MasterTemplete import M02_Doc_Import as dim
got = dim.hourly_link(key, "노무비")
if not got:
return None
return f"로직({got['키']}{''.join(f', {k}={v}' for k, v in got['입력'].items())})"
def combo_link(one: dict, parts: list[dict]) -> str | None:
"""조합 기계 둘이 우리 조합 식(GC000270 · 271) 고르기에 있으면 그 부름 글 · 줄 자원에 `조합식` 키."""
from M02_MasterTemplete import M02_Machine_Hourly as mh
got = mh.combo(parts[0]["키"], parts[1]["키"]) if len(parts) == 2 and all(p["키"] for p in parts) else None # fmt: skip
if got:
one["자원"]["조합식"] = got[0]
return got[1] if got else None
# ── 못 이은 줄 까닭(814-1 · 요구 745 C2) ─────────────────────────────────
FREIGHT = ("운임", "화물", "운반비", "하차")
def why(item: dict) -> str:
"""이음 없는 가져온 줄 → 까닭 한 마디(문서 설명 · 띠 · 원문 카드) — 「없음」은 흠이 아니라 우리 정보로
못 이은 까닭(자재 없음 · 운임표 없음 · 기계 표에 없음 · 별칭 대기 · 후보 여럿 · 시간당 경비 자료 없음 · 갈래 밖)."""
res = item.get("자원") or {}
kind = res.get("갈래") or item.get("종류")
name = str(res.get("이름") or item.get("이름") or "")
if res.get("조합식"):
return f"조합 — 우리 조합 식 {res['조합식']} 에 듦"
if kind == "기계":
found = res.get("찾기") or {}
verdict = found.get("판정")
if verdict == "못 찾음":
return (
"별칭 대기"
if str(found.get("까닭") or "").startswith("기종 모름")
else "기계 표에 없음"
)
if verdict == "후보 여럿":
return "기계 후보 여럿 — 고름"
return "시간당 경비 자료 없음" if found.get("키") else "기계 찾기 없음"
if kind == "재료":
from M02_MasterTemplete import M02_Hopyo_Elem as elem
from M02_MasterTemplete import M02_Hopyo_Row as hrow
if elem.oil_rows(name):
return "유가 후보 여럿 — 고름"
line = {"이름": name, "규격": res.get("규격") or "", "단위": res.get("단위") or ""}
if hrow.m01_picks("재료", line):
return "자재 후보 여럿 — 고름"
return "운임표 없음" if any(w in name for w in FREIGHT) else "자재 없음(우리 자재표)"
if kind == "인력":
return "노임 없음"
if kind == "단가산출":
return "안쪽 단가산출 못 이음"
return "갈래 밖(견적 · 고정 값)"
def tally(logic: dict) -> dict:
"""우리 정보로 셈 — 자원 줄 {됨, 줄, 조합(형제 단가산출 부름뿐)} (연결 판정 「없음」 띠 · 814-1)."""
items = logic.get("호표") or []
done = sum(
1 for x in items if x.get("이음") or x.get("참조") or (x.get("자원") or {}).get("조합식")
)
inner = [x for x in items if (x.get("자원") or {}).get("갈래") == "단가산출" or x.get("참조")]
return {"됨": done, "줄": len(items), "조합": bool(items) and len(inner) == len(items)}
+7 -4
View File
@@ -94,10 +94,13 @@ def upload_native(folder: str | Path, name: str, data: bytes, who: Any) -> dict[
native = fn.read(name, data)
except fn.NativeError as e:
raise store.StoreError(400, str(e)) from e
row = hopyo_store.save(folder, name, fn.hopyo(native), who, fn.grid(native), mark)
path = _path(folder, row["키"])
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"파일": name, "원파일": native})
def first(key: str) -> None: # HP 를 목록에 보이기 전에 산근 보관(814-1)
path = _path(folder, key)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_write_json(path, {"파일": name, "원파일": native})
row = hopyo_store.save(folder, name, fn.hopyo(native), who, fn.grid(native), mark, first)
return {**row, "원파일": True}
+4 -2
View File
@@ -304,11 +304,13 @@ def same_part(doc: Any) -> dict | None:
def unlinked(doc: dict) -> list[dict]:
"""이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름}]`."""
"""이음 없는 자원 줄(표지 · 사람이 고름) `[{자리, 이름, 까닭}]`(까닭 = 814-1 `M02_San_Link.why`)."""
from M02_MasterTemplete import M02_San_Link as link
row = row_of(doc) or {}
ids = sd.line_ids(row)
return [
{"자리": ids[n], "이름": one.get("이름") or ""}
{"자리": ids[n], "이름": one.get("이름") or "", "까닭": link.why(one)}
for n, one in enumerate(doc[KEY]["로직"].get("호표") or [])
if not one.get("이음") and not one.get("참조")
]
@@ -22,6 +22,8 @@ export interface MatchFound {
금액?: { 가져온?: Record<string, number | null>; 우리?: Record<string, number | null> };
후보수?: number;
가까움?: { 키?: string; 이름?: string; 다른?: { 항: string; 글: string }[] };
/** 우리 정보로 셈(814-1) — 자원 줄 이음 수 · 형제 단가산출 부름뿐 = 조합 */
우리정보?: { 됨?: number; 줄?: number; 조합?: boolean };
}
export type MatchAction = "찾기" | "바꾸기" | "되돌리기" | null;
@@ -114,9 +116,13 @@ export function matchBand(sk: SkLike | null | undefined): MatchBand | null {
};
}
const near = f.가까움;
const mine = f.우리정보; // 「없음」 = 흠 아님(814-1 · 요구 745) — 우리 정보로 다시 만든 정도
const made = mine?.줄 ? ` — 우리 정보로 셈 ${mine.됨 ?? 0}/${mine.줄} 줄` : "";
return {
tone: "info",
text: `없음 — 우리 기초 후보 ${f.후보수 ?? 0} 개와 맞지 않음`,
text: mine?.조합
? `조합 — 다른 단가산출 ${mine.줄 ?? 0} 개 묶음(안의 단가산출로 셈)`
: `우리 기초 없음${made}`,
details: near ? [`가장 가까운 ${near.키 ?? ""} ${near.이름 ?? ""}`, ...lines(near.다른)] : [],
action: null,
};
@@ -42,6 +42,8 @@ export interface Origin {
요약?: string;
/** 원파일 확정 「확정 N · 표지 N · 추정 N · 확정률 N %」 */
확정?: string;
/** 확정 칸 이름 — 원파일 = 「원파일 확정」 · 엑셀 = 「엑셀 읽기 확정」(814-1) */
확정이름?: string;
}
const BOOKS: [RegExp, string][] = [
@@ -111,6 +113,7 @@ export function originOf(meta: OriginMeta | null | undefined): Origin | null {
...(sure
? {
확정: `확정 ${sure.확정 ?? 0} · 표지 ${sure.표지 ?? 0} · 추정 ${sure.추정 ?? 0} · 확정률 ${(Math.floor(rate * 1000) / 10).toFixed(1)} %`,
확정이름: src.원파일 ? "원파일 확정" : "엑셀 읽기 확정",
}
: {}),
};