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
+7
View File
@@ -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,
+9 -1
View File
@@ -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]:
+8 -2
View File
@@ -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);
+7
View File
@@ -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<string, TabBuild>;
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,
@@ -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 || {});
});
}
+212
View File
@@ -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