Files
Aislo/resources/tester/test_m02_labor_doc.py
T
eomsangdonandClaude Opus 5.5 64affbb00e feat(830-2): 재료 DB 가격 열 재정의 · 항목 통합 이어 감 — 조달청 · 일반가격 · 상하차 · 재료 아닌 줄 삭제
- 열 한 벌 = 키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 상하차 · 조달청 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 출처 · 비고 · 면수(material_merge.COLUMNS 한 곳 · check_master 가 씀)
- 「관급」 칸 → 「조달청」(값 0 · EST 가격정보 값 안 씀) · 새 칸 「일반가격」(빈칸 · 시스템 관리자 손 입력 · 빌더가 원문번호로 이어 받음) · 상하차 = 단위 바로 뒤
- 남는 줄 지역 기준 = 전국 › 지역 없음 › 서울 › 서울 권역 › 경기 › 그 밖(값 중간) · 기준 지역은 비고 「통합: 기준 지역 …」(지역 없는 묶음은 안 적음)
- 재료 아닌 줄 삭제(요구 832) — 품질시험 수수료 60(가격정보 출처 「2026(경북)」) · 일반 도로용 블록 5(「~26.10.16」) · 치즐 2 줄은 남김 · 값 없음
- 재료_자재품목 24,092 → 17,692 줄(통합 6,335 · 재료 아닌 줄 65) · 남는 줄 키 = 830-1 키(7565ee787 이 지운 대장 키를 되살려 다시 지음 · 새 키 0) · 남는 줄 물가지 값 바뀐 것 0 · 빌더 두 번 = 같은 파일
- 가장 낮은 값 = 사급 칸(물가지 넷 + 일반가격) — master_material.SLOTS · M02_Elem_Alias.PRICES · M02 PRICE_SLOTS · 조달청 칸 안 봄 · M01 고르기 「관급」 표시 = 조달청 칸 · B09 대비표 「관급」 출처 = M01 조달청 칸(이름 그대로)
- M01 재료 화면 상하차 = 고르기(부품 createSelectField) · 새 줄 기본 「-」 · 로직 고르기 표 · 시험 계산 값 줄에 일반가격
- check_master 틀 · 로직 · 조합 0 · 단위 27 · 배 7(전과 같음) · _틀.md · _갱신절차.md 열 정의
- 시험: 새 test_830_2_material_merge · test_830_1 지움(EST 그대로 상태 = 830-2 로 끝남 · 남는 검사는 830_2 로) · 지역 줄 · 관급 이름 · 관급 칸을 보던 시험 기대값 고침(m01_api · m01_pick · masterdata_check · 814_1 · 815_1 · 827_2 · 827_3 · 750_1 · m02_hopyo_match · m02_labor_doc) · 「시멘트 전국」 검색어 시험 = xfail(830-3 연결 할 일)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 18:36:51 +09:00

265 lines
12 KiB
Python

"""일위대가 문서(`labor/<열id>.json` · PLAN 39-1a) — 정리 · 검사 · 계산 · 알약 · 저장 때 계산값.
로직 · 자재는 M01 마스터(읽기만) · 층은 견본 표(`write_sample_table`)를 임시 폴더에.
"""
import json
import pytest
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_structure import write_sample_table
TEN = "GC000421" # 원/10㎡ — 배 10
THOUSAND = "GF000017" # 원/1,000본 — 배 1000
PLAIN = "GC000422" # ㎡/시간 — 돈 아닌 로직
STONE = "GF000219" # 원/㎡ · 입력 뒷길이 · 돌 · 쌓기 · 높이 · 초과증가율
MAT = "MT000008" # 자재품목 · 물가정보 83000
def _doc(**extra):
return {"열": "rv", "담은로직": [], "자재": [], **extra}
def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것():
got = labor.clean(
_doc(
담은로직=[
{"로직": STONE, "입력값": {"돌": "깬돌", "모름": 1, "높이": ""}, "수량": None}
],
자재=[{"코드": MAT, "이름": "아무 이름", "수량": {"손": "2"}}],
단위="개소",
)
)
assert got["담은로직"] == [
{"로직": STONE, "별칭": None, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None}
]
assert got["자재"][0]["이름"] == "치 즐(0.2㎥)" and got["자재"][0]["수량"] == {"손": 2}
assert (got["종류"], got["단위"], got["본뜬"]) == ("일위대가", "개소", None)
@pytest.mark.parametrize(
"bad",
[
_doc(담은로직=[{"로직": STONE}, {"로직": STONE}]), # 같은 로직 두 번
_doc(담은로직=[{"로직": "GF999999"}]), # 없는 로직
_doc(담은로직=[{"로직": STONE, "수량": {"손": "열"}}]), # 수 아님
_doc(담은로직=[{"로직": STONE, "수량": 3}]), # 꼴 틀림
_doc(담은로직=[{"로직": STONE, "별칭": 3}]), # 별칭 글 아님(44-6)
_doc(담은로직=[{"로직": STONE, "별칭": "가" * 41}]), # 별칭 40자 넘음
_doc(자재=[{"코드": "LB000001"}]), # 자재품목 아님
_doc(자재=[{"코드": MAT}, {"코드": MAT}]), # 같은 자재 두 번
"문서 아님",
],
)
def test_틀린_문서는_400(bad):
with pytest.raises(store.StoreError) as e:
labor.clean(bad)
assert e.value.status == 400
def test_별칭은_앞뒤_빈칸_떼고_빈_글은_null():
got = labor.clean(
_doc(담은로직=[{"로직": STONE, "별칭": " 돌붙임 "}, {"로직": TEN, "별칭": " "}])
)
assert [r["별칭"] for r in got["담은로직"]] == ["돌붙임", None]
def test_빈_일위대가도_됨():
got = labor.server(_doc())
assert got["계산값"]["기준합"]["계"] == 0 and got["계산값"]["반영합"]["계"] == 0
def test_PER_천_단위_쉼표는_배():
pills = combo_store.combo_pills({"담은로직": [{"로직": THOUSAND}, {"로직": TEN}]})["물량"]
assert {p["단위"]: p.get("배") for p in pills} == {"본": 1000, "㎡": 10}
def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_없음():
doc = labor.clean(
_doc(담은로직=[{"로직": TEN, "수량": {"손": 4}}, {"로직": PLAIN, "수량": None}])
)
got = labor.calc(doc, {})
ten, plain = got["줄"]
assert (ten["배"], ten["수량"], ten["수량출처"]) == (10, 4, "손")
for k in ("노무비", "재료비", "경비", "계"):
assert ten["반영"][k] == pytest.approx(
ten["기준"][k] * 4 / 10, abs=0.3
) # 0.1 원 버림(751-3)
assert plain["기준"] is None and plain["반영"] is None and "결과" in plain
assert 0 <= ten["반영"]["계"] - got["반영합"]["계"] < 3 # 반영합 = 비목마다 1 원 버림(751-3)
def test_돈_로직_줄에_재료_노무_경비_줄_실림_합은_기준():
got = labor.calc(labor.clean(_doc(담은로직=[{"로직": TEN}, {"로직": PLAIN}])), {})
ten, plain = got["줄"]
assert ten["줄"] and all({"이름", "수량", "단위", "금액"} <= set(x) for x in ten["줄"])
assert 0 <= sum(x["금액"] for x in ten["줄"]) - ten["기준"]["계"] < 3 # 751-3 비목 1 원 버림
assert "줄" not in plain
def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로():
doc = labor.clean(
_doc(
담은로직=[{"로직": TEN, "수량": None}, {"로직": THOUSAND, "수량": {"손": 1}}],
자재=[{"코드": MAT, "수량": None}],
)
)
keys = {p["키"] for p in labor.pills(doc)}
ten_key = next(k for k in keys if k.endswith("|㎡"))
thousand_key = next(k for k in keys if k.endswith("|본"))
assert f"자재|{MAT}" in keys
got = labor.calc(doc, {ten_key: "2.5", thousand_key: 99, f"자재|{MAT}": "3"})
ten, thousand = got["줄"]
assert (ten["수량"], ten["수량출처"]) == (2.5, "칸")
assert (thousand["수량"], thousand["수량출처"]) == (1, "손")
mat = got["자재"][0]
assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000)
# 43-3 — M01 값 열 다섯(빈 것 null) · 기준단가가 된 열 이름
assert list(mat["가격"]) == ["물가자료", "유통물가", "물가정보", "거래가격", "일반가격"]
assert mat["기준칸"] == "물가정보" and mat["가격"]["물가정보"] == 83000
nothing = labor.calc(doc, {})["줄"][0]
assert (nothing["수량출처"], nothing["반영"]) == ("없음", None)
def test_면적_값_식_칸은_계산값_그_밖은_칸_값():
area = {
"시트": [
{
"id": "s1",
"칸": {"B6": {"값": "1,200"}, "C1": {"값": "글"}},
"연결": [{"키": "a", "칸": "B6"}, {"키": "b", "칸": "D2"}, {"키": "c", "칸": "C1"}],
}
],
"계산값": {"s1": {"D2": {"수": "7.5"}}},
}
got = labor.area_values(area)
assert {k: float(v) for k, v in got.items()} == {"a": 1200, "b": 7.5}
@pytest.fixture()
def system(tmp_path, monkeypatch):
write_sample_table(tmp_path)
monkeypatch.setattr(store, "FOLDER", tmp_path)
store.create_structure("rv")
return tmp_path
def test_구조물_만들면_빈_일위대가_같은_뒷자리(system):
doc = store.read("labor", "rv")["문서"]
assert doc["일위대가번"] == "SU" + store.read("structure", "rv")["문서"]["도번"][2:]
assert doc["담은로직"] == [] and doc["자재"] == []
def test_저장_때_서버가_계산값_번호는_그대로_면적_저장이_계산값_새로(system):
have = store.read("labor", "rv")
sent = {
**have["문서"],
"일위대가번": "SU-999999",
"담은로직": [{"로직": TEN, "수량": None}],
"계산값": {"브라우저": "값"},
}
store.write("labor", "rv", have["판"], sent)
doc = store.read("labor", "rv")["문서"]
assert doc["일위대가번"] == have["문서"]["일위대가번"]
assert doc["계산값"]["줄"][0]["수량출처"] == "없음"
pill = labor.pills(doc)[0]["키"]
area = store.read("area", "rv")
sheet = area["문서"]["시트"][0]
sheet["칸"] = {"B6": {"값": "5"}}
sheet["연결"] = [{"키": pill, "이름": "x", "칸": "B6", "단위": "㎡"}]
store.write("area", "rv", area["판"], area["문서"])
line = store.read("labor", "rv")["문서"]["계산값"]["줄"][0]
assert (line["수량"], line["수량출처"]) == (5, "칸")
assert line["반영"]["계"] == pytest.approx(line["기준"]["계"] * 5 / 10)
def test_틀린_일위대가_저장은_400_파일_그대로(system):
have = store.read("labor", "rv")
with pytest.raises(store.StoreError) as e:
store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [{"로직": "GF999999"}]})
assert e.value.status == 400
assert json.loads((system / "labor" / "rv.json").read_bytes())["담은로직"] == []
# ── 길(`M02_Labor_Router`) ─────────────────────────────────────────────
@pytest.fixture()
def client(tmp_path, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_Labor_Router as labor_router
from M02_MasterTemplete import M02_Template_Layers as layers
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
(tmp_path / "storage").mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
with store.at(system):
store.create_structure("rv")
app = FastAPI()
app.include_router(labor_router.router)
app.dependency_overrides[verify_session] = lambda: {
"user_id": 42,
"company_id": 7,
"role": "USER",
"is_master": False,
}
return TestClient(app)
def test_길_calc_는_저장_안_한_문서와_알약_값으로_셈_알약도_같이(client):
url = "/api/m02/layers/system/labor/rv/calc"
sent = {"담은로직": [{"로직": TEN, "수량": None}], "자재": [{"코드": MAT, "수량": {"손": 1}}]}
got = client.post(url, json={"doc": sent, "수량": {"사석 수상고르기|㎡": 20}}).json()
assert (got["줄"][0]["수량출처"], got["자재"][0]["반영"]) == ("칸", 83000)
assert {p["키"] for p in got["알약"]} == {"사석 수상고르기|㎡", f"자재|{MAT}"}
saved = client.post(url, json={}).json() # 저장본 = 빈 일위대가
assert saved["줄"] == [] and saved["알약"] == []
assert client.get("/api/m02/layers/system/labor/rv/pills").json() == {"열": "rv", "알약": []}
def test_길_틀린_문서_400_없는_열_404_남의_개인_층_403(client):
url = "/api/m02/layers/{}/labor/{}/calc"
bad = {"doc": {"담은로직": [{"로직": "GF999999"}]}}
assert client.post(url.format("system", "rv"), json=bad).status_code == 400
assert client.post(url.format("system", "없는열"), json={}).status_code == 404
other = client.post(url.format("personal", "rv") + "?owner=7/99", json={})
assert other.status_code == 403
def test_로직_지우기_검사는_모든_층_일위대가를_봄(tmp_path, monkeypatch):
from config import config_system
from M01_MasterData import M01_MasterData_Store_Cache as cache
system, storage = tmp_path / "system", tmp_path / "storage"
for folder, code in (
(system / "labor", STONE),
(storage / "7" / "templates" / "labor", TEN), # 회사 층
(storage / "7" / "42" / "templates" / "labor", STONE), # 개인 층
(storage / "7" / "42" / "p1" / "templates" / "labor", STONE), # 프로젝트 — 안 봄
):
folder.mkdir(parents=True)
body = {"담은로직": [{"로직": code}]}
(folder / "a.json").write_text(json.dumps(body), encoding="utf-8")
from M02_MasterTemplete import M02_Template_Layers as layers
monkeypatch.setattr(layers, "SYSTEM_ROOT", system) # 검사는 39-1d 한 벌(층 자리 = layers)
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
class Gone: # STONE 을 지운 마스터
def logic(self, code):
if code == STONE:
raise cache.cm.mf.FormulaError("없음")
got = cache.labor_errors(Gone())
assert len(got) == 2 and all(STONE in x for x in got)
assert any("마스터" in x for x in got) and any("개인 7/42" in x for x in got)