feat(b09): 공기 초안 — 메인 탭 공기 · 공기집계 · 공기산출 (draft 폴더 · 252)

설계내역서 항목 줄 역전개(단위당 장비 시간 · 인) → 작업일수 · 착공 연월부터 달마다 작업가능일 채움 · 공휴일 표 2024~2040 · 본 코드 훅은 draft 없으면 건너뜀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
2026-10-11 01:51:32 +09:00
co-authored by Claude Opus 5.5
parent f598a9a530
commit f87831533c
10 changed files with 1193 additions and 3 deletions
@@ -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)}
@@ -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<string, string>(
((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]),
};
}
@@ -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", "기독탄신일"]]
}
@@ -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
@@ -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<string, string> = {
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<number | null> = 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 || {});
});
}