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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/** 로그인 여부 (토큰 존재 확인) */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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])}
|
||||
@@ -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")
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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칸.
|
||||
@@ -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",
|
||||
"조건": { "공종": "고르기" },
|
||||
"값칸": { "기준": "글", "할석공": "인", "보통인부": "인" },
|
||||
"줄": [
|
||||
{ "공종": "메쌓기 뒷길이 45∼60㎝", "기준": "㎡당", "보통인부": 0.2 },
|
||||
{ "공종": "메쌓기 뒷길이 60∼90㎝", "기준": "㎡당", "보통인부": 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",
|
||||
"조건": { "규격(㎜)범위": "고르기" },
|
||||
"값칸": { "라이닝기": "글", "공기압축기": "대" },
|
||||
"줄": [
|
||||
{ "규격(㎜)범위": "ø15∼50", "라이닝기": "1set", "공기압축기": 1 },
|
||||
{ "규격(㎜)범위": "ø65∼100", "라이닝기": "1set", "공기압축기": 2 },
|
||||
{ "규격(㎜)범위": "ø125∼200", "라이닝기": "1set", "공기압축기": 5 },
|
||||
{ "규격(㎜)범위": "ø250∼300", "라이닝기": "1set", "공기압축기": 6 }
|
||||
],
|
||||
"주": [ "⑧ 장비조합은 다음을 기준한다." ]
|
||||
},
|
||||
{
|
||||
"열쇠": "4-3-3 배관누수 검사",
|
||||
"이름": "배관누수 검사",
|
||||
"기준": "일당",
|
||||
"출처": "건설품셈 유지관리 4-3-3",
|
||||
"조건": {},
|
||||
"값칸": { "배관공": "인", "시공량": "회" },
|
||||
"줄": [ { "배관공": 2, "시공량": 2.8 } ],
|
||||
"주": [
|
||||
"① 본 품은 급수용, 급탕용, 난방용 옥내배관(∅50mm이하)의 누수보수를 위해 배관을 검사하는 기준이다.",
|
||||
"② 본 품은 작업준비, 수도검침 및 기록, 미터기 해체 및 재설치, 공기압시험 및 누수탐지, 정리 작업을 포함한다.",
|
||||
"③ 누수부위에 대한 해체 및 복구, 누수배관 교체 작업은 별도 계상한다.",
|
||||
"④ 공구손료 및 경장비(공기압축기, 압력계 등)의 기계경비는 인력품의 3%로 계상한다."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 자재수량"}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user