Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user