Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-13 19:12:20 +09:00
7 changed files with 520 additions and 264 deletions
@@ -114,9 +114,10 @@ def save_personal(folder: Path, template: dict[str, Any], overrides: dict[str, A
type_id = template.get("type_id")
same = [item for item in _items(folder) if item.get("type_id") == type_id]
code = str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
# 고친 식이 곧 이 항목의 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
# 고친 식·반올림이 곧 이 항목의 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
dropped = {"default_formula", "default_rounding"}
rows = [
{**{k: v for k, v in row.items() if k != "default_formula"}, "source": "library"}
{**{k: v for k, v in row.items() if k not in dropped}, "source": "library"}
for row in overridden_rows(template, overrides)
]
item = {k: v for k, v in template.items() if k != "imported_from"}
@@ -79,16 +79,52 @@ def template_vars(
return values
#: 반올림 갈래 — 엑셀 대응(명세 13장 대응표). 사유·근거 문구에 엑셀 이름으로 보임.
ROUNDING_WORDS = {
"none": "안 함",
"floor": "INT",
"trunc": "ROUNDDOWN",
"round": "ROUND",
"ceil_away": "ROUNDUP",
"ceil": "위로(엑셀 없음)",
"round_half_even": "짝수 반올림(엑셀 없음)",
}
def rounding_key(rounding: dict[str, Any] | None) -> tuple[str, int]:
"""같은 반올림인지 — 「안 함」은 자리수와 무관하게 하나."""
mode = str((rounding or {}).get("mode") or "none")
return (mode, 0) if mode == "none" else (mode, int((rounding or {}).get("digits") or 0))
def _edit_of(row: dict[str, Any], edit: dict[str, Any]) -> dict[str, Any]:
"""저장된 고침 한 줄에서 **양식과 다른 칸만** — 식·반올림. 같으면 뺌(「고친 적 없음」)."""
changes: dict[str, Any] = {}
formula = str(edit.get("formula") or "").strip()
if formula and formula != row.get("formula"):
changes["formula"] = formula
rounding = edit.get("rounding")
if rounding and rounding_key(rounding) != rounding_key(row.get("rounding")):
mode, digits = rounding_key(rounding)
changes["rounding"] = {"mode": mode, "digits": digits}
return changes
def overridden_rows(
template: dict[str, Any], overrides: dict[str, Any] | None
) -> list[dict[str, Any]]:
"""양식 줄에 **그 장에서 사용자가 고친 식**을 얹음 — 고친 줄은 출처 `user`, 원래 은 따로."""
"""양식 줄에 **사용자가 고친 식·반올림**을 얹음 — 고친 줄은 출처 `user`, 원래 은 따로."""
rows: list[dict[str, Any]] = []
for row in template.get("rows") or []:
edit = (overrides or {}).get(str(row["seq"])) or {}
formula = str(edit.get("formula") or "").strip()
if formula and formula != row.get("formula"):
row = {**row, "formula": formula, "source": "user", "default_formula": row["formula"]}
changes = _edit_of(row, (overrides or {}).get(str(row["seq"])) or {})
if changes:
row = {
**row,
**changes,
"source": "user",
"default_formula": row.get("formula"),
"default_rounding": row.get("rounding"),
}
rows.append(row)
return rows
@@ -133,6 +169,7 @@ def _library_rows(
"source": source.get("source") or "library",
"destination": source.get("destination") or "",
"rounding": source.get("rounding"),
"default_rounding": source.get("default_rounding", source.get("rounding")),
"skipped": bool(result.get("skipped")),
"reason": result.get("reason") or "",
"error": result.get("error") or "",
@@ -141,6 +178,25 @@ def _library_rows(
return rows
def _user_basis(row: dict[str, Any]) -> str:
"""사용자가 고친 줄의 근거 — 고친 칸만 적음(식 · 반올림), 양식 값을 괄호로."""
parts = []
if row.get("formula") != row.get("default_formula"):
parts.append(f"사용자 식 = {row.get('formula')} (양식 식 {row.get('default_formula')})")
if rounding_key(row.get("rounding")) != rounding_key(row.get("default_rounding")):
parts.append(
f"사용자 반올림 = {_rounding_words(row.get('rounding'))}"
f" (양식 {_rounding_words(row.get('default_rounding'))})"
)
return " · ".join(parts)
def _rounding_words(rounding: dict[str, Any] | None) -> str:
mode, digits = rounding_key(rounding)
word = ROUNDING_WORDS.get(mode, mode)
return word if mode == "none" else f"{word} {digits}자리"
def replace_with_templates(
quantities: list[Any],
inputs: list[dict[str, Any]],
@@ -155,6 +211,8 @@ def replace_with_templates(
⚠ Node 가 안 돌면 전개 값을 두고 **사유를 남김** — 조용히 섞이지 않게.
⚠ 오류 난 양식 줄은 성분에서 빼고 사유로 — 전개도 원문 「-」 줄을 안 세우고 사유로 둠.
⚠ 사용자가 고친 식은 **양식(type_id)** 으로 찾음 — 구조물도와 같은 값이 되게.
⚠ **L=1(m당)로 풀고 연장을 곱함** — 실무 구조물도는 m당 값을 줄마다 반올림하고 뒤 줄이 그
반올림 값을 참조함. 실제 연장으로 풀면 합계를 반올림하게 되어 구조물도 m당 × 연장과 갈림.
"""
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
@@ -167,6 +225,9 @@ def replace_with_templates(
template = template_of(quantity.type_id, templates)
if template is None or not quantity.components:
continue
# 구조물도와 같은 조건 — m당 양식만(`apply_templates` 의 `_PER_LENGTH_UNITS`).
if (quantity.billing_unit or "m") not in _PER_LENGTH_UNITS:
continue
options = item.get("options") or {}
face, face_reason = structure_face_role(
_section_mode_at(item, section_modes), options.get("side")
@@ -178,11 +239,7 @@ def replace_with_templates(
"face": face,
"face_reason": face_reason,
}
structure = {
"height_m": quantity.height_m,
"length_m": quantity.length_m,
"options": options,
}
structure = {"height_m": quantity.height_m, "length_m": 1.0, "options": options}
values = template_vars(template, structure, slope_of(sheet)[0], settings)
overrides = (formula_overrides or {}).get(quantity.type_id)
targets.append((quantity, template_sheet(template, values, overrides)))
@@ -215,7 +272,7 @@ def replace_with_templates(
name = str(result["name"])
user = source.get("source") == "user"
basis = (
f"사용자 식 = {source.get('formula')} (양식 식 {source.get('default_formula')})"
_user_basis(source)
if user
else engine_basis.get(name) or str(source.get("formula_text") or "")
)
@@ -223,7 +280,7 @@ def replace_with_templates(
Component(
name,
str(source.get("unit") or ""),
float(result["amount"]),
float(result["amount"]) * quantity.length_m,
str(source.get("destination") or ""),
basis,
source="user" if user else engine_source.get(name, ""),
@@ -292,6 +349,17 @@ def apply_templates(
"imported_from": template.get("imported_from"),
}
sheet["formula_sheet"] = body
# 실무 관측값 같은 「대안 후보」 — 값을 바꾸지 않고 칸 옆에 보이기만(판정 Ⓑ).
sheet["var_candidates"] = [
{
"name": name,
"label": spec.get("label") or name,
"value": body["vars"].get(name),
"candidates": spec["candidates"],
}
for name, spec in (template.get("vars") or {}).items()
if spec.get("candidates")
]
def save_sheet_overrides(
@@ -300,10 +368,11 @@ def save_sheet_overrides(
template: dict[str, Any],
edits: list[dict[str, Any]],
) -> tuple[dict[str, Any], int]:
"""양식 하나(`key` = type_id)의 고친 식을 갈아 끼운 **새 전체 값**과 바뀐 줄 수.
"""양식 하나(`key` = type_id)의 고친 식·반올림을 갈아 끼운 **새 전체 값**과 바뀐 줄 수.
⚠ 빈 식·양식 식과 같은 식은 **지움**(「고친 적 없음」으로 되돌림) — 같은 값을 박아 두면
양식이 바뀌어도 그 장만 옛 식으로 남음.
한 줄 고침은 **그 줄의 뜻한 상태 전부**(식·반올림) — 빠진 칸은 양식대로.
⚠ 빈 칸·양식과 같은 값은 **지움**(「고친 적 없음」으로 되돌림) — 같은 값을 박아 두면
양식이 바뀌어도 그 장만 옛 값으로 남음.
⚠ 양식에 없는 차례는 받지 않음(오류) — 모르는 줄이 산출물에 끼지 않게.
"""
known = {row["seq"]: row for row in template.get("rows") or []}
@@ -314,15 +383,15 @@ def save_sheet_overrides(
seq = int(edit["seq"])
if seq not in known:
raise ValueError(f"양식에 없는 줄 차례: {seq}")
formula = str(edit.get("formula") or "").strip()
before = (sheet.get(str(seq)) or {}).get("formula")
if not formula or formula == known[seq].get("formula"):
entry = _edit_of(known[seq], edit)
before = sheet.get(str(seq))
if not entry:
if sheet.pop(str(seq), None) is not None:
changed += 1
continue
if before != formula:
if before != entry:
changed += 1
sheet[str(seq)] = {"formula": formula}
sheet[str(seq)] = entry
if sheet:
merged[key] = sheet
else:
@@ -117,6 +117,15 @@ class StandardSheetSpecRequest(BaseModel):
blinding_concrete: str | None = None
class RoundingEdit(BaseModel):
"""줄 반올림 — 갈래는 명세 13장 일곱 가지만. 자리수가 음수면 10의 자리 쪽."""
model_config = ConfigDict(extra="forbid")
mode: Literal["none", "floor", "trunc", "round", "ceil_away", "ceil", "round_half_even"]
digits: int = Field(default=0, ge=-6, le=10)
class FormulaEdit(BaseModel):
"""고친 식 한 줄 — 빈 식(null·"")은 **양식 식으로 되돌림**."""
@@ -124,6 +133,8 @@ class FormulaEdit(BaseModel):
seq: int = Field(ge=1)
formula: str | None = Field(default=None, max_length=2000)
#: 빠지면(null) 양식 반올림 — 식과 같이 **그 줄의 뜻한 상태 전부**를 보냄.
rounding: RoundingEdit | None = None
class StructureSheetFormulaRequest(BaseModel):
+38 -210
View File
@@ -13,7 +13,17 @@
import { API_BASE_URL } from "@config/config_frontend";
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { evaluateSheet, type FormulaSheet } from "./B08_Quantity_Formula";
import type { FormulaSheet } from "./B08_Quantity_Formula";
import {
amountText,
DESTINATION_LABELS,
el,
formulaTable,
noteText,
num,
type FormulaEditRow,
type Rounding,
} from "./B08_Quantity_UI_StructureSheet_Formula";
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
import {
@@ -39,55 +49,15 @@ export interface StructureSheetRow {
/** 양식 원래 식 — 고친 줄이면 `formula` 와 다름(되돌릴 자리). */
default_formula?: string;
destination?: string;
rounding?: { mode: string; digits: number } | null;
rounding?: Rounding | null;
/** 양식 원래 반올림 — 고친 줄이면 `rounding` 과 다름. */
default_rounding?: Rounding | null;
/** `when` 이 거짓이라 안 선 줄 — 「안 섬」과 까닭을 보임(0 으로 안 적음). */
skipped?: boolean;
reason?: string;
error?: string;
}
const DESTINATION_LABELS: Record<string, string> = {
earthwork: "토공집계",
material: "자재총괄",
unit_price: "일위대가",
reference: "보여주기",
haul_deduction: "운반 공제",
};
const ROUNDING_LABELS: Record<string, string> = {
floor: "내림(INT)",
trunc: "버림",
round: "반올림",
ceil_away: "올림",
ceil: "위로",
round_half_even: "짝수 반올림",
};
/** 수량 칸 — 안 선 줄은 「안 섬」, 못 푼 줄은 「-」(0 으로 때우지 않음). */
function amountText(row: StructureSheetRow): string {
if (row.skipped) return "안 섬";
return num(row.unit_amount, 3);
}
/** 비고 칸 — 까닭이 있으면 까닭이 먼저, 없으면 값의 출처와 반올림. */
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 interface StructureSheet extends StandardSheetSpec {
height_m: number;
unit_label: string;
@@ -114,6 +84,13 @@ export interface StructureSheet extends StandardSheetSpec {
};
/** 화면이 조작 중 왕복 없이 다시 풀 장 한 벌(L=1, 고친 식 얹힘). */
formula_sheet?: FormulaSheet;
/** 제원 칸의 대안 후보(실무 관측값 등) — 값은 안 바꾸고 보이기만. */
var_candidates?: {
name: string;
label: string;
value: number | string | null;
candidates: { value: number | string; source?: string }[];
}[];
}
export interface StructureSheetsResponse {
@@ -142,6 +119,9 @@ const CSS = `
border: 1px solid var(--color-border); }
.b08-sheet__formula input.is-changed { border-color: var(--color-accent, #6c8ebf); }
.b08-sheet__formula button { font-size: 11px; padding: 0 6px; cursor: pointer; }
.b08-sheet__formula select { font-size: 11px; max-width: 11rem; }
.b08-sheet__formula select.is-changed { outline: 1px solid var(--color-accent, #6c8ebf); }
.b08-sheet__formula .b08-sheet__digits { flex: 0 0 3.2rem; min-width: 3.2rem; }
.b08-sheet__actions { display: flex; gap: 8px; align-items: center; }
@media (max-width: 900px) {
.b08-sheet { flex-direction: column; }
@@ -190,11 +170,11 @@ async function putStructureSheetSpec(
return { changed: payload.changed ?? 0, notes: payload.notes ?? [] };
}
/** 고친 식 저장 — 식만 보냄. 돌아오는 줄 값은 **서버가 다시 푼 것**(판정 Ⓐ). */
/** 고친 식·반올림 저장 — 값은 안 보냄. 돌아오는 줄 값은 **서버가 다시 푼 것**(판정 Ⓐ). */
async function putStructureSheetFormulas(
projectId: string,
sheetKey: string,
rows: { seq: number; formula: string | null }[],
rows: FormulaEditRow[],
): Promise<{ changed: number; errors: string[] }> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/formulas`,
@@ -214,170 +194,6 @@ async function putStructureSheetFormulas(
return { changed: payload.changed ?? 0, errors: payload.errors ?? [] };
}
/**
* 양식 장의 원단위 수량표 — 식 칸을 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂.
* `onSave` 는 고친 식만 받음 — 값은 서버가 다시 냄. 되돌린 줄은 빈 식(= 양식 식)으로 감.
*/
function formulaTable(
sheet: StructureSheet,
onSave: (rows: { seq: number; formula: string | null }[]) => 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 ?? ""]));
const defaults = new Map(sheet.rows.map((row) => [row.no, row.default_formula ?? ""]));
const edits = new Map<number, string>();
const cells = new Map<
number,
{ amount: HTMLElement; note: HTMLElement; input: HTMLInputElement }
>();
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) =>
edits.has(row.seq) ? { ...row, formula: edits.get(row.seq), source: "user" } : 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 formula = cell.input.value.trim();
const local: StructureSheetRow = {
...row,
unit_amount: result.amount === null ? null : Number(result.amount),
skipped: result.skipped,
reason: result.reason ?? "",
error: result.error ?? "",
source: formula && formula !== defaults.get(result.seq) ? "user" : "library",
};
cell.amount.textContent = amountText(local);
cell.note.textContent = noteText(local);
cell.input.classList.toggle("is-changed", formula !== saved.get(result.seq));
}
const dirty = [...cells.entries()].some(
([seq, cell]) => cell.input.value.trim() !== 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", () => {
const value = input.value.trim();
if (value === saved.get(row.no)) edits.delete(row.no);
else edits.set(row.no, value || (defaults.get(row.no) ?? ""));
recompute();
});
const revert = el("button", "", "양식 식으로");
revert.type = "button";
revert.title = "이 줄을 양식 원래 식으로 되돌림";
revert.addEventListener("click", () => {
input.value = defaults.get(row.no) ?? "";
input.dispatchEvent(new Event("input"));
});
line.append(input, 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 });
tbody.append(tr);
}
grid.append(thead, tbody);
scroller.append(grid);
saveButton.addEventListener("click", () => {
void (async () => {
saveButton.disabled = true;
status.textContent = "저장 중…";
const rows = [...cells.entries()]
.filter(([seq, cell]) => cell.input.value.trim() !== saved.get(seq))
.map(([seq, cell]) => {
const value = cell.input.value.trim();
// 양식 식과 같거나 비면 「고친 적 없음」으로 되돌림.
return { seq, formula: value && value !== defaults.get(seq) ? value : 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", () => {
edits.clear();
for (const [seq, cell] of cells) cell.input.value = saved.get(seq) ?? "";
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;
}
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;
}
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,
});
}
function warn(title: string, items: string[]): HTMLElement | null {
if (!items.length) return null;
return el("p", "b08-grid__caption b08-grid__caption--warn", `${title}: ${items.join(" · ")}`);
@@ -452,6 +268,18 @@ function sheetBody(sheet: StructureSheet, editor: HTMLElement | null): HTMLEleme
),
);
}
for (const item of sheet.var_candidates ?? []) {
const others = item.candidates
.map((c) => `${c.value}${c.source ? `(${c.source})` : ""}`)
.join(" · ");
main.append(
el(
"p",
"b08-grid__caption",
`대안 후보 — ${item.label}: 지금 ${item.value} · 후보 ${others}`,
),
);
}
for (const notice of [
warn("단위당을 못 낸 줄", sheet.unpriced_rows),
warn(
@@ -0,0 +1,300 @@
/* =============================================================================
* 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,
});
}
@@ -8,42 +8,19 @@
---
## 00. 브레인 인수인계 — 새 브레인이 먼저 읽을 것
## 00. 브레인 인수인계
**브레인이 바뀌거나 컨텍스트가 요약되면 여기부터 읽으면 이어짐.**
**브레인 지침과 작업 기억은 이 문서가 아니라 `.claude/` 에 있음** (2026-09-13 사용자 확정).
### 읽을 것 다섯 (차례대로)
| 파일 | 무엇 |
| --- | --- |
| `.claude/BRAIN.md` | 브레인일 때 추가로 지킬 것 — 컨텍스트 규율·판정 방식·창 조율 |
| `.claude/BRAIN_MEM.md` | 지금 상태 — 창 배정·다음 차례·확인해 둔 것·아픈 자리 |
| # | 무엇 | 자리 |
| --- | --- | --- |
| 1 | **이 문서** — 두 축·식 칸·반올림·검산 계약 전부 | 지금 보는 것 |
| 2 | **계획서 0장** — 계산 사슬 그림 · 확정 일곱 · 창 배정 | `PLAN.md` |
| 3 | **계획서 10장** — 브레인이 임의로 정한 것 한 줄씩(사후 확인 대기) | `PLAN.md` |
| 4 | **코더 자체검증** — 각 장 아래에 일감마다 적혀 있음 | `PLAN.md` 1·3·4장 |
| 5 | **경쟁사 분석 인덱스** — B08·B09 정본 참조 | `…/STmate 분석/20_분석/00_분석_요약과_읽는법.md` |
⚠ 둘 다 **git 밖**(시놀로지·정션으로 공유)이라 **이력이 없음.**
**계약·판정처럼 잃으면 안 되는 것은 이 명세와 `PLAN.md` 에 둘 것.**
### 브레인이
```
명세 관리 · 일감 분배 · 판정 · 검산 판정 ⛔ 코드 편집은 안 함
제어 대상 = 랩탑 둘뿐 (데스크탑_서브는 사용자 소관)
```
- **막히면 기다리지 않음** — `resources/knowledge/technical_info/` 를 먼저 보고
**스스로 정해 진행**, 계획서 10장에 **한 줄**. 확인되면 그 줄을 지움 (사용자 위임 2026-09-13)
- **판정·근거·부채는 그 자리에서 파일에 한 줄.** 컨텍스트에만 두지 않음 —
**요약 손실 = 파일에 안 적은 것**임
- **검증은 증거로 받음** — 코더가 전·후 수치와 시험 결과를 내고 브레인은 판정만.
의심스러울 때만 직접 봄(브레인이 코드를 읽으면 컨텍스트가 코더처럼 무거워짐)
- **쪽지는 8줄 안쪽** — 수치·표·근거는 계획서에 쓰고 「어디를 보라」만
### 완료 목표
```
ⓐ 이 명세가 두 창이 따를 계약으로 설 것 ← 섬 (계속 갱신 중)
ⓑ STmate 골든셋 재현 시험이 초록일 것 ← 남음
시험 코드는 코더가 짜고 판정은 브레인. 8장 참고
```
브레인이 차례 — `.claude/BRAIN.md``.claude/BRAIN_MEM.md`**이 명세**`PLAN.md` 0장·판정 절.
---
@@ -167,6 +167,76 @@ def test_식을_고쳐_저장하면_서버가_다시_풀고_되돌릴_수_있다
assert stored["quantity"]["structure_formula_overrides"] == {}
def test_줄마다_반올림을_고치면_m당에서_반올림하고_뒷줄이_그_값을_본다(
client: TestClient, project: Path
) -> None:
"""PLAN 3장 반올림 칸 — 실무 구조물도처럼 m당 값을 반올림 · 원단위는 m당 × 연장."""
sheet = _sheet(client)
rows = {row["name"]: row for row in sheet["rows"]}
area = rows["돌쌓기"]["unit_amount"] # 2.5 × √1.09 ≈ 2.61
saved = client.put(
f"{SHEETS}/formulas",
json={
"sheet_key": sheet["key"],
"rows": [
{
"seq": rows["돌쌓기"]["no"],
"formula": None,
"rounding": {"mode": "floor", "digits": 0},
}
],
},
)
assert saved.status_code == 200 and saved.json()["changed"] == 1, saved.text
after = {row["name"]: row for row in _sheet(client)["rows"]}
assert after["돌쌓기"]["unit_amount"] == pytest.approx(2.0) and area > 2.5
assert after["돌쌓기"]["source"] == "user"
assert after["돌쌓기"]["formula"] == rows["돌쌓기"]["formula"] # 식은 그대로
assert after["돌쌓기"]["default_rounding"] == {"mode": "none", "digits": 0}
# 뒷줄(모르터 = A × 0.009)이 반올림한 A 를 봄.
assert after["모르터"]["unit_amount"] == pytest.approx(2.0 * 0.009)
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
assert stored["quantity"]["structure_formula_overrides"]["masonry_wet"] == {
str(rows["돌쌓기"]["no"]): {"rounding": {"mode": "floor", "digits": 0}}
}
# 원단위(build_table) = m당 반올림 값 × 연장 10m — 합계를 반올림하지 않음.
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
structures, names, _ = _collect_structures(str(project))
table = build_table(
structures, names, structure_formulas=stored["quantity"]["structure_formula_overrides"]
)
parts = {c["name"]: c for c in table["structures"][0]["components"]}
assert parts["돌쌓기"]["amount"] == pytest.approx(20.0)
assert parts["모르터"]["amount"] == pytest.approx(0.18)
assert parts["돌쌓기"]["basis"] == "사용자 반올림 = INT 0자리 (양식 안 함)"
# 되돌리기 — 반올림 null 이면 양식대로, 저장 칸에서도 지워짐.
client.put(
f"{SHEETS}/formulas",
json={"sheet_key": sheet["key"], "rows": [{"seq": rows["돌쌓기"]["no"], "rounding": None}]},
)
stored = json.loads((project / "project_settings.json").read_text(encoding="utf-8"))
assert stored["quantity"]["structure_formula_overrides"] == {}
# 모르는 갈래는 받지 않음.
bad = client.put(
f"{SHEETS}/formulas",
json={
"sheet_key": sheet["key"],
"rows": [{"seq": 1, "rounding": {"mode": "bankers", "digits": 0}}],
},
)
assert bad.status_code == 422
def test_대안_후보를_싣는다(client: TestClient) -> None:
candidates = {item["name"]: item for item in _sheet(client)["var_candidates"]}
assert candidates["HOLE_AREA"]["value"] == 2
assert candidates["HOLE_AREA"]["candidates"][0]["value"] == 2.5
def test_제원을_고쳐도_고친_식이_따라간다(client: TestClient) -> None:
"""고친 식은 양식 + 프로젝트에 묶임 — 뒷길이를 고쳐 장 이름이 바뀌어도 사용자 식이 남음."""
sheet = _sheet(client)