- 식 칸 옆 반올림 고르개·자리수 · 고르개에 「버림 — 엑셀 ROUNDDOWN」처럼 엑셀 이름과 음수 보기 - 고친 반올림은 고친 식과 같은 자리(양식 + 프로젝트)에 저장 · 양식과 같으면 지움 · [양식대로]로 식·반올림 함께 되돌림 - build_table 도 양식을 m당(L=1)으로 풀고 연장을 곱함 — 실무 시트처럼 뒷줄이 반올림한 값을 보고 구조물도와 안 갈림 - 「대안 후보」(물구멍 2.5㎡ 등) 표 밑에 보임 · UI 700줄 넘어 식 칸 표를 _Formula.ts 로 뗌 - 시험 2개 추가 · 전체 1533 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
301 lines
12 KiB
TypeScript
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,
|
|
});
|
|
}
|