Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8

This commit is contained in:
2026-10-31 15:16:26 +09:00
15 changed files with 825 additions and 51 deletions
+226
View File
@@ -0,0 +1,226 @@
"""705-3 실무자료 불러오기 빠짐 0 · 시트 통째 한 번 · [품셈으로 바꾸기](요구서 1장 · 4장 2 · 3 · 7).
- 확인 안 한 줄 · 노임 · % · 「우리에 없음」 = 고정 줄(엑셀 숫자 그대로) · 체크한 줄만 품셈 · 하위 호표 = 참조
- % 줄 = 비율 고정 줄 — M02 settle 거울(파이썬 ↔ TS) · 사전조합 셈 · B09 `pct` 줄
- [모두 고정으로 들여오기] — 북부청 · 실무 xlsx 넷: 빠짐 0 · 엑셀 합계 = 우리 합계(호표마다) · 줄 꼬리표 수 ·
확인된 호표 그대로 · 이음 = 단가까지 같은 줄만 · 같은 이름 줄 차례 가름 · 몸에 숫자 칸 없음(422)
- [품셈으로 바꾸기] 전후 — 체크 한 줄 = 품셈 · 나머지 고정 · 되돌리기(빈 몸) = 모두 고정
층은 임시 폴더(공용 storage 안 씀).
"""
import importlib.util
import json
from pathlib import Path
import pytest
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
from B09_Estimation.B09_Estimation_Labor_Source import fixed_line
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Template_Layers as layers
# 올리기 길 시험의 `client` 마당을 같이 씀
# ruff: noqa: F811
from resources.tester.test_m02_hopyo_excel import NORTH, _post, client, master_hash # noqa: F401
def _load(name):
spec = importlib.util.spec_from_file_location(name, Path(__file__).with_name(f"{name}.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
m02 = _load("test_m02_labor_calc_mirror")
ALL = "/api/m02/layers/{}/hopyo-build-all"
BUILD = "/api/m02/layers/{}/hopyo-build"
DOCS = Path(__file__).resolve().parents[2] / "resources" / "knowledge" / "original" / "실무문서"
FILES = { # 실무 xlsx 넷 — EST 꼴 거창 · STmate 꼴 영월 · 울진 대흥 · 울진 소광
"거창": "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
"영월": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
"대흥": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/"
"2024년 간선임도사업(기번3).xlsx",
"소광": "25년 산불진화임도(기번8)(울진소광)/"
"2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
}
# (호표, 줄, 고정, 하위 참조, 이음, 엑셀 = 우리) — 영월 제22호표 = STmate 하위 호표 비목 반올림 1 원 끝수
EXPECT = {
"거창": (14, 68, 56, 12, 11, 14),
"영월": (23, 138, 135, 3, 0, 22),
"대흥": (34, 171, 143, 28, 0, 34),
"소광": (25, 117, 98, 19, 0, 25),
}
def _fx(name, price, rate=None, unit="식"):
out = {"이름": name, "규격": "", "단위": unit, "단가": price, "이음": {}}
return {**out, "비율": rate} if rate else out
def _all(client, key="HP-000001"):
res = client.post(ALL.format("personal"), json={"층": "personal", "키": key})
assert res.status_code == 200, res.text
return res.json()
def _docs():
folder = layers.personal_dir(7, 42) / "precombo"
return [json.loads(p.read_bytes()) for p in sorted(folder.glob("SC-*.json"))]
# ── 비율 고정 줄 셈 ──────────────────────────────────────────────────────────
def _ratio_doc():
return labor.clean(
{
"열": "rv",
"담은로직": [
{"고정": _fx("고화매트", {"재료비": 37000}, unit="㎡"), "수량": {"손": 1}},
{"고정": _fx("고정핀", {"재료비": 200}, unit="EA"), "수량": {"손": 4.95}},
{"고정": _fx("인부", {"노무비": 1000}, unit="인"), "수량": None},
{"로직": m02.PLAIN, "수량": None},
{
"고정": _fx("잡재료비", {}, {"기준": "재료비", "비목": "재료비"}),
"수량": {"손": 3},
},
{
"고정": _fx("공구손료", {}, {"기준": "노무비", "비목": "경비"}),
"수량": {"손": 2},
},
],
"자재": [{"코드": m02.MAT, "수량": None}],
}
)
@pytest.mark.parametrize("values", [{}, {f"자재|{m02.MAT}": "3"}, {"사석 수상고르기|㎡": 2}])
def test_비율_고정_줄_파이썬과_TS_가_같은_답(values):
doc = _ratio_doc()
answer = labor.calc(doc, {})
py = labor.settle(answer, doc, values)
m02._same(py, m02._ts(answer, doc, values))
assert py == labor.calc(doc, values)
misc, tool = [ln for ln in py["줄"] if (ln.get("고정") or {}).get("비율")]
others = [ln for ln in py["줄"] if ln not in (misc, tool)]
under = sum((ln["반영"] or {}).get("재료비", 0) for ln in others) # 자재 = 별도계상(합 밖)
assert misc["반영"]["재료비"] == pytest.approx(under * 3 / 100) and misc["배"] == 100
assert misc["반영"]["노무비"] == misc["반영"]["경비"] == 0
assert tool["반영"]["경비"] == 0 # 인부 수량 없음 = 노무비 밑 0
assert py["반영합"]["계"] == pytest.approx(
sum((ln["반영"] or {}).get("계", 0) for ln in py["줄"])
)
def test_비율_정리_막힘():
bad = _fx("잡", {}, {"기준": "합계", "비목": "재료비"})
with pytest.raises(Exception, match="비율"):
labor.clean({"담은로직": [{"고정": bad, "수량": {"손": 1}}]})
def test_B09_비율_고정_줄_pct():
fx = _fx("잡재료비", {}, {"기준": "재료비", "비목": "재료비"})
one = fixed_line(fx, 3)
assert {k: one[k] for k in ("kind", "part", "base", "qty", "unit")} == {
"kind": "pct", "part": "material", "base": "part:material", "qty": 3.0, "unit": "%"
} # fmt: skip
lines = [
{"kind": "fixed", "name": "고화매트", "qty": 1, "own": {"material": 37000.0},
"link": {"labor": None, "material": None, "machine": None}},
{"kind": "fixed", "name": "고정핀", "qty": 4.95, "own": {"material": 200.0},
"link": {"labor": None, "material": None, "machine": None}},
one,
] # fmt: skip
for n, ln in enumerate(lines):
ln["cid"], ln["n"] = f"{ln['kind']}:{ln['name']}#0", n
block = {"id": "DGF", "cover": "S:f", "kind": "조합", "name": "고화매트재료비", "spec": "",
"unit": "㎡", "cite": "", "origin": {}, "lines": lines} # fmt: skip
out = compute_labor({"blocks": [block], "owners": {}}, {}, {})
got = out["daega_blocks"][0]
pct = next(ln for ln in got["lines"] if ln["kind"] == "pct")
assert pct["amounts"]["material"] == 1139.7 # (37000 + 990) × 3 %
assert got["blank"] == 0 and out["gaps"] == []
# ── 줄 하나 → 고정 줄 ────────────────────────────────────────────────────────
def test_같은_이름_차례_가름_합계만_재료비_금액만():
linker, taken = hfix.Linker(), set()
line = {"차례": 0, "이름": "잡석", "규격": "", "단위": "㎥", "수량": 2, "갈래": "자재",
"단가": {"계": 5000, "노무비": None, "재료비": None, "경비": None}} # fmt: skip
a = hfix.line_of(line, [], {"파일": "x", "호표": 1}, taken, linker)
b = hfix.line_of({**line, "차례": 1}, [], {"파일": "x", "호표": 1}, taken, linker)
assert (a["고정"]["이름"], b["고정"]["이름"]) == ("잡석", "잡석 (2)")
assert a["고정"]["단가"] == {"노무비": 0, "재료비": 5000, "경비": 0} # 합계만 = 재료비
assert b["고정"]["출처"] == {"파일": "x", "호표": 1, "줄": 1} and a["수량"] == {"손": 2}
doc = labor.clean({"담은로직": [a, b]}) # 같은 줄 가름 400 안 남
assert len(doc["담은로직"]) == 2
only = {**line, "차례": 2, "이름": "운반", "수량": None,
"금액": {"계": 700, "노무비": 700, "재료비": None, "경비": None}} # fmt: skip
c = hfix.line_of(only, [], {}, taken, linker)
assert c["수량"] == {"손": 1} and c["고정"]["단가"]["노무비"] == 700 # 금액만
def test_퍼센트_밑수_다르면_금액_그대로():
pct = {"차례": 2, "이름": "소모자재", "규격": "주재료비의", "단위": "%", "수량": 11, "갈래": "%",
"단가": {"계": 7937.2}, "금액": {"계": 873, "노무비": 0, "재료비": 873, "경비": 0}} # fmt: skip
got = hfix.line_of(pct, [], {}, set(), hfix.Linker())
assert "비율" not in got["고정"] and got["고정"]["단위"] == "식"
assert got["수량"] == {"손": 1} and got["고정"]["단가"]["재료비"] == 873
# ── 시트 통째 ────────────────────────────────────────────────────────────────
def test_북부청_모두_고정_빠짐_0_합계_같음_그대로(client):
before = master_hash()
assert _post(client).status_code == 200
got = _all(client)
assert {k: got[k] for k in ("호표", "줄", "품셈", "고정", "호표참조", "빠짐", "같음")} == {
"호표": 68, "줄": 415, "품셈": 0, "고정": 414, "호표참조": 1, "빠짐": 0, "같음": 68
} # fmt: skip
assert got["실패"] == [] and got["다름"] == [] and got["그대로"] == 0
assert got["이음"] == 13 and got["이음후보"] == 31
docs = [d for d in _docs() if d["본뜬"]["키"].count(" ") == 1] # 호표 문서(감싼 줄 빼고)
lines = [x for d in docs for x in d["담은로직"]]
assert len(docs) == 68 and all(d["본뜬"]["품셈"] == [] for d in docs)
assert sum("고정" in x for x in lines) == 414 and sum("참조" in x for x in lines) == 1
assert sum(bool(x.get("고정", {}).get("비율")) for x in lines) == 1 # 잡재료비 3 %
again = _all(client) # 다시 = 확인된 호표 덮지 않음
assert again["그대로"] == 68 and again["고정"] == 0 and again["같음"] == 68
assert master_hash() == before
@pytest.mark.parametrize("tag", list(FILES))
def test_실무_xlsx_넷_모두_고정(client, tag):
path = DOCS / FILES[tag]
res = _post(client, data=path.read_bytes(), name=path.name)
assert res.status_code == 200, res.text
got = _all(client)
no, rows, fixed_n, kids, links, same = EXPECT[tag]
assert (got["호표"], got["줄"], got["고정"], got["호표참조"]) == (no, rows, fixed_n, kids)
assert got["빠짐"] == 0 and got["품셈"] == 0 and got["실패"] == []
assert got["고정"] + got["호표참조"] == got["줄"] # 줄 하나도 안 빠짐
assert got["이음"] == links and got["같음"] == same
assert all(abs(x["차이"]) <= 1 for x in got["다름"])
def test_몸에_숫자_칸_없음(client):
assert _post(client).status_code == 200
bad = {"층": "personal", "키": "HP-000001", "금액": 1}
assert client.post(ALL.format("personal"), json=bad).status_code == 422
# ── [품셈으로 바꾸기] 전후 ────────────────────────────────────────────────────
def test_품셈으로_바꾸기_되돌리기(client):
assert _post(client).status_code == 200
_all(client)
body = {"층": "personal", "키": "HP-000001", "번": 3}
one = {"차례": 1, "대상": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1}]}
res = client.post(BUILD.format("personal"), json={**body, "줄": [one]})
assert res.status_code == 200, res.text
after = res.json()
assert after["다시씀"] is True and after["줄수"]["품셈"] == 1 and after["줄수"]["고정"] == 3
doc = next(d for d in _docs() if d["본뜬"]["키"] == "HP-000001 제3호표")
assert doc["본뜬"]["품셈"] == [1] and doc["담은로직"][1]["로직"] == "GF000101"
assert after["비교"]["같음"] is False # 품셈 줄 = 우리 값(엑셀과 다름)
view = client.get("/api/m02/layers/personal/hopyo/HP-000001/3").json()
assert view["호표"]["만든"]["품셈"] == [1] and view["호표"]["만든"]["고정"] == 3
back = client.post(BUILD.format("personal"), json={**body, "줄": []}).json() # 되돌리기
assert back["줄수"]["품셈"] == 0 and back["비교"]["같음"] is True
+14 -11
View File
@@ -59,7 +59,12 @@ def test_확인한_줄만_별칭_수량_빠진_줄_장부(client):
["personal", "7/42"],
)
assert origin["확인"]["누가"] == {"id": 42, "이름": "갑"} and origin["확인"]["때"]
assert doc["담은로직"] == [
stone, *made, soil = doc["담은로직"] # 705-3 — 확인 안 한 줄 · 「우리에 없음」 = 고정 줄
assert stone["고정"]["단가"] == {"노무비": 0, "재료비": 1400000, "경비": 0}
assert stone["고정"]["출처"]["줄"] == 0 and stone["수량"] == {"손": 1}
assert soil["고정"]["이름"] == "잔토처리(인력100%)" and soil["수량"] == {"손": 0.09}
assert origin["품셈"] == [1, 2] and got["줄수"]["고정"] == 2 and got["빠진줄"] == []
assert made == [
{
"로직": "GF000101",
"입력값": {"지역": "서울"},
@@ -75,11 +80,7 @@ def test_확인한_줄만_별칭_수량_빠진_줄_장부(client):
"메모": "실무 0.05㎥ × 2",
},
]
assert doc["자재"] == [] # 표지석 = 몸에 없음 → 안 들어감
assert [(x["이름"], x["금액"], x["까닭"]) for x in got["빠진줄"]] == [
("임도표지석(받침,글새김포함)", 1400000, "확인 안 함"),
("잔토처리(인력100%)", 3097.1, "뺌 — 우리에 없음"),
]
assert doc["자재"] == [] # 표지석 = 몸에 없음 → 고정 줄
assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0
assert got["다시씀"] is False and got["장부"] == {"씀": 3}
book = _book(mine)
@@ -104,7 +105,8 @@ def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client):
got = _build(client, 3, lines)
mine = layers.personal_dir(7, 42)
doc = _doc(mine, got["참조"]["키"])
first, second, wrapped = doc["담은로직"]
first, second, fx, wrapped = doc["담은로직"]
assert fx["고정"]["출처"]["줄"] == 2 # 확인 안 한 줄 = 고정(705-3)
assert first["별칭"] == second["별칭"] == "구조물터파기(인력100%)" # 한 줄 로직 둘 = 같은 별칭
assert second["수량"] == {"손": 0.014} and second["메모"] == "실무 0.14㎥ × 0.1(몫 0.1) · 토사"
assert wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] == {"손": 0.09}
@@ -119,7 +121,7 @@ def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client):
files = sorted(p.name for p in (mine / "precombo").glob("*.json"))
again = _build(client, 3, lines[:1]) # 다시 확인 = 같은 본뜬 덮음
assert again["다시씀"] is True and again["참조"] == got["참조"]
assert _doc(mine, got["참조"]["키"])["담은로직"] == []
assert [x["고정"]["출처"]["줄"] for x in _doc(mine, got["참조"]["키"])["담은로직"]] == [1, 2, 3]
assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files
@@ -177,8 +179,7 @@ def test_전국_자재_키_없이_본뜬에(client):
def test_몸_없음_재검사_400(client):
assert _post(client).status_code == 200
one = {"로직": "GF000101", "배": 1}
_build(client, 3, want=400) # 옛 몸(줄 없음) = 확인한 줄이 없음
_build(client, 3, [], want=400)
_build(client, 3, 통째={"대상": []}, want=400) # 빈 몸 = 모두 고정(705-3 시험) · 빈 통째 = 400
bad = [
[{"차례": 9, "대상": [one]}], # 호표 밖 줄
[{"차례": 1, "대상": [one]}, {"차례": 1, "대상": [one]}], # 겹친 줄
@@ -226,7 +227,9 @@ def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client):
ref = [{"차례": 0, "대상": [{"호표": 46, "배": 1}]}]
assert "제46호표 먼저 확인" in _build(client, 45, ref, want=409)["detail"]
child = _build(client, 46, [{"차례": 0, "뺌": "안 씀"}])
assert any(x["까닭"].startswith("% 줄") for x in child["빠진줄"])
assert [x["까닭"] for x in child["빠진줄"]] == ["뺌 — 안 씀"] # % 줄 = 비율 고정 줄(705-3)
rate = _doc(layers.personal_dir(7, 42), child["참조"]["키"])["담은로직"][-1]["고정"]
assert rate["비율"] == {"기준": "재료비", "비목": "재료비"}
got = _build(client, 45, ref)
mine = layers.personal_dir(7, 42)
line = _doc(mine, got["참조"]["키"])["담은로직"][0]
+3 -2
View File
@@ -113,7 +113,8 @@ def test_끝까지_확인한_줄만_별칭(client):
k: "별칭" for k in again["철근가공조립(현장)"]["대상"][0]["입력값"]
}
first = _one(client, "HP-000001", no)
assert first["만든"] == {"키": made["참조"]["키"], "확인": True}
assert {k: first["만든"][k] for k in ("키", "확인")} == {"키": made["참조"]["키"], "확인": True}
assert first["만든"]["품셈"] == doc["본뜬"]["품셈"] # 705-3 — 품셈 줄 차례(첫 체크)
assert master_hash() == before
@@ -137,7 +138,7 @@ def test_막는_길(client):
"/api/m02/layers/personal/hopyo-build",
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": []},
)
assert empty.status_code == 400 and "확인한 줄이 없음" in empty.text
assert empty.status_code == 200 and empty.json()["줄수"]["품셈"] == 0 # 빈 몸 = 모두 고정(705-3)
money = client.post(
"/api/m02/layers/personal/hopyo-build",
json={"층": "personal", "키": "HP-000001", "번": 1, "줄": [{"차례": 1, "금액": 1}]},