feat(M02): 338-3b 확인한 줄만 사전조합 — Build 새 몸 · 재검사 · 별칭 장부 씀 · 장부 길 · 옛 즉시 확정 길 없앰

- M02_Hopyo_Build — 몸 줄만(없으면 400) · 대상 여럿 같은 별칭 · 배 · 몫 · 통째 · 하위 호표 확인 전 409 · 본뜬 원본 · 확인 · 같은 본뜬 덮음 · confirmed(folder, source)
- M02_Labor_Router — hopyo-build 엄격 몸(extra forbid) · hopyo-ledger GET · PUT · hopyo-memory 없앰 · picks 확인 · 올린 뒤 Fit.ensure · 시동 Ours.warm(있을 때만) · ?to= 만들 층
- M02_Hopyo_Ledger — times_of 나눔
- 시험 test_m02_hopyo_build.py 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
This commit is contained in:
2026-10-15 23:21:09 +09:00
co-authored by Claude Opus 5.5
parent ea21006352
commit bf5d130e32
4 changed files with 508 additions and 223 deletions
+161 -76
View File
@@ -1,11 +1,13 @@
"""일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · `M02_Hopyo_Build`) — 별칭 남음 ·
수량 = 엑셀 × 환산 · 자재 · 같은 로직 둘 = 감쌈 · 일위N 참조 · 순환 ·
같은 호표 다시 = 같은 사전조합 · 새로 = 덮음 ·
빠진 줄 · 값 비교 · 길 권한 · master_data 원본 그대로. 층은 임시 폴더.
"""일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · 338-3 · `M02_Hopyo_Build`) — 확인한 줄만 ·
몸 없음 400 · 빠진 줄 까닭 · 로직 여럿 같은 별칭 · 같은 로직 감쌈 · 배 · 몫 · 자재 · 통째 ·
재검사 400(없는 로직 · 모르는 입력 · 배 ≤ 0 · 금액 칸) · 하위 호표 409 → 확인 뒤 참조 · 장부 씀 ·
`본뜬.확인` · 덮음 · 옛 즉시 확정 길 404 · 장부 길(보기 · 고치기 · 권한) · picks 확인 ·
master_data 원본 그대로. 층은 임시 폴더.
"""
import json
from M02_MasterTemplete import M02_Hopyo_Build as hb
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Template_Layers as layers
@@ -16,18 +18,14 @@ from resources.tester.test_m02_hopyo_excel import URL, _post, client, master_has
DIG = "구조물터파기(인력100%)|토사|m3"
FILL = "구조물되메우기(인력100%)||m3"
SOIL = "잔토처리(인력100%)||m3"
STONE = "임도표지석(받침,글새김포함)|900x600x200x150|m3"
MEMORY = "/api/m02/layers/{}/hopyo-memory"
BUILD = "/api/m02/layers/{}/hopyo-build"
LEDGER = "/api/m02/layers/{}/hopyo-ledger"
def _remember(client, layer="personal", **lines):
res = client.post(MEMORY.format(layer), json={"줄": lines})
assert res.status_code == 200, res.text
def _build(client, no, layer="personal", want=200, **extra):
def _build(client, no, lines=None, layer="personal", want=200, **extra):
body = {"층": "personal", "키": "HP-000001", "번": no, **extra}
if lines is not None:
body["줄"] = lines
res = client.post(BUILD.format(layer), json=body)
assert res.status_code == want, res.text
return res.json()
@@ -37,22 +35,30 @@ def _doc(layer_dir, key):
return json.loads((layer_dir / "precombo" / f"{key}.json").read_bytes())
def test_만들기_별칭_수량_메모_빠진_줄_비교(client):
def _book(layer_dir):
return json.loads((layer_dir / hs.DIRNAME / f"{hs.MEMORY}.json").read_bytes())["줄"]
def test_확인한_줄만_별칭_수량_빠진_줄_장부(client):
before = master_hash()
assert _post(client).status_code == 200
_remember(
client,
**{
DIG: {"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1},
FILL: {"로직": "GF000113", "환산": 2},
},
)
got = _build(client, 3)
lines = [
{"차례": 1, "대상": [{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1}]},
{"차례": 2, "대상": [{"로직": "GF000113", "배": 2}]},
{"차례": 3, "뺌": "우리에 없음"},
]
got = _build(client, 3, lines)
mine = layers.personal_dir(7, 42)
doc = _doc(mine, got["참조"]["키"])
assert (doc["이름"], doc["단위"], doc["구분"]) == ("임도표지석 910×600×150", "개소", "실무자료")
assert doc["상세구분"].endswith("(공통단가).xls")
assert doc["본뜬"]["층"] == "실무자료" and doc["본뜬"]["키"] == "HP-000001 제3호표"
origin = doc["본뜬"]
assert (origin["층"], origin["키"], origin["원본"]) == (
"실무자료",
"HP-000001 제3호표",
["personal", "7/42"],
)
assert origin["확인"]["누가"] == {"id": 42, "이름": "갑"} and origin["확인"]["때"]
assert doc["담은로직"] == [
{
"로직": "GF000101",
@@ -65,88 +71,167 @@ def test_만들기_별칭_수량_메모_빠진_줄_비교(client):
"로직": "GF000113",
"입력값": {},
"별칭": "구조물되메우기(인력100%)",
"수량": {"손": 0.1}, # 엑셀 0.05 × 환산 2
"수량": {"손": 0.1}, # 엑셀 0.05 × 배 2
"메모": "실무 0.05㎥ × 2",
},
]
assert [(x["이름"], x["금액"]) for x in got["빠진줄"]] == [
("임도표지석(받침,글새김포함)", 1400000),
("잔토처리(인력100%)", 3097.1),
assert doc["자재"] == [] # 표지석 = 몸에 없음 → 안 들어감
assert [(x["이름"], x["금액"], x["까닭"]) for x in got["빠진줄"]] == [
("임도표지석(받침,글새김포함)", 1400000, "확인 안 함"),
("잔토처리(인력100%)", 3097.1, "뺌 — 우리에 없음"),
]
assert got["비교"]["엑셀"] == 1415169 and got["비교"]["우리"] > 0
assert got["비교"]["차이"] == round(got["비교"]["우리"] - 1415169, 1)
assert got["다시씀"] is False and got["장부"] == {"씀": 3}
book = _book(mine)
assert book[DIG]["갈래"] == "짝" and book[DIG]["짝"] == [
{"로직": "GF000101", "입력값": {"지역": "서울"}, "배": 1, "몫": None}
]
assert book[DIG]["처음파일"].endswith("(공통단가).xls")
assert book[DIG]["원래"] == {"품명": "구조물터파기(인력100%)", "규격": "토사", "단위": "㎥"}
assert book[SOIL]["갈래"] == "우리에 없음" and book[FILL]["짝"][0]["배"] == 2
assert hb.confirmed(mine) == {"HP-000001 제3호표": got["참조"]["키"]}
assert hb.confirmed(mine, ("system", None)) == {} # 다른 파일 층 = 따로
assert master_hash() == before
def test_자재_같은_로직_감쌈_다시씀_새로(client):
def test_로직_여럿_같은_별칭_감쌈_자재_몫_덮음(client):
assert _post(client).status_code == 200
_remember(
client,
**{
DIG: {"로직": "GF000101", "환산": 1},
SOIL: {"로직": "GF000101", "환산": 1},
STONE: {"자재": "MT004258", "환산": 1},
},
)
got = _build(client, 3)
lines = [
{"차례": 0, "대상": [{"자재": "MT004258", "배": 1}]},
{"차례": 1, "대상": [{"로직": "GF000101"}, {"로직": "GF000113", "배": 0.1, "몫": 0.1}]},
{"차례": 3, "대상": [{"로직": "GF000101"}]},
]
got = _build(client, 3, lines)
mine = layers.personal_dir(7, 42)
doc = _doc(mine, got["참조"]["키"])
first, wrapped = doc["담은로직"]
assert first["로직"] == "GF000101" and first["별칭"] == "구조물터파기(인력100%)"
first, second, wrapped = doc["담은로직"]
assert first["별칭"] == second["별칭"] == "구조물터파기(인력100%)" # 한 줄 로직 둘 = 같은 별칭
assert second["수량"] == {"손": 0.014} and second["메모"] == "실무 0.14㎥ × 0.1(몫 0.1) · 토사"
assert wrapped["별칭"] == "잔토처리(인력100%)" and wrapped["수량"] == {"손": 0.09}
inner = _doc(mine, wrapped["참조"]["키"])
assert inner["본뜬"]["키"] == "HP-000001 제3호표 4째 줄"
assert inner["본뜬"]["키"] == "HP-000001 제3호표 4째 줄 GF000101"
assert inner["담은로직"][0]["로직"] == "GF000101" and inner["담은로직"][0]["수량"] == {"손": 1}
assert [(m["코드"], m["수량"], m["별도계상"]) for m in doc["자재"]] == [
("MT004258", {"손": 1}, "직접계산")
]
stone = next(v for k, v in _book(mine).items() if k.startswith("임도표지석(받침"))
assert stone["짝"][0]["자재"] == "MT004258"
files = sorted(p.name for p in (mine / "precombo").glob("*.json"))
again = _build(client, 3)
again = _build(client, 3, lines[:1]) # 다시 확인 = 같은 본뜬 덮음
assert again["다시씀"] is True and again["참조"] == got["참조"]
assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files
_remember(client, **{SOIL: {"빼기": True}})
fresh = _build(client, 3, 새로=True)
assert fresh["참조"] == got["참조"] and fresh["다시씀"] is True
assert len(_doc(mine, got["참조"]["키"])["담은로직"]) == 1 # 덮음
assert _doc(mine, got["참조"]["키"])["담은로직"] == []
assert sorted(p.name for p in (mine / "precombo").glob("*.json")) == files
def test_일위N_참조_먼저_만듦(client):
def test_몸_없음_재검사_400(client):
assert _post(client).status_code == 200
got = _build(client, 45)
one = {"로직": "GF000101", "배": 1}
_build(client, 3, want=400) # 옛 몸(줄 없음) = 확인한 줄이 없음
_build(client, 3, [], want=400)
bad = [
[{"차례": 9, "대상": [one]}], # 호표 밖 줄
[{"차례": 1, "대상": [one]}, {"차례": 1, "대상": [one]}], # 겹친 줄
[{"차례": 1, "대상": []}], # 대상 · 뺌 없음
[{"차례": 1, "대상": [one], "뺌": "안 씀"}],
[{"차례": 1, "대상": [{"로직": "XX"}]}],
[{"차례": 1, "대상": [{"로직": "GC000422"}]}], # 돈 아닌 로직
[{"차례": 1, "대상": [{**one, "입력값": {"없는칸": 1}}]}],
[{"차례": 1, "대상": [{**one, "배": 0}]}],
[{"차례": 1, "대상": [{**one, "배": -1}]}],
[{"차례": 1, "대상": [{**one, "몫": 1.5}]}],
[{"차례": 1, "대상": [{**one, "자재": "MT004258"}]}],
[{"차례": 1, "대상": [{"자재": "MT000000"}]}],
[{"차례": 1, "대상": [{"호표": 3}]}], # 자기를 담음
]
for lines in bad:
_build(client, 3, lines, want=400)
for extra in ({"금액": 100}, {"품": {"보통인부": 1}}): # 금액 · 품 칸 = 받지 않음
assert (
client.post(
BUILD.format("personal"),
json={
"층": "personal",
"키": "HP-000001",
"번": 3,
"줄": [{"차례": 1, "대상": [{**one, **extra}]}],
},
).status_code
== 422
)
assert client.post(BUILD.format("personal"), json={
"층": "personal", "키": "HP-000001", "번": 3, "금액": 1,
"줄": [{"차례": 1, "대상": [one]}]}).status_code == 422 # fmt: skip
assert not (layers.personal_dir(7, 42) / "precombo").exists() or not list(
(layers.personal_dir(7, 42) / "precombo").glob("SC-*.json")
) # 틀린 몸은 아무것도 안 씀
assert client.post("/api/m02/layers/personal/hopyo-memory", json={"줄": {}}).status_code in (
404,
405,
) # 옛 즉시 확정 길 없음
def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client):
assert _post(client).status_code == 200
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["빠진줄"])
got = _build(client, 45, ref)
mine = layers.personal_dir(7, 42)
lines = _doc(mine, got["참조"]["키"])["담은로직"]
ref = lines[0]
assert ref["별칭"] == "고화매트재료비" and ref["수량"] == {"손": 2.3}
child = _doc(mine, ref["참조"]["키"])
assert child["이름"] == "고화매트재료비" and child["본뜬"]["키"] == "HP-000001 제46호표"
assert any(x["까닭"].startswith("% 줄") for x in _build(client, 46)["빠진줄"])
line = _doc(mine, got["참조"]["키"])["담은로직"][0]
assert line["참조"] == child["참조"] and line["별칭"] == "고화매트재료비"
assert line["수량"] == {"손": 2.3}
assert "고화매트재료비||m2" not in _book(mine) # 호표 참조 줄 = 장부 안 씀
whole = _build(client, 3, 통째={"대상": [{"로직": "GF000101", "배": 1}]})
doc = _doc(mine, whole["참조"]["키"])
assert [x["로직"] for x in doc["담은로직"]] == ["GF000101"]
assert doc["담은로직"][0]["수량"] == {"손": 1}
assert {x["까닭"] for x in whole["빠진줄"]} == {"호표 통째로 대신"}
head = next(v for k, v in _book(mine).items() if k.startswith("호표|임도표지석|"))
assert head["짝"][0]["로직"] == "GF000101" and head["원래"]["품명"] == "임도표지석"
_build(client, 3, [ref[0] | {"차례": 1}], 통째={"대상": [{"로직": "GF000101"}]}, want=400)
def test_순환은_빠진_줄(client):
folder = layers.personal_dir(7, 42)
_post(client) # 층 뼈대
line = {"차례": 0, "이름": "남", "규격": "", "수량": 1, "단위": "식", "갈래": "호표"}
money = {"계": 1, "노무비": 1, "재료비": None, "경비": None}
hopyos = [
{"번": 1, "이름": "가", "규격": "", "단위": "식", "금액": money, "줄": [
{**line, "참조": {"종류": "일위", "번": 2}, "금액": money}]},
{"번": 2, "이름": "나", "규격": "", "단위": "식", "금액": money, "줄": [
{**line, "참조": {"종류": "일위", "번": 1}, "금액": money}]},
] # fmt: skip
key = hs.save(folder, "loop.xls", {"시트": "일위대가", "호표": hopyos, "검산": {}}, None)["키"]
got = _build(client, 1, 키=key)
assert got["빠진줄"] == []
inner = _doc(folder, _doc(folder, got["참조"]["키"])["담은로직"][0]["참조"]["키"])
assert inner["담은로직"] == [] # 제2호표 안 제1호표 = 순환 → 빠짐
def test_길_권한과_위_층_파일(client):
def test_길_권한과_위_층_파일_picks_확인(client):
client.who.update(role="SYSTEM_ADMIN")
assert _post(client, "system").status_code == 200
client.who.update(role="USER")
_build(client, 3, layer="system", 층="system", want=403) # 마스터 쓰기 = 시스템 관리자
got = _build(client, 3, 층="system") # 마스터 파일 → 내 층 사전조합
lines = [{"차례": 1, "대상": [{"로직": "GF000101"}]}]
_build(client, 3, lines, layer="system", 층="system", want=403) # 마스터 쓰기 = 시스템 관리자
got = _build(client, 3, lines, 층="system") # 마스터 파일 → 내 층 사전조합
assert got["참조"]["층"] == "personal" and got["참조"]["주인"] == "7/42"
assert _build(client, 99, 층="system", want=404)
_build(client, 99, lines, 층="system", want=404)
assert client.get(URL.format("personal")).json()["줄"] == [] # 파일은 마스터에만
rows = client.get("/api/m02/layers/personal/hopyo-picks").json()["줄"]
three = next(r for r in rows if r["번"] == 3)
assert three["확인"] == {"층": "personal", "주인": "7/42", "키": got["참조"]["키"]}
assert next(r for r in rows if r["번"] == 4)["확인"] is None
def test_장부_길_보기_고치기_권한(client):
before = master_hash()
assert _post(client).status_code == 200
_build(client, 3, [{"차례": 1, "대상": [{"로직": "GF000101"}]}])
rows = client.get(LEDGER.format("personal")).json()["줄"]
assert [(r["열쇠"], r["층"], r["고침"]) for r in rows] == [(DIG, "personal", True)]
put = {
"줄": {DIG: {"갈래": "짝", "짝": [{"로직": "GF000103", "배": 2}]}, SOIL: {"갈래": "안 씀"}}
}
res = client.put(LEDGER.format("personal"), json=put)
assert res.status_code == 200, res.text
assert res.json()["씀"] == 2
book = _book(layers.personal_dir(7, 42))
assert book[DIG]["짝"][0] == {"로직": "GF000103", "입력값": {}, "배": 2, "몫": None}
assert book[DIG]["처음파일"].endswith("(공통단가).xls") # 처음파일 둠
bad = {"줄": {DIG: {"갈래": "짝", "짝": [{"로직": "XX"}]}}}
assert client.put(LEDGER.format("personal"), json=bad).status_code == 400
assert client.put(LEDGER.format("personal"), json={"줄": {DIG: None}}).status_code == 200
assert DIG not in _book(layers.personal_dir(7, 42))
assert client.put(LEDGER.format("system"), json=put).status_code == 403 # 위 층 = 읽기만
client.who.update(role="SYSTEM_ADMIN")
assert client.put(LEDGER.format("system"), json=put).status_code == 200
client.who.update(role="USER")
rows = client.get(LEDGER.format("personal")).json()["줄"]
assert {(r["층"], r["고침"]) for r in rows} == {("personal", True), ("system", False)}
assert client.get(LEDGER.format("system")).json()["줄"][0]["고침"] is False
assert master_hash() == before