설계내역서 항목 줄 역전개(단위당 장비 시간 · 인) → 작업일수 · 착공 연월부터 달마다 작업가능일 채움 · 공휴일 표 2024~2040 · 본 코드 훅은 draft 없으면 건너뜀 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
213 lines
10 KiB
Python
213 lines
10 KiB
Python
"""PLAN 252 — B09 공기 초안(공기산출 · 공기집계 셈 짝 · 서버 원천 · 사용자 값 다듬기)."""
|
||
|
||
import json
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule # noqa: E402
|
||
from B09_Estimation.draft.B09_Draft_Schedule_Source import ( # noqa: E402
|
||
clean_schedule,
|
||
holiday_table,
|
||
schedule_inputs,
|
||
unit_resources,
|
||
)
|
||
|
||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||
|
||
#: 참고 설계서(25 거창) 공기산출 — [이름, 시공량, 일작업량(= 8 × Q)] · 기타공 2일
|
||
GEOCHANG = [
|
||
("토사절취", 7641, 1206.4), ("암석절취", 2669, 160), ("암석절취", 297, 48),
|
||
("측구터파기", 110, 362.24), ("측구터파기", 103, 20), ("지장목제거", 10782, 4519.76),
|
||
("초류종자파종", 2718.4, 2400), ("전석메쌓기", 540, 74.4), ("유입구골막이", 4, 4),
|
||
("유출구물받이", 16, 6), ("터파기", 757, 838.24), ("터파기", 57, 80), ("터파기", 57, 48),
|
||
("되메우기", 523, 904.16), ("관구체다짐", 324, 180), ("파형강관매설", 185, 160),
|
||
("배수관날개벽", 16, 4), ("BOX암거", 5, 0.8), ("BOX암거 난간", 13, 6.4),
|
||
("콘크리트포장", 2170, 200), ("혼합석부설", 2870, 1108), ("노견줄떼", 820, 255.2),
|
||
] # fmt: skip
|
||
#: 같은 설계서 공기집계 — 기후(강수 + 혹서) · 법정공휴일(2026 기준 · 그 설계서 값)
|
||
B_REF = [5, 7, 6, 4, 7, 5, 4, 7, 7, 7, 5, 5]
|
||
|
||
|
||
def _bill(rows):
|
||
out = [{"kind": "title", "key": "sec:1", "name": "토사", "level": 1, "section": "1"}]
|
||
for i, (name, qty, _d) in enumerate(rows):
|
||
out.append({"kind": "item", "key": f"r{i}", "name": name, "qty": qty, "level": 2,
|
||
"section": "1.2", "unit": "㎥"}) # fmt: skip
|
||
return out
|
||
|
||
|
||
def _ref_user():
|
||
return {
|
||
"rows": {f"r{i}": {"daily": d} for i, (_n, _q, d) in enumerate(GEOCHANG)},
|
||
"etc": 2,
|
||
"start": {"year": 2026, "month": 3},
|
||
"holiday": {f"2026-{m:02d}": b for m, b in enumerate(B_REF, 1)},
|
||
}
|
||
|
||
|
||
SRC = {
|
||
"units": {},
|
||
"holidays": holiday_table(),
|
||
"climate": {
|
||
"rain": [1.2, 1.4, 3.6, 4.5, 3.6, 4.4, 7.7, 6.9, 5.5, 3.1, 2.2, 1.2],
|
||
"heat": [0, 0, 0, 0, 0.5, 1.2, 5.9, 8.4, 0, 0, 0, 0],
|
||
},
|
||
"start": {"year": 2027, "month": 3},
|
||
}
|
||
|
||
|
||
def test_참고_설계서_거창_공기산출_재현():
|
||
work = compute_schedule(SRC, _bill(GEOCHANG), _ref_user())["work"]
|
||
days = [r["days"] for r in work["rows"] if r["kind"] == "item"]
|
||
assert days[:6] == [6.4, 16.7, 6.2, 0.4, 5.2, 2.4] # 설계서 F7 · F8 · F9 · F10 · F11 · F14
|
||
assert days[-3:] == [10.9, 2.6, 3.3] # F37 · F38 · F39
|
||
assert (work["prep"], work["subtotal"], work["tidy"], work["total"]) == (4, 87, 4, 95)
|
||
assert [r["kind"] for r in work["rows"][:2]] == ["head", "item"] # 묶음 줄 먼저
|
||
|
||
|
||
def test_참고_설계서_거창_작업가능일_채움():
|
||
cal = compute_schedule(SRC, _bill(GEOCHANG), _ref_user())["calendar"]
|
||
months = cal["months"]
|
||
# 설계서 3 ~ 7월 작업가능일수(달력 − ROUNDDOWN(A + B − A×B÷달력))
|
||
assert [m["avail"] for m in months] == [23, 23, 21, 21, 16]
|
||
assert [(m["prep"], m["work"], m["tidy"]) for m in months] == [
|
||
(4, 19, 0), (0, 23, 0), (0, 21, 0), (0, 21, 0), (0, 3, 4),
|
||
] # fmt: skip
|
||
# 다 쓴 달 = 비작업 전부 · 마지막 달 = ROUNDUP(15 × 7 ÷ 16) = 7
|
||
assert [m["off_used"] for m in months] == [8, 7, 10, 9, 7]
|
||
assert (cal["days"], cal["months_n"], cal["end"]) == (136, 4.5, "2026-07-14")
|
||
|
||
|
||
def test_공휴일_자동_2026():
|
||
cal = compute_schedule(SRC, [], {"start": {"year": 2026, "month": 1}, "tidy": 400})
|
||
auto = [m["b_auto"] for m in cal["calendar"]["months"][:12]]
|
||
# 설계서 값과 다른 달 = 5월 노동절 · 6월 지방선거 · 7월 제헌절(공휴일 법 바뀜 · 목록이 넣음)
|
||
assert auto == [5, 7, 6, 4, 8, 6, 5, 7, 7, 7, 5, 5]
|
||
may = cal["calendar"]["months"][4]["names"]
|
||
assert "5일 어린이날" in may and "25일 부처님오신날 대체 휴일" in may
|
||
assert cal["calendar"]["months"][1]["days"] == 28 # 2026.2 = 28일(설계서는 29)
|
||
|
||
|
||
def test_장비_인력_입력_반영률():
|
||
src = {**SRC, "units": {
|
||
"a": {"machine": {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066}, "labor": 0.01},
|
||
"b": {"machine": None, "labor": 1.5},
|
||
"c": {"machine": None, "labor": None},
|
||
}} # fmt: skip
|
||
bill = [
|
||
{"kind": "item", "key": "a", "qty": 1000, "section": "1.2", "level": 2},
|
||
{"kind": "item", "key": "b", "qty": 80, "section": "2.1", "level": 2},
|
||
{"kind": "item", "key": "c", "qty": 5, "section": "2.3", "level": 2},
|
||
{"kind": "item", "key": "h", "qty": 9, "section": "3", "level": 2},
|
||
{"kind": "item", "key": "mat:x", "qty": 9, "section": "4", "level": 2},
|
||
{"kind": "item", "key": "z", "qty": 0, "section": "1.2", "level": 2},
|
||
]
|
||
rows = {r["key"]: r for r in compute_schedule(src, bill, {})["work"]["rows"]}
|
||
a, b, c, h = rows["a"], rows["b"], rows["c"], rows["h"]
|
||
# 장비 = 1000 × 0.0066 ÷ 8 = 0.825 → 0.9 · Q = 1 ÷ 0.0066
|
||
assert (a["basis"], a["days"], round(a["q"], 2), a["put"]) == ("장비", 0.9, 151.52, 1)
|
||
assert (b["basis"], b["days"], b["put"]) == ("인력", 12, 10) # 80 × 1.5 ÷ 10
|
||
assert (c["basis"], c["days"]) == ("연결 없음", None)
|
||
assert (h["rate"], h["days"]) == (0, 0) # 운반공 기본 0 = 단위당 자원 없어도 0일
|
||
assert "mat:x" not in rows and "z" not in rows # 자재대 · 수량 0 = 줄 없음
|
||
user = {"rows": {"a": {"units": 2, "rate": 50}, "b": {"crew": 5}, "c": {"daily": 2}}}
|
||
rows = {r["key"]: r for r in compute_schedule(src, bill, user)["work"]["rows"]}
|
||
assert rows["a"]["days"] == 0.3 # 1000 × 0.5 × 0.0066 ÷ 16 = 0.20625 → 0.3
|
||
assert rows["b"]["days"] == 24 and (rows["c"]["basis"], rows["c"]["days"]) == ("입력", 2.5)
|
||
|
||
|
||
def _ts_schedule(src, bill, user):
|
||
base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8"))
|
||
calc = (ROOT / "B09_Estimation/draft/B09_Draft_Schedule_Calc.ts").read_text("utf-8")
|
||
js = (
|
||
base + IMPORTS.sub("", calc)
|
||
+ f"\nconsole.log(JSON.stringify(computeSchedule({json.dumps(src, ensure_ascii=False)}, "
|
||
f"{json.dumps(bill, ensure_ascii=False)}, {json.dumps(user, ensure_ascii=False)})));"
|
||
) # fmt: skip
|
||
run = subprocess.run(
|
||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
|
||
input=js, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||
) # fmt: skip
|
||
assert run.returncode == 0, run.stderr
|
||
return json.loads(run.stdout)
|
||
|
||
|
||
def test_거울_TS_가_같은_값():
|
||
src = {**SRC, "units": {"r0": {"machine": {"key": "M", "name": "굴", "hr": 0.0066}},
|
||
"r1": {"labor": 1.5}}} # fmt: skip
|
||
cases = [
|
||
(SRC, _bill(GEOCHANG), _ref_user()),
|
||
(src, _bill(GEOCHANG[:4]), {"rows": {"r0": {"rate": 50}},
|
||
"start": {"year": 2027, "month": 11},
|
||
"climate": {"rain": [9] * 12, "heat": [None] * 12}}),
|
||
(SRC, [], {}),
|
||
] # fmt: skip
|
||
for src_, bill, user in cases:
|
||
py = json.loads(json.dumps(compute_schedule(src_, bill, user), ensure_ascii=False))
|
||
assert _ts_schedule(src_, bill, user) == py
|
||
|
||
|
||
def test_사용자_값_다듬기():
|
||
raw = {
|
||
"hours": 9, "tidy": -1, "etc": "x", "start": {"year": 2027, "month": 13},
|
||
"climate": {"rain": [1] * 12, "heat": [1, 2]},
|
||
"holiday": {"2027-05": 6, "bad": 3, "2027-06": 40},
|
||
"rows": {"a": {"rate": 50, "units": 0, "crew": 5, "daily": None, "x": 1},
|
||
"b": {"rate": 120}},
|
||
"junk": 1,
|
||
} # fmt: skip
|
||
assert clean_schedule(raw) == {
|
||
"hours": 9,
|
||
"climate": {"rain": [1] * 12},
|
||
"holiday": {"2027-05": 6},
|
||
"rows": {"a": {"rate": 50, "crew": 5}},
|
||
}
|
||
assert clean_schedule(None) == {}
|
||
from B09_Estimation.B09_Estimation_Store import clean_user
|
||
|
||
assert clean_user({"schedule": {"tidy": 3}})["schedule"] == {"tidy": 3}
|
||
assert "schedule" not in clean_user({"schedule": {"junk": 1}})
|
||
|
||
|
||
def test_서버_원천_단위당_자원():
|
||
sheets = {
|
||
"bill": {"rows": [
|
||
{"kind": "item", "key": "earth:4", "ref": {"tab": "sg", "key": "S1"}},
|
||
{"kind": "item", "key": "hand:x", "ref": {"tab": "dg", "key": "D1"}},
|
||
{"kind": "item", "key": "earth:5", "ref": None},
|
||
]},
|
||
"sg": [{"key": "S1", "lines": [
|
||
{"kind": "machine", "ref": {"key": "M1"}, "qty": 0.0066},
|
||
{"kind": "machine", "ref": {"key": "M2"}, "qty": 0.002},
|
||
{"kind": "labor", "ref": {"key": "L1"}, "qty": 0.01},
|
||
]}],
|
||
"daega_blocks": [{"key": "D1", "lines": [
|
||
{"kind": "labor", "ref": {"key": "L1"}, "qty": 0.5},
|
||
{"kind": "sangeun", "ref": {"key": "S1"}, "qty": 2},
|
||
{"kind": "labor", "ref": {"key": "L2"}, "qty": 1.0},
|
||
]}],
|
||
"machine": [{"key": "M1", "name": "굴착기", "spec": "0.7㎥"}],
|
||
} # fmt: skip
|
||
units = unit_resources(sheets)
|
||
assert units["earth:4"]["machine"] == {"key": "M1", "name": "굴착기 0.7㎥", "hr": 0.0066}
|
||
assert units["earth:4"]["labor"] == 0.01
|
||
# 겹 호표 = 끝까지(산근 2 단위 → 굴착기 0.0132 시간 · 인 0.5 + 0.02 + 1.0)
|
||
assert round(units["hand:x"]["machine"]["hr"], 6) == 0.0132
|
||
assert round(units["hand:x"]["labor"], 6) == 1.52
|
||
assert "earth:5" not in units
|
||
got = schedule_inputs(sheets, __import__("datetime").date(2026, 10, 11))
|
||
assert got["start"] == {"year": 2027, "month": 3} and "2027" in got["holidays"]
|
||
|
||
|
||
def test_본_코드_훅_지워도_돌게():
|
||
store = (ROOT / "B09_Estimation/B09_Estimation_Store.py").read_text("utf-8")
|
||
router = (ROOT / "B09_Estimation/B09_Estimation_Router.py").read_text("utf-8")
|
||
shell = (ROOT / "B09_Estimation/B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||
assert "except ImportError" in store and "except ImportError" in router
|
||
assert 'import.meta.glob("./draft/*_UI.ts", { eager: true })' in shell
|