"""B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐). 짝 = `B09_Draft_Schedule_Calc.ts`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py` · `test_264_2_b09_schedule_options.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 옵션(264 · user.schedule · 기본값 = 위 식 그대로) — 착공일(1일 아니면 첫 달 = 그날부터 · A = 날수 비례) · 기후 자료(원천 climates 고르기 · 달마다 덮개가 이김) · 쉬는 날(일 · 토일) · 공휴일 빼기(대체 · 선거일 · 노동절 · 제헌절 — 이름으로 가름) · 투입 기본(장비 대 · 인력 인) · 반영률 기본(운반공 · 그 밖) · 주 장비 기준(가장 긴 장비 · 장비 시간 합) · 중복 C 끝수(그대로 · 소수 1자리 반올림) · 마지막 달 비작업(쓴 비율 올림 · 전부) """ from __future__ import annotations import datetime from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num, round_half, rounddown, roundup #: 사용자 값 기본(264 패널 · 표 칸 같은 값) DEFAULTS = { "hours": 8, "units": 1, "crew": 10, "prep_docs": 2, "prep_setup": 2, "tidy": 4, "etc": 0, "rate_haul": 0, "rate_base": 100, } #: 공휴일 빼기 갈래 — 이름에 든 글 HOLIDAY_KINDS = { "substitute": ("대체",), "election": ("선거",), "labor": ("노동절", "근로자"), "constitution": ("제헌절",), } #: 공사 아닌 공종(자재대 · 관급자재대) · 기본 반영률 운반공(= 다른 공종과 겹침) 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) -> float: value = num(user.get(key)) return DEFAULTS[key] if value is None or value < 0 else value def _work_row(item: dict[str, Any], unit: dict[str, Any] | None, cfg: dict, base: dict) -> dict: hours = base["hours"] qty = num(item.get("qty")) or 0.0 rate = num(cfg.get("rate")) if rate is None: rate = base["rate_haul"] if _top(item.get("section")) in OVERLAP_TOP else base["rate_base"] daily_in = num(cfg.get("daily")) machine = (unit or {}).get("machine") total = (unit or {}).get("machine_sum") if machine and base["sum"] and total and (num(total.get("hr")) or 0) > 0: many = f" 외 {total['n'] - 1}종" if (total.get("n") or 1) > 1 else "" machine = {**machine, "name": f"{machine.get('name') or ''}{many}", "hr": total["hr"]} 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 base["units"] 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 base["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") or DEFAULTS["hours"] base = { "hours": hours, "units": _cfg(user, "units") or DEFAULTS["units"], "crew": _cfg(user, "crew") or DEFAULTS["crew"], "rate_haul": min(_cfg(user, "rate_haul"), 100), "rate_base": min(_cfg(user, "rate_base"), 100), "sum": user.get("machine_basis") == "sum", } 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 {}, base)) docs = _cfg(user, "prep_docs") setup = _cfg(user, "prep_setup") etc = _cfg(user, "etc") tidy = _cfg(user, "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 _counted(name: str, off: list[str]) -> bool: """공휴일 넣음 — 「;」 로 묶인 이름 중 하나라도 뺀 갈래가 아니면 넣음.""" for part in name.split(";"): kinds = [k for k, words in HOLIDAY_KINDS.items() if any(w in part for w in words)] if not any(k in off for k in kinds): return True return False def _holidays( src: dict[str, Any], year: int, month: int, first: int, user: dict[str, Any] ) -> tuple[int, list[str]]: """그 달(first 일부터) 법정공휴일 수(쉬는 요일 + 공휴일 겹침 한 번) · 공휴일 이름(「일 이름」).""" head = f"{year:04d}-{month:02d}-" off = user.get("holiday_off") or [] named = { d: n for d, n in (src.get("holidays") or {}).get(str(year)) or [] if d.startswith(head) and int(d[-2:]) >= first and _counted(n, off) } rest = (5, 6) if user.get("rest") == "satsun" else (6,) count = 0 for day in range(first, _month_days(year, month) + 1): iso = f"{head}{day:02d}" if datetime.date(year, month, day).weekday() in rest or iso in named: count += 1 return count, [f"{int(d[-2:])}일 {n}" for d, n in sorted(named.items())] def climate_base(src: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: """고른 기후 자료(원천 climates) · 없으면 원천 기본값.""" return (src.get("climates") or {}).get(user.get("climate_set")) or src.get("climate") or {} 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((climate_base(src, user).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) first = min(max(int(start.get("day") or 1), 1), _month_days(year, month)) begin = datetime.date(year, month, first) 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 full = _month_days(year, month) days = full - first + 1 rain = _climate(src, user, "rain", month) heat = _climate(src, user, "heat", month) a = roundup(rain + heat if first == 1 else (rain + heat) * days / full, 1) auto, names = _holidays(src, year, month, first, user) 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 if user.get("c_round") == "round1": c = round_half(c, 1) 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 or user.get("last_off") == "all": 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 = 1 end = begin + datetime.timedelta(days=max(total, 1) - 1) return { "start": {"year": begin.year, "month": begin.month, "day": begin.day}, "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)}