diff --git a/A00_Common/calc_sheet.css b/A00_Common/calc_sheet.css new file mode 100644 index 000000000..9ddd4e44a --- /dev/null +++ b/A00_Common/calc_sheet.css @@ -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); +} diff --git a/A00_Common/calc_sheet.ts b/A00_Common/calc_sheet.ts index b8422bdc3..d5c3bad9a 100644 --- a/A00_Common/calc_sheet.ts +++ b/A00_Common/calc_sheet.ts @@ -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; - 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 = { 노무비: "인력", 재료비: "재료", 경비: "경비" }; +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(); + const merged = new Map(); + 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(); + 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; +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((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, + 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; } diff --git a/M01_MasterData/M01_MasterData_Store_Sheet.py b/M01_MasterData/M01_MasterData_Store_Sheet.py index 7d893f62b..57181e401 100644 --- a/M01_MasterData/M01_MasterData_Store_Sheet.py +++ b/M01_MasterData/M01_MasterData_Store_Sheet.py @@ -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 diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts index adef8655f..dc8dd6cf0 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Est.ts @@ -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; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index d14afae32..4cf179ae1 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -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 [ diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 6890e0432..7586b1b50 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -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 = diff --git a/M02_MasterTemplete/M02_Logic_Standard.py b/M02_MasterTemplete/M02_Logic_Standard.py index fd9959833..5ba2fc45a 100644 --- a/M02_MasterTemplete/M02_Logic_Standard.py +++ b/M02_MasterTemplete/M02_Logic_Standard.py @@ -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) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index c7286f964..d4707fb31 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -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, ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_LibMeta.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_LibMeta.ts new file mode 100644 index 000000000..d65966386 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_LibMeta.ts @@ -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]; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_MatTable.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_MatTable.ts new file mode 100644 index 000000000..e4f785c45 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_MatTable.ts @@ -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; +} + +/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금 + * 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) · + * 보기 갈래는 `on` 없이 */ +export function matTable(data: MatTableData, on?: Omit): 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") }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 84f9ca0fd..d3bc1ad1c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -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 | 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; -} - /** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */ export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const; - -/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금 - * 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) · - * 보기 갈래는 `on` 없이 */ -export function matTable(data: MatTableData, on?: Omit): 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") }); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts index 0fdbd43a0..aefc57fb2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts @@ -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"; diff --git a/docs/raw/요구_1101/_결과_553_라이다_외부도구시험.md b/docs/raw/요구_1101/_결과_553_라이다_외부도구시험.md new file mode 100644 index 000000000..05d9d3400 --- /dev/null +++ b/docs/raw/요구_1101/_결과_553_라이다_외부도구시험.md @@ -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 ⑤ 그대로). diff --git a/resources/master_data/scripts/master_sheet_line.py b/resources/master_data/scripts/master_sheet_line.py index 1fe93a425..d567b5554 100644 --- a/resources/master_data/scripts/master_sheet_line.py +++ b/resources/master_data/scripts/master_sheet_line.py @@ -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: diff --git a/resources/tester/test_714_1_sheet_line.py b/resources/tester/test_714_1_sheet_line.py index f2266a8e5..a317f93e4 100644 --- a/resources/tester/test_714_1_sheet_line.py +++ b/resources/tester/test_714_1_sheet_line.py @@ -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] diff --git a/resources/tester/test_714_2_calc_sheet.py b/resources/tester/test_714_2_calc_sheet.py index b2c8772d5..5b2c4418e 100644 --- a/resources/tester/test_714_2_calc_sheet.py +++ b/resources/tester/test_714_2_calc_sheet.py @@ -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["쓰는입력"] == ["지역"] diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 909b26079..dfd6fc510 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -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;" in pick and "...later" in pick diff --git a/resources/tester/test_m02_labor_mat_marks.py b/resources/tester/test_m02_labor_mat_marks.py index e788663a8..556c47686 100644 --- a/resources/tester/test_m02_labor_mat_marks.py +++ b/resources/tester/test_m02_labor_mat_marks.py @@ -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") diff --git a/resources/tester/test_m02_precombo_class.py b/resources/tester/test_m02_precombo_class.py index 73eb21215..b80d9bc82 100644 --- a/resources/tester/test_m02_precombo_class.py +++ b/resources/tester/test_m02_precombo_class.py @@ -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")