feat(B09 설계내역서): 경쟁사 내역 엑셀 줄 불러오기 서버 — 읽기 · 짜기 · 고정 단가 칸 (PLAN 707-1)

- 읽기(Bill_Excel) — STmate 「설계내역서」 숨은 코드 · 부모 코드 열 · EST 「내역서」 공종 번호 깊이 · 비고 대가 · 산근 · 단산 · 일위 · 제외 묶음(관급 · 제외금액) · 조달수수료 = 율 · DRM = 「읽을 수 없음」 · 열 머리 = M02 실무자료 칸 읽기 재사용
- 짜기(Bill_Import) — 공종 = 새 묶음 · 항목 = 새 줄 + 고정 단가 칸 · 수량 갈래 take · price · 이름 같은 자동 줄 붙임 · 남은 자동 줄 지움 · 같은 파일 다시 = 갈아 끼움 · 어긋난 줄 목록
- 길(Router_Import) — import/bill(file · file_key) 미리 보기 · import/bill/apply(같은 파일 이름 실무자료 사전조합 → 서고 사본 연결 · 층 쓰기 0) · 저장 안 함
- 셈 짝(Calc_Bill py · ts) — 줄 price = 연결이 단가를 못 낼 때 금액 · fixed · src_qty · Store 다듬기
- 확인 = 기번3 710,359,136 · 영월 767,148,700 · 봉화 781,283,415 · 거창 384,916,227 직접비 합 = 엑셀 · 빈 줄 0 · 어긋남 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:43:51 +09:00
co-authored by Claude Opus 5.5
parent df5da11b37
commit c0ea73501f
8 changed files with 910 additions and 8 deletions
+187
View File
@@ -0,0 +1,187 @@
"""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",
"영월": K / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
"봉화": K / "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
"거창": K / "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
} # 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"}},
] # 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["붙임"] == 1 # 절토(토사) 첫 줄만(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
assert bill["totals"]["direct"]["sum"] == 710359136 + 3
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 수량
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, 2)
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["붙임"] == 1
assert bill["totals"]["direct"]["sum"] == 710359136 + 3 # 갈아 끼움 · 두 번 안 셈
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]