merge: dev 받기 — 8장 기계 고르기 · 재료_자체 양쪽 줄 합침

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
This commit is contained in:
2026-09-19 18:48:03 +09:00
co-authored by Claude Opus 5
37 changed files with 28682 additions and 20 deletions
+1
View File
@@ -27,6 +27,7 @@ const FOOTERLESS_ROUTES: readonly RoutePath[] = [
ROUTES.B10_PAYMENT,
ROUTES.B11_STATUS,
ROUTES.Z01_MASTER_DATA,
ROUTES.M01_MASTER_DATA,
];
function bootstrap(): void {
+2
View File
@@ -61,6 +61,8 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading,
[ROUTES.Z01_MASTER_DATA]: async () =>
(await import("../Z01_MasterData/Z01_MasterData_UI_Page")).renderZ01MasterData,
[ROUTES.M01_MASTER_DATA]: async () =>
(await import("../M01_MasterData/M01_MasterData_UI_Page")).renderM01MasterData,
};
/** 로그인 여부 (토큰 존재 확인) */
+5
View File
@@ -139,6 +139,11 @@ function buildSystemSettingsPanel(): HTMLElement {
variant: "ghost",
onClick: () => navigateTo(ROUTES.Z01_MASTER_DATA),
}),
createButton({
label: L("B01_Dashboard_MasterElements"),
variant: "ghost",
onClick: () => navigateTo(ROUTES.M01_MASTER_DATA),
}),
);
return wrap;
}
+109
View File
@@ -0,0 +1,109 @@
/* =============================================================================
* M01_MasterData_Api_Fetch.ts
* 마스터 요소 화면 서버 호출 — 계약 `resources/master_data/_화면_계약.md` · 시스템 관리자만
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
/** 줄·표 한 개 — 파일 그대로(한글 칸). */
export type Row = Record<string, unknown>;
export interface GroupInfo {
group: string;
files: number;
rows: number;
}
export interface FileInfo {
file: string;
book: string | null;
chapter: string;
edition: string | null;
rows: number;
version: string;
}
export interface RowsPage {
file: string;
version: string;
total: number;
page: number;
size: number;
rows: Row[];
}
export interface TableHead {
열쇠: string;
이름: string;
기준: string;
출처: string;
조건: Record<string, string>;
값칸: Record<string, string>;
count: number;
}
export interface Change {
op: "edit" | "add" | "delete";
key?: string;
row?: Row;
}
export interface FileChanges {
file: string;
version: string;
changes: Change[];
}
/** 저장 결과 — 200 은 새 판본 · 나머지는 화면이 그대로 안내할 상태. */
export type SaveResult =
| { status: 200; files: { file: string; version: string }[] }
| { status: 409; stale: string[] }
| { status: 422; errors: string[] }
| { status: number; detail: string };
async function call<T>(path: string, init: RequestInit = {}): Promise<{ status: number; body: T }> {
const response = await fetch(`${API_BASE_URL}/m01${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
return { status: response.status, body: (await response.json()) as T };
}
async function get<T>(path: string, params: Record<string, string | number>): Promise<T> {
const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)]));
const { status, body } = await call<T & { detail?: unknown }>(`${path}?${query}`);
if (status !== 200) throw new Error(String(body.detail ?? `HTTP ${status}`));
return body;
}
export const fetchGroups = async (): Promise<GroupInfo[]> =>
(await get<{ groups: GroupInfo[] }>("/groups", {})).groups;
export const fetchFiles = async (group: string): Promise<FileInfo[]> =>
(await get<{ files: FileInfo[] }>(`/groups/${encodeURIComponent(group)}/files`, {})).files;
export const fetchRows = (file: string, page: number, size: number, q: string): Promise<RowsPage> =>
get<RowsPage>("/rows", { file, page, size, q });
export const fetchTables = (
file: string,
q: string,
): Promise<{ version: string; tables: TableHead[] }> => get("/tables", { file, q });
export const fetchTable = (
file: string,
key: string,
): Promise<{ version: string; table: Row & { : Row[] } }> => get("/table", { file, key });
export async function saveFiles(files: FileChanges[]): Promise<SaveResult> {
const { status, body } = await call<Record<string, unknown>>("/save", {
method: "POST",
body: JSON.stringify({ files }),
});
const detail = body.detail as { stale?: string[]; errors?: string[] } | string | undefined;
if (status === 200) return { status, files: body.files as { file: string; version: string }[] };
if (status === 409 && typeof detail === "object") return { status, stale: detail.stale ?? [] };
if (status === 422 && typeof detail === "object") return { status, errors: detail.errors ?? [] };
return { status, detail: typeof detail === "string" ? detail : JSON.stringify(detail) };
}
+161
View File
@@ -0,0 +1,161 @@
/* =============================================================================
* M01_MasterData_Draft.ts
* 고친 것 초안 — sessionStorage 에만 쌓임(자동저장 없음) · [저장] 때만 서버로.
*
* 요소 파일 = 줄 단위(edits · adds · deletes) · 표형 파일 = 표 하나 통째(tables).
* ========================================================================== */
import type { FileChanges, Row } from "./M01_MasterData_Api_Fetch";
/** o = 원래 줄 번호(새 줄은 null) — 노랑 칠은 이 번호의 원래 줄과 견줌. */
export interface TableItem {
o: number | null;
r: Row;
}
interface TableDraft {
base: Row;
items: TableItem[];
}
interface FileDraft {
version: string;
edits: Record<string, Row>;
adds: Row[];
deletes: string[];
tables: Record<string, TableDraft>;
}
const STORAGE_KEY = "m01_master_draft";
const listeners = new Set<() => void>();
function readStore(): Record<string, FileDraft> {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "{}") as Record<string, FileDraft>;
} catch {
return {};
}
}
let store = readStore();
export const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
/** 구독 — 돌려받은 함수를 부르면 해제. */
export function onDraftChange(fn: () => void): () => void {
listeners.add(fn);
return () => void listeners.delete(fn);
}
function touch(file: string): void {
const d = store[file];
if (
d &&
!Object.keys(d.edits).length &&
!d.adds.length &&
!d.deletes.length &&
!Object.keys(d.tables).length
) {
delete store[file];
}
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
/* 저장소 막힘 — 이 탭 메모리에서만 유지 */
}
listeners.forEach((fn) => fn());
}
function slot(file: string, version: string): FileDraft {
return (store[file] ??= { version, edits: {}, adds: [], deletes: [], tables: {} });
}
export function peek(file: string): FileDraft | undefined {
return store[file];
}
const countOf = (d: FileDraft): number =>
Object.keys(d.edits).length + d.adds.length + d.deletes.length + Object.keys(d.tables).length;
export const fileCount = (file: string): number => (store[file] ? countOf(store[file]) : 0);
export const totalCount = (): number => Object.values(store).reduce((n, d) => n + countOf(d), 0);
/** 그 파일을 초안이 받은 판본과 다른 판본으로 읽었나 — 저장하면 409 로 막힐 초안. */
export const isStale = (file: string, version: string): boolean =>
store[file] !== undefined && store[file].version !== version;
/* --- 요소 파일 --- */
export function editRow(file: string, version: string, key: string, orig: Row, next: Row): void {
const d = slot(file, version);
if (same(orig, next)) delete d.edits[key];
else d.edits[key] = next;
touch(file);
}
export function addRow(file: string, version: string, row: Row): void {
slot(file, version).adds.push(row);
touch(file);
}
export function setAdd(file: string, index: number, row: Row): void {
store[file].adds[index] = row;
touch(file);
}
export function removeAdd(file: string, index: number): void {
store[file].adds.splice(index, 1);
touch(file);
}
export function toggleDelete(file: string, version: string, key: string): void {
const d = slot(file, version);
d.deletes = d.deletes.includes(key) ? d.deletes.filter((k) => k !== key) : [...d.deletes, key];
touch(file);
}
/* --- 표형 파일 --- */
export function setTable(
file: string,
version: string,
key: string,
base: Row,
items: TableItem[],
orig: Row[],
): void {
const d = slot(file, version);
if (items.length === orig.length && items.every((it, i) => it.o === i && same(it.r, orig[i]))) {
delete d.tables[key];
} else {
d.tables[key] = { base, items };
}
touch(file);
}
export const tableItems = (file: string, key: string): TableItem[] | undefined =>
store[file]?.tables[key]?.items;
/* --- 저장·버리기 --- */
export function payload(): FileChanges[] {
return Object.entries(store).map(([file, d]) => ({
file,
version: d.version,
changes: [
...Object.entries(d.edits)
.filter(([key]) => !d.deletes.includes(key))
.map(([key, row]) => ({ op: "edit" as const, key, row })),
...Object.entries(d.tables).map(([key, t]) => ({
op: "edit" as const,
key,
row: { ...t.base, : t.items.map((it) => it.r) },
})),
...d.adds.map((row) => ({ op: "add" as const, row })),
...d.deletes.map((key) => ({ op: "delete" as const, key })),
],
}));
}
/** files 를 안 주면 전부 버림. */
export function discard(files?: string[]): void {
for (const file of files ?? Object.keys(store)) delete store[file];
touch("");
}
+108
View File
@@ -0,0 +1,108 @@
"""M01 마스터 데이터 관리자 API — 계약 `resources/master_data/_화면_계약.md`.
⚠ 권한은 등록하는 쪽(`main.py`)이 `system_admin_only` 로 붙임.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any, Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from M01_MasterData import M01_MasterData_Store as store
router = APIRouter(prefix="/api/m01", tags=["M01 MasterData"])
class CalcBody(BaseModel):
book: str
key: str
inputs: dict[str, Any] = {}
row: dict[str, Any] | None = None # 저장 전 고친 로직 줄(없으면 저장된 파일로)
file: str | None = None # 새 로직이면 더할 로직 파일
class Change(BaseModel):
op: Literal["edit", "add", "delete"]
key: str | None = None
row: dict[str, Any] | None = None
class FileChanges(BaseModel):
file: str
version: str
changes: list[Change]
class SaveBody(BaseModel):
files: list[FileChanges]
def _num(v):
"""Decimal → JSON 수(그냥 두면 글자로 나감)."""
if isinstance(v, Decimal):
return int(v) if v == v.to_integral_value() else float(v)
if isinstance(v, list):
return [_num(x) for x in v]
if isinstance(v, dict):
return {k: _num(x) for k, x in v.items()}
return v
def _call(fn, *args):
try:
return _num(fn(*args))
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
@router.get("/groups")
def get_groups() -> dict:
return {"groups": _call(store.groups)}
@router.get("/groups/{group}/files")
def get_files(group: str) -> dict:
return {"files": _call(store.files_of, group)}
@router.get("/rows")
def get_rows(file: str, page: int = 1, size: int = 50, q: str = "") -> dict:
return _call(store.rows, file, page, size, q)
@router.get("/tables")
def get_tables(file: str, q: str = "") -> dict:
return _call(store.tables, file, q)
@router.get("/table")
def get_table(file: str, key: str) -> dict:
return _call(store.table, file, key)
@router.get("/logics")
def get_logics(book: str = "", chapter: str = "", q: str = "", blocked: int | None = None) -> dict:
return {"logics": _call(store.logics, book, chapter, q, blocked)}
@router.get("/logic")
def get_logic(book: str, key: str) -> dict:
return _call(store.logic, book, key)
@router.get("/elements")
def get_elements(group: str, q: str = "", limit: int = 50) -> dict:
return _call(store.elements, group, q, limit)
@router.post("/calc")
def post_calc(body: CalcBody) -> dict:
return _call(store.calc, body.book, body.key, body.inputs, body.row, body.file)
@router.post("/save")
def post_save(body: SaveBody) -> dict:
return {"files": _call(store.save, [f.model_dump() for f in body.files])}
+353
View File
@@ -0,0 +1,353 @@
"""M01 마스터 데이터 파일 저장소 — `resources/master_data/` 첫 층 JSON 읽기·검사·쓰기.
계약: `resources/master_data/_화면_계약.md` · 파일 모양: `_틀.md`
검사·계산 엔진: `scripts/check_master.py`.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import sys
import threading
from decimal import Decimal
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1] / "resources/master_data/scripts"
sys.path.insert(0, str(SCRIPTS))
import check_master as cm # noqa: E402
FOLDER: Path = cm.MASTER # 시험은 사본 폴더로 바꿈
_CHAPTER = re.compile(r"_([^_]*\d+장)\.json$") # 「공통3장」 · 「13장」
# ponytail: 저장은 한 번에 하나(프로세스 안 잠금) · 서버를 여럿 띄우면 파일 잠금으로
_LOCK = threading.Lock()
class StoreError(Exception):
def __init__(self, status: int, detail):
super().__init__(detail)
self.status, self.detail = status, detail
# ── 읽기 ──────────────────────────────────────────────────────────────
def _path(file: str) -> Path:
path = FOLDER / file
if "/" in file or "\\" in file or file.startswith("_") or not path.is_file():
raise StoreError(404, f"없는 파일 「{file}")
return path
def version_of(raw: bytes) -> str:
return hashlib.sha256(raw).hexdigest()[:16]
def read(file: str) -> tuple[dict, str]:
raw = _path(file).read_bytes()
return json.loads(raw, parse_float=Decimal, parse_int=Decimal), version_of(raw)
def items_key(data: dict) -> str:
return "" if data.get("그룹") in cm.mf.TABLE_GROUPS else ""
def chapter_of(file: str) -> str:
m = _CHAPTER.search(file)
return m.group(1) if m else ""
def _hit(row: dict, q: str) -> bool:
q = q.lower()
return not q or q in str(row.get("열쇠", "")).lower() or q in str(row.get("이름", "")).lower()
def groups() -> list[dict]:
out = {g: {"group": g, "files": 0, "rows": 0} for g in cm.mf.GROUPS}
for data in cm.load(folder=FOLDER).values():
slot = out.get(data.get("그룹"))
if slot:
slot["files"] += 1
slot["rows"] += len(data.get(items_key(data)) or [])
return list(out.values())
def files_of(group: str) -> list[dict]:
if group not in cm.mf.GROUPS:
raise StoreError(404, f"없는 그룹 「{group}")
out = []
for path in sorted(FOLDER.glob(f"{group}_*.json")):
data, version = read(path.name)
out.append(
{
"file": path.name,
"book": data.get("원문"),
"chapter": chapter_of(path.name),
"edition": data.get(""),
"rows": len(data.get(items_key(data)) or []),
"version": version,
}
)
return out
def rows(file: str, page: int, size: int, q: str) -> dict:
data, version = read(file)
hits = [r for r in data.get(items_key(data)) or [] if _hit(r, q)]
page, size = max(page, 1), min(max(size, 1), 500)
return {
"file": file,
"version": version,
"total": len(hits),
"page": page,
"size": size,
"rows": hits[(page - 1) * size : page * size],
}
def tables(file: str, q: str) -> dict:
data, version = read(file)
if items_key(data) != "":
raise StoreError(400, f"표형 파일 아님 「{file}")
heads = ("열쇠", "이름", "기준", "출처", "조건", "값칸")
return {
"file": file,
"version": version,
"tables": [
{**{k: t.get(k) for k in heads}, "count": len(t.get("") or [])}
for t in data[""]
if _hit(t, q)
],
}
def _one(data: dict, key: str) -> dict:
for row in data.get(items_key(data)) or []:
if str(row.get("열쇠")) == key:
return row
raise StoreError(404, f"없는 열쇠 「{key}")
def table(file: str, key: str) -> dict:
data, version = read(file)
return {"file": file, "version": version, "table": _one(data, key)}
def _logic_files(files: dict[str, dict]):
for name, data in files.items():
if data.get("그룹") == "로직":
yield name, data
def logics(book: str, chapter: str, q: str, blocked: int | None) -> list[dict]:
files = cm.load(folder=FOLDER)
whole = cm.mf.Master(files)
out = []
for name, data in _logic_files(files):
if (book and data.get("원문") != book) or (chapter and chapter_of(name) != chapter):
continue
for row in data.get("") or []:
if not _hit(row, q):
continue
reasons = cm.mf.check_logic(whole, data.get("원문"), row)[0]
if blocked is not None and bool(reasons) != bool(blocked):
continue
out.append(
{
"file": name,
"book": data.get("원문"),
"chapter": chapter_of(name),
**{k: row.get(k) for k in ("열쇠", "이름", "결과단위", "출처")},
"blocked": bool(reasons),
"reasons": reasons,
}
)
return out
def logic(book: str, key: str) -> dict:
files = cm.load(folder=FOLDER)
for name, data in _logic_files(files):
if data.get("원문") != book:
continue
for row in data.get("") or []:
if str(row.get("열쇠")) == key:
whole = cm.mf.Master(files)
reasons = cm.mf.check_logic(whole, book, row)[0]
_, version = read(name)
return {
"file": name,
"version": version,
"logic": row,
"blocked": bool(reasons),
"reasons": reasons,
"prices": _prices(whole, row),
}
raise StoreError(404, f"없는 로직 「{book}:{key}")
def _brief(ref: str, row: dict) -> dict:
return {"ref": ref, **{k: row.get(k) for k in ("이름", "규격", "단위", "", "값칸")}}
def _prices(whole, row: dict) -> dict:
"""호표 요소 → 요소 줄 요약(단가 자동 칸) · `{이름}` 낀 열쇠·하위 로직은 계산 때 정해짐."""
out = {}
for item in row.get("호표") or []:
ref = str(item.get("요소", ""))
if item.get("종류") != "로직" and "{" not in ref:
try:
out[ref] = _brief(ref, whole.get(ref))
except cm.mf.FormulaError:
out[ref] = None
return out
def elements(group: str, q: str, limit: int) -> dict:
"""요소 찾기 창 — 그룹 전체에서 이름·열쇠 찾기 · `ref` 는 식에 그대로 넣는 `그룹:원문:열쇠`."""
if group not in cm.mf.GROUPS:
raise StoreError(404, f"없는 그룹 「{group}")
hits = []
for name, data in cm.load(folder=FOLDER).items():
if data.get("그룹") != group:
continue
for row in data.get(items_key(data)) or []:
if _hit(row, q):
ref = f"{group}:{data.get('원문')}:{row.get('열쇠')}"
hits.append({**_brief(ref, row), "file": name})
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
# ── 시험 계산 ──────────────────────────────────────────────────────────
def _swap(files: dict, book: str, key: str, row: dict, file: str | None) -> None:
"""저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함)."""
row = _dec(row)
for name, data in _logic_files(files):
if data.get("원문") == book:
items = data.get("") or []
for i, old in enumerate(items):
if str(old.get("열쇠")) == key:
items[i] = row
return
if file not in files or files[file].get("그룹") != "로직":
raise StoreError(404, f"없는 로직 파일 「{file}")
files[file].setdefault("", []).append(row)
def calc(book: str, key: str, inputs: dict, row: dict | None = None, file: str | None = None):
given = {k: _dec(v) for k, v in inputs.items()}
files = cm.load(folder=FOLDER)
if row is not None:
_swap(files, book, key, row, file)
key = str(row.get("열쇠", key))
try:
result = cm.calc(files, f"{book}:{key}", given)
except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"ok": False, "reason": str(e) or type(e).__name__}
if "결과" in result:
return {"ok": True, "result": result["결과"], "middle": result["중간"]}
return {
"ok": True,
"lines": result[""],
"sums": {k: result[k] for k in cm.mf.BUNDLE},
"middle": result["중간"],
}
# ── 저장 ──────────────────────────────────────────────────────────────
def _dec(v):
"""화면이 보낸 수 → Decimal(파일을 읽은 모양과 같게)."""
if isinstance(v, bool) or v is None:
return v
if isinstance(v, (int, float)):
return Decimal(str(v))
if isinstance(v, list):
return [_dec(x) for x in v]
if isinstance(v, dict):
return {k: _dec(x) for k, x in v.items()}
return v
def _apply(data: dict, changes: list[dict], file: str) -> None:
items = data.setdefault(items_key(data), [])
for ch in changes:
op, key, row = ch.get("op"), ch.get("key"), _dec(ch.get("row"))
if op not in ("edit", "add", "delete"):
raise StoreError(400, f"모르는 op 「{op}")
if op != "add":
at = next((i for i, r in enumerate(items) if str(r.get("열쇠")) == key), None)
if at is None:
raise StoreError(404, f"{file} · 없는 열쇠 「{key}")
if op != "delete" and not (isinstance(row, dict) and "열쇠" in row):
raise StoreError(400, f"{file} · row 에 열쇠 없음")
new_key = str(row["열쇠"]) if op != "delete" else None
taken = {str(r.get("열쇠")) for i, r in enumerate(items) if op == "add" or i != at}
if new_key in taken:
raise StoreError(400, f"{file} · 열쇠 겹침 「{new_key}")
if op == "add":
items.append(row)
elif op == "edit":
items[at] = row
else:
del items[at]
def save(batch: list[dict]) -> list[dict]:
with _LOCK:
stale = [p.get("file", "") for p in batch if read(p.get("file", ""))[1] != p.get("version")]
if stale:
raise StoreError(409, {"stale": stale})
before, after = cm.load(folder=FOLDER), cm.load(folder=FOLDER) # after = 고칠 사본
names = [part["file"] for part in batch]
for part in batch:
_apply(after[part["file"]], part.get("changes") or [], part["file"])
old = set(cm.check_saved(before, names))
new = [x for x in cm.check_saved(after, names) if x not in old]
if new:
raise StoreError(422, {"errors": new})
out = []
for name in dict.fromkeys(names):
text = dump(after[name])
tmp = FOLDER / f".{name}.tmp"
tmp.write_text(text, encoding="utf-8", newline="\n")
os.replace(tmp, FOLDER / name)
out.append({"file": name, "version": version_of(text.encode("utf-8"))})
return out
# ── 쓰기 모양 ─────────────────────────────────────────────────────────
def _atom(v) -> str:
if isinstance(v, Decimal):
return str(v)
return json.dumps(v, ensure_ascii=False)
def _flat(v) -> str:
if isinstance(v, dict):
return (
"{ " + ", ".join(f"{_atom(k)}: {_flat(x)}" for k, x in v.items()) + " }" if v else "{}"
)
if isinstance(v, list):
return "[" + ", ".join(_flat(x) for x in v) + "]"
return _atom(v)
def dump(v, indent: int = 0, width: int = 160) -> str:
"""UTF-8 · 들여쓰기 2칸 · 한 줄에 들어가는 묶음은 한 줄로."""
flat = _flat(v)
if indent and len(flat) + indent <= width or not isinstance(v, (dict, list)) or not v:
return flat + ("" if indent else "\n")
pad = " " * (indent + 2)
if isinstance(v, dict):
body = [f"{pad}{_atom(k)}: {dump(x, indent + 2, width)}" for k, x in v.items()]
text = "{\n" + ",\n".join(body) + "\n" + " " * indent + "}"
else:
text = (
"[\n"
+ ",\n".join(pad + dump(x, indent + 2, width) for x in v)
+ "\n"
+ " " * indent
+ "]"
)
return text + ("" if indent else "\n")
+121
View File
@@ -0,0 +1,121 @@
/* =============================================================================
* M01_MasterData_UI_Cells.ts
* 칸 다루기 — 값 → 글 · 글 → 값 · 눌러 고치기 · 쪽 나눔 띠 (요소·표 화면 공용)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import type { Row } from "./M01_MasterData_Api_Fetch";
/** 묶음 값(`값 = {가격: …}`)을 「값›가격」 칸으로 펼칠 때의 구분자. */
export const SEP = "";
export const isScalar = (v: unknown): boolean =>
v === null || v === undefined || ["string", "number", "boolean"].includes(typeof v);
/** 줄 → 칸 이름별 값. 묶음(dict) 칸은 한 단계 펼침. */
export function flatten(row: Row): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(row)) {
if (v && typeof v === "object" && !Array.isArray(v)) {
for (const [k2, v2] of Object.entries(v)) out[`${k}${SEP}${k2}`] = v2;
} else {
out[k] = v;
}
}
return out;
}
/** 칸 이름(펼친 것 포함)에 값을 넣은 새 줄. */
export function withCell(row: Row, path: string, value: unknown): Row {
const [head, sub] = path.split(SEP);
if (sub === undefined) return { ...row, [head]: value };
return { ...row, [head]: { ...(row[head] as Row), [sub]: value } };
}
export function show(v: unknown): string {
if (v === null || v === undefined) return "";
if (Array.isArray(v)) return v.map((x) => (x === null ? "" : String(x))).join(" ~ ");
return typeof v === "object" ? JSON.stringify(v) : String(v);
}
const NUMBER = /^-?\d+(\.\d+)?$/;
/** 글 → 수(수 꼴이면) 아니면 글. 빈 글 = null. */
export function toNumberish(text: string): number | string | null {
const s = text.replace(/,/g, "").trim();
if (s === "") return null;
return NUMBER.test(s) ? Number(s) : text.trim();
}
/** 원래 값의 꼴을 따라 글을 값으로 — 글이었으면 글 · 수·빈 칸이었으면 수 꼴이면 수. */
export const coerce = (text: string, orig: unknown): unknown =>
typeof orig === "string" ? text : toNumberish(text);
/** 「아래 ~ 위」 → [아래, 위] (빈 쪽 = null · 「~」 없으면 한 값). */
export function toRange(text: string): unknown {
if (!text.includes("~")) return toNumberish(text);
return text.split("~").map((s) => (s.trim() === "" ? null : toNumberish(s)));
}
/** 칸을 눌러 고침 — Enter·칸 밖 누름 = 확정 · Esc = 취소. */
export function startEdit(td: HTMLElement, text: string, commit: (value: string) => void): void {
if (td.querySelector("input")) return;
const input = el("input", { className: "m01-master__cell-input", attrs: { type: "text" } });
input.value = text;
td.textContent = "";
td.append(input);
input.focus();
input.select();
let done = false;
const finish = (ok: boolean): void => {
if (done) return;
done = true;
if (ok && input.value !== text) commit(input.value);
else td.textContent = text;
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") finish(true);
else if (e.key === "Escape") finish(false);
});
input.addEventListener("blur", () => finish(true));
}
/** 쪽 나눔 띠 — 이전·다음 · 「n / N 쪽 · 전체 줄」. */
export function buildPager(
total: number,
size: number,
page: number,
goto: (page: number) => void,
): HTMLElement {
const last = Math.max(1, Math.ceil(total / size));
const prev = createButton({ label: L("M01_Prev"), variant: "ghost", disabled: page <= 1 });
const next = createButton({ label: L("M01_Next"), variant: "ghost", disabled: page >= last });
prev.addEventListener("click", () => goto(page - 1));
next.addEventListener("click", () => goto(page + 1));
return el("div", {
className: "m01-master__pager",
children: [
el("span", {
text: `${page} / ${last} ${L("M01_Page")} · ${total.toLocaleString("ko-KR")} ${L("M01_Rows")}`,
}),
prev,
next,
],
});
}
/** 칸 하나 — 노랑(고침) · 눌러 고침. */
export function buildCell(
text: string,
opts: { changed: boolean; onEdit?: (value: string) => void },
): HTMLTableCellElement {
const td = el("td", { text, attrs: { title: text } });
td.classList.toggle("is-changed", opts.changed);
if (opts.onEdit) {
const onEdit = opts.onEdit;
td.classList.add("is-editable");
td.addEventListener("click", () => startEdit(td, text, onEdit));
}
return td;
}
@@ -0,0 +1,164 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Api.ts
* 로직 화면이 부르는 서버 길 — 계약 `resources/master_data/_화면_계약.md` · `/api/m01`
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
/** 로직 한 줄 — 파일 그대로(한글 칸) · 틀 `_틀.md` 6장 */
export interface LogicInput {
이름: string;
단위?: string;
고르기?: string[];
?: [number, number];
}
export interface HoLine {
종류: string;
요소: string;
이름?: string;
규격?: string;
단위?: string;
수량: string;
비목?: string;
}
export interface NamedFormula {
이름: string;
: string;
비목?: string;
출처?: string;
}
export interface LogicRow {
열쇠: string;
이름: string;
결과단위: string;
출처: string;
소유?: string;
입력: LogicInput[];
중간: NamedFormula[];
호표?: HoLine[];
?: { : string };
덧줄?: NamedFormula[];
끝수?: string | null;
비고?: string;
[extra: string]: unknown;
}
export interface LogicSummary {
file: string;
book: string;
chapter: string;
열쇠: string;
이름: string;
결과단위: string;
출처: string;
blocked: boolean;
reasons: string[];
}
export interface ElementBrief {
ref: string;
file?: string;
이름?: string;
규격?: string;
단위?: unknown;
값?: unknown;
값칸?: Record<string, string> | null;
}
export interface LogicOne {
file: string;
version: string;
logic: LogicRow;
blocked: boolean;
reasons: string[];
prices: Record<string, ElementBrief | null>;
}
export interface CalcLine {
이름: string;
단위: string;
수량: number;
단가: number;
금액: number;
비목: Record<string, number>;
}
export type CalcAnswer =
| {
ok: true;
lines?: CalcLine[];
sums?: Record<string, number>;
result?: unknown;
middle: Record<string, unknown>;
}
| { ok: false; reason: string };
export interface LogicFile {
file: string;
book: string;
chapter: string;
version: string;
}
export interface SaveChange {
op: "edit" | "add" | "delete";
key?: string;
row?: LogicRow;
}
export interface SaveFile {
file: string;
version: string;
changes: SaveChange[];
}
/** 서버가 준 몸과 상태 번호 — 409·422 는 몸(`detail`)을 화면이 풀어 보임 */
export class ApiError extends Error {
constructor(
public status: number,
public detail: unknown,
) {
super(typeof detail === "string" ? detail : JSON.stringify(detail));
}
}
async function request<T>(path: string, body?: unknown): Promise<T> {
const response = await fetch(`${API_BASE_URL}/m01${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
return data;
}
const query = (params: Record<string, string>): string => new URLSearchParams(params).toString();
export const fetchLogics = (): Promise<LogicSummary[]> =>
request<{ logics: LogicSummary[] }>("/logics").then((d) => d.logics);
export const fetchLogic = (book: string, key: string): Promise<LogicOne> =>
request(`/logic?${query({ book, key })}`);
export const fetchLogicFiles = (): Promise<LogicFile[]> =>
request<{ files: LogicFile[] }>(`/groups/${encodeURIComponent("로직")}/files`).then(
(d) => d.files,
);
export const searchElements = (
group: string,
q: string,
): Promise<{ total: number; items: ElementBrief[] }> =>
request(`/elements?${query({ group, q, limit: "100" })}`);
export const runCalc = (body: {
book: string;
key: string;
inputs: Record<string, unknown>;
row?: LogicRow;
file?: string;
}): Promise<CalcAnswer> => request("/calc", body);
export const saveFiles = (
files: SaveFile[],
): Promise<{ files: { file: string; version: string }[] }> => request("/save", { files });
@@ -0,0 +1,164 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Calc.ts
* 시험 계산 — 받을 값을 넣고 [계산] → 줄별 금액 · 비목 합 · 멈춘 까닭(엔진 글 그대로)
* 고친 로직은 저장 전 줄(`row`)을 같이 보내 메모리에서 셈(`POST /calc`) — 파일에 안 씀
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import {
runCalc,
type CalcAnswer,
type CalcLine,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { tx } from "./M01_MasterData_UI_Logic_Text";
export interface CalcContext {
book: string;
/** 저장된 열쇠 — 새 로직은 null */
savedKey: string | null;
file: string;
row: LogicRow;
dirty: () => boolean;
/** 로직마다 넣은 값 — 다시 그려도 남음 */
values: Record<string, string>;
onLines: (lines: CalcLine[] | null) => void;
}
const SUMS = ["노무비", "재료비", "경비", "계"];
export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
const fields = (ctx.row. ?? []).map((spec) => {
const name = spec.;
let control: HTMLInputElement | HTMLSelectElement;
if (spec.?.length) {
control = el("select", { className: "m01-logic__input" });
for (const option of ["", ...spec.]) {
control.append(el("option", { text: option, attrs: { value: option } }));
}
} else {
control = el("input", {
className: "m01-logic__input",
attrs: { type: "text", inputmode: "decimal" },
});
if (spec.) control.placeholder = `${spec.[0]} ${spec.[1]}`;
}
control.value = ctx.values[name] ?? "";
control.addEventListener("input", () => (ctx.values[name] = control.value));
control.addEventListener("change", () => (ctx.values[name] = control.value));
const label = spec. ? `${name} (${spec.})` : name;
return el("label", {
className: "m01-logic__field",
children: [el("span", { text: label }), control],
});
});
const out = el("div", { className: "m01-logic__calc-out" });
const run = async (): Promise<void> => {
const inputs: Record<string, unknown> = {};
for (const spec of ctx.row. ?? []) {
const raw = (ctx.values[spec.] ?? "").trim();
if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤
inputs[spec.] = spec.?.length || Number.isNaN(Number(raw)) ? raw : Number(raw);
}
const draft = ctx.dirty() || ctx.savedKey === null;
try {
const answer = await runCalc({
book: ctx.book,
key: ctx.savedKey ?? ctx.row.,
inputs,
...(draft ? { row: ctx.row, file: ctx.file } : {}),
});
ctx.onLines(answer.ok ? (answer.lines ?? null) : null);
out.replaceChildren(...answerView(answer, draft));
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
host.replaceChildren(
el("h3", { text: tx("Calc_Title") }),
...(fields.length
? fields
: [el("p", { className: "m01-logic__muted", text: tx("Calc_NoInputs") })]),
createButton({ label: tx("Calc_Run"), onClick: () => void run() }),
out,
);
}
function answerView(answer: CalcAnswer, draft: boolean): HTMLElement[] {
const note = draft ? [el("p", { className: "m01-logic__muted", text: tx("Calc_Draft") })] : [];
if (!answer.ok) {
return [
...note,
el("div", {
className: "m01-logic__reasons",
children: [el("strong", { text: tx("Calc_Stopped") }), el("div", { text: answer.reason })],
}),
];
}
const parts: HTMLElement[] = [...note];
if (answer.lines) {
const rows = answer.lines.map((line) =>
el("tr", {
children: [
el("td", { text: line.이름 }),
el("td", { text: `${formatNumber(line.)} ${line.}` }),
el("td", { className: "m01-logic__money", text: formatNumber(line.) }),
],
}),
);
parts.push(
el("table", {
className: "m01-logic__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [tx("Head_Name"), tx("Ho_Qty"), tx("Ho_Amount")].map((h) =>
el("th", { text: h }),
),
}),
],
}),
el("tbody", { children: rows }),
],
}),
);
}
if (answer.sums) {
parts.push(
el("dl", {
className: "m01-logic__sums",
children: SUMS.flatMap((k) => [
el("dt", { text: k === "계" ? tx("Calc_Sum") : k }),
el("dd", { className: "m01-logic__money", text: formatNumber(answer.sums?.[k] ?? 0) }),
]),
}),
);
}
if (answer.result !== undefined) {
parts.push(
el("dl", {
className: "m01-logic__sums",
children: [
el("dt", { text: tx("Calc_Result") }),
el("dd", { text: formatNumber(answer.result) }),
],
}),
);
}
const middle = Object.entries(answer.middle ?? {});
if (middle.length) {
parts.push(
el("h4", { text: tx("Middle_Title") }),
el("dl", {
className: "m01-logic__sums",
children: middle.flatMap(([k, v]) => [
el("dt", { text: k }),
el("dd", { text: formatNumber(v) }),
]),
}),
);
}
return parts;
}
@@ -0,0 +1,423 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Edit.ts
* 로직 한 줄 고치기 — 실무 일위대가표(호표) 모양
* 머리(이름·결과단위·출처·소유) · 설계에서 받을 값 · 호표 줄 · 중간 값 · 결과 식 · 덧줄 · 끝수
*
* 칸을 고치면 `row` 를 그 자리에서 바꾸고 `onChange` 만 부름(다시 그리지 않음).
* 줄 더하기·지우기 · 요소 고르기처럼 모양이 바뀔 때만 `rerender`.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type {
CalcLine,
ElementBrief,
HoLine,
LogicInput,
LogicRow,
NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
import { tx } from "./M01_MasterData_UI_Logic_Text";
export type PickMode = "element" | "table";
export type PickDone = (item: ElementBrief, column?: string) => void;
export interface EditContext {
row: LogicRow;
file: string;
isNew: boolean;
files: string[];
reasons: string[];
prices: Record<string, ElementBrief | null>;
/** 마지막 시험 계산의 줄 — 호표 차례와 같음(덧줄은 뒤에 붙음) */
lines: CalcLine[] | null;
onChange: () => void;
onFile: (file: string) => void;
onPick: (mode: PickMode, group: string, done: PickDone) => void;
}
const KINDS = ["인력", "재료", "기계", "로직"];
const COSTS = ["노무비", "재료비", "경비"];
const COST_OF: Record<string, string> = { : "노무비", : "재료비", : "경비" };
export function formatNumber(value: unknown): string {
if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
if (value === null || value === undefined) return "";
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
function textBox(value: unknown, onInput: (v: string) => void, className = ""): HTMLInputElement {
const input = el("input", {
className: `m01-logic__input ${className}`,
attrs: { type: "text" },
});
input.value = value === null || value === undefined ? "" : String(value);
input.addEventListener("input", () => onInput(input.value));
return input;
}
function choice(
options: string[],
value: string | undefined,
onPick: (v: string) => void,
): HTMLSelectElement {
const select = el("select", { className: "m01-logic__input" });
for (const option of value && !options.includes(value) ? [value, ...options] : options) {
select.append(el("option", { text: option, attrs: { value: option } }));
}
select.value = value ?? options[0];
select.addEventListener("change", () => onPick(select.value));
return select;
}
function dropButton(onClick: () => void): HTMLButtonElement {
const button = el("button", {
className: "m01-logic__drop",
text: "×",
attrs: { type: "button", title: tx("DeleteRow") },
});
button.addEventListener("click", onClick);
return button;
}
function section(title: string, body: HTMLElement, onAdd?: () => void): HTMLElement {
const head = el("div", {
className: "m01-logic__section-head",
children: [el("h3", { text: title })],
});
if (onAdd) head.append(createButton({ label: tx("AddRow"), variant: "ghost", onClick: onAdd }));
return el("section", { className: "m01-logic__section", children: [head, body] });
}
function grid(headers: string[], rows: HTMLElement[][], className: string): HTMLElement {
const table = el("table", { className: `m01-logic__grid ${className}` });
table.append(
el("thead", { children: [el("tr", { children: headers.map((h) => el("th", { text: h })) })] }),
);
const body = el("tbody");
for (const cells of rows)
body.append(el("tr", { children: cells.map((c) => el("td", { children: [c] })) }));
table.append(body);
return el("div", { className: "m01-logic__scroll", children: [table] });
}
export function qtyKind(qty: string): string {
const text = qty.trim();
if (/^-?\d+(\.\d+)?$/.test(text)) return tx("Ho_QtyNumber");
if (text.startsWith("찾기(")) return tx("Ho_QtyTable");
return tx("Ho_QtyFormula");
}
export function buildEditor(host: HTMLElement, ctx: EditContext): void {
const rerender = (): void => {
ctx.onChange();
buildEditor(host, ctx);
};
const row = ctx.row;
const set = (patch: () => void): void => {
patch();
ctx.onChange();
};
host.innerHTML = "";
host.append(head(ctx, set, rerender));
if (ctx.reasons.length) {
host.append(
el("div", {
className: "m01-logic__reasons",
children: [
el("strong", { text: tx("Head_Blocked") }),
...ctx.reasons.map((r) => el("div", { text: r })),
],
}),
);
}
host.append(inputsSection(row, set, rerender));
const money = !("결과" in row) && (row. ?? "").startsWith("원");
if (money) host.append(hoSection(ctx, set, rerender));
host.append(middleSection(row, set, rerender));
if (!money) {
const formula = textBox(row.?. ?? "", (v) =>
set(() => {
row. = { : v };
}),
);
host.append(section(tx("Result_Title"), formula));
} else {
host.append(extraSection(row, set, rerender));
const rounding = textBox(row. ?? "", (v) =>
set(() => {
row. = v.trim() ? v : null;
}),
);
host.append(section(tx("Rounding"), rounding));
}
}
function field(label: string, control: HTMLElement, wide = false): HTMLElement {
return el("label", {
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
children: [el("span", { text: label }), control],
});
}
function head(ctx: EditContext, set: (p: () => void) => void, rerender: () => void): HTMLElement {
const row = ctx.row;
const file = ctx.isNew
? choice(ctx.files, ctx.file, (v) => ctx.onFile(v))
: el("span", { className: "m01-logic__file", text: ctx.file });
const unit = textBox(row., (v) => set(() => (row. = v)));
unit.addEventListener("change", rerender); // 돈 로직 ↔ 돈 아닌 로직 칸이 바뀜
const note = el("textarea", { className: "m01-logic__input m01-logic__note" });
note.value = row. ?? "";
note.addEventListener("input", () =>
set(() => {
if (note.value.trim()) row. = note.value;
else delete row.;
}),
);
return el("div", {
className: "m01-logic__head",
children: [
field(tx("Head_File"), file, true),
field(
tx("Head_Key"),
textBox(row., (v) => set(() => (row. = v))),
),
field(
tx("Head_Name"),
textBox(row., (v) => set(() => (row. = v))),
),
field(tx("Head_Unit"), unit),
field(
tx("Head_Source"),
textBox(row., (v) => set(() => (row. = v))),
),
field(
tx("Head_Owner"),
choice(["공용", "개인"], row., (v) => set(() => (row. = v))),
),
field(tx("Head_Note"), note, true),
],
});
}
function inputsSection(
row: LogicRow,
set: (p: () => void) => void,
rerender: () => void,
): HTMLElement {
const list = row. ?? [];
const rows = list.map((spec: LogicInput, i) => {
const low = textBox(spec.?.[0] ?? "", () => range(), "m01-logic__num");
const high = textBox(spec.?.[1] ?? "", () => range(), "m01-logic__num");
const range = (): void =>
set(() => {
if (low.value.trim() === "" && high.value.trim() === "") delete spec.;
else spec. = [Number(low.value), Number(high.value)];
});
return [
textBox(spec., (v) => set(() => (spec. = v))),
textBox(spec. ?? "", (v) =>
set(() => {
if (v.trim()) spec. = v;
else delete spec.;
}),
),
textBox((spec. ?? []).join(", "), (v) =>
set(() => {
const items = v
.split(",")
.map((x) => x.trim())
.filter(Boolean);
if (items.length) spec. = items;
else delete spec.;
}),
),
el("div", {
className: "m01-logic__range",
children: [low, el("span", { text: "" }), high],
}),
dropButton(() => {
list.splice(i, 1);
rerender();
}),
];
});
const headers = [
tx("Head_Name"),
tx("Inputs_Unit"),
tx("Inputs_Choices"),
tx("Inputs_Range"),
"",
];
return section(tx("Inputs_Title"), grid(headers, rows, "m01-logic__grid--inputs"), () => {
(row. ??= []).push({ : "" });
rerender();
});
}
function priceCell(ctx: EditContext, item: HoLine, i: number): HTMLElement {
const line = ctx.lines?.[i];
if (line) return el("span", { className: "m01-logic__money", text: formatNumber(line.) });
if (item. === "로직" || item..includes("{")) {
return el("span", { className: "m01-logic__muted", text: tx("Ho_PriceLater") });
}
const brief = ctx.prices[item.];
if (brief === undefined) return el("span", { className: "m01-logic__muted", text: "" });
if (brief === null) return el("span", { className: "m01-logic__bad", text: tx("Ho_Missing") });
return el("span", { className: "m01-logic__money", text: formatNumber(brief.) });
}
function hoSection(
ctx: EditContext,
set: (p: () => void) => void,
rerender: () => void,
): HTMLElement {
const list = ctx.row. ?? [];
const rows = list.map((item: HoLine, i) => {
const element = textBox(item., (v) => set(() => (item. = v)), "m01-logic__ref");
const pick = createButton({
label: tx("Ho_Pick"),
variant: "ghost",
onClick: () =>
ctx.onPick("element", item., (found) => {
const [group, book] = found.ref.split(":");
const key = found.ref.slice(group.length + book.length + 2);
item. = group === "로직" ? `로직(${book}:${key})` : found.ref;
if (KINDS.includes(group)) item. = group;
item. = found. ?? item.;
if (found.) item. = found.;
else delete item.;
if (!item. && COST_OF[item.]) item. = COST_OF[item.];
if (group !== "로직") ctx.prices[found.ref] = found;
rerender();
}),
});
const brief = ctx.prices[item.];
const spec = item. ?? brief?. ?? "";
const qty = textBox(item., (v) => {
set(() => (item. = v));
tag.textContent = qtyKind(v);
});
const tag = el("span", { className: "m01-logic__tag", text: qtyKind(item.) });
const table = createButton({
label: tx("Ho_PickTable"),
variant: "ghost",
onClick: () =>
ctx.onPick("table", "소요량", (found, column) => {
const ref = found.ref;
item. = `찾기(${ref})${column ? `.${column}` : ""}`;
rerender();
}),
});
const cost =
item. === "로직" && !item.
? choice(["", ...COSTS], "", (v) => set(() => (item. = v || undefined)))
: choice(COSTS, item., (v) => set(() => (item. = v)));
return [
choice(KINDS, item., (v) => {
item. = v;
rerender();
}),
el("div", { className: "m01-logic__pair", children: [element, pick] }),
el("div", {
className: "m01-logic__stack",
children: [
textBox(item. ?? "", (v) => set(() => (item. = v))),
el("span", { className: "m01-logic__muted", text: spec }),
],
}),
textBox(item. ?? "", (v) => set(() => (item. = v)), "m01-logic__unit"),
el("div", { className: "m01-logic__pair", children: [tag, qty, table] }),
priceCell(ctx, item, i),
el("span", { className: "m01-logic__money", text: formatNumber(ctx.lines?.[i]?. ?? "") }),
cost,
dropButton(() => {
list.splice(i, 1);
rerender();
}),
];
});
const headers = [
tx("Ho_Kind"),
tx("Ho_Element"),
tx("Ho_Name"),
tx("Ho_Unit"),
tx("Ho_Qty"),
tx("Ho_Price"),
tx("Ho_Amount"),
tx("Ho_Cost"),
"",
];
return section(tx("Ho_Title"), grid(headers, rows, "m01-logic__grid--ho"), () => {
(ctx.row. ??= []).push({
: "인력",
: "",
: "",
: "인",
: "",
: "노무비",
});
rerender();
});
}
function formulaRows(
list: NamedFormula[],
set: (p: () => void) => void,
rerender: () => void,
withCost: boolean,
): HTMLElement[][] {
return list.map((item, i) => {
const cells: HTMLElement[] = [
textBox(item., (v) => set(() => (item. = v))),
textBox(item., (v) => set(() => (item. = v)), "m01-logic__formula"),
];
if (withCost) {
cells.push(
choice(COSTS, item., (v) => set(() => (item. = v))),
textBox(item. ?? "", (v) => set(() => (item. = v))),
);
}
cells.push(
dropButton(() => {
list.splice(i, 1);
rerender();
}),
);
return cells;
});
}
function middleSection(
row: LogicRow,
set: (p: () => void) => void,
rerender: () => void,
): HTMLElement {
const list = row. ?? [];
const table = grid(
[tx("Head_Name"), tx("Formula"), ""],
formulaRows(list, set, rerender, false),
"",
);
return section(tx("Middle_Title"), table, () => {
(row. ??= []).push({ : "", : "" });
rerender();
});
}
function extraSection(
row: LogicRow,
set: (p: () => void) => void,
rerender: () => void,
): HTMLElement {
const list = row. ?? [];
const headers = [tx("Head_Name"), tx("Formula"), tx("Ho_Cost"), tx("Head_Source"), ""];
return section(
tx("Extra_Title"),
grid(headers, formulaRows(list, set, rerender, true), ""),
() => {
(row. ??= []).push({ : "", : "", : "재료비", : "" });
rerender();
},
);
}
@@ -0,0 +1,155 @@
/* =============================================================================
* M01_MasterData_UI_Logic_List.ts
* 왼쪽 로직 목록 — 원문 · 장 · 이름 찾기 · 막힘만 · 막힘(⛔)·고침(●) 표시
* 거르기는 화면에서(목록은 한 번 받음 · 391 줄 남짓)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import type { LogicSummary } from "./M01_MasterData_UI_Logic_Api";
import { tx } from "./M01_MasterData_UI_Logic_Text";
export type ListMark = "edited" | "new" | "deleted";
export interface ListItem {
id: string;
book: string;
chapter: string;
key: string;
name: string;
blocked: boolean;
reasons: string[];
}
export interface ListHandle {
root: HTMLElement;
setItems: (items: LogicSummary[]) => void;
/** 저장 안 한 새 로직도 목록 맨 위에 */
setMarks: (marks: Map<string, ListMark>, extra: ListItem[]) => void;
setActive: (id: string | null) => void;
}
export const logicId = (book: string, key: string): string => `${book}\n${key}`;
export function buildList(onOpen: (item: ListItem) => void): ListHandle {
let items: ListItem[] = [];
let extra: ListItem[] = [];
let marks = new Map<string, ListMark>();
let active: string | null = null;
const book = el("select", { className: "m01-logic__input" });
const chapter = el("select", { className: "m01-logic__input" });
const search = el("input", {
className: "m01-logic__input",
attrs: { type: "search", placeholder: tx("List_Search") },
});
const blockedOnly = el("input", { attrs: { type: "checkbox" } });
const count = el("span", { className: "m01-logic__muted" });
const list = el("ul", { className: "m01-logic__list" });
const options = (select: HTMLSelectElement, all: string, values: string[]): void => {
const keep = select.value;
select.replaceChildren(
el("option", { text: all, attrs: { value: "" } }),
...values.map((v) => el("option", { text: v, attrs: { value: v } })),
);
select.value = values.includes(keep) ? keep : "";
};
const chapters = (): void =>
options(chapter, tx("List_AllChapters"), [
...new Set(items.filter((x) => !book.value || x.book === book.value).map((x) => x.chapter)),
]);
const draw = (): void => {
const q = search.value.trim().toLowerCase();
const shown = [...extra, ...items].filter(
(x) =>
(!book.value || x.book === book.value) &&
(!chapter.value || x.chapter === chapter.value) &&
(!blockedOnly.checked || x.blocked) &&
(!q || x.key.toLowerCase().includes(q) || x.name.toLowerCase().includes(q)),
);
count.textContent = tx("List_Count", { n: shown.length });
list.replaceChildren(...shown.slice(0, 500).map(line));
};
const line = (item: ListItem): HTMLElement => {
const mark = marks.get(item.id);
const badges: HTMLElement[] = [];
if (item.blocked) {
badges.push(
el("span", {
className: "m01-logic__badge m01-logic__badge--blocked",
text: `${tx("List_Blocked")}`,
attrs: { title: item.reasons.join("\n") },
}),
);
}
if (mark) {
const label = { edited: "List_Edited", new: "List_New", deleted: "List_Deleted" } as const;
badges.push(el("span", { className: "m01-logic__badge", text: `${tx(label[mark])}` }));
}
const button = el("button", {
className: `m01-logic__item${item.id === active ? " is-active" : ""}`,
attrs: { type: "button", title: item.key },
children: [
el("span", { className: "m01-logic__muted", text: `${item.book} ${item.chapter}` }),
el("span", { text: item.name || item.key }),
el("span", { className: "m01-logic__badges", children: badges }),
],
});
button.addEventListener("click", () => onOpen(item));
return el("li", { children: [button] });
};
book.addEventListener("change", () => {
chapters();
draw();
});
chapter.addEventListener("change", draw);
search.addEventListener("input", draw);
blockedOnly.addEventListener("change", draw);
const root = el("aside", {
className: "m01-logic__side",
children: [
el("div", {
className: "m01-logic__filters",
children: [
book,
chapter,
search,
el("label", {
className: "m01-logic__check",
children: [blockedOnly, el("span", { text: tx("List_BlockedOnly") })],
}),
count,
],
}),
list,
],
});
return {
root,
setItems: (logics) => {
items = logics.map((x) => ({
id: logicId(x.book, x.),
book: x.book,
chapter: x.chapter,
key: x.열쇠,
name: x.이름,
blocked: x.blocked,
reasons: x.reasons,
}));
options(book, tx("List_AllBooks"), [...new Set(items.map((x) => x.book))]);
chapters();
draw();
},
setMarks: (next, more) => {
marks = next;
extra = more;
draw();
},
setActive: (id) => {
active = id;
draw();
},
};
}
@@ -0,0 +1,360 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Page.ts
* 관리자 화면 — 로직 칸. 왼쪽 목록 / 가운데 일위대가표(호표) 고치기 / 오른쪽 시험 계산
* 진입: 공용 진입 파일이 `mountM01Logic(host)` 한 줄로 붙임(요소 칸은 sub_laptop_3 몫)
*
* 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`.
* 409 = 그 사이 파일이 바뀜 · 422 = 검사 걸림(아무것도 안 씀) — 까닭은 서버 글 그대로 보임.
* ========================================================================== */
import {
createButton,
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import {
ApiError,
fetchLogic,
fetchLogicFiles,
fetchLogics,
saveFiles,
type CalcLine,
type ElementBrief,
type LogicFile,
type LogicRow,
type SaveFile,
} from "./M01_MasterData_UI_Logic_Api";
import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import "./M01_MasterData_UI_Logic_Style.css";
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
interface Draft {
book: string;
file: string;
version: string;
origKey: string | null;
row: LogicRow | null;
}
interface Opened extends Draft {
id: string;
row: LogicRow | null;
original: string | null;
prices: Record<string, ElementBrief | null>;
reasons: string[];
lines: CalcLine[] | null;
values: Record<string, string>;
}
const CACHE_KEY = "m01_logic_drafts";
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
function loadDrafts(): Record<string, Draft> {
try {
return JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? "{}") as Record<string, Draft>;
} catch {
return {};
}
}
export async function mountM01Logic(host: HTMLElement): Promise<void> {
let drafts = loadDrafts();
let files: LogicFile[] = [];
let opened: Opened | null = null;
let calcInputs = "";
const editor = el("div", { className: "m01-logic__editor" });
const errors = el("div", { className: "m01-logic__reasons", attrs: { hidden: "" } });
const calc = el("aside", { className: "m01-logic__calc" });
const pending = el("span", { className: "m01-logic__muted" });
const list = buildList((item) => void open(item.id, item.book, item.key));
const persist = (): void => {
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify(drafts));
} catch {
/* 캐시가 막혀도 화면 안의 고친 것은 남음 */
}
const marks = new Map<string, ListMark>();
const extra: ListItem[] = [];
for (const [id, d] of Object.entries(drafts)) {
marks.set(id, d.origKey === null ? "new" : d.row === null ? "deleted" : "edited");
if (d.origKey === null && d.row) {
extra.push({
id,
book: d.book,
chapter: "",
key: d.row.열쇠,
name: d.row.이름,
blocked: false,
reasons: [],
});
}
}
list.setMarks(marks, extra);
const n = Object.keys(drafts).length;
pending.textContent = n ? tx("Bar_Pending", { n }) : "";
saveButton.disabled = discardButton.disabled = n === 0;
};
const touch = (): void => {
if (!opened?.row) return;
const now = JSON.stringify(opened.row);
if (opened.origKey !== null && now === opened.original) delete drafts[opened.id];
else drafts[opened.id] = pick(opened, clone(opened.row));
persist();
const inputs = JSON.stringify(opened.row. ?? []);
if (inputs !== calcInputs) drawCalc();
};
const pick = (o: Opened, row: LogicRow | null): Draft => ({
book: o.book,
file: o.file,
version: o.version,
origKey: o.origKey,
row,
});
const drawCalc = (): void => {
if (!opened?.row) {
calc.replaceChildren();
return;
}
const current = opened;
calcInputs = JSON.stringify(current.row?. ?? []);
buildCalc(calc, {
book: current.book,
savedKey: current.origKey,
file: current.file,
row: current.row as LogicRow,
dirty: () => current.id in drafts,
values: current.values,
onLines: (lines) => {
current.lines = lines;
drawEditor();
},
});
};
const drawEditor = (): void => {
list.setActive(opened?.id ?? null);
if (!opened) {
editor.replaceChildren(el("p", { className: "m01-logic__empty", text: tx("Empty") }));
return;
}
if (!opened.row) {
editor.replaceChildren(
el("p", { className: "m01-logic__empty", text: `${tx("List_Deleted")}` }),
);
return;
}
const current = opened;
buildEditor(editor, {
row: current.row as LogicRow,
file: current.file,
isNew: current.origKey === null,
files: files.map((f) => f.file),
reasons: current.reasons,
prices: current.prices,
lines: current.lines,
onChange: touch,
onFile: (file) => {
const f = files.find((x) => x.file === file);
if (!f) return;
Object.assign(current, { file: f.file, book: f.book, version: f.version });
touch();
},
onPick: openPicker,
});
};
const show = (next: Opened | null): void => {
opened = next;
errors.hidden = true;
drawEditor();
drawCalc();
};
const open = async (id: string, book: string, key: string): Promise<void> => {
const draft = drafts[id];
if (draft?.origKey === null) {
show({ ...draft, id, original: null, prices: {}, reasons: [], lines: null, values: {} });
return;
}
try {
const one = await fetchLogic(book, key);
show({
id,
book,
file: draft?.file ?? one.file,
version: draft?.version ?? one.version,
origKey: key,
row: draft ? (draft.row === null ? null : clone(draft.row)) : clone(one.logic),
original: JSON.stringify(one.logic),
prices: one.prices,
reasons: one.reasons,
lines: null,
values: {},
});
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
const reload = async (): Promise<void> => {
const [logics, logicFiles] = await Promise.all([fetchLogics(), fetchLogicFiles()]);
files = logicFiles;
list.setItems(logics);
persist();
};
const onNew = (): void => {
const f = files.find((x) => x.file === opened?.file) ?? files[0];
if (!f) return;
const id = `new:${Date.now()}`;
const row: LogicRow = {
: `${tx("New_Key")} ${Object.keys(drafts).length + 1}`,
: "",
: "원/",
: "",
: "공용",
: [],
: [],
: [],
: [],
끝수: null,
};
const next: Opened = {
id,
book: f.book,
file: f.file,
version: f.version,
origKey: null,
row,
original: null,
prices: {},
reasons: [],
lines: null,
values: {},
};
drafts[id] = pick(next, clone(row));
persist();
show(next);
};
const onDelete = async (): Promise<void> => {
if (!opened?.row) return;
if (!(await showConfirmDialog(tx("Confirm_Delete", { v: opened.row.열쇠 }), tx("Bar_Delete"))))
return;
if (opened.origKey === null) {
delete drafts[opened.id];
persist();
show(null);
return;
}
drafts[opened.id] = pick(opened, null);
opened.row = null;
persist();
show(opened);
};
const onDiscard = async (): Promise<void> => {
if (!(await showConfirmDialog(tx("Confirm_Discard"), tx("Bar_Discard")))) return;
drafts = {};
persist();
const was = opened;
if (was && was.origKey !== null)
await open(logicId(was.book, was.origKey), was.book, was.origKey);
else show(null);
};
const onSave = async (): Promise<void> => {
const byFile = new Map<string, SaveFile>();
for (const d of Object.values(drafts)) {
const part = byFile.get(d.file) ?? { file: d.file, version: d.version, changes: [] };
if (d.origKey === null) {
if (d.row) part.changes.push({ op: "add", row: d.row });
} else if (d.row === null) part.changes.push({ op: "delete", key: d.origKey });
else part.changes.push({ op: "edit", key: d.origKey, row: d.row });
byFile.set(d.file, part);
}
if (!byFile.size) {
showToast(tx("Save_Nothing"), "info");
return;
}
showLoadingOverlay();
try {
await saveFiles([...byFile.values()]);
const was = opened;
drafts = {};
await reload();
showToast(tx("Save_Done"), "success");
if (was?.row) await open(logicId(was.book, was.row.), was.book, was.row.);
else show(null);
} catch (error) {
failed(error);
} finally {
hideLoadingOverlay();
}
};
const failed = (error: unknown): void => {
const detail =
error instanceof ApiError ? (error.detail as { stale?: string[]; errors?: string[] }) : null;
if (error instanceof ApiError && error.status === 409) {
showToast(tx("Save_Stale", { v: (detail?.stale ?? []).join(", ") }), "error");
} else if (error instanceof ApiError && error.status === 422) {
errors.replaceChildren(
el("strong", { text: tx("Save_Errors") }),
...(detail?.errors ?? []).map((x) => el("div", { text: x })),
);
errors.hidden = false;
} else {
showToast(error instanceof Error ? error.message : tx("Save_Failed"), "error");
}
};
const saveButton = createButton({ label: tx("Bar_Save"), onClick: () => void onSave() });
const discardButton = createButton({
label: tx("Bar_Discard"),
variant: "ghost",
onClick: () => void onDiscard(),
});
const bar = el("div", {
className: "m01-logic__bar",
children: [
el("h2", { text: tx("Title") }),
pending,
createButton({ label: tx("Bar_New"), variant: "ghost", onClick: onNew }),
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
discardButton,
saveButton,
],
});
host.replaceChildren(
el("div", {
className: "m01-logic",
children: [
list.root,
el("main", { className: "m01-logic__main", children: [bar, errors, editor] }),
calc,
],
}),
);
show(null);
showLoadingOverlay();
try {
await reload();
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
@@ -0,0 +1,109 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Pick.ts
* · (`GET /elements`)
* (·) `찾기(…).칸`
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { searchElements, type ElementBrief } from "./M01_MasterData_UI_Logic_Api";
import { formatNumber, type PickDone, type PickMode } from "./M01_MasterData_UI_Logic_Edit";
import { tx } from "./M01_MasterData_UI_Logic_Text";
const GROUPS: Record<PickMode, string[]> = {
element: ["인력", "재료", "기계", "로직"],
table: ["소요량", "계수"],
};
export function openPicker(mode: PickMode, group: string, done: PickDone): void {
const groups = GROUPS[mode];
const select = el("select", { className: "m01-logic__input" });
for (const g of groups) select.append(el("option", { text: g, attrs: { value: g } }));
select.value = groups.includes(group) ? group : groups[0];
const search = el("input", {
className: "m01-logic__input",
attrs: { type: "search", placeholder: tx("Pick_Search") },
});
const count = el("span", { className: "m01-logic__muted" });
const list = el("div", { className: "m01-logic__pick-list" });
const close = (): void => backdrop.remove();
const dialog = el("div", {
className: "m01-logic__pick",
attrs: { role: "dialog", "aria-label": tx("Pick_Title") },
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h3", { text: tx("Pick_Title") }),
createButton({ label: tx("Pick_Close"), variant: "ghost", onClick: close }),
],
}),
el("div", { className: "m01-logic__pick-bar", children: [select, search, count] }),
list,
],
});
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
const choose = (item: ElementBrief, column?: string): void => {
close();
done(item, column);
};
let seq = 0;
const load = async (): Promise<void> => {
const mine = ++seq;
try {
const found = await searchElements(select.value, search.value.trim());
if (mine !== seq) return; // 최신 응답만
count.textContent = tx("Pick_Total", { n: found.total });
list.replaceChildren(...found.items.map((item) => itemRow(item, mode, choose)));
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
let timer = 0;
search.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(load, 250);
});
select.addEventListener("change", load);
document.body.append(backdrop);
search.focus();
void load();
}
function itemRow(
item: ElementBrief,
mode: PickMode,
choose: (item: ElementBrief, column?: string) => void,
): HTMLElement {
const text = el("div", {
className: "m01-logic__stack",
children: [
el("strong", { text: `${item. ?? ""} ${item. ?? ""}`.trim() }),
el("span", { className: "m01-logic__muted", text: item.ref }),
],
});
if (mode === "table") {
const columns = Object.keys(item. ?? {});
const chips = columns.map((column) =>
createButton({ label: column, variant: "pill", onClick: () => choose(item, column) }),
);
return el("div", {
className: "m01-logic__pick-row",
children: [text, el("div", { className: "m01-logic__chips", children: chips })],
});
}
const unit = typeof item. === "string" ? item. : "";
const value = typeof item. === "object" && item. !== null ? "{…}" : formatNumber(item.);
const row = el("button", {
className: "m01-logic__pick-row m01-logic__pick-row--button",
attrs: { type: "button" },
children: [
text,
el("span", { className: "m01-logic__money", text: `${value} ${unit}`.trim() }),
],
});
row.addEventListener("click", () => choose(item));
return row;
}
@@ -0,0 +1,388 @@
/* M01 로직 칸 — 왼쪽 목록 / 가운데 일위대가표 / 오른쪽 시험 계산 */
.m01-logic [hidden] {
display: none !important;
}
.m01-logic {
display: grid;
grid-template-columns: 240px minmax(0, 1fr) 280px;
gap: var(--spacing-12);
height: 100%;
min-height: 0;
padding: var(--spacing-12);
box-sizing: border-box;
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m01-logic__side,
.m01-logic__main,
.m01-logic__calc {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-height: 0;
overflow: auto;
}
.m01-logic__side,
.m01-logic__calc {
padding: var(--spacing-8);
border-radius: var(--radius-cards);
background: var(--color-surface);
}
.m01-logic__filters {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-logic__check {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.m01-logic__list {
margin: 0;
padding: 0;
list-style: none;
overflow: auto;
}
.m01-logic__item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
padding: var(--spacing-4) var(--spacing-8);
border: 0;
border-radius: var(--radius-buttons);
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.m01-logic__item:hover {
background: var(--color-paper);
}
.m01-logic__item.is-active {
background: var(--color-mist-violet);
color: var(--color-accent);
}
.m01-logic__badges {
display: flex;
gap: var(--spacing-4);
}
.m01-logic__badge {
font-size: var(--text-caption);
color: var(--color-warning);
}
.m01-logic__badge--blocked {
color: var(--color-danger);
}
.m01-logic__bar {
display: flex;
align-items: center;
gap: var(--spacing-8);
flex-wrap: wrap;
}
.m01-logic__bar h2 {
margin: 0 auto 0 0;
font-size: 18px;
color: var(--color-text);
}
.m01-logic__empty {
color: var(--color-text-muted);
}
.m01-logic__head {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--spacing-8);
}
.m01-logic__field {
display: flex;
flex-direction: column;
gap: 2px;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.m01-logic__field--wide {
grid-column: 1 / -1;
}
.m01-logic__file {
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m01-logic__input {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
}
.m01-logic__note {
min-height: 48px;
resize: vertical;
}
.m01-logic__section {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-logic__section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
}
.m01-logic__section-head h3,
.m01-logic__calc h3 {
margin: 0;
font-size: var(--text-body-sm);
color: var(--color-text);
}
.m01-logic__grid {
width: 100%;
border-collapse: collapse;
}
.m01-logic__grid th,
.m01-logic__grid td {
padding: 2px 4px;
border-bottom: 1px solid var(--color-border);
vertical-align: top;
text-align: left;
}
.m01-logic__grid th {
color: var(--color-text-secondary);
font-size: var(--text-caption);
font-weight: 600;
}
.m01-logic__grid th,
.m01-logic__grid .ui-btn {
white-space: nowrap;
}
.m01-logic__scroll {
overflow-x: auto;
}
.m01-logic__grid--ho {
min-width: 1124px;
table-layout: fixed;
}
.m01-logic__grid--ho th:nth-child(1) {
width: 76px;
}
.m01-logic__grid--ho th:nth-child(2) {
width: 220px;
}
.m01-logic__grid--ho th:nth-child(3) {
width: 150px;
}
.m01-logic__grid--ho th:nth-child(4) {
width: 56px;
}
.m01-logic__grid--ho th:nth-child(5) {
width: 300px;
}
.m01-logic__grid--ho th:nth-child(6),
.m01-logic__grid--ho th:nth-child(7) {
width: 100px;
}
.m01-logic__grid--ho th:nth-child(8) {
width: 90px;
}
.m01-logic__grid--ho th:nth-child(9) {
width: 32px;
}
.m01-logic__pair {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.m01-logic__stack {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.m01-logic__range {
display: flex;
align-items: center;
gap: 2px;
}
.m01-logic__num {
width: 64px;
}
.m01-logic__tag {
flex: none;
padding: 0 6px;
border-radius: var(--radius-pills);
background: var(--color-mist-violet);
color: var(--color-accent);
font-size: var(--text-caption);
}
.m01-logic__money {
display: block;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.m01-logic__muted {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m01-logic__bad {
color: var(--color-danger);
font-size: var(--text-caption);
}
.m01-logic__drop {
border: 0;
background: none;
color: var(--color-text-muted);
font-size: 16px;
cursor: pointer;
}
.m01-logic__drop:hover {
color: var(--color-danger);
}
.m01-logic__reasons {
padding: var(--spacing-8);
border-left: 3px solid var(--color-danger);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-danger);
}
.m01-logic__sums {
display: grid;
grid-template-columns: auto 1fr;
gap: 2px var(--spacing-8);
margin: 0;
}
.m01-logic__sums dt {
color: var(--color-text-secondary);
}
.m01-logic__sums dd {
margin: 0;
text-align: right;
}
.m01-logic__calc-out {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m01-logic__backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--color-ink) 30%, transparent);
}
.m01-logic__pick {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: min(720px, 92vw);
max-height: 80vh;
padding: var(--spacing-16);
border-radius: var(--radius-xl);
background: var(--color-surface-raised);
font-size: var(--text-body-sm);
}
.m01-logic__pick-bar {
display: grid;
grid-template-columns: 120px 1fr auto;
align-items: center;
gap: var(--spacing-8);
}
.m01-logic__pick-list {
overflow: auto;
}
.m01-logic__pick-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
width: 100%;
padding: var(--spacing-4) var(--spacing-8);
border: 0;
border-bottom: 1px solid var(--color-border);
background: none;
color: inherit;
font: inherit;
text-align: left;
}
.m01-logic__pick-row--button {
cursor: pointer;
}
.m01-logic__pick-row--button:hover {
background: var(--color-paper);
}
.m01-logic__chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}
@@ -0,0 +1,99 @@
/* =============================================================================
* M01_MasterData_UI_Logic_Text.ts
* [, ] · ·
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["일위대가 로직", "Unit-cost logic"],
List_Search: ["이름 찾기", "Search name"],
List_AllBooks: ["원문 모두", "All sources"],
List_AllChapters: ["장 모두", "All chapters"],
List_BlockedOnly: ["막힘만", "Blocked only"],
List_Count: ["{n} 개", "{n} items"],
List_Blocked: ["막힘", "Blocked"],
List_Edited: ["고침", "Edited"],
List_New: ["새것", "New"],
List_Deleted: ["지움", "Deleted"],
Bar_New: ["로직 새로 만들기", "New logic"],
Bar_Delete: ["로직 지우기", "Delete logic"],
Bar_Save: ["저장", "Save"],
Bar_Discard: ["고친 것 버리기", "Discard edits"],
Bar_Pending: ["저장 안 한 로직 {n} 개", "{n} unsaved logics"],
Empty: ["왼쪽 목록에서 로직을 고르세요", "Pick a logic from the list"],
Head_File: ["파일", "File"],
Head_Key: ["열쇠", "Key"],
Head_Name: ["이름", "Name"],
Head_Unit: ["결과단위", "Result unit"],
Head_Source: ["출처", "Source"],
Head_Owner: ["소유", "Owner"],
Head_Note: ["비고", "Note"],
Head_Blocked: ["막힌 까닭", "Why blocked"],
Inputs_Title: ["설계에서 받을 값", "Values from design"],
Inputs_Unit: ["단위", "Unit"],
Inputs_Choices: ["고르기(쉼표로)", "Choices (comma)"],
Inputs_Range: ["범위", "Range"],
Ho_Title: ["호표", "Unit-cost lines"],
Ho_Kind: ["종류", "Kind"],
Ho_Element: ["요소", "Element"],
Ho_Name: ["이름·규격", "Name · spec"],
Ho_Unit: ["단위", "Unit"],
Ho_Qty: ["수량", "Qty"],
Ho_Price: ["단가", "Unit price"],
Ho_Amount: ["금액", "Amount"],
Ho_Cost: ["비목", "Cost item"],
Ho_Pick: ["찾기", "Find"],
Ho_PickTable: ["표", "Table"],
Ho_QtyNumber: ["수", "Number"],
Ho_QtyTable: ["표 찾기", "Table lookup"],
Ho_QtyFormula: ["식", "Formula"],
Ho_PriceLater: ["계산 때", "At calc"],
Ho_Missing: ["없는 요소", "Missing element"],
Middle_Title: ["중간 값", "Intermediate values"],
Result_Title: ["결과 식(돈 아닌 로직)", "Result formula (non-money)"],
Extra_Title: ["덧줄", "Extra lines"],
Rounding: ["끝수", "Rounding"],
Formula: ["식", "Formula"],
AddRow: ["줄 더하기", "Add line"],
DeleteRow: ["줄 지우기", "Delete line"],
Calc_Title: ["시험 계산", "Test calculation"],
Calc_Run: ["계산", "Calculate"],
Calc_NoInputs: ["받을 값 없음", "No inputs"],
Calc_Draft: ["고친 줄로 계산(저장 전)", "Using unsaved edits"],
Calc_Stopped: ["멈춤", "Stopped"],
Calc_Result: ["결과", "Result"],
Calc_Sum: ["계", "Total"],
Pick_Title: ["요소 찾기", "Find element"],
Pick_Search: ["이름·열쇠", "Name · key"],
Pick_Total: ["{n} 개 중 앞 100 개", "first 100 of {n}"],
Pick_Close: ["닫기", "Close"],
Save_Done: ["저장했음", "Saved"],
Save_Stale: [
"그 사이 파일이 바뀜 — 고친 것을 버리고 다시 여세요: {v}",
"Files changed meanwhile — discard and reopen: {v}",
],
Save_Errors: ["검사에 걸려 저장 안 함", "Check failed — nothing saved"],
Save_Failed: ["저장 못 함", "Save failed"],
Save_Nothing: ["고친 것 없음", "Nothing to save"],
Load_Failed: ["불러오지 못함", "Load failed"],
Confirm_Delete: [
"로직 「{v}」 을 지울까요? [저장] 때 파일에서 빠짐",
"Delete logic “{v}”? Removed on save",
],
Confirm_Discard: ["고친 것을 모두 버릴까요?", "Discard all edits?"],
Confirm_Leave: [
"저장 안 한 로직이 있음 — 다른 로직을 열어도 고친 것은 남음",
"Unsaved logics stay in the draft",
],
New_Key: ["새 로직", "New logic"],
} as const satisfies Record<string, readonly [string, string]>;
export type TextKey = keyof typeof TEXT;
/** 공용 `t()` 와 같은 뜻 — `{n}`·`{v}` 는 채움 */
export function tx(key: TextKey, fill: Record<string, string | number> = {}): string {
const entry = TEXT[key];
const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0];
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
}
+226
View File
@@ -0,0 +1,226 @@
/* =============================================================================
* M01_MasterData_UI_Page.ts
* ( 7 · ) / ·
*
* . (sessionStorage) []
* . .
* ========================================================================== */
import "@ui/ui_template_workflow_layout.css";
import { ROUTES } from "@config/config_frontend";
import {
createButton,
el,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { navigateTo } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import {
fetchFiles,
fetchGroups,
saveFiles,
type FileInfo,
type GroupInfo,
} from "./M01_MasterData_Api_Fetch";
import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft";
import { renderRows } from "./M01_MasterData_UI_Rows";
import { renderTables } from "./M01_MasterData_UI_Tables";
import "./M01_MasterData_UI_Style.css";
const TABLE_GROUPS = ["소요량", "계수"];
const fileLabel = (file: string): string => file.replace(/^[^_]+_/, "").replace(/\.json$/, "");
export async function renderM01MasterData(root: HTMLElement): Promise<void> {
const user = await fetchSessionUser().catch(() => null);
if (user?.role !== "SYSTEM_ADMIN") {
showToast(L("M01_AdminOnly"), "error");
navigateTo(ROUTES.B01_ACCOUNT);
return;
}
showLoadingOverlay();
let groups: GroupInfo[] = [];
try {
groups = (await fetchGroups()).filter((g) => g.group !== "로직");
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
} finally {
hideLoadingOverlay();
}
root.innerHTML = "";
root.append(buildPage(groups));
}
function buildPage(groups: GroupInfo[]): HTMLElement {
let group = "";
let current: FileInfo | null = null;
let query = "";
let dispose = (): void => {};
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") });
const search = el("input", {
className: "m01-master__search",
attrs: { type: "search", placeholder: L("M01_Search") },
});
const summary = el("span", { className: "m01-master__summary" });
const save = createButton({ label: L("M01_Save"), variant: "filled" });
const drop = createButton({ label: L("M01_Discard"), variant: "ghost" });
const notice = el("div", { className: "m01-master__notice", attrs: { hidden: "" } });
const body = el("div", { className: "m01-master__body" });
const head = el("div", {
className: "m01-master__head",
children: [title, search, summary, drop, save],
});
const showNotice = (nodes: (HTMLElement | string)[]): void => {
notice.replaceChildren(...nodes);
notice.hidden = nodes.length === 0;
};
const refreshBar = (): void => {
const n = totalCount();
summary.textContent = n ? L("M01_Changed").replace("{value}", String(n)) : "";
save.disabled = drop.disabled = n === 0;
};
onDraftChange(refreshBar);
refreshBar();
const openFile = (file: FileInfo): void => {
dispose();
current = file;
title.textContent = `${group} · ${fileLabel(file.file)}`;
const view = TABLE_GROUPS.includes(group) ? renderTables : renderRows;
dispose = view(body, file.file, query);
showNotice(isStale(file.file, file.version) ? [L("M01_FileStale")] : []);
};
let searchTimer: number | undefined;
search.addEventListener("input", () => {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
query = search.value.trim();
if (current) openFile(current);
}, 300);
});
drop.addEventListener("click", () => {
if (!window.confirm(L("M01_DiscardConfirm"))) return;
discard();
showNotice([]);
});
save.addEventListener("click", async () => {
save.disabled = true;
try {
const result = await saveFiles(payload());
if (result.status === 200 && "files" in result) {
discard(result.files.map((f) => f.file));
showNotice([]);
showToast(L("M01_Saved"), "success");
await reloadFiles();
} else if (result.status === 409 && "stale" in result) {
showNotice(staleNotice(result.stale));
} else if (result.status === 422 && "errors" in result) {
showNotice([
el("strong", { text: L("M01_CheckErrors") }),
...result.errors.map((e) => el("p", { text: e })),
]);
} else if ("detail" in result) {
showNotice([L("M01_SaveFailed").replace("{value}", result.detail)]);
}
} catch (error) {
showNotice([
L("M01_SaveFailed").replace("{value}", error instanceof Error ? error.message : ""),
]);
} finally {
refreshBar();
}
});
const staleNotice = (stale: string[]): (HTMLElement | string)[] => {
const again = createButton({ label: L("M01_StaleReload"), variant: "ghost" });
again.addEventListener("click", async () => {
discard(stale);
showNotice([]);
await reloadFiles();
});
return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again];
};
/* --- 좌측: 그룹 탭 + 파일 --- */
const tabs = el("div", { className: "m01-master__tabs" });
const files = el("div", { className: "m01-master__files" });
const reloadFiles = async (): Promise<void> => {
if (!group) return;
let list: FileInfo[] = [];
try {
list = await fetchFiles(group);
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
}
files.replaceChildren(
...list.map((f) => {
const button = el("button", {
className: "m01-master__file",
attrs: { type: "button" },
children: [
el("span", { text: fileLabel(f.file) }),
el("span", { className: "m01-master__muted", text: f.rows.toLocaleString("ko-KR") }),
],
});
button.classList.toggle("is-active", f.file === current?.file);
button.addEventListener("click", () => {
files.querySelector(".is-active")?.classList.remove("is-active");
button.classList.add("is-active");
openFile(f);
});
return button;
}),
);
const again = list.find((f) => f.file === current?.file);
if (again) openFile(again);
};
for (const g of groups) {
const tab = el("button", {
className: "m01-master__tab",
attrs: { type: "button" },
children: [
el("span", { text: g.group }),
el("span", { className: "m01-master__muted", text: String(g.files) }),
],
});
tab.addEventListener("click", () => {
tabs.querySelector(".is-active")?.classList.remove("is-active");
tab.classList.add("is-active");
group = g.group;
current = null;
dispose();
body.replaceChildren();
title.textContent = L("M01_PickFile");
void reloadFiles();
});
tabs.append(tab);
}
const layout = el("div", { className: "ui-workflow-layout m01-master" });
const main = el("main", {
className: "ui-workflow-layout__main",
children: [el("div", { className: "m01-master__panel", children: [head, notice, body] })],
});
const overlays = createWorkflowOverlays({
title: L("M01_Title"),
optionsContent: el("div", { className: "m01-master__side", children: [tabs, files] }),
showProjectName: false,
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
});
layout.append(
el("div", { className: "ui-workflow-layout__body", children: [main] }),
overlays.root,
);
return layout;
}
+153
View File
@@ -0,0 +1,153 @@
/* =============================================================================
* M01_MasterData_UI_Rows.ts
* (····) = · · · ()
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch";
import {
addRow,
editRow,
onDraftChange,
peek,
removeAdd,
same,
setAdd,
toggleDelete,
} from "./M01_MasterData_Draft";
import {
buildCell,
buildPager,
coerce,
flatten,
isScalar,
show,
withCell,
} from "./M01_MasterData_UI_Cells";
const SIZE = 50;
const FALLBACK: Row = { : "", : "", : "", : "", : null, : "" };
/** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */
function blank(sample: Row): Row {
const out: Row = {};
for (const [k, v] of Object.entries(sample)) {
if (v && typeof v === "object" && !Array.isArray(v)) out[k] = blank(v as Row);
else out[k] = typeof v === "string" && k !== "값" ? "" : null;
}
return out;
}
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
export function renderRows(host: HTMLElement, file: string, q: string): () => void {
let page = 1;
let data: RowsPage | null = null;
const paint = (): void => {
if (!data) return;
const d = data;
const draft = peek(file);
const adds = draft?.adds ?? [];
const cols: string[] = [];
for (const row of [...adds, ...d.rows]) {
for (const k of Object.keys(flatten(row))) if (!cols.includes(k)) cols.push(k);
}
const head = el("tr", { children: [el("th"), ...cols.map((c) => el("th", { text: c }))] });
const body = el("tbody");
adds.forEach((row, i) => {
const flat = flatten(row);
const tr = el("tr", { className: "is-new" });
tr.append(actionCell(L("M01_RowRemove"), () => removeAdd(file, i)));
for (const c of cols) {
tr.append(
buildCell(show(flat[c]), {
changed: true,
onEdit: (text) => setAdd(file, i, withCell(row, c, coerce(text, flat[c]))),
}),
);
}
body.append(tr);
});
for (const row of d.rows) {
const key = String(row["열쇠"]);
const cur = draft?.edits[key] ?? row;
const deleted = draft?.deletes.includes(key) ?? false;
const orig = flatten(row);
const now = flatten(cur);
const tr = el("tr");
tr.classList.toggle("is-deleted", deleted);
tr.append(
actionCell(deleted ? L("M01_RowRestore") : L("M01_RowDelete"), () =>
toggleDelete(file, d.version, key),
),
);
for (const c of cols) {
const editable = !deleted && isScalar(orig[c]);
tr.append(
buildCell(show(now[c]), {
changed: !same(now[c], orig[c]),
onEdit: editable
? (text) =>
editRow(file, d.version, key, row, withCell(cur, c, coerce(text, orig[c])))
: undefined,
}),
);
}
body.append(tr);
}
const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" });
add.addEventListener("click", () => addRow(file, d.version, blank(d.rows[0] ?? FALLBACK)));
const empty =
d.rows.length || adds.length
? []
: [el("p", { className: "m01-master__empty", text: L("M01_NoRows") })];
host.replaceChildren(
el("div", {
className: "m01-master__bar",
children: [add, buildPager(d.total, SIZE, d.page, go)],
}),
el("div", {
className: "m01-master__grid-wrap",
children: [
el("table", {
className: "m01-master__grid",
children: [el("thead", { children: [head] }), body],
}),
],
}),
...empty,
);
};
const go = (next: number): void => {
page = next;
void load();
};
const load = async (): Promise<void> => {
try {
data = await fetchRows(file, page, SIZE, q);
paint();
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
}
};
const off = onDraftChange(paint);
void load();
return off;
}
function actionCell(label: string, onClick: () => void): HTMLTableCellElement {
const button = el("button", {
className: "m01-master__row-btn",
text: label,
attrs: { type: "button" },
});
button.addEventListener("click", onClick);
return el("td", { children: [button] });
}
+219
View File
@@ -0,0 +1,219 @@
/* M01 마스터 요소 — 좌측 도킹 패널(그룹 탭 · 파일) / 우측 표 */
.m01-master [hidden] {
display: none !important;
}
.m01-master__side {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding-top: var(--spacing-8);
}
.m01-master__tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-4);
}
.m01-master__files {
display: flex;
flex-direction: column;
gap: 2px;
}
.m01-master__tab,
.m01-master__file {
display: flex;
justify-content: space-between;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
text-align: left;
cursor: pointer;
}
.m01-master__file {
border-color: transparent;
background: transparent;
}
.m01-master__tab:hover,
.m01-master__file:hover {
background: var(--color-paper);
}
.m01-master__tab.is-active,
.m01-master__file.is-active {
background: var(--color-mist-violet);
color: var(--color-accent);
}
.m01-master__muted {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
/* ── 우측 ── */
.m01-master__panel {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
padding: var(--spacing-16) var(--spacing-24);
}
.m01-master__head,
.m01-master__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-12);
}
.m01-master__title {
flex: 1 1 auto;
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
}
.m01-master__summary {
color: var(--color-accent);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-bold);
}
.m01-master__notice {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8) var(--spacing-12);
max-height: 30vh;
overflow: auto;
border-left: 4px solid var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 10%, var(--color-canvas));
color: var(--color-danger);
font-size: var(--text-body-sm);
}
.m01-master__notice p {
margin: 0;
}
.m01-master__body {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.m01-master__grid-wrap {
max-height: calc(100vh - 300px);
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.m01-master__grid {
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.m01-master__grid th,
.m01-master__grid td {
max-width: 320px;
padding: var(--spacing-8) var(--spacing-12);
overflow: hidden;
border-bottom: 1px solid var(--color-border);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.m01-master__grid th {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-surface);
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
}
.m01-master__grid td.is-editable {
cursor: text;
}
/* 고친 칸 — 노랑 */
.m01-master__grid td.is-changed {
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
}
.m01-master__grid tr.is-deleted td {
color: var(--color-text-muted);
text-decoration: line-through;
background: color-mix(in srgb, var(--color-danger) 8%, var(--color-canvas));
}
.m01-master__cell-input {
width: 100%;
min-width: 6em;
padding: 2px var(--spacing-4);
}
.m01-master__row-btn {
padding: 2px var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-caption);
cursor: pointer;
}
.m01-master__pager {
display: flex;
align-items: center;
gap: var(--spacing-12);
margin-left: auto;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01-master__tables {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-8);
}
.m01-master__table-card {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
text-align: left;
cursor: pointer;
}
.m01-master__table-card:hover {
background: var(--color-paper);
}
.m01-master__table-card.is-changed {
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
}
.m01-master__empty {
color: var(--color-text-muted);
}
+200
View File
@@ -0,0 +1,200 @@
/* =============================================================================
* M01_MasterData_UI_Tables.ts
* (·) ( + ) · () · ·
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { fetchTable, fetchTables, type Row, type TableHead } from "./M01_MasterData_Api_Fetch";
import {
onDraftChange,
peek,
same,
setTable,
tableItems,
type TableItem,
} from "./M01_MasterData_Draft";
import {
buildCell,
buildPager,
coerce,
show,
toNumberish,
toRange,
} from "./M01_MasterData_UI_Cells";
const LIST_SIZE = 30;
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
export function renderTables(host: HTMLElement, file: string, q: string): () => void {
let stop = (): void => {};
const list = (page = 1): void => {
stop();
stop = (): void => {};
void showList(host, file, q, page, (key) => {
stop = openTable(host, file, key, () => list(page));
});
};
list();
const off = onDraftChange(
() => host.querySelector(".m01-master__tables") && markChanged(host, file),
);
return () => {
stop();
off();
};
}
/** 목록 위 「고침」 표시만 다시 — 목록을 다시 받지 않음. */
function markChanged(host: HTMLElement, file: string): void {
const changed = peek(file)?.tables ?? {};
host.querySelectorAll<HTMLElement>(".m01-master__table-card").forEach((card) => {
card.classList.toggle("is-changed", card.dataset.key! in changed);
});
}
async function showList(
host: HTMLElement,
file: string,
q: string,
page: number,
open: (key: string) => void,
): Promise<void> {
let tables: TableHead[];
try {
tables = (await fetchTables(file, q)).tables;
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
return;
}
const cards = tables.slice((page - 1) * LIST_SIZE, page * LIST_SIZE).map((t) => {
const card = el("button", {
className: "m01-master__table-card",
attrs: { type: "button", "data-key": t. },
children: [
el("strong", { text: t.이름 || t. }),
el("span", { className: "m01-master__muted", text: `${t.} · ${t. || "—"}` }),
el("span", {
className: "m01-master__muted",
text: `${Object.keys(t. ?? {}).join(" · ") || "—"} → ${Object.keys(t. ?? {}).join(" · ")} · ${t.count} ${L("M01_Rows")}`,
}),
],
});
card.classList.toggle("is-changed", t. in (peek(file)?.tables ?? {}));
card.addEventListener("click", () => open(t.));
return card;
});
host.replaceChildren(
buildPager(tables.length, LIST_SIZE, page, (p) => void showList(host, file, q, p, open)),
el("div", { className: "m01-master__tables", children: cards }),
);
}
/** 표 하나. 돌려받은 함수 = 해제. */
function openTable(host: HTMLElement, file: string, key: string, back: () => void): () => void {
let off = (): void => {};
void (async () => {
let version: string;
let table: Row & { : Row[] };
try {
({ version, table } = await fetchTable(file, key));
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
return;
}
const { : orig, ...base } = table;
const conds = (table. ?? {}) as Record<string, string>;
const values = (table. ?? {}) as Record<string, string>;
const cols = [...Object.keys(conds), ...Object.keys(values)];
for (const row of orig) for (const k of Object.keys(row)) if (!cols.includes(k)) cols.push(k);
const items = (): TableItem[] => tableItems(file, key) ?? orig.map((r, o) => ({ o, r }));
const commit = (next: TableItem[]): void => setTable(file, version, key, base, next, orig);
const parse = (col: string, text: string, prev: unknown): unknown => {
if (conds[col] === "범위") return toRange(text);
if (conds[col] === "고르기") return text;
return col in values || conds[col] === "수" ? toNumberish(text) : coerce(text, prev);
};
const headText = (c: string): string =>
c in conds ? `${c} (${conds[c]})` : c in values ? `${c} (${values[c]})` : c;
const paint = (): void => {
const now = items();
const body = el("tbody");
now.forEach((item, i) => {
const tr = el("tr");
tr.classList.toggle("is-new", item.o === null);
const del = el("button", {
className: "m01-master__row-btn",
text: L("M01_RowRemove"),
attrs: { type: "button" },
});
del.addEventListener("click", () => commit(now.filter((_, j) => j !== i)));
tr.append(el("td", { children: [del] }));
for (const c of cols) {
const was = item.o === null ? undefined : orig[item.o][c];
tr.append(
buildCell(show(item.r[c]), {
changed: item.o === null || !same(item.r[c], was),
onEdit: (text) => {
const value = parse(c, text, item.r[c]);
const r = { ...item.r };
if (value === null || value === "")
delete r[c]; // 원문 「-」 = 칸을 뺌
else r[c] = value;
commit(now.map((it, j) => (j === i ? { ...it, r } : it)));
},
}),
);
}
body.append(tr);
});
const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" });
add.addEventListener("click", () => commit([...now, { o: null, r: {} }]));
const backButton = createButton({ label: L("M01_BackToTables"), variant: "ghost" });
backButton.addEventListener("click", () => {
off();
back();
});
const notes = ((table. ?? []) as string[]).map((n) =>
el("p", { className: "m01-master__muted", text: n }),
);
host.replaceChildren(
el("div", {
className: "m01-master__bar",
children: [
backButton,
el("strong", { text: String(table. ?? key) }),
el("span", {
className: "m01-master__muted",
text: `${key} · ${table. || "—"} · ${table. || ""}`,
}),
add,
],
}),
el("div", {
className: "m01-master__grid-wrap",
children: [
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [el("th"), ...cols.map((c) => el("th", { text: headText(c) }))],
}),
],
}),
body,
],
}),
],
}),
...notes,
);
};
off = onDraftChange(paint);
paint();
})();
return () => off();
}
+3
View File
@@ -118,6 +118,8 @@ export const ROUTES = {
B11_LOADING: "b11-loading",
// 시스템 관리자 전용 — 로직·기초값 마스터 보기 (2026-09-15 브레인 Z01).
Z01_MASTER_DATA: "z01-master-data",
// 시스템 관리자 전용 — 마스터 요소(파일 첫 층 JSON) 보기·고치기.
M01_MASTER_DATA: "m01-master-data",
} as const;
export type RouteKey = keyof typeof ROUTES;
@@ -141,6 +143,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [
ROUTES.B11_STATUS,
ROUTES.B11_LOADING,
ROUTES.Z01_MASTER_DATA,
ROUTES.M01_MASTER_DATA,
];
/* -----------------------------------------------------------------------------
+2
View File
@@ -76,6 +76,7 @@ from B09_Estimation.B09_Estimation_Router_MaterialPrices import (
router as b09_material_prices_router,
)
from Z01_MasterData.Z01_MasterData_Router import router as z01_master_data_router
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
from common_util.common_util_audit import note_api_call, record_call_burst
from common_util.common_util_auth import (
require_company,
@@ -659,6 +660,7 @@ app.include_router(b09_material_prices_router, dependencies=protected_with_compa
# Z01 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다(2026-09-15 브레인 Z01).
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
app.include_router(z01_master_data_router, dependencies=system_admin_only)
app.include_router(m01_master_data_router, dependencies=system_admin_only)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
# 그 위에 서버가 환경까지 한 번 더 본다.
app.include_router(dev_unlock_router, dependencies=protected_with_company)
+62
View File
@@ -0,0 +1,62 @@
# 관리자 화면 계약 (M01)
`resources/master_data/` 첫 층 JSON 을 보고 고치는 화면과 서버(`M01_MasterData/`) 사이의 약속. 파일 모양은 `_틀.md`.
## 1. 공통
- 길 머리 `/api/m01` · 시스템 관리자만(아니면 403).
- 봉투 칸 이름은 영문 · 줄·표·로직 알맹이는 파일 그대로(한글 칸).
- 수는 JSON 수로 옴.
- `version` = 파일 판본(내용 해시). 읽을 때 받아 두고 저장 때 그대로 돌려줌.
- `file` = 파일 이름(`소요량_건설품셈_기계설비7장.json`). 밑줄 파일·하위 폴더는 없음.
- 없는 파일·열쇠 = 404 · 틀린 요청 = 400.
## 2. 읽기
| 길 | 받음 | 줌 |
|---|---|---|
| `GET /groups` | | `{groups: [{group, files, rows}]}` — 그룹 차례는 `_틀.md` 2장 |
| `GET /groups/{group}/files` | | `{files: [{file, book, chapter, edition, rows, version}]}` |
| `GET /rows` | `file` · `page`(1) · `size`(50) · `q` | `{file, version, total, page, size, rows: [줄…]}` |
| `GET /tables` | `file` · `q` | `{file, version, tables: [{열쇠, 이름, 기준, 출처, 조건, 값칸, count}]}` |
| `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` |
| `GET /logics` | `book` · `chapter` · `q` · `blocked`(0/1) | `{logics: [{file, book, chapter, 열쇠, 이름, 결과단위, 출처, blocked, reasons}]}` |
| `GET /logic` | `book` · `key` | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` |
| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 이름, 규격, 단위, 값, 값칸}]}` |
- `rows` 는 요소·로직 파일은 `줄`, 표형 파일(소요량·계수)은 `표` 를 줄로 봄.
- `q` = 이름 찾기 — `열쇠`·`이름` 에 든 글(대소문자 무시).
- `chapter` = 파일 이름의 장(`공통3장` · `13장` · `01장`) · 없으면 "".
- `blocked` = 로직 검사(`check_master.py 로직`)에 걸림 · `reasons` = 걸린 까닭 글.
- `prices` = 호표 `요소``{ref, 이름, 규격, 단위, 값}`(단가 자동 칸) · 없는 요소는 `null` · `{이름}` 이 낀 열쇠·하위 로직 줄은 빠짐(계산 때 정해짐).
- `elements` = 요소 찾기 창 — 그룹 전체에서 `q` 찾기 · `ref` = 식에 넣는 `그룹:원문:열쇠` · 표형 그룹은 `값칸` 이 옴.
## 3. 시험 계산
`POST /calc` 받음 `{book, key, inputs: {이름: 값}, row?, file?}`
- 됨 — `{ok: true, lines: [{이름, 단위, 수량, 단가, 금액, 비목: {비목: 금액}}], sums: {노무비, 재료비, 경비, 계}, middle: {이름: 값}}`
- 돈 아닌 로직 — `{ok: true, result, middle}`
- 멈춤 — `{ok: false, reason}` (200). 까닭 = 엔진 글 그대로(「입력 「돌」 없음」 · 「값 없음 — 관리자가 채울 값」 …).
- `row` 없음 = 저장된 파일로 계산 · `row` 있음 = 그 로직만 고친 줄로 바꿔(없던 로직이면 `file` 에 더해) 메모리에서 계산 — 파일에 안 씀. 요소 값은 늘 저장된 파일.
## 4. 저장
`POST /save` 받음 `{files: [{file, version, changes: [{op, key, row}]}]}`
- `op``edit`(열쇠 `key` 줄을 `row` 로 바꿈) · `add`(`row` 더함 · `key` 안 씀) · `delete`(`key` 줄 지움).
- 요소 파일 = `줄` 한 줄 · 표형 파일 = `표` 하나 · 로직 파일 = 로직 한 줄. `row` 는 통째(칸 일부만 보내지 않음).
- 화면은 조작을 캐시에 쌓고 [저장] 한 번에 보냄 — 서버는 이때만 파일에 씀.
- 순서: 모든 `version` 대조 → 모두 적용(메모리) → 검사 → 모두 씀. 하나라도 걸리면 아무것도 안 씀.
| 답 | 뜻 | 몸 |
|---|---|---|
| 200 | 씀 | `{files: [{file, version}]}` — 새 판본 |
| 409 | 낡은 화면 — 그 사이 파일이 바뀜 | `{detail: {stale: [file…]}}` → 다시 읽고 다시 고침 |
| 422 | 검사 걸림 | `{detail: {errors: [글…]}}` |
| 404 | 없는 파일·열쇠 | `{detail}` |
| 400 | 틀린 `op` · 더할 열쇠가 이미 있음 · 고친 줄의 열쇠가 딴 줄과 겹침 | `{detail}` |
- 검사 = `check_master.py` 의 틀(고친 파일) + 로직 변수(모든 로직 파일, 고친 뒤 마스터 기준). **고친 뒤 새로 생긴 걸림**만 막음 — 원래 있던 걸림은 막지 않음.
- 요소를 지워 어떤 로직이 가리키던 것이 사라지면 로직 변수 검사에 걸림(422).
- 쓰기는 UTF-8 · LF · 들여쓰기 2칸.
+22 -6
View File
@@ -59,10 +59,10 @@ ROW_KEYS = {
}
def load(names: list[str] | None = None) -> dict[str, dict]:
"""첫 층 JSON 모두(밑줄 파일 빼고) — 수는 Decimal."""
def load(names: list[str] | None = None, folder: Path = MASTER) -> dict[str, dict]:
"""첫 층 JSON 모두(밑줄 파일 빼고) — 수는 Decimal. `folder` = 마스터 폴더(M01 시험은 사본)."""
out = {}
for path in sorted(MASTER.glob("*.json")):
for path in sorted(Path(folder).glob("*.json")):
if path.name.startswith("_") or (
names and path.name not in names and path.stem not in names
):
@@ -73,8 +73,19 @@ def load(names: list[str] | None = None) -> dict[str, dict]:
return out
def master() -> mf.Master:
return mf.Master(load())
def master(folder: Path = MASTER) -> mf.Master:
return mf.Master(load(folder=folder))
def check_saved(files: dict[str, dict], changed: list[str]) -> list[str]:
"""저장 전 검사(M01) — 고친 파일의 틀 + 모든 로직의 변수. `files` = 폴더 전부(고친 뒤)."""
out = [x for name in changed for x in check_form(name, files[name])]
return out + check_logics(files, mf.Master(files))
def calc(files: dict[str, dict], ref: str, given: dict) -> dict:
"""시험 계산(M01) — `ref` = 「원문:로직 열쇠」 · 멈추면 FormulaError."""
return mf.run(mf.Master(files), ref, given)
# ── (1) 틀 ────────────────────────────────────────────────────────────
@@ -228,7 +239,12 @@ def check_body(files: dict[str, dict]) -> list[str]:
if not text:
out.append(f"{where} · 본문 절 못 찾음 「{table.get('출처')}")
continue
mine = _flat(table.get("")) | _flat(table.get("기준")) | _flat(table.get("")) | _flat(table.get("값칸"))
mine = (
_flat(table.get(""))
| _flat(table.get("기준"))
| _flat(table.get(""))
| _flat(table.get("값칸"))
)
fake = mine - numbers_in(text)
if fake:
out.append(f"{where} · 허구 {len(fake)} {sorted(fake, key=Decimal)}")
@@ -184,7 +184,7 @@
"단위": { "손료계수": "1일 1대당 4시간 가동", "취득가액": "원" },
"값": { "손료계수": 0.0040, "취득가액": 9894500 },
"출처": { "손료계수": "산림품셈 2-2-8", "취득가액": "산림품셈 2-2-8" } },
{ "열쇠": "2-2-8 리튬폴리머 배터리(평균가)", "이름": "리튬폴리머 배터리(평균가)", "규격": "16,000mA, 32,000mA",
{ "열쇠": "2-2-8 리튬폴리머 배터리(평균가)", "이름": "리튬폴리머 배터리(평균가)", "규격": "16,000mA, 32000mA",
"단위": { "손료계수": "1일 1대당 4시간 가동", "취득가액": "원" },
"값": { "손료계수": 0.0028, "취득가액": 7104000 },
"출처": { "손료계수": "산림품셈 2-2-8", "취득가액": "산림품셈 2-2-8" },
@@ -69,7 +69,7 @@
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"소유": "공용",
"입력": [
{ "이름": "기계", "고르기": ["1730-0015", "7204-0055", "0602-0150", "2702-0020", "0101-0007", "0101-0010", "0101-0012", "0101-0019", "0101-0032", "2105-0005", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "4504-0032", "4504-0036", "4504-0041", "4504-0043", "4504-0047", "4504-0052", "4205-0010", "4205-0017", "4205-0020", "4205-0030", "4205-0040", "4205-0045", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "2502-0025", "0602-0025", "2502-0050", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "7204-0018", "7204-0160", "5205-0210", "5405-0110", "7505-0450", "5205-0255", "7505-0150", "5205-0103", "6701-0147", "6701-0184", "5205-0170", "7505-0350", "0302-0134", "7505-0100", "7505-0050", "5205-0071", "7505-0025", "0502-0036", "1306-0100", "1506-0032", "0121-0004", "1305-0007", "7204-0038", "7204-0065", "0302-0057", "5701-0020", "0302-0095", "3201-0003", "1106-0012", "1406-0015", "1206-0008", "3601-0102", "1306-0025", "0211-0018", "0602-0045", "3901-0300", "7505-0250", "2502-0035"] },
{ "이름": "기계", "고르기": ["1730-0015", "7204-0055", "0602-0150", "2702-0020", "0101-0007", "0101-0010", "0101-0012", "0101-0019", "0101-0032", "2105-0005", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "4504-0032", "4504-0036", "4504-0041", "4504-0043", "4504-0047", "4504-0052", "4205-0010", "4205-0017", "4205-0020", "4205-0030", "4205-0040", "4205-0045", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "2502-0025", "0602-0025", "2502-0050", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "7204-0018", "7204-0160", "5205-0210", "5405-0110", "7505-0450", "5205-0255", "7505-0150", "5205-0103", "6701-0147", "6701-0184", "5205-0170", "7505-0350", "0302-0134", "7505-0100", "7505-0050", "5205-0071", "7505-0025", "0502-0036", "1306-0100", "1506-0032", "0121-0004", "1305-0007", "7204-0038", "7204-0065", "0302-0057", "5701-0020", "0302-0095", "3201-0003", "1106-0012", "1406-0015", "1206-0008", "3601-0102", "1306-0025", "0211-0018", "0602-0045", "3901-0300", "7505-0250", "2502-0035", "1306-0120", "1106-0010"] },
{
"이름": "지역",
"고르기": [
@@ -309,7 +309,7 @@
"입력": [
{
"이름": "기계",
"고르기": ["4611-0350", "7812-0015", "4506-0200", "4506-0400", "1730-0015", "1305-0007", "2106-0002", "2106-0003", "2106-0005", "7210-0485", "7360-0090", "6540-0131"]
"고르기": ["4611-0350", "7812-0015", "4506-0200", "4506-0400", "1730-0015", "1305-0007", "2106-0002", "2106-0003", "2106-0005", "7210-0485", "7360-0090", "6540-0131", "3302-0038", "4430-0400"]
},
{
"이름": "지역",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,394 @@
{
"그룹": "로직",
"원문": "건설품셈",
"판": "2026",
"줄": [
{
"열쇠": "2-1-1 사석부설",
"이름": "사석부설",
"결과단위": "원/㎥",
"출처": "건설품셈 토목 2-1-1",
"소유": "공용",
"입력": [
{
"이름": "지역",
"고르기": [
"전국평균",
"전국",
"서울",
"경기",
"강원",
"충북",
"충남",
"전북",
"경북",
"경남",
"부산",
"제주",
"대구",
"인천",
"대전",
"울산",
"세종",
"전남광주"
],
"출처": "건설품셈 공통 8-1-7 5"
},
{ "이름": "보정작업", "고르기": ["해당 없음", "암석작업(연암・보통암・경암)", "전석섞인토사"], "출처": "건설품셈 공통 8-1-7 1" }
],
"중간": [],
"호표": [
{ "종류": "인력", "요소": "인력:건설노임:1002", "이름": "보통인부", "단위": "인", "수량": "찾기(소요량:건설품셈:2-1-1 사석부설).보통인부", "비목": "노무비" },
{
"종류": "로직",
"요소": "로직(건설품셈:8 굴착기(무한궤도) 시간당 경비, 기계='0201-0100', 지역=지역, 보정작업=보정작업)",
"이름": "굴착기 1.0㎥",
"단위": "시간",
"수량": "찾기(소요량:건설품셈:2-1-1 사석부설).굴삭기"
}
],
"덧줄": [],
"끝수": null,
"비고": "필터매트는 건설품셈 공통 5-2-1 매트부설 · 사석 재료 원문 표에 없음"
},
{
"열쇠": "2-1-2 사석고르기",
"이름": "사석고르기",
"결과단위": "원/㎡",
"출처": "건설품셈 토목 2-1-2",
"소유": "공용",
"입력": [],
"중간": [],
"호표": [
{ "종류": "인력", "요소": "인력:건설노임:1002", "이름": "보통인부", "단위": "인", "수량": "찾기(소요량:건설품셈:2-1-2 사석고르기).보통인부", "비목": "노무비" }
],
"덧줄": [],
"끝수": null,
"비고": "잡석 채움 포함(주②) · 막힘: 굴삭기+부착용집게 빠짐 — 칸 이름 「굴삭기+부착용집게」 식에서 못 부름"
},
{
"열쇠": "2-2-2 매트리스형 돌망태 설치",
"이름": "매트리스형 돌망태 설치",
"결과단위": "원/㎡",
"출처": "건설품셈 토목 2-2-2",
"소유": "공용",
"입력": [
{
"이름": "지역",
"고르기": [
"전국평균",
"전국",
"서울",
"경기",
"강원",
"충북",
"충남",
"전북",
"경북",
"경남",
"부산",
"제주",
"대구",
"인천",
"대전",
"울산",
"세종",
"전남광주"
],
"출처": "건설품셈 공통 8-1-7 5"
},
{ "이름": "보정작업", "고르기": ["해당 없음", "암석작업(연암・보통암・경암)", "전석섞인토사"], "출처": "건설품셈 공통 8-1-7 1" }
],
"중간": [],
"호표": [
{ "종류": "인력", "요소": "인력:건설노임:1033", "이름": "석공", "단위": "인", "수량": "찾기(소요량:건설품셈:2-2-2 매트리스형 돌망태 설치).석공", "비목": "노무비" },
{
"종류": "인력",
"요소": "인력:건설노임:1003",
"이름": "특별인부",
"단위": "인",
"수량": "찾기(소요량:건설품셈:2-2-2 매트리스형 돌망태 설치).특별인부",
"비목": "노무비"
},
{
"종류": "인력",
"요소": "인력:건설노임:1002",
"이름": "보통인부",
"단위": "인",
"수량": "찾기(소요량:건설품셈:2-2-2 매트리스형 돌망태 설치).보통인부",
"비목": "노무비"
},
{
"종류": "로직",
"요소": "로직(건설품셈:8 굴착기(무한궤도) 시간당 경비, 기계='0201-0100', 지역=지역, 보정작업=보정작업)",
"이름": "굴착기 1.0㎥",
"단위": "시간",
"수량": "찾기(소요량:건설품셈:2-2-2 매트리스형 돌망태 설치).굴착기"
}
],
"덧줄": [],
"끝수": null,
"비고": "폭 200㎝ · 높이 30㎝ 기준(주①) · 필터매트는 건설품셈 공통 5-2-1 매트부설 · 망태·망태석 재료 원문 표에 없음"
},
{
"열쇠": "2-2-3 돌망태형옹벽 설치",
"이름": "돌망태형옹벽 설치",
"결과단위": "원/㎥",
"출처": "건설품셈 토목 2-2-3",
"소유": "공용",
"입력": [
{
"이름": "지역",
"고르기": [
"전국평균",
"전국",
"서울",
"경기",
"강원",
"충북",
"충남",
"전북",
"경북",
"경남",
"부산",
"제주",
"대구",
"인천",
"대전",
"울산",
"세종",
"전남광주"
],
"출처": "건설품셈 공통 8-1-7 5"
},
{ "이름": "보정작업", "고르기": ["해당 없음", "암석작업(연암・보통암・경암)", "전석섞인토사"], "출처": "건설품셈 공통 8-1-7 1" }
],
"중간": [],
"호표": [
{ "종류": "인력", "요소": "인력:건설노임:1033", "이름": "석공", "단위": "인", "수량": "찾기(소요량:건설품셈:2-2-3 돌망태형옹벽 설치).석공", "비목": "노무비" },
{
"종류": "인력",
"요소": "인력:건설노임:1003",
"이름": "특별인부",
"단위": "인",
"수량": "찾기(소요량:건설품셈:2-2-3 돌망태형옹벽 설치).특별인부",
"비목": "노무비"
},
{
"종류": "인력",
"요소": "인력:건설노임:1002",
"이름": "보통인부",
"단위": "인",
"수량": "찾기(소요량:건설품셈:2-2-3 돌망태형옹벽 설치).보통인부",
"비목": "노무비"
},
{
"종류": "로직",
"요소": "로직(건설품셈:8 굴착기(무한궤도) 시간당 경비, 기계='0201-0060', 지역=지역, 보정작업=보정작업)",
"이름": "굴착기 0.6㎥",
"단위": "시간",
"수량": "찾기(소요량:건설품셈:2-2-3 돌망태형옹벽 설치).굴착기"
}
],
"덧줄": [],
"끝수": null,
"비고": "높이 5m 이하(주①) · 터파기·지반고르기 별도(주③) · 필터매트는 건설품셈 공통 5-2-1 매트부설 · 철망태·망태석 재료 원문 표에 없음"
},
{
"열쇠": "2-3-1 식생매트 설치",
"이름": "식생매트 설치",
"결과단위": "원/㎡",
"출처": "건설품셈 토목 2-3-1",
"소유": "공용",
"입력": [
{ "이름": "작업", "고르기": ["식생매트설치", "복토"] },
{
"이름": "지역",
"고르기": [
"전국평균",
"전국",
"서울",
"경기",
"강원",
"충북",
"충남",
"전북",
"경북",
"경남",
"부산",
"제주",
"대구",
"인천",
"대전",
"울산",
"세종",
"전남광주"
],
"출처": "건설품셈 공통 8-1-7 5"
},
{ "이름": "보정작업", "고르기": ["해당 없음", "암석작업(연암・보통암・경암)", "전석섞인토사"], "출처": "건설품셈 공통 8-1-7 1" }
],
"중간": [],
"호표": [
{
"종류": "인력",
"요소": "인력:건설노임:1003",
"이름": "특별인부",
"단위": "인",
"수량": "만약(작업 == '식생매트설치', 찾기(소요량:건설품셈:2-3-1 식생매트 설치, 작업=작업).특별인부, 0)",
"비목": "노무비"
},
{
"종류": "인력",
"요소": "인력:건설노임:1002",
"이름": "보통인부",
"단위": "인",
"수량": "찾기(소요량:건설품셈:2-3-1 식생매트 설치, 작업=작업).보통인부",
"비목": "노무비"
},
{
"종류": "로직",
"요소": "로직(건설품셈:8 굴착기(무한궤도) 시간당 경비, 기계='0201-0060', 지역=지역, 보정작업=보정작업)",
"이름": "굴착기 0.6㎥",
"단위": "시간",
"수량": "만약(작업 == '복토', 찾기(소요량:건설품셈:2-3-1 식생매트 설치, 작업=작업).굴삭기, 0)"
}
],
"덧줄": [],
"끝수": null,
"비고": "작업(식생매트설치 · 복토)마다 따로 계상 — 둘 다 하면 두 줄 · 종자살포·잔디·관수·시비 별도(주③) · 식생매트 재료 원문 표에 없음"
},
{
"열쇠": "2-3-2 블록 붙이기(인력)",
"이름": "블록 붙이기(인력)",
"결과단위": "원/㎡",
"출처": "건설품셈 토목 2-3-2",
"소유": "공용",
"입력": [],
"중간": [],
"호표": [
{
"종류": "인력",
"요소": "인력:건설노임:1003",
"이름": "특별인부",
"단위": "인",
"수량": "찾기(소요량:건설품셈:2-3-2 블록 붙이기(인력)).특별인부",
"비목": "노무비"
},
{
"종류": "인력",
"요소": "인력:건설노임:1002",
"이름": "보통인부",
"단위": "인",
"수량": "찾기(소요량:건설품셈:2-3-2 블록 붙이기(인력)).보통인부",
"비목": "노무비"
}
],
"덧줄": [],
"끝수": null,
"비고": "비탈면 고르기·흙 채움·잔디심기 별도(주③) · 호안블록 재료 원문 표에 없음"
},
{
"열쇠": "2-3-3 블록 붙이기(기계)",
"이름": "블록 붙이기(기계)",
"결과단위": "원/㎡",
"출처": "건설품셈 토목 2-3-3",
"소유": "공용",
"입력": [
{ "이름": "장비", "고르기": ["크레인", "굴착기"], "출처": "건설품셈 토목 2-3-3 주④" },
{
"이름": "크레인",
"고르기": [
"2105-0005",
"2104-0010",
"2104-0015",
"2104-0020",
"2104-0025",
"2104-0030",
"2104-0035",
"2104-0040",
"2104-0045",
"2104-0050",
"2104-0060",
"2104-0070",
"2104-0080",
"2104-0100",
"2104-0130",
"2104-0160",
"2104-0200",
"2104-0220",
"2104-0250",
"2104-0300",
"2105-0002",
"2105-0003",
"2105-0010",
"2105-0015",
"2105-0018"
]
},
{
"이름": "지역",
"고르기": [
"전국평균",
"전국",
"서울",
"경기",
"강원",
"충북",
"충남",
"전북",
"경북",
"경남",
"부산",
"제주",
"대구",
"인천",
"대전",
"울산",
"세종",
"전남광주"
],
"출처": "건설품셈 공통 8-1-7 5"
},
{ "이름": "보정작업", "고르기": ["해당 없음", "암석작업(연암・보통암・경암)", "전석섞인토사"], "출처": "건설품셈 공통 8-1-7 1" }
],
"중간": [],
"호표": [
{
"종류": "인력",
"요소": "인력:건설노임:1003",
"이름": "특별인부",
"단위": "인",
"수량": "찾기(소요량:건설품셈:2-3-3 블록 붙이기(기계)).특별인부",
"비목": "노무비"
},
{
"종류": "인력",
"요소": "인력:건설노임:1002",
"이름": "보통인부",
"단위": "인",
"수량": "찾기(소요량:건설품셈:2-3-3 블록 붙이기(기계)).보통인부",
"비목": "노무비"
},
{
"종류": "로직",
"요소": "로직(건설품셈:8 기계 시간당 경비, 기계=크레인, 지역=지역)",
"이름": "크레인",
"단위": "시간",
"수량": "만약(장비 == '크레인', 찾기(소요량:건설품셈:2-3-3 블록 붙이기(기계)).크레인, 0)"
},
{
"종류": "로직",
"요소": "로직(건설품셈:8 굴착기(무한궤도) 시간당 경비, 기계='0201-0020', 지역=지역, 보정작업=보정작업)",
"이름": "굴착기 0.2㎥",
"단위": "시간",
"수량": "만약(장비 == '굴착기', 찾기(소요량:건설품셈:2-3-3 블록 붙이기(기계) 계수 주④, 구분='크레인 대체 굴착기 사용시간').값, 0)",
"출처": "건설품셈 토목 2-3-3 주④"
}
],
"덧줄": [],
"끝수": null,
"비고": "크레인 종류·규격 설계자 고름 — 원문 규격 10톤 · 현장여건 따라 크레인 대신 굴착기 0.2㎥ 0.063hr(주④) · 비탈면 고르기·흙 채움·잔디심기 별도(주③) · 호안블록 재료 원문 표에 없음"
}
]
}
@@ -501,16 +501,16 @@
"조건": { "사용횟수": "고르기" },
"값칸": { "합판": "%", "소모자재": "%" },
"줄": [
{ "사용횟수": "1회", "합판": 55.0, "소모자재": 4.0 },
{ "사용횟수": "2회", "합판": 44.3, "소모자재": 7.0 },
{ "사용횟수": "3회", "합판": 38.0, "소모자재": 8.0 },
{ "사용횟수": "4회", "합판": 35.0, "소모자재": 9.0 },
{ "사용횟수": "5회", "합판": 32.7, "소모자재": 10.0 },
{ "사용횟수": "6회", "소모자재": 11.0 }
{ "사용횟수": "1회", "소모자재": 4.0 },
{ "사용횟수": "2회", "합판": 55.0, "소모자재": 7.0 },
{ "사용횟수": "3회", "합판": 44.3, "소모자재": 8.0 },
{ "사용횟수": "4회", "합판": 38.0, "소모자재": 9.0 },
{ "사용횟수": "5회", "합판": 35.0, "소모자재": 10.0 },
{ "사용횟수": "6회", "합판": 32.7, "소모자재": 11.0 }
],
"주": [
"소모자재(박리재 등)는 주자재비의 %",
"합판 6회 값은 원문 「-」(빈칸)"
"합판 1회는 수량 칸(1.03㎡)이라 %가 없음"
]
},
{
@@ -0,0 +1,501 @@
{
"그룹": "소요량",
"원문": "건설품셈",
"부문": "유지관리",
"판": "2026",
"표": [
{
"열쇠": "3-1-1 콘크리트구조물 헐기(인력)",
"이름": "콘크리트구조물 헐기(인력)",
"기준": "일당",
"출처": "건설품셈 유지관리 3-1-1",
"조건": { "철근유무": "고르기" },
"값칸": { "착암공": "인", "보통인부": "인", "소형브레이커(1.5kW)": "대", "시공량": "㎥" },
"줄": [
{ "철근유무": "무근", "착암공": 2, "보통인부": 1, "소형브레이커(1.5kW)": 2, "시공량": 2.7 },
{ "철근유무": "철근", "착암공": 2, "보통인부": 1, "소형브레이커(1.5kW)": 2, "시공량": 2.3 }
],
"주": [
"규격 — 소형브레이커 1.5kW.",
"① 본 품은 소형브레이커(전기식)를 사용하여 콘크리트구조물을 철거하는 기준이다.",
"② 본 품은 콘크리트 헐기, 발생재 정리 작업을 포함한다.",
"③ 장애물 제거(철근, 파이프 등)가 필요한 경우 별도 계상한다.",
"④ 잡재료비(치즐 등)는 인력품의 1%로 계상한다."
]
},
{
"열쇠": "3-1-2 콘크리트구조물 헐기(기계)",
"이름": "콘크리트구조물 헐기(기계)",
"기준": "일당",
"출처": "건설품셈 유지관리 3-1-2",
"조건": { "장애물": "고르기" },
"값칸": { "용접공": "인", "특별인부": "인", "보통인부": "인", "굴착기+압쇄기(1.0㎥)": "대", "굴착기(0.6㎥)": "대", "시공량": "㎥" },
"줄": [
{ "장애물": "미제거", "특별인부": 2, "보통인부": 1, "굴착기+압쇄기(1.0㎥)": 1, "굴착기(0.6㎥)": 1, "시공량": 50 },
{ "장애물": "제거", "용접공": 1, "특별인부": 2, "보통인부": 1, "굴착기+압쇄기(1.0㎥)": 1, "굴착기(0.6㎥)": 1, "시공량": 45 }
],
"주": [
"규격 — 굴착기+압쇄기 1.0㎥ · 굴착기 0.6㎥.",
"① 본 품은 장비(굴착기+압쇄기)를 사용한 철근콘크리트 구조물을 해체하는 기준이다.",
"② 본 품은 콘크리트 헐기 및 부수기, 발생재 정리 작업을 포함한다.",
"③ 본 품은 높이 10m이하 기준이며, 특수조건(하부구조보강 필요 등)에 대한 비용은 별도 계상한다.",
"④ 공사장의 보호 및 안전시설 설치비, 폐기물 상차 및 운반, 폐기물 처리비용은 별도 계상한다.",
"⑤ 장비는 현장여건에 따라 규격을 변경하여 적용할 수 있다.",
"⑥ 대형브레이커가 필요한 경우 '[공통부문] 8-2-13 대형브레이커'를 참조하여 별도 계상한다.",
"⑦ 공구손료 및 경장비(살수장비 등)의 기계경비는 인력품의 4%로 계상한다.",
"⑧ 장애물 제거(철근, 파이프 등) 시 재료량은 재료량 표를 참고한다."
]
},
{
"열쇠": "3-1-2 콘크리트구조물 헐기(기계) 재료량",
"이름": "콘크리트구조물 헐기(기계) 장애물 제거 재료량(㎥당)",
"기준": "㎥당",
"출처": "건설품셈 유지관리 3-1-2",
"조건": { "구분": "고르기" },
"값칸": { "단위": "글", "수량": "수" },
"줄": [
{ "구분": "산소(대기압상태기준)", "단위": "L", "수량": 135 },
{ "구분": "아세틸렌", "단위": "㎏", "수량": 0.05 }
],
"주": [ "※ 산소량은 대기압상태의 기준량이며, 압축산소는 35℃에서 150기압으로 압축용기에 넣어 사용하는 것을 기준한다." ]
},
{
"열쇠": "3-1-3 철골재 철거(인력)",
"이름": "철골재 철거(인력)",
"기준": "일당",
"출처": "건설품셈 유지관리 3-1-3",
"조건": {},
"값칸": { "용접공": "인", "보통인부": "인", "시공량": "ton" },
"줄": [ { "용접공": 3, "보통인부": 2, "시공량": 1.4 } ],
"주": [
"① 본 품은 산소용접기를 사용하여 철골재 구조물을 해체하는 기준이다.",
"② 본 품은 철골재 철거, 발생재 정리 작업을 포함한다.",
"③ 공사장의 보호 및 안전시설 설치비, 폐기물 상차 및 운반, 폐기물 처리비용은 별도 계상한다.",
"④ 재료량은 재료량 표를 참고하여 적용한다."
]
},
{
"열쇠": "3-1-3 철골재 철거(인력) 재료량",
"이름": "철골재 철거(인력) 재료량(ton당)",
"기준": "ton당",
"출처": "건설품셈 유지관리 3-1-3",
"조건": { "구분": "고르기" },
"값칸": { "단위": "글", "수량": "수" },
"줄": [
{ "구분": "산소", "단위": "병", "수량": 0.7 },
{ "구분": "아세틸렌", "단위": "㎏", "수량": 2.5 },
{ "구분": "LPG", "단위": "㎏", "수량": 2.0 }
],
"주": [
"※ 아세틸렌(산소포함) 또는 L.P.G 중 한가지만 선택 사용한다.",
"※ 산소량은 대기압상태의 기준량이며, 압축산소는 35℃에서 150기압으로 압축용기에 넣어 사용하는 것을 기준한다."
]
},
{
"열쇠": "3-1-4 철골재 철거(기계)",
"이름": "철골재 철거(기계)",
"기준": "일당",
"출처": "건설품셈 유지관리 3-1-4",
"조건": {},
"값칸": { "특별인부": "인", "보통인부": "인", "굴착기+빔커터기(1.0㎥)": "대", "굴착기(1.0㎥)": "대", "크레인": "대", "시공량": "ton" },
"줄": [ { "특별인부": 2, "보통인부": 1, "굴착기+빔커터기(1.0㎥)": 1, "굴착기(1.0㎥)": 1, "크레인": 1, "시공량": 22 } ],
"주": [
"① 본 품은 장비(굴착기+빔커터기 등)를 사용하여 철골재 구조물을 해체하는 기준이다.",
"② 본 품은 철골재 철거, 발생재 정리 작업을 포함한다.",
"③ 높이 10m이하(지상에서 철거) 기준이며, 특수조건(소형장비 추가투입 등)에 대한 비용은 별도 계상한다.",
"④ 공사장의 보호 및 안전시설 설치비, 폐기물 상차 및 운반, 폐기물 처리비용은 별도 계상한다.",
"⑤ 크레인의 규격은 작업여건(시공높이, 시공위치 등) 및 안전율(적정하중, 작업반경 등)을 고려하여 적합한 규격을 적용한다.",
"⑥ 장비는 현장여건에 따라 규격을 변경하여 적용할 수 있다.",
"⑦ 공구손료 및 경장비(살수장비 등)의 기계경비는 인력품의 4%로 계상한다."
]
},
{
"열쇠": "3-1-5 석축 헐기(인력)",
"이름": "석축 헐기(인력)",
"기준": "정의별",
"출처": "건설품셈 유지관리 3-1-5",
"조건": { "공종": "고르기" },
"값칸": { "기준": "글", "할석공": "인", "보통인부": "인" },
"줄": [
{ "공종": "메쌓기 뒷길이 4560㎝", "기준": "㎡당", "보통인부": 0.2 },
{ "공종": "메쌓기 뒷길이 6090㎝", "기준": "㎡당", "보통인부": 0.3 },
{ "공종": "찰쌓기", "기준": "㎡당", "보통인부": 0.6 },
{ "공종": "절석(마름돌) 쌓기", "기준": "㎥당", "할석공": 0.1, "보통인부": 1.1 }
],
"주": [
"원문 그대로 — 이 표는 밑줄(칸 경계선) 없이 찍힘.",
"① 본 품은 기준높이 3.6m일 때의 인력헐기를 기준한 것이며, 그 이상일 때의 작업 안전설비 및 특수 조건에 대한 품은 별도 계상한다.",
"② 발생품을 재사용코자 할 때나 제자리 고르기를 할 경우는 별도 계상한다.",
"③ 본 품은 부수기내의 장애물 제거(철근, 파이프 등) 및 공구손료가 포함되어 있다.",
"④ 잡재료는 인력품의 5%이내에서 계상한다."
]
},
{
"열쇠": "3-2-1 금속기와 해체",
"이름": "금속기와 해체",
"기준": "㎡당",
"출처": "건설품셈 유지관리 3-2-1",
"조건": {},
"값칸": { "지붕잇기공": "인", "보통인부": "인" },
"줄": [ { "지붕잇기공": 0.018, "보통인부": 0.012 } ],
"주": [
"① 본 품은 금속기와 지붕을 재사용하지 아니하는 때의 절단하여 해체하는 기준이다.",
"② 본 품은 지붕재 및 후레싱 해체 작업을 포함한다.",
"③ 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"④ 폐기물 처리비용은 별도 계상한다.",
"⑤ 공구손료 및 경장비(절단기 등)의 기계경비는 인력품의 3%로 계상한다."
]
},
{
"열쇠": "3-2-2 흡음텍스 해체",
"이름": "흡음텍스 해체",
"기준": "㎡당",
"출처": "건설품셈 유지관리 3-2-2",
"조건": {},
"값칸": { "내장공": "인", "보통인부": "인" },
"줄": [ { "내장공": 0.016, "보통인부": 0.011 } ],
"주": [
"① 본 품은 흡음텍스를 재사용하지 아니하는 때의 해체하는 기준이다.",
"② 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"③ 폐기물 처리비용은 별도 계상한다."
]
},
{
"열쇠": "3-2-3 경량천장철골틀 해체",
"이름": "경량천장철골틀 해체",
"기준": "㎡당",
"출처": "건설품셈 유지관리 3-2-3",
"조건": {},
"값칸": { "내장공": "인", "보통인부": "인" },
"줄": [ { "내장공": 0.018, "보통인부": 0.012 } ],
"주": [
"① 본 품은 경량천장철골틀을 재사용하지 아니하는 때의 절단하여 해체하는 기준이다.",
"② 본 품은 천장틀(채널, BAR 등) 해체, 달대 및 행거 해체 작업을 포함한다.",
"③ 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"④ 폐기물 처리비용은 별도 계상한다.",
"⑤ 공구손료 및 경장비(절단기 등)의 기계경비는 인력품의 2%로 계상한다."
]
},
{
"열쇠": "3-2-4 조적벽 해체",
"이름": "조적벽 해체",
"기준": "㎥당",
"출처": "건설품셈 유지관리 3-2-4",
"조건": {},
"값칸": { "조적공": "인", "보통인부": "인" },
"줄": [ { "조적공": 0.380, "보통인부": 0.252 } ],
"주": [
"① 본 품은 조적벽(높이 3.6m이하)을 재사용하지 아니하는 때의 해체하는 기준이다.",
"② 본 품은 조적벽 해체, 고정철물 해체 작업을 포함한다.",
"③ 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"④ 폐기물 처리비용은 별도 계상한다.",
"⑤ 공구손료 및 경장비(함마 등)의 기계경비는 인력품의 2%로 계상한다."
]
},
{
"열쇠": "3-2-5 경량벽체철골틀 해체",
"이름": "경량벽체철골틀 해체",
"기준": "㎡당",
"출처": "건설품셈 유지관리 3-2-5",
"조건": {},
"값칸": { "내장공": "인", "보통인부": "인" },
"줄": [ { "내장공": 0.016, "보통인부": 0.011 } ],
"주": [
"① 본 품은 경량벽체철골틀을 재사용하지 아니하는 때의 절단하여 해체하는 기준이다.",
"② 본 품은 러너 및 스터드 해체 작업을 포함한다.",
"③ 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"④ 폐기물 처리비용은 별도 계상한다.",
"⑤ 공구손료 및 경장비(절단기 등)의 기계경비는 인력품의 2%로 계상한다."
]
},
{
"열쇠": "3-2-6 석고판 해체",
"이름": "석고판 해체",
"기준": "㎡당",
"출처": "건설품셈 유지관리 3-2-6",
"조건": { "부위": "고르기" },
"값칸": { "내장공": "인", "보통인부": "인" },
"줄": [
{ "부위": "벽", "내장공": 0.014, "보통인부": 0.010 },
{ "부위": "천장", "내장공": 0.016, "보통인부": 0.012 }
],
"주": [
"① 본 품은 석고판을 재사용하지 아니하는 때의 절단하여 해체하는 기준이다.",
"② 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"③ 폐기물 처리비용은 별도 계상한다.",
"④ 공구손료 및 경장비(절단기 등)의 기계경비는 인력품의 2%로 계상한다."
]
},
{
"열쇠": "3-2-7 도배 해체",
"이름": "도배 해체",
"기준": "㎡당",
"출처": "건설품셈 유지관리 3-2-7",
"조건": { "부위": "고르기" },
"값칸": { "도배공": "인", "보통인부": "인" },
"줄": [
{ "부위": "벽", "도배공": 0.008, "보통인부": 0.005 },
{ "부위": "천장", "도배공": 0.010, "보통인부": 0.007 }
],
"주": [
"① 본 품은 도배지를 재사용하지 아니하는 때의 해체하는 기준이다.",
"② 본 품은 정배지 및 초배지 해체 작업을 포함한다.",
"③ 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"④ 폐기물 처리비용은 별도 계상한다."
]
},
{
"열쇠": "3-2-8 PVC계바닥재 해체",
"이름": "PVC계바닥재 해체",
"기준": "㎡당",
"출처": "건설품셈 유지관리 3-2-8",
"조건": {},
"값칸": { "내장공": "인", "보통인부": "인" },
"줄": [ { "내장공": 0.006, "보통인부": 0.004 } ],
"주": [
"① 본 품은 PVC계 바닥재(시트)를 재사용하지 아니하는 때의 해체하는 기준이다.",
"② 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"③ 폐기물 처리비용은 별도 계상한다."
]
},
{
"열쇠": "3-2-9 타일 해체",
"이름": "타일 해체",
"기준": "㎡당",
"출처": "건설품셈 유지관리 3-2-9",
"조건": { "공법": "고르기" },
"값칸": { "타일공": "인", "보통인부": "인" },
"줄": [
{ "공법": "떠붙이기", "타일공": 0.037, "보통인부": 0.024 },
{ "공법": "압착붙이기,접착붙이기", "타일공": 0.041, "보통인부": 0.027 }
],
"주": [
"① 본 품은 타일을 재사용하지 아니하는 때의 해체하는 기준이다.",
"② 본 품은 타일 및 접착제 깨기 작업을 포함한다.",
"③ 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"④ 폐기물 처리비용은 별도 계상한다.",
"⑤ 공구손료 및 경장비(절단기 등)의 기계경비는 인력품의 6%로 계상한다."
]
},
{
"열쇠": "3-2-10 기존방수층 및 보호층 철거",
"이름": "기존방수층 및 보호층 철거",
"기준": "㎡당",
"출처": "건설품셈 유지관리 3-2-10",
"조건": {},
"값칸": { "착암공": "인", "보통인부": "인", "소형브레이커(1.3㎥/min)": "시간", "공기압축기(3.5㎥/min)": "시간" },
"줄": [ { "착암공": 0.06, "보통인부": 0.22, "소형브레이커(1.3㎥/min)": 0.10, "공기압축기(3.5㎥/min)": 0.05 } ],
"주": [
"규격 — 소형브레이커 1.3㎥/min · 공기압축기 3.5㎥/min.",
"① 본 품은 아스팔트 8층 방수를 보수하기 위하여 방수층을 철거하는 품으로 누름 콘크리트층의 파쇄, 방수층 철거, 폐자재 소운반 및 정리품이 포함되어 있다.",
"② 소규모공사(개소당 작업면적 40㎡미만)인 경우는 장비 사용기간 및 품을 40% 범위 내에서 가산하여 적용한다.",
"③ 누름 콘크리트 두께 8㎝ 기준이다."
]
},
{
"열쇠": "3-2-11 기존방수층 제거 및 바탕처리",
"이름": "기존방수층 제거 및 바탕처리",
"기준": "㎡당",
"출처": "건설품셈 유지관리 3-2-11",
"조건": { "부위": "고르기" },
"값칸": { "방수공": "인", "보통인부": "인" },
"줄": [
{ "부위": "바닥", "방수공": 0.037, "보통인부": 0.015 },
{ "부위": "수직부", "방수공": 0.041, "보통인부": 0.017 }
],
"주": [
"① 본 품은 재방수를 하기 위하여 기존방수층(도막방수)을 제거하고 바탕처리하는 기준이다.",
"② 본 품은 방수층 제거, 홈메우기, 불순물 청소, 퍼티 작업을 포함한다.",
"③ 공구손료 및 경장비(엔진송풍기, 연마기 등)의 기계경비는 인력품의 6%로 계상한다.",
"④ 바탕처리에 사용되는 재료(퍼티, 방수테이프 등)는 별도 계상한다."
]
},
{
"열쇠": "3-2-12 석면건축자재 해체",
"이름": "석면건축자재 해체",
"기준": "㎡당",
"출처": "건설품셈 유지관리 3-2-12",
"조건": { "부위": "고르기" },
"값칸": { "석면해체공": "인", "보통인부": "인" },
"줄": [
{ "부위": "내장재", "석면해체공": 0.120, "보통인부": 0.017 },
{ "부위": "외장재", "석면해체공": 0.045, "보통인부": 0.011 },
{ "부위": "뿜칠재", "석면해체공": 0.5 }
],
"주": [
"① 본 품은 석면이 함유된 자재를 해체하는 품으로 적용기준은 다음과 같다.",
"㉮ 내장재는 건축물의 내부 천장재, 내벽체, 간막이재 철거를 기준한 것이다.",
"㉯ 외장재는 슬레이트 지붕재 해체를 기준한 것이다.",
"㉰ 뿜칠재는 철골내화피복재를 기준으로 한 것으로 철골면의 하부면, 측면부, 상부면 등의 해체공사와 철재로 시공된 천장면에 부착되어 있는 뿜칠재의 해체를 기준한 것이다.",
"② 뿜칠재의 경우, 콘크리트면에 부착된 석면 뿜칠재의 해체는 본 품의 20%를 할증하여 적용한다.",
"③ 본 품은 비닐보양재, 오염제거구역 설치 및 해체가 포함된 것이며, 보양막(외장재) 설치 및 해체품은 제외되어 있다.",
"④ 본 품은 일일 작업시간 6시간을 기준한 것이다.",
"⑤ 석면자재의 해체 작업 시 소요되는 기기경비 및 재료비, 소모품비는 별도 계상한다.",
"⑥ 실내 고소작업 및 실외 비계설치를 위한 가설재의 설치는 별도 계상한다."
]
},
{
"열쇠": "3-3-1 재도장 시 바탕처리(콘크리트·모르타르면)",
"이름": "재도장 시 바탕처리(콘크리트·모르타르면)",
"기준": "일당",
"출처": "건설품셈 유지관리 3-3-1",
"조건": {},
"값칸": { "도장공": "인", "보통인부": "인", "시공량": "㎡" },
"줄": [ { "도장공": 2, "보통인부": 1, "시공량": 230 } ],
"주": [
"① 본 품은 콘크리트·모르타르면 재도장 시 바탕처리하는 기준이다.",
"② 본 품은 기존 도장면을 제거하지 않고, 곰팡이 등 오염, 균열 부위에 부분적으로 퍼티 및 연마하는 작업 기준이다.",
"③ 공구손료 및 잡재료비(연마지 등)는 인력품의 3%로 계상한다."
]
},
{
"열쇠": "3-3-2 재도장 시 바탕처리(철재면)",
"이름": "재도장 시 바탕처리(철재면)",
"기준": "일당",
"출처": "건설품셈 유지관리 3-3-2",
"조건": { "등급": "고르기" },
"값칸": { "도장공": "인", "보통인부": "인", "시공량": "㎡" },
"줄": [
{ "등급": "A급", "도장공": 2, "보통인부": 1, "시공량": 20 },
{ "등급": "B급", "도장공": 2, "보통인부": 1, "시공량": 60 }
],
"주": [
"① 본 품은 철재면 재도장 시 바탕처리하는 기준이다.",
"② 본 품은 오염(기름때 등) 및 부착물 제거, 도장면 연마 및 청소 작업을 포함한다.",
"③ 대상면의 상태에 따른 적용기준은 적용기준 표와 같다.",
"④ 공구손료 및 잡재료비(연마지 등)는 인력품의 3%로 계상한다."
]
},
{
"열쇠": "3-3-2 재도장 시 바탕처리(철재면) 적용기준",
"이름": "재도장 시 바탕처리(철재면) 대상면 적용기준",
"기준": "정의",
"출처": "건설품셈 유지관리 3-3-2",
"조건": { "구분": "고르기" },
"값칸": { "적용기준": "글" },
"줄": [
{ "구분": "A급", "적용기준": "재래도장의 탈락이 심하고 부분적으로 부식되어 약품을 사용하여 도막 및 기타 부착물의 완전 제거를 요하는 정도" },
{ "구분": "B급", "적용기준": "재래도장의 부출되어 있는 녹을 제거하고 와이어 브러쉬로 청소할 정도" }
],
"주": [ "③ 대상면의 상태에 따른 적용기준은 다음과 같다." ]
},
{
"열쇠": "3-3-3 재도장 시 바탕처리(목재면)",
"이름": "재도장 시 바탕처리(목재면)",
"기준": "일당",
"출처": "건설품셈 유지관리 3-3-3",
"조건": { "등급": "고르기" },
"값칸": { "도장공": "인", "보통인부": "인", "시공량": "㎡" },
"줄": [
{ "등급": "A급", "도장공": 2, "보통인부": 1, "시공량": 110 },
{ "등급": "B급", "도장공": 2, "보통인부": 1, "시공량": 270 }
],
"주": [
"① 본 품은 목재면 재도장 시 바탕처리하는 기준이다.",
"② 본 품은 오염 및 부착물 제거, 틈새 및 구멍 충진, 퍼티 및 연마 작업을 포함한다.",
"③ 대상면의 상태에 따른 적용기준은 적용기준 표와 같다.",
"④ 공구손료 및 잡재료비(연마지 등)는 인력품의 3%로 계상한다."
]
},
{
"열쇠": "3-3-3 재도장 시 바탕처리(목재면) 적용기준",
"이름": "재도장 시 바탕처리(목재면) 대상면 적용기준",
"기준": "정의",
"출처": "건설품셈 유지관리 3-3-3",
"조건": { "구분": "고르기" },
"값칸": { "적용기준": "글" },
"줄": [
{ "구분": "A급", "적용기준": "재래도장의 탈락 및 목재의 손상이 심하여 갈라진틈, 구멍 땜 등을 충진하고, 평탄하게 연마해야하는 정도" },
{ "구분": "B급", "적용기준": "재래도장의 탈락 및 목재의 손상이 거의 없으며, 부착물 제거, 부분적으로 퍼티 및 연마를 요하는 정도" }
],
"주": [ "③ 대상면의 상태에 따른 적용기준은 다음과 같다." ]
},
{
"열쇠": "3-4-1 지붕 덧씌우기",
"이름": "지붕 덧씌우기",
"기준": "일당",
"출처": "건설품셈 유지관리 3-4-1",
"조건": {},
"값칸": { "지붕잇기공": "인", "보통인부": "인", "시공량": "㎡" },
"줄": [ { "지붕잇기공": 4, "보통인부": 2, "시공량": 85 } ],
"주": [
"비고 — 맞배지붕(경사를 짓는 지붕면이 2개소)은 시공량을 20% 가산하여 적용한다.",
"① 본 품은 기존의 지붕 위에 신규 지붕을 덧씌워 보수하는 기준이다.",
"② 본 품은 바탕정리, 지붕틀 설치, 지붕재(금속기와) 설치, 용마루 및 후레싱 마감 작업을 포함한다.",
"③ 홈통 및 빗물받이 설치는 '[건축부문] 7-2 홈통'을 따른다.",
"④ 비계매기, 비산방지, 보호 및 안전시설의 설치비는 별도 계상한다.",
"⑤ 공구손료 및 경장비(에어콤프, 절단기 등)의 기계경비는 인력품의 2%로 계상한다."
]
},
{
"열쇠": "3-4-2 지붕 재설치",
"이름": "지붕 재설치",
"기준": "일당",
"출처": "건설품셈 유지관리 3-4-2",
"조건": {},
"값칸": { "지붕잇기공": "인", "보통인부": "인", "시공량": "㎡" },
"줄": [ { "지붕잇기공": 6, "보통인부": 2, "시공량": 50 } ],
"주": [
"비고 — 맞배지붕(경사를 짓는 지붕면이 2개소)은 시공량을 20% 가산하여 적용한다.",
"① 본 품은 기존의 지붕재가 철거된 상태에서 신규 지붕을 재설치하는 기준이다.",
"② 본 품은 바탕정리, 지붕틀 및 바탕합판 설치, 방수시트 및 단열재 설치, 지붕재(금속기와) 설치, 용마루 및 후레싱 마감 작업을 포함한다.",
"③ 홈통 및 빗물받이 설치는 '[건축부문] 7-2 홈통'을 따른다.",
"④ 지붕재 철거는 별도 계상한다.",
"⑤ 비계매기, 비산방지, 보호 및 안전시설(비계 등)의 설치비는 별도 계상한다.",
"⑥ 공구손료 및 경장비(에어콤프, 절단기 등)의 기계경비는 인력품의 2%로 계상한다."
]
},
{
"열쇠": "3-4-3 도배 교체",
"이름": "도배 교체",
"기준": "일당",
"출처": "건설품셈 유지관리 3-4-3",
"조건": { "부위": "고르기" },
"값칸": { "내장공": "인", "시공량": "㎡" },
"줄": [
{ "부위": "벽", "내장공": 2, "시공량": 46 },
{ "부위": "천장", "내장공": 2, "시공량": 35 }
],
"주": [
"비고 — 사용중인 세대로 가구 등의 지장물이 있는 경우 시공량의 15%를 감한다.",
"① 본 품은 도배지를 해체(재사용하지 아니하는 때)하고 재설치하는 기준이다.",
"② 본 품은 도배지 해체, 바탕정리, 풀먹임, 초배 및 정배 바름 작업을 포함한다.",
"③ 가구 등 지장물의 운반은 별도 계상한다."
]
},
{
"열쇠": "3-4-4 PVC계바닥재 교체",
"이름": "PVC계바닥재 교체",
"기준": "일당",
"출처": "건설품셈 유지관리 3-4-4",
"조건": {},
"값칸": { "내장공": "인", "시공량": "㎡" },
"줄": [ { "내장공": 2, "시공량": 61 } ],
"주": [
"비고 — 사용중인 세대로 가구 등의 지장물이 있는 경우 시공량의 15%를 감한다.",
"① 본 품은 PVC계 바닥재(시트)를 해체(재사용하지 아니하는 때)하고 재설치하는 기준이다.",
"② 본 품은 바닥재 해체, 바탕정리, 접착제(부분접합 방식) 바름, 바닥재 설치 작업을 포함한다.",
"③ 가구 등 지장물의 운반은 별도 계상한다."
]
},
{
"열쇠": "3-4-5 타일 교체",
"이름": "타일 교체",
"기준": "일당",
"출처": "건설품셈 유지관리 3-4-5",
"조건": { "공법": "고르기" },
"값칸": { "타일공": "인", "시공량": "㎡" },
"줄": [
{ "공법": "떠붙이기(벽)", "타일공": 2, "시공량": 7 },
{ "공법": "압착붙이기(바닥)", "타일공": 2, "시공량": 8 }
],
"주": [
"비고 — 사용중인 세대로 가구 등의 지장물이 있는 경우 시공량의 15%를 감한다.",
"① 본 품은 타일을 해체(재사용하지 아니하는 때)하고 재설치하는 기준이다.",
"② 본 품은 타일 해체, 바탕정리, 타일 붙임, 줄눈 설치 및 마무리 작업을 포함한다.",
"③ 방수 작업은 별도 계상한다.",
"④ 가구 등 지장물의 운반은 별도 계상한다."
]
}
]
}
@@ -0,0 +1,353 @@
{
"그룹": "소요량",
"원문": "건설품셈",
"부문": "유지관리",
"판": "2026",
"표": [
{
"열쇠": "4-1-1 배관 해체",
"이름": "배관 해체",
"기준": "m당",
"출처": "건설품셈 유지관리 4-1-1",
"조건": { "관종": "고르기", "규격(㎜)": "고르기" },
"값칸": { "배관공": "인", "보통인부": "인" },
"줄": [
{ "관종": "강관", "규격(㎜)": "ø15이하", "배관공": 0.012, "보통인부": 0.008 },
{ "관종": "강관", "규격(㎜)": "20", "배관공": 0.013, "보통인부": 0.009 },
{ "관종": "강관", "규격(㎜)": "25", "배관공": 0.017, "보통인부": 0.011 },
{ "관종": "강관", "규격(㎜)": "32", "배관공": 0.019, "보통인부": 0.013 },
{ "관종": "강관", "규격(㎜)": "40", "배관공": 0.021, "보통인부": 0.014 },
{ "관종": "강관", "규격(㎜)": "50", "배관공": 0.027, "보통인부": 0.018 },
{ "관종": "강관", "규격(㎜)": "65", "배관공": 0.031, "보통인부": 0.021 },
{ "관종": "강관", "규격(㎜)": "80", "배관공": 0.039, "보통인부": 0.026 },
{ "관종": "강관", "규격(㎜)": "100", "배관공": 0.053, "보통인부": 0.035 },
{ "관종": "강관", "규격(㎜)": "125", "배관공": 0.067, "보통인부": 0.045 },
{ "관종": "강관", "규격(㎜)": "150", "배관공": 0.079, "보통인부": 0.053 },
{ "관종": "강관", "규격(㎜)": "200", "배관공": 0.121, "보통인부": 0.080 },
{ "관종": "강관", "규격(㎜)": "250", "배관공": 0.161, "보통인부": 0.107 },
{ "관종": "강관", "규격(㎜)": "300", "배관공": 0.208, "보통인부": 0.139 },
{ "관종": "강관", "규격(㎜)": "350", "배관공": 0.250, "보통인부": 0.167 },
{ "관종": "강관", "규격(㎜)": "400", "배관공": 0.296, "보통인부": 0.197 },
{ "관종": "동관", "규격(㎜)": "ø15이하", "배관공": 0.010, "보통인부": 0.007 },
{ "관종": "동관", "규격(㎜)": "20", "배관공": 0.012, "보통인부": 0.008 },
{ "관종": "동관", "규격(㎜)": "25", "배관공": 0.015, "보통인부": 0.010 },
{ "관종": "동관", "규격(㎜)": "32", "배관공": 0.018, "보통인부": 0.012 },
{ "관종": "동관", "규격(㎜)": "40", "배관공": 0.020, "보통인부": 0.014 },
{ "관종": "동관", "규격(㎜)": "50", "배관공": 0.027, "보통인부": 0.018 },
{ "관종": "동관", "규격(㎜)": "65", "배관공": 0.031, "보통인부": 0.021 },
{ "관종": "동관", "규격(㎜)": "80", "배관공": 0.039, "보통인부": 0.026 },
{ "관종": "동관", "규격(㎜)": "100", "배관공": 0.053, "보통인부": 0.035 },
{ "관종": "동관", "규격(㎜)": "125", "배관공": 0.066, "보통인부": 0.044 },
{ "관종": "동관", "규격(㎜)": "150", "배관공": 0.078, "보통인부": 0.052 },
{ "관종": "동관", "규격(㎜)": "200", "배관공": 0.116, "보통인부": 0.077 },
{ "관종": "동관", "규격(㎜)": "250", "배관공": 0.153, "보통인부": 0.102 }
],
"주": [
"원문 그대로 — 동관 300~400 규격 칸은 빈 칸(글자층 전각 빈칸).",
"① 본 품은 배관을 재사용하지 아니하는 때의 절단하여 해체하는 기준이다.",
"② 본 품은 지지철물, 배관 해체를 포함한다.",
"③ 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"④ 폐기물 처리비용은 별도 계상한다.",
"⑤ 공구손료 및 경장비(절단기 등)의 기계경비는 인력품의 2%로 계상한다."
]
},
{
"열쇠": "4-1-2 각형덕트 해체",
"이름": "각형덕트 해체",
"기준": "㎡당",
"출처": "건설품셈 유지관리 4-1-2",
"조건": { "호칭두께(mm)": "고르기" },
"값칸": { "덕트공": "인", "보통인부": "인" },
"줄": [
{ "호칭두께(mm)": "0.5", "덕트공": 0.064, "보통인부": 0.043 },
{ "호칭두께(mm)": "0.6", "덕트공": 0.060, "보통인부": 0.040 },
{ "호칭두께(mm)": "0.8", "덕트공": 0.063, "보통인부": 0.042 },
{ "호칭두께(mm)": "1.0", "덕트공": 0.077, "보통인부": 0.051 },
{ "호칭두께(mm)": "1.2", "덕트공": 0.089, "보통인부": 0.059 },
{ "호칭두께(mm)": "1.6", "덕트공": 0.111, "보통인부": 0.074 }
],
"주": [
"① 본 품은 각형덕트(아연도금강판, 스테인리스)를 재사용하지 아니하는 때의 절단하여 해체하는 기준이다.",
"② 본 품은 지지철물, 덕트 절단 및 해체를 포함한다.",
"③ 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"④ 폐기물 처리비용은 별도 계상한다.",
"⑤ 공구손료 및 경장비(절단기 등)의 기계경비는 인력품의 2%로 계상한다."
]
},
{
"열쇠": "4-1-3 스파이럴덕트 해체",
"이름": "스파이럴덕트 해체",
"기준": "m당",
"출처": "건설품셈 유지관리 4-1-3",
"조건": { "철판두께(㎜)": "고르기", "규격(㎜)": "고르기" },
"값칸": { "덕트공": "인", "보통인부": "인" },
"줄": [
{ "철판두께(㎜)": "0.5", "규격(㎜)": "ø150이하", "덕트공": 0.036, "보통인부": 0.024 },
{ "철판두께(㎜)": "0.5", "규격(㎜)": "160", "덕트공": 0.037, "보통인부": 0.025 },
{ "철판두께(㎜)": "0.5", "규격(㎜)": "180", "덕트공": 0.041, "보통인부": 0.028 },
{ "철판두께(㎜)": "0.5", "규격(㎜)": "200", "덕트공": 0.045, "보통인부": 0.030 },
{ "철판두께(㎜)": "0.6", "규격(㎜)": "225", "덕트공": 0.050, "보통인부": 0.033 },
{ "철판두께(㎜)": "0.6", "규격(㎜)": "250", "덕트공": 0.055, "보통인부": 0.036 },
{ "철판두께(㎜)": "0.6", "규격(㎜)": "275", "덕트공": 0.059, "보통인부": 0.040 },
{ "철판두께(㎜)": "0.6", "규격(㎜)": "300", "덕트공": 0.064, "보통인부": 0.043 },
{ "철판두께(㎜)": "0.6", "규격(㎜)": "350", "덕트공": 0.074, "보통인부": 0.049 },
{ "철판두께(㎜)": "0.6", "규격(㎜)": "400", "덕트공": 0.084, "보통인부": 0.056 },
{ "철판두께(㎜)": "0.6", "규격(㎜)": "450", "덕트공": 0.104, "보통인부": 0.069 },
{ "철판두께(㎜)": "0.6", "규격(㎜)": "500", "덕트공": 0.114, "보통인부": 0.076 },
{ "철판두께(㎜)": "0.6", "규격(㎜)": "550", "덕트공": 0.123, "보통인부": 0.082 },
{ "철판두께(㎜)": "0.6", "규격(㎜)": "600", "덕트공": 0.132, "보통인부": 0.088 }
],
"주": [
"① 본 품은 스파이럴덕트(아연도금강판)를 재사용하지 아니하는 때의 절단하여 해체하는 기준이다.",
"② 본 품은 지지철물, 덕트 절단 및 해체를 포함한다.",
"③ 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"④ 폐기물 처리비용은 별도 계상한다.",
"⑤ 공구손료 및 경장비(절단기 등)의 기계경비는 인력품의 2%로 계상한다."
]
},
{
"열쇠": "4-1-4 배관보온 해체",
"이름": "배관보온 해체",
"기준": "m당",
"출처": "건설품셈 유지관리 4-1-4",
"조건": { "보온재종류": "고르기", "규격(㎜)": "고르기" },
"값칸": { "보온공": "인", "보통인부": "인" },
"줄": [
{ "보온재종류": "고무발포보온재", "규격(㎜)": "ø15", "보온공": 0.014, "보통인부": 0.010 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "20", "보온공": 0.016, "보통인부": 0.011 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "25", "보온공": 0.017, "보통인부": 0.011 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "32", "보온공": 0.020, "보통인부": 0.013 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "40", "보온공": 0.023, "보통인부": 0.016 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "50", "보온공": 0.027, "보통인부": 0.018 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "65", "보온공": 0.029, "보통인부": 0.020 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "80", "보온공": 0.033, "보통인부": 0.022 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "100", "보온공": 0.038, "보통인부": 0.025 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "125", "보온공": 0.046, "보통인부": 0.030 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "150", "보온공": 0.053, "보통인부": 0.035 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "200", "보온공": 0.064, "보통인부": 0.042 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "250", "보온공": 0.073, "보통인부": 0.049 },
{ "보온재종류": "고무발포보온재", "규격(㎜)": "300", "보온공": 0.083, "보통인부": 0.055 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "ø15", "보온공": 0.010, "보통인부": 0.007 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "20", "보온공": 0.012, "보통인부": 0.008 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "25", "보온공": 0.012, "보통인부": 0.008 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "32", "보온공": 0.014, "보통인부": 0.010 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "40", "보온공": 0.017, "보통인부": 0.011 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "50", "보온공": 0.019, "보통인부": 0.013 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "65", "보온공": 0.021, "보통인부": 0.014 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "80", "보온공": 0.024, "보통인부": 0.016 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "100", "보온공": 0.027, "보통인부": 0.018 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "125", "보온공": 0.033, "보통인부": 0.022 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "150", "보온공": 0.038, "보통인부": 0.025 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "200", "보온공": 0.045, "보통인부": 0.030 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "250", "보온공": 0.052, "보통인부": 0.035 },
{ "보온재종류": "발포폴리에틸렌보온재", "규격(㎜)": "300", "보온공": 0.059, "보통인부": 0.039 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "ø15", "보온공": 0.016, "보통인부": 0.011 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "20", "보온공": 0.018, "보통인부": 0.012 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "25", "보온공": 0.019, "보통인부": 0.013 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "32", "보온공": 0.023, "보통인부": 0.015 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "40", "보온공": 0.026, "보통인부": 0.017 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "50", "보온공": 0.031, "보통인부": 0.020 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "65", "보온공": 0.033, "보통인부": 0.022 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "80", "보온공": 0.038, "보통인부": 0.025 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "100", "보온공": 0.043, "보통인부": 0.028 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "125", "보온공": 0.051, "보통인부": 0.034 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "150", "보온공": 0.060, "보통인부": 0.040 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "200", "보온공": 0.072, "보통인부": 0.048 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "250", "보온공": 0.082, "보통인부": 0.055 },
{ "보온재종류": "유리면보온재(글라스울)", "규격(㎜)": "300", "보온공": 0.093, "보통인부": 0.062 }
],
"주": [
"① 본 품은 배관보온재(보온두께 50mm이하)를 재사용하지 아니하는 때의 절단하여 해체하는 기준이다.",
"② 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"③ 폐기물 처리비용은 별도 계상한다."
]
},
{
"열쇠": "4-1-5 덕트보온 해체",
"이름": "덕트보온 해체",
"기준": "㎡당",
"출처": "건설품셈 유지관리 4-1-5",
"조건": { "보온재종류": "고르기" },
"값칸": { "보온공": "인", "보통인부": "인" },
"줄": [
{ "보온재종류": "고무발포보온재·발포폴리에틸렌보온재", "보온공": 0.081, "보통인부": 0.054 },
{ "보온재종류": "유리면보온재(글라스울)", "보온공": 0.096, "보통인부": 0.064 }
],
"주": [
"① 본 품은 재사용하지 아니하는 때의 보온재를 절단하여 해체하는 기준이다.",
"② 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"③ 폐기물 처리비용은 별도 계상한다."
]
},
{
"열쇠": "4-1-6 펌프 해체",
"이름": "펌프 해체",
"기준": "대당",
"출처": "건설품셈 유지관리 4-1-6",
"조건": { "규격(kW)": "고르기" },
"값칸": { "기계설비공": "인", "보통인부": "인" },
"줄": [
{ "규격(kW)": "0.75 이하", "기계설비공": 0.245, "보통인부": 0.163 },
{ "규격(kW)": "1.5 이하", "기계설비공": 0.271, "보통인부": 0.181 },
{ "규격(kW)": "2.2 이하", "기계설비공": 0.312, "보통인부": 0.208 },
{ "규격(kW)": "3.7 이하", "기계설비공": 0.359, "보통인부": 0.239 },
{ "규격(kW)": "5.5 이하", "기계설비공": 0.432, "보통인부": 0.288 },
{ "규격(kW)": "7.5 이하", "기계설비공": 0.545, "보통인부": 0.363 },
{ "규격(kW)": "11 이하", "기계설비공": 0.685, "보통인부": 0.457 },
{ "규격(kW)": "15 이하", "기계설비공": 0.727, "보통인부": 0.485 },
{ "규격(kW)": "22 이하", "기계설비공": 1.175, "보통인부": 0.783 },
{ "규격(kW)": "37 이하", "기계설비공": 1.517, "보통인부": 1.011 },
{ "규격(kW)": "55 이하", "기계설비공": 2.440, "보통인부": 1.627 },
{ "규격(kW)": "75 이하", "기계설비공": 2.989, "보통인부": 1.993 }
],
"주": [
"① 본 품은 일반펌프(급수 및 소방펌프)를 재사용하지 아니하는 때의 절단하여 해체하는 기준이다.",
"② 본 품은 방진가대 해체, 펌프 절단 및 해체를 포함한다.",
"③ 비산방지, 보호 및 안전시설 등의 설치비는 별도 계상한다.",
"④ 폐기물 처리비용은 별도 계상한다.",
"⑤ 공구손료 및 경장비(절단기 등)의 기계경비는 인력품의 2%로 계상한다."
]
},
{
"열쇠": "4-1-7 일반기계설비 철거 및 이설",
"이름": "일반기계설비 철거 및 이설 요율",
"기준": "단위:%",
"출처": "건설품셈 유지관리 4-1-7",
"조건": { "구분": "고르기" },
"값칸": { "철거(재사용고려)": "%", "철거(재사용안함)": "%", "동일구내(인접장소)이설": "%" },
"줄": [
{ "구분": "1. 기기류", "철거(재사용고려)": 80, "철거(재사용안함)": 60, "동일구내(인접장소)이설": 160 },
{ "구분": "2. 철골류", "철거(재사용고려)": 70, "철거(재사용안함)": 50, "동일구내(인접장소)이설": 150 },
{ "구분": "3. 배관류", "철거(재사용고려)": 60, "철거(재사용안함)": 40, "동일구내(인접장소)이설": 140 },
{ "구분": "4. BELT CONVEYOR류", "철거(재사용고려)": 80, "철거(재사용안함)": 60, "동일구내(인접장소)이설": 160 },
{ "구분": "5. 보온재", "철거(재사용고려)": 60, "철거(재사용안함)": 40, "동일구내(인접장소)이설": 140 },
{ "구분": "6. HEATER & TANK류", "철거(재사용고려)": 70, "철거(재사용안함)": 50, "동일구내(인접장소)이설": 150 },
{ "구분": "7. PUMP & FAN류", "철거(재사용고려)": 60, "철거(재사용안함)": 40, "동일구내(인접장소)이설": 140 },
{ "구분": "8. CRANE류", "철거(재사용고려)": 70, "철거(재사용안함)": 50, "동일구내(인접장소)이설": 150 }
],
"주": [
"① '4-1-1 배관 해체~4-1-6 펌프 해체'의 각 항목을 우선 적용하며, 외의 항목은 상기류 유사폼목에 적용할 수 있다.(원문 그대로 「유사폼목」)",
"② 공구손료 및 소모재료는 별도 계상한다.",
"③ 상기의 율은 설치를 100%로 볼 때이다.",
"④ 특수기기에 대하여는 별도 계상한다.",
"⑤ 철거한 설비를 동일구내 또한 인접한 장소가 아닌 곳에 재 설치할 경우에는 설치품+철거품(재사용을 고려할 경우)으로 계상한다.",
"⑥ 다음 항목의 철거는 신설의 50%(재사용을 고려치 않을 경우)로 계상한다 — 적용항목 표를 참고한다."
]
},
{
"열쇠": "4-1-7 일반기계설비 철거 및 이설 적용항목",
"이름": "일반기계설비 철거 신설 50% 적용항목",
"기준": "정의",
"출처": "건설품셈 유지관리 4-1-7",
"조건": {},
"값칸": { "항목": "글" },
"줄": [ { "항목": "1-4-1 주철관 기계식 접합 및 배관 · 4-2-1 송풍기 설치 · 5-1-1 일반밸브 및 콕류 설치 · 5-1-2 감압밸브장치 설치 · 5-2-1 스팀트랩 장치 설치 · 5-3-1 익스팬션조인트 설치 · 5-3-2 플랙시블커넥터 설치 · 8-1-2 냉동기 설치 · 8-1-3 냉각탑 설치 · 8-2-1 공기가열기, 공기냉각기, 공기여과기 설치 · 8-2-3 공기조화기(Air Handling Unit) 설치 · 8-3-6 방열기 설치 · 10-1-1 옥내소화전함설치 · 10-1-2 소화용구 격납상자설치 · 10-3-1 지하식설치 · 10-3-2 지상식설치 · 10-4-1 일반송수구설치 · 10-4-2 방수구설치" } ],
"주": [ "⑥ 다음 항목의 철거는 신설의 50%(재사용을 고려치 않을 경우)로 계상한다." ]
},
{
"열쇠": "4-2-1 자동제어설비 철거 및 이설",
"이름": "자동제어설비 철거 및 이설",
"기준": "적용내용",
"출처": "건설품셈 유지관리 4-2-1",
"조건": {},
"값칸": { "항목": "글", "철거요율": "%", "이설요율": "%" },
"줄": [
{
"항목": "12-1-1 계기반 설치 · 12-1-2 플랜트계기 설치 · 12-2-2 계량기 설치 · 12-2-3 도압배관 · 12-2-4 Control Air 배관 · 12-2-5 압축공기 발생장치 및 공기관 배관",
"철거요율": 40,
"이설요율": 140
}
],
"주": [
"적용내용 — 철거는 본 품의 40%(재사용)를 계상한다. 이설은 본 품의 140%를 계상한다."
]
},
{
"열쇠": "4-3-1 유량계 교체",
"이름": "유량계 교체",
"기준": "일당",
"출처": "건설품셈 유지관리 4-3-1",
"조건": { "규격(mm)": "고르기" },
"값칸": { "배관공": "인", "보통인부": "인", "시공량(보호통)": "개", "시공량(유량계)": "개" },
"줄": [
{ "규격(mm)": "ø13~15", "배관공": 1, "보통인부": 1, "시공량(보호통)": 6.0, "시공량(유량계)": 8.0 },
{ "규격(mm)": "ø20~32", "배관공": 1, "보통인부": 1, "시공량(보호통)": 5.0, "시공량(유량계)": 7.0 },
{ "규격(mm)": "ø40~50", "배관공": 1, "보통인부": 1, "시공량(보호통)": 4.0, "시공량(유량계)": 6.0 },
{ "규격(mm)": "ø65~80", "배관공": 1, "보통인부": 1, "시공량(유량계)": 2.0 },
{ "규격(mm)": "ø100~150", "배관공": 1, "보통인부": 1, "시공량(유량계)": 1.5 },
{ "규격(mm)": "ø200~300", "배관공": 1, "보통인부": 1, "시공량(유량계)": 1.0 }
],
"주": [
"비고 — 동일장소에서 수도미터, 온수미터를 병행 교체 시(해체 후 재부착)에는 유량계 교체 시공량에 30%를 감한다.",
"① 본 품은 수도미터(급수용), 온수미터(급탕용, 난방용)의 옥내배관 교체(해체 후 재부착) 기준이다.",
"② 보호통·뚜껑철거 및 재설치가 요구되는 경우에 보호통을 적용한다.",
"③ 본 품은 유량계 해체 및 재부착, 작동시험 및 마무리 작업을 포함한다.",
"④ 공구손료 및 경장비의 기계경비는 인력품의 1%로 계상한다."
]
},
{
"열쇠": "4-3-2 관갱생공",
"이름": "관갱생공",
"기준": "m당",
"출처": "건설품셈 유지관리 4-3-2",
"조건": { "규격(㎜)": "고르기" },
"값칸": { "규사": "㎏", "에폭시도료": "㎏", "배관공": "인", "특별인부": "인", "장비사용시간": "시간" },
"줄": [
{ "규격(㎜)": "ø15", "규사": 0.520, "에폭시도료": 0.060, "배관공": 0.072, "특별인부": 0.036, "장비사용시간": 0.053 },
{ "규격(㎜)": "20", "규사": 0.590, "에폭시도료": 0.107, "배관공": 0.072, "특별인부": 0.036, "장비사용시간": 0.053 },
{ "규격(㎜)": "25", "규사": 0.707, "에폭시도료": 0.127, "배관공": 0.072, "특별인부": 0.036, "장비사용시간": 0.053 },
{ "규격(㎜)": "32", "규사": 0.880, "에폭시도료": 0.173, "배관공": 0.072, "특별인부": 0.036, "장비사용시간": 0.053 },
{ "규격(㎜)": "40", "규사": 1.083, "에폭시도료": 0.203, "배관공": 0.072, "특별인부": 0.036, "장비사용시간": 0.053 },
{ "규격(㎜)": "50", "규사": 1.343, "에폭시도료": 0.260, "배관공": 0.072, "특별인부": 0.036, "장비사용시간": 0.053 },
{ "규격(㎜)": "65", "규사": 1.687, "에폭시도료": 0.330, "배관공": 0.081, "특별인부": 0.039, "장비사용시간": 0.064 },
{ "규격(㎜)": "80", "규사": 2.083, "에폭시도료": 0.387, "배관공": 0.081, "특별인부": 0.039, "장비사용시간": 0.064 },
{ "규격(㎜)": "100", "규사": 2.580, "에폭시도료": 0.513, "배관공": 0.081, "특별인부": 0.039, "장비사용시간": 0.064 },
{ "규격(㎜)": "125", "규사": 3.177, "에폭시도료": 0.647, "배관공": 0.101, "특별인부": 0.050, "장비사용시간": 0.080 },
{ "규격(㎜)": "150", "규사": 3.977, "에폭시도료": 0.777, "배관공": 0.101, "특별인부": 0.050, "장비사용시간": 0.080 },
{ "규격(㎜)": "200", "규사": 5.030, "에폭시도료": 1.027, "배관공": 0.101, "특별인부": 0.050, "장비사용시간": 0.080 },
{ "규격(㎜)": "250", "규사": 6.297, "에폭시도료": 1.277, "배관공": 0.111, "특별인부": 0.056, "장비사용시간": 0.089 },
{ "규격(㎜)": "300", "규사": 7.610, "에폭시도료": 1.650, "배관공": 0.111, "특별인부": 0.056, "장비사용시간": 0.089 }
],
"주": [
"① 본 품은 에어샌드공법을 기준한 것이다.",
"② 도장두께는 0.3∼1㎜일 때를 기준한 것이다.",
"③ 본 품에는 강관 갱생을 위한 관내부세척, 열풍건조, 관내부 피복코팅 및 소운반 품이 포함되어 있다.",
"④ 입상관의 경우는 본 품에 30%를 가산한다.",
"⑤ 검사구 설치, 밸브 및 보온 해체 복구, 가설급수 배관 및 해체에 대한 비용은 별도 계상한다.",
"⑥ 관세척 공사시 발생되는 폐기물을 폐기물관리법 등의 규정에 따라 적정하게 처리하는데 소요되는 비용은 별도 계상한다.",
"⑦ 사용장비중 공기압축기는 규격 25.5㎥/min를 기준한 것이며, 라이닝기(1set)에 대한 기계경비는 별도 계상한다.",
"⑧ 장비조합은 장비조합 표를 기준한다."
]
},
{
"열쇠": "4-3-2 관갱생공 장비조합",
"이름": "관갱생공 장비조합",
"기준": "정의",
"출처": "건설품셈 유지관리 4-3-2",
"조건": { "규격(㎜)범위": "고르기" },
"값칸": { "라이닝기": "글", "공기압축기": "대" },
"줄": [
{ "규격(㎜)범위": "ø1550", "라이닝기": "1set", "공기압축기": 1 },
{ "규격(㎜)범위": "ø65100", "라이닝기": "1set", "공기압축기": 2 },
{ "규격(㎜)범위": "ø125200", "라이닝기": "1set", "공기압축기": 5 },
{ "규격(㎜)범위": "ø250300", "라이닝기": "1set", "공기압축기": 6 }
],
"주": [ "⑧ 장비조합은 다음을 기준한다." ]
},
{
"열쇠": "4-3-3 배관누수 검사",
"이름": "배관누수 검사",
"기준": "일당",
"출처": "건설품셈 유지관리 4-3-3",
"조건": {},
"값칸": { "배관공": "인", "시공량": "회" },
"줄": [ { "배관공": 2, "시공량": 2.8 } ],
"주": [
"① 본 품은 급수용, 급탕용, 난방용 옥내배관(∅50mm이하)의 누수보수를 위해 배관을 검사하는 기준이다.",
"② 본 품은 작업준비, 수도검침 및 기록, 미터기 해체 및 재설치, 공기압시험 및 누수탐지, 정리 작업을 포함한다.",
"③ 누수부위에 대한 해체 및 복구, 누수배관 교체 작업은 별도 계상한다.",
"④ 공구손료 및 경장비(공기압축기, 압력계 등)의 기계경비는 인력품의 3%로 계상한다."
]
}
]
}
File diff suppressed because it is too large Load Diff
+19 -4
View File
@@ -71,6 +71,21 @@
{ "열쇠": "틈메우기돌", "이름": "틈메우기돌", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 13-5-1 주④" },
{ "열쇠": "철망태", "이름": "철망태(사각형 돌망태)", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 13-11-4" },
{ "열쇠": "돌망태 채움재", "이름": "채움재(돌망태)", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 13-11-4" },
{ "열쇠": "농약(Fluroxypyr-meptyl+Triclypyr-TEA 미탁제)", "이름": "농약", "규격": "Fluroxypyr-meptyl+Triclypyr-TEA 미탁제", "단위": "병", "값": null, "출처": "자체", "비고": "산림품셈 2-1-5-1 · 6-4-2" },
{ "열쇠": "우드칩", "이름": "우드칩(목재칩)", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 6-8" },
{ "열쇠": "깃발", "이름": "깃발", "규격": "", "단위": "개", "값": null, "출처": "자체", "비고": "산림품셈 8-6-1" },
{ "열쇠": "폭약", "이름": "폭약", "규격": "", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 공통 3-3-7 · 3-3-9" },
{ "열쇠": "뇌관", "이름": "뇌관", "규격": "", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 3-3-7 · 3-3-9" },
{ "열쇠": "비트", "이름": "비트(소형브레이커 천공)", "규격": "", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 3-3-7 · 3-3-9" },
{ "열쇠": "초류종자", "이름": "종자(초류)", "규격": "", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 공통 4-1-3 재료량" },
{ "열쇠": "복합비료", "이름": "비료", "규격": "복합비료", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 공통 4-1-3 재료량" },
{ "열쇠": "피복제", "이름": "피복제", "규격": "화이버/펄프류", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 공통 4-1-3 재료량" },
{ "열쇠": "침식방지안정제", "이름": "침식방지안정제", "규격": "합성접착제", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 공통 4-1-3 재료량" },
{ "열쇠": "착색제", "이름": "색소", "규격": "착색제", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 공통 4-1-3 재료량" },
{ "열쇠": "차선도색 프라이머", "이름": "프라이머", "규격": "융착식 도료 차선도색용", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 토목 1-8-9 융착식 도료 수동식 재료" },
{ "열쇠": "낙석방지망 철망", "이름": "철망", "규격": "낙석방지망용", "단위": "㎡", "값": null, "출처": "자체", "비고": "건설품셈 토목 1-9-4 낙석방지망 재료(참고자료)" },
{ "열쇠": "결속선", "이름": "결속선", "규격": "", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 토목 1-9-4 낙석방지망 재료(참고자료)" },
{ "열쇠": "에폭시", "이름": "에폭시", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 토목 1-9-4 낙석방지망 재료(참고자료) · 포켓식만" },
{"열쇠": "에폭시 신구콘크리트 접착제", "이름": "Epoxy 신구-콘크리트 접착제", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-1-5"},
{"열쇠": "에폭시 콘크리트고무 접착제", "이름": "Epoxy-콘크리트 고무 접착제", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-1-5"},
{"열쇠": "폼타이 9.5㎜", "이름": "폼타이(Form Tie)", "규격": "9.5㎜", "단위": "본", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-3-1 주④"},
@@ -139,9 +154,9 @@
{"열쇠": "피복제", "이름": "피복제", "규격": "화이버/펄프류", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 공통 4-1-3 재료량"},
{"열쇠": "침식방지안정제", "이름": "침식방지안정제", "규격": "합성접착제", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 공통 4-1-3 재료량"},
{"열쇠": "착색제", "이름": "색소", "규격": "착색제", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 공통 4-1-3 재료량"},
{ "열쇠": "아세틸렌가스()", "이름": "아세틸렌가스", "규격": "", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-2-5 · 원문 ℓ 단위(나라장터 20204095 는 kg)" },
{ "열쇠": "노면파쇄기 팁(날)", "이름": "팁(날)", "규격": "노면파쇄기 절삭용", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 유지관리 2-1-3 주④ · 2-1-4 주④ · 2-1-5 주②" },
{ "열쇠": "차선도색 프라이머", "이름": "프라이머", "규격": "차선도색 융착식용", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 유지관리 2-1-15 4 자재수량" },
{ "열쇠": "프로판가스", "이름": "프로판가스", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 유지관리 2-1-15 4 자재수량" }
{"열쇠": "아세틸렌가스()", "이름": "아세틸렌가스", "규격": "", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-2-5 · 원문 ℓ 단위(나라장터 20204095 는 kg)"},
{"열쇠": "노면파쇄기 팁(날)", "이름": "팁(날)", "규격": "노면파쇄기 절삭용", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 유지관리 2-1-3 주④ · 2-1-4 주④ · 2-1-5 주②"},
{"열쇠": "차선도색 프라이머", "이름": "프라이머", "규격": "차선도색 융착식용", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 유지관리 2-1-15 4 자재수량"},
{"열쇠": "프로판가스", "이름": "프로판가스", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 유지관리 2-1-15 4 자재수량"}
]
}
+198
View File
@@ -0,0 +1,198 @@
"""M01 마스터 데이터 API — 계약 `resources/master_data/_화면_계약.md`.
임시 폴더에 master_data JSON 사본을 두고 읽기 · 시험 계산 · 저장 · 409 · 422.
"""
from __future__ import annotations
import json
import shutil
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
REAL = store.FOLDER
LABOR = "인력_건설노임.json"
LOGIC_BOOK, LOGIC_KEY = (
"건설품셈",
"3-3-1 암발파(미진동굴착 TYPE-)",
) # 화약취급공 1016 · 보통인부 1002
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
for path in REAL.glob("*.json"):
if not path.name.startswith("_"):
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(store, "FOLDER", tmp_path)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def _get(client: TestClient, url: str, **params) -> dict:
res = client.get(url, params=params)
assert res.status_code == 200, res.text
return res.json()
def _calc(client: TestClient, **inputs) -> dict:
res = client.post(
"/api/m01/calc", json={"book": LOGIC_BOOK, "key": LOGIC_KEY, "inputs": inputs}
)
assert res.status_code == 200, res.text
return res.json()
def _labor_row(client: TestClient, key: str) -> tuple[dict, str]:
got = _get(client, "/api/m01/rows", file=LABOR, q="", size=500)
return next(r for r in got["rows"] if r["열쇠"] == key), got["version"]
def test_그룹_파일_줄_쪽나눔_찾기(client: TestClient) -> None:
groups = {g["group"]: g for g in _get(client, "/api/m01/groups")["groups"]}
assert list(groups) == ["인력", "재료", "기계", "소요량", "계수", "환율", "요율", "로직"]
files = _get(client, "/api/m01/groups/인력/files")["files"]
assert LABOR in [f["file"] for f in files]
assert groups["인력"]["files"] == len(files)
page2 = _get(client, "/api/m01/rows", file=LABOR, page=2, size=10)
assert page2["page"] == 2 and len(page2["rows"]) == 10 and page2["total"] > 20
hit = _get(client, "/api/m01/rows", file=LABOR, q="보통인부")
assert [r["열쇠"] for r in hit["rows"]] == ["1002"]
assert client.get("/api/m01/rows", params={"file": "없음.json"}).status_code == 404
assert client.get("/api/m01/rows", params={"file": "../main.py"}).status_code == 404
def test_표형_요소_표_목록과_표_하나(client: TestClient) -> None:
file = "소요량_건설품셈_공통3장.json"
listed = _get(client, "/api/m01/tables", file=file, q="암발파")
one = next(t for t in listed["tables"] if t["열쇠"] == LOGIC_KEY)
assert one["count"] >= 1 and "" not in one
table = _get(client, "/api/m01/table", file=file, key=LOGIC_KEY)["table"]
assert len(table[""]) == one["count"] and table["값칸"] == one["값칸"]
assert client.get("/api/m01/tables", params={"file": LABOR}).status_code == 400
def test_로직_목록_하나_시험계산(client: TestClient) -> None:
listed = _get(client, "/api/m01/logics", book=LOGIC_BOOK, chapter="공통3장", q="암발파")[
"logics"
]
assert LOGIC_KEY in [x["열쇠"] for x in listed]
assert all(x["chapter"] == "공통3장" and not x["blocked"] for x in listed)
assert _get(client, "/api/m01/logics", blocked=1)["logics"] == []
one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY)
assert one["file"] == "로직_건설품셈_공통3장.json" and one["blocked"] is False
got = _calc(client)
assert got["ok"] is True
lines = {x["이름"]: x for x in got["lines"]}
assert Decimal(str(lines["화약취급공"]["금액"])) == Decimal(
str(lines["화약취급공"]["수량"])
) * Decimal(str(lines["화약취급공"]["단가"]))
assert got["sums"][""] == pytest.approx(sum(x["금액"] for x in got["lines"]))
stop = _calc(client, 없는입력=1)
assert stop["ok"] is False and "모르는 입력" in stop["reason"]
def test_저장_고침은_파일에_쓰고_낡은_판본은_409(client: TestClient) -> None:
row, version = _labor_row(client, "1016")
before = _calc(client)["sums"][""]
edited = {**row, "": row[""] + 1000}
body = {
"files": [
{
"file": LABOR,
"version": version,
"changes": [{"op": "edit", "key": "1016", "row": edited}],
}
]
}
res = client.post("/api/m01/save", json=body)
assert res.status_code == 200, res.text
new_version = res.json()["files"][0]["version"]
assert new_version != version
assert _labor_row(client, "1016") == (edited, new_version)
assert _calc(client)["sums"][""] == pytest.approx(before + 1000 * 0.04) # 화약취급공 0.040 인
data = json.loads((store.FOLDER / LABOR).read_text(encoding="utf-8"))
assert (
data["그룹"] == "인력"
and len(data[""]) == _get(client, "/api/m01/rows", file=LABOR)["total"]
)
again = client.post("/api/m01/save", json=body) # 옛 판본 그대로
assert again.status_code == 409 and again.json()["detail"]["stale"] == [LABOR]
def test_저장_검사에_걸리면_아무것도_안_씀(client: TestClient) -> None:
_, version = _labor_row(client, "1016")
raw = (store.FOLDER / LABOR).read_bytes()
gone = {"file": LABOR, "version": version, "changes": [{"op": "delete", "key": "1016"}]}
res = client.post("/api/m01/save", json={"files": [gone]})
assert res.status_code == 422 and any("1016" in x for x in res.json()["detail"]["errors"])
bad_form = {**_labor_row(client, "1002")[0], "열쇠": "9999"}
del bad_form["출처"]
res = client.post(
"/api/m01/save", json={"files": [{**gone, "changes": [{"op": "add", "row": bad_form}]}]}
)
assert res.status_code == 422 and any("출처" in x for x in res.json()["detail"]["errors"])
dup = {**gone, "changes": [{"op": "add", "row": _labor_row(client, "1002")[0]}]}
assert client.post("/api/m01/save", json={"files": [dup]}).status_code == 400
assert (store.FOLDER / LABOR).read_bytes() == raw
def test_더함_지움_로직_파일도_같은_길(client: TestClient) -> None:
one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY)
copy = {**one["logic"], "열쇠": LOGIC_KEY + " 사본"}
add = {"file": one["file"], "version": one["version"], "changes": [{"op": "add", "row": copy}]}
res = client.post("/api/m01/save", json={"files": [add]})
assert res.status_code == 200, res.text
assert _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=copy["열쇠"])["blocked"] is False
drop = {
"file": one["file"],
"version": res.json()["files"][0]["version"],
"changes": [{"op": "delete", "key": copy["열쇠"]}],
}
assert client.post("/api/m01/save", json={"files": [drop]}).status_code == 200
assert (
client.get("/api/m01/logic", params={"book": LOGIC_BOOK, "key": copy["열쇠"]}).status_code
== 404
)
def test_단가_자동_요소_찾기_저장전_시험계산(client: TestClient) -> None:
one = _get(client, "/api/m01/logic", book=LOGIC_BOOK, key=LOGIC_KEY)
labor = one["prices"]["인력:건설노임:1016"]
assert labor["이름"] == "화약취급공" and labor[""] > 0
found = _get(client, "/api/m01/elements", group="인력", q="보통인부")
assert "인력:건설노임:1002" in [x["ref"] for x in found["items"]]
tables = _get(client, "/api/m01/elements", group="소요량", q="암발파", limit=5)
assert tables["items"] and all(x["값칸"] for x in tables["items"])
raw = (store.FOLDER / one["file"]).read_bytes()
before = _calc(client)["sums"][""]
half = {**one["logic"], "호표": one["logic"]["호표"][:1]} # 보통인부 줄 뺌
res = client.post(
"/api/m01/calc", json={"book": LOGIC_BOOK, "key": LOGIC_KEY, "inputs": {}, "row": half}
)
lines = res.json()["lines"]
assert [x["이름"] for x in lines] == ["화약취급공"] and res.json()["sums"][""] < before
new = {**half, "열쇠": "새 로직"}
body = {"book": LOGIC_BOOK, "key": "새 로직", "inputs": {}, "row": new, "file": one["file"]}
assert client.post("/api/m01/calc", json=body).json()["ok"] is True
assert (store.FOLDER / one["file"]).read_bytes() == raw # 시험 계산은 안 씀
def test_쓰기_모양은_읽은_값과_같음() -> None:
for name in ("소요량_건설품셈_기계설비13장.json", "로직_건설품셈_토목2장.json", LABOR):
data = json.loads((REAL / name).read_text(encoding="utf-8"), parse_float=Decimal)
text = store.dump(data)
assert json.loads(text, parse_float=Decimal) == data
assert "\r" not in text and text.endswith("}\n")
def test_main_은_시스템관리자만() -> None:
main = (Path(__file__).resolve().parents[2] / "main.py").read_text(encoding="utf-8")
assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main
+32
View File
@@ -19,6 +19,8 @@ SAMPLES = [
"로직_건설품셈_공통4장.json",
"로직_건설품셈_공통7장.json",
"로직_건설품셈_공통8장.json",
"로직_건설품셈_토목1장.json",
"로직_건설품셈_토목2장.json",
"로직_산림품셈_03장.json",
"로직_산림품셈_04장.json",
"로직_산림품셈_05장.json",
@@ -241,6 +243,36 @@ def test_건설_흙깎기_암_치즐(whole):
assert got[""][-1]["금액"] == brk["금액"] / 100
def test_토목_동상방지층_인력_살수(whole):
"""토목 1-2-1 — 165㎥/일 · 인력 살수면 살수차 0 · 보통인부 100㎡당 1인 = 두께 20㎝ 에서 1/20 인/㎥."""
given = {"살수": "인력 살수", "두께": 20, "지역": "전국평균", "보정작업": "해당 없음"}
got = mf.run(whole, "건설품셈:1-2-1 인력식 소규모장비 포설", given)
qty = {line["이름"]: line["수량"] for line in got[""]}
assert qty["굴착기 0.6㎥"] == Decimal(8) / 165
assert qty["살수차 5,500"] == 0
assert got[""][-1]["수량"] == Decimal(1) / 20
def test_토목_보도블록_감(whole):
"""토목 1-7-1 — B-Type 190㎡/일 × 0.9(유도블록) · 정밀절단 아니면 특별인부 2 - 1."""
given = {
"형식": "B-Type",
"유도블록": "유도・점자블록",
"정밀절단": "아님",
"지역": "전국평균",
"보정작업": "해당 없음",
}
got = mf.run(whole, "건설품셈:1-7-1 보도용 블록 설치(소형)", given)
assert got[""][1]["수량"] == Decimal(1) / Decimal(171)
def test_토목_블록붙이기_굴착기_대체(whole):
"""토목 2-3-3 주④ — 크레인 대신 굴착기 0.2㎥ 0.063hr · 크레인 0."""
given = {"장비": "굴착기", "크레인": "2104-0010", "지역": "전국평균", "보정작업": "해당 없음"}
got = mf.run(whole, "건설품셈:2-3-3 블록 붙이기(기계)", given)
assert [line["수량"] for line in got[""][2:]] == [0, Decimal("0.063")]
def test_범위_입력은_막음(whole):
with pytest.raises(mf.FormulaError):
mf.run(
+2
View File
@@ -24,6 +24,7 @@ import { ui_locales_a } from "./ui_template_locale_a";
import { ui_locales_b1 } from "./ui_template_locale_b1";
import { ui_locales_b2 } from "./ui_template_locale_b2";
import { ui_locales_b3 } from "./ui_template_locale_b3";
import { ui_locales_m1 } from "./ui_template_locale_m1";
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
export const LANGUAGES = ["ko", "en"] as const;
@@ -58,6 +59,7 @@ export const ui_locales = {
...ui_locales_b1,
...ui_locales_b2,
...ui_locales_b3,
...ui_locales_m1,
} as const;
export type LocaleKey = keyof typeof ui_locales;
+39
View File
@@ -0,0 +1,39 @@
/* =============================================================================
* ui_template_locale_m1.ts
* M01 ( ) .
* ========================================================================== */
export const ui_locales_m1 = {
B01_Dashboard_MasterElements: ["마스터 요소", "Master Elements"],
M01_Title: ["마스터 요소", "Master Elements"],
M01_AdminOnly: ["시스템 관리자만 볼 수 있음", "System administrators only"],
M01_LoadFailed: ["마스터 요소를 못 읽음", "Failed to load master elements"],
M01_PickFile: ["왼쪽에서 그룹과 파일을 고를 것", "Pick a group and a file on the left"],
M01_Search: ["이름 찾기", "Find by name"],
M01_Prev: ["이전", "Prev"],
M01_Next: ["다음", "Next"],
M01_Page: ["쪽", "pages"],
M01_Rows: ["줄", "rows"],
M01_NoRows: ["줄 없음", "No rows"],
M01_RowAdd: ["줄 더하기", "Add row"],
M01_RowDelete: ["지우기", "Delete"],
M01_RowRestore: ["되살리기", "Restore"],
M01_RowRemove: ["빼기", "Remove"],
M01_BackToTables: ["← 표 목록", "← Tables"],
M01_Changed: ["고친 곳 {value}", "{value} changes"],
M01_Save: ["저장", "Save"],
M01_Discard: ["고친 것 버리기", "Discard changes"],
M01_DiscardConfirm: ["고친 것을 모두 버릴까?", "Discard all changes?"],
M01_Saved: ["저장했음", "Saved"],
M01_SaveFailed: ["저장 못 함 — {value}", "Save failed — {value}"],
M01_Stale: [
"그 사이 파일이 바뀜({value}) — 고친 것을 버리고 다시 읽은 뒤 다시 고칠 것",
"Files changed meanwhile ({value}) — discard and reload, then edit again",
],
M01_StaleReload: ["버리고 다시 읽기", "Discard and reload"],
M01_FileStale: [
"고치기 시작한 뒤 이 파일이 바뀜 — 저장하면 막힘",
"This file changed after you started editing — saving will be refused",
],
M01_CheckErrors: ["검사에 걸림", "Validation failed"],
} as const;