Files
Aislo/resources/tester/test_707_4_bill_link.py
T
eomsangdonandClaude Opus 5.5 28e9a2a667 feat(718-2): 별칭 장부 시스템 모으기 서버 — 회사 · 개인 층 맞춤 + 공사 내역맞춤 후보 · 익명 · 충돌 · 승인 → 마스터 · 반려 기억
- 새 M01_MasterData_Alias(/api/m01/alias-ledger · 시스템 관리자만 · main.py 한 줄) — 공유 storage 읽기만 · 같은 짝 묶음(서명) · 회사 수 · 사람 수만 · 같은 열쇠 다른 짝 · 마스터와 다름 = 충돌 · 승인 = 서버가 다시 모아 찾음 → 맞춤 ledger.put 재검사 · 내역맞춤 마스터 기억 · 반려 = 마스터 층 별칭반려 기억 · 자동 반영 없음
- B09 잇기 기억 읽기에 마스터 층을 맨 아래로(브레인 허락) · test_707_4 기억 층 시험에 마스터 줄 한 줄
- 새 시험 test_718_2_alias_gather(임시 storage — 모음 수 · 익명 · 충돌 · 승인 → 마스터 줄 · 다른 회사 새 사용자가 씀 · 반려 기억 · 권한)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:50:02 +09:00

322 lines
16 KiB
Python

"""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)
monkeypatch.setattr(route.m02_store, "FOLDER", tmp_path / "system")
master = {"a||": {"확정": "earth:9"}, "m||": {"확정": "earth:1"}} # 시스템 승인(718-2)
hopyo_store.remember(tmp_path / "system", master, lk.MEMORY)
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 got["m||"]["확정"] == "earth:1" # 회사 · 개인 줄이 마스터보다 앞섬(a = 아래 층)
assert route.recalled({"user_id": 3, "role": role}) == master # 회사 없음 = 마스터만
def _same(a, b):
"""두 셈 = 같은 줄 · 같은 직접비."""
assert [r["key"] for r in a["rows"]] == [r["key"] for r in b["rows"]]
assert a["totals"]["direct"]["sum"] == b["totals"]["direct"]["sum"]
@pytest.mark.parametrize("mode", ["take", "price"])
def test_잇기_풀기_제자리(geo, mode):
user, rows = _load(geo)
before = _bill(user)
group = next(r for r in rows if r["name"] == "콘크리트구조물")
pairs = [*_pick(lk.suggest(rows, {})), (group["key"], ST)]
made, report, bill = _link(user, rows, pairs, mode)
assert report["이음"] == 12 and report["묶음"] == 1
linked = [e for e in made["bill"]["rows"] if e.get("was")]
assert len(linked) == 13 # 저장 다듬기 뒤에도 되돌릴 것 남음
for entry in linked[::2] + linked[1::2]: # 이은 차례와 다르게
made, why = lk.unlink(made, bill["rows"], entry["key"])
assert why == ""
made = store.clean_user(made)
bill = _bill(made)
_same(bill, before)
assert lk.unlink(made, bill["rows"], "earth:4")[1] == "이은 줄이 아님"
def test_줄마다_갈래_판(geo):
user, rows = _load(geo)
cut = next(r for r in rows if r["name"] == "토사절취")
side = next(r for r in rows if r["name"] == "측구터파기" and r["spec"] == "토사")
pairs = [(cut["key"], "earth:4"), (side["key"], "earth:11")]
made, report, bill = _link(user, rows, pairs, "take", modes={side["key"]: "price"})
got = {r["key"]: r for r in bill["rows"]}
assert got["earth:4"]["qty"] == cut["qty"] and got["earth:11"]["qty"] == 100
with pytest.raises(ValueError):
lk.link(user, rows, pairs, "take", modes={cut["key"]: "both"})
board = lk.board(made, bill["rows"])
left = {r["key"] for r in board["남은"]}
assert cut["key"] not in left and side["key"] not in left
assert {r["key"] for r in board["이은"]} == {"earth:4", "earth:11"}
assert board["이은"][0]["memo"] in report["기억"]
assert not {"earth:4", "earth:11"} & {r["key"] for r in board["자리"]}
group = next(r for r in rows if r["name"] == "콘크리트구조물")
inner = {r["key"] for r in rows if r["parent"] == group["key"]}
made, _r, bill = _link(made, bill["rows"], [(group["key"], ST)], "take")
left = {r["key"] for r in lk.board(made, bill["rows"])["남은"]}
assert not inner & left # 구조물 묶음에 딸린 줄 = 남은 줄 아님
def test_이_짝_잊기(tmp_path, monkeypatch):
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
user = {"company_id": 7, "user_id": 3, "role": "USER"}
gone = {lk.FORGOT: True}
route.keep(user, {"a||": {"확정": "earth:4"}, "b||": {"확정": "earth:5"}})
hopyo_store.remember(tmp_path / "company", {"c||": {"확정": "x"}}, lk.MEMORY)
assert route.keep(user, {"a||": gone}) == "personal"
assert route.recalled(user)["a||"] == gone and route.recalled(user)["b||"]["확정"] == "earth:5"
route.keep(user, {"c||": gone}) # 일반 = 회사 기억을 개인 층 표시로 덮음
assert route.recalled(user)["c||"] == gone
def test_잊은_짝_느슨한_기억에도_안_나옴(tmp_path, monkeypatch):
monkeypatch.setattr(route.access, "folder", lambda got, create=False: tmp_path / got.layer)
user = {"company_id": 7, "user_id": 3, "role": "USER"}
row = {"name": "토사절취", "spec": "A", "unit": "m3"}
other = {"name": "토사절취", "spec": "B", "unit": "m3"}
live = {"확정": "earth:4", "이름": "절토(토사)"}
route.keep(user, {lk.key_of(other): live})
assert (
lk.recall(route.recalled(user), row) == live
) # 규격 달라도 품명 · 단위 같음 = 느슨한 기억
route.keep(user, {lk.key_of(row): {lk.FORGOT: True}})
memory = route.recalled(user)
assert lk.recall(memory, row) is None # 잊음 = 느슨한 기억도 끊김
assert lk.recall(memory, other) == live # 다른 줄은 그대로
third = {"name": "토사절취", "spec": "C", "unit": "m3"}
assert lk.recall(memory, third) is None # 잊은 짝 곁에서는 느슨하게 안 좁힘
route.keep(user, {lk.key_of(row): live}) # 다시 이음 = 풀림
assert lk.recall(route.recalled(user), row) == live
def test_잊은_짝_짝_표_제안에도_안_나옴(geo):
_user, rows = _load(geo)
now = {s["key"]: s for s in lk.suggest(rows, {})}
line = next(r for r in rows if (r["name"], r.get("spec")) == ("토사절취", "백호0.7㎥"))
assert now[line["key"]]["꼬리표"] == lk.TABLE
memory = {lk.key_of(line): {lk.FORGOT: True}}
after = {s["key"]: s for s in lk.suggest(rows, memory)}
assert line["key"] not in after # 짝 표 제안도 잊음
assert set(now) - set(after) == {line["key"]} # 다른 줄은 그대로
memory[lk.key_of(line)] = {"확정": "earth:4", "이름": "절토(토사)"} # 다시 이음 = 풀림
assert lk.suggest(rows, memory)[0] is not None
assert line["key"] in {s["key"] for s in lk.suggest(rows, memory)}