feat(B09): 원가 바탕 단가 — 뼈대 · 노무 · 경비 · 환율 · 자재 대비 · 중기 탭(144-1~4)

- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 연결 점검) · 탭 하나 = 공용 표 하나 · 호표 칸 누름 = 그 탭 그 줄로 이동
- 저장 = 프로젝트 B09_Estimation/v1(사용자 값 · 굳힌 단가 한 벌) · 결과는 GET 때 서버 셈 · [저장] · [단가 갱신] 길
- 줄 = 수요 따라(연결된 사본 원천 자리 · 기계 → 운전사 · 연료 · B08 총괄 자재) · 연결 없으면 빈칸 + 연결 점검 목록
- 셈 = 파이썬 · TS 짝 + 거울(운전사 시간 노임 · 자재 최저 자동 + 사용자 고침 · 관급 · 별도 · 중기 손료 + 운전사 + 연료 + 잡품 · 엑셀 끝수)
- 공용 표 = 칸 누름 · 줄로 이동(강조) 더함
- 시험 = test_144_b09_skeleton · test_144_b09_base_calc(엑셀 중기 126,000 · 76,436 · 96,829 · 40,659 · 운전사 55,700 · 47,231 · 33,571)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X
This commit is contained in:
2026-10-09 15:01:27 +09:00
co-authored by Claude Opus 5.5
parent c0cf0ab90a
commit e1d83398d4
15 changed files with 2075 additions and 11 deletions
+2
View File
@@ -178,6 +178,8 @@ export const STATE_REGISTRY = {
"b08-hydro": { bucket: "draft", scope: "project", version: 3 },
/** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */
"b08-options": { bucket: "draft", scope: "project" },
/** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */
"b09-estimation": { bucket: "draft", scope: "project" },
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
* 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */
"alignment-draft": {
+319
View File
@@ -0,0 +1,319 @@
"""B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · 파일 · M01 안 읽음.
⚠ 짝 = `B09_Estimation_Calc_Base.ts`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
거울 시험 `resources/tester/test_144_b09_base_calc.py`.
조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈.
실무 엑셀(기번3 공통 xlsx) 탭 꼴 · 끝수 = 엑셀 자리 그대로:
노무비목록표 · 경비목록표 = 굳힌 단가 그대로(사용자 고침 없음 · [단가 갱신] 때만 새로)
환율및기초자료 = 환율 · 운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0)
(분자 · 분모 = M01 CC000336 1/8 × 16/12 × 25/20 — 서버가 넣어 줌)
자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌) 자동 · 사용자 적용 단가가 있으면 그 값
· 관급 · 별도계상 = 사용자 표시 → 재료비목록표 단가 = 적용 단가
중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임)
+ 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100)
줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표
빈칸 규칙 = 값을 낸 원천이 없으면 None(0 을 채우지 않음) · 그 칸을 쓰는 합도 None ·
「연결 없음」 목록(`gaps`)에 탭 · 줄 키 · 까닭을 남김(목차 탭이 이동 링크로 씀).
"""
from __future__ import annotations
import math
from typing import Any
#: 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시
EPS = 1e-7
#: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료 차례(같은 값이면 앞 칸) ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷
SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가")
#: 자재 줄 사용자 표시
SUPPLY = ("관급", "별도")
def _scaled(value: float, places: int, fn) -> float:
a = abs(value)
if places >= 0:
scale = 10**places
out = fn(a * scale) / scale
else:
unit = 10 ** (-places)
out = fn(a / unit) * unit
return -out if value < 0 else out
def rounddown(value: float, places: int = 0) -> float:
"""엑셀 ROUNDDOWN — 0 쪽으로 버림."""
return _scaled(value, places, lambda x: math.floor(x + EPS))
def roundup(value: float, places: int = 0) -> float:
"""엑셀 ROUNDUP — 0 에서 먼 쪽으로 올림."""
return _scaled(value, places, lambda x: math.ceil(x - EPS))
def round_half(value: float, places: int = 0) -> float:
"""엑셀 ROUND — 반 올림(0 에서 먼 쪽)."""
return _scaled(value, places, lambda x: math.floor(x + 0.5 + EPS))
def num(value: Any) -> float | None:
"""수 · 빈칸 — 수가 아니면 None."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value) if math.isfinite(value) else None
def total(values: list[float | None]) -> float | None:
"""합 — 하나라도 빈칸이면 빈칸(연결 안 된 값은 비움)."""
out = 0.0
for v in values:
if v is None:
return None
out += v
return out
def gap(tab: str, key: str, name: str, why: str) -> dict[str, str]:
return {"tab": tab, "key": key, "name": name, "why": why}
def driver_hour(day: float | None, factor: dict[str, Any] | None) -> float | None:
"""운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0)."""
if day is None or not factor or not factor.get("den"):
return None
return rounddown(day * factor["num"] / factor["den"], 0)
def labor_sheet(labor: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]:
rows = []
for no, row in enumerate(labor, 1):
price = num(row.get("price"))
if price is None:
gaps.append(gap("labor", row["key"], row["name"], "노임 단가 없음"))
rows.append(
{
**_head(row, no),
"code": row.get("code"),
"unit": row.get("unit") or "인",
"price": price,
}
)
return rows
def _head(row: dict[str, Any], no: int) -> dict[str, Any]:
return {
"no": no,
"key": row["key"],
"name": row.get("name") or "",
"spec": row.get("spec") or "",
"source": row.get("source") or "",
}
def expense_sheet(machines: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]:
"""경비목록표 — 기계 손료 기준가(천원)."""
rows = []
for no, m in enumerate(machines, 1):
price = num(m.get("price"))
if price is None:
gaps.append(gap("expense", m["key"], m["name"], "기계 기준가 없음"))
rows.append({**_head(m, no), "unit": "천원", "price": price})
return rows
def lowest(prices: dict[str, Any]) -> tuple[float | None, str | None]:
"""출처 단가 중 최저 — 0 · 빈칸 뺌 · 같으면 앞 칸."""
best: tuple[float | None, str | None] = (None, None)
for source in SOURCES:
v = num(prices.get(source))
if v is not None and v > 0 and (best[0] is None or v < best[0]):
best = (v, source)
return best
def compare_sheet(
materials: list[dict[str, Any]], user: dict[str, Any], gaps: list
) -> list[dict[str, Any]]:
"""자재단가대비표 — 출처 · 최저 · 적용(사용자 값이 앞섬) · 관급 · 별도 표시."""
rows = []
for no, m in enumerate(materials, 1):
mine = user.get(m["key"]) or {}
prices = m.get("prices") or {}
low, low_from = lowest(prices)
if "applied" in mine:
applied, applied_from = num(mine["applied"]), "사용자"
else:
applied, applied_from = low, low_from
if applied is None:
gaps.append(gap("compare", m["key"], m["name"], "적용 단가 없음(출처 없음)"))
supply = mine.get("supply") if mine.get("supply") in SUPPLY else None
rows.append(
{
**_head(m, no),
"unit": m.get("unit") or "",
"origin": m.get("origin") or "",
"prices": {s: num(prices.get(s)) for s in SOURCES},
"pages": {s: (m.get("pages") or {}).get(s) for s in SOURCES},
"lowest": low,
"lowest_from": low_from,
"applied": applied,
"applied_from": applied_from if applied is not None else None,
"supply": supply,
}
)
return rows
def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""재료비목록표 — 단가 = 대비표 적용 단가."""
keep = ("no", "key", "name", "spec", "unit", "supply", "origin")
return [{**{k: row[k] for k in keep}, "price": row["applied"]} for row in compare]
def _line(name, spec, qty, unit, kind, price, ref, pct=False) -> dict[str, Any]:
if price is None or qty is None:
amount = None
elif pct:
amount = rounddown(price * qty / 100, 1)
else:
amount = rounddown(price * qty, 1)
return {
"name": name,
"spec": spec,
"qty": qty,
"unit": unit,
"kind": kind,
"price": price,
"amount": amount,
"ref": ref,
}
def machine_block(
m: dict[str, Any],
drivers: dict[str, dict[str, Any]],
fuels: dict[str, dict[str, Any]],
gaps: list,
) -> dict[str, Any]:
"""중기사용료 한 블록 — 손료 · 운전사 · 연료 · 잡품 줄 + 비목 합."""
lines = []
loss = num(m.get("loss"))
coef = loss / 10000 if loss is not None else None
price = num(m.get("price"))
lines.append(_line(m["name"], m.get("spec") or "", coef, "천원", "expense", price,
{"tab": "expense", "key": m["key"]})) # fmt: skip
crew = num(m.get("crew"))
if crew:
code = str(m.get("crew_code") or "")
driver = drivers.get(code) or {}
hour = driver.get("hour")
if hour is None:
gaps.append(gap("machine_blocks", m["key"], m["name"], "운전사 노임 없음"))
lines.append(_line(driver.get("name") or "운전사", "", crew, "인", "labor", hour,
{"tab": "basis", "key": code})) # fmt: skip
lph = num(m.get("fuel_lph"))
if lph:
kind = str(m.get("fuel") or "")
fuel = fuels.get(kind) or {}
fprice = fuel.get("applied")
if fprice is None:
gaps.append(gap("machine_blocks", m["key"], m["name"], f"연료({kind}) 단가 없음"))
fuel_line = _line(fuel.get("name") or kind, fuel.get("spec") or "", lph, "ℓ", "material",
fprice, {"tab": "compare", "key": fuel.get("key") or ""}) # fmt: skip
lines.append(fuel_line)
misc = num(m.get("misc_pct"))
if misc:
lines.append(_line("잡품", "주연료의 %", misc, "%", "material", fuel_line["amount"],
None, pct=True)) # fmt: skip
if price is None or coef is None:
gaps.append(gap("machine_blocks", m["key"], m["name"], "손료 기준가 · 계수 없음"))
sums: dict[str, float | None] = {}
for part in ("labor", "material", "expense"):
s = total([ln["amount"] for ln in lines if ln["kind"] == part])
sums[part] = None if s is None else rounddown(s, 0)
return {
"key": m["key"],
"name": m["name"],
"spec": m.get("spec") or "",
"unit": "시간",
"lines": lines,
"labor": sums["labor"],
"material": sums["material"],
"expense": sums["expense"],
"sum": total([sums["labor"], sums["material"], sums["expense"]]),
}
def basis_sheet(
inputs: dict[str, Any], labor_rows: list[dict[str, Any]]
) -> dict[str, list[dict[str, Any]]]:
"""환율및기초자료 — 환율 · 운전사 시간 노임(기계가 부르는 직종만)."""
factor = inputs.get("driver_factor")
by_code = {str(row.get("code")): row for row in labor_rows if row.get("code") is not None}
codes: list[str] = []
for m in inputs.get("machines") or []:
code = str(m.get("crew_code")) if m.get("crew_code") else None
if code and num(m.get("crew")) and code not in codes:
codes.append(code)
drivers = []
for code in codes:
row = by_code.get(code) or {}
day = row.get("price")
drivers.append(
{
"key": code,
"labor_key": row.get("key"),
"name": row.get("name") or f"직종 {code}",
"day": day,
"factor": (factor or {}).get("text") or "",
"hour": driver_hour(day, factor),
}
)
fx = [
{"key": f["key"], "name": f.get("name") or "", "unit": f.get("unit") or "",
"value": num(f.get("value")), "date": f.get("date") or ""}
for f in inputs.get("fx") or []
] # fmt: skip
return {"fx": fx, "drivers": drivers}
def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
"""144 탭 일곱 + 「연결 없음」 목록."""
gaps: list[dict[str, str]] = []
labor_rows = labor_sheet(inputs.get("labor") or [], gaps)
expense = expense_sheet(inputs.get("machines") or [], gaps)
compare = compare_sheet(inputs.get("materials") or [], user.get("materials") or {}, gaps)
basis = basis_sheet(inputs, labor_rows)
drivers = {d["key"]: d for d in basis["drivers"]}
by_key = {row["key"]: row for row in compare}
fuels = {
kind: by_key[key] for kind, key in (inputs.get("fuel_keys") or {}).items() if key in by_key
}
blocks = [machine_block(m, drivers, fuels, gaps) for m in inputs.get("machines") or []]
machine = [
{
"no": no,
"key": b["key"],
"name": b["name"],
"spec": b["spec"],
"unit": b["unit"],
"sum": b["sum"],
"labor": b["labor"],
"material": b["material"],
"expense": b["expense"],
}
for no, b in enumerate(blocks, 1)
]
return {
"labor": labor_rows,
"expense": expense,
"basis": basis,
"compare": compare,
"material": material_sheet(compare),
"machine_blocks": blocks,
"machine": machine,
"gaps": gaps,
}
+289
View File
@@ -0,0 +1,289 @@
/* =============================================================================
* B09_Estimation_Calc_Base.ts
* B09 원가 바탕 단가 셈(PLAN 144) — 화면 없음 · 저장 안 함 · import 없음(시험이 node 로 돌림).
*
* ⚠ 짝 = `B09_Estimation_Calc_Base.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
* 거울 시험 `resources/tester/test_144_b09_base_calc.py`.
* 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈.
*
* 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0)
* 자재단가대비표 = 출처 중 최저 자동 · 사용자 적용 단가 앞섬 · 관급 · 별도 표시 → 재료비목록표
* 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표
* 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김
* ========================================================================== */
export type Num = number | null;
export type Row = Record<string, any>;
export interface Gap {
tab: string;
key: string;
name: string;
why: string;
}
/** 떠도는 끝수(부동소수) — 이만큼은 자리 올림 · 버림에서 무시 */
const EPS = 1e-7;
/** 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸) */
export const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가"] as const;
/** 자재 줄 사용자 표시 */
export const SUPPLY = ["관급", "별도"] as const;
function scaled(value: number, places: number, fn: (x: number) => number): number {
const a = Math.abs(value);
let out: number;
if (places >= 0) {
const scale = 10 ** places;
out = fn(a * scale) / scale;
} else {
const unit = 10 ** -places;
out = fn(a / unit) * unit;
}
return value < 0 ? -out : out;
}
/** 엑셀 ROUNDDOWN */
export const rounddown = (value: number, places = 0): number =>
scaled(value, places, (x) => Math.floor(x + EPS));
/** 엑셀 ROUNDUP */
export const roundup = (value: number, places = 0): number =>
scaled(value, places, (x) => Math.ceil(x - EPS));
/** 엑셀 ROUND(반 올림) */
export const roundHalf = (value: number, places = 0): number =>
scaled(value, places, (x) => Math.floor(x + 0.5 + EPS));
export function num(value: unknown): Num {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
/** 합 — 하나라도 빈칸이면 빈칸 */
export function total(values: Num[]): Num {
let out = 0;
for (const v of values) {
if (v === null) return null;
out += v;
}
return out;
}
export const gap = (tab: string, key: string, name: string, why: string): Gap => ({
tab,
key,
name,
why,
});
export function driverHour(day: Num, factor: Row | null | undefined): Num {
if (day === null || !factor || !factor.den) return null;
return rounddown((day * factor.num) / factor.den, 0);
}
const head = (row: Row, no: number): Row => ({
no,
key: row.key,
name: row.name || "",
spec: row.spec || "",
source: row.source || "",
});
function laborSheet(labor: Row[], gaps: Gap[]): Row[] {
return labor.map((row, i) => {
const price = num(row.price);
if (price === null) gaps.push(gap("labor", row.key, row.name, "노임 단가 없음"));
return { ...head(row, i + 1), code: row.code ?? null, unit: row.unit || "인", price };
});
}
function expenseSheet(machines: Row[], gaps: Gap[]): Row[] {
return machines.map((m, i) => {
const price = num(m.price);
if (price === null) gaps.push(gap("expense", m.key, m.name, "기계 기준가 없음"));
return { ...head(m, i + 1), unit: "천원", price };
});
}
export function lowest(prices: Row): [Num, string | null] {
let best: [Num, string | null] = [null, null];
for (const source of SOURCES) {
const v = num(prices[source]);
if (v !== null && v > 0 && (best[0] === null || v < best[0])) best = [v, source];
}
return best;
}
function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] {
return materials.map((m, i) => {
const mine: Row = user[m.key] || {};
const prices: Row = m.prices || {};
const [low, lowFrom] = lowest(prices);
let applied: Num;
let appliedFrom: string | null;
if ("applied" in mine) [applied, appliedFrom] = [num(mine.applied), "사용자"];
else [applied, appliedFrom] = [low, lowFrom];
if (applied === null) gaps.push(gap("compare", m.key, m.name, "적용 단가 없음(출처 없음)"));
const supply = (SUPPLY as readonly string[]).includes(mine.supply) ? mine.supply : null;
const pages: Row = m.pages || {};
return {
...head(m, i + 1),
unit: m.unit || "",
origin: m.origin || "",
prices: Object.fromEntries(SOURCES.map((s) => [s, num(prices[s])])),
pages: Object.fromEntries(SOURCES.map((s) => [s, pages[s] ?? null])),
lowest: low,
lowest_from: lowFrom,
applied,
applied_from: applied !== null ? appliedFrom : null,
supply,
};
});
}
function materialSheet(compare: Row[]): Row[] {
const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"];
return compare.map((row) => ({
...Object.fromEntries(keep.map((k) => [k, row[k]])),
price: row.applied,
}));
}
function line(
name: string,
spec: string,
qty: Num,
unit: string,
kind: string,
price: Num,
ref: Row | null,
pct = false,
): Row {
let amount: Num;
if (price === null || qty === null) amount = null;
else if (pct) amount = rounddown((price * qty) / 100, 1);
else amount = rounddown(price * qty, 1);
return { name, spec, qty, unit, kind, price, amount, ref };
}
function machineBlock(m: Row, drivers: Row, fuels: Row, gaps: Gap[]): Row {
const lines: Row[] = [];
const loss = num(m.loss);
const coef = loss !== null ? loss / 10000 : null;
const price = num(m.price);
lines.push(
line(m.name, m.spec || "", coef, "천원", "expense", price, { tab: "expense", key: m.key }),
);
const crew = num(m.crew);
if (crew) {
const code = String(m.crew_code || "");
const driver: Row = drivers[code] || {};
const hour: Num = driver.hour ?? null;
if (hour === null) gaps.push(gap("machine_blocks", m.key, m.name, "운전사 노임 없음"));
lines.push(
line(driver.name || "운전사", "", crew, "인", "labor", hour, { tab: "basis", key: code }),
);
}
const lph = num(m.fuel_lph);
if (lph) {
const kind = String(m.fuel || "");
const fuel: Row = fuels[kind] || {};
const fprice: Num = fuel.applied ?? null;
if (fprice === null) gaps.push(gap("machine_blocks", m.key, m.name, `연료(${kind}) 단가 없음`));
const fuelLine = line(fuel.name || kind, fuel.spec || "", lph, "ℓ", "material", fprice, {
tab: "compare",
key: fuel.key || "",
});
lines.push(fuelLine);
const misc = num(m.misc_pct);
if (misc)
lines.push(line("잡품", "주연료의 %", misc, "%", "material", fuelLine.amount, null, true));
}
if (price === null || coef === null)
gaps.push(gap("machine_blocks", m.key, m.name, "손료 기준가 · 계수 없음"));
const sums: Record<string, Num> = {};
for (const part of ["labor", "material", "expense"]) {
const s = total(lines.filter((ln) => ln.kind === part).map((ln) => ln.amount));
sums[part] = s === null ? null : rounddown(s, 0);
}
return {
key: m.key,
name: m.name,
spec: m.spec || "",
unit: "시간",
lines,
labor: sums.labor,
material: sums.material,
expense: sums.expense,
sum: total([sums.labor, sums.material, sums.expense]),
};
}
function basisSheet(inputs: Row, laborRows: Row[]): Row {
const factor = inputs.driver_factor;
const byCode: Row = {};
for (const row of laborRows)
if (row.code !== null && row.code !== undefined) byCode[String(row.code)] = row;
const codes: string[] = [];
for (const m of inputs.machines || []) {
const code = m.crew_code ? String(m.crew_code) : null;
if (code && num(m.crew) && !codes.includes(code)) codes.push(code);
}
const drivers = codes.map((code) => {
const row: Row = byCode[code] || {};
const day: Num = row.price ?? null;
return {
key: code,
labor_key: row.key ?? null,
name: row.name || `직종 ${code}`,
day,
factor: (factor || {}).text || "",
hour: driverHour(day, factor),
};
});
const fx = (inputs.fx || []).map((f: Row) => ({
key: f.key,
name: f.name || "",
unit: f.unit || "",
value: num(f.value),
date: f.date || "",
}));
return { fx, drivers };
}
/** 144 탭 일곱 + 「연결 없음」 목록 */
export function computeBase(inputs: Row, user: Row): Row {
const gaps: Gap[] = [];
const laborRows = laborSheet(inputs.labor || [], gaps);
const expense = expenseSheet(inputs.machines || [], gaps);
const compare = compareSheet(inputs.materials || [], user.materials || {}, gaps);
const basis = basisSheet(inputs, laborRows);
const drivers: Row = Object.fromEntries(basis.drivers.map((d: Row) => [d.key, d]));
const byKey: Row = Object.fromEntries(compare.map((row) => [row.key, row]));
const fuels: Row = {};
for (const [kind, key] of Object.entries(inputs.fuel_keys || {}))
if ((key as string) in byKey) fuels[kind] = byKey[key as string];
const blocks = (inputs.machines || []).map((m: Row) => machineBlock(m, drivers, fuels, gaps));
const machine = blocks.map((b: Row, i: number) => ({
no: i + 1,
key: b.key,
name: b.name,
spec: b.spec,
unit: b.unit,
sum: b.sum,
labor: b.labor,
material: b.material,
expense: b.expense,
}));
return {
labor: laborRows,
expense,
basis,
compare,
material: materialSheet(compare),
machine_blocks: blocks,
machine,
gaps,
};
}
+266
View File
@@ -0,0 +1,266 @@
"""B09 원가 단가 한 벌 · 수요 모음(PLAN 144) — 서버 단독(M01 을 읽는 곳은 여기 하나).
원가 단가 = 프로젝트 한 벌 — 원가산출에서 M01 단가를 한 번 복사해 굳힘(`price_book.json`) ·
[단가 갱신] 때만 새로 · 대가 · 산근도 이 단가로 셈(한 직종 · 한 자재 = 한 단가).
줄 = 수요 따라(사용자 승인 ① · 10-9) — 엑셀 목록을 기본으로 깔지 않음:
연결된 사본이 쓰는 자원 키(`linked_resource_keys` — 143-1 「연결된 사본」 원천 · 145-1 · 2 호표가
들어오면 그 결과를 돌려줌 · 그전엔 빈 목록 = 빈 표 「연결 없음」)
+ 기계가 부르는 운전사(조종원직종 → 인력 원문번호) · 연료(주연료종류 → 유가 「종류:전국평균」)
+ B08 총괄자재수량집계표 자재(M01 짝 없음 → 출처 빈칸 · 적용 단가는 사용자 입력)
운전사 시간 계수 = M01 CC000336(시간환산 · 상여계수 · 휴지계수 분자 · 분모 곱).
연료 지역 = 전국평균(M01 기계경비 로직 지역 첫 값) — 다른 지역 값은 자재 적용 단가를 고쳐 씀.
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
#: 운전사 시간 노임 계수 표(건설품셈 공통 8-1-3 · 건협기술-2230)
DRIVER_TABLE = "CC000336"
DRIVER_PARTS = ("시간환산", "상여계수", "휴지계수")
#: 연료 지역 — M01 기계경비 로직 지역 고르기 첫 값
FUEL_REGION = "전국평균"
#: 자재품목 값 열 → 대비표 출처 칸
MT_SOURCES = ("관급", "물가자료", "유통물가", "물가정보", "거래가격")
def linked_resource_keys(project_root: str | Path) -> list[str]:
"""연결된 사본(구조물 조합 사본)이 쓰는 자원 키(LB · EQ · MT · MO) — 자리만.
143-1 「연결된 사본」 원천 함수 · 145-1 · 2 산근 · 대가 호표가 들어오면 그 자원 키를 돌려줌 ·
그전엔 빈 목록(구조물 조합 사본 없으면 그 구조물은 없음 · 연결 안 된 값은 비움).
"""
return []
def _f(value: Any) -> float | None:
if isinstance(value, Decimal):
return float(value)
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value)
def _master():
return m01.loaded()[1]
def _row(master, key: str) -> dict[str, Any] | None:
return master.index.get(key[:2], {}).get(key)
def labor_input(row: dict[str, Any]) -> dict[str, Any]:
return {
"key": row["키"],
"code": str(row.get("원문번호") or ""),
"name": row.get("이름") or "",
"spec": row.get("규격") or "",
"unit": "인",
"price": _f(row.get("값")),
"source": row.get("출처") or "",
}
def machine_input(row: dict[str, Any]) -> dict[str, Any]:
v = row.get("값") or {}
return {
"key": row["키"],
"code": str(row.get("원문번호") or ""),
"name": row.get("이름") or "",
"spec": row.get("규격") or "",
"price": _f(v.get("가격")),
"loss": _f(v.get("손료계수")),
"fuel": v.get("주연료종류") or None,
"fuel_lph": _f(v.get("주연료")),
"misc_pct": _f(v.get("잡재료율")),
"crew": _f(v.get("조종원")),
"crew_code": str(v["조종원직종"]) if v.get("조종원직종") else None,
"source": (row.get("출처") or {}).get("가격") or row.get("원문") or "",
}
def material_input(row: dict[str, Any]) -> dict[str, Any]:
"""자재품목(MT) · 유가(MO) 줄 → 대비표 입력 줄."""
if row["키"].startswith("MO"):
prices = {"유가": _f(row.get("값"))}
pages = {"유가": row.get("규격") or None}
origin = "유가"
spec = f"{row.get('상세구분') or ''} {row.get('규격') or ''}".strip()
unit = (row.get("단위") or "").replace("원/", "")
else:
prices = {s: _f(row.get(s)) for s in MT_SOURCES}
pages = {s: (row.get("면수") or {}).get(s) for s in MT_SOURCES if s != "관급"}
origin = "M01"
spec = row.get("규격") or ""
unit = row.get("단위") or ""
return {
"key": row["키"],
"name": row.get("이름") or "",
"spec": spec,
"unit": unit,
"origin": origin,
"prices": prices,
"pages": pages,
"source": row.get("출처") or "",
}
def driver_factor(master) -> dict[str, Any] | None:
table = _row(master, DRIVER_TABLE)
if not table:
return None
parts = {r.get("구분"): r for r in table.get("줄") or []}
if any(p not in parts for p in DRIVER_PARTS):
return None
top = bottom = 1
text = []
for p in DRIVER_PARTS:
top *= int(parts[p]["분자"])
bottom *= int(parts[p]["분모"])
text.append(f"{parts[p]['분자']}/{parts[p]['분모']}")
return {"num": top, "den": bottom, "text": " × ".join(text)}
def fx_inputs(master) -> list[dict[str, Any]]:
return [
{"key": key, "name": row.get("이름") or "", "unit": row.get("단위") or "",
"value": _f(row.get("값")), "date": row.get("규격") or ""}
for key, row in sorted(master.index.get("FX", {}).items())
] # fmt: skip
def _fuel_row(master, kind: str) -> dict[str, Any] | None:
return master.numbers.get("MO", {}).get(f"{kind}:{FUEL_REGION}")
def demand(master, keys: list[str]) -> dict[str, Any]:
"""수요 키 → 줄 차례(노무 · 기계 · 자재) + 연료 짝 · 찾지 못한 키."""
labor: list[str] = []
machines: list[str] = []
materials: list[str] = []
missing: list[str] = []
fuel_keys: dict[str, str] = {}
def add(bucket: list[str], key: str) -> None:
if key not in bucket:
bucket.append(key)
for key in keys:
if _row(master, key) is None:
missing.append(key)
elif key.startswith("LB"):
add(labor, key)
elif key.startswith("EQ"):
add(machines, key)
elif key.startswith(("MT", "MO")):
add(materials, key)
for key in machines:
v = _row(master, key).get("값") or {}
code = v.get("조종원직종")
driver = master.numbers.get("LB", {}).get(str(code)) if code and v.get("조종원") else None
if driver:
add(labor, driver["키"])
kind = v.get("주연료종류")
fuel = _fuel_row(master, kind) if kind and v.get("주연료") else None
if fuel:
add(materials, fuel["키"])
fuel_keys[kind] = fuel["키"]
return {
"labor": labor,
"machines": machines,
"materials": materials,
"fuel_keys": fuel_keys,
"missing": missing,
}
def _convert(master, key: str) -> dict[str, Any]:
row = _row(master, key)
if key.startswith("LB"):
return labor_input(row)
if key.startswith("EQ"):
return machine_input(row)
return material_input(row)
def refresh_book(keys: list[str]) -> dict[str, Any]:
"""[단가 갱신] — 수요 키 전부를 지금 M01 값으로 새로 굳힘."""
master = _master()
want = demand(master, keys)
rows = {
key: _convert(master, key)
for key in [*want["labor"], *want["machines"], *want["materials"]]
}
return {
"made": datetime.now().isoformat(timespec="seconds"),
"rows": rows,
"driver_factor": driver_factor(master),
"fx": fx_inputs(master),
}
def extend_book(book: dict[str, Any] | None, keys: list[str]) -> dict[str, Any]:
"""[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘."""
if book is None:
return refresh_book(keys)
master = _master()
want = demand(master, keys)
rows = dict(book["rows"])
for key in [*want["labor"], *want["machines"], *want["materials"]]:
if key not in rows:
rows[key] = _convert(master, key)
return {**book, "rows": rows}
def b08_material_rows(b08_sheets: dict[str, Any] | None) -> list[dict[str, Any]]:
"""B08 총괄자재수량집계표 줄(수량 있는 줄만) → 자재 입력 줄(M01 짝 없음 · 출처 빈칸)."""
rows = ((b08_sheets or {}).get("material") or {}).get("rows") or []
out = []
for row in rows:
if row.get("amount") is None:
continue
out.append(
{
"key": f"b08:{row['key']}",
"name": row.get("group") or "",
"spec": row.get("spec") or "",
"unit": row.get("unit") or "",
"origin": "B08",
"prices": {},
"pages": {},
"source": "B08 총괄자재수량집계표",
"qty": row.get("amount"),
}
)
return out
def build_inputs(
book: dict[str, Any] | None, keys: list[str], b08_sheets: dict[str, Any] | None = None
) -> dict[str, Any]:
"""표 셈 입력 — 줄 = 수요 · 값 = 굳힌 책(없는 줄은 지금 M01 값 · `fresh` 표시)."""
master = _master()
want = demand(master, keys)
saved = (book or {}).get("rows") or {}
def pick(key: str) -> dict[str, Any]:
if key in saved:
return saved[key]
return {**_convert(master, key), "fresh": True}
factor = (book or {}).get("driver_factor") if book else driver_factor(master)
return {
"labor": [pick(k) for k in want["labor"]],
"machines": [pick(k) for k in want["machines"]],
"materials": [pick(k) for k in want["materials"]] + b08_material_rows(b08_sheets),
"fuel_keys": want["fuel_keys"],
"driver_factor": factor,
"fx": (book or {}).get("fx") if book else fx_inputs(master),
"missing": want["missing"],
}
+84
View File
@@ -0,0 +1,84 @@
"""B09 원가계산 길(PLAN 144 · 145) — 결과는 저장하지 않고 GET 때 서버가 셈(B08 꼴).
GET /api/projects/{id}/estimation 입력(굳힌 단가 · 수요 · B08 수량) + 사용자 값 → 표 전부
PUT /api/projects/{id}/estimation {user} [저장] — 사용자 값 · 책에 없는 새 단가 줄만 굳힘
→ 서버가 정본으로 다시 셈
POST /api/projects/{id}/estimation/prices [단가 갱신] — 수요 단가 전부를 지금 M01 값으로 새로 굳힘
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) · 단가 = `B09_Estimation_Prices` ·
셈 = `B09_Estimation_Calc`(화면 짝과 같은 식).
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Body
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
from B08_Quantity.B08_Quantity_Router_Material import material_sheets
from B09_Estimation import B09_Estimation_Prices as prices
from B09_Estimation import B09_Estimation_Store as store
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"])
async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]:
"""B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음)."""
out: dict[str, Any] = {}
try:
out["materials"] = await material_sheets(project_id)
except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림
notes.append(f"B08 자재 집계표 못 읽음 — {getattr(error, 'detail', error)}")
out["materials"] = None
return out
async def payload(project_id: UUID, root: Path) -> dict[str, Any]:
notes: list[str] = []
b08 = await _b08(project_id, notes)
keys = prices.linked_resource_keys(root)
if not keys:
notes.append(
"연결된 구조물 조합 사본 없음 — 노무 · 경비 · 중기 단가 줄은 사본이 연결되면 생김"
)
book = await asyncio.to_thread(store.read_book, root)
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"])
user = await asyncio.to_thread(store.read_user, root)
return {
"inputs": inputs,
"user": user,
"book": {"made": book.get("made")} if book else None,
"sheets": compute_base(inputs, user),
"notes": notes,
}
@router.get("/{project_id}/estimation")
async def estimation(project_id: UUID) -> dict[str, Any]:
root = await _project_root(project_id)
return await payload(project_id, root)
@router.put("/{project_id}/estimation")
async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
root = await _project_root(project_id)
await asyncio.to_thread(store.write_user, root, body.get("user"))
keys = prices.linked_resource_keys(root)
book = await asyncio.to_thread(store.read_book, root)
book = await asyncio.to_thread(prices.extend_book, book, keys)
await asyncio.to_thread(store.write_book, root, book)
return await payload(project_id, root)
@router.post("/{project_id}/estimation/prices")
async def refresh_prices(project_id: UUID) -> dict[str, Any]:
root = await _project_root(project_id)
keys = prices.linked_resource_keys(root)
book = await asyncio.to_thread(prices.refresh_book, keys)
await asyncio.to_thread(store.write_book, root, book)
return await payload(project_id, root)
+109
View File
@@ -0,0 +1,109 @@
"""B09 원가 저장 자리(PLAN 144-1) — 프로젝트 안 `B09_Estimation/v1/`.
price_book.json 굳힌 단가 한 벌 {"made": 시각, "rows": {키: 입력 줄}, "driver_factor": …}
— [단가 갱신] 때만 M01 로 새로 · [저장] 때 책에 없는 새 줄만 더함(있는 줄 안 바꿈)
user_values.json 사용자 값 {"materials": {자재 키: {applied?, supply?}}, "estimate": {…}}
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
결과(표)는 저장하지 않음 — GET 때 서버가 셈(B08 꼴).
"""
from __future__ import annotations
import json
import math
import os
from pathlib import Path
from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY
FOLDER = ("B09_Estimation", "v1")
BOOK = "price_book.json"
USER = "user_values.json"
#: 원가계산 사용자 입력 칸 — 요율 고르는 값 · 손입력(엑셀 총괄 · 원가계산서 · 내역 손입력 칸)
ESTIMATE_FIELDS = (
"estimated_price", # 추정가격(원) — 요율 구간 고름
"duration_days", # 공사기간(일)
"work_type", # 공종(글)
"safety_base", # 산업안전보건관리비 기초액(원)
"profit_adjust", # 이윤 보정(원 · 빼는 값)
"vat_adjust", # 부가가치세 조정(원)
"indirect_material", # 간접재료비(원)
"scrap", # 작업설 부산물(원 · 빼는 값)
"procurement_fee_pct", # 조달수수료율(%)
)
def folder(project_root: str | Path) -> Path:
return Path(project_root).joinpath(*FOLDER)
def _read(path: Path) -> dict[str, Any] | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def _write(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_suffix(".tmp")
temp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(temp, path)
def read_book(project_root: str | Path) -> dict[str, Any] | None:
book = _read(folder(project_root) / BOOK)
if book is None or not isinstance(book.get("rows"), dict):
return None
return book
def write_book(project_root: str | Path, book: dict[str, Any]) -> None:
_write(folder(project_root) / BOOK, book)
def _num(value: Any) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
return float(value) if math.isfinite(value) else None
def clean_user(raw: Any) -> dict[str, Any]:
"""사용자 값 다듬기 — 모르는 칸 버림 · 수 칸은 수 · null(빈칸)만."""
raw = raw if isinstance(raw, dict) else {}
materials: dict[str, dict[str, Any]] = {}
for key, cell in (raw.get("materials") or {}).items():
if not isinstance(cell, dict):
continue
out: dict[str, Any] = {}
if "applied" in cell:
out["applied"] = _num(cell["applied"])
if cell.get("supply") in SUPPLY:
out["supply"] = cell["supply"]
if out:
materials[str(key)] = out
estimate: dict[str, Any] = {}
given = raw.get("estimate") if isinstance(raw.get("estimate"), dict) else {}
for field in ESTIMATE_FIELDS:
if field not in given:
continue
value = given[field]
estimate[field] = (str(value) if value else None) if field == "work_type" else _num(value)
rates = given.get("rates")
if isinstance(rates, dict):
estimate["rates"] = {str(k): _num(v) for k, v in rates.items()}
return {"materials": materials, "estimate": estimate}
def read_user(project_root: str | Path) -> dict[str, Any]:
return clean_user(_read(folder(project_root) / USER))
def write_user(project_root: str | Path, user: dict[str, Any]) -> dict[str, Any]:
clean = clean_user(user)
_write(folder(project_root) / USER, clean)
return clean
+32
View File
@@ -0,0 +1,32 @@
/* B09 원가계산 — 탭 줄 · 안내 글. 색은 테마 변수만(밝음 · 어두움 같이). */
.b09-estimation {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-16) var(--spacing-24); /* B08 본문과 같은 값 */
}
.b09-estimation .ui-general-block__empty,
.b09-estimation__hint {
color: var(--color-text-secondary);
}
.b09-estimation__hint {
margin: 0 0 var(--spacing-8);
font-size: var(--text-caption);
}
.b09-estimation__tabs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
/* 표 머리 — 좁은 열에서 말줄임 대신 줄바꿈 */
[data-b09-table] thead th {
white-space: normal;
overflow-wrap: anywhere;
}
+220 -8
View File
@@ -1,39 +1,251 @@
/* =============================================================================
* B09_Estimation_UI_Shell.ts
* 로그인 후 09: 6차 워크플로우 (원가계산) — **빈 화면** (PLAN 7-3)
* 로그인 후 09: 6차 워크플로우 (원가계산) — 탭 화면 (PLAN 144-1)
*
* - 옛 원가계산 코드는 통째로 `old_code/B09_Estimation/` 에 있음(지우지 않음).
* - 다시 짤 때까지 메뉴와 주소만 남김 — 제목·단계 막대만 뜨고 탭·계산은 없음.
* 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄
* 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) +
* 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [단가 갱신] = POST …/prices
* 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음
* ========================================================================== */
import "@ui/ui_template_general_layout.css";
import "./B09_Estimation_UI_Page.css";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import {
createButton,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { createDataTable } from "@ui/ui_template_data_table";
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
import { computeBase, type Row } from "./B09_Estimation_Calc_Base";
import {
buildCheck,
rowIndex,
TABS,
type Link,
type TabBuild,
type TabContext,
type TabId,
} from "./B09_Estimation_UI_Tabs";
import {
buildBasis,
buildCompare,
buildExpense,
buildLabor,
buildMachine,
buildMachineBlocks,
buildMaterial,
} from "./B09_Estimation_UI_Tabs_Base";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const LATER: Record<string, string> = {
dg_list: "일위대가목록표 — 145-2(다른 창)가 들어오면 생김",
dg: "일위대가표 — 145-2(다른 창)가 들어오면 생김",
sg_list: "단가산출근거목록표 — 145-1(다른 창)가 들어오면 생김",
sg: "단가산출근거 — 145-1(다른 창)가 들어오면 생김",
};
const BUILDERS: Partial<Record<TabId, TabBuild>> = {
check: buildCheck,
basis: buildBasis,
machine: buildMachine,
machine_blocks: buildMachineBlocks,
material: buildMaterial,
labor: buildLabor,
expense: buildExpense,
compare: buildCompare,
};
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const body = (await response.json().catch(() => null)) as { detail?: unknown } | null;
if (!response.ok) {
const detail = body?.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return body as T;
}
function note(textValue: string): HTMLElement {
const p = document.createElement("p");
p.className = "ui-general-block__empty";
p.textContent = textValue;
return p;
}
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) */
export function computeSheets(payload: Row, user: Row): Row {
return computeBase(payload.inputs, user);
}
export function renderB09Estimation(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
let payload: Row | null = null;
let user: Row = {};
let sheets: Row = {};
let current: TabId = "check";
const note = document.createElement("p");
note.className = "b09-estimation__rebuilding";
note.textContent = L("B09_Estimation_Rebuilding");
const tabBar = document.createElement("div");
tabBar.className = "b09-estimation__tabs";
tabBar.setAttribute("role", "tablist");
const panel = document.createElement("div");
panel.className = "b09-estimation__panel";
panel.setAttribute("role", "tabpanel");
const body = document.createElement("div");
body.append(note);
body.className = "b09-estimation";
body.append(tabBar, panel);
const made = document.createElement("p");
made.className = "b09-estimation__hint";
const recompute = (): void => {
if (payload) sheets = computeSheets(payload, user);
};
const setUser = (next: Row): void => {
user = next;
if (projectId) writeState("b09-estimation", user, projectId);
recompute();
show(current);
};
/** 탭 하나 그림 · focus = 이동해 온 줄 키 */
const show = (tab: TabId, focus?: string): void => {
current = tab;
tabBar.replaceChildren(
...TABS.map(([id, label]) => {
const button = createButton({
label,
variant: id === tab ? "filled" : "ghost",
size: "sm",
onClick: () => id !== current && show(id),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(id === tab));
button.dataset.b09Tab = id;
return button;
}),
);
if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요."));
if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…"));
const build = BUILDERS[tab];
if (!build) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움"));
const ctx: TabContext = { sheets, payload, user, setUser };
const spec = build(ctx);
if (typeof spec === "string") return void panel.replaceChildren(note(spec));
const goto = (link: Link | null): void => {
if (link) show(link.tab, link.key);
};
const table = createDataTable({
columns: spec.columns,
rows: spec.rows,
total: spec.total ?? null,
frozen: spec.frozen ?? 0,
empty: "",
caption: TABS.find(([id]) => id === tab)?.[1],
editable: spec.editable,
onEdit: spec.onEdit,
onCellClick: (i, key) => {
if (spec.onCell) spec.onCell(i, key);
if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key));
},
});
table.root.dataset.b09Table = tab;
panel.replaceChildren(...(spec.hint ? [note(spec.hint)] : []), table.root);
if (focus !== undefined) {
const index = rowIndex(spec.rows, focus);
if (index >= 0) requestAnimationFrame(() => table.focusRow(index));
}
};
const apply = (next: Row, fromServer: boolean): void => {
payload = next;
const draft = projectId && !fromServer ? readState<Row>("b09-estimation", projectId) : null;
user = draft ?? next.user ?? {};
recompute();
made.textContent = next.book?.made
? `단가 굳힘 ${String(next.book.made).replace("T", " ")} — [단가 갱신] 때만 새로`
: "단가 아직 안 굳힘 — [저장] 이나 [단가 갱신] 때 M01 값을 굳힘";
show(current);
};
const run = async (path: string, method: string, done: string, init?: Row): Promise<void> => {
if (!projectId) return;
showLoadingOverlay();
try {
const next = await request<Row>(`/projects/${projectId}${path}`, {
method,
...(init ? { body: JSON.stringify(init) } : {}),
});
clearState("b09-estimation", projectId);
apply(next, true);
showToast(done, "success");
} catch (error) {
showToast(`하지 못했습니다 — ${String(error)}`, "error");
} finally {
hideLoadingOverlay();
}
};
const save = createButton({
label: "저장",
variant: "filled",
onClick: () => void run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }),
});
save.dataset.b09Save = "";
const refresh = createButton({
label: "단가 갱신",
variant: "ghost",
onClick: () =>
void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."),
});
refresh.dataset.b09Refresh = "";
const section = document.createElement("section");
section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card";
const heading = document.createElement("h3");
heading.className = "ui-collapsible__title";
heading.textContent = "원가 단가";
section.append(heading, made, refresh);
const actions = document.createElement("div");
actions.className = "ui-sidebar-actions";
actions.append(save);
const left = document.createElement("div");
left.className = "ui-sidebar-panel b09-options";
left.append(section, actions);
attachCollapsible(left);
const layout = createWorkflowLayout({
title: L("B09_Estimation_Title"),
steps: workflowSteps(),
activeStep: 6,
mainContent: body,
leftPanel: left,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex: number) => {
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
root.append(layout.root);
show(current);
if (projectId)
request<Row>(`/projects/${projectId}/estimation`).then(
(next) => apply(next, false),
(error: unknown) =>
panel.replaceChildren(note(`원가 자료를 불러오지 못했습니다 — ${String(error)}`)),
);
}
+164
View File
@@ -0,0 +1,164 @@
/* =============================================================================
* B09_Estimation_UI_Tabs.ts
* B09 탭 목록 · 표 짜기 공용(PLAN 144-1) — 탭 하나 = 화면 표 하나(실무 엑셀 탭 차례).
*
* 탭 = 연결 점검(목차 · 안내) · 원가 · 총괄 · 내역 · 대가 둘 · 산근 둘 · 기초 · 중기 둘 · 단가 넷 · 집계표 넷
* 표 = 공용 `createDataTable` · 「연결 없음」 칸 = 빈칸 · 호표 칸 누름 = 그 탭 그 줄로 이동(`link`)
* 탭별 표 = `…_UI_Tabs_Base`(144) · `…_UI_Tabs_Cost`(145) · 여기는 차례 · 꼴 · 연결 점검만
* ========================================================================== */
import type { DataColumn, DataRow } from "@ui/ui_template_data_table";
import type { Row } from "./B09_Estimation_Calc_Base";
export type TabId =
| "check"
| "cost"
| "total"
| "bill"
| "dg_list"
| "dg"
| "sg_list"
| "sg"
| "basis"
| "machine"
| "machine_blocks"
| "material"
| "labor"
| "expense"
| "compare"
| "sum_material"
| "sum_labor"
| "sum_expense"
| "sum_machine";
/** 엑셀 탭 차례 — [id, 탭 이름] */
export const TABS: Array<[TabId, string]> = [
["check", "연결 점검"],
["cost", "공사원가계산서"],
["total", "총괄설계내역서"],
["bill", "설계내역서"],
["dg_list", "일위대가목록표"],
["dg", "일위대가표"],
["sg_list", "단가산출근거목록표"],
["sg", "단가산출근거"],
["basis", "환율및기초자료"],
["machine", "중기목록표"],
["machine_blocks", "중기사용료"],
["material", "재료비목록표"],
["labor", "노무비목록표"],
["expense", "경비목록표"],
["compare", "자재단가대비표"],
["sum_material", "재료비수량금액집계표"],
["sum_labor", "노무비수량금액집계표"],
["sum_expense", "경비수량금액집계표"],
["sum_machine", "중기시간금액집계표"],
];
export const tabName = (id: string): string => TABS.find(([t]) => t === id)?.[1] ?? id;
/** 다른 탭 줄 — 키가 같은 줄로 이동 */
export interface Link {
tab: TabId;
key: string;
}
/** 페이지가 탭 짜기에 주는 것 */
export interface TabContext {
sheets: Row;
payload: Row;
user: Row;
/** 사용자 값 고침 → 캐시 · 바로 다시 셈 */
setUser: (user: Row) => void;
}
/** 탭 한 장 = 표 하나 */
export interface TabTable {
columns: DataColumn[];
rows: DataRow[];
total?: DataRow | null;
frozen?: number;
/** 표 위 안내 글(근거 · 끝수) */
hint?: string;
/** 칸 누름 → 갈 줄(호표 칸) */
link?: (index: number, key: string) => Link | null;
editable?: (index: number, key: string) => boolean;
onEdit?: (index: number, key: string, text: string) => void;
/** 칸 누름 → 그 자리 동작(예 관급 · 별도 표시 바꿈) */
onCell?: (index: number, key: string) => void;
}
export type TabBuild = (ctx: TabContext) => TabTable | string;
/* ── 열 꼴 ────────────────────────────────────────────────────────────── */
export const text = (key: string, label: string, width?: number, group?: string[]): DataColumn => ({
key,
label,
width,
group,
});
export const money = (key: string, label: string, group?: string[], decimals = 0): DataColumn => ({
key,
label,
group,
kind: "number",
decimals,
width: 96,
});
export const qty = (key: string, label: string, decimals = 4, group?: string[]): DataColumn => ({
key,
label,
group,
kind: "number",
decimals,
trim: true,
width: 76,
});
/** 글 → 값 · 빈칸 = null · 수가 아니면 undefined(무시) */
export function parseNumber(value: string): number | null | undefined {
const t = value.replace(/,/g, "").trim();
if (!t) return null;
const v = Number(t);
return Number.isFinite(v) ? v : undefined;
}
/** 줄 키로 줄 번호 찾기(이동) */
export function rowIndex(rows: DataRow[], key: string): number {
return rows.findIndex((row) => row.key === key);
}
/* ── 연결 점검(목차 · 안내) ───────────────────────────────────────────── */
export const buildCheck: TabBuild = ({ sheets, payload }): TabTable => {
const gaps: Row[] = sheets.gaps || [];
const rows: DataRow[] = gaps.map((g, i) => ({
no: i + 1,
key: `${g.tab}|${g.key}|${i}`,
tab: tabName(g.tab),
name: g.name,
why: g.why,
}));
const notes: string[] = [...(payload.notes || [])];
const missing: string[] = payload.inputs?.missing || [];
if (missing.length) notes.push(`M01 에 없는 키 ${missing.length} — ${missing.join(" · ")}`);
return {
columns: [
qty("no", "번호", 0),
text("tab", "탭", 150),
text("name", "명칭", 260),
text("why", "까닭", 320),
],
rows,
frozen: 1,
hint:
`연결 없음 ${gaps.length}곳 — 줄을 누르면 그 탭 그 줄로 · 빈칸 = 값을 낸 원천이 없음(0 을 채우지 않음)` +
(notes.length ? ` · ${notes.join(" · ")}` : ""),
link: (i) => {
const g = gaps[i];
return g ? { tab: g.tab as TabId, key: g.key } : null;
},
};
};
@@ -0,0 +1,275 @@
/* =============================================================================
* B09_Estimation_UI_Tabs_Base.ts
* B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 ·
* 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`).
*
* 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈
* 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표
* ========================================================================== */
import type { DataRow } from "@ui/ui_template_data_table";
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base";
import {
money,
parseNumber,
qty,
text,
type TabBuild,
type TabId,
type TabTable,
} from "./B09_Estimation_UI_Tabs";
const LINK_HINT = "줄을 누르면 단가를 낸 탭 · 줄로 이동";
/** 단가 줄 표(노무비 · 경비목록표) — 호표 · 명칭 · 규격 · 단위 · 단가 · 비고(출처) */
function priceList(rows: Row[], unitPrice: string, hint: string): TabTable {
return {
columns: [
qty("no", "호표", 0),
text("name", "명칭", 200),
text("spec", "규격", 140),
text("unit", "단위", 56),
money("price", unitPrice),
text("source", "비고", 220),
],
rows: rows.map((r) => ({ ...r })),
frozen: 2,
hint,
};
}
export const buildLabor: TabBuild = ({ sheets, payload }) =>
priceList(
sheets.labor || [],
"단가(원/인)",
`노임 = 굳힌 단가(${madeText(payload)}) · M01 인력 · 바꾸려면 [단가 갱신]`,
);
export const buildExpense: TabBuild = ({ sheets, payload }) =>
priceList(
sheets.expense || [],
"기준가(천원)",
`기계 손료 기준가 = 굳힌 단가(${madeText(payload)}) · M01 기계 가격 · 바꾸려면 [단가 갱신]`,
);
function madeText(payload: Row): string {
return payload.book?.made ? `${String(payload.book.made).slice(0, 10)} 굳힘` : "아직 안 굳힘";
}
export const buildBasis: TabBuild = ({ sheets }): TabTable => {
const basis: Row = sheets.basis || { fx: [], drivers: [] };
const rows: DataRow[] = [
...basis.fx.map((f: Row) => ({
part: "1. 환율",
key: f.key,
name: f.name,
spec: f.date,
unit: f.unit,
day: null,
factor: "",
value: f.value,
})),
...basis.drivers.map((d: Row) => ({
part: "2. 인건비",
key: d.key,
name: d.name,
spec: `직종 ${d.key}`,
unit: "원/시간",
day: d.day,
factor: d.factor,
value: d.hour,
labor_key: d.labor_key,
})),
];
return {
columns: [
text("part", "구분", 90),
text("name", "명칭", 160),
text("spec", "규격 · 기준일", 120),
text("unit", "단위", 80),
money("day", "일당(원)"),
text("factor", "시간 환산", 160),
money("value", "값", undefined, 2),
],
rows,
frozen: 2,
hint: "운전사 시간 노임 = ROUNDDOWN(일당 × 1/8 × 16/12 × 25/20, 0)(M01 CC000336 · 건협기술-2230) · 환율 = M01 한국은행 · 인건비 줄 누름 = 노무비목록표",
link: (i) => {
const row = rows[i];
return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null;
},
};
};
const SUPPLY_NEXT: Record<string, string | null> = { "": "관급", 관급: "별도", 별도: null };
export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
const rows: Row[] = sheets.compare || [];
const out: DataRow[] = rows.map((r) => {
const row: DataRow = {
no: r.no,
key: r.key,
name: r.name,
spec: r.spec,
unit: r.unit,
lowest: r.lowest,
applied: r.applied,
applied_from: r.applied_from === "사용자" ? "사용자" : (r.applied_from ?? ""),
supply: r.supply ?? "",
};
for (const s of SOURCES) {
row[`p:${s}`] = r.prices[s];
row[`g:${s}`] = r.pages[s] ?? "";
}
return row;
});
const mine = (key: string): Row => ({ ...((user.materials || {})[key] || {}) });
const put = (key: string, cell: Row): void => {
const materials = { ...(user.materials || {}) };
if (Object.keys(cell).length) materials[key] = cell;
else delete materials[key];
setUser({ ...user, materials });
};
return {
columns: [
qty("no", "번호", 0),
text("name", "품명", 180),
text("spec", "규격", 140),
text("unit", "단위", 56),
...SOURCES.flatMap((s) => [money(`p:${s}`, "단가", [s]), text(`g:${s}`, "면", 64, [s])]),
money("lowest", "최저"),
money("applied", "적용 단가", ["적용"]),
text("applied_from", "출처", 80, ["적용"]),
text("supply", "관급 · 별도", 80),
],
rows: out,
frozen: 2,
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도",
editable: (_i, key) => key === "applied",
onEdit: (i, key, value) => {
const row = out[i];
if (!row || key !== "applied") return;
const v = parseNumber(value);
if (v === undefined) return;
const cell = mine(String(row.key));
if (v === null) delete cell.applied;
else cell.applied = v;
put(String(row.key), cell);
},
onCell: (i, key) => {
const row = out[i];
if (!row || key !== "supply") return;
const cell = mine(String(row.key));
const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null;
if (next) cell.supply = next;
else delete cell.supply;
put(String(row.key), cell);
},
};
};
export const buildMaterial: TabBuild = ({ sheets }): TabTable => {
const rows: DataRow[] = (sheets.material || []).map((r: Row) => ({
...r,
supply: r.supply ?? "",
}));
return {
columns: [
qty("no", "호표", 0),
text("name", "명칭", 200),
text("spec", "규격", 160),
text("unit", "단위", 56),
money("price", "단가"),
text("supply", "관급 · 별도", 80),
text("origin", "비고", 90),
],
rows,
frozen: 2,
hint: `단가 = 자재단가대비표 적용 단가 · ${LINK_HINT}`,
link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null),
};
};
export const buildMachine: TabBuild = ({ sheets }): TabTable => {
const rows: DataRow[] = (sheets.machine || []).map((r: Row) => ({ ...r }));
return {
columns: [
qty("no", "호표", 0),
text("name", "명칭", 200),
text("spec", "규격", 120),
text("unit", "단위", 56),
money("sum", "합계"),
money("labor", "노무비"),
money("material", "재료비"),
money("expense", "경비"),
],
rows,
frozen: 2,
hint: `시간당 = 중기사용료 블록 합계 · ${LINK_HINT}`,
link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null),
};
};
const KIND_TAB: Record<string, TabId> = {
expense: "expense",
labor: "basis",
material: "compare",
};
export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
const rows: DataRow[] = [];
const links: Array<{ tab: TabId; key: string } | null> = [];
(sheets.machine_blocks || []).forEach((b: Row, n: number) => {
rows.push({ key: b.key, name: `제 ${n + 1} 호표 ${b.name}`, spec: b.spec, unit: b.unit });
links.push(null);
for (const ln of b.lines) {
const at = (part: string): number | null => (ln.kind === part ? ln.price : null);
const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null);
rows.push({
key: `${b.key}|${ln.name}`,
name: ` ${ln.name}`,
spec: ln.spec,
qty: ln.qty,
unit: ln.unit,
sum_price: ln.price,
sum: ln.amount,
labor_price: at("labor"),
labor: amt("labor"),
material_price: at("material"),
material: amt("material"),
expense_price: at("expense"),
expense: amt("expense"),
});
links.push(ln.ref?.key ? { tab: KIND_TAB[ln.kind] ?? "compare", key: ln.ref.key } : null);
}
rows.push({
key: `${b.key}|합계`,
name: " 합 계",
sum: b.sum,
labor: b.labor,
material: b.material,
expense: b.expense,
});
links.push(null);
});
const pair = (key: string, label: string) => [
money(`${key}_price`, "단가", [label], 1),
money(key, "금액", [label], 1),
];
return {
columns: [
text("name", "명칭", 220),
text("spec", "규격", 120),
qty("qty", "수량"),
text("unit", "단위", 56),
...pair("sum", "합계"),
...pair("labor", "노무비"),
...pair("material", "재료비"),
...pair("expense", "경비"),
],
rows,
frozen: 1,
hint: "손료 = 기준가(천원) × 손료계수 ÷ 10,000 · 운전사 = 인 × 시간 노임 · 연료 = ℓ/hr × 적용 단가 · 잡품 = 연료 금액 × % · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 · 줄 누름 = 단가를 낸 탭",
link: (i) => links[i] ?? null,
};
};
+2
View File
@@ -68,6 +68,7 @@ from B08_Quantity.B08_Quantity_Router_Hydro import router as b08_hydro_router
from B08_Quantity.B08_Quantity_Router_Options import router as b08_options_router
from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_router
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
@@ -653,6 +654,7 @@ app.include_router(b08_earthwork_router, dependencies=protected_with_company)
app.include_router(b08_summary_router, dependencies=protected_with_company)
app.include_router(b08_material_router, dependencies=protected_with_company)
app.include_router(b08_options_router, dependencies=protected_with_company)
app.include_router(b09_estimation_router, dependencies=protected_with_company)
# B08 수량·B09 원가계산 라우터 13 개는 끊음 — 코드는 `old_code/` 에 있고 화면은 빈 틀만 뜬다(PLAN 7-3).
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
+170
View File
@@ -0,0 +1,170 @@
"""144-2~4 B09 바탕 단가 셈 — 엑셀 검산 대조 · 거울(`B09_Estimation_Calc_Base.py` ↔ `.ts`).
견본 = 기번3 공통 엑셀(`2024년 간선임도사업(기번3).xlsx`) 같은 입력:
노무비목록표 E10~E12 · 환율및기초자료 G6~G8 · 경비목록표 E4 · E5 · E7 · E13 · E16 · E21 ·
자재단가대비표 5~12줄 · 중기사용료 1 · 2 · 4 · 11 · 14 · 16 호표(F11 126,000 …)
"""
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Calc_Base import ( # noqa: E402
compute_base,
rounddown,
round_half,
roundup,
)
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
FACTOR = {"num": 400, "den": 1920, "text": "1/8 × 16/12 × 25/20"}
LABOR = [
{"key": "LB000048", "code": "1048", "name": "건설기계운전사", "price": 267360},
{"key": "LB000049", "code": "1049", "name": "화물차운전사", "price": 226709},
{"key": "LB000050", "code": "1050", "name": "일반기계운전사", "price": 161142},
{"key": "LB000002", "code": "1002", "name": "보통인부", "price": None},
]
def _m(key, name, price, loss, fuel, lph, misc, crew_code):
return {"key": key, "name": name, "spec": "", "price": price, "loss": loss, "fuel": fuel,
"fuel_lph": lph, "misc_pct": misc, "crew": 1 if crew_code else None,
"crew_code": crew_code} # fmt: skip
MACHINES = [
_m("X00001", "불도우저19ton", 184499, 1811, "경유", 25, 16, "1048"),
_m("X00003", "굴삭기(0.2m3)", 62550, 2085, "경유", 5, 21, "1048"),
_m("X00005", "굴삭기(0.7m3)", 110926, 2085, "경유", 11.6, 22, "1048"),
_m("X00032", "덤프자동덮개", 1546, 2684, None, None, None, None),
_m("X00048", "콘크리트믹서0.45m3", 5409, 3039, "휘발유", 3.9, 2, "1050"),
_m("X00074", "덤프트럭2.5ton(토사)", 20793, 2967, "경유", 2.9, None, "1049"),
_m("X09999", "기준가 없는 기계", None, 2000, "중유", 3, 10, "1048"),
]
def _mt(key, name, **prices):
return {"key": key, "name": name, "spec": "", "unit": "", "origin": "M01", "prices": prices}
MATERIALS = [
_mt("MO1", "휘발유", 관급=1369, 물가정보=0, 물가자료=0),
_mt("MO2", "경유", 관급=1272),
_mt("M3", "각재", 관급=500661, 물가정보=829341, 물가자료=748503),
_mt("M4", "못", 관급=1503, 물가정보=1935, 물가자료=1665),
_mt("M8", "화강석", 관급=0, 물가정보=288000, 물가자료=288000),
_mt("M9", "사용자 값 줄", 물가자료=1000),
_mt("b08:sand", "골재"),
]
INPUTS = {
"labor": LABOR,
"machines": MACHINES,
"materials": MATERIALS,
"fuel_keys": {"경유": "MO2", "휘발유": "MO1"},
"driver_factor": FACTOR,
"fx": [
{"key": "FX000001", "name": "USD", "unit": "원/USD", "value": 1414.9, "date": "2026-08-14"}
],
}
USER = {"materials": {"M9": {"applied": 950, "supply": "관급"}, "M4": {"supply": "별도"},
"b08:sand": {"applied": None}}} # fmt: skip
def _run_ts(inputs, user):
calc = ROOT / "B09_Estimation" / "B09_Estimation_Calc_Base.ts"
body = (
f"\nconst i = {json.dumps(inputs, ensure_ascii=False)};"
f"\nconst u = {json.dumps(user, ensure_ascii=False)};"
"\nconsole.log(JSON.stringify(computeBase(i, u)));"
)
script = IMPORTS.sub("", calc.read_text(encoding="utf-8")) + body
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_끝수_엑셀_자리():
assert rounddown(33412.7689, 1) == 33412.7 and rounddown(126000.9) == 126000
assert rounddown(-1.25, 1) == -1.2 # 0 쪽으로
assert roundup(69849380, -3) == 69850000 and roundup(69850000, -3) == 69850000
assert round_half(2.5) == 3 and round_half(0.125, 2) == 0.13 and round_half(-2.5) == -3
assert rounddown(0.1 + 0.2, 1) == 0.3 # 떠도는 끝수는 버리지 않음
def test_144_2_노무_경비_환율_기초자료():
s = compute_base(INPUTS, {})
assert [r["price"] for r in s["labor"]] == [267360, 226709, 161142, None]
assert ("labor", "LB000002") in {(g["tab"], g["key"]) for g in s["gaps"]}
hours = {d["key"]: d["hour"] for d in s["basis"]["drivers"]}
assert hours == {"1048": 55700, "1050": 33571, "1049": 47231} # 기초자료 G6 · G8 · G7
assert s["basis"]["drivers"][0]["factor"] == "1/8 × 16/12 × 25/20"
assert [r["price"] for r in s["expense"]][:3] == [
184499,
62550,
110926,
] # 경비목록표 E4 · E5 · E7
assert s["expense"][0]["unit"] == "천원"
assert s["basis"]["fx"][0]["value"] == 1414.9
def test_144_3_자재단가대비_최저_사용자_관급():
s = compute_base(INPUTS, USER)
rows = {r["key"]: r for r in s["compare"]}
# 엑셀 O 열 = 최저 · P 열 출처(0 은 뺌 · 같은 값이면 앞 칸 — 화강석 「물정」)
assert (rows["MO1"]["applied"], rows["MO1"]["applied_from"]) == (1369, "관급")
assert (rows["M3"]["applied"], rows["M3"]["lowest_from"]) == (500661, "관급")
assert rows["M4"]["applied"] == 1503 and rows["M4"]["supply"] == "별도"
assert (rows["M8"]["applied"], rows["M8"]["applied_from"]) == (288000, "물가정보")
assert (rows["M9"]["lowest"], rows["M9"]["applied"], rows["M9"]["applied_from"]) == (
1000,
950,
"사용자",
)
assert rows["M9"]["supply"] == "관급"
assert rows["b08:sand"]["applied"] is None # 출처 없음 · 사용자 빈칸 = 빈칸
material = {r["key"]: r["price"] for r in s["material"]}
assert material == {r["key"]: r["applied"] for r in s["compare"]} # 재료비목록표 = 적용 단가
gaps = [(g["tab"], g["key"]) for g in s["gaps"] if g["tab"] == "compare"]
assert gaps == [("compare", "b08:sand")]
def test_144_4_중기사용료_엑셀_검산():
s = compute_base(INPUTS, {})
got = {r["key"]: (r["sum"], r["labor"], r["material"], r["expense"]) for r in s["machine"]}
assert got["X00001"] == (126000, 55700, 36888, 33412) # 중기목록표 4줄
assert got["X00003"] == (76436, 55700, 7695, 13041)
assert got["X00005"] == (96829, 55700, 18001, 23128)
assert got["X00032"] == (414, 0, 0, 414) # 덮개 — 운전사 · 연료 없음
assert got["X00048"] == (40659, 33571, 5445, 1643) # 일반기계운전사 · 휘발유 · 잡유 2 %
assert got["X00074"][1:3] == (47231, 3688) # 화물차운전사 · 잡품 없음
assert got["X09999"] == (None, 55700, None, None) # 기준가 · 중유 단가 없음 = 그 비목 · 합 빈칸
block = s["machine_blocks"][0]
assert [ln["amount"] for ln in block["lines"]] == [
33412.7,
55700,
31800,
5088,
] # 중기사용료 F7~F10
assert block["lines"][0]["qty"] == 0.1811 and block["lines"][0]["ref"] == {
"tab": "expense",
"key": "X00001",
}
why = {g["why"] for g in s["gaps"] if g["key"] == "X09999"}
assert why == {"연료(중유) 단가 없음", "기계 기준가 없음", "손료 기준가 · 계수 없음"}
def test_거울_TS_가_같은_값():
for user in ({}, USER):
assert _run_ts(INPUTS, user) == compute_base(INPUTS, user)
+114
View File
@@ -0,0 +1,114 @@
"""144-1 B09 뼈대 — 탭 차례 · 저장 자리 · 길 · 빈칸 규칙 · 「연결 없음」 목록 · 단가 굳히기.
- 탭 = 기번3 공통 엑셀 보이는 탭 차례(목차 · 안내 = 「연결 점검」 한 탭 · INITIAL 안 옮김)
- 저장 = 프로젝트 안 `B09_Estimation/v1/`(사용자 값 · 굳힌 단가) · 결과는 저장 안 함
- 줄 = 수요 따라(사용자 승인 ①) — 연결된 사본 없으면 노무 · 경비 · 중기 빈 표 · B08 자재만 줄 · 출처 빈칸
"""
import asyncio
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402
from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
#: 엑셀 보이는 탭(목차 · 안내 빼고) — 조사 F 1절 차례
EXCEL_TABS = [
"공사원가계산서", "총괄설계내역서", "설계내역서", "일위대가목록표", "일위대가표",
"단가산출근거목록표", "단가산출근거", "환율및기초자료", "중기목록표", "중기사용료",
"재료비목록표", "노무비목록표", "경비목록표", "자재단가대비표", "재료비수량금액집계표",
"노무비수량금액집계표", "경비수량금액집계표", "중기시간금액집계표",
] # fmt: skip
B08_SHEETS = {
"material": {
"rows": [
{"key": "sand", "group": "골재", "spec": "모래", "unit": "㎥", "amount": 16},
{"key": "rubble", "group": "골재", "spec": "막자갈", "unit": "㎥", "amount": None},
{"key": "cement", "group": "시멘트", "spec": "40kg/대", "unit": "대", "amount": 244},
]
}
}
def test_탭_차례가_엑셀과_같다():
source = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs.ts").read_text(encoding="utf-8")
block = source[
source.index("export const TABS") : source.index("];", source.index("export const TABS"))
]
names = re.findall(r'\["\w+", "([^"]+)"\]', block)
assert names[0] == "연결 점검"
assert names[1:] == EXCEL_TABS
def test_길_셋():
routes = {(r.path, tuple(sorted(r.methods))) for r in router_mod.router.routes}
assert ("/api/projects/{project_id}/estimation", ("GET",)) in routes
assert ("/api/projects/{project_id}/estimation", ("PUT",)) in routes
assert ("/api/projects/{project_id}/estimation/prices", ("POST",)) in routes
main = (ROOT / "main.py").read_text(encoding="utf-8")
assert "app.include_router(b09_estimation_router" in main
def test_사용자_값_다듬기_저장_자리(tmp_path):
raw = {
"materials": {"MT1": {"applied": 1200, "supply": "관급", "x": 1}, "MT2": {"applied": None},
"MT3": {"supply": "엉뚱"}},
"estimate": {"estimated_price": "abc", "profit_adjust": 905980, "work_type": "토목",
"rates": {"RT000001": 3.56, "RT000002": None}, "zzz": 1},
"other": 1,
} # fmt: skip
clean = store.write_user(tmp_path, raw)
assert clean == {
"materials": {"MT1": {"applied": 1200.0, "supply": "관급"}, "MT2": {"applied": None}},
"estimate": {"estimated_price": None, "profit_adjust": 905980.0, "work_type": "토목",
"rates": {"RT000001": 3.56, "RT000002": None}},
} # fmt: skip
path = tmp_path / "B09_Estimation" / "v1" / "user_values.json"
assert json.loads(path.read_text(encoding="utf-8")) == clean
assert store.read_user(tmp_path) == clean
assert store.read_book(tmp_path) is None
def test_단가_굳히기_운전사_연료_따라옴():
book = prices.refresh_book(["EQ000004"]) # 불도저(무한궤도) 19ton · 조종원 1048 · 경유
rows = book["rows"]
assert set(rows) == {"EQ000004", "LB000048", "MO000002"}
assert rows["EQ000004"]["loss"] == 1811 and rows["EQ000004"]["crew_code"] == "1048"
assert rows["LB000048"]["code"] == "1048" and rows["MO000002"]["prices"]["유가"] > 0
assert book["driver_factor"] == {"num": 400, "den": 1920, "text": "1/8 × 16/12 × 25/20"}
# [저장] = 책에 없는 새 줄만 더함 — 있는 줄 값은 안 바꿈
stale = {**book, "rows": {**rows, "LB000048": {**rows["LB000048"], "price": 1}}}
grown = prices.extend_book(stale, ["EQ000004", "LB000002"])
assert grown["rows"]["LB000048"]["price"] == 1 and "LB000002" in grown["rows"]
# 책 밖 줄 = 지금 M01 값 + fresh 표시
inputs = prices.build_inputs(stale, ["EQ000004", "LB000002"])
fresh = {row["key"]: row.get("fresh", False) for row in inputs["labor"]}
assert fresh == {"LB000002": True, "LB000048": False}
assert inputs["fuel_keys"] == {"경유": "MO000002"}
def test_연결_없으면_빈_표와_연결_없음(tmp_path, monkeypatch):
async def fake_materials(_project_id):
return B08_SHEETS
monkeypatch.setattr(router_mod, "material_sheets", fake_materials)
body = asyncio.run(router_mod.payload("p", tmp_path))
sheets = body["sheets"]
assert sheets["labor"] == [] and sheets["expense"] == [] and sheets["machine"] == []
assert [row["key"] for row in sheets["compare"]] == ["b08:sand", "b08:cement"] # 수량 있는 줄만
assert all(row["applied"] is None for row in sheets["compare"]) # 출처 없음 = 빈칸(0 아님)
assert [(g["tab"], g["key"]) for g in sheets["gaps"]] == [
("compare", "b08:sand"),
("compare", "b08:cement"),
]
assert any("연결된 구조물 조합 사본 없음" in n for n in body["notes"])
assert body["book"] is None # GET 은 아무것도 쓰지 않음
assert not (tmp_path / "B09_Estimation").exists()
+6
View File
@@ -138,6 +138,12 @@
cursor: pointer;
}
/* 이동해 온 줄(PLAN 144 · focusRow) */
.ui-data-table__table tbody tr.is-focus td {
background: var(--dt-hover);
box-shadow: inset 0 -2px 0 var(--color-accent);
}
/* 고칠 칸(PLAN 121) — 칸 가득 글 상자 · 숫자 오른쪽 맞춤 */
.ui-data-table__table td.is-edit {
padding: 0;
+23 -3
View File
@@ -8,6 +8,7 @@
* host.append(t.root); t.setRows(rows2, total2);
* 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 }
* 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴
* 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조)
* ========================================================================== */
import "./ui_template_data_table.css";
@@ -48,6 +49,10 @@ export interface DataTableOptions {
empty?: string;
/** 줄 누름(본문 줄만 · 합계 제외) — 주면 줄에 `data-i`(줄 번호)·`is-clickable` 이 붙음 */
onRowClick?: (index: number) => void;
/** 칸 누름(본문 줄만) — 주면 줄에 `data-i`·`is-clickable` 이 붙음 · 줄 누름보다 먼저 부름 */
onCellClick?: (index: number, key: string) => void;
/** 강조할 줄(`focusRow` 가 정함) */
focus?: number | null;
/** 고칠 칸인지(본문 줄만 · 합계 제외) — 참이면 칸이 글 상자 */
editable?: (index: number, key: string) => boolean;
/** 고친 칸 글(바꾼 뒤 · 커서가 옮겨 간 다음에 부름) */
@@ -57,6 +62,8 @@ export interface DataTableOptions {
export interface DataTable {
root: HTMLElement;
setRows: (rows: DataRow[], total?: DataRow | null) => void;
/** 그 줄로 스크롤하고 강조 */
focusRow: (index: number) => void;
}
const EMPTY = "-";
@@ -124,6 +131,7 @@ function rowHtml(
index: number | null = null,
edit: number | null = null,
editable?: (index: number, key: string) => boolean,
focus = false,
): string {
let tr = "";
for (let j = 0; j < cols.length; j++) {
@@ -146,7 +154,9 @@ function rowHtml(
(fmts[j] ? ' data-num=""' : "");
tr += `<td${attrs}>${esc(txt)}</td>`;
}
const cls = [alt && "is-alt", index !== null && "is-clickable"].filter(Boolean).join(" ");
const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus"]
.filter(Boolean)
.join(" ");
const data = index !== null ? ` data-i="${index}"` : "";
return cls || data ? `<tr${cls ? ` class="${cls}"` : ""}${data}>${tr}</tr>` : `<tr>${tr}</tr>`;
}
@@ -185,7 +195,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
const to = win ? Math.max(from, Math.min(win.to, n)) : n;
let body = win ? pad(from * win.rowH, cols.length) : "";
const blank = o.empty ?? EMPTY;
const pick = o.onRowClick !== undefined;
const pick = o.onRowClick !== undefined || o.onCellClick !== undefined;
for (let i = from; i < to; i++)
body += rowHtml(
cols,
@@ -197,6 +207,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
pick ? i : null,
i,
o.editable,
o.focus === i,
);
if (win) body += pad((n - to) * win.rowH, cols.length);
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
@@ -250,7 +261,11 @@ export function createDataTable(opts: DataTableOptions): DataTable {
});
root.addEventListener("click", (event) => {
const row = (event.target as Element).closest<HTMLElement>("tr[data-i]");
if (row && root.contains(row)) state.onRowClick?.(Number(row.dataset.i));
if (!row || !root.contains(row)) return;
const cell = (event.target as Element).closest<HTMLTableCellElement>("td");
const column = cell ? state.columns[cell.cellIndex] : undefined;
if (column) state.onCellClick?.(Number(row.dataset.i), column.key);
state.onRowClick?.(Number(row.dataset.i));
});
draw(true);
return {
@@ -260,5 +275,10 @@ export function createDataTable(opts: DataTableOptions): DataTable {
if (total !== undefined) state.total = total;
draw(true);
},
focusRow(index) {
state.focus = index;
root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3);
draw(true);
},
};
}