merge(master_data): dev 받기 · 공통8장 기계 고르기 · 재료_자체 손 병합

- 로직_건설품셈_공통8장: 양쪽 고르기 합침
- 재료_자체: 양쪽 줄 합침 · dev 쪽 겹친 열쇠 12개 한 줄로(비고 합침)
- 로직_건설품셈_유지관리1장: 3-7-5 편성 칸 이름 크롤러드릴_공기식

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
2026-09-19 19:07:46 +09:00
co-authored by Claude Opus 5
30 changed files with 38264 additions and 83 deletions
+1
View File
@@ -27,6 +27,7 @@ const FOOTERLESS_ROUTES: readonly RoutePath[] = [
ROUTES.B10_PAYMENT,
ROUTES.B11_STATUS,
ROUTES.Z01_MASTER_DATA,
ROUTES.M01_MASTER_DATA,
];
function bootstrap(): void {
+2
View File
@@ -61,6 +61,8 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading,
[ROUTES.Z01_MASTER_DATA]: async () =>
(await import("../Z01_MasterData/Z01_MasterData_UI_Page")).renderZ01MasterData,
[ROUTES.M01_MASTER_DATA]: async () =>
(await import("../M01_MasterData/M01_MasterData_UI_Page")).renderM01MasterData,
};
/** 로그인 여부 (토큰 존재 확인) */
+5
View File
@@ -139,6 +139,11 @@ function buildSystemSettingsPanel(): HTMLElement {
variant: "ghost",
onClick: () => navigateTo(ROUTES.Z01_MASTER_DATA),
}),
createButton({
label: L("B01_Dashboard_MasterElements"),
variant: "ghost",
onClick: () => navigateTo(ROUTES.M01_MASTER_DATA),
}),
);
return wrap;
}
+109
View File
@@ -0,0 +1,109 @@
/* =============================================================================
* M01_MasterData_Api_Fetch.ts
* 마스터 요소 화면 서버 호출 — 계약 `resources/master_data/_화면_계약.md` · 시스템 관리자만
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
/** 줄·표 한 개 — 파일 그대로(한글 칸). */
export type Row = Record<string, unknown>;
export interface GroupInfo {
group: string;
files: number;
rows: number;
}
export interface FileInfo {
file: string;
book: string | null;
chapter: string;
edition: string | null;
rows: number;
version: string;
}
export interface RowsPage {
file: string;
version: string;
total: number;
page: number;
size: number;
rows: Row[];
}
export interface TableHead {
열쇠: string;
이름: string;
기준: string;
출처: string;
조건: Record<string, string>;
값칸: Record<string, string>;
count: number;
}
export interface Change {
op: "edit" | "add" | "delete";
key?: string;
row?: Row;
}
export interface FileChanges {
file: string;
version: string;
changes: Change[];
}
/** 저장 결과 — 200 은 새 판본 · 나머지는 화면이 그대로 안내할 상태. */
export type SaveResult =
| { status: 200; files: { file: string; version: string }[] }
| { status: 409; stale: string[] }
| { status: 422; errors: string[] }
| { status: number; detail: string };
async function call<T>(path: string, init: RequestInit = {}): Promise<{ status: number; body: T }> {
const response = await fetch(`${API_BASE_URL}/m01${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
return { status: response.status, body: (await response.json()) as T };
}
async function get<T>(path: string, params: Record<string, string | number>): Promise<T> {
const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)]));
const { status, body } = await call<T & { detail?: unknown }>(`${path}?${query}`);
if (status !== 200) throw new Error(String(body.detail ?? `HTTP ${status}`));
return body;
}
export const fetchGroups = async (): Promise<GroupInfo[]> =>
(await get<{ groups: GroupInfo[] }>("/groups", {})).groups;
export const fetchFiles = async (group: string): Promise<FileInfo[]> =>
(await get<{ files: FileInfo[] }>(`/groups/${encodeURIComponent(group)}/files`, {})).files;
export const fetchRows = (file: string, page: number, size: number, q: string): Promise<RowsPage> =>
get<RowsPage>("/rows", { file, page, size, q });
export const fetchTables = (
file: string,
q: string,
): Promise<{ version: string; tables: TableHead[] }> => get("/tables", { file, q });
export const fetchTable = (
file: string,
key: string,
): Promise<{ version: string; table: Row & { : Row[] } }> => get("/table", { file, key });
export async function saveFiles(files: FileChanges[]): Promise<SaveResult> {
const { status, body } = await call<Record<string, unknown>>("/save", {
method: "POST",
body: JSON.stringify({ files }),
});
const detail = body.detail as { stale?: string[]; errors?: string[] } | string | undefined;
if (status === 200) return { status, files: body.files as { file: string; version: string }[] };
if (status === 409 && typeof detail === "object") return { status, stale: detail.stale ?? [] };
if (status === 422 && typeof detail === "object") return { status, errors: detail.errors ?? [] };
return { status, detail: typeof detail === "string" ? detail : JSON.stringify(detail) };
}
+161
View File
@@ -0,0 +1,161 @@
/* =============================================================================
* M01_MasterData_Draft.ts
* 고친 것 초안 — sessionStorage 에만 쌓임(자동저장 없음) · [저장] 때만 서버로.
*
* 요소 파일 = 줄 단위(edits · adds · deletes) · 표형 파일 = 표 하나 통째(tables).
* ========================================================================== */
import type { FileChanges, Row } from "./M01_MasterData_Api_Fetch";
/** o = 원래 줄 번호(새 줄은 null) — 노랑 칠은 이 번호의 원래 줄과 견줌. */
export interface TableItem {
o: number | null;
r: Row;
}
interface TableDraft {
base: Row;
items: TableItem[];
}
interface FileDraft {
version: string;
edits: Record<string, Row>;
adds: Row[];
deletes: string[];
tables: Record<string, TableDraft>;
}
const STORAGE_KEY = "m01_master_draft";
const listeners = new Set<() => void>();
function readStore(): Record<string, FileDraft> {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "{}") as Record<string, FileDraft>;
} catch {
return {};
}
}
let store = readStore();
export const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
/** 구독 — 돌려받은 함수를 부르면 해제. */
export function onDraftChange(fn: () => void): () => void {
listeners.add(fn);
return () => void listeners.delete(fn);
}
function touch(file: string): void {
const d = store[file];
if (
d &&
!Object.keys(d.edits).length &&
!d.adds.length &&
!d.deletes.length &&
!Object.keys(d.tables).length
) {
delete store[file];
}
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
/* 저장소 막힘 — 이 탭 메모리에서만 유지 */
}
listeners.forEach((fn) => fn());
}
function slot(file: string, version: string): FileDraft {
return (store[file] ??= { version, edits: {}, adds: [], deletes: [], tables: {} });
}
export function peek(file: string): FileDraft | undefined {
return store[file];
}
const countOf = (d: FileDraft): number =>
Object.keys(d.edits).length + d.adds.length + d.deletes.length + Object.keys(d.tables).length;
export const fileCount = (file: string): number => (store[file] ? countOf(store[file]) : 0);
export const totalCount = (): number => Object.values(store).reduce((n, d) => n + countOf(d), 0);
/** 그 파일을 초안이 받은 판본과 다른 판본으로 읽었나 — 저장하면 409 로 막힐 초안. */
export const isStale = (file: string, version: string): boolean =>
store[file] !== undefined && store[file].version !== version;
/* --- 요소 파일 --- */
export function editRow(file: string, version: string, key: string, orig: Row, next: Row): void {
const d = slot(file, version);
if (same(orig, next)) delete d.edits[key];
else d.edits[key] = next;
touch(file);
}
export function addRow(file: string, version: string, row: Row): void {
slot(file, version).adds.push(row);
touch(file);
}
export function setAdd(file: string, index: number, row: Row): void {
store[file].adds[index] = row;
touch(file);
}
export function removeAdd(file: string, index: number): void {
store[file].adds.splice(index, 1);
touch(file);
}
export function toggleDelete(file: string, version: string, key: string): void {
const d = slot(file, version);
d.deletes = d.deletes.includes(key) ? d.deletes.filter((k) => k !== key) : [...d.deletes, key];
touch(file);
}
/* --- 표형 파일 --- */
export function setTable(
file: string,
version: string,
key: string,
base: Row,
items: TableItem[],
orig: Row[],
): void {
const d = slot(file, version);
if (items.length === orig.length && items.every((it, i) => it.o === i && same(it.r, orig[i]))) {
delete d.tables[key];
} else {
d.tables[key] = { base, items };
}
touch(file);
}
export const tableItems = (file: string, key: string): TableItem[] | undefined =>
store[file]?.tables[key]?.items;
/* --- 저장·버리기 --- */
export function payload(): FileChanges[] {
return Object.entries(store).map(([file, d]) => ({
file,
version: d.version,
changes: [
...Object.entries(d.edits)
.filter(([key]) => !d.deletes.includes(key))
.map(([key, row]) => ({ op: "edit" as const, key, row })),
...Object.entries(d.tables).map(([key, t]) => ({
op: "edit" as const,
key,
row: { ...t.base, : t.items.map((it) => it.r) },
})),
...d.adds.map((row) => ({ op: "add" as const, row })),
...d.deletes.map((key) => ({ op: "delete" as const, key })),
],
}));
}
/** files 를 안 주면 전부 버림. */
export function discard(files?: string[]): void {
for (const file of files ?? Object.keys(store)) delete store[file];
touch("");
}
+121
View File
@@ -0,0 +1,121 @@
/* =============================================================================
* M01_MasterData_UI_Cells.ts
* 칸 다루기 — 값 → 글 · 글 → 값 · 눌러 고치기 · 쪽 나눔 띠 (요소·표 화면 공용)
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import type { Row } from "./M01_MasterData_Api_Fetch";
/** 묶음 값(`값 = {가격: …}`)을 「값›가격」 칸으로 펼칠 때의 구분자. */
export const SEP = "";
export const isScalar = (v: unknown): boolean =>
v === null || v === undefined || ["string", "number", "boolean"].includes(typeof v);
/** 줄 → 칸 이름별 값. 묶음(dict) 칸은 한 단계 펼침. */
export function flatten(row: Row): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(row)) {
if (v && typeof v === "object" && !Array.isArray(v)) {
for (const [k2, v2] of Object.entries(v)) out[`${k}${SEP}${k2}`] = v2;
} else {
out[k] = v;
}
}
return out;
}
/** 칸 이름(펼친 것 포함)에 값을 넣은 새 줄. */
export function withCell(row: Row, path: string, value: unknown): Row {
const [head, sub] = path.split(SEP);
if (sub === undefined) return { ...row, [head]: value };
return { ...row, [head]: { ...(row[head] as Row), [sub]: value } };
}
export function show(v: unknown): string {
if (v === null || v === undefined) return "";
if (Array.isArray(v)) return v.map((x) => (x === null ? "" : String(x))).join(" ~ ");
return typeof v === "object" ? JSON.stringify(v) : String(v);
}
const NUMBER = /^-?\d+(\.\d+)?$/;
/** 글 → 수(수 꼴이면) 아니면 글. 빈 글 = null. */
export function toNumberish(text: string): number | string | null {
const s = text.replace(/,/g, "").trim();
if (s === "") return null;
return NUMBER.test(s) ? Number(s) : text.trim();
}
/** 원래 값의 꼴을 따라 글을 값으로 — 글이었으면 글 · 수·빈 칸이었으면 수 꼴이면 수. */
export const coerce = (text: string, orig: unknown): unknown =>
typeof orig === "string" ? text : toNumberish(text);
/** 「아래 ~ 위」 → [아래, 위] (빈 쪽 = null · 「~」 없으면 한 값). */
export function toRange(text: string): unknown {
if (!text.includes("~")) return toNumberish(text);
return text.split("~").map((s) => (s.trim() === "" ? null : toNumberish(s)));
}
/** 칸을 눌러 고침 — Enter·칸 밖 누름 = 확정 · Esc = 취소. */
export function startEdit(td: HTMLElement, text: string, commit: (value: string) => void): void {
if (td.querySelector("input")) return;
const input = el("input", { className: "m01-master__cell-input", attrs: { type: "text" } });
input.value = text;
td.textContent = "";
td.append(input);
input.focus();
input.select();
let done = false;
const finish = (ok: boolean): void => {
if (done) return;
done = true;
if (ok && input.value !== text) commit(input.value);
else td.textContent = text;
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") finish(true);
else if (e.key === "Escape") finish(false);
});
input.addEventListener("blur", () => finish(true));
}
/** 쪽 나눔 띠 — 이전·다음 · 「n / N 쪽 · 전체 줄」. */
export function buildPager(
total: number,
size: number,
page: number,
goto: (page: number) => void,
): HTMLElement {
const last = Math.max(1, Math.ceil(total / size));
const prev = createButton({ label: L("M01_Prev"), variant: "ghost", disabled: page <= 1 });
const next = createButton({ label: L("M01_Next"), variant: "ghost", disabled: page >= last });
prev.addEventListener("click", () => goto(page - 1));
next.addEventListener("click", () => goto(page + 1));
return el("div", {
className: "m01-master__pager",
children: [
el("span", {
text: `${page} / ${last} ${L("M01_Page")} · ${total.toLocaleString("ko-KR")} ${L("M01_Rows")}`,
}),
prev,
next,
],
});
}
/** 칸 하나 — 노랑(고침) · 눌러 고침. */
export function buildCell(
text: string,
opts: { changed: boolean; onEdit?: (value: string) => void },
): HTMLTableCellElement {
const td = el("td", { text, attrs: { title: text } });
td.classList.toggle("is-changed", opts.changed);
if (opts.onEdit) {
const onEdit = opts.onEdit;
td.classList.add("is-editable");
td.addEventListener("click", () => startEdit(td, text, onEdit));
}
return td;
}
+244
View File
@@ -0,0 +1,244 @@
/* =============================================================================
* 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 logicTab = el("button", {
className: "m01-master__tab",
attrs: { type: "button" },
text: L("M01_LogicTab"),
});
logicTab.addEventListener("click", () => {
tabs.querySelector(".is-active")?.classList.remove("is-active");
logicTab.classList.add("is-active");
group = "";
current = null;
dispose();
files.replaceChildren();
body.replaceChildren();
title.textContent = L("M01_LogicTab");
void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(body));
});
tabs.append(logicTab);
const layout = el("div", { className: "ui-workflow-layout m01-master" });
const main = el("main", {
className: "ui-workflow-layout__main",
children: [el("div", { className: "m01-master__panel", children: [head, notice, body] })],
});
const overlays = createWorkflowOverlays({
title: L("M01_Title"),
optionsContent: el("div", { className: "m01-master__side", children: [tabs, files] }),
showProjectName: false,
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
});
layout.append(
el("div", { className: "ui-workflow-layout__body", children: [main] }),
overlays.root,
);
return layout;
}
+153
View File
@@ -0,0 +1,153 @@
/* =============================================================================
* M01_MasterData_UI_Rows.ts
* 요소 파일(인력·재료·기계·환율·요율) 표 — 한 줄 = 요소 · 이름 찾기 · 쪽 나눔 · 눌러 고침(노랑)
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch";
import {
addRow,
editRow,
onDraftChange,
peek,
removeAdd,
same,
setAdd,
toggleDelete,
} from "./M01_MasterData_Draft";
import {
buildCell,
buildPager,
coerce,
flatten,
isScalar,
show,
withCell,
} from "./M01_MasterData_UI_Cells";
const SIZE = 50;
const FALLBACK: Row = { : "", : "", : "", : "", : null, : "" };
/** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */
function blank(sample: Row): Row {
const out: Row = {};
for (const [k, v] of Object.entries(sample)) {
if (v && typeof v === "object" && !Array.isArray(v)) out[k] = blank(v as Row);
else out[k] = typeof v === "string" && k !== "값" ? "" : null;
}
return out;
}
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
export function renderRows(host: HTMLElement, file: string, q: string): () => void {
let page = 1;
let data: RowsPage | null = null;
const paint = (): void => {
if (!data) return;
const d = data;
const draft = peek(file);
const adds = draft?.adds ?? [];
const cols: string[] = [];
for (const row of [...adds, ...d.rows]) {
for (const k of Object.keys(flatten(row))) if (!cols.includes(k)) cols.push(k);
}
const head = el("tr", { children: [el("th"), ...cols.map((c) => el("th", { text: c }))] });
const body = el("tbody");
adds.forEach((row, i) => {
const flat = flatten(row);
const tr = el("tr", { className: "is-new" });
tr.append(actionCell(L("M01_RowRemove"), () => removeAdd(file, i)));
for (const c of cols) {
tr.append(
buildCell(show(flat[c]), {
changed: true,
onEdit: (text) => setAdd(file, i, withCell(row, c, coerce(text, flat[c]))),
}),
);
}
body.append(tr);
});
for (const row of d.rows) {
const key = String(row["열쇠"]);
const cur = draft?.edits[key] ?? row;
const deleted = draft?.deletes.includes(key) ?? false;
const orig = flatten(row);
const now = flatten(cur);
const tr = el("tr");
tr.classList.toggle("is-deleted", deleted);
tr.append(
actionCell(deleted ? L("M01_RowRestore") : L("M01_RowDelete"), () =>
toggleDelete(file, d.version, key),
),
);
for (const c of cols) {
const editable = !deleted && isScalar(orig[c]);
tr.append(
buildCell(show(now[c]), {
changed: !same(now[c], orig[c]),
onEdit: editable
? (text) =>
editRow(file, d.version, key, row, withCell(cur, c, coerce(text, orig[c])))
: undefined,
}),
);
}
body.append(tr);
}
const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" });
add.addEventListener("click", () => addRow(file, d.version, blank(d.rows[0] ?? FALLBACK)));
const empty =
d.rows.length || adds.length
? []
: [el("p", { className: "m01-master__empty", text: L("M01_NoRows") })];
host.replaceChildren(
el("div", {
className: "m01-master__bar",
children: [add, buildPager(d.total, SIZE, d.page, go)],
}),
el("div", {
className: "m01-master__grid-wrap",
children: [
el("table", {
className: "m01-master__grid",
children: [el("thead", { children: [head] }), body],
}),
],
}),
...empty,
);
};
const go = (next: number): void => {
page = next;
void load();
};
const load = async (): Promise<void> => {
try {
data = await fetchRows(file, page, SIZE, q);
paint();
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
}
};
const off = onDraftChange(paint);
void load();
return off;
}
function actionCell(label: string, onClick: () => void): HTMLTableCellElement {
const button = el("button", {
className: "m01-master__row-btn",
text: label,
attrs: { type: "button" },
});
button.addEventListener("click", onClick);
return el("td", { children: [button] });
}
+219
View File
@@ -0,0 +1,219 @@
/* M01 마스터 요소 — 좌측 도킹 패널(그룹 탭 · 파일) / 우측 표 */
.m01-master [hidden] {
display: none !important;
}
.m01-master__side {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding-top: var(--spacing-8);
}
.m01-master__tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-4);
}
.m01-master__files {
display: flex;
flex-direction: column;
gap: 2px;
}
.m01-master__tab,
.m01-master__file {
display: flex;
justify-content: space-between;
gap: var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
text-align: left;
cursor: pointer;
}
.m01-master__file {
border-color: transparent;
background: transparent;
}
.m01-master__tab:hover,
.m01-master__file:hover {
background: var(--color-paper);
}
.m01-master__tab.is-active,
.m01-master__file.is-active {
background: var(--color-mist-violet);
color: var(--color-accent);
}
.m01-master__muted {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
/* ── 우측 ── */
.m01-master__panel {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
padding: var(--spacing-16) var(--spacing-24);
}
.m01-master__head,
.m01-master__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-12);
}
.m01-master__title {
flex: 1 1 auto;
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
}
.m01-master__summary {
color: var(--color-accent);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-bold);
}
.m01-master__notice {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8) var(--spacing-12);
max-height: 30vh;
overflow: auto;
border-left: 4px solid var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 10%, var(--color-canvas));
color: var(--color-danger);
font-size: var(--text-body-sm);
}
.m01-master__notice p {
margin: 0;
}
.m01-master__body {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
}
.m01-master__grid-wrap {
max-height: calc(100vh - 300px);
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
}
.m01-master__grid {
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.m01-master__grid th,
.m01-master__grid td {
max-width: 320px;
padding: var(--spacing-8) var(--spacing-12);
overflow: hidden;
border-bottom: 1px solid var(--color-border);
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.m01-master__grid th {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-surface);
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
}
.m01-master__grid td.is-editable {
cursor: text;
}
/* 고친 칸 — 노랑 */
.m01-master__grid td.is-changed {
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
}
.m01-master__grid tr.is-deleted td {
color: var(--color-text-muted);
text-decoration: line-through;
background: color-mix(in srgb, var(--color-danger) 8%, var(--color-canvas));
}
.m01-master__cell-input {
width: 100%;
min-width: 6em;
padding: 2px var(--spacing-4);
}
.m01-master__row-btn {
padding: 2px var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-caption);
cursor: pointer;
}
.m01-master__pager {
display: flex;
align-items: center;
gap: var(--spacing-12);
margin-left: auto;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m01-master__tables {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-8);
}
.m01-master__table-card {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-text-body);
font: inherit;
text-align: left;
cursor: pointer;
}
.m01-master__table-card:hover {
background: var(--color-paper);
}
.m01-master__table-card.is-changed {
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
}
.m01-master__empty {
color: var(--color-text-muted);
}
+200
View File
@@ -0,0 +1,200 @@
/* =============================================================================
* M01_MasterData_UI_Tables.ts
* 표형 파일(소요량·계수) — 표 목록 → 표 하나(조건 칸 + 값 칸의 긴 줄) · 눌러 고침(노랑) · 줄 더하기·지우기
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { fetchTable, fetchTables, type Row, type TableHead } from "./M01_MasterData_Api_Fetch";
import {
onDraftChange,
peek,
same,
setTable,
tableItems,
type TableItem,
} from "./M01_MasterData_Draft";
import {
buildCell,
buildPager,
coerce,
show,
toNumberish,
toRange,
} from "./M01_MasterData_UI_Cells";
const LIST_SIZE = 30;
/** 돌려받은 함수 = 이 화면을 걷을 때 부를 해제. */
export function renderTables(host: HTMLElement, file: string, q: string): () => void {
let stop = (): void => {};
const list = (page = 1): void => {
stop();
stop = (): void => {};
void showList(host, file, q, page, (key) => {
stop = openTable(host, file, key, () => list(page));
});
};
list();
const off = onDraftChange(
() => host.querySelector(".m01-master__tables") && markChanged(host, file),
);
return () => {
stop();
off();
};
}
/** 목록 위 「고침」 표시만 다시 — 목록을 다시 받지 않음. */
function markChanged(host: HTMLElement, file: string): void {
const changed = peek(file)?.tables ?? {};
host.querySelectorAll<HTMLElement>(".m01-master__table-card").forEach((card) => {
card.classList.toggle("is-changed", card.dataset.key! in changed);
});
}
async function showList(
host: HTMLElement,
file: string,
q: string,
page: number,
open: (key: string) => void,
): Promise<void> {
let tables: TableHead[];
try {
tables = (await fetchTables(file, q)).tables;
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
return;
}
const cards = tables.slice((page - 1) * LIST_SIZE, page * LIST_SIZE).map((t) => {
const card = el("button", {
className: "m01-master__table-card",
attrs: { type: "button", "data-key": t. },
children: [
el("strong", { text: t.이름 || t. }),
el("span", { className: "m01-master__muted", text: `${t.} · ${t. || "—"}` }),
el("span", {
className: "m01-master__muted",
text: `${Object.keys(t. ?? {}).join(" · ") || "—"} → ${Object.keys(t. ?? {}).join(" · ")} · ${t.count} ${L("M01_Rows")}`,
}),
],
});
card.classList.toggle("is-changed", t. in (peek(file)?.tables ?? {}));
card.addEventListener("click", () => open(t.));
return card;
});
host.replaceChildren(
buildPager(tables.length, LIST_SIZE, page, (p) => void showList(host, file, q, p, open)),
el("div", { className: "m01-master__tables", children: cards }),
);
}
/** 표 하나. 돌려받은 함수 = 해제. */
function openTable(host: HTMLElement, file: string, key: string, back: () => void): () => void {
let off = (): void => {};
void (async () => {
let version: string;
let table: Row & { : Row[] };
try {
({ version, table } = await fetchTable(file, key));
} catch (error) {
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error");
return;
}
const { : orig, ...base } = table;
const conds = (table. ?? {}) as Record<string, string>;
const values = (table. ?? {}) as Record<string, string>;
const cols = [...Object.keys(conds), ...Object.keys(values)];
for (const row of orig) for (const k of Object.keys(row)) if (!cols.includes(k)) cols.push(k);
const items = (): TableItem[] => tableItems(file, key) ?? orig.map((r, o) => ({ o, r }));
const commit = (next: TableItem[]): void => setTable(file, version, key, base, next, orig);
const parse = (col: string, text: string, prev: unknown): unknown => {
if (conds[col] === "범위") return toRange(text);
if (conds[col] === "고르기") return text;
return col in values || conds[col] === "수" ? toNumberish(text) : coerce(text, prev);
};
const headText = (c: string): string =>
c in conds ? `${c} (${conds[c]})` : c in values ? `${c} (${values[c]})` : c;
const paint = (): void => {
const now = items();
const body = el("tbody");
now.forEach((item, i) => {
const tr = el("tr");
tr.classList.toggle("is-new", item.o === null);
const del = el("button", {
className: "m01-master__row-btn",
text: L("M01_RowRemove"),
attrs: { type: "button" },
});
del.addEventListener("click", () => commit(now.filter((_, j) => j !== i)));
tr.append(el("td", { children: [del] }));
for (const c of cols) {
const was = item.o === null ? undefined : orig[item.o][c];
tr.append(
buildCell(show(item.r[c]), {
changed: item.o === null || !same(item.r[c], was),
onEdit: (text) => {
const value = parse(c, text, item.r[c]);
const r = { ...item.r };
if (value === null || value === "")
delete r[c]; // 원문 「-」 = 칸을 뺌
else r[c] = value;
commit(now.map((it, j) => (j === i ? { ...it, r } : it)));
},
}),
);
}
body.append(tr);
});
const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" });
add.addEventListener("click", () => commit([...now, { o: null, r: {} }]));
const backButton = createButton({ label: L("M01_BackToTables"), variant: "ghost" });
backButton.addEventListener("click", () => {
off();
back();
});
const notes = ((table. ?? []) as string[]).map((n) =>
el("p", { className: "m01-master__muted", text: n }),
);
host.replaceChildren(
el("div", {
className: "m01-master__bar",
children: [
backButton,
el("strong", { text: String(table. ?? key) }),
el("span", {
className: "m01-master__muted",
text: `${key} · ${table. || "—"} · ${table. || ""}`,
}),
add,
],
}),
el("div", {
className: "m01-master__grid-wrap",
children: [
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [el("th"), ...cols.map((c) => el("th", { text: headText(c) }))],
}),
],
}),
body,
],
}),
],
}),
...notes,
);
};
off = onDraftChange(paint);
paint();
})();
return () => off();
}
+3
View File
@@ -118,6 +118,8 @@ export const ROUTES = {
B11_LOADING: "b11-loading",
// 시스템 관리자 전용 — 로직·기초값 마스터 보기 (2026-09-15 브레인 Z01).
Z01_MASTER_DATA: "z01-master-data",
// 시스템 관리자 전용 — 마스터 요소(파일 첫 층 JSON) 보기·고치기.
M01_MASTER_DATA: "m01-master-data",
} as const;
export type RouteKey = keyof typeof ROUTES;
@@ -141,6 +143,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [
ROUTES.B11_STATUS,
ROUTES.B11_LOADING,
ROUTES.Z01_MASTER_DATA,
ROUTES.M01_MASTER_DATA,
];
/* -----------------------------------------------------------------------------
+2
View File
@@ -60,6 +60,7 @@
```
- 조건 종류 — `수`(같은 값) · `고르기`(글자) · `범위`(줄에 `[아래, 위]` · 끝 열림은 `null` · 포함 여부는 `범위규칙` 에 「초과·이하」처럼).
- `범위규칙` 첫 마디 = 엔진이 읽는 이름 — 「초과·이하」 · 「이상·미만」 · 「이하」(위 끝만) · 「이상·이하」(양 끝 포함). 뒤 마디(` · ` 뒤)는 원문 설명. 「이하」 · 「이상·이하」는 여러 줄이 맞으면 위 끝이 가장 작은 줄.
- 원문 「-」 칸은 줄에서 그 값 칸을 뺌. 줄 전체가 「-」 면 줄을 뺌.
- 원문에 없는 조건 칸은 줄에서 뺌 — 빈 조건 칸은 그 조건에 무엇이든 맞음.
- 값 칸에 범위(「80 ∼ 100」)는 `[아래, 위]` — 로직은 설계자 입력 칸으로 받음(기본값 두지 않음).
@@ -112,6 +113,7 @@
- 요소 — `그룹:원문:열쇠` · 값 묶음은 `.칸``기계:건설품셈:0201-0070.주연료` · 둘째 쌍점 뒤는 모두 열쇠(`재료:오피넷유가:경유:전국평균`).
- 열쇠에 `{이름}` — 입력·중간 값을 끼움 — `기계:건설품셈:{기계}.가격`
- 표 찾기 — `찾기(소요량:산림품셈:13-4-1 메쌓기, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공`
- 맞는 줄이 여럿이면 찾는 값 칸이 있는 줄만 봄. 값 칸·조건 이름에 괄호·빈칸이 있으면 작은따옴표 — `.'시공량(m)'` · `'규준틀 높이'=높이`
- 찾기·로직 인자의 글자 값은 작은따옴표 — `구분='V(다짐속도,km/hr)'` · 따옴표 없으면 이름(입력·중간).
- 로직 부르기 — `로직(건설품셈:8 굴착기(무한궤도) 시간당 경비, 기계='0201-0070')` · 돈 로직의 결과는 비목 묶음 `.노무비` `.재료비` `.경비` `.계` · 돈 아닌 로직은 수 하나.
- 입력 · 중간 · 비목 합은 이름 그대로.
@@ -104,9 +104,16 @@ class _Parser:
return ("neg", self.unary())
node = self.primary()
while self.take("."):
node = ("field", node, self.name())
node = ("field", node, self.quoted() if self.take("'") else self.name())
return node
def quoted(self) -> str:
end = self.s.find("'", self.i)
if end < 0:
self.fail("작은따옴표 안 닫힘")
text, self.i = self.s[self.i : end], end + 1
return text
def name(self) -> str:
self.ws()
m = _NAME.match(self.s, self.i)
@@ -115,8 +122,9 @@ class _Parser:
self.i = m.end()
return m.group()
def ref(self) -> str:
"""열쇠 글자 — 괄호 밖의 `,` · `)` · `.칸` · ` 연산자 ` 앞에서 멈춤(열쇠에 빈칸·괄호가 들어감)."""
def ref(self, head: bool = False) -> str:
"""열쇠 글자 — 괄호 밖의 `,` · `)` · `.칸` · ` 연산자 ` 앞에서 멈춤(열쇠에 빈칸·괄호가 들어감).
head = 찾기·로직 머리 — `,` · `)` 에서만 멈춤(표 이름의 `P.P마대` 같은 점)."""
start, depth = self.i, 0
while self.i < len(self.s):
ch = self.s[self.i]
@@ -128,6 +136,8 @@ class _Parser:
depth -= 1
elif depth == 0 and ch == ",":
break
elif head:
pass
elif depth == 0 and ch == "." and not self.s[self.i + 1 : self.i + 2].isdigit():
# 「K.P」「T.S」 처럼 영문자 사이 점은 열쇠 글자
around = self.s[self.i - 1 : self.i + 2 : 2]
@@ -141,7 +151,7 @@ class _Parser:
def args(self) -> list[tuple[str, object]]:
out = []
while self.take(","):
key = self.name()
key = self.quoted() if self.take("'") else self.name()
self.need("=")
out.append((key, self.expr()))
self.need(")")
@@ -168,10 +178,10 @@ class _Parser:
self.need("(")
head = self.name()
self.need(":")
return ("find", f"{head}:{self.ref()}", self.args())
return ("find", f"{head}:{self.ref(True)}", self.args())
if word == "로직":
self.need("(")
return ("logic", self.ref(), self.args())
return ("logic", self.ref(True), self.args())
if word in FUNCS:
self.need("(")
items = [self.expr()]
@@ -269,20 +279,24 @@ def _match(table: dict, row: dict, cond: str, want) -> bool:
)
if rule == "이하": # 칸마다 위 끝만 — 맞는 줄 가운데 위 끝이 가장 작은 줄(find)
return (low is None or want > low) and (high is None or want <= high)
if rule == "이상·이하": # 양 끝 포함 — 이웃 칸 끝이 겹치면 위 끝이 가장 작은 줄(find)
return (low is None or want >= low) and (high is None or want <= high)
raise FormulaError(f"모르는 범위규칙 「{table.get('범위규칙')}")
return have == want
def find(table: dict, conds: dict) -> dict:
def find(table: dict, conds: dict, want: str | None = None) -> dict:
for cond in conds:
if cond not in table.get("조건", {}):
raise FormulaError(f"{table['열쇠']}」 에 없는 조건 「{cond}")
hits = [row for row in table[""] if all(_match(table, row, c, v) for c, v in conds.items())]
if len(hits) > 1 and table.get("범위규칙", "").split(" · ")[0] == "이하":
if len(hits) > 1 and table.get("범위규칙", "").split(" · ")[0] in ("이하", "상·이"):
for c in conds:
ups = [r[c][1] for r in hits if table["조건"].get(c) == "범위" and r.get(c)]
if ups and None not in ups:
hits = [r for r in hits if not r.get(c) or r[c][1] == min(ups)]
if len(hits) > 1 and want: # 여러 줄이면 찾는 값 칸이 있는 줄만
hits = [row for row in hits if want in row]
if len(hits) != 1:
raise FormulaError(f"{table['열쇠']}{conds} 맞는 줄 {len(hits)}")
return hits[0]
@@ -304,7 +318,12 @@ def evaluate(node, env: dict, master: Master, depth: int = 0):
raise FormulaError(f"{node[1]}」 값이 범위 — 입력으로 받아야 함")
return value
if kind == "field":
base = evaluate(node[1], env, master, depth)
if node[1][0] == "find":
table = master.get(fill(node[1][1], env))
conds = {k: evaluate(v, env, master, depth) for k, v in node[1][2]}
base = find(table, conds, node[2])
else:
base = evaluate(node[1], env, master, depth)
if not isinstance(base, dict) or node[2] not in base:
raise FormulaError(f"칸 「{node[2]}」 없음")
value = base[node[2]]
@@ -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" },
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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", "5205-0071", "5205-0103", "5205-0210", "5405-0110", "7505-0025", "0502-0036", "1306-0100", "7204-0160", "1506-0032", "0121-0004", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "1305-0007", "1306-0120", "1106-0010", "1106-0012", "1206-0008", "3201-0003", "5205-0170", "3201-0001", "1406-0008", "1406-0015", "0302-0057", "3601-0202", "3601-0402", "3611-0142", "2502-0050"] },
{ "이름": "기계", "고르기": ["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", "3201-0001", "1406-0008", "3601-0202", "3601-0402", "3611-0142"] },
{
"이름": "지역",
"고르기": [
@@ -309,18 +309,7 @@
"입력": [
{
"이름": "기계",
"고르기": [
"4611-0350",
"7812-0015",
"1730-0015",
"1305-0007",
"2106-0002",
"2106-0003",
"2106-0005",
"3302-0038",
"4430-0400",
"7505-0100"
]
"고르기": ["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", "7505-0100"]
},
{
"이름": "지역",
@@ -396,28 +385,7 @@
"입력": [
{
"이름": "기계",
"고르기": [
"7620-0003",
"7730-0150",
"5501-0080",
"6516-0060",
"7750-0016",
"7750-0025",
"7730-0050",
"5401-0017",
"6105-0190",
"6202-0060",
"5204-0200",
"5204-0300",
"5204-0400",
"5204-0500",
"5204-0600",
"7950-0149",
"7951-0066",
"7952-0246",
"7953-3400",
"7954-1119"
]
"고르기": ["7620-0003", "7730-0150", "6516-0060", "6516-0120", "6516-0250", "6516-0300", "6105-0190", "6202-0060", "0230-0002", "6410-0080", "6410-0100", "6410-0120", "6410-0150", "6410-0200", "6901-0010", "6530-0090", "6530-0120", "6517-0100", "6517-0150", "6517-0200", "6517-0250", "6517-0300", "6510-0100", "6510-0150", "6510-0200", "6510-0250", "6510-0300", "6518-0100", "6518-0150", "6518-0200", "6518-0250", "6518-0300", "5501-0080", "7750-0016", "7750-0025", "7730-0050", "5401-0017", "5202-0127", "5202-0240", "5202-0300", "7611-0200", "7612-0200", "5204-0200", "5204-0300", "5204-0400", "5204-0500", "5204-0600", "5702-0095", "3430-0400", "7431-1300", "7740-0080", "7740-0150", "7950-0149", "7951-0066", "7952-0246", "7953-3400", "7954-1119"]
}
],
"중간": [
@@ -438,6 +406,131 @@
],
"끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12)",
"비고": "원문 운전경비 표에 주연료·조종원이 없는 기종(절단기 · 펌프) — 동력비는 원문대로 별도"
},
{
"열쇠": "8 기계 시간당 경비(조종원직종 입력)",
"이름": "기계 시간당 기계경비(조종원직종 입력)",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"소유": "공용",
"입력": [
{
"이름": "기계",
"고르기": ["2106-0002", "2106-0003", "2106-0005", "6802-0040", "6802-0060", "6802-0100", "6802-0120", "6802-0135", "6802-0160", "6801-0010", "6803-0100", "6803-0120", "4430-0400", "3302-0038", "3801-0795", "7330-0010", "7954-1119", "7110-0025", "7110-0013", "7120-0746"]
},
{
"이름": "지역",
"고르기": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"],
"출처": "건설품셈 공통 8-1-7 5"
},
{
"이름": "조종원직종",
"고르기": ["1048", "1049", "1050"],
"출처": "건설품셈 공통 8-1-3 5",
"비고": "원문 운전사 구분에 기종 이름 없음 — 설계자가 고름(1048 건설기계운전사 · 1049 화물차운전사 · 1050 일반기계운전사)"
}
],
"중간": [
{
"이름": "손료",
"식": "버림(기계:건설품셈:{기계}.가격 * 1000 * ((기계:건설품셈:{기계}.상각비계수 + 기계:건설품셈:{기계}.정비비계수) + 기계:건설품셈:{기계}.관리비계수) / 10000000, 0)",
"출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4"
},
{
"이름": "연료",
"식": "기계:건설품셈:{기계}.주연료종류",
"출처": "건설품셈 공통 8-4 주①㉰"
}
],
"호표": [
{
"종류": "재료",
"요소": "재료:오피넷유가:{연료}:{지역}",
"이름": "주연료",
"단위": "",
"수량": "기계:건설품셈:{기계}.주연료",
"비목": "재료비",
"출처": "건설품셈 공통 8-4-1"
},
{
"종류": "인력",
"요소": "인력:건설노임:{조종원직종}",
"이름": "조종원",
"단위": "인",
"수량": "기계:건설품셈:{기계}.조종원 / 8",
"비목": "노무비",
"출처": "건설품셈 공통 8-1-3 6",
"비고": "환산 근거 미확인 — 우선 하루 ÷ 8"
}
],
"덧줄": [
{
"이름": "잡재료비",
"식": "재료비 * 기계:건설품셈:{기계}.잡재료율 / 100",
"비목": "재료비",
"출처": "건설품셈 공통 8-4 주②"
},
{
"이름": "기계손료",
"식": "손료",
"비목": "경비",
"출처": "건설품셈 공통 8-1-6 2"
}
],
"끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음",
"비고": "원문 8-1-3 5 운전사 구분에 이름 없는 기종 — 조종원직종을 설계자가 고름"
},
{
"열쇠": "8 기계 시간당 경비(연료 없음)",
"이름": "기계 시간당 기계경비(연료 없는 기종)",
"결과단위": "원/시간",
"출처": "건설품셈 공통 8-1-5 · 8-1-6",
"소유": "공용",
"입력": [
{
"이름": "기계",
"고르기": ["5401-0017"]
},
{
"이름": "지역",
"고르기": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"],
"출처": "건설품셈 공통 8-1-7 5"
}
],
"중간": [
{
"이름": "손료",
"식": "버림(기계:건설품셈:{기계}.가격 * 1000 * ((기계:건설품셈:{기계}.상각비계수 + 기계:건설품셈:{기계}.정비비계수) + 기계:건설품셈:{기계}.관리비계수) / 10000000, 0)",
"출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4"
},
{
"이름": "직종",
"식": "기계:건설품셈:{기계}.조종원직종",
"출처": "건설품셈 공통 8-1-3 5"
}
],
"호표": [
{
"종류": "인력",
"요소": "인력:건설노임:{직종}",
"이름": "조종원",
"단위": "인",
"수량": "기계:건설품셈:{기계}.조종원 / 8",
"비목": "노무비",
"출처": "건설품셈 공통 8-1-3 6",
"비고": "환산 근거 미확인 — 우선 하루 ÷ 8"
}
],
"덧줄": [
{
"이름": "기계손료",
"식": "손료",
"비목": "경비",
"출처": "건설품셈 공통 8-1-6 2"
}
],
"끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음",
"비고": "원문 운전경비 표에 주연료가 빈 기종(공기식 크롤러드릴 등 — 동력은 공기압축기) — 손료 + 조종원"
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -737,7 +737,42 @@
"결과단위": "원/m",
"출처": "산림품셈 12-7-1",
"소유": "공용",
"입력": [],
"입력": [
{
"이름": "지역",
"고르기": [
"전국평균",
"전국",
"서울",
"경기",
"강원",
"충북",
"충남",
"전북",
"경북",
"경남",
"부산",
"제주",
"대구",
"인천",
"대전",
"울산",
"세종",
"전남광주"
],
"출처": "건설품셈 공통 8-1-7 5"
},
{
"이름": "조종원직종",
"고르기": [
"1048",
"1049",
"1050"
],
"출처": "건설품셈 공통 8-1-3 5",
"비고": "원문 운전사 구분에 기종 이름 없음 — 설계자가 고름(1048 건설기계운전사 · 1049 화물차운전사 · 1050 일반기계운전사)"
}
],
"중간": [
{
"이름": "시공량",
@@ -761,6 +796,15 @@
"수량": "찾기(소요량:산림품셈:12-7-1 포장절단, 구분='보통인부').적용 / 시공량",
"비목": "노무비"
},
{
"종류": "로직",
"요소": "로직(건설품셈:8 기계 시간당 경비(조종원직종 입력), 기계='4430-0400', 지역=지역, 조종원직종=조종원직종)",
"이름": "커터(콘크리트 및 아스팔트용) 320~400㎜",
"단위": "시간",
"수량": "찾기(소요량:산림품셈:12-7-1 포장절단, 구분='커터').적용 * 8 / 시공량",
"출처": "산림품셈 12-7-1",
"비고": "해석: 1대 × 1일 8시간(건설품셈 공통 1-1) ÷ 일당 시공량"
},
{
"종류": "재료",
"요소": "재료:자체:커터 블레이드",
@@ -782,7 +826,7 @@
],
"덧줄": [],
"끝수": null,
"비고": "막힘: 커터(4430-0400) 조종원직종 요소 빔 — 원문 8-1-3 5 운전사 구분에 커터 이름 없음 · 기계경비 못 부름 · 블레이드 · 물은 주③④ 글의 수를 식에 적음"
"비고": "커터 조종원직종은 원문(건설품셈 공통 8-1-3 5) 운전사 구분에 이름 없음 — 설계자가 고름 · 블레이드 · 물은 주③④ 글의 수를 식에 적음"
},
{
"열쇠": "12-7-2 줄눈설치",
@@ -672,7 +672,7 @@
],
"덧줄": [],
"끝수": null,
"비고": "해석: 채움 콘크리트 = 레디믹스트콘크리트 · 해석: 돌 이름을 채움·고임돌 표 종별로 맞춤(조약돌·호박돌 및 야면석 → 야면석 · 채움은 호박돌) · 줄눈 모르타르는 원문대로 메붙임에도 얹음 · 뒷길이 70 은 채움·고임돌 표에 없어 찾기 실패"
"비고": "해석: 채움 콘크리트 = 레디믹스트콘크리트 · 해석: 돌 이름을 채움·고임돌 표 종별로 맞춤(조약돌·호박돌 및 야면석 → 야면석 · 채움은 호박돌) · 줄눈 모르타르는 원문대로 메붙임에도 얹음 · 뒷길이 70 은 찾기 실패 — 원문 표에 70 없음"
},
{
"열쇠": "13-5-2 메붙임(장비)",
@@ -10,7 +10,7 @@
"출처": "건설품셈 공통 2-1-1",
"조건": { "직접노무비(억)": "범위", "시설": "고르기" },
"값칸": { "면적": "㎡" },
"범위규칙": "「1.5억 미만」 = 위 끝 제외 · 「500억 이상」 = 아래 끝 포함 · 「1.5 ∼ 3억」 등 = 양 끝 포함",
"범위규칙": "이상·미만 · 원문 「1.5억 미만」 · 「1.5 ∼ 3억」 등 · 「500억 이상」 — 이웃 칸 끝이 겹쳐 아래 끝 포함으로 읽음",
"줄": [
{ "직접노무비(억)": [null, 1.5], "시설": "현장사무소 감독・감리자", "면적": 40 },
{ "직접노무비(억)": [null, 1.5], "시설": "현장사무소 수급자", "면적": 50 },
@@ -66,7 +66,7 @@
"출처": "건설품셈 공통 2-1-2",
"조건": { "직접노무비(억)": "범위", "시설": "고르기" },
"값칸": { "면적": "㎡" },
"범위규칙": "「1.5억 미만」 = 위 끝 제외 · 「500억 이상」 = 아래 끝 포함 · 「1.5 ∼ 3억」 등 = 양 끝 포함",
"범위규칙": "이상·미만 · 원문 「1.5억 미만」 · 「1.5 ∼ 3억」 등 · 「500억 이상」 — 이웃 칸 끝이 겹쳐 아래 끝 포함으로 읽음",
"줄": [
{ "직접노무비(억)": [null, 1.5], "시설": "현장사무소 감독・감리자", "면적": 30 },
{ "직접노무비(억)": [null, 1.5], "시설": "현장사무소 수급자", "면적": 30 },
@@ -3006,7 +3006,7 @@
"출처": "건설품셈 공통 2-12-4 2",
"조건": { "명칭": "고르기", "추진관경(㎜)": "범위" },
"값칸": { "수량": "줄 따름" },
"범위규칙": "「300600」 등 양 끝 포함",
"범위규칙": "이상·이하 · 원문 「300600」 등 양 끝 포함",
"줄": [
{ "명칭": "중급기술자", "단위": "인", "추진관경(㎜)": [300, 600], "수량": 1 },
{ "명칭": "중급기술자", "단위": "인", "추진관경(㎜)": [700, 900], "수량": 1 },
@@ -3045,7 +3045,7 @@
"출처": "건설품셈 공통 2-12-4 4",
"조건": { "토질별": "고르기", "관경(㎜)": "범위", "추진장(m)": "범위" },
"값칸": { "작업능력": "m/일" },
"범위규칙": "「300∼500」 등 양 끝 포함 · 추진장 「0∼10m」 · 「0∼20m」 등은 원문 그대로 겹친 누적 구간(양 끝 포함)",
"범위규칙": "이상·이하 · 원문 「300∼500」 등 양 끝 포함 · 추진장 「0∼10m」 · 「0∼20m」 등은 원문 그대로 겹친 누적 구간 — 위 끝이 가장 작은 줄",
"줄": [
{ "토질별": "점토・실트", "관경(㎜)": [300, 500], "추진장(m)": [0, 10], "작업능력": 13 },
{ "토질별": "점토・실트", "관경(㎜)": [300, 500], "추진장(m)": [0, 20], "작업능력": 12 },
@@ -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㎡)이라 %가 없음"
]
},
{
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"그룹": "인력",
"원문": "자체",
"판": "2026-09-19",
"비고": "건설노임에 없는 직종 — 이름·단위만 · 값은 관리자가 채움 · 다른 직종에 대응시키지 않음",
"줄": [
{ "열쇠": "중급기술자", "이름": "중급기술자", "규격": "", "단위": "원/일", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-9-12 · 2-11-8 · 2-11-9 · 2-12-4 작업편성인원 · 5-1-7 인장 · 건설품셈 유지관리 1-1-2" },
{ "열쇠": "절단공", "이름": "절단공", "규격": "", "단위": "원/일", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-2-5" },
{ "열쇠": "특수비계공", "이름": "특수비계공", "규격": "", "단위": "원/일", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-3-6 설치 및 해체(해체)" },
{ "열쇠": "초급기술자", "이름": "초급기술자", "규격": "", "단위": "원/일", "값": null, "출처": "자체", "비고": "건설품셈 유지관리 2-4-3" }
]
}
+84 -24
View File
@@ -4,10 +4,10 @@
"판": "2026-09-19",
"비고": "공시에 없는 재료 — 이름·규격·단위만 · 값은 관리자가 물가지·견적으로 채움",
"줄": [
{ "열쇠": "레미콘", "이름": "레디믹스트콘크리트", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "건설품셈 공통 7-3-1 주⑥ 채움콘크리트 · 산림품셈 12-9-1 · 12-11-1 · 12-11-2-1 · 12-12 · 12-13 · 12-15 · 12-16 · 12-17-2 · 12-17-3 · 13-4-4 주① · 13-4-5 · 13-5-1 주⑤ · 13-5-2 · 규격(강도·골재·슬럼프)별 값이 다르면 관리자가 줄을 나눔" },
{ "열쇠": "시멘트", "이름": "시멘트", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "산림품셈 12-1-3 주⑤⑥ · 12-17-1-2 주③" },
{"열쇠": "레미콘", "이름": "레디믹스트콘크리트", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 12-9-1 · 12-11-1 · 12-11-2-1 · 12-12 · 12-13 · 12-15 · 12-16 · 12-17-2 · 12-17-3 · 13-4-4 주① · 13-4-5 · 13-5-1 주⑤ · 13-5-2 · 규격(강도·골재·슬럼프)별 값이 다르면 관리자가 줄을 나눔 · 건설품셈 공통 7-3-1 주⑥ 채움콘크리트"},
{"열쇠": "시멘트", "이름": "시멘트", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "산림품셈 12-1-3 주⑤⑥ · 12-17-1-2 주③ · 건설품셈 공통 6-3-1 주⑤"},
{ "열쇠": "모래(중량)", "이름": "모래", "규격": "건조상태", "단위": "kg", "값": null, "출처": "자체", "비고": "산림품셈 12-1-3 주⑤" },
{ "열쇠": "모래", "이름": "모래", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 12-11-3 · 12-17-1-2 주③" },
{ "열쇠": "모래", "이름": "모래", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 12-11-3 · 12-17-1-2 주③ · 건설품셈 공통 6-3-1 주⑤" },
{ "열쇠": "자갈(중량)", "이름": "자갈 또는 부순돌", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "산림품셈 12-1-3 주⑤" },
{ "열쇠": "자갈", "이름": "자갈", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 12-17-1-2 주③" },
{ "열쇠": "이형철근", "이름": "이형철근", "규격": "", "단위": "ton", "값": null, "출처": "자체", "비고": "산림품셈 12-15 · 12-16" },
@@ -17,14 +17,14 @@
{ "열쇠": "강봉 D32㎜", "이름": "강봉", "규격": "D32㎜, L=1000㎜", "단위": "EA", "값": null, "출처": "자체", "비고": "산림품셈 12-30-1" },
{ "열쇠": "사다리", "이름": "사다리", "규격": "∅19㎜ 아연도금 및 스테인레스", "단위": "ton", "값": null, "출처": "자체", "비고": "산림품셈 12-16" },
{ "열쇠": "고철", "이름": "고철", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "산림품셈 9-8-1 주4 · 12-34-3" },
{ "열쇠": "폼타이", "이름": "폼타이(Form Tie)", "규격": "D형 1/2인치", "단위": "본", "값": null, "출처": "자체", "비고": "산림품셈 12-4 주⑦" },
{ "열쇠": "폼타이", "이름": "폼타이(Form Tie)", "규격": "D형 1/2인치", "단위": "본", "값": null, "출처": "자체", "비고": "산림품셈 12-4 주⑦ · 건설품셈 공통 6-3-1 주④" },
{ "열쇠": "문양 스티로폴", "이름": "문양 스티로폴(자재비 포함)", "규격": "", "단위": "㎡", "값": null, "출처": "자체", "비고": "산림품셈 12-5" },
{ "열쇠": "커터 블레이드", "이름": "커터 블레이드", "규격": "320~400㎜", "단위": "개", "값": null, "출처": "자체", "비고": "산림품셈 12-7-1 주③" },
{ "열쇠": "물", "이름": "물", "규격": "", "단위": "", "값": null, "출처": "자체", "비고": "산림품셈 12-7-1 주④" },
{ "열쇠": "철재 거푸집", "이름": "철재 거푸집(콘크리트 포장)", "규격": "1본 3m", "단위": "본", "값": null, "출처": "자체", "비고": "산림품셈 12-8 주①④" },
{ "열쇠": "핀폴", "이름": "핀폴", "규격": "", "단위": "개", "값": null, "출처": "자체", "비고": "산림품셈 12-8 주①④" },
{ "열쇠": "유공관 ∅200㎜", "이름": "유공관", "규격": "∅200㎜", "단위": "m", "값": null, "출처": "자체", "비고": "산림품셈 12-10" },
{ "열쇠": "부직포", "이름": "부직포", "규격": "", "단위": "㎡", "값": null, "출처": "자체", "비고": "산림품셈 12-10 · 12-23" },
{ "열쇠": "부직포", "이름": "부직포", "규격": "", "단위": "㎡", "값": null, "출처": "자체", "비고": "산림품셈 12-10 · 12-23 · 건설품셈 공통 2-11-1 재료량 · 건설품셈 공통 5-4-1 재료량" },
{ "열쇠": "잡석", "이름": "잡석", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 12-10 · 13-4-2 · 13-5-2" },
{ "열쇠": "VR관 ∅800㎜", "이름": "VR관(소켓식)", "규격": "∅800㎜", "단위": "m", "값": null, "출처": "자체", "비고": "산림품셈 12-11-1" },
{ "열쇠": "고무링 ∅800㎜", "이름": "고무링(VR관)", "규격": "∅800㎜", "단위": "개", "값": null, "출처": "자체", "비고": "산림품셈 12-11-1" },
@@ -66,7 +66,7 @@
{ "열쇠": "하드너", "이름": "콘크리트 표면 강화재(하드너)", "규격": "", "단위": "", "값": null, "출처": "자체", "비고": "산림품셈 12-35 · 원문 단위 없음" },
{ "열쇠": "소재", "이름": "소재(원목)", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 10-8-2" },
{ "열쇠": "각재·판재", "이름": "각재·판재", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 10-8-2" },
{ "열쇠": "목재", "이름": "목재(규준틀)", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 11-2 · 11-3 · 해석: 원문 단위 없음 — ㎥" },
{ "열쇠": "목재", "이름": "목재(규준틀)", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 11-2 · 11-3 · 해석: 원문 단위 없음 — ㎥ · 건설품셈 공통 2-5-4 · 2-11-1 재료량" },
{ "열쇠": "줄눈 모르타르", "이름": "줄눈메꿈 모르타르", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 13-4-4 주④ · 13-5-1 주③" },
{ "열쇠": "틈메우기돌", "이름": "틈메우기돌", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 13-5-1 주④" },
{ "열쇠": "철망태", "이름": "철망태(사각형 돌망태)", "규격": "", "단위": "㎥", "값": null, "출처": "자체", "비고": "산림품셈 13-11-4" },
@@ -82,26 +82,86 @@
{ "열쇠": "피복제", "이름": "피복제", "규격": "화이버/펄프류", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 공통 4-1-3 재료량" },
{ "열쇠": "침식방지안정제", "이름": "침식방지안정제", "규격": "합성접착제", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 공통 4-1-3 재료량" },
{ "열쇠": "착색제", "이름": "색소", "규격": "착색제", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 공통 4-1-3 재료량" },
{ "열쇠": "차선도색 프라이머", "이름": "프라이머", "규격": "융착식 도료 차선도색용", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 토목 1-8-9 융착식 도료 수동식 재료" },
{ "열쇠": "차선도색 프라이머", "이름": "프라이머", "규격": "융착식 도료 차선도색용", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 토목 1-8-9 융착식 도료 수동식 재료 · 건설품셈 유지관리 2-1-15 4 자재수량" },
{ "열쇠": "낙석방지망 철망", "이름": "철망", "규격": "낙석방지망용", "단위": "㎡", "값": null, "출처": "자체", "비고": "건설품셈 토목 1-9-4 낙석방지망 재료(참고자료)" },
{ "열쇠": "결속선", "이름": "결속선", "규격": "", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 토목 1-9-4 낙석방지망 재료(참고자료)" },
{ "열쇠": "에폭시", "이름": "에폭시", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 토목 1-9-4 낙석방지망 재료(참고자료) · 포켓식만" },
{ "열쇠": "IV전선 2.0㎜", "이름": "IV전선", "규격": "2.0㎜", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 토목 6-7-5 강관압입추진공 조명시설" },
{ "열쇠": "백열등 100W", "이름": "백열등", "규격": "100W", "단위": "EA", "값": null, "출처": "자체", "비고": "건설품셈 토목 6-7-5 강관압입추진공 조명시설" },
{ "열쇠": "일체식 방수시트", "이름": "일체식 방수시트", "규격": "부직포 부착 터널 방수시트", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 3-4-1 터널 방수 재료량" },
{ "열쇠": "바퀴숫돌 단면용", "이름": "바퀴숫돌", "규격": "단면용 A36m B11호 A150×8×22 KSL 6501", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-1 가스압접 소모재료(참고자료)" },
{ "열쇠": "바퀴숫돌 측면용", "이름": "바퀴숫돌", "규격": "측면용 A24 QWV1호 A205×25×25 KSL 6501", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-1 가스압접 소모재료(참고자료)" },
{ "열쇠": "바퀴숫돌 평면용", "이름": "바퀴숫돌", "규격": "평면용 A24 QWV1호 A205×25×25 KSL 6501", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-1 가스압접 소모재료(참고자료)" },
{ "열쇠": "바퀴숫돌 최종용", "이름": "바퀴숫돌", "규격": "최종용 A24 QWV 5호 A205×22×22", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-1 가스압접 소모재료(참고자료)" },
{ "열쇠": "압접가열용 버너", "이름": "버너", "규격": "압접가열용", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-1 가스압접 소모재료(참고자료)" },
{ "열쇠": "압접버너용 노즐", "이름": "노즐", "규격": "압접버너용", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-1 가스압접 소모재료(참고자료)" },
{ "열쇠": "테르밋용재", "이름": "테르밋용재", "규격": "점화용", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-2 테르밋 용접 소모재료(참고자료)" },
{ "열쇠": "테르밋 몰드", "이름": "몰드", "규격": "테르밋 용접용", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-2 테르밋 용접 소모재료(참고자료)" },
{ "열쇠": "테르밋 골무", "이름": "골무", "규격": "테르밋 용접용", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-2 테르밋 용접 소모재료(참고자료)" },
{ "열쇠": "테르밋 퓨즈", "이름": "퓨즈", "규격": "테르밋 용접용", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-2 테르밋 용접 소모재료(참고자료)" },
{ "열쇠": "강교 용접봉", "이름": "용접봉", "규격": "강교 제작용", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 5-1-3 기타재료 소요량" },
{ "열쇠": "철구(Shot ball)", "이름": "철구(Shot ball)", "규격": "", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 5-2-1 소재 표면처리" },
{ "열쇠": "무기질아연말샵프라이머", "이름": "무기질아연말 샵프라이머", "규격": "도막두께 20μm", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 5-2-1 소재 표면처리" },
{ "열쇠": "철편(Grit)", "이름": "철편(Grit)", "규격": "", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 5-2-2 제품 표면처리" }
{"열쇠": "에폭시 신구콘크리트 접착제", "이름": "Epoxy 신구-콘크리트 접착제", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-1-5"},
{"열쇠": "에폭시 콘크리트고무 접착제", "이름": "Epoxy-콘크리트 고무 접착제", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-1-5"},
{"열쇠": "폼타이 9.5㎜", "이름": "폼타이(Form Tie)", "규격": "9.5㎜", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-3-1 주④"},
{"열쇠": "폼타이 7.9㎜", "이름": "폼타이(Form Tie)", "규격": "7.9㎜", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-3-1 주④"},
{"열쇠": "강재거푸집", "이름": "강재거푸집", "규격": "두께 3.2㎜", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-3-2"},
{"열쇠": "문양거푸집 판넬", "이름": "문양거푸집(판넬)", "규격": "", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-3-4"},
{"열쇠": "합성수지 원형맨홀거푸집 ø740", "이름": "합성수지(P.E) 원형 맨홀 거푸집", "규격": "ø740", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-3-5 주④"},
{"열쇠": "합성수지 원형맨홀거푸집 ø900", "이름": "합성수지(P.E) 원형 맨홀 거푸집", "규격": "ø900", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-3-5 주④"},
{"열쇠": "합성수지 원형맨홀거푸집 ø1200", "이름": "합성수지(P.E) 원형 맨홀 거푸집", "규격": "ø1200", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-3-5 주④"},
{"열쇠": "합성수지 원형맨홀거푸집 ø1500", "이름": "합성수지(P.E) 원형 맨홀 거푸집", "규격": "ø1500", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-3-5 주④"},
{"열쇠": "합성수지 원형맨홀거푸집 ø1800", "이름": "합성수지(P.E) 원형 맨홀 거푸집", "규격": "ø1800", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-3-5 주④"},
{"열쇠": "쉬즈관", "이름": "쉬즈관", "규격": "", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-4-1 주④ · 6-4-2 주④"},
{"열쇠": "조립식 가설건축물 BASECHANNEL", "이름": "BASECHANNEL", "규격": "두께 2.0㎜ 이상", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 TOPCHANNEL", "이름": "TOPCHANNEL", "규격": "두께 2.0㎜ 이상", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 외부PANEL(벽)", "이름": "외부PANEL(벽)", "규격": "1,200×2,400㎜", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 외부PANEL (창문)", "이름": "외부PANEL (창문)", "규격": "1,200×2,400㎜", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 외부PANEL (철재문)", "이름": "외부PANEL (철재문)", "규격": "1,200×2,400㎜", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 내부PANEL(벽)", "이름": "내부PANEL(벽)", "규격": "1,200×2,400㎜", "단위": "매", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 내부PANEL (목재문)", "이름": "내부PANEL (목재문)", "규격": "1,200×2,400㎜", "단위": "매", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 PANEL JOINT(AL-BAR)", "이름": "PANEL JOINT(AL-BAR)", "규격": "L=2,400㎜", "단위": "조", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 CANOPY(출입구채양)", "이름": "CANOPY(출입구채양)", "규격": "600×1,200㎜", "단위": "매", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 박공PANEL", "이름": "박공PANEL", "규격": "", "단위": "매", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 ROOFSHEET", "이름": "ROOFSHEET", "규격": "0.5㎜ COLOR SHEET", "단위": "㎡", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 트러스", "이름": "트러스", "규격": "L=7.2m", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 중도리(PURIN)", "이름": "중도리(PURIN)", "규격": "두께 2.0 이상", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 천장판", "이름": "천장판", "규격": "미장합판+50㎜ GLASSWOOL", "단위": "매", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "조립식 가설건축물 T-BAR", "이름": "T-BAR", "규격": "", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 주자재"},
{"열쇠": "콘테이너형 가설건축물 3.0x3.0m", "이름": "콘테이너형 가설건축물", "규격": "3.0x3.0m", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 · 2-3-2"},
{"열쇠": "콘테이너형 가설건축물 3.0x6.0m", "이름": "콘테이너형 가설건축물", "규격": "3.0x6.0m", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 · 2-3-2"},
{"열쇠": "콘테이너형 가설건축물 3.0x9.0m", "이름": "콘테이너형 가설건축물", "규격": "3.0x9.0m", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-3 · 2-3-2"},
{"열쇠": "축중계", "이름": "축중계(이동식)", "규격": "", "단위": "대", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-2-6"},
{"열쇠": "조립말비계 가새", "이름": "조립말비계 가새", "규격": "L1518-2개", "단위": "조", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-7-6 재료량"},
{"열쇠": "조립말비계 수평띠장", "이름": "조립말비계 수평띠장", "규격": "L1829", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-7-6 재료량"},
{"열쇠": "조립말비계 손잡이기둥", "이름": "조립말비계 손잡이기둥", "규격": "", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-7-6 재료량"},
{"열쇠": "조립말비계 손잡이 L1219", "이름": "조립말비계 손잡이", "규격": "L1219", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-7-6 재료량"},
{"열쇠": "조립말비계 손잡이 L1829", "이름": "조립말비계 손잡이", "규격": "L1829", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-7-6 재료량"},
{"열쇠": "조립말비계 바퀴", "이름": "조립말비계 바퀴", "규격": "", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-7-6 재료량"},
{"열쇠": "조립말비계 자키", "이름": "조립말비계 자키", "규격": "", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-7-6 재료량"},
{"열쇠": "조립말비계 발판", "이름": "조립말비계 발판", "규격": "45×200×2000", "단위": "장", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-7-6 재료량"},
{"열쇠": "강관 ø48.6×2.4㎜", "이름": "강관(비계용)", "규격": "ø48.6㎜×2.4㎜", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-8-1 재료량 · 2-8-2 재료량"},
{"열쇠": "낙하물방지망 브라켓", "이름": "브라켓(낙하물방지망)", "규격": "", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-8-1 재료량 · 2-8-2 재료량"},
{"열쇠": "비계 클램프", "이름": "클램프", "규격": "", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-8-1 재료량 · 2-8-2 재료량"},
{"열쇠": "그물망", "이름": "그물망", "규격": "", "단위": "㎡", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-8-1 재료량 · 2-8-2 재료량 · 2-8-7 재료량"},
{"열쇠": "플라잉넷 사다리", "이름": "사다리(플라잉넷)", "규격": "폭 30㎝×길이 3m 기준", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-8-2 재료량"},
{"열쇠": "철골 안전망 보강재", "이름": "보강재(철골 안전망)", "규격": "", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-8-7 재료량"},
{"열쇠": "보호망", "이름": "보호망", "규격": "", "단위": "㎡", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-8-8 재료량 · 2-8-9 재료량"},
{"열쇠": "보호막", "이름": "보호막", "규격": "", "단위": "㎡", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-10-1 재료량"},
{"열쇠": "방진망", "이름": "방진망", "규격": "", "단위": "㎡", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-10-2 재료량"},
{"열쇠": "풀", "이름": "풀(보양지)", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 공통 2-11-1 재료량"},
{"열쇠": "토목섬유 매트", "이름": "매트(합성수지 토목섬유)", "규격": "", "단위": "㎡", "값": null, "출처": "자체", "비고": "건설품셈 공통 5-2-1 매트고정 재료량"},
{"열쇠": "PP로프 9㎜", "이름": "P.P로프", "규격": "9㎜", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 공통 5-2-1 매트고정 재료량"},
{"열쇠": "모래주머니", "이름": "모래주머니", "규격": "", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 공통 5-2-1 매트고정 재료량"},
{"열쇠": "철근 19㎜", "이름": "철근", "규격": "19㎜", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 공통 5-2-1 매트고정 재료량"},
{"열쇠": "드레인 보드", "이름": "드레인 보드(연직배수재)", "규격": "", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 공통 5-2-3 주②"},
{"열쇠": "지오콤포지트", "이름": "지오콤포지트", "규격": "6.0㎜", "단위": "㎡", "값": null, "출처": "자체", "비고": "건설품셈 공통 5-4-1 재료량"},
{"열쇠": "벤토나이트매트", "이름": "벤토나이트매트", "규격": "6.0㎜", "단위": "㎡", "값": null, "출처": "자체", "비고": "건설품셈 공통 5-4-1 재료량"},
{"열쇠": "HDPE 시트", "이름": "HDPE 시트", "규격": "22.5㎜", "단위": "㎡", "값": null, "출처": "자체", "비고": "건설품셈 공통 5-4-1 재료량"},
{"열쇠": "아세틸렌가스()", "이름": "아세틸렌가스", "규격": "", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 공통 6-2-5 · 원문 ℓ 단위(나라장터 20204095 는 kg)"},
{"열쇠": "노면파쇄기 팁(날)", "이름": "팁(날)", "규격": "노면파쇄기 절삭용", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 유지관리 2-1-3 주④ · 2-1-4 주④ · 2-1-5 주②"},
{"열쇠": "프로판가스", "이름": "프로판가스", "규격": "", "단위": "kg", "값": null, "출처": "자체", "비고": "건설품셈 유지관리 2-1-15 4 자재수량"},
{"열쇠": "IV전선 2.0㎜", "이름": "IV전선", "규격": "2.0㎜", "단위": "m", "값": null, "출처": "자체", "비고": "건설품셈 토목 6-7-5 강관압입추진공 조명시설"},
{"열쇠": "백열등 100W", "이름": "백열등", "규격": "100W", "단위": "EA", "값": null, "출처": "자체", "비고": "건설품셈 토목 6-7-5 강관압입추진공 조명시설"},
{"열쇠": "일체식 방수시트", "이름": "일체식 방수시트", "규격": "부직포 부착 터널 방수시트", "단위": "㎡", "값": null, "출처": "자체", "비고": "건설품셈 토목 3-4-1 터널 방수 재료량"},
{"열쇠": "바퀴숫돌 단면용", "이름": "바퀴숫돌", "규격": "단면용 A36m B11호 A150×8×22 KSL 6501", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-1 가스압접 소모재료(참고자료)"},
{"열쇠": "바퀴숫돌 측면용", "이름": "바퀴숫돌", "규격": "측면용 A24 QWV1호 A205×25×25 KSL 6501", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-1 가스압접 소모재료(참고자료)"},
{"열쇠": "바퀴숫돌 평면용", "이름": "바퀴숫돌", "규격": "평면용 A24 QWV1호 A205×25×25 KSL 6501", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-1 가스압접 소모재료(참고자료)"},
{"열쇠": "바퀴숫돌 최종용", "이름": "바퀴숫돌", "규격": "최종용 A24 QWV 5호 A205×22×22", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-1 가스압접 소모재료(참고자료)"},
{"열쇠": "압접가열용 버너", "이름": "버너", "규격": "압접가열용", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-1 가스압접 소모재료(참고자료)"},
{"열쇠": "압접버너용 노즐", "이름": "노즐", "규격": "압접버너용", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-1 가스압접 소모재료(참고자료)"},
{"열쇠": "테르밋용재", "이름": "테르밋용재", "규격": "점화용", "단위": "포", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-2 테르밋 용접 소모재료(참고자료)"},
{"열쇠": "테르밋 몰드", "이름": "몰드", "규격": "테르밋 용접용", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-2 테르밋 용접 소모재료(참고자료)"},
{"열쇠": "테르밋 골무", "이름": "골무", "규격": "테르밋 용접용", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-2 테르밋 용접 소모재료(참고자료)"},
{"열쇠": "테르밋 퓨즈", "이름": "퓨즈", "규격": "테르밋 용접용", "단위": "개", "값": null, "출처": "자체", "비고": "건설품셈 토목 4-5-2 테르밋 용접 소모재료(참고자료)"},
{"열쇠": "강교 용접봉", "이름": "용접봉", "규격": "강교 제작용", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 토목 5-1-3 기타재료 소요량"},
{"열쇠": "철구(Shot ball)", "이름": "철구(Shot ball)", "규격": "", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 토목 5-2-1 소재 표면처리"},
{"열쇠": "무기질아연말샵프라이머", "이름": "무기질아연말 샵프라이머", "규격": "도막두께 20μm", "단위": "", "값": null, "출처": "자체", "비고": "건설품셈 토목 5-2-1 소재 표면처리"},
{"열쇠": "철편(Grit)", "이름": "철편(Grit)", "규격": "", "단위": "㎏", "값": null, "출처": "자체", "비고": "건설품셈 토목 5-2-2 제품 표면처리"}
]
}
+42
View File
@@ -377,3 +377,45 @@ def test_로직_검사가_틀린_열쇠를_잡음(whole):
def test_돌고_도는_참조():
assert mf.find_loops({"": {""}, "": {""}}) == [["", "", ""]]
def test_찾기_값칸_있는_줄만():
table = {
"열쇠": "시험",
"조건": {"구분": "고르기", "높이": "고르기"},
"": [
{"구분": "비계공", "수량": 3},
{"높이": "4m이하", "시공량(m)": 100},
{"높이": "7m이하", "시공량(m)": 70},
],
}
assert mf.find(table, {"구분": "비계공"}, "수량")["수량"] == 3
assert mf.find(table, {"높이": "7m이하"}, "시공량(m)")["시공량(m)"] == 70
with pytest.raises(mf.FormulaError):
mf.find(table, {"높이": "7m이하"})
assert mf.parse("찾기(소요량:건설품셈:시험, 높이='7m이하').'시공량(m)'")[2] == "시공량(m)"
assert (
mf.parse("찾기(소요량:건설품셈:시험, '규준틀 높이'=높이).수량")[1][2][0][0] == "규준틀 높이"
)
assert (
mf.parse("찾기(소요량:건설품셈:5-1-1 P.P마대 쌓기, 구분=구분).수량")[1][1]
== "소요량:건설품셈:5-1-1 P.P마대 쌓기"
)
def test_범위규칙_이상이하():
table = {
"열쇠": "시험",
"조건": {"길이": "범위"},
"범위규칙": "이상·이하 · 겹친 누적 구간",
"": [
{"길이": [0, 10], "": 1},
{"길이": [0, 20], "": 2},
{"길이": [30, 40], "": 3},
],
}
assert mf.find(table, {"길이": Decimal(10)})[""] == 1
assert mf.find(table, {"길이": Decimal(15)})[""] == 2
assert mf.find(table, {"길이": Decimal(30)})[""] == 3
with pytest.raises(mf.FormulaError):
mf.find(table, {"길이": Decimal(25)})
+2
View File
@@ -24,6 +24,7 @@ import { ui_locales_a } from "./ui_template_locale_a";
import { ui_locales_b1 } from "./ui_template_locale_b1";
import { ui_locales_b2 } from "./ui_template_locale_b2";
import { ui_locales_b3 } from "./ui_template_locale_b3";
import { ui_locales_m1 } from "./ui_template_locale_m1";
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
export const LANGUAGES = ["ko", "en"] as const;
@@ -58,6 +59,7 @@ export const ui_locales = {
...ui_locales_b1,
...ui_locales_b2,
...ui_locales_b3,
...ui_locales_m1,
} as const;
export type LocaleKey = keyof typeof ui_locales;
+40
View File
@@ -0,0 +1,40 @@
/* =============================================================================
* ui_template_locale_m1.ts
* M01 ( ) .
* ========================================================================== */
export const ui_locales_m1 = {
B01_Dashboard_MasterElements: ["마스터 요소", "Master Elements"],
M01_LogicTab: ["로직", "Logic"],
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;