설계내역서 항목 줄 역전개(단위당 장비 시간 · 인) → 작업일수 · 착공 연월부터 달마다 작업가능일 채움 · 공휴일 표 2024~2040 · 본 코드 훅은 draft 없으면 건너뜀 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
258 lines
10 KiB
Python
258 lines
10 KiB
Python
"""B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐).
|
||
|
||
짝 = `B09_Draft_Schedule_Calc.ts`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`).
|
||
근거 = 실무 설계서 공기 탭 식(거창 25 · 울진 24 · 울진소광 25) — 값 베끼기가 아니라 식만:
|
||
|
||
공기산출 설계내역서 항목 줄마다 작업일수 = ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1)
|
||
일작업량 = 장비 — 일 작업시간 × 대수 ÷ 단위당 주 장비 시간
|
||
(= 일 작업시간 × Q · Q = 1 ÷ 단위당 시간)
|
||
인력 — 투입 인원 ÷ 단위당 인(장비 없는 줄)
|
||
입력 — 사용자 값(연결 없는 줄 · 덮어쓰기)
|
||
단위당 자원 = 서버 역전개(`…_Source.unit_resources` · 호표 구성 1 단위)
|
||
반영률 = 다른 공종과 겹침(100 = 혼자 · 50 = 반 겹침 · 0 = 다 겹침) · 운반공 기본 0
|
||
소계 = ROUND(Σ 작업일수 + 기타, 0) · 총작업일수 = ROUND(준비 + 소계 + 정리, 0)
|
||
공기집계 착공 연월부터 달마다 — 달력일수 · 기후여건 A = ROUNDUP(강수일 + 혹서일, 1) ·
|
||
법정공휴일 B = 일요일 + 공휴일(대체 · 선거일 포함 · 사용자 덮개) ·
|
||
중복 C = A × B ÷ 달력 ·
|
||
비작업일수 = ROUNDDOWN(A + B − C, 0) · 작업가능일수 = 달력 − 비작업
|
||
준비 → 작업 → 정리 차례로 작업가능일수를 채움 · 다 쓴 달 = 그 달 비작업 전부 ·
|
||
마지막 달(덜 쓴 달) = ROUNDUP(비작업 × 쓴 날 ÷ 작업가능일, 0)
|
||
공사기간 = Σ(쓴 날 + 비작업) · 준공 예정일 = 착공(그 달 1일) + 공사기간 − 1
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime
|
||
from typing import Any
|
||
|
||
from B09_Estimation.B09_Estimation_Calc_Base import num, round_half, rounddown, roundup
|
||
|
||
HOURS = 8
|
||
CREW = 10
|
||
PREP_DOCS = 2
|
||
PREP_SETUP = 2
|
||
TIDY = 4
|
||
#: 공사 아닌 공종(자재대 · 관급자재대) · 기본 반영률 0(운반 = 다른 공종과 겹침)
|
||
SKIP_TOP = ("4", "G")
|
||
OVERLAP_TOP = ("3",)
|
||
MAX_MONTHS = 120
|
||
|
||
|
||
def _top(section: Any) -> str:
|
||
return str(section or "").split(".")[0]
|
||
|
||
|
||
def _cfg(user: dict[str, Any], key: str, default: float) -> float:
|
||
value = num(user.get(key))
|
||
return default if value is None or value < 0 else value
|
||
|
||
|
||
def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, hours: float) -> dict:
|
||
qty = num(item.get("qty")) or 0.0
|
||
rate = num(cfg.get("rate"))
|
||
if rate is None:
|
||
rate = 0.0 if _top(item.get("section")) in OVERLAP_TOP else 100.0
|
||
daily_in = num(cfg.get("daily"))
|
||
machine = (unit or {}).get("machine")
|
||
labor = num((unit or {}).get("labor"))
|
||
out = {
|
||
"kind": "item",
|
||
"key": item["key"],
|
||
"name": item.get("name") or "",
|
||
"spec": item.get("spec") or "",
|
||
"unit": item.get("unit") or "",
|
||
"lv": item.get("level") or 1,
|
||
"qty": qty,
|
||
"rate": rate,
|
||
"basis": "",
|
||
"who": "",
|
||
"per": None,
|
||
"put": None,
|
||
"q": None,
|
||
"daily": None,
|
||
"daily_in": daily_in,
|
||
"days": None,
|
||
}
|
||
if daily_in is not None and daily_in > 0:
|
||
out.update(basis="입력", daily=daily_in)
|
||
need = qty * rate / 100 / daily_in
|
||
elif machine and (num(machine.get("hr")) or 0) > 0:
|
||
hr = num(machine["hr"])
|
||
units = num(cfg.get("units")) or 1.0
|
||
out.update(basis="장비", who=machine.get("name") or "", per=hr, put=units)
|
||
out.update(q=1 / hr, daily=hours * units / hr)
|
||
need = qty * rate / 100 * hr / (hours * units)
|
||
elif labor is not None and labor > 0:
|
||
crew = num(cfg.get("crew")) or float(CREW)
|
||
out.update(basis="인력", who="인력", per=labor, put=crew, daily=crew / labor)
|
||
need = qty * rate / 100 * labor / crew
|
||
else:
|
||
out["basis"] = "연결 없음"
|
||
out["days"] = 0.0 if rate == 0 else None # 다 겹침 = 일작업량 없어도 0
|
||
return out
|
||
out["days"] = roundup(need, 1)
|
||
return out
|
||
|
||
|
||
def work_table(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict) -> dict:
|
||
"""공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리."""
|
||
hours = _cfg(user, "hours", HOURS) or HOURS
|
||
units = src.get("units") or {}
|
||
cfgs = user.get("rows") or {}
|
||
rows: list[dict[str, Any]] = []
|
||
pending: list[list[Any]] = [] # [줄, 넣음]
|
||
for row in bill_rows:
|
||
level = row.get("level") or 1
|
||
if row.get("kind") != "item":
|
||
pending = [p for p in pending if (p[0].get("level") or 1) < level]
|
||
pending.append([row, False])
|
||
continue
|
||
key = str(row.get("key") or "")
|
||
qty = num(row.get("qty"))
|
||
if (
|
||
_top(row.get("section")) in SKIP_TOP
|
||
or key.startswith("mat:")
|
||
or key == "fee"
|
||
or qty is None
|
||
or qty <= 0
|
||
):
|
||
continue
|
||
for p in pending:
|
||
if not p[1]:
|
||
p[1] = True
|
||
head = p[0]
|
||
rows.append(
|
||
{
|
||
"kind": "head",
|
||
"key": head["key"],
|
||
"name": head.get("name") or "",
|
||
"lv": head.get("level") or 1,
|
||
} # fmt: skip
|
||
)
|
||
rows.append(_work_row(row, units.get(key), cfgs.get(key) or {}, hours))
|
||
docs = _cfg(user, "prep_docs", PREP_DOCS)
|
||
setup = _cfg(user, "prep_setup", PREP_SETUP)
|
||
etc = _cfg(user, "etc", 0)
|
||
tidy = _cfg(user, "tidy", TIDY)
|
||
days = sum(r["days"] or 0 for r in rows if r["kind"] == "item")
|
||
subtotal = round_half(days + etc, 0)
|
||
missing = sum(1 for r in rows if r["kind"] == "item" and r["days"] is None)
|
||
return {
|
||
"hours": hours,
|
||
"rows": rows,
|
||
"prep_docs": docs,
|
||
"prep_setup": setup,
|
||
"prep": docs + setup,
|
||
"etc": etc,
|
||
"subtotal": subtotal,
|
||
"tidy": tidy,
|
||
"total": round_half(docs + setup + subtotal + tidy, 0),
|
||
"missing": missing,
|
||
}
|
||
|
||
|
||
def _month_days(year: int, month: int) -> int:
|
||
nxt = datetime.date(year + month // 12, month % 12 + 1, 1)
|
||
return (nxt - datetime.date(year, month, 1)).days
|
||
|
||
|
||
def _holidays(src: dict[str, Any], year: int, month: int) -> tuple[int, list[str]]:
|
||
"""그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름(「일 이름」)."""
|
||
head = f"{year:04d}-{month:02d}-"
|
||
named = {
|
||
d: n for d, n in (src.get("holidays") or {}).get(str(year)) or [] if d.startswith(head)
|
||
}
|
||
count = 0
|
||
for day in range(1, _month_days(year, month) + 1):
|
||
iso = f"{head}{day:02d}"
|
||
if datetime.date(year, month, day).weekday() == 6 or iso in named:
|
||
count += 1
|
||
return count, [f"{int(d[-2:])}일 {n}" for d, n in sorted(named.items())]
|
||
|
||
|
||
def _climate(src: dict[str, Any], user: dict[str, Any], kind: str, month: int) -> float:
|
||
mine = ((user.get("climate") or {}).get(kind) or [None] * 12)[month - 1]
|
||
if num(mine) is not None:
|
||
return num(mine)
|
||
return num(((src.get("climate") or {}).get(kind) or [0] * 12)[month - 1]) or 0.0
|
||
|
||
|
||
def month_table(src: dict[str, Any], user: dict[str, Any], parts: list[float]) -> dict[str, Any]:
|
||
"""공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움."""
|
||
start = {**(src.get("start") or {}), **(user.get("start") or {})}
|
||
year, month = int(start.get("year") or 2000), int(start.get("month") or 1)
|
||
left = [max(p, 0) for p in parts]
|
||
months: list[dict[str, Any]] = []
|
||
total = 0
|
||
while sum(left) > 0 or not months:
|
||
if len(months) >= MAX_MONTHS:
|
||
break
|
||
days = _month_days(year, month)
|
||
rain = _climate(src, user, "rain", month)
|
||
heat = _climate(src, user, "heat", month)
|
||
a = roundup(rain + heat, 1)
|
||
auto, names = _holidays(src, year, month)
|
||
mine = num((user.get("holiday") or {}).get(f"{year:04d}-{month:02d}"))
|
||
b = mine if mine is not None else auto
|
||
c = a * b / days
|
||
off = rounddown(a + b - c, 0)
|
||
avail = days - off
|
||
cap = max(avail, 0)
|
||
use = [0.0, 0.0, 0.0]
|
||
room = cap
|
||
for i in range(3):
|
||
take = min(room, left[i])
|
||
use[i] = take
|
||
left[i] -= take
|
||
room -= take
|
||
used = sum(use)
|
||
if cap <= 0:
|
||
off_used = days
|
||
elif used >= cap:
|
||
off_used = off
|
||
else:
|
||
off_used = roundup(off * used / cap, 0)
|
||
span = used + off_used
|
||
total += span
|
||
months.append(
|
||
{
|
||
"year": year,
|
||
"month": month,
|
||
"days": days,
|
||
"rain": rain,
|
||
"heat": heat,
|
||
"a": a,
|
||
"b": b,
|
||
"b_auto": auto,
|
||
"names": names,
|
||
"c": c,
|
||
"off": off,
|
||
"avail": avail,
|
||
"prep": use[0],
|
||
"work": use[1],
|
||
"tidy": use[2],
|
||
"used": used,
|
||
"off_used": off_used,
|
||
"span": span,
|
||
} # fmt: skip
|
||
)
|
||
year, month = (year + 1, 1) if month == 12 else (year, month + 1)
|
||
first = months[0]
|
||
begin = datetime.date(first["year"], first["month"], 1)
|
||
end = begin + datetime.timedelta(days=max(total, 1) - 1)
|
||
return {
|
||
"start": {"year": first["year"], "month": first["month"]},
|
||
"months": months,
|
||
"days": total,
|
||
"months_n": round_half(total / 30, 1),
|
||
"end": end.isoformat(),
|
||
}
|
||
|
||
|
||
def compute_schedule(src: dict[str, Any], bill_rows: list[dict[str, Any]], user: dict) -> dict:
|
||
"""공기 한 벌 — {work: 공기산출, calendar: 공기집계}."""
|
||
user = user or {}
|
||
work = work_table(src or {}, bill_rows or [], user)
|
||
parts = [work["prep"], work["subtotal"], work["tidy"]]
|
||
return {"work": work, "calendar": month_table(src or {}, user, parts)}
|