Files
Aislo/B08_Quantity/B08_Quantity_UI_StructureSheet_Formula.ts
T
eomsangdonandClaude Opus 5 475ad7875e feat(b08·b05·b09): 10-A 우리가 정한 SW 규칙을 화면에 드러냄 — 훑기 21건 중 안 보이거나 반만 보이던 13건 채움 + 9-21 제근 굴착기 크기 칸
- 문구 못박음 셋: ① 옹벽 높이 칸 밑 「기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음」(등록부 default_basis 를 모든 칸 밑 회색 한 줄로) · ② 일반관리비 「임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음」 · ③ 모르타르 배합 「산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)」
- B08: 막자갈 비고 「칸을 새로 두는 것은 B05 등록부 몫」 · 일위대가 머리 5단 한도 · 야면석 계수 칸 밑 「돌 종류는 그대로」 · 식 저장 전 상태 줄 「제원을 바꿔도 따라감」 · 양식 장 머리 「반올림은 m당 값에 걸고 수량 = m당 × 연장」
- 산출 조건 제근 굴착기 크기 칸(임목축적 등급 밑 · 「안 정함」 · 회색 제안 0.7㎥ + 근거 + [제안값 넣기] 누른 때만) — 936be972 소림 + 0.7 → 뿌리뽑기 55원/㎡ × 17,873.80㎡ = 983,057원(되돌림)
- 라이브러리 이름표: [내 라이브러리에 저장]·[발행] 창이 장 이름(종류 + 제원 요약)을 채워 묻고 고친 이름으로 저장 · 취소면 안 씀 · 비우면 양식 이름
- B05 계곡 통과 시설 폼 머리 「[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸은 그대로 둠」 · B09 폐기물처리비 자리 칸 밑 「법정경비 밑수에는 안 넣음」 · 내역서 「금액을 못 세운 줄」 첫 줄 「사유는 아래 단계가 낸 것을 그대로 옮김」
- 남은 하나(표 형태 66표 까닭 form_basis 화면)는 자원 축 파일을 거쳐야 해 브레인 물음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 19:44:18 +09:00

301 lines
12 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_StructureSheet_Formula.ts
* 구조물도 양식 장의 **식·반올림 칸 표** (PLAN 3장 ⑤ · 반올림 칸) — 700줄 제한으로 본 파일에서 뗌.
*
* ⚠ 칸을 고치면 **이 화면이 같은 풀이기(TS)로 즉시** 다시 풂(왕복 없음) · 값은 [식 저장] 때 서버가 다시 냄.
* ========================================================================== */
import {
evaluateSheet,
type FormulaRounding,
type FormulaSheet,
type RoundingMode,
} from "./B08_Quantity_Formula";
import type { StructureSheet, StructureSheetRow } from "./B08_Quantity_UI_StructureSheet";
export const DESTINATION_LABELS: Record<string, string> = {
earthwork: "토공집계",
material: "자재총괄",
unit_price: "일위대가",
reference: "보여주기",
haul_deduction: "운반 공제",
};
/**
* 반올림 갈래 — **엑셀 이름을 함께 보임**(명세 13장 대응표). ⚠ INT↔ROUNDDOWN · 위로↔ROUNDUP 은
* 양수에서 같고 **음수에서만 갈려** 시험을 빠져나감 — 이름만 보고 고르면 틀림.
*/
export const ROUNDING_LABELS: Record<string, string> = {
none: "안 함",
floor: "내림 — 엑셀 INT",
trunc: "버림 — 엑셀 ROUNDDOWN",
round: "사사오입 — 엑셀 ROUND",
ceil_away: "올림 — 엑셀 ROUNDUP",
ceil: "위로(+쪽) — 엑셀 없음",
round_half_even: "짝수 반올림 — 엑셀 없음",
};
export const ROUNDING_HINT =
"음수에서 갈림: -2.5 → INT -3 · ROUNDDOWN -2 · ROUNDUP -3 · 위로 -2 (양수는 INT=ROUNDDOWN)";
export type Rounding = { mode: string; digits: number };
/** 같은 반올림인지 — 「안 함」은 자리수와 무관하게 하나(서버 `rounding_key` 와 같은 뜻). */
export function roundingKey(rounding: Rounding | null | undefined): string {
const mode = rounding?.mode ?? "none";
return mode === "none" ? "none" : `${mode}:${Math.trunc(rounding?.digits ?? 0)}`;
}
/** 수량 칸 — 안 선 줄은 「안 섬」, 못 푼 줄은 「-」(0 으로 때우지 않음). */
export function amountText(row: StructureSheetRow): string {
if (row.skipped) return "안 섬";
return num(row.unit_amount, 3);
}
/** 비고 칸 — 까닭이 있으면 까닭이 먼저, 없으면 값의 출처와 반올림. */
export function noteText(row: StructureSheetRow): string {
if (row.skipped) return row.reason ?? "";
if (row.error) return `⚠ ${row.error}`;
// 고친 줄은 「사용자 식」 — 양식·전개와 한 단 더 갈림(브레인 챙길 것 ③).
const origin =
row.source === "user"
? "사용자 식"
: row.source === "library"
? "양식"
: row.basis_kind === "observed"
? "실무 관측"
: "치수 전개";
const mode = row.rounding?.mode;
return mode && mode !== "none"
? `${origin} · ${ROUNDING_LABELS[mode] ?? mode} ${row.rounding?.digits}자리`
: origin;
}
export type FormulaEditRow = { seq: number; formula: string | null; rounding: Rounding | null };
/**
* 양식 장의 원단위 수량표 — 식·반올림 칸을 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂.
* `onSave` 는 바뀐 줄의 **뜻한 상태 전부**(식·반올림)를 받음 — 양식과 같은 칸은 null. 값은 서버가 다시 냄.
*/
export function formulaTable(
sheet: StructureSheet,
onSave: (rows: FormulaEditRow[]) => Promise<string[]>,
onDirty: (dirty: boolean) => void,
): HTMLElement {
const body = sheet.formula_sheet as FormulaSheet;
const saved = new Map(
sheet.rows.map((row) => [row.no, `${row.formula ?? ""}|${roundingKey(row.rounding)}`]),
);
const defaults = new Map(
sheet.rows.map((row) => [
row.no,
{ formula: row.default_formula ?? "", rounding: row.default_rounding ?? null },
]),
);
const cells = new Map<
number,
{
amount: HTMLElement;
note: HTMLElement;
input: HTMLInputElement;
mode: HTMLSelectElement;
digits: HTMLInputElement;
}
>();
// 칸에서 읽은 한 줄 — 빈 식은 양식 식(되돌림).
const current = (seq: number): { formula: string; rounding: FormulaRounding } => {
const cell = cells.get(seq)!;
return {
formula: cell.input.value.trim() || defaults.get(seq)?.formula || "",
rounding: {
mode: cell.mode.value as RoundingMode,
digits: Math.trunc(Number(cell.digits.value) || 0),
},
};
};
const stateOf = (seq: number): string => {
const now = current(seq);
return `${now.formula}|${roundingKey(now.rounding)}`;
};
const isUser = (seq: number): boolean => {
const now = current(seq);
const base = defaults.get(seq);
return (
now.formula !== base?.formula || roundingKey(now.rounding) !== roundingKey(base?.rounding)
);
};
const wrap = el("div", "b08-grid");
const scroller = el("div", "b08-grid__scroll");
const grid = el("table", "b08-grid__table b08-grid__table--summary b08-sheet__rows");
const headRow = document.createElement("tr");
for (const label of ["공종", "규격", "산출 근거 · 식", "수량", "단위", "갈 곳", "비고"]) {
headRow.append(el("th", "", label));
}
const thead = document.createElement("thead");
thead.append(headRow);
const tbody = document.createElement("tbody");
const status = el("span", "b08-grid__caption");
const saveButton = el("button", "b08-spec__save", "식 저장");
saveButton.type = "button";
const discardButton = el("button", "b08-quantity__tab", "고친 것 버리기");
discardButton.type = "button";
// 칸 값으로 장을 다시 풀어 **모든 줄**을 고침 — 앞 줄을 고치면 뒷줄도 따라 바뀜.
const recompute = (): void => {
const results = evaluateSheet({
...body,
rows: body.rows.map((row) => (cells.has(row.seq) ? { ...row, ...current(row.seq) } : row)),
});
for (const result of results) {
const cell = cells.get(result.seq);
const row = sheet.rows.find((item) => item.no === result.seq);
if (!cell || !row) continue;
const local: StructureSheetRow = {
...row,
unit_amount: result.amount === null ? null : Number(result.amount),
skipped: result.skipped,
reason: result.reason ?? "",
error: result.error ?? "",
rounding: current(result.seq).rounding,
source: isUser(result.seq) ? "user" : "library",
};
cell.amount.textContent = amountText(local);
cell.note.textContent = noteText(local);
const changed = stateOf(result.seq) !== saved.get(result.seq);
cell.input.classList.toggle("is-changed", changed);
cell.mode.classList.toggle("is-changed", changed);
cell.digits.disabled = cell.mode.value === "none";
}
const dirty = [...cells.keys()].some((seq) => stateOf(seq) !== saved.get(seq));
saveButton.disabled = !dirty;
discardButton.disabled = !dirty;
status.textContent = dirty
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림 · 고친 식은 이 양식·프로젝트에 묶여 제원(높이 등)을 바꿔도 따라감)"
: "";
onDirty(dirty);
};
for (const row of sheet.rows) {
const tr = document.createElement("tr");
const basis = el("td", "", row.basis.replace(/\*\*/g, ""));
const line = el("div", "b08-sheet__formula");
const input = document.createElement("input");
input.type = "text";
input.value = row.formula ?? "";
input.title = `양식 식: ${row.default_formula ?? ""}`;
input.addEventListener("input", recompute);
// 반올림 — 고르개에 엑셀 이름을 함께 적음(명세 13장 대응표, 음수에서만 갈리는 함정).
const base = row.default_rounding ?? null;
const baseText = `${ROUNDING_LABELS[base?.mode ?? "none"]}${base && base.mode !== "none" ? ` ${base.digits}자리` : ""}`;
const mode = document.createElement("select");
mode.title = `${ROUNDING_HINT} · 양식: ${baseText}`;
for (const [value, label] of Object.entries(ROUNDING_LABELS)) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
mode.append(option);
}
mode.value = row.rounding?.mode ?? "none";
mode.addEventListener("change", recompute);
const digits = document.createElement("input");
digits.type = "number";
digits.min = "-6";
digits.max = "10";
digits.step = "1";
digits.value = String(row.rounding?.digits ?? 0);
digits.title = "자리수 — 2 는 소수 둘째 자리, -1 은 10의 자리";
digits.className = "b08-sheet__digits";
digits.addEventListener("input", recompute);
const revert = el("button", "", "양식대로");
revert.type = "button";
revert.title = "이 줄의 식·반올림을 양식 원래 값으로 되돌림";
revert.addEventListener("click", () => {
input.value = defaults.get(row.no)?.formula ?? "";
mode.value = base?.mode ?? "none";
digits.value = String(base?.digits ?? 0);
recompute();
});
line.append(input, mode, digits, revert);
basis.append(line);
const amount = el("td", "", amountText(row));
const note = el("td", "", noteText(row));
tr.append(
el("td", "", row.name),
el("td", "", row.spec),
basis,
amount,
el("td", "", row.unit),
el("td", "", DESTINATION_LABELS[row.destination ?? ""] ?? row.destination ?? ""),
note,
);
cells.set(row.no, { amount, note, input, mode, digits });
tbody.append(tr);
}
grid.append(thead, tbody);
scroller.append(grid);
saveButton.addEventListener("click", () => {
void (async () => {
saveButton.disabled = true;
status.textContent = "저장 중…";
const rows = [...cells.keys()]
.filter((seq) => stateOf(seq) !== saved.get(seq))
.map((seq) => {
const now = current(seq);
const base = defaults.get(seq);
// 양식과 같은 칸은 null — 서버가 「고친 적 없음」으로 지움.
return {
seq,
formula: now.formula !== base?.formula ? now.formula : null,
rounding:
roundingKey(now.rounding) !== roundingKey(base?.rounding) ? now.rounding : null,
};
});
try {
const errors = await onSave(rows);
status.textContent = errors.length ? `⚠ 저장했으나 안 서는 줄: ${errors.join(" · ")}` : "";
} catch (error) {
status.textContent = error instanceof Error ? error.message : "식을 저장하지 못함";
saveButton.disabled = false;
}
})();
});
discardButton.addEventListener("click", () => {
for (const row of sheet.rows) {
const cell = cells.get(row.no);
if (!cell) continue;
cell.input.value = row.formula ?? "";
cell.mode.value = row.rounding?.mode ?? "none";
cell.digits.value = String(row.rounding?.digits ?? 0);
}
recompute();
});
const actions = el("div", "b08-sheet__actions");
actions.append(saveButton, discardButton, status);
saveButton.disabled = true;
discardButton.disabled = true;
wrap.append(scroller, actions);
return wrap;
}
export function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
text = "",
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
node.className = className;
node.textContent = text;
return node;
}
export function num(value: number | null | undefined, digits: number): string {
if (value === null || value === undefined || Number.isNaN(value)) return "-";
return value.toLocaleString("ko-KR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}