From f87831533cd20232d61a0c8587b5791f9a6552dd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 01:51:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EA=B3=B5=EA=B8=B0=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=20=E2=80=94=20=EB=A9=94=EC=9D=B8=20=ED=83=AD=20?= =?UTF-8?q?=EA=B3=B5=EA=B8=B0=20=C2=B7=20=EA=B3=B5=EA=B8=B0=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=C2=B7=20=EA=B3=B5=EA=B8=B0=EC=82=B0=EC=B6=9C=20(dr?= =?UTF-8?q?aft=20=ED=8F=B4=EB=8D=94=20=C2=B7=20252)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설계내역서 항목 줄 역전개(단위당 장비 시간 · 인) → 작업일수 · 착공 연월부터 달마다 작업가능일 채움 · 공휴일 표 2024~2040 · 본 코드 훅은 draft 없으면 건너뜀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- B09_Estimation/B09_Estimation_Router.py | 7 + B09_Estimation/B09_Estimation_Store.py | 10 +- B09_Estimation/B09_Estimation_UI_Shell.ts | 10 +- B09_Estimation/B09_Estimation_UI_Tabs.ts | 7 + .../draft/B09_Draft_Schedule_Calc.py | 257 +++++++++++++++ .../draft/B09_Draft_Schedule_Calc.ts | 232 +++++++++++++ .../draft/B09_Draft_Schedule_Holidays.json | 19 ++ .../draft/B09_Draft_Schedule_Source.py | 136 ++++++++ B09_Estimation/draft/B09_Draft_Schedule_UI.ts | 306 ++++++++++++++++++ resources/tester/test_252_1_b09_schedule.py | 212 ++++++++++++ 10 files changed, 1193 insertions(+), 3 deletions(-) create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_Calc.py create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_Calc.ts create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_Holidays.json create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_Source.py create mode 100644 B09_Estimation/draft/B09_Draft_Schedule_UI.ts create mode 100644 resources/tester/test_252_1_b09_schedule.py diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 5af0b89f7..05f8d0c3b 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -50,6 +50,11 @@ from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete.M02_Item_Snapshot import importer +try: # 252 공기 초안(draft 폴더 · 지우면 공기 탭 원천 없이) + from B09_Estimation.draft import B09_Draft_Schedule_Source as schedule_draft +except ImportError: # pragma: no cover + schedule_draft = None + router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -144,6 +149,8 @@ async def payload(project_id: UUID, root: Path) -> dict[str, Any]: user = await asyncio.to_thread(store.read_user, root) calc = {**user, "haul_refs": await asyncio.to_thread(haul_refs, root)} sheets, summary = await asyncio.to_thread(compute_twice, root, inputs, calc) + if schedule_draft: + schedule_draft.attach(inputs, sheets, user) return { "inputs": inputs, "user": user, diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index bd839b659..07496606d 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -30,6 +30,11 @@ from urllib.parse import unquote from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS +try: # 252 공기 초안(draft 폴더 · 지우면 공기 값 없이) + from B09_Estimation.draft.B09_Draft_Schedule_Source import clean_schedule +except ImportError: # pragma: no cover + clean_schedule = None + FOLDER = ("B09_Estimation", "v1") BOOK = "price_book.json" USER = "user_values.json" @@ -233,7 +238,10 @@ def clean_user(raw: Any) -> dict[str, Any]: if isinstance(rates, dict): estimate["rates"] = {str(k): _num(v) for k, v in rates.items()} bill = clean_bill(raw.get("bill")) - return {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})} + out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})} + if clean_schedule and (schedule := clean_schedule(raw.get("schedule"))): + out["schedule"] = schedule + return out def read_user(project_root: str | Path) -> dict[str, Any]: diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 78682d64f..445f1c234 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -34,6 +34,7 @@ import { createTabBars } from "./B09_Estimation_UI_TabBar"; import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; import { + DRAFT, rowIndex, TABS, type Link, @@ -63,6 +64,9 @@ import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups"; +// 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음) +import.meta.glob("./draft/*_UI.ts", { eager: true }); + function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } @@ -136,7 +140,7 @@ export function computeSheets(payload: Row, user: Row): Row { books, ); const { gaps: daegaGaps, ...daega } = labor; - return { + const out: Row = { ...base, ...kept, ...daega, @@ -144,6 +148,8 @@ export function computeSheets(payload: Row, user: Row): Row { // 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 요율(사용자 ?? 자동 166-1) · 손입력 total: computeTotal(base.bill?.sums, user.estimate, payload.rate_table), }; + for (const extend of DRAFT.extend) extend(payload, user, out); + return out; } export function renderB09Estimation(root: HTMLElement): void { @@ -186,7 +192,7 @@ export function renderB09Estimation(root: HTMLElement): void { bars.setTools([]); // 보기 단추 · 열 수는 묶음 탭만 다시 채움(202-1) if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); - const build = BUILDERS[tab]; + const build = BUILDERS[tab] ?? DRAFT.builders[tab]; if (!build) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); const ctx: TabContext = { sheets, payload, user, setUser }; const spec = build(ctx); diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index 1b5a903b7..3dea31f92 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -120,6 +120,13 @@ export interface TabTable { export type TabBuild = (ctx: TabContext) => TabTable | string; +/** 초안 탭(252 · `draft/*_UI.ts` 가 TABS · TAB_GROUPS 와 함께 넣음 · 폴더째 지우면 빈 채로) — + * builders = 탭 짜기 · extend = 브라우저 셈 곁(셈 결과에 더함) */ +export const DRAFT: { + builders: Record; + extend: Array<(payload: Row, user: Row, sheets: Row) => void>; +} = { builders: {}, extend: [] }; + /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ export const blockLevel = (key: string, top: (index: number) => boolean): TabTable["level"] => ({ key, diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Calc.py b/B09_Estimation/draft/B09_Draft_Schedule_Calc.py new file mode 100644 index 000000000..defcf5af0 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Calc.py @@ -0,0 +1,257 @@ +"""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)} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts b/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts new file mode 100644 index 000000000..0ae43dce9 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Calc.ts @@ -0,0 +1,232 @@ +/* ============================================================================= + * B09_Draft_Schedule_Calc.ts + * B09 공기 셈 짝(PLAN 252-1 · 사용자 161번 · 초안 — draft 폴더째 지우면 사라짐). + * 짝 = `B09_Draft_Schedule_Calc.py`(같은 식 · 거울 시험 `test_252_1_b09_schedule.py`) — 식 설명은 그쪽 머리. + * 공기산출 = 설계내역서 항목 줄마다 ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비 · 인력 · 입력 + * 공기집계 = 착공 연월부터 달마다 작업가능일수(달력 − (A + B − A×B÷달력))로 준비 → 작업 → 정리를 채움 + * ========================================================================== */ + +import { num, roundHalf, rounddown, roundup, type Row } from "../B09_Estimation_Calc_Base"; + +const HOURS = 8; +const CREW = 10; +const PREP_DOCS = 2; +const PREP_SETUP = 2; +const TIDY = 4; +const SKIP_TOP = ["4", "G"]; +const OVERLAP_TOP = ["3"]; +const MAX_MONTHS = 120; + +const top = (section: unknown): string => String(section ?? "").split(".")[0]; + +function cfg(user: Row, key: string, fallback: number): number { + const value = num(user[key]); + return value === null || value < 0 ? fallback : value; +} + +function workRow(item: Row, unit: Row | undefined, c: Row, hours: number): Row { + const qty = num(item.qty) ?? 0; + let rate = num(c.rate); + if (rate === null) rate = OVERLAP_TOP.includes(top(item.section)) ? 0 : 100; + const dailyIn = num(c.daily); + const machine = unit?.machine as Row | null | undefined; + const labor = num(unit?.labor); + const out: Row = { + kind: "item", + key: item.key, + name: item.name || "", + spec: item.spec || "", + unit: item.unit || "", + lv: item.level || 1, + qty, + rate, + basis: "", + who: "", + per: null, + put: null, + q: null, + daily: null, + daily_in: dailyIn, + days: null, + }; + let need: number; + if (dailyIn !== null && dailyIn > 0) { + Object.assign(out, { basis: "입력", daily: dailyIn }); + need = (qty * rate) / 100 / dailyIn; + } else if (machine && (num(machine.hr) ?? 0) > 0) { + const hr = num(machine.hr) as number; + const units = num(c.units) || 1; + Object.assign(out, { basis: "장비", who: machine.name || "", per: hr, put: units }); + Object.assign(out, { q: 1 / hr, daily: (hours * units) / hr }); + need = (((qty * rate) / 100) * hr) / (hours * units); + } else if (labor !== null && labor > 0) { + const crew = num(c.crew) || CREW; + Object.assign(out, { basis: "인력", who: "인력", per: labor, put: crew, daily: crew / labor }); + need = (((qty * rate) / 100) * labor) / crew; + } else { + out.basis = "연결 없음"; + out.days = rate === 0 ? 0 : null; // 다 겹침 = 일작업량 없어도 0 + return out; + } + out.days = roundup(need, 1); + return out; +} + +/** 공기산출 — 설계내역서 차례(묶음 줄 = 그 아래 셀 줄이 있을 때만) · 준비 · 기타 · 정리 */ +export function workTable(src: Row, billRows: Row[], user: Row): Row { + const hours = cfg(user, "hours", HOURS) || HOURS; + const units: Row = src.units || {}; + const cfgs: Row = user.rows || {}; + const rows: Row[] = []; + let pending: Array<[Row, boolean]> = []; + for (const row of billRows) { + const level = row.level || 1; + if (row.kind !== "item") { + pending = pending.filter(([p]) => (p.level || 1) < level); + pending.push([row, false]); + continue; + } + const key = String(row.key ?? ""); + const qty = num(row.qty); + if ( + SKIP_TOP.includes(top(row.section)) || + key.startsWith("mat:") || + key === "fee" || + qty === null || + qty <= 0 + ) + continue; + for (const p of pending) { + if (p[1]) continue; + p[1] = true; + rows.push({ kind: "head", key: p[0].key, name: p[0].name || "", lv: p[0].level || 1 }); + } + rows.push(workRow(row, units[key], cfgs[key] || {}, hours)); + } + const docs = cfg(user, "prep_docs", PREP_DOCS); + const setup = cfg(user, "prep_setup", PREP_SETUP); + const etc = cfg(user, "etc", 0); + const tidy = cfg(user, "tidy", TIDY); + const items = rows.filter((r) => r.kind === "item"); + const days = items.reduce((s: number, r) => s + (r.days || 0), 0); + const subtotal = roundHalf(days + etc, 0); + return { + hours, + rows, + prep_docs: docs, + prep_setup: setup, + prep: docs + setup, + etc, + subtotal, + tidy, + total: roundHalf(docs + setup + subtotal + tidy, 0), + missing: items.filter((r) => r.days === null).length, + }; +} + +const monthDays = (year: number, month: number): number => + new Date(Date.UTC(year, month, 0)).getUTCDate(); + +const pad = (n: number): string => String(n).padStart(2, "0"); + +/** 그 달 법정공휴일 수(일요일 + 공휴일 겹침 한 번) · 공휴일 이름 */ +function holidays(src: Row, year: number, month: number): [number, string[]] { + const head = `${String(year).padStart(4, "0")}-${pad(month)}-`; + const named = new Map( + ((src.holidays || {})[String(year)] || []).filter(([d]: [string]) => d.startsWith(head)), + ); + let count = 0; + for (let day = 1; day <= monthDays(year, month); day += 1) { + const sunday = new Date(Date.UTC(year, month - 1, day)).getUTCDay() === 0; + if (sunday || named.has(`${head}${pad(day)}`)) count += 1; + } + const names = [...named.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([d, n]) => `${Number(d.slice(-2))}일 ${n}`); + return [count, names]; +} + +function climate(src: Row, user: Row, kind: string, month: number): number { + const mine = num(((user.climate || {})[kind] || [])[month - 1]); + if (mine !== null) return mine; + return num(((src.climate || {})[kind] || [])[month - 1]) ?? 0; +} + +/** 공기집계 — parts = [준비, 작업, 정리] 일수 · 착공 연월부터 채움 */ +export function monthTable(src: Row, user: Row, parts: number[]): Row { + const start: Row = { ...(src.start || {}), ...(user.start || {}) }; + let year = Math.trunc(Number(start.year) || 2000); + let month = Math.trunc(Number(start.month) || 1); + const left = parts.map((p) => Math.max(p, 0)); + const months: Row[] = []; + let total = 0; + while (left.reduce((s, v) => s + v, 0) > 0 || !months.length) { + if (months.length >= MAX_MONTHS) break; + const days = monthDays(year, month); + const rain = climate(src, user, "rain", month); + const heat = climate(src, user, "heat", month); + const a = roundup(rain + heat, 1); + const [auto, names] = holidays(src, year, month); + const mine = num((user.holiday || {})[`${String(year).padStart(4, "0")}-${pad(month)}`]); + const b = mine !== null ? mine : auto; + const c = (a * b) / days; + const off = rounddown(a + b - c, 0); + const avail = days - off; + const cap = Math.max(avail, 0); + const use = [0, 0, 0]; + let room = cap; + for (let i = 0; i < 3; i += 1) { + const take = Math.min(room, left[i]); + use[i] = take; + left[i] -= take; + room -= take; + } + const used = use[0] + use[1] + use[2]; + const offUsed = cap <= 0 ? days : used >= cap ? off : roundup((off * used) / cap, 0); + const span = used + offUsed; + total += span; + months.push({ + year, + month, + days, + rain, + heat, + a, + b, + b_auto: auto, + names, + c, + off, + avail, + prep: use[0], + work: use[1], + tidy: use[2], + used, + off_used: offUsed, + span, + }); + if (month === 12) { + year += 1; + month = 1; + } else month += 1; + } + const first = months[0]; + const end = new Date(Date.UTC(first.year, first.month - 1, 1) + (Math.max(total, 1) - 1) * 864e5); + return { + start: { year: first.year, month: first.month }, + months, + days: total, + months_n: roundHalf(total / 30, 1), + end: end.toISOString().slice(0, 10), + }; +} + +/** 공기 한 벌 — {work: 공기산출, calendar: 공기집계} */ +export function computeSchedule(src: Row, billRows: Row[], user: Row): Row { + const u = user || {}; + const work = workTable(src || {}, billRows || [], u); + return { + work, + calendar: monthTable(src || {}, u, [work.prep, work.subtotal, work.tidy]), + }; +} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Holidays.json b/B09_Estimation/draft/B09_Draft_Schedule_Holidays.json new file mode 100644 index 000000000..b349e0842 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Holidays.json @@ -0,0 +1,19 @@ +{ + "2024": [["2024-01-01", "신정"], ["2024-02-09", "설날 전날"], ["2024-02-10", "설날"], ["2024-02-11", "설날 다음날"], ["2024-02-12", "설날 대체 휴일"], ["2024-03-01", "삼일절"], ["2024-04-10", "국회의원 선거일"], ["2024-05-05", "어린이날"], ["2024-05-06", "어린이날 대체 휴일"], ["2024-05-15", "부처님오신날"], ["2024-06-06", "현충일"], ["2024-08-15", "광복절"], ["2024-09-16", "추석 전날"], ["2024-09-17", "추석"], ["2024-09-18", "추석 다음날"], ["2024-10-01", "국군의 날"], ["2024-10-03", "개천절"], ["2024-10-09", "한글날"], ["2024-12-25", "기독탄신일"]], + "2025": [["2025-01-01", "신정"], ["2025-01-27", "임시공휴일"], ["2025-01-28", "설날 전날"], ["2025-01-29", "설날"], ["2025-01-30", "설날 다음날"], ["2025-03-01", "삼일절"], ["2025-03-03", "삼일절 대체 휴일"], ["2025-05-05", "부처님오신날; 어린이날"], ["2025-05-06", "부처님오신날 대체 휴일; 어린이날 대체 휴일"], ["2025-06-03", "대통령 선거일"], ["2025-06-06", "현충일"], ["2025-08-15", "광복절"], ["2025-10-03", "개천절"], ["2025-10-05", "추석 전날"], ["2025-10-06", "추석"], ["2025-10-07", "추석 다음날"], ["2025-10-08", "추석 대체 휴일"], ["2025-10-09", "한글날"], ["2025-12-25", "기독탄신일"]], + "2026": [["2026-01-01", "신정"], ["2026-02-16", "설날 전날"], ["2026-02-17", "설날"], ["2026-02-18", "설날 다음날"], ["2026-03-01", "삼일절"], ["2026-03-02", "삼일절 대체 휴일"], ["2026-05-01", "노동절"], ["2026-05-05", "어린이날"], ["2026-05-24", "부처님오신날"], ["2026-05-25", "부처님오신날 대체 휴일"], ["2026-06-03", "지방선거일"], ["2026-06-06", "현충일"], ["2026-07-17", "제헌절"], ["2026-08-15", "광복절"], ["2026-08-17", "광복절 대체 휴일"], ["2026-09-24", "추석 전날"], ["2026-09-25", "추석"], ["2026-09-26", "추석 다음날"], ["2026-10-03", "개천절"], ["2026-10-05", "개천절 대체 휴일"], ["2026-10-09", "한글날"], ["2026-12-25", "기독탄신일"]], + "2027": [["2027-01-01", "신정"], ["2027-02-06", "설날 전날"], ["2027-02-07", "설날"], ["2027-02-08", "설날 다음날"], ["2027-02-09", "설날 대체 휴일"], ["2027-03-01", "삼일절"], ["2027-05-01", "노동절"], ["2027-05-03", "노동절 대체 휴일"], ["2027-05-05", "어린이날"], ["2027-05-13", "부처님오신날"], ["2027-06-06", "현충일"], ["2027-07-17", "제헌절"], ["2027-07-19", "제헌절 대체 휴일"], ["2027-08-15", "광복절"], ["2027-08-16", "광복절 대체 휴일"], ["2027-09-14", "추석 전날"], ["2027-09-15", "추석"], ["2027-09-16", "추석 다음날"], ["2027-10-03", "개천절"], ["2027-10-04", "개천절 대체 휴일"], ["2027-10-09", "한글날"], ["2027-10-11", "한글날 대체 휴일"], ["2027-12-25", "기독탄신일"], ["2027-12-27", "기독탄신일 대체 휴일"]], + "2028": [["2028-01-01", "신정"], ["2028-01-26", "설날 전날"], ["2028-01-27", "설날"], ["2028-01-28", "설날 다음날"], ["2028-03-01", "삼일절"], ["2028-05-01", "노동절"], ["2028-05-02", "부처님오신날"], ["2028-05-05", "어린이날"], ["2028-06-06", "현충일"], ["2028-07-17", "제헌절"], ["2028-08-15", "광복절"], ["2028-10-02", "추석 전날"], ["2028-10-03", "개천절; 추석"], ["2028-10-04", "추석 다음날"], ["2028-10-05", "추석 대체 휴일"], ["2028-10-09", "한글날"], ["2028-12-25", "기독탄신일"]], + "2029": [["2029-01-01", "신정"], ["2029-02-12", "설날 전날"], ["2029-02-13", "설날"], ["2029-02-14", "설날 다음날"], ["2029-03-01", "삼일절"], ["2029-05-01", "노동절"], ["2029-05-05", "어린이날"], ["2029-05-07", "어린이날 대체 휴일"], ["2029-05-20", "부처님오신날"], ["2029-05-21", "부처님오신날 대체 휴일"], ["2029-06-06", "현충일"], ["2029-07-17", "제헌절"], ["2029-08-15", "광복절"], ["2029-09-21", "추석 전날"], ["2029-09-22", "추석"], ["2029-09-23", "추석 다음날"], ["2029-09-24", "추석 대체 휴일"], ["2029-10-03", "개천절"], ["2029-10-09", "한글날"], ["2029-12-25", "기독탄신일"]], + "2030": [["2030-01-01", "신정"], ["2030-02-02", "설날 전날"], ["2030-02-03", "설날"], ["2030-02-04", "설날 다음날"], ["2030-02-05", "설날 대체 휴일"], ["2030-03-01", "삼일절"], ["2030-05-01", "노동절"], ["2030-05-05", "어린이날"], ["2030-05-06", "어린이날 대체 휴일"], ["2030-05-09", "부처님오신날"], ["2030-06-06", "현충일"], ["2030-07-17", "제헌절"], ["2030-08-15", "광복절"], ["2030-09-11", "추석 전날"], ["2030-09-12", "추석"], ["2030-09-13", "추석 다음날"], ["2030-10-03", "개천절"], ["2030-10-09", "한글날"], ["2030-12-25", "기독탄신일"]], + "2031": [["2031-01-01", "신정"], ["2031-01-22", "설날 전날"], ["2031-01-23", "설날"], ["2031-01-24", "설날 다음날"], ["2031-03-01", "삼일절"], ["2031-03-03", "삼일절 대체 휴일"], ["2031-05-01", "노동절"], ["2031-05-05", "어린이날"], ["2031-05-28", "부처님오신날"], ["2031-06-06", "현충일"], ["2031-07-17", "제헌절"], ["2031-08-15", "광복절"], ["2031-09-30", "추석 전날"], ["2031-10-01", "추석"], ["2031-10-02", "추석 다음날"], ["2031-10-03", "개천절"], ["2031-10-09", "한글날"], ["2031-12-25", "기독탄신일"]], + "2032": [["2032-01-01", "신정"], ["2032-02-10", "설날 전날"], ["2032-02-11", "설날"], ["2032-02-12", "설날 다음날"], ["2032-03-01", "삼일절"], ["2032-05-01", "노동절"], ["2032-05-03", "노동절 대체 휴일"], ["2032-05-05", "어린이날"], ["2032-05-16", "부처님오신날"], ["2032-05-17", "부처님오신날 대체 휴일"], ["2032-06-06", "현충일"], ["2032-07-17", "제헌절"], ["2032-07-19", "제헌절 대체 휴일"], ["2032-08-15", "광복절"], ["2032-08-16", "광복절 대체 휴일"], ["2032-09-18", "추석 전날"], ["2032-09-19", "추석"], ["2032-09-20", "추석 다음날"], ["2032-09-21", "추석 대체 휴일"], ["2032-10-03", "개천절"], ["2032-10-04", "개천절 대체 휴일"], ["2032-10-09", "한글날"], ["2032-10-11", "한글날 대체 휴일"], ["2032-12-25", "기독탄신일"], ["2032-12-27", "기독탄신일 대체 휴일"]], + "2033": [["2033-01-01", "신정"], ["2033-01-30", "설날 전날"], ["2033-01-31", "설날"], ["2033-02-01", "설날 다음날"], ["2033-02-02", "설날 대체 휴일"], ["2033-03-01", "삼일절"], ["2033-05-01", "노동절"], ["2033-05-02", "노동절 대체 휴일"], ["2033-05-05", "어린이날"], ["2033-05-06", "부처님오신날"], ["2033-06-06", "현충일"], ["2033-07-17", "제헌절"], ["2033-07-18", "제헌절 대체 휴일"], ["2033-08-15", "광복절"], ["2033-09-07", "추석 전날"], ["2033-09-08", "추석"], ["2033-09-09", "추석 다음날"], ["2033-10-03", "개천절"], ["2033-10-09", "한글날"], ["2033-10-10", "한글날 대체 휴일"], ["2033-12-25", "기독탄신일"], ["2033-12-26", "기독탄신일 대체 휴일"]], + "2034": [["2034-01-01", "신정"], ["2034-02-18", "설날 전날"], ["2034-02-19", "설날"], ["2034-02-20", "설날 다음날"], ["2034-02-21", "설날 대체 휴일"], ["2034-03-01", "삼일절"], ["2034-05-01", "노동절"], ["2034-05-05", "어린이날"], ["2034-05-25", "부처님오신날"], ["2034-06-06", "현충일"], ["2034-07-17", "제헌절"], ["2034-08-15", "광복절"], ["2034-09-26", "추석 전날"], ["2034-09-27", "추석"], ["2034-09-28", "추석 다음날"], ["2034-10-03", "개천절"], ["2034-10-09", "한글날"], ["2034-12-25", "기독탄신일"]], + "2035": [["2035-01-01", "신정"], ["2035-02-07", "설날 전날"], ["2035-02-08", "설날"], ["2035-02-09", "설날 다음날"], ["2035-03-01", "삼일절"], ["2035-05-01", "노동절"], ["2035-05-05", "어린이날"], ["2035-05-07", "어린이날 대체 휴일"], ["2035-05-15", "부처님오신날"], ["2035-06-06", "현충일"], ["2035-07-17", "제헌절"], ["2035-08-15", "광복절"], ["2035-09-15", "추석 전날"], ["2035-09-16", "추석"], ["2035-09-17", "추석 다음날"], ["2035-09-18", "추석 대체 휴일"], ["2035-10-03", "개천절"], ["2035-10-09", "한글날"], ["2035-12-25", "기독탄신일"]], + "2036": [["2036-01-01", "신정"], ["2036-01-27", "설날 전날"], ["2036-01-28", "설날"], ["2036-01-29", "설날 다음날"], ["2036-01-30", "설날 대체 휴일"], ["2036-03-01", "삼일절"], ["2036-03-03", "삼일절 대체 휴일"], ["2036-05-01", "노동절"], ["2036-05-03", "부처님오신날"], ["2036-05-05", "어린이날"], ["2036-05-06", "부처님오신날 대체 휴일"], ["2036-06-06", "현충일"], ["2036-07-17", "제헌절"], ["2036-08-15", "광복절"], ["2036-10-03", "개천절; 추석 전날"], ["2036-10-04", "추석"], ["2036-10-05", "추석 다음날"], ["2036-10-06", "추석 대체 휴일"], ["2036-10-07", "추석 대체 휴일"], ["2036-10-09", "한글날"], ["2036-12-25", "기독탄신일"]], + "2037": [["2037-01-01", "신정"], ["2037-02-14", "설날 전날"], ["2037-02-15", "설날"], ["2037-02-16", "설날 다음날"], ["2037-02-17", "설날 대체 휴일"], ["2037-03-01", "삼일절"], ["2037-03-02", "삼일절 대체 휴일"], ["2037-05-01", "노동절"], ["2037-05-05", "어린이날"], ["2037-05-22", "부처님오신날"], ["2037-06-06", "현충일"], ["2037-07-17", "제헌절"], ["2037-08-15", "광복절"], ["2037-08-17", "광복절 대체 휴일"], ["2037-09-23", "추석 전날"], ["2037-09-24", "추석"], ["2037-09-25", "추석 다음날"], ["2037-10-03", "개천절"], ["2037-10-05", "개천절 대체 휴일"], ["2037-10-09", "한글날"], ["2037-12-25", "기독탄신일"]], + "2038": [["2038-01-01", "신정"], ["2038-02-03", "설날 전날"], ["2038-02-04", "설날"], ["2038-02-05", "설날 다음날"], ["2038-03-01", "삼일절"], ["2038-05-01", "노동절"], ["2038-05-03", "노동절 대체 휴일"], ["2038-05-05", "어린이날"], ["2038-05-11", "부처님오신날"], ["2038-06-06", "현충일"], ["2038-07-17", "제헌절"], ["2038-07-19", "제헌절 대체 휴일"], ["2038-08-15", "광복절"], ["2038-08-16", "광복절 대체 휴일"], ["2038-09-12", "추석 전날"], ["2038-09-13", "추석"], ["2038-09-14", "추석 다음날"], ["2038-09-15", "추석 대체 휴일"], ["2038-10-03", "개천절"], ["2038-10-04", "개천절 대체 휴일"], ["2038-10-09", "한글날"], ["2038-10-11", "한글날 대체 휴일"], ["2038-12-25", "기독탄신일"], ["2038-12-27", "기독탄신일 대체 휴일"]], + "2039": [["2039-01-01", "신정"], ["2039-01-23", "설날 전날"], ["2039-01-24", "설날"], ["2039-01-25", "설날 다음날"], ["2039-01-26", "설날 대체 휴일"], ["2039-03-01", "삼일절"], ["2039-04-30", "부처님오신날"], ["2039-05-01", "노동절"], ["2039-05-02", "부처님오신날 대체 휴일"], ["2039-05-03", "노동절 대체 휴일"], ["2039-05-05", "어린이날"], ["2039-06-06", "현충일"], ["2039-07-17", "제헌절"], ["2039-07-18", "제헌절 대체 휴일"], ["2039-08-15", "광복절"], ["2039-10-01", "추석 전날"], ["2039-10-02", "추석"], ["2039-10-03", "개천절; 추석 다음날"], ["2039-10-04", "추석 대체 휴일"], ["2039-10-05", "추석 대체 휴일"], ["2039-10-09", "한글날"], ["2039-10-10", "한글날 대체 휴일"], ["2039-12-25", "기독탄신일"], ["2039-12-26", "기독탄신일 대체 휴일"]], + "2040": [["2040-01-01", "신정"], ["2040-02-11", "설날 전날"], ["2040-02-12", "설날"], ["2040-02-13", "설날 다음날"], ["2040-02-14", "설날 대체 휴일"], ["2040-03-01", "삼일절"], ["2040-05-01", "노동절"], ["2040-05-05", "어린이날"], ["2040-05-07", "어린이날 대체 휴일"], ["2040-05-18", "부처님오신날"], ["2040-06-06", "현충일"], ["2040-07-17", "제헌절"], ["2040-08-15", "광복절"], ["2040-09-20", "추석 전날"], ["2040-09-21", "추석"], ["2040-09-22", "추석 다음날"], ["2040-10-03", "개천절"], ["2040-10-09", "한글날"], ["2040-12-25", "기독탄신일"]] +} diff --git a/B09_Estimation/draft/B09_Draft_Schedule_Source.py b/B09_Estimation/draft/B09_Draft_Schedule_Source.py new file mode 100644 index 000000000..3f9e7972c --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_Source.py @@ -0,0 +1,136 @@ +"""B09 공기 초안 서버 원천(PLAN 252-2 · 사용자 161번 — draft 폴더째 지우면 사라짐). + +본 코드 훅 = `B09_Estimation_Router.payload` · `B09_Estimation_Store.clean_user` 가 이 모듈을 + 가져오지 못하면 건너뜀(공기 값 · 탭 없이 그대로). + inputs["schedule"] = 원천(줄마다 단위당 자원 · 공휴일 표 · 기후 기본값 · 착공 기본값) + sheets["schedule"] = 서버 정본 셈(`…_Calc.compute_schedule` · 화면은 TS 짝으로 즉시) +단위당 자원 = 설계내역서 항목 줄의 호표 1 단위 역전개(`B09_Estimation_Summary.expand`) — + 주 장비 = 단위당 시간이 가장 긴 장비 · 인력 = 단위당 인 합(직종 무관) +공휴일 = `B09_Draft_Schedule_Holidays.json`(2024~2040 · 법정 · 대체 · 확정 선거일 — 공개 `holidays` + 목록 0.106 에서 뽑음 · 2027 이후 선거일은 날짜가 안 정해져 뺌 · 달마다 사용자 덮개) +기후 기본값 = 참고 설계서(25 거창) 월별 강수일(일강수량 5mm 이상) · 혹서일(33℃ 이상) — + 지역마다 사용자 고침 +사용자 값 = user["schedule"] — 다듬기 `clean_schedule`(모르는 칸 버림 · 수 칸은 수) +""" + +from __future__ import annotations + +import datetime +import json +from pathlib import Path +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import num +from B09_Estimation.B09_Estimation_Summary import expand, hopyo_lines +from B09_Estimation.draft.B09_Draft_Schedule_Calc import compute_schedule + +HOLIDAYS = Path(__file__).with_name("B09_Draft_Schedule_Holidays.json") +CLIMATE = { + "label": "경남 거창(참고 설계서 · 강수 = 일강수량 5mm 이상 · 혹서 = 33℃ 이상)", + "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_MONTH = 3 +NUMBERS = ("hours", "prep_docs", "prep_setup", "etc", "tidy") + + +def unit_resources(sheets: dict[str, Any]) -> dict[str, dict[str, Any]]: + """설계내역서 항목 줄 → {줄 키: {machine: {key, name, hr} | None, labor: 인 | None}}(1 단위).""" + lines_of = hopyo_lines(sheets) + machines = {row["key"]: row for row in sheets.get("machine") or []} + out: dict[str, dict[str, Any]] = {} + for row in (sheets.get("bill") or {}).get("rows") or []: + key = str((row.get("ref") or {}).get("key") or "") + if row.get("kind") != "item" or key not in lines_of: + continue + used: dict[str, dict[str, float]] = {} + expand(lines_of, key, 1.0, used) + machine = None + hours = used.get("machine") or {} + if hours: + mkey, hr = max(hours.items(), key=lambda kv: kv[1]) + if hr > 0: + src = machines.get(mkey) or {} + name = " ".join(t for t in (src.get("name"), src.get("spec")) if t) or mkey + machine = {"key": mkey, "name": name, "hr": hr} + labor = sum((used.get("labor") or {}).values()) + out[row["key"]] = {"machine": machine, "labor": labor if labor > 0 else None} + return out + + +def holiday_table() -> dict[str, list[list[str]]]: + try: + return json.loads(HOLIDAYS.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def schedule_inputs(sheets: dict[str, Any], today: datetime.date | None = None) -> dict[str, Any]: + today = today or datetime.date.today() + return { + "units": unit_resources(sheets), + "holidays": holiday_table(), + "climate": CLIMATE, + "start": {"year": today.year + 1, "month": START_MONTH}, + } + + +def attach(inputs: dict[str, Any], sheets: dict[str, Any], user: dict[str, Any]) -> None: + """payload 훅 — 원천 · 서버 정본 셈을 끼움.""" + inputs["schedule"] = schedule_inputs(sheets) + bill = (sheets.get("bill") or {}).get("rows") or [] + sheets["schedule"] = compute_schedule(inputs["schedule"], bill, user.get("schedule") or {}) + + +def _positive(value: Any) -> float | None: + v = num(value) + return v if v is not None and v > 0 else None + + +def _twelve(values: Any) -> list[float | None] | None: + if not isinstance(values, list) or len(values) != 12: + return None + return [v if (v := num(x)) is not None and v >= 0 else None for x in values] + + +def clean_schedule(raw: Any) -> dict[str, Any]: + """user["schedule"] 다듬기 — 모르는 칸 버림 · 수 칸은 0 이상 수 · 빈 것 = 칸 없음.""" + if not isinstance(raw, dict): + return {} + out: dict[str, Any] = {} + for field in NUMBERS: + v = num(raw.get(field)) + if v is not None and 0 <= v <= 1000: + out[field] = v + start = raw.get("start") if isinstance(raw.get("start"), dict) else {} + year, month = num(start.get("year")), num(start.get("month")) + if year and month and 2000 <= year <= 2100 and 1 <= month <= 12: + out["start"] = {"year": int(year), "month": int(month)} + climate = raw.get("climate") if isinstance(raw.get("climate"), dict) else {} + kept = {k: v for k in ("rain", "heat") if (v := _twelve(climate.get(k)))} + if kept: + out["climate"] = kept + holiday = raw.get("holiday") if isinstance(raw.get("holiday"), dict) else {} + marks = { + str(k): v + for k, x in holiday.items() + if len(str(k)) == 7 and (v := num(x)) is not None and 0 <= v <= 31 + } + if marks: + out["holiday"] = marks + rows = {} + for key, cell in (raw.get("rows") or {}).items() if isinstance(raw.get("rows"), dict) else []: + if not isinstance(cell, dict) or not str(key) or len(str(key)) > 200: + continue + one = {} + rate = num(cell.get("rate")) + if rate is not None and 0 <= rate <= 100: + one["rate"] = rate + for field in ("units", "crew", "daily"): + if (v := _positive(cell.get(field))) is not None: + one[field] = v + if one: + rows[str(key)] = one + if rows: + out["rows"] = rows + return out diff --git a/B09_Estimation/draft/B09_Draft_Schedule_UI.ts b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts new file mode 100644 index 000000000..ad39247d6 --- /dev/null +++ b/B09_Estimation/draft/B09_Draft_Schedule_UI.ts @@ -0,0 +1,306 @@ +/* ============================================================================= + * B09_Draft_Schedule_UI.ts + * B09 위 탭 「공기」(PLAN 252-3 · 사용자 161번 · 초안) — 아래 탭 공기집계 · 공기산출. + * 등록 = 이 파일을 `…_UI_Shell` 이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 TABS · TAB_GROUPS · + * DRAFT(탭 짜기 · 셈 곁)에 넣음 — draft 폴더째 지우면 탭 · 셈이 함께 사라짐 + * 셈 = `B09_Draft_Schedule_Calc`(TS 짝 · 조작 즉시) · 원천 = 서버 `inputs.schedule` · 값 = `user.schedule` + * 고칠 칸(더블클릭) — 공기산출: 준비 · 일 작업시간 · 투입 · 일작업량 · 반영률 · 기타 · 정리 / + * 공기집계: 착공 연월 · 강수 · 혹서(달마다 · 해 무관) · 법정공휴일(그 해 그 달 덮개 · 빈칸 = 자동) + * ========================================================================== */ + +import type { DataColumn, DataRow } from "@ui/ui_template_data_table"; +import { num, type Row } from "../B09_Estimation_Calc_Base"; +import { + DRAFT, + parseNumber, + qty, + TAB_GROUPS, + TABS, + text, + type TabContext, + type TabId, + type TabTable, +} from "../B09_Estimation_UI_Tabs"; +import { computeSchedule } from "./B09_Draft_Schedule_Calc"; + +const SUM = "schedule_sum" as TabId; +const CALC = "schedule_calc" as TabId; + +/** 사용자 공기 값 고침 → 캐시 · 바로 다시 셈 */ +function change(ctx: TabContext, fn: (s: Row) => void): void { + const next: Row = structuredClone(ctx.user.schedule || {}); + fn(next); + ctx.setUser({ ...ctx.user, schedule: next }); +} + +/** 칸 값 — 빈칸 = 지움(기본값) · 수 아님 = 무시 */ +function setNum(box: Row, key: string, value: string, min = 0, max = 1000): void { + const v = parseNumber(value); + if (v === undefined) return; + if (v === null || v < min || v > max) delete box[key]; + else box[key] = v; +} + +const pct = (n: number): string => String(Math.round(n * 10) / 10); + +/* ── 공기산출 ─────────────────────────────────────────────────────────── */ + +const CALC_COLUMNS: DataColumn[] = [ + text("name", "구분 · 명칭", 220), + text("spec", "규격", 120), + qty("qty", "시공량", 2), + text("unit", "단위", 56), + text("basis", "기준", 64), + text("who", "주 자원", 150), + qty("per", "단위당 시간 · 인", 4), + qty("put", "투입 대 · 인", 1), + qty("q", "시간당 작업량 Q", 2), + qty("daily", "일작업량", 2), + qty("rate", "반영률 %", 1), + qty("days", "작업일수", 1), + text("note", "비고", 220), +]; +const FIXED: Record = { + prep_docs: "days", + prep_setup: "days", + hours: "put", + etc: "days", + tidy: "days", +}; + +function itemNote(r: Row): string { + if (r.rate === 0) return "다른 공종과 겹침(반영 0 %)"; + if (r.basis === "연결 없음") return "설계내역서 [단가연결] 하면 자동 · 또는 일작업량 입력"; + if (r.basis === "장비") return `Q = 1 ÷ ${r.per} 시간`; + if (r.basis === "인력") return `${r.put}인 ÷ 단위당 ${r.per}인`; + return "일작업량 = 입력"; +} + +export function buildScheduleCalc(ctx: TabContext): TabTable | string { + const sch: Row | undefined = ctx.sheets.schedule; + if (!sch) return "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)"; + const w: Row = sch.work; + const rows: DataRow[] = [ + { key: "h:prep", name: "1. 준비기간", lv: 1, head: true }, + { key: "prep_docs", name: "각종 서류 및 착수 준비", lv: 2, days: w.prep_docs }, + { key: "prep_setup", name: "공종별 준비기간", lv: 2, days: w.prep_setup, note: "자재운반 등" }, + { key: "h:work", name: "2. 공종별 작업일수", lv: 1, head: true }, + { + key: "hours", + name: "일 작업시간", + lv: 2, + put: w.hours, + unit: "시간", + note: "장비 일작업량 = 이 시간 × Q × 대수", + }, + ...(w.rows as Row[]).map((r) => + r.kind === "head" + ? { key: r.key, name: r.name, lv: r.lv + 1, head: true } + : { + ...r, + lv: r.lv + 1, + qty: r.qty, + per: r.per ?? "", + put: r.put ?? "", + q: r.q ?? "", + daily: r.daily ?? "", + days: r.days ?? "", + note: itemNote(r), + item: true, + }, + ), + { key: "etc", name: "기타(현장여건 감안)", lv: 2, days: w.etc }, + { + key: "subtotal", + name: "작업일수 소계", + lv: 2, + days: w.subtotal, + note: "ROUND(Σ 작업일수 + 기타, 0)", + head: true, + }, + { key: "tidy", name: "3. 정리기간", lv: 1, days: w.tidy, note: "1개월 범위 안", head: true }, + ]; + const missing = w.missing ? ` · 연결 없음 ${w.missing}줄` : ""; + return { + columns: CALC_COLUMNS, + rows, + total: { name: "총작업일수", days: w.total, note: "준비 + 소계 + 정리 → 공기집계로" }, + frozen: 1, + level: { key: "name", of: (i) => Number(rows[i].lv) || 1 }, + rowClass: (i) => (rows[i].head ? "is-title" : null), + // 사용자 덮개 칸(기본값과 다름) = 설계내역서 고친 칸 꼴 + cellClass: (i, key) => { + const k = String(rows[i].key); + const mine: Row = ctx.user.schedule || {}; + if (FIXED[k]) return FIXED[k] === key && num(mine[k]) !== null ? "is-edited" : null; + const own: Row = mine.rows?.[k] || {}; + const field = key === "put" ? (rows[i].basis === "장비" ? "units" : "crew") : key; + return rows[i].item && ["rate", "daily", "put"].includes(key) && num(own[field]) !== null + ? "is-edited" + : null; + }, + hint: `작업일수 = ROUNDUP(시공량 × 반영률 ÷ 일작업량, 1) · 일작업량 = 장비(일 작업시간 × Q × 대수) · 인력(인원 ÷ 단위당 인) · 입력${missing}`, + editable: (i, key) => { + const r = rows[i]; + if (FIXED[String(r.key)]) return FIXED[String(r.key)] === key; + if (!r.item) return false; + if (key === "put") return r.basis === "장비" || r.basis === "인력"; + return key === "daily" || key === "rate"; + }, + onEdit: (i, key, value) => { + const r = rows[i]; + const k = String(r.key); + change(ctx, (s) => { + if (FIXED[k]) + return setNum(s, k, value, k === "hours" ? 0.5 : 0, k === "hours" ? 24 : 1000); + s.rows ??= {}; + const box: Row = (s.rows[k] ??= {}); + if (key === "rate") setNum(box, "rate", value, 0, 100); + else if (key === "daily") setNum(box, "daily", value, 1e-9, 1e9); + else if (key === "put") + setNum(box, r.basis === "장비" ? "units" : "crew", value, 1e-9, 1e4); + if (!Object.keys(box).length) delete s.rows[k]; + }); + }, + }; +} + +/* ── 공기집계 ─────────────────────────────────────────────────────────── */ + +const LINES: Array<[string, string, string, string]> = [ + // [키(달 값 칸), 구분, 세부, 비고] + ["days", "달력일수", "", ""], + ["rain", "기후여건 A", "강수일", "일강수량 5mm 이상(달마다 · 해 무관)"], + ["heat", "기후여건 A", "혹서일", "33℃ 이상"], + ["a", "기후여건 A", "소계", "ROUNDUP(강수 + 혹서, 1)"], + ["b", "법정공휴일 B", "", "일요일 + 공휴일 · 대체 · 선거일(풍선 = 이름 · 고치면 그 달 덮개)"], + ["c", "중복일수 C", "", "A × B ÷ 달력일수"], + ["off", "비작업일수", "", "ROUNDDOWN(A + B − C, 0)"], + ["avail", "작업가능일수", "", "달력일수 − 비작업일수"], + ["prep", "공사기간", "준비기간", "작업가능일을 준비 → 작업 → 정리 차례로 채움"], + ["work", "공사기간", "작업일수", ""], + ["tidy", "공사기간", "정리기간", ""], + ["used", "공사기간", "작업일수 계", ""], + ["off_used", "공사기간", "비작업일수", "다 쓴 달 = 전부 · 마지막 달 = 쓴 비율 올림"], + ["span", "공사기간", "공사일수", "작업일수 계 + 비작업일수"], +]; + +export function buildScheduleSum(ctx: TabContext): TabTable | string { + const sch: Row | undefined = ctx.sheets.schedule; + if (!sch) return "공기 자료 없음 — 서버가 공기 원천을 아직 주지 않음([업데이트] 뒤 다시)"; + const cal: Row = sch.calendar; + const months: Row[] = cal.months; + const src: Row = ctx.payload.inputs?.schedule || {}; + const mine: Row = ctx.user.schedule || {}; + const start = `${cal.start.year}.${String(cal.start.month).padStart(2, "0")}`; + const columns: DataColumn[] = [ + text("label", "구분", 96), + text("sub", "세부", 96), + ...months.map((m, i) => ({ + ...qty(`m${i}`, `${m.month}월`, 2), + group: [`${m.year}년`], + width: 58, + })), + qty("sum", "계", 2), + text("note", "비고", 340), + ]; + const rows: DataRow[] = [ + { + key: "start", + label: "착공", + sub: start, + note: `준공 예정 ${cal.end} · 공사기간 ${cal.days}일(≒ ${pct(cal.months_n)}개월)`, + }, + ...LINES.map(([key, label, sub, note]) => { + const row: DataRow = { key, label, sub, note }; + let sum = 0; + months.forEach((m, i) => { + row[`m${i}`] = m[key]; + sum += Number(m[key]) || 0; + }); + row.sum = sum; + if (key === "rain" && src.climate?.label) + row.note = `${note} · 기본값 = ${src.climate.label}`; + return row; + }), + ]; + const monthOf = (key: string): number => (key.startsWith("m") ? Number(key.slice(1)) : -1); + const ym = (m: Row): string => `${m.year}-${String(m.month).padStart(2, "0")}`; + return { + columns, + rows, + frozen: 2, + mergeSame: ["label"], + rowClass: (i) => (rows[i].key === "span" || rows[i].key === "start" ? "is-title" : null), + hint: "공사기간 = 준비기간 + 작업일수 + 비작업일수 + 정리기간 · 착공 = 그 달 1일 · 강수 · 혹서 · 공휴일 칸은 더블클릭으로 고침(빈칸 = 기본값)", + tip: (i, key) => { + const m = months[monthOf(key)]; + if (!m || rows[i].key !== "b") return null; + const names = (m.names as string[]).join(" · "); + const own = num(mine.holiday?.[ym(m)]); + return [ + names || "공휴일 없음(일요일만)", + own !== null ? `자동 ${m.b_auto} → 덮개 ${own}` : "", + ] + .filter(Boolean) + .join(" · "); + }, + cellClass: (i, key) => { + const m = months[monthOf(key)]; + if (!m) return null; + const k = String(rows[i].key); + const own = + k === "b" + ? num(mine.holiday?.[ym(m)]) + : k === "rain" || k === "heat" + ? num(mine.climate?.[k]?.[m.month - 1]) + : null; + return own !== null ? "is-edited" : null; // 사용자 덮개(기본값과 다름) + }, + editable: (i, key) => { + const k = String(rows[i].key); + if (k === "start") return key === "sub"; + return (k === "rain" || k === "heat" || k === "b") && monthOf(key) >= 0; + }, + onEdit: (i, key, value) => { + const k = String(rows[i].key); + change(ctx, (s) => { + if (k === "start") { + const hit = /(\d{4})\D+(\d{1,2})/.exec(value); + if (!value.trim()) delete s.start; + else if (hit && Number(hit[2]) >= 1 && Number(hit[2]) <= 12) + s.start = { year: Number(hit[1]), month: Number(hit[2]) }; + return; + } + const m = months[monthOf(key)]; + if (k === "b") { + s.holiday ??= {}; + setNum(s.holiday, ym(m), value, 0, 31); + if (!Object.keys(s.holiday).length) delete s.holiday; + return; + } + s.climate ??= {}; + const list: Array = s.climate[k] ?? Array(12).fill(null); + const v = parseNumber(value); + if (v === undefined) return; + list[m.month - 1] = v === null || v < 0 || v > 31 ? null : v; + if (list.some((x) => x !== null)) s.climate[k] = list; + else delete s.climate[k]; + if (!Object.keys(s.climate).length) delete s.climate; + }); + }, + }; +} + +/* ── 등록 ─────────────────────────────────────────────────────────────── */ + +if (!TABS.some(([id]) => id === SUM)) { + TABS.push([SUM, "공기집계"], [CALC, "공기산출"]); + TAB_GROUPS.push(["공기", [SUM, CALC]]); + DRAFT.builders[SUM] = buildScheduleSum; + DRAFT.builders[CALC] = buildScheduleCalc; + DRAFT.extend.push((payload, user, sheets) => { + const src = payload.inputs?.schedule; + if (src) sheets.schedule = computeSchedule(src, sheets.bill?.rows || [], user.schedule || {}); + }); +} diff --git a/resources/tester/test_252_1_b09_schedule.py b/resources/tester/test_252_1_b09_schedule.py new file mode 100644 index 000000000..84b8b3f69 --- /dev/null +++ b/resources/tester/test_252_1_b09_schedule.py @@ -0,0 +1,212 @@ +"""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