fix(B08): 자기 감사 — 안 부르던 검사 넷 연결 + 표토 두께 입력 칸

㉘ 자기 감사. B09 에 겨눈 네 축을 내 것에 그대로 겨눔.

안 부르던 검사 — 시험에서만 불리고 있던 것들을 실제 경로에 이음
- verify_no_code_on_materials · verify_bill_flags → build_handoff 결과에 실음
- check_against_plan → 운반 표 옆에 haul_check 로 실음 (계획이 있을 때만)
- 바로 위 주석이 「검사는 실제로 부른다」였는데 형제 둘이 놀고 있었음

표토 두께 — 서버·엔진은 받고 있었는데 화면에 넣을 칸이 없었음
- optionalNumberField 신설 (빈 값 = 「안 정함」, 0 과 구별)
- null 을 저장에서 버리던 것 수정 (NULLABLE_SETTING_KEYS) —
  한 번 넣으면 「안 정함」으로 못 되돌리던 자리

짝 시험 — 규칙은 있는데 시험이 없던 둘
- REFERENCE_MARKS (41건을 지웠던 그 규칙): 기준표는 잡고 「단 위」는
  안 잡는 것을 양쪽으로 박음
- attachments_of: 칸이 있을 때만 줄이 서고 부모와 id 가 갈리는 것

시험: 662 passed · 24 skipped (B05 코리도 1건 기존 깨짐, 무관) · tsc 오류 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 05:46:12 +09:00
co-authored by Claude Opus 5
parent f1d86e2525
commit 3e729439cb
5 changed files with 85 additions and 1 deletions
@@ -775,7 +775,11 @@ def build_handoff(
"excluded_row_count": sum(1 for row in work_items if not row["in_bill"]), "excluded_row_count": sum(1 for row in work_items if not row["in_bill"]),
} }
# ⚠ 검사는 **실제로 부른다** — 만들어 두고 안 부르면 없는 것과 같다. # ⚠ 검사는 **실제로 부른다** — 만들어 두고 안 부르면 없는 것과 같다.
# 2026-09-08 ㉘ 자기 감사: 아래 줄 하나만 이어져 있고 형제 둘은 **시험에서만** 불리고
# 있었다. B09 에서 같은 병(가드 둘이 놀고 있음)을 지적해 놓고 내 쪽도 같았다.
result["ratio_math_warnings"] = verify_ratio_math(result) result["ratio_math_warnings"] = verify_ratio_math(result)
result["material_code_warnings"] = verify_no_code_on_materials(result)
result["bill_flag_warnings"] = verify_bill_flags(result)
return result return result
+24 -1
View File
@@ -32,6 +32,7 @@ from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table from B08_Quantity.B08_Quantity_Engine_HaulSummary import build_table as build_haul_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import check_against_plan
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping
from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table from B08_Quantity.B08_Quantity_Engine_Preparation import build_table as build_preparation_table
from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows from B08_Quantity.B08_Quantity_Engine_HaulSummary import summary_input_rows
@@ -85,6 +86,17 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
table["haul"] = haul table["haul"] = haul
# 운반계획은 [저장]·[확정]에서 정본에 남는 값이다 — 아직 없으면 빈 표가 정직하다. # 운반계획은 [저장]·[확정]에서 정본에 남는 값이다 — 아직 없으면 빈 표가 정직하다.
table["haul_available"] = bool(plan) table["haul_available"] = bool(plan)
# ⚠ 검산을 **실제로 부른다** — 무대·도자·덤프 합이 운반계획 총량과 맞는가(8-7 ㉡).
# 2026-09-08 ㉘ 자기 감사: 만들어 두고 시험에서만 부르고 있었다. 값을 막지는 않고
# 차이만 실어 화면이 띄우게 한다 — 막으면 계획이 없는 정상 상태에서도 멈춘다.
if plan:
check = check_against_plan(haul, plan)
table["haul_check"] = {
"hauled_total_m3": check.hauled_total_m3,
"plan_total_m3": check.plan_total_m3,
"difference_m3": check.difference_m3,
"by_equipment": check.details,
}
table["summary"] = build_summary_table( table["summary"] = build_summary_table(
SummaryInput( SummaryInput(
@@ -189,6 +201,11 @@ class QuantitySettingsBody(BaseModel):
topsoil_thickness_m: float | None = None topsoil_thickness_m: float | None = None
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
#: 빈 문자열로 되돌리는 칸(시공법·타설 방식)과 달리 숫자 칸은 되돌릴 값이 `None` 뿐이다.
NULLABLE_SETTING_KEYS = ("topsoil_thickness_m",)
@router.put("/{project_id}/quantity/settings") @router.put("/{project_id}/quantity/settings")
async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -> JSONResponse: async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -> JSONResponse:
"""산출 조건을 정본에 남긴다 — [저장]이 부르는 자리. """산출 조건을 정본에 남긴다 — [저장]이 부르는 자리.
@@ -206,6 +223,12 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."}, content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
) )
values = {key: value for key, value in body.model_dump().items() if value is not None} values = {key: value for key, value in body.model_dump().items() if value is not None}
# ⚠ `None` 을 통째로 버리면 **「안 정함」으로 되돌릴 길이 없다** — 한 번 넣은 값이
# 영영 남는다(2026-09-08 ㉘ 자기 감사). 시공법·타설 방식은 빈 문자열로 되돌리지만
# 숫자 칸은 되돌리는 값이 `None` 뿐이라, **화면이 보낸 것**만 골라 살린다.
for key in NULLABLE_SETTING_KEYS:
if key in body.model_fields_set:
values[key] = getattr(body, key)
if "concrete_placing_method" in values: if "concrete_placing_method" in values:
method = values["concrete_placing_method"] method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리). # 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
@@ -225,7 +248,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
_save_quantity, _save_quantity,
root, root,
values, values,
("rock_methods", "material_supply", "concrete_placing_method"), ("rock_methods", "material_supply", "concrete_placing_method") + NULLABLE_SETTING_KEYS,
) )
except Exception: except Exception:
logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id) logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id)
@@ -65,6 +65,8 @@ export interface QuantitySettings {
material_supply?: Record<string, { supply: string; install_by: string | null }>; material_supply?: Record<string, { supply: string; install_by: string | null }>;
/** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */ /** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */
concrete_placing_method?: string | null; concrete_placing_method?: string | null;
/** 표토 두께(m). `null`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */
topsoil_thickness_m?: number | null;
} }
export interface EarthworkTable { export interface EarthworkTable {
+52
View File
@@ -84,6 +84,8 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
rock_methods: draft.rock_methods, rock_methods: draft.rock_methods,
material_supply: draft.material_supply, material_supply: draft.material_supply,
concrete_placing_method: draft.concrete_placing_method, concrete_placing_method: draft.concrete_placing_method,
// ⚠ `null` 도 그대로 보낸다 — 「안 정함」으로 되돌릴 길이 있어야 한다(시공법과 같은 규칙).
topsoil_thickness_m: draft.topsoil_thickness_m,
}), }),
}, },
); );
@@ -134,6 +136,37 @@ function numberField(label: string, value: number, onInput: (value: number) => v
return row; return row;
} }
/** ** ** 0 .
*
* `numberField` 0 0m .
* ** ** .
*/
function optionalNumberField(
label: string,
value: number | null,
step: string,
onInput: (value: number | null) => void,
): HTMLElement {
const row = document.createElement("label");
row.className = "b08-quantity__field";
const name = document.createElement("span");
name.textContent = label;
const input = document.createElement("input");
input.type = "number";
input.className = "b08-quantity__input";
input.min = "0";
input.step = step;
input.placeholder = L("B08_Quantity_Unset_Placeholder");
input.value = value === null || value === undefined ? "" : String(value);
// 자동저장은 만들지 않는다 — 입력은 캐시에만 남는다(CLAUDE.md 5장).
input.addEventListener("input", () => {
const text = input.value.trim();
onInput(text === "" ? null : Number(text));
});
row.append(name, input);
return row;
}
/** 타설 방식 표기 — 코드가 아니라 사람이 읽는 이름으로 보인다. */ /** 타설 방식 표기 — 코드가 아니라 사람이 읽는 이름으로 보인다. */
const PLACING_LABELS: Record<string, string> = { const PLACING_LABELS: Record<string, string> = {
ready_mixed: "레디믹스트", ready_mixed: "레디믹스트",
@@ -182,6 +215,8 @@ interface DraftSettings {
rock_methods: Record<string, string>; rock_methods: Record<string, string>;
// 콘크리트 타설 방식 — `""` 는 「안 정함」이고 저장에서 지워진다. // 콘크리트 타설 방식 — `""` 는 「안 정함」이고 저장에서 지워진다.
concrete_placing_method: string; concrete_placing_method: string;
// 표토 두께(m) — `null` 은 「안 정함」. 정해야 표토제거 줄이 선다(품셈 9-15 [주]② 의 T).
topsoil_thickness_m: number | null;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값. // 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>; material_supply: Record<string, SupplyChoice>;
dirty: boolean; dirty: boolean;
@@ -265,6 +300,22 @@ function buildQuantitySidePanel(
} }
} }
// ── 표토 두께 — 정해야 준비공 표토제거 줄이 선다(품셈 9-15 [주]② 의 「T : 표토두께(m)」) ──
// ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
// 「죽은 칸」(넣어도 안 쓰임)의 반대 짝이다 — 쓰이는데 넣을 데가 없던 자리.
panel.append(field(L("B08_Quantity_Side_Topsoil"), ""));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_Topsoil_Label"),
draft.topsoil_thickness_m,
"0.01",
(value) => {
draft.topsoil_thickness_m = value;
draft.dirty = true;
},
),
);
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ── // ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
panel.append(field(L("B08_Quantity_Side_Placing"), "")); panel.append(field(L("B08_Quantity_Side_Placing"), ""));
panel.append( panel.append(
@@ -503,6 +554,7 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) }, application_ratios_pct: { ...(stored.application_ratios_pct ?? {}) },
rock_methods: { ...((stored.rock_methods ?? {}) as Record<string, string>) }, rock_methods: { ...((stored.rock_methods ?? {}) as Record<string, string>) },
concrete_placing_method: (stored.concrete_placing_method as string) ?? "", concrete_placing_method: (stored.concrete_placing_method as string) ?? "",
topsoil_thickness_m: (stored.topsoil_thickness_m as number | null) ?? null,
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) }, material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
dirty: false, dirty: false,
}; };
+3
View File
@@ -628,6 +628,9 @@ export const ui_locales_b2 = {
B08_Quantity_Method_Unset: ["안 정함", "Not set"], B08_Quantity_Method_Unset: ["안 정함", "Not set"],
B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"], B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"],
B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"], B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"],
B08_Quantity_Side_Topsoil: ["표토제거", "Topsoil Removal"],
B08_Quantity_Side_Topsoil_Label: ["표토 두께(m)", "Topsoil thickness (m)"],
B08_Quantity_Unset_Placeholder: ["안 정함", "Not set"],
B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"], B08_Quantity_Side_Placing: ["콘크리트 타설", "Concrete Placing"],
B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"], B08_Quantity_Side_Placing_Label: ["타설 방식", "Method"],
B08_Quantity_Placing_Unset: ["안 정함(기본값 사용)", "Not set (default)"], B08_Quantity_Placing_Unset: ["안 정함(기본값 사용)", "Not set (default)"],