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
This commit is contained in:
2026-09-14 19:44:18 +09:00
co-authored by Claude Opus 5
parent 3ca1a45b2b
commit 475ad7875e
25 changed files with 334 additions and 31 deletions
@@ -40,6 +40,8 @@ export interface StructureOptionField {
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 칸 이름 옆 「표에 안 쓰임」 + 툴팁 사유. */
not_in_table?: string | null;
/** 기본값의 뜻(도메인 확정값이 아닐 때) — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
+1 -1
View File
@@ -727,7 +727,7 @@
"input": "number",
"unit": "m",
"default": 2.0,
"default_basis": "제안값 2.0 — 관측 원단위 자료가 반중력식 H=2.0 한 벌뿐이라 그 값(2026-09-14 A4 · 옛 2.5 는 자료 없음)",
"default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음",
"required": false,
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
@@ -361,10 +361,17 @@ export function createFacilityOptionsForm(
emit();
});
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
const saveNote = document.createElement("p");
saveNote.className = "b05-structure__owner-note";
saveNote.textContent =
"[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀";
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
grid(suggest),
saveNote,
pipeRow,
wingRow,
ford.widthRow,
@@ -9,12 +9,23 @@
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
export function field(
labelText: string,
input: HTMLElement,
basis?: string | null,
): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = labelText;
wrapper.append(caption, input);
// 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정).
if (basis) {
const note = document.createElement("small");
note.className = "b05-structure__basis";
note.textContent = basis;
wrapper.append(note);
}
return wrapper;
}
@@ -45,6 +56,8 @@ interface OptionShape {
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 툴팁이 이 사유를 먼저 보임. */
not_in_table?: string | null;
/** 기본값의 뜻 — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/**
@@ -332,7 +332,7 @@ export function createStructuresSection(
input.classList.add("is-locked");
input.title = "지금은 고를 수 없는 항목입니다.";
}
optionRow.append(field(label, input));
optionRow.append(field(label, input, option.default_basis));
input.addEventListener("change", () => liveCommit());
optionInputs.push({
key: option.key,
@@ -214,6 +214,18 @@
line-height: 1.5;
}
/* 칸 밑 근거 한 줄 — 등록부 `default_basis`(10-A). 회색 작은 글씨. */
.b05-structure__basis {
color: var(--color-text-muted, #9aa1ad);
font-size: var(--text-caption, 12px);
line-height: 1.3;
}
/* 근거 줄로 옆 칸이 길어져도 [제안값 넣기]가 세로로 늘지 않게. */
.b05-structure__suggest {
align-self: start;
}
/* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치
* (2026-08-17 사용자 지시 2). */
.b05-structure__position-row {
@@ -180,9 +180,12 @@ def save_personal(
overrides: dict[str, Any] | None,
unit_price_rows: list[dict[str, Any]] | None = None,
tier: str = "personal",
name: str | None = None,
) -> str:
"""[내 라이브러리에 저장]·발행 — 양식 + 고친 식·줄 조합을 **그 단에 한 벌**로 씀. 코드.
`name` 이름표(화면이 종류 + 제원 요약 제안 · 사용자가 고침 · 10-A ). 비면 양식 이름.
반대 방향(작업본 라이브러리)이라 프로젝트는 바꿈(브레인 판정 ).
같은 종류가 있으면 ** 코드로 덮어씀** 지금은 종류당 하나(판정 ).
`tier` 회사(MASTER)·프로그램 기본(SYSTEM_ADMIN) 발행도 같은 모양(2026-09-14 브레인 승인).
@@ -199,6 +202,8 @@ def save_personal(
for row in overridden_rows(template, overrides)
]
item = {k: v for k, v in template.items() if k != "imported_from"}
if name and name.strip():
item["name"] = name.strip()
if tier == "program" and isinstance(item.get("origin"), dict):
# 모든 회사로 가는 발행본 — 원문 공사명·파일명은 안 실음(원본·회사 단은 그대로 · 브레인 ②③).
kept = {k: v for k, v in item["origin"].items() if k not in ORIGIN_MASKED_KEYS}
@@ -422,7 +422,7 @@ def stone_masonry(
notes.append(
f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · "
f"{backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다"
"(소광리는 같은 식에 0.30/0.60 을 씁니다)"
"(소광리는 같은 식에 0.30/0.60 을 씁니다) · 칸을 새로 두는 것은 B05 등록부 몫"
)
# ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가
# 없으면 아무도 못 본다(2026-09-09 그물 침).
@@ -417,6 +417,7 @@ class LibrarySaveRequest(BaseModel):
sheet_key: str
tier: Literal["personal", "company", "program"] = "personal"
name: str | None = Field(default=None, max_length=200) # 이름표(10-A ⑫) · 비면 양식 이름
@router.put("/{project_id}/quantity/structure-sheets/library/personal")
@@ -430,10 +431,7 @@ async def put_structure_library_personal(
프로젝트 작업본은 바꿈 반대 방향(작업본 개인 )이라(판정 ).
"""
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import (
project_templates,
save_personal,
)
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates, save_personal
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import OVERRIDES_KEY, template_of
from B08_Quantity.B08_Quantity_Engine_StructureUnitPrice import ROWS_KEY
from common_util.common_util_project_settings import quantity_settings
@@ -461,7 +459,8 @@ async def put_structure_library_personal(
overrides = (settings.get(OVERRIDES_KEY) or {}).get(type_id)
# ⛔ 수동 단가(`MANUAL_KEY`)는 안 넘김 — 프로젝트의 값(브레인 판정).
rows = (settings.get(ROWS_KEY) or {}).get(type_id)
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows, payload.tier)
tier, name = payload.tier, payload.name
code = await asyncio.to_thread(save_personal, folder, template, overrides, rows, tier, name)
return JSONResponse(content={"status": "success", "code": code, "edited": len(overrides or {})})
@@ -100,6 +100,8 @@ export interface QuantitySettings {
ancillary_counts?: Record<string, number | null>;
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
stand_volume_class?: string | null;
/** 제근 굴착기 크기 — `"0.2"`·`"0.7"`(서버 선택지 안) · 비면 「안 정함」. */
root_removal_excavator_m3?: string | null;
/** 면고르기 갈래(서버 선택지 안) · 면적 덮어쓰기(㎡, 없음 = 파종 면적). */
face_dressing_cut_class?: string | null;
face_dressing_fill_class?: string | null;
@@ -162,6 +164,11 @@ export interface EarthworkTable {
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
/** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */
face_dressing_choices?: { cut: string[]; fill: string[] };
/** 제근 굴착기 크기 선택지·제안(회색 · [제안값 넣기]) — 서버 한 곳. */
root_removal_excavator_choices?: {
choices: string[];
suggested?: { value: string; basis: string };
};
/** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */
conversion_factor_pumsem_ranges?: PumsemRange[];
/** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 20 m 는 규정이라 값만 보인다. */
+11
View File
@@ -42,6 +42,7 @@ import { renderStructureSheets } from "./B08_Quantity_UI_StructureSheet";
import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary";
import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste";
import { appendFaceDressingFields } from "./B08_Quantity_UI_Side_FaceDressing";
import { appendRootRemovalFields } from "./B08_Quantity_UI_Side_RootRemoval";
import { appendBenchCutFields } from "./B08_Quantity_UI_Side_BenchCut";
/** locale 헬퍼 */
@@ -109,6 +110,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
// `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 None 으로 둔다.
topsoil_target: draft.topsoil_target,
stand_volume_class: draft.stand_volume_class,
root_removal_excavator_m3: draft.root_removal_excavator_m3,
// 면고르기 — 갈래 `""`·면적 `null` 도 그대로(「안 정함」·「파종 면적 그대로」로 되돌리는 길).
face_dressing_cut_class: draft.face_dressing_cut_class,
face_dressing_fill_class: draft.face_dressing_fill_class,
@@ -307,6 +309,8 @@ interface DraftSettings {
ancillary_counts: Record<string, number | null>;
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①).
stand_volume_class: string;
// 제근 굴착기 크기 — "0.2"·"0.7"(서버 선택지) · `""` 는 「안 정함」. 칸은 `_Side_RootRemoval`.
root_removal_excavator_m3: string;
// 면고르기 — 갈래 둘(서버 선택지) · 면적 덮어쓰기 둘(`null` = 파종 면적). 칸은 `_Side_FaceDressing`.
face_dressing_cut_class: string;
face_dressing_fill_class: string;
@@ -637,6 +641,12 @@ function buildQuantitySidePanel(
),
);
panel.append(hintRow(L("B08_Quantity_Side_StandVolume_Hint")));
appendRootRemovalFields(panel, draft, table?.root_removal_excavator_choices, {
field,
optionalNumberField,
selectField,
hintRow,
});
// ── 부대시설 개소 — ⚠ **산식으로 만들지 않는다**(확정 13). 넣어야 줄이 선다 ──
panel.append(field(L("B08_Quantity_Side_Ancillary"), ""));
@@ -1021,6 +1031,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null,
topsoil_target: (stored.topsoil_target as string) ?? "",
stand_volume_class: (stored.stand_volume_class as string) ?? "",
root_removal_excavator_m3: (stored.root_removal_excavator_m3 as string) ?? "",
face_dressing_cut_class: (stored.face_dressing_cut_class as string) ?? "",
face_dressing_fill_class: (stored.face_dressing_fill_class as string) ?? "",
face_dressing_fill_area_m2: (stored.face_dressing_fill_area_m2 as number | null) ?? null,
@@ -0,0 +1,59 @@
/* =============================================================================
* B08_Quantity_UI_Side_RootRemoval.ts
* (PLAN 10-A · 9-21 64a52df6).
*
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* · ** **(`root_removal_excavator_choices`) .
* . 0.7
* + [ ] ** **( ).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton } from "@ui/ui_template_elements";
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface RootRemovalChoices {
choices: string[];
suggested?: { value: string; basis: string };
}
export function appendRootRemovalFields(
panel: HTMLElement,
draft: { root_removal_excavator_m3: string; dirty: boolean },
choices: RootRemovalChoices | undefined,
h: SideFieldHelpers,
): void {
if (!choices) return;
const row = h.selectField(
L("B08_Quantity_Side_RootRemoval_Label"),
draft.root_removal_excavator_m3,
[
{ value: "", label: L("B08_Quantity_RootRemoval_Unset") },
...choices.choices.map((size) => ({ value: size, label: `${size}` })),
],
(picked) => {
draft.root_removal_excavator_m3 = picked;
draft.dirty = true;
},
);
panel.append(row);
const suggested = choices.suggested;
if (!suggested?.value) return;
panel.append(
h.hintRow(`${L("B08_Quantity_RootRemoval_Suggest")} ${suggested.value}㎥ — ${suggested.basis}`),
createButton({
label: L("B08_Quantity_RootRemoval_FillSuggested"),
variant: "ghost",
onClick: () => {
const select = row.querySelector("select");
if (select) select.value = suggested.value;
draft.root_removal_excavator_m3 = suggested.value;
draft.dirty = true;
},
}),
);
}
@@ -262,7 +262,14 @@ function sheetBody(
: "양식 없음(지금 전개)"),
),
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
el("span", "b08-grid__caption", sheet.unit_label),
el(
"span",
"b08-grid__caption",
// 10-A ⑯ 양식은 L=1 로 풂(`replace_with_templates` · m당 양식만) — 규칙을 머리에 드러냄.
sheet.library_item && sheet.unit_label === "m당"
? `m당 — 반올림은 m당 값에 걸고 수량 = m당 × 연장`
: sheet.unit_label,
),
);
// ㉱ 규격 다름 — 미확정과 같은 급(빨간 테두리 + 배지). 금액은 서고 막지 않음.
if (sheet.spec_mismatch) {
@@ -425,6 +432,7 @@ export function renderStructureSheets(projectId: string | null): HTMLElement {
typeId: sheet.library_item.type_id,
currentCode: sheet.library_item.code ?? null,
originProject: sheet.library_item.origin_project,
defaultName: sheet.title,
isDirty: () => dirty,
confirmTake: () => {
const edited = editedRows(sheet);
@@ -171,7 +171,7 @@ export function formulaTable(
saveButton.disabled = !dirty;
discardButton.disabled = !dirty;
status.textContent = dirty
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림)"
? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림 · 고친 식은 이 양식·프로젝트에 묶여 제원(높이 등)을 바꿔도 따라감)"
: "";
onDirty(dirty);
};
@@ -18,6 +18,8 @@ const TIER_LABELS: Record<string, string> = {
};
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" };
/** 이름표 — 「종류 + 제원 요약」을 미리 채워 두고 고치게 함(10-A ⑫ · 코드는 난수라 이름이 흔들려도 안전). */
const NAME_TAG_HINT = "이름표(종류 + 제원 요약 · 고칠 수 있음 · 비우면 양식 이름):";
interface LibraryItem {
tier: string;
@@ -59,6 +61,8 @@ export interface LibraryPanelOptions {
onImported: (notes: string[]) => Promise<void>;
/** 이 장 양식이 뽑아 온 원문 공사명 — 프로그램 기본 발행 확인창에 「빼고 발행」을 알림. */
originProject?: string | null;
/** 저장·발행 이름표 제안 — 장 이름(종류 + 제원 요약). */
defaultName: string;
}
/** 가져오기 · [내 라이브러리에 저장] · [내 것 지우기] 칸. 개인 단 두 단추는 프로젝트를 안 바꿈. */
@@ -272,17 +276,17 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
};
const save = personal("내 라이브러리에 저장", async () => {
if (isDirty()) return "저장 안 한 식이 있음 — [식 저장] 먼저";
if (
!window.confirm("이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀")
) {
return "";
}
const tag = window.prompt(
`이 장의 양식과 고친 식을 내 라이브러리에 저장 — 같은 종류가 있으면 덮어씀\n${NAME_TAG_HINT}`,
options.defaultName,
);
if (tag === null) return "";
const result = await readJson<{ edited: number }>(
await fetch(libraryUrl(projectId, "/personal"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet_key: sheetKey }),
body: JSON.stringify({ sheet_key: sheetKey, name: tag }),
}),
);
return `내 라이브러리에 저장함${result.edited ? ` · 고친 식 ${result.edited}줄 포함` : ""}`;
@@ -307,19 +311,17 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
tier === "program" && options.originProject
? `\n이 항목은 「${options.originProject}」에서 뽑은 것 — 공사명은 빼고 발행됩니다`
: "";
if (
!window.confirm(
`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀${masked}`,
)
) {
return "";
}
const tag = window.prompt(
`이 장의 양식과 고친 식을 ${whom}에 발행 — 같은 종류가 있으면 덮어씀${masked}\n${NAME_TAG_HINT}`,
options.defaultName,
);
if (tag === null) return "";
await readJson(
await fetch(libraryUrl(projectId, "/publish"), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sheet_key: sheetKey, tier }),
body: JSON.stringify({ sheet_key: sheetKey, tier, name: tag }),
}),
);
return `${whom}에 발행함`;
@@ -196,7 +196,11 @@ export function buildStandardSpecPanel(
slope,
judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.",
),
field("야면석 계수", coeff, "비우면 품셈 열을 씁니다."),
field(
"야면석 계수",
coeff,
"비우면 품셈 열을 씁니다. 계수 열을 바꿔도 돌 종류는 그대로(다른 칸)입니다.",
),
field("채움 강도 (MPa)", mpa, "비우면 210. 180 은 국가기준 하한입니다."),
field("버림 콘크리트", blinding, "비우면 넣습니다(두께 100㎜) — 빼려면 「안 넣음」."),
);
@@ -123,7 +123,7 @@ function render(table: UnitPriceTable): HTMLElement[] {
const head = el(
"p",
"b08-sheet__head",
`일위대가 ${table.code} · ${table.unit}${total} (미리보기 — 내역 금액은 원가계산이 셈)`,
`일위대가 ${table.code} · ${table.unit}${total} (미리보기 — 내역 금액은 원가계산이 셈 · 하위 일위대가는 5단까지 풀고 더 깊거나 돌면 막힘)`,
);
if (table.unconfirmed) {
head.append(el("span", "b08-unit__badge", `미확정 ${table.unconfirmed}`));
+4 -2
View File
@@ -64,7 +64,8 @@ FIELD_HINTS: dict[str, str] = {
"자동 = 요율 데이터 적용 하한(추정금액 1억 이상)일 때 적용 · STmate 의 토목·준설·건축·기타"
" 구분은 현행 제비율(2026-04-13)에서 율이 같아(2.3%) 칸으로 두지 않음"
),
"overhead_class": "임도가 (주)공사·전문공사 어느 쪽인지 정한 규정 없음 — 기본 (주)공사",
# 10-A ② 문구 못박음(2026-09-14 사용자 확정 — SW 규칙).
"overhead_class": "임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음",
"cut_basis": (
"기본 「총공사비 1,000원 미만 버림」 — 근거: 산림청고시 제2025-82호 「금액의 단위표준」"
"(설계서의 총액 · 원 · 1,000 · 미만버림) · 실무 6건이 여섯 다 000 으로 끝남 ·"
@@ -99,7 +100,8 @@ FIELD_HINTS: dict[str, str] = {
"waste_placement": (
"법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤"
" 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고"
"(공급가액 뒤)는 승률 밖 — 금액이 크게 갈려 설계자가 고름"
"(공급가액 뒤)는 승률 밖 — 금액이 크게 갈려 설계자가 고름 ·"
" 어느 자리든 법정경비 밑수(직접공사비·노무비)에는 안 넣음"
),
}
_GRADE_OPTIONS = [
@@ -246,6 +246,8 @@ function drawBill(ctx: B09TabContext, bill: BillDto, reload: () => void): void {
"b09s-hint b09s-hint--warn",
`${L("B09_Sheet_Missing")} ${bill.summary.missing.length}${L("B09_Sheet_Count")}`,
),
// 10-A ⑳ 까닭은 그대로 올림(2026-09-14 브레인 규칙) — 규칙을 목록 머리에 드러냄.
hint(L("B09_Sheet_Missing_Passed")),
);
for (const item of bill.summary.missing) {
details.append(hint(`${item.name}${item.reason}`));
@@ -20,9 +20,10 @@ WORK_ITEMS: dict[str, dict[str, Any]] = {
"name": "모르타르 배합",
"spec": "1:3",
"unit": "",
# 10-A ③ 문구 못박음(2026-09-14 사용자 확정 — SW 규칙) · 교차 참조 명시(지침 2장).
"basis": (
"건설공사 표준품셈(2026) [건축부문] 9-1-1 모르타르 배합(㎥당)"
"산림 품셈엔 배합 절이 없음 · 배합비 1:3 은 돌쌓기 시방(시멘트:잔골재 부피비) · 실무 울진 대흥(2024)·소광(2025)"
"산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)"
"건설공사 표준품셈(2026) [건축부문] 9-1-1 모르타르 배합(㎥당) · 배합비 1:3 은 돌쌓기 시방(시멘트:잔골재 부피비) · 실무 울진 대흥(2024)·소광(2025)"
" 「모르타르배합 1:3」 과 같은 짜임"
),
# (노임 코드, 수량, 칸) — 원문 [주]② 「배합이 포함된 것이며, 비빔은 제외」.
@@ -0,0 +1,56 @@
"""10-A ⑫ 라이브러리 항목 이름표 = 「종류 + 제원 요약」 자동 · 사용자가 고칠 수 있음 (2026-09-14 사용자 확정).
코드는 난수라 이름이 흔들려도 안전 이름은 고르개에 보이는 글자일 .
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from pydantic import ValidationError
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import B08_Quantity.B08_Quantity_Engine_StructureLibrary as library_module # noqa: E402
import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import load_template # noqa: E402
def _saved(folder: Path, code: str) -> dict:
return json.loads((folder / f"{code}.json").read_text(encoding="utf-8"))
def test_저장_때_적은_이름표가_실린다(tmp_path: Path) -> None:
template = load_template("masonry_wet")
code = library_module.save_personal(
tmp_path, template, None, None, "personal", name=" 돌쌓기(찰) H=2.5 1:0.3 봉화 "
)
assert _saved(tmp_path, code)["name"] == "돌쌓기(찰) H=2.5 1:0.3 봉화"
assert template["name"] == load_template("masonry_wet")["name"] # 원본 안 건드림
def test_이름표를_비우면_양식_이름_그대로(tmp_path: Path) -> None:
template = load_template("masonry_wet")
code = library_module.save_personal(tmp_path, template, None, None, "personal", name=" ")
assert _saved(tmp_path, code)["name"] == template["name"]
def test_요청은_이름표를_받고_길이를_막는다() -> None:
request = router_module.LibrarySaveRequest.model_validate({"sheet_key": "k", "name": ""})
assert request.name == ""
with pytest.raises(ValidationError):
router_module.LibrarySaveRequest.model_validate({"sheet_key": "k", "name": "" * 201})
source = (ROOT / "B08_Quantity" / "B08_Quantity_Router_StructureSheet.py").read_text("utf-8")
assert "payload.name" in source
def test_저장_발행_창이_종류_제원_요약을_이름표로_제안한다() -> None:
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Library.ts").read_text("utf-8")
assert ui.count("window.prompt(") == 2 # [내 라이브러리에 저장] · [발행]
assert "options.defaultName" in ui and "name: tag" in ui
sheet_ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet.ts").read_text("utf-8")
assert "defaultName: sheet.title" in sheet_ui
@@ -0,0 +1,59 @@
"""10-A — 우리가 정한 SW 규칙을 화면에 드러내기(B05·B09 몫) · 2026-09-14 사용자 확정 · PLAN 12장.
번호는 PLAN 10 사용자 확정 SW 규칙 차례. 문구 못박을 () 글자 그대로 .
"""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _read(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_11_일반관리비_문구_못박음() -> None:
from B09_Estimation.B09_Estimation_CostSheet import FIELD_HINTS
assert FIELD_HINTS["overhead_class"] == (
"임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음"
)
def test_13_모르타르_배합_교차_참조_문구() -> None:
from B09_Estimation.B09_Estimation_WorkItems_AX import MORTAR_MIX, WORK_ITEMS
assert WORK_ITEMS[MORTAR_MIX]["basis"].startswith(
"산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)"
)
def test_15_폐기물처리비는_법정경비_밑수에_안_넣음() -> None:
from B09_Estimation.B09_Estimation_CostSheet import FIELD_HINTS
assert "법정경비 밑수(직접공사비·노무비)에는 안 넣음" in FIELD_HINTS["waste_placement"]
def test_17_옹벽_높이_기본값_문구와_칸_밑_근거() -> None:
types = json.loads(_read("B05_Profile/B05_Profile_Structure_Types.json"))
wall = next(t for t in types["types"] if t["type_id"] == "retaining_wall")
height = next(o for o in wall["options"] if o["key"] == "height_m")
assert height["default_basis"] == "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음"
# 칸 밑 근거 한 줄 — 등록부 폼이 `default_basis` 를 칸 밑에 그림
assert "field(label, input, option.default_basis)" in _read(
"B05_Profile/B05_Profile_UI_Structures_Panel.ts"
)
def test_19_시설_저장은_폼에_없는_칸을_그대로_둠() -> None:
assert "폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠" in _read(
"B05_Profile/B05_Profile_UI_Drainage_Facility.ts"
)
def test_20_막힌_사유는_아래_단계_것을_그대로() -> None:
assert 'hint(L("B09_Sheet_Missing_Passed"))' in _read(
"B09_Estimation/B09_Estimation_UI_Tab_Bill.ts"
)
assert "그대로 옮김 — 여기서 새로 짓지 않음" in _read("ui_template/ui_template_locale_b3.ts")
@@ -0,0 +1,46 @@
"""10-A — 우리가 정한 SW 규칙을 화면에 드러내기(B08 몫) · 2026-09-14 사용자 확정 · PLAN 12장.
훑기(21) 결과 보이거나 반만 보이던 자리만 채움 근거 · 사유 문구 · 제안 회색.
번호는 PLAN 10 사용자 확정 SW 규칙 차례(PLAN 4 10-A 절에 목록).
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B08 = ROOT / "B08_Quantity"
def _read(name: str) -> str:
return (B08 / name).read_text(encoding="utf-8")
def test_3_뒷채움_폭_칸_신설은_B05_등록부_몫() -> None:
assert "칸을 새로 두는 것은 B05 등록부 몫" in _read(
"B08_Quantity_Engine_UnitQuantity_Masonry.py"
)
def test_4_하위_일위대가_깊이_한도를_미리_보인다() -> None:
assert "하위 일위대가는 5단까지" in _read("B08_Quantity_UI_StructureSheet_UnitPrice.ts")
def test_5_계수_열과_돌_종류는_다른_축() -> None:
assert "돌 종류는 그대로" in _read("B08_Quantity_UI_StructureSheet_Spec.ts")
def test_9_고친_식은_양식과_프로젝트에_묶임() -> None:
assert "제원(높이 등)을 바꿔도 따라감" in _read("B08_Quantity_UI_StructureSheet_Formula.ts")
def test_16_양식_반올림은_m당_값에() -> None:
assert "반올림은 m당 값에 걸고" in _read("B08_Quantity_UI_StructureSheet.ts")
def test_22_제근_굴착기_크기_칸() -> None:
page = _read("B08_Quantity_UI_Page.ts")
side = _read("B08_Quantity_UI_Side_RootRemoval.ts")
assert "root_removal_excavator_m3: draft.root_removal_excavator_m3" in page
assert "root_removal_excavator_m3: (stored.root_removal_excavator_m3" in page
assert "appendRootRemovalFields(" in page
assert "choices.suggested" in side and "createButton(" in side
assert side.count("= suggested.value") == 2 # 단추 안에서만 넣음(비우면 「안 정함」)
+4
View File
@@ -693,6 +693,10 @@ export const ui_locales_b2 = {
"⚠ 「육상」은 통상값이고 사용자 확정이 아닙니다(확정 3차 ④) — 품셈 9-13 의 18구분이 이 값으로 갈립니다",
"⚠ “Dry” is a customary default, not a user decision — it selects one of the 18 sub-items",
],
B08_Quantity_Side_RootRemoval_Label: ["제근 굴착기 크기", "Root removal excavator size"],
B08_Quantity_RootRemoval_Unset: ["안 정함(금액 안 섬)", "Not set (no amount)"],
B08_Quantity_RootRemoval_Suggest: ["제안(비우면 안 정함):", "Suggested (blank = not set):"],
B08_Quantity_RootRemoval_FillSuggested: ["제안값 넣기", "Fill suggested"],
B08_Quantity_Side_StandVolume_Label: ["임목축적 등급", "Stand volume class"],
B08_Quantity_StandVolume_Unset: ["안 정함(줄이 막힘)", "Not set (row blocked)"],
B08_Quantity_StandVolume_Low: ["소림(30~60㎥/㏊)", "Low (3060 ㎥/ha)"],
+4
View File
@@ -28,6 +28,10 @@ export const ui_locales_b3 = {
B09_Sheet_Unconfirmed: ["미확정", "Unconfirmed"],
B09_Sheet_Unpriced: ["금액에 안 들어감", "not in the amount"],
B09_Sheet_Missing: ["금액을 못 세운 줄", "Rows without a price"],
B09_Sheet_Missing_Passed: [
"사유는 아래 단계(수량·단가)가 낸 것을 그대로 옮김 — 여기서 새로 짓지 않음",
"Reasons are passed through from the quantity/price step as-is",
],
B09_Sheet_Reload: ["다시 불러오기", "Reload"],
B09_Sheet_Level: ["보이는 레벨", "Show levels"],
B09_Sheet_Level_All: ["모두", "All"],