Author SHA1 Message Date
eomsangdonandClaude Opus 5 16d3461573 fix(b09): ㉱ 조립(묶음) 줄이 조각의 「일부만」·「밑수 없음」 을 올림 — 조각 제목만 있으면 온전한 금액처럼 서던 구조 결함
· 조각마다 보통 줄과 같은 두 검사 — 단가가 일부 몫만 섰거나(붙은 몫 N%) 밑수를 모르는 표면 묶음 금액을 안 세우고 어느 조각이 왜인지 사유
· 700줄 넘던 줄 만들기 파일에서 자재 줄(_material_row)을 자재 모듈로 그대로 옮김(순수 나누기 · 부르는 쪽 한 줄)

프로젝트 내역 그대로 · 잴 시험 둘 빨강→초록(온전한 조각 시험은 처음부터 초록) · 전체 시험 1705 + 367 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 00:45:40 +09:00
eomsangdon 4d33f8d61c Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-15 00:24:08 +09:00
eomsangdonandClaude Opus 5 ac66a25cc7 feat(b09): ㉰ 표토제거 답외구간 9-15-2 일위대가 — 계수표를 불도저 식 + T 로 · 영월 호표 어긋남(E 자리에 e) 기록
· 계수표(T·L·E·q0·e·f·V1·V2·t) 칸을 기호로 읽어 불도저 식(8-2-1 · dozer_hourly_output) 그대로 Q1 47.92 ㎥/hr · Q = Q1 ÷ T 0.2 = 239.60 ㎡/hr
· 기종은 q0 3.2 · V1 40 · V2 46(1단)로 8-2-1 표에서 되짚음 → 무한궤도 19ton([주]① 과 맞음) · D 줄 1/Q hr/㎡
· 원문 대 실무 어긋남 기록 — 영월 호표 Q=576.95 는 E 자리에 e(0.96)를 넣은 값으로 정확히 역산됨 · 다른 실무엔 이 호표 없음 → 원문이 또렷해 원문 E=0.4 로 셈(공종 사유 한 줄)

새 제목 B·D 618원/㎡ · 바뀐 제목 0 · 프로젝트 내역 표토제거 9,954.32㎡ 가 금액으로 섬(+6,151,768) · 잴 시험 둘 빨강→초록 · 끄기 셋 빨강 · 전체 시험 1703 + 364 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 00:23:33 +09:00
eomsangdon cd6c7f3fed Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-15 00:16:45 +09:00
eomsangdonandClaude Opus 5 b34eb546f9 feat(b06): 성토사면 길이 5m 초과 경고 줄 — 요약 한 줄 + 측점 목록 · 벽 선 쪽만 뺌(좌·우 갈라) · 경고만(㉳ (나) · 브레인 승인 문구)
- 판정 `_Cross_FillSlope_Warn` — 길이는 카드 머리 「성토사면」 칸과 같은 fillSlopeLengths · 5m 를 넘는 쪽만
- 벽 = 배관 유입(상단측)·유출 기슭막이(집수정은 아님) · 독립 기슭막이 설치 측 · 세월교·BOX암거 양쪽
- 카드 목록 위 <details> — 펼치면 측점 단추, 누르면 그 카드로 · 구조물 자동 배치 없음
- 700줄 — 뷰 조종기 타입을 `_Section_View_Types` 로 뗌(706 → 669)
- 936be972: 37측점 경고 · 5m 넘는 48측점 중 벽 선 11곳 빠짐(80·258.1·260·320·352.1 세월교·440·534·620·720·804.2·982.6)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-15 00:15:11 +09:00
eomsangdon 2ad20e5d02 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-15 00:04:52 +09:00
eomsangdonandClaude Opus 5 9deaf479d0 feat(b09): 판정표가 안 읽은 줄을 자동으로 「못 붙은 줄」 목록에 — 표를 넣을 때마다 손 사유를 안 달아도 안 샘
· 판정표 공통 한 곳(_unread_rows) — 읽힌 줄 밖의 줄을 올림
· 넘치지 않게: 읽힌 줄 · 이미 못 맞춤 이름 · 다른 갈래가 쓴 기계 줄 · 자원 머리(첫 줄 머리 모양 · 횟수별) · 빈 줄은 뺌 · 분류 딱지 줄(자재·인력·기계)은 이름 칸으로 · 비고는 글 앞머리와 함께
· 손 사유와 안 겹치게: 공종 사유 한 줄에 이미 적힌 이름은 안 올림 · 오늘 손으로 단 날개벽·면벽·맨홀 줄 목록 사유는 걷고 [주] 30% 할증만 남김
· 새던 셋이 함께 닫힘 — 12-15 거푸집·철근·뚜껑·설치비 · 12-04 비고(수직고 7m 할증) · 12-34-1 레미콘 별도계상

판정표 9표 늘어난 줄: 9-19-1 0 · 12-04 +1 · 12-15 +4 · 12-12 +8 · 12-13 +6 · 12-16 +7 · 12-34-1 +1 · 제목 금액·막힘·일부만 그대로 · 잴 시험 넷 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1702 + 362 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-15 00:04:41 +09:00
eomsangdonandClaude Opus 5 419148901b fix(b07): 횡단도 [확정]은 설계 상태만 올림 — 입력 셋으로 다시 계산한 값으로 정본을 덮던 것 걷음(브레인 ②)
- 옛 길: 지반·단면·측구 쪽 셋만으로 compute_cross_design 을 돌려 설계를 통째로 update
  → 암선·절토경사·표준 횡단·구조물 트림·사용자 입력(측구 끔)이 사라짐
- 936be972 실측(읽기만): 62측점 전부 단면적 바뀜 · 절토 5,868.92 → 4,650.76㎥(−20.8%) · 성토 +5.8% · 종점 1078.01 측구 끔→켬
- 지금: patch = {status: confirmed} 뿐 · 응답 설계는 저장값 그대로 · _recompute_confirmed_design 지움
- 확인: 실제 라우터로 23장 전부 [확정] → 62측점 상태 뺀 값 차이 0 · 상태 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 23:43:58 +09:00
eomsangdon b516ffed66 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 23:34:02 +09:00
eomsangdonandClaude Opus 5 15d91be7b0 feat(b09): 봉상후렉시블 셋(12-12 날개벽 · 12-13 면벽 · 12-16 맨홀) + 12-34-1 — 같은 진동기 줄 하나가 막던 네 공종을 판정표로 세움
· 12-12·12-13·12-16 — 12-15 집수정과 같은 모양(㎥ 줄 비고 인력 + 다짐기 줄) · 판정표 한 벌에 더함 · 맨홀은 비고가 끝 칸이 아니라 단위 뒤 칸 전체에서 찾음
· 비고가 인/㎥ 인 판정표는 밑수 없음 목록을 안 봄(12-12 는 「개소당」 머리조차 없음)
· 12-34-1 — 형태 판정 참조→소요량(「별도계상」 은 레미콘 줄 비고일 뿐) · 새 모양 per_m3_rows · 진동기(3.5HP) 대 2 → 4611-0350 × 2/5.4 · 봉상후렉시블 대 2 는 엔진식 진동기의 봉이라 안 셈 · 머리 개소당 ↔ 인력 0.17·0.29(12-16 ㎥당과 같음)·Q ㎥/hr ⇒ ㎥당으로 읽고 고른 쪽·버린 쪽을 사유에 나란히(브레인 판정)
· 범위 별칭 넷 → 엔진식 4611-0350 · 표의 나머지 줄(거푸집·철근·기초잡석·원형거푸집·설치비 등)은 원문 그대로 사유 한 줄(조용히 안 사라지게)
· 실무 영월 「콘크리트타설(임도품셈 5-5)」 호표가 같은 모양 — 인력 인/㎥ + 진동기 엔진식 1/Q · 손료 252천원×0.5101 · 노무 0
· 마스터 재생성 — 네 공종 갈래 키 · F0385 형태만

새 제목 다섯 · 바뀐 제목 0 · 프로젝트 내역 그대로 · 잴 시험 넷 빨강→초록 · 끄기 다섯 빨강 · 전체 시험 1699 + 337 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 23:33:47 +09:00
eomsangdon 0fa6cd0898 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1 2026-09-14 23:16:10 +09:00
eomsangdon 0989471901 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 23:16:09 +09:00
eomsangdonandClaude Opus 5 6ea73956e5 chore(tester): 잘못 올라간 pytest 임시 폴더 걷어냄(auto 커밋 8ed4be85 에 섞임 · 시험 산출물)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 23:14:42 +09:00
eomsangdon 8ed4be855b auto: 2026-09-14 23:13 (ESD_LAPTOP) 2026-09-14 23:13:49 +09:00
eomsangdonandClaude Opus 5 20597071bd fix(b06): 유토 배분 수량은 거르지 않은 계획 · 잔진동 거르기는 그림에만(브레인 ①)
- computeHaulPlan 기본 = 거르지 않음, drawing: true 일 때만 진폭 2% 거르기
- 서버 정본 두 벌 — haul_plan(수량·B08) · haul_plan_drawing(토적도) · 화면 선반입은 그림용
- B07 토적도는 그림용 먼저, 옛 저장분은 haul_plan
- 936be972 실측: 운반 0 → 245.81㎥(7블록 15띠) · 토취 11,100.86 그대로 · 내역 154,857,626 → 155,207,971

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 23:13:48 +09:00
eomsangdon fd0ea82975 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 22:56:28 +09:00
eomsangdonandClaude Opus 5 8dddc76839 feat(b09): 672 품 할증 여럿 고르면 합산 — 근거를 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + … + an)」 로(교차 참조)
· 산림품셈 1-4 에 겹침 규정이 없어 건설 공통 1-4-2 「할증의 중복가산요령」 을 따름 — 「우리가 둔 합산 · 확정 대기」 문구를 원문 근거로 바꿈
· 같은 조 단서 「동일성격의 품할증요소의 이중적용은 불가」 — 어느 계열끼리 동일성격인지 원문이 안 정해 막지 않고 화면 근거에 단서로 보임
· 셈법(합산) 자체는 그대로라 금액 변화 없음

잴 시험 둘 빨강→초록 · 전체 시험 1695 + 332 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 22:55:45 +09:00
33 changed files with 1392 additions and 219 deletions
+22 -11
View File
@@ -83,6 +83,8 @@ if (input.haul_plan_for) {
structure_spoil_points: input.context?.structure_spoil_points ?? null,
// 잔토는 자연상태로 오고 곡선은 다짐상태다 — 담기 전에 ×C 하는 데 쓴다.
conversion: input.context?.earthwork_conversion ?? null,
// 화면이 그리는 계획 — 잔진동을 거른다(수량은 저장 정본 `haul_plan` 이 따로 낸다).
drawing: true,
});
writeFileSync(outputPath, JSON.stringify({ haul_plan: plan ?? null }));
process.exit(0);
@@ -104,18 +106,27 @@ const result = conversion
)
: null;
// 배분은 **서버만** 만든다 — 그래야 그 코드가 브라우저 번들에서 빠진다(2026-09-06).
const plan = result
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
conversion: conversion ?? null,
})
: null;
// 두 벌을 남긴다 — `haul_plan` 은 **거르지 않은** 수량 정본(B08), `haul_plan_drawing` 은
// 잔진동을 거른 그림(B07 토적도). 거르기가 수량에 닿으면 운반이 사라진다(2026-09-14 브레인 ①).
const planFor = (drawing: boolean) =>
result
? computeHaulPlan(result, input.context?.haul_equipment_limits, {
collected_stone_deduction_m3: input.context?.collected_stone_deduction_m3 ?? null,
collected_stone_by_ground_m3: input.context?.collected_stone_by_ground_m3 ?? null,
collected_stone_ground_unknown_m3: input.context?.collected_stone_ground_unknown_m3 ?? null,
structure_spoil_m3: input.context?.structure_spoil_m3 ?? null,
structure_spoil_points: input.context?.structure_spoil_points ?? null,
conversion: conversion ?? null,
drawing,
})
: null;
const plan = planFor(false);
const drawingPlan = planFor(true);
const massHaul = result
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
? massHaulPayload(result, {
...(plan ? { haul_plan: haulPlanPayload(plan) } : {}),
...(drawingPlan ? { haul_plan_drawing: haulPlanPayload(drawingPlan) } : {}),
})
: null;
// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움.
@@ -0,0 +1,49 @@
/* =============================================================================
* B06_Section_UI_Cross_FillSlope_Notice.ts
* 성토사면 5m 초과 **경고 줄** — 횡단 카드 목록 위 요약 한 줄 + 펼치면 측점 목록
* (2026-09-14 브레인 승인 (나)). 판정은 `_Cross_FillSlope_Warn`, 여기는 그리기만.
* 측점을 누르면 그 카드로 간다. 경고만 — 구조물을 세우거나 값을 바꾸지 않는다.
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { fillSlopeLengths } from "./B06_Section_UI_Cross_Fit";
import { FILL_SLOPE_WARN_TEXT, fillSlopeWarnings } from "./B06_Section_UI_Cross_FillSlope_Warn";
import { L, stationLabel } from "./B06_Section_UI_Section_Common";
export interface FillSlopeNotice {
root: HTMLDetailsElement;
/** 측점 설계가 바뀔 때마다 부른다 — 펼침 상태는 그대로 둔다. */
update: (sections: ReadonlyArray<CrossSection>, stationInterval: number) => void;
}
export function createFillSlopeNotice(onPick: (stationId: string) => void): FillSlopeNotice {
const root = document.createElement("details");
root.className = "b06-section__notice";
root.hidden = true;
const summary = document.createElement("summary");
const list = document.createElement("div");
list.className = "b06-section__notice-list";
root.append(summary, list);
return {
root,
update(sections, stationInterval) {
const warnings = fillSlopeWarnings(sections, fillSlopeLengths);
root.hidden = !warnings.length;
summary.textContent = `${FILL_SLOPE_WARN_TEXT} · ${warnings.length}측점`;
list.replaceChildren(
...warnings.map(({ section, sides }) => {
const button = document.createElement("button");
button.type = "button";
const parts = sides.map(({ side, lengthM, open }) => {
const label = L(side === "left" ? "B06_Design_Ditch_Left" : "B06_Design_Ditch_Right");
return `${label} ${open ? "≥" : ""}${lengthM.toFixed(2)}m`;
});
button.textContent = `${stationLabel(section.chainage_m, stationInterval)} ${parts.join(" · ")}`;
button.addEventListener("click", () => onPick(section.station_id));
return button;
}),
);
},
};
}
@@ -0,0 +1,88 @@
/* =============================================================================
* B06_Section_UI_Cross_FillSlope_Warn.ts
* 성토사면 길이 5m 초과 **경고** 판정(2026-09-14 브레인 승인 (나)) — 값만 가리고 그리지 않는다.
*
* 근거 — 산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나)
* 「성토사면 길이 5m 초과 시 옹벽·석축」. 실무 표본(오솔길 W열 성토면 거리)에서도 흔해
* (영월 63% · 봉화 49%) **경고까지만** — 구조물을 자동으로 세우지 않는다(설계자 판단).
*
* 벽이 선 쪽은 뺀다 — 기슭막이·옹벽이 사면을 끊은 쪽은 이미 조치된 자리다. **좌·우를 갈라**
* 한쪽에만 벽이 서면 반대쪽은 그대로 경고한다.
* 길이는 `fillSlopeLengths`(카드 머리 「성토사면」 칸과 같은 값)를 받아 쓴다 — 여기서 다시 재지 않는다.
* ========================================================================== */
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { FILL_SLOPE_MAX_LENGTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
/** 브레인 승인 문구 그대로(2026-09-14) — 고치면 승인을 다시 받을 것. */
export const FILL_SLOPE_WARN_TEXT =
"성토사면 길이 5m 초과 — 법령상 옹벽·석축 설치 대상 (산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나)) ※ 실무 표본에서도 흔함(영월 63% · 봉화 49%) — 설치 여부는 설계자 판단";
export type FillSlopeSideName = "left" | "right";
export interface FillSlopeSideLength {
lengthM: number;
/** 원지반을 못 만나 거기까지만 잰 하한값. */
open: boolean;
}
export interface FillSlopeWarning {
section: CrossSection;
sides: Array<{ side: FillSlopeSideName } & FillSlopeSideLength>;
}
/** 벽이 서서 성토사면을 끊는 쪽 — 배관 기슭막이 · 독립 기슭막이 · 세월교·BOX암거 측벽. */
export function wallSides(section: CrossSection): Set<FillSlopeSideName> {
const sides = new Set<FillSlopeSideName>();
if (section.ford || section.box) return new Set(["left", "right"]);
const culvert = section.culvert;
if (culvert?.hidden_pipe) {
// 독립 기슭막이 설치 측 — 좌 = +offset(`restrictToSide`) · 양쪽·미지정은 둘 다.
if (culvert.side !== "우") sides.add("left");
if (culvert.side !== "좌") sides.add("right");
} else if (culvert) {
// 유입 = 상단측(미상이면 좌) · 집수정은 벽이 아니다.
const inlet: FillSlopeSideName = (section.uphill_side ?? "left") === "left" ? "left" : "right";
if (culvert.inlet.structure !== "집수정") sides.add(inlet);
if (culvert.outlet.structure !== "집수정") sides.add(inlet === "left" ? "right" : "left");
}
const revetment = section.revetment;
if (revetment) {
// 설치 측이 비면 성토가 나는 쪽(`computeRevetmentLayout` 과 같은 규칙).
const mode = section.design?.section_mode;
const side =
revetment.side === "우"
? "right"
: revetment.side === "좌"
? "left"
: mode === "left_cut"
? "right"
: mode === "right_cut" || mode === "both_fill"
? "left"
: null;
if (side) sides.add(side);
}
return sides;
}
/** 5m 를 **넘는** 성토사면(벽 선 쪽 뺌)이 있는 측점만 — 측점 순서 그대로. */
export function fillSlopeWarnings(
sections: ReadonlyArray<CrossSection>,
lengthsOf: (section: CrossSection) => Record<FillSlopeSideName, FillSlopeSideLength | null>,
): FillSlopeWarning[] {
const warnings: FillSlopeWarning[] = [];
for (const section of sections) {
const lengths = lengthsOf(section);
const walls = wallSides(section);
const sides = (["left", "right"] as const)
.filter((side) => !walls.has(side))
.flatMap((side) => {
const length = lengths[side];
return length && length.lengthM > FILL_SLOPE_MAX_LENGTH_M + 1e-6
? [{ side, ...length }]
: [];
});
if (sides.length) warnings.push({ section, sides });
}
return warnings;
}
+9 -48
View File
@@ -20,7 +20,6 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import type {
CrossSection,
EarthworkConversion,
HaulEquipmentLimit,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
@@ -81,53 +80,10 @@ import {
inferStationInterval,
L,
} from "./B06_Section_UI_Section_Common";
import type { SectionViewController } from "./B06_Section_UI_Section_View_Types";
import { createFillSlopeNotice } from "./B06_Section_UI_Cross_FillSlope_Notice";
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
/**
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
*
* 평소에는 쓰지 않는다 — 그래프 몫은 `chartWrap`을 직접 재서 정한다(어림값이 실제보다 크면
* 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는
* 출발값이고, 최소 패널 높이 계산의 기준이기도 하다.
*/
export interface SectionViewController {
root: HTMLElement;
render: (
detail: SectionDetailResponse,
verticalExaggeration: number,
crossHalfWidth?: number,
stationInterval?: number,
earthworkConversion?: EarthworkConversion,
haulEquipmentLimits?: HaulEquipmentLimit[],
/** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */
balloonScope?: string,
/** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */
naturalSpoilMinSlope?: number,
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
refreshCards: (chainages: ReadonlyArray<number>) => void;
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */
focusStation: (stationId: string) => void;
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
* 올린다(2026-08-29 일원화). null = 선택 해제. */
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
clear: () => void;
/** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자
* 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */
setStructureMarks: (
structures: ReadonlyArray<StructureInstance>,
types: ReadonlyArray<StructureType>,
) => void;
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
setStructureEdit: (edit: SectionStructureEdit | null) => void;
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
dispose: () => void;
}
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl, SectionViewController };
export function createSectionView(
onDesignChange?: DesignChangeHandler,
@@ -236,6 +192,10 @@ export function createSectionView(
},
});
panel.append(panelResizer.root);
// 성토사면 5m 초과 경고 줄(2026-09-14 브레인 (나)) — 패널과 카드 사이 · 측점 누르면 그 카드로.
const fillSlopeNotice = createFillSlopeNotice((id) =>
selectedStationId === id ? revealCard(id, "smooth") : selectStation(id, true),
);
/**
* 패널 **어디에서든** 굴린 휠은 페이지가 아니라 그래프를 좌우로 민다(2026-08-02 사용자 지시).
@@ -489,6 +449,7 @@ export function createSectionView(
// 보던 자리가 맨 앞으로 튀면 못 쓴다. 위치를 잡아 뒀다 되돌린다.
const keepScrollLeft = chartWrap.scrollLeft;
const detail = currentDetail;
fillSlopeNotice.update(detail.cross_sections, cachedStationInterval); // 카드 갱신도 여기를 거침
// 그래프 몫은 **추정하지 않고 잰다**. `chartWrap`은 `flex: 1 / min-height: 0`이라 높이가
// 내용이 아니라 패널에서 정해지므로, 재서 쓰면 되먹임 없이 한 번에 수렴한다.
// 화면에 붙기 전(detached)에는 잴 수 없으니 그때만 `PANEL_CHROME_PX` 추정치로 시작한다.
@@ -599,7 +560,7 @@ export function createSectionView(
}
// panel은 이미 root의 자식이라 replaceChildren이 떼었다 붙이면서 스크롤을 잃는다.
const keepScrollLeft = chartWrap.scrollLeft;
root.replaceChildren(panel, grid);
root.replaceChildren(panel, fillSlopeNotice.root, grid);
chartWrap.scrollLeft = keepScrollLeft;
};
@@ -0,0 +1,59 @@
/* =============================================================================
* B06_Section_UI_Section_View_Types.ts
* `createSectionView` 가 돌려주는 조종기 모양 — 700줄 제한으로 뷰 본체에서 떼어 냄(2026-09-14).
* 부르는 쪽은 종전대로 `_UI_Section_View` 에서 가져간다(거기서 다시 내보냄).
* ========================================================================== */
import type {
EarthworkConversion,
HaulEquipmentLimit,
SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
import type { LongitudinalPanelInput } from "./B06_Section_UI_Section_View_Draw";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
/**
* 상태 행·요약줄·하단 접기 손잡이·테두리가 먹는 세로 공간의 **어림값**.
*
* 평소에는 쓰지 않는다 — 그래프 몫은 `chartWrap`을 직접 재서 정한다(어림값이 실제보다 크면
* 유토곡선 아래에 빈 공간이 남는다). 화면에 붙기 전이라 잴 수 없는 첫 렌더에서만 쓰는
* 출발값이고, 최소 패널 높이 계산의 기준이기도 하다.
*/
export interface SectionViewController {
root: HTMLElement;
render: (
detail: SectionDetailResponse,
verticalExaggeration: number,
crossHalfWidth?: number,
stationInterval?: number,
earthworkConversion?: EarthworkConversion,
haulEquipmentLimits?: HaulEquipmentLimit[],
/** balloon 위치 캐시를 가르는 키(프로젝트+경로). 노선이 다르면 위치가 섞이면 안 된다. */
balloonScope?: string,
/** 자연방토 판정 경사(config). 못 받으면 자연방토 없음으로 본다. */
naturalSpoilMinSlope?: number,
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
refreshCards: (chainages: ReadonlyArray<number>) => void;
/** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음
* (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */
focusStation: (stationId: string) => void;
/** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을
* 올린다(2026-08-29 일원화). null = 선택 해제. */
setStationSelectListener: (listener: (stationId: string | null) => void) => void;
clear: () => void;
/** 종단 아래 구조물 알약 레인에 쓸 목록 — B05 와 같은 부품·같은 표기(2026-09-07 사용자
* 지시 4 「구조물 표시 통일」). 좌측 「구조물 배치」가 목록을 받을 때마다 넘겨준다. */
setStructureMarks: (
structures: ReadonlyArray<StructureInstance>,
types: ReadonlyArray<StructureType>,
) => void;
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
setStructureEdit: (edit: SectionStructureEdit | null) => void;
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
dispose: () => void;
}
@@ -195,6 +195,29 @@
gap: var(--spacing-16);
}
/* 성토사면 5m 초과 경고 줄(2026-09-14) — 요약 한 줄, 펼치면 측점 단추 목록. */
.b06-section__notice {
margin-bottom: var(--spacing-8);
color: var(--color-warning);
font-size: var(--text-caption);
}
.b06-section__notice > summary {
cursor: pointer;
}
.b06-section__notice-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
padding-top: var(--spacing-4);
}
.b06-section__notice-list > button {
font-size: var(--text-caption);
white-space: nowrap;
}
.b06-cross-card {
cursor: pointer;
transition:
@@ -520,7 +520,9 @@ def build_mass_haul_drawing(
if curve_entity:
entities.append(curve_entity)
plan = mass_haul.get("haul_plan")
# 그림은 잔진동을 거른 계획을 그린다 — 수량 정본(`haul_plan`)은 거르지 않아 balloon 이
# 너무 많다(2026-09-14 브레인 ①). 옛 저장분은 그림용이 없어 `haul_plan` 을 그린다.
plan = mass_haul.get("haul_plan_drawing") or mass_haul.get("haul_plan")
if isinstance(plan, dict):
entities.extend(_band_entities(drawing_id, plan, curve, mm_h))
entities.extend(_residual_entities(drawing_id, plan, curve, interval_m, mm_h))
+13 -31
View File
@@ -43,7 +43,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import (
_invalidate_drawing,
_read_drawing,
_read_json,
_recompute_confirmed_design,
_store_confirmed_drawing,
landuse_source,
lidar_source,
@@ -405,7 +404,7 @@ async def confirm_design_drawing(
) -> DesignDrawingConfirmResponse | JSONResponse:
"""현재 편집 도면을 영구 저장하고 도면별 확정 상태를 기록한다.
횡단도 확정 B06 지정 잠정치를 동일 엔진으로 재계산해 확정치로 승격·저장한다.
횡단도 확정 담긴 측점 설계의 **상태만** 확정으로 올린다(값은 B06 정본 그대로).
"""
try:
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
@@ -444,9 +443,10 @@ async def confirm_design_drawing(
quantity_tables or None,
)
# 횡단도면이면 확정 단면적을 재계산한다 (재계산 실패는 도면 확정을 막지 않음).
# 장은 담긴 측점 전부를 함께 확정한다.
recomputed: list[tuple[int, dict[str, Any]]] = []
# 횡단도면이면 담긴 측점 설계를 확정으로 올린다 — **상태만** 바꾼다. 장은 담긴 측점 전부.
# B07 CAD 에는 설계를 고치는 자리가 없어 덮을 값이 없다. 예전에는 입력 셋(지반·단면·측구
# 쪽)만으로 단면적을 다시 계산해 설계를 통째로 덮어, 암선·절토경사·표준 횡단·구조물
# 트림과 사용자 입력(측구 끔)이 사라졌다(2026-09-14 936be972 실측 62측점 · 브레인 ②).
cross_match = _CROSS_ID.fullmatch(drawing_id)
pool = get_db_pool()
targets: list[int] = []
@@ -454,39 +454,21 @@ async def confirm_design_drawing(
targets = list(sheet["chainages"])
elif item.kind == "cross" and cross_match:
targets = [int(cross_match.group(1))]
for chainage_int in targets:
designation = designs.get(chainage_int)
if not designation:
continue
try:
recomputed.append(
(
chainage_int,
await asyncio.to_thread(
_recompute_confirmed_design,
longitudinal_path,
f"cross_{chainage_int:05d}m",
designation,
),
)
)
except (ValueError, KeyError, FileNotFoundError, OSError):
logger.warning(
"B07 확정 단면적 재계산 실패 (도면 확정은 유지): drawing_id=%s 측점=%s",
drawing_id,
chainage_int,
exc_info=True,
)
confirmed_designs = [
(chainage_int, {**designs[chainage_int], "status": "confirmed"})
for chainage_int in targets
if designs.get(chainage_int)
]
async with pool.acquire() as connection:
await connection.begin()
try:
for chainage_int, confirmed_design in recomputed:
for chainage_int, _design in confirmed_designs:
await merge_cross_section_design_by_round(
connection,
route_id=route_id,
chainage_int=chainage_int,
patch=confirmed_design,
patch={"status": "confirmed"},
)
async with connection.cursor() as cursor:
if all_confirmed:
@@ -502,7 +484,7 @@ async def confirm_design_drawing(
id=drawing_id,
confirmed=True,
all_confirmed=all_confirmed,
design=recomputed[0][1] if len(recomputed) == 1 else None,
design=confirmed_designs[0][1] if len(confirmed_designs) == 1 else None,
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
@@ -646,32 +646,3 @@ def _invalidate_drawing(project_root: Path, drawing_id: str) -> None:
if entry:
entry["confirmed"] = False
_write_manifest(project_root, manifest)
def _recompute_confirmed_design(
longitudinal_path: Path, cross_stem: str, designation: dict[str, Any]
) -> dict[str, Any]:
"""B06 지정값과 현재 계획고로 절·성토 단면적을 재계산해 확정치(status=confirmed)로 만든다.
B07 CAD에는 아직 편집 가능한 설계선이 없으므로, 저장된 지정값(지반유형·단면유형·
측구위치) 계획고로 동일 엔진을 재실행해 확정 시점 값을 고정한다.
"""
longitudinal = _read_json(longitudinal_path)
cross_path = longitudinal_path.parent.parent / "cross_sections" / f"{cross_stem}.json"
source = _read_json(cross_path)
samples = source.get("samples")
if not isinstance(samples, list):
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
design_elevation = design_elevation_from_longitudinal(
longitudinal, float(source.get("chainage_m", 0.0))
)
design = compute_cross_design(
samples,
design_elevation,
ground_type=designation["ground_type"],
section_mode=designation["section_mode"],
ditch_side=designation.get("ditch_side"),
**curve_widening_args(source),
)
design["status"] = "confirmed"
return design
+1 -1
View File
@@ -86,7 +86,7 @@ class DesignDrawingConfirmResponse(BaseModel):
id: str
confirmed: bool
all_confirmed: bool
# 횡단도 확정 시 재계산된 확정 설계(status=confirmed). 종단도·재계산 불가 시 None.
# 횡단도 확정 시 저장된 설계(상태만 status=confirmed). 종단도·장·설계 없음이면 None.
design: dict[str, Any] | None = None
+1 -1
View File
@@ -418,7 +418,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// **기다린다**: 안 기다리면 오버레이가 먼저 걷혀, 버튼은 [수정]인데 CAD는 아직
// 편집이 열린 어긋난 순간이 생긴다.
await loadDrawing(currentDrawing, currentIndex);
// 확정 시 재계산된 확정 단면적으로 지반/계획 정보 패널을 갱신한다.
// 확정한 설계(B06 정본 그대로 · 상태만 확정)로 지반/계획 정보 패널을 갱신한다.
if (currentDrawing.kind === "cross") {
infoPanelHost.replaceChildren(
buildDesignInfoPanel(
@@ -181,7 +181,7 @@ function infoRow(label: string, value: string): HTMLElement {
}
/**
* / 2 (, B07 ).
* / 2 (B06 · B07 ).
*
* **( ) ** `design`
* (2026-09-03 ).
@@ -110,6 +110,11 @@ FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
# 절 제목으로 읽어 12-2 에 붙어 있던 표(빌더가 앞 절 이어받기로 고침). 형태 판정은 그대로.
"F0358": ("12-17-1", "reference", "시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명"),
"F0360": ("12-17-1", "reference", "현장조건 Type-Ⅰ~Ⅲ 적용 기준 설명"),
"F0385": (
"12-34-1",
"requirement",
"인력(인)·기계(대, Q=5.4㎥/hr) 소요량 표 — 「별도계상」 은 레미콘 자재 줄 비고일 뿐",
),
"F0388": ("12-34-4", "reference", "「재료비 JOINT FILLER」 항목만 — 값이 없는 구성 안내"),
"F0390": ("12-36", "reference", "제작비·운송비·설치비 「견적처리」"),
"F0392": ("12-38-1", "coefficient", "사용횟수별 잔존율(12회 25%·25회 10%)"),
@@ -414,11 +414,11 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402
_composite_row,
_excluded_row,
_leaf_row,
_material_row,
_structure_price_row,
_sum_groups,
bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름)
)
from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import _material_row # noqa: E402
def build_bill(
@@ -15,7 +15,13 @@ from __future__ import annotations
from decimal import Decimal
from typing import Any, Callable
from B09_Estimation.B09_Estimation_BillOfQuantities import BillResult, BillRow
from B09_Estimation.B09_Estimation_BillOfQuantities import (
SUPPLY_OWNER,
SUPPLY_UNKNOWN,
BillResult,
BillRow,
HandoffMaterial,
)
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceKind
from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, UnitPriceBuild
@@ -24,6 +30,71 @@ DOUBLE_COUNT_SUSPECT = "double_count_suspect"
_ZERO = Decimal(0)
def _material_row(
material: HandoffMaterial, result: BillResult, manual: dict | None = None
) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.
`manual` 자재 단가 수동 단가( 이름 규격). 사급 줄에 값이 있으면 빠진 목록에 올림
(금액은 본체 자재(사급) 줄이 `BillOfQuantities_Materials`).
"""
row = BillRow(
item_no="",
level=1,
code=None,
name=material.material_name,
spec=material.spec,
unit=material.unit,
quantity=material.total_amount,
)
# 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다.
row.add_note("quantity", material.surcharge_note)
if material.supply_type == SUPPLY_UNKNOWN:
row.add_note(
"", "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다."
)
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": "공급 구분 미정(unknown)",
}
)
return row
# ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이
# `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도
# 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지).
if material.supply_type == SUPPLY_OWNER:
row.add_note(
"unit_price_krw",
"관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
"관급자재대(총원가 밖 별도 표기)로 갑니다.",
)
reason = "관급 자재 단가 없음"
elif f"{material.material_name} {material.spec}".strip() in (manual or {}):
row.add_note(
"unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬"
)
return row
else:
row.add_note(
"unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기."
)
reason = "사급 자재 단가 없음(미결 No.18)"
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": reason,
"supply_type": material.supply_type,
}
)
return row
def _flat(text: Any) -> str:
return "".join(str(text or "").split())
@@ -12,11 +12,8 @@ from __future__ import annotations
from decimal import Decimal
from B09_Estimation.B09_Estimation_BillOfQuantities import (
SUPPLY_OWNER,
SUPPLY_UNKNOWN,
BillResult,
BillRow,
HandoffMaterial,
HandoffWorkItem,
_BLOCKED_LABELS,
_MasterNode,
@@ -112,6 +109,7 @@ def _composite_row(
in_bill=item.in_bill,
)
missing_parts: list[str] = []
reasons: list[str] = []
money = None
for part in item.composite_parts:
code = str(part.get("code") or "")
@@ -119,12 +117,24 @@ def _composite_row(
if not code or amount is None or f"B-{code}" not in unit_prices.book.titles:
missing_parts.append(code or str(part.get("name") or "이름 없음"))
continue
# 조각도 보통 줄과 같은 두 검사 — 일부 몫만 선 단가·밑수 모르는 표를 묶음에 더하면
# 묶음 줄만 온전한 금액처럼 섬(2026-09-14 ㉱ 구조 결함).
plain = code.split("#", 1)[0]
covered = unit_prices.partial_ratio.get(plain)
basis = unit_prices.basis_missing.get(plain)
if covered is not None or basis:
missing_parts.append(code)
reasons.append(
f"{code}: 단가 일부만 섬(붙은 몫 {covered}%)"
if covered is not None
else f"{code}: 밑수 미확보 — 원문 {basis}"
)
continue
# 묶음도 호표 한 장 — 조각 줄 0.1원 · 성분 소계 원 미만 절사(아래 `floored`, 명세 7장).
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1"))
money = scaled if money is None else money + scaled
row.parts.append((f"B-{code}", amount))
reasons: list[str] = []
for pending in item.composite_not_ready:
# 「단가 없음」과 「물량 없음」을 가른다 — 사유가 다르면 할 일도 다르다.
if isinstance(pending, str):
@@ -613,71 +623,6 @@ def pending_formula_note(code: str | None) -> str:
return ""
def _material_row(
material: HandoffMaterial, result: BillResult, manual: dict | None = None
) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.
`manual` 자재 단가 수동 단가( 이름 규격). 사급 줄에 값이 있으면 빠진 목록에 올림
(금액은 본체 자재(사급) 줄이 `BillOfQuantities_Materials`).
"""
row = BillRow(
item_no="",
level=1,
code=None,
name=material.material_name,
spec=material.spec,
unit=material.unit,
quantity=material.total_amount,
)
# 할증 사유는 **수량**에 닿는다 — 할증이 곱해진 뒤의 수량이기 때문이다.
row.add_note("quantity", material.surcharge_note)
if material.supply_type == SUPPLY_UNKNOWN:
row.add_note(
"", "관급·사급이 안 갈렸습니다 — 관급자재대에도, 도급 재료비에도 넣지 않습니다."
)
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": "공급 구분 미정(unknown)",
}
)
return row
# ⚠ **관급을 「사급」이라 적으면 안 된다** (2026-09-08 메인 창 실측 — 물구멍·야면석이
# `owner_supplied` 인데 「사급 자재 단가 미확보」로 뜨고 있었다). 갈래마다 **가는 자리도
# 원천도 다르다** — 관급은 총원가 밖 관급자재대(나라장터), 사급은 도급 재료비(물가지).
if material.supply_type == SUPPLY_OWNER:
row.add_note(
"unit_price_krw",
"관급 자재 단가 미확보 — 나라장터 목록에 이 품목이 없습니다. "
"관급자재대(총원가 밖 별도 표기)로 갑니다.",
)
reason = "관급 자재 단가 없음"
elif f"{material.material_name} {material.spec}".strip() in (manual or {}):
row.add_note(
"unit_price_krw", "⚠ 사급 자재 수동 단가(미확정) — 본체 「자재(사급)」 줄로 섬"
)
return row
else:
row.add_note(
"unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기."
)
reason = "사급 자재 단가 없음(미결 No.18)"
result.missing.append(
{
"name": material.display_name,
"unit": material.unit,
"quantity": str(material.total_amount),
"reason": reason,
"supply_type": material.supply_type,
}
)
return row
#: 같은 단위의 다른 표기 — 표기만 다르고 뜻이 같은 것을 「다르다」고 하면 멀쩡한 줄이 멈춘다.
_UNIT_ALIASES = {
"": "m3",
@@ -55,6 +55,21 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).",
),
# 2026-09-14 ㉮ — 사용횟수 갈래로 푼 뒤 남는 원문 몫. 값을 짓지 않고 말만.
# 봉상후렉시블 셋의 표 나머지 줄은 판정표 자동 목록(`_unread_rows`)이 맡음 — 줄이 아닌 [주] 만 여기.
"FP-12-12": ("원문 [주]", "ⓘ [주] 「성토부 날개벽 설치시 인건비 30% 할증」 은 안 걺(선택)."),
# 원문 대 실무 어긋남 기록(2026-09-15 브레인 규칙 — 원문이 또렷하면 원문 · 어긋남은 늘 기록).
"FP-09-15-02": (
"실무 어긋남",
"ⓘ 실무 영월 「표토제거 답외구간」 호표 Q=576.95 는 E 자리에 e(0.96)를 넣은 값으로 역산됨"
"(60×3.07×0.77×0.96÷(1.18×0.2)) — 원문 9-15-2 가 E=0.4 로 또렷해 원문으로 셈 · 다른 실무(봉화·대흥·"
"소광·거창)엔 이 호표 없음.",
),
"FP-12-34-01": (
"원문 머리",
"ⓘ 원문 머리 「(단위: 개소당)」 ↔ 인력 콘크리트공 0.17·보통인부 0.29 가 12-16 맨홀 ㎥당(0.17인/㎥"
" · 0.29인/㎥)과 같고 기계도 Q=5.4㎥/hr ⇒ ㎥당으로 읽음(고른 쪽 ㎥당 · 버린 쪽 개소당) ·"
" 봉상후렉시블(45mm) 대 2 는 엔진식 진동기의 봉이라 두 번 안 셈(2026-09-14 브레인).",
),
"FP-12-04": (
"원문 [주]",
"ⓘ 「사용고재 평가기준 23%(합판과 각재의 설계단가 기준)」 은 원문이 셈을 안 줘 값으로 안 씀"
@@ -12,8 +12,10 @@
(1-4-1 어린나무가꾸기에 한하여 · 1-4-2 줄베기 · 1-4-9 숲가꾸기 병해충방제
작업로). **임도 토공에 붙이라는 지시가 원문에 없다.** 그래서 켜는 것은 사용자 몫이고,
계열의 **[] 원문을 화면에 그대로** 띄워 어디에 쓰라는 표인지 보이게 한다.
**여럿을 고를 합산인가 곱인가** 원문에 없다. 지금은 **합산**으로 두고 사실을
화면 근거에 적는다(실무 서식이 대개 합산이나 원문 근거는 아니다).
**여럿을 고를 합산인가 곱인가** 산림품셈 1-4 에는 없고 **건설 공통 1-4-2 할증의
중복가산요령** 정함(교차 참조 · 2026-09-14 브레인 672): W = 기본품 × (1 + a1 + + an)
· , 동일성격의 품할증요소의 이중적용은 불가 **합산**. 동일성격 어느 계열끼리인지는
원문이 정해 막지 않고 단서를 화면에 보임(설계자가 가림).
**기본은 고름** 계열도 고르면 금액이 원도 움직인다.
@@ -46,13 +48,14 @@ _NOTE_LOOKAHEAD = 12
_RE_PERCENT = re.compile(r"^-?\d+(?:\.\d+)?%$")
_RE_SECTION = re.compile(r"^(1-4-\d+)\.\s*(.+)$")
#: ⚠ **여럿을 고를 때 어떻게 셈하나 — 원문이 안 정한 자리다.**
#: 지금은 「합산」이고 **여기 한 곳만 갈아 끼우면 바뀐다**(코드 깊이 박지 않는다).
#: 여럿을 고를 때 셈법 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + … + an)」 합산(교차 참조 · 672).
#: **여기 한 곳**이 정한다(코드 깊이 박지 않는다).
#: `"sum"` = 10% + 5% = 15% · `"product"` = 1.10 × 1.05 1 = 15.5%
COMBINE_RULE = "sum"
COMBINE_NOTE = (
"여럿을 고르면 더합니다 — 원문이 합산인지 곱인지 안 정해 우리가 그렇게 두었습니다"
" (사용자 확정 대기)."
"여럿을 고르면 합산 — 건설 공통 1-4-2 「W = 기본품 × (1 + a1 + a2 + … + an)」"
"(산림품셈 1-4 에 겹침 규정이 없어 교차 참조) · ⚠ 같은 조 단서 「동일성격의 품할증요소의"
" 이중적용은 불가」 — 어느 계열끼리 동일성격인지는 원문이 안 정해 설계자가 가림"
)
SEAT_NOTE = (
"품 할인·할증은 품(인력) 줄에 붙습니다 — 물량에 곱하면 자재·기계까지 부풀어"
@@ -63,7 +63,82 @@ JUDGED_TABLES: dict[str, dict[str, Any]] = {
"needs_machine": {"구체콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6460 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
# 12-15 와 같은 모양 셋 — 같은 봉상후렉시블 줄 하나가 셋을 막고 있었음(2026-09-14 브레인 · 672 다음).
"F0350": {
"code": "FP-12-12",
"shape": "remark_labor",
"prefix": "날개벽",
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6419 12-12 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
"F0351": {
"code": "FP-12-13",
"shape": "remark_labor",
"prefix": "면벽",
"needs_machine": {"콘크리트": "다짐:봉상후렉시블(45mm)"},
"why": "원문 L6436 12-13 「콘크리트(레미콘) ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
"F0354": {
"code": "FP-12-16",
"shape": "remark_labor",
"prefix": "맨홀",
# 칸이 하나 밀려 비고가 끝 칸이 아님(「구체콘크리트 | 철근 | ㎥ | | 비고 | 」).
"needs_machine": {"구체콘크리트": "봉상후렉시블(45mm)"},
"why": "원문 L6474 12-16 「구체·버림콘크리트 ㎥ — 비고 칸 콘크리트공·보통인부 인/㎥」",
},
# 12-34-1 — 머리 「(단위: 개소당)」 이나 인력 0.17·0.29 가 12-16 맨홀 ㎥당과 같고 기계가 Q ㎥/hr
# ⇒ ㎥당으로 읽음 · 「봉상후렉시블 대 2」 는 엔진식 진동기(엔진+플렉시블 한 대)의 봉(2026-09-14 브레인).
"F0385": {
"code": "FP-12-34-01",
"shape": "per_m3_rows",
"prefix": "콘크리트 타설",
# 엔진식 진동기(건설품셈 8-3 (4611) 엔진+플렉시블 한 대)의 봉 — 「진동기(3.5HP) 대 2」 로 셈.
"same_machine": ("봉상후렉시블(45mm)",),
"why": "원문 L6839 12-34-1 「인력 콘크리트공·보통인부 인 · 기계 대 (Q=5.4㎥/hr)」",
},
}
#: 줄 첫 칸이 분류 딱지인 표(12-34-1 「자재 | 콘크리트(레미콘)」) — 이름은 다음 칸.
_ROW_CATEGORIES = ("자재", "인력", "기계")
#: 자원이 아닌 머리 줄(12-04 「횟수별 | 재료별(%) | 노무비(%)」).
_HEADER_ROWS = ("횟수별", "구분")
UNREAD_REASON = "판정표가 안 읽은 줄 — 이 일위대가에 안 넣음(자동 · 2026-09-14)"
def _loose(text: str) -> str:
"""겹침 비교용 — 빈칸·괄호·가운뎃점·쉼표를 뺌(「적사(굴착기 0.7㎥)」 ↔ 「적사 굴착기 0.7㎥」)."""
return re.sub(r"[\s()·,:]", "", str(text))
def _unread_rows(code, table_id, judged, rows, staged) -> list:
"""읽힌 줄 밖의 줄을 「못 붙은 줄」 로 — 표를 넣을 때마다 손으로 사유를 안 달아도 안 샘.
빼는 : 읽힌 (`raw_row_index`) · 이미 맞춤으로 이름 · 다른 갈래가 기계
(`needs_machine`·`same_machine`) · 자원 머리(`header_row` · 횟수별) ·
사유(`known_gap_note`) 이미 적은 이름은 올림 같은 말이 뜨게
"""
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
read = {item.raw_row_index for item in staged if isinstance(item, ResourceRow)}
taken = {_loose(item.cell) for item in staged if isinstance(item, UnmatchedRow)}
taken |= {_loose(name) for name in judged.get("needs_machine", {}).values()}
taken |= {_loose(name) for name in judged.get("same_machine", ())}
hand = _loose(known_gap_note(code))
unread: list = []
for index, cells in enumerate(rows):
cells = [c for c in cells]
if index in read or not any(cells) or (judged["shape"] == "header_row" and index == 0):
continue
name = cells[1] if cells[0] in _ROW_CATEGORIES and len(cells) > 1 else cells[0]
key = _loose(name)
if not key or key in _HEADER_ROWS or key in taken or key in hand:
continue
if key == "비고":
name = f"비고 — {' '.join(' '.join(cells[1:]).split())[:40]}"
unread.append(UnmatchedRow(code, table_id, " ".join(name.split()), UNREAD_REASON))
taken.add(key)
return unread
#: 비고 칸 인력 — 「콘크리트공0.24인/㎥, 보통인부 0.42인/㎥」.
_RE_REMARK_LABOR = re.compile(r"([가-힣]+)\s*(\d+(?:\.\d+)?)\s*인/㎥")
_RE_Q = re.compile(r"Q\s*=\s*(\d+(?:\.\d+)?)")
@@ -122,7 +197,8 @@ def match_judged_table(
result.partial_items[code] = why
return True
if basis_quantity in (None, 0):
# 비고·Q 가 ㎥당을 적는 모양은 표 머리 밑수를 안 씀(12-12 날개벽은 「개소당」 머리조차 없음).
if basis_quantity in (None, 0) and judged["shape"] not in ("remark_labor", "per_m3_rows"):
return block("판정 표에 밑수가 없습니다")
if judged["shape"] == "header_row":
staged = _header_row(code, table, judged, rows, catalog, basis_quantity, unit)
@@ -130,10 +206,13 @@ def match_judged_table(
staged = _use_count(code, table, rows, catalog, basis_quantity, unit)
elif judged["shape"] == "remark_labor":
staged = _remark_labor(code, table, judged, rows, catalog)
elif judged["shape"] == "per_m3_rows":
staged = _per_m3_rows(code, table, judged, rows, catalog)
else:
staged = _merged_first(code, table, judged, rows, catalog, basis_quantity, unit)
if isinstance(staged, str):
return block(f"{staged} — 판정({judged['why']})과 칸이 달라 안 읽음")
staged = [*staged, *_unread_rows(code, table_id, judged, rows, staged)]
for item in staged:
if isinstance(item, UnmatchedRow):
result.unmatched.append(item)
@@ -240,7 +319,7 @@ def _remark_labor(code, table, judged, rows, catalog) -> list | str:
by_name = {"".join(cells[0].split()): cells for cells in rows if cells}
staged: list = []
for index, cells in enumerate(rows):
labors = _RE_REMARK_LABOR.findall(cells[-1] if cells else "")
labors = _RE_REMARK_LABOR.findall(" ".join(cells[3:])) # 비고가 끝 칸이 아닌 표(12-16)
if len(cells) < 3 or cells[2] != "" or not labors:
continue
variant = cells[0].split("(")[0].strip()
@@ -268,3 +347,26 @@ def _remark_labor(code, table, judged, rows, catalog) -> list | str:
for entry, amount in pieces:
staged.append(_row(code, table, entry, amount, "", index, variant))
return staged
def _per_m3_rows(code, table, judged, rows, catalog) -> list | str:
"""「인」 칸 앞 이름 · 뒤 수(인/㎥) · 「대」 칸 앞 이름 · 뒤 대수 ÷ Q — 칸이 밀린 줄도 단위 칸으로 찾음."""
staged: list = []
for index, cells in enumerate(rows):
unit = next((i for i, c in enumerate(cells) if c in ("", "") and i > 0), None)
if unit is None:
continue
name = cells[unit - 1]
if "".join(name.split()) in judged.get("same_machine", {}):
continue # 같은 기계 두 번 안 셈 — 까닭은 공종 사유 한 줄(`KnownGaps`)이 화면에 보임
amount = next((parse_amount(c) for c in cells[unit + 1 :] if parse_amount(c)), None)
entry = _entry(catalog, name, code)
if amount is None or entry is None:
return f"{name}"
if cells[unit] == "":
found_q = _RE_Q.search(" ".join(cells))
if found_q is None:
return f"{name} Q"
amount = amount / Decimal(found_q.group(1))
staged.append(_row(code, table, entry, amount, "", index, ""))
return staged
@@ -0,0 +1,112 @@
"""B09 원가계산 — **표토제거 답외구간** 9-15-2 (2026-09-14 브레인 ㉰ 첫째).
B08 준비공 표토제거 줄이 부르는 코드인데 표가 계수표(T·L·E·q0·e·f·V1·V2·t) 일위대가가 섰음.
q = q0 × e · = L/V1 + L/V2 + t · Q1 = 60 × q × f × E / (/hr) · Q = Q1 / T (/hr)
[] 무한궤도 불도저(19ton) · 건설품셈 8-2-1 불도저 참조 불도저 (`dozer_hourly_output`) 그대로 + T 나눔
기종은 표의 q0·V1·V2(1) 8-2-1 표에서 되짚음(`resolve_dozer`) [] 19ton 맞는지 시험이
실무 영월 호표 Q=576.95 E 자리에 e(0.96) 넣은 원문이 또렷해 원문 E (까닭은 `KnownGaps`).
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
CODE = "FP-09-15-02"
TABLE = "F0282"
_RE_SYMBOL = re.compile(r"^([A-Za-z]\d?)\s*\(")
_RE_GEAR = re.compile(r"(\d+)\s*단")
def _factors(node: dict[str, Any]):
"""(불도저 계수, T) — 칸이 모자라거나 기종이 안 좁혀지면 까닭 글."""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import DozerFactors, resolve_dozer
table = next((t for t in node.get("tables") or [] if t.get("pum_table_id") == TABLE), {})
values: dict[str, Decimal] = {}
gear = 1
for row in table.get("raw_row") or []:
found = _RE_SYMBOL.match(str(row[0]).strip()) if row else None
value = parse_measure(str(row[1])) if found and len(row) > 1 else None
if value is not None:
values[found.group(1)] = value
shift = _RE_GEAR.search(str(row[1]))
gear = int(shift.group(1)) if shift else gear
missing = [k for k in ("T", "L", "E", "q0", "e", "f", "V1", "V2") if k not in values]
if missing:
return f"9-15-2 표 칸 없음: {', '.join(missing)}"
machine = resolve_dozer(values["q0"], values["V1"], values["V2"], gear)
if machine is None:
return f"삽날 {values['q0']}㎥ · {values['V1']}/{values['V2']}m/분({gear}단) 으로 불도저가 안 좁혀짐"
factors = DozerFactors(
work_item_code=CODE,
blade_capacity_m3=values["q0"],
distance_factor=values["e"],
volume_factor=values["f"],
efficiency=values["E"],
haul_distance_m=values["L"],
forward_speed_m_min=values["V1"],
reverse_speed_m_min=values["V2"],
machine_code=machine[0],
machine_name=machine[1],
)
return factors, values["T"]
def topsoil_output(node: dict[str, Any] | None = None) -> tuple[Decimal, Decimal]:
"""(Q1 ㎥/hr, Q ㎡/hr) — 둘 다 소수 2자리로 확정한 뒤 씀(명세 7장)."""
from B09_Estimation.B09_Estimation_MachineProductivity import fix2
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import dozer_hourly_output
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
if node is None:
node = next(n for n in load_work_item_master()["work_items"] if n["work_item_code"] == CODE)
found = _factors(node)
if isinstance(found, str):
raise ValueError(found)
factors, thickness = found
q1 = dozer_hourly_output(factors)
return q1, fix2(q1 / thickness)
def attach_topsoil_removal(build: Any, nodes_by_code: dict[str, dict[str, Any]]) -> None:
"""`B-FP-09-15-02` — 불도저 1/Q hr/㎡ 한 줄(D). 기계 층이 없거나 표가 달라지면 까닭만."""
from B09_Estimation.B09_Estimation_PriceBook import PriceKind, PriceTitle
node = nodes_by_code.get(CODE)
title_code = f"B-{CODE}"
if node is None or title_code in build.book.titles:
return
found = _factors(node)
if isinstance(found, str):
build.component_gaps[CODE] = found
return
factors, thickness = found
hourly = f"X-{factors.machine_code}"
if hourly not in build.book.titles:
build.component_gaps[CODE] = f"{factors.machine_name} 시간당 사용료가 안 섬"
return
q1, q = topsoil_output(node)
build.book.add_title(
PriceTitle(
code=title_code,
kind=PriceKind.UNIT_PRICE,
name=str(node.get("name") or CODE),
spec="표토제거",
unit="",
)
)
build.book.add_output_detail(
title_code,
hourly,
Decimal(1) / q,
f"{factors.formula_text} → Q = Q1 {q1} ÷ T {thickness}m = {q} ㎡/hr"
" (산림품셈 9-15-2 [주]②③ · 건설 8-2-1)",
output=q,
)
if CODE in build.skipped:
build.skipped.remove(CODE)
+9 -1
View File
@@ -899,9 +899,13 @@ def build_unit_prices(
# 예외 = 사용자가 다른 품셈 밑수를 확정한 자리(9-21 「1,000㎡당」 · ÷ 는 ChooseOne 이 검) —
# 마스터 목록은 원문 사실이라 그대로 두고 여기서만 걷음(2026-09-14 브레인 · 사유는 비고).
borrowed = work_item_code.startswith(tuple(BORROWED_BASIS_PER))
from B09_Estimation.B09_Estimation_ResourceAxis_JudgedTable import JUDGED_TABLES
for row in rows:
section = missing_basis.get(str(row.pum_table_id))
if section and not borrowed:
# 비고가 「인/㎥」 로 밑수를 적은 판정표(12-12 날개벽 「개소당」 머리 없음)는 밑수가 ㎥ 로 섬.
per_remark = JUDGED_TABLES.get(str(row.pum_table_id), {}).get("shape") == "remark_labor"
if section and not borrowed and not per_remark:
build.basis_missing[work_item_code] = section
break
@@ -1091,6 +1095,10 @@ def build_unit_prices(
from B09_Estimation.B09_Estimation_Explosives import attach_explosives
attach_explosives(build, nodes_by_code)
# 표토제거 답외구간(9-15-2) — 계수표를 불도저 식 + T 로(2026-09-14 ㉰).
from B09_Estimation.B09_Estimation_TopsoilRemoval import attach_topsoil_removal
attach_topsoil_removal(build, nodes_by_code)
# ⚠ **마지막에 한 번** — 조합 사용 공종의 본체 기계를 잡재료 16% 층으로 바꿔 단다
# (품셈 제8장 [주]⑤). 조립 도중에 바꾸면 어느 공종이 조합인지 아직 모른다.
build.combined_swapped = _apply_combined_misc_rate(
+9 -4
View File
@@ -220,8 +220,10 @@ export interface HaulPlan {
}
/**
* . balloon이
* . .
* **** .
* balloon이 . .
* 0
* (2026-09-14 936be972 474.14 0 · ).
*/
const MIN_SWING_RATIO = 0.02;
@@ -603,14 +605,17 @@ export function computeHaulPlan(
/** () **** .
* ( ). */
conversion?: EarthworkConversion | null;
/** 그림용 — 잔진동을 거른다(`MIN_SWING_RATIO`). 안 오면 **거르지 않는다**(수량 계획). */
drawing?: boolean;
},
): HaulPlan | null {
const points = result.points;
if (points.length < 2) return null;
const range = Math.max(result.max_cumulative_m3 - result.min_cumulative_m3, 0);
const minSwing = Math.max(range * MIN_SWING_RATIO, 1);
const extrema = pruneExtrema(points, extremaIndices(points), minSwing);
const extrema = options?.drawing
? pruneExtrema(points, extremaIndices(points), Math.max(range * MIN_SWING_RATIO, 1))
: extremaIndices(points);
const tiers = sortedLimits(limits);
const blocks: HaulBlock[] = [];
@@ -51,6 +51,38 @@
"pum_edition": "2026-01-01",
"basis": "12-15 집수정 표(원문 L6460)는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 건설품셈 8-3 (4611) 은 전기식 플렉시블형 ø45(0.75㎾)·엔진식 플렉시블형 ø45(2.6㎾) 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표(8-4)에 엔진식 4611-0350(휘발유 1.0L)만 있음 → 엔진식. 전기식이 필요하면 그때 엶. 2026-09-14 브레인 승인(661 뒤 ①)"
},
{
"axis": "resource",
"from": "다짐:봉상후렉시블(45mm)",
"to": "4611-0350",
"scope": "FP-12-12",
"pum_edition": "2026-01-01",
"basis": "12-12 날개벽(원문 L6419) 표는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 12-15 집수정과 같은 줄 · 같은 근거(건설품셈 8-3 (4611) 전기식·엔진식 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표에 엔진식 4611-0350 만) → 엔진식. 2026-09-14 브레인(672 다음 · 봉상후렉시블 셋)"
},
{
"axis": "resource",
"from": "다짐:봉상후렉시블(45mm)",
"to": "4611-0350",
"scope": "FP-12-13",
"pum_edition": "2026-01-01",
"basis": "12-13 면벽(원문 L6436) 표는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 12-15 집수정과 같은 줄 · 같은 근거(건설품셈 8-3 (4611) 전기식·엔진식 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표에 엔진식 4611-0350 만) → 엔진식. 2026-09-14 브레인(672 다음 · 봉상후렉시블 셋)"
},
{
"axis": "resource",
"from": "봉상후렉시블(45mm)",
"to": "4611-0350",
"scope": "FP-12-16",
"pum_edition": "2026-01-01",
"basis": "12-16 맨홀(원문 L6474) 표는 「봉상후렉시블(45mm) Q=5.4㎥/hr」 만 적고 전기·엔진을 안 적음 — 12-15 집수정과 같은 줄 · 같은 근거(건설품셈 8-3 (4611) 전기식·엔진식 둘 · 12-34-1 「콘크리트 진동기(3.5HP)」 = 2.6㎾ 엔진식 · 운전경비표에 엔진식 4611-0350 만) → 엔진식. 2026-09-14 브레인(672 다음 · 봉상후렉시블 셋)"
},
{
"axis": "resource",
"from": "콘크리트 진동기(3.5HP)",
"to": "4611-0350",
"scope": "FP-12-34-01",
"pum_edition": "2026-01-01",
"basis": "12-34-1 「콘크리트 진동기(3.5HP)」 — 3.5HP = 2.6㎾ · 건설품셈 8-3 (4611) 엔진식 플렉시블형 ø45(2.6㎾) · 운전경비표에 엔진식만 · 실무 영월 중기목록 「콘크리트 진동기 45φ(2.6㎾)엔진식플렉시블형」. 2026-09-14 브레인"
},
{
"axis": "variant",
"from": "0.2·소림",
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-14T22:25:14+09:00",
"generated_at": "2026-09-14T23:14:39+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_forest",
@@ -12,8 +12,8 @@
"files": [
{
"file": "work_item_master_2026-01-01.json",
"sha256": "2f0d8bfb3836597ee6f2fd755e2f2e172cb33df1e1e9061af81982a43497f9b2",
"size_bytes": 838655
"sha256": "708a6a80d016c42d3402bd2a6cb5266fa18e1e2b5301bac823e6ff79d17fb2b6",
"size_bytes": 838969
},
{
"file": "form_undetermined_2026-01-01.json",
@@ -3,7 +3,7 @@
"dataset_id": "work_item_master_forest",
"effective_date": "2026-01-01",
"pum_edition": "2026-01-01",
"generated_at": "2026-09-14T22:25:14+09:00",
"generated_at": "2026-09-14T23:14:39+09:00",
"dataset_version": {
"dataset_id": "pum_forest",
"effective_date": "2026-01-01",
@@ -32888,7 +32888,9 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"콘크리트"
],
"condition_note": [
"구 분",
"규 격",
@@ -32970,7 +32972,9 @@
]
}
],
"variant_keys": []
"variant_keys": [
"콘크리트"
]
},
{
"work_item_code": "FP-12-13",
@@ -32997,7 +33001,9 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"콘크리트"
],
"condition_note": [
"구 분",
"규 격",
@@ -33065,7 +33071,9 @@
]
}
],
"variant_keys": []
"variant_keys": [
"콘크리트"
]
},
{
"work_item_code": "FP-12-14",
@@ -33242,7 +33250,10 @@
"formula_rows": [],
"special_glyphs": [],
"capacity_formula_here": false,
"variant_key": [],
"variant_key": [
"구체콘크리트",
"버림콘크리트"
],
"condition_note": [
"구 분",
"단위",
@@ -33349,7 +33360,10 @@
]
}
],
"variant_keys": []
"variant_keys": [
"구체콘크리트",
"버림콘크리트"
]
},
{
"work_item_code": "FP-12-17",
@@ -35258,8 +35272,8 @@
"pum_table_id": "F0385",
"section": "12-34-1. 콘크리트 타설(철근 진동기포함)",
"source_line": 6843,
"pum_form": "reference",
"form_basis": "'별도계상' — 값이 아니라 참조 지시",
"pum_form": "requirement",
"form_basis": "사람 판정 — 인력(인)·기계(대, Q=5.4㎥/hr) 소요량 표 — 「별도계상」 은 레미콘 자재 줄 비고일 뿐",
"basis_quantity": 1.0,
"basis_unit": "개소",
"basis_source": "본문",
@@ -0,0 +1,155 @@
"""성토사면 길이 5m 초과 경고 — 벽 선 쪽만 빼고(좌·우 갈라) 경고만 (2026-09-14 브레인 (나)).
5m **넘는** 쪽만 5.00 아님 · 원지반을 만난 하한값() 5m 이하면 아님
벽이 쪽은 뺀다 배관 유입(상단측)·유출(반대측) 기슭막이, 집수정은 아님
한쪽에만 반대쪽은 그대로 경고 · 독립 기슭막이 설치 (//양쪽)
세월교·BOX암거는 양쪽 측벽
문구는 브레인 승인 그대로
TS 실제로 돌린다(파이썬 짝이 없는 화면 판정).
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[2]
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
SOURCE = PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Cross_FillSlope_Warn.ts"
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { FILL_SLOPE_WARN_TEXT, fillSlopeWarnings } from "./B06_Section_UI_Cross_FillSlope_Warn.js";
const [inputPath, outputPath] = process.argv.slice(2);
const cases = JSON.parse(readFileSync(inputPath, "utf8"));
const warnings = fillSlopeWarnings(cases.map((c) => c.section), (section) =>
cases.find((c) => c.section.station_id === section.station_id).lengths,
);
writeFileSync(outputPath, JSON.stringify({
text: FILL_SLOPE_WARN_TEXT,
warnings: warnings.map((w) => [w.section.station_id, w.sides.map((s) => s.side)]),
}));
"""
LONG = {"lengthM": 7.0, "open": False}
BOTH = {"left": LONG, "right": LONG}
def _culvert(inlet: str = "기슭막이", outlet: str = "기슭막이", **extra: object) -> dict:
return {"inlet": {"structure": inlet}, "outlet": {"structure": outlet}, **extra}
CASES = [
{"section": {"station_id": "plain"}, "lengths": BOTH},
{
"section": {"station_id": "edge"},
"lengths": {
"left": {"lengthM": 5.0, "open": False},
"right": {"lengthM": 4.2, "open": True},
},
},
# 상단측 좌 → 유입(좌) 기슭막이 · 유출(우) 기슭막이 — 양쪽 다 벽.
{
"section": {"station_id": "pipe", "uphill_side": "left", "culvert": _culvert()},
"lengths": BOTH,
},
# 유입이 집수정 → 좌(유입측)는 벽 없음 → 좌만 경고.
{
"section": {"station_id": "basin", "uphill_side": "left", "culvert": _culvert("집수정")},
"lengths": BOTH,
},
# 상단측 우 → 유출은 좌 · 유출이 집수정이면 좌만 경고.
{
"section": {
"station_id": "right_up",
"uphill_side": "right",
"culvert": _culvert(outlet="집수정"),
},
"lengths": BOTH,
},
{
"section": {"station_id": "own_left", "culvert": _culvert(hidden_pipe=True, side="")},
"lengths": BOTH,
},
{
"section": {
"station_id": "revet_auto",
"revetment": {"side": None},
"design": {"section_mode": "left_cut"},
},
"lengths": {"left": None, "right": LONG},
},
{"section": {"station_id": "ford", "ford": {}}, "lengths": BOTH},
]
def _run(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(SOURCE),
# 실행에 드는 import 는 이것 하나 — 나머지는 타입 import 라 지워진다.
str(SOURCE.with_name("B06_Section_UI_Cross_Culvert_Const.ts")),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
# 타입 줄기가 별칭(@util 등)으로 번져 단독 컴파일로는 못 푼다 — 검사는 typecheck 몫.
"--noCheck",
"--noResolve",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
for emitted in out.glob("*.js"):
text = emitted.read_text(encoding="utf-8")
emitted.write_text(
re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
payload, result = tmp_path / "input.json", tmp_path / "output.json"
payload.write_text(json.dumps(CASES, ensure_ascii=False), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(payload), str(result)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_벽_선_쪽만_빼고_5m_넘는_쪽을_경고한다(tmp_path: Path) -> None:
got = dict(_run(tmp_path)["warnings"])
assert got == {
"plain": ["left", "right"],
"basin": ["left"],
"right_up": ["left"],
"own_left": ["right"],
}
# 5.00 · 하한값 4.2(≥) · 양쪽 벽 · 자동 설치 측 벽 · 세월교는 경고 없음.
assert not {"edge", "pipe", "revet_auto", "ford"} & set(got)
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_문구는_승인된_그대로(tmp_path: Path) -> None:
assert _run(tmp_path)["text"] == (
"성토사면 길이 5m 초과 — 법령상 옹벽·석축 설치 대상 "
"(산림자원법 시행규칙 별표2 Ⅰ.2.차.(3).(나) · 임도설치 규정 별표7 2.차.(3).(나)) "
"※ 실무 표본에서도 흔함(영월 63% · 봉화 49%) — 설치 여부는 설계자 판단"
)
@@ -0,0 +1,104 @@
"""B07 횡단도 [확정]은 설계 **상태만** 올린다 — 정본 설계값을 덮지 않는다 (2026-09-14 브레인 ②).
실측(936be972 · 읽기만): [확정] 입력 (지반·단면·측구 )만으로 단면적을 다시 계산해
설계를 통째로 덮었다 62측점 전부 단면적이 바뀌고(절토 20.8% · 성토 +5.8%) 암선·절토경사·
표준 횡단·구조물 트림이 빠졌으며, 종점 1078.01 **사용자가 측구가 켜졌다**.
B07 CAD 에는 설계를 고치는 자리가 없으므로 덮을 값이 없다 반만 계산할 거면 반만 덮는다.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
from uuid import UUID
import B07_DesignDetail.B07_DesignDetail_Router as router
from B07_DesignDetail.B07_DesignDetail_Schema import DesignDrawingConfirmRequest
STORED = {
"status": "provisional",
"ground_type": "ripping_rock",
"section_mode": "left_cut",
"ditch_side": "left",
"ditch_enabled": False,
"rock_boundary_offset_m": 0.8,
"cut_slope_ratio": 0.5,
"cut_area_m2": 48.894,
"fill_area_m2": 54.84,
}
class _Cursor:
async def __aenter__(self) -> _Cursor:
return self
async def __aexit__(self, *_: object) -> bool:
return False
class _Connection:
async def begin(self) -> None: ...
async def commit(self) -> None: ...
async def rollback(self) -> None: ...
def cursor(self) -> _Cursor:
return _Cursor()
class _Acquire:
async def __aenter__(self) -> _Connection:
return _Connection()
async def __aexit__(self, *_: object) -> bool:
return False
def test_확정은_상태만_올리고_설계값을_안_덮는다(monkeypatch, tmp_path: Path) -> None:
patches: list[dict] = []
async def source(_project_id: UUID) -> tuple[int, Path, Path, bool]:
return 182, tmp_path, tmp_path / "longitudinal.json", False
async def designs(_route_id: int) -> dict[int, dict]:
return {700: dict(STORED)}
async def merge(_connection: object, **kwargs: object) -> bool:
patches.append(dict(kwargs["patch"])) # type: ignore[arg-type]
return True
async def stage(*_: object) -> None: ...
monkeypatch.setattr(router, "_confirmed_source", source)
monkeypatch.setattr(router, "_designs_by_chainage", designs)
monkeypatch.setattr(
router, "_drawing_list", lambda *_: [SimpleNamespace(id="cross_00700m", kind="cross")]
)
monkeypatch.setattr(router, "extract_quantity_table", lambda *_: None)
monkeypatch.setattr(router, "_store_confirmed_drawing", lambda *_: False)
monkeypatch.setattr(router, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire))
monkeypatch.setattr(router, "merge_cross_section_design_by_round", merge)
monkeypatch.setattr(router, "start_stage", stage)
monkeypatch.setattr(router, "complete_stage", stage)
# 옛 길 — 입력 셋으로 다시 계산한 값(암 절토경사가 빠져 절토가 줄어든 모양)을 흉내 낸다.
monkeypatch.setattr(
router,
"_recompute_confirmed_design",
lambda *_: {**STORED, "ditch_enabled": True, "cut_area_m2": 7.527, "status": "confirmed"},
raising=False,
)
response = asyncio.run(
router.confirm_design_drawing(
UUID("936be972-11bc-46c2-8bf3-b15d8de7df0d"),
"cross_00700m",
DesignDrawingConfirmRequest(drawing={}),
)
)
# 덮는 것은 상태 하나뿐 — 단면적·사용자 입력(측구 끔)은 그대로.
assert patches == [{"status": "confirmed"}]
# 화면 정보 패널이 받는 설계도 저장값 그대로(상태만 확정).
assert response.design == {**STORED, "status": "confirmed"}
@@ -0,0 +1,54 @@
"""조립(묶음) 줄이 조각의 「일부만」·「밑수 없음」 을 올림 — 2026-09-14 브레인 ㉱ 첫째(구조 결함).
종전 `_composite_row` 조각 제목이 **있기만 하면** 금액에 더했음 조각 단가가 일부 몫만 섰거나(인력만)
밑수를 모르는 표여도 조립 줄이 **온전한 금액처럼** 섰음. 보통 줄은 둘을 막는데 조립 줄만 새던 자리.
조각마다 보통 줄과 같은 검사 걸리면 금액을 세우고 어느 조각이 왜인지 사유.
"""
from __future__ import annotations
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
def _composite(parts: list[dict]) -> dict:
return {
"work_items": [
{
"work_item_code": None,
"name": "시험 묶음",
"spec": "",
"unit": "개소",
"quantity": "1",
"in_bill": True,
"composite_parts": parts,
"composite_not_ready": [],
}
],
"materials": [],
}
def _line(parts: list[dict]):
rows = build_bill(_composite(parts)).rows
return next(r for r in rows if r.code is None and r.name == "시험 묶음")
def test_일부만_선_조각이_있으면_묶음_금액을_안_세우고_까닭() -> None:
build = cached_build()
partial = next(c for c in build.partial_ratio if f"B-{c}" in build.book.titles)
line = _line([{"code": partial, "quantity": 1}])
assert line.amount_krw is None, (partial, line.amount_krw)
assert partial in line.note and "일부만" in line.note, line.note
def test_밑수_없는_조각도_같이_막음() -> None:
build = cached_build()
missing = next(c for c in build.basis_missing if f"B-{c}" in build.book.titles)
line = _line([{"code": missing, "quantity": 1}])
assert line.amount_krw is None and "밑수" in line.note, (missing, line.note)
def test_온전한_조각만이면_종전대로_금액() -> None:
line = _line([{"code": "FP-09-15-02", "quantity": 2}])
assert line.amount_krw and line.amount_krw > 0, line.note
@@ -0,0 +1,83 @@
"""봉상후렉시블 셋 + 12-34-1 — 2026-09-14 브레인(672 다음).
같은 봉상후렉시블(45mm) Q=5.4/hr 하나가 12-12 날개벽 · 12-13 면벽 · 12-16 맨홀을 막고 있었음.
표는 12-15 집수정과 같은 모양( 비고 인력 + 다짐기 ) 판정표 벌에 더하고 엔진식 4611-0350 이음.
실무 영월 콘크리트타설(임도품셈 5-5) 호표가 같은 모양: 콘크리트공 0.15·보통인부 0.27 / + 진동기 1/Q(5.4)
· 진동기 45φ(2.6)엔진식플렉시블형 손료 252천원 × 0.5101 · 노무 0 엔진식·계수·조종원 없음이 실무 실증.
12-34-1 콘크리트 타설(철근 진동기 포함) 실무 호표 없음 브레인 판정
콘크리트 진동기(3.5HP) 2 4611-0350 × 2 · 봉상후렉시블(45mm) 2 같은 기계의 봉이라
머리 (단위: 개소당) 인력 0.17·0.29 12-16 맨홀 당과 같고 기계도 Q /hr 당으로 읽고 사유에 나란히
"""
from __future__ import annotations
from decimal import Decimal
from B09_Estimation.B09_Estimation_UnitPrice import cached_build, detail_of
VIBRATOR = "X-4611-0350"
PER_Q = Decimal(1) / Decimal("5.4")
def _rows(build, code: str) -> dict[str, Decimal]:
return {
r["ref_code"]: Decimal(r["quantity"])
for r in detail_of(build, code)["rows"]
if r.get("ref_code")
}
def test_날개벽_면벽은_콘크리트_갈래가_진동기와_함께_섬() -> None:
build = cached_build()
for code in ("B-FP-12-12#콘크리트", "B-FP-12-13#콘크리트"):
rows = _rows(build, code)
assert rows[VIBRATOR] == PER_Q and rows["1013"] == Decimal("0.15"), (code, rows)
assert rows["1002"] == Decimal("0.27"), (code, rows)
assert build.book.titles[code].unit == ""
assert (
"FP-12-12" not in build.basis_missing
) # 비고가 인/㎥ — 「개소당」 머리가 없어도 밑수는 ㎥
def test_맨홀은_비고가_끝_칸이_아니어도_구체_버림_갈래() -> None:
build = cached_build()
body = _rows(build, "B-FP-12-16#구체콘크리트")
assert (
body[VIBRATOR] == PER_Q
and body["1013"] == Decimal("0.17")
and body["1002"] == Decimal("0.29")
)
lean = _rows(build, "B-FP-12-16#버림콘크리트")
assert lean == {"1013": Decimal("0.15"), "1002": Decimal("0.27")}, lean
for code in ("FP-12-12", "FP-12-13", "FP-12-16"):
assert "봉상후렉시블" not in " ".join(build.unattached.get(code, [])), code
def test_12_34_1_진동기_두_대만_세고_봉은_두_번_안_셈() -> None:
build = cached_build()
rows = _rows(build, "B-FP-12-34-01")
assert rows[VIBRATOR] == 2 * PER_Q, rows
assert rows["1013"] == Decimal("0.17") and rows["1002"] == Decimal("0.29"), rows
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
assert "봉상후렉시블" in known_gap_note("FP-12-34-01") and "두 번 안 셈" in known_gap_note(
"FP-12-34-01"
)
refs = [r.get("ref_code") for r in detail_of(build, "B-FP-12-34-01")["rows"]]
assert refs.count(VIBRATOR) == 1, refs # 봉 줄이 기계로 또 안 섬(한 줄 · 2대 몫)
def test_12_34_1_은_개소당_머리를_세제곱미터당으로_읽은_까닭을_보임() -> None:
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
build = cached_build()
assert build.book.titles["B-FP-12-34-01"].unit == ""
note = known_gap_note("FP-12-34-01")
assert "개소당" in note and "㎥당" in note and "12-16" in note, note
def test_표의_나머지_줄은_조용히_안_사라지고_사유에_원문_그대로() -> None:
"""손 사유로 달던 줄 목록은 판정표 자동 목록(`_unread_rows`)으로 옮김 — `test_b09_judged_unread_rows`."""
left = " ".join(cached_build().unattached.get("FP-12-16", []))
assert "원형거푸집" in left and "설치비" in left, left
@@ -0,0 +1,50 @@
"""판정표가 안 읽은 줄 — 자동으로 「못 붙은 줄」 목록에 (2026-09-14 브레인 · ㉰ 앞 뿌리 고침).
판정표는 적어 줄만 읽음 나머지 (거푸집·철근·뚜껑·설치비·비고 ) 사유 없이 사라지고 있었음
(12-15 · 12-04 비고 · 12-34-1 레미콘). 표를 넣을 때마다 손으로 사유를 달면 다음 표가 .
판정표 공통 곳에서 읽힌 밖의 올림.
넘치지 않게 자원 머리( 머리 모양) · · 다짐기처럼 다른 줄이 줄은
사유와 겹치게 공종 사유 (`known_gap_note`) 이미 적은 이름은 올림
"""
from __future__ import annotations
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
def _left(build, code: str) -> str:
return " / ".join(build.unattached.get(code, []))
def test_집수정_표의_거푸집_철근_뚜껑_설치비가_목록에_뜸() -> None:
left = _left(cached_build(), "FP-12-15")
for name in ("거 푸 집", "철근", "집수정 뚜껑", "설치비"):
assert name in left, (name, left)
assert "봉상후렉시블" not in left, left # 구체 갈래가 쓴 다짐기 줄은 안 올림(㉠)
def test_합판거푸집_비고는_글과_함께_뜨고_손_사유에_있는_사용고재는_안_겹침() -> None:
left = _left(cached_build(), "FP-12-04")
assert "비고" in left and "7m" in left, left
assert "사용고재" in known_gap_note("FP-12-04") and "사용고재" not in left, left # ㉡
assert "횟수별" not in left, left # 머리 줄(㉠)
def test_토사면_고르기_자원_머리_줄은_안_뜸() -> None:
left = _left(cached_build(), "FP-09-19-01")
assert "보통인부 (인)" not in left and "공기압축기 (시간)" not in left, left
def test_12_34_1_레미콘_별도계상_줄은_이름으로_뜸() -> None:
left = _left(cached_build(), "FP-12-34-01")
assert "콘크리트(레미콘)" in left and "자재" not in left.split(" / "), left
def test_날개벽_면벽_맨홀은_자동_목록이_맡고_손_사유는_줄_목록을_안_되풀이() -> None:
build = cached_build()
assert "원형거푸집" in _left(build, "FP-12-16") and "기초잡석" in _left(build, "FP-12-12")
assert "기초잡석" in _left(build, "FP-12-13")
for code in ("FP-12-12", "FP-12-13", "FP-12-16"):
assert "거푸집" not in known_gap_note(code), code
assert "30%" in known_gap_note("FP-12-12") # 줄이 아닌 [주] 는 손 사유로 남음
@@ -0,0 +1,26 @@
"""품 할증 겹침 — 2026-09-14 브레인 672: 건설 공통 1-4-2 「할증의 중복가산요령」.
원문: W = 기본품 × (1 + a1 + a2 + a3 + + an) · , 동일성격의 품할증요소의 이중적용은 불가함
산림품셈 1-4 에는 겹침 규정이 없어 **교차 참조** 건설 규정을 따름 합산은 이제 우리가 아니라 원문.
동일성격 어느 계열끼리인지는 원문이 정함 막지 않고 설계자에게 단서를 그대로 보임.
"""
from __future__ import annotations
from decimal import Decimal
import B09_Estimation.B09_Estimation_LaborSurcharge as LS
def test_여럿을_고르면_합산이고_근거는_건설_1_4_2() -> None:
assert LS.COMBINE_RULE == "sum"
picks = LS.parse_choices({"1-4-1": "1-4-1:0", "1-4-5": "1-4-5:1"}) # 10% · 5%
assert LS.total_percent(picks)[0] == Decimal(15)
note = LS.COMBINE_NOTE
assert "1-4-2" in note and "1 + a1 + a2" in note and "교차 참조" in note, note
assert "확정 대기" not in note and "우리가 그렇게" not in note, note
def test_동일성격_이중적용_불가_단서를_보임() -> None:
assert "동일성격" in LS.COMBINE_NOTE and "이중적용" in LS.COMBINE_NOTE
assert "설계자" in LS.COMBINE_NOTE # 어느 계열이 동일성격인지는 원문이 안 정함
@@ -0,0 +1,44 @@
"""표토제거 답외구간 9-15-2 — 2026-09-14 브레인 ㉰ 첫째(B08 준비공 표토제거 줄이 부르는 코드).
원문 L5288: T 0.2m · L 20m · E 0.4 · q0 3.2 · e 0.96 · f 1/1.3 · V1 40m/(1) · V2 46m/(1) · t 0.25
[] 무한궤도 불도저(19ton) q = 3.2 × e · = L/V1 + L/V2 + 0.25 · Q1 = 60 × q × f × E / /시간 · Q = Q1/T /시간
건설품셈 8-2-1 불도저 참조 불도저 (`dozer_hourly_output`) 그대로 쓰고 T 나눔
실무 영월 표토제거 답외구간 호표 Q=576.95 **E 자리에 e(0.96) 넣은 **으로 정확히 역산됨
(60 × 3.07 × 0.77 × 0.96 ÷ (1.18 × 0.2) = 576.95) · 다른 실무(봉화·대흥·소광·거창) 호표 없음
현장 실수 · 원문이 또렷하므로 원문 E=0.4 (2026-09-15 브레인 규칙 · 어긋남 기록)
"""
from __future__ import annotations
from decimal import Decimal
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
def test_표토제거_답외구간_일위대가가_불도저_19톤_1_Q_로_섬() -> None:
from B09_Estimation.B09_Estimation_TopsoilRemoval import topsoil_output
q1, q = topsoil_output()
assert q1 == Decimal("47.92") and q == Decimal("239.60"), (q1, q)
book = cached_build().book
title = book.titles["B-FP-09-15-02"]
assert title.unit == ""
rows = [
r for d in book.details["B-FP-09-15-02"] for r in [d, *book.details.get(d.ref_code, [])]
]
dozer = next(r for r in rows if r.ref_code == "X-0101-0019")
assert dozer.quantity == Decimal(1) / q and dozer.output == q, dozer
def test_식_줄에_T_로_나눈_것과_실무_어긋남이_적힘() -> None:
book = cached_build().book
notes = " ".join(
r.note
for d in book.details["B-FP-09-15-02"]
for r in [d, *book.details.get(d.ref_code, [])]
)
assert "T" in notes and "9-15-2" in notes, notes
from B09_Estimation.B09_Estimation_KnownGaps import known_gap_note
note = known_gap_note("FP-09-15-02")
assert "576.95" in note and "0.96" in note and "E=0.4" in note, note
@@ -0,0 +1,199 @@
"""유토 배분 — **수량은 거르지 않은 계획**, 잔진동 거르기는 **그림에만** (2026-09-14 브레인 ①).
실측(936be972 · 샘플 넓힘 ): 성토가 늘어 곡선 진폭이 커지자 진폭 × 2% 거르기가
작은 절토 봉우리를 통째로 지워 **운반량 474.14 0** 됐다. 거르기는 balloon
수십 깔리는 것을 막으려고 **도면용 손질**이라 수량에 닿으면 된다.
`computeHaulPlan` 기본은 거르지 않는다 작은 봉우리도 블록(운반)으로 남는다.
`drawing: true` 때만 거른다 그림은 종전대로 깔끔하다.
서버 정본은 둘을 따로 둔다 `haul_plan`(수량) · `haul_plan_drawing`(토적도·화면).
B07 토적도는 그림용을 먼저 읽는다(없으면 저장분 `haul_plan`).
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing
PROJECT_ROOT = Path(__file__).resolve().parents[2]
TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import { computeHaulPlan } from "./common_util_mass_haul_balance.js";
const [inputPath, outputPath] = process.argv.slice(2);
const input = JSON.parse(readFileSync(inputPath, "utf8"));
const out = input.cases.map((options) => {
const plan = computeHaulPlan(input.result, null, options);
return plan && {
hauled_m3: plan.hauled_m3, borrow_m3: plan.borrow_m3, blocks: plan.blocks.length,
};
});
writeFileSync(outputPath, JSON.stringify(out));
"""
def _bump_then_fill() -> dict:
"""앞에 작은 절토 봉우리(+10㎥) · 뒤에 큰 성토(1,000㎥).
진폭 2% 20 그림용 거르기에서는 봉우리가 지워진다."""
nets = [0.0, 10.0, -10.0] + [-100.0] * 10
points, cumulative = [], 0.0
for index, net in enumerate(nets):
cumulative += net
points.append(
{
"station_id": f"S{index:03d}",
"chainage_m": index * 20.0,
"net_volume_m3": net,
"cumulative_volume_m3": cumulative,
"cut_soil_m3": max(net, 0.0),
"cut_rock_m3": 0.0,
"cut_rr_m3": 0.0,
"cut_br_m3": 0.0,
"cut_compacted_m3": max(net, 0.0),
"fill_m3": max(-net, 0.0),
"natural_spoil": False,
"net_area_m2": net / 20.0,
}
)
return {
"points": points,
"cut_natural_m3": {"ea": 10.0, "rr": 0.0, "br": 0.0},
"cut_compacted_m3": 10.0,
"fill_compacted_m3": 1010.0,
"final_cumulative_m3": cumulative,
"surplus_m3": 0.0,
"shortage_m3": -cumulative,
"min_cumulative_m3": cumulative,
"max_cumulative_m3": 10.0,
"conversion": {"soil": 1.0, "ripping_rock": 1.0, "blasting_rock": 1.0},
}
def _run(tmp_path: Path, cases: list[dict]) -> list[dict | None]:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(PROJECT_ROOT / "common_util" / "common_util_mass_haul_balance.ts"),
"--outDir",
str(out),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
for emitted in out.glob("*.js"):
text = emitted.read_text(encoding="utf-8")
emitted.write_text(
re.sub(r'(from "\./[^"]+?)(")', lambda m: m.group(1) + ".js" + m.group(2), text),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
payload = tmp_path / "input.json"
result = tmp_path / "output.json"
payload.write_text(json.dumps({"result": _bump_then_fill(), "cases": cases}), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(payload), str(result)],
cwd=str(PROJECT_ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_수량_계획은_작은_봉우리도_운반으로_센다(tmp_path: Path) -> None:
quantity, drawing = _run(tmp_path, [{}, {"drawing": True}])
assert quantity is not None and drawing is not None
# ① 기본(수량) — 봉우리 10㎥ 가 뒤 성토로 옮겨진다.
assert quantity["hauled_m3"] == pytest.approx(10.0)
# ② 그림 — 종전대로 걸러져 블록이 없다.
assert drawing["blocks"] == 0
assert drawing["hauled_m3"] == pytest.approx(0.0)
# 토취(순 부족분)는 거르기와 무관하게 같다 — 운반만 사라졌던 것이다.
assert quantity["borrow_m3"] == pytest.approx(drawing["borrow_m3"])
def test_서버_정본은_수량과_그림을_따로_둔다() -> None:
source = (PROJECT_ROOT / "B06_Section" / "B06_Section_Server_Calc_Node.ts").read_text(
encoding="utf-8"
)
# ③ 화면 선반입(그림)은 거른 계획, 저장 정본은 둘 다.
assert "drawing: true" in source
assert "haul_plan_drawing" in source
def test_토적도는_그림용_계획을_먼저_그린다() -> None:
def band(volume: float) -> dict:
return {
"index": 1,
"equipment": "free_haul",
"volume_m3": volume,
"haul_distance_m": 10.0,
"ea_m3": volume,
"rr_m3": 0.0,
"br_m3": 0.0,
"level_base_m3": 0.0,
"level_apex_m3": volume,
"boundary_from_m": 0.0,
"boundary_to_m": 40.0,
"haul_from_m": 10.0,
"haul_to_m": 30.0,
}
def plan(volume: float) -> dict:
block = {
"index": 1,
"from_m": 0.0,
"to_m": 40.0,
"base_m3": 0.0,
"volume_m3": volume,
"direction": "forward",
"bands": [band(volume)],
}
return {"blocks": [block], "residuals": [], "transfers": []}
longitudinal = {"stations": [{"chainage_m": x, "station_id": f"s{x}"} for x in (0, 20, 40)]}
points = [
{"station_id": "s0", "chainage_m": 0.0, "cumulative_volume_m3": 0.0},
{"station_id": "s20", "chainage_m": 20.0, "cumulative_volume_m3": 700.0},
{"station_id": "s40", "chainage_m": 40.0, "cumulative_volume_m3": 0.0},
]
mass_haul = {"points": points, "haul_plan": plan(111.0), "haul_plan_drawing": plan(222.0)}
def labels(drawing: dict) -> list[str]:
out: list[str] = []
def walk(entity: dict) -> None:
if entity.get("type") == "Text":
out.append(entity["shapeData"]["label"])
for child in entity.get("children") or []:
walk(child)
for entity in drawing["entities"]:
walk(entity)
return out
drawn = labels(build_mass_haul_drawing(longitudinal, mass_haul, "mass_haul"))
assert "Q= 222.00M3" in drawn and "Q= 111.00M3" not in drawn
# 옛 저장분(그림용 없음)은 종전대로 `haul_plan` 을 그린다.
del mass_haul["haul_plan_drawing"]
legacy = labels(build_mass_haul_drawing(longitudinal, mass_haul, "mass_haul"))
assert "Q= 111.00M3" in legacy