- 기준 목록 별칭에 「터파기」→「파기」 낱말(측구파기 → 측구터파기) · 목록 모양 SHAPE 올림 - 줄 0 개 일위대가 = 빈 줄 하나 · 빈 줄 있는 대가를 이은 내역 줄 = 단가 모름(금액 0 아님) · py · ts 짝 + 거울 시험 - 단가연결 때 줄 단위 ≠ 일위대가 단위면 「단위 다름」 알림(연결은 됨) - 잊은 짝은 짝 표 제안에서도 안 나옴(다시 이으면 풀림) - 낡은 시험 여섯: 707-4 잇기 메뉴 묶음(UI_Link) 빠진 소스 목록 보충 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
347 lines
16 KiB
Python
347 lines
16 KiB
Python
"""B09 내역 줄 ↔ 우리 공종 잇기(PLAN 707-4) — 불러온 줄(707-1 `hand:x…`)을
|
|
자동 줄(earth · st · haul)에 이어 B08 수량이 붙게. 파일 · 저장 안 만짐(맞춤 기억 읽기 · 쓰기 =
|
|
부르는 쪽이 층 폴더로).
|
|
|
|
제안(`suggest`) — 아직 안 이은 불러온 줄마다 후보 · 꼬리표:
|
|
「지난번 이음」 = 맞춤 기억 `{품명|규격|단위: {확정: 줄 키, 이름}}`(실무자료 맞춤 꼴 ·
|
|
규격이 달라도 품명 · 단위가 같고 짝이 하나면) · 키가 없는 공사 = 같은 이름 자동 줄
|
|
「짝 표」 = 토공 이름 짝 표(`PAIRS` · 코드 상수) · 우리 이름(`Bill_Items.EARTH`) 그대로도
|
|
`하나` = 한 줄 ↔ 한 줄 — 아니면(암 갈래 · 여러 줄 → 한 줄) 사용자 고름 ·
|
|
`auto_pairs` = 기억 · 하나만
|
|
잇기(`link`) — 항목 줄 ↔ 자동 항목 줄 = 707-1 붙임과 같은 꼴(불러온 줄 자리에 자동 줄 ·
|
|
고정 단가 칸 · 연결 · 수량 갈래 take = 엑셀 수량 덮개 / price = B08(없으면 엑셀) ·
|
|
불러온 줄 지움 = 두 번 셈 0)
|
|
묶음 줄 ↔ 구조물 줄(`st:`) 하나(1 : N) — take = 구조물 줄이 묶음 자리 ·
|
|
안 줄이 그 아래(저절로 묶음 · 금액 = 안 줄 합) / price = 안 줄 금액 합 ÷ 개수(기본 = B08 수량)
|
|
= 1 단위 고정 단가 · 안 줄 지움
|
|
(B08 수량 × 단가 · 끝수 비목마다 1 원 안).
|
|
이은 자동 줄 `was` = 되돌릴 것 {rows: [불러온 줄 · price 안 줄], old: 자동 줄 덮개,
|
|
prev · next: 그때 같은 부모 앞 · 뒤 줄 키} · 수량 갈래 = 줄마다(`modes`) · 없으면 `mode`
|
|
풀기(`unlink`) — `was` 대로 불러온 줄 · 자동 줄 제자리(맞춤 기억은 그대로 · 잊기 = 부르는 쪽).
|
|
판(`board`) — 남은 불러온 줄 · 빈 자리(이을 수 있는 자동 줄) · 이은 줄.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import re
|
|
from typing import Any
|
|
|
|
from B09_Estimation.B09_Estimation_Bill_Import import MODES, PARTS, _flat, freeze
|
|
from B09_Estimation.B09_Estimation_Bill_Items import EARTH
|
|
from M02_MasterTemplete.M02_Hopyo_Match import line_key
|
|
|
|
MEMORY = "내역맞춤"
|
|
TABLE, RECALL = "짝 표", "지난번 이음"
|
|
FORGOT = "잊음" # 기억 칸 {잊음: True} = 이 짝 잊기 표시 — 느슨한 기억에서도 안 불러옴 · 다시 이으면 덮임
|
|
HEADS = ("earth", "st", "haul")
|
|
_TAIL = re.compile(r"^(.+?)\((.*)\)$")
|
|
ROCK = ("절토(연암)", "암발파")
|
|
#: 토공 이름 짝 표 — (경쟁사 이름, 규격에 든 글 | "", 우리 이름) · 우리 이름 둘 = 한 줄 ↔ 한 줄 아님
|
|
#: (EST 암 = 연암 90 % · 보통암 10 % ↔ 우리 연암 · 경암) · 이름 괄호 글 = 규격으로도 봄
|
|
PAIRS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
|
("수평규준틀", "", ("규준틀설치 종단규준틀",)),
|
|
("비탈규준틀", "", ("규준틀설치 횡단규준틀",)),
|
|
("뿌리뽑기", "", ("제근",)),
|
|
("토사절취", "", ("절토(토사)",)),
|
|
("암석절취", "", ROCK),
|
|
("암절취", "", ROCK),
|
|
("구조물터파기", "토사", ("구조물터파기(토사)",)),
|
|
("구조물터파기", "암", ("구조물터파기(연암)",)),
|
|
("터파기", "암", ("구조물터파기(연암)",)),
|
|
("구조물되메우기", "", ("구조물 되메우기",)),
|
|
("되메우기", "", ("구조물 되메우기",)),
|
|
("측구터파기", "토사", ("측구파기(토사)",)),
|
|
("측구터파기", "암", ("측구파기(연암)",)),
|
|
("도자운반-토사", "", ("도자운반",)),
|
|
("도자운반-암", "", ("도자운반",)),
|
|
("흙운반D/Z19Ton", "", ("도자운반",)),
|
|
("덤프운반-토사", "", ("덤프운반",)),
|
|
("덤프운반-암", "", ("덤프운반",)),
|
|
("흙운반D/P+B/H", "", ("덤프운반",)),
|
|
("토사운반", "", ("덤프운반",)),
|
|
("잔토처리", "", ("사토처리",)),
|
|
("사토운반", "", ("사토처리",)),
|
|
("사토무대처리", "", ("사토처리",)),
|
|
("노면고르기및다짐", "", ("노면정리",)),
|
|
)
|
|
EARTH_KEY = {name: f"earth:{n}" for n, (_s, name, _u, _w) in enumerate(EARTH, 1)}
|
|
|
|
|
|
def _texts(name: Any, spec: Any) -> list[tuple[str, str]]:
|
|
"""(이름, 규격) 보기 — 그대로 · 이름 끝 괄호 글을 규격 앞에."""
|
|
n, s = _flat(name), _flat(spec)
|
|
hit = _TAIL.match(n)
|
|
return [(n, s), *([(hit[1], hit[2] + s)] if hit else [])]
|
|
|
|
|
|
def table(name: Any, spec: Any) -> tuple[str, ...]:
|
|
"""짝 표 → 우리 토공 이름들(없음 = ())."""
|
|
n, s = _texts(name, spec)[0]
|
|
for ours in EARTH_KEY:
|
|
if _flat(ours) in (n, n + s):
|
|
return (ours,)
|
|
for alias, part, targets in PAIRS:
|
|
if any(t == _flat(alias) and _flat(part) in u for t, u in _texts(name, spec)):
|
|
return targets
|
|
return ()
|
|
|
|
|
|
def key_of(row: dict[str, Any]) -> str:
|
|
"""맞춤 기억 열쇠 = 품명 | 규격 | 단위(실무자료 맞춤과 같은 정규화)."""
|
|
return line_key({"이름": row.get("name"), "규격": row.get("spec"), "단위": row.get("unit")})
|
|
|
|
|
|
def _loose(key: str) -> str:
|
|
name, _spec, unit = (key.split("|") + ["", ""])[:3]
|
|
return f"{name}|{unit}"
|
|
|
|
|
|
def recall(memory: dict[str, dict[str, Any]], row: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""기억 한 칸 — 같은 열쇠 · 없으면 품명 · 단위 같고 짝이 하나인 것 ·
|
|
잊은 칸은 없음 취급 + 느슨한 기억에서는 다른 짝으로 셈(잊은 짝 곁에서 하나로 좁히지 않음)."""
|
|
key = key_of(row)
|
|
if isinstance(memory.get(key), dict):
|
|
return None if memory[key].get(FORGOT) else memory[key]
|
|
loose = [v for k, v in memory.items() if isinstance(v, dict) and _loose(k) == _loose(key)]
|
|
return loose[0] if len({v.get("확정") for v in loose}) == 1 and loose[0].get("확정") else None
|
|
|
|
|
|
def _targets(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
|
"""이을 수 있는 자동 줄 {키: 줄} — earth · st · haul(항목이든 저절로 묶음이든)."""
|
|
return {r["key"]: r for r in rows if r.get("auto") and r["key"].split(":")[0] in HEADS}
|
|
|
|
|
|
def _linked(rows: list[dict[str, Any]]) -> set[str]:
|
|
"""이미 이은 자동 줄 — 불러온 수량(비교 칸) 있음 · 불러온 묶음 아래 · 안 줄을 거느림."""
|
|
parents = {r.get("parent") for r in rows}
|
|
return {
|
|
k
|
|
for k, r in _targets(rows).items()
|
|
if r.get("src_qty") is not None
|
|
or str(r.get("parent") or "").startswith("hand:x")
|
|
or (k.startswith("st:") and k in parents)
|
|
}
|
|
|
|
|
|
def _resolve(got: dict[str, Any], targets: dict[str, dict[str, Any]]) -> str | None:
|
|
key = got.get("확정")
|
|
if key in targets:
|
|
return key
|
|
head = str(key or "").split(":")[0]
|
|
same = [
|
|
k for k, r in targets.items() if k.startswith(f"{head}:") and r["name"] == got.get("이름")
|
|
]
|
|
return same[0] if len(same) == 1 else None
|
|
|
|
|
|
def suggest(rows: list[dict[str, Any]], memory: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""[{key, kind, name, spec, unit, 후보 [{key, name}], 꼬리표, 하나, 까닭}] —
|
|
후보 있는 불러온 줄만."""
|
|
targets = _targets(rows)
|
|
free = {k: r for k, r in targets.items() if k not in _linked(rows)}
|
|
out = []
|
|
for row in rows:
|
|
if not row["key"].startswith("hand:x"):
|
|
continue
|
|
got = recall(memory, row)
|
|
hit = _resolve(got, free) if got else None
|
|
if hit:
|
|
keys, tag = [hit], RECALL
|
|
elif row["kind"] == "item" and not (memory.get(key_of(row)) or {}).get(FORGOT):
|
|
keys, tag = [EARTH_KEY[n] for n in table(row["name"], row["spec"])], TABLE
|
|
keys = [k for k in keys if k in free]
|
|
else:
|
|
keys = []
|
|
if not keys:
|
|
continue
|
|
one = len(keys) == 1
|
|
out.append(
|
|
{
|
|
**{f: row.get(f) for f in ("key", "kind", "name", "spec", "unit")},
|
|
"후보": [{"key": k, "name": free[k]["name"]} for k in keys],
|
|
"꼬리표": tag,
|
|
"하나": one,
|
|
"까닭": "" if one else "한 줄 ↔ 한 줄 아님 — 고름",
|
|
}
|
|
)
|
|
seen: dict[str, int] = {}
|
|
for s in out:
|
|
if s["하나"]:
|
|
seen[s["후보"][0]["key"]] = seen.get(s["후보"][0]["key"], 0) + 1
|
|
for s in out: # 여러 줄 → 한 자동 줄(도자운반-토사 · -암) = 고름
|
|
if s["하나"] and seen[s["후보"][0]["key"]] > 1:
|
|
s.update({"하나": False, "까닭": "여러 줄 → 한 줄 — 고름"})
|
|
return out
|
|
|
|
|
|
def auto_pairs(suggestions: list[dict[str, Any]]) -> list[tuple[str, str]]:
|
|
"""저절로 이을 짝 [(불러온 줄 키, 자동 줄 키)] — 지난번 이음 · 하나만."""
|
|
return [
|
|
(s["key"], s["후보"][0]["key"]) for s in suggestions if s["꼬리표"] == RECALL and s["하나"]
|
|
]
|
|
|
|
|
|
def _descendants(entries: list[dict[str, Any]], key: str) -> set[str]:
|
|
out, grow = set(), {key}
|
|
while grow:
|
|
grow = {e["key"] for e in entries if e.get("parent") in grow} - out
|
|
out |= grow
|
|
return out
|
|
|
|
|
|
def _place(bill: dict[str, Any], at: int, entry: dict[str, Any], parent: Any) -> None:
|
|
entry.pop("parent", None)
|
|
if parent:
|
|
entry["parent"] = parent
|
|
bill["rows"].insert(at, entry)
|
|
if entry["key"] in bill["gone"]:
|
|
bill["gone"].remove(entry["key"])
|
|
|
|
|
|
def link(
|
|
user: dict[str, Any],
|
|
rows: list[dict[str, Any]],
|
|
pairs: list[tuple[str, str]],
|
|
mode: str = "take",
|
|
counts: dict[str, float] | None = None,
|
|
modes: dict[str, str] | None = None,
|
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
"""(새 사용자 값, 알림 {이음, 묶음, 지움, 못함 [{key, 까닭}], 기억 {열쇠: 확정}}) —
|
|
`rows` = 지금 셈 줄 ·
|
|
`counts` = 묶음 개수 {불러온 묶음 키: 수}(price 갈래 · 없으면 B08 수량) ·
|
|
`modes` = 줄마다 수량 갈래 {불러온 줄 키: take | price}."""
|
|
modes = modes or {}
|
|
if any(m not in MODES for m in (mode, *modes.values())):
|
|
raise ValueError(f"수량 갈래는 {', '.join(MODES)} 중 하나")
|
|
counts = counts or {}
|
|
by_key = {r["key"]: r for r in rows}
|
|
targets = _targets(rows)
|
|
used = _linked(rows)
|
|
bill = freeze(user, rows)
|
|
report: dict[str, Any] = {"이음": 0, "묶음": 0, "지움": 0, "못함": [], "기억": {}}
|
|
for line, target in pairs:
|
|
mode_ = modes.get(line, mode)
|
|
entries = bill["rows"]
|
|
at = next((n for n, e in enumerate(entries) if e["key"] == line), -1)
|
|
row, goal = by_key.get(line), targets.get(target)
|
|
why = (
|
|
"불러온 줄이 아님" if at < 0 or not line.startswith("hand:x") or row is None
|
|
else "이을 수 없는 줄" if goal is None
|
|
else "이미 이은 줄" if target in used
|
|
else "묶음 ↔ 구조물 줄만" if (row["kind"] != "item") != target.startswith("st:")
|
|
else ""
|
|
) # fmt: skip
|
|
if not why and row["kind"] != "item" and mode_ == "price":
|
|
count = counts.get(line, goal.get("auto_qty", goal.get("qty")))
|
|
why = "" if count else "개수 없음(B08 수량 없음) — 넘겨받기로"
|
|
if why:
|
|
report["못함"].append({"key": line, "까닭": why})
|
|
continue
|
|
old_at = next((n for n, e in enumerate(entries) if e["key"] == target), -1)
|
|
old = entries.pop(old_at) if old_at >= 0 else None
|
|
sibs = [e["key"] for e in entries if old and e.get("parent") == old.get("parent")]
|
|
cut = sum(1 for e in entries[:old_at] if old and e.get("parent") == old.get("parent"))
|
|
was = {"old": copy.deepcopy(old), "prev": sibs[:cut], "next": sibs[cut:]}
|
|
at = next(n for n, e in enumerate(entries) if e["key"] == line)
|
|
hand = entries.pop(at)
|
|
was["rows"] = [copy.deepcopy(hand)]
|
|
skip = ("price", "src_qty", "was")
|
|
auto = {k: v for k, v in (old or {"key": target}).items() if k not in skip}
|
|
if row["kind"] == "item":
|
|
auto.update({"ref": hand.get("ref"), "src_qty": hand.get("src_qty", hand.get("qty"))})
|
|
if hand.get("price"):
|
|
auto["price"] = hand["price"]
|
|
if mode_ == "take" or goal.get("auto_qty", goal.get("qty")) is None:
|
|
auto["qty"] = hand.get("qty")
|
|
report["이음"] += 1
|
|
elif mode_ == "take":
|
|
for e in entries: # 안 줄 = 구조물 줄 아래 · 금액 = 안 줄 합
|
|
if e.get("parent") == line:
|
|
e["parent"] = target
|
|
report["묶음"] += 1
|
|
else:
|
|
inner = _descendants(entries, line)
|
|
was["rows"] += [copy.deepcopy(e) for e in entries if e["key"] in inner]
|
|
items = [by_key[k] for k in inner if by_key.get(k, {}).get("kind") == "item"]
|
|
auto.update(
|
|
{
|
|
"ref": None,
|
|
"price": {p: sum(r.get(p) or 0 for r in items) / count for p in PARTS},
|
|
"src_qty": count,
|
|
}
|
|
)
|
|
bill["rows"] = entries = [e for e in entries if e["key"] not in inner]
|
|
at = min(at, len(entries))
|
|
report.update(묶음=report["묶음"] + 1, 지움=report["지움"] + len(inner))
|
|
auto["was"] = was
|
|
_place(bill, at, auto, hand.get("parent"))
|
|
used.add(target)
|
|
report["기억"][key_of(row)] = {"확정": target, "이름": goal["name"]}
|
|
return {**user, "bill": bill}, report
|
|
|
|
|
|
def unlink(
|
|
user: dict[str, Any], rows: list[dict[str, Any]], target: str
|
|
) -> tuple[dict[str, Any], str]:
|
|
"""(새 사용자 값, 못한 까닭 · 됨 = "") — 이은 자동 줄 `was` 대로 불러온 줄 · 자동 줄 되돌림."""
|
|
bill = freeze(user, rows)
|
|
entries = bill["rows"]
|
|
at = next((n for n, e in enumerate(entries) if e["key"] == target), -1)
|
|
was = entries[at].get("was") if at >= 0 else None
|
|
back = was.get("rows") if isinstance(was, dict) else None
|
|
if not back:
|
|
return user, "이은 줄이 아님"
|
|
entries.pop(at)
|
|
head = back[0]["key"]
|
|
for e in entries: # take 묶음 안 줄 = 불러온 묶음 아래로
|
|
if e.get("parent") == target:
|
|
e["parent"] = head
|
|
entries[at:at] = copy.deepcopy(back)
|
|
old = was.get("old")
|
|
if old: # 자동 줄 = 그때 뒤 줄 앞 · 없으면 앞 줄 뒤 · 둘 다 없음 = 셈이 기본 부모 끝
|
|
where = {e["key"]: n for n, e in enumerate(entries)}
|
|
after = [where[k] + 1 for k in was.get("prev") or [] if k in where]
|
|
spot = next((where[k] for k in was.get("next") or [] if k in where), None)
|
|
spot = spot if spot is not None else (after[-1] if after else None)
|
|
if spot is not None:
|
|
entries.insert(spot, old)
|
|
return {**user, "bill": bill}, ""
|
|
|
|
|
|
def _under(rows: list[dict[str, Any]], key: str) -> bool:
|
|
"""불러온 줄이 이은 자동 줄(구조물 take 묶음) 아래인지."""
|
|
by_key = {r["key"]: r for r in rows}
|
|
up = by_key.get(key, {}).get("parent")
|
|
while up:
|
|
if not str(up).startswith("hand:"):
|
|
return up in by_key and str(up).split(":")[0] in HEADS
|
|
up = by_key.get(up, {}).get("parent")
|
|
return False
|
|
|
|
|
|
def board(user: dict[str, Any], rows: list[dict[str, Any]]) -> dict[str, Any]:
|
|
"""잇기 창 판 — 남은 불러온 줄(구조물 묶음에 딸린 줄 뺌) · 빈 자리 · 이은 줄."""
|
|
|
|
def pick(r: dict[str, Any]) -> dict[str, Any]:
|
|
return {f: r.get(f) for f in ("key", "kind", "name", "spec", "unit")}
|
|
|
|
used = _linked(rows)
|
|
entries = {e["key"]: e for e in ((user.get("bill") or {}).get("rows") or [])}
|
|
by_key = {r["key"]: r for r in rows}
|
|
linked = []
|
|
for key, e in entries.items():
|
|
was = e.get("was") if isinstance(e.get("was"), dict) else None
|
|
if was and was.get("rows") and key in by_key:
|
|
hand = was["rows"][0]
|
|
linked.append({**pick(by_key[key]), "from": pick(hand), "memo": key_of(hand)})
|
|
return {
|
|
"남은": [
|
|
pick(r) for r in rows if r["key"].startswith("hand:x") and not _under(rows, r["key"])
|
|
],
|
|
"자리": [
|
|
{**pick(r), "head": k.split(":")[0]} for k, r in _targets(rows).items() if k not in used
|
|
],
|
|
"이은": linked,
|
|
}
|