Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into sub_laptop_8

This commit is contained in:
2026-11-01 16:33:31 +09:00
19 changed files with 591 additions and 222 deletions
+14
View File
@@ -0,0 +1,14 @@
/* 공용 단가산출 계산서(714-2) — 장비 · 하위 로직 한 줄 = 누르면 펼침 */
[data-sheet-equip] {
cursor: pointer;
border-radius: var(--radius-sm);
}
[data-sheet-equip]:hover,
[data-sheet-equip]:focus-visible {
background: var(--color-hover);
}
[data-sheet-part] .ui-calc-row__label {
padding-left: var(--spacing-16);
}
+178 -31
View File
@@ -1,22 +1,34 @@
/* =============================================================================
* calc_sheet.ts
* 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5) — 704-2 M02 계산서 보기를 올린 것.
* 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5 · 6) — 704-2 M02 계산서 보기를 올린 것.
* 쓰는 곳 = M01 「인력·재료·경비」 보기 · M02 기준 목록 계산서 보기(704-2) · (뒤에) 일위대가 모달 · B09 단가산출근거
* 꼴 = 묶음마다 [머리(변수 칸이 있을 때만) · 쓰는 입력 값 줄] · 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계
* 값 = 서버 셈 그대로 찍음(화면에서 다시 셈 안 함) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈
* 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 요소 · 페이지 CSS 없음
* 꼴 둘 —
* 비목(기본 · 704-2) = 비목 묶음마다 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계
* 묶음(`bundle`) = 인력 · 재료 · 경비(장비) · 하위 — 줄의 `묶음`(714-1 서버 덧칸) 으로 다시 묶음 ·
* 장비 · 하위 로직 = 한 줄(노 · 재 · 경 칸 · 합) + 누르면 펼침(그 줄의 노 · 재 · 경 식) ·
* 묶음 머리 = 이름 · 기준단위 · 환산 근거 · 그 묶음이 쓰는 입력 칸(`쓰는입력`) · 맨 아래 비목 소계 셋 + 계
* 값 = 서버 셈 그대로(묶음 소계만 서버 줄 금액의 합) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈
* 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 단추 · 표 · 페이지 CSS 없음
* ========================================================================== */
import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import "./calc_sheet.css";
export interface SheetLine {
이름: string;
/** 서버 값 식 「단가 * 품 = 금액」 — 「= 금액」 은 떼고 「*」 → 「×」 */
글: string;
금액: number | null;
/** 714-1 서버 덧칸 — 그 줄 값이 쓰는 로직 입력 · 묶음 · 장비 · 찾는 표 기준 · 환산 근거 */
쓰는입력?: string[];
묶음?: string | null;
장비?: boolean | null;
기준단위?: string | null;
환산근거?: string;
}
export interface SheetGroup {
비목: string;
@@ -34,22 +46,103 @@ export interface SheetOptions {
계: number | null;
/** 맨 아래 계 줄 이름(「계 (원/㎡)」) */
sumLabel: string;
/** 변수 칸 — 입력 꼴 · 지금 값 · 묶음이 쓰는 입력 이름 · 바꾼 뒤 할 일(없으면 변수 칸 없음) */
/** 인력 · 재료 · 경비(장비) 묶음으로 다시 묶음(없으면 서버 비목 묶음 그대로 · 704-2) */
bundle?: boolean;
/** 변수 칸 — 입력 꼴 · 지금 값 · 바꾼 뒤 할 일(없으면 변수 칸 없음) · 칸 = 묶음 줄들의 `쓰는입력` */
inputs?: SheetInput[];
values?: Record<string, string>;
uses?: (group: SheetGroup, index: number) => string[];
onInput?: (name: string, value: string) => void;
}
/* ── 순수 셈 ── */
/** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */
export const sheetFormula = (text: string): string =>
text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × ");
/** 장비 · 하위 로직 한 줄 — 비목마다 몫 줄(서버 줄 그대로) · 합 */
export interface SheetMerged {
이름: string;
몫: { 비목: string; 줄: SheetLine }[];
금액: number | null;
}
export interface SheetBundle {
묶음: string;
줄: (SheetLine | SheetMerged)[];
소계: number;
쓰는입력: string[];
기준단위: string[];
환산근거: string[];
}
const ORDER = ["인력", "재료", "경비", "하위"];
const OF_COST: Record<string, string> = { 노무비: "인력", 재료비: "재료", 경비: "경비" };
const add = (a: number | null, b: number | null): number | null =>
a === null && b === null ? null : (a ?? 0) + (b ?? 0);
const merges = (l: SheetLine): boolean => !!l.장비 || l.묶음 === "하위";
/** 비목 묶음 → 인력 · 재료 · 경비 · 하위 묶음 — 장비 · 하위 줄은 비목마다 갈린 몫을 한 줄로
* (같은 이름 k 번째끼리 짝 — 서버 줄 차례 = 호표 차례 · 비목마다 같은 차례) */
export function bundleLines(groups: SheetGroup[]): SheetBundle[] {
const bundles = new Map<string, SheetBundle>();
const merged = new Map<string, SheetMerged>();
const take = (name: string): SheetBundle => {
let b = bundles.get(name);
if (!b) {
b = { 묶음: name, 줄: [], 소계: 0, 쓰는입력: [], 기준단위: [], 환산근거: [] };
bundles.set(name, b);
}
return b;
};
for (const g of groups) {
const seen = new Map<string, number>();
for (const line of g.줄) {
const b = take(line.묶음 || OF_COST[g.비목] || g.비목);
b.소계 += line.금액 ?? 0;
for (const n of line.쓰는입력 ?? []) if (!b.쓰는입력.includes(n)) b.쓰는입력.push(n);
if (line.기준단위 && !b.기준단위.includes(line.기준단위)) b.기준단위.push(line.기준단위);
if (line.환산근거 && !b.환산근거.includes(line.환산근거)) b.환산근거.push(line.환산근거);
if (!merges(line)) {
b.줄.push(line);
continue;
}
const k = seen.get(line.이름) ?? 0;
seen.set(line.이름, k + 1);
const id = `${b.묶음}\n${line.이름}\n${k}`;
let m = merged.get(id);
if (!m) {
m = { 이름: line.이름, 몫: [], 금액: null };
merged.set(id, m);
b.줄.push(m);
}
m.몫.push({ 비목: g.비목, 줄: line });
m.금액 = add(m.금액, line.금액);
}
}
const rank = (x: string): number => (ORDER.includes(x) ? ORDER.indexOf(x) : ORDER.length);
return [...bundles.values()].sort((a, b) => rank(a.묶음) - rank(b.묶음));
}
/* ── 순수 셈 끝 ── */
const TEXT = {
인력: ["인력", "Labor"],
재료: ["재료", "Material"],
경비: ["경비(장비)", "Expense (equipment)"],
하위: ["하위 로직", "Sub logic"],
Basis: ["기준", "Basis"],
Short: ["노 {a} · 재 {b} · 경 {c}", "L {a} · M {b} · E {c}"],
Fold: ["누르면 노 · 재 · 경 식 펼침", "Click to expand labor · material · expense"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
const bundleName = (b: string): string => (b in TEXT ? t(b as keyof typeof TEXT) : b);
const won = (v: number | null, digits = 0): string =>
v === null ? "-" : v.toLocaleString("ko-KR", { maximumFractionDigits: digits });
const out = (v: number | null, key = false, digits = 0): HTMLElement =>
el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) });
/** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */
export const sheetFormula = (text: string): string =>
text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × ");
const lineRow = (line: SheetLine, label = line.이름): HTMLElement =>
createCalcFormulaRow(label, createCalcFormula(sheetFormula(line.글)), out(line.금액, false, 1));
/** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */
function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement {
@@ -75,32 +168,86 @@ function varRow(spec: SheetInput, value: string, onInput: (v: string) => void):
return row;
}
/** 장비 · 하위 한 줄 — 노 · 재 · 경 칸 + 합 · 누르면(Enter · Space) 몫 식 줄 펼침 */
function mergedRows(m: SheetMerged): HTMLElement[] {
const share = (cost: string): number | null =>
m.몫.filter((x) => x.비목 === cost).reduce<number | null>((s, x) => add(s, x.줄.금액), null);
const short = t("Short")
.replace("{a}", won(share("노무비"), 1))
.replace("{b}", won(share("재료비"), 1))
.replace("{c}", won(share("경비"), 1));
const head = createCalcFormulaRow(`▸ ${m.이름}`, createCalcFormula(short), out(m.금액, false, 1));
const parts = m.몫.map((x) => lineRow(x.줄, `${x.비목}`));
for (const p of parts) {
p.hidden = true;
p.dataset.sheetPart = m.이름;
}
head.dataset.sheetEquip = m.이름;
head.title = t("Fold");
head.tabIndex = 0;
head.setAttribute("role", "button");
head.setAttribute("aria-expanded", "false");
const label = head.firstElementChild as HTMLElement;
const toggle = (): void => {
const open = head.getAttribute("aria-expanded") !== "true";
head.setAttribute("aria-expanded", String(open));
label.textContent = `${open ? "▾" : "▸"} ${m.이름}`;
parts.forEach((p) => (p.hidden = !open));
};
head.addEventListener("click", toggle);
head.addEventListener("keydown", (ev) => {
if (ev.key !== "Enter" && ev.key !== " ") return;
ev.preventDefault();
toggle();
});
return [head, ...parts];
}
/** 묶음 머리 — 이름 · 기준 · 환산 근거 · 쓰는 입력 칸 */
function headRows(
name: string,
uses: string[],
o: SheetOptions,
specs: Map<string, SheetInput>,
basis: string[] = [],
notes: string[] = [],
): HTMLElement[] {
const vars = o.onInput ? uses.filter((n) => specs.has(n)) : [];
if (!vars.length && !o.bundle) return [];
const title = [name, basis.length ? `${t("Basis")} ${basis.join(" · ")}` : ""].filter(Boolean);
const head = createText(title.join(" — "), "muted", 14);
head.dataset.sheetHead = name;
const rows: HTMLElement[] = [head];
if (notes.length) rows.push(createText(notes.join(" · "), "hint"));
const onInput = o.onInput;
if (onInput)
for (const n of vars)
rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v)));
return rows;
}
const subtotal = (g: SheetGroup): HTMLElement =>
createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true));
/** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */
export function calcSheetRows(o: SheetOptions): HTMLElement[] {
const rows: HTMLElement[] = [];
const specs = new Map((o.onInput ? (o.inputs ?? []) : []).map((s) => [s.이름, s]));
o.묶음.forEach((g, gi) => {
const vars = (o.uses?.(g, gi) ?? []).filter((n) => specs.has(n));
if (vars.length && o.onInput) {
const onInput = o.onInput;
const head = createText(g.비목, "muted", 14);
head.dataset.sheetHead = g.비목;
rows.push(head);
for (const n of vars)
rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v)));
const specs = new Map((o.inputs ?? []).map((s) => [s.이름, s]));
if (o.bundle) {
for (const b of bundleLines(o.묶음)) {
const name = bundleName(b.묶음);
rows.push(...headRows(name, b.쓰는입력, o, specs, b.기준단위, b.환산근거));
for (const line of b.줄) rows.push(...("몫" in line ? mergedRows(line) : [lineRow(line)]));
rows.push(createCalcFormulaRow(name, null, out(b.소계, true, 1)));
}
for (const line of g.줄)
rows.push(
createCalcFormulaRow(
line.이름,
createCalcFormula(sheetFormula(line.글)),
out(line.금액, false, 1),
),
);
rows.push(
createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true)),
);
});
rows.push(...o.묶음.map(subtotal)); // 맨 아래 비목 소계(서버 끝수 그대로) — 장비 노무는 여기서 인력 노무와 더해짐
} else {
for (const g of o.묶음) {
const uses = [...new Set(g.줄.flatMap((l) => l.쓰는입력 ?? []))];
rows.push(...headRows(g.비목, uses, o, specs));
rows.push(...g.줄.map((line) => lineRow(line)), subtotal(g));
}
}
rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true)));
return rows;
}
+3 -3
View File
@@ -17,11 +17,11 @@ def text(key: str, inputs: dict, row: dict | None = None, file: str | None = Non
given = {k: store._dec(v) for k, v in inputs.items()}
try:
if row is None:
master = store.loaded()[1]
files, whole = store.loaded()
else: # 저장 전 시험 — `store.text` 와 같은 메모리 사본
files = store.cm.load(folder=store.FOLDER)
key = store._swap(files, str(row.get("키") or key), row, file)
master = mf.Master(files)
return msl.annotate(master, key, mf.fill_empty(master.logic(key), given), got)
whole = mf.Master(files)
return msl.annotate(files, key, mf.fill_empty(whole.logic(key), given), got)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
return got
@@ -1,7 +1,7 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Est.ts
* 「인력·재료·경비」 보기(PLAN 714-2) — 공용 단가산출 계산서(`A00_Common/calc_sheet.ts`)에 시험 계산 읽는 식(`/text`)을 그대로.
* 묶음 머리 변수 칸 = 그 묶음 줄들이 쓰는 시험 입력(714-3 `usedInputs`) · 바꾸면 시험 계산 입력 고침 + 다시 셈(정본 안 바뀜)
* 인력 · 재료 · 경비(장비) 묶음(714-1 줄 덧칸 `묶음`) · 묶음 머리 변수 칸 = 줄들의 `쓰는입력` · 바꾸면 시험 계산 입력 고침 + 다시 셈(정본 안 바뀜)
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
@@ -9,7 +9,7 @@ import { createText } from "@ui/ui_template_text";
import { calcSheetRows } from "../A00_Common/calc_sheet";
import type { LogicInput } from "./M01_MasterData_UI_Logic_Api";
import { safeReason } from "./M01_MasterData_UI_Logic_Text";
import { usedInputs, type PreviewTextAnswer } from "./M01_MasterData_UI_LogicLab_Formula";
import type { PreviewTextAnswer } from "./M01_MasterData_UI_LogicLab_Formula";
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
export interface EstContext {
@@ -31,18 +31,15 @@ export function buildEst(ctx: EstContext): HTMLElement {
host.append(createText(safeReason(text.reason), "error"));
return host;
}
const names = ctx.inputs.map((x) => x.이름);
host.append(
...calcSheetRows({
묶음: text.groups,
계: text.계,
sumLabel: `${tl("Total")} (${ctx.unit || "원"})`,
bundle: true,
inputs: ctx.inputs,
values: ctx.values,
onInput: ctx.onInput,
uses: (_, gi) => [
...new Set(text.groups[gi].줄.flatMap((line) => usedInputs(line, names).map(([n]) => n))),
],
}),
);
return host;
@@ -3,7 +3,7 @@
* 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음)
* 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이
* 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게
* 714-3 — 줄이 쓰는 입력(표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 →
* 714-3 — 줄이 쓰는 입력(서버 `쓰는입력` · 없으면 표 찾기 행 값 · 이름 조각)을 오른쪽 칸 아래 작은 단추로 · 누르면 그 자리에 고르기/글 칸 →
* 바꾸면 `onInput`(시험 계산 입력 고침 + 다시 셈) · 정본은 안 바뀜(요구서 4-1 확정 8)
* ========================================================================== */
@@ -123,10 +123,12 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void {
const rows: HTMLElement[] = [];
const specs = ctx.onInput ? (ctx.inputs ?? []) : [];
const vars = (line: TextLine): HTMLElement[] => {
const used = usedInputs(
line,
specs.map((x) => x.이름),
);
const names = specs.map((x) => x.이름);
const seen = new Map(usedInputs(line, names));
// 서버 「쓰는입력」(714-1 · 조건 · 재료 고르기 · 중간값까지) 이 있으면 그것 · 없으면 조각에서
const used: [string, string][] = line.쓰는입력
? line.쓰는입력.filter((n) => names.includes(n)).map((n) => [n, seen.get(n) ?? ""])
: [...seen];
if (!used.length || !ctx.onInput) return [];
const onInput = ctx.onInput;
return [
@@ -148,6 +148,12 @@ export interface TextLine {
짝?: number;
금액: number;
까닭?: string;
/** 714-1 계산서 줄 덧칸 — 쓰는 로직 입력 · 묶음(인력 · 재료 · 경비 · 하위) · 장비 · 찾는 표 기준 · 환산 근거 */
쓰는입력?: string[];
묶음?: string | null;
장비?: boolean | null;
기준단위?: string | null;
환산근거?: string;
}
/** `POST /text` 답 — 서버가 만든 읽는 식 줄(화면은 그대로 찍음) */
export type TextAnswer =
+1 -1
View File
@@ -334,7 +334,7 @@ def sheet(key: str, change: dict) -> dict:
raise m01.StoreError(404, str(e)) from e
given, ax = _given(whole, logic, change)
filled = m01.cm.mf.fill_empty(logic, given)
got = msl.annotate(whole, key, filled, m01.mtx.lines(files, key, filled)) # 줄 덧칸(714-1)
got = msl.annotate(files, key, filled, m01.mtx.lines(files, key, filled)) # 줄 덧칸(714-1)
done = settle.settle(whole, key, filled)
if "결과" not in done: # 돈 로직 — 줄마다 버림(B09 산근 규칙)
settle.apply(got.get("groups") or [], m01.cm.mf.run(whole, key, filled), done)
@@ -16,13 +16,7 @@
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`)
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { ROUTES } from "@config/config_frontend";
import { navigateTo } from "../A00_Common/router";
@@ -64,15 +58,15 @@ import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks";
import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
import { logicTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows";
import { matTable } from "./M02_MasterTemplete_Labor_MatTable";
import { laborGrid } from "./M02_MasterTemplete_Labor_Grid";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model";
import { keepKnown } from "./M02_Labor_Values_Model";
import { addMaterialButton } from "./M02_Material_Pick";
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo";
import { libMeta } from "./M02_MasterTemplete_Labor_LibMeta";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
import "@ui/ui_template_general_layout.css";
@@ -574,52 +568,6 @@ export function mountLaborDetail(
opt.onChanged(dirty());
});
/** 서고 구분 · 상세구분(51-12 · 134-5) — 구조물군 › 종류 + 공통(`precomboClasses`)에서 고름 ·
* 구분 바꾸면 상세구분 = 그 첫 줄(공통) · 빈 값 = 미분류 */
const meta = cur as { 구분?: string | null; 상세구분?: string | null };
const libMeta = (): HTMLElement[] => {
const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit });
const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit });
sub.root.dataset.laborField = "구분";
detail.root.dataset.laborField = "상세구분";
let subs: { name: string; details: string[] }[] = [];
// 목록에 없는 지금 값(빈 것 = 「—」 · 옛 문서)도 선택지로 둠
const opts = (names: string[], now: string | null | undefined) =>
[...new Set([now ?? "", ...names])]
.filter((v, i) => v || i === 0)
.map((v) => ({ value: v, text: v || "—" }));
const fill = (): void => {
sub.setOptions(
opts(
subs.map((s) => s.name),
meta.구분,
),
meta.구분 ?? "",
);
const details = subs.find((s) => s.name === meta.구분)?.details ?? [];
detail.setOptions(opts(details, meta.상세구분), meta.상세구분 ?? "");
};
sub.select.addEventListener("change", () => {
meta.구분 = sub.select.value;
meta.상세구분 = subs.find((s) => s.name === meta.구분)?.details[0] ?? null;
fill();
opt.onChanged(dirty());
});
detail.select.addEventListener("change", () => {
meta.상세구분 = detail.select.value;
opt.onChanged(dirty());
});
fill();
void fetchStructureDef().then(
(def) => {
subs = precomboClasses(def);
fill();
},
() => undefined,
);
return [sub.root, detail.root];
};
const editor = (body: HTMLElement): HTMLElement =>
side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body;
host.replaceChildren(
@@ -637,7 +585,9 @@ export function mountLaborDetail(
el("div", {
className: "m01lab__info",
children: [
...(lib ? [nameField.root, ...libMeta()] : []),
...(lib
? [nameField.root, ...libMeta(cur, edit, () => opt.onChanged(dirty()))]
: []),
unit.root,
origin.root,
],
@@ -0,0 +1,56 @@
/* =============================================================================
* M02_MasterTemplete_Labor_LibMeta.ts
* 서고 모드(일위대가 · 사전조합) 정보 칸 구분 · 상세구분 고르기(51-12 · 134-5 · `Labor_Detail` 에서 뗌 · 717-3)
* 고침 = 문서 `구분` · `상세구분` 바로(화면 캐시) · 바꾼 뒤 `changed()`.
* ========================================================================== */
import { createSelectField } from "@ui/ui_template_elements";
import { fetchStructureDef } from "./StructOptions/M02_SO_Api";
import { precomboClasses } from "./M02_MasterTemplete_UI_Side_Precombo";
import { tc } from "./M02_MasterTemplete_Labor_Text";
/** 서고 구분 · 상세구분(51-12 · 134-5) — 구조물군 › 종류 + 공통(`precomboClasses`)에서 고름 ·
* 구분 바꾸면 상세구분 = 그 첫 줄(공통) · 빈 값 = 미분류 */
export function libMeta(doc: unknown, edit: boolean, changed: () => void): HTMLElement[] {
const meta = doc as { 구분?: string | null; 상세구분?: string | null };
const sub = createSelectField({ options: [], label: tc("Sub"), disabled: !edit });
const detail = createSelectField({ options: [], label: tc("Detail"), disabled: !edit });
sub.root.dataset.laborField = "구분";
detail.root.dataset.laborField = "상세구분";
let subs: { name: string; details: string[] }[] = [];
// 목록에 없는 지금 값(빈 것 = 「—」 · 옛 문서)도 선택지로 둠
const opts = (names: string[], now: string | null | undefined) =>
[...new Set([now ?? "", ...names])]
.filter((v, i) => v || i === 0)
.map((v) => ({ value: v, text: v || "—" }));
const fill = (): void => {
sub.setOptions(
opts(
subs.map((s) => s.name),
meta.구분,
),
meta.구분 ?? "",
);
const details = subs.find((s) => s.name === meta.구분)?.details ?? [];
detail.setOptions(opts(details, meta.상세구분), meta.상세구분 ?? "");
};
sub.select.addEventListener("change", () => {
meta.구분 = sub.select.value;
meta.상세구분 = subs.find((s) => s.name === meta.구분)?.details[0] ?? null;
fill();
changed();
});
detail.select.addEventListener("change", () => {
meta.상세구분 = detail.select.value;
changed();
});
fill();
void fetchStructureDef().then(
(def) => {
subs = precomboClasses(def);
fill();
},
() => undefined,
);
return [sub.root, detail.root];
}
@@ -0,0 +1,127 @@
/* =============================================================================
* M02_MasterTemplete_Labor_MatTable.ts
* 일위대가 자재목록 표(43-3 · `Labor_Rows` 에서 뗌 · 717-3) — 상세 · 보기 갈래가 같이 씀.
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액.
* 금액은 calc 답(없으면 「…」) · 줄 누름 · [삭제]는 `on` 이 있을 때만.
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type {
LaborCalc,
LaborCalcLine,
LaborCalcMat,
LaborMat,
} from "./M02_MasterTemplete_Labor_Api";
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
import {
moneyCell,
PRICE_SLOTS,
qtyCell,
table,
titled,
type RowHandlers,
} from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
export interface MatTableData {
mats: LaborMat[];
/** 로직 자재 줄(103-1 · `Labor_LogicMats`) — 사용자 자재 앞 · 같은 칸 */
lead?: HTMLElement[];
calc: LaborCalc | null;
fail?: string | null;
cells?: ReadonlyMap<string, string>;
}
/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금
* 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) ·
* 보기 갈래는 `on` 없이 */
export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HTMLElement {
const wait = !data.calc && !data.fail;
const remove = on?.remove;
const show = (v: number | null | undefined): string =>
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
const trs = data.mats.map((mat, i) => {
const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드);
// 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1
const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine;
const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => {
const base = !!line?.기준칸 && line.기준칸 === slot;
return el("td", {
className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`,
text: show(line?.가격?.[slot]),
attrs: {
"data-label": slot,
"data-price-slot": slot,
...(base ? { title: tc("Labor_BasePrice") } : {}),
},
});
};
const tr = el("tr", {
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
attrs: { "data-labor-mat": mat.코드 },
children: [
el("td", { className: "m02-labrow__code", text: mat.코드 }),
titled(
el("td", {
className: "m02-labrow__name",
children: [
el("strong", { text: mat.이름 ?? "" }),
...(line?.까닭
? [el("small", { className: "ui-text ui-text--error", text: line.까닭 })]
: []),
],
}),
),
el("td", {
className: "m02-labrow__spec",
text: mat.규격 ?? "",
attrs: { title: mat.규격 ?? "" },
}),
qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null),
el("td", {
className: "m02-labrow__unit",
text: mat.단위 ?? "",
attrs: { "data-label": tc("Labor_Unit") },
}),
...markCells(mat, on?.mark ? (k, v) => on.mark?.(i, k, v) : undefined),
...PRICE_SLOTS.map(priceCell),
moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")),
...(remove
? [
el("td", {
className: "m02-labrow__move",
children: [
createButton({
label: tc("Remove"),
variant: "danger",
onClick: (e) => {
e.stopPropagation();
remove(i);
},
}),
],
}),
]
: []),
],
});
if (on) tr.addEventListener("click", () => on.open(i));
return tr;
});
const heads = [
tc("Labor_Code"),
tc("Col_Name"),
tc("Labor_Spec"),
tc("Labor_QtyCol"),
tc("Labor_Unit"),
...markHeads(),
...PRICE_SLOTS,
`${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`,
...(remove ? [tc("Col_Move")] : []),
];
const lead = data.lead ?? [];
return data.mats.length || lead.length
? table(heads, [...lead, ...trs], [])
: el("p", { className: "ui-text ui-text--muted", text: tc("Labor_NoMat") });
}
@@ -5,7 +5,7 @@
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) ·
* 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 구분 ~ 별칭에 걸친 한 줄(나머지 칸은 위 줄과 두 줄 병합 · 113-1) ·
* 맨 아래 합계 줄(전체 합계 강조).
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
* 자재목록 표 = `Labor_MatTable`(717-3 나눔).
* 금액은 calc 답(없으면 「…」).
* 줄 누름 · 줄 단추는 `on` 이 있을 때만(보기 갈래 = 없음) — 순서 칸 2행(51-14 · 위 [위로] [아래로] / 아래 [옵션] 또는 [수정] · [삭제] ·
* 묶음 `ui-btn-group` · 순서 없는 카드 갈래 = 아래 줄만) · 일위조합 줄 = [수정](`ref` · 서고 원본 · 옛 단가승격 참조 줄 = 단추 없음) ·
@@ -20,14 +20,11 @@ import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type {
LaborCalc,
LaborCalcLine,
LaborCalcMat,
LaborCost,
LaborMat,
LaborRow,
MatMarkKey,
} from "./M02_MasterTemplete_Labor_Api";
import { lineId } from "./M02_MasterTemplete_Labor_Calc";
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "@ui/ui_template_general_layout.css";
@@ -124,7 +121,7 @@ export function qtyShort(
/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「수기 5 ㎥」 · 「12.5(B6)」 · 「—」) ·
* `open` 이 있으면 단추(줄 누름과 따로) */
function qtyCell(
export function qtyCell(
line: LaborCalcLine | undefined,
cells: ReadonlyMap<string, string> | undefined,
open: (() => void) | null,
@@ -180,12 +177,12 @@ export function moneyCell(
}
/** 이름 칸 글마다 title = 전체 글(한 줄 말줄임 · 45-7) */
const titled = (td: HTMLElement): HTMLElement => {
export const titled = (td: HTMLElement): HTMLElement => {
for (const c of td.children) c.setAttribute("title", c.textContent ?? "");
return td;
};
const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement =>
export const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap",
children: [
@@ -565,107 +562,5 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
return out;
}
export interface MatTableData {
mats: LaborMat[];
/** 로직 자재 줄(103-1 · `Labor_LogicMats`) — 사용자 자재 앞 · 같은 칸 */
lead?: HTMLElement[];
calc: LaborCalc | null;
fail?: string | null;
cells?: ReadonlyMap<string, string>;
}
/** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */
export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const;
/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금
* 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) ·
* 보기 갈래는 `on` 없이 */
export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HTMLElement {
const wait = !data.calc && !data.fail;
const remove = on?.remove;
const show = (v: number | null | undefined): string =>
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
const trs = data.mats.map((mat, i) => {
const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드);
// 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1
const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine;
const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => {
const base = !!line?.기준칸 && line.기준칸 === slot;
return el("td", {
className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`,
text: show(line?.가격?.[slot]),
attrs: {
"data-label": slot,
"data-price-slot": slot,
...(base ? { title: tc("Labor_BasePrice") } : {}),
},
});
};
const tr = el("tr", {
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
attrs: { "data-labor-mat": mat.코드 },
children: [
el("td", { className: "m02-labrow__code", text: mat.코드 }),
titled(
el("td", {
className: "m02-labrow__name",
children: [
el("strong", { text: mat.이름 ?? "" }),
...(line?.까닭
? [el("small", { className: "ui-text ui-text--error", text: line.까닭 })]
: []),
],
}),
),
el("td", {
className: "m02-labrow__spec",
text: mat.규격 ?? "",
attrs: { title: mat.규격 ?? "" },
}),
qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null),
el("td", {
className: "m02-labrow__unit",
text: mat.단위 ?? "",
attrs: { "data-label": tc("Labor_Unit") },
}),
...markCells(mat, on?.mark ? (k, v) => on.mark?.(i, k, v) : undefined),
...PRICE_SLOTS.map(priceCell),
moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")),
...(remove
? [
el("td", {
className: "m02-labrow__move",
children: [
createButton({
label: tc("Remove"),
variant: "danger",
onClick: (e) => {
e.stopPropagation();
remove(i);
},
}),
],
}),
]
: []),
],
});
if (on) tr.addEventListener("click", () => on.open(i));
return tr;
});
const heads = [
tc("Labor_Code"),
tc("Col_Name"),
tc("Labor_Spec"),
tc("Labor_QtyCol"),
tc("Labor_Unit"),
...markHeads(),
...PRICE_SLOTS,
`${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`,
...(remove ? [tc("Col_Move")] : []),
];
const lead = data.lead ?? [];
return data.mats.length || lead.length
? table(heads, [...lead, ...trs], [])
: el("p", { className: "ui-text ui-text--muted", text: tc("Labor_NoMat") });
}
@@ -21,7 +21,8 @@ import {
} from "./M02_MasterTemplete_Labor_Api";
import { lineId, settle } from "./M02_MasterTemplete_Labor_Calc";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
import { logicTable } from "./M02_MasterTemplete_Labor_Rows";
import { matTable } from "./M02_MasterTemplete_Labor_MatTable";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import "./M02_MasterTemplete_Labor.css";
@@ -0,0 +1,93 @@
# 553 결과 — 라이다 지형: 외부 무료 도구 시험 비교
시험만 · 앱 코드 · storage · git 손대지 않음. 자료 = 용화.las(4,900.7 만 점 · 업체 분류 1 · 2) · 검사점 = 업체 지면점 20 %(22.8 만 · 씨앗 553 · 비교 범위 안 20.2 만) · 모든 방법을 `dtm_classification.npz` 의 x · y(1 m)에 맞춰 잼. 차 = 검사점 − DEM(+ 이면 DEM 이 낮음).
## ① 결론 다섯 줄
1. **평활 뒤에도 차이가 그대로 남음.** 지금 RMS 0.77 m → 평활 0.75 m(2 % 줄어듦) · DEM 이 0.60 m 낮은 치우침은 평활 전후 같음. 같은 업체 지면점을 TIN 으로만 지으면 0.12 m(약 6.5 배 나음).
2. 사용자 말(평활 · 평균에서 조정)은 **잔요철엔 일부 맞음** — 급경사 빨강 32 % → 27 % · 기울기 p90 120 → 107 %. 다만 TIN(빨강 16 % · p90 89 %)에는 못 미치고, **높이 치우침 · 깎기 비탈 1 m 넘는 오차는 평활로 안 풀림**(그림 숲 가장자리).
3. 흠의 주인은 **지면 가르기가 아니라 격자 짓기**(칸 최저점 · 반 칸 자리 · 가까운 값 메움). 이 사업 유효 칸의 49 % 가 지면점 없는 메운 칸.
4. 원자료로 지면을 다시 가르는 도구 가운데 이 숲(숲 98 % · 기울기 중앙 70 %)에 쓸 만한 것은 **PDAL SMRF(0.27 m)** · **WhiteboxTools 경사 필터(0.26 m)** 둘. CSF(PDAL · 원 라이브러리)와 **Whitebox Workflows NG 개선 필터는 급경사에서 실패**(절반 빔 · 또는 나무 위를 땅으로 잡아 RMS 3.7~4.4 m) — 553-B 의 「버금 = WbW NG」는 이 자료로는 맞지 않음.
5. **권함** — 업체 분류가 있는 LAS: 지금 지면 가르기 그대로 두고 **격자만 TIN** 으로(앱이 이미 가진 WhiteboxTools `LidarTINGridding` · 66 만 점 0.8 초 · 새 의존성 없음). 분류 없는 LAS: PDAL SMRF + TIN(값 맞추기 시험 뒤).
## ② 수치 표
방법 — A 앱 저장 파일 그대로 · B PDAL 2.10.2 · C Whitebox · D CSF 원 라이브러리. TIN 은 모두 1 m · 긴 변 50 m 끊기. 「t」= 급경사용 값 덧 시험.
### 높이 오차(검사점 대비 · m) · 평활 전 → 후
| 방법 | RMS | 평균 차 | 95 % 절대 오차 | 검사점 덮임 % | 유효 범위 안 빈칸 % |
| --- | --- | --- | --- | --- | --- |
| A0 지금 업체 분류(A1 = 평활본) | 0.767 → 0.751 | +0.601 → +0.602 | 1.371 → 1.337 | 97.8 | 0 (지면점 없어 가까운 값 메움 49.4) |
| A2 지금 CSF(A3 = 평활본) | 0.711 → 0.687 | +0.542 → +0.543 | 1.286 → 1.228 | 97.6 | 0 (메움 53.7) |
| **B1 업체 분류 + PDAL TIN** | **0.117 → 0.131** | −0.008 → −0.009 | 0.232 → 0.264 | 97.8 | 0.02 |
| B1w 업체 분류 + WBT TIN | 0.117 → 0.131 | −0.007 → −0.009 | 0.232 → 0.264 | 97.8 | 0.02 |
| B2 PDAL SMRF(기본값) | 0.265 → 0.272 | −0.108 → −0.121 | 0.492 → 0.513 | 97.8 | 0.03 |
| B3 PDAL CSF(기본값) | 0.944 → 0.932 | +0.304 → +0.301 | 1.695 → 1.686 | 50.6 | 47.4 |
| B3t PDAL CSF(무른 천 · 0.5 m) | 0.684 → 0.687 | −0.282 → −0.303 | 1.544 → 1.534 | 97.6 | 0.2 |
| C1 WbW NG 개선 필터(기본값 · 타일 2×2) | 4.416 → 4.532 | −0.934 → −1.020 | 7.86 → 7.81 | 47.8 | 51.6 |
| C1t WbW NG(경사 45° · 0.3 m) | 3.727 → 3.812 | −0.983 → −1.060 | 4.03 → 4.07 | 71.6 | 30.0 |
| C2 WBT 경사 필터(기본값) | 0.257 → 0.264 | +0.056 → +0.047 | 0.439 → 0.471 | 97.8 | 0.02 |
| D1 CSF 원 라이브러리(앱 CSF 와 같은 값) | 0.930 → 0.948 | +0.233 → +0.233 | 1.942 → 1.964 | 20.6 | 80.7 |
### 지형 꼴 · 분류 · 비용
| 방법 | 빨강 %(83.9 % 이상) 전 → 후 | 기울기 p50 / p90 % 전 → 후 | 업체 지면 대비 맞음 · 놓침 · 잘못 넣음 % | 시간 s | 최대 메모리 GB |
| --- | --- | --- | --- | --- | --- |
| A0 | 32.0 → 26.8 | 69 / 120 → 68 / 107 | 100 · 0 · 0 | 앱 WF1 239(기록) | 기록 없음 |
| A2 | 35.2 → 29.4 | 70 / 129 → 69 / 113 | 55.1 · 44.9 · 12.2 | (같은 WF1 · CSF 4.1) | 기록 없음 |
| B1 | 16.4 → 14.8 | 70 / 89 → 69 / 88 | 100 · 0 · 0 | 20.3 | 3.6 |
| B1w | 16.3 → 14.8 | 70 / 89 → 69 / 88 | 100 · 0 · 0 | 4.1 | 1.8 |
| B2 | 22.5 → 20.1 | 70 / 98 → 69 / 95 | 94.6 · 5.4 · 53.4 | 57.8 | 6.7 |
| B3 | 2.7 → 2.2 ※ | 63 / 76 → 63 / 75 | 21.9 · 78.1 · 11.1 | 101.9 | 7.9 |
| B3t | 34.0 → 31.3 | 75 / 117 → 74 / 112 | 74.3 · 25.7 · 77.9 | 273.5 | 8.6 |
| C1 | 38.8 → 36.6 | 76 / 211 → 75 / 190 | 12.7 · 87.3 · 15.0 | 173.3 | 20.1 |
| C1t | 52.1 → 49.5 | 86 / 234 → 83 / 210 | 41.4 · 58.6 · 51.1 | 176.5 | 19.8 |
| C2 | 27.5 → 26.1 | 72 / 130 → 72 / 122 | 61.3 · 38.7 · 9.2 | 453.4 | 5.4 |
| D1 | 2.2 → 1.7 ※ | 57 / 71 → 56 / 70 | 8.8 · 91.2 · 7.4 | 25.1 | 6.0 |
- ※ B3 · D1 은 급경사 절반 이상이 비어 남은 완경사만 잰 값 — 좋은 값 아님.
- 시간 = LAS 읽기 ~ 1 m 격자 GeoTIFF 쓰기까지(분류 LAS 쓰기 포함). 메모리 = 프로세스 나무 합.
- C1 은 통째(4,900 만 점)로 돌리면 16 GB 넘어 멈춤 → 2×2 타일(겹침 30 m)로 돌림 · 메모리 20 GB 중 약 5 GB 는 타일 나누는 laspy 몫.
- 「잘못 넣음」은 업체가 지면으로 안 본 점 — 진짜 오류인지 업체 누락인지 가르지 못함(SMRF 는 지도로 보면 덤불 · 그루터기 자리 +1 m 혹이 많음).
- 앱 평활 재현 확인 — 이 시험의 평활(가우스 σ 0.5 m + 쌍삼차 → 1.078 m 칸)로 A0 를 다시 평활하면 저장된 A1 과 최대 0.1 mm 차.
- 잘 지은 DEM(B1 · B2 · C2)에는 앱 평활이 오히려 오차를 조금 늘림(0.117 → 0.131).
## ③ 그림(`figs/` · 100 m × 100 m · 각 구역 두 장)
| 파일 | 보이는 것 |
| --- | --- |
| `steep_1_음영기복.png` · `steep_2_차이.png` | 가장 가파른 곳(평균 86 %). A0 는 1 m 계단 · 줄무늬가 음영 전체에 깔림 · A1 은 무늬가 흐려질 뿐 남음. B1 은 매끈한 비탈 · 골. 차이 지도에서 A0 · A1 은 거의 전면 파랑(B1 보다 0.5~1 m 낮음) · RMS 1.09 / 0.98 m. SMRF 는 군데군데 빨강 혹(덤불) · 나머지는 흰색(같음). |
| `edge_1_음영기복.png` · `edge_2_차이.png` | 빈터가 가장 많은 곳 — 임도 노면 · 깎기 비탈이 지나감. A0 · A1 은 **깎기 비탈 띠를 따라 1 m 넘게 낮음**(진한 파랑 띠) — 평활 뒤에도 그대로. SMRF 는 노면 · 비탈을 잘 따라감(RMS 0.45 m) · WBT 경사 필터는 비탈 끝을 깎아 먹음. |
| `flat_1_음영기복.png` · `flat_2_차이.png` | 가장 완만한 곳(그래도 평균 57 % · 이 사업엔 평지가 없음). A0 줄무늬 · 전면 파랑, A1 은 무늬만 흐려짐(RMS 0.66 → 0.54 m). SMRF 0.37 m · WBT 0.96 m(나무 둘레 혹). |
## ④ 설치한 것(모두 `lidar_lab/` 안 · git 무시 · 시스템 · PATH · 레지스트리 손대지 않음)
| 것 | 판 | 크기 | 걸린 시간 |
| --- | --- | --- | --- |
| micromamba(단일 실행 파일) | latest(26-11) | 4.7 MB 받음 | 수 초 |
| PDAL 환경 `env_pdal`(pdal · python-pdal · gdal · python 3.12 · conda-forge) | PDAL 2.10.2 | 4.0 GB + 받은 꾸러미 보관 1.3 GB(`mamba_root` · 지워도 됨) | 약 6 분 |
| 파이썬 venv(데스크탑 3.12) — whitebox-workflows · cloth-simulation-filter · laspy[lazrs] · numpy · scipy · rasterio · matplotlib · psutil | WbW 2.0.6 · CSF 1.1.7 · laspy 2.7.0 · lazrs 0.8.2 · rasterio 1.5.2 | 428 MB | 약 2 분 |
| WhiteboxTools Open Core | 2.4.0 | 받음 25 MB · 풀어 99 MB | 수 초 |
| QGIS | 설치 안 함(지면점 분류 = PDAL SMRF → B2 로 갈음) | — | — |
- 시험 자료 `data/`(검사점 표시 LAS 1.6 GB · 분류 표) 2.0 GB — 다시 돌릴 일 없으면 지워도 됨. 시험 스크립트 `scripts/` · 원 기록 `runs/`(방법마다 시간 · 메모리 json · 로그 · `metrics.json`).
## ⑤ 앱에 넣는다면(553-B ④ 와 맞춤) · 위험
| 길 | 이번 시험이 말하는 것 |
| --- | --- |
| **격자만 TIN 으로(553-A 경계 C · D)** — 업체 분류 LAS | 가장 큰 이득(0.77 → 0.12 m) · 새 도구 없이 지금 스택의 WhiteboxTools `LidarTINGridding`(MIT · 0.8 초)로 됨 · 553-B 길 A(Whitebox)와 같은 집안 |
| 553-B 길 B — PDAL 따로 실행 | 분류 없는 LAS 의 지면 가르기 = SMRF 가 가장 나음(0.27 m · 58 초) · 다만 환경 4 GB · 낮은 덤불 혹 → `slope` · `threshold` 값 시험 필요 |
| 553-B 길 A — WbW NG 개선 필터 | **이 숲에선 쓰지 않음**(RMS 3.7 m 넘음 · 4,900 만 점 통째 16 GB 넘어 멈춤) |
| 553-B 길 C — CSF 원 라이브러리 | **쓰지 않음** — 앱 자체 CSF(0.71 m)보다도 못함(급경사 80 % 빔) |
| WBT 경사 필터(Open Core) | 0.26 m 로 쓸 만하나 4,900 만 점 7.5 분 · 업체 지면 39 % 놓침 |
위험
- **WhiteboxTools GeoTIFF 반 칸 어긋남** — Open Core 가 쓴 TIFF 를 rasterio 로 그대로 읽으면 반 칸(0.5 m) 밀려 같은 B1 이 0.12 → 0.39 m 로 나빠짐(지금 앱의 칸 모서리 흠과 같은 꼴). 넣을 때 칸 자리 대조 시험 필수. WbW NG(geotiff-rs)는 어긋남 없음.
- **값이 바뀜** — DEM 이 평균 약 0.6 m 오르고 깎기 비탈 · 노면 꼴이 바뀜 → 기존 프로젝트의 종단 · 토량 · 급경사 칸 % 가 달라짐. 새 프로젝트부터 또는 고르는 칸으로(사용자 협의).
- 앱 평활은 좋은 DEM 엔 오히려 오차를 조금 늘림 → TIN 으로 바꾸면 평활 기본값도 다시 볼 거리.
- TIN 긴 변 끊기(이번 50 m) 값에 따라 큰 빈터가 빔 — 이번엔 유효 범위 안 0.02 %.
- 이 시험은 업체 분류를 정답으로 둠 — SMRF 의 「잘못 넣음」 53 % 중 진짜 지면 몫은 가르지 못함. 숲 · 급경사 한 사업 하나만 잼(평지 · 미분류 LAS 는 따로 시험 필요).
- 두 PC 판 차이 · 도구 판 고정(553-B ⑤ 그대로).
@@ -6,6 +6,8 @@
- `쓰는입력` = 그 줄 값(수량 식 · 요소 · 덧줄 식)에 쓰인 로직 입력 이름 — 중간값을 거슬러 감 ·
덧줄이 부르는 비목 합 · 줄 금액은 거스르지 않음(율 식에 직접 쓴 입력만).
- `묶음` · `장비` = 712-1 `master_bundle`(엔진 줄 덧칸 그대로).
- `펼침`(장비 줄만 · 714-1b) = 부른 기계경비 로직의 계산서 줄(조종원 · 주연료 · 잡재료비 · 기계손료 …) ·
시간당 비목 소계 · 계 · 부른 키 · 인자 — 셈 결과를 그대로 실음(환산 규칙을 여기 두지 않음).
- `기준단위` = 그 줄 수량 식이 찾는 표의 기준(「10㎡」 · 712-3 `master_basis.basis`) · 답 머리
`기준단위` = 로직 결과단위 분모(「10㎡」 · 여럿이면 「㎡ · ㎥」).
- `환산근거` = 표 기준배 ↔ 결과단위 배(「10㎡당 → 1㎡당 ÷10」) · 재료 품목 단위 ↔ 줄 단위
@@ -24,7 +26,7 @@ import master_formula as mf
import master_text as mtx
import master_unit as mu
KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거")
KEYS = ("쓰는입력", "묶음", "장비", "기준단위", "환산근거", "펼침")
_HOLE = re.compile(r"\{([^{}]+)\}")
_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError)
@@ -117,10 +119,39 @@ def _fields(master, row, exprs, env, per, line: dict, bundle: tuple[str, bool] |
return out
def annotate(master, key: str, given: dict, answer: dict) -> dict:
def _spread(files: dict, master, item: dict, env: dict) -> dict:
"""장비 줄이 부른 기계경비 로직 한 장 — `{키, 이름, 단위, 입력, 줄, 소계, 계}` · 못 셈 = 까닭."""
node = mf.parse(item["요소"])
key = node[1]
try:
key = mf.fill(key, env) if "{" in key else key
given = {k: mf.evaluate(v, env, master, 1) for k, v in node[2]}
got = mtx.lines(files, key, given)
except (*_ERRORS, mf.EmptyInput) as e:
return {"키": key, "까닭": str(e) or type(e).__name__}
if not got.get("ok"):
return {"키": key, "입력": given, "까닭": got.get("reason")}
groups = got.get("groups") or []
return {
"키": key,
"이름": master.logic(key).get("이름") or "",
"단위": _per_text(mbs.result_per(master.logic(key).get("결과단위"))) or None,
"입력": given,
"줄": [
{"비목": g["비목"], **{k: one.get(k) for k in ("이름", "글", "금액")}}
for g in groups
for one in g["줄"]
],
"소계": {g["비목"]: g.get("소계") for g in groups},
"계": got.get("계"),
}
def annotate(files: dict, key: str, given: dict, answer: dict) -> dict:
"""`master_text.lines` 답에 줄마다 `KEYS` 덧칸 · 머리 `기준단위` — 못 셈이면 답 그대로."""
if not answer.get("ok") or "groups" not in answer:
return answer
master = mf.Master(files)
row = master.logic(key)
per = mbs.result_per(row.get("결과단위"))
answer["기준단위"] = _per_text(per) or None
@@ -148,6 +179,8 @@ def annotate(master, key: str, given: dict, answer: dict) -> dict:
parts = [item.get("비목")]
exprs = [item.get("수량"), item.get("요소")]
got = _fields(master, row, exprs, env, per, line, mb.bundle(master, item, env))
if got.get("장비"):
got["펼침"] = _spread(files, master, item, env)
for part in parts:
target = next(slots.get(part, iter(())), None)
if target is not None:
+14 -1
View File
@@ -14,6 +14,7 @@ from M01_MasterData import M01_MasterData_Store_Sheet as sheet # noqa: E402
from M02_MasterTemplete import M02_Logic_Standard as standard # noqa: E402
msl = sheet.msl
num = standard.labor_inputs._num # Decimal → JSON 수(두 길 같은 규칙)
def _text(key: str) -> dict:
@@ -36,6 +37,18 @@ def test_line_keys_and_bundle():
assert any(line["묶음"] == "인력" for line in lines)
def test_machine_spread():
"""714-1b — 장비 줄 「펼침」 = 부른 기계경비 로직 계산서(조종원 · 연료 · 손료) · 시간당 계."""
lines = _lines(_text("GF000103"))
spread = next(line for line in lines if line.get("장비"))["펼침"]
assert spread["키"].startswith("GC0002") and spread["단위"] == "시간"
names = " ".join(one["이름"] for one in spread["줄"])
assert "조종원" in names and "손료" in names
sums = [v for v in spread["소계"].values() if v is not None]
assert spread["계"] == sum(sums) and spread["계"] > 0
assert not any(line.get("펼침") for line in lines if not line.get("장비"))
def test_uses_follow_middle_values():
"""중간값을 거슬러 입력까지 — 돌쌓기 석공 품은 높이 · 쌓기 · 돌 입력을 씀."""
lines = _lines(_text("GF000219"))
@@ -59,4 +72,4 @@ def test_two_routes_same_shape():
for a, b in zip(m01, m02):
assert set(b) == {"이름", "글", "금액", *msl.KEYS}
assert a["이름"] == b["이름"]
assert [a.get(k) for k in msl.KEYS] == [b.get(k) for k in msl.KEYS]
assert num([a.get(k) for k in msl.KEYS]) == [b.get(k) for k in msl.KEYS]
+38 -1
View File
@@ -36,7 +36,7 @@ def test_부품_상자만():
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_식_글():
s = _text(SHEET)
pure = s[s.index("export const sheetFormula") : s.index("/** 변수 칸 하나")]
pure = s[s.index("/* ── 순수 셈 ── */") : s.index("/* ── 순수 셈 끝 ── */")]
body = 'console.log(JSON.stringify([sheetFormula("268,908 * 0.12 = 32,269"), sheetFormula("59,020 * 1 * 8 / 165 = 2,862"), sheetFormula("")]))'
out = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
@@ -45,3 +45,40 @@ def test_식_글():
) # fmt: skip
assert out.returncode == 0, out.stderr
assert json.loads(out.stdout) == ["268,908 × 0.12", "59,020 × 1 × 8 / 165", ""]
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_묶음_장비_한_줄():
s = _text(SHEET)
pure = s[s.index("/* ── 순수 셈 ── */") : s.index("/* ── 순수 셈 끝 ── */")]
body = """
const eq = (n, a, 쓰는입력 = []) => ({ 이름: n, 글: "", 금액: a, 묶음: "경비", 장비: true, 쓰는입력, 기준단위: "일" });
const groups = [
{ 비목: "노무비", 소계: 0, 줄: [
{ 이름: "포설공", 글: "", 금액: 10, 묶음: "인력", 쓰는입력: [] },
eq("굴착기", 100, ["지역"]), eq("굴착기", 7),
{ 이름: "보통인부", 글: "", 금액: 5, 묶음: "인력", 쓰는입력: ["두께"], 기준단위: "10㎡", 환산근거: "10㎡당 → 1㎡당 ÷10" } ] },
{ 비목: "재료비", 소계: 0, 줄: [ eq("굴착기", 20), eq("굴착기", 2), { 이름: "모래", 글: "", 금액: 3 } ] },
{ 비목: "경비", 소계: 0, 줄: [ eq("굴착기", 30), eq("굴착기", null), { 이름: "공구손료", 글: "", 금액: 1, 묶음: "경비" } ] },
];
const got = bundleLines(groups).map((b) => ({ 묶음: b.묶음, 소계: b.소계, 쓰는입력: b.쓰는입력, 기준단위: b.기준단위, 환산근거: b.환산근거,
줄: b.줄.map((l) => "몫" in l ? [l.이름, l.금액, l.몫.map((x) => x.비목)] : l.이름) }));
console.log(JSON.stringify(got));
"""
out = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", pure + chr(10) + body],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert out.returncode == 0, out.stderr
got = json.loads(out.stdout)
# 차례 = 인력 · 재료 · 경비 · 같은 이름 장비 둘은 k 번째끼리 한 줄(노 · 재 · 경 몫) · 비목 노무비 장비는 경비(장비) 묶음
assert [b["묶음"] for b in got] == ["인력", "재료", "경비"]
assert got[0]["줄"] == ["포설공", "보통인부"] and got[0]["소계"] == 15
assert got[0]["쓰는입력"] == ["두께"] and got[0]["기준단위"] == ["10㎡"]
assert got[0]["환산근거"] == ["10㎡당 → 1㎡당 ÷10"]
assert got[1]["줄"] == ["모래"]
eq = got[2]
assert eq["줄"][0] == ["굴착기", 150, ["노무비", "재료비", "경비"]]
assert eq["줄"][1] == ["굴착기", 9, ["노무비", "재료비", "경비"]]
assert eq["줄"][2] == "공구손료" and eq["소계"] == 160 and eq["쓰는입력"] == ["지역"]
+1 -3
View File
@@ -361,9 +361,7 @@ def test_좌측_판_자리와_권한():
def test_로직_추가_한_곳과_파일_규칙():
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
assert (
len(detail.splitlines()) == 700
) # 줄 수 그대로(361-2 전국 자재 부르는 줄 둘 · 553-5 보조 글 부품 -2 · 704-3 격자 +7)
assert len(detail.splitlines()) <= 700 # 700 줄 넘지 않음(717-3 나눔으로 줄어듦)
assert "withHopyo(library)," in detail
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
assert "more?: () => Promise<PickExtra[]>;" in pick and "...later" in pick
+1 -1
View File
@@ -121,7 +121,7 @@ def test_화면_칸_부품_사용자_줄_로직_줄_같은_칸():
marks = _src("M02_MasterTemplete_Labor_MatMarks.ts")
assert '"—"' not in marks # 「—」 항목 없음(162-1)
assert "e.stopPropagation()" in marks # 줄 누름과 따로
rows = _src("M02_MasterTemplete_Labor_Rows.ts")
rows = _src("M02_MasterTemplete_Labor_MatTable.ts") # 717-3 자재목록 표 나눔
assert "...markCells(mat, on?.mark" in rows and "...markHeads()," in rows
assert "...markCells(" in _src("M02_MasterTemplete_Labor_LogicMats.ts")
detail = _src("M02_MasterTemplete_Labor_Detail.ts")
+1 -1
View File
@@ -62,7 +62,7 @@ def test_옛_품셈_분류는_미분류_새_값은_그대로():
def test_화면_원천은_구조물_정의_새_문서는_미분류():
detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8")
detail = (M02 / "M02_MasterTemplete_Labor_LibMeta.ts").read_text("utf-8") # 717-3 나눔
assert "subs = precomboClasses(def);" in detail and "fetchLibLogicSubs" not in detail
assert '[...new Set([now ?? "", ...names])]' in detail # 지금 값도 선택지 그대로
lib = (M02 / "M02_MasterTemplete_Library_List.ts").read_text("utf-8")