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:
@@ -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 || {});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user