feat(812-9): 가져온 기계 잇기 다시 — 기계표 전체 · 표기 정규화 · 괄호 낱말 · 범위 규격 · 별칭 먼저 · 조합 기계 둘 · 조회어 기종만

- 새 `M02_Machine_Find` — 기종 = 기계표 이름 · 괄호 붙임 · 상세구분(낱말표 24 안 씀) · 소리 표기 한 글(로우더 = 로더 · 로울러 = 롤러 · 브레이카 = 브레이커 · 굴삭기 = 굴착기 · 램 머 = 래머 …) · 기계표 괄호 낱말로 좁힘(표에 없는 낱말 발파 · 리핑 = 안 봄) · 규격 = 수 + 단위 전부(범위 · 복수 · 인치 = ㎝) · 하나 = 이음 · 여럿 = 「기계 후보 n — 고르기」 · 0 = 「기계 못 찾음」 표지(가까운 규격으로 대신 잇지 않음)
- 별칭 먼저 — 마스터 층 + 지금 층 별칭 장부 기계 짝(이름 + 규격 · 이름만 · 규격 속 할증 글 뺌) · `M02_Doc_Match_Note._ALIAS` 여섯 쌍 걷음(소리 표기 = 정규화 · 같은 기종 = 기계표 · 별칭 장부)
- 조합 기계 = 기계마다 항목 하나(요구 738-3 · 제목 = 그 기계 · 줄 쌓기 차림 `M02_Doc_NativeLayout`) · 줄 둘(「+」 · 괄호 속 다른 기종 · 「…조합」 · 덤프트럭(자동덮개) = 덤프트럭 + 자동덮개시설) — 같은 수량 식 · 엑셀 값은 첫 줄 · 다음 줄 0 + 표지 「조합 기계 — 경비는 한 덩이」 · 우리 단가로 = 각자 시간당 경비
- 원파일 EST · STmate · 엑셀 읽기기 · 일위대가 호표(`Hopyo_Elem` · `Hopyo_Fixed._eqs` · `Hopyo_Row.m01_picks`)가 같은 찾기 · STmate 묶음 기계 줄도 시간당 경비 이음
- 문서 머리 `가져온.기계`(기호마다 판정 · 후보 · 찾기어) · 못 이은 기계 줄도 기계 줄(이름 단추 · `owned_of` 파이썬 · TS 짝) · 이름 단추 조회어 = 기종 이름만(규격 뺌) · 고르면 엑셀 값 기억 그대로(금액 안 바뀜) · 문서 [저장] = 고른 EQ → 이 층 별칭 장부 줄 → M01 별칭 장부 후보(승인 흐름 그대로) · 장부 짝 글 = 「기계 EQ…」
- 마스터 층 별칭 장부(resources/master_template/실무자료/맞춤.json) = 사용자 승인 다섯(요구 735-5 · 736) — 아스팔트페이버(피니셔) = 아스팔트 피니셔 · 디젤 엔진 = 엔진(디젤) · 워터제트 = 워터젯트 · 버킷준설기 = 버킷식준설기 · 바이브레이트 봉상후렉시블 = 콘크리트 진동기 엔진식 ø45(2.6㎾)
- 시험 — 새 test_812_9(예 전부 · 정규화 · 괄호 · 범위 · 조합 둘 · 별칭 먼저 · 표지 · 고르면 별칭 줄 · 금액 전후) · test_812_3 전석쌓기 · 측구터파기 연암 항목을 기계마다 항목으로 고침(조합 기계 둘로 가름 · 요구 738-3 이 바꾼 까닭)
- 전수 목록 docs/raw/요구_1104/_목록_812-9_기계이음.xlsx

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 21:02:29 +09:00
co-authored by Claude Opus 5.5
parent af156a3c3a
commit 3d4b2ec1d1
20 changed files with 1011 additions and 123 deletions
+25 -1
View File
@@ -239,6 +239,21 @@ def _link_of(item: dict) -> tuple[dict | None, str]:
return link, ""
MACHINES = "_기계" # 문서 셈 동안 잠깐 — `derive` 가 머리 `가져온.기계` 로 옮김
def _machine_entry(item: dict, sym: str) -> dict:
"""기계 줄 찾기 판정 한 칸 — {기호, 원래 {이름, 규격, 단위}, 판정, 키, 후보, 찾기어, 까닭, 조합?}."""
res = item["자원"]
got = res["찾기"]
one = {
"기호": sym,
"원래": {"이름": got["이름"], "규격": got["규격"], "단위": item.get("단위")},
}
one |= {k: got.get(k) for k in ("판정", "키", "후보", "찾기어", "까닭")}
return one | {k: True for k in ("조합", "조합첫") if res.get(k)}
def _excel(item: dict) -> Any:
if item.get("값묶음"):
return {k: Decimal(str(item["값묶음"].get(k) or 0)) for k in imp.COSTS}
@@ -246,12 +261,16 @@ def _excel(item: dict) -> Any:
def _attach(doc: dict, row: dict, ids: list[str], src: dict[str, dict]) -> None:
"""금액 줄 단가 숫자(엑셀 값) → 이은 연결 + 엑셀 값 기억(SK 고침으로 바뀐 값 = 숫자 그대로)."""
"""금액 줄 단가 숫자(엑셀 값) → 이은 연결 + 엑셀 값 기억(SK 고침으로 바뀐 값 = 숫자 그대로) ·
기계 찾기 판정(812-9) = 문서 `가져온.기계`(단가 기호마다 · 화면 고르기 · 저장 때 별칭)."""
by_id = {dc.md.var_id(v): v for v in doc.get("변수") or []}
found: list[dict] = doc.setdefault(MACHINES, [])
for at, line in enumerate((doc.get("금액") or [])[: len(row["호표"])]):
item = src.get(ids[at]) if at < len(ids) else None
price = str(line.get("단가") or "")
var = by_id.get(price.removesuffix(".계"))
if item is not None and var is not None and (item.get("자원") or {}).get("찾기"):
found.append(_machine_entry(item, dc.md.var_id(var)))
if item is None or var is None or (var.get("연결") or {}).get("갈래") != "숫자":
continue
excel = _excel(item)
@@ -322,8 +341,13 @@ def derive(sk: dict, ctx: tuple | None = None, whole: Any = None) -> dict:
if not (doc.get("금액") or doc.get("결과") or row["호표"] or row.get("덧줄")):
_empty(doc) # 빈 산근 = 엑셀 0 원(줄 없음 합) — 「—」 아님
marks.append(EMPTY)
found = doc.pop(MACHINES, None)
head |= {"기계": found} if found else {} # 812-9 기계 찾기 판정(줄 쌓기 고르기 · 저장 때 별칭)
doc["가져온"] = head | {"표지": marks} | ({"알림": notes} if notes else {})
shape.shape(doc, meta) # 809-1 · 요구 704 — 지은 이름 → 엑셀 글 머리 · EST 기계 세 줄 = 한 줄
if found: # 세 줄을 한 줄로 합쳐 사라진 기호의 기계 판정은 뺌
alive = {dc.md.var_id(v) for v in doc.get("변수") or []}
doc["가져온"]["기계"] = [x for x in found if x["기호"] in alive]
return {**doc, "키": str(sk.get("키") or key), "이름": str(sk.get("이름") or doc.get("이름"))}
+8 -8
View File
@@ -14,6 +14,7 @@ from decimal import Decimal, InvalidOperation
from typing import Any
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Machine_Find as mfind
md = dc.md
PRICED = ("노임", "재료", "기계") # 시간당 · 단위 단가 = 값(참고)
@@ -130,15 +131,9 @@ def txt(v: Any) -> str:
return str(v)
_ALIAS = (("로울러", "롤러"), ("굴삭", "굴착"), ("브레이카", "브레이커"), ("로우더", "로더"))
_ALIAS += (("물탱크", "살수차"), ("아스팔트페이버", "아스팔트피니셔")) # 같은 기계 다른 이름
def _plain(text: str) -> str:
t = re.sub(r"\s", "", text)
for old, new in _ALIAS:
t = t.replace(old, new)
return t
"""표기 정규화(소리 표기 · 빈칸) — 기계 찾기와 같은 규칙(812-9 `M02_Machine_Find.norm`)."""
return mfind.norm(text)
def _head(text: str) -> str:
@@ -148,6 +143,11 @@ def _head(text: str) -> str:
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)
+27 -21
View File
@@ -79,6 +79,7 @@ class Block:
self.got = {"확정": 0, "표지": 0, "추정": 0}
self.memo: list[int] = [] # 글 줄 행(812-3 차림 · 분수 그림 위아래 줄 뺌)
self.frac: set[int] = set()
self.noted: set[tuple] = set() # 기계 표지 단 자원(812-9 · 블록에 한 번)
# ── 작은 도구 ──────────────────────────────────────────────────
def mark(self, ln: dict, why: str) -> None:
@@ -313,6 +314,9 @@ class Block:
prices = {ln["비목"]: (ln.get("연결단가") or [0])[0] for ln in rows}
r = self.res(rows[0], None, None)
r |= {"단가": prices}
parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
r |= {"찾기": parts[0]} if parts else {}
r |= {"조합첫": True} if len(parts) > 1 else {}
head = next(
(b for b in reversed(self.lines[: self.lines.index(rows[0])])
if (b.get("자원") or {}).get("코드") == r.get("코드") and not b.get("비목")),
@@ -321,7 +325,8 @@ 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}
one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": self.logic_link(r), "자원": r}
link = em.hourly_text(parts[0]["키"]) if parts else None
one |= {"종류": "로직", "값묶음": em.bundle(r), "이음": link, "자원": r}
one |= {"자리": place} if place else {}
value = total["결과"][-1] if total and total.get("결과") else None
for ln in rows: # 노 · 재 · 경 글 = 펼침(맞댈 값은 한 줄 계 — 소계 글 · 없으면 첫 줄)
@@ -329,37 +334,38 @@ class Block:
at = total or rows[0]
value = value if value is not None else sum(ln["결과"][-1] for ln in rows)
self.o.add_line(one, True, at["행"], at.get("보임") or "", value)
call = self.parts(one, parts, at, rows[0])
if total:
self.register(total, f"줄.'{name}'")
self.register(total, call)
self.got["확정"] += 3
def parts(self, one: dict, parts: list[dict], at: dict, ln: dict) -> str:
"""조합 기계 다음 줄(값 0 · 제 이음) · 기계 표지(블록에 한 번) → 이 기계 부름 글(줄 합)."""
calls = [f"줄.'{one['이름']}'"]
for p in parts[1:]:
extra = em.part_line(one, p, self.o.names)
self.o.add_line(extra, True, at["행"], at.get("보임") or "", None)
calls.append(f"줄.'{extra['이름']}'")
seen = (one["자원"].get("이름"), one["자원"].get("규격"))
if seen not in self.noted:
self.noted.add(seen)
for m in em.part_marks(one["자원"], parts):
self.mark(ln, m.removeprefix(NOTE))
return calls[0] if len(calls) == 1 else f"({' + '.join(calls)})"
def machine_line(self, ln: dict, got: tuple | None) -> None:
qty, place = got if got else ("1", self.frozen(ln, "수량 식 못 읽음")[1])
price = (ln.get("연결단가") or [ln["결과"][-1]])[0]
r = self.res(ln, ln["비목"], price if got else ln["결과"][-1])
r["갈래"] = "기계" if r["갈래"] == "기계" else r["갈래"]
name = self.o.names.item(f"{ln.get('품명') or r.get('이름') or '줄'} {COMP[ln['비목']]}")
link, _ = em._m01(r["갈래"], r) if self.keys else (None, None)
one = em.resource(name, r.get("단위"), qty, r, link)
parts = em.machine_parts(r) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
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)
one |= {"자리": place} if place else {}
self.o.add_line(one, True, ln["행"], ln.get("보임") or "", ln["결과"][-1])
self.got["확정"] += bool(got)
self.register(ln, f"줄.'{name}'")
def logic_link(self, r: dict) -> str | None:
"""기계 = 시간당 기계경비 로직 부름 글(797-1 짝 고름) · 그 밖 = 없음(산근 = 형제 SK 참조)."""
if not self.keys or r.get("갈래") != "기계":
return None
key, _ = em._m01("기계", r)
if not key:
return None
from M02_MasterTemplete import M02_Doc_Import as dim
got = dim.hourly_link(key, "노무비")
if not got:
return None
args = "".join(f", {k}={v}" for k, v in got["입력"].items())
return f"로직({got['키']}{args})"
self.register(ln, self.parts(one, parts, ln, ln))
def subtotal(
self, ln: dict, kids: list[str], part: str | None = None, glyph: bool = True
+34 -1
View File
@@ -166,6 +166,12 @@ def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dic
items: list[dict] = []
cur: dict | None = None
cut = False # STmate 계 줄 뒤 — 다음 첫 글 줄 = 제목
money = doc.get("금액") or []
mach = {
str(x.get("기호")): x
for x in (doc.get("가져온") or {}).get("기계") or []
if isinstance(x, dict) and (x.get("조합") or x.get("조합첫"))
} # 조합 기계(812-9 · 요구 738-3) = 기계마다 항목 하나 · 제목 = 그 기계
def new(title: str = "", sub: bool = False) -> dict:
it = {"제목": title, "가나다": sub, "줄": [], "합계": False}
@@ -186,6 +192,11 @@ def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dic
cur = new(title, sub)
cut = False
else:
one = mach.get(price_sym(money[val]) or "") if kind == "금액" else None
if one and one.get("조합"):
cur = new(_mach_title(one), bool(cur and cur["가나다"]))
elif one and cur is not None and "+" in cur["제목"]:
cur["제목"] = _mach_title(one)
cur = cur if cur is not None else new()
if kind == "공식" and cur["줄"] and cur["줄"][-1][0] == "공식":
cur["줄"][-1][1].extend(x for x in val if x not in cur["줄"][-1][1])
@@ -200,6 +211,16 @@ def _items(doc: dict, texts: list[dict], rows: list[int], stm: bool) -> list[dic
return keep
def _mach_title(one: dict) -> str:
"""조합 기계 항목 제목 — 이은 우리 기계 이름 · 규격 › 가져온 이름 · 규격."""
first = (one.get("후보") or [{}])[0] if one.get("키") else {}
said = first or {
"이름": (one.get("원래") or {}).get("이름"),
"규격": (one.get("원래") or {}).get("규격"),
}
return " ".join(str(said.get(k) or "") for k in ("이름", "규격")).strip()
def _save_item(it: dict, money: list[dict]) -> dict:
out: dict[str, Any] = {}
if it["제목"]:
@@ -269,6 +290,12 @@ def price_sym(line: dict) -> str | None:
return None
def machine_syms(doc: dict) -> set[str]:
"""가져온 기계 줄 단가 기호(812-9 `가져온.기계`) — 못 이어 숫자 연결이어도 그 줄 기계(이름 단추 · 고르기)."""
got = (doc.get("가져온") or {}).get("기계") if isinstance(doc.get("가져온"), dict) else None
return {str(x.get("기호")) for x in got or [] if isinstance(x, dict)}
def owned_of(doc: dict) -> dict[int, str]:
count: dict[str, int] = {}
for ns in (
@@ -280,11 +307,17 @@ def owned_of(doc: dict) -> dict[int, str]:
count[n] = count.get(n, 0) + 1
vars_ = {md.var_id(v): v for v in doc.get("변수") or []}
lines = {str(x.get("기호") or "") for x in doc.get("식") or []}
mach = machine_syms(doc)
out = {}
for i, line in enumerate(doc.get("금액") or []):
s = price_sym(line)
kind = ((vars_.get(s) or {}).get("연결") or {}).get("갈래") if s else None
if s and kind in ("요소", "단가산출") and count.get(s) == 1 and s not in lines:
if (
s
and (kind in ("요소", "단가산출") or s in mach)
and count.get(s) == 1
and s not in lines
):
out[i] = s
return out
+7 -1
View File
@@ -4,6 +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)
⚠ 등록은 `main.py`(로그인만 · 층 권한은 길 안에서).
@@ -18,6 +19,7 @@ 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"])
@@ -84,4 +86,8 @@ def save_doc(
session: Session = Depends(verify_session),
) -> dict:
folder = _root(session, layer, owner, write=True)
return _call(folder, path.save, key, body.model_dump(exclude_none=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
+18 -58
View File
@@ -4,8 +4,8 @@
고르면 지금 잇기 길 `{노임|기계|자재|유가: 키}`).
자재 = `M02_Hopyo_Material.candidates` 점수(이름 글자 묶음 · 규격) · 단가 = M01 자재 값(엑셀 줄 단위)
노임 = LB 이름 글자 묶음 닮음 · 단가 = LB 값
기계 = EQ 갈래 겹침 · 규격 수(같음 1 · 겹침 0.5) · 이름 낱말 덤 · 괄호 낱말 어긋남 깎음 ·
단가 없음(기계경비는 로직 셈) · `machine` = 하나로 가름(778-1)
기계 = 기계 찾기(812-9 `M02_Machine_Find` — 기계표 전체 · 표기 정규화 · 괄호 낱말 · 규격) 후보 ·
단가 없음(기계경비는 로직 셈) · `machine` = 하나로 정할 때만
유가(785-3) = MO 이름에 글 이름이 듦(경유 ⊂ 자동차용경유) · 전국 먼저 · 단가 = MO 값
차례 = 점수(0.1 단위로 접음) › 엑셀 단가에 가까운 것(엑셀 단가 = 블록 단위 줄 단가 · 모르면 점수만).
서버 단독 · 읽기만.
@@ -21,18 +21,11 @@ from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Material as hmat
from M02_MasterTemplete import M02_Hopyo_UnitLine as unit_line
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, nfkc, specnums
from M02_MasterTemplete import M02_Machine_Find as mfind
from M02_MasterTemplete.M02_Hopyo_Words import nfkc
TOP = 3
POOL = 5
# 괄호 낱말(778-1) — 글에 있으면 EQ 이름에도 있어야 함 · 부속 = 글에 없으면 본 장비 아님
FORMS = (
("타이어", ("타이어",)),
("무한", ("무한궤도",)),
("습지", ("습지",)),
("트럭탑재", ("트럭탑재",)),
)
PARTS = ("자동덮개", "부착용", "콤팩터", "습지")
def prices(block: dict[str, Any] | None) -> dict[str, float]:
@@ -125,57 +118,24 @@ def oils(name: str) -> list[dict[str, Any]]:
]
def _form(text: str, name: Any) -> tuple[bool, bool]:
"""괄호 낱말(778-1) — (글 형식 낱말이 EQ 이름에 다 있음, EQ 이름에 글에 없는 부속 있음)."""
t, n = nfkc(text), nfkc(name)
ok = all(any(w in n for w in ws) for k, ws in FORMS if k in t)
return ok, any(w in n and w not in t for w in PARTS)
def _eq(one: dict[str, Any]) -> dict[str, Any]:
raw = m01.loaded()[1].numbers.get("EQ", {}).get(str(one.get("원문번호")), {})
unit = (raw.get("단위") or {}).get("내용시간") if isinstance(raw.get("단위"), dict) else ""
return {"키": one["키"], "갈래": "기계", "이름": one["이름"], "규격": one.get("규격") or "",
"단위": unit or "시간", "단가": None} # fmt: skip
def narrow(text: str, rows: list[tuple[str, Any]]) -> list[str]:
"""EQ `[(키, 이름)]` → 괄호 낱말로 가른 키 — 형식 다름 뺌 · 부속은 남는 것 있을 때만 뺌."""
keep = [(c, _form(text, n)[1]) for c, n in rows if _form(text, n)[0]]
return [c for c, part in keep if not part] or [c for c, _ in keep]
def machine(text: str, *_: Any) -> dict | None:
"""기계 글(이름 규격) → EQ 한 줄 — 기계 찾기(812-9 `M02_Machine_Find`)가 하나로 정할 때만."""
got = mfind.find(text)
if got["판정"] not in (mfind.LINKED, mfind.ALIAS):
return None
return m01.loaded()[1].numbers.get("EQ", {}).get(str(got["후보"][0]["원문번호"]))
def machine(text: str, kinds: set | None = None, nums: set | None = None) -> dict | None:
"""기계 글 → EQ 한 줄 — 갈래 겹침 + 규격 수 같음 · 괄호 낱말로 하나일 때만."""
kinds = set(kinds or ()) or mtypes(text)
nums = set(nums or ()) or set(specnums(text))
rows = {
str(r.get("키")): r
for r in m01.loaded()[1].numbers.get("EQ", {}).values()
if nums
and kinds & mtypes(f"{r.get('상세구분')} {r.get('이름')}")
and nums == set(specnums(r.get("규격")))
}
got = narrow(text, [(k, r.get("이름")) for k, r in rows.items()])
return rows[got[0]] if len(got) == 1 else None
def machines(text: str, kinds: set | None = None, nums: set | None = None) -> list[dict]:
"""기계 글(이름 규격) → EQ 후보 — 갈래 · 규격 수 없으면 글에서 읽음 · 괄호 낱말 어긋남 깎음."""
whole = m01.loaded()[1]
kinds = set(kinds or ()) or mtypes(text)
nums = set(nums or ()) or set(specnums(text))
mine = hm.words(text)
rows = []
for row in whole.numbers.get("EQ", {}).values():
types = mtypes(f"{row.get('상세구분')} {row.get('이름')}")
if not kinds & types:
continue
spec = set(specnums(row.get("규격")))
score = len(kinds & types) / len(kinds | types)
score += 1.0 if nums and nums == spec else 0.5 if nums & spec else 0.0
score += 0.3 * len(mine & hm.words(row.get("이름")))
ok, part = _form(text, row.get("이름"))
score -= (0.0 if ok else 1.0) + (0.5 if part else 0.0)
one = {"키": str(row.get("키")), "갈래": "기계", "이름": row.get("이름")}
unit = (row.get("단위") or {}).get("내용시간") if isinstance(row.get("단위"), dict) else ""
rows.append(
(score, {**one, "규격": row.get("규격") or "", "단위": unit or "시간", "단가": None})
)
return _order(rows, None)
def machines(text: str, *_: Any) -> list[dict]:
"""기계 글 → EQ 후보(보여주기만 · 812-9) — 이음 · 후보 여럿 = 그것 · 못 찾음 = 같은 기종(가까운 규격 먼저)."""
return [_eq(one) for one in mfind.find(text)["후보"][:TOP]]
def lost(one: dict[str, Any], want: float | None = None) -> list[dict[str, Any]]:
+7 -14
View File
@@ -20,13 +20,12 @@ from decimal import Decimal
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Elem as elem
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Material as hmat
from M02_MasterTemplete import M02_Hopyo_Shares as shares
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Fixed as fixed
from M02_MasterTemplete.M02_Hopyo_Words import mtypes, specnums
from M02_MasterTemplete import M02_Machine_Find as mfind
COSTS = ("노무비", "재료비", "경비")
_BASE = re.compile(r"(?<!주)(노무비|재료비|경비|인건비)\s*의")
@@ -93,13 +92,9 @@ class Linker:
pair = (str(row.get("키")), _d(row.get("값")))
self.labor.setdefault(str(row.get("이름") or ""), []).append(pair)
self.machines = [
(str(row.get("키")), mtypes(f"{row.get('상세구분')} {row.get('이름')}"),
set(specnums(row.get("규격"))), mm_plain(row.get("이름")), mm_plain(row.get("규격")))
(str(row.get("키")), mm_plain(row.get("이름")), mm_plain(row.get("규격")))
for row in self.whole.numbers.get("EQ", {}).values()
] # fmt: skip
self.eq_names = {
str(r.get("키")): r.get("이름") for r in self.whole.numbers.get("EQ", {}).values()
}
]
self.near = 0 # 못 이은 이음 후보 수
def link(self, line: dict[str, Any], price: dict[str, Decimal]) -> dict[str, str | None]:
@@ -117,14 +112,12 @@ class Linker:
return out
def _eqs(self, line: dict[str, Any]) -> list[str]:
"""기계 줄 → EQ 후보 — 이름 · 규격 글 같음 › 갈래 + 규격 수 · 괄호 낱말."""
"""기계 줄 → EQ 후보 — 이름 · 규격 글 같음 › 기계 찾기(812-9 `M02_Machine_Find`)."""
name, spec = mm_plain(line.get("이름")), mm_plain(line.get("규격"))
same = [c for c, _, _, n, s in self.machines if n == name and s == spec]
same = [c for c, n, s in self.machines if n == name and s == spec]
if len(same) != 1:
text = f"{line.get('이름') or ''} {line.get('규격') or ''}"
kinds, nums = mtypes(text), set(specnums(text))
same = [c for c, t, s, _, _ in self.machines if kinds & t and nums and nums == s]
same = elem.narrow(text, [(c, self.eq_names.get(c)) for c in same])
got = mfind.find(line.get("이름"), line.get("규격"))
same = [] if got["판정"] == mfind.NONE else [c["키"] for c in got["후보"]]
return same
def candidates(self, line: dict[str, Any]) -> list[str]:
+6 -7
View File
@@ -20,6 +20,7 @@ from M02_MasterTemplete import M02_Hopyo_Elem as elem
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Material as hmat
from M02_MasterTemplete import M02_Hopyo_ReadEst as est
from M02_MasterTemplete import M02_Machine_Find as mfind
mm, mp = hm.mm, hm.mp
COSTS = ("노무비", "재료비", "경비")
@@ -65,6 +66,11 @@ def _rows(table: str) -> dict[str, dict[str, Any]]:
def m01_picks(kind: str, line: dict[str, Any]) -> list[dict[str, Any]]:
"""갈래 하나 → M01 고른 것 후보(LB · MT · MO · EQ · FX) — 유일 짝이면 하나 · 같은 글 한 번."""
if kind == "기계": # 812-9 기계 찾기 — 별칭 장부가 바뀌므로 lru 밖(찾기가 기계표를 기억)
got = mfind.find(line.get("이름"), line.get("규격"))
rows = [] if got["판정"] == mfind.NONE else got["후보"]
unit = str(line.get("단위") or "") or "hr"
return [pick("기계", x["키"], x["이름"], x.get("규격"), unit) for x in rows]
text = tuple(str(line.get(k) or "") for k in ("이름", "규격", "단위"))
return copy.deepcopy(_m01_picks(kind, *text, id(m01.loaded()[1])))
@@ -80,13 +86,6 @@ def _m01_picks(kind: str, name: str, spec: str, unit: str, _whole: int) -> list[
]
got = elem.labor(str(name or ""))
return [pick("인력", x["키"], x["이름"], "", "인", 단가=x["단가"]) for x in got]
if kind == "기계":
text = f"{name} {spec}"
one = elem.machine(text)
rows = [one] if one else []
got = [{"키": r["키"], "이름": r["이름"], "규격": r.get("규격")} for r in rows]
got = got or elem.machines(text)
return [pick("기계", x["키"], x["이름"], x.get("규격"), unit or "hr") for x in got]
if kind == "유가":
return [
pick("유가", x["키"], x["이름"], x["규격"], x["단위"], 단가=x["단가"])
+395
View File
@@ -0,0 +1,395 @@
"""M02 기계 찾기(PLAN 812-9 · 요구 718 · 719) — 가져온 기계 글(이름 · 규격) → 우리 EQ · 서버 단독.
과녁 = M01 기계표 전체(EQ) · 부르는 곳 = 불러오기 이음(`M02_Hopyo_Row.m01_picks` · `M02_San_Emit._m01`) ·
일위대가 호표(`M02_Hopyo_Elem.machine(s)` · `M02_Hopyo_Fixed._eqs`) · 연결 찾기(`M02_Doc_Match_Note`).
차례:
1. 별칭 장부(`M02_Hopyo_Ledger` · 마스터 층 + 지금 층 · 짝 갈래 기계) — 이름 + 규격 열쇠 = 그 EQ ·
이름만(규격 빈) 열쇠 = 그 EQ 이름으로 바꿔 2 로
2. 기종 = EQ 이름(괄호 뗌) · 괄호 붙임 · 상세구분과 같은 글 › 그 글을 품은 EQ(표기 정규화 `norm`)
3. 괄호 낱말 — 기계표 괄호에 있는 낱말(무한궤도 · 타이어 · 자주식 …)만 거름 · 표에 없는 낱말(발파 · 리핑)은 안 봄
4. 규격 = 수 + 단위(범위 · 여럿) 모두 EQ 규격에 같은 값(단위 한쪽 없음 = 봄) › 없으면 EQ 범위 안
하나 = 이음 · 여럿 = 「후보 여럿」 · 0 = 「못 찾음」(가까운 규격으로 대신 잇지 않음)
조합 기계(「+」 · 괄호 속 다른 기종 · 「…조합」 · 「덤프트럭(자동덮개)」) = `parts` 로 갈라 각각 찾음 ·
규격 없는 쪽은 앞 쪽 규격.
"""
from __future__ import annotations
import math
import re
import time
import unicodedata
from functools import lru_cache
from pathlib import Path
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
# 소리만 다른 표기 → 한 글(양쪽에 같이 씀 · 차례대로)
SOUND = (
("로우더", "로더"), ("로울러", "롤러"), ("브레이카", "브레이커"), ("브레카", "브레이커"),
("굴삭기", "굴착기"), ("굴삭", "굴착"), ("불도우저", "불도저"), ("램머", "래머"),
("크로울러", "크롤러"), ("콤프레샤", "공기압축기"), ("컴프레셔", "공기압축기"),
("컴프레서", "공기압축기"), ("컴팩터", "콤팩터"), ("타이어형", "타이어"), ("젯트", "제트"),
("후렉시블", "플렉시블"), ("스프레야", "스프레이어"), ("코어", "코아"),
) # fmt: skip
_UNIT = (
("m3/min", "m3/min"), ("m3/분", "m3/min"), ("m3/hr", "m3/hr"), ("m3/h", "m3/hr"),
("km/hr", "km/hr"), ("km/h", "km/hr"), ("l/min", "l/min"), ("t/hr", "t/hr"),
("kg/cm2", "kg/cm2"), ("kg/min", "kg/min"), ("m3", "m3"), ("톤용", "ton"), ("ton", "ton"),
("톤", "ton"), ("t", "ton"), ("kw", "kw"), ("mm", "mm"), ("cm", "cm"), ("kg", "kg"),
("km", "km"), ("m", "m"), ("l", "l"), ("hp", "hp"), ("amp", "a"), ("cc", "cc"), ('"', "in"),
("inch", "in"), ("인치", "in"),
) # fmt: skip
_NUM = r"\d+(?:\.\d+)?"
_TOKEN = re.compile(
rf"({_NUM})(?:\s*([~∼\-])\s*({_NUM}))?\s*("
+ "|".join(re.escape(u) for u, _ in _UNIT)
+ r")?(?![a-z])"
)
_PAREN = re.compile(r"[(\[]([^()\[\]]*)[)\]]")
_EXTRA = re.compile(
r"[:,]?\s*\(?할증율?[^)]*\)?"
) # 「0.2㎥:할증120%」 · 「(할증125%)」 = 율(규격 아님)
NOTE = "참고 · "
LINKED, ALIAS, MANY, NONE = "이어짐", "별칭", "후보 여럿", "못 찾음"
SHOW = 8 # 후보 글 · 목록 상한
def norm(text: Any) -> str:
"""표기 정규화 — NFKC · 작은 글자 · 빈칸 뺌 · 소리 표기 한 글."""
t = re.sub(r"\s+", "", unicodedata.normalize("NFKC", str(text or "")).lower())
for old, new in SOUND:
t = t.replace(old, new)
return t
def _clean(text: Any) -> str:
t = unicodedata.normalize("NFKC", str(text or "")).lower()
t = _EXTRA.sub(" ", t)
return re.sub(r"(?<=\d),(?=\d{3}(?!\d))", "", t) # 천 자리 쉼표
def tokens(text: Any) -> list[tuple[float, float, str | None]]:
"""규격 글 → [(아래, 위, 단위)] — 범위 「10~12」 · 여럿 「32m,80~95㎥/hr」 · 단위 없음 = None."""
out = []
for m in _TOKEN.finditer(_clean(text)):
lo, sep, hi, unit = float(m.group(1)), m.group(2), m.group(3), m.group(4)
top = float(hi) if hi else lo
if sep == "-" and top <= lo: # 「50-2.6m」 = 범위 아님 — 앞 수만
top = lo
canon = dict(_UNIT).get(unit) if unit else None
if canon == "in": # 인치 = ㎝(×2.54 · 우리 표는 ㎝)
lo, top, canon = round(lo * 2.54, 6), round(top * 2.54, 6), "cm"
out.append((lo, top, canon))
return out
def _unit_ok(a: str | None, b: str | None) -> bool:
return a is None or b is None or a == b or {a, b} <= {"ton", "t/hr"}
def spec_fit(mine: list, theirs: list) -> str | None:
"""가져온 규격 수가 EQ 규격에 다 있음 = 「같음」 · EQ 범위 안 = 「안」 · 아니면 None."""
if not mine:
return "같음" if not theirs else None
same = all(
any(abs(a - c) < 1e-9 and abs(b - d) < 1e-9 and _unit_ok(u, w) for c, d, w in theirs)
for a, b, u in mine
)
if same:
return "같음"
inside = all(
any(c - 1e-9 <= a and b <= d + 1e-9 and c < d and _unit_ok(u, w) for c, d, w in theirs)
for a, b, u in mine
)
return "안" if inside else None
def _words(inner: str) -> list[str]:
return [norm(w) for w in re.split(r"[\s,;·/]+", inner) if w.strip()]
def split_name(text: Any) -> tuple[str, list[list[str]], list[str]]:
"""이름(+규격) 글 → (기종 글 정규화, 괄호 낱말 묶음들, 규격 조각들) — 괄호 속 수 = 규격."""
raw = _clean(text)
groups, specs = [], []
for inner in _PAREN.findall(raw):
bits = [b for b in re.split(r"[,;]", inner) if b.strip()]
specs += [b for b in bits if re.search(r"\d", b)]
groups.append([w for b in bits if not re.search(r"\d", b) for w in _words(b)])
base = _PAREN.sub(" ", raw)
specs += [m.group(0) for m in _TOKEN.finditer(base)]
base = _TOKEN.sub(" ", base)
return norm(base), groups, specs
# ── 기계표 ────────────────────────────────────────────────────────────
class Row:
__slots__ = ("raw", "key", "base", "detail", "joined", "full", "words", "spec", "said")
def __init__(self, r: dict) -> None:
name = str(r.get("이름") or "")
self.raw, self.key = r, str(r.get("키"))
inner = _PAREN.findall(unicodedata.normalize("NFKC", name))
self.words = {w for x in inner for w in _words(x)}
self.base = norm(_PAREN.sub("", unicodedata.normalize("NFKC", name)))
self.detail = norm(r.get("상세구분"))
self.joined = {w + self.base for w in self.words} | {self.base + w for w in self.words}
self.full = norm(name)
self.spec = tokens(r.get("규격"))
self.said = norm(
_TOKEN.sub(" ", _clean(r.get("규격")))
) # 규격 글 낱말(「더블」 · 「엔진식」)
@lru_cache(maxsize=2)
def _table(_whole: int) -> tuple[list[Row], frozenset[str], dict[str, Row]]:
rows = [Row(r) for r in m01.loaded()[1].numbers.get("EQ", {}).values()]
words = frozenset(w for r in rows for w in r.words)
return rows, words, {r.key: r for r in rows}
_SEEN: list = [0.0, None] # (때, 표) — M01 판 확인(파일 도장)은 1 초에 한 번
def table() -> tuple[list[Row], frozenset[str], dict[str, Row]]:
now = time.monotonic()
if _SEEN[1] is None or now - _SEEN[0] > 1.0:
_SEEN[:] = [now, _table(id(m01.loaded()[1]))]
return _SEEN[1]
def by_code(code: Any) -> str | None:
"""원문번호 → EQ 키."""
want = str(code or "").strip("'\"")
return next((r.key for r in table()[0] if r.raw.get("원문번호") == want), None)
def kind_rows(base: str) -> list[Row]:
"""기종 글 → EQ 줄 — 이름 · 괄호 붙임 같음 › 상세구분 같음 › 그 글을 품음(첫 층만)."""
if len(base) < 2:
return []
rows = table()[0]
for test in (
lambda r: base == r.base or base in r.joined,
lambda r: base == r.detail,
lambda r: base in r.full or base in r.detail,
):
got = [r for r in rows if test(r)]
if got:
return got
return []
def _kind_word(word: str, base: str) -> str | None:
"""괄호 낱말이 다른 기종(조합)이면 그 기종 글 — 「부착용집게」 · 「브레이커조합」 · 「자동덮개」."""
w = word.removesuffix("조합")
if len(w) < 2 or w == base:
return None
rows = table()[0]
if any(w in (r.base, r.detail) for r in rows):
return w
if any(r.base.startswith(base + w) for r in rows):
return base + w
return None
# ── 조합 기계 ─────────────────────────────────────────────────────────
def parts(name: Any, spec: Any = "") -> list[tuple[str, str]]:
"""기계 글 → [(이름, 규격)] — 「+」 · 괄호 속 다른 기종이면 둘 이상 · 규격 없는 쪽 = 앞 쪽 규격 ·
하나면 받은 그대로."""
text = f"{name or ''} {spec or ''}".strip()
known = table()[1]
out: list[tuple[str, str]] = []
for piece in [p for p in text.split("+") if p.strip()] or [text]:
main, base, extra = _clean(piece), split_name(piece)[0], []
for m in _PAREN.finditer(_clean(piece)):
bits = [b for b in re.split(r"[,;]", m.group(1)) if b.strip()]
words = [w for b in bits if not re.search(r"\d", b) for w in _words(b)]
kind = next((k for w in words if w not in known and (k := _kind_word(w, base))), None)
if kind:
main = main.replace(m.group(0), " ")
extra.append((kind, ",".join(b for b in bits if re.search(r"\d", b))))
bare = _TOKEN.sub(
" ", _PAREN.sub(lambda m: " " if re.search(r"\d", m.group(1)) else m.group(0), main)
)
own = " ".join(split_name(main)[2])
out += [(" ".join(bare.split()).strip(" ,"), own), *extra]
if len(out) < 2:
return [(str(name or ""), str(spec or ""))]
first = next((s for _, s in out if s), "")
return [(n, s or first) for n, s in out]
# ── 별칭 장부 ─────────────────────────────────────────────────────────
def _alias_rows() -> dict[tuple[str, str], list[str]]:
"""(이름, 규격) 정규화 → EQ 키들 — 마스터 층 + 지금 층 별칭 장부 기계 짝."""
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_MasterTemplete_Store as store
here, top = Path(store.root()), Path(store.FOLDER)
folders = [here] + ([top] if here != top else [])
files = [(f, hopyo_store._dir(f) / f"{hopyo_store.MEMORY}.json") for f in folders]
stamp = tuple((str(f), p.stat().st_mtime_ns if p.is_file() else 0) for f, p in files)
return _alias_read(stamp)
@lru_cache(maxsize=8)
def _alias_read(stamp: tuple) -> dict[tuple[str, str], list[str]]:
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
out: dict[tuple[str, str], list[str]] = {}
for folder, _ in stamp:
for row in ledger.read(folder).values():
pairs = row.get("짝") or []
eqs = [str(p["키"]) for p in pairs if p.get("갈래") == "기계" and p.get("키")]
if row.get("갈래") != "짝" or not eqs:
continue
origin = row.get("원래") or {}
out.setdefault((norm(origin.get("품명")), norm(_clean(origin.get("규격")))), eqs)
return out
def alias(name: Any, spec: Any) -> list[str]:
"""별칭 장부 기계 짝 — 이름 + 규격 열쇠(규격 속 할증 글은 뺌) · 없으면 []."""
return _alias_rows().get((norm(name), norm(_clean(spec))), [])
# ── 찾기 ──────────────────────────────────────────────────────────────
def _brief(r: Row) -> dict[str, Any]:
raw = r.raw
return {"키": r.key, "이름": raw.get("이름"), "규격": raw.get("규격") or "",
"원문번호": raw.get("원문번호")} # fmt: skip
def _query(rows: list[Row], base: str) -> str:
names = {str(r.raw.get("이름")) for r in rows}
if len(names) == 1:
return names.pop()
details = {str(r.raw.get("상세구분")) for r in rows}
return details.pop() if len(details) == 1 else base
def _ans(kind: str, rows: list[Row], query: str, why: str = "") -> dict[str, Any]:
key = rows[0].key if kind in (LINKED, ALIAS) else None
return {"판정": kind, "키": key, "후보": [_brief(r) for r in rows[:SHOW]], "찾기어": query,
"까닭": why, "수": len(rows)} # fmt: skip
def find(name: Any, spec: Any = "") -> dict[str, Any]:
"""기계 하나(조합 아님) → `{판정, 키, 후보, 찾기어, 까닭, 수}`."""
rows_by = table()[2]
hit = alias(name, spec)
if len(hit) == 1 and hit[0] in rows_by:
r = rows_by[hit[0]]
return _ans(ALIAS, [r], str(r.raw.get("이름")))
base = split_name(name)[0]
_, groups, specs = split_name(f"{name or ''} {spec or ''}") # 규격 괄호 낱말 「3.6m(일반용)」
groups.append(
_words(_TOKEN.sub(" ", _PAREN.sub(" ", _clean(spec))))
) # 규격 글 낱말 「탑승유압식」
named = alias(name, "")
if len(named) == 1 and named[0] in rows_by: # 이름만 별칭 = 그 EQ 기종으로
base = rows_by[named[0]].base
mine = tokens(" ".join(specs))
kinds = kind_rows(base)
known = table()[1]
rows = kinds
for w in (w for g in groups for w in g):
if w in known: # 기계표 괄호 낱말 = 꼭 맞아야 함
rows = [r for r in rows if w in r.full]
elif len(w) > 1 and (kept := [r for r in rows if w in r.said]): # 규격 글 낱말 = 좁힐 때만
rows = kept
if not mine: # 규격 수 없음 = 그 기종 전부(괄호 · 규격 낱말로 거른 것)
got = rows
else:
same = [r for r in rows if spec_fit(mine, r.spec) == "같음"]
got = same or [r for r in rows if spec_fit(mine, r.spec) == "안"]
if len(got) == 1: # 이름만 별칭으로 기종을 정했으면 「별칭」
return _ans(ALIAS if len(named) == 1 else LINKED, got, str(got[0].raw.get("이름")))
if got:
return _ans(MANY, got, _query(got, base), f"후보 {len(got)}")
if not kinds:
return _ans(NONE, [], base, "기종 모름 — 별칭")
why = "우리 표에 없음(규격)" if rows else "우리 표에 없음(괄호 낱말)"
return _ans(
NONE, sorted(rows or kinds, key=lambda r: _gap(mine, r.spec)), _query(kinds, base), why
)
def _gap(mine: list, theirs: list) -> float:
"""가까운 규격 차례(보여주기만) — 첫 수끼리 비."""
if not mine or not theirs or mine[0][0] <= 0 or theirs[0][0] <= 0:
return math.inf
return abs(math.log(mine[0][0] / theirs[0][0]))
def find_all(name: Any, spec: Any = "") -> list[dict[str, Any]]:
"""기계 글 → 부분마다 `find` + {이름, 규격}(조합 = 둘 이상) · 글 통째 별칭(짝 여럿)이 먼저."""
whole = alias(name, spec)
rows_by = table()[2]
if len(whole) > 1 and all(k in rows_by for k in whole):
made = [_ans(ALIAS, [rows_by[k]], str(rows_by[k].raw.get("이름"))) for k in whole]
return [{**x, "이름": str(name or ""), "규격": str(spec or "")} for x in made]
out: list[dict[str, Any]] = []
for n, s in parts(name, spec):
if out and s and not split_name(n)[2] and s == out[0]["규격"] and out[0]["키"]:
s = str(out[0]["후보"][0]["규격"]) # 앞 줄 규격을 받은 쪽 = 이은 우리 규격(단위까지)
out.append({**find(n, s), "이름": n, "규격": s})
return out
def mark(got: dict[str, Any], name: Any, spec: Any) -> str | None:
"""못 이은 기계 표지 글(「참고 · 」 머리) — 이음 · 별칭 = None."""
said = f"「{' '.join(x for x in (str(name or ''), str(spec or '')) if x)}」"
if got["판정"] == MANY:
names = " · ".join(f"{c['이름']} {c['규격']}".strip() for c in got["후보"])
return f"{NOTE}기계 후보 {got['수']} — 고르기 {said}: {names}"
if got["판정"] == NONE:
return f"{NOTE}기계 못 찾음 — {said} {got['까닭']}"
return None
def same_kind(a: Any, b: Any) -> bool | None:
"""두 기계 이름이 같은 기종인지(연결 찾기) — 둘 다 기종을 찾으면 판정 · 아니면 None."""
found = []
for t in (a, b):
named = alias(t, "")
row = table()[2].get(named[0]) if len(named) == 1 else None
found.append(kind_rows(row.base if row else split_name(t)[0]))
if not all(found):
return None
return bool({r.base for r in found[0]} & {r.base for r in found[1]})
# ── 고른 것 → 별칭 장부(저장 때) ──────────────────────────────────────
def learn(doc: Any, who: Any) -> int:
"""가져온 식 문서 [저장] — 기계 줄에 사람이 고른 EQ(찾기 판정과 다름) = 지금 층 별칭 장부 줄
(갈래 기계 · 열쇠 「이름|규격|단위」) → M01 별칭 장부 후보로 모임(승인은 M01) · 쓴 줄 수."""
from M02_MasterTemplete import M02_Doc_Copy as dc
from M02_MasterTemplete import M02_Hopyo_Ledger as ledger
from M02_MasterTemplete import M02_MasterTemplete_Store as store
head = (doc or {}).get("가져온") if isinstance(doc, dict) else None
if not isinstance(head, dict) or not isinstance(head.get("기계"), list):
return 0
by_id = {dc.md.var_id(v): v for v in doc.get("변수") or [] if isinstance(v, dict)}
rows: dict[str, dict] = {}
for one in head["기계"]:
link = ((by_id.get(str((one or {}).get("기호"))) or {}).get("연결")) or {}
code = (link.get("입력") or {}).get("기계") if link.get("갈래") == "단가산출" else None
eq = by_code(code)
origin = one.get("원래") or {}
if not eq or eq == one.get("키") or not origin.get("이름"):
continue
if alias(origin["이름"], origin.get("규격")) == [eq]: # 이미 장부에 같은 짝
continue
plain = {"품명": str(origin["이름"]), "규격": str(origin.get("규격") or ""),
"단위": str(origin.get("단위") or "hr")} # fmt: skip
full = ledger.key({"이름": plain["품명"], "규격": plain["규격"], "단위": plain["단위"]})
pair = {"갈래": "기계", "키": eq, "입력값": {}, "배": 1}
rows[full] = {"원래": plain, "갈래": "짝", "짝": [pair]}
if rows:
ledger.put(store.root(), rows, who)
return len(rows)
@@ -101,6 +101,9 @@ export interface HopyoBuiltAll {
export interface LedgerPair {
로직?: string;
자재?: string;
/** 고른 것 꼴(785-3 · 812-9 기계) — 갈래 + 키 */
갈래?: string;
키?: string;
입력값: Record<string, unknown>;
배: number;
몫: number | null;
@@ -106,7 +106,7 @@ export function mergeRows(rows: LedgerRow[], edits: Edits, scope: HopyoScope): L
/** 짝 글 — 「GC000198 × 1 · 몫 0.1 · 소량감률=50」 */
export const pairText = (p: LedgerPair): string =>
[
`${p.로직 ?? p.자재 ?? ""} × ${p.배}`,
`${p.로직 ?? p.자재 ?? [p.갈래, p.키].filter(Boolean).join(" ")} × ${p.배}`,
p.몫 != null ? `몫 ${p.몫}` : "",
Object.entries(p.입력값 ?? {})
.map(([k, v]) => `${k}=${formatNumber(v)}`)
+79 -4
View File
@@ -177,7 +177,7 @@ def reprice(logic: dict, where: dict, price: dict[str, float]) -> int:
def _m01(kind: str, src: dict) -> tuple[str | None, str | None]:
"""1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 ·
환율만 — 재료 · 인력 · 기계는 지금처럼 조용히 이음 없음). 재료 줄 이름이 M01 유가전력(MO)이면
환율만 — 재료 · 인력은 조용히 이음 없음) · 기계 = 기계 찾기(812-9) 못 이으면 늘 표지. 재료 줄 이름이 M01 유가전력(MO)이면
유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님)."""
if kind not in ("인력", "재료", "기계"):
return None, None
@@ -185,6 +185,13 @@ def _m01(kind: str, src: dict) -> tuple[str | None, str | None]:
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 = "유가"
@@ -211,6 +218,60 @@ def _m01(kind: str, src: dict) -> tuple[str | None, str | None]:
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` · 조합 = 둘 이상) · 기계 아님 = []."""
if src.get("갈래") != "기계":
return []
from M02_MasterTemplete import M02_Machine_Find as mfind
return mfind.find_all(src.get("이름"), src.get("규격"))
def part_line(main: dict, part: dict, names: "Names") -> dict:
"""조합 기계 다음 줄 — 같은 수량 식 · 값 0(파일 경비 = 한 덩이 = 첫 줄) · 제 이음."""
res = {**main["자원"], "이름": part["이름"], "규격": part["규격"], "찾기": part, "조합": True}
res.pop("조합첫", None)
res["단가"] = dict.fromkeys(res.get("단가") or COMP, 0)
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["키"])}
else:
one |= {"종류": main["종류"], "비목": main["비목"], "단가": Decimal(0), "이음": part["키"]}
return one | ({"자리": main["자리"]} if main.get("자리") else {})
def part_marks(src: dict, parts: list[dict]) -> list[str]:
"""부분 표지 — 못 이은 부분(후보 여럿 · 못 찾음) · 조합 기계(경비 한 덩이 = 첫 줄 · 다음 줄 0)."""
from M02_MasterTemplete import M02_Machine_Find as mfind
out = [m for p in parts if (m := mfind.mark(p, p["이름"], p["규격"]))]
if len(parts) > 1:
said = " ".join(x for x in (str(src.get("이름") or ""), str(src.get("규격") or "")) if x)
names = " + ".join(
f"{p['후보'][0]['이름']} {p['후보'][0]['규격']}" if p["키"] else p["이름"]
for p in parts
)
out.append(
f"{NOTE}조합 기계 — 경비는 한 덩이 「{said}」 = {names} · 엑셀 값은 첫 줄 · 다음 줄 0"
)
return out
# ── 내보낼 로직 틀 ─────────────────────────────────────────────────────
class Out:
def __init__(self, name: str, unit: str, form: str, code: Any) -> None:
@@ -290,6 +351,7 @@ class _Stm:
self.open: list[tuple[int, list[str]]] = [] # 위 단계가 아직 안 담은 단계(엔진 `_cover`)
self.out: dict[str, int] = {} # 제외 단계 줄 → 그 계 줄까지 줄 수(합에서 빠지는 때)
self.fix: list[tuple[dict, int]] = [] # 누계 % 덧줄 · 그때 줄 수
self.noted: set[tuple] = set() # 기계 표지 단 자원(812-9 · 블록에 한 번)
def brackets(self, node: tuple, src: dict, kept: dict) -> None:
"""끼움 `[Q=0.208]` → 중간(왼쪽부터 · 원문 차례)."""
@@ -395,11 +457,24 @@ class _Stm:
if not one_ and len(sole) == 1: # 비목 꼬리 없어도 값 든 비목 하나면 단가 줄(셈 같음)
one_, to = sole[0], None
res |= {"비목": one_, "옮김": to} if one_ else {}
link, many = _m01(res["갈래"], res) if self.keys else (None, None)
parts = machine_parts(res) if self.keys else [] # 812-9 기계 찾기 · 조합 = 줄 둘
if parts:
res["찾기"], link, many = parts[0], parts[0]["키"], None
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)
if parts and one["종류"] == "로직":
one["이음"] = hourly_text(link)
self.line(one, node, True, src)
if many:
o.glyph(src["행"], src["글"], mark=many)
bare = {k: v for k, v in node.items() if k != "번지"}
for p in parts[1:]:
self.line(part_line(one, p, o.names), bare, True, src)
seen = (res.get("이름"), res.get("규격"))
marks = [] if seen in self.noted else part_marks(res, parts)
self.noted.add(seen)
for m in [*marks, *([many] if many else [])]:
o.glyph(src["행"], src["글"], mark=m)
def rate(self, node: dict, src: dict, cf: str) -> None:
rate, o = node["율"], self.o