feat(B09): 단가산출근거 · 목록표 — 승격 줄 · 옛 SP → 산근 블록 · B09 굳힌 단가 · 엑셀 끝수(145-1)
연결된 사본 일위대가 로직 줄 중 승격 + 옛 SP 참조 줄 · 같은 로직 + 같은 입력값 = 산근 하나 · 서버 단독 풀이 · 대가 · 내역서가 읽는 길 sangeun_key · price_of · Linked.used_keys 빈 입력 = 자동값. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
This commit is contained in:
@@ -130,5 +130,6 @@ def test_연결된_사본이_부른_자원만(tmp_path):
|
||||
(tmp_path / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
assert used_keys(tmp_path) == ["EQ000004", "MO000002", "LB000048"]
|
||||
(tmp_path / "입력값.json").write_text("{}", encoding="utf-8")
|
||||
assert used_keys(tmp_path) == [] # 입력 빈 로직은 못 돎 — 부른 줄 없음
|
||||
auto = used_keys(tmp_path) # 빈 입력 = 자동값(145-1 · 142-1) — 자동 기계 한 대 자원만
|
||||
assert auto and len(auto) <= 3 and auto[0].startswith("EQ")
|
||||
assert used_keys(tmp_path / "없음") == []
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""145-1 B09 단가산출근거 · 목록표 — 승격 줄 → 산근 블록 · 엑셀 검산 · 같은 산근 하나 · 빈 표.
|
||||
|
||||
- 산근 = 연결된 사본 일위대가 로직 줄 중 `승격: true` + 옛 SP 참조 줄(134-2 ③) · 승격 아닌 줄은 안 담음
|
||||
- 줄 금액 = ROUNDDOWN(B09 굳힌 단가 × 1 단위 수량, 1) · 총계 = 비목마다 ROUNDDOWN 0 — 엑셀
|
||||
단가산출근거 제 6호표(뒷채움) 줄 값 · 총계 8,328(6,878 · 538 · 912)과 같음
|
||||
- 같은 로직 + 같은 입력값 = 산근 하나(쓰는 구조물이 늘어남) · 입력이 다르면 둘 · 번호 = 첫 등장
|
||||
- 사본 없으면 빈 표 + 「연결 없음」 · 조합 밖 토공 · 운반 산근 안내 한 줄 · 작업량 Q = ROUND 2
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
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_Prices as prices # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402
|
||||
|
||||
STONE = "GF000219" # 돌쌓기 메쌓기(인력) — 석공 0.195 · 보통인부 0.156 / ㎡
|
||||
NET = "GC000337" # 낙석방지망 — 노무 · 크레인(중기) · 자재 셋
|
||||
NET_IN = {"구분": "기계식", "방지망": "포켓식", "지역": "전국평균", "크레인": "2104-0010"}
|
||||
SP_REF = {"층": "system", "주인": None, "키": "SP-000001"}
|
||||
|
||||
|
||||
def _snapshot(folder: Path, rows, values=None, copies=None) -> Path:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
labor = {
|
||||
"줄": {"종류": "일위대가", "담은로직": rows},
|
||||
**({"참조사본": copies} if copies else {}),
|
||||
}
|
||||
files = {
|
||||
"단가.json": item_prices.to_json(item_prices.collect([STONE, NET])),
|
||||
"일위대가.json": labor,
|
||||
"입력값.json": {k: {n: {"값": v} for n, v in d.items()} for k, d in (values or {}).items()},
|
||||
}
|
||||
for name, data in files.items():
|
||||
(folder / name).write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
return folder
|
||||
|
||||
|
||||
def _sheets(*folders: Path) -> dict:
|
||||
keys = [k for f in folders for k in used_keys(f)]
|
||||
return compute_base(prices.build_inputs(None, list(dict.fromkeys(keys))), {})
|
||||
|
||||
|
||||
def test_승격_줄만_산근_블록_단가는_B09_표(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": STONE, "승격": True}, {"로직": NET}], {NET: NET_IN})
|
||||
sheets = _sheets(a)
|
||||
got = sg.collect([("s1", a)], sheets)
|
||||
(block,) = got["sg"]
|
||||
assert (block["no"], block["logic"], block["unit"]) == (1, STONE, "㎡")
|
||||
labor = {r["key"]: r["price"] for r in sheets["labor"]}
|
||||
lines = block["lines"]
|
||||
assert [(ln["kind"], ln["ref"]["key"], ln["qty"]) for ln in lines] == [
|
||||
("labor", "LB000033", 0.195),
|
||||
("labor", "LB000002", 0.156),
|
||||
]
|
||||
want = [sg.rounddown(labor[ln["ref"]["key"]] * ln["qty"], 1) for ln in lines]
|
||||
assert [ln["labor"] for ln in lines] == want
|
||||
assert block["labor"] == sg.rounddown(sum(want), 0) and block["sum"] == block["labor"]
|
||||
assert block["material"] == 0 and block["expense"] == 0 and block["why"] is None
|
||||
assert got["sg_list"][0]["key"] == block["key"] and got["sg_gaps"] == []
|
||||
assert sg.price_of(got, block["key"]) == {
|
||||
"no": 1, "labor": block["labor"], "material": 0.0, "expense": 0.0, "sum": block["sum"],
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def test_중기는_중기목록표_노_재_경_자재는_적용_단가(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": NET, "승격": True}], {NET: NET_IN})
|
||||
sheets = _sheets(a)
|
||||
(block,) = sg.collect([("s1", a)], sheets)["sg"]
|
||||
kinds = [ln["kind"] for ln in block["lines"]]
|
||||
assert kinds == ["labor", "labor", "machine", "material", "material", "material"]
|
||||
crane = block["lines"][2]
|
||||
row = next(r for r in sheets["machine"] if r["key"] == crane["ref"]["key"])
|
||||
for part in sg.PARTS:
|
||||
assert crane[part] == sg.rounddown(row[part] * crane["qty"], 1)
|
||||
net = block["lines"][3]
|
||||
applied = next(r["applied"] for r in sheets["compare"] if r["key"] == net["ref"]["key"])
|
||||
assert net["material"] == sg.rounddown(applied * net["qty"], 1)
|
||||
assert block["sum"] == block["labor"] + block["material"] + block["expense"]
|
||||
|
||||
|
||||
def test_엑셀_6호표_뒷채움_검산():
|
||||
"""단가산출근거 C285 = 8,328(6,878 · 538 · 912) — 보통인부 0.018 × 165,545 · 굴삭기(0.2m3) 0.07 ×
|
||||
중기목록표 55,700 · 7,695 · 13,041(줄 2,979.8 · 3,899.0 · 538.6 · 912.8 · 계 8,330.2 → 비목마다 버림)."""
|
||||
zero = dict.fromkeys(sg.PARTS, 0.0)
|
||||
labor = {**zero, "labor": sg._money(165545, 0.018)}
|
||||
machine = {p: sg._money(v, 0.07) for p, v in zip(sg.PARTS, (55700, 7695, 13041))}
|
||||
assert labor["labor"] == 2979.8
|
||||
assert (machine["labor"], machine["material"], machine["expense"]) == (3899.0, 538.6, 912.8)
|
||||
assert sg.block_totals([labor, machine]) == {
|
||||
"labor": 6878, "material": 538, "expense": 912, "sum": 8328,
|
||||
} # fmt: skip
|
||||
assert sg.block_totals([labor, {**machine, "material": None}])["sum"] is None # 빈칸 = 빈칸
|
||||
|
||||
|
||||
def test_같은_로직_같은_입력은_하나_다르면_둘_옛_SP_도_같은_산근(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": NET, "승격": True}], {NET: NET_IN})
|
||||
b = _snapshot(tmp_path / "b", [{"로직": NET, "승격": True}], {NET: {**NET_IN, "": ""}})
|
||||
other = {**NET_IN, "방지망": "비포켓식"}
|
||||
c = _snapshot(tmp_path / "c", [{"로직": NET, "승격": True}], {NET: other})
|
||||
sp = {"담은로직": [{"로직": NET, "입력값": NET_IN}], "배": 1}
|
||||
d = _snapshot(
|
||||
tmp_path / "d",
|
||||
[{"로직": "", "참조": SP_REF}, {"로직": STONE, "승격": True}],
|
||||
copies={"참조|system||SP-000001": sp},
|
||||
)
|
||||
got = sg.collect([("s1", a), ("s2", b), ("s3", c), ("s4", d)], _sheets(a, c, d))
|
||||
blocks = got["sg"]
|
||||
assert [(x["no"], x["logic"], x["uses"]) for x in blocks] == [
|
||||
(1, NET, ["s1", "s2", "s4"]), # 빈 입력 칸은 무시 · 옛 SP(같은 로직 · 입력) = 같은 산근
|
||||
(2, NET, ["s3"]),
|
||||
(3, STONE, ["s4"]),
|
||||
]
|
||||
assert blocks[0]["key"] == sg.sangeun_key(NET, {**NET_IN, "x": None}, 100, {})
|
||||
assert blocks[0]["key"] != sg.sangeun_key(NET, NET_IN, 90) # 켜쌓기율이 다르면 다른 산근
|
||||
|
||||
|
||||
def test_사본_없으면_빈_표_연결_없음(tmp_path, monkeypatch):
|
||||
got = sg.collect([("s1", tmp_path / "없음")], {})
|
||||
assert got["sg"] == [] and got["sg_list"] == [] and got["sg_gaps"] == []
|
||||
assert got["sg_notes"] == [sg.NO_SOURCE, sg.EMPTY]
|
||||
plain = _snapshot(tmp_path / "p", [{"로직": STONE}]) # 승격 없는 사본도 빈 표
|
||||
assert sg.collect([("s1", plain)], {})["sg"] == []
|
||||
|
||||
async def fake_materials(_project_id):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(router_mod, "material_sheets", fake_materials)
|
||||
sheets = asyncio.run(router_mod.payload("p", tmp_path))["sheets"]
|
||||
assert sheets["sg"] == [] and sheets["sg_list"] == []
|
||||
assert sg.EMPTY in sheets["sg_notes"] and sg.NO_SOURCE in sheets["sg_notes"]
|
||||
|
||||
|
||||
def test_단가_없으면_빈칸과_연결_없음(tmp_path):
|
||||
a = _snapshot(tmp_path / "a", [{"로직": STONE, "승격": True}])
|
||||
got = sg.collect([("s1", a)], {"labor": [], "machine": [], "compare": []})
|
||||
(block,) = got["sg"]
|
||||
assert block["labor"] is None and block["sum"] is None
|
||||
assert "노무비목록표에 없음" in block["why"]
|
||||
assert [(g["tab"], g["key"]) for g in got["sg_gaps"]] == [("sg", block["key"])]
|
||||
|
||||
|
||||
def test_작업량_Q는_소수_둘():
|
||||
import M01_MasterData.M01_MasterData_Store as m01
|
||||
|
||||
whole = m01.loaded()[1]
|
||||
for code, row in sorted((whole.index.get("GF") or {}).items()):
|
||||
if not any(sg._Q.fullmatch(s["이름"]) for s in row.get("중간", [])):
|
||||
continue
|
||||
try:
|
||||
env = sg._env(whole, row, {})
|
||||
except Exception: # noqa: BLE001 — 입력이 필요한 로직은 건너뜀
|
||||
continue
|
||||
q = next(env[s["이름"]] for s in row["중간"] if sg._Q.fullmatch(s["이름"]))
|
||||
if hasattr(q, "as_tuple"):
|
||||
assert q == round(q, 2), code
|
||||
return
|
||||
pytest.skip("자동값으로 도는 작업량 Q 로직 없음")
|
||||
|
||||
|
||||
def test_화면_탭_둘_서버_산근_그대로():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "sg_list: buildSgList," in shell and "sg: buildSg," in shell
|
||||
assert 'const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;' in shell
|
||||
assert "gaps: [...base.gaps, ...(server.sg_gaps || [])]" in shell
|
||||
tabs = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Sangeun.ts").read_text("utf-8")
|
||||
assert 'link: (i) => (rows[i] ? { tab: "sg", key: String(rows[i].key) } : null),' in tabs
|
||||
assert "PARTS.filter((p) => ln.price?.[p] !== 0)" in tabs # 중기 = 노 · 재 · 경 세 줄
|
||||
Reference in New Issue
Block a user