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

This commit is contained in:
2026-09-14 12:48:28 +09:00
76 changed files with 2950 additions and 525 deletions
+15 -9
View File
@@ -134,9 +134,10 @@
"label": "유입 기슭막이 높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
},
{
"key": "outlet_type",
@@ -186,9 +187,10 @@
"label": "유출 기슭막이 높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
},
{
"key": "revet_foundation",
@@ -726,7 +728,8 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -812,7 +815,8 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -985,7 +989,8 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -1371,7 +1376,8 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -1939,7 +1945,7 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_kind",
+94 -185
View File
@@ -26,10 +26,6 @@
* 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다.
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import type {
DetailPipeInput,
PipeFacility,
@@ -42,7 +38,6 @@ import {
createRevetmentFields,
createRevetSideGroup,
createWingFields,
fordSection,
grid,
group,
INLET_KINDS,
@@ -59,9 +54,10 @@ import {
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets";
import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford";
// 유입구 형식 목록은 700줄 한계로 `_Fields` 로 옮김(2026-09-14) — 부르던 곳은 그대로 여기서.
export type { InletStructureKind };
export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로
/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을
* 가지면 저장한다. */
@@ -191,17 +187,13 @@ export function createFacilityOptionsForm(
root.hidden = true;
// ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ──────────────
// 관종 기본값 = 파형강관(2026-08-17 사용자 확정) — 빈 항목 없이 셋 중 하나.
const pipeMaterial = document.createElement("select");
pipeMaterial.replaceChildren(
...["흄관", "VR관", "파형강관"].map((value) => new Option(value, value)),
);
pipeMaterial.value = "파형강관";
// 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③).
const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]);
const pipeDiameter = document.createElement("select");
pipeDiameter.replaceChildren(
new Option("— 안 정함 (제안 Ø1000) —", ""),
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
);
pipeDiameter.value = "1000"; // 별표2 원칙값 + 사용자 확정 기본 (2026-08-17)
// 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다
// (2026-09-09, 계획서 4-13 · 소광리 원본 탭 다섯). 비우면 날개벽을 안 센다.
const wingWall = optionalSelect("안 놓음", ["A-TYPE", "C-TYPE", "A-TYPE+집수정"]);
@@ -210,11 +202,12 @@ export function createFacilityOptionsForm(
// ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ─────────────────
const inletGroup = group("유입구");
// 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로
// 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시).
// 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③).
const inletType = document.createElement("select");
inletType.replaceChildren(...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)));
inletType.value = INLET_KINDS[0].label;
inletType.replaceChildren(
new Option("— 안 정함 (제안 기슭막이) —", ""),
...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)),
);
const basinForm = optionalSelect("선택", [
"동물이동형",
"□형(기본형)",
@@ -230,14 +223,16 @@ export function createFacilityOptionsForm(
]);
const basinMaterialField = labeled("형태", basinMaterial);
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
const basinLength = numberInput("0.1");
basinLength.value = "2";
const basinLength = numberInput("0.1", "0", "제안 2");
const basinRows = [
grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))),
];
const height = (): string =>
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(찰)",
height,
});
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
// (2026-08-17 사용자 지시 3).
@@ -255,11 +250,11 @@ export function createFacilityOptionsForm(
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
const outletGroup = group("유출구");
const outletType = document.createElement("select");
outletType.replaceChildren(new Option("기슭막이", "기슭막이"));
const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]);
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(메)",
height,
});
outletGroup.body.append(
grid(labeled("구조", outletType), outletRevet.formField),
@@ -281,8 +276,7 @@ export function createFacilityOptionsForm(
// 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
const revetSide = optionalSelect("양쪽", ["양쪽", "좌", "우"]);
revetSide.value = "양쪽";
const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]);
const revetTopRow = grid(labeled("설치 측", revetSide));
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)");
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)");
@@ -320,12 +314,12 @@ export function createFacilityOptionsForm(
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
const boxSize = optionalSelect("미지정", [
const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [
...BOX_SIZE_PRESETS.map(([label]) => label),
BOX_SIZE_CUSTOM,
]);
const boxWidth = numberInput("0.1");
const boxHeight = numberInput("0.1");
const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`);
const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`);
const boxCustomRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight));
const boxWrap = document.createElement("div");
boxWrap.append(grid(labeled("본체 규격 (m)", boxSize)), boxCustomRow);
@@ -340,106 +334,43 @@ export function createFacilityOptionsForm(
boxWidth.value = String(preset[1]);
boxHeight.value = String(preset[2]);
}
boxCustomRow.hidden = !!preset;
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름).
boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM;
}
// ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자
// 지시) 수량만 따로 받는다.
const fordCount = numberInput("1", "1", "련");
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음(2026-08-30 사용자 지시 3).
// 련은 정수라 소수 자릿수를 두지 않는다.
const fordRow = grid(labeled("수량 (련)", stepper(fordCount, 1, 0)));
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만).
const ford = createFordFields(() => emit());
// ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시).
// 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음).
// 높이는 설계유량·폭으로 되짚은 필요 수심을 자동으로 채우고, 사용자가 키울 수는
// 있으나 계산값(필요 최소 수심) 미만은 되돌린다.
const fordWidth = numberInput("0.1");
const fordHeight = numberInput("0.01");
const fordWidthRow = grid(
labeled("월류 폭 (m)", stepper(fordWidth, 0.1)),
labeled("월류 높이 (m)", stepper(fordHeight, 0.1)),
);
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const fordSlope = numberInput("0.1");
fordSlope.placeholder = "노면 기울기";
// 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(2026-09-14 A3).
// 비우면 표가 안 서고 그 까닭이 뜸 — 지어내지 않는다.
const fordThickness = numberInput("1");
const fordLength = numberInput("0.1");
const fordSlopeRow = grid(
labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)),
labeled("포장 두께 (㎝)", stepper(fordThickness, 1, 0)),
labeled("포장 길이 노폭 방향 (m)", stepper(fordLength, 0.1)),
);
const fordSummary = document.createElement("p");
fordSummary.className = "b05-drainage__facility-note";
/** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */
let designFlowM3s: number | null = null;
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let fordMinDepthM: number | null = null;
/** 직전에 자동으로 채워 넣은 필요 수심(m) — 칸이 아직 그 값이면 "자동"으로 보고
* 월류 폭이 바뀔 때마다 계산값을 계속 따라가게 한다. 사용자가 다른 값을 넣으면
* 그때부터 그 값이 이긴다(2026-08-30 사용자: 폭을 바꿔도 높이가 안 따라온다). */
let fordAutoDepthM: number | null = null;
function syncFordSummary(): void {
const section =
designFlowM3s !== null
? fordSection(designFlowM3s, Number.parseFloat(fordWidth.value))
: null;
fordMinDepthM = section ? section.depthM : null;
if (section) {
// 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나, 그보다 작거나, 아직 직전
// 자동값 그대로면 계산값으로 다시 채운다.
const min = Number(section.depthM.toFixed(2));
const current = Number.parseFloat(fordHeight.value);
const untouched = fordAutoDepthM !== null && Math.abs(current - fordAutoDepthM) < 0.005;
if (!Number.isFinite(current) || current < min || untouched)
fordHeight.value = min.toFixed(2);
fordAutoDepthM = min;
}
if (designFlowM3s === null) {
fordSummary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
return;
}
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
if (!section) {
fordSummary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
return;
}
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
fordSummary.textContent =
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` +
`월류 높이는 필요 수심 이상만 입력됩니다.`;
}
// 계산값 미만은 입력을 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다.
fordHeight.addEventListener("change", () => {
if (fordMinDepthM !== null) {
const min = Number(fordMinDepthM.toFixed(2));
const value = Number.parseFloat(fordHeight.value);
if (!Number.isFinite(value) || value < min) {
fordHeight.value = min.toFixed(2);
fordHeight.classList.add("is-invalid");
window.setTimeout(() => fordHeight.classList.remove("is-invalid"), 900);
}
}
// [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·BOX 규격·날개벽·월류).
// 벽 칸(기슭막이)은 벽마다 단추가 따로 있음. 기본값을 몰래 확정으로 바꾸지 않는다(2026-09-14 판정 ③).
const suggest = document.createElement("button");
suggest.type = "button";
suggest.className = "b05-structure__suggest";
suggest.textContent = "제안값 넣기";
suggest.addEventListener("click", () => {
if (!pipeMaterial.value) pipeMaterial.value = "파형강관";
if (!pipeDiameter.value) pipeDiameter.value = "1000";
if (!inletType.value) inletType.value = INLET_KINDS[0].label;
if (!outletType.value) outletType.value = "기슭막이";
if (!basinLength.value) basinLength.value = "2";
if (!revetSide.value) revetSide.value = "양쪽";
if (current === "box_culvert" && !boxSize.value) boxSize.value = BOX_SIZE_PRESETS[0][0];
wingInFields.fillSuggested();
wingOutFields.fillSuggested();
if (current === "ford_pavement" || current === "ford_bridge") ford.fillSuggested(current);
emit();
});
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
grid(suggest),
pipeRow,
wingRow,
fordWidthRow,
fordSlopeRow,
fordRow,
fordSummary,
ford.widthRow,
ford.slopeRow,
ford.countRow,
ford.summary,
inletGroup.root,
outletGroup.root,
extraGroup.root,
@@ -470,24 +401,23 @@ export function createFacilityOptionsForm(
wingInFields.root.hidden = !hasWing;
wingOutFields.root.hidden = !hasWing;
const isFord = current === "ford_pavement" || current === "ford_bridge";
fordRow.hidden = current !== "ford_bridge";
fordWidthRow.hidden = !isFord;
ford.countRow.hidden = current !== "ford_bridge";
ford.widthRow.hidden = !isFord;
// 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다.
fordSlopeRow.hidden = current !== "ford_pavement";
fordSummary.hidden = !isFord;
ford.slopeRow.hidden = current !== "ford_pavement";
ford.summary.hidden = !isFord;
const isRevet = current === "revetment";
revetTopRow.hidden = !isRevet;
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다.
revetInlet.root.hidden =
!isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.inlet);
revetOutlet.root.hidden =
!isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.outlet);
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임).
const side = revetSide.value || "양쪽";
revetInlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.inlet);
revetOutlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.outlet);
revetInlet.legend.textContent = `기슭막이 (${revetSideLabels.inlet})`;
revetOutlet.legend.textContent = `기슭막이 (${revetSideLabels.outlet})`;
// 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고,
// 선택이 풀리면 스스로 사라진다.
extraGroup.root.hidden = extraSlot.childElementCount === 0;
if (isFord) syncFordSummary();
if (isFord) ford.sync();
if (isBox) syncBoxSize();
if (hasWing) {
wingInFields.syncVisibility();
@@ -506,12 +436,6 @@ export function createFacilityOptionsForm(
}
}
/** 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). */
function putFordHeight(options: Record<string, string | number>): void {
const value = Number.parseFloat(fordHeight.value);
if (Number.isFinite(value) && value > 0) options.ford_height_m = Number(value.toFixed(2));
}
function emit(): void {
syncVisibility();
callbacks.onChange?.();
@@ -533,8 +457,7 @@ export function createFacilityOptionsForm(
boxHeight,
...wingInFields.inputs,
...wingOutFields.inputs,
fordCount,
fordWidth,
...ford.inputs,
].forEach((input) => input.addEventListener("change", emit));
/** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */
@@ -571,35 +494,36 @@ export function createFacilityOptionsForm(
},
setFacility(facility, options = {}, designFlow = null) {
current = facility;
designFlowM3s = designFlow ?? null;
ford.setDesignFlow(designFlow ?? null);
if (facility === null) {
syncVisibility();
return;
}
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
const isFord = facility === "ford_bridge";
pipeMaterial.value = text("pipe_kind") || "파형강관";
pipeDiameter.value = text("pipe_diameter_mm") || "1000";
// ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③).
pipeMaterial.value = text("pipe_kind");
pipeDiameter.value = text("pipe_diameter_mm");
// 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다.
const savedType = text("inlet_type") || "기슭막이";
const savedType = text("inlet_type");
const savedStructure = text("inlet_structure");
inletType.value = (
INLET_KINDS.find(
(kind) =>
kind.type === savedType && (!savedStructure || kind.structure === savedStructure),
) ?? inletKindOf(savedType)
).label;
outletType.value = text("outlet_type") || "기슭막이";
inletType.value = savedType
? (
INLET_KINDS.find(
(kind) =>
kind.type === savedType && (!savedStructure || kind.structure === savedStructure),
) ?? inletKindOf(savedType)
).label
: "";
outletType.value = text("outlet_type");
wingWall.value = text("wing_wall_type");
basinForm.value = text("inlet_basin_form");
basinMaterial.value = text("inlet_basin_material");
basinLength.value = text("inlet_basin_length_m") || "2";
basinLength.value = text("inlet_basin_length_m");
inletRevet.write(options);
outletRevet.write(options);
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 첫 프리셋(2.0×2.0)이 기본이다.
const [firstLabel, firstWidth, firstHeight] = BOX_SIZE_PRESETS[0];
// 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만).
const savedWidth = Number.parseFloat(text("body_width_m"));
const savedHeight = Number.parseFloat(text("body_height_m"));
const hasSaved =
@@ -613,45 +537,38 @@ export function createFacilityOptionsForm(
Math.abs(width - savedWidth) < 0.05 && Math.abs(height - savedHeight) < 0.05,
)
: undefined;
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : firstLabel;
boxWidth.value = String(hasSaved ? savedWidth : firstWidth);
boxHeight.value = String(hasSaved ? savedHeight : firstHeight);
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : "";
boxWidth.value = hasSaved ? String(savedWidth) : "";
boxHeight.value = hasSaved ? String(savedHeight) : "";
wingInFields.write(options);
wingOutFields.write(options);
fordCount.value = isFord ? text("pipe_count") : "";
fordWidth.value = text("ford_width_m");
fordHeight.value = text("ford_height_m");
// 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다.
fordAutoDepthM = null;
fordSlope.value = text("ford_slope_pct");
fordThickness.value = text("thickness_cm");
fordLength.value = text("length_m");
revetSide.value = text("side") || "양쪽";
if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options);
revetSide.value = text("side");
const spread = legacyRevetOptions(options);
revetInlet.fields.write(spread);
revetOutlet.fields.write(spread);
// 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처).
if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) {
fordWidth.value = String(
facility === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M,
);
}
syncVisibility();
},
setDesignFlow(designFlow) {
designFlowM3s = designFlow ?? null;
ford.setDesignFlow(designFlow ?? null);
syncVisibility();
},
readOptions() {
const options: Record<string, string | number> = {};
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
const putPipe = (): void => {
if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value);
if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value;
};
if (current === "pipe") {
options.pipe_diameter_mm = Number(pipeDiameter.value);
options.pipe_kind = pipeMaterial.value;
putPipe();
const inletKind = inletKindOf(inletType.value);
if (wingWall.value) options.wing_wall_type = wingWall.value;
options.inlet_type = inletKind.type;
// B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29).
options.inlet_structure = inletKind.structure;
if (inletType.value) {
options.inlet_type = inletKind.type;
options.inlet_structure = inletKind.structure;
}
if (inletKind.type === "집수정") {
if (basinForm.value) options.inlet_basin_form = basinForm.value;
if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value;
@@ -659,7 +576,7 @@ export function createFacilityOptionsForm(
} else {
inletRevet.read(options);
}
options.outlet_type = outletType.value;
if (outletType.value) options.outlet_type = outletType.value;
outletRevet.read(options);
} else if (current === "box_culvert") {
// 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다.
@@ -668,22 +585,14 @@ export function createFacilityOptionsForm(
wingInFields.read(options);
wingOutFields.read(options);
} else if (current === "ford_pavement") {
putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options);
putNumber(options, "ford_slope_pct", fordSlope.value);
putNumber(options, "thickness_cm", fordThickness.value);
putNumber(options, "length_m", fordLength.value);
ford.read(current, options);
} else if (current === "ford_bridge") {
options.pipe_kind = pipeMaterial.value;
options.pipe_diameter_mm = Number(pipeDiameter.value);
const count = Number.parseInt(fordCount.value, 10);
if (Number.isFinite(count) && count > 0) options.pipe_count = count;
putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options);
putPipe();
ford.read(current, options);
wingInFields.read(options);
wingOutFields.read(options);
} else if (current === "revetment") {
options.side = revetSide.value;
if (revetSide.value) options.side = revetSide.value;
// 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고
// 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제).
// 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30).
@@ -153,14 +153,18 @@ export function putNumber(target: Record<string, string | number>, key: string,
if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1));
}
/** 선택지 셀렉트 — 빈("미지정") 항목을 두지 않는다. 첫 항목이 곧 기본값이고,
* 기본값은 구조물별로 따로 지정한다(2026-08-17 사용자 지시 1). */
/** 선택지 셀렉트 — **맨 앞에 빈 보기(「안 정함」)** 를 둠. 첫 보기를 미리 고르지 않음
* (2026-09-14 브레인 판정 · 옛 2026-08-17 「빈 항목 두지 않음」 걷음) — 고른 적 없는 첫 값이 저장돼
* 확정으로 굳지 않게. 기본값이 있는 칸은 부르는 쪽이 값을 넣음(그것은 따로 차례). */
export function optionalSelect(
_blankLabel: string,
blankLabel: string,
choices: ReadonlyArray<string>,
): HTMLSelectElement {
const element = document.createElement("select");
element.replaceChildren(...choices.map((choice) => new Option(choice, choice)));
element.replaceChildren(
new Option(blankLabel, ""),
...choices.map((choice) => new Option(choice, choice)),
);
return element;
}
@@ -201,16 +205,20 @@ export interface RevetmentKeys {
after?: string;
}
/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m. */
/** 부위별 **제안값** — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m. 관 벽 높이는 관경 기준
* (함수로 받아 볼 때마다 셈).
* ⚠ 제안값은 **칸에 채우지 않고 회색 글씨로만** 보임 — 사용자가 적거나 [제안값 넣기]를 누른 때만
* 값이 됨(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」). 칸에 채워 두면 저장이
* 그 값을 적어 **미확정이 저장 한 번에 확정으로 바뀜** — 사용자가 한 일이 없는데 금액이 섬. */
export interface RevetmentDefaults {
form: string;
length: string;
height: string;
height: string | (() => string);
before: string;
after: string;
}
/** 부위와 무관한 공통 기본 치수 — 길이 10m(전 5·후 5)·높이 2.5m. */
/** 부위와 무관한 공통 제안 치수 — 길이 10m(전 5·후 5)·높이 2.5m(독립 기슭막이 · 관 벽은 관경 기준). */
export const REVET_COMMON_DEFAULTS = {
length: "10",
height: "2.5",
@@ -284,26 +292,30 @@ export function createRevetmentFields(
keys: RevetmentKeys,
defaults: RevetmentDefaults,
): RevetmentFields {
// ⚠ 제안값은 **회색 글씨(placeholder)로만** — 칸은 비워 둠(`RevetmentDefaults` 주석).
const suggestHeight = (): string =>
typeof defaults.height === "function" ? defaults.height() : defaults.height;
const form = document.createElement("select");
form.replaceChildren(...REVET_FORMS.map((value) => new Option(value, value)));
form.value = defaults.form;
const length = keys.length ? numberInput("0.1") : null;
if (length) length.value = defaults.length;
const height = numberInput("0.1");
height.value = defaults.height;
form.replaceChildren(
new Option(`제안 ${defaults.form}`, ""),
...REVET_FORMS.map((value) => new Option(value, value)),
);
const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null;
const height = numberInput("0.1", "0", `제안 ${suggestHeight()}`);
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
height.addEventListener("focus", () => (height.placeholder = `제안 ${suggestHeight()}`));
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
const before = keys.before && length ? numberInput("0.1") : null;
const after = keys.after && length ? numberInput("0.1") : null;
if (before) before.value = defaults.before;
if (after) after.value = defaults.after;
const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null;
const after = keys.after && length ? numberInput("0.1", "0", `제안 ${defaults.after}`) : null;
let lastSplitEdit: "before" | "after" = "before";
const readNumber = (input: HTMLInputElement | null): number => {
const value = input ? Number.parseFloat(input.value) : Number.NaN;
return Number.isFinite(value) && value >= 0 ? value : 0;
};
const syncSplit = (source: "length" | "before" | "after"): void => {
if (!length || !before || !after) return;
// 길이가 비었으면 전/후를 0 으로 채우지 않음 — 빈 칸이 「0 으로 적음」이 되면 안 됨.
if (!length || !before || !after || length.value === "") return;
if (source !== "length") lastSplitEdit = source;
const total = readNumber(length);
const keep = lastSplitEdit === "after" ? after : before;
@@ -319,10 +331,25 @@ export function createRevetmentFields(
const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5)) : null;
const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5)) : null;
// [제안값 넣기] — **누른 때만** 빈 칸에 제안값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양.
const fill = document.createElement("button");
fill.type = "button";
fill.className = "b05-structure__suggest";
fill.textContent = "제안값 넣기";
fill.addEventListener("click", () => {
if (!form.value) form.value = defaults.form;
if (length && !length.value) length.value = defaults.length;
if (!height.value) height.value = suggestHeight();
if (before && !before.value) before.value = defaults.before;
if (after && !after.value) after.value = defaults.after;
form.dispatchEvent(new Event("change"));
});
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
const rows = [
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
...(beforeField && afterField ? [grid(beforeField, afterField)] : []),
grid(fill),
];
return {
@@ -330,13 +357,15 @@ export function createRevetmentFields(
rows,
lengthInput: length,
write(options) {
// 저장된 값만 칸에 — 없으면 비워 두고 제안값은 회색 글씨로(몰래 채우지 않음).
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
form.value = text(keys.form) || defaults.form;
if (length && keys.length) length.value = text(keys.length) || defaults.length;
height.value = text(keys.height) || defaults.height;
if (before && keys.before) before.value = text(keys.before) || defaults.before;
if (after && keys.after) after.value = text(keys.after) || defaults.after;
form.value = text(keys.form);
if (length && keys.length) length.value = text(keys.length);
height.value = text(keys.height);
height.placeholder = `제안 ${suggestHeight()}`;
if (before && keys.before) before.value = text(keys.before);
if (after && keys.after) after.value = text(keys.after);
syncSplit("length");
},
read(target) {
@@ -421,19 +450,18 @@ interface WingFields {
syncVisibility: () => void;
write: (options: Record<string, string | number>) => void;
read: (target: Record<string, string | number>) => void;
/** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */
fillSuggested: () => void;
}
/** 날개벽 한 벌(설치 유무 + 짧은쪽 높이·길이·각도) — 유입·유출이 공유하는 조각.
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴). */
* 설치 "없음"이면 제원 칸을 접는다(기슭막이 보호공 치수와 같은 패턴).
* ⚠ 기본 제원(있음·1·2·45, 사용자 확정)은 **제안으로만** — 칸은 비워 둠(2026-09-14 판정 ③). */
export function createWingFields(title: string, keys: WingKeys): WingFields {
const install = optionalSelect("미지정", ["있음", "없음"]);
install.value = WING_DEFAULTS.install;
const height = numberInput("0.1");
height.value = WING_DEFAULTS.height;
const length = numberInput("0.1");
length.value = WING_DEFAULTS.length;
const angle = numberInput("1");
angle.value = WING_DEFAULTS.angle;
const install = optionalSelect(`— 안 정함 (제안 ${WING_DEFAULTS.install}) —`, ["있음", "없음"]);
const height = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.height}`);
const length = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.length}`);
const angle = numberInput("1", "0", `제안 ${WING_DEFAULTS.angle}`);
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자).
const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1));
@@ -452,7 +480,6 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
dimsRow.hidden = off;
}
// 만들자마자 기본값("있음") 상태를 맞춘다.
syncVisibility();
return {
@@ -463,18 +490,25 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
write(options) {
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
install.value = text(keys.install) || WING_DEFAULTS.install;
height.value = text(keys.height) || WING_DEFAULTS.height;
length.value = text(keys.length) || WING_DEFAULTS.length;
angle.value = text(keys.angle) || WING_DEFAULTS.angle;
install.value = text(keys.install);
height.value = text(keys.height);
length.value = text(keys.length);
angle.value = text(keys.angle);
syncVisibility();
},
read(target) {
target[keys.install] = install.value;
if (install.value !== "음") return;
if (install.value) target[keys.install] = install.value;
if (install.value === "음") return;
putNumber(target, keys.height, height.value);
putNumber(target, keys.length, length.value);
putNumber(target, keys.angle, angle.value);
},
fillSuggested() {
if (!install.value) install.value = WING_DEFAULTS.install;
if (!height.value) height.value = WING_DEFAULTS.height;
if (!length.value) length.value = WING_DEFAULTS.length;
if (!angle.value) angle.value = WING_DEFAULTS.angle;
syncVisibility();
},
};
}
@@ -0,0 +1,151 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility_Ford.ts
* 물넘이포장·세월교 칸 묶음 — 월류 폭·높이·바닥 경사·포장 두께·길이·련 수 + 개략 단면 줄.
*
* 서브폼 본체(`_Drainage_Facility.ts`)가 700줄에 닿아 떼어 냄(2026-09-14).
* ⚠ 기본값을 칸에 안 채움(2026-09-14 브레인 판정 「기본값을 몰래 확정으로 바꾸지 않는다」):
* 월류 폭 세월교 10m·물넘이 5m(사용자 확정)는 **회색 제안**, 월류 높이는 설계유량·폭으로 되짚은
* **필요 최소 수심을 회색 제안**으로만 — 사용자가 적거나 [제안값 넣기]를 누른 때만 값.
* 적은 높이가 필요 수심보다 낮으면 여전히 되돌림(계산값 미만은 안 받음).
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import {
fordSection,
grid,
labeled,
numberInput,
putNumber,
stepper,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
type FordKind = "ford_pavement" | "ford_bridge";
export interface FordFields {
widthRow: HTMLElement;
slopeRow: HTMLElement;
countRow: HTMLElement;
summary: HTMLElement;
/** 바뀌면 저장 흐름을 울릴 칸(높이는 자기 검사 뒤 스스로 울림). */
inputs: HTMLInputElement[];
setDesignFlow: (designFlow: number | null) => void;
sync: () => void;
write: (kind: FordKind, options: Record<string, string | number>) => void;
read: (kind: FordKind, target: Record<string, string | number>) => void;
fillSuggested: (kind: FordKind) => void;
}
export function createFordFields(emit: () => void): FordFields {
// 세월교 — 구체 내 배관 수량(련). 련은 정수라 소수 자릿수를 두지 않는다.
const count = numberInput("1", "1", "련");
const countRow = grid(labeled("수량 (련)", stepper(count, 1, 0)));
const width = numberInput("0.1");
const height = numberInput("0.01");
const widthRow = grid(
labeled("월류 폭 (m)", stepper(width, 0.1)),
labeled("월류 높이 (m)", stepper(height, 0.1)),
);
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const slope = numberInput("0.1");
slope.placeholder = "노면 기울기";
// 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(A3).
const thickness = numberInput("1");
const length = numberInput("0.1");
const slopeRow = grid(
labeled("바닥 경사 유입→유출 (%)", stepper(slope, 0.1)),
labeled("포장 두께 (㎝)", stepper(thickness, 1, 0)),
labeled("포장 길이 노폭 방향 (m)", stepper(length, 0.1)),
);
const summary = document.createElement("p");
summary.className = "b05-drainage__facility-note";
let designFlowM3s: number | null = null;
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let minDepthM: number | null = null;
const suggestedWidth = (kind: FordKind): number =>
kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M;
function sync(): void {
const widthM = Number.parseFloat(width.value || width.dataset.suggested || "");
const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null;
minDepthM = section ? Number(section.depthM.toFixed(2)) : null;
height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : "";
if (designFlowM3s === null) {
summary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
return;
}
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
if (!section) {
summary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
return;
}
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
summary.textContent =
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` +
`월류 높이는 필요 수심 이상만 입력됩니다.`;
}
// 적은 값이 계산값 미만이면 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다(빈 칸은 그대로 빈 칸).
height.addEventListener("change", () => {
const value = Number.parseFloat(height.value);
if (
minDepthM !== null &&
height.value !== "" &&
(!Number.isFinite(value) || value < minDepthM)
) {
height.value = minDepthM.toFixed(2);
height.classList.add("is-invalid");
window.setTimeout(() => height.classList.remove("is-invalid"), 900);
}
emit();
});
return {
widthRow,
slopeRow,
countRow,
summary,
inputs: [count, width],
setDesignFlow(designFlow) {
designFlowM3s = designFlow ?? null;
},
sync,
write(kind, options) {
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
count.value = kind === "ford_bridge" ? text("pipe_count") : "";
width.value = text("ford_width_m");
width.dataset.suggested = String(suggestedWidth(kind));
width.placeholder = `제안 ${suggestedWidth(kind)}`;
height.value = text("ford_height_m");
slope.value = text("ford_slope_pct");
thickness.value = text("thickness_cm");
length.value = text("length_m");
},
read(kind, target) {
putNumber(target, "ford_width_m", width.value);
// 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림).
const value = Number.parseFloat(height.value);
if (Number.isFinite(value) && value > 0) target.ford_height_m = Number(value.toFixed(2));
if (kind === "ford_pavement") {
putNumber(target, "ford_slope_pct", slope.value);
putNumber(target, "thickness_cm", thickness.value);
putNumber(target, "length_m", length.value);
return;
}
const pipes = Number.parseInt(count.value, 10);
if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes;
},
fillSuggested(kind) {
if (!width.value) width.value = String(suggestedWidth(kind));
sync();
if (!height.value && minDepthM !== null) height.value = minDepthM.toFixed(2);
},
};
}
@@ -32,6 +32,68 @@ export function select(options: ReadonlyArray<[string, string]>): HTMLSelectElem
return element;
}
/** 등록부 옵션 한 칸의 모양 — `StructureOptionField` 에서 이 칸이 쓰는 몫만. */
interface OptionShape {
input: "select" | "number" | "text";
choices: string[];
default: string | number | null;
required?: boolean;
/** 비워 두면 어떻게 되나 — 칸에 마우스를 올리면 보임. */
empty_means?: string | null;
}
/**
* ** , **
* (· , ). 2026-09-14
* .
* .
*/
export function optionControl(
option: OptionShape,
stored: string | number | undefined,
): HTMLInputElement | HTMLSelectElement {
const value = stored === undefined || stored === null ? "" : String(stored);
const suggested = option.default === null || option.default === "" ? "" : String(option.default);
if (option.input === "select") {
const blank = suggested
? `— 안 정함 (제안 ${suggested}) —`
: option.required
? "— 선택 —"
: "— 안 정함 —";
const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]);
element.value = value;
if (option.empty_means) element.title = option.empty_means;
return element;
}
const element =
option.input === "number" ? numberInput("0.1", "0") : document.createElement("input");
if (option.input !== "number") element.type = "text";
element.value = value;
element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : "";
if (option.empty_means) element.title = option.empty_means;
return element;
}
/** [제안값 넣기] — **누른 때만** 빈 칸에 등록부 기본값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. */
export function suggestButton(
entries: ReadonlyArray<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }>,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b05-structure__suggest";
button.textContent = "제안값 넣기";
button.addEventListener("click", () => {
const filled = entries.filter((entry) => entry.suggested && !entry.input.value);
filled.forEach((entry) => {
entry.input.value = entry.suggested;
entry.input.dispatchEvent(new Event("input", { bubbles: true }));
});
// 반영은 한 번만 — 칸마다 저장 흐름이 돌지 않게 마지막 칸만 change 를 울림.
filled.at(-1)?.input.dispatchEvent(new Event("change", { bubbles: true }));
});
return button;
}
/** 측점번호 + 잔여거리 두 칸 묶음. */
export interface StationFields {
wrap: HTMLElement;
+18 -33
View File
@@ -14,7 +14,6 @@
* ========================================================================== */
import {
defaultOptions,
isB05Option,
structureAnchorM,
type StructureInstance,
@@ -24,7 +23,7 @@ import {
} from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { field, numberInput, select } from "./B05_Profile_UI_Structures_Fields";
import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields";
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
import {
bindStructuresEvents,
@@ -316,36 +315,18 @@ export function createStructuresSection(
return;
}
optionRow.hidden = false;
const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> =
[];
visible.forEach((option) => {
const preset = values[option.key] ?? option.default ?? "";
let input: HTMLInputElement | HTMLSelectElement;
if (option.input === "select") {
// 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은
// 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1).
const choices = option.choices.map((choice) => [choice, choice] as [string, string]);
// 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다.
const mustPick = option.required === true && (option.default ?? "") === "";
// **비워 두는 것이 뜻인 칸**(`empty_means`)도 같다 — 비면 계산 쪽이 기준값으로 도는데
// 첫 보기가 골라져 저장되면 기준과 다른 값이 조용히 나간다(2026-09-13:
// `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). 기본값이 **있는** 칸은
// 그대로 첫 보기를 쓴다 — 2026-08-17 「빈 보기 두지 않음」 지시가 그 자리다.
const meansEmpty = (option.empty_means ?? "") !== "" && (option.default ?? "") === "";
const keepEmpty = mustPick || meansEmpty;
if (keepEmpty) choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"]);
input = select(choices);
input.value = String(preset || (keepEmpty ? "" : (option.choices[0] ?? "")));
} else if (option.input === "number") {
input = numberInput("0.1", "0");
input.value = String(preset ?? "");
if (option.required) (input as HTMLInputElement).placeholder = "필수 입력";
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = "text";
input.value = String(preset ?? "");
}
// ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은
// 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석).
const input = optionControl(option, values[option.key]);
suggestions.push({ input, suggested: String(option.default ?? "") });
const label = option.unit ? `${option.label} (${option.unit})` : option.label;
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다.
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다
// (고를 수 없는 칸이라 제안이 아니라 프로그램 값).
const locked = option.enabled === false;
if (locked && !input.value) input.value = String(option.default ?? "");
if (locked) {
input.disabled = true;
input.classList.add("is-locked");
@@ -390,6 +371,9 @@ export function createStructuresSection(
input.addEventListener("change", () => refresh(true));
}
});
if (suggestions.some((entry) => entry.suggested)) {
optionRow.append(suggestButton(suggestions));
}
syncOptionLock();
syncRangeDisplay();
}
@@ -677,10 +661,11 @@ export function createStructuresSection(
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
memoField.value = "";
syncPlacementFields();
renderOptionFields(type.managed_by ? undefined : defaultOptions(type));
// BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을
// 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지).
syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {});
// 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②).
renderOptionFields();
// BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음).
// 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함.
syncFacilityForm({});
// 임시 배치도 즉시 진행(A군).
if (type.managed_by) commitTempPipe();
syncButtons();
@@ -231,6 +231,8 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus();
return;
}
// 높이는 비워도 놓음 — 자리부터 잡고 치수를 뒤에 넣는 길을 막지 않음. 높이가 물량 밑수인
// 벽은 B08 에서 줄만 서고 미확정(금액 밖)으로 뜸(2026-09-14 브레인 판정 ①, `missing_height_reason`).
const before = ctx.readBeforeM();
if (anchor - before < -0.005) {
const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input;
+4
View File
@@ -265,6 +265,8 @@ export interface FordSet {
min_cover_m: number;
wing_in: FordWingSpec;
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
@@ -283,6 +285,8 @@ export interface BoxSet {
span_m: number;
wing_in: FordWingSpec;
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
export interface CrossSection extends SectionStation {
+96 -12
View File
@@ -74,6 +74,26 @@ APRON_THICKNESS_M = 0.45
# 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다.
REVET_FACE_SLOPE = 0.3
# ── 관 벽 최소 높이 — ⚠ TS 짝: `B06_Section_UI_Cross_Culvert_Const.ts`
# (`REVET_FREEBOARD_M`·`REVET_HEIGHT_STEP_M`·`REVET_EMBED_DEPTH_M`·`pipeWallMinPureHeight`).
# 횡단도가 관 벽을 그리는 그 높이 — 구조물 표·구조물도 그림도 이 값을 씀(2026-09-14 브레인 판정
# 「그림 둘이 같은 벽을 다르게 그리면 결함 · 근거 있는 관경 기준으로 맞춤」). 거울 시험
# `test_b06_pipe_wall_height_mirror`.
REVET_FREEBOARD_M = 0.5 # 관 상단 ~ 벽 상단 여유고(잠정 · 토피 0.5 와 같은 값)
REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금
REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이
def pipe_wall_min_height_m(diameter_m: float) -> float:
"""관 벽 **순수 높이**(근입 포함) 최소값 — 관경 + 여유고를 0.1m 로 올린 뒤 근입을 더함.
최소 실제 설계값은 있어 값으로 수량은 미확정으로 (금액 ).
"""
raw = float(diameter_m) + REVET_FREEBOARD_M
steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9)
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3)
# (2026-08-20 사용자 확정) 배관은 **수평도 가능** — 그림에서 경사를 강제로 만들지
# 않는다. config `DRAINAGE_PIPE_SLOPE_DEG`(10도)는 유효직경 수리계산 전용으로 남는다.
@@ -139,7 +159,9 @@ def _number(value: Any, fallback: float | None) -> float | None:
return fallback
def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
def _side_spec(
options: dict[str, Any], defaults: dict[str, Any], side: str, diameter_m: float = 1.0
) -> dict[str, Any]:
"""유입("inlet")·유출("outlet") 한쪽의 부속 제원.
구조가 집수정이면 기슭막이·보호공을 만들지 않는다 집수정 단면은 후속 작업이라
@@ -164,9 +186,10 @@ def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) ->
)
return spec
# 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음).
height = _number(
options.get(f"{side}_revet_height_m"),
_number(defaults.get(f"{side}_revet_height_m"), None),
_number(defaults.get(f"{side}_revet_height_m"), pipe_wall_min_height_m(diameter_m)),
)
length = _number(
options.get(f"{side}_revet_length_m"),
@@ -208,10 +231,11 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
values.get("pipe_diameter_mm"), _number(defaults.get("pipe_diameter_mm"), 1000.0)
)
kind = values.get("pipe_kind") or defaults.get("pipe_kind")
diameter_m = round((diameter_mm or 1000.0) / 1000.0, 3)
return {
"type": "pipe",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"diameter_m": diameter_m,
"min_cover_m": MIN_PIPE_COVER_M,
# 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다(2026-09-09). 한쪽만 기초유로
# 하는 일이 드물어 폼을 둘로 늘리지 않았다. 실무가 갈라 쓰면 그때 나눌 것.
@@ -219,8 +243,8 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
str(values.get("revet_foundation") or defaults.get("revet_foundation") or "").strip()
or None
),
"inlet": _side_spec(values, defaults, "inlet"),
"outlet": _side_spec(values, defaults, "outlet"),
"inlet": _side_spec(values, defaults, "inlet", diameter_m),
"outlet": _side_spec(values, defaults, "outlet", diameter_m),
}
@@ -308,6 +332,36 @@ def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> d
}
def _defaulted(values: dict[str, Any], fields: list[tuple[str, str, Any]]) -> list[str]:
"""정본에 안 적혀 **기본값으로 그린 칸** 「이름 값」 — 횡단도가 「기본값(미확정)」으로 적음.
2026-09-14 브레인 판정 저장만 하는 것으로는 모자람 · 그림이 설계값처럼 보이면 같은 .
TS `defaulted`(`common_util_culvert_sets.ts`).
"""
found = []
for key, label, used in fields:
if used is None or values.get(key) not in (None, ""):
continue
found.append(f"{label} {used:g}" if isinstance(used, float) else f"{label} {used}")
return found
def _wing_defaulted(values: dict[str, Any], wing: dict[str, Any], side: str) -> list[str]:
"""날개벽 한쪽의 기본값 칸 — 안 세운 날개벽은 치수를 안 봄."""
name = "유입" if side == "in" else "유출"
prefix = f"wing_{side}"
fields: list[tuple[str, str, Any]] = [
(prefix, f"{name} 날개벽", "있음" if wing["installed"] else "없음")
]
if wing["installed"]:
fields += [
(f"{prefix}_height_m", f"{name} 날개벽 높이", wing["height_m"]),
(f"{prefix}_length_m", f"{name} 날개벽 길이", wing["length_m"]),
(f"{prefix}_angle_deg", f"{name} 날개벽 각도", wing["angle_deg"]),
]
return _defaulted(values, fields)
def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장).
@@ -323,12 +377,26 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
count = _number(values.get("pipe_count"), _number(defaults.get("pipe_count"), None))
depth = _number(values.get("ford_height_m"), None)
span = width if width and width > 0 else FORD_DEFAULT_WIDTH_M
pipe_count = max(int(count), 1) if count else 1
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
defaulted = _defaulted(
values,
[
("pipe_kind", "관종", str(kind) if kind else None),
("pipe_diameter_mm", "관경", diameter_mm),
("ford_width_m", "월류 폭", span),
("pipe_count", "배관 수량", pipe_count),
("ford_height_m", "월류 높이", 0.0),
],
)
return {
"type": "ford",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"pipe_count": max(int(count), 1) if count else 1,
"span_m": width if width and width > 0 else FORD_DEFAULT_WIDTH_M,
"pipe_count": pipe_count,
"span_m": span,
# 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다(단면은 월류부 가장 아래를 자른
# 자리다). 계획고를 통째로 내려 측벽·바닥판·절성토 면적이 함께 따라간다
# (2026-08-30 사용자 확정). 값이 없으면 0 = 내리지 않는다.
@@ -336,8 +404,11 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
"slab_thickness_m": FORD_SLAB_THICKNESS_M,
"wall_thickness_m": FORD_WALL_THICKNESS_M,
"min_cover_m": MIN_PIPE_COVER_M,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
"wing_in": wing_in,
"wing_out": wing_out,
"defaulted": defaulted
+ _wing_defaulted(values, wing_in, "in")
+ _wing_defaulted(values, wing_out, "out"),
}
@@ -353,12 +424,14 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
depth = _number(values.get("ford_height_m"), None)
slope = _number(values.get("ford_slope_pct"), None)
span = width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M
return {
"type": "ford_pavement",
"span_m": width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M,
"span_m": span,
# 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
"depth_m": depth if depth and depth > 0 else None,
"slope_pct": slope,
"defaulted": _defaulted(values, [("ford_width_m", "월류 폭", span)]),
}
@@ -375,6 +448,8 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0))
wall = FORD_WALL_THICKNESS_M
slab = FORD_SLAB_THICKNESS_M
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
return {
"type": "box",
"inner_width_m": inner_width or 2.0,
@@ -385,8 +460,17 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
"cover_m": BOX_COVER_M,
# 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. 이 폭만큼 측점에 걸친다.
"span_m": (inner_width or 2.0) + 2 * wall,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
"wing_in": wing_in,
"wing_out": wing_out,
"defaulted": _defaulted(
values,
[
("body_width_m", "본체 폭", inner_width or 2.0),
("body_height_m", "본체 높이", inner_height or 2.0),
],
)
+ _wing_defaulted(values, wing_in, "in")
+ _wing_defaulted(values, wing_out, "out"),
}
@@ -49,6 +49,33 @@ function fillSlopeInfo(section: CrossSection): HTMLElement | null {
return info;
}
/**
* ** ** `[보이는 글, 툴팁]` (2026-09-14 ).
* .
*/
function facilityNotes(section: CrossSection): Array<[string, string]> {
const notes: Array<[string, string]> = [];
const sets: Array<[string, string[] | undefined]> = [
["BOX암거", section.box?.defaulted],
["세월교", section.ford?.defaulted],
["물넘이포장", section.ford_pavement?.defaulted],
];
for (const [name, items] of sets) {
if (!items?.length) continue;
notes.push([
`${name} 기본값(미확정)`,
`B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`,
]);
}
if (section.ford_pavement && !section.ford_pavement.depth_m) {
notes.push([
"⚠ 월류 높이 없음 — 파임 안 그림",
"물넘이포장 월류 높이를 안 적어 파인 노면을 안 그림(수치를 지어내지 않음) — B05 시설 칸에 월류 높이를 적으면 그림",
]);
}
return notes;
}
/**
* ( ).
*
@@ -86,6 +113,13 @@ export function appendCardHeader(
openSlope.title = L("B06_Cross_SlopeUnclosed_Tip");
meta.append(openSlope);
}
for (const [text, tip] of facilityNotes(section)) {
const note = document.createElement("span");
note.className = "b06-cross-card__warning";
note.textContent = text;
note.title = tip;
meta.append(note);
}
const structureName = section.structure;
if (structureName) {
const structure = document.createElement("span");
@@ -27,6 +27,8 @@ export interface FordPavementSpec {
depth_m: number | null;
/** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */
slope_pct: number | null;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
interface Edge {
+4
View File
@@ -166,6 +166,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
// 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음
// (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」).
if (adjust?.h == null) return null;
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
.pureHeight;
},
@@ -180,6 +183,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④)
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form;
},
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
@@ -36,6 +36,38 @@ function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record<string,
return options;
}
/** 옵션 키 → 「기본값(미확정)」 목록의 이름(파이썬 `_defaulted` 이름표와 같은 글). */
const DEFAULTED_LABELS: Record<string, string> = {
pipe_kind: "관종",
pipe_diameter_mm: "관경",
ford_width_m: "월류 폭",
pipe_count: "배관 수량",
body_width_m: "본체 폭",
body_height_m: "본체 높이",
};
for (const [side, name] of [
["in", "유입"],
["out", "유출"],
]) {
DEFAULTED_LABELS[`wing_${side}`] = `${name} 날개벽`;
DEFAULTED_LABELS[`wing_${side}_height_m`] = `${name} 날개벽 높이`;
DEFAULTED_LABELS[`wing_${side}_length_m`] = `${name} 날개벽 길이`;
DEFAULTED_LABELS[`wing_${side}_angle_deg`] = `${name} 날개벽 각도`;
}
/** ()
* (2026-09-14 ). ( ). */
function dropDefaulted(spec: { defaulted?: string[] } | undefined, options: object): void {
if (!spec?.defaulted) return;
const labels = Object.keys(options).flatMap((key) => DEFAULTED_LABELS[key] ?? []);
spec.defaulted = spec.defaulted.filter(
(item) =>
!labels.some(
(label) => item.startsWith(`${label} `) && !item.slice(label.length + 1).includes(" "),
),
);
}
/** 좌측 폼이 낸 옵션에서 그 측 날개벽 조작값을 읽는다(없는 항목은 빼고 돌려준다). */
function wingPatchFrom(patch: Record<string, number | string>, prefix: string): WingPatch {
const num = (key: string): number | undefined => {
@@ -157,6 +189,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
// 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리.
if (patch.ford_width_m) spec.span_m = patch.ford_width_m;
}
dropDefaulted(spec, patch);
deps.queuePipeOptions(chainageM, patch);
deps.refreshCard(chainageM);
},
@@ -171,6 +204,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg;
wing.slab_extend_m = wingSlabExtendM(wing.installed, wing.length_m, wing.angle_deg);
}
dropDefaulted(spec, wingOptions(role, patch));
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
deps.refreshCard(chainageM);
},
@@ -349,6 +383,7 @@ export function createBoxControls(deps: FordControlDeps): {
}
if (patch.body_height_m) spec.inner_height_m = patch.body_height_m;
}
dropDefaulted(spec, patch);
deps.queuePipeOptions(chainageM, patch as Record<string, number | string>);
deps.refreshCard(chainageM);
},
@@ -364,6 +399,7 @@ export function createBoxControls(deps: FordControlDeps): {
? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0)
: 0;
}
dropDefaulted(spec, wingOptions(role, patch));
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
deps.refreshCard(chainageM);
},
@@ -31,6 +31,7 @@ import {
writeStructurePick,
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { showToast } from "@ui/ui_template_elements";
import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend";
import {
adjustDockRoot,
refreshAdjustSlots,
@@ -125,105 +126,153 @@ function coversChainage(structure: StructureInstance, chainageM: number): boolea
return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M;
}
type Merged = Record<string, string | number>;
/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */
function registryDefaults(types: StructureType[], typeId: string): Record<string, unknown> {
const options = types.find((type) => type.type_id === typeId)?.options ?? [];
return Object.fromEntries(options.map((option) => [option.key, option.default]));
}
/**
* ** **(`section.ford`)
* ,
* (2026-08-30 사용자: 수량() 1).
* ** , .**
* ·[]
* (2026-09-14 ). .
*/
function keepSpec(
merged: Merged,
stored: Merged | undefined,
key: string,
spec: string | number | null | undefined,
fromDefault: unknown,
): void {
if (spec === null || spec === undefined) return;
if (stored?.[key] !== undefined || String(spec) !== String(fromDefault ?? "")) merged[key] = spec;
}
type WingSpec = {
installed: boolean;
height_m: number | null;
length_m: number | null;
angle_deg: number | null;
};
function keepWings(
merged: Merged,
stored: Merged | undefined,
defaults: Record<string, unknown>,
wings: ReadonlyArray<readonly [WingSpec, "wing_in" | "wing_out"]>,
): void {
for (const [wing, prefix] of wings) {
// 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45.
const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음";
keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install);
keepSpec(
merged,
stored,
`${prefix}_height_m`,
wing.height_m,
defaults[`${prefix}_height_m`] ?? 0,
);
keepSpec(
merged,
stored,
`${prefix}_length_m`,
wing.length_m,
defaults[`${prefix}_length_m`] ?? 0,
);
keepSpec(
merged,
stored,
`${prefix}_angle_deg`,
wing.angle_deg,
defaults[`${prefix}_angle_deg`] ?? 45,
);
}
}
/**
* ** **(`section.ford`) · .
* (2026-08-30 사용자: 수량()
* 1) 1 ** ** ( ).
*/
function withFordSpec(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
ford: NonNullable<SectionDetailResponse["cross_sections"][number]["ford"]>,
): Record<string, string | number> {
const merged: Record<string, string | number> = { ...(options ?? {}) };
if (ford.pipe_kind) merged.pipe_kind = ford.pipe_kind;
merged.pipe_diameter_mm = Math.round(ford.diameter_m * 1000);
merged.pipe_count = ford.pipe_count;
merged.ford_width_m = ford.span_m;
for (const [wing, prefix] of [
defaults: Record<string, unknown>,
): Merged {
const merged: Merged = { ...(options ?? {}) };
keepSpec(merged, options, "pipe_kind", ford.pipe_kind, defaults.pipe_kind);
const diameter = Math.round(ford.diameter_m * 1000);
keepSpec(merged, options, "pipe_diameter_mm", diameter, defaults.pipe_diameter_mm ?? 1000);
const count = Number(defaults.pipe_count ?? 0);
keepSpec(
merged,
options,
"pipe_count",
ford.pipe_count,
count > 0 ? Math.max(Math.trunc(count), 1) : 1,
);
keepSpec(
merged,
options,
"ford_width_m",
ford.span_m,
defaults.ford_width_m ?? FORD_BRIDGE_DEFAULT_WIDTH_M,
);
keepWings(merged, options, defaults, [
[ford.wing_in, "wing_in"],
[ford.wing_out, "wing_out"],
] as const) {
merged[prefix] = wing.installed ? "있음" : "없음";
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
}
]);
return merged;
}
/**
* BOX암거 ** **(`section.box`)
* (2026-08-30 ). · , ·
* .
*/
/** BOX암거 폼 옵션 — 세월교와 같은 규칙(조작·저장분만). 구체 길이·표고는 조정 채널이라 여기 안 옴. */
function withBoxSpec(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
box: NonNullable<SectionDetailResponse["cross_sections"][number]["box"]>,
): Record<string, string | number> {
const merged: Record<string, string | number> = { ...(options ?? {}) };
merged.body_width_m = box.inner_width_m;
merged.body_height_m = box.inner_height_m;
for (const [wing, prefix] of [
defaults: Record<string, unknown>,
): Merged {
const merged: Merged = { ...(options ?? {}) };
keepSpec(merged, options, "body_width_m", box.inner_width_m, defaults.body_width_m ?? 2);
keepSpec(merged, options, "body_height_m", box.inner_height_m, defaults.body_height_m ?? 2);
keepWings(merged, options, defaults, [
[box.wing_in, "wing_in"],
[box.wing_out, "wing_out"],
] as const) {
merged[prefix] = wing.installed ? "있음" : "없음";
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
}
]);
return merged;
}
/**
* ** **
* (2026-08-29 ). ,
* ( ·) .
* ** ** (2026-08-29 ).
* (··· ·/· ) ** **
* (2026-09-14 ). .
*/
function withSpecDefaults(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
detail: SectionDetailResponse | null,
chainageM: number,
types: StructureType[],
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null,
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null,
): Record<string, string | number> | undefined {
): Merged | undefined {
const owner = detail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
);
// 세월교는 폼과 조정창이 **같은 캐시**(`section.ford`)를 본다 — 어느 쪽에서 만졌든
// 그 스펙이 지금 그려진 값이라 저장된 옵션보다 앞선다(2026-08-30 사용자: 프론트는
// 구조물 배치 상세 UI, 로직·값은 조정창 것).
if (owner?.ford) return withFordSpec(options, owner.ford);
if (owner?.box) return withBoxSpec(options, owner.box);
// 세월교·BOX는 폼과 조정창이 **같은 캐시**를 봄 — 조작한 값은 저장된 옵션보다 앞섬(2026-08-30).
if (owner?.ford) return withFordSpec(options, owner.ford, registryDefaults(types, "ford_bridge"));
if (owner?.box) return withBoxSpec(options, owner.box, registryDefaults(types, "box_culvert"));
const culvert = owner?.culvert;
if (!culvert) return options;
const merged: Record<string, string | number> = { ...(options ?? {}) };
const put = (key: string, value: string | number | null | undefined): void => {
if (merged[key] === undefined && value !== null && value !== undefined) merged[key] = value;
};
put("pipe_kind", culvert.pipe_kind);
put("pipe_diameter_mm", Math.round(culvert.diameter_m * 1000));
for (const [side, prefix] of [
[culvert.inlet, "inlet"],
[culvert.outlet, "outlet"],
] as const) {
put(`${prefix}_type`, side.structure);
// 형태·높이는 **지금 그려진 값**이 이긴다 — 조정값(조작)이 저장된 옵션보다
// 앞서기 때문이다. 저장값을 그대로 두면 폼과 그림이 갈린다(2026-08-30 사용자:
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다).
const merged: Merged = { ...(options ?? {}) };
for (const prefix of ["inlet", "outlet"] as const) {
// 형태·높이는 **조작한 값**이 이긴다 — 조정값이 저장된 옵션보다 앞서기 때문(2026-08-30 사용자:
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). 조작이 없으면 부르는 쪽이 null 을 줌.
const drawnForm = wallForm?.(chainageM, prefix);
if (drawnForm) merged[`${prefix}_revet_form`] = drawnForm;
else put(`${prefix}_revet_form`, side.revet_form);
const drawnHeight = wallHeight?.(chainageM, prefix);
if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1));
else put(`${prefix}_revet_height_m`, side.revet_height_m);
put(`${prefix}_revet_length_m`, side.revet_length_m);
put(`${prefix}_revet_before_m`, side.revet_before_m);
put(`${prefix}_revet_after_m`, side.revet_after_m);
}
put("inlet_basin_length_m", culvert.inlet.basin_length_m);
put("inlet_basin_before_m", culvert.inlet.basin_before_m);
put("inlet_basin_after_m", culvert.inlet.basin_after_m);
return merged;
}
@@ -374,6 +423,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
pipe.options,
detail,
pipe.chainage_m,
types,
deps.wallHeight,
deps.wallForm,
),
@@ -418,6 +468,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
hit.options,
deps.detail?.() ?? null,
hit.chainage_m,
markTypes,
deps.wallHeight,
deps.wallForm,
);
@@ -72,6 +72,9 @@ def verify_double_count_boundaries(
name = str(component.get("name") or "").strip()
destination = str(component.get("destination") or "")
destinations.setdefault(name, set()).add(destination)
# 집는 쪽은 이름+규격 키(「이형철근 D13」)로도 집음 — 두 키에 다 올려야 검사가 안 놂.
key = f"{name} {str(component.get('spec') or '').strip()}".strip()
destinations.setdefault(key, set()).add(destination)
if name in EARTHWORK_ONLY and destination != "earthwork":
found.append(
f"{label}{name}」 갈 곳이 {destination or '(없음)'}"
@@ -327,10 +327,18 @@ def composite_quantities(
note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음"
return [], [{"code": None, "reason": note}]
amounts: dict[str, tuple[float, str]] = {}
for component in structure.get("components") or []:
# ⚠ 조각 이름은 **이름+규격 키**(「이형철근 D13」)로도, 이름만(「콘크리트」)으로도 옴 — 두
# 색인을 함께 둠. 이름 한 칸만 키로 쓰면 규격만 다른 성분(D13·D16)이 **서로 덮어씀**.
by_key: dict[str, float] = {}
by_name: dict[str, float] = {}
key_of: dict[int, str] = {}
for index, component in enumerate(structure.get("components") or []):
name = str(component.get("name") or "").strip()
amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or ""))
key = f"{name} {str(component.get('spec') or '').strip()}".strip()
amount = float(component.get("amount") or 0.0)
by_key[key] = by_key.get(key, 0.0) + amount
by_name[name] = by_name.get(name, 0.0) + amount
key_of[index] = key
kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001)
parts: list[dict[str, Any]] = []
@@ -340,14 +348,14 @@ def composite_quantities(
parts.append({"code": str(spec)})
continue
sources = list(spec.get("from_components") or [])
found = [name for name in sources if name in amounts]
total = sum(amounts[name][0] for name in found)
found = [name for name in sources if name in by_key or name in by_name]
total = sum(by_key[name] if name in by_key else by_name[name] for name in found)
if spec.get("unit_from") == "kg" and spec.get("unit") == "ton":
total *= kg_to_ton
kinds = {
component.get("basis_kind")
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in found
for index, component in enumerate(structure.get("components") or [])
if key_of[index] in found or str(component.get("name") or "").strip() in found
}
suffix = spec.get("kind_suffix")
entry: dict[str, Any] = {
@@ -23,6 +23,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
ORIGIN_HAUL,
WorkItemMapping,
)
from B08_Quantity.B08_Quantity_Engine_Handoff_Rows import LOADING_EQUIPMENT
SPOIL_NAME = "사토 운반"
#: ⚠ **사토장 사면 물량은 안 센다**(2026-09-09 세 창 확인). 교본 6장 3절은 「완료 구간 비탈면을
@@ -127,8 +128,33 @@ def spoil_haul_rows(
extra=GROUND_UNKNOWN,
)
)
return rows
return [_spoil_row(code, volume, distance, blocked, reason, note, None, GROUND_UNKNOWN)]
return _with_loading(rows)
return _with_loading(
[_spoil_row(code, volume, distance, blocked, reason, note, None, GROUND_UNKNOWN)]
)
def _with_loading(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""사토도 싣는 일이 따로 — 흙깎기 운반과 같은 「덤프 적재」 짝 줄(10-12 「1. 적재」).
내역에 서는 운반 줄에만 붙음 · 거리 무관 · 수량은 운반량 그대로(2026-09-14 브레인 판정).
운반토량 검산은 `dump_loading` 빼므로 .
"""
out: list[dict[str, Any]] = []
for row in rows:
out.append(row)
if row["in_bill"]:
out.append(
{
**row,
"name": "덤프 적재",
"spec": row["ground_class"] or "",
"haul_distance_m": None,
"haul_equipment": LOADING_EQUIPMENT,
"spec_detail": f"{row['spec_detail']} · 사토량만큼 싣기(10-12 「1. 적재」)",
}
)
return out
def _spoil_row(
@@ -165,8 +191,10 @@ def _spoil_row(
"structure_kind": None,
"blocked_kind": BLOCKED_INPUT_MISSING if blocked else None,
"blocked_reason": reason,
"variant_axis": None,
"variant_value": None,
# 갈래는 `variant_value` 한 통로로 — 덤프 운반·적재 단가가 토사·암으로 갈림
# (종전 None 이라 B09 가 10-12 잎을 못 골라 사토 운반이 「갈래 미지정」으로 막힘).
"variant_axis": "ground_class" if ground else None,
"variant_value": ground,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
@@ -92,6 +92,10 @@ SURCHARGE_RATE_UNAVAILABLE = "rate_unavailable" # 자재는 있는데 율을
NOTE_RATE_MISSING = "할증률 미확보"
NOTE_INCLUDED = "품셈에 할증 포함 — 중복 적용 안 함"
#: ⚠ **안 정한 자재는 사급으로 선다**(2026-09-14 브레인 판정 ⓐ — 「기본 사급 · 관급은 프로젝트
#: 설정이 정함」 · 품셈 제1장 「관계규정이나 계약조건에 따른다」). 종전 `unknown` 은 어느 합계에도
#: 안 들어 자재비가 통째로 빠져 있었음. 명시적으로 `unknown` 을 고른 줄은 그대로 미정.
NOTE_SUPPLY_DEFAULT = "관급구분 기본 사급 — 관급이면 산출 조건에서 고름"
def _latest_dataset_path(directory: Path | None = None) -> Path | None:
@@ -109,6 +113,9 @@ class SurchargeTable:
effective_date: str = ""
source: dict[str, Any] = field(default_factory=dict)
rates: dict[str, dict[str, Any]] = field(default_factory=dict)
#: 율이 없는 **까닭** — 자재 → 사유. 보류(조건이 다름)와 이름 없음을 줄에 드러냄.
#: ⚠ 「할증률 미확보」만 적으면 「아직 안 찾음」과 「찾았으나 일부러 안 걺」이 안 갈림.
missing_reasons: dict[str, str] = field(default_factory=dict)
def rate_for(self, material: str, condition: str | None = None) -> tuple[float | None, str]:
"""(할증률 %, 근거). 표에 없으면 `(None, "")` — **0 을 돌려주지 않는다.**"""
@@ -138,10 +145,25 @@ def load_surcharge_table(path: Path | None = None) -> SurchargeTable:
for row in payload.get("rates_pct", [])
if row.get("material") is not None and row.get("rate") is not None
}
reasons = {
str(item.get("material")): (
f"조건이 다름 — 사용자 확정 후 옮길 것 · 원문 「{item.get('listed_condition')}"
f" {item.get('rate')}% · {item.get('why_not_applied')}"
)
for item in (payload.get("candidates_pending_user") or {}).get("items") or []
if item.get("material")
}
reasons.update(
{
str(name): "할증률표에 이름 없음(산림·건설 품셈 1-3-1 확인) — 임의로 안 정함"
for name in (payload.get("not_found") or {}).get("materials") or []
}
)
return SurchargeTable(
effective_date=str(payload.get("effective_date") or ""),
source=payload.get("source") or {},
rates=rates,
missing_reasons=reasons,
)
@@ -154,7 +176,10 @@ class MaterialRow:
spec: str = "" # 규격 — 돌 종류·콘크리트 강도처럼 같은 이름을 가르는 값(명세 13장 Ⓒ)
net_amount: float = 0.0 # 순수량 — 할증 전
surcharge_pct: float | None = None # None = 미확보
surcharge_cap: float | None = None # 할증률표 값(1-3-1 「이내」의 상한) — 고른 값의 천장
supply: str = SUPPLY_UNKNOWN
#: 산출 조건에 안 적어 **기본 사급**으로 선 줄 — 그 사실을 비고에 적음.
supply_default: bool = False
install_by: str | None = None # 관급 줄에만 — 사급은 비워 둔다
surcharge_included: bool = False # 품셈에 이미 포함
basis: str = ""
@@ -187,6 +212,8 @@ class MaterialRow:
parts.append(self.basis)
if self.supply == SUPPLY_OWNER and self.install_by is None:
parts.append(NOTE_INSTALL_BY_MISSING)
if self.supply_default:
parts.append(NOTE_SUPPLY_DEFAULT)
return " · ".join(parts)
@@ -286,6 +313,19 @@ def _supply_setting(supply: dict[str, Any], row: MaterialRow) -> Any:
return None
def _chosen_rate(cap: float | None, raw: Any) -> tuple[float | None, str]:
"""(쓸 할증률, 사유) — 고른 값은 0 ~ 표값(상한)만. 표에 없으면 고른 값도 안 씀(임의 금지)."""
if raw is None or str(raw).strip() == "" or cap is None:
return cap, ""
try:
value = float(raw)
except (TypeError, ValueError):
return cap, f"고른 할증률 「{raw}」 을 수로 못 읽어 표값 {cap:g}% 씀"
if not 0 <= value <= cap:
return cap, f"고른 할증률 {value:g}% 는 표값 {cap:g}% 이내가 아니라 안 씀(1-3-1 「이내」)"
return value, f"산출 조건에서 고른 값 {value:g}% (표 상한 {cap:g}% 이내)"
#: 건너뛴 까닭 칸 — 치수가 없어 기본값으로 선 구조물(구조물 수로 셈).
UNCONFIRMED_SKIP = "미확정(기본값으로 선 구조물)"
@@ -320,11 +360,16 @@ def build_table(
supply_map: dict[str, Any] | None = None,
extra_materials: Iterable[dict[str, Any]] = (),
concrete_placing_method: str | None = None,
surcharge_overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양.
`extra_materials` 구조물 전개 밖에서 오는 자재(·초류종자 사면 계열) 받는 자리다.
모양은 원단위 성분과 같다(`name`·`unit`·`amount`·`destination`).
`surcharge_overrides` `{이름 규격: %}` 산출 조건에서 고른 할증률. 품셈 1-3-1
표의 **이내** **0 ~ 표값만** 받음 · 표에 없는 자재·할증 포함 줄엔
(넘으면 쓰고 사유).
"""
table = surcharge_table or load_surcharge_table()
rows, skipped = _collect(unit_quantity_table)
@@ -341,9 +386,14 @@ def build_table(
missing_rate: list[str] = []
missing_supply: list[str] = []
missing_install_by: list[str] = []
chosen_rates = surcharge_overrides or {}
for row in rows.values():
name = row.name
row.supply, row.install_by = _supply_of(_supply_setting(supply, row))
setting = _supply_setting(supply, row)
row.supply, row.install_by = (
_supply_of(setting) if setting is not None else (SUPPLY_CONTRACTOR, None)
)
row.supply_default = setting is None
if row.supply == SUPPLY_UNKNOWN:
missing_supply.append(row.supply_key)
# ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다.
@@ -353,8 +403,11 @@ def build_table(
continue
lookup, alias_note = surcharge_lookup_name(name, concrete_placing_method)
rate, basis = table.rate_for(lookup)
row.surcharge_cap = rate
rate, chosen_note = _chosen_rate(rate, chosen_rates.get(row.supply_key))
row.surcharge_pct = rate
row.basis = " · ".join(part for part in (basis, alias_note) if part)
reason = table.missing_reasons.get(lookup, "") if rate is None else ""
row.basis = " · ".join(part for part in (basis, alias_note, chosen_note, reason) if part)
if rate is None:
missing_rate.append(name)
@@ -379,6 +432,8 @@ def build_table(
"unit": row.unit,
"net_amount": row.net_amount,
"surcharge_pct": row.surcharge_pct,
# 고를 수 있는 천장(표값) — `None` 이면 칸이 안 섬(표에 없거나 할증 포함 줄).
"surcharge_cap": None if row.surcharge_included else row.surcharge_cap,
"total_amount": row.total_amount,
"supply": row.supply,
"supply_label": SUPPLY_LABELS.get(row.supply, row.supply),
@@ -283,8 +283,16 @@ def expand_observed(
source_key = str(found.get("source") or "")
options = structure.get("options") or {}
components: list[dict[str, Any]] = []
blocked: list[str] = []
for item in found.get("components") or []:
note = str(item.get("basis_note") or "")
if not item.get("destination"):
# ⛔ 갈 곳 기본값 금지(명세 13장) — 종전 `or "material"` 은 줄이 늘 때 조용히 자재로 샘.
blocked.append(
f"{item['name']}」 관측 원단위 줄에 갈 곳이 없어 물량을 어느 축에도 안 보냄"
" — 표(`structure_unit_observed`)에 적을 것(명세 13장 갈 곳 기본값 금지)"
)
continue
weep = (
weep_component(found, options, scale, scale_note)
if item["name"] == "물구멍관"
@@ -298,15 +306,17 @@ def expand_observed(
"name": item["name"],
"unit": item["unit"],
"amount": float(item["amount"]) * scale,
"destination": item.get("destination") or "material",
"destination": item["destination"],
# 근거를 값 옆에 붙인다 — 관측값임을 화면·인계에서 바로 알아야 한다.
"basis": f"관측 원단위 {item['amount']:g}/{found.get('unit')} × {scale_note}"
+ (f" ({note})" if note else ""),
"basis_kind": BASIS_OBSERVED,
"source": source_key,
# 규격은 이름과 따로(명세 13장 Ⓒ) — 「이형철근」 + 「D13」(2026-09-14 가르기).
"spec": str(item.get("spec") or ""),
}
)
notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}"]
notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}", *blocked]
# 줄에 붙은 사유(원문끼리 다름 등) — 구조물도 그림도 같은 글자를 씀.
notes.extend(str(note) for note in found.get("notes") or [])
earthwork_note = earthwork_missing_note(found)
+31 -8
View File
@@ -208,12 +208,15 @@ REVET_ROLES = (
("inlet", "유입부 기슭막이", "유입 칸 벽"),
("outlet", "유출부 기슭막이", "유출 칸 벽"),
)
#: ⚠ 「횡단도와 같은 값」이 아님 — B06 은 관 벽 높이를 **관경 기준 최소 높이**로 따로 그림
#: (`revetWallSpec`), 독립 기슭막이도 저장 높이가 없으면 근입 깊이로 그림. 형태·길이만 같은 기본값.
#: 관 벽 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(B06 `pipe_wall_min_height_m`,
#: TS 짝 `pipeWallMinPureHeight`)로 섬 — 등록부·구조물도·횡단도가 한 값(2026-09-14 브레인 판정).
#: ⚠ 「최소」라 실제 설계값이 더 클 수 있어 **미확정·금액 밖은 그대로**.
NOTE_DEFAULT_WALL = (
"⚠ 시설 지점에 {filled} 을 안 적어 등록부 기본값으로 섰음 — 미확정이라 금액에 안 들어감"
" · 횡단도 벽 높이는 관경 기준으로 따로 그려져 다를 수 있음"
"⚠ 시설 지점에 {filled} 을 안 적어 기본값으로 섰음 — 미확정이라 금액에 안 들어감"
)
#: 꼬리 — 관 벽은 횡단도와 같은 높이 · 독립 기슭막이는 아직 갈림(관경이 없음).
NOTE_WALL_HEIGHT_PIPE = " · 높이는 횡단도와 같은 관경 기준 최소 높이(실제 설계값은 더 클 수 있음)"
NOTE_WALL_HEIGHT_OWN = " · ⚠ 횡단도는 저장 높이가 없으면 근입 0.5m 만 그림 — 표 높이와 다름"
#: 금액에서 뺄 까닭(2026-09-14 브레인 판정) — 줄·표·그림은 서되 내역 합에는 안 듦.
UNCONFIRMED_WALL = "벽 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬"
NOTE_SIDE_UNKNOWN = (
@@ -234,9 +237,13 @@ def _num_or_zero(value: Any) -> float:
def _revet_values(
options: dict[str, Any], role: str, defaults: dict[str, Any], legacy: bool
options: dict[str, Any],
role: str,
defaults: dict[str, Any],
legacy: bool,
min_height_m: float | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""벽 한쪽 제원과 등록부 기본값으로 채운 칸 — B06 세트 스펙(`_side_spec`)과 같은 채움."""
"""벽 한쪽 제원과 기본값으로 채운 칸 — 등록부 기본값 · 관 벽 높이는 관경 기준 최소 높이."""
values: dict[str, Any] = {}
filled: list[str] = []
for name, label in REVET_FIELDS:
@@ -245,12 +252,23 @@ def _revet_values(
value = options.get(name)
if _blank(value):
value = defaults.get(name if legacy else f"{role}_revet_{name}")
if value is None and name == "height_m" and min_height_m is not None:
value = min_height_m
label = f"{label}(관경 기준)"
if label and value is not None:
filled.append(f"{label} {value}")
values[name] = value
return values, filled
def _pipe_diameter_m(options: dict[str, Any], defaults: dict[str, Any]) -> float:
"""관경(m) — 저장값, 없으면 등록부 기본(B06 세트 스펙과 같은 채움)."""
raw = options.get("pipe_diameter_mm")
if _blank(raw):
raw = defaults.get("pipe_diameter_mm")
return _num_or_zero(raw) / 1000.0 or 1.0
def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""계곡 통과 시설(`pipe_points.json`) → 원단위 전개가 읽는 구조물 줄 (A1, 2026-09-14).
@@ -260,6 +278,7 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]:
집수정·기슭막이 터파기·되메우기는 전개 성분(`destination: earthwork`)이라 토공집계로만 .
"""
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import pipe_wall_min_height_m
from B08_Quantity.B08_Quantity_Engine_StructureSummary import pipe_row_id
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import attachments_of, child_row
@@ -304,11 +323,15 @@ def facility_structures(points: list[dict[str, Any]]) -> list[dict[str, Any]]:
)
rows.extend(children)
walls = []
min_height = (
None if legacy else pipe_wall_min_height_m(_pipe_diameter_m(options, wall_defaults))
)
for role, pipe_label, own_label in roles:
values, filled = _revet_values(options, role, wall_defaults, legacy)
values, filled = _revet_values(options, role, wall_defaults, legacy, min_height)
notes = list(inlet_notes) if role == "inlet" else []
if filled:
notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled)))
tail = NOTE_WALL_HEIGHT_OWN if legacy else NOTE_WALL_HEIGHT_PIPE
notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled)) + tail)
walls.append([role, own_label if legacy else pipe_label, values, notes, False, filled])
side = str(options.get("side") or "") if legacy else ""
if side in ("", ""):
@@ -523,6 +523,9 @@ FRAME_MATERIAL_SOURCE = (
" 품셈 11-2·11-3 [주]④ 는 「재료량은 설계수량에 따른다」로만 둠. 산출 조건에서 고칠 수 있음"
)
FRAME_LOSS_RATE = {"비탈 규준틀": 50, "수평 규준틀": 80}
#: 자재 줄의 **이름 · 규격** — 이름 칸에 규격을 섞으면 할증률표(「각재」·「판재」)와 안 맞음
#: (2026-09-14 · 돌 줄 8210c2b7 과 같은 병). 산출 조건 키·관급구분 키(이름+규격)는 글자 그대로.
FRAME_MATERIAL_NAME_SPEC = {"각재 50×50": ("각재", "50×50"), "판재 T12": ("판재", "T12")}
def frame_material_rows(
@@ -549,9 +552,11 @@ def frame_material_rows(
except (TypeError, ValueError):
per_ea = float(default)
picked = "산출 조건에서 고른 값" if raw not in (None, "") else "제안값(기본)"
material, spec = FRAME_MATERIAL_NAME_SPEC.get(name, (name, ""))
rows.append(
{
"name": name,
"name": material,
"spec": spec,
"unit": unit,
"amount": float(count) * per_ea,
"destination": "material",
@@ -27,7 +27,7 @@ from __future__ import annotations
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Pipe import NOTE_DEFAULT_WALL
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_is_wet, slope_of
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
_back_length,
back_length_default_note,
@@ -94,9 +94,14 @@ def back_length_cm(sheet: dict[str, Any]) -> tuple[int, str | None]:
양식 장은 양식이 (`formula_sheet.vars.L3`), 전개 장은 `_back_length` 표와 .
"""
options = sheet.get("options") or {}
wet, height = sheet_is_wet(sheet), float(sheet.get("height_m") or 0.0)
solved = ((sheet.get("formula_sheet") or {}).get("vars") or {}).get("L3")
back = int(float(solved)) if isinstance(solved, (int, float)) else _back_length(options)
return back, back_length_default_note(options, back)
back = (
int(float(solved))
if isinstance(solved, (int, float))
else _back_length(options, wet=wet, height_m=height)
)
return back, back_length_default_note(options, back, wet=wet, height_m=height)
def figure_reason(sheet: dict[str, Any]) -> str | None:
@@ -103,23 +103,28 @@ SLOPE_TYPE_IDS: frozenset[str] = frozenset(
REVETMENT_STONE_FORMS: dict[str, bool] = {"돌쌓기(찰)": True, "돌쌓기(메)": False}
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
""" 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다.
def sheet_is_wet(sheet: dict[str, Any]) -> bool:
"""의 메/찰 — 표준경사·뒷길이 표준이 같은 갈래로 읽음.
/찰은 종류에서 온다. 큰돌쌓기는 `bond` (메쌓기/찰쌓기) 그것이고, 고르면
찰쌓기로 본다 품셈 13-6 1:0.3 **이상**이라 그림 기울기가 갈리지 않는다.
큰돌쌓기는 `bond` (메쌓기/찰쌓기) 그것이고, 고르면 찰쌓기로 본다 품셈 13-6
1:0.3 **이상**이라 그림 기울기가 갈리지 않는다.
"""
options = sheet.get("options") or {}
type_id = str(sheet.get("type_id") or "")
if type_id == "masonry_dry":
wet = False
elif type_id == "boulder_masonry":
wet = options.get("bond") != "메쌓기"
elif type_id == "revetment":
return False
if type_id == "boulder_masonry":
return options.get("bond") != "메쌓기"
if type_id == "revetment":
# 표(기슭막이 전개)가 넘긴 그 갈래 — 형태 「돌쌓기(메)」만 메.
wet = REVETMENT_STONE_FORMS.get(str(options.get("form") or "").strip(), True)
else:
wet = True
return REVETMENT_STONE_FORMS.get(str(options.get("form") or "").strip(), True)
return True
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
"""장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다."""
options = sheet.get("options") or {}
wet = sheet_is_wet(sheet)
return face_slope_ratio(
options,
wet=wet,
@@ -16,7 +16,11 @@ import json
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import standard_back_length
ROOT = Path(__file__).resolve().parents[1]
#: 양식 칸 `default_from` — 빈 뒷길이를 뒷길이 표준표 하한으로(전개와 한 벌).
STANDARD_BACK_LENGTH = "standard_back_length"
TEMPLATE_DIR = ROOT / "resources" / "library_structure"
#: 사용자가 고친 식이 사는 칸 — B08 산출 조건 구획(`project_settings.json` 의 `quantity`).
@@ -75,6 +79,12 @@ def template_vars(
raw = options.get(spec["option"])
else:
raw = (settings or {}).get(spec.get("setting"))
if raw in (None, "") and spec.get("default_from") == STANDARD_BACK_LENGTH:
# 안 고른 뒷길이 — 전개와 같은 표준표 하한(`standard_back_length`, 브레인 판정 ㉮).
wet = template.get("type_id") != "masonry_dry"
height = float(structure.get("height_m") or 0.0)
values[name] = float(standard_back_length(wet=wet, height_m=height)[0])
continue
values[name] = default if raw in (None, "") else _typed(raw, default)
return values
@@ -52,6 +52,7 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import ( # noqa: F401
StructureQuantity,
_num,
is_unconfirmed,
missing_height_reason,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Masonry import ( # noqa: F401 — 다시 내보냄
boulder_masonry,
@@ -529,7 +530,7 @@ def build_table(
if rubble is not None:
quantity.components.append(rubble)
_append_section_trench(quantity, item, observed, rubble_base_thickness_m)
quantity.unconfirmed = str(item.get("unconfirmed") or "")
quantity.unconfirmed = str(item.get("unconfirmed") or "") or missing_height_reason(item)
quantities.append(quantity)
if use_templates:
# 늦게 부름 — 양식 모듈이 이 모듈을 부르므로 맨 위에서 부르면 맞물림.
@@ -51,8 +51,34 @@ DESTINATION = {
# 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다.
# 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다.
RUBBLE_BASE_NAME: "unit_price",
# ⭐ 2026-09-14 — 갈 곳 기본값을 걷자 드러난 넷(L형수로·개거·떼흙막이 정본 줄).
# 관측 원단위(`structure_unit_observed`)가 같은 이름에 둔 갈 곳과 맞춤(두 벌이 안 갈리게).
# 철근은 이름 「이형철근」 + 규격 「D13·D16」(같은 날 가름 — 대응표 조각은 이름+규격 키로 집음).
"이형철근": "material",
"유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품
"면목": "material",
"": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1)
}
#: ⛔ **갈 곳 기본값 금지**(명세 13장 「빠진 것이 조용해짐」) — 표에 없는 이름은 막고 사유를 냄.
#: 종전엔 `DESTINATION.get(name, "quantity")` 로 떨어져 **아무 축에도 안 가고** 자재총괄
#: 건너뛴 수로만 셈(2026-09-14 L형수로 이형철근·떼흙막이 떼가 통째로 빠져 있던 자리).
DESTINATION_MISSING = (
"{name}」 갈 곳이 표(`DESTINATION`)에 없어 물량을 어느 축에도 안 보냄"
" — 표에 올릴 것(명세 13장 갈 곳 기본값 금지)"
)
def routed(name: str, notes: list[str]) -> str | None:
"""성분 이름의 갈 곳 — 표에 없으면 `None` 이고 사유 한 줄을 `notes` 에 더함(이름마다 한 번)."""
destination = DESTINATION.get(name)
if destination is None:
text = DESTINATION_MISSING.format(name=name)
if text not in notes:
notes.append(text)
return destination
# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다.
# ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로
# 오탐된다 — 개발 중 실제로 걸렸던 자리다.
@@ -118,5 +144,22 @@ def is_unconfirmed(structure: dict[str, Any]) -> bool:
return bool(structure.get("unconfirmed"))
#: 높이가 물량 밑수인 벽 — 높이를 안 적고 놓아도 줄은 서되 금액 밖(2026-09-14 브레인 판정 ①
#: 「설계자가 자리부터 잡고 치수를 뒤에 넣는 길을 막지 말 것」). 흙막이(떼 개소당)는 높이를 안 씀.
HEIGHT_DRIVEN_TYPES = frozenset({"retaining_wall", "masonry_wet", "masonry_dry", "boulder_masonry"})
UNCONFIRMED_NO_HEIGHT = "높이를 안 적고 놓았음 — 높이를 적으면 금액이 섬"
def missing_height_reason(structure: dict[str, Any]) -> str:
"""높이 없이 놓인 벽이면 미확정 까닭 한 줄, 아니면 빈 글."""
if structure.get("type_id") not in HEIGHT_DRIVEN_TYPES:
return ""
try:
height = float((structure.get("options") or {}).get("height_m") or 0.0)
except (TypeError, ValueError):
height = 0.0
return "" if height > 0 else UNCONFIRMED_NO_HEIGHT
def _num(value: Any, fallback: float = 0.0) -> float:
return float(value) if isinstance(value, (int, float)) else fallback
@@ -23,6 +23,8 @@ from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import (
back_length_default_note,
face_slope_ratio,
fill_concrete_mpa,
STONE_SUPPLY_DEFAULT_NOTE,
STONE_SUPPLY_KEYS,
is_collected_stone,
load_stone_kind_table,
stone_coefficients,
@@ -258,14 +260,14 @@ def stone_masonry(
if height_m <= 0 or length_m <= 0:
return [], ["높이·연장이 없어 전개하지 않음"]
back_cm = _back_length(options)
back_cm = _back_length(options, wet=wet, height_m=height_m)
if back_cm not in STONE_BACK_LENGTH_TABLE:
# ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다.
return [], [
f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다"
]
table = dict(STONE_BACK_LENGTH_TABLE[back_cm])
default_note = back_length_default_note(options, back_cm)
default_note = back_length_default_note(options, back_cm, wet=wet, height_m=height_m)
if default_note:
notes.append(default_note)
# ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3).
@@ -487,6 +489,9 @@ def stone_masonry(
# 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.**
# ⚠ 밑수가 확정 5차로 바뀌었다 — 면석 몸통은 이제 **석적**(정면적×뒷길이×0.77)이고
# 막자갈은 **뒷채움 사다리꼴**이다. 옛 「입적 − 몸통 − 고임돌」 몫이 아니다.
if all(options.get(key) in (None, "") for key in STONE_SUPPLY_KEYS):
# 폼이 기본값을 값으로 안 보내게 된 뒤(2026-09-14) 빈 조달이 조용히 「채집」이 되지 않게.
notes.append(STONE_SUPPLY_DEFAULT_NOTE)
if is_collected_stone(options):
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0
collected = max(stone_pile + wedge + rubble, 0.0)
@@ -25,6 +25,7 @@ from typing import Any
from B08_Quantity.B08_Quantity_Engine_StructureSheet import REVETMENT_STONE_FORMS
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _num, stone_masonry
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import routed
#: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다.
#: ⚠ 구조물도 기울기·그림이 같은 표를 씀(`StructureSheet.REVETMENT_STONE_FORMS` 한 벌).
@@ -369,8 +370,9 @@ def erosion_check_dam(
)
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec))
Component(name, unit, amount, destination, basis, spec="".join(spec))
for name, unit, amount, basis, *spec in rows
if (destination := routed(name, notes))
]
mpa, mpa_basis = fill_concrete_mpa(options)
fill_per = coeff["fill_concrete_m3_per_m2"]
@@ -437,7 +439,7 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
0.1508,
"{(0.2+0.15)÷2×0.7} + (0.21×0.13)×1 + {(0.15+0.21)÷2×0.20}×1",
),
("이형철근 D13", "kg", 0.398, "0.2 × 2 × 0.995"),
("이형철근", "kg", 0.398, "0.2 × 2 × 0.995", "D13"), # 이름 · 규격 따로
("거푸집", "", 0.738, "0.2 + 0.33 + √(0.2² + 0.06²)"),
("면목", "m", 1.0, "1"),
),
@@ -489,7 +491,6 @@ def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
치수가 **전부 평균 붙박이** 정본 표에 (평균)이라 적혀 있고 개소별 치수조서가
따로 없다. 그래서 높이·길이를 받지 않는다. 개소별로 갈리게 하려면 치수 칸이 먼저다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
form = str(options.get("form") or "").strip()
if not form:
@@ -501,13 +502,16 @@ def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"— 흙막이는 앞면 1:0.3·뒷면 수직·천단 30㎝(교본)라 축이 다르고 근거가 없습니다"
]
notes: list[str] = []
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
Component(name, unit, amount, destination, basis)
for name, unit, amount, basis in SOIL_GUARD_SOD["rows"]
if (destination := routed(name, notes))
]
return components, [
*notes,
f"떼흙막이는 **{SOIL_GUARD_SOD['unit_label']}**이고 치수가 정본 평균 붙박이입니다"
"(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다"
"(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다",
]
@@ -516,7 +520,6 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component
규격을 고를 칸이 없어 지금은 **콘크리트 개거 150×200 붙박이**. 사실을 줄로 낸다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
if length_m <= 0:
from B08_Quantity.B08_Quantity_Wording import option_missing
@@ -529,17 +532,19 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component
f"개거 규격 「{spec}」 원단위가 없습니다 — 있는 것: {' · '.join(OPEN_DITCH_FORMS)}"
]
notes: list[str] = []
components = [
Component(
name,
unit,
per_m * length_m,
DESTINATION.get(name, "quantity"),
destination,
f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})",
spec="".join(spec),
)
for name, unit, per_m, basis in table["rows"]
for name, unit, per_m, basis, *spec in table["rows"]
if (destination := routed(name, notes))
]
notes: list[str] = []
if not options.get("ditch_spec"):
notes.append(f"규격을 안 골라 「{spec}」으로 섰습니다 — 제원에서 고를 수 있습니다")
if spec == "콘크리트 개거 150×200":
@@ -562,7 +567,6 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
등록부 밑수도 `area_m2` 축이 그대로 맞는다.
기울기 몫이 **없다** 평면에 붙이는 것이라 (1+) 곱하지 않는다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
form = str(options.get("form") or "").strip()
table = BED_SILL_FORMS.get(form)
@@ -607,11 +611,15 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
# 터파기 — 정본은 「두께 × 1 × 1」이라 **면적 × 두께**와 같다.
rows.append(("터파기", "", area_m2 * back, f"면적 × 두께 {back:g}m"))
notes: list[str] = []
components = [
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis, spec="".join(spec))
Component(name, unit, amount, destination, basis, spec="".join(spec))
for name, unit, amount, basis, *spec in rows
if (destination := routed(name, notes))
]
notes = [f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"]
notes.append(
f"뒷길이 {back:g}m 는 정본 탭 붙박이 값입니다 — 바닥막이 제원에 뒷길이 칸이 없습니다"
)
if form == "돌붙임(찰)":
notes.append(
"버림 콘크리트 0.1 ㎥/㎡ 포함 — 정본 주기 「바닥 10㎝ 이상 콘크리트(버림) 포설 후 "
@@ -279,6 +279,8 @@ STONE_MASONRY = {
#: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다.
STONE_SUPPLY_KEYS = ("stone_supply", "stone_source")
STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"}
#: 조달을 안 정한 구조물의 사유 — 셈은 확정 ② 「기본은 캔다」대로 채집이되 **안 정한 사실**을 보임.
STONE_SUPPLY_DEFAULT_NOTE = "돌 조달(채집·구입)을 안 정해 「채집」으로 셈 — 사용자 확정 ② 기본 · 구조물 상세에서 고르면 바뀜"
#: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도
#: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다).
@@ -442,8 +444,57 @@ def wants_blinding(options: dict[str, Any]) -> bool:
BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm")
def _back_length(options: dict[str, Any]) -> int:
"""저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 기본 45.
#: 뒷길이 표준표 — 품셈 13-4-4 [주]⑩. 안 고른 돌쌓기는 붙박이 45 가 아니라 이 표의 하한
#: (2026-09-14 브레인 판정 ㉮ 「범위 안에 드는 규격 중 가장 작은 것」). 파일이 없으면 종전 45.
BACK_LENGTH_PREFIX = "masonry_back_length_"
@lru_cache(maxsize=1)
def load_back_length_table() -> dict[str, Any]:
"""뒷길이 표준표. 없으면 빈 표 — 그때는 종전 기본 45㎝ 로 돎."""
files = sorted(SLOPE_DIR.glob(BACK_LENGTH_PREFIX + "*.json")) if SLOPE_DIR.is_dir() else []
return json.loads(files[-1].read_text(encoding="utf-8")) if files else {}
def standard_back_length(*, wet: bool, height_m: float) -> tuple[int, str]:
"""(제안 뒷길이 ㎝, 근거) — 높이·메/찰 칸 범위 안 품셈 규격 중 가장 작은 것.
하한이 규격(25·30·35·45·55·60·75) 없으면 ** 규격** 메쌓기 ~3m 하한 36 45.
내리면 품셈 범위 밖이 . 경계값은 표준경사표와 같이 ** **(높이 3.0m `~3`).
"""
table = load_back_length_table()
steps = table.get("steps_m") or []
bond = "찰쌓기" if wet else "메쌓기"
rows = (table.get("table_cm") or {}).get(bond) or []
if not rows:
return DEFAULT_BACK_LENGTH_CM, f"뒷길이 표준표를 못 읽어 종전 {DEFAULT_BACK_LENGTH_CM}"
index = next((i for i, limit in enumerate(steps) if height_m <= float(limit)), len(steps))
low, high = rows[min(index, len(rows) - 1)]
label = f"직고 {height_m:g}m" + (f"{steps[index]:g}m" if index < len(steps) else " 7m 초과")
span = f"{low}~{high}" if high is not None else f"{low}㎝ 이상"
picked = next(
(
size
for size in sorted(STONE_BACK_LENGTH_TABLE)
if size >= low and (high is None or size <= high)
),
None,
)
head = f"품셈 13-4-4 [주]⑩ 뒷길이 표준(원문 L7172~7181) · {bond} {label} {span}"
if picked is None:
return (
DEFAULT_BACK_LENGTH_CM,
f"{head} 안에 품셈 규격이 없어 종전 {DEFAULT_BACK_LENGTH_CM}",
)
tail = "" if picked == low else f"(하한 {low}㎝ 는 규격 일곱(원문 L7152)에 없어 그 위 첫 규격)"
return picked, f"{head} → 하한 {picked}{tail}"
def _back_length(
options: dict[str, Any], *, wet: bool | None = None, height_m: float | None = None
) -> int:
"""저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 뒷길이 표준표 하한(메/찰·높이를 받으면),
받는 자리(골막이 ) 종전 기본 45.
**접지 않는다.** 품셈 13-4-3·13-4-4 [] 25·30·35·45·55·60·75 **일곱 규격**
주므로 접을 까닭이 없다. 밖의 (40 ) **계수가 없다고 드러낸다**
@@ -461,13 +512,26 @@ def _back_length(options: dict[str, Any]) -> int:
# 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측).
# 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다.
return value
return DEFAULT_BACK_LENGTH_CM
if wet is None or height_m is None:
return DEFAULT_BACK_LENGTH_CM
return standard_back_length(wet=wet, height_m=height_m)[0]
def back_length_default_note(options: dict[str, Any], back_cm: int) -> str | None:
def back_length_default_note(
options: dict[str, Any],
back_cm: int,
*,
wet: bool | None = None,
height_m: float | None = None,
) -> str | None:
"""뒷길이를 안 골라 기본으로 섰을 때의 한 줄 — 표 사유·구조물도 그림이 **같은 말**을 씀."""
if any(options.get(key) not in (None, "") for key in BACK_LENGTH_KEYS):
return None
if wet is not None and height_m is not None:
return (
f"⚠ 뒷길이를 안 골라 {standard_back_length(wet=wet, height_m=height_m)[1]} 로 섰습니다 "
"— 구조물 상세에서 고르면 바뀜 · 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
)
return (
f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 "
"— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
@@ -432,6 +432,8 @@ class QuantitySettingsBody(BaseModel):
# 자재별 관급/사급 — `{자재명: {"supply": …, "install_by": …}}`.
# 표 안에서 줄마다 고른 값이 여기로 온다(2026-09-07 확정).
material_supply: dict[str, Any] | None = None
# 자재별 할증률 — `{이름 규격: %}`. 품셈 1-3-1 「표의 값 이내」라 0~표값만 씀(엔진이 거름).
material_surcharge: dict[str, Any] | None = None
# 콘크리트 타설 방식. `""` 는 「안 정함」으로 되돌리는 뜻이라 서버가 None 으로 만든다.
concrete_placing_method: str | None = None
# 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②).
@@ -574,6 +576,7 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
(
"rock_methods",
"material_supply",
"material_surcharge",
"concrete_placing_method",
"topsoil_target",
"tree_waste",
+32 -26
View File
@@ -146,6 +146,33 @@ async def _section_modes(project_id: UUID) -> dict[float, str]:
return section_modes_from_designs(designs)
def material_table_for(
unit_table: dict[str, Any],
settings: dict[str, Any],
preparation_table: dict[str, Any] | None,
) -> dict[str, Any]:
"""자재총괄 **한 벌** — 화면(`material-summary`)·인계(`handoff`)가 같이 부름(브레인 판정).
규준틀 재료는 **준비공 개소가 뒤에야** 서므로 준비공 표를 받아 여기서 얹음. 종전엔 인계만
얹어 화면 6 · 인계 9줄로 갈림 화면에서 규준틀 재료의 관급구분·할증을 만짐.
화면 쪽에 따로 복사하지 벌이 되면 갈림.
값은 **제안값(실무 관측)**이고 산출 조건에서 고칠 있음 사실이 사유에 적힘.
"""
frame_rows = [
row
for row in ((preparation_table or {}).get("rows") or [])
if str(row.get("item") or "").endswith("규준틀")
]
return build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
surcharge_overrides=settings.get("material_surcharge") or {},
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
concrete_placing_method=settings.get("concrete_placing_method"),
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}),
)
@router.get("/{project_id}/quantity/material-summary")
async def get_material_summary(project_id: UUID) -> JSONResponse:
"""구조물 원단위와 자재총괄을 **한 응답**으로 낸다.
@@ -183,12 +210,9 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
# 프로젝트에 박힌 양식(PLAN 4장) — 같은 까닭.
structure_templates=project_templates(project_root),
)
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
# 콘크리트 할증은 **레미콘일 때만** 붙는다 — 방식이 이름을 가른다(확정 3차 ⑥).
concrete_placing_method=settings.get("concrete_placing_method"),
)
# 인계와 같은 한 벌 — 규준틀 재료가 준비공 표를 따라 서므로 토공 표를 받음.
earthwork = await _earthwork_tables(project_id)
material_table = material_table_for(unit_table, settings, earthwork.get("preparation"))
# 묶음으로 서는 구조물의 조각을 화면에도 보인다 — 코드만으로는 사람이 검증 못 한다.
handoff = build_handoff(unit_quantity_table=unit_table)
composite = [
@@ -436,28 +460,10 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
structure_formulas=settings.get("structure_formula_overrides"),
structure_templates=project_templates(project_root),
)
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
concrete_placing_method=settings.get("concrete_placing_method"),
)
# 토공·운반 표는 토적표 라우터의 것을 그대로 쓴다 — 여기서 다시 만들지 않는다.
earthwork = await _earthwork_tables(project_id)
# 규준틀 재료 — **개소가 선 뒤에야 설 수 있어** 준비공 표를 받은 다음 자재 축에 얹는다.
# ⚠ 값은 **제안값(실무 관측)**이고 산출 조건에서 고칠 수 있다 — 그 사실이 줄 사유에 적힌다.
frame_rows = [
row
for row in ((earthwork.get("preparation") or {}).get("rows") or [])
if str(row.get("item") or "").endswith("규준틀")
]
material_table = build_material_table(
unit_table,
supply_map=settings.get("material_supply") or {},
concrete_placing_method=settings.get("concrete_placing_method"),
extra_materials=frame_material_rows(frame_rows, settings.get("frame_material") or {}),
)
# 자재총괄 — 화면과 같은 한 벌(규준틀 재료 포함).
material_table = material_table_for(unit_table, settings, earthwork.get("preparation"))
handoff = build_handoff(
summary_table=earthwork.get("summary"),
@@ -78,6 +78,8 @@ export interface QuantitySettings {
rock_methods?: Record<string, string>;
/** 자재별 관급/사급 — 표 안에서 줄마다 고른 값. */
material_supply?: Record<string, { supply: string; install_by: string | null }>;
/** 자재별 할증률(%) — 표값 이내에서 고른 값만. `null`·없음이면 표값. */
material_surcharge?: Record<string, number | null>;
/** 콘크리트 타설 방식. `null`·없음이면 **아직 안 정한 것**이고 화면이 기본값 안내를 띄운다. */
concrete_placing_method?: string | null;
/** 표토 두께(m). `null`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */
+49 -1
View File
@@ -28,6 +28,8 @@ export interface MaterialRow {
unit: string;
net_amount: number;
surcharge_pct: number | null;
/** 할증률표 값 = 고를 수 있는 천장(품셈 1-3-1 「표의 값 이내」). `null` 이면 칸이 안 섬. */
surcharge_cap?: number | null;
total_amount: number;
supply: string;
supply_label: string;
@@ -241,9 +243,50 @@ export interface SupplyChoice {
export interface MaterialGridOptions {
/** 저장 전 변경분 — 고른 값은 여기 쌓이고 [저장]에서만 정본으로 간다. */
choices: Record<string, SupplyChoice>;
/** `{ : % | null}` `material_surcharge`. .
* `null` = . 0 ~ ( ). */
surcharge?: Record<string, number | null>;
onChange: () => void;
}
/** 할증률 칸 — 표값(상한) 안에서만. 범위를 넘으면 칸이 빨갛게 되고 값을 안 받음. */
function surchargeCell(
row: MaterialRow,
key: string,
picks: Record<string, number | null>,
onChange: () => void,
): HTMLTableCellElement {
const cap = row.surcharge_cap as number;
const td = document.createElement("td");
const input = document.createElement("input");
input.type = "number";
input.min = "0";
input.max = String(cap);
input.step = "0.1";
input.className = "b08-grid__select";
input.style.width = "4.5em";
const picked = picks[key];
// 안 고른 칸은 **비워 두고 회색 제안값**(지금 붙는 표값)만 — 기본값을 값처럼 채우지 않음(폼 ② 규칙).
input.value = picked === null || picked === undefined ? "" : String(picked);
input.placeholder = String(row.surcharge_pct ?? cap);
input.title = `표값 ${cap}% 이내(품셈 1-3-1) · 비우면 표값`;
input.addEventListener("change", () => {
const text = input.value.trim();
const value = Number(text);
if (text !== "" && !(Number.isFinite(value) && value >= 0 && value <= cap)) {
input.setCustomValidity(`0 ~ ${cap}% 만 받음`);
input.reportValidity();
return;
}
input.setCustomValidity("");
picks[key] = text === "" ? null : value;
td.classList.add("is-changed");
onChange();
});
td.append(input);
return td;
}
/** 표 안의 고르는 칸. 바꾼 줄은 **표시가 남는다** — 무엇을 만졌는지 보여야 한다. */
function choiceCell(
value: string,
@@ -337,7 +380,12 @@ export function renderMaterialGrid(
tr.append(textCell(row.unit, "b08-grid__unit"));
tr.append(textCell(num(row.net_amount, 2)));
// 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다.
tr.append(textCell(row.surcharge_pct === null ? "" : num(row.surcharge_pct, 0)));
// 표값이 있는 줄은 「이내」로 고를 칸(초안 `surcharge` 가 이어졌을 때만).
tr.append(
options?.surcharge && typeof row.surcharge_cap === "number"
? surchargeCell(row, key, options.surcharge, options.onChange)
: textCell(row.surcharge_pct === null ? "" : num(row.surcharge_pct, 0)),
);
tr.append(textCell(num(row.total_amount, 2)));
if (options) {
// 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정).
+7
View File
@@ -95,6 +95,7 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
// 병합해 옛 값이 남아 되돌릴 길이 없다(화면에서 걸린 자리). 빈 값은 서버가 버린다.
rock_methods: draft.rock_methods,
material_supply: draft.material_supply,
material_surcharge: draft.material_surcharge,
concrete_placing_method: draft.concrete_placing_method,
// ⚠ `null` 도 그대로 보낸다 — 「안 정함」으로 되돌릴 길이 있어야 한다(시공법과 같은 규칙).
topsoil_thickness_m: draft.topsoil_thickness_m,
@@ -314,6 +315,8 @@ interface DraftSettings {
tree_waste_root_method: string;
// 자재별 관급/사급 — 표 안에서 줄마다 고른 값.
material_supply: Record<string, SupplyChoice>;
// 자재별 할증률(%) — 표값 이내에서 고른 값만 · `null` 은 표값.
material_surcharge: Record<string, number | null>;
// 갈래별 토량환산계수(다짐) — `compacted` 가 `null` 이면 「안 고름」이라 기본값이 선다.
conversion_factors: Record<string, FactorDraft>;
// 도쟈 한계거리(m) — `null` 은 기본값(서버 정본 60 m)이 선다.
@@ -925,6 +928,7 @@ function buildQuantityBody(
material.material,
{
choices: draft.material_supply,
surcharge: draft.material_surcharge,
onChange: () => {
draft.dirty = true;
},
@@ -1011,6 +1015,9 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
...((stored.ancillary_counts ?? {}) as Record<string, number | null>),
},
material_supply: { ...((stored.material_supply ?? {}) as Record<string, SupplyChoice>) },
material_surcharge: {
...((stored.material_surcharge ?? {}) as Record<string, number | null>),
},
// ⚠ 저장분에 있는 갈래만 담는다 — 기본값을 복사해 넣으면 「안 고름」이 사라진다.
conversion_factors: Object.fromEntries(
Object.entries(stored.conversion_factors_override ?? {}).map(([kind, entry]) => [
@@ -428,12 +428,17 @@ def build_bill(
master: dict[str, Any] | None = None,
structure_prices: dict[str, dict[str, Any]] | None = None,
rates: dict[str, Any] | None = None,
material_prices: dict[str, Any] | None = None,
) -> BillResult:
"""인계 응답 한 벌을 ④ 예산내역서 한 장으로 접는다.
`structure_prices` 구조물도 호표 금액 `{B-AX-ST-#키: …}`(B08 `structure_bill_prices`,
** `build` 단가표로** ). 주면 호표 줄은 금액 없이 사유와 함께 .
`material_prices` 자재 단가 저장본. 사급 자재총괄 줄이 값으로 본체 자재 줄이 .
"""
from B09_Estimation.B09_Estimation_MaterialPrices import normalize as normalize_prices
manual_prices = normalize_prices(material_prices)
work_items, materials = parse_handoff(payload)
unit_prices = build or cached_build()
master = master or load_work_item_master()
@@ -576,7 +581,7 @@ def build_bill(
# ── 3) 자재 벌 ────────────────────────────────────────────────────────────
for material in materials:
result.material_rows.append(_material_row(material, result))
result.material_rows.append(_material_row(material, result, manual_prices))
# ── 4) 검사 — `in_bill=false` 줄에 금액이 붙지 않았는가 ──────────────────────
check_excluded_rows_not_priced(rows=[r.as_dict() for r in result.excluded])
@@ -649,7 +654,12 @@ def build_bill(
result.material_sheet = build_material_sheet(
materials,
surcharge_status=str(payload.get("surcharge_status") or "rate_unavailable"),
manual=manual_prices,
)
# 사급·수동 단가 자재 → 본체 「자재(사급)」 줄(이중계상 가드 · PLAN 1장 Ⓐ-2).
from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import raise_material_rows
raise_material_rows(result, unit_prices, next_number, bill_line)
# ③ 단가산출서 번호를 줄 비고에 단다 — 실무가 내역서를 검산하는 길이다(8-13).
from B09_Estimation.B09_Estimation_PriceBasis import build_price_basis
@@ -0,0 +1,122 @@
"""B09 원가계산 — 사급 자재총괄 줄을 **내역 본체 「자재」 줄**로 (PLAN 1장 Ⓐ-2 · 브레인 판정).
셈부터 (배정 프로젝트 자재총괄 9): 일위대가를 거쳐 이미 내역에 자재 0 · 일위대가에
드는 자재 9 · 할증 0 품셈 표에 재료 줄이 없는 공종(규준틀·찰쌓기·타설) 자재라 따로
계상하는 것이 실무 모양. 본체 줄로 서므로 도급 재료비·계약·기성·집계가 고칠 없이 줄을 .
올리는 사급 · 수동 단가가 줄만(미확정 1) 관급은 수량표에만(도급 금액 )
이중계상 가드 같은 구조물 내역 줄의 일위대가 자재(붙은 · 붙은 ) 겹치면 올리고
사유로 멈춤. **코드가 있으면 코드로, 없을 때만 이름으로**(명세 2). 막는 쪽이라 틀려도 과소
(줄이 서고 사유가 ) 양식이 늘어 생기는 금액이 조용히 부풀지 않게.
"""
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_PriceBook import Money3, PriceKind
from B09_Estimation.B09_Estimation_UnitPrice import FUEL_CODE_PREFIX, UnitPriceBuild
MATERIAL_GROUP_NAME = "자재(사급)"
DOUBLE_COUNT_SUSPECT = "double_count_suspect"
_ZERO = Decimal(0)
def _flat(text: Any) -> str:
return "".join(str(text or "").split())
def unit_price_materials(row: BillRow, unit_prices: UnitPriceBuild) -> list[tuple[str, str]]:
"""그 내역 줄의 일위대가가 밟는 자재 — 붙은 것 (코드, 이름) · 못 붙은 줄 (「」, 글)."""
book = unit_prices.book
stack = [code for code in (row.price_code, *(code for code, _ in row.parts)) if code]
seen: set[str] = set()
found: list[tuple[str, str]] = []
while stack:
code = stack.pop()
if code in seen:
continue
seen.add(code)
title = book.titles.get(code)
if title is not None and title.kind is PriceKind.MATERIAL:
if not code.startswith(FUEL_CODE_PREFIX): # 기계 연료는 자재총괄 자재가 아님
found.append((code, title.name))
if code.startswith("B-"):
labels = unit_prices.unattached.get(code[2:].split("#", 1)[0]) or []
found.extend(("", label) for label in labels)
stack.extend(detail.ref_code for detail in book.details.get(code, []))
return found
def overlap(name: str, code: str, inside: list[tuple[str, str]]) -> str:
"""겹친 일위대가 자재 글 — 코드가 있으면 코드로, 없으면 이름(공백 무시 앞머리)으로."""
if code:
return next((label for found, label in inside if found == code), "")
wanted = _flat(name)
return next((label for _, label in inside if wanted and _flat(label).startswith(wanted)), "")
def raise_material_rows(
result: BillResult,
unit_prices: UnitPriceBuild,
next_number: Callable[[str], str],
bill_line: Callable[[Money3, Decimal], Money3],
) -> None:
"""자재대 표의 사급·수동 단가 줄 → 본체 「자재(사급)」 묶음 줄. 가드에 걸린 줄은 사유만."""
from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import settle_quantity
sheet = result.material_sheet
picked = []
for item in getattr(sheet, "contractor_rows", []):
if item.amount_krw is None or not item.manual:
continue
inside = [
pair
for row in result.rows
if not row.is_group and row.name in item.source_structure
for pair in unit_price_materials(row, unit_prices)
]
hit = overlap(item.name, getattr(item, "code", ""), inside)
if hit:
reason = f"두 번 셀 수 있어 내역 줄로 안 올림 — 같은 구조물 일위대가에 「{hit}"
item.note = f"{item.note} · {reason}"
result.missing.append(
{
"name": item.key,
"unit": item.unit,
"quantity": str(item.total_amount),
"reason": reason,
"blocked_kind": DOUBLE_COUNT_SUSPECT,
}
)
continue
picked.append(item)
if not picked:
return
group_no = next_number("")
result.rows.append(
BillRow(item_no=group_no, level=1, code=None, name=MATERIAL_GROUP_NAME, is_group=True)
)
for index, item in enumerate(picked, start=1):
price = item.unit_price_krw
row = BillRow(
item_no=f"{group_no}-{index}",
level=2,
code=None,
name=item.name,
spec=item.spec,
unit=item.unit,
quantity=item.total_amount,
)
line = bill_line(Money3(material=price), settle_quantity(row)) # 수량 확정 뒤 금액
row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = price, _ZERO, _ZERO
row.unit_price_krw = price
row.amount_krw = line.total
row.material_krw = line.material
row.unconfirmed = 1
row.add_note("quantity", f"자재총괄 할증 뒤 수량 — {', '.join(item.source_structure)}")
row.add_note("unit_price_krw", item.note)
result.unconfirmed.append({"name": row.name, "code": item.key, "count": 1})
result.rows.append(row)
@@ -29,7 +29,9 @@ from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
dump_title_code,
loading_title_code,
)
from B09_Estimation.B09_Estimation_MaterialPrices import manual_count
from B09_Estimation.B09_Estimation_PriceBook import Money3
from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import (
UnitPriceBuild,
@@ -51,6 +53,17 @@ def bill_line(unit: Money3, quantity) -> Money3:
)
def settle_quantity(row: BillRow) -> Decimal:
"""수량을 품셈 1-2-2 자리로 **반올림해 확정**하고 그 값을 돌려줌 — 금액은 확정한 수량으로 셈.
산림청고시 2025-82 1-2-2 [] 설계서 수량의 단위와 소수자리 표시는 표에 따르며,
**반올림하여 적용**한다 수량 먼저 확정 금액(`bill_line` 버림). 둘을 섞지 않음(2026-09-14
브레인 판정). 종전엔 인계 전정밀(0.2818199996) 곱해 미만 절사에 1원씩 샜음.
"""
row.quantity, _ = round_quantity(row.quantity, row.name, row.unit, row.spec)
return row.quantity
def _sum_groups(rows: list[BillRow]) -> None:
"""머리글 줄 금액 = 그 아래 줄 금액의 합(성분마다) — 실무 내역서 계 줄. 화면은 더하지 않음.
@@ -140,7 +153,7 @@ def _composite_row(
return row
money = money.floored(Decimal(1))
line = bill_line(money, item.quantity)
line = bill_line(money, settle_quantity(row))
_set_unit(row, money)
row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW)
row.amount_krw = line.total
@@ -148,6 +161,7 @@ def _composite_row(
row.labor_krw = line.labor
row.expense_krw = line.expense
row.add_note("quantity", f"묶음 {len(item.composite_parts)}조각 합계")
_mark_manual_materials(row, [code for code, _ in row.parts], unit_prices, result)
return row
@@ -197,7 +211,7 @@ def _structure_price_row(
return row
# 단가 = 호표 계금(구조물도 화면과 같은 값) · 금액 = 호표 성분 소계 × 수량(명세 7장).
line = bill_line(entry["money"], item.quantity)
line = bill_line(entry["money"], settle_quantity(row))
_set_unit(row, entry["money"])
row.price_code = ref
row.parts = list(entry.get("parts") or [])
@@ -542,7 +556,7 @@ def _leaf_row(
row.price_code = price_code
unit_money = unit_prices.book.resolve(price_code)
line = bill_line(unit_money, item.quantity)
line = bill_line(unit_money, settle_quantity(row))
_set_unit(row, unit_money)
row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW)
# 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상.
@@ -551,9 +565,23 @@ def _leaf_row(
row.material_krw = line.material
row.labor_krw = line.labor
row.expense_krw = line.expense
_mark_manual_materials(row, [price_code], unit_prices, result)
return row
def _mark_manual_materials(
row: BillRow, codes: list[str], unit_prices: UnitPriceBuild, result: BillResult
) -> None:
"""자재 수동 단가가 닿은 줄 — 금액은 세우되 「미확정 N건」(구조물도 수동 단가와 같은 통로)."""
manual = getattr(unit_prices, "manual_materials", {})
count = sum(manual_count(unit_prices.book, code, manual) for code in codes)
if not count:
return
row.unconfirmed += count
row.add_note("unit_price_krw", f"⚠ 자재 수동 단가 {count}건 미확정")
result.unconfirmed.append({"name": row.name, "code": codes[0], "count": count})
#: 수량 산식이 **사용자 확정을 기다리는** 공종 — 금액은 세우되 그 사실을 함께 싣는다.
#:
#: ⚠ **2026-09-09 저녁 비었다.** 걸려 있던 셋(구조물터파기·되메우기·잔토처리)이
@@ -580,8 +608,14 @@ def pending_formula_note(code: str | None) -> str:
return ""
def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**."""
def _material_row(
material: HandoffMaterial, result: BillResult, manual: dict | None = None
) -> BillRow:
"""자재 한 줄. 공급 구분이 안 갈렸으면 **어느 쪽에도 안 넣는다**.
`manual` 자재 단가 수동 단가( 이름 규격). 사급 줄에 값이 있으면 빠진 목록에 올림
(금액은 본체 자재(사급) 줄이 `BillOfQuantities_Materials`).
"""
row = BillRow(
item_no="",
level=1,
@@ -616,9 +650,14 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow:
"관급자재대(총원가 밖 별도 표기)로 갑니다.",
)
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", "사급 자재 단가 미확보 — 6번 슬롯(적용 단가) 수동 입력 대기."
"unit_price_krw", "사급 자재 단가 미확보 — 「자재 단가」 탭에서 수동 입력 대기."
)
reason = "사급 자재 단가 없음(미결 No.18)"
@@ -207,6 +207,7 @@ def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Dec
f"×{_LOADING_E0[code]}/22 = {output}㎥/hr · {_LOADING_NOTES[code]}",
output=output,
)
build.formula_variants[title_code] = "산림품셈 10-12 「1. 적재」 본문 식"
if not distances_m:
return
hourly = f"X-{DUMP_TRUCK_CODE}"
@@ -236,6 +237,7 @@ def attach_dump_hauls(build: Any, master: dict[str, Any], distances_m: tuple[Dec
haul.formula_text + " · " + " · ".join(material.notes),
output=haul.hourly_output,
)
build.formula_variants[title_code] = "산림품셈 10-12 「2. 운반」 본문 식 · 운반거리"
def dump_haul_distances(payload: dict[str, Any]) -> tuple[str, ...]:
@@ -0,0 +1,234 @@
"""B09 원가계산 — **자재 수동 단가** 한 벌 (PLAN 1장 Ⓐ · 2026-09-14 브레인 판정).
저장 자리 산출 조건 `estimation.material_prices`
{: {price_krw, source, entered_at, name, spec, unit}}
자원 코드가 있으면 **코드**(`AR-M-`) · 코드 없는 자재만 이름 규격
(명세 2 잇기는 코드로 · 11 규격은 조인 )
이름·규격·단위는 **보이기용** 맞추는 . 이름이 고쳐져도 단가가 끊김
얹는 자리 조립 자재 제목(M) 세움 6 슬롯 = 수동 단가 · 쪽수 = 출처
자원 자재 줄이 제목에 붙어 일위대가 재료비가
넣은 ·출처가 같으면 다시 저장해도 바뀜(구조물도 수동 단가 `save_manual` 같은 )
미확정 수동 단가가 닿은 내역 줄마다 미확정 N건(구조물도 수동 단가·폐기물과 같은 통로)
값이 없거나 0 이하인 칸은 받지 않음 0 원으로 채우면 단가 없음 금액 0 으로 숨음.
코드 없는 이름 규격 줄은 자재총괄 (다음 단계) 조립에는 코드 줄만 얹음.
이름 줄에 나중에 코드가 붙으면 코드 키로 옮겨 붙일 자리 아직 만듦(브레인 판정).
"""
from __future__ import annotations
import json
import os
import re
from decimal import Decimal, InvalidOperation
from functools import lru_cache
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import (
PRICE_SLOT_COUNT,
PriceBook,
PriceKind,
PriceTitle,
)
MATERIAL_PRICES_KEY = "material_prices"
#: 조립에 얹는 키 — 자원 목록 자재 코드(명세 §2 ③ 모양).
_RE_MATERIAL_CODE = re.compile(r"^AR-M-[0-9a-f]{8}$")
#: 출처를 안 적었을 때 쪽수 칸 글.
MANUAL_SOURCE = "수동 입력"
_TEXT_FIELDS = ("source", "name", "spec", "unit")
class MaterialPriceError(ValueError):
"""받을 수 없는 단가 — 저장하지 않고 까닭을 돌려줌."""
def _price(value: Any) -> Decimal | None:
try:
price = Decimal(str(value).replace(",", "").strip())
except (InvalidOperation, ValueError):
return None
return price if price.is_finite() and price > 0 else None
def is_code_key(key: str) -> bool:
"""조립에 얹는 코드 키인가 — 아니면 「이름 규격」 키."""
return bool(_RE_MATERIAL_CODE.match(key))
def normalize(raw: Any) -> dict[str, dict[str, str]]:
"""저장본을 받을 수 있는 줄만 — 값이 없거나 0 이하면 버림."""
prices: dict[str, dict[str, str]] = {}
if not isinstance(raw, dict):
return prices
for key, entry in raw.items():
price = _price(entry.get("price_krw")) if isinstance(entry, dict) else None
if not str(key).strip() or price is None:
continue
prices[str(key)] = {
"price_krw": str(price),
"entered_at": str(entry.get("entered_at") or ""),
**{field: str(entry.get(field) or "") for field in _TEXT_FIELDS},
}
return prices
def build_key(raw: Any) -> tuple[tuple[str, str, str], ...]:
"""조립 캐시 키 — 코드 키만 (키, 단가, 출처). 같은 값이면 같은 벌."""
return tuple(
sorted(
(key, entry["price_krw"], entry["source"])
for key, entry in normalize(raw).items()
if is_code_key(key)
)
)
def merge(stored: Any, changes: list[dict[str, Any]], today: str) -> dict[str, dict[str, str]]:
"""바꿀 것 여럿을 한 번에 — `price_krw` 가 비면 그 줄을 지움(단가 없음으로 돌아감)."""
prices = normalize(stored)
for change in changes:
key = str(change.get("key") or "").strip()
if not key:
raise MaterialPriceError("자재 키가 비었습니다")
raw_price = change.get("price_krw")
if raw_price is None or str(raw_price).strip() == "":
prices.pop(key, None)
continue
price = _price(raw_price)
if price is None:
raise MaterialPriceError(f"단가는 0 보다 큰 수여야 합니다: {raw_price}")
old = prices.get(key) or {}
source = str(change.get("source") or "").strip()
same = old.get("price_krw") == str(price) and old.get("source") == source
prices[key] = {
"price_krw": str(price),
"entered_at": old["entered_at"] if same and old.get("entered_at") else today,
"source": source,
**{
field: str(change.get(field) or old.get(field) or "")
for field in ("name", "spec", "unit")
},
}
return prices
@lru_cache(maxsize=1)
def material_catalog_rows() -> dict[str, dict[str, str]]:
"""자원 목록(`AR-M-`) 코드 → 이름·규격·단위 — 제목을 세우고 화면에 보일 글."""
from B09_Estimation.B09_Estimation_ResourceAxis_Join import EXT_CATALOG_FILE
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
path = os.path.join(root, "resources", "data_resource_catalog", EXT_CATALOG_FILE)
if not os.path.isfile(path):
return {}
with open(path, encoding="utf-8") as handle:
entries = json.load(handle).get("entries") or []
return {
str(row["code"]): {
"name": str(row.get("name") or ""),
"spec": str(row.get("spec") or ""),
"unit": str(row.get("unit") or ""),
}
for row in entries
if is_code_key(str(row.get("code") or ""))
}
def add_material_titles(
book: PriceBook, prices: tuple[tuple[str, str, str], ...]
) -> dict[str, str]:
"""코드 키 수동 단가로 자재 제목(M)을 세움 — 돌려주는 값 = 코드 → 출처(미확정 셈에 씀).
자원 목록에 없는 코드는 세움 화면 목록이 단가표에서 사라진 코드 드러냄(`listing`).
"""
catalog = material_catalog_rows()
manual: dict[str, str] = {}
for code, price, source in prices:
row = catalog.get(code)
if row is None or code in book.titles:
continue
slots: list[Decimal | None] = [None] * PRICE_SLOT_COUNT
pages: list[str | None] = [None] * PRICE_SLOT_COUNT
slots[-1] = Decimal(price)
pages[-1] = source or MANUAL_SOURCE
book.add_title(
PriceTitle(
code=code,
kind=PriceKind.MATERIAL,
name=row["name"],
spec=row["spec"],
unit=row["unit"],
slots=slots,
slot_pages=pages,
)
)
manual[code] = source or MANUAL_SOURCE
return manual
def manual_count(book: PriceBook, code: str | None, manual: dict[str, str]) -> int:
"""그 단가가 밟는 **수동 단가 자재 수**(같은 자재는 한 번) — 내역 줄 「미확정 N건」."""
if not code or not manual:
return 0
found: set[str] = set()
seen: set[str] = set()
stack = [code]
while stack:
current = stack.pop()
if current in seen:
continue
seen.add(current)
if current in manual:
found.add(current)
stack.extend(detail.ref_code for detail in book.details.get(current, []))
return len(found)
def listing(
build: Any, stored: Any, materials: list[dict[str, Any]] | None = None
) -> list[dict[str, Any]]:
"""화면 줄 — 자원 축이 쓰는 자재(코드) + 자재총괄 자재(「이름 규격」) + 저장만 남은 줄.
`materials` B08 인계 `materials`(자재총괄). 사급 줄만 단가 칸이 (관급은 도급 금액 ).
사라진 줄도 조용히 버림 사라진 자재 보이고 사용자가 지움.
"""
prices = normalize(stored)
catalog = material_catalog_rows()
uses: dict[str, list[str]] = getattr(build, "material_uses", {}) or {}
rows: list[dict[str, Any]] = []
def add(key: str, shown: dict[str, Any], **extra: Any) -> None:
entry = prices.get(key) or {}
rows.append(
{
"key": key,
**{
field: shown.get(field) or entry.get(field, "")
for field in ("name", "spec", "unit")
},
"price_krw": entry.get("price_krw"),
"source": entry.get("source", ""),
"entered_at": entry.get("entered_at", ""),
"missing": False,
**extra,
}
)
for code in sorted(uses, key=lambda c: (catalog.get(c, {}).get("name", ""), c)):
add(code, catalog.get(code, {}), work_items=sorted(uses[code]), origin="unit_price")
for material in materials or []:
key = f"{material.get('material_name', '')} {material.get('spec') or ''}".strip()
add(
key,
{**material, "name": material.get("material_name")},
work_items=list(material.get("source_structure") or []),
origin="material_sheet",
supply_type=material.get("supply_type") or "",
quantity=str(material.get("total_amount")),
)
listed = {row["key"] for row in rows}
for key, entry in sorted(prices.items()):
if key not in listed:
rows.append({"key": key, **entry, "work_items": [], "missing": True})
return rows
+33 -7
View File
@@ -58,6 +58,9 @@ class MaterialSheetRow:
surcharge_pct: Decimal | None = None
source_structure: tuple[str, ...] = ()
note: str = ""
#: 「자재 단가」 탭 키(이름 규격) · 수동 단가로 섰나(= 미확정).
key: str = ""
manual: bool = False
def as_dict(self) -> dict[str, Any]:
def money(value: Decimal | None) -> str | None:
@@ -75,6 +78,8 @@ class MaterialSheetRow:
"surcharge_pct": money(self.surcharge_pct),
"source_structure": list(self.source_structure),
"note": self.note,
"key": self.key,
"manual": self.manual,
}
@@ -119,8 +124,14 @@ def build_material_sheet(
*,
surcharge_status: str = SURCHARGE_RATE_UNAVAILABLE,
catalog: MaterialCatalog | None = None,
manual: dict[str, dict[str, str]] | None = None,
) -> MaterialSheet:
"""자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**."""
"""자재 목록에 단가를 붙인다. 못 붙이면 **금액을 비우고 사유를 남긴다**.
`manual` 자재 단가 수동 단가(`MaterialPrices.normalize`, 이름 규격).
**사급 단가는 수동 단가만**(2026-09-14 PLAN 1 -2) 나라장터는 관급 단가라 사급에 쓰면
도급 재료비가 관급 값으로 조용히 . 관급 줄은 종전대로 나라장터(총원가 ).
"""
book = catalog or load_material_catalog()
sheet = MaterialSheet()
@@ -141,6 +152,7 @@ def build_material_sheet(
surcharge_pct=getattr(material, "surcharge_pct", None),
source_structure=tuple(getattr(material, "source_structure", ()) or ()),
)
row.key = f"{row.name} {row.spec}".strip()
if row.supply_type == SUPPLY_UNKNOWN:
# ⚠ 어느 쪽에도 안 넣는다 — 넣으면 총액이 틀리고 나중에 못 가린다.
@@ -151,19 +163,33 @@ def build_material_sheet(
)
continue
found = book.resolve(row.name, row.spec)
if found is None:
entry = (manual or {}).get(row.key) if row.supply_type == SUPPLY_CONTRACTOR else None
found = book.resolve(row.name, row.spec) if row.supply_type == SUPPLY_OWNER else None
price = Decimal(entry["price_krw"]) if entry else found.price_krw if found else None
if price is None:
row.note = (
"자재 단가습니다 — 유료 물가지 미결(No.18). "
"6번 슬롯(적용 단가) 수동 입력 대기."
"사급 자재 단가 없음 — 「자재 단가」 탭에서 수동 입력(유료 물가지 미결 No.18)"
if row.supply_type == SUPPLY_CONTRACTOR
else "관급 자재 단가 없음 — 나라장터 목록에 이 품목이 없음"
)
sheet.missing.append(
{"name": row.name, "unit": row.unit, "reason": "자재 단가 없음(미결 No.18)"}
)
else:
row.unit_price_krw = found.price_krw
row.unit_price_krw = price
# 자재대 줄도 **내역서 본체와 같은 절사** 자리다.
row.amount_krw = round_at(found.price_krw * row.total_amount, OutputPlace.BOQ_ROW)
row.amount_krw = round_at(price * row.total_amount, OutputPlace.BOQ_ROW)
if entry:
row.manual = True
row.note = " · ".join(
part
for part in (
"⚠ 수동 단가(미확정)",
entry.get("source"),
entry.get("entered_at"),
)
if part
)
if row.supply_type == SUPPLY_OWNER:
sheet.owner_rows.append(row)
+19 -12
View File
@@ -16,9 +16,10 @@
**항목에서 제시하는 소숫자리를 우선**). 그래서 `override` 받는다. 다만 지금 공종
마스터에 항목의 소수자리 담은 칸이 **아직 없다** 칸이 생기면 여기로 흘리면 된다.
**모르 종목은 손대지 않는다.** 규칙을 넓게 잡아 엉뚱한 줄까지 자르는 사고를 오늘만
여러 겪었다. 표에 없으면 `None` 돌려주고 **화면이 종전대로** 찍게 둔다
모른다 보이는 편이 조용히 틀리는 것보다 낫다.
**표에 종목은 우리 기본 소수 2자리**(2026-09-14 브레인 판정 종전 손대지 않음 폐기).
수량을 자리로 **먼저 확정한 ** 금액을 [] 시킨 차례이고, 명세의 Q 소수 2자리로
먼저 확정한 나눔 같은 원칙(내역 `BillOfQuantities_Rows.settle_quantity`).
맞춘 이름은 종전대로 로그로 드러냄.
"""
from __future__ import annotations
@@ -50,6 +51,9 @@ _DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = (
(("철강재", "강재"), ("kg", ""), 3),
(("용접봉",), ("kg", ""), 1),
(("철근",), ("kg", ""), 0),
# 같은 줄의 단위 환산 — 원문 「철근 kg 정수」 = ton 소수 3자리. STmate 실무 원본 영월·울진
# 설계내역서 철근·철근운반 Ton 0.264·0.008 이 그 자리(2026-09-14 골든셋 대조 · 원문 값 아님).
(("철근",), ("ton", ""), 3),
(("볼트", "너트", "꺽쇠"), ("",), 0),
(("철선", "철사"), ("kg", ""), 2),
(("",), ("kg", ""), 2),
@@ -68,6 +72,8 @@ _DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = (
#: ⚠ **면적을 1자리로 내리지 않는다** — 「토적(단면적)」은 횡단면적을 말하는 것이라
#: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다.
_UNIT_ONLY = {"": 2, "m3": 2}
#: 표에도 단위에도 안 걸리는 줄의 자리 — 우리 기본(브레인 판정 2026-09-14).
DEFAULT_DIGITS = 2
logger = logging.getLogger(__name__)
#: 못 맞춘 줄을 이미 알린 (이름, 단위) — 내역을 그릴 때마다 같은 로그가 쌓이지 않게.
@@ -78,15 +84,16 @@ def _tight(text: str) -> str:
return "".join(str(text or "").split())
def digits_for(name: str, unit: str, spec: str = "") -> int | None:
"""그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다.
def digits_for(name: str, unit: str, spec: str = "") -> int:
"""그 줄의 수량 소수자리. 표에 없으면 단위(㎥ 2) → 그것도 없으면 우리 기본 2.
이름으로 종목을 찾는다 품셈 1-2-2 자체가 종목 이름 표라 코드로 옮길 대응표가 없음
(금액이 아닌 표시 자리). **이름을 맞춘 줄은 로그** 남긴다 마스터 갈래 (1)
서면 함께 정리함(2026-09-13 브레인 판정: 이름은 그대로 두고 맞춘 것만 드러냄).
"""
haystack = _tight(name) + _tight(spec)
unit_tight = _tight(unit)
# 단위 대소문자는 뜻이 같음(실무 원본 「M3」·「TON」·「Ton」) — 표기만 흡수.
unit_tight = _tight(unit).lower()
for words, units, digits in _DIGITS:
if unit_tight not in units:
continue
@@ -100,9 +107,11 @@ def digits_for(name: str, unit: str, spec: str = "") -> int | None:
"B09 수량 자리 — 종목 이름을 못 맞춤: 「%s%s%s",
name,
unit,
"단위만으로 %s자리" % fallback if fallback is not None else "표시 자리 없음",
"단위만으로 %s자리" % fallback
if fallback is not None
else "기본 %s자리" % DEFAULT_DIGITS,
)
return _UNIT_ONLY.get(unit_tight)
return _UNIT_ONLY.get(unit_tight, DEFAULT_DIGITS)
def round_quantity(
@@ -114,11 +123,9 @@ def round_quantity(
) -> tuple[Decimal, int | None]:
"""수량을 그 종목의 자리로 **반올림**한다.
돌려주는 (자른 , 자리). 자리를 찾으면 **값을 건드리고** `(, None)`.
`override` 품셈 **항목이 따로 제시한 자리**([]) 표보다 우선한다.
돌려주는 (자른 , 자리).
`override` 품셈 **항목이 따로 제시한 자리**([]) 표보다 우선한다(마스터 대기).
"""
digits = override if override is not None else digits_for(name, unit, spec)
if digits is None:
return value, None
quantum = Decimal(1).scaleb(-digits)
return value.quantize(quantum, rounding=ROUND_HALF_UP), digits
+12 -3
View File
@@ -275,6 +275,8 @@ async def _build_for(project_id: UUID, dump_haul_m: tuple[str, ...] = ()):
# 유가 지역 — 안 고르면 전국평균(품셈 8-1-7 5호 「해당지역의 가격」).
# 기계 수송비 — 거리·도로 구분이 있어야 선다(산림품셈 10-4 [주]).
from B09_Estimation.B09_Estimation_Edits import edited_build, edits_key
from B09_Estimation.B09_Estimation_MaterialPrices import MATERIAL_PRICES_KEY
from B09_Estimation.B09_Estimation_MaterialPrices import build_key as material_prices_key
args = (
ranges,
@@ -288,6 +290,8 @@ async def _build_for(project_id: UUID, dump_haul_m: tuple[str, ...] = ()):
# 조종원 시간당 노임 자르는 자리 — 실무마다 다름(명세 7장 정정). 안 정하면 원 미만.
str(settings.get("operator_wage_digits") or ""),
tuple(sorted(set(dump_haul_m), key=Decimal)),
# 자재 수동 단가(PLAN 1장 Ⓐ) — 코드 키만 조립에 얹음. 없으면 종전 벌 그대로.
material_prices_key(settings.get(MATERIAL_PRICES_KEY)),
)
# 사용자가 고친 값(PLAN 12장 2차) — 없으면 기본 조립 그 벌 그대로.
return edited_build(args, edits_key(settings.get("edits")))
@@ -499,10 +503,15 @@ async def get_bill(project_id: UUID) -> JSONResponse:
from common_util.common_util_project_settings import estimation_settings
root = await _project_root_of(project_id)
rates = normalize((estimation_settings(root) if root else {}).get("edits")).get(
"bill_rates"
stored = estimation_settings(root) if root else {}
rates = normalize(stored.get("edits")).get("bill_rates")
result = build_bill(
payload,
build=build,
structure_prices=structure_prices,
rates=rates,
material_prices=stored.get("material_prices"),
)
result = build_bill(payload, build=build, structure_prices=structure_prices, rates=rates)
except DoubleCountError as error:
# 이중계상 감시에 걸린 경우 — 표를 그리지 않고 멈춘다.
logger.warning("B09 내역서 이중계상 감지: project_id=%s, %s", project_id, error)
@@ -0,0 +1,111 @@
"""B09 원가계산 라우터 — **자재 수동 단가** 읽기·저장 (PLAN 1장 Ⓐ · `…_MaterialPrices`).
저장은 단가·출처만 금액은 다음 조회 서버가 값으로 **다시 조립**
(브라우저 값을 받아 적지 않음).
"""
from __future__ import annotations
import logging
from datetime import date
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B09_Estimation.B09_Estimation_MaterialPrices import (
MATERIAL_PRICES_KEY,
MaterialPriceError,
listing,
merge,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B09 Estimation Material Prices"])
class MaterialPriceChange(BaseModel):
"""단가 칸 하나 — `price_krw` 가 비면 그 줄을 지움(단가 없음으로 돌아감)."""
key: str
price_krw: Any = None
source: str = ""
name: str = ""
spec: str = ""
unit: str = ""
class MaterialPriceRequest(BaseModel):
changes: list[MaterialPriceChange] = Field(default_factory=list)
async def _rows(project_id: UUID) -> dict[str, Any]:
import json
from B08_Quantity.B08_Quantity_Router_Material import get_handoff
from B09_Estimation.B09_Estimation_Router import _build_for, _project_root_of
from common_util.common_util_project_settings import estimation_settings
root = await _project_root_of(project_id)
settings = estimation_settings(root) if root else {}
build = await _build_for(project_id)
# 자재총괄 줄(「이름 규격」 키) — 인계를 못 받으면 코드 줄만 서고 그 사실을 `handoff_note` 로.
handoff = json.loads(bytes((await get_handoff(project_id)).body).decode("utf-8"))
materials = handoff.get("materials")
rows = listing(build, settings.get(MATERIAL_PRICES_KEY), materials)
return {
"status": "success",
"rows": rows,
"handoff_note": ""
if materials is not None
else f"B08 인계를 못 받아 자재총괄 줄이 빠짐 — {handoff.get('message') or ''}",
"unconfirmed_count": sum(1 for row in rows if row.get("price_krw")),
}
@router.get("/{project_id}/estimation/material-prices")
async def get_material_prices(project_id: UUID) -> JSONResponse:
"""단가 칸을 낼 자재 줄 — 자원 축 자재(코드) + 자원 축에서 사라진 저장 줄."""
try:
return JSONResponse(content=await _rows(project_id))
except Exception:
logger.exception("B09 자재 수동 단가 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "자재 단가 목록을 못 만들었습니다."},
)
@router.put("/{project_id}/estimation/material-prices")
async def put_material_prices(project_id: UUID, payload: MaterialPriceRequest) -> JSONResponse:
"""단가 저장 — 0 이하·수가 아닌 값은 받지 않음 · 값·출처가 같으면 넣은 날 그대로."""
from B09_Estimation.B09_Estimation_Router import _project_root_of
from common_util.common_util_project_settings import estimation_settings, save_section
root = await _project_root_of(project_id)
if root is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
try:
prices = merge(
estimation_settings(root).get(MATERIAL_PRICES_KEY),
[change.model_dump() for change in payload.changes],
date.today().isoformat(),
)
except MaterialPriceError as error:
return JSONResponse(status_code=422, content={"status": "error", "message": str(error)})
try:
save_section(
root, "estimation", {MATERIAL_PRICES_KEY: prices}, replace_keys=(MATERIAL_PRICES_KEY,)
)
return JSONResponse(content=await _rows(project_id))
except Exception:
logger.exception("B09 자재 수동 단가 저장 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "자재 단가를 저장하지 못했습니다."},
)
@@ -20,6 +20,7 @@ import { unitPriceTab } from "./B09_Estimation_UI_Tab_UnitPrice";
import { priceBasisTab } from "./B09_Estimation_UI_Tab_PriceBasis";
import { machineTab } from "./B09_Estimation_UI_Tab_Machine";
import { priceCompareTab } from "./B09_Estimation_UI_Tab_PriceCompare";
import { materialPricesTab } from "./B09_Estimation_UI_Tab_MaterialPrices";
import { summaryTab } from "./B09_Estimation_UI_Tab_Summary";
import { listsTab } from "./B09_Estimation_UI_Tab_Lists";
import { designDocTab } from "./B09_Estimation_UI_Tab_DesignDoc";
@@ -40,6 +41,7 @@ const TABS: B09Tab[] = [
priceBasisTab,
machineTab,
priceCompareTab,
materialPricesTab, // 랩탑_메인 — 자재단가대비표 옆(자재 수동 단가)
summaryTab,
listsTab,
supplyTab,
@@ -0,0 +1,205 @@
/* =============================================================================
* B09_Estimation_UI_Tab_MaterialPrices.ts
* (PLAN 1 · · 2026-09-14 )
*
* - = ( `AR-M-…`) · · · · · ·
* · · . = ( = · · ).
* - [] = . ( ) · 0 .
* - . · .
* - .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
import { L, el, hint, injectSheetStyles, plainTable, won } from "./B09_Estimation_UI_Sheet";
interface MaterialPriceRow {
key: string;
name: string;
spec: string;
unit: string;
work_items: string[];
price_krw: string | null;
source: string;
entered_at: string;
missing: boolean;
/** `unit_price` = 일위대가 자원(코드 키) · `material_sheet` = 자재총괄(이름 규격 키). */
origin?: string;
supply_type?: string;
quantity?: string;
}
interface MaterialPricesDto {
status: string;
message?: string;
rows: MaterialPriceRow[];
unconfirmed_count: number;
handoff_note?: string;
}
const SUPPLY_LABELS: Record<string, string> = {
contractor_supplied: "사급",
owner_supplied: "관급 — 도급 금액 밖(수량만)",
unknown: "미정 — 관급구분을 먼저 고름(B08 자재총괄)",
};
function whereText(row: MaterialPriceRow): string {
if (row.missing) return "⚠ 목록에서 사라진 자재 — 단가를 비우고 저장해 지움";
if (row.origin === "material_sheet") {
return `자재총괄 ${row.quantity ?? ""}${row.unit} · ${row.work_items.join(", ")} · ${
SUPPLY_LABELS[row.supply_type ?? ""] ?? row.supply_type ?? ""
}`;
}
return `일위대가 · ${row.work_items.join(", ")}`;
}
interface Change {
key: string;
price_krw: string;
source: string;
name: string;
spec: string;
unit: string;
}
async function request(projectId: string, init: RequestInit = {}): Promise<MaterialPricesDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/material-prices`,
{ credentials: "include", ...init },
);
const body = (await response.json()) as MaterialPricesDto;
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
return body;
}
function input(value: string, width: string, placeholder = ""): HTMLInputElement {
const node = el("input", "b09s-edit-input");
node.value = value;
node.placeholder = placeholder;
node.style.width = width;
return node;
}
function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): void {
const drafts: Array<{
row: MaterialPriceRow;
price: HTMLInputElement;
source: HTMLInputElement;
}> = [];
const save = el("button", "b09s-undo", "저장");
save.type = "button";
const bar = el("div", "b09s-bar");
bar.append(el("span", "b09s-title", L("B09_Estimation_Tab_MaterialPrices")), save);
if (data.unconfirmed_count > 0) {
bar.append(el("span", "b09s-badge", `수동 단가 ${data.unconfirmed_count}건 — 미확정`));
}
const { wrap, tbody } = plainTable([
"코드·키",
"명칭",
"규격",
"단위",
"쓰이는 곳",
"단가(원)",
"출처",
"넣은 날",
]);
for (const row of data.rows) {
const tr = el("tr", row.price_krw ? "is-manual" : "");
const price = input(row.price_krw ?? "", "96px", "비움 = 단가 없음");
price.inputMode = "decimal";
price.title = row.price_krw ? won(row.price_krw) : "";
const source = input(row.source, "160px", "견적 업체·물가지 쪽");
// 자재총괄 줄은 사급만 금액이 섬 — 관급·미정 줄은 칸을 잠금(값이 남아 있으면 지울 수는 있게).
const locked =
row.origin === "material_sheet" &&
row.supply_type !== "contractor_supplied" &&
!row.price_krw;
price.disabled = locked;
source.disabled = locked;
const priceCell = el("td", "b09s-num");
priceCell.append(price);
const sourceCell = el("td");
sourceCell.append(source);
tr.append(
el("td", "", row.key),
el("td", "", row.name),
el("td", "", row.spec),
el("td", "", row.unit),
el("td", "b09s-note", whereText(row)),
priceCell,
sourceCell,
el("td", "", row.entered_at),
);
tbody.append(tr);
drafts.push({ row, price, source });
}
save.addEventListener("click", () => {
const changes: Change[] = drafts
.filter(
({ row, price, source }) =>
price.value.trim() !== (row.price_krw ?? "") || source.value.trim() !== row.source,
)
.map(({ row, price, source }) => ({
key: row.key,
price_krw: price.value.trim(),
source: source.value.trim(),
name: row.name,
spec: row.spec,
unit: row.unit,
}));
if (changes.length === 0) return;
save.disabled = true;
request(projectId, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }),
})
.then((next) => {
showToast(L("B09_Sheet_Saved"), "success");
ctx.body.replaceChildren();
draw(ctx, projectId, next);
})
.catch((error: Error) => {
save.disabled = false;
showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error");
});
});
ctx.body.append(bar, wrap);
if (data.handoff_note) ctx.body.append(hint(data.handoff_note, true));
if (data.rows.length === 0) ctx.body.append(hint("단가 칸을 낼 자재가 없음"));
ctx.body.append(
hint(
"일위대가 자재 — 6번 슬롯(적용 단가)으로 서고 닿은 내역 줄마다 「미확정」으로 셈 · 자재단가대비표에도 보임",
),
hint(
"자재총괄 사급 자재 — 내역서 끝 「자재(사급)」 줄로 섬(할증 뒤 수량 × 단가) · 관급은 도급 금액 밖",
),
hint("값·출처가 같으면 다시 저장해도 넣은 날은 그대로"),
);
}
export const materialPricesTab: B09Tab = {
key: "material_prices",
label: () => L("B09_Estimation_Tab_MaterialPrices"),
render(ctx) {
injectSheetStyles();
if (!ctx.projectId) {
ctx.body.append(hint(L("B09_Sheet_NoProject")));
return;
}
const projectId = ctx.projectId;
ctx.body.append(hint(L("B09_Sheet_Loading")));
request(projectId)
.then((data) => {
ctx.body.replaceChildren();
draw(ctx, projectId, data);
})
.catch((error: Error) => {
ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true));
});
},
};
+27 -6
View File
@@ -141,6 +141,14 @@ class UnitPriceBuild:
parent_modes: dict[str, str] = field(default_factory=dict)
#: 합산형 부모 → (갈래, 근거) — 내역 줄의 갈래가 표에 없을 때 **원문이 정한** 갈래(9-4-1 [주]① 평균).
default_variants: dict[str, tuple[str, str]] = field(default_factory=dict)
#: 갈래가 **어디서 왔나** — 표가 아니라 **식이 낸** 갈래 제목 → 그 식(2026-09-14 브레인 판정).
#: 표에서 온 갈래는 마스터 갈래 키에 있어야 하고(짝 시험), 식이 낸 갈래는 여기 적혀 있어야 함
#: (덤프 운반·적재 10-12 `#L…m`·`#적재` — 본문 식이라 마스터 표에 갈래가 없음).
formula_variants: dict[str, str] = field(default_factory=dict)
#: 수동 단가로 선 자재 제목 → 출처 — 내역 줄 「미확정 N건」(PLAN 1장 Ⓐ).
manual_materials: dict[str, str] = field(default_factory=dict)
#: 자원 축 자재 코드 → 쓰는 공종들 — 「자재 단가」 탭이 단가 칸을 낼 줄.
material_uses: dict[str, list[str]] = field(default_factory=dict)
def _add_labor_titles(book: PriceBook, wages: dict[str, Decimal]) -> None:
@@ -618,6 +626,7 @@ def build_unit_prices(
labor_surcharge_choices: dict[str, str] | None = None,
operator_wage_digits: int = 0,
dump_haul_m: tuple[Decimal, ...] = (),
material_prices: tuple[tuple[str, str, str], ...] = (),
) -> UnitPriceBuild:
"""자원 축을 일위대가(`B`)로 조립한다.
@@ -784,6 +793,15 @@ def build_unit_prices(
build.incomplete_machines = _add_machine_layers(
build.book, machine_codes, fuel_region, operator_wage_digits
)
# 자재 수동 단가(PLAN 1장 Ⓐ) — 코드 키만 제목으로 섬. 안 넣으면 자재 줄은 종전처럼 안 붙음.
from B09_Estimation.B09_Estimation_MaterialPrices import add_material_titles
build.manual_materials = add_material_titles(build.book, material_prices)
for row in axis.rows:
if row.resource_kind == "material":
uses = build.material_uses.setdefault(row.resource_code, [])
if row.work_item_code not in uses:
uses.append(row.work_item_code)
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
# 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다.
@@ -830,10 +848,7 @@ def build_unit_prices(
# ⚠ **붙을 상세를 먼저 모으고, 하나도 없으면 제목도 안 세운다.**
# 제목만 세워 두면 「상세 줄이 없어 단가를 못 조립」하는 빈 일위대가가 남는다
# (기계 층이 안 선 기종만 참조하는 공종에서 실제로 생겼음).
attachable = [
(row, row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}")
for row in rows
]
attachable = [(row, _ref_of(row)) for row in rows]
attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles]
# ⚠ **맞췄으나 단가 층이 없는 줄**(카탈로그 밖 `AR-` 자원 · 층을 못 세운 기계)은 조용히
# 빼지 않는다 — 못 맞춘 줄처럼 드러내고, 기계 몫이면 단가를 막는다(2026-09-13 축 C).
@@ -1089,6 +1104,11 @@ def _has_full_formula(
)
def _ref_of(row) -> str:
"""자원 줄이 붙을 단가표 제목 — 노임·자재는 코드 그대로, 기계는 시간당 층(`X-`)."""
return f"X-{row.resource_code}" if row.resource_kind == "machine" else row.resource_code
def _share_of(row) -> Decimal:
"""그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다."""
ratio = getattr(row, "group_ratio_pct", None)
@@ -1110,8 +1130,7 @@ def _covered_ratio_pct(
ratio = getattr(row, "group_ratio_pct", None)
if ratio is None or ratio in seen:
continue
ref = row.resource_code if row.resource_kind == "labor" else f"X-{row.resource_code}"
if ref in attached_refs:
if _ref_of(row) in attached_refs:
seen.add(ratio)
covered += ratio
return covered
@@ -1173,6 +1192,7 @@ def cached_build(
labor_surcharge: tuple[tuple[str, str], ...] = (),
operator_wage_digits: str = "",
dump_haul_m: tuple[str, ...] = (),
material_prices: tuple[tuple[str, str, str], ...] = (),
) -> UnitPriceBuild:
"""조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다.
@@ -1201,6 +1221,7 @@ def cached_build(
labor_surcharge_choices=dict(labor_surcharge),
operator_wage_digits=parse_operator_wage_digits(operator_wage_digits),
dump_haul_m=tuple(Decimal(value) for value in dump_haul_m),
material_prices=material_prices,
)
+85 -12
View File
@@ -46,6 +46,18 @@ export const FORD_WALL_THICKNESS_M = 0.2;
export const FORD_DEFAULT_WIDTH_M = 10.0;
export const FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0;
export const BOX_COVER_M = 0.5;
/** `B06_Section_UI_Cross_Culvert_Const.ts` ( ).
* (2026-09-14 · 2.5 ). */
export const REVET_FREEBOARD_M = 0.5;
export const REVET_HEIGHT_STEP_M = 0.1;
export const REVET_EMBED_DEPTH_M = 0.5;
/** 관 벽 순수 높이(근입 포함) 최소값 — 파이썬 `pipe_wall_min_height_m`. */
export function pipeWallMinHeightM(diameterM: number): number {
const raw = diameterM + REVET_FREEBOARD_M;
const steps = Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9);
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3);
}
/** 시설 종류 — 파이썬 `common_util_drainage_pipes` 의 상수와 같은 문자열. */
const FACILITY_PIPE = "pipe";
@@ -82,6 +94,7 @@ function sideSpec(
options: Record<string, unknown>,
defaults: Record<string, unknown>,
side: string,
diameterM: number,
): CulvertSetSpec {
const structure = text(options[`${side}_type`]) ?? text(defaults[`${side}_type`]) ?? "기슭막이";
const spec: CulvertSetSpec = { role: side, structure };
@@ -100,7 +113,7 @@ function sideSpec(
const height = num(
options[`${side}_revet_height_m`],
num(defaults[`${side}_revet_height_m`], null),
num(defaults[`${side}_revet_height_m`], pipeWallMinHeightM(diameterM)),
);
const length = num(
options[`${side}_revet_length_m`],
@@ -133,17 +146,18 @@ function culvertSet(
): CulvertSetSpec {
const defaults = registry.pipe ?? {};
const diameterMm = num(options.pipe_diameter_mm, num(defaults.pipe_diameter_mm, 1000.0));
const diameterM = pipeDiameterM(diameterMm);
return {
type: "pipe",
pipe_kind: text(options.pipe_kind) ?? text(defaults.pipe_kind),
diameter_m: pipeDiameterM(diameterMm),
diameter_m: diameterM,
min_cover_m: MIN_PIPE_COVER_M,
// 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다. 한 공사에서 한쪽만 기초유로
// 하는 일은 드물어 폼을 둘로 늘리지 않았다(2026-09-09). 실무가 갈라 쓰는 것이
// 확인되면 그때 나눌 것.
foundation: text(options.revet_foundation) ?? text(defaults.revet_foundation),
inlet: sideSpec(options, defaults, "inlet"),
outlet: sideSpec(options, defaults, "outlet"),
inlet: sideSpec(options, defaults, "inlet", diameterM),
outlet: sideSpec(options, defaults, "outlet", diameterM),
};
}
@@ -233,6 +247,37 @@ function wingSpec(
};
}
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 파이썬 `_defaulted`(2026-09-14 브레인 판정 ②). */
function defaulted(
values: Record<string, unknown>,
fields: ReadonlyArray<[string, string, unknown]>,
): string[] {
return fields
.filter(([key, , used]) => used !== null && used !== undefined && text(values[key]) === null)
.map(([, label, used]) => `${label} ${String(used)}`);
}
/** 날개벽 한쪽의 기본값 칸 — 파이썬 `_wing_defaulted`. */
function wingDefaulted(
values: Record<string, unknown>,
wing: CulvertSetSpec,
side: string,
): string[] {
const name = side === "in" ? "유입" : "유출";
const prefix = `wing_${side}`;
const fields: Array<[string, string, unknown]> = [
[prefix, `${name} 날개벽`, wing.installed ? "있음" : "없음"],
];
if (wing.installed) {
fields.push(
[`${prefix}_height_m`, `${name} 날개벽 높이`, wing.height_m],
[`${prefix}_length_m`, `${name} 날개벽 길이`, wing.length_m],
[`${prefix}_angle_deg`, `${name} 날개벽 각도`, wing.angle_deg],
);
}
return defaulted(values, fields);
}
/** 세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장). */
function fordSet(
values: Record<string, unknown>,
@@ -243,19 +288,35 @@ function fordSet(
const width = num(values.ford_width_m, num(defaults.ford_width_m, null));
const count = num(values.pipe_count, num(defaults.pipe_count, null));
const depth = num(values.ford_height_m, null);
const kind = text(values.pipe_kind) ?? text(defaults.pipe_kind);
const span = width && width > 0 ? width : FORD_DEFAULT_WIDTH_M;
const pipeCount = count ? Math.max(Math.trunc(count), 1) : 1;
const wingIn = wingSpec(values, defaults, "in");
const wingOut = wingSpec(values, defaults, "out");
return {
type: "ford",
pipe_kind: text(values.pipe_kind) ?? text(defaults.pipe_kind),
pipe_kind: kind,
diameter_m: pipeDiameterM(diameterMm),
pipe_count: count ? Math.max(Math.trunc(count), 1) : 1,
span_m: width && width > 0 ? width : FORD_DEFAULT_WIDTH_M,
pipe_count: pipeCount,
span_m: span,
// 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다. 없으면 0 = 내리지 않는다.
overflow_depth_m: depth && depth > 0 ? depth : 0.0,
slab_thickness_m: FORD_SLAB_THICKNESS_M,
wall_thickness_m: FORD_WALL_THICKNESS_M,
min_cover_m: MIN_PIPE_COVER_M,
wing_in: wingSpec(values, defaults, "in"),
wing_out: wingSpec(values, defaults, "out"),
wing_in: wingIn,
wing_out: wingOut,
defaulted: [
...defaulted(values, [
["pipe_kind", "관종", kind],
["pipe_diameter_mm", "관경", diameterMm],
["ford_width_m", "월류 폭", span],
["pipe_count", "배관 수량", pipeCount],
["ford_height_m", "월류 높이", 0],
]),
...wingDefaulted(values, wingIn, "in"),
...wingDefaulted(values, wingOut, "out"),
],
};
}
@@ -267,12 +328,14 @@ function fordPavementSet(
const defaults = registry.ford_pavement ?? {};
const width = num(values.ford_width_m, num(defaults.ford_width_m, null));
const depth = num(values.ford_height_m, null);
const span = width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M;
return {
type: "ford_pavement",
span_m: width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M,
span_m: span,
// 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
depth_m: depth && depth > 0 ? depth : null,
slope_pct: num(values.ford_slope_pct, null),
defaulted: defaulted(values, [["ford_width_m", "월류 폭", span]]),
};
}
@@ -286,6 +349,8 @@ function boxSet(
const innerHeight = num(values.body_height_m, num(defaults.body_height_m, 2.0));
const wall = FORD_WALL_THICKNESS_M;
const slab = FORD_SLAB_THICKNESS_M;
const wingIn = wingSpec(values, defaults, "in");
const wingOut = wingSpec(values, defaults, "out");
return {
type: "box",
inner_width_m: innerWidth || 2.0,
@@ -295,8 +360,16 @@ function boxSet(
top_thickness_m: slab,
cover_m: BOX_COVER_M,
span_m: boxSpanM(innerWidth || 2.0),
wing_in: wingSpec(values, defaults, "in"),
wing_out: wingSpec(values, defaults, "out"),
wing_in: wingIn,
wing_out: wingOut,
defaulted: [
...defaulted(values, [
["body_width_m", "본체 폭", innerWidth || 2.0],
["body_height_m", "본체 높이", innerHeight || 2.0],
]),
...wingDefaulted(values, wingIn, "in"),
...wingDefaulted(values, wingOut, "out"),
],
};
}
+4
View File
@@ -71,6 +71,9 @@ from B09_Estimation.B09_Estimation_Router_Progress import router as b09_progress
from B09_Estimation.B09_Estimation_Router_CostSheet import router as b09_cost_sheet_router
from B09_Estimation.B09_Estimation_Router_Edits import router as b09_edits_router
from B09_Estimation.B09_Estimation_Router_Factors import router as b09_factors_router
from B09_Estimation.B09_Estimation_Router_MaterialPrices import (
router as b09_material_prices_router,
)
from common_util.common_util_audit import note_api_call, record_call_burst
from common_util.common_util_auth import (
require_company,
@@ -648,6 +651,7 @@ app.include_router(b09_execution_router, dependencies=protected_with_company)
app.include_router(b09_progress_router, dependencies=protected_with_company)
app.include_router(b09_edits_router, dependencies=protected_with_company)
app.include_router(b09_factors_router, dependencies=protected_with_company)
app.include_router(b09_material_prices_router, dependencies=protected_with_company)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
# 그 위에 서버가 환경까지 한 번 더 본다.
app.include_router(dev_unlock_router, dependencies=protected_with_company)
@@ -0,0 +1,40 @@
{
"schema_version": "1.0",
"dataset_id": "masonry_back_length_standard",
"effective_date": "2026-01-01",
"note": "돌쌓기 뒷길이 표준 — **높이 × 메/찰** 로 범위가 갈린다. 뒷길이를 안 고른 돌쌓기는 붙박이 45㎝ 로 서고 있었는데, 그 자리를 이 표의 **하한**으로 바꿈(2026-09-14 브레인 판정). 사용자가 칸에서 고르면 그 값이 이김.",
"source": {
"doc": "산림사업 표준품셈(산림청고시 제2025-82호) 13-4-4 [주]⑩ <뒷길이 표준>",
"line": "원문 L7172~7181",
"quote": "높은 메쌓기는 밑으로 내려갈수록 뒷길이를 길게 하는 것이 원칙이다 · 높이는 첨단으로부터 기준 한다 · 특수한 조건에서는 본 기준과 달리 적용할 수 있다"
},
"policy": {
"suggestion": "⭐ 제안값 = **범위 안에 드는 품셈 규격 중 가장 작은 것**(브레인 판정 ㉮). 하한이 규격(25·30·35·45·55·60·75)에 없으면 그 위 첫 규격 — 메쌓기 ~3m·~5m 하한 36㎝ 는 45㎝. 35 로 내리면 품셈 범위 아래로 나가 안 됨.",
"boundary": "⚠ 원문 표기 `∼1.5 · ∼3 · ∼5 · ∼7 · 7이상` — 표준경사 표(`masonry_slope`)와 같이 「∼」를 **이하**로 읽어 경계값은 앞 칸(높이 3.0m → `~3` 칸).",
"no_write_back": "⚠ 제안값을 저장에 박지 않음 — 「안 고름」과 「45 로 고름」이 구별돼야 함(표준경사와 같은 자리)."
},
"steps_m": [1.5, 3.0, 5.0, 7.0],
"table_cm": {
"메쌓기": [
[25, 35],
[36, 45],
[36, 60],
[45, 75],
[75, null]
],
"찰쌓기": [
[25, 35],
[30, 35],
[35, 45],
[35, 55],
[45, 60]
]
},
"not_here": {
"note": "이 표가 쓰지 않는 칸.",
"items": [
"돌붙이기 열(메 25~60 · 찰 20~40)은 높이로 안 갈림 — 바닥막이 돌붙임은 뒷길이가 붙박이(`_UnitQuantity_Revetment.BED_SILL_FORMS`)라 여기서 안 씀",
"골막이는 뒷길이 기본이 종전대로 45㎝ — 이 표의 대상(돌쌓기 13-4)이 아님"
]
}
}
@@ -6,8 +6,8 @@
"files": [
{
"file": "material_surcharge_2026-01-01.json",
"sha256": "75138c44944e7f56df850ea5749b60fc35a2db0c8b282b9a5d61927d69ac4fcc",
"size_bytes": 4724
"sha256": "30a1e11acb6f6bd52c72e0ff471c96f772917858306ea61f66ecbcb5df433c9f",
"size_bytes": 5087
}
]
}
@@ -149,18 +149,20 @@
{
"material": "막자갈",
"rate": 4,
"pumsem": "const",
"pumsem": "forest",
"listed_in": "산림청고시 제2025-82호 1-3-1 3호 「부순돌ㆍ자갈ㆍ막자갈 4」(건설품셈도 같은 조건)",
"listed_condition": "노상 및 노반재료(선택층·보조기층·기층)",
"our_usage": "돌쌓기 뒤채움",
"why_not_applied": "조노반재료 한정이라 뒤채움에 그대로 쓸 근거가 없음"
"why_not_applied": "조쓰임을 노반재료로 못박아 뒤채움에 그 상한을 끌어올 근거가 없음(2026-09-14 브레인 판정 보류 유지 · 사방기술교본도 할증률 없음)"
}
]
},
"not_found": {
"note": "산림·건설 두 품셈의 재료 할증률표를 다 뒤졌으나 **이름이 없는** 자재. 석재 계열은 건설품셈도 해상 사석(기초·피복·뒤채움)과 원석(마름돌용)만 다룬다.",
"materials": ["야면석", "고임돌", "물구멍관"],
"materials": ["야면석", "고임돌", "물구멍관", "못"],
"checked": [
"산림품셈 1-3-1 전 19종",
"못 — 산림청고시 제2025-82호 1-3-1 6호 기타재료에 없음(2026-09-14 브레인 확인)",
"건설품셈 1-3-1 1~7호(콘크리트·노반·관기초·토사(해상)·사석(해상)·속채움(해상)·강재류)",
"건설품셈 제7장 돌공사 — 재료 할증률표 없음"
]
@@ -86,13 +86,15 @@
"basis_note": "Ø50"
},
{
"name": "이형철근 D13",
"name": "이형철근",
"spec": "D13",
"unit": "kg",
"amount": 13.45,
"destination": "material"
},
{
"name": "이형철근 D16",
"name": "이형철근",
"spec": "D16",
"unit": "kg",
"amount": 30.42,
"destination": "material"
@@ -218,7 +220,8 @@
"basis_note": "원문 값(라이브러리 표기 21.28)"
},
{
"name": "이형철근 D13",
"name": "이형철근",
"spec": "D13",
"unit": "kg",
"amount": 4.776,
"destination": "material",
+7 -1
View File
@@ -19,7 +19,13 @@
"source": "judged_face_slope",
"note": "사용자 값이 있으면 그 값, 없으면 품셈 13-4-4 [주]⑪ 표준경사를 전개가 판정해 넘김"
},
"L3": { "label": "뒷길이(㎝)", "option": "back_len_cm", "default": 45 },
"L3": {
"label": "뒷길이(㎝)",
"option": "back_len_cm",
"default": 45,
"default_from": "standard_back_length",
"note": "안 고르면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·메/찰 · 범위 안 가장 작은 규격) — 전개와 한 벌(2026-09-14 브레인 판정 ㉮). 45 는 표를 못 읽을 때만"
},
"돌종류": { "label": "돌 종류", "option": "stone_kind", "default": "" },
"계수기준": { "label": "야면석 계수", "option": "stone_coeff_basis", "default": "" },
"TOP_IN": {
+15 -10
View File
@@ -24,6 +24,9 @@ if str(ROOT) not in sys.path:
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
PANEL = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text(encoding="utf-8")
#: 2026-09-14 칸 만들기가 `_Structures_Fields.optionControl` 로 옮겨감(브레인 판정 ①② — 고르기 칸은
#: 늘 빈 보기 · 기본값은 제안으로만).
FIELDS = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(encoding="utf-8")
API = (ROOT / "B05_Profile" / "B05_Profile_Api_Structures.ts").read_text(encoding="utf-8")
COMMIT = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Commit.ts").read_text(
encoding="utf-8"
@@ -39,22 +42,24 @@ def _registry_options(type_id: str) -> dict[str, dict]:
def test_폼이_empty_means_를_보고_빈_보기를_둔다() -> None:
"""소스 검사 — 빈 보기와 초기값이 **같은 판정**(`keepEmpty`)을 쓴다."""
assert "option.empty_means" in PANEL
assert "const keepEmpty = mustPick || meansEmpty;" in PANEL
assert 'if (keepEmpty) choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"]);' in PANEL
# 빈 보기만 넣고 초기값을 첫 보기로 두면 화면은 「안 정함」인데 값은 첫 보기가 된다.
assert 'String(preset || (keepEmpty ? "" : (option.choices[0] ?? "")))' in PANEL
"""소스 검사 — 고르기 칸은 **늘** 빈 보기를 두고, 초기값은 저장된 값뿐(첫 보기를 안 박음)."""
assert "optionControl(option, values[option.key])" in PANEL
assert 'select([["", blank], ...option.choices' in FIELDS
# 초기값은 저장된 값만 — 첫 보기·기본값으로 채우지 않음.
assert "element.value = value;" in FIELDS
assert "option.choices[0]" not in PANEL
# 클라이언트 옵션 타입에 칸이 있어야 서버 응답의 `empty_means` 가 살아 온다.
assert "empty_means?: string | null;" in API
# 빈 칸은 아예 안 실린다 — 이 규칙이 있어야 「안 정함」이 정본에 안 박힌다.
assert "if (input.isEmpty()) return;" in COMMIT
def test_기본값_있는_칸_빈_보기를_안_둔() -> None:
"""`empty_means` 가 적혀 있어도 **기본값이 있으면** 그 값이 뜬다(2026-08-17 지시 유지)."""
def test_기본값_있는_칸_빈_보기에_제안으로만_보인() -> None:
"""⭐ 2026-09-14 브레인 판정으로 뒤집힘 — 기본값이 **있어도** 칸에 채우지 않고 빈 보기 이름에
제안 으로만 보임( 2026-08-17 보기가 기본값 걷음). 저장 번에 기본값이
확정으로 굳던 뿌리였음."""
options = _registry_options("masonry_wet")
# 버림 콘크리트 — empty_means 가 있으나 기본이 「넣음」이다.
# 버림 콘크리트 — empty_means 가 있 기본이 「넣음」.
assert options["blinding_concrete"]["empty_means"]
assert options["blinding_concrete"]["default"] == "넣음"
# 채움 강도·야면석 계수 — 기본이 없어 빈 보기가 서야 하는 칸.
@@ -62,7 +67,7 @@ def test_기본값_있는_칸은_빈_보기를_안_둔다() -> None:
assert options[key]["empty_means"], key
assert options[key]["default"] is None, key
assert options[key]["required"] is False, key
assert '(option.default ?? "") === ""' in PANEL # 기본값 있으면 빈 보기를 안 둠
assert "`— 안 정함 (제안 ${suggested}) —`" in FIELDS
def _wall(**options) -> dict:
@@ -451,10 +451,12 @@ def test_pipe_accessory_options_defined():
"outlet_pitching_area_m2",
):
assert gone not in options, f"pipe.{gone}는 보호공으로 합쳐졌다"
# 기본 치수 — 길이 10m·높이 2.5m.
# 기본 치수 — 길이 10m. 높이는 **기본값 없음**(2026-09-14 브레인 판정): 비우면 관경 기준 최소
# 높이로 횡단도·구조물도·표가 같은 값을 씀(옛 2.5 는 횡단도가 안 쓰던 근거 없는 값이라 걷음).
for side in ("inlet", "outlet"):
assert options[f"{side}_revet_length_m"].default == 10
assert options[f"{side}_revet_height_m"].default == 2.5
assert options[f"{side}_revet_height_m"].default is None
assert "관경 기준" in options[f"{side}_revet_height_m"].empty_means
# 유무·길이·관경·유입 구조 = b05, 형태·치수 제원 = detail.
for key in ("inlet_type", "inlet_revet_length_m", "outlet_revet_length_m"):
assert options[key].phase == "b05", f"pipe.{key}는 b05 phase"
+9 -5
View File
@@ -34,7 +34,11 @@ def test_cross_reference_constants_match_plan():
def test_empty_options_fall_back_to_registry_defaults():
"""옵션이 빈 저장분은 레지스트리 기본값(Ø1000·기슭막이 H2.5/L10)으로 채운다."""
"""옵션이 빈 저장분은 레지스트리 기본값(Ø1000·L10)으로 채운다.
높이는 2026-09-14 브레인 판정으로 **관경 기준 최소 높이**(Ø1000 1.5 + 근입 0.5 = 2.0)
횡단도가 그리는 높이. 기본 2.5 횡단도가 쓰던 값이라 걷음.
"""
spec = _culvert_set(None)
assert spec["type"] == "pipe"
assert spec["diameter_m"] == 1.0
@@ -42,11 +46,11 @@ def test_empty_options_fall_back_to_registry_defaults():
for side in ("inlet", "outlet"):
part = spec[side]
assert part["structure"] == "기슭막이"
assert part["revet_height_m"] == 2.5
assert part["revet_height_m"] == 2.0
assert part["revet_length_m"] == 10
assert part["face_slope"] == REVET_FACE_SLOPE
# 보호공 = 낙차고(2.5) × 2 = 5.0m, 두께 0.45m(사용자 확정).
assert part["apron_length_m"] == 5.0
# 보호공 = 낙차고(2.0) × 2 = 4.0m, 두께 0.45m(사용자 확정).
assert part["apron_length_m"] == 4.0
assert part["apron_thickness_m"] == 0.45
# 레지스트리 기본 형태: 유입 찰 / 유출 메.
assert spec["inlet"]["revet_form"] == "돌쌓기(찰)"
@@ -61,7 +65,7 @@ def test_basin_inlet_skips_revet_and_apron():
assert "apron_length_m" not in spec["inlet"]
# 유출측은 그대로 세트를 갖춘다.
assert spec["outlet"]["structure"] == "기슭막이"
assert spec["outlet"]["apron_length_m"] == 5.0
assert spec["outlet"]["apron_length_m"] == 4.0 # 관경 기준 높이 2.0 × 2
def test_string_diameter_and_custom_height():
+6 -3
View File
@@ -39,10 +39,13 @@ def test_B05_는_종전대로_유무_종류_위치만():
def test_기본값_없는_필수_항목은_빈_칸으로_연다():
"""첫 항목을 슬쩍 고르면 **근거 없는 값**(뒷길이 25㎝ 같은)이 수량·단가로 흘러간다."""
panel = _PANEL.read_text(encoding="utf-8")
# 2026-09-13 — 빈 보기를 두는 까닭이 둘이 되어(필수 + `empty_means`) 한 줄로 합쳐졌다.
fields = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Fields.ts").read_text(
encoding="utf-8"
)
# 2026-09-14 — 칸 만들기가 `optionControl` 로 옮겨감 · 고르기 칸은 **늘** 빈 보기(브레인 판정 ①).
# 필수 칸의 글자는 그대로 「— 선택 —」이다.
assert 'choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"])' in panel
assert 'option.required === true && (option.default ?? "") === ""' in panel
assert "optionControl(option, values[option.key])" in panel
assert '? "— 선택 —"' in fields and 'select([["", blank]' in fields
def test_큰돌쌓기에_메_찰_구분이_있다():
@@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""BOX암거·세월교·물넘이포장 — **기본값으로 그린 칸을 그림에 적음** (2026-09-14 브레인 판정 ②③).
폼이 기본값을 보내게 고친 , 비운 칸은 횡단도가 등록부 기본값으로 그리고 있었음. 저장만 하면
된다고 일이 아님 그림이 설계값처럼 보이는 것이 같은 . 세트 제원이 `defaulted` 이름
싣고 카드 머리가 기본값(미확정)으로 보임. 물넘이 월류 높이가 비면 파임을 그리는 까닭도 보임.
TS 짝은 거울 시험(`test_b06_culvert_sets_mirror`) 딕셔너리째 대조함.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
_box_set,
_ford_pavement_set,
_ford_set,
)
CHROME = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Card_Chrome.ts").read_text(encoding="utf-8")
CONTROLS = (ROOT / "B06_Section" / "B06_Section_UI_Page_Ford_Controls.ts").read_text(
encoding="utf-8"
)
def test_BOX_빈_칸은_기본값_목록에_선다() -> None:
items = _box_set({})["defaulted"]
assert "본체 폭 2" in items and "본체 높이 2" in items
assert "유입 날개벽 있음" in items and "유출 날개벽 각도 45" in items
def test_적은_칸은_목록에서_빠진다() -> None:
items = _box_set({"body_width_m": 3.0, "wing_in": "없음"})["defaulted"]
assert not any(item.startswith("본체 폭 ") for item in items)
# 안 세운 날개벽은 치수를 안 봄.
assert not any(item.startswith("유입 날개벽") for item in items)
assert "본체 높이 2" in items
def test_세월교_월류_폭_관경도_적힌다() -> None:
items = _ford_set({"ford_width_m": 12.0})["defaulted"]
assert "관경 1000" in items and "월류 높이 0" in items
assert not any(item.startswith("월류 폭 ") for item in items)
def test_물넘이_월류_높이가_비면_파임_없음() -> None:
spec = _ford_pavement_set({})
assert spec["depth_m"] is None
assert spec["defaulted"] == ["월류 폭 5"]
def test_카드_머리가_목록과_까닭을_보인다() -> None:
assert "기본값(미확정)" in CHROME
assert "월류 높이 없음 — 파임 안 그림" in CHROME
# 조작으로 값이 오면 목록에서 뺌 — 캐시를 바로 고치는 길.
assert CONTROLS.count("dropDefaulted(spec,") == 4
@@ -0,0 +1,43 @@
"""관 벽 최소 높이 거울 — 파이썬(`B06_Section_Engine_Culvert`) ↔ TS(`_Cross_Culvert_Const.ts`).
횡단도(TS) 그리는 높이와 구조물 ·구조물도 그림(파이썬) 같은 벽을 다르게 그리면 결함
(2026-09-14 브레인 판정). 상수 셋이 같고 식이 같으면 같은 .
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B06_Section import B06_Section_Engine_Culvert as culvert # noqa: E402
TS = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Culvert_Const.ts").read_text(encoding="utf-8")
#: 세트 스펙 TS 짝(`common_util_culvert_sets.ts`)도 같은 상수를 들고 있음 — 셋을 함께 대조.
SETS = (ROOT / "common_util" / "common_util_culvert_sets.ts").read_text(encoding="utf-8")
def _ts_const(source: str, name: str) -> float:
return float(re.search(rf"export const {name} = ([\d.]+);", source).group(1))
def test_상수_셋이_TS_와_같다() -> None:
for name in ("REVET_FREEBOARD_M", "REVET_HEIGHT_STEP_M", "REVET_EMBED_DEPTH_M"):
assert getattr(culvert, name) == _ts_const(TS, name) == _ts_const(SETS, name), name
def test_TS_식_모양이_그대로다() -> None:
"""식을 바꾸면 여기서 잡힘 — 파이썬 식도 함께 고칠 것."""
assert "const raw = diameterM + REVET_FREEBOARD_M;" in TS
assert "Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M" in TS
assert "return revetTargetHeight(diameterM) + REVET_EMBED_DEPTH_M;" in TS
def test_관경별_최소_높이() -> None:
assert culvert.pipe_wall_min_height_m(1.0) == 2.0
assert culvert.pipe_wall_min_height_m(0.8) == 1.8
assert culvert.pipe_wall_min_height_m(1.2) == 2.2
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""뒷길이 표준(품셈 13-4-4 [주]⑩) — 안 고른 돌쌓기의 뒷길이는 **표의 하한** (2026-09-14 브레인 판정 ㉮).
앞서 붙박이 45 섰음. 표는 높이 × / 범위를 하한이 규격(25·30·35·45·55·60·75)
없으면 **범위 규격**(메쌓기 ~3m·~5m 하한 36 45). 경계값은 (표준경사와 같음).
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: E402
_back_length,
back_length_default_note,
standard_back_length,
)
# (높이, 메쌓기 제안, 찰쌓기 제안) — 원문 L7174~7177
CELLS = [
(1.0, 25, 25),
(1.5, 25, 25), # 경계값은 앞 칸
(2.5, 45, 30), # 메 36~45 → 45 · 찰 30~35 → 30
(3.0, 45, 30),
(4.0, 45, 35), # 메 36~60 → 45 · 찰 35~45 → 35
(6.0, 45, 35), # 메 45~75 · 찰 35~55
(7.5, 75, 45), # 메 75이상 · 찰 45~60
]
@pytest.mark.parametrize("height, dry, wet", CELLS)
def test_표_하한(height: float, dry: int, wet: int) -> None:
assert standard_back_length(wet=False, height_m=height)[0] == dry
assert standard_back_length(wet=True, height_m=height)[0] == wet
def test_하한이_규격에_없으면_그_위_첫_규격_사유() -> None:
cm, basis = standard_back_length(wet=False, height_m=2.5)
assert cm == 45
assert "36~45㎝" in basis and "하한 36㎝ 는 규격 일곱(원문 L7152)에 없어" in basis
assert "원문 L7172~7181" in basis # 사용자가 되짚을 줄 번호(브레인 2026-09-14)
def test_고른_값이_이긴다() -> None:
assert _back_length({"back_len_cm": "55"}, wet=True, height_m=2.5) == 55
assert back_length_default_note({"back_len_cm": 55}, 55, wet=True, height_m=2.5) is None
def test_메찰_높이를_모르는_자리는_종전_45() -> None:
assert _back_length({}) == 45
assert "기본 45㎝" in (back_length_default_note({}, 45) or "")
@@ -38,6 +38,9 @@ def _structures() -> list[dict]:
"length_m": 10,
"foundation": "기초유",
"stone_kind": "야면석·호박돌",
# 2026-09-14 — 안 고르면 뒷길이 표준 하한(찰 ≤3m 30㎝)이라 야면석 관측 무게(35·45·55)가
# 없어 돌 줄이 안 섬. 돌 줄을 보는 시험이라 45 로 고름 · 빈 칸 길은 아래 시험.
"back_len_cm": 45,
},
},
{
@@ -76,6 +79,21 @@ def test_양식_갈음은_값을_안_움직인다() -> None:
assert stone["source"] == "uljin_library"
def test_뒷길이_안_고르면_양식도_전개와_같은_표준_하한() -> None:
"""품셈 13-4-4 [주]⑩ 하한(찰 ≤3m 30㎝)을 전개와 양식이 한 벌로 씀(브레인 판정 ㉮)."""
structures = _structures()[:1]
structures[0]["options"].pop("back_len_cm")
structures[0]["options"].pop("stone_kind")
names = {"masonry_wet": "돌쌓기(찰)"}
before = build_table(structures, names, None, None, 0.2, use_templates=False)["structures"][0]
after = build_table(structures, names, None, None, 0.2)["structures"][0]
assert after["library_item"] == "돌쌓기(찰)"
assert [c["name"] for c in after["components"]] == [c["name"] for c in before["components"]]
for got, want in zip(after["components"], before["components"]):
assert got["amount"] == pytest.approx(want["amount"], rel=1e-9, abs=1e-12), got["name"]
assert any("하한 30㎝" in note for note in before["notes"])
def test_자재총괄_갈래와_배합_성분() -> None:
names = {"masonry_wet": "돌쌓기(찰)", "masonry_dry": "돌쌓기(메)"}
before = build_material_table(
@@ -0,0 +1,88 @@
"""갈 곳(`destination`) 기본값 금지 — 명세 13장 「⛔ 기본값 금지(빠진 것이 조용해짐)」 (2026-09-14).
종전 `DESTINATION.get(name, "quantity")` 표에 없는 이름을 **아무 축에도 가는 **으로 떨어뜨려
L형수로 이형철근·면목 · 개거 유로폼·면목 · 떼흙막이 떼가 자재총괄에서 통째로 빠져 있었음.
지키는
표에 없는 이름은 성분이 서고 **사유가 **(조용히 새지 않음)
정본 (개거·떼흙막이·바닥막이) 모든 이름이 표에 있음 줄이 늘면 여기서 걸림
드러난 넷이 제자리로 (관측 원단위와 같은 )
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION, routed # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import ( # noqa: E402
BED_SILL_FORMS,
OPEN_DITCH_FORMS,
SOIL_GUARD_SOD,
open_ditch,
soil_guard,
)
def test_표에_없는_이름은_막히고_사유가_뜸() -> None:
notes: list[str] = []
assert routed("낯선성분", notes) is None
assert routed("낯선성분", notes) is None
assert len(notes) == 1 and "갈 곳이 표" in notes[0] and "기본값 금지" in notes[0]
assert routed("터파기", notes) == "earthwork" and len(notes) == 1
def test_정본_표의_줄_이름은_전부_갈_곳_표에_있음() -> None:
names = {row[0] for table in OPEN_DITCH_FORMS.values() for row in table["rows"]}
names |= {row[0] for row in SOIL_GUARD_SOD["rows"]}
names |= {table["stone_name"] for table in BED_SILL_FORMS.values()}
assert sorted(name for name in names if name not in DESTINATION) == []
def test_드러난_넷이_제자리로_감() -> None:
ditch, notes = open_ditch(10.0, {"ditch_spec": "L형수로 H=0.2"})
got = {component.name: component.destination for component in ditch}
assert got["이형철근"] == "material" and got["면목"] == "material"
assert next(c.spec for c in ditch if c.name == "이형철근") == "D13" # 이름·규격 따로
assert not [note for note in notes if "갈 곳이 표" in note]
plain, _ = open_ditch(10.0, {})
assert {c.name: c.destination for c in plain}["유로폼"] == "unit_price"
sod, sod_notes = soil_guard({"form": ""})
assert {c.name: c.destination for c in sod}[""] == "material"
assert not [note for note in sod_notes if "갈 곳이 표" in note]
def test_관측_원단위_줄도_갈_곳이_없으면_막히고_사유() -> None:
"""관측 원단위 로더의 `destination or "material"` 도 같은 병 — 줄이 늘 때 바로 드러나게."""
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ObservedUnitTable, expand_observed
table = ObservedUnitTable(
entries=[
{
"type_id": "시험구조물",
"unit": "개소",
"components": [
{"name": "콘크리트", "unit": "", "amount": 1.0, "destination": "unit_price"},
{"name": "새자재", "unit": "", "amount": 2.0},
],
}
]
)
components, notes = expand_observed("시험구조물", {}, {"options": {}}, table)
assert [c["name"] for c in components] == ["콘크리트"]
assert any("새자재" in note and "기본값 금지" in note for note in notes)
def test_관측_원단위_표의_전_줄에_갈_곳이_있음() -> None:
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table
missing = [
(entry.get("type_id"), item.get("name"))
for entry in load_observed_table().entries
for item in entry.get("components") or []
if not item.get("destination")
]
assert missing == []
@@ -34,7 +34,9 @@ def test_관_하나에_기슭막이_둘이_기본값_사유와_함께_서고_관
inlet, outlet = table["structures"]
assert inlet["options"]["form"] == "돌쌓기(찰)" and outlet["options"]["form"] == "돌쌓기(메)"
assert all(s["components"] and s["length_m"] == 10 for s in table["structures"])
assert "등록부 기본값으로 섰음" in inlet["notes"][0]
assert "기본값으로 섰음" in inlet["notes"][0]
# 높이는 횡단도와 같은 관경 기준 최소 높이(Ø1000 → 2.0) — 그림 둘·표가 한 값(브레인 판정)
assert inlet["height_m"] == outlet["height_m"] == 2.0 and "관경 기준" in inlet["notes"][0]
# 관 줄은 관 연장만 — 기슭막이가 거기 섞이지 않음(두 번 안 셈).
pipe = build_rows([point], [{"chainage_m": 100.0, "design": {"pipe_length_m": 8}}])
assert [row["quantity"] for row in pipe["rows"]] == [8.0]
+38 -1
View File
@@ -33,7 +33,22 @@ from B08_Quantity.B08_Quantity_Engine_Preparation import ( # noqa: E402
def 이름별(rows: list[dict]) -> dict:
return {(row["source"], row["name"]): row for row in rows}
return {(row["source"], f"{row['name']} {row.get('spec', '')}".strip()): row for row in rows}
def test_이름과_규격을_가른다() -> None:
"""이름 칸에 규격을 섞으면 할증률표(각재 5 · 판재 10)와 안 맞음 — 관급구분 키는 그대로."""
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table
rows = frame_material_rows(규준틀)
assert {(row["name"], row["spec"]) for row in rows} == {
("각재", "50×50"),
("판재", "T12"),
("", ""),
}
table = build_table({"structures": []}, extra_materials=rows)
rates = {row["supply_key"]: row["surcharge_pct"] for row in table["rows"]}
assert rates == {"각재 50×50": 5, "판재 T12": 10, "": None}
def test_개소가_서면_재료도_선다() -> None:
@@ -70,6 +85,28 @@ def test_자재_축으로_간다() -> None:
assert all(row["destination"] == "material" for row in frame_material_rows(규준틀))
def test_화면과_인계가_같은_한_벌을_부른다() -> None:
"""자재총괄 화면(6줄)·인계(9줄)가 갈리던 자리 — 규준틀 재료를 한 곳에서 얹음(2026-09-14).
라우터가 함수를 부르는지까지 본다 한쪽이 길로 돌아가면 다시 갈림.
"""
import inspect
from B08_Quantity import B08_Quantity_Router_Material as router
table = router.material_table_for(
{"structures": []},
{"material_supply": {"각재 50×50": "owner_supplied"}},
{"rows": 규준틀},
)
keys = {row["supply_key"]: row for row in table["rows"]}
assert set(keys) == {"각재 50×50", "판재 T12", ""}
assert keys["각재 50×50"]["supply"] == "owner_supplied" # 화면에서 고른 값이 먹음
for route in (router.get_material_summary, router.get_handoff):
source = inspect.getsource(route)
assert "material_table_for(" in source and "build_material_table(" not in source
def test_제안값이_실무_관측값과_같다() -> None:
"""울진 소광 §8 — 각재 50×50 0.0044㎥ · 판재 T12 0.0029㎥ · 못 0.03㎏(개소당)."""
assert FRAME_MATERIAL_SUGGESTED["각재 50×50"][0] == 0.0044
+4 -3
View File
@@ -552,11 +552,12 @@ def test_자재가_없으면_not_applied() -> None:
assert handoff["surcharge_status"] == "not_applied"
def test_관급구분_미분류가_계약값임() -> None:
"""`unknown` 은 세 번째 값이다 — B09 는 이 줄을 관급에도 도급에도 안 넣는다."""
def test_관급구분_안_정하면_기본_사급() -> None:
"""2026-09-14 브레인 판정 ⓐ — 안 정한 자재는 사급으로 인계(관급은 산출 조건이 정함)."""
unit = build_unit_table([구조물()])
handoff = build_handoff(unit_quantity_table=unit, material_table=build_material_table(unit))
assert all(row["supply_type"] == "unknown" for row in handoff["materials"])
assert handoff["materials"]
assert all(row["supply_type"] == "contractor_supplied" for row in handoff["materials"])
# ── 운반 줄 (2026-09-07 조율 창 요청) ───────────────────────────────
+35 -8
View File
@@ -103,7 +103,7 @@ def test_품셈에_포함된_항목은_또_붙이지_않음() -> None:
table = build_table(원단위표(성분("모래", "", 10.0, surcharge_included=True)))
row = (table, "모래")
assert row["total_amount"] == pytest.approx(10.0)
assert row["note"] == NOTE_INCLUDED
assert row["note"].split(" · ")[0] == NOTE_INCLUDED # 뒤는 「기본 사급」
# ── destination 가르기 ──────────────────────────────────────────────
@@ -214,7 +214,7 @@ def test_표에_없는_자재는_0퍼센트로_넘기지_않음() -> None:
table = build_table(원단위표(성분("낯선자재", "", 10.0)))
row = (table, "낯선자재")
assert row["surcharge_pct"] is None
assert row["note"] == NOTE_RATE_MISSING
assert row["note"].split(" · ")[0] == NOTE_RATE_MISSING # 뒤는 「기본 사급」
assert "낯선자재" in table["missing_rate_materials"]
# 값은 잃지 않는다 — 순수량 그대로 둔다.
assert row["total_amount"] == pytest.approx(10.0)
@@ -252,13 +252,40 @@ def test_합계는_반올림하지_않음() -> None:
# ── 관급/사급 ───────────────────────────────────────────────────────
def test_안_정한_자재는_미정으로_드러남() -> None:
"""⚠ 표기에 **누가 정하는지**가 있어야 한다 — 「미분류」로만 적으면 우리가 못 만든 것처럼
읽힌다(2026-09-09 화면 확인). (`unknown`) 판정 그대로."""
def test_안_정한_자재는_기본_사급으로_서고_그_사실이_적힘() -> None:
"""2026-09-14 브레인 판정 ⓐ — 기본 사급 · 관급은 산출 조건이 정함(종전 `unknown` 은 어느
합계에도 들어 자재비가 통째로 빠짐). 명시적으로 미정을 고른 그대로 미정."""
table = build_table(원단위표(성분("야면석", "", 5.0)))
assert (table, "야면석")["supply"] == SUPPLY_UNKNOWN
assert (table, "야면석")["supply_label"] == "미정(발주기관 결정)"
assert table["missing_supply_materials"] == ["야면석"]
assert (table, "야면석")["supply"] == "contractor_supplied"
assert "기본 사급" in (table, "야면석")["note"]
assert table["missing_supply_materials"] == []
picked = build_table(원단위표(성분("야면석", "", 5.0)), supply_map={"야면석": "unknown"})
assert (picked, "야면석")["supply"] == SUPPLY_UNKNOWN
assert (picked, "야면석")["supply_label"] == "미정(발주기관 결정)"
assert picked["missing_supply_materials"] == ["야면석"]
def test_율이_없는_까닭이_줄에_뜸() -> None:
"""보류(조건이 다름)와 이름 없음을 가름 — 「미확보」만으로는 할 일이 안 보임."""
table = build_table(원단위표(성분("막자갈", "", 10.0), 성분("", "", 1.0)))
assert (table, "막자갈")["surcharge_pct"] is None
assert "조건이 다름 — 사용자 확정 후 옮길 것" in (table, "막자갈")["note"]
assert "할증률표에 이름 없음" in (table, "")["note"]
def test_할증률은_표값_이내에서만_고름() -> None:
"""1-3-1 「표의 값 이내」 — 0~표값만 받고 넘거나 표에 없는 자재는 안 씀."""
fake = SurchargeTable(rates={"판재": {"material": "판재", "rate": 10}})
unit = 원단위표(성분("판재", "", 1.0), 성분("낯선자재", "", 1.0))
lower = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 6, "낯선자재": 3})
assert (lower, "판재")["surcharge_pct"] == 6 and (lower, "판재")["surcharge_cap"] == 10
assert "표 상한 10% 이내" in (lower, "판재")["note"]
assert (lower, "낯선자재")["surcharge_pct"] is None # 표에 없으면 고른 값도 안 씀
over = build_table(unit, surcharge_table=fake, surcharge_overrides={"판재": 12})
assert (
(over, "판재")["surcharge_pct"] == 10
and "이내가 아니라 안 씀" in (over, "판재")["note"]
)
def test_설정이_정한_구분을_따름() -> None:
@@ -0,0 +1,116 @@
"""철근 이름·규격 가르기(2026-09-14 · 목록 3+4) — **가르기 전후 물량이 그대로**인지 잼.
이형철근 D13 칸을 이름 이형철근 + 규격 D13 으로 가르면 셋이 같은 글자에 묶여 있어
하나만 고치면 조용히 어긋남(브레인 경고): 묶음 조각 `from_components` · `amounts[name]`
(D13·D16 서로 덮어씀) 이중계상 경계 검사 `_pickers`·`destinations`( 집으면 검사가 )
자재총괄 ·할증. 시험은 **가르기 코드에서 먼저 초록**으로 만들고 가른 뒤에도 초록이어야 .
옹벽 반중력식 10m D13 13.45 · D16 30.42 /m 조각 0.4387 ton · 자재 134.5 · 304.2
집수정 Ø800 1개소 D13 4.776
L형수로 H=0.2 10m D13 0.398 /m 3.98
"""
from __future__ import annotations
import copy
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping # noqa: E402
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import ( # noqa: E402
verify_double_count_boundaries,
)
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402
def _구조물들() -> dict:
return build_unit(
[
{
"structure_id": "w1",
"type_id": "retaining_wall",
"start_m": 100.0,
"end_m": 110.0,
"options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0},
},
{
"structure_id": "b1",
"type_id": "pipe_inlet_basin",
"start_m": 200.0,
"end_m": 200.0,
"options": {
"inlet_basin_form": "□형(기본형)",
"inlet_basin_material": "콘크리트",
"pipe_diameter_mm": "800",
},
},
{
"structure_id": "d1",
"type_id": "open_ditch",
"start_m": 300.0,
"end_m": 310.0,
"options": {"ditch_spec": "L형수로 H=0.2", "length_m": 10.0},
},
],
{"retaining_wall": "옹벽", "pipe_inlet_basin": "집수정", "open_ditch": "개거"},
)
def test_옹벽_철근_조각은_D13_D16_을_다_모아_ton() -> None:
row = next(
r
for r in build_handoff(unit_quantity_table=_구조물들())["work_items"]
if r.get("composite_parts")
)
rebar = next(p for p in row["composite_parts"] if "12-03" in str(p.get("code")))
assert rebar["quantity"] == pytest.approx(0.4387) and not rebar.get("not_ready")
from B08_Quantity.B08_Quantity_Engine_Handoff import structure_kind
wall = next(s for s in _구조물들()["structures"] if s["type_id"] == "retaining_wall")
assert structure_kind(wall) == "철근구조물"
def test_자재총괄_철근_줄_물량과_할증이_그대로() -> None:
table = build_table(_구조물들())
rebar = {
row["supply_key"]: (row["net_amount"], row["surcharge_pct"], row["total_amount"])
for row in table["rows"]
if row["supply_key"].startswith("이형철근")
}
assert set(rebar) == {"이형철근 D13", "이형철근 D16"} # 관급구분·단가 키는 전후 같은 글자
assert rebar["이형철근 D13"][0] == pytest.approx(134.5 + 4.776 + 3.98)
assert rebar["이형철근 D16"][0] == pytest.approx(304.2)
assert rebar["이형철근 D13"][1] == 3 and rebar["이형철근 D16"][1] == 3 # 1-3-1 이형철근
assert "이형철근" not in " ".join(table["missing_rate_materials"])
def test_경계_검사가_철근을_여전히_집는다() -> None:
"""철근이 토공 축으로 잘못 가면 묶음 조각이 또 센다고 걸려야 함 — 못 집으면 검사가 놂."""
unit = copy.deepcopy(_구조물들())
wall = next(s for s in unit["structures"] if s["type_id"] == "retaining_wall")
for component in wall["components"]:
if str(component["name"]).startswith("이형철근"):
component["destination"] = "earthwork"
found = verify_double_count_boundaries(unit, None, load_mapping())
assert any("이형철근" in text and "묶음 조각" in text for text in found), found
assert not verify_double_count_boundaries(_구조물들(), None, load_mapping())
def test_가른_뒤_이름과_규격이_따로_서고_할증은_별칭_없이_이어짐() -> None:
table = build_table(_구조물들())
rows = [(r["name"], r["spec"]) for r in table["rows"] if r["name"].startswith("이형철근")]
assert sorted(rows) == [("이형철근", "D13"), ("이형철근", "D16")]
unit = _구조물들()
specs = {
(c["name"], c.get("spec"))
for s in unit["structures"]
for c in s["components"]
if str(c["name"]).startswith("이형철근")
}
assert specs == {("이형철근", "D13"), ("이형철근", "D16")}
+31
View File
@@ -167,6 +167,37 @@ def test_사토도_자연상태로_선다() -> None:
assert "자연상태" in rows[0]["spec_detail"]
def test_사토에도_적재_짝_줄이_서고_단가가_선다() -> None:
"""사토도 싣는 일이 따로(10-12 「1. 적재」) — 내역에 서는 운반 줄에만 붙고, 검산엔 안 셈."""
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import dump_haul_distances
from B09_Estimation.B09_Estimation_UnitPrice import cached_build
payload = build_handoff(
haul_table={
"rows": [],
"spoil": {
"volume_m3": 100.0,
"distance_m": 800.0,
"by_ground_m3": {"ea_m3": 70.0},
"ground_unknown_m3": 30.0,
},
}
)
rows = payload["work_items"]
loads = [row for row in rows if row["name"] == "덤프 적재"]
assert [(row["variant_value"], row["quantity"]) for row in loads] == [("토사", 70.0)]
assert loads[0]["haul_distance_m"] is None and loads[0]["haul_equipment"] == "dump_loading"
# 갈래 못 붙인 몫은 막힌 줄이라 짝 줄이 없음
assert len([row for row in rows if row["name"] == "사토 운반"]) == 2
bill = build_bill(payload, build=cached_build(dump_haul_m=dump_haul_distances(payload)))
got = {row.name: row for row in bill.rows if row.name in ("사토 운반", "덤프 적재")}
assert got["사토 운반"].price_code.startswith("B-FP-10-12-01#L800")
assert got["덤프 적재"].price_code == "B-FP-10-12-01#적재"
assert got["사토 운반"].amount_krw > 0 and got["덤프 적재"].amount_krw > 0
def test_되돌린_값이_없으면_다짐_그대로_두고_사유() -> None:
rows = 줄들(volume_m3=61.88, distance_m=800.0, by_ground_m3={"rr_m3": 61.88})
assert rows[0]["quantity"] == 61.88
@@ -89,7 +89,9 @@ def test_기본값으로_그린_장은_표_사유와_같은_말을_적는다() -
options = {"height_m": 2.0}
_rows, table_notes = stone_masonry(2.0, 1.0, options, wet=False)
table_default = [note for note in table_notes if "뒷길이를 안 골라" in note]
assert table_default == [back_length_default_note(options, 45)]
# 2026-09-14 — 기본은 뒷길이 표준 하한(메 ≤3m 36~45㎝ → 45) · 표·그림이 같은 말.
assert table_default == [back_length_default_note(options, 45, wet=False, height_m=2.0)]
assert "하한 36㎝ 는 규격 일곱" in table_default[0]
texts = _texts(build_figure(_sheet()))
assert table_default[0] in texts
@@ -61,7 +61,16 @@ CASES = [
dict(height=2.0, length=7.5, back_len_cm=45, face_slope_ratio=0.35, foundation="기초유"),
dict(height=2.0, length=10.0, back_len_cm=55, thickness_top_m=0.9, foundation="기초버림"),
dict(height=2.0, length=10.0, back_len_cm=45, thickness_bottom_m="1.4", stone_kind="깬잡석"),
dict(height=2.0, length=10.0, stone_kind="야면석·호박돌", stone_coeff_basis="실무 관행"),
# 뒷길이 45 — 안 고르면 표준 하한 30㎝ 라 야면석 관측 무게(35·45·55)가 없어 양식만 오류 줄(명세 13장 Ⓐ)
dict(
height=2.0,
length=10.0,
back_len_cm=45,
stone_kind="야면석·호박돌",
stone_coeff_basis="실무 관행",
),
# 뒷길이 안 고름 — 전개·양식이 같은 표준 하한(찰 ≤3m 30㎝, 브레인 판정 ㉮)
dict(height=2.0, length=10.0, stone_kind="깬돌", foundation="기초유"),
dict(height=1.5, length=10.0, back_len_cm=35, weep_hole_area_m2=3, foundation="기초유"),
dict(height=2.5, length=12.0, back_len_cm=60, stone_kind="견치돌", rubble=0.3),
# 줄이 안 서는 갈래 — `when`
+3 -2
View File
@@ -125,7 +125,8 @@ def test_실무_시트_m당_값_재현() -> None:
품셈 1-2-2 소수 자리는 표기 규칙이고 계산은 전정밀로 둔다(PLAN 8-16).
맞추려고 식을 비틀지 않는다 허용오차를 차이만큼 둔다.
"""
result = expand(돌쌓기찰(height=1.5, length=1.0))
# 실무 시트가 뒷길이 45 — 2026-09-14 부터 안 고르면 표준 하한(찰 ≤1.5m 25㎝)이라 고름.
result = expand(돌쌓기찰(height=1.5, length=1.0, back_len_cm=45))
assert result.height_m == 1.5
assert result.length_m == 1.0
assert 성분(result, "터파기").amount == pytest.approx(1.55, abs=0.02)
@@ -135,7 +136,7 @@ def test_실무_시트_m당_값_재현() -> None:
def test_평균두께는_전정밀로() -> None:
"""실무가 0.83 으로 반올림해 재계산한 자리 — 우리는 0.825 를 그대로 쓴다."""
result = expand(돌쌓기찰(height=1.5, length=1.0))
result = expand(돌쌓기찰(height=1.5, length=1.0, back_len_cm=45))
# 터파기 = 높이 × (평균두께 + 0.2) × 연장 = 1.5 × 1.025 = 1.5375
assert 성분(result, "터파기").amount == pytest.approx(1.5375)
@@ -0,0 +1,64 @@
# -*- coding: utf-8 -*-
"""높이 없이 놓인 벽 — **놓기는 되고 줄은 서되 미확정(금액 밖)** (2026-09-14 브레인 판정 ①).
앞서 B05 폼이 C군 높이가 비면 놓기를 막았음. 우리 규칙 줄은 서고 금액은 어긋나 풀고,
높이가 물량 밑수인 (옹벽·돌쌓기 /·큰돌쌓기) B08 표에서 미확정으로 뜨게 .
흙막이( 개소당) 높이를 그대로.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import ( # noqa: E402
UNCONFIRMED_NO_HEIGHT,
missing_height_reason,
)
COMMIT = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel_Commit.ts").read_text(
encoding="utf-8"
)
def _wall(type_id: str = "masonry_wet", **options) -> dict:
return {
"structure_id": "w",
"type_id": type_id,
"start_m": 0.0,
"end_m": 10.0,
"options": {
"length_m": 10.0,
"back_len_cm": 45,
"stone_kind": "깬돌",
"foundation": "기초유",
**options,
},
}
def test_높이_없는_돌쌓기는_미확정으로_선다() -> None:
table = build_table([_wall()], {"masonry_wet": "돌쌓기(찰)"}, {})
row = table["structures"][0]
assert row["unconfirmed"] == UNCONFIRMED_NO_HEIGHT
def test_높이를_적으면_미확정이_아니다() -> None:
table = build_table([_wall(height_m=2.0)], {"masonry_wet": "돌쌓기(찰)"}, {})
assert table["structures"][0]["unconfirmed"] == ""
def test_높이를_안_쓰는_종류는_그대로() -> None:
assert missing_height_reason(_wall("soil_guard")) == ""
for type_id in ("retaining_wall", "masonry_dry", "boulder_masonry"):
assert missing_height_reason(_wall(type_id)) == UNCONFIRMED_NO_HEIGHT, type_id
assert missing_height_reason(_wall(type_id, height_m="1.5")) == "", type_id
def test_폼이_높이_빈칸으로_놓기를_안_막는다() -> None:
assert "height?.isEmpty()" not in COMMIT
@@ -0,0 +1,189 @@
"""자재 수동 단가(PLAN 1장 Ⓐ · 2026-09-14 브레인 판정).
지키는
= 코드(`AR-M-`) · 코드 없는 자재만 이름 규격 조립엔 코드 키만 얹음
0 이하· 아님은 받음 · 비우면 지움 · ·출처가 같으면 넣은 그대로
넣으면 자재 제목(6 슬롯) 서고 자원 자재 줄이 붙어 재료비가 넣으면 종전 그대로
수동 단가가 닿은 내역 = 금액은 서되 미확정 N건
"""
from __future__ import annotations
import sys
from decimal import Decimal
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_MaterialPrices import ( # noqa: E402
MaterialPriceError,
build_key,
listing,
merge,
)
from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402
STAKE = "AR-M-dce99e46" # 말뚝 직경46㎝ — FP-05-15 에 2개/단위
def test_키와_넣은_날() -> None:
first = merge({}, [{"key": STAKE, "price_krw": "1,500", "source": "견적 A"}], "2026-09-14")
assert first[STAKE]["price_krw"] == "1500" and first[STAKE]["entered_at"] == "2026-09-14"
same = merge(first, [{"key": STAKE, "price_krw": "1500", "source": "견적 A"}], "2026-09-20")
assert same[STAKE]["entered_at"] == "2026-09-14" # 다시 저장해도 안 바뀜
moved = merge(first, [{"key": STAKE, "price_krw": "1600", "source": "견적 A"}], "2026-09-20")
assert moved[STAKE]["entered_at"] == "2026-09-20"
assert merge(first, [{"key": STAKE, "price_krw": ""}], "2026-09-20") == {}
for bad in ("0", "-3", "abc"):
with pytest.raises(MaterialPriceError):
merge({}, [{"key": STAKE, "price_krw": bad}], "2026-09-14")
named = merge(first, [{"key": "각재 50×50", "price_krw": "900000"}], "2026-09-14")
assert build_key(named) == ((STAKE, "1500", "견적 A"),) # 이름 키는 조립에 안 얹음
def test_넣으면_재료비가_서고_안_넣으면_종전_그대로() -> None:
base = cached_build()
assert base.book.resolve("B-FP-05-15").material == 0
assert STAKE not in base.book.titles and not base.manual_materials
built = cached_build(material_prices=((STAKE, "1500", "견적 A"),))
title = built.book.titles[STAKE]
assert (title.slots[5], title.slot_pages[5], title.unit) == (Decimal("1500"), "견적 A", "")
assert built.book.resolve("B-FP-05-15").material == Decimal("3000") # 2개 × 1,500
assert built.book.resolve("B-FP-05-15").labor == base.book.resolve("B-FP-05-15").labor
assert "FP-05-15" in built.material_uses[STAKE]
def test_내역_줄은_미확정으로_선다() -> None:
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
built = cached_build(material_prices=((STAKE, "1500", "견적 A"),))
payload = {
"work_items": [
{
"work_item_code": "FP-05-15",
"name": "말뚝박기",
"unit": built.book.titles["B-FP-05-15"].unit,
"quantity": 10,
"in_bill": True,
}
],
"materials": [],
}
row = next(r for r in build_bill(payload, build=built).rows if r.name == "말뚝박기")
assert row.material_krw == Decimal("30000") and row.unconfirmed == 1
result = build_bill(payload, build=built)
assert result.unconfirmed == [{"name": "말뚝박기", "code": "B-FP-05-15", "count": 1}]
plain = build_bill(payload, build=cached_build())
assert not plain.unconfirmed
def _자재(name: str, spec: str, total: str, supply: str, source: list[str]) -> dict:
return {
"material_name": name,
"spec": spec,
"unit": "",
"net_amount": total,
"total_amount": total,
"supply_type": supply,
"source_structure": source,
}
def test_사급_자재총괄_줄은_본체_자재_줄로_서고_관급은_수량만() -> None:
"""Ⓐ-2 — 셈(㉠ 0 · ㉡ 9 · ㉢ 0) 뒤 브레인 판정. 도급 재료비에 들고 미확정으로 셈."""
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
payload = {
"work_items": [],
"materials": [
_자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"]),
_자재("판재", "T12", "0.1769", "contractor_supplied", ["비탈 규준틀"]),
_자재("채움콘크리트", "180", "3.4", "owner_supplied", ["돌쌓기(찰)"]),
],
}
prices = {"각재 50×50": {"price_krw": "650000"}, "채움콘크리트 180": {"price_krw": "90000"}}
result = build_bill(payload, build=cached_build(), material_prices=prices)
group = next(r for r in result.rows if r.name == "자재(사급)")
rows = [r for r in result.rows if r.item_no.startswith(f"{group.item_no}-")]
assert [(r.name, r.spec) for r in rows] == [("각재", "50×50")] # 판재 단가 없음 · 관급 안 올림
# 수량을 1-2-2 자리(㎥ 2자리)로 반올림해 확정한 뒤 곱함 — 0.2684 → 0.27 × 650,000
assert rows[0].quantity == Decimal("0.27") and rows[0].material_krw == Decimal("175500")
assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("175500")
assert any(m["name"] == "판재 T12" and "단가 없음" in m["reason"] for m in result.missing)
sheet = result.material_sheet
assert (
sheet.contractor_rows[0].manual and not sheet.owner_rows[0].manual
) # 관급엔 수동 단가 안 씀
plain = build_bill(payload, build=cached_build())
assert not any(r.name == "자재(사급)" for r in plain.rows) and plain.direct_material_krw == 0
def test_같은_구조물_일위대가_자재와_겹치면_안_올리고_사유() -> None:
"""이중계상 가드 — 코드가 있으면 코드로, 없을 때만 이름으로(막는 쪽이라 틀려도 과소)."""
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
from B09_Estimation.B09_Estimation_BillOfQuantities_Materials import overlap
built = cached_build(material_prices=((STAKE, "1500", ""),))
unit = built.book.titles["B-FP-05-15"].unit
payload = {
"work_items": [
{"work_item_code": "FP-05-15", "name": "말뚝박기", "unit": unit, "quantity": 10}
],
"materials": [_자재("말뚝", "", "20", "contractor_supplied", ["말뚝박기"])],
}
result = build_bill(payload, build=built, material_prices={"말뚝": {"price_krw": "1500"}})
assert not any(r.name == "자재(사급)" for r in result.rows)
hit = next(m for m in result.missing if m.get("blocked_kind") == "double_count_suspect")
assert "말뚝" in hit["reason"]
inside = [(STAKE, "말뚝"), ("", "시멘트 510 kg — 자재 단가 층 없음")]
assert overlap("시멘트", "", inside).startswith("시멘트")
assert overlap("말뚝", "AR-M-00000000", inside) == "" # 코드가 있으면 코드로만
def test_목록은_자원_축_자재와_사라진_저장_줄() -> None:
built = cached_build()
stored = {
STAKE: {"price_krw": "1500"},
"AR-M-00000000": {"price_krw": "7", "name": "옛 자재"},
}
materials = [_자재("각재", "50×50", "0.2684", "contractor_supplied", ["비탈 규준틀"])]
rows = {
row["key"]: row for row in listing(built, {**stored, "": {"price_krw": "3"}}, materials)
}
assert rows[STAKE]["name"] == "말뚝" and rows[STAKE]["price_krw"] == "1500"
assert rows["AR-M-00000000"]["missing"] is True # 조용히 안 버림
assert rows["AR-M-b0853497"]["price_krw"] is None
sheet_row = rows["각재 50×50"]
assert (sheet_row["name"], sheet_row["spec"], sheet_row["origin"]) == (
"각재",
"50×50",
"material_sheet",
)
assert sheet_row["supply_type"] == "contractor_supplied" and rows[""]["missing"] is True
def test_내역_수량은_자리로_반올림해_확정한_뒤_금액은_버림() -> None:
"""1-2-2 [주]① 수량 반올림 → 금액 버림 — 둘을 섞지 않음(2026-09-14 브레인 판정).
인계 전정밀 0.2818199996 곱하면 183,182 (1 ) 확정 수량 0.28 × 650,000 = 182,000.
"""
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
from B09_Estimation.B09_Estimation_QuantityDigits import digits_for
payload = {
"work_items": [],
"materials": [
_자재("각재", "50×50", "0.28181999999999996", "contractor_supplied", ["비탈 규준틀"])
],
}
result = build_bill(
payload, build=cached_build(), material_prices={"각재 50×50": {"price_krw": "650000"}}
)
row = next(r for r in result.rows if r.name == "각재")
assert (row.quantity, row.amount_krw) == (Decimal("0.28"), Decimal("182000"))
assert row.as_dict()["quantity"] == "0.28" # 계약·기성이 읽는 수량도 확정값
assert digits_for("낯선종목", "") == 2 # 표에 없는 종목은 우리 기본 2자리
assert digits_for("돌쌓기(찰)", "") == 1 and digits_for("철근", "kg") == 0
@@ -0,0 +1,29 @@
"""수량 자리 맞춤 골든 대조 — STmate 실무 원본 내역 수량이 우리 자리 규칙으로 **안 바뀌어야** 함.
2026-09-14 브레인 판정 내역 수량은 1-2-2 자리로 반올림해 확정한 금액(버림). 골든 금액 시험
(`test_b09_golden_stmate`) 원본 수량을 `bill_line` 바로 넣어 길을 거치므로, 원본 수량
자체로 자리 규칙을 따로 잰다(321 · 철근 ton 소수 3자리가 대조에서 드러남).
"""
from __future__ import annotations
import pytest
from test_b09_golden_stmate import _bill_rows, _num
from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity
def test_실무_원본_내역_수량이_자리_규칙으로_안_바뀐다() -> None:
rows = _bill_rows()
if not rows:
pytest.skip("실무 원본 XLSX 가 없음")
changed = []
for name, row in rows:
quantity, unit = _num(row[3]), str(row[4] or "").strip()
if quantity is None or unit == "%":
continue
settled, digits = round_quantity(quantity, str(row[1] or ""), unit, str(row[2] or ""))
if settled != quantity:
changed.append((name, row[1], unit, str(quantity), digits))
assert len(rows) >= 300, len(rows)
assert not changed, (len(changed), changed[:5])
@@ -18,14 +18,18 @@ def _nodes() -> dict[str, dict]:
def test_단가표_갈래_제목은_전부_마스터_갈래_키에_있다() -> None:
"""갈래를 **어디서 왔나**로 가름(2026-09-14 브레인 판정) — 표에서 온 갈래만 마스터에 있어야 함.
식이 갈래(덤프 운반·적재 10-12) 마스터가 없어 검사 대상이 아니되
`formula_variants` 식이 냈다 적혀 있어야 (아래 시험).
"""
nodes = _nodes()
missing = []
for code in cached_build().book.titles:
build = cached_build(dump_haul_m=("164.23",))
for code in build.book.titles:
if not code.startswith("B-") or "#" not in code:
continue
if code.startswith("B-FP-10-12-"):
# 덤프 운반·적재(10-12)는 **표가 아니라 본문 식**이라 마스터 표에 갈래가 없음 —
# 갈래(`#적재`·`#L…m`)는 식이 냄(2026-09-14 · `MachineProductivity_Dump`).
if code in build.formula_variants:
continue
work_item, variant = code[2:].split("#", 1)
keys = {
@@ -36,6 +40,18 @@ def test_단가표_갈래_제목은_전부_마스터_갈래_키에_있다() -> N
assert not missing, f"마스터를 다시 뽑을 것(B08_Quantity_Build_WorkItemMaster): {missing[:5]}"
def test_식이_낸_갈래는_출처가_적히고_마스터엔_없다() -> None:
build = cached_build(dump_haul_m=("164.23",))
formula = build.formula_variants
assert "B-FP-10-12-01#적재" in formula and "B-FP-10-12-02#L164.23m" in formula
assert all(code in build.book.titles and why for code, why in formula.items())
nodes = _nodes()
for code in formula:
work_item, variant = code[2:].split("#", 1)
keys = {normalize_variant_key(k) for k in nodes[work_item].get("variant_keys") or []}
assert variant not in keys # 마스터에 있으면 「표에서 옴」이라 여기 적을 까닭이 없음
def test_표_모양대로_갈래를_가르고_못_가르면_빈칸() -> None:
nodes = _nodes()
felling = nodes["FP-04-02-02"]["tables"][0]["variant_key"]
+1
View File
@@ -12,6 +12,7 @@ export const ui_locales_b4 = {
B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"],
B09_Estimation_Tab_Progress: ["기성", "Progress Payment"],
B09_Estimation_Tab_Completion: ["준공", "Completion"],
B09_Estimation_Tab_MaterialPrices: ["자재 단가", "Material Prices"],
B08_Quantity_Side_BenchCut_Fill: [
"제안값 넣기 — KDS 표준치수 토사 0.5 m",
"Insert suggested value — KDS standard soil 0.5 m",