Merge remote-tracking branch 'origin/dev' into sub_desktop_1
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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+n²) 를 곱하지 않는다.
|
||||
"""
|
||||
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",
|
||||
|
||||
@@ -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`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */
|
||||
|
||||
@@ -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 확정).
|
||||
|
||||
@@ -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.28181999…96)로 곱해 원 미만 절사에 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 조율 창 요청) ───────────────────────────────
|
||||
|
||||
@@ -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")}
|
||||
@@ -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`
|
||||
|
||||
@@ -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" # 말뚝 직경4~6㎝ — 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.28181999…96 로 곱하면 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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user