diff --git a/B09_Estimation/B09_Estimation_Bill_Link.py b/B09_Estimation/B09_Estimation_Bill_Link.py new file mode 100644 index 000000000..5fee2e7a1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Bill_Link.py @@ -0,0 +1,264 @@ +"""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 원 안). +""" + +from __future__ import annotations + +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 = "짝 표", "지난번 이음" +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 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 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": + 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, +) -> tuple[dict[str, Any], dict[str, Any]]: + """(새 사용자 값, 알림 {이음, 묶음, 지움, 못함 [{key, 까닭}], 기억 {열쇠: 확정}}) — + `rows` = 지금 셈 줄 · + `counts` = 묶음 개수 {불러온 묶음 키: 수}(price 갈래 · 없으면 B08 수량).""" + if mode not in MODES: + 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: + 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 = next((e for e in entries if e["key"] == target), None) + if old is not None: + entries.remove(old) + at = next(n for n, e in enumerate(entries) if e["key"] == line) + hand = entries.pop(at) + auto = {k: v for k, v in (old or {"key": target}).items() if k not in ("price", "src_qty")} + 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) + 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)) + _place(bill, at, auto, hand.get("parent")) + used.add(target) + report["기억"][key_of(row)] = {"확정": target, "이름": goal["name"]} + return {**user, "bill": bill}, report diff --git a/B09_Estimation/B09_Estimation_Router_Link.py b/B09_Estimation/B09_Estimation_Router_Link.py new file mode 100644 index 000000000..a5cb113f1 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_Link.py @@ -0,0 +1,125 @@ +"""B09 내역 줄 ↔ 우리 공종 잇기 길(PLAN 707-4) — 저장 안 함(화면 캐시로 → [저장] 때 서버 정본). + + POST /api/projects/{id}/estimation/link/suggest {user?} + → {줄: 제안(`B09_Estimation_Bill_Link.suggest`) · 저절로: 지난번 이음 · 하나 수} + POST /api/projects/{id}/estimation/link/apply + {pairs [{line, target}], auto, mode, counts?, user?} + 잇기(`link`) — `auto` = 지난번 이음 · 하나도 더함 → + {user(새 캐시), 알림, payload(그 캐시로 셈)} · + 이은 짝 = 맞춤 기억에 씀(회사 층 = 회사 관리자 · 아니면 개인 층) +맞춤 기억 = 층 `실무자료/내역맞춤.json`(실무자료 맞춤 꼴) · 읽기 = 회사 + 개인(개인이 이김). +끼워 넣기 = `B09_Estimation_Router`(같은 머리 · 같은 권한) — 화면 고리 일감 몫. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from B09_Estimation import B09_Estimation_Bill_Link as linker +from B09_Estimation import B09_Estimation_Store as store +from common_util.common_util_auth import verify_session +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Layer_Access as access + +router = APIRouter() +PATH = "/{project_id}/estimation/link" +LAYERS = ("company", "personal") + + +def _folders(session: dict[str, Any]) -> dict[str, tuple[Path, bool]]: + """{층: (폴더, 쓰기)} — 회사 없음 등 = 그 층 건너뜀.""" + out = {} + for layer in LAYERS: + try: + got = access.grant(session, layer) + out[layer] = (access.folder(got, create=True), got.write) + except HTTPException: + continue + return out + + +def recalled(session: dict[str, Any]) -> dict[str, dict[str, Any]]: + """맞춤 기억 — 회사 위에 개인을 덮음.""" + out: dict[str, dict[str, Any]] = {} + for folder, _write in _folders(session).values(): + out.update(hopyo_store.memory(folder, linker.MEMORY)) + return out + + +def keep(session: dict[str, Any], entries: dict[str, dict[str, Any]]) -> str | None: + """이은 짝 → 회사 층(쓸 수 있으면) · 아니면 개인 층 → 쓴 층(없음 = None).""" + folders = _folders(session) + layer = next((n for n in LAYERS if n in folders and folders[n][1]), None) + if entries and layer: + hopyo_store.remember(folders[layer][0], entries, linker.MEMORY) + return layer if entries else None + + +def _draft(raw: Any) -> dict[str, Any] | None: + return store.clean_user(raw) if isinstance(raw, dict) else None + + +class SuggestBody(BaseModel): + user: dict[str, Any] | None = None + + +class Pair(BaseModel): + line: str = Field(min_length=1, max_length=80) + target: str = Field(min_length=1, max_length=200) + + +class ApplyBody(BaseModel): + pairs: list[Pair] = Field(default_factory=list, max_length=2000) + auto: bool = False + mode: str = "take" + counts: dict[str, float] | None = None + user: dict[str, Any] | None = None + + +async def _rows(project_id: UUID, root: Path, user: dict[str, Any]) -> list[dict[str, Any]]: + from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만 + + return (await payload(project_id, root, user, True))["sheets"]["bill"]["rows"] + + +@router.post(PATH + "/suggest") +async def suggest( + project_id: UUID, body: SuggestBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + root = await _project_root(project_id) + draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root) + rows = await _rows(project_id, root, draft) + lines = linker.suggest(rows, await asyncio.to_thread(recalled, session)) + return {"줄": lines, "저절로": len(linker.auto_pairs(lines))} + + +@router.post(PATH + "/apply") +async def apply( + project_id: UUID, body: ApplyBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + from B09_Estimation.B09_Estimation_Router import payload # 서로 부름 — 부를 때만 + + if body.mode not in linker.MODES: + raise HTTPException(status_code=400, detail="수량 갈래는 take · price 중 하나") + root = await _project_root(project_id) + draft = _draft(body.user) or await asyncio.to_thread(store.read_user, root) + rows = await _rows(project_id, root, draft) + pairs = [(p.line, p.target) for p in body.pairs] + if body.auto: + memory = await asyncio.to_thread(recalled, session) + pairs += [p for p in linker.auto_pairs(linker.suggest(rows, memory)) if p not in pairs] + made, report = linker.link(draft, rows, pairs, body.mode, body.counts) + made = store.clean_user(made) + layer = await asyncio.to_thread(keep, session, report.pop("기억")) + return { + "user": made, + "알림": {**report, "기억층": layer}, + "payload": await payload(project_id, root, made, True), + } diff --git a/M02_MasterTemplete/M02_Hopyo_Store.py b/M02_MasterTemplete/M02_Hopyo_Store.py index 52f045339..d55b7ae7a 100644 --- a/M02_MasterTemplete/M02_Hopyo_Store.py +++ b/M02_MasterTemplete/M02_Hopyo_Store.py @@ -120,21 +120,24 @@ def delete(folder: str | Path, key: str) -> None: # ── 맞춤 기억 ───────────────────────────────────────────────────────── -def memory(folder: str | Path) -> dict[str, dict[str, Any]]: - """층 맞춤 기억 `{줄 열쇠: 확정}` — 없으면 빈 것.""" - doc = _load(_dir(folder) / f"{MEMORY}.json") +def memory(folder: str | Path, name: str = MEMORY) -> dict[str, dict[str, Any]]: + """층 맞춤 기억 `{줄 열쇠: 확정}` — 없으면 빈 것 · + `name` = 기억 파일(내역 잇기 707-4 = 「내역맞춤」).""" + doc = _load(_dir(folder) / f"{name}.json") got = doc.get("줄") if isinstance(doc, dict) else None return got if isinstance(got, dict) else {} -def remember(folder: str | Path, entries: dict[str, dict[str, Any] | None]) -> dict[str, Any]: +def remember( + folder: str | Path, entries: dict[str, dict[str, Any] | None], name: str = MEMORY +) -> dict[str, Any]: """기억 더함 · 바꿈(값 None = 지움) → 새 기억 전부.""" with _LOCK: - got = memory(folder) + got = memory(folder, name) for key, value in entries.items(): if value is None: got.pop(key, None) else: got[key] = value - atomic_write_json(_dir(folder) / f"{MEMORY}.json", {"줄": got}) + atomic_write_json(_dir(folder) / f"{name}.json", {"줄": got}) return got diff --git a/resources/tester/test_707_4_bill_link.py b/resources/tester/test_707_4_bill_link.py new file mode 100644 index 000000000..24b03ff01 --- /dev/null +++ b/resources/tester/test_707_4_bill_link.py @@ -0,0 +1,223 @@ +"""707-4 내역 줄 ↔ 우리 공종 잇기 — 짝 표 · 맞춤 기억 · 묶음 통째 잇기(`Bill_Link`) · +기억 층(`Router_Link`). + +- 짝 표 = 우리 토공 줄(`Bill_Items.EARTH`) 모두 · 실무 이름(EST 거창 · STmate 봉화) → 우리 줄 · + 암 갈래(EST 연암 · 보통암) = 후보 둘 · 하나 아님 · 여러 줄 → 한 줄 = 하나 아님 +- 맞춤 기억 = 거창 엑셀에서 이은 짝 → 같은 회사 장수 · 진안 .estx 저절로 이음 · 두 번 셈 0 +- 수량 갈래 take = 엑셀 수량 덮개 / price = B08 · 엑셀 = 비교 칸 +- 묶음 ↔ 구조물 줄 — take = 안 줄이 구조물 줄 아래 / price = 1 단위 고정 단가 · 안 줄 지움 · + 금액 = 엑셀 +- 못 잇는 짝(남의 줄 · 자재 줄 · 이미 이음 · 꼴 다름) · 기억 층 = 회사 관리자 회사 · 아니면 개인 +- 셈 짝(`Calc_Bill` py · ts) 안 바뀜 — 거울은 test_707_1 · test_145_3 그대로 +""" + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Bill_Import as imp # noqa: E402 +from B09_Estimation import B09_Estimation_Bill_Link as lk # noqa: E402 +from B09_Estimation import B09_Estimation_Estx as ex # noqa: E402 +from B09_Estimation import B09_Estimation_Router_Link as route # noqa: E402 +from B09_Estimation import B09_Estimation_Store as store # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Excel import read_bill # noqa: E402 +from B09_Estimation.B09_Estimation_Bill_Items import EARTH, earth_items # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store # noqa: E402 + +K = ROOT / "resources" / "knowledge" / "original" / "실무문서" +GEO = ( + K / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx" +) +BONG = K / "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx" +ESTX = { + "장수": K / "02.산불 장수/01.내역서/00.단가변경_26년_산불_무주(장수,장수,덕산,산54-1).estx", + "진안": K / "03.산불진안/01.내역서/00.단가변경_26년_산불_무주(진안,백운,백암,산1-2).estx", +} +ST = "st:pipe:box|1" +ITEMS = [{**i, "qty": 100} for i in earth_items(None)] + [ + {"key": ST, "section": "2.2", "name": "BOX 암거", "unit": "m", "qty": 10, + "ref": {"tab": "sg_list", "key": "X"}}, +] # fmt: skip +INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": {"X": { + "labor": 100, "material": 0, "expense": 0}}} # fmt: skip + + +def _bill(user): + return compute_estimation(INPUTS, user, None, {})["bill"] + + +def _load(parsed): + """707-1 먼저 싣기(붙임 없음) → (사용자 값, 셈 줄).""" + user, _ = imp.apply(parsed, {}, _bill({})["rows"], "abcd1234", attach=False) + user = store.clean_user(user) + return user, _bill(user)["rows"] + + +def _link(user, rows, pairs, mode="take", **kw): + made, report = lk.link(user, rows, pairs, mode, **kw) + made = store.clean_user(made) + return made, report, _bill(made) + + +@pytest.fixture(scope="module") +def geo(): + return read_bill(GEO.name, GEO.read_bytes()) + + +def _pick(suggestions): + """사용자 고름 꼴 — 보통암 = 둘째(암발파) · 그 밖 = 첫 후보 · 자동 줄 하나에 한 줄.""" + out, used = [], set() + for s in suggestions: + keys = [c["key"] for c in s["후보"]] + goal = keys[1] if len(keys) > 1 and "보통암" in s["spec"] else keys[0] + if goal not in used: + out.append((s["key"], goal)) + used.add(goal) + return out + + +def test_짝_표_우리_토공_줄_모두(): + names = {name for _s, name, _u, _w in EARTH} + assert len(names) == len(EARTH) == 17 + assert all(lk.table(n, "") == (n,) for n in names) # 우리 이름 그대로 + assert all(set(t) <= names for _a, _p, t in lk.PAIRS) # 짝 표 = 있는 줄만 + assert set(lk.EARTH_KEY.values()) == {f"earth:{n}" for n in range(1, 18)} + + +def test_짝_표_실무_이름_암_갈래(geo): + user, rows = _load(geo) + got = {(s["name"], s["spec"]): s for s in lk.suggest(rows, {})} + one = {k: s["후보"][0]["name"] for k, s in got.items() if s["하나"]} + assert one == { + ("토사절취", "백호0.7㎥"): "절토(토사)", + ("측구터파기", "토사"): "측구파기(토사)", + ("성토사면다짐", "B/H0.7+유압식콤팩터"): "성토사면다짐", + ("토사운반", "굴삭기+덤프+굴삭기"): "덤프운반", + ("수평규준틀", ""): "규준틀설치 종단규준틀", + ("비탈규준틀", ""): "규준틀설치 횡단규준틀", + ("구조물 터파기(토사)", "육샹토사(0~2m,굴삭기0.2)"): "구조물터파기(토사)", + ("터파기(암)", ""): "구조물터파기(연암)", + ("되메우기", "백호0.7㎥(100%)"): "구조물 되메우기", + } + rock = got[("암석절취", "보통암")] + assert [c["name"] for c in rock["후보"]] == ["절토(연암)", "암발파"] + assert not rock["하나"] and rock["까닭"] == "한 줄 ↔ 한 줄 아님 — 고름" + side = got[("측구터파기", "연암")] # 연암 · 보통암 둘 → 측구파기(연암) 하나 + assert not side["하나"] and side["까닭"] == "여러 줄 → 한 줄 — 고름" + assert all(s["꼬리표"] == lk.TABLE for s in got.values()) + bong = lk.suggest(_load(read_bill(BONG.name, BONG.read_bytes()))[1], {}) + named = {s["name"]: s["후보"][0]["name"] for s in bong if s["하나"]} + assert named["토사절취(흙깎기)"] == "절토(토사)" + assert named["구조물터파기(육상암0~1m)"] == "구조물터파기(연암)" + assert named["비탈규준틀(@60M)"] == "규준틀설치 횡단규준틀" + + +def test_맞춤_기억_두_번째_공사_저절로(geo, tmp_path): + user, rows = _load(geo) + made, report, bill = _link(user, rows, _pick(lk.suggest(rows, {}))) + assert report["이음"] == 12 and report["못함"] == [] + hopyo_store.remember(tmp_path, report["기억"], lk.MEMORY) + assert hopyo_store.memory(tmp_path) == {} # 실무자료 맞춤과 다른 파일 + memory = hopyo_store.memory(tmp_path, lk.MEMORY) + assert memory["암석절취|보통암|m3"]["확정"] == "earth:6" + for name, path in ESTX.items(): + parsed = ex.read_bill(path.name, path.read_bytes()) + user, rows = _load(parsed) + lines = lk.suggest(rows, memory) + assert sum(s["꼬리표"] == lk.RECALL for s in lines) == 12, name + pairs = lk.auto_pairs(lines) + assert len(pairs) == 11, name # 측구 연암 = 보통암 줄과 한 자동 줄 → 고름 + made, report, bill = _link(user, rows, pairs) + assert report["이음"] == 11 and report["못함"] == [] + keys = [r["key"] for r in bill["rows"]] + assert len(keys) == len(set(keys)) # 두 번 셈 0 — 줄 하나씩 + assert not {k for k, _t in pairs} & set(keys) # 이은 불러온 줄 = 지움 + # take = 엑셀 수량 · 고정 단가 → 직접비(아는 금액) = 파일 합 + 안 이은 구조물 줄 + assert bill["totals"]["direct"]["sum"] == parsed["합계"]["직접"] + 1000, name + assert lk.auto_pairs(lk.suggest(bill["rows"], memory)) == [] # 다시 = 이음 없음 + + +def test_수량_갈래_가_나(geo): + user, rows = _load(geo) + line = next(r for r in rows if r["name"] == "토사절취") + for mode, qty in (("take", 7641), ("price", 100)): + made, report, bill = _link(user, rows, [(line["key"], "earth:4")], mode) + cut = next(r for r in bill["rows"] if r["key"] == "earth:4") + assert (cut["qty"], cut["src_qty"], cut["fixed"]) == (qty, 7641, True) + assert cut["parent"] == line["parent"] # 불러온 자리 + assert cut["price_sum"] == line["price_sum"] + assert line["key"] not in {r["key"] for r in bill["rows"]} + + +def test_묶음_통째_잇기(geo): + user, rows = _load(geo) + base = _bill(user)["totals"]["direct"]["sum"] + group = next(r for r in rows if r["name"] == "콘크리트구조물") + inner = [r["key"] for r in rows if r["parent"] == group["key"]] + made, report, bill = _link(user, rows, [(group["key"], ST)], "take") + got = {r["key"]: r for r in bill["rows"]} + assert report["묶음"] == 1 and group["key"] not in got + assert got[ST]["kind"] == "group" and got[ST]["sum"] == group["sum"] + assert all(got[k]["parent"] == ST for k in inner) # 안 줄 = 딸려 감(1 : N) + assert bill["totals"]["direct"]["sum"] == base - 1000 # 구조물 줄 제 금액 = 안 줄 합으로 + assert report["기억"] == {"콘크리트구조물||": {"확정": ST, "이름": "BOX 암거"}} + made, report, bill = _link(user, rows, [(group["key"], ST)], "price") + got = {r["key"]: r for r in bill["rows"]} + assert report["지움"] == len(inner) and not set(inner) & set(got) + assert got[ST]["kind"] == "item" and (got[ST]["qty"], got[ST]["src_qty"]) == (10, 10) + assert abs(got[ST]["sum"] - group["sum"]) <= 3 # B08 수량 × 1 단위 단가 · 끝수 + made, report, bill = _link(user, rows, [(group["key"], ST)], "price", counts={group["key"]: 5}) + assert next(r for r in bill["rows"] if r["key"] == ST)["sum"] == pytest.approx( + group["sum"] * 2, abs=3 + ) # 경쟁사 5 개 · B08 10 개 = 두 배 + memory = {"콘크리트구조물||": {"확정": "st:other", "이름": "BOX 암거"}} + assert lk.auto_pairs(lk.suggest(rows, memory)) == [(group["key"], ST)] # 키 없음 = 같은 이름 + + +def test_못_잇는_짝(geo): + user, rows = _load(geo) + line = next(r for r in rows if r["name"] == "토사절취") + group = next(r for r in rows if r["name"] == "콘크리트구조물") + pairs = [ + ("earth:3", "earth:4"), + (line["key"], "mat:x"), + (line["key"], ST), + (group["key"], "earth:4"), + (line["key"], "earth:4"), + (next(r for r in rows if r["name"] == "수평규준틀")["key"], "earth:4"), + ] + _made, report, _bill = _link(user, rows, pairs) + assert [m["까닭"] for m in report["못함"]] == [ + "불러온 줄이 아님", + "이을 수 없는 줄", + "묶음 ↔ 구조물 줄만", + "묶음 ↔ 구조물 줄만", + "이미 이은 줄", + ] + assert report["이음"] == 1 + with pytest.raises(ValueError): + lk.link(user, rows, [], "both") + assert lk.link(user, rows, [], "take")[1]["이음"] == 0 + + +@pytest.mark.parametrize( + "role, layer", [("USER", "personal"), ("ADMIN", "company")], ids=["일반", "회사관리자"] +) +def test_기억_층(tmp_path, monkeypatch, role, layer): + monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer) + session = {"company_id": 7, "user_id": 3, "role": role} + assert route.keep(session, {"a||": {"확정": "earth:4", "이름": "절토(토사)"}}) == layer + assert route.keep(session, {}) is None + hopyo_store.remember( + tmp_path / "company", {"a||": {"확정": "earth:5"}, "b||": {"확정": "x"}}, lk.MEMORY + ) + got = route.recalled(session) + assert got["a||"]["확정"] == ("earth:4" if layer == "personal" else "earth:5") + assert got["b||"]["확정"] == "x" + assert route.recalled({"user_id": 3, "role": role}) == {} # 회사 없음 = 층 없음