feat(B09 설계내역서): 내역 줄 ↔ 우리 공종 잇기 서버 몫 — 토공 이름 짝 표 · 맞춤 기억 · 구조물 묶음 통째 잇기 (PLAN 707-4)

- B09_Estimation_Bill_Link: 짝 표(우리 토공 17 줄 · EST · STmate 이름 24 짝 · 암 갈래 = 후보 둘) · 제안(지난번 이음 · 짝 표 · 하나 아님 = 고름) · 잇기(707-1 붙임 꼴 · take = 엑셀 수량 덮개 / price = B08 · 불러온 줄 지움 = 두 번 셈 0) · 묶음 ↔ 구조물 줄(take = 안 줄이 그 아래 / price = 1 단위 고정 단가)
- B09_Estimation_Router_Link: suggest · apply 길(끼워 넣기 = 화면 고리 일감) · 맞춤 기억 = 층 실무자료/내역맞춤.json(회사 관리자 = 회사 · 아니면 개인 · 읽기 = 회사 + 개인)
- M02_Hopyo_Store: memory · remember 에 기억 파일 이름 칸(기본 = 맞춤)
- 시험 test_707_4 8(거창 엑셀 기억 → 장수 · 진안 .estx 저절로 11 · 두 번 셈 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:45:36 +09:00
co-authored by Claude Opus 5.5
parent 550c0adcce
commit 77c6f9105c
4 changed files with 621 additions and 6 deletions
+264
View File
@@ -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
@@ -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),
}
+9 -6
View File
@@ -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
+223
View File
@@ -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}) == {} # 회사 없음 = 층 없음