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