Files
Aislo/resources/tester/test_m01_make.py
T
eomsangdonandClaude Opus 5.5 f4be6782a5 feat(751-3): M01 · M02 셈 끝수 = 품셈 1-2-2 버림 — 줄 0.1 원 · 비목 소계 1 원 · 계 = 셋 합 · B09 와 같은 값
- 끝수 한 곳 `master_cut.py`(B09 `rounddown` 재사용) — 엔진 답에 입힘: 줄 비목마다 0.1 원 버림 · 하위 로직 줄 = 하위 끝수 계 × 수량 · 덧줄 = 버린 줄 합으로 다시 셈 · 비목 합 1 원 버림 · 중간 칸도 버린 값
- 쓰는 곳: M01 시험 계산(`check_master.calc`) · 읽는 식(`master_text.lines`) · 옛 조합 미리보기(`master_combo`) · M02 `run_line`(단가산출 · 일위대가 · 펼침) · 별도계상 뺀 기준 · 켜쌓기율 곱 뒤 · 일위대가 반영 · 반영합 · 비율 줄 · 사전조합 안 줄
- 일위대가 반영 TS 짝(`Labor_Calc.ts`) 같은 끝수 · 거울 헬퍼가 상대 경로 .ts 가져옴
- B09 = `run_line(raw=True)` 엔진 날 값(제 단가로 다시 버림 · % 덧줄 율 비 그대로) — 검증_sub3 B09 247 칸 전후 같음
- `master_text` 꼴 셋 = `common_util_money` 로 갈음(줄 금액 `fmt_line_money` · 식 안 금액 = 버린 0.1 원)
- 화면: 상세 줄 금액 · 시험 계산 줄 · 중간 돈 = `formatLineMoney` · 일위대가 반영 칸 0.1 원 · 상세 바닥 · 박스 · 인력재료경비 비목 소계 = 줄 합 1 원 버림
- 테스트1 네 보기 · 서고 목록 = 124,419(옛 124,420 / 124,419.85) · 재료 줄 11,694.6 + 23,425.6 + 1,404.8 = 소계 36,525
- 새 지킴 시험 test_751_3_money_cut
- 옛 날 금액(끝수 없음)을 대조하던 시험 24 파일 고침 — 까닭 = 금액 규칙이 줄 0.1 원 · 소계 1 원 버림으로 바뀜(고정 값 → 새 값 · 줄 합 = 계 → 줄 합 아래 비목마다 1 원 안 · 곱 비 = 끝수 폭 안) · 실무 xlsx 영월 엑셀 같음 22 → 23 · 이어 붙이는 TS 시험(704_3 · 717_2b)에 `rounddown` 같은 식

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 06:16:01 +09:00

222 lines
10 KiB
Python

"""M01 「만들기」 API — 절 목록 · 절 거르기 · 표 조건 값 · 소유 거름 · 자체 로직 · 끝수.
계약 `resources/master_data/_화면_계약.md` 2장·5장·6장. 임시 폴더 사본에서 씀.
"""
from __future__ import annotations
import shutil
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Make as make
REAL = store.FOLDER
LABOR = "LB000002" # 보통인부
SAMPLE = "GF000219" # 산림 13-4-1 메쌓기(인력)
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
for path in REAL.glob("*.json"):
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(store, "FOLDER", tmp_path)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def _get(client: TestClient, url: str, **params) -> dict:
res = client.get(url, params=params)
assert res.status_code == 200, res.text
return res.json()
def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
res = client.post(url, json=body)
assert res.status_code == want, res.text
return res.json()
def _draft(끝수=None) -> dict:
"""보통인부 1/7 인 한 줄짜리 초안 — 저장 안 함."""
return {
"원문번호": "",
"이름": "시험 초안",
"결과단위": "원/㎡",
"출처": "자체",
"소유": "현장",
"입력": [],
"중간": [],
"호표": [
{
"종류": "인력",
"요소": LABOR,
"이름": "보통인부",
"단위": "인",
"수량": "1 / 7",
"비목": "노무비",
}
],
"덧줄": [],
"끝수": 끝수,
}
# ── 끝수 ──────────────────────────────────────────────────────────────
def test_끝수_묶음이_계에_붙고_글자는_안_붙는다(client: TestClient) -> None:
plain = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft()})
assert plain["ok"] is True, plain
raw = Decimal(str(plain["sums"]["계"]))
assert raw == 24581 # 172068 / 7 = 24,581.14 → 줄 0.1 원 · 소계 1 원 버림(751-3 · 품셈 1-2-2)
cut = _post(
client,
"/api/m01/calc",
{"key": "", "inputs": {}, "row": _draft({"대상": "계", "자리": -1, "방법": "올림"})},
)
assert Decimal(str(cut["sums"]["계"])) == 24590 # 로직 끝수 묶음 = 1-2-2 버림 뒤 한 번 더
assert cut["sums"]["노무비"] == cut["sums"]["계"] # 비목 합마다 끊고 계 = 그 합
text = _post(client, "/api/m01/calc", {"key": "", "inputs": {}, "row": _draft("원 미만 버림")})
assert Decimal(str(text["sums"]["계"])) == raw # 글자 끝수는 설명일 뿐
def test_끝수_묶음_모양을_검사가_잡는다() -> None:
bad = {"키": "GX000001", "원문번호": "", "호표": [], "끝수": {"대상": "줄", "방법": "내림"}}
found = store.cm.mf.check_logic(store.cm.master(REAL), bad)[0]
assert any("대상" in x for x in found) and any("방법" in x for x in found)
# ── 절 목록 · 절 거르기 · 표 조건 값 ──────────────────────────────────
def test_절_목록과_절로_표_거르기(client: TestClient) -> None:
got = _get(client, "/api/m01/sections", book="산림품셈", chapter="13")
one = next(s for s in got["sections"] if s["section"] == "13-4-1")
assert one["title"].startswith("메쌓기") and one["chapter"] == "13장 구조물"
assert one["tables"] >= 1 and one["logics"] >= 1
assert client.get("/api/m01/sections", params={"book": "없는품셈"}).status_code == 404
hits = _get(client, "/api/m01/tables", group="소요량", section="13-4", size=500)["tables"]
assert hits and all(store.in_section(t["원문번호"], "13-4") for t in hits)
assert not store.in_section("13-40", "13-4") and store.in_section("13-4", "13-4")
def test_표_조건_값만_받는다(client: TestClient) -> None:
listed = _get(client, "/api/m01/tables", group="소요량", section="13-4-1", size=50, q="메쌓기")[
"tables"
]
one = listed[0]
got = _get(client, "/api/m01/table/options", file=one["file"], key=one["키"])
assert got["count"] >= 1 and "줄" not in got
for name, kind in got["조건"].items():
assert name in got["값"]
if kind == "고르기":
assert len(got["값"][name]) == len(set(map(str, got["값"][name])))
def test_로직_목록을_소유로_거른다(client: TestClient) -> None:
every = _get(client, "/api/m01/logics")["logics"]
assert all("소유" in x for x in every)
only = _get(client, "/api/m01/logics", owner="공용")["logics"]
assert only and all(x["소유"] == "공용" for x in only)
assert _get(client, "/api/m01/logics", owner="현장")["logics"] == []
# ── 자체 로직 ─────────────────────────────────────────────────────────
def test_자체_로직_만들기_본뜨기_고치기_지우기(client: TestClient) -> None:
made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"})
assert made["key"].startswith("GX") and made["file"] == make.OWN
assert made["logic"]["구분"] == "자체" and made["logic"]["소유"] == "현장"
assert (store.FOLDER / make.OWN).is_file()
again = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"})
assert again["key"] != made["key"] # 원문번호가 같아도 키는 하나씩
copied = _post(client, "/api/m01/logic/copy", {"key": SAMPLE, "이름": "메쌓기 본뜸"})
assert copied["key"] != SAMPLE and f"{SAMPLE} 를 본뜸" in copied["logic"]["비고"]
assert copied["logic"]["이름"] == "메쌓기 본뜸" and copied["logic"]["소유"] == "현장"
assert copied["logic"]["상세구분"] == "13장 구조물"
fixed = dict(copied["logic"], 이름="고친 이름")
done = _post(
client,
"/api/m01/logic/edit",
{"key": copied["key"], "version": copied["version"], "logic": fixed},
)
assert done["logic"]["이름"] == "고친 이름" and done["logic"]["키"] == copied["key"]
stale = {"key": copied["key"], "version": copied["version"], "logic": fixed}
_post(client, "/api/m01/logic/edit", stale, want=409)
gone = _post(
client, "/api/m01/logic/delete", {"key": copied["key"], "version": done["version"]}
)
assert gone["file"] == make.OWN
assert client.get("/api/m01/logic", params={"key": copied["key"]}).status_code == 404
def test_정본_로직은_못_고친다(client: TestClient) -> None:
got = _get(client, "/api/m01/logic", key=SAMPLE)
body = {"key": SAMPLE, "version": got["version"], "logic": got["logic"]}
_post(client, "/api/m01/logic/edit", body, want=403)
_post(client, "/api/m01/logic/delete", {"key": SAMPLE, "version": got["version"]}, want=403)
_post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "남의것"}, want=400)
def test_만든_자체_로직이_시험_계산에_돈다(client: TestClient) -> None:
made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "공용"})
got = _post(client, "/api/m01/calc", {"key": made["key"], "inputs": {}})
assert got["ok"] is True and Decimal(str(got["sums"]["노무비"])) > 0
listed = _get(client, "/api/m01/logics", owner="공용")["logics"]
assert made["key"] in [x["키"] for x in listed]
def test_일위대가가_담은_로직은_못_지운다(client: TestClient, tmp_path: Path, monkeypatch) -> None:
"""39-1d — 로직 지우기 검사 = 모든 층(마스터 · 회사 · 개인) labor 담은로직 + 실무문서(마스터
일위대가 조합) · 쓰는 곳을 알림 · 다 빼면 지워짐(임시 storage · 임시 마스터)."""
import json
from config import config_system
from M02_MasterTemplete import M02_Template_Layers as layers
system, storage = tmp_path / "system", tmp_path / "storage"
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
made = _post(client, "/api/m01/logic/new", {"logic": _draft(), "owner": "현장"})
key = made["key"]
docs = {
"회사 3": storage / "3/templates/labor/item1.json",
"개인 3/15": storage / "3/15/templates/labor/item2.json",
"마스터": system / "labor/item3.json",
}
for at, path in enumerate(docs.values()):
path.parent.mkdir(parents=True)
doc = {"일위대가번": f"SU-00000{at}", "담은로직": [{"로직": SAMPLE}, {"로직": key}]}
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
combos = store.FOLDER / store.mcb.FILE
raw = json.loads(combos.read_text(encoding="utf-8"))
raw["줄"].append({**raw["줄"][0], "키": "UA999999", "담은로직": [{"로직": key, "메모": None}]})
combos.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
def delete(want: int) -> dict:
version = _get(client, "/api/m01/logic", key=key)["version"]
return _post(client, "/api/m01/logic/delete", {"key": key, "version": version}, want)
errors = " ".join(delete(422)["detail"]["errors"])
for where in docs:
assert f"일위대가 {where} 「SU-" in errors and key in errors # 어디서 쓰는지 알림
assert store.mcb.FILE in errors # 실무문서(마스터 일위대가 조합)
for path in docs.values(): # 일위대가에서 뺌 — 실무문서만 남음
path.write_text(json.dumps({"담은로직": [{"로직": SAMPLE}]}), encoding="utf-8")
assert "일위대가 " not in " ".join(delete(422)["detail"]["errors"])
raw["줄"].pop()
combos.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8")
assert delete(200)["file"] == make.OWN # 쓰는 곳 0 = 지워짐