245 lines
13 KiB
Python
245 lines
13 KiB
Python
"""707-1 경쟁사 내역 줄 불러오기 — 읽기(`Bill_Excel`) · 짜기(`Bill_Import`) · 고정 단가 칸 셈 거울.
|
||
|
||
- 실무 xlsx(STmate 기번3 · 영월 · 봉화 · EST 거창) = 빠짐 0 · 불러온 뒤 직접비 합 = 엑셀 합 ·
|
||
어긋난 줄 0 · 조달수수료 = 율(줄로 안 실음) · DRM = 「읽을 수 없음」
|
||
- 수량 갈래 (가) take = 붙인 자동 줄 수량 엑셀 · auto_qty = B08 / (나) price = B08 · src_qty = 엑셀
|
||
- 두 번 셈 막음 = 이름 같은 자동 줄에 붙임(한 번만 셈) · drop = 남은 자동 줄 지움 ·
|
||
같은 파일 다시 = 갈아 끼움 · 조달수수료율 = 빈 때만
|
||
- 고정 단가 칸 = 연결이 단가를 못 낼 때만 · 다듬기 · TS 짝 같은 값
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import subprocess
|
||
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_Store as store # noqa: E402
|
||
from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, read_bill # noqa: E402
|
||
from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402
|
||
|
||
K = ROOT / "resources" / "knowledge" / "original" / "실무문서"
|
||
FILES = {
|
||
"기번3": K / "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx", # noqa: E501
|
||
"영월": K / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
|
||
"봉화": K / "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
|
||
"거창": K / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx", # noqa: E501
|
||
} # fmt: skip
|
||
#: (꼴, 항목 수, 엑셀 직접비 합)
|
||
WANT = {
|
||
"기번3": ("STmate", 136, 710359136),
|
||
"영월": ("STmate", 108, 767148700),
|
||
"봉화": ("STmate", 77, 781283415),
|
||
"거창": ("EST", 60, 384916227),
|
||
}
|
||
EMPTY = {"materials": [], "bill": {"items": []}, "hopyo": {}}
|
||
ITEMS = [
|
||
{"key": "earth:4", "section": "1.2", "name": "절토(토사)", "unit": "㎥", "qty": 10,
|
||
"ref": {"tab": "sg_list", "key": "D01341"}},
|
||
{"key": "earth:9", "section": "1.6", "name": "노면 정리X", "unit": "㎡", "qty": 3,
|
||
"ref": {"tab": "sg_list", "key": "D01341"}},
|
||
{"key": "earth:6", "section": "1.2", "name": "암발파", "unit": "㎥", "qty": None,
|
||
"ref": {"tab": "sg_list", "key": "D01341"}},
|
||
{"key": "haul:3", "section": "3", "name": "모래", "unit": "㎥", "qty": 2,
|
||
"ref": {"tab": "sg_list", "key": "D01341"}},
|
||
] # fmt: skip
|
||
INPUTS = {"materials": [], "bill": {"items": ITEMS}, "hopyo": {"D01341": {
|
||
"labor": 1, "material": 0, "expense": 0}}} # fmt: skip
|
||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def parsed():
|
||
return {k: read_bill(p.name, p.read_bytes()) for k, p in FILES.items()}
|
||
|
||
|
||
def _rows(inputs, user):
|
||
return compute_estimation(inputs, user, None, {})["bill"]
|
||
|
||
|
||
def _load(got, inputs=EMPTY, user=None, **kw):
|
||
user = user or {}
|
||
made, report = imp.apply(got, user, _rows(inputs, user)["rows"], "abcd1234", **kw)
|
||
made = store.clean_user(made)
|
||
return made, report, _rows(inputs, made)
|
||
|
||
|
||
@pytest.mark.parametrize("name", list(FILES))
|
||
def test_실무_내역_빠짐_0_합계_엑셀과_같음(parsed, name):
|
||
got = parsed[name]
|
||
kind, items, total = WANT[name]
|
||
assert (got["꼴"], sum(1 for x in got["줄"] if not x["묶음"])) == (kind, items)
|
||
assert got["합계"]["직접"] == total and got["조달수수료율"] == 0.54
|
||
made, report, bill = _load(got)
|
||
assert report["실음"] == len(got["줄"]) and report["붙임"] == 0
|
||
assert bill["totals"]["direct"]["sum"] == total and bill["totals"]["direct"]["blank"] == 0
|
||
assert imp.differences(got, report, bill["rows"]) == []
|
||
assert made["estimate"]["procurement_fee_pct"] == 0.54
|
||
assert max(r["level"] for r in bill["rows"]) <= 5
|
||
keys = {r["key"] for r in bill["rows"]}
|
||
assert all(k in keys for k in report["줄"].values()) # 실은 줄 모두 보임
|
||
# 관급 = 제외(관급자재대 아래 · 직접비 밖)
|
||
assert bill["totals"]["owner"]["net_material"] > 0
|
||
|
||
|
||
def test_EST_비고_참조_STmate_코드(parsed):
|
||
est = [x["호표"] for x in parsed["거창"]["줄"] if not x["묶음"]]
|
||
assert {"종류": "산근", "번": 46} in est and {"종류": "대가", "번": 8} in est
|
||
st = next(x for x in parsed["기번3"]["줄"] if x["이름"] == "규준틀설치")
|
||
assert st["호표"] == {"종류": "대가", "번": 5, "코드": "B00194"}
|
||
|
||
|
||
def test_DRM_읽을_수_없음():
|
||
with pytest.raises(BillExcelError, match="읽을 수 없음"):
|
||
read_bill("a.xlsx", b"\x9b DRMONE" + b"\x00" * 300)
|
||
with pytest.raises(BillExcelError, match="엑셀"):
|
||
read_bill("a.xlsx", b"PK\x03\x04 broken")
|
||
|
||
|
||
def test_가_넘겨받기_붙인_자동_줄_수량_엑셀(parsed):
|
||
made, report, bill = _load(parsed["기번3"], INPUTS, mode="take")
|
||
rows = {r["key"]: r for r in bill["rows"]}
|
||
assert report["붙임"] == 2 # 절토(토사) 첫 줄만(1공구) · 암발파 · 운반 「모래」 = 안 붙음
|
||
cut = rows["earth:4"]
|
||
assert (cut["qty"], cut["auto_qty"], cut["src_qty"]) == (5675, 10, 5675)
|
||
assert cut["fixed"] and cut["sum"] == 11003825 # 엑셀 단가 · 연결 덮개 null
|
||
assert cut["parent"].startswith("hand:xabcd1234-") # 불러온 자리로 옮김
|
||
assert sum(1 for r in bill["rows"] if r["name"] == "절토(토사)") == 3 # 두 번 셈 없음
|
||
# 붙인 줄 금액 = 엑셀 · 안 붙은 자동 줄(노면 정리X 3 · 운반 모래 2) 그대로 = 직접비 = 엑셀 + 5
|
||
assert bill["totals"]["direct"]["sum"] == 710359136 + 5
|
||
assert "haul:3" in rows and rows["haul:3"]["parent"] == "sec:3"
|
||
tops = [r["key"] for r in bill["rows"] if r["parent"] is None]
|
||
assert tops.index("hand:xabcd1234-00006") < tops.index("sec:G") # 1공구 = 관급자재대 앞
|
||
|
||
|
||
def test_나_단가만_B08_이김_비교_칸(parsed):
|
||
made, report, bill = _load(parsed["기번3"], INPUTS, mode="price")
|
||
cut = {r["key"]: r for r in bill["rows"]}["earth:4"]
|
||
assert (cut["qty"], cut["src_qty"], "auto_qty" in cut) == (10, 5675, False)
|
||
assert cut["sum"] == 1939 * 10 # 엑셀 단가 × B08 수량
|
||
blast = {r["key"]: r for r in bill["rows"]}["earth:6"]
|
||
assert (blast["qty"], blast["sum"]) == (3889, 43700693) # B08 수량 없음 = 엑셀 수량
|
||
diff = imp.differences(parsed["기번3"], report, bill["rows"])
|
||
assert [d["까닭"] for d in diff] == ["수량 = B08(단가만)"]
|
||
|
||
|
||
def test_붙이지_않음_지우기_다시_부르기(parsed):
|
||
_, report, bill = _load(parsed["기번3"], INPUTS, attach=False, drop=True)
|
||
assert (report["붙임"], report["지움"]) == (0, 4) # 운반 줄도 지움
|
||
assert bill["totals"]["direct"]["sum"] == 710359136
|
||
made, _, _ = _load(parsed["기번3"], INPUTS)
|
||
made["bill"]["rows"].append({"key": "hand:mine", "parent": "hand:xabcd1234-00007",
|
||
"name": "내 줄", "qty": 1, "ref": None}) # fmt: skip
|
||
again, report, bill = _load(parsed["기번3"], INPUTS, user=made)
|
||
keys = [e["key"] for e in again["bill"]["rows"]]
|
||
assert len(keys) == len(set(keys)) and report["붙임"] == 2
|
||
assert bill["totals"]["direct"]["sum"] == 710359136 + 5 # 갈아 끼움 · 두 번 안 셈
|
||
mine = next(e for e in again["bill"]["rows"] if e["key"] == "hand:mine")
|
||
assert "parent" not in mine # 내 줄 = 남음(맨 위)
|
||
|
||
|
||
def test_조달수수료율_빈_때만(parsed):
|
||
made, _, _ = _load(parsed["거창"], user={"estimate": {"procurement_fee_pct": 1.0}})
|
||
assert made["estimate"]["procurement_fee_pct"] == 1.0
|
||
|
||
|
||
def test_고정_단가_칸_다듬기_연결이_이김():
|
||
raw = {"bill": {"v": 2, "rows": [
|
||
{"key": "hand:a", "name": "a", "qty": 2, "ref": {"tab": "sg", "key": "SG:1"},
|
||
"price": {"labor": 5, "material": "x"}, "src_qty": 3, "note": "대가 5호표"},
|
||
{"key": "earth:4", "price": {"expense": 7}, "src_qty": "y"},
|
||
]}} # fmt: skip
|
||
user = store.clean_user(raw)
|
||
hand, auto = user["bill"]["rows"]
|
||
assert hand["price"] == {"labor": 5, "material": 0, "expense": 0} and hand["src_qty"] == 3
|
||
assert hand["note"] == "대가 5호표" and auto == {"key": "earth:4", "price": {
|
||
"labor": 0, "material": 0, "expense": 7}} # fmt: skip
|
||
books = {"sg_list": [{"no": 1, "key": "SG:1", "labor": 100, "material": 0, "expense": 0,
|
||
"sum": 100}]} # fmt: skip
|
||
rows = {r["key"]: r for r in compute_estimation(INPUTS, user, None, books)["bill"]["rows"]}
|
||
assert (rows["hand:a"]["sum"], rows["hand:a"].get("fixed")) == (200, None) # 연결 이김
|
||
assert rows["earth:4"]["sum"] == 10 and "fixed" not in rows["earth:4"] # 자동 연결 이김
|
||
rows = {r["key"]: r for r in compute_estimation(INPUTS, user, None, {})["bill"]["rows"]}
|
||
assert (rows["hand:a"]["sum"], rows["hand:a"]["fixed"]) == (10, True)
|
||
assert rows["hand:a"]["note"] == "대가 5호표"
|
||
|
||
|
||
def test_거울_TS_가_같은_값(parsed):
|
||
calc = "".join(
|
||
IMPORTS.sub("", (ROOT / "B09_Estimation" / f"B09_Estimation_{n}.ts").read_text("utf-8"))
|
||
for n in ("Calc_Base", "Calc_Bill")
|
||
)
|
||
users = [
|
||
_load(parsed["거창"], INPUTS, mode="take")[0],
|
||
_load(parsed["영월"], INPUTS, mode="price")[0],
|
||
{"bill": {"v": 2, "rows": [{"key": "earth:4", "ref": None, "price": {"labor": 3}},
|
||
{"key": "hand:a", "qty": 2, "price": {"material": 4}}]}},
|
||
] # fmt: skip
|
||
body = (
|
||
f"\nconst i = {json.dumps(INPUTS, ensure_ascii=False)};"
|
||
f"\nconst us = {json.dumps(users, ensure_ascii=False)};"
|
||
"\nconsole.log(JSON.stringify(us.map((u) => computeEstimation(i, u, null, {}))));"
|
||
)
|
||
result = subprocess.run(
|
||
["node", "--experimental-strip-types", "--no-warnings",
|
||
"--input-type=module-typescript"],
|
||
input=calc + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||
) # fmt: skip
|
||
assert result.returncode == 0, result.stderr
|
||
assert json.loads(result.stdout) == [compute_estimation(INPUTS, u, None, {}) for u in users]
|
||
|
||
|
||
def test_이음_같은_책_일위대가_이음_단가_어긋남_알림(parsed):
|
||
got = parsed["기번3"]
|
||
link = {"tab": "dg", "key": "DG:5", "lib": "precombo@personal@1-3@SC-000001"}
|
||
made, report, _ = _load(got, links={5: link})
|
||
lines = [e for e in made["bill"]["rows"] if e.get("ref")]
|
||
assert report["이음"] == 3 and len(lines) == 3 # 대가 5호표(종단규준틀) 공구마다
|
||
assert lines[0]["ref"] == link and lines[0]["price"]["labor"] == 21936 # 고정 단가 곁에 남김
|
||
same = {"daega": [{"no": 5, "key": "DG:5", "labor": 21936, "material": 3885, "expense": 0,
|
||
"sum": 25821}]} # fmt: skip
|
||
rows = compute_estimation(EMPTY, made, None, same)["bill"]["rows"]
|
||
assert imp.differences(got, report, rows) == [] # 실무자료 사본 = 엑셀 단가 그대로
|
||
other = {"daega": [{**same["daega"][0], "labor": 30000, "sum": 33885}]}
|
||
rows = compute_estimation(EMPTY, made, None, other)["bill"]["rows"]
|
||
diff = imp.differences(got, report, rows)
|
||
assert len(diff) == 3 and {d["까닭"] for d in diff} == {"이음 단가 = 우리 셈"}
|
||
# 사본이 없어짐(연결이 단가를 못 냄) = 고정 단가
|
||
rows = compute_estimation(EMPTY, made, None, {})["bill"]["rows"]
|
||
assert imp.differences(got, report, rows) == []
|
||
|
||
|
||
def test_이음_찾기_같은_파일_이름_본뜬만(tmp_path, monkeypatch):
|
||
from B09_Estimation import B09_Estimation_Router_Import as route
|
||
|
||
folder = tmp_path / "precombo"
|
||
folder.mkdir()
|
||
docs = {
|
||
"SC-000001": {"층": "실무자료", "키": "HP-000001 제5호표", "파일": "a.xlsx"},
|
||
"SC-000002": {"층": "실무자료", "키": "HP-000001 제5호표 2째 줄", "파일": "a.xlsx"},
|
||
"SC-000003": {"층": "실무자료", "키": "HP-000002 제8호표", "파일": "b.xlsx"},
|
||
"SC-000004": {"층": "실무자료", "키": "HP-000001 제9호표", "파일": "a.xlsx"},
|
||
}
|
||
for key, origin in docs.items():
|
||
(folder / f"{key}.json").write_text(json.dumps({"본뜬": origin}), encoding="utf-8")
|
||
|
||
class Got:
|
||
layer, owner = "personal", "1/3"
|
||
|
||
def grant(session, layer):
|
||
if layer != "personal":
|
||
raise route.HTTPException(status_code=409)
|
||
return Got
|
||
|
||
monkeypatch.setattr(route.access, "grant", grant)
|
||
monkeypatch.setattr(route.refs, "folder_of", lambda layer, owner: tmp_path)
|
||
found = route._found({}, "a.xlsx", {5, 8})
|
||
assert found == {5: ("personal", "1/3", "SC-000001")} # 감싼 줄 · 다른 파일 · 안 쓰는 번 뺌
|