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

This commit is contained in:
2026-09-14 16:45:03 +09:00
260 changed files with 27142 additions and 7555 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
[ROUTES.B08_QUANTITY]: async () =>
(await import("../B08_Quantity/B08_Quantity_UI_Page")).renderB08Quantity,
[ROUTES.B09_ESTIMATION]: async () =>
(await import("../B09_Estimation/B09_Estimation_UI_Page")).renderB09Estimation,
(await import("../B09_Estimation/B09_Estimation_UI_Shell")).renderB09Estimation,
[ROUTES.B10_PAYMENT]: async () =>
(await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment,
[ROUTES.B11_STATUS]: async () =>
@@ -35,6 +35,11 @@ export interface StructureOptionField {
* ⚠ 이 칸의 select 는 **빈 보기를 둔다** — 첫 보기를 슬쩍 고르면 기준값과 다른 값이
* 조용히 저장된다(2026-09-13: `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). */
empty_means?: string | null;
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음) — 기준값과 까닭 한 줄. */
warn_above?: number | null;
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 칸 이름 옆 「표에 안 쓰임」 + 툴팁 사유. */
not_in_table?: string | null;
}
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
+127 -35
View File
@@ -134,9 +134,10 @@
"label": "유입 기슭막이 높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
},
{
"key": "outlet_type",
@@ -186,9 +187,10 @@
"label": "유출 기슭막이 높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
},
{
"key": "revet_foundation",
@@ -332,6 +334,26 @@
"unit": "%",
"default": null,
"required": false
},
{
"key": "thickness_cm",
"label": "포장 두께",
"input": "number",
"unit": "㎝",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)"
},
{
"key": "length_m",
"label": "포장 길이(노폭 방향)",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)"
}
]
},
@@ -479,6 +501,15 @@
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎"
},
{
"key": "length_m",
"label": "연장",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)"
}
]
},
@@ -695,9 +726,11 @@
"label": "높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": 2.0,
"default_basis": "제안값 2.0 — 관측 원단위 자료가 반중력식 H=2.0 한 벌뿐이라 그 값(2026-09-14 A4 · 옛 2.5 는 자료 없음)",
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -783,7 +816,8 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -888,6 +922,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -956,7 +1000,10 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)",
"warn_above": 2.0,
"warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)"
},
{
"key": "length_m",
@@ -1061,6 +1108,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -1222,7 +1279,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "thickness_bottom_m",
@@ -1232,7 +1290,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "back_len_cm",
@@ -1242,7 +1301,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_kind",
@@ -1252,7 +1312,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_supply",
@@ -1262,7 +1323,8 @@
"default": "채집",
"required": false,
"phase": "detail",
"default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음"
"default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1272,7 +1334,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1282,7 +1345,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "face_slope_ratio",
@@ -1291,7 +1355,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "foundation",
@@ -1310,7 +1375,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "weep_hole_area_m2",
@@ -1320,7 +1386,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
}
]
},
@@ -1342,7 +1409,8 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -1396,7 +1464,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1406,7 +1475,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1416,7 +1486,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "face_slope_ratio",
@@ -1452,7 +1523,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "weep_hole_area_m2",
@@ -1462,7 +1534,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "blinding_concrete",
@@ -1590,7 +1663,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "thickness_bottom_m",
@@ -1600,7 +1674,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "back_len_cm",
@@ -1618,7 +1693,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1628,7 +1704,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1739,7 +1816,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1749,7 +1827,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1759,7 +1838,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "face_slope_ratio",
@@ -1768,7 +1848,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "foundation",
@@ -1910,7 +1991,7 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_kind",
@@ -1960,6 +2041,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -1974,9 +2065,10 @@
"label": "높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)"
},
{
"key": "length_m",
@@ -60,6 +60,13 @@ class StructureOptionField(BaseModel):
# 기본값이 **도메인 확정값이 아닐 때** 그 뜻을 적는다(예: 「다단 없음」·「안 더함」).
# 법정·확정 수치면 비워 둔다 — 비어 있는 것이 「확정값」이라는 뜻이다.
default_basis: str | None = None
# 이 값을 **넘으면** 칸이 경고색 + 툴팁 — 막지 않음(2026-09-14 브레인 판정 ①·㉰ 「놓기를 막지 말 것」).
# 넘는지 보는 기준값과 그 까닭 한 줄(원문 쪽·줄 번호).
warn_above: float | None = None
warn_message: str | None = None
# 칸은 있는데 **원단위 표가 아직 안 읽는** 칸 — 넣어도 수량·금액이 안 바뀜을 칸 옆에 보임(2026-09-14 ①).
# 참말인지는 `test_b05_not_in_table_marks` 가 값을 바꿔 돌려 보고 지킴.
not_in_table: str | None = None
class StructureType(BaseModel):
+97 -194
View File
@@ -26,10 +26,6 @@
* 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다.
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import type {
DetailPipeInput,
PipeFacility,
@@ -42,10 +38,12 @@ import {
createRevetmentFields,
createRevetSideGroup,
createWingFields,
fordSection,
grid,
group,
INLET_KINDS,
INLET_REVET_KEYS,
inletKindOf,
type InletStructureKind,
labeled,
numberInput,
optionalSelect,
@@ -56,30 +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";
/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합).
* 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한
* 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다).
* 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06
* 유입측 형식(`inlet_structure`). */
const INLET_KINDS: ReadonlyArray<{
label: string;
type: "기슭막이" | "집수정";
structure: InletStructureKind;
}> = [
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
{ label: "집수정", type: "집수정", structure: "auto" },
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
{ label: "집수정 I형", type: "집수정", structure: "I" },
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
];
const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로
/** 시설 확장 정보 한 건 — 관 지점(chainage)에 얹힌다. 배관(pipe)도 부속 옵션을
* 가지면 저장한다. */
@@ -209,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+집수정"]);
@@ -228,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("선택", [
"동물이동형",
"□형(기본형)",
@@ -248,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).
@@ -273,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),
@@ -299,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, "돌쌓기(메)");
@@ -338,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);
@@ -358,98 +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 = "노면 기울기";
const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 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,
@@ -480,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();
@@ -516,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?.();
@@ -543,8 +457,7 @@ export function createFacilityOptionsForm(
boxHeight,
...wingInFields.inputs,
...wingOutFields.inputs,
fordCount,
fordWidth,
...ford.inputs,
].forEach((input) => input.addEventListener("change", emit));
/** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */
@@ -581,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 =
@@ -623,43 +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");
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;
@@ -667,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") {
// 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다.
@@ -676,20 +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);
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).
@@ -9,6 +9,33 @@
* ========================================================================== */
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json";
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
/** B06 유입측 구조물 형식 — 조정창 드롭다운과 같은 값 집합(2026-08-29 병합).
* 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한
* 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다).
* 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06
* 유입측 형식(`inlet_structure`). */
export const INLET_KINDS: ReadonlyArray<{
label: string;
type: "기슭막이" | "집수정";
structure: InletStructureKind;
}> = [
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
{ label: "집수정", type: "집수정", structure: "auto" },
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
{ label: "집수정 I형", type: "집수정", structure: "I" },
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
];
export const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
/* ── 물넘이·세월교 개략 단면 ────────────────────────────────────────────── */
@@ -127,14 +154,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;
}
@@ -175,16 +206,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",
@@ -218,16 +253,43 @@ export interface RevetSideGroup {
slot: HTMLElement;
}
/** 독립 기슭막이 치수 근거 줄 — `revetment_sabang_*.json` 한 벌(B08 사유와 같은 파일 · 쪽·줄 번호).
* ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험(`test_b05_revetment_sabang_basis`)이 최신 판을 대조. */
function sabangBasisLines(): string[] {
const { items, scope } = revetmentSabang;
const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`;
return [
`높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`,
`계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`,
`둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`,
`뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`,
scope,
];
}
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
const box = group("기슭막이");
const kind = document.createElement("select");
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
const fields = createRevetmentFields(keys, { ...REVET_COMMON_DEFAULTS, form: formDefault });
// 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음.
const fields = createRevetmentFields(keys, {
...REVET_COMMON_DEFAULTS,
form: formDefault,
height: "",
});
const basis = document.createElement("p");
basis.className = "b05-route__note";
basis.replaceChildren(
...sabangBasisLines().flatMap((line, index) =>
index ? [document.createElement("br"), line] : [line],
),
);
const slot = document.createElement("div");
slot.className = "b05-structure__adjust-slot";
box.body.append(
grid(labeled("구조", kind), fields.formField),
...fields.rows,
basis,
// 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과
// 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자).
slot,
@@ -258,26 +320,32 @@ 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 heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력");
const height = numberInput("0.1", "0", heightHint());
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
height.addEventListener("focus", () => (height.placeholder = heightHint()));
// 기준측점 전/후(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;
@@ -293,10 +361,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 {
@@ -304,13 +387,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 = heightHint();
if (before && keys.before) before.value = text(keys.before);
if (after && keys.after) after.value = text(keys.after);
syncSplit("length");
},
read(target) {
@@ -395,19 +480,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));
@@ -426,7 +510,6 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
dimsRow.hidden = off;
}
// 만들자마자 기본값("있음") 상태를 맞춘다.
syncVisibility();
return {
@@ -437,18 +520,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);
},
};
}
@@ -0,0 +1,73 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility_Merge.ts
* 계곡 통과 시설 [저장] — **폼이 아는 칸만 갈아 끼우고, 폼이 모르는 칸은 건드리지 않음**
* (2026-09-14 브레인 판정).
*
* ⚠ 앞서 저장이 옵션을 **통째로** 갈아 끼웠음 — 구조물 집계표·구조물도로 적은 칸(관 기슭막이
* 기초 `revet_foundation` · 독립 기슭막이 `foundation`·뒷길이·돌 종류 …)이 B05 에서 그 시설을
* 한 번 더 저장하면 **조용히 지워졌음**. 「작업본 쓰기는 [저장]에서만」인데 그 [저장]이 옆 칸까지
* 지우던 자리.
* ⚠ 통째로 갈아 끼우는 것은 **시설 종류 자체를 바꿀 때만** — 그때는 옛 칸이 뜻을 잃음.
* ⚠ 폼이 아는 칸을 폼이 비워 보내면(값을 지움·유입구를 집수정 → 기슭막이로 바꿔 집수정 칸이 빠짐)
* 그 칸은 지워짐 — 사용자가 폼에서 한 일이므로 맞음.
* ⚠ 폼에 칸을 늘리면 아래 목록에도 넣을 것. 빠뜨리면 그 칸을 **폼에서 지워도 옛 값이 남는**
* 쪽으로 틀림(지워지는 쪽보다 덜 위험). 거울 시험 `test_b05_facility_options_merge`.
* ⚠ import 없음 — 시험이 이 파일만 옮겨 돌림.
* ========================================================================== */
const revetKeys = (side: "inlet" | "outlet"): string[] =>
["form", "length_m", "height_m", "before_m", "after_m"].map((key) => `${side}_revet_${key}`);
const wingKeys = (side: "in" | "out"): string[] => [
`wing_${side}`,
`wing_${side}_height_m`,
`wing_${side}_length_m`,
`wing_${side}_angle_deg`,
];
/** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */
export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
pipe: [
"pipe_diameter_mm",
"pipe_kind",
"wing_wall_type",
"inlet_type",
"inlet_structure",
"inlet_basin_form",
"inlet_basin_material",
"inlet_basin_length_m",
...revetKeys("inlet"),
"outlet_type",
...revetKeys("outlet"),
],
box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")],
ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"],
ford_bridge: [
"pipe_kind",
"pipe_diameter_mm",
"pipe_count",
"ford_width_m",
"ford_height_m",
...wingKeys("in"),
...wingKeys("out"),
],
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
};
interface MergeableAttributes {
facility: string;
start_m?: number;
end_m?: number;
options?: Record<string, string | number>;
}
/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */
export function mergeFacilityOptions<T extends MergeableAttributes>(
previous: T | null,
next: T,
): T {
if (!previous || previous.facility !== next.facility) return next;
const managed = new Set(FORM_OPTION_KEYS[next.facility] ?? []);
const kept = Object.entries(previous.options ?? {}).filter(([key]) => !managed.has(key));
const options = { ...Object.fromEntries(kept), ...(next.options ?? {}) };
return { ...next, options: Object.keys(options).length ? options : undefined };
}
+4 -1
View File
@@ -30,6 +30,7 @@ import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
@@ -607,8 +608,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
pipeEditor.addAtChainage(chainageM);
},
updatePipeFacility(fromChainageM, toChainageM, attributes) {
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
const previous = facilityStore.get(fromChainageM);
facilityStore.set(fromChainageM, null);
facilityStore.set(toChainageM, attributes);
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
// attach가 새 위치로 시설 정보를 승계한다.
@@ -32,6 +32,86 @@ 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;
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음). */
warn_above?: number | null;
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 툴팁이 이 사유를 먼저 보임. */
not_in_table?: 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);
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
const tip = option.not_in_table ?? option.empty_means ?? "";
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 (tip) element.title = tip;
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 (tip) element.title = tip;
const limit = option.warn_above;
if (typeof limit === "number") {
// 기준을 넘으면 알리기만 — 막지 않음(2026-09-14 브레인 판정 ①·㉰).
const warn = (): void => {
const over = element.value.trim() !== "" && Number(element.value) > limit;
element.classList.toggle("is-outside-standard", over);
element.title = over ? (option.warn_message ?? "") : tip;
};
element.addEventListener("input", warn);
warn();
}
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;
+19 -34
View File
@@ -14,7 +14,6 @@
* ========================================================================== */
import {
defaultOptions,
isB05Option,
structureAnchorM,
type StructureInstance,
@@ -24,7 +23,7 @@ import {
} from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { field, numberInput, select } from "./B05_Profile_UI_Structures_Fields";
import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields";
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
import {
bindStructuresEvents,
@@ -316,36 +315,18 @@ export function createStructuresSection(
return;
}
optionRow.hidden = false;
const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> =
[];
visible.forEach((option) => {
const preset = values[option.key] ?? option.default ?? "";
let input: HTMLInputElement | HTMLSelectElement;
if (option.input === "select") {
// 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은
// 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1).
const choices = option.choices.map((choice) => [choice, choice] as [string, string]);
// 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다.
const mustPick = option.required === true && (option.default ?? "") === "";
// **비워 두는 것이 뜻인 칸**(`empty_means`)도 같다 — 비면 계산 쪽이 기준값으로 도는데
// 첫 보기가 골라져 저장되면 기준과 다른 값이 조용히 나간다(2026-09-13:
// `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). 기본값이 **있는** 칸은
// 그대로 첫 보기를 쓴다 — 2026-08-17 「빈 보기 두지 않음」 지시가 그 자리다.
const meansEmpty = (option.empty_means ?? "") !== "" && (option.default ?? "") === "";
const keepEmpty = mustPick || meansEmpty;
if (keepEmpty) choices.unshift(["", mustPick ? "— 선택 —" : "— 안 정함 —"]);
input = select(choices);
input.value = String(preset || (keepEmpty ? "" : (option.choices[0] ?? "")));
} else if (option.input === "number") {
input = numberInput("0.1", "0");
input.value = String(preset ?? "");
if (option.required) (input as HTMLInputElement).placeholder = "필수 입력";
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = "text";
input.value = String(preset ?? "");
}
const label = option.unit ? `${option.label} (${option.unit})` : option.label;
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다.
// ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은
// 첫 보기를 미리 안 고름(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}${option.not_in_table ? " · 표에 안 쓰임" : ""}`;
// `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;
+9 -1
View File
@@ -207,8 +207,12 @@ export interface CulvertSideSpec {
/** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */
basin_before_m?: number | null;
basin_after_m?: number | null;
/** 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3). */
/** 기슭막이 전면 기울기(1:n) 종전값 — 그림은 `revetWallSpec().lean`(표준경사 표)을 씀. */
face_slope?: number;
/** 독립 기슭막이 기울기 판정 칸(저장 원본) — 설치 측 · 성토/절토 · 전면 기울기(B08 과 같은 칸). */
face_side?: string | null;
face_role?: string | null;
face_slope_ratio?: number | string | null;
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
apron_length_m?: number;
/** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */
@@ -265,6 +269,8 @@ export interface FordSet {
min_cover_m: number;
wing_in: FordWingSpec;
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
@@ -283,6 +289,8 @@ export interface BoxSet {
span_m: number;
wing_in: FordWingSpec;
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
export interface CrossSection extends SectionStation {
+101 -12
View File
@@ -74,6 +74,26 @@ APRON_THICKNESS_M = 0.45
# 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다.
REVET_FACE_SLOPE = 0.3
# ── 관 벽 최소 높이 — ⚠ TS 짝: `B06_Section_UI_Cross_Culvert_Const.ts`
# (`REVET_FREEBOARD_M`·`REVET_HEIGHT_STEP_M`·`REVET_EMBED_DEPTH_M`·`pipeWallMinPureHeight`).
# 횡단도가 관 벽을 그리는 그 높이 — 구조물 표·구조물도 그림도 이 값을 씀(2026-09-14 브레인 판정
# 「그림 둘이 같은 벽을 다르게 그리면 결함 · 근거 있는 관경 기준으로 맞춤」). 거울 시험
# `test_b06_pipe_wall_height_mirror`.
REVET_FREEBOARD_M = 0.5 # 관 상단 ~ 벽 상단 여유고(잠정 · 토피 0.5 와 같은 값)
REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금
REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이
def pipe_wall_min_height_m(diameter_m: float) -> float:
"""관 벽 **순수 높이**(근입 포함) 최소값 — 관경 + 여유고를 0.1m 로 올린 뒤 근입을 더함.
⚠ 「최소」다 — 실제 설계값은 더 클 수 있어 이 값으로 선 수량은 미확정으로 둠(금액 밖).
"""
raw = float(diameter_m) + REVET_FREEBOARD_M
steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9)
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3)
# (2026-08-20 사용자 확정) 배관은 **수평도 가능** — 그림에서 경사를 강제로 만들지
# 않는다. config `DRAINAGE_PIPE_SLOPE_DEG`(10도)는 유효직경 수리계산 전용으로 남는다.
@@ -139,7 +159,9 @@ def _number(value: Any, fallback: float | None) -> float | None:
return fallback
def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
def _side_spec(
options: dict[str, Any], defaults: dict[str, Any], side: str, diameter_m: float = 1.0
) -> dict[str, Any]:
"""유입("inlet")·유출("outlet") 한쪽의 부속 제원.
구조가 집수정이면 기슭막이·보호공을 만들지 않는다 — 집수정 단면은 후속 작업이라
@@ -164,9 +186,10 @@ def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) ->
)
return spec
# 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음).
height = _number(
options.get(f"{side}_revet_height_m"),
_number(defaults.get(f"{side}_revet_height_m"), None),
_number(defaults.get(f"{side}_revet_height_m"), pipe_wall_min_height_m(diameter_m)),
)
length = _number(
options.get(f"{side}_revet_length_m"),
@@ -208,10 +231,11 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
values.get("pipe_diameter_mm"), _number(defaults.get("pipe_diameter_mm"), 1000.0)
)
kind = values.get("pipe_kind") or defaults.get("pipe_kind")
diameter_m = round((diameter_mm or 1000.0) / 1000.0, 3)
return {
"type": "pipe",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"diameter_m": diameter_m,
"min_cover_m": MIN_PIPE_COVER_M,
# 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다(2026-09-09). 한쪽만 기초유로
# 하는 일이 드물어 폼을 둘로 늘리지 않았다. 실무가 갈라 쓰면 그때 나눌 것.
@@ -219,8 +243,8 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
str(values.get("revet_foundation") or defaults.get("revet_foundation") or "").strip()
or None
),
"inlet": _side_spec(values, defaults, "inlet"),
"outlet": _side_spec(values, defaults, "outlet"),
"inlet": _side_spec(values, defaults, "inlet", diameter_m),
"outlet": _side_spec(values, defaults, "outlet", diameter_m),
}
@@ -249,6 +273,11 @@ def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
values.get(f"{role}_revet_after_m"), _number(values.get("after_m"), None)
),
"face_slope": REVET_FACE_SLOPE,
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본 그대로(2026-09-14 B5 · TS `revetSide`).
# 설치 측은 `side` 기본값(양쪽)을 붙이기 **전** 값이라야 B08(빈 칸 = 자동)과 같게 갈림.
"face_side": values.get("side"),
"face_role": values.get("face_role"),
"face_slope_ratio": values.get("face_slope_ratio"),
}
if height is not None and height > 0:
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
@@ -308,6 +337,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 +382,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 +409,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 +429,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 +453,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 +465,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"),
}
@@ -79,6 +79,9 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
"tiers": options.get("tiers"),
"lift_m": options.get("lift_m"),
"shift_m": options.get("shift_m"),
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
"face_role": options.get("face_role"),
"face_slope_ratio": options.get("face_slope_ratio"),
}
)
return found
+12 -3
View File
@@ -14,8 +14,10 @@
* 실행: node <번들> <입력.json> <출력.json>
* 입력 { detail: 종횡단 상세(API와 같은 꼴), context: { earthwork_conversion,
* natural_spoil_min_ground_slope, haul_equipment_limits } }
* 출력 { areas: [{ chainage_m, cut_area_m2, … }], mass_haul: {…} | null }
* 출력 { areas: [{ chainage_m, cut_area_m2, … }], mass_haul: {…} | null,
* extra_walls: [{ chainage_m, extra_walls: [선 다단 벽…] }] }
* — areas 는 **구조물 트림이 있는 측점만**. 나머지는 표준 계산값이 이미 맞다.
* — extra_walls 는 관 주인 측점마다(빈 목록 포함) · B08 이 다단 기슭막이 줄을 세움(④).
* 끝 코드: 0 성공 / 2 인자 오류
* ========================================================================== */
@@ -23,7 +25,11 @@ import { readFileSync, writeFileSync } from "node:fs";
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
import { computeHaulPlan, haulPlanPayload } from "@util/common_util_mass_haul_balance";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import {
applyStructureAreaRows,
extraWallRows,
structureAreaRows,
} from "./B06_Section_Structure_Layouts";
interface ServerCalcInput {
detail?: SectionDetailResponse;
@@ -112,4 +118,7 @@ const massHaul = result
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
: null;
writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul }));
// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움.
const extraWalls = extraWallRows(sections);
writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul, extra_walls: extraWalls }));
@@ -245,7 +245,20 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
output = {}
rows = output.get("areas")
mass_haul = output.get("mass_haul")
if not fixed and not rows and not mass_haul:
# 선 다단 벽 목록(④) — 목록째 얹음(수가 아니라 `_AREA_KEYS` 로는 못 거름). 주인 측점엔 빈 목록도.
# 관 기준벽 벽 몸 겹침(㉡) — 역할별 ㎡ 한 벌. 빈 dict 도 실어 옛 값을 지움.
extra_walls = [
(
float(row["chainage_m"]),
{
"extra_walls": row["extra_walls"],
"wall_fill_overlap": row.get("wall_fill_overlap") or {},
},
)
for row in output.get("extra_walls") or []
if isinstance(row, dict) and isinstance(row.get("extra_walls"), list)
]
if not fixed and not rows and not mass_haul and not extra_walls:
return 0
updated = 0
@@ -281,6 +294,10 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
updated = await merge_cross_section_designs(
connection, route_id=route_id, entries=area_entries, replace=False
)
if extra_walls:
await merge_cross_section_designs(
connection, route_id=route_id, entries=extra_walls, replace=False
)
if isinstance(mass_haul, dict):
await merge_longitudinal_section_data(
connection, route_id=route_id, data_patch={"mass_haul": mass_haul}
+103 -12
View File
@@ -10,12 +10,17 @@
* DOM·SVG 를 부르지 않는다. 브라우저에서도 Node 에서도 같은 결과가 나와야 한다.
* ========================================================================== */
import { computeStructureAreas } from "@util/common_util_cross_structure_areas";
import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross_structure_areas";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { computeCardCulvert, culvertLinkFor } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import {
computeCardCulvert,
culvertLinkFor,
tierSpanOf,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
ExtraWallControl,
InletStructureControl,
@@ -144,6 +149,22 @@ export const STRUCTURE_ROW_KEYS = [
"pipe_length_m",
] as const;
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
const trim = trimOfLayouts(layouts);
const designLine = layouts.design.design_line;
if (!trim || !Array.isArray(designLine) || designLine.length < 2) return null;
const ground = section.samples
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
return {
designLine: designLine as Array<{ offset_m: number; elevation_m: number }>,
ground,
trim,
};
}
/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */
function areaRowOf(
section: CrossSection,
@@ -176,17 +197,11 @@ function areaRowOf(
typeof pipeLengthM === "number" && pipeLengthM > 0
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
: null;
const trim = trimOfLayouts(layouts);
const areaInput = areaInputOf(section, layouts);
if (!areaInput) return pipeRow;
const design = layouts.design;
if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return pipeRow;
const ground = section.samples
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
const areas = computeStructureAreas({
designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>,
ground,
trim,
...areaInput,
rockBoundaryOffsetM:
typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null,
});
@@ -219,6 +234,82 @@ export function structureAreaRows(
.filter((row): row is Record<string, number> => !!row);
}
/** 선 다단 벽 한 매 — B08 이 줄을 세우는 값(파이썬 `B08_Quantity_Engine_Pipe.facility_structures`). */
export interface BuiltExtraWall {
key: string;
side: "outlet" | "basin";
form: string;
/** 순수 높이(m) — 바닥~상단(근입 0.5 포함). 지형에 맞춰 선 값. */
height_m: number;
/** 사용자가 높이·형태를 적었나 — 안 적었으면 B08 이 미확정으로 셈. */
height_set: boolean;
form_set: boolean;
before_m: number;
after_m: number;
/** 벽 몸 중 성토 폐회로 안에 든 넓이(㎡) — 구조물 면적을 안 빼서(2026-09-06 확정) 성토에도 셈.
* B08 이 「두 번 셈」 사유로 올림(2026-09-14 브레인). 면적을 못 내는 측점은 null. */
fill_overlap_m2: number | null;
}
export interface ExtraWallRow {
chainage_m: number;
extra_walls: BuiltExtraWall[];
wall_fill_overlap: Record<string, number>;
}
/**
* 관(·독립 기슭막이) 주인 측점마다 **실제로 선** 다단 벽 목록(④, 2026-09-14).
*
* 단 수·높이는 지형이 정해 요청보다 적게 설 수 있음 — 그래서 요청 칸(`extra_wall_counts`)이 아니라
* 기하가 세운 결과를 남김(관 연장 `pipe_length_m` 과 같은 길). 주인 측점에는 빈 목록도 실어 옛 값을 지움.
* `wall_fill_overlap` — 관 기준벽 역할별 벽 몸 ∩ 성토 폐회로(㎡). B08 기준벽 줄 비고.
*/
export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[] {
const rows: ExtraWallRow[] = [];
for (const section of sections) {
if (!section.culvert) continue;
const owner = pipeOwnerChainage(section, sections);
if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue;
const layouts = computeStoredLayouts(section, sections);
const culvert = layouts?.culvert;
if (!layouts || !culvert) continue;
const areaInput = areaInputOf(section, layouts);
const walls: BuiltExtraWall[] = [];
const built: Array<["outlet" | "basin", WallLayout[], WallAdjust[]]> = [
["outlet", culvert.extraWalls, culvert.revetShift.extras],
["basin", culvert.basinExtras, culvert.revetShift.basinExtras],
];
for (const [side, list, applied] of built) {
list.forEach((wall, index) => {
const key = `${side === "basin" ? "bextra" : "extra"}${index}`;
const span = tierSpanOf(section, key);
walls.push({
key,
side,
form: wall.form ?? "",
height_m: Number((wall.height + REVET_EMBED_DEPTH_M).toFixed(3)),
height_set: applied[index]?.h != null,
form_set: section.design?.revet_adjust?.[key]?.m != null,
before_m: span.beforeM,
after_m: span.afterM,
fill_overlap_m2: areaInput
? Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3))
: null,
});
});
}
// 관 기준벽(유입·유출)도 같은 겹침 — 다단에만 사유가 뜨면 「다단만 문제」로 읽힘(㉡ 2026-09-14).
const wall_fill_overlap: Record<string, number> = {};
for (const wall of culvert.walls) {
if (areaInput && (wall.role === "inlet" || wall.role === "outlet")) {
wall_fill_overlap[wall.role] = Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3));
}
}
rows.push({ chainage_m: section.chainage_m, extra_walls: walls, wall_fill_overlap });
}
return rows;
}
/** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */
export function applyStructureAreaRows(
sections: readonly CrossSection[],
@@ -49,6 +49,40 @@ 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(" · ")} · 적으면 그 값으로 그림`,
]);
}
const own = section.culvert?.hidden_pipe ? section.culvert : null;
if (own && [own.inlet, own.outlet].some((side) => !side.revet_height_m)) {
notes.push([
"⚠ 기슭막이 높이 없음 — 근입만 그림",
"독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림",
]);
}
if (section.ford_pavement && !section.ford_pavement.depth_m) {
notes.push([
"⚠ 월류 높이 없음 — 파임 안 그림",
"물넘이포장 월류 높이를 안 적어 파인 노면을 안 그림(수치를 지어내지 않음) — B05 시설 칸에 월류 높이를 적으면 그림",
]);
}
return notes;
}
/**
* 카드 제목행을 만들어 카드에 붙인다(설계 조작이 있으면 조작 바까지).
*
@@ -86,6 +120,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");
+1 -2
View File
@@ -13,7 +13,6 @@
import {
REVET_EMBED_DEPTH_M,
FILL_SLOPE_RATIO_MIN,
REVET_LEAN_RATIO,
pipeWallThicknessM,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Geom";
@@ -183,7 +182,7 @@ export function appendCulvertOverlay(
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${(
wall.height + REVET_EMBED_DEPTH_M
).toFixed(1)}m` +
`(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` +
`(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사)` +
`, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` +
(wall.floatGapM > 0.01
? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)`
@@ -16,7 +16,8 @@ export const MIN_PIPE_COVER_M = 0.5;
/** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */
export const REVET_THICKNESS_M = 0.45;
/** 기슭막이 전면 기울기(1:n) — 돌쌓기 전면 1:0.3(교본 7-3). 벽이 사면 쪽으로 기운다. */
/** (1:n) **** (·) /
* . `B06_Section_UI_Cross_Lean.leanOf`( 13-4-4 [] ). */
export const REVET_LEAN_RATIO = 0.3;
/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */
@@ -6,7 +6,7 @@
* (2026-08-22 ):
* · ** 1:1.2** .
* · **( ) **.
* · = ** +0.5m와 (1:0.3)
* · = ** +0.5m와 (1:n · )
* **( ).
* · ( 0.5m ) ** ** .
* ========================================================================== */
@@ -48,6 +48,8 @@ export interface OutletExtrasInput {
ownerForm?: string | null;
/** 1회성 등간격 배치(2026-08-22 사용자 ①) — 단별 d를 사면 구간이 같아지게 재계산. */
equalize?: boolean;
/** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */
leanFor?: (form: string, pureHeightM: number) => number;
}
export interface OutletExtrasResult {
@@ -167,9 +169,9 @@ export function inletGroundConnector(
return null;
}
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */
function jointRunOf(height: number): number {
return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height;
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이·기울기에 따라 커진다. */
function jointRunOf(height: number, lean: number): number {
return 0.25 * REVET_THICKNESS_M + (lean / 2) * height;
}
/** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */
@@ -226,16 +228,17 @@ function buildExtraWall(
material: RevetMaterial,
floatGapM: number,
form: string | null = null,
lean: number = REVET_LEAN_RATIO,
): WallLayout {
const thickness = REVET_THICKNESS_M;
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
topFront + outward * lean * (topElevation - elevation);
// 하단 = 기준선(anchor) 아래 근입 0.5m **고정** — 원지반에 묻혀도 바닥을 더
// 내리지 않는다. 벽 형상은 높이값으로만 정한다(2026-08-23 사용자 확정 —
// 종전 전면 발끝 지반 추적 수렴 삭제).
@@ -256,6 +259,7 @@ function buildExtraWall(
height,
floatGapM,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -281,7 +285,6 @@ function buildExtraWall(
export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult {
const { outward, groundAt, limitOffset } = input;
const thickness = REVET_THICKNESS_M;
const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN;
/** 다단 1회 전개 — plan: "user"(조작값) / "greedy"(등간격 1차 근사) / d 명시 배열. */
const cascadeOnce = (plan: "user" | "greedy" | number[]): OutletExtrasResult => {
const walls: WallLayout[] = [];
@@ -315,6 +318,9 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
/** ( )~ · .
* **** . */
let exposed = height - REVET_EMBED_DEPTH_M;
// 전면 기울기 — 형태·요청 높이로 표준경사(2026-09-14 B5). 자리 닫힌식의 분모도 따라감.
const lean = input.leanFor?.(form, height) ?? REVET_LEAN_RATIO;
const heightDenominator = 1 - lean / 2 / FILL_SLOPE_RATIO_MIN;
/** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */
const heightAt = (x: number): number => {
@@ -350,7 +356,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
}
if (autoOffset === null) autoOffset = bestOffset;
if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만.
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed);
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed, lean);
/** 자동 자리(선반 0·수직 0)의 벽 상단 표고 — 조작은 여기서부터 잰다. */
const autoTop = src.elevation - autoJointRun / FILL_SLOPE_RATIO_MIN;
// 관통 금지: 상단 ≤ 윗단 하단 → 그만큼은 반드시 내려간다.
@@ -378,7 +384,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const placeable = (xShift: number, vShift: number): boolean => {
const topE = autoTop - vShift;
const aX = autoOffset + outward * xShift;
const jointX = aX - outward * jointRunOf(exposed);
const jointX = aX - outward * jointRunOf(exposed, lean);
if (
Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >=
topE - 0.01
@@ -388,7 +394,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const bottom = bottomAt(aX, baseE);
const startE = bottom + REVET_EMBED_DEPTH_M;
const topFrontX = jointX + outward * REVET_THICKNESS_M;
const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE);
const startX = topFrontX + outward * lean * (topE - startE);
if (groundAt(startX) < startE + 0.05) return true;
// 매몰 — 3도 절토선(계류 쪽 내림)이 원지반과 다시 만나면 허용, 아니면 이동 금지.
return (
@@ -438,6 +444,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
material,
Math.max(0, base - groundBase),
form,
lean,
);
// 요청한 좌우 이동을 지형이 막았으면 그 양을 남긴다 — 「눌러도 안 움직인다」의 까닭을
// 툴팁으로 알리기 위함이다(2026-09-06 실측: 다섯 측점 중 넷이 1.0m 요청에 0.0m 이동).
@@ -479,8 +486,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
src = {
offset:
wall.points[2].offset +
outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation),
wall.points[2].offset + outward * wall.lean * (wall.topJoint.elevation - startElevation),
elevation: startElevation,
};
prevBottom = wall.bottomBack.elevation;
@@ -2,7 +2,7 @@
* B06_Section_UI_Cross_Culvert_Geom.ts
* (··) ** ** (`_Cross_Culvert.ts`) .
* `section.culvert` ( ).
* 1:0.3 , ,
* 1:n (n = · `leanOf`), ,
* (designTrim). = . `_Basin.ts`, · `_Extra.ts`.
* ========================================================================== */
@@ -24,6 +24,7 @@ import {
revetTargetHeight,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { leanOf } from "./B06_Section_UI_Cross_Lean";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
BasinLayout,
@@ -171,8 +172,19 @@ export function computeCulvertLayout(
const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) });
const adjInlet = adjustOf(revetShift?.inlet);
const adjOutlet = adjustOf(revetShift?.outlet);
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) =>
revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
// 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 형태 × 높이 — B08 과 한 벌, 2026-09-14 B5).
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => {
const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
const lean = leanOf({
form: wall.form,
height_m: wall.pureHeight,
section_mode: section.design?.section_mode,
side: culvert.hidden_pipe ? spec.face_side : null,
face_role: spec.face_role,
face_slope_ratio: spec.face_slope_ratio,
});
return { ...wall, lean };
};
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 조정창 선택지 가용성 — 판정은 Basin이 맡는다.
@@ -204,14 +216,21 @@ export function computeCulvertLayout(
edge: { offset_m: number },
outward: number,
wallHeight: number,
): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight) / 2 - REVET_TRAP_TOP_M);
lean: number,
): number =>
edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_TRAP_TOP_M);
if (!basinReason && designAt) {
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
// 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라
// 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard).
const placed = placeInletWall({
autoOffset: slopeZeroAnchor(inletInfo.edge, inletInfo.outward, inletWallSpec.height),
autoOffset: slopeZeroAnchor(
inletInfo.edge,
inletInfo.outward,
inletWallSpec.height,
inletWallSpec.lean,
),
outward: inletInfo.outward,
height: inletWallSpec.height,
baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height,
@@ -238,6 +257,7 @@ export function computeCulvertLayout(
outletInfo.limit,
),
limitOffset: outletInfo.limit,
lean: outletWallSpec.lean,
},
});
appliedAdjust.inlet.x = placed.x;
@@ -308,6 +328,7 @@ export function computeCulvertLayout(
vertical: WallVertical | null = null,
material: RevetMaterial = "dry",
formLabel: string | null = null,
lean: number = REVET_LEAN_RATIO,
): WallLayout | null => {
// spec의 "집수정"은 ruleReason에 이미 반영 — 여기서 되살리면 revet 선택이 깨진다.
const reason: BasinLayout["reason"] | null = forceBasinReason;
@@ -340,13 +361,13 @@ export function computeCulvertLayout(
}
return null;
}
// 형상: 배면 수직 + 계류측 1:0.3 평행사변형 띠. 높이는 vertical(계산용)이 들고 온다.
// 형상: 배면 수직 + 계류측 1:n 평행사변형 띠(n = 표준경사). 높이는 vertical(계산용)이 들고 온다.
const height =
vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
if (!(height > 0.05)) return null;
const floatGapM = vertical?.floatGapM ?? 0;
// 자리 기준 = **하단선 중점**(2026-08-21) — anchor.elevation은 그 자리 관 invert.
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
@@ -357,7 +378,7 @@ export function computeCulvertLayout(
// 원지반에 묻혀도 바닥을 더 내리지 않는다. 벽 형상은 높이값으로만 정한다
// (2026-08-23 사용자 확정 — 종전 전면 발끝 지반 추적 수렴 삭제).
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
topFront + outward * lean * (topElevation - elevation);
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
@@ -376,6 +397,7 @@ export function computeCulvertLayout(
height,
floatGapM,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -411,6 +433,7 @@ export function computeCulvertLayout(
wallVertical.inlet,
inletWallSpec.material,
inletWallSpec.form,
inletWallSpec.lean,
);
// 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21).
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
@@ -421,6 +444,7 @@ export function computeCulvertLayout(
outletInfo.edge,
outletInfo.outward,
outletWallSpec.height,
outletWallSpec.lean,
);
const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height;
const outletLineInvertAt = (offset: number): number =>
@@ -461,6 +485,7 @@ export function computeCulvertLayout(
wallVertical.outlet,
outletWallSpec.material,
outletWallSpec.form,
outletWallSpec.lean,
);
// ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다.
const pipeStart = basinPipeEnd ?? inlet;
@@ -471,8 +496,7 @@ export function computeCulvertLayout(
const reference = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
return {
offset:
wall.points[2].offset +
wall.outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - reference),
wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - reference),
elevation: reference,
};
};
@@ -527,6 +551,7 @@ export function computeCulvertLayout(
},
outletWallSpec.material,
outletWallSpec.form,
outletWallSpec.lean,
);
// 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①).
const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor;
@@ -599,9 +624,18 @@ export function computeCulvertLayout(
)
: null;
// 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 형태·높이마다 표준경사(B08 은 다단을 안 셈).
const extraLean = (form: string, pureHeightM: number): number =>
leanOf({
form,
height_m: pureHeightM,
section_mode: section.design?.section_mode,
side: culvert.hidden_pipe ? culvert.inlet.face_side : null,
});
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
const extras = buildExtrasAt(outletWall ? pipeCorners.outlet.bottom : null, {
const outletStart = outletWall ? pipeCorners.outlet.bottom : null;
const outletInput = {
startBottomElevation: outletWall?.bottomBack.elevation ?? 0,
outward: outletInfo.outward,
groundAt,
@@ -609,23 +643,47 @@ export function computeCulvertLayout(
adjusts: (revetShift?.extras ?? []).map(adjustOf),
ownerForm: outletWallSpec.form,
equalize: equalizeExtras === true,
});
leanFor: extraLean,
};
const extras = buildExtrasAt(outletStart, outletInput);
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
const basinExtras = buildExtrasAt(
culvert.hidden_pipe ? (inletWall ? pipeCorners.inlet.bottom : null) : basinFillStart,
{
startBottomElevation: culvert.hidden_pipe
? (inletWall?.bottomBack.elevation ?? 0)
: basinFillBottom,
outward: inletInfo.outward,
groundAt,
limitOffset: inletInfo.limit,
adjusts: (revetShift?.basinExtras ?? []).map(adjustOf),
ownerForm: inletWallSpec.form,
const basinStart = culvert.hidden_pipe
? inletWall
? pipeCorners.inlet.bottom
: null
: basinFillStart;
const basinInput = {
startBottomElevation: culvert.hidden_pipe
? (inletWall?.bottomBack.elevation ?? 0)
: basinFillBottom,
outward: inletInfo.outward,
groundAt,
limitOffset: inletInfo.limit,
adjusts: (revetShift?.basinExtras ?? []).map(adjustOf),
ownerForm: inletWallSpec.form,
equalize: false,
leanFor: extraLean,
};
const basinExtras = buildExtrasAt(basinStart, basinInput);
/** ** ( ) ** (2026-09-14
* · ). .
* B08 (`facility_structures`) . */
const confirmedOnly = (
built: OutletExtrasResult,
start: OffsetPoint | null,
input: typeof outletInput,
): OutletExtrasResult => {
const firstUnset = built.appliedAdjusts.findIndex((applied) => applied.h == null);
if (firstUnset < 0) return built;
return buildExtrasAt(start, {
...input,
adjusts: input.adjusts.slice(0, firstUnset),
equalize: false,
},
);
});
};
const extrasForArea = confirmedOnly(extras, outletStart, outletInput);
const basinExtrasForArea = confirmedOnly(basinExtras, basinStart, basinInput);
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
@@ -665,8 +723,8 @@ export function computeCulvertLayout(
trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] };
}
};
extendTrimSlope(drawnFillPoints(extras), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtras), inletInfo.outward);
extendTrimSlope(drawnFillPoints(extrasForArea), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtrasForArea), inletInfo.outward);
const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null;
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
@@ -23,9 +23,10 @@ import type {
} from "./B06_Section_UI_Cross_Culvert_Types";
import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */
export function fillWallBaseWidth(height: number): number {
return REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height;
/** (m) . ( ) .
* `lean` = ( · `revetWallSpec().lean`). */
export function fillWallBaseWidth(height: number, lean: number = REVET_LEAN_RATIO): number {
return REVET_THICKNESS_M * 1.5 + lean * height;
}
/** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */
@@ -252,6 +253,7 @@ export function minShoulderWallOffset(
baseAt: (offset: number) => number,
toeOffset: number,
limitOffset: number,
lean: number = REVET_LEAN_RATIO,
): number | null {
const span = (limitOffset - edge.offset_m) * outward;
if (!(span > 0)) return null;
@@ -260,7 +262,7 @@ export function minShoulderWallOffset(
const offset = edge.offset_m + outward * ((span * i) / steps);
const rise = edge.elevation_m - (baseAt(offset) + height);
if (rise < FILL_MIN_RISE_M) continue;
const joint = offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
const joint = offset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
if (((joint - edge.offset_m) * outward) / rise < FILL_SLOPE_RATIO_MIN) continue;
if ((offset - toeOffset) * outward > 1e-6) {
const toeRise = edge.elevation_m - (baseAt(toeOffset) + height);
@@ -296,13 +298,14 @@ export function solveWallVertical(
groundBase: number,
minHeight: number,
limitHeight: number,
lean: number = REVET_LEAN_RATIO,
): WallVertical {
const gridUp = (value: number): number =>
Math.ceil(value / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M;
let height = minHeight;
let desiredTop = groundBase + minHeight;
for (let i = 0; i < 4; i += 1) {
const joint = anchorOffset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
const joint = anchorOffset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
const run = Math.max(0, (joint - edge.offset_m) * outward);
desiredTop = edge.elevation_m - run / FILL_SLOPE_RATIO_MIN;
const next = Math.min(Math.max(gridUp(desiredTop - groundBase), minHeight), limitHeight);
@@ -438,6 +441,8 @@ export function outletReceivable(input: {
toeOffset: number;
limitOffset: number;
groundAt: (offset: number) => number;
/** 유출 벽 전면 기울기(표준경사). */
lean?: number;
}): boolean {
const invertAt = (offset: number): number =>
Math.min(input.groundAt(offset), input.inletElevation);
@@ -449,6 +454,7 @@ export function outletReceivable(input: {
invertAt,
input.toeOffset,
input.limitOffset,
input.lean,
) ?? input.toeOffset;
const invert = invertAt(auto);
if (invert >= input.groundAt(auto) - 0.01) return true;
@@ -486,6 +492,7 @@ export function placeInletWall(input: {
height: number;
toeOffset: number;
limitOffset: number;
lean?: number;
};
/** 매몰-무교차 자리 금지(유출 벽 규칙) — 독립 기슭막이는 유입도 같은 규칙을 탄다. */
requireCrossing?: boolean;
@@ -80,6 +80,8 @@ export interface WallLayout {
shiftFloorM?: number;
/** 재질(메/찰/콘크리트) — 높이 한계를 정한다(2026-08-22 사용자). */
material: RevetMaterial;
/** 전면 기울기 1:n 의 n — 품셈 13-4-4 [주]⑪ 표준경사(`wallLeanRatio`, B08 과 한 벌 · 2026-09-14). */
lean: number;
outward: number;
/** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */
points: OffsetPoint[];
@@ -27,6 +27,8 @@ export interface FordPavementSpec {
depth_m: number | null;
/** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */
slope_pct: number | null;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
interface Edge {
+18
View File
@@ -0,0 +1,18 @@
/* =============================================================================
* B06_Section_UI_Cross_Lean.ts
* 13-4-4 [] (`resources/data_masonry/masonry_slope_*`)
* (2026-09-14 B5 · B08 ). `common_util_masonry_slope.ts`( ).
*
* (JSON) Node
* . ** **,
* ( ··C군 ).
* import `test_b06_wall_lean_table` .
* ========================================================================== */
import { type WallLeanInput, wallLeanRatio } from "../common_util/common_util_masonry_slope";
import masonrySlope from "../resources/data_masonry/masonry_slope_2026-01-01.json";
/** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */
export function leanOf(input: WallLeanInput): number {
return wallLeanRatio(masonrySlope, input);
}
+30 -3
View File
@@ -23,10 +23,10 @@ import {
PIPE_CONNECT_GRADE,
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Const";
import { leanOf } from "./B06_Section_UI_Cross_Lean";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
import { buildExtrasAt, slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
@@ -61,8 +61,17 @@ export interface RevetmentSpec {
/** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */
lift_m?: number | null;
shift_m?: number | null;
/** 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 성토/절토 · 전면 기울기(2026-09-14 B5). */
face_role?: string | null;
face_slope_ratio?: number | string | null;
}
/** 표준경사 표를 읽는 벽 종류 → 형태. B08 이 돌쌓기 식으로 세는 것만(큰돌쌓기·옹벽은 종전 0.3). */
const TABLE_FORM_BY_TYPE: Record<string, string> = {
masonry_wet: "돌쌓기(찰)",
masonry_dry: "돌쌓기(메)",
};
/** 조정창 조작값 중 이 벽이 쓰는 축 — 배관 벽과 같은 형태(x 좌우·d 사면·h 높이). */
export interface RevetmentAdjust {
x?: number;
@@ -187,9 +196,19 @@ export function computeRevetmentLayout(
/** 기준선(근입 위)~상단 — 도형 계산은 종전대로 이 값으로 한다. */
const height = pureHeight - REVET_EMBED_DEPTH_M;
// 전면 기울기 — B08 이 그 벽을 셀 때와 같은 칸(종류·요청 높이·단면유형·설치 측·사용자 칸)으로
// 표준경사를 고름(2026-09-14 B5). 높이는 형태 한계로 자르기 **전** 값 — B08 은 저장 높이를 씀.
const lean = leanOf({
form: TABLE_FORM_BY_TYPE[spec.type_id] ?? (spec.type_id === "revetment" ? spec.form : null),
height_m: requestedHeight,
section_mode: design.section_mode,
side: spec.side,
face_role: spec.face_role,
face_slope_ratio: spec.face_slope_ratio,
});
const roadSlope = roadSlopePerOutward(design, side, outward);
const autoOffset =
edge.offset_m + outward * (fillWallBaseWidth(height) / 2 - REVET_THICKNESS_M / 2);
edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_THICKNESS_M / 2);
// 자동 자리 = 성토사면(1:1.2) 위 기본 지점 — 배관 유출 벽과 같은 기준값.
const autoRun = PIPE_WALL_DEFAULT_RUN_M;
/**
@@ -244,6 +263,7 @@ export function computeRevetmentLayout(
form: spec.form,
lengthM,
floatGapM,
lean,
});
// 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07).
// 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고,
@@ -267,6 +287,13 @@ export function computeRevetmentLayout(
// 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우).
ownerForm: spec.form,
equalize: false,
leanFor: (form, pureHeightM) =>
leanOf({
form,
height_m: pureHeightM,
section_mode: design.section_mode,
side: spec.side,
}),
})
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
@@ -375,7 +402,7 @@ export function appendRevetmentOverlay(
const keyId = index === 0 ? "own" : `own-extra${index - 1}`;
const tooltip =
`독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` +
`(상단 = 성토선 접점, 전면 1:${REVET_LEAN_RATIO}, 높이 한계 ${revetHeightLimit(
`(상단 = 성토선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사), 높이 한계 ${revetHeightLimit(
wall.form,
).toFixed(1)}m 7-3)` +
(wall.floatGapM > 0.01
+6 -2
View File
@@ -55,6 +55,8 @@ export interface RevetWallGeometryInput {
thickness?: number;
floatGapM?: number;
extraIndex?: number;
/** 전면 기울기 1:n 의 n — 표준경사(`leanOf`). 없으면 종전 0.3. */
lean?: number;
}
/**
@@ -65,14 +67,15 @@ export interface RevetWallGeometryInput {
export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayout {
const { anchor, outward, height, material, role } = input;
const thickness = input.thickness ?? REVET_THICKNESS_M;
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const lean = input.lean ?? REVET_LEAN_RATIO;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
topFront + outward * lean * (topElevation - elevation);
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
@@ -90,6 +93,7 @@ export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayou
height,
floatGapM: input.floatGapM ?? 0,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
+4
View File
@@ -166,6 +166,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
// 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음
// (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」).
if (adjust?.h == null) return null;
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
.pureHeight;
},
@@ -180,6 +183,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④)
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form;
},
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
@@ -36,6 +36,38 @@ function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record<string,
return options;
}
/** 옵션 키 → 「기본값(미확정)」 목록의 이름(파이썬 `_defaulted` 이름표와 같은 글). */
const DEFAULTED_LABELS: Record<string, string> = {
pipe_kind: "관종",
pipe_diameter_mm: "관경",
ford_width_m: "월류 폭",
pipe_count: "배관 수량",
body_width_m: "본체 폭",
body_height_m: "본체 높이",
};
for (const [side, name] of [
["in", "유입"],
["out", "유출"],
]) {
DEFAULTED_LABELS[`wing_${side}`] = `${name} 날개벽`;
DEFAULTED_LABELS[`wing_${side}_height_m`] = `${name} 날개벽 높이`;
DEFAULTED_LABELS[`wing_${side}_length_m`] = `${name} 날개벽 길이`;
DEFAULTED_LABELS[`wing_${side}_angle_deg`] = `${name} 날개벽 각도`;
}
/** ()
* (2026-09-14 ). ( ). */
function dropDefaulted(spec: { defaulted?: string[] } | undefined, options: object): void {
if (!spec?.defaulted) return;
const labels = Object.keys(options).flatMap((key) => DEFAULTED_LABELS[key] ?? []);
spec.defaulted = spec.defaulted.filter(
(item) =>
!labels.some(
(label) => item.startsWith(`${label} `) && !item.slice(label.length + 1).includes(" "),
),
);
}
/** 좌측 폼이 낸 옵션에서 그 측 날개벽 조작값을 읽는다(없는 항목은 빼고 돌려준다). */
function wingPatchFrom(patch: Record<string, number | string>, prefix: string): WingPatch {
const num = (key: string): number | undefined => {
@@ -157,6 +189,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
// 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리.
if (patch.ford_width_m) spec.span_m = patch.ford_width_m;
}
dropDefaulted(spec, patch);
deps.queuePipeOptions(chainageM, patch);
deps.refreshCard(chainageM);
},
@@ -171,6 +204,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg;
wing.slab_extend_m = wingSlabExtendM(wing.installed, wing.length_m, wing.angle_deg);
}
dropDefaulted(spec, wingOptions(role, patch));
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
deps.refreshCard(chainageM);
},
@@ -349,6 +383,7 @@ export function createBoxControls(deps: FordControlDeps): {
}
if (patch.body_height_m) spec.inner_height_m = patch.body_height_m;
}
dropDefaulted(spec, patch);
deps.queuePipeOptions(chainageM, patch as Record<string, number | string>);
deps.refreshCard(chainageM);
},
@@ -364,6 +399,7 @@ export function createBoxControls(deps: FordControlDeps): {
? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0)
: 0;
}
dropDefaulted(spec, wingOptions(role, patch));
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
deps.refreshCard(chainageM);
},
@@ -31,6 +31,7 @@ import {
writeStructurePick,
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { showToast } from "@ui/ui_template_elements";
import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend";
import {
adjustDockRoot,
refreshAdjustSlots,
@@ -125,105 +126,153 @@ function coversChainage(structure: StructureInstance, chainageM: number): boolea
return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M;
}
type Merged = Record<string, string | number>;
/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */
function registryDefaults(types: StructureType[], typeId: string): Record<string, unknown> {
const options = types.find((type) => type.type_id === typeId)?.options ?? [];
return Object.fromEntries(options.map((option) => [option.key, option.default]));
}
/**
* ** **(`section.ford`)
* ,
* (2026-08-30 사용자: 수량() 1).
* ** , .**
* ·[]
* (2026-09-14 ). .
*/
function keepSpec(
merged: Merged,
stored: Merged | undefined,
key: string,
spec: string | number | null | undefined,
fromDefault: unknown,
): void {
if (spec === null || spec === undefined) return;
if (stored?.[key] !== undefined || String(spec) !== String(fromDefault ?? "")) merged[key] = spec;
}
type WingSpec = {
installed: boolean;
height_m: number | null;
length_m: number | null;
angle_deg: number | null;
};
function keepWings(
merged: Merged,
stored: Merged | undefined,
defaults: Record<string, unknown>,
wings: ReadonlyArray<readonly [WingSpec, "wing_in" | "wing_out"]>,
): void {
for (const [wing, prefix] of wings) {
// 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45.
const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음";
keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install);
keepSpec(
merged,
stored,
`${prefix}_height_m`,
wing.height_m,
defaults[`${prefix}_height_m`] ?? 0,
);
keepSpec(
merged,
stored,
`${prefix}_length_m`,
wing.length_m,
defaults[`${prefix}_length_m`] ?? 0,
);
keepSpec(
merged,
stored,
`${prefix}_angle_deg`,
wing.angle_deg,
defaults[`${prefix}_angle_deg`] ?? 45,
);
}
}
/**
* ** **(`section.ford`) · .
* (2026-08-30 사용자: 수량()
* 1) 1 ** ** ( ).
*/
function withFordSpec(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
ford: NonNullable<SectionDetailResponse["cross_sections"][number]["ford"]>,
): Record<string, string | number> {
const merged: Record<string, string | number> = { ...(options ?? {}) };
if (ford.pipe_kind) merged.pipe_kind = ford.pipe_kind;
merged.pipe_diameter_mm = Math.round(ford.diameter_m * 1000);
merged.pipe_count = ford.pipe_count;
merged.ford_width_m = ford.span_m;
for (const [wing, prefix] of [
defaults: Record<string, unknown>,
): Merged {
const merged: Merged = { ...(options ?? {}) };
keepSpec(merged, options, "pipe_kind", ford.pipe_kind, defaults.pipe_kind);
const diameter = Math.round(ford.diameter_m * 1000);
keepSpec(merged, options, "pipe_diameter_mm", diameter, defaults.pipe_diameter_mm ?? 1000);
const count = Number(defaults.pipe_count ?? 0);
keepSpec(
merged,
options,
"pipe_count",
ford.pipe_count,
count > 0 ? Math.max(Math.trunc(count), 1) : 1,
);
keepSpec(
merged,
options,
"ford_width_m",
ford.span_m,
defaults.ford_width_m ?? FORD_BRIDGE_DEFAULT_WIDTH_M,
);
keepWings(merged, options, defaults, [
[ford.wing_in, "wing_in"],
[ford.wing_out, "wing_out"],
] as const) {
merged[prefix] = wing.installed ? "있음" : "없음";
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
}
]);
return merged;
}
/**
* BOX암거 ** **(`section.box`)
* (2026-08-30 ). · , ·
* .
*/
/** BOX암거 폼 옵션 — 세월교와 같은 규칙(조작·저장분만). 구체 길이·표고는 조정 채널이라 여기 안 옴. */
function withBoxSpec(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
box: NonNullable<SectionDetailResponse["cross_sections"][number]["box"]>,
): Record<string, string | number> {
const merged: Record<string, string | number> = { ...(options ?? {}) };
merged.body_width_m = box.inner_width_m;
merged.body_height_m = box.inner_height_m;
for (const [wing, prefix] of [
defaults: Record<string, unknown>,
): Merged {
const merged: Merged = { ...(options ?? {}) };
keepSpec(merged, options, "body_width_m", box.inner_width_m, defaults.body_width_m ?? 2);
keepSpec(merged, options, "body_height_m", box.inner_height_m, defaults.body_height_m ?? 2);
keepWings(merged, options, defaults, [
[box.wing_in, "wing_in"],
[box.wing_out, "wing_out"],
] as const) {
merged[prefix] = wing.installed ? "있음" : "없음";
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
}
]);
return merged;
}
/**
* ** **
* (2026-08-29 ). ,
* ( ·) .
* ** ** (2026-08-29 ).
* (··· ·/· ) ** **
* (2026-09-14 ). .
*/
function withSpecDefaults(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
detail: SectionDetailResponse | null,
chainageM: number,
types: StructureType[],
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null,
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null,
): Record<string, string | number> | undefined {
): Merged | undefined {
const owner = detail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
);
// 세월교는 폼과 조정창이 **같은 캐시**(`section.ford`)를 본다 — 어느 쪽에서 만졌든
// 그 스펙이 지금 그려진 값이라 저장된 옵션보다 앞선다(2026-08-30 사용자: 프론트는
// 구조물 배치 상세 UI, 로직·값은 조정창 것).
if (owner?.ford) return withFordSpec(options, owner.ford);
if (owner?.box) return withBoxSpec(options, owner.box);
// 세월교·BOX는 폼과 조정창이 **같은 캐시**를 봄 — 조작한 값은 저장된 옵션보다 앞섬(2026-08-30).
if (owner?.ford) return withFordSpec(options, owner.ford, registryDefaults(types, "ford_bridge"));
if (owner?.box) return withBoxSpec(options, owner.box, registryDefaults(types, "box_culvert"));
const culvert = owner?.culvert;
if (!culvert) return options;
const merged: Record<string, string | number> = { ...(options ?? {}) };
const put = (key: string, value: string | number | null | undefined): void => {
if (merged[key] === undefined && value !== null && value !== undefined) merged[key] = value;
};
put("pipe_kind", culvert.pipe_kind);
put("pipe_diameter_mm", Math.round(culvert.diameter_m * 1000));
for (const [side, prefix] of [
[culvert.inlet, "inlet"],
[culvert.outlet, "outlet"],
] as const) {
put(`${prefix}_type`, side.structure);
// 형태·높이는 **지금 그려진 값**이 이긴다 — 조정값(조작)이 저장된 옵션보다
// 앞서기 때문이다. 저장값을 그대로 두면 폼과 그림이 갈린다(2026-08-30 사용자:
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다).
const merged: Merged = { ...(options ?? {}) };
for (const prefix of ["inlet", "outlet"] as const) {
// 형태·높이는 **조작한 값**이 이긴다 — 조정값이 저장된 옵션보다 앞서기 때문(2026-08-30 사용자:
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). 조작이 없으면 부르는 쪽이 null 을 줌.
const drawnForm = wallForm?.(chainageM, prefix);
if (drawnForm) merged[`${prefix}_revet_form`] = drawnForm;
else put(`${prefix}_revet_form`, side.revet_form);
const drawnHeight = wallHeight?.(chainageM, prefix);
if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1));
else put(`${prefix}_revet_height_m`, side.revet_height_m);
put(`${prefix}_revet_length_m`, side.revet_length_m);
put(`${prefix}_revet_before_m`, side.revet_before_m);
put(`${prefix}_revet_after_m`, side.revet_after_m);
}
put("inlet_basin_length_m", culvert.inlet.basin_length_m);
put("inlet_basin_before_m", culvert.inlet.basin_before_m);
put("inlet_basin_after_m", culvert.inlet.basin_after_m);
return merged;
}
@@ -374,6 +423,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
pipe.options,
detail,
pipe.chainage_m,
types,
deps.wallHeight,
deps.wallForm,
),
@@ -418,6 +468,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
hit.options,
deps.detail?.() ?? null,
hit.chainage_m,
markTypes,
deps.wallHeight,
deps.wallForm,
);
@@ -1,290 +0,0 @@
"""표준도 **위쪽 그림** — 돌쌓기 단면(2단계, 2026-09-09).
실무 원본 탭이 치수조서 + 아래 수량산출서인데, 아래 표는 1단계에서 섰고 여기가 위다.
**기울기를 값으로 받는다 0.3 박지 않는다.**
판정은 **B08 `face_slope_ratio()` ** 그대로 부른다(품셈 13-4-4 [] 표준경사
직고·메찰·성절토). 사용자가 정했으면 값이 이긴다(확정 ). 그림은 **수량이 바로
**으로 기울고, 근거 문구도 같이 받아 그림에 적는다 판정을 벌로 짜면 그림과
수량이 갈린다.
**수량이 쓰는 상수로 그린다** `B08_Quantity_Engine_UnitQuantity.STONE_MASONRY`
직접 읽는다. 치수를 여기서 다시 적으면 **그림과 표가 갈린다**(CLAUDE.md 5).
상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H 1.0)
터파기 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림)
터파기 치수는 **`common_util_excavation` ** 읽는다 횡단도가 쓰는 상수다.
여기서 다시 적으면 도면이 다른 터파기를 그린다.
**뒷길이가 두께에 들어간다**(확정 2 , 실무 구조물도 ). 뒷길이가 다르면 벽이
두꺼워지고 그림도 그만큼 넓어진다 예전 (0.45+0.10H / 0.45+0.40H) 뒷길이를
아예 봐서 35 45 같은 그림이 나왔다.
뒷길이를 정한 장은 두께를 낸다 그때는 **그리지 않는다**(0 으로 때우면
거짓 그림이 된다).
**치수가 없는 것은 그리되 치수를 적지 않는다** 막자갈(뒷채움) 우리 식이 입적에서
몸통·고임돌을 이라 ·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다.
없는 치수를 그림에 적으면 **표와 다른 번째 정본** 생긴다.
"""
from __future__ import annotations
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_line_entity,
_text_entity,
polyline_entity,
)
# 기울기 판정은 구조물도 엔진이 가진 한 벌 — 2026-09-13 B08 로 이관(PLAN 3장). 받아 쓰기만 함.
from B08_Quantity.B08_Quantity_Engine_StructureSheet import ( # noqa: F401
SLOPE_TYPE_IDS,
slope_of,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
STONE_MASONRY,
weep_hole_spec,
)
from common_util.common_util_excavation import (
WALL_BLINDING_DEPTH_M,
WALL_BLINDING_WIDTH_M,
WALL_FOUNDATION_DEPTH_M,
WALL_FOUNDATION_WIDTH_M,
WALL_TRENCH_CLEARANCE_M,
)
#: 그림 축척 — 1/25(1m = 40㎜). 표(줄 높이 9㎜) 위에 얹어도 한 면에 드는 크기다.
SCALE_MM_PER_M = 40.0
#: 이 그림을 그리는 종류. 옹벽·집수정처럼 단면이 다른 것은 아직 그리지 않는다.
#: ⚠ **큰돌쌓기(`boulder_masonry`)를 뺐다**(2026-09-09). 여기 있었지만 그림이 두께를
#: **뒷길이**에서 내는데 큰돌쌓기는 규격 축이 **직경**(`stone_cm`)이라 **영영 못 그렸다** —
#: 목록에만 있고 결과는 늘 빈 그림이었다. 품셈 **13-6(직경) ↔ 13-4(뒷길이)** 로 축이
#: 갈리는 자리이고 수량 쪽에서 이미 갈라 둔 그것이다(`EXPANDERS` 주석).
#: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄.
FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"})
#: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다
#: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
_NO_FIGURE_REASONS: dict[str, str] = {
"boulder_masonry": (
"그림 없음 — 큰돌쌓기는 규격이 **직경**이라 두께를 낼 식이 아직 없습니다"
"(뒷길이로 내는 돌쌓기 식과 축이 다름 · 사용자 확정 대기)"
),
"retaining_wall": "그림 없음 — 옹벽은 단면이 달라 아직 그리지 않습니다(표준도 3단계)",
}
_NO_FIGURE_DEFAULT = "그림 없음 — 이 형식은 아직 단면을 그리지 않습니다"
def figure_reason(sheet: dict[str, Any]) -> str | None:
"""그림이 **안 서는 까닭** 한 줄. 서는 장이면 `None`.
무엇을 받아야 서는지까지 적는다 뒷길이만 고르면 서는 장과, 자체가 없는 장은
사용자가 일이 다르다.
"""
type_id = str(sheet.get("type_id") or "")
if type_id not in FIGURE_TYPE_IDS:
return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT)
if float(sheet.get("height_m") or 0.0) <= 0:
return "그림 없음 — 높이가 없습니다(제원에서 높이를 넣으면 그림이 섭니다)"
if back_length_cm(sheet) is None:
return "그림 없음 — **뒷길이를 고르면 그림이 섭니다**(두께가 뒷길이에서 나옵니다)"
return None
_LABEL_FONT = 4.0
_DIM_FONT = 3.4
def wall_thickness(height_m: float, back_cm: float) -> tuple[float, float]:
"""(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다."""
top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"]
bottom_t = top_t + STONE_MASONRY["thickness_slope_per_m"] * max(
height_m - STONE_MASONRY["thickness_height_base_m"], 0.0
)
return top_t, bottom_t
def back_length_cm(sheet: dict[str, Any]) -> float | None:
"""장의 뒷길이(㎝). 안 정했으면 `None` — 그때는 두께를 못 내므로 그리지 않는다."""
options = sheet.get("options") or {}
raw = options.get("back_len_cm") or options.get("stone_back_length_cm")
try:
return float(raw) if raw not in (None, "") else None
except (TypeError, ValueError):
return None
def section_points(
height_m: float, slope_ratio: float, back_cm: float
) -> list[tuple[float, float]]:
"""벽 단면 네 점(m 단위, 밑면 앞끝이 원점). 앞면이 뒤로 `n·H` 기운다."""
top_t, bottom_t = wall_thickness(height_m, back_cm)
lean = slope_ratio * height_m
return [
(0.0, 0.0),
(lean, height_m),
(lean + top_t, height_m),
(bottom_t, 0.0),
(0.0, 0.0),
]
def build_figure(
drawing_id: str,
sheet: dict[str, Any],
layer_id: str,
origin: tuple[float, float],
line_color: str,
label_color: str,
guide_color: str,
) -> tuple[list[dict[str, Any]], float]:
"""장 하나의 그림. `(엔티티, 그림이 차지한 높이 ㎜)` — 못 그리면 `([], 0)`."""
if str(sheet.get("type_id") or "") not in FIGURE_TYPE_IDS:
return [], 0.0
height_m = float(sheet.get("height_m") or 0.0)
if height_m <= 0:
return [], 0.0
back_cm = back_length_cm(sheet)
if back_cm is None:
# 뒷길이가 없으면 두께를 못 낸다 — 0 으로 때우지 않고 그리지 않는다.
return [], 0.0
slope, slope_note = slope_of(sheet)
scale = SCALE_MM_PER_M
ox, oy = origin
def mm(point: tuple[float, float]) -> tuple[float, float]:
return (ox + point[0] * scale, oy + point[1] * scale)
points = section_points(height_m, slope, back_cm)
top_t, bottom_t = wall_thickness(height_m, back_cm)
average_t = (top_t + bottom_t) / 2.0
dig_width = average_t + WALL_TRENCH_CLEARANCE_M
# 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목).
foundation = str((sheet.get("options") or {}).get("foundation") or "")
if foundation == "기초유":
base_w, base_d = WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M
elif foundation == "기초버림":
base_w, base_d = WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M
else:
base_w = base_d = 0.0 # 안 정한 장은 기초를 안 그린다 — 지어내지 않는다.
entities: list[dict[str, Any]] = []
wall = polyline_entity(
f"{drawing_id}:fig:wall", [mm(p) for p in points], layer_id, line_color, width=2
)
if wall is not None:
entities.append(wall)
# 터파기 — 우리 수량식(평균두께+0.2) × 높이 를 **그대로** 그린다. 점선.
dig = polyline_entity(
f"{drawing_id}:fig:dig",
[mm(p) for p in ((0.0, 0.0), (0.0, height_m), (dig_width, height_m), (dig_width, 0.0))],
layer_id,
guide_color,
dash=[4, 3],
)
if dig is not None:
entities.append(dig)
# 기초 — 벽 밑에 놓이는 칸. 「안 정함」이면 안 그린다.
if base_d > 0:
base = polyline_entity(
f"{drawing_id}:fig:base",
[mm(p) for p in ((0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0))],
layer_id,
guide_color,
)
if base is not None:
entities.append(base)
# 물구멍 — 벽을 가로지르는 짧은 선 하나. 개소 간격은 글자로 적는다(면적당이라 그림에 못 씀).
# ⚠ 값은 **그 장의 제원**에서 읽는다 — 수량표와 같은 함수를 쓴다(2026-09-13). 기본 상수를
# 적으면 사용자가 지름·면적을 고쳐도 그림 주석만 옛 값으로 남아 표와 갈린다.
weep_area_m2, weep_diameter_mm, _weep_basis = weep_hole_spec(sheet.get("options") or {})
weep_y = height_m * 0.5
entities.append(
_line_entity(
f"{drawing_id}:fig:weep",
mm((slope * weep_y, weep_y)),
mm((slope * weep_y + top_t, weep_y)),
layer_id,
guide_color,
)
)
labels: list[tuple[str, tuple[float, float], str]] = [
(f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"),
(f"상부 {top_t:.2f} m", (slope * height_m + top_t / 2.0, height_m + 0.14), "center"),
(f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30), "center"),
(f"1 : {slope:g}", (slope * height_m / 2.0 - 0.30, height_m * 0.72), "right"),
(
f"터파기 폭 {dig_width:.2f} m (평균두께 {average_t:.2f} + 0.20)",
(dig_width + 0.16, height_m * 0.92),
"left",
),
(
f"기초 {base_w:g} × {base_d:g} m ({foundation})"
if base_d > 0
else "기초 — 안 정함(기초유/기초버림)",
(dig_width + 0.16, -base_d / 2.0 if base_d else 0.0),
"left",
),
(
f"물구멍 Ø{weep_diameter_mm}{weep_area_m2:g}㎡당 1개소",
(slope * weep_y + top_t + 0.16, weep_y),
"left",
),
("막자갈(뒷채움) — 수량은 아래 표", (dig_width + 0.16, height_m * 0.55), "left"),
]
labels.append((f"뒷길이 ℓ₃ = {int(back_cm)}", (dig_width + 0.16, height_m * 0.72), "left"))
for index, (text, point, align) in enumerate(labels):
x, y = mm(point)
entities.append(
_text_entity(
f"{drawing_id}:fig:label:{index}",
text,
x,
y,
layer_id,
_DIM_FONT,
label_color,
align,
)
)
# 기울기 근거는 **그림 밑에** 한 줄 — 「사용자 지정」인지 「품셈 표준경사」인지 보여야 한다.
note_x, note_y = mm((0.0, -0.62))
entities.append(
_text_entity(
f"{drawing_id}:fig:slopenote",
f"전면 기울기 — {slope_note}",
note_x,
note_y,
layer_id,
_DIM_FONT,
label_color,
"left",
)
)
title_x, title_y = mm((0.0, height_m + 0.52))
entities.append(
_text_entity(
f"{drawing_id}:fig:title",
f"{sheet.get('title') or '돌쌓기'} (축척 1/{int(1000 / SCALE_MM_PER_M)})",
title_x,
title_y,
layer_id,
_LABEL_FONT,
label_color,
"left",
)
)
used_mm = (height_m + 0.95 + base_d) * scale
return entities, used_mm
@@ -23,19 +23,21 @@ from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
_text_entity,
TABLE_LABEL_COLOR,
TABLE_LINE_COLOR,
TABLE_VALUE_COLOR,
_format,
_layer,
_text_entity,
polyline_entity,
table_entity,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import (
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
from B08_Quantity.B08_Quantity_Engine_StructureFigure import (
SCALE_MM_PER_M as FIGURE_SCALE_MM_PER_M,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import build_figure, figure_reason
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
from B08_Quantity.B08_Quantity_Engine_StructureFigure import build_figure as figure_shapes
from B08_Quantity.B08_Quantity_Engine_StructureFigure import figure_reason
STANDARD_LAYER_ID = "standard"
@@ -66,6 +68,44 @@ def _figure_height(sheet: dict[str, Any]) -> float:
return (height_m + 0.95) * FIGURE_SCALE_MM_PER_M if height_m > 0 else 0.0
def build_figure(
drawing_id: str, sheet: dict[str, Any], origin: tuple[float, float]
) -> tuple[list[dict[str, Any]], float]:
"""B08 그림 모양 목록(m)을 CAD 엔티티(종이 ㎜)로. `(엔티티, 높이 ㎜)` — 못 그리면 `([], 0)`."""
shapes = figure_shapes(sheet) or []
ox, oy = origin
scale = FIGURE_SCALE_MM_PER_M
entities: list[dict[str, Any]] = []
for index, shape in enumerate(shapes):
x, y = (shape.get("at") or [0.0, 0.0])[:2]
if shape["kind"] == "text":
entities.append(
_text_entity(
f"{drawing_id}:fig:{index}",
shape["text"],
ox + x * scale,
oy + y * scale,
STANDARD_LAYER_ID,
shape["size"] * scale,
TABLE_LABEL_COLOR,
shape["align"],
)
)
continue
poly = polyline_entity(
f"{drawing_id}:fig:{index}",
[(ox + px * scale, oy + py * scale) for px, py in shape["points"]],
STANDARD_LAYER_ID,
TABLE_LINE_COLOR if shape["role"] == "wall" else TABLE_VALUE_COLOR,
dash=[4, 3] if shape["dash"] else None,
width=2 if shape["role"] == "wall" else 1,
)
if poly is not None:
entities.append(poly)
ys = [point[1] for shape in shapes for point in shape.get("points") or [shape["at"]]]
return entities, ((max(ys) - min(ys)) * scale if ys else 0.0)
def _cell(
text: str, color: str, *, align: str = "left", col_span: int = 1, bold: bool = False
) -> dict[str, Any]:
@@ -166,11 +206,7 @@ def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any])
figure, used = build_figure(
drawing_id,
sheet,
STANDARD_LAYER_ID,
(x0 + _FIGURE_INSET, top - _FIGURE_TOP_PAD - _figure_height(sheet)),
TABLE_LINE_COLOR,
TABLE_LABEL_COLOR,
TABLE_VALUE_COLOR,
)
if figure:
entities.extend(figure)
@@ -178,6 +178,18 @@ def parse_toc(rows: list[list[str]]) -> list[dict[str, Any]]:
return nodes
def _is_section_title(section: str, by_number: dict[str, dict[str, Any]]) -> bool:
"""진짜 절 제목인가 — 「번호. 이름」, 또는 마침표 없이 번호 뒤 글이 목차 절 이름과 같음."""
if re.match(r"^\s*\d+(?:-\d+){0,3}\.\s", section):
return True
number = section_number(section)
node = by_number.get(number) if number else None
if node is None:
return False
tail = section.strip()[len(number) :]
return "".join(tail.split()) == "".join(str(node.get("name") or "").split())
def section_number(section: str) -> str | None:
"""`"9-3-1. 인력"` → `"9-3-1"`. 번호가 없으면 `None`."""
m = re.match(r"^\s*(\d+(?:-\d+){0,3})[.\s]", section + " ")
@@ -598,10 +610,19 @@ def build() -> dict[str, Any]:
orphans: list[dict[str, Any]] = []
undetermined: list[dict[str, Any]] = []
last_section = ""
for table in tables:
if table["table_id"] == "F0001":
continue # 목차 자신은 공종이 아니다.
section = norm(table.get("section"))
# ⚠ 원본 자료가 [주] 문장 조각을 절 제목으로 읽은 표가 있음 — 「12-2 표면 마무리를 따른다.」
# (12-17-1 펌프카 타설 [주]) 가 표 7장을 12-2 에 붙여 가짜 갈래가 섰음(2026-09-14).
# 절 제목은 「번호. 이름」 이거나(마침표 없는 원문 제목 「7-15-2 숲가꾸기, …」 도 있음)
# 번호 뒤 글이 **목차의 그 절 이름과 같음** — 둘 다 아니면 문장 조각이라 앞 절을 이어받음.
if _is_section_title(section, by_number):
last_section = section
elif last_section:
section = last_section
number = section_number(section)
chapter = number.split("-")[0] if number else None
form, why = detect_form(table, chapter)
@@ -67,7 +67,10 @@ FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
"F0216": ("8-2-4", "reference", "위와 같은 선정약제 목록"),
"F0218": ("8-2-5", "reference", "위와 같은 선정약제 목록"),
"F0453": (
"8-2-3",
# 2026-09-14 절 8-2-3 → 14-2 — 원본이 [주] 「…8-2-3 굴착기(2025)」…」 를 절로 읽던 것을
# 앞 절 이어받기로 고치자 부록 앞 마지막 절(14-2)로 옮겨 감. 부록엔 절 번호가 없어 여전히
# 제자리가 아님 — 참고표라 값에 안 닿음.
"14-2",
"reference",
"〔부록 2〕 산림사업별 적용 가능 공종 목록 — 절 경계가 밀려 붙음",
),
@@ -103,8 +106,10 @@ FORM_JUDGMENTS: dict[str, tuple[str, str, str]] = {
),
# ── 제11·12장 ───────────────────────────────────────────────────
"F0328": ("11-4", "coefficient", "행이 q·k·f·E — [주]① Q=3600×q×k×f×E/Cm"),
"F0358": ("12-2", "reference", "시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명"),
"F0360": ("12-2", "reference", "현장조건 Type-Ⅰ~Ⅲ 적용 기준 설명"),
# 2026-09-14 절 12-2 → 12-17-1 — 원본이 펌프카 타설 [주] 「…12-2 표면 마무리를 따른다.」 를
# 절 제목으로 읽어 12-2 에 붙어 있던 표(빌더가 앞 절 이어받기로 고침). 형태 판정은 그대로.
"F0358": ("12-17-1", "reference", "시설유형 Type-Ⅰ~Ⅳ 적용 기준 설명"),
"F0360": ("12-17-1", "reference", "현장조건 Type-Ⅰ~Ⅲ 적용 기준 설명"),
"F0388": ("12-34-4", "reference", "「재료비 JOINT FILLER」 항목만 — 값이 없는 구성 안내"),
"F0390": ("12-36", "reference", "제작비·운송비·설치비 「견적처리」"),
"F0392": ("12-38-1", "coefficient", "사용횟수별 잔존율(12회 25%·25회 10%)"),
@@ -26,7 +26,39 @@ from dataclasses import dataclass, field
from typing import Any, Iterable
# 지반 구분이 붙는 공종 — 실무 시트가 이 셋을 각각 암 갈래만큼 늘려 적는다.
GROUND_SPLIT_GROUPS = ("흙깎기", "측구터파기", "구조물터파기")
# 면고르기는 절토면만 토사/암 갈래(성토면 줄은 `item` 이 비어 지반 없음 · 2026-09-14 Ⓑ).
GROUND_SPLIT_GROUPS = ("흙깎기", "측구터파기", "구조물터파기", "면고르기")
#: 면고르기 갈래 — 품셈 9-19-1 원문 표 두 벌 그대로(L5414 절토면 토질 6 · L5430 성토면 시공·토질).
#: B09 표 읽기(`_ResourceAxis_JudgedTable`)가 같은 이름으로 갈래를 세움 — 짝 시험이 마스터와 대조.
#: ⚠ 토질·시공은 제안값 없음(판정 Ⓒ) · 성토면 기계는 굴착기 0.6㎥ 형식 둘로 갈래(브레인 ②).
FACE_DRESSING_CUT_CLASSES = (
"절토면 · 모래ㆍ사질토ㆍ점토ㆍ점질토",
"절토면 · 연질토ㆍ불순자갈",
"절토면 · 호박돌 섞인 고결토ㆍ경질토",
"절토면 · 풍화암",
"절토면 · 연암",
"절토면 · 보통암ㆍ경암",
)
FACE_DRESSING_FILL_CLASSES = (
"성토면 · 인력 · 점토 또는 점질토",
"성토면 · 인력 · 모래 또는 사질토",
"성토면 · 기계 · 무한궤도",
"성토면 · 기계 · 타이어",
)
#: 성토면 제안 — 칸 곁 회색 제안일 뿐 스스로 고르지 않음(「기본값을 몰래 확정으로 안 바꿈」).
FACE_DRESSING_FILL_SUGGESTED = (
"성토면 · 기계 · 무한궤도",
"영월 실무 설계내역 「성토사면고르기 06M3 B/H」(백호 = 무한궤도 굴착기) · 임도는 산지라"
" 타이어식이 잘 안 들어감 — 타이어식도 고를 수 있음(2026-09-14 브레인 ②)",
)
#: ⭐ 2026-09-14 브레인 판정 Ⓐ — 밑수 = 초류종자살포(파종) 면적. 반영률 칸을 따로 두면 같은 값을
#: 두 곳에서 관리하게 됨. 실무도 그렇게 움직임(거창 파종 성토 50%·절토 100% · 영월 성토 50%).
FACE_DRESSING_NOTE = (
"초류종자살포와 같은 면적(식재 대상) — 품셈 9-19-1 성토면 [주] 「식재를 위한 성토사면」 ·"
" 9-19-2·3 [주]① 「비탈면 식재기반 조성에만」"
)
FACE_DRESSING_ZERO = "파종 면적이 0 — 식재 대상 아님(면고르기 안 셈)"
# 반영률 키 ↔ 집계 공종. 값은 프로젝트 설정에서 온다(기본 100 %).
RATIO_OF_ROW = {
@@ -74,6 +106,8 @@ class SummaryInput:
application_ratios: dict[str, float] = field(default_factory=dict)
# 노체다짐 — 기본 꺼짐(2026-09-13 판정). 켜면 성토 밑에 별도 줄이 선다.
subgrade_compaction_enabled: bool = False
# 면고르기 면적 덮어쓰기 `{fill, cut}`(㎡) — 비우면 파종 면적(판정 Ⓐ).
face_dressing_area_m2: dict[str, float | None] = field(default_factory=dict)
#: 노체다짐 줄 — ⭐ 2026-09-13 판정 「별도 줄 · 칸으로 켜고 끔 · 기본 꺼짐」.
@@ -193,6 +227,7 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
note=_seed_note(source),
)
)
rows.extend(_face_dressing_rows(source, fill_face, cut_face))
removal = slope.get("tree_removal_fill", 0.0) + slope.get("tree_removal_cut", 0.0)
# ⭐ 2026-09-09 **사용자 확정 5차 2번** — 지장목제거를 **두 줄로 가른다**(실무 서식).
# 영월 설계내역서 1.9 지장목제거가 두 줄이고 **같은 면적을 나눠 쓴다**:
@@ -249,6 +284,61 @@ def build_rows(source: SummaryInput) -> list[SummaryRow]:
return rows
def _face_dressing_rows(
source: SummaryInput, fill_face: float, cut_face: float
) -> list[SummaryRow]:
"""면고르기 — 성토면 한 줄 · 절토면 토사 한 줄 + 암 갈래(판정 Ⓐ·Ⓑ). 밑수 = 파종 면적."""
rows: list[SummaryRow] = []
for face, key, gross, ratio_key in (
("성토면", "fill", fill_face, "seed_spray_fill"),
("절토면", "cut", cut_face, "seed_spray_cut"),
):
ratio = _ratio(source, ratio_key)
seeded = gross * ratio
given = source.face_dressing_area_m2.get(key)
amount = float(given) if isinstance(given, (int, float)) else seeded
if amount <= 0:
note = FACE_DRESSING_ZERO
elif isinstance(given, (int, float)):
note = f"덮어쓴 면적 {amount:,.2f}㎡ (파종 면적 {seeded:,.2f}㎡)"
else:
note = FACE_DRESSING_NOTE
common = {
"group": "면고르기",
"spec": face,
"unit": "",
"amount_gross": gross,
"application_ratio_pct": None if isinstance(given, (int, float)) else ratio * 100.0,
}
if face == "성토면":
rows.append(SummaryRow(**common, amount=amount, note=note, in_bill=amount > 0))
continue
# 절토면 — 사면 조각의 토사/암 몫(적용 전 면적)으로 나누고 같은 배율을 곱함.
# 못 가른 몫은 안 세고 드러냄. 몫마다 적용 전 값을 따로 실어 율 거울 검사가 맞게.
slope = source.slope_totals
soil = slope.get("face_dressing_cut_soil", 0.0)
rock = slope.get("face_dressing_cut_rock", 0.0)
scale = amount / cut_face if cut_face > 0 else 0.0
unknown = max(cut_face - soil - rock, 0.0) * scale
if unknown > 1e-6:
note += f" · ⚠ 토사/암을 못 가른 사면 {unknown:,.2f}㎡ 는 안 셈(측점 지반 프리셋 없음)"
parts = [("토사", soil, "")] + (_split_by_rock(rock, source) if rock > 0 else [])
for name, part_gross, split_note in parts:
part = part_gross * scale
# 토사 몫은 갈래 값(「절토면 · 모래…」)이 규격을 말함 · 암 몫은 규격에 암 갈래
spec = face if name == "토사" else f"절토면 · {name}"
rows.append(
SummaryRow(
**{**common, "amount_gross": part_gross, "spec": spec},
item=name,
amount=part,
note=" · ".join(text for text in (note, split_note) if text),
in_bill=part > 0,
)
)
return rows
def _ratio_note(source: SummaryInput, key: str, base: str) -> str:
ratio = _ratio(source, key)
return "" if abs(ratio - 1.0) < 1e-9 else f"{base} {ratio * 100:g} % 반영"
+10 -2
View File
@@ -54,6 +54,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Trench import (
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import ( # noqa: F401
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
BLOCKED_UNCONFIRMED,
BLOCKED_UNIT_DATA_MISSING,
METHOD_TO_GROUND,
NOTE_METHOD_MISSING,
@@ -112,6 +113,8 @@ def build_handoff(
bench_cut_depth_m: float | None = None,
structure_trench_water: str | None = None,
stand_volume_class: str | None = None,
face_dressing_cut_class: str | None = None,
face_dressing_fill_class: str | None = None,
priced_sheets: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**.
@@ -126,7 +129,12 @@ def build_handoff(
methods = {key: value for key, value in (ground_methods or {}).items() if value}
if summary_table:
# 임목축적 등급 — 지장목제거 뿌리뽑기(9-21 제근)의 품 갈래(2026-09-13 판정 Ⓑ).
variant_inputs = {"stand_volume_class": stand_volume_class}
# 면고르기 절토면 토질 · 성토면 시공·토질 — 9-19-1 갈래(2026-09-14 판정 Ⓒ).
variant_inputs = {
"stand_volume_class": stand_volume_class,
"face_dressing_cut_class": face_dressing_cut_class,
"face_dressing_fill_class": face_dressing_fill_class,
}
rows, misses = _earthwork_rows(
summary_table, table, methods, bench_cut_depth_m, variant_inputs
)
@@ -185,7 +193,7 @@ def build_handoff(
str(row.get("ground_class"))
for row in work_items
if row.get("ground_class")
and row.get("ground_class") != "토사"
and row.get("ground_class") not in ("토사", "") # 「암」은 구성비가 빠진 것
and row.get("work_item_code") is None
and row.get("origin") == ORIGIN_EARTHWORK
}
@@ -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 '(없음)'}"
@@ -121,6 +121,12 @@ ORIGIN_PIPE = "pipe"
#: 갈래 이름(연암·보통암…)만으로는 공종을 못 고른다(2026-09-07 일감 9 실서버에서 드러남).
METHOD_TO_GROUND = {"ripping": "리핑암", "blasting": "발파암"}
NOTE_METHOD_MISSING = "시공법 미지정으로 공종을 못 고름"
#: 암 총량이 갈래로 안 나뉘어 한 줄 「암」으로 선 경우 — 시공법은 **갈래마다** 고르므로 이 줄은
#: 시공법만 골라서는 안 풀린다. 빠진 입력은 구성비다(2026-09-14 흙깎기·측구터파기 암).
NOTE_ROCK_RATIO_MISSING = (
"암 갈래 구성비(%)가 아직 없어 암 총량이 한 줄 「암」으로 섬 — 산출 조건 「암 갈래 구성비」를 "
"넣고 갈래마다 시공법(리핑·발파)을 고르면 공종이 섬"
)
#: 철근으로 보는 성분 이름 조각. **정확한 낱말이 아니라 앞머리**로 본다 —
#: 「이형철근 D13」·「원형철근」처럼 규격이 뒤에 붙기 때문이다. `철근콘크리트`는 성분 이름이
@@ -132,6 +138,9 @@ REBAR_PREFIXES = ("이형철근", "원형철근", "철근")
BLOCKED_INPUT_MISSING = "input_missing" # 저장 제원 칸이 비어 있음 — 입력하면 풀림
BLOCKED_UNIT_DATA_MISSING = "unit_data_missing" # 원단위·표준 물량 자료가 없음
BLOCKED_FORMULA_MISSING = "formula_missing" # 수량 산출식 자체가 없음
#: 치수가 없어 **등록부 기본값으로 선** 구조물 줄(2026-09-14 브레인 판정) — 수량은 보이되 금액·합계엔
#: 안 듦. B09 가 내역 제자리에 빈 금액 + 빨간 테두리로 세우고 「미확정 N건 — 금액에 안 들어감」.
BLOCKED_UNCONFIRMED = "unconfirmed"
#: 사면 계열 — 토공집계에 함께 실리지만 출처가 사면적이다.
#: ⚠ `item` 칸이 **지반 갈래**인 공종 — 그 밖의 공종에서 `item` 은 **작업 갈래**다
@@ -318,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]] = []
@@ -331,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] = {
@@ -13,12 +13,14 @@ from B08_Quantity.B08_Quantity_Engine_BasisUnit import normalize_unit, unit_for_
from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
BLOCKED_FORMULA_MISSING,
BLOCKED_INPUT_MISSING,
BLOCKED_UNCONFIRMED,
BLOCKED_UNIT_DATA_MISSING,
GROUND_SPLIT_GROUPS,
HAUL_SUMMARY_GROUPS,
METHOD_TO_GROUND,
NOTE_HAUL_IN_SUMMARY,
NOTE_METHOD_MISSING,
NOTE_ROCK_RATIO_MISSING,
ORIGIN_EARTHWORK,
ORIGIN_HAUL,
ORIGIN_SLOPE,
@@ -33,6 +35,9 @@ from B08_Quantity.B08_Quantity_Engine_Handoff_Mapping import (
)
from B08_Quantity.B08_Quantity_Wording import type_label as wording_type_label
#: 기본값으로 선 구조물 줄 사유 머리 — B09 「미확정 N건 — 금액에 안 들어감」과 같은 말.
UNCONFIRMED_LABEL = "미확정 — 금액에 안 들어감"
def _spec_detail(structure: dict[str, Any]) -> str:
"""규격 표기 — 저장된 제원에서 만든다. 없는 값은 적지 않는다."""
@@ -54,6 +59,8 @@ def _mapping_ground(ground: str | None, methods: dict[str, str | None]) -> tuple
"""
if ground is None or ground == "토사":
return ground, ""
if ground == "":
return None, NOTE_ROCK_RATIO_MISSING # 갈래로 안 나뉜 총량 — 시공법만으론 안 풀림
method = methods.get(ground)
mapped = METHOD_TO_GROUND.get(method or "")
if mapped:
@@ -144,6 +151,18 @@ def _earthwork_rows(
# 보는 쪽을 고쳤으면 **다는 쪽도** 빠짐없이 달아야 한다.
blocked_kind = mismatch[1] if mismatch else None
blocked_reason = mismatch[2] if mismatch else ""
variant_value = (
(entry or {}).get("variant_value")
or {
**(variant_inputs or {}),
"ground_class": ground,
}.get(str((entry or {}).get("variant_from") or ""))
or None
)
# 매핑이 「이 칸이 비면 못 고름」이라 적은 갈래 — 금액 없이 입력 사유(면고르기 · 09-14 Ⓒ).
if code and not variant_value and (entry or {}).get("variant_missing_reason"):
blocked_kind = blocked_kind or BLOCKED_INPUT_MISSING
blocked_reason = blocked_reason or str(entry["variant_missing_reason"])
if code is None and not is_subtotal:
label = f"{group}({ground})" if ground else group
unmatched.append(f"{label}{method_note}" if method_note else label)
@@ -178,11 +197,7 @@ def _earthwork_rows(
# 매핑이 갈래를 적은 작업 갈래(잡관목제거 → 단목베기 「5m 미만」)만 값을 싣는다.
"variant_axis": (entry or {}).get("variant_axis"),
# 암 갈래(연암·보통암·경암)는 줄 자신의 갈래를 넘김 — 9-4·9-5 단계 합산형(축 C Ⓐ).
"variant_value": (entry or {}).get("variant_value")
or {**(variant_inputs or {}), "ground_class": ground}.get(
str((entry or {}).get("variant_from") or "")
)
or None,
"variant_value": variant_value,
"secondary_axes": None,
"spec_class": None,
"spec_class_basis": "",
@@ -281,9 +296,28 @@ def _haul_rows(
"origin": ORIGIN_HAUL,
}
)
if equipment == "dump_truck" and in_bill:
# 덤프 운반은 **싣는 일이 따로** — 산림품셈 10-12 「1. 적재」
# (굴착기 0.7㎥ · ㎝ 22초·180°).
# 흙깎기 9-3-2(㎝ 20초·135°, 깎아 옆에 둠)에 안 들어 있어 운반과 한 벌로 짝 줄을 냄
# (2026-09-14 브레인 판정). 거리와 무관 · 수량은 운반량 그대로.
rows.append(
{
**rows[-1],
"name": "덤프 적재",
"haul_distance_m": None,
"haul_equipment": LOADING_EQUIPMENT,
"spec_detail": f"{state_note} · 덤프 운반량만큼 싣기(10-12 「1. 적재」)",
}
)
return rows, unmatched
#: 인계 「덤프 적재」 짝 줄의 수단 표지 —
#: B09 `MachineProductivity_Dump.LOADING_EQUIPMENT` 와 같은 글.
LOADING_EQUIPMENT = "dump_loading"
def blocked_of(
structure: dict[str, Any], class_basis: str = "", has_code: bool = False
) -> tuple[str | None, str]:
@@ -378,6 +412,11 @@ def _structure_rows(
"큰돌쌓기 쌓기 방식이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 "
"메쌓기·찰쌓기 중 하나를 고르면 공종이 정해집니다"
)
if entry.get("class_from") == "form":
# 기슭막이 — **형태**가 공종을 가름(돌쌓기 찰·메). 그 밖 형태는 전개 사유가 막음.
form = str((structure.get("options") or {}).get("form") or "").strip()
code = (entry.get("form_codes") or {}).get(form)
class_basis = f"형태 「{form}」 → {code}" if code else ""
if code and entry.get("class_from") == "back_length":
class_key, class_basis = masonry_class(structure.get("options") or {})
@@ -442,6 +481,13 @@ def _structure_rows(
if notes
else "물량이 0 이라 내역에 안 세움 — 저장 제원에서 치수·면적을 넣으면 값이 섭니다"
)
# ⭐ 치수가 없어 기본값으로 선 구조물(2026-09-14 브레인 판정) — **줄은 서되 금액은 안 듦.**
# B09 가 내역 제자리에 빈 금액 + 빨간 테두리로 세우고 「미확정 N건 — 금액에 안 들어감」.
unconfirmed = str(structure.get("unconfirmed") or "")
if unconfirmed:
in_bill = False
zero_reason = f"{UNCONFIRMED_LABEL}{unconfirmed}"
blocked_kind, blocked_reason = BLOCKED_UNCONFIRMED, zero_reason
rows.append(
{
"work_item_code": code,
@@ -486,7 +532,7 @@ def _structure_rows(
"origin": ORIGIN_STRUCTURE,
}
)
if sheet_entry is not None:
if sheet_entry is not None and not unconfirmed:
rows[-1] = template_row(rows[-1], structure, sheet_entry)
return rows, unmatched
@@ -536,8 +582,10 @@ def _placing_rows(
# 2026-09-08 B09 가 「철근이 겹치나」를 물어 그 김에 드러난 자리다 —
# 철근은 안 겹치고(자재는 재료·묶음 조각은 품, 재료 0원) **타설이 겹쳤다.**
# ⚠ 묶음이 아직 미확보라 지금은 값이 안 걸렸을 뿐, 묶음이 서는 날 두 번이 된다.
if mapping.composite_for(str(structure.get("type_id") or "")):
continue
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
"unconfirmed"
):
continue # 기본값으로 선 구조물도 — 금액에 안 듦
# ⚠ 버림은 **따로 센다** — 실무 내역이 「레미콘타설(장비) **무근,버림**」으로 갈라
# 적는다(봉화 제50호표, 2026-09-09 데스크탑 보조 확인). 같은 공종·같은 단가라
# 금액은 안 움직이고 **이름만 맞추는 것**이다.
@@ -48,6 +48,8 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
status = str(row.get("status") or "")
amount = row.get("amount")
ready = status == PREP_READY and amount is not None
# 값은 서도 **내역 줄이 아닌 줄**(경비 — 임목폐기물 처리). 막힘이 아니라 가는 곳이 다름.
billable = ready and row.get("bill") is not False
rows.append(
{
"work_item_code": row.get("work_item_code"),
@@ -88,8 +90,14 @@ def _preparation_rows(preparation_table: dict[str, Any]) -> list[dict[str, Any]]
"spec_class_basis": str(row.get("reason") or ""),
"composite_not_ready": None,
# 값이 없는 줄은 **내역에 세우지 않는다** — 0 원 줄을 만들면 더 나쁘다.
"in_bill": ready,
"in_bill_reason": "" if ready else str(row.get("reason") or status),
"in_bill": billable,
"in_bill_reason": (
""
if billable
else str(row.get("bill_reason") or "")
if ready
else str(row.get("reason") or status)
),
"origin": ORIGIN_PREPARATION,
}
)
@@ -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": "",
@@ -166,8 +166,10 @@ def rubble_base_rows(
total = 0.0
bases: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
if mapping.composite_for(str(structure.get("type_id") or "")):
continue
if mapping.composite_for(str(structure.get("type_id") or "")) or structure.get(
"unconfirmed"
):
continue # 묶음 조각이 품음 · 기본값으로 선 구조물은 금액에 안 듦
sheet_entry = priced.get(str(structure.get("structure_id")))
if sheet_entry is not None and RUBBLE_GROUP in sheet_entry["covered"]:
continue
@@ -219,9 +221,13 @@ def structure_earthwork_rows(
# 사유도 안 났다). 관측 원단위로 가는 종류는 표에 터파기 줄이 없으면 이렇게 된다.
without_trench: list[str] = []
for structure in unit_quantity_table.get("structures") or []:
if structure.get("unconfirmed"):
continue # 기본값으로 선 구조물 — 제 줄에 사유가 섬, 토공 금액엔 안 듦
options = structure.get("options") or {}
height = float(structure.get("height_m") or options.get("height_m") or 0.0)
band = _depth_band(height + _foundation_depth(options))
# 단면으로 판 깊이가 있으면 그것(옹벽은 기초분만 팜 · 비탈분 제외) — 없으면 직고 + 기초 깊이.
dug = structure.get("trench_depth_m")
band = _depth_band(float(dug) if dug else height + _foundation_depth(options))
ground = structure.get("ground_type") or None
ground_basis = str(structure.get("ground_type_basis") or "")
for component in structure.get("components") or []:
@@ -241,7 +247,8 @@ def structure_earthwork_rows(
backfill += amount
elif name == "잔토처리":
spoil += amount
if not any(
# 성분이 아예 없는 구조물(세월교 등 산출식 없음)은 제 줄이 이미 사유로 막힘 — 여기 안 적음.
if structure.get("components") and not any(
str(component.get("name") or "") == "터파기"
and float(component.get("amount") or 0.0) > 0
for component in structure.get("components") or []
@@ -97,6 +97,8 @@ def haul_inputs(unit_quantity_table: dict[str, Any] | None) -> dict[str, Any]:
stone_by_ground: dict[str, float] = {}
stone_unknown = 0.0
for structure in unit_quantity_table.get("structures") or []:
if structure.get("unconfirmed"):
continue # 기본값으로 선 구조물 — 잔토·채집석이 사토 운반 금액으로 번지지 않게
amount = 0.0
stone = 0.0
for component in structure.get("components") or []:
@@ -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,23 @@ 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 = "미확정(기본값으로 선 구조물)"
def _collect(
unit_quantity_table: dict[str, Any],
) -> tuple[dict[tuple[str, str, str], MaterialRow], dict[str, int]]:
@@ -293,6 +337,10 @@ def _collect(
rows: dict[tuple[str, str, str], MaterialRow] = {}
skipped: dict[str, int] = {}
for structure in unit_quantity_table.get("structures", []):
if structure.get("unconfirmed"):
# 기본값으로 선 구조물 — 자재에 안 듦(2026-09-14 브레인 판정 「없으면 없다고 보이기」).
skipped[UNCONFIRMED_SKIP] = skipped.get(UNCONFIRMED_SKIP, 0) + 1
continue
label = str(structure.get("name") or structure.get("type_id") or "")
for component in structure.get("components", []):
destination = str(component.get("destination") or "") or "(없음)"
@@ -312,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)
@@ -333,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)
# ⚠ 설치 주체는 관급 줄에만 묻는다. 사급은 애초에 대상액 밖이라 비워 두는 것이 맞다.
@@ -345,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)
@@ -371,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),
@@ -151,6 +151,109 @@ def billing_of(
return str(found.get("unit") or "개소"), scale
#: 단면으로 터파기를 세운 줄의 사유 — 표 사유·구조물도 그림이 **같은 말**(브레인 판정 2026-09-14).
SECTION_TRENCH_NOTE = (
"터파기는 기초 폭 + 양쪽 여유 · 옆면 1:0.5(판 깊이 기준)"
" — 벽 높이 몫은 지반선이 제원에 없어 제외"
)
def earthwork_missing_note(entry: dict[str, Any]) -> str | None:
"""관측 줄에 토공 줄이 없고 단면으로도 못 세우면 한 줄 — 표 사유·구조물도 그림이 같은 말."""
if any(item.get("destination") == "earthwork" for item in entry.get("components") or []):
return None
if (entry.get("section") or {}).get("trench"):
return SECTION_TRENCH_NOTE
return "⚠ 터파기·되메우기·잔토가 안 섬 — 관측 원단위 표에 그 줄이 없음(값을 지어내지 않음)"
def weep_component(
entry: dict[str, Any], options: dict[str, Any], scale: float, scale_note: str
) -> dict[str, Any] | None:
"""물구멍관 — 단면이 있는 관측 줄은 **실무 식에 제원 칸을 넣어** 다시 셈(붙박이 값 대신).
` 높이 ÷ 개소당 벽면적 × 길이`(소광리 옹벽2.0 T31). 길이는 실무 관측값이라
`basis_kind` 그대로 `observed`. 규격 `Ø지름` 달아 돌쌓기 물구멍관과 (이름+규격)으로 합침.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import weep_hole_spec
section = entry.get("section") or {}
weep = section.get("weep")
if not weep:
return None
area, diameter, spec_basis = weep_hole_spec(options)
height, length = section["wall"]["height_m"], weep["pipe_length_m"]
per_unit = height / area * length
return {
"name": "물구멍관",
"unit": "m",
"amount": per_unit * scale,
"destination": "material",
"basis": (
f"관측 원단위 식(소광리 「옹벽2.0」 T31) 벽 높이 {height:g} ÷ {area:g}㎡/개소"
f" ×{length:g} m = {per_unit:.3f}/{entry.get('unit')} × {scale_note}"
f" · 관 길이는 관측값 · {spec_basis}"
),
"basis_kind": BASIS_OBSERVED,
"source": str(entry.get("source") or ""),
"spec": f"Ø{diameter}",
}
def section_trench_components(
entry: dict[str, Any], rubble_thickness_m: float, scale: float
) -> tuple[list[dict[str, Any]], float] | None:
"""단면이 있는 관측 줄의 터파기·되메우기·잔토(`destination: earthwork`)와 파는 깊이(m).
같은 파일 식생옹벽블럭 기초 터파기 밑폭 = 기초 + 양쪽 여유 0.3 · 옆면 1:0.5
(윗폭 = 밑폭 + 0.5 × 깊이 × 2) · 깊이 = 기초 + 버림 + 기초잡석 · 높이 몫은
(지반선이 제원에 없음). 브레인 판정 2026-09-14 처음 수직에서 1:0.5 고침.
되메우기 = 터파기 안에 (기초·전단키 콘크리트 + 버림 + 기초잡석) 잔토 = .
"""
section = entry.get("section") or {}
trench = section.get("trench")
if not trench:
return None
footing, key, blinding = section["footing"], section["key"], section["blinding"]
clearance, slope = trench["clearance_m"], trench.get("side_slope", 0.0)
width = footing["width_m"] + 2 * clearance
depth = footing["thickness_m"] + blinding["thickness_m"] + rubble_thickness_m
top_width = width + 2 * slope * depth
key_below = max(key["depth_m"] - blinding["thickness_m"] - rubble_thickness_m, 0.0)
excavation = (width + top_width) / 2 * depth + key["width_m"] * key_below
layer_width = footing["width_m"] + 2 * blinding["overhang_m"] - key["width_m"]
filled = (
footing["width_m"] * footing["thickness_m"]
+ key["width_m"] * key["depth_m"]
+ layer_width * (blinding["thickness_m"] + rubble_thickness_m)
)
backfill = excavation - filled
basis = (
f"(밑폭 {width:.2f}(기초 {footing['width_m']:g} + 여유 {clearance:g}×2)"
f" + 윗폭 {top_width:.2f}(1:{slope:g})) ÷ 2 × 깊이 {depth:.2f}"
f"(기초 {footing['thickness_m']:g} + 버림 {blinding['thickness_m']:g}"
f" + 기초잡석 {rubble_thickness_m:g})"
f" · {SECTION_TRENCH_NOTE} · 여유·기울기는 소광리 식생옹벽블럭 기초 터파기"
)
rows = [
("터파기", excavation, basis),
("되메우기", backfill, f"터파기 든 것 {filled:.3f}(기초·전단키 + 버림 + 기초잡석)"),
("잔토처리", filled, "터파기 되메우기"),
]
return [
{
"name": name,
"unit": "",
"amount": amount * scale,
"destination": "earthwork",
"basis": f"{text} = {amount:.3f}/{entry.get('unit')}",
"basis_kind": BASIS_DERIVED,
"source": str(entry.get("source") or ""),
}
for name, amount, text in rows
], depth
def expand_observed(
type_id: str,
spec: dict[str, Any],
@@ -170,23 +273,53 @@ def expand_observed(
if scale <= 0:
from B08_Quantity.B08_Quantity_Wording import type_label
return [], [f"{type_label(type_id)}의 연장·면적이 0 이라 물량을 내지 않았습니다"]
where = (
"면적(월류 폭 × 포장 길이)이 0 — 두 칸을 적으면 섬"
if found.get("unit") == ""
else "연장이 0 — 연장을 적으면 섬"
)
return [], [f"{type_label(type_id)}{where}"]
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"] == "물구멍관"
else None
)
if weep is not None:
components.append(weep)
continue
components.append(
{
"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 ""),
}
)
return components, [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)
if earthwork_note:
notes.append(earthwork_note)
return components, notes
+350 -3
View File
@@ -16,9 +16,9 @@
기슭막이가 같은 파일에 있다. `pipe_diameter_mm` 유무로 가르면 **관경 미지정 관을 놓친다**
(실측: `5601e828` 11 2점이 `facility: ford_bridge`).
**유출·유입부 기슭막이는 여기서 않는다.**
옵션(`outlet_revet_*`) 정본이 구조물 목록에서는 빠졌(2026-08-28 이관).
구조물 으로 이중계상이다.
**유출·유입부 기슭막이는 줄에 않는다 `facility_structures` 원단위 전개로 .**
옵션(`outlet_revet_*`) 정본이다(2026-08-28 이관). 부설(품셈 12-11 m당: ·기초콘크리트·
거푸집) 기슭막이 몫이 없어 겹치지 않는다. 2026-09-14 까지는 **어느 있었다**(A1).
**터파기·되메우기를 줄에 붙이지 않는다.**
부설과 굴착이 각각 오면 **같은 굴착을 ** 센다(B09 가드와 같은 자리).
@@ -26,6 +26,9 @@
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
#: 배관으로 보는 `facility` 값. 그 밖(BOX암거·물넘이·세월교·독립 기슭막이)은 관이 아니다.
@@ -192,3 +195,347 @@ def build_rows(
"ready_count": sum(1 for row in rows if row["in_bill"]),
"length_total_m": round(sum(row["quantity"] for row in rows if row["in_bill"]), 3),
}
INLET_BASIN = "집수정"
#: 기슭막이 벽 칸 — 관·독립 기슭막이 모두 `{쪽}_revet_{칸}` · 독립의 옛 저장분은 `{칸}`(B06 `_revet_side`).
REVET_FIELDS = (
("form", "형태"),
("height_m", "높이"),
("length_m", "길이"),
("before_m", ""),
("after_m", ""),
)
#: (저장 채널, 관에 딸린 줄 이름 꼬리, 독립 기슭막이 줄 이름 꼬리)
REVET_ROLES = (
("inlet", "유입부 기슭막이", "유입 칸 벽"),
("outlet", "유출부 기슭막이", "유출 칸 벽"),
)
#: 관 벽 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(B06 `pipe_wall_min_height_m`,
#: TS 짝 `pipeWallMinPureHeight`)로 섬 — 등록부·구조물도·횡단도가 한 값(2026-09-14 브레인 판정).
#: ⚠ 「최소」라 실제 설계값이 더 클 수 있어 **미확정·금액 밖은 그대로**.
NOTE_DEFAULT_WALL = (
"⚠ 시설 지점에 {filled} 을 안 적어 기본값으로 섰음 — 미확정이라 금액에 안 들어감"
)
#: 꼬리 — 관 벽은 횡단도와 같은 높이.
NOTE_WALL_HEIGHT_PIPE = " · 높이는 횡단도와 같은 관경 기준 최소 높이(실제 설계값은 더 클 수 있음)"
#: 독립 기슭막이 치수 **한 벌**(`resources/data_masonry/revetment_sabang_*.json`) — B05 칸 밑 근거와
#: 이 사유가 같은 파일을 읽음(2026-09-14 브레인 판정). 높이는 **설계자 입력** — 등록부 기본 2.5 걷음.
REVET_SABANG_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
REVET_SABANG_PREFIX = "revetment_sabang_"
UNCONFIRMED_OWN_HEIGHT = "높이를 안 적음 — {rule}({source}) 설계자 입력 · 적으면 금액이 섬"
@lru_cache(maxsize=1)
def load_revetment_sabang() -> dict[str, Any]:
"""독립 기슭막이 치수 한 벌. 없으면 빈 표 — 사유가 근거 없이 짧아질 뿐 값은 안 바뀜."""
files = sorted(REVET_SABANG_DIR.glob(REVET_SABANG_PREFIX + "*.json"))
return json.loads(files[-1].read_text(encoding="utf-8")) if files else {}
def _span(values: list[float], unit: str = "") -> str:
return f"{values[0]:g}~{values[1]:g}{unit}"
def own_revetment_basis() -> str:
"""독립 기슭막이 근거 한 줄 — 높이·계획비탈·둑마루·뒷채움, 쪽·줄 번호 그대로."""
data = load_revetment_sabang()
items = data.get("items") or {}
if not items:
return ""
height, slope = items["height"], items["face_slope"]
crown, pebble = items["crown_thickness"], items["backfill_pebble"]
return (
f"ⓘ 독립 기슭막이 치수 근거 — 높이 {height['rule']}({height['source']})"
f" · 계획비탈 1:{_span(slope['standard_ratio'])}({slope['source']})"
f" · 둑마루 두께 {_span(crown['standard_m'], 'm')}({crown['source']} · 콘크리트 기준)"
f" · 뒷채움 조약돌 두께 {pebble['source']} · {data.get('scope', '')}"
)
def own_height_unconfirmed() -> str:
"""높이를 안 적은 독립 기슭막이의 미확정 까닭."""
height = (load_revetment_sabang().get("items") or {}).get("height") or {}
return UNCONFIRMED_OWN_HEIGHT.format(
rule=height.get("rule", "계획홍수위 + 0.5~0.7m"),
source=height.get("source", "사방기술교본"),
)
#: 금액에서 뺄 까닭(2026-09-14 브레인 판정) — 줄·표·그림은 서되 내역 합에는 안 듦.
UNCONFIRMED_WALL = "벽 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬"
NOTE_SIDE_UNKNOWN = (
"⚠ 설치 측 「{side}」인데 두 칸(유입·유출) 값이 달라 어느 칸이 그쪽인지 서버가 못 가림"
"(횡단 지형이 정함) — 값을 안 세움 · 두 칸을 같게 적으면 섬"
)
def _blank(value: Any) -> bool:
return value is None or value == ""
def _num_or_zero(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _revet_values(
options: dict[str, Any],
role: str,
defaults: dict[str, Any],
legacy: bool,
min_height_m: float | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""벽 한쪽 제원과 기본값으로 채운 칸 — 등록부 기본값 · 관 벽 높이는 관경 기준 최소 높이."""
values: dict[str, Any] = {}
filled: list[str] = []
for name, label in REVET_FIELDS:
value = options.get(f"{role}_revet_{name}")
if _blank(value) and legacy:
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
#: 다단 추가 기슭막이(④, 2026-09-14) — 횡단 설계가 정본이고 **실제로 선** 단 수·높이는 지형이 정함.
#: 서버 Node 재계산(`B06_Section_Structure_Layouts.extraWallRows`)이 소유 측점 `design.extra_walls` 에
#: 「선 다단 벽 목록」을 남기고 여기서 읽음(관 연장 `pipe_length_m` 과 같은 길).
EXTRA_WALLS_KEY = "extra_walls"
EXTRA_SIDE_LABELS = {"outlet": "유출측", "basin": "유입측"}
#: 다단이 딸린 기준벽 — 유출측 다단은 유출 벽, 유입측(집수정 계류측) 다단은 유입 벽.
EXTRA_SIDE_ROLES = {"outlet": "outlet", "basin": "inlet"}
UNCONFIRMED_EXTRA_HEIGHT = (
"다단 높이를 안 적음 — 기본 1.5m 를 지반에 맞춰 그린 {height}m 로 섰음 · 적으면 금액이 섬"
)
UNCONFIRMED_EXTRA_FORM = "다단 형태를 안 정해 기준벽의 기본 형태 「{form}」로 섰음"
#: 첫 미확정 단 아래는 횡단도 면적도 없는 것으로 봄(B06 `confirmedOnly`) — 같은 선에서 끊음.
UNCONFIRMED_EXTRA_ABOVE = "윗단({above}단)이 미확정이라 이 단도 미확정 — 윗단 높이를 적으면 섬"
#: 벽 몸이 성토 폐회로에도 듦(2026-09-06 사용자 확정 「구조물 면적 안 뺌」) — 고치지 않고 사유로(2026-09-14 브레인).
#: 넓이는 서버 Node 가 횡단도 벽 몸 ∩ 성토 폐회로로 잼(`wallBodyInFillM2` · `fill_overlap_m2`).
NOTE_FILL_OVERLAP = (
"⚠ 벽 몸 자리가 성토 면적에도 들어 있음(2026-09-06 확정 「구조물 면적 안 뺌」) — 이 측점 횡단도 "
"벽 몸 {area:.2f}× 연장 {length:g}m ≈ {volume:.1f}㎥ 를 벽과 성토에 두 번 셈 · "
"벽 뒤 막자갈 자리도 성토선 아래라 더 겹침"
)
#: 관 기준벽(유입·유출) 벽 몸 겹침 ㎡ — `design.wall_fill_overlap` {inlet, outlet}(서버 Node 가 잼, ㉡ 2026-09-14).
#: 호출부(`Router_Material`)를 안 늘리려고 다단 목록 통에 `base` 표시 칸으로 실어 옴 — 다단 줄은 거름.
WALL_OVERLAP_KEY = "wall_fill_overlap"
def extra_walls_from_designs(designs: list[dict[str, Any]] | None) -> dict[float, list[dict]]:
"""측점별 「선 다단 벽 목록」 — 목록이 있는 측점만(빈 목록도 담음 — 「다단 없음」).
기준벽 겹침 값이 있으면 `{"base": 역할, "fill_overlap_m2": }` 칸을 뒤에 붙임."""
found: dict[float, list[dict]] = {}
for row in designs or []:
design = row.get("design") if isinstance(row, dict) else None
if not isinstance(design, dict):
continue
walls = design.get(EXTRA_WALLS_KEY)
overlaps = design.get(WALL_OVERLAP_KEY)
if not isinstance(walls, list) and not isinstance(overlaps, dict):
continue
entries = list(walls) if isinstance(walls, list) else []
if isinstance(overlaps, dict):
entries += [{"base": role, "fill_overlap_m2": area} for role, area in overlaps.items()]
found[round(float(row.get("chainage_m") or 0.0), 3)] = entries
return found
def _extra_walls_at(extra_walls: dict[float, list[dict]] | None, chainage: float) -> list[dict]:
"""관 자리의 주인 측점 목록 — 스냅 때문에 최대 0.5m 어긋남(`SECTION_MATCH_TOLERANCE_M`)."""
if not extra_walls:
return []
best = min(extra_walls, key=lambda value: abs(value - chainage))
return extra_walls[best] if abs(best - chainage) <= SECTION_MATCH_TOLERANCE_M else []
def facility_structures(
points: list[dict[str, Any]],
extra_walls: dict[float, list[dict]] | None = None,
) -> list[dict[str, Any]]:
"""계곡 통과 시설(`pipe_points.json`) → 원단위 전개가 읽는 구조물 줄 (A1, 2026-09-14).
자체는 `build_rows` 연장으로 .
다단 추가 기슭막이는 `extra_walls`(측점 목록, `extra_walls_from_designs`) 줄을 세움.
적힌 칸은 **등록부 기본값** 사실을 사유로 붙이고 `unconfirmed` 금액 합에서
(2026-09-14 브레인 판정 줄은 서되 금액은 실제 값이 있을 때만).
집수정·기슭막이 터파기·되메우기는 전개 성분(`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
types = structure_type_map()
def defaults(type_id: str) -> dict[str, Any]:
found = types.get(type_id)
return {option.key: option.default for option in found.options} if found else {}
rows: list[dict[str, Any]] = []
for point in points or []:
facility = str(point.get("facility") or FACILITY_PIPE)
chainage = float(point.get("chainage_m") or 0.0)
options = dict(point.get("options") or {})
base = {
"structure_id": pipe_row_id(point),
"type_id": facility,
"chainage_m": chainage,
"start_m": point.get("start_m") if point.get("start_m") is not None else chainage,
"end_m": point.get("end_m") if point.get("end_m") is not None else chainage,
"options": options,
}
if facility not in (FACILITY_PIPE, "revetment"):
rows.append(base) # BOX암거·물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬
continue
legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀
wall_defaults = defaults(facility)
roles = list(REVET_ROLES)
inlet_notes: list[str] = []
if not legacy:
parent = {**base, "type_id": "pipe"}
children = [r for r in attachments_of(parent) if r["type_id"] != "pipe_inlet_basin"]
inlet = str(options.get("inlet_type") or wall_defaults.get("inlet_type") or "")
if inlet == INLET_BASIN:
# 형식을 안 골랐어도 줄은 세움 — 관측표가 「형식을 골라야 섬」 사유를 냄.
children.append(child_row(parent, "pipe_inlet_basin", "유입부 집수정"))
roles = roles[1:]
elif options.get("inlet_basin_form"):
inlet_notes.append(
f"⚠ 집수정 형식({options['inlet_basin_form']})이 적혀 있으나 유입구 구조가 "
f"{inlet}」라 집수정은 안 셈 — 유입구를 「집수정」으로 바꾸면 섬"
)
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, min_height)
notes = list(inlet_notes) if role == "inlet" else []
if filled:
tail = "" if legacy else NOTE_WALL_HEIGHT_PIPE
notes.append(NOTE_DEFAULT_WALL.format(filled=" · ".join(filled)) + tail)
if legacy:
# 독립 기슭막이 — 치수 한 벌 근거(사방 교본 기준 · 쪽·줄 번호)를 늘 붙임.
basis = own_revetment_basis()
if basis:
notes.append(basis)
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 ("", ""):
# 좌·우가 유입/유출 어느 칸인지는 **횡단 지형**이 정함 — 두 칸이 같을 때만 한 벽으로 셈.
same = walls[0][2] == walls[1][2]
walls = [[walls[0][0], f"{side}", walls[0][2], walls[0][3], not same, walls[0][5]]]
if not same:
walls[0][3].append(NOTE_SIDE_UNKNOWN.format(side=side))
foundation = options.get("foundation" if legacy else "revet_foundation")
kept = {k: v for k, v in options.items() if not k.startswith(("inlet_", "outlet_"))}
station_walls = _extra_walls_at(extra_walls, chainage)
base_overlap = {
str(entry["base"]): _num_or_zero(entry.get("fill_overlap_m2"))
for entry in station_walls
if entry.get("base")
}
for role, label, values, notes, withheld, filled in walls:
before, after = _num_or_zero(values["before_m"]), _num_or_zero(values["after_m"])
row = child_row(base, "revetment", label, f"{role}_revet")
reasons = [UNCONFIRMED_WALL.format(filled=" · ".join(filled))] if filled else []
if legacy and _blank(values["height_m"]):
# 높이는 설계자 입력(계획홍수위 + 여유고) — 없으면 줄만 서고 금액 밖.
reasons.append(own_height_unconfirmed())
# 좌·우 한 벽으로 합친 독립 기슭막이는 그 측점에 선 벽 몸 전부.
overlap = (
sum(base_overlap.values())
if legacy and len(walls) == 1
else base_overlap.get(role, 0.0)
)
if overlap > 0 and not reasons: # 미확정 벽은 금액에 안 듦 — 두 번 셀 금액이 없음
notes.append(
NOTE_FILL_OVERLAP.format(
area=overlap, length=before + after, volume=overlap * (before + after)
)
)
row.update(
start_m=chainage - before,
end_m=chainage + after,
options={**(kept if legacy else {}), **values, "foundation": foundation},
notes=notes,
withheld=withheld,
unconfirmed=" · ".join(reasons),
)
rows.append(row)
# 다단 — 기준벽의 기초·(독립이면) 제원 칸을 물려받고, 형태·높이·구간은 선 벽 목록 값.
parents = {role: (values, filled) for role, _label, values, _notes, _w, filled in walls}
counts = {"outlet": 0, "basin": 0}
first_unset: dict[str, int] = {}
for tier in station_walls:
if tier.get("base"):
continue
side = "basin" if str(tier.get("side")) == "basin" else "outlet"
counts[side] += 1
if not tier.get("height_set"):
first_unset.setdefault(side, counts[side])
parent_values, parent_filled = parents.get(
EXTRA_SIDE_ROLES[side], next(iter(parents.values()), ({}, []))
)
height = _num_or_zero(tier.get("height_m"))
form = str(tier.get("form") or parent_values.get("form") or "")
before, after = _num_or_zero(tier.get("before_m")), _num_or_zero(tier.get("after_m"))
reasons = []
if not tier.get("height_set"):
reasons.append(UNCONFIRMED_EXTRA_HEIGHT.format(height=f"{height:g}"))
elif first_unset.get(side, counts[side]) < counts[side]:
reasons.append(UNCONFIRMED_EXTRA_ABOVE.format(above=first_unset[side]))
if not tier.get("form_set") and any(str(f).startswith("형태") for f in parent_filled):
reasons.append(UNCONFIRMED_EXTRA_FORM.format(form=form))
label = f"{EXTRA_SIDE_LABELS[side]} 다단 기슭막이 {counts[side]}"
row = child_row(base, "revetment", label, str(tier.get("key") or label))
tier_notes = [
f"ⓘ 횡단도에 선 다단 벽 — 높이 {height:g}m · 형태 {form} · 전 {before:g}/후 {after:g}m"
]
overlap = _num_or_zero(tier.get("fill_overlap_m2"))
if overlap > 0 and not reasons: # 미확정 단은 면적에도 금액에도 안 듦
tier_notes.append(
NOTE_FILL_OVERLAP.format(
area=overlap, length=before + after, volume=overlap * (before + after)
)
)
row.update(
start_m=chainage - before,
end_m=chainage + after,
options={
**(kept if legacy else {}),
"form": form,
"height_m": height,
"length_m": before + after,
"before_m": before,
"after_m": after,
"foundation": foundation,
},
notes=tier_notes,
withheld=False,
unconfirmed=" · ".join(reasons),
)
rows.append(row)
return rows
@@ -56,6 +56,7 @@ from B08_Quantity.B08_Quantity_Engine_Preparation_Status import ( # noqa: E402
STATUS_PENDING,
STATUS_READY,
)
from B08_Quantity.B08_Quantity_Engine_Preparation_TreeWaste import tree_waste_rows # noqa: E402
__all__ = ["ANCILLARY_ITEMS", "ancillary_rows"] # 갈라 나간 뒤에도 여기서 읽을 수 있게
@@ -522,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(
@@ -548,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",
@@ -667,6 +673,10 @@ def build_table(
chipping_volume_m3: Any = None,
road_surface_area_m2: float | None = None,
topsoil_road_only: bool = False,
tree_waste: dict[str, Any] | None = None,
tree_waste_unit_price_krw_per_ton: Any = None,
waste_separate_order: Any = False,
tree_waste_root_method: Any = None,
) -> dict[str, Any]:
"""화면·인계가 그대로 쓰는 모양. **못 서는 줄도 목록에 남는다.**"""
rows = (
@@ -681,6 +691,14 @@ def build_table(
)
+ erosion_rows(structures, names)
+ chipping_rows(chipping_enabled, chipping_volume_m3)
# 임목폐기물 — 톤 · 비목은 경비(내역 줄 아님 · 2026-09-14 판정).
+ tree_waste_rows(
slope_totals,
tree_waste,
tree_waste_unit_price_krw_per_ton,
waste_separate_order,
tree_waste_root_method,
)
+ ancillary_rows(ancillary_counts)
)
return {
@@ -0,0 +1,181 @@
"""준비공 — 임목폐기물 처리 한 줄 (톤) · 2026-09-14 판정 「폐기물처리비」.
수량 산식 한국건설기술연구원 건설현장 임목폐기물의 재활용 자원화 기술개발(2012) 3.2
= 환경친화적인 도로건설 지침 부록1 문단 493~508 3) 임목폐기물(산출근거: 조경설계요람)
지상부 WA = k × 3.14 × (B/2)² × h × (1+p) × W1 × N (kg · 지침 식이 π 3.14 적음)
뿌리부 지침이 방법을 둔다. 고르는 .
분배비(기본) WR = WA × 15/85 (KICT 일반 수목류 2026-09-14 판정 유지)
뿌리분 체적 WR = 0.3927 × × 1,300 × N (지침 V = πD²(D/2 + RH2/3) = 0.3927
· UW1 뿌리부분의 단위중량 1,300kg/m3). D 지침이 뿌리분의 직경(DBH, m)으로
적었으나 2026-09-14 판정으로 **뿌리분 직경**(흉고직경 아님) 따로 받고 기본값 없음.
k 수간 형상계수 0.5 · p 지엽 보합률(임목 0.3) **산식 상수** 칸이 없다.
B 평균 흉고직경 · h 평균 수고 · W1 수목 단위체적중량 · N 본수
**현장 조사값**이라 칸으로 받는다.
N = 표본지 1,000 실측 본수 × 대상 면적 ÷ 1,000 (실정보고 134/1000 × 7,450 모양).
대상 면적 = 벌목·지장목제거 면적(같은 나무를 자리).
실무 실정보고(임목폐기물처리, 현장) 뿌리 14:86 · 무게 0.65/ ·
처리비를 공급가액 (승률 ) 두었다 판정은 문언·공인 자료 (PLAN 10).
발주처가 요구하면 칸으로 자리.
금액은 **처리단가가 없어** 선다(KCRA 중간처리단가 미확보) 수동 단가를 넣으면
금액이 서되 미확정으로 센다(빨간 테두리). 비목은 **경비** 내역 줄이 아니고
원가계산서 폐기물처리비 줄로 간다(예정가격작성기준 제19조③18호).
"""
from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_Preparation_Status import STATUS_PENDING, STATUS_READY
TREE_WASTE_ITEM = "임목폐기물 처리"
STEM_FORM_FACTOR = 0.5 # k
FOLIAGE_ALLOWANCE = 0.3 # p — 임목(고립목은 1.0)
PI_AS_WRITTEN = 3.14 # 지침 부록1 식 원문 · 실정보고도 3.14
ROOT_SHARE, ABOVE_SHARE = 15.0, 85.0 # 일반 수목류 분배비
ROOT_BALL_FACTOR = 0.3927 # V = 0.3927 D³ (지침 부록1)
ROOT_BALL_UNIT_WEIGHT = 1300.0 # ㎏/㎥ (지침 부록1 UW1)
ROOT_METHOD_RATIO, ROOT_METHOD_BALL = "ratio", "root_ball"
#: 칸 이름 · 화면 이름 · 단위 — 모두 채워야 수량이 선다.
TREE_WASTE_INPUTS: tuple[tuple[str, str], ...] = (
("stems_per_1000m2", "표본지 1,000㎡당 본수"),
("dbh_cm", "평균 흉고직경(㎝)"),
("height_m", "평균 수고(m)"),
("unit_weight_kg_m3", "단위체적중량(㎏/㎥)"),
)
TREE_WASTE_BASIS = (
"환경친화적인 도로건설 지침 부록1 · 한국건설기술연구원(2012) 3.2 — k 0.5 · p 0.3(임목) · π 3.14"
" · N = 표본지 1,000㎡ 본수 × 벌목·지장목제거 면적"
)
ROOT_RATIO_BASIS = "뿌리 WR = WA × 15/85(분배비 · 일반 수목류)"
ROOT_BALL_BASIS = "뿌리 WR = 0.3927 ×× 1,300㎏/㎥ × N(뿌리분 체적 · 지침 부록1)"
ROOT_BALL_LABEL = "뿌리분 직경 D(m, 흉고직경 아님 · 굴취 흙덩이 직경)"
#: 배출자 신고 경계 — 화면이 알릴 뿐 값을 바꾸지 않는다.
REPORT_THRESHOLD_TON = 5.0
NOT_SEPARATE_NOTE = (
"임목폐기물은 분리발주 대상 아님(환경부 질의회신 — 임목폐기물처리 실정보고 p4 ·"
" 건설폐기물 업무처리지침: 건설폐기물 아닌 사업장 일반폐기물)"
)
EXPENSE_NOTE = (
"비목은 경비(예정가격작성기준 제19조③18호) — 내역 줄이 아니라 원가계산서 폐기물처리비로 감"
)
def _positive(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
def _missing(inputs: dict[str, Any], root_method: str) -> list[str]:
"""비어 있는 칸 이름 — 뿌리분 체적법이면 뿌리분 직경도 든다."""
missing = [label for key, label in TREE_WASTE_INPUTS if _positive(inputs.get(key)) is None]
if root_method == ROOT_METHOD_BALL and _positive(inputs.get("root_ball_diameter_m")) is None:
missing.append(ROOT_BALL_LABEL)
return missing
def tree_waste_tons(
area_m2: float, inputs: dict[str, Any], root_method: str = ROOT_METHOD_RATIO
) -> tuple[float, float] | None:
"""(지상부 톤, 뿌리부 톤). 칸이 하나라도 비거나 면적이 0 이면 `None`."""
if area_m2 <= 0 or _missing(inputs, root_method):
return None
stems = float(inputs["stems_per_1000m2"]) * area_m2 / 1000.0
diameter = float(inputs["dbh_cm"]) / 100.0
above_kg = (
STEM_FORM_FACTOR
* PI_AS_WRITTEN
* (diameter / 2.0) ** 2
* float(inputs["height_m"])
* (1.0 + FOLIAGE_ALLOWANCE)
* float(inputs["unit_weight_kg_m3"])
* stems
)
if root_method == ROOT_METHOD_BALL:
ball = float(inputs["root_ball_diameter_m"])
root_kg = ROOT_BALL_FACTOR * ball**3 * ROOT_BALL_UNIT_WEIGHT * stems
else:
root_kg = above_kg * ROOT_SHARE / ABOVE_SHARE
return above_kg / 1000.0, root_kg / 1000.0
def tree_waste_rows(
slope_totals: dict[str, float] | None,
inputs: dict[str, Any] | None,
unit_price_krw_per_ton: Any = None,
separate_order: Any = False,
root_method: Any = None,
) -> list[dict[str, Any]]:
"""임목폐기물 처리 한 줄 — 값이 없어도 줄은 선다(무엇을 넣으면 풀리는지 적음)."""
slope = slope_totals or {}
area = float(slope.get("tree_removal_fill", 0.0)) + float(slope.get("tree_removal_cut", 0.0))
inputs = inputs or {}
method = ROOT_METHOD_BALL if root_method == ROOT_METHOD_BALL else ROOT_METHOD_RATIO
root_basis = ROOT_BALL_BASIS if method == ROOT_METHOD_BALL else ROOT_RATIO_BASIS
base = {
"group": "준비공",
"item": TREE_WASTE_ITEM,
"unit": "",
"work_item_code": None,
# 경비 — 내역에 세우지 않는다(인계가 이 칸을 본다).
"bill": False,
"bill_reason": EXPENSE_NOTE,
"separate_order": bool(separate_order),
}
tons = tree_waste_tons(area, inputs, method)
if tons is None:
missing = _missing(inputs, method)
why = (
f"현장 조사값이 비어 있음 — {' · '.join(missing)}"
if missing
else "벌목·지장목제거 면적이 0 ㎡ — 사면표가 아직 안 섬"
)
return [
{
**base,
"amount": None,
"status": STATUS_PENDING,
"reason": f"{why}. 산식: {TREE_WASTE_BASIS} · {root_basis}",
"reference_amount": area,
}
]
above, root = tons
total = above + root
notes = [
f"지상부 WA {above:,.2f}톤 + 뿌리부 WR {root:,.2f}",
TREE_WASTE_BASIS,
root_basis,
EXPENSE_NOTE,
NOT_SEPARATE_NOTE,
]
if total >= REPORT_THRESHOLD_TON:
notes.append("5톤 이상 — 배출자 신고 대상 사업장(폐기물관리법 시행령 제2조8호)")
price = _positive(unit_price_krw_per_ton)
row = {
**base,
"amount": total,
"status": STATUS_READY,
"reference_amount": area,
"above_ground_ton": above,
"root_ton": root,
"unit_price_krw_per_ton": price,
"amount_krw": math.floor(total * price) if price else None,
# 수동 단가로 선 금액 — 화면 「미확정 N건」·빨간 테두리.
"unconfirmed": 1 if price else 0,
}
if price:
notes.append(
f"처리비 {total:,.2f}× {price:,.0f}원 = {math.floor(total * price):,}"
" (⚠ 수동 단가 — 미확정 · KCRA 중간처리단가 미확보)"
)
else:
notes.append(
"처리단가 없음(KCRA 중간처리단가 미확보) — 산출 조건에 수동으로 넣으면 금액이 섬"
)
row["reason"] = " · ".join(notes)
return [row]
+20 -1
View File
@@ -134,6 +134,24 @@ def totals(rows: list[SlopeAreaRow]) -> dict[str, float]:
return {key: sum(row.areas.get(key, 0.0) for row in rows) for key in keys}
def cut_material_totals(
slopes: Iterable[StationSlope], ratios: SlopeRatios | None = None
) -> dict[str, float]:
"""면고르기 절토면의 토사·암 몫 면적 — 같은 평균단면적법 · 같은 반영률(합 ≤ 절토면 전체)."""
rate = (ratios or SlopeRatios()).of("face_dressing")
ordered = sorted(slopes, key=lambda s: s.chainage_m)
result = {"face_dressing_cut_soil": 0.0, "face_dressing_cut_rock": 0.0}
for before, after in zip(ordered, ordered[1:]):
distance = after.chainage_m - before.chainage_m
for key, field_name in (
("face_dressing_cut_soil", "cut_soil_length_m"),
("face_dressing_cut_rock", "cut_rock_length_m"),
):
pair = getattr(before, field_name) + getattr(after, field_name)
result[key] += pair / 2.0 * distance * rate
return result
def unclosed_stations(rows: list[SlopeAreaRow]) -> list[float]:
"""사면이 원지반을 못 만나 **면적이 잘린** 측점 목록.
@@ -148,6 +166,7 @@ def build_table(
) -> dict[str, Any]:
"""화면·API 가 그대로 쓰는 모양."""
rates = ratios or SlopeRatios()
slopes = list(slopes)
rows = build_rows(slopes, rates)
return {
"method": "average_end_area",
@@ -166,7 +185,7 @@ def build_table(
}
for row in rows
],
"totals": totals(rows),
"totals": {**totals(rows), **cut_material_totals(slopes, rates)},
"unclosed_stations": unclosed_stations(rows),
"station_count": len(rows),
}
@@ -58,6 +58,10 @@ class StationSlope:
chainage_m: float
cut_length_m: float = 0.0
fill_length_m: float = 0.0
#: 절토 사면길이의 토사·암 몫 — 면고르기가 9-19-1(토사)·9-19-2·3(암)으로 갈림(2026-09-14).
#: 둘의 합이 `cut_length_m` 보다 작으면 그 차이는 **못 가른 몫**(설계 지반 프리셋 없음).
cut_soil_length_m: float = 0.0
cut_rock_length_m: float = 0.0
#: 층따기 밑수 — **원지반 표면**의 경사길이(m). B06 설계가 측점마다 낸다
#: (`design.bench_cut_length_m`, 2026-09-09 랩탑 메인).
#: ⚠ **성토 비탈면 길이와 다른 면이다** — 층따기는 성토부 **아래 원지반**을 계단으로
@@ -240,9 +244,18 @@ def station_slope(chainage_m: float, design: dict[str, Any]) -> StationSlope:
side: sum(abs(s.rise_m) for s in segments if s.role == "fill" and s.side == side)
for side in ("left", "right")
}
# 2단 비탈은 경사비가 토사·암을 가름 · 1단은 그 측점 설계의 지반 프리셋(토사/암)이 비탈 전체
preset = {"soil": "soil", "rock": "rock"}.get(str(design.get("geometry_preset") or ""))
cut_by = {"soil": 0.0, "rock": 0.0}
for segment in segments:
material = segment.material or preset
if segment.role == "cut" and material in cut_by:
cut_by[material] += segment.length_m
return StationSlope(
chainage_m=float(chainage_m),
cut_length_m=sum(s.length_m for s in segments if s.role == "cut"),
cut_soil_length_m=cut_by["soil"],
cut_rock_length_m=cut_by["rock"],
fill_length_m=sum(s.length_m for s in segments if s.role == "fill"),
# ⚠ 없는 측점은 0 이다 — 성토 사면길이로 **대신 채우지 않는다**(면이 다름).
bench_cut_length_m=_num(design.get("bench_cut_length_m")) or 0.0,
@@ -0,0 +1,222 @@
"""STmate 출력 엑셀 「일위대가표」 읽개 → **고정형** 라이브러리 항목 (PLAN 4장 · 2026-09-14 브레인 판정).
엑셀인가 STC 호표(B) 구성이 난독화된 BDQTY 에만 있어 읽음(7파일 전수) · 경쟁사 보호 장치는
. 출력 엑셀은 평문이고 모양은 코덱스 `recipe_extract.py`(실무 6 누락 0) 그대로.
**모양이 다르면 억지로 읽지 않음** 머리 ·수량 칸이 어긋나면 어느 칸이 맞는지 사유로
돌려보내고 호표는 통째로 . 추측해서 맞추면 수량이 조용히 틀린 라이브러리에 들어가 계속 쓰임.
뽑는 것은 **수량만**(판정 ) 원문 단가·금액은 실음. 수량도 시점 품셈값이라 사유를 붙임.
단만(판정 ) 구성 줄이 하위 호표면 이름·수량만. 하위 전개는 우리 일위대가 .
원문 호표·자원 코드(B#####·M#####)는 파일 안 카운터라 **출처로 기록만**(판정 Ⓓ).
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import Any, BinaryIO
SHEET = "일위대가표"
HEADER_ROW = 3
HEADER = ("명칭", "규격", "수량", "단위")
COLUMNS = "ABCD"
HOPYO = re.compile(r"^제(\d+)호표$")
ROW_CODE = re.compile(r"(?<!BD)COD\|([A-Z]\d{5})")
HOPYO_CODE = re.compile(r"BDCOD\|(B\d{5})")
#: 비고가 이것이면 별도계상 자재 — 자재총괄 자리(판정 ㉮). 그 밖의 비고로는 갈 곳을 안 가름.
SEPARATE_MATERIAL = ("별산자재", "별산M")
#: 착공·변경 내역서 판에만 섞이는 낙찰률 줄 — 설계 조합이 아니라 안 뽑음(판정 ㉰).
#: 실무 6건 전수에서 단위 없는 줄은 이 다섯 꼴뿐: 합계 · 계 · 소계 · 계약단가 · 계 x 낙찰율(88 %).
CONTRACT_ROWS = ("계약단가", "계x낙찰율")
NOTE_SOURCE_QTY = "원문 시점 수량 · 현행 품셈 대조 전"
NOTE_PERCENT = "원문에 가산 행 {count}줄 있었음({names}) — 우리 계산에는 안 듦"
NOTE_CONTRACT = "원문 계약단가·낙찰율 줄 {count}줄은 설계 조합이 아니라 안 뽑음"
def _text(value: Any) -> str:
return "" if value is None else str(value).strip()
def read_recipes(path: str | Path | BinaryIO) -> dict[str, Any]:
"""`{"project": 공사명, "hopyo": [호표…], "problems": [사유…]}` — 못 읽은 호표는 목록에 없고 사유만."""
import openpyxl
problems: list[str] = []
try:
book = openpyxl.load_workbook(path, data_only=True, read_only=True)
except Exception as error: # noqa: BLE001 — 어떤 파일이든 사유로 돌려보냄
return {"project": "", "hopyo": [], "problems": [f"엑셀을 못 엶 — {error}"]}
if SHEET not in book.sheetnames:
return {
"project": "",
"hopyo": [],
"problems": [f"시트 「{SHEET}」가 없음 — STmate 출력 엑셀이 아님"],
}
rows = [list(r) + [None] * 14 for r in book[SHEET].iter_rows(max_col=14, values_only=True)]
head = rows[HEADER_ROW - 1] if len(rows) >= HEADER_ROW else [None] * 14
for column, want, got in zip(COLUMNS, HEADER, head):
if _text(got).replace(" ", "") != want:
problems.append(
f"{column}{HEADER_ROW} 칸이 「{want}」이 아니라 「{_text(got)}」 — 이 모양은 못 읽음"
)
if problems:
return {"project": "", "hopyo": [], "problems": problems}
project = _text(rows[1][0]).split(":", 1)[-1].strip() if len(rows) > 1 else ""
hopyo: list[dict[str, Any]] = []
current: dict[str, Any] | None = None
def close() -> None:
if current and not current["broken"]:
if current["name"]:
hopyo.append({k: v for k, v in current.items() if k != "broken"})
else:
problems.append(f"{current['no']}호표 제목 줄이 없음 — 안 읽음")
for index, row in enumerate(rows[HEADER_ROW:], start=HEADER_ROW + 1):
name, spec, qty, unit = _text(row[0]), _text(row[1]), row[2], _text(row[3])
key = name.replace(" ", "")
found = HOPYO.match(key)
if found:
close()
code = HOPYO_CODE.search(_text(row[13]))
current = {
"no": int(found.group(1)),
"source_code": code.group(1) if code else "",
"name": "",
"spec": "",
"unit": "",
"rows": [],
"basis": [],
"contract_rows": 0,
"broken": False,
}
continue
if key.startswith("합계"):
close()
current = None
continue
if current is None or current["broken"] or not any(_text(c) for c in row[:4]):
continue
where = f"{current['no']}호표"
if not current["name"]:
if not name or qty not in (None, "") or not unit:
problems.append(
f"A{index}: {where} 제목 줄(명칭·단위 · 수량 빈칸) 모양이 아님 — 안 읽음"
)
current["broken"] = True
else:
current.update(name=name, spec=spec, unit=unit)
continue
if not unit and (key == "" or key.startswith("소계")):
continue # 착공·변경 판 소계 줄 — 구성 아님
if not unit and key.startswith(CONTRACT_ROWS):
current["contract_rows"] += 1
continue
if not unit and qty == 0 and not isinstance(qty, bool):
current["basis"].append(f"{name} {spec}".strip()) # 품셈 근거 줄 — 수량 0 · 단위 없음
continue
if isinstance(qty, bool) or not isinstance(qty, (int, float)):
problems.append(
f"C{index}: {where}{name}」 수량 「{_text(qty)}」이 수가 아님 — 안 읽음"
)
current["broken"] = True
continue
if not name or not unit:
empty = "명칭" if not name else "단위"
problems.append(
f"{'A' if not name else 'D'}{index}: {where}{name}{empty}이 빔 — 이 모양은 못 읽음"
)
current["broken"] = True
continue
code = ROW_CODE.search(_text(row[13]))
current["rows"].append(
{
"name": name,
"spec": spec,
"amount": qty,
"unit": unit,
"remark": _text(row[12]),
"source_code": code.group(1) if code else "",
}
)
close()
return {"project": project, "hopyo": hopyo, "problems": problems}
def recipe_item(
hopyo: dict[str, Any], *, type_id: str, file_name: str, project: str
) -> dict[str, Any]:
"""읽은 호표 하나 → 고정형 라이브러리 항목(명세 13장 칸 계약 · `formula` 빈칸 = 고정형). 코드는 저장이 발급."""
basis = " · ".join(hopyo.get("basis") or [])
rows = []
percent = []
for seq, source in enumerate(hopyo["rows"], start=1):
is_percent = source["unit"] == "%"
if is_percent:
percent.append(source["name"])
separate = any(mark in source["remark"].replace(" ", "") for mark in SEPARATE_MATERIAL)
rows.append(
{
"seq": seq,
"name": source["name"],
"spec": source["spec"],
"formula": "",
"formula_text": " · ".join(part for part in ("STmate 원문 수량", basis) if part),
"amount": str(source["amount"]),
"unit": source["unit"],
# 원문에서 구성 줄은 그 호표 단가 안으로 들어감(호표 = 일위대가) · 별도계상만 자재총괄(판정 ㉮)
"destination": "reference"
if is_percent
else "material"
if separate
else "unit_price",
"rounding": {"mode": "none", "digits": 0},
"source": "library",
"reason": "가산 행 — 수량 계산에 안 씀" if is_percent else NOTE_SOURCE_QTY,
}
)
# ㉮ 일위대가 구성 — 일위대가로 가는 줄마다 한 줄(수량 = 그 원단위 줄). **공종 코드는 비움**:
# 원문 코드는 파일 카운터라 못 쓰고 이름으로 확정도 안 함(명세 2장) → 사용자가 고르개로 고름.
# 고르기 전엔 그 장 금액이 「미완 — 공종 코드 미정」(조용한 0 아님 · 2026-09-14 판정).
unit_rows = [
{
"seq": index,
"name": row["name"],
"spec": row["spec"],
"unit": row["unit"],
"from_row": row["seq"],
}
for index, row in enumerate(
(row for row in rows if row["destination"] == "unit_price"), start=1
)
]
notes = [NOTE_SOURCE_QTY]
if percent:
notes.append(NOTE_PERCENT.format(count=len(percent), names=" · ".join(percent)))
if hopyo.get("contract_rows"):
notes.append(NOTE_CONTRACT.format(count=hopyo["contract_rows"]))
return {
"schema_version": 1,
"item_kind": "fixed",
"type_id": type_id,
"name": f"{hopyo['name']} {hopyo['spec']}".strip(),
"unit": hopyo["unit"],
"note": " · ".join(notes),
"vars": {},
"tables": {},
"rows": rows,
"unit_price": {
"unit": hopyo["unit"],
"note": "STmate 호표 구성 — 줄마다 공종 코드를 고르개로 골라야 금액이 섬(원문 코드는 안 씀)",
"rows": unit_rows,
},
# 출처 — 원문 공사명·파일명이 들어감. 공유 만들 때 뺄지·가릴지 판정(PLAN 4장).
"origin": {
"kind": "stmate_xlsx",
"file": file_name,
"project": project,
"hopyo_no": hopyo["no"],
"hopyo_code": hopyo["source_code"],
},
}
@@ -0,0 +1,456 @@
"""구조물도 **상단 그림** — 돌쌓기 단면 (PLAN 12장 · 3장 한 장의 짜임 「상단 그림」).
**2026-09-14 B07 에서 이관** 자리 `B07_DesignDetail_Engine_Cad_StandardFigure.py`.
CAD 엔티티 대신 **모양 목록(·글자, m 단위, 위가 +y)** 화면이 SVG 그리고(읽기 전용),
CAD 일감이 같은 목록을 받아 (B07 `StandardSheet` 이미 옮겨 적음).
**기울기를 값으로 받는다 0.3 박지 않는다.**
판정은 B08 `slope_of()` (품셈 13-4-4 [] 표준경사 직고·메찰·성절토). 사용자가 정했으면
값이 이긴다(확정 ). 그림은 **수량이 바로 **으로 기울고 근거 문구도 같이 적는다.
**두께는 수량표가 쓰는 함수**(`UnitQuantity.wall_thickness`) 사용자가 넣은 상부·하부 두께가
식을 이긴다. B07 그림은 식을 따로 적어 **두께를 넣어도 그림만 두께** 섰음(이관 잡음).
상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H 1.0)
터파기 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림)
터파기 치수는 **`common_util_excavation` ** 읽는다 횡단도가 쓰는 상수다.
뒷길이를 정한 장은 **표가 기본값으로 그리고 사유와 같은 말을 적는다**
(2026-09-14 브레인 판정 표가 서면 그림도 ). 까닭은 정말 그리는 (큰돌쌓기 ).
**치수가 없는 것은 그리되 치수를 적지 않는다** 막자갈(뒷채움) 우리 식이 입적에서
몸통·고임돌을 이라 ·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다.
"""
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 sheet_is_wet, slope_of
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
_back_length,
back_length_default_note,
wall_thickness,
weep_hole_spec,
)
from common_util.common_util_excavation import (
WALL_BLINDING_DEPTH_M,
WALL_BLINDING_WIDTH_M,
WALL_FOUNDATION_DEPTH_M,
WALL_FOUNDATION_WIDTH_M,
WALL_TRENCH_CLEARANCE_M,
)
#: 그림 축척 — 1/25(1m = 40㎜). CAD 로 옮길 때 글자 크기(㎜)를 m 로 바꾸는 데도 씀.
SCALE_MM_PER_M = 40.0
#: 이 그림을 그리는 종류. 집수정처럼 단면이 다른 것은 아직 그리지 않는다.
#: ⚠ **큰돌쌓기(`boulder_masonry`)를 뺐다**(2026-09-09). 그림이 두께를 **뒷길이**에서 내는데
#: 큰돌쌓기는 규격 축이 **직경**(`stone_cm`)이라 **영영 못 그린다** — 품셈 13-6(직경) ↔
#: 13-4(뒷길이). 「직경에서 두께를 내는 식」은 도메인 판단이라 사용자 몫.
#: ⓘ 옹벽(2026-09-14)은 **관측 원단위 줄에 실린 단면 치수**로 그림 — 표 수량이 나온 그 단면.
#: 기슭막이는 형태가 돌쌓기면 **돌쌓기 식 그대로**라 그림도 같음(표가 안 선 형태는 표 사유가 까닭).
MASONRY_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "revetment"})
#: 정본 개소당·㎡당 구조물 — `_StructureFigure_Small`(치수는 표가 쓴 한 벌).
SMALL_TYPE_IDS: frozenset[str] = frozenset({"erosion_check", "soil_guard", "bed_sill"})
#: 관측 원단위 줄의 `section`(표 수량이 나온 그 단면)으로 그리는 종류 — 단면 없는 규격은 까닭.
OBSERVED_TYPE_IDS: frozenset[str] = frozenset(
{"retaining_wall", "pipe_inlet_basin", "pipe_wing_wall", "ford_pavement"}
)
FIGURE_TYPE_IDS: frozenset[str] = MASONRY_TYPE_IDS | SMALL_TYPE_IDS | OBSERVED_TYPE_IDS
#: 개거 — 표는 서는데 그림을 못 정한 까닭(2026-09-14 원문 대조).
_OPEN_DITCH_REASONS: dict[str, str] = {
"콘크리트 개거 150×200": (
"그림 없음 — 원문 둘이 다름(산출식 0.3×0.1 ↔ 그림 홈 150×200 + 밑 보 400×200)"
" · 같은 탭 「개거(150-200)」 안에서 갈림"
" · 어느 쪽으로 그릴지 확인 대기(임의로 고르지 않음)"
),
"L형수로 H=0.2": (
"그림 없음 — 원문 「L형수로-(201)」 탭에 단면 그림이 없어"
" 산출식만으로 모양을 정하지 않습니다"
),
}
#: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다
#: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
_NO_FIGURE_REASONS: dict[str, str] = {
"boulder_masonry": (
"그림 없음 — 큰돌쌓기는 규격이 **직경**이라 두께를 낼 식이 아직 없습니다"
"(뒷길이로 내는 돌쌓기 식과 축이 다름 · 사용자 확정 대기)"
),
}
_NO_FIGURE_DEFAULT = "그림 없음 — 이 형식은 아직 단면을 그리지 않습니다"
#: 글자 크기(m) — 종이 ㎜ 를 축척으로 나눈 값(옛 CAD 그림과 같은 크기).
_LABEL_SIZE = 4.0 / SCALE_MM_PER_M
_DIM_SIZE = 3.4 / SCALE_MM_PER_M
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, 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:
"""그림이 **안 서는 까닭** 한 줄. 서는 장이면 `None`."""
type_id = str(sheet.get("type_id") or "")
if "rows" in sheet and not sheet["rows"]:
# 표가 안 선 장 — 표가 적은 그 사유를 그대로(표 = 그림 · 같은 말).
notes = [str(note).replace("**", "") for note in sheet.get("notes") or []]
return f"그림 없음 — {notes[0] if notes else '표가 서지 않았습니다'}"
if type_id == "open_ditch":
spec = str((sheet.get("options") or {}).get("ditch_spec") or "콘크리트 개거 150×200")
return _OPEN_DITCH_REASONS.get(spec, _NO_FIGURE_DEFAULT)
if type_id not in FIGURE_TYPE_IDS:
return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT)
if type_id in SMALL_TYPE_IDS:
return None # 치수가 없으면 표가 안 서고, 그때는 위 「표가 안 선 장」이 사유를 냄
if type_id in OBSERVED_TYPE_IDS:
return _observed_section(sheet)[1]
if float(sheet.get("height_m") or 0.0) <= 0:
return "그림 없음 — 높이가 없습니다(제원에서 높이를 넣으면 그림이 섭니다)"
return None
def _observed_section(sheet: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
"""(관측 원단위 줄, 못 그리는 까닭) — 표가 줄을 꺼낸 **그 조회**(`OBSERVED_SPEC_KEYS`)를 씀.
표가 장은 사유와 같은 앞에 그림 없음 붙임.
"""
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import load_observed_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import OBSERVED_SPEC_KEYS
from B08_Quantity.B08_Quantity_Wording import option_missing, spec_missing
type_id = str(sheet.get("type_id") or "")
keys = OBSERVED_SPEC_KEYS.get(type_id) or ()
options = sheet.get("options") or {}
spec = {key: options[key] for key in keys if options.get(key) is not None}
table = load_observed_table()
if not spec:
return None, f"그림 없음 — {option_missing(keys[0], type_id)}"
entry = table.find(type_id, spec)
if entry is None:
return None, f"그림 없음 — {spec_missing(type_id, table.specs_for(type_id))}"
if not entry.get("section"):
why = _NO_SECTION_REASONS.get(type_id) or _NO_SECTION_BY_SOURCE.get(
str(entry.get("source") or ""), "이 규격은 자료에 수량만 있고 단면 치수가 없습니다"
)
return None, f"그림 없음 — {why}"
return entry, None
#: 관측 줄에 단면이 없는 까닭 — 종류별(원문을 봤는데 못 정한 자리).
_NO_SECTION_REASONS: dict[str, str] = {
"pipe_wing_wall": (
"원문 「날개벽(…)산출근거」 탭에 구조도 그림이 없고(「구조도 참조」만) 날개 모양이"
" 산출식에 손으로 적은 수로만 있어 모양을 정하지 않음 — 구조도 원본 확보 대기"
),
}
#: 출처별 — 수량 집계표 관측값은 치수 원문 자체가 없음.
_NO_SECTION_BY_SOURCE: dict[str, str] = {
"uljin_compare": "수량 집계표 관측값(울진 1공구)이라 구조도·치수 원문이 없습니다",
}
def _path(points: list[tuple[float, float]], role: str, *, dash: bool = False) -> dict[str, Any]:
return {"kind": "path", "points": [list(p) for p in points], "role": role, "dash": dash}
def _text(
text: str, at: tuple[float, float], align: str, size: float = _DIM_SIZE
) -> dict[str, Any]:
return {"kind": "text", "text": text, "at": list(at), "align": align, "size": size}
def build_figure(
sheet: dict[str, Any], rubble_thickness_m: float | None = None
) -> list[dict[str, Any]] | None:
"""장 하나의 그림 — 모양 목록(밑면 앞끝이 원점). 못 그리면 `None`(까닭은 `figure_reason`).
`rubble_thickness_m` 산출 조건 기초잡석 두께(표의 기초잡석 줄과 같은 · 비면 기본).
"""
if figure_reason(sheet) is not None:
return None
type_id = sheet.get("type_id")
if type_id == "retaining_wall":
entry, _reason = _observed_section(sheet)
return _wall_figure(sheet, entry or {}, rubble_thickness_m)
if type_id in ("pipe_inlet_basin", "ford_pavement"):
from B08_Quantity import B08_Quantity_Engine_StructureFigure_Small as small
entry, _reason = _observed_section(sheet)
if type_id == "pipe_inlet_basin":
return small.inlet_basin_figure(sheet, entry or {})
return small.pavement_figure(sheet, entry or {})
if type_id in SMALL_TYPE_IDS:
from B08_Quantity import B08_Quantity_Engine_StructureFigure_Small as small
if type_id == "bed_sill":
return small.bed_sill_figure(sheet, rubble_thickness_m)
if type_id == "erosion_check":
return small.erosion_check_figure(sheet)
return small.soil_guard_figure(sheet)
return _masonry_figure(sheet)
def _masonry_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]:
"""돌쌓기 단면 — 두께는 수량표 함수 한 벌."""
options = sheet.get("options") or {}
height_m = float(sheet.get("height_m") or 0.0)
back_cm, default_note = back_length_cm(sheet)
slope, slope_note = slope_of(sheet)
top_t, bottom_t, _basis = wall_thickness(options, back_cm=back_cm, height_m=height_m)
lean = slope * height_m
average_t = (top_t + bottom_t) / 2.0
dig_width = average_t + WALL_TRENCH_CLEARANCE_M
# 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목). 안 정한 장은 안 그린다.
foundation = str(options.get("foundation") or "")
base_w, base_d = {
"기초유": (WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M),
"기초버림": (WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M),
}.get(foundation, (0.0, 0.0))
shapes = [
_path(
[(0.0, 0.0), (lean, height_m), (lean + top_t, height_m), (bottom_t, 0.0), (0.0, 0.0)],
"wall",
),
# 터파기 — 수량식 H × (평균두께 + 0.2) 와 **넓이가 같은** 평행사변형(앞면 따라 기움).
_path(
[
(0.0, 0.0),
(lean, height_m),
(lean + dig_width, height_m),
(dig_width, 0.0),
(0.0, 0.0),
],
"guide",
dash=True,
),
]
if base_d > 0:
shapes.append(
_path([(0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0)], "guide")
)
# 물구멍 — 벽을 가로지르는 짧은 선. 값은 **그 장의 제원**에서(수량표와 같은 함수).
weep_area_m2, weep_diameter_mm, _weep_basis = weep_hole_spec(options)
weep_y = height_m * 0.5
shapes.append(_path([(slope * weep_y, weep_y), (slope * weep_y + top_t, weep_y)], "guide"))
right = max(lean + dig_width, base_w) + 0.16
shapes += [
_text(f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"),
_text(f"상부 {top_t:.2f} m", (lean + top_t / 2.0, height_m + 0.14), "center"),
_text(f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30 - base_d), "center"),
_text(f"1 : {slope:g}", (lean / 2.0 - 0.30, height_m * 0.72), "right"),
_text(
f"터파기 폭 {dig_width:.2f} m "
f"(평균두께 {average_t:.2f} + {WALL_TRENCH_CLEARANCE_M:g})",
(right, height_m * 0.92),
"left",
),
_text(
f"뒷길이 ℓ₃ = {back_cm}{' (기본)' if default_note else ''}",
(right, height_m * 0.72),
"left",
),
_text("막자갈(뒷채움) — 수량은 아래 표", (right, height_m * 0.55), "left"),
_text(
f"물구멍 Ø{weep_diameter_mm}{weep_area_m2:g}㎡당 1개소",
(right, weep_y - 0.2),
"left",
),
_text(
f"기초 {base_w:g} × {base_d:g} m ({foundation})"
if base_d > 0
else "기초 — 안 정함(기초유/기초버림)",
(right, -base_d / 2.0 if base_d else 0.0),
"left",
),
# 기울기 근거는 그림 밑에 한 줄 — 「사용자 지정」인지 「품셈 표준경사」인지 보여야 한다.
_text(f"전면 기울기 — {slope_note}", (0.0, -0.62 - base_d), "left"),
_text(
f"{sheet.get('title') or '돌쌓기'} (축척 1/{int(1000 / SCALE_MM_PER_M)})",
(0.0, height_m + 0.52),
"left",
_LABEL_SIZE,
),
]
# 「이 값이 어디서 왔나」 — 표 사유 줄과 **글자까지 같게**(브레인 판정). 벽 치수 자체가
# 기본값인 관 지점 기슭막이는 그 줄도 — 그림이 설계값처럼 보이면 더 위험함(2026-09-14).
wall_prefix = NOTE_DEFAULT_WALL.split("{")[0]
lines = [default_note] + [
str(n) for n in sheet.get("notes") or [] if str(n).startswith(wall_prefix)
]
for index, line in enumerate(line for line in lines if line):
shapes.append(_text(line, (0.0, -0.80 - base_d - index * 0.18), "left"))
return shapes
def _wall_figure(
sheet: dict[str, Any], entry: dict[str, Any], rubble_thickness_m: float | None
) -> list[dict[str, Any]]:
"""옹벽 단면 — 관측 원단위 줄의 `section`(표 수량이 나온 그 단면)으로 그림.
x 앞굽 끝이 0(앞면 왼쪽), y 기초 밑면이 0. 버림·기초잡석은 기초 , 전단키가 뚫고 내려감.
기초잡석 두께는 표의 기초잡석 줄과 같은 산출 조건 (비면 기본) 폭은 버림과 같음.
"""
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import earthwork_missing_note
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import RUBBLE_BASE_THICKNESS_M
section = entry["section"]
footing, wall, key = section["footing"], section["wall"], section["key"]
blinding, weep = section["blinding"], section["weep"]
f_w, f_t, toe = footing["width_m"], footing["thickness_m"], footing["toe_m"]
w_h, w_top = wall["height_m"], wall["top_m"]
front, back = wall["front_batter_m"], wall["back_batter_m"]
w_bottom = w_top + front + back
heel = f_w - toe - w_bottom
k_w, k_d = key["width_m"], key["depth_m"]
k_left = f_w - key["from_heel_m"] - k_w
b_t, over = blinding["thickness_m"], blinding["overhang_m"]
rubble = RUBBLE_BASE_THICKNESS_M if rubble_thickness_m is None else float(rubble_thickness_m)
top = f_t + w_h
shapes = [
_path(
[
(0.0, 0.0),
(0.0, f_t),
(toe, f_t),
(toe + front, top),
(toe + front + w_top, top),
(toe + w_bottom, f_t),
(f_w, f_t),
(f_w, 0.0),
(k_left + k_w, 0.0),
(k_left + k_w, -k_d),
(k_left, -k_d),
(k_left, 0.0),
(0.0, 0.0),
],
"wall",
),
]
# 버림(키 자리는 비움) · 그 밑 기초잡석(같은 폭).
layers = [(0.0, b_t, False)] + ([(b_t, rubble, True)] if rubble > 0 else [])
for depth, thickness, dash in layers:
for x0, x1 in ((-over, k_left), (k_left + k_w, f_w + over)):
shapes.append(
_path(
[
(x0, -depth),
(x0, -depth - thickness),
(x1, -depth - thickness),
(x1, -depth),
],
"guide",
dash=dash,
)
)
weep_y = f_t + w_h * 0.5
ratio = (weep_y - f_t) / w_h
shapes.append(
_path([(toe + front * ratio, weep_y), (toe + w_bottom - back * ratio, weep_y)], "guide")
)
# 터파기 — 표 줄과 같은 식(기초 폭 + 양쪽 여유 · 수직 · 기초 윗면에서 기초잡석 밑까지). 점선.
trench = section.get("trench")
clearance = trench["clearance_m"] if trench else 0.0
side = (trench or {}).get("side_slope", 0.0) * (f_t + b_t + rubble)
dig_bottom = -(b_t + rubble)
if trench:
shapes.append(
_path(
[
(-clearance - side, f_t),
(-clearance, dig_bottom),
(f_w + clearance, dig_bottom),
(f_w + clearance + side, f_t),
],
"guide",
dash=True,
)
)
right = max(f_w + over, f_w + clearance + side) + 0.2
weep_area, weep_dia, _weep_basis = weep_hole_spec(sheet.get("options") or {})
blinding_width = f_w + 2 * over - k_w
shapes += [
_text(
f"{sheet.get('title') or '옹벽'} {sheet.get('options', {}).get('form') or ''}"
f" (축척 1/{int(1000 / SCALE_MM_PER_M)})",
(0.0, top + 0.52),
"left",
_LABEL_SIZE,
),
_text(f"H = {top:g} m", (-0.28, top / 2.0), "right"),
_text(f"상단 {w_top:.2f} m", (toe + front + w_top / 2.0, top + 0.14), "center"),
_text(
f"벽체 높이 {w_h:.2f} · 하단 {w_bottom:.2f} m (앞면 {front:g} · 뒷면 {back:g} 기움)",
(right, top * 0.9),
"left",
),
_text(
f"물구멍 Ø{weep_dia}{weep_area:g}㎡당 1개소"
f" · 관 {weep['pipe_length_m']:g} m(관측값)",
(right, weep_y),
"left",
),
_text(
f"기초 {f_w:.2f} × {f_t:.2f} m (앞굽 {toe:.2f} · 뒷굽 {heel:.2f})",
(right, f_t / 2.0),
"left",
),
_text(
f"버림 T={b_t:.2f} · 폭 {f_w + 2 * over:.2f} {k_w:.2f} = {blinding_width:.2f} m",
(right, -b_t / 2.0),
"left",
),
_text(f"전단키 {k_w:.2f} × {k_d:.2f} m", (k_left + k_w / 2.0, -k_d - 0.14), "center"),
]
if rubble > 0:
shapes.append(
_text(
f"기초잡석 T={rubble:g} m — 산출 조건 · 폭은 버림과 같음",
(right, -b_t - rubble / 2.0),
"left",
)
)
if trench:
shapes.append(
_text(
f"터파기 밑폭 {f_w + 2 * clearance:.2f} · 윗폭 {f_w + 2 * (clearance + side):.2f}"
f" × 깊이 {f_t - dig_bottom:.2f} m (기초 {f_w:g} + 여유 {clearance:g}×2"
f" · 옆면 1:{trench.get('side_slope', 0.0):g})",
(right, f_t + 0.25),
"left",
)
)
below = -max(k_d, b_t + rubble) - 0.36
notes = [
f"단면 — 관측 원단위 {entry.get('source_note') or ''}의 수량이 나온 그 단면",
earthwork_missing_note(entry),
]
for index, note in enumerate(note for note in notes if note):
shapes.append(_text(note, (0.0, below - index * 0.16), "left"))
return shapes
def attach_figures(payload: dict[str, Any], rubble_thickness_m: float | None = None) -> None:
"""장마다 `figure`(모양 목록 또는 `None`)와 `figure_reason` 을 실음 — 화면은 둘 중 하나."""
for sheet in payload.get("sheets") or []:
sheet["figure_reason"] = figure_reason(sheet)
sheet["figure"] = build_figure(sheet, rubble_thickness_m)
@@ -0,0 +1,321 @@
"""구조물도 상단 그림 — **골막이 · 떼흙막이 · 바닥막이** (정본 개소당·㎡당 구조물, PLAN 12장).
치수는 **표가 **에서 읽음 골막이 `erosion_check_section` ·
떼흙막이 `SOIL_GUARD_SOD.dims` · 바닥막이 `BED_SILL_FORMS` + 산출 조건 기초잡석 두께.
사유 글자도 사유와 같은 상수(`BED_SILL_TRENCH_NOTE` ).
좌표는 m · 위가 +y · 모양 목록 규약은 `B08_Quantity_Engine_StructureFigure` 같음.
"""
from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_StructureFigure import (
_LABEL_SIZE,
SCALE_MM_PER_M,
_path,
_text,
)
def _title(sheet: dict[str, Any], y: float) -> dict[str, Any]:
return _text(
f"{sheet.get('title') or ''} (축척 1/{int(1000 / SCALE_MM_PER_M)})",
(0.0, y),
"left",
_LABEL_SIZE,
)
def _front_with_trench(
top: float, bottom: float, height: float, depth: float, x0: float = 0.0
) -> list[dict[str, Any]]:
"""정면 사다리꼴(윗변 `top` · 밑변 `bottom` 가운데 정렬) + 사면 둘·밑변을 따라 판 띠(점선)."""
inset = (top - bottom) / 2
corners = [(x0, height), (x0 + inset, 0.0), (x0 + inset + bottom, 0.0), (x0 + top, height)]
shapes = [_path(corners + [corners[0]], "wall")]
if depth > 0:
# 사면 둘·밑변을 바깥으로 `depth` 만큼 민 선 — 이웃 변의 교점으로 잇음.
normals = []
for (ax, ay), (bx, by) in zip(corners[:3], corners[1:4]):
length = math.hypot(bx - ax, by - ay)
normals.append(((by - ay) / length, -(bx - ax) / length))
offset = []
for index, (px, py) in enumerate(corners):
used = [normals[i] for i in (index - 1, index) if 0 <= i < 3]
nx = sum(n[0] for n in used)
ny = sum(n[1] for n in used)
dot = used[0][0] * nx + used[0][1] * ny
offset.append((px + nx * depth / dot, py + ny * depth / dot))
shapes.append(_path(offset, "guide", dash=True))
return shapes
def _circle(cx: float, cy: float, r: float) -> list[tuple[float, float]]:
return [
(cx + r * math.cos(math.tau * i / 24), cy + r * math.sin(math.tau * i / 24))
for i in range(25)
]
def _rect(x0: float, y0: float, x1: float, y1: float) -> list[tuple[float, float]]:
return [(x0, y0), (x0, y1), (x1, y1), (x1, y0), (x0, y0)]
def inlet_basin_figure(sheet: dict[str, Any], entry: dict[str, Any]) -> list[dict[str, Any]]:
"""집수정(□형) — 긴 벽 정면(바닥기초 · 관 · 방수로 · 터파기) + 평면(바깥·안 벽).
치수는 관측 `section`( 수량이 나온 치수). ·방수로 **자리** 원문 입체도 모식.
"""
section = entry["section"]
inner, wall, base = section["inner"], section["wall_m"], section["base_m"]
spill, dig = section["spillway"], section["dig"]
length, width = inner["length_m"] + 2 * wall, inner["width_m"] + 2 * wall
height = inner["height_m"]
top = base + height
radius = section["pipe_d_m"] / 2
mid = length / 2
notch_half_top, notch_half_bottom = spill["top_m"] / 2, spill["bottom_m"] / 2
# 긴 벽 정면 — 윗변 가운데에 방수로 한 곳(모두 `count` 곳).
outline = [
(0.0, 0.0),
(0.0, top),
(mid - notch_half_top, top),
(mid - notch_half_bottom, top - spill["depth_m"]),
(mid + notch_half_bottom, top - spill["depth_m"]),
(mid + notch_half_top, top),
(length, top),
(length, 0.0),
(0.0, 0.0),
]
dig_len = length + dig["extra_length_m"]
shapes = [
_path(outline, "wall"),
_path([(0.0, base), (length, base)], "guide"),
_path(_circle(mid, base + radius, radius), "guide"),
_path(
_rect(-dig["extra_length_m"] / 2, 0.0, length + dig["extra_length_m"] / 2, top),
"guide",
dash=True,
),
]
# 평면 — 바깥 벽 · 안 벽 · 터파기 평면(점선).
px = length + 1.2
shapes += [
_path(_rect(px, 0.0, px + length, width), "wall"),
_path(_rect(px + wall, wall, px + length - wall, width - wall), "guide"),
_path(
_rect(
px - dig["extra_length_m"] / 2,
(width - dig["width_m"]) / 2,
px + length + dig["extra_length_m"] / 2,
(width + dig["width_m"]) / 2,
),
"guide",
dash=True,
),
]
right = px + length + 0.5
shapes += [
_title(sheet, max(top, dig["width_m"]) + 0.55),
_text("정면(긴 벽)", (mid, top + 0.15), "center"),
_text("평면", (px + length / 2, width + 0.15 + (dig["width_m"] - width) / 2), "center"),
_text(
f"{inner['length_m']:g}×{inner['width_m']:g}×{height:g} · 벽 {wall:g} · 바닥기초"
f" {length:g}×{width:g}×{base:g} m",
(right, top * 0.9),
"left",
),
_text(f"관 Ø{section['pipe_d_m']:g}", (right, top * 0.65), "left"),
_text(
f"방수로 {spill['count']}곳 (상장 {spill['top_m']:g} · 하장 {spill['bottom_m']:g}"
f" · 고 {spill['depth_m']:g})",
(right, top * 0.4),
"left",
),
_text(
f"터파기 {dig_len:g} × {dig['width_m']:g} × 깊이 {top:g} m",
(right, top * 0.15),
"left",
),
_text("관·방수로 자리는 원문 입체도 모식 — 치수는 산출식", (0.0, -0.3), "left"),
]
return shapes
def pavement_figure(sheet: dict[str, Any], entry: dict[str, Any]) -> list[dict[str, Any]]:
"""물넘이포장 — ㎡당 한 칸 단면: 슬래브 두께(규격) · 와이어메쉬(2/3T) · 터파기(두께만큼)."""
thickness = float(entry["spec"]["thickness_cm"]) / 100
mesh = thickness * entry["section"]["mesh_from_bottom_ratio"]
width = 1.0
shapes = [
_path(_rect(0.0, 0.0, width, thickness), "wall"),
_path([(0.05, mesh), (width - 0.05, mesh)], "guide", dash=True),
_path(_rect(-0.12, 0.0, width + 0.12, thickness), "guide", dash=True),
]
right = width + 0.4
shapes += [
_title(sheet, thickness + 0.45),
_text("폭 1 m 당(표는 ㎡당)", (width / 2, thickness + 0.12), "center"),
_text(f"콘크리트 T={thickness:g} m", (right, thickness * 0.8), "left"),
_text(f"와이어메쉬 — 밑에서 {mesh:.3f} m (2/3T)", (right, mesh - 0.02), "left"),
_text(f"터파기 깊이 {thickness:g} m (= 두께)", (right, 0.0), "left"),
]
for index, note in enumerate(entry.get("notes") or []):
shapes.append(_text(str(note), (0.0, -0.25 - index * 0.14), "left"))
return shapes
def erosion_check_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]:
"""골막이 — 정면(사다리꼴 · 방수로 관 · 바닥파기 띠) + 옆 단면(상부·하부 두께)."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import erosion_check_section
geo = erosion_check_section(float(sheet.get("height_m") or 0.0), sheet.get("options") or {})
top, bottom, height = geo["top_m"], geo["bottom_m"], geo["height_m"]
shapes = _front_with_trench(top, bottom, height, geo["trench_depth"])
if geo["spillway"]:
shapes.append(_path(_circle(top / 2, geo["pipe_r"], geo["pipe_r"]), "guide"))
# 옆 단면 — 뒷면 수직 · 앞면이 하부 두께에서 상부 두께로 좁아짐(표의 두께식 그대로).
x0 = top + 1.2
shapes.append(
_path(
[
(x0, 0.0),
(x0, height),
(x0 + geo["top_t"], height),
(x0 + geo["bottom_t"], 0.0),
(x0, 0.0),
],
"wall",
)
)
right = x0 + geo["bottom_t"] + 0.3
shapes += [
_title(sheet, height + 0.55),
_text(f"상장 ⓐ {top:g} m", (top / 2, height + 0.14), "center"),
_text(f"하장 ⓑ {bottom:g} m", (top / 2, -geo["trench_depth"] - 0.18), "center"),
_text(f"H = {height:g} m", (-0.3 - geo["trench_depth"], height / 2), "right"),
_text(
f"상부 {geo['top_t']:.2f} · 하부 {geo['bottom_t']:.2f} · 평균 {geo['thickness']:.2f} m"
f" (뒷길이 {geo['back_cm']}㎝ + 0.1H / 0.4H)",
(right, height * 0.85),
"left",
),
_text(f"반수면 1:{geo['slope']:g}", (right, height * 0.62), "left"),
_text(
f"바닥파기 폭 {geo['trench_width']:.2f} × 깊이 {geo['trench_depth']:g} m"
f" (사면장 {geo['slant']:g} × 2 + 하장)",
(right, height * 0.39),
"left",
),
]
if geo["spillway"]:
shapes.append(
_text(
f"방수로 파형강관 Ø{geo['pipe_r'] * 2:g} — 정면적에서 뺌",
(right, height * 0.16),
"left",
)
)
return shapes
def soil_guard_figure(sheet: dict[str, Any]) -> list[dict[str, Any]]:
"""떼흙막이 — 정본 평균 붙박이 치수의 정면(떼 두께 · 바닥파기 띠)."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import SOIL_GUARD_SOD
d = SOIL_GUARD_SOD["dims"]
top, bottom, height = d["top_m"], d["bottom_m"], d["height_m"]
shapes = _front_with_trench(top, bottom, height, d["trench_depth_m"])
# 머리떼 — 윗변 위 떼 한 켜(두께 = 떼 폭 0.2 로 보임).
shapes.append(
_path(
[(0.0, height), (0.0, height + d["sod_m"]), (top, height + d["sod_m"]), (top, height)],
"guide",
)
)
right = top + 0.4
shapes += [
_title(sheet, height + d["sod_m"] + 0.45),
_text(f"상단 {top:g} m", (top / 2, height + d["sod_m"] + 0.12), "center"),
_text(f"하단 {bottom:g} m", (top / 2, -d["trench_depth_m"] - 0.15), "center"),
_text(f"H = {height:g} m", (-0.3, height / 2), "right"),
_text(
f"떼 20×20㎝ · 폭 {d['sod_m']:g} · 기슭(사면장) {d['slant_m']:g} m",
(right, height * 0.9),
"left",
),
_text(
f"바닥파기 폭 {d['trench_width_m']:g} × 깊이 {d['trench_depth_m']:g} m"
" (기슭 × 2 + 하단)",
(right, height * 0.45),
"left",
),
_text("치수 — 정본 「떼흙막이」 평균 붙박이(개소 제원을 안 봄)", (right, 0.0), "left"),
]
return shapes
def bed_sill_figure(
sheet: dict[str, Any], rubble_thickness_m: float | None
) -> list[dict[str, Any]]:
"""바닥막이(돌붙임) — ㎡당 한 칸의 단면: 돌 켜 · (찰) 버림 · 기초잡석 · 터파기(돌 두께만)."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import RUBBLE_BASE_THICKNESS_M
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import (
BED_SILL_FORMS,
BED_SILL_TRENCH_NOTE,
)
form = str((sheet.get("options") or {}).get("form") or "").strip()
table = BED_SILL_FORMS[form]
back = table["back_len_m"]
blinding = table["blinding_m3_per_m2"] or 0.0 # ㎥/㎡ = 두께(m)
rubble = RUBBLE_BASE_THICKNESS_M if rubble_thickness_m is None else float(rubble_thickness_m)
rubble = rubble if blinding > 0 else 0.0 # 표도 버림이 선 곳에만 기초잡석을 세움
width = 1.0
shapes = [_path([(0.0, 0.0), (0.0, back), (width, back), (width, 0.0), (0.0, 0.0)], "wall")]
if blinding > 0:
shapes.append(
_path([(0.0, 0.0), (0.0, -blinding), (width, -blinding), (width, 0.0)], "guide")
)
if rubble > 0:
shapes.append(
_path(
[
(0.0, -blinding),
(0.0, -blinding - rubble),
(width, -blinding - rubble),
(width, -blinding),
],
"guide",
dash=True,
)
)
# 터파기 — 표 식 그대로 면적 × 돌 두께(윗면에서 돌 켜 밑까지).
shapes.append(
_path(
[(-0.15, back), (-0.15, 0.0), (width + 0.15, 0.0), (width + 0.15, back)],
"guide",
dash=True,
)
)
right = width + 0.4
shapes += [
_title(sheet, back + 0.4),
_text(
f"{form} — 두께(뒷길이) {back:g} m · {table['stone_spec']}",
(right, back * 0.75),
"left",
),
_text("폭 1 m 당(표는 ㎡당)", (width / 2, back + 0.12), "center"),
_text(f"터파기 깊이 {back:g} m (면적 × 두께)", (right, back * 0.3), "left"),
]
if blinding > 0:
shapes.append(_text(f"버림 T={blinding:g} m", (right, -blinding / 2), "left"))
if rubble > 0:
shapes.append(
_text(f"기초잡석 T={rubble:g} m — 산출 조건", (right, -blinding - rubble / 2), "left")
)
shapes.append(_text(BED_SILL_TRENCH_NOTE, (0.0, -blinding - rubble - 0.25), "left"))
return shapes
@@ -44,10 +44,26 @@ def _items(folder: Path) -> list[dict[str, Any]]:
return [json.loads(p.read_text(encoding="utf-8")) for p in sorted(folder.glob("*.json"))]
def item_kind(item: dict[str, Any]) -> str:
"""양식형(`form`) · 고정형(`fixed`) — 명세 13장: 칸은 같고 **식이 한 줄이라도 있나**로만 가름.
저장된 `item_kind` 표시는 믿음 줄과 어긋나면 배지가 거짓이 ."""
return (
"form"
if any(str(row.get("formula") or "").strip() for row in item.get("rows") or [])
else "fixed"
)
def list_items(dirs: dict[str, Path], type_id: str) -> list[dict[str, Any]]:
"""가져오기 고르개 — 단마다 그 종류의 항목(단·코드·이름)."""
"""가져오기 고르개 — 단마다 그 종류의 항목(단·코드·이름·종류)."""
return [
{"tier": tier, "code": item.get("code"), "name": item.get("name"), "type_id": type_id}
{
"tier": tier,
"code": item.get("code"),
"name": item.get("name"),
"type_id": type_id,
"kind": item_kind(item),
}
for tier in TIERS
if tier in dirs
for item in _items(dirs[tier])
@@ -111,6 +127,19 @@ def _write(folder: Path, item: dict[str, Any]) -> None:
(folder / f"{item['code']}.json").write_text(text, encoding="utf-8")
def _personal_code(folder: Path, type_id: Any) -> str:
"""개인 단 코드 — 같은 종류가 있으면 그 코드(덮어씀 · 판정 Ⓐ 종류당 하나), 없으면 새로."""
same = [item for item in _items(folder) if item.get("type_id") == type_id]
return str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
def save_item_personal(folder: Path, item: dict[str, Any]) -> str:
"""이미 만든 항목(STmate 에서 뽑은 고정형 등)을 개인 단에 씀. 코드."""
code = _personal_code(folder, item.get("type_id"))
_write(folder, {**item, "code": code, "library_tier": "personal"})
return code
def save_personal(
folder: Path,
template: dict[str, Any],
@@ -125,9 +154,7 @@ def save_personal(
"""
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import overridden_rows
type_id = template.get("type_id")
same = [item for item in _items(folder) if item.get("type_id") == type_id]
code = str(same[0]["code"]) if same else f"AX-ST-{secrets.token_hex(4)}"
code = _personal_code(folder, template.get("type_id"))
# 고친 식·반올림이 곧 이 항목의 값 — 「사용자」 표시와 되돌릴 자리는 떼어 냄.
dropped = {"default_formula", "default_rounding"}
rows = [
@@ -160,6 +160,12 @@ def price_sheets(
"total": Decimal(str(table["total"])),
"blocked": table["blocked"],
"unconfirmed": table["unconfirmed"],
# 단위당 구성(단가표 코드, 수량) — B09 집계표가 자원까지 풂. 수동 단가 줄은 못 풂.
"parts": [
(str(row["ref_code"]), Decimal(str(row["quantity"])))
for row in table["rows"]
if row.get("ref_code") and "total" in row and not row.get("manual")
],
"reasons": [
f"{row['name']}: {row['reason']}"
for row in table["rows"]
@@ -96,23 +96,35 @@ def sheet_key(structure: dict[str, Any]) -> str:
#: 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다(품셈 13-6 「1:0.3 이상」).
#: 한 목록으로 묶어 두었더니 큰돌쌓기를 그림에서 뺄 때 **장 제목의 「1:0.3」까지
#: 사라졌다**(2026-09-09 화면 실측에서 잡음).
SLOPE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"})
SLOPE_TYPE_IDS: frozenset[str] = frozenset(
{"masonry_wet", "masonry_dry", "boulder_masonry", "revetment"}
)
#: 기슭막이는 **형태가 돌쌓기일 때만** 돌쌓기 식이 섬(`_UnitQuantity_Revetment.STONE_FORMS`).
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") != "메쌓기"
else:
wet = True
return False
if type_id == "boulder_masonry":
return options.get("bond") != "메쌓기"
if type_id == "revetment":
# 표(기슭막이 전개)가 넘긴 그 갈래 — 형태 「돌쌓기(메)」만 메.
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,
@@ -127,8 +139,12 @@ def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
def _slope_or_none(structure: dict[str, Any]) -> float | None:
"""돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`."""
# ⚠ **그림 대상 목록을 쓰지 않는다** — 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다.
if str(structure.get("type_id") or "") not in SLOPE_TYPE_IDS:
type_id = str(structure.get("type_id") or "")
if type_id not in SLOPE_TYPE_IDS:
return None
form = str((structure.get("options") or {}).get("form") or "").strip()
if type_id == "revetment" and form not in REVETMENT_STONE_FORMS:
return None # 돌쌓기형이 아닌 기슭막이는 기울기 판정이 없음(표도 안 섬)
return slope_of(structure)[0]
@@ -196,7 +212,7 @@ def build_standard_sheets(
계산을 다시 하지 않는다 들어온 전개를 제원 조합으로 묶고 단위당으로 나눌 뿐이다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role
from common_util.common_util_structure_face_role import structure_face_role_of
groups: dict[str, dict[str, Any]] = {}
for structure in unit_table.get("structures") or []:
@@ -204,7 +220,7 @@ def build_standard_sheets(
# 근거를 못 받아 종전값으로 떨어져 **표와 갈린다**(2026-09-09 실측: 표 1:0.35 ·
# 제목 1:0.3). 자리를 고르는 것도 B08 이 쓰는 그 함수를 쓴다.
mode = _section_mode_at(structure, section_modes)
face, face_reason = structure_face_role(mode, (structure.get("options") or {}).get("side"))
face, face_reason = structure_face_role_of(mode, structure.get("options"))
structure = {**structure, "face": face, "face_reason": face_reason}
key = sheet_key(structure)
sheet = groups.get(key)
@@ -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
@@ -217,7 +227,7 @@ def replace_with_templates(
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role
from common_util.common_util_structure_face_role import structure_face_role_of
settings = {"rubble_base_thickness_m": rubble_base_thickness_m}
targets: list[tuple[Any, dict[str, Any]]] = []
@@ -229,9 +239,7 @@ def replace_with_templates(
if (quantity.billing_unit or "m") not in _PER_LENGTH_UNITS:
continue
options = item.get("options") or {}
face, face_reason = structure_face_role(
_section_mode_at(item, section_modes), options.get("side")
)
face, face_reason = structure_face_role_of(_section_mode_at(item, section_modes), options)
sheet = {
"type_id": quantity.type_id,
"height_m": quantity.height_m,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
"""구조물 원단위 — **성분 계약과 갈 곳**(이중계상 경계) · 2026-09-14 `UnitQuantity` 에서 가름.
파일의 `DESTINATION`·`MIX_COMPONENTS` 이중계상 경계다 규칙 셋은 `UnitQuantity` 머리글.
배합을 분해하지 않음(`MIX_COMPONENTS`) · 할증 붙임 ·
터파기·되메우기·잔토는 토공으로(`DESTINATION`).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from B08_Quantity.B08_Quantity_Engine_ObservedUnit import BASIS_DERIVED
#: 기초잡석 줄 이름 — 갈 곳 표(`DESTINATION`)가 이 이름을 키로 씀. 두께 상수는 `_StoneSpec`.
RUBBLE_BASE_NAME = "기초잡석"
DESTINATION = {
"터파기": "earthwork",
"되메우기": "earthwork",
"잔토처리": "earthwork",
"돌쌓기": "unit_price",
"돌붙임": "unit_price",
"깬돌": "material",
"고임돌": "material",
"막자갈": "material",
# ⭐ 2026-09-09 사용자 확정 3차 ⑥ — **콘크리트는 자재 축에 세운다**(사용자 명시).
# ⚠ 타설 줄(품셈 12-1)은 **품만** 주고 재료를 안 준다(서브 일위대가도 재료 0원).
# 그래서 자재로 안 보내면 **재료비가 통째로 빠진다** — B09 매김에서 드러난 자리다.
# ⚠ 배합을 분해하지 않는 규칙(㉢)은 그대로다 — 「콘크리트 ㎥」에서 멈춘다.
"콘크리트": "material",
"채움콘크리트": "material",
"버림콘크리트": "material",
"모르터": "unit_price",
"거푸집": "unit_price",
"물구멍관": "material",
# ⚠ 자재도 토공도 아닌 자리 — **유토곡선이 사토에서 뺄 밑수**다. 자재총괄은
# `material` 만 모으므로 여기 섞이지 않는다.
"채집석": "haul_deduction", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약)
# ⚠ **보여 주기만 하는 줄** — 자재도 토공도 일위대가도 아니다. 정본 계산표 좌측 열에
# 「입적」으로 실려 있어 그 이름 그대로 낸다(2026-09-08 확정 ⑦). 값은 이미 식 안에
# 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다.
# ⚠ 「석적」은 정본에 없고 소광리 시트에만 있으나 **확정 5차로 세운다** — 같은
# 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다.
"입적": "reference",
"석적": "reference",
# 돌 줄은 **이름 「돌」 + 규격(`spec`)에 종류**다(명세 13장 Ⓒ) — 정본 계산표 줄 이름 그대로
# (「돌 ℓ3=45cm」). 자재총괄이 (이름·규격·단위)로 묶으므로 종류 이름을 따로 두지 않는다.
"": "material",
# 기초잡석(품셈 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 일위대가가 배합표로 분해한다.
# ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로
# 오탐된다 — 개발 중 실제로 걸렸던 자리다.
MIX_COMPONENTS = frozenset(
{"시멘트", "모래", "자갈", "친모래", "친자갈", "잔골재", "굵은골재", "부순돌"}
)
@dataclass(slots=True)
class Component:
"""전개 결과 한 성분. `basis` 는 어떤 식으로 나왔는지 사람이 읽는 근거다."""
name: str
unit: str
amount: float
destination: str
basis: str = ""
# ⚠ 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표).
# 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다.
basis_kind: str = BASIS_DERIVED
source: str = ""
#: 규격 — 같은 이름이라도 단가가 갈리는 값(채움 콘크리트 강도 등). 받는 쪽이 읽는다.
spec: str = ""
@dataclass(slots=True)
class StructureQuantity:
"""구조물 하나의 원단위 전개 결과."""
structure_id: str | None
type_id: str
name: str
length_m: float = 0.0
height_m: float = 0.0
# 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계).
start_m: float | None = None
end_m: float | None = None
# 저장된 제원 — **형식(반중력식…)처럼 뒤 단계가 봐야 하는 값**이 여기 있다.
# 치수를 다시 쓰라는 뜻이 아니라 **읽으라고** 실어 나른다(치수 정본은 여전히 하나).
options: dict[str, Any] = field(default_factory=dict)
components: list[Component] = field(default_factory=list)
notes: list[str] = field(default_factory=list)
#: 내역 줄이 설 **단위와 그 단위로 센 수량**. 관측 원단위가 「개소당」·「㎡당」인
#: 종류는 연장(m)으로 세면 축이 어긋난다(2026-09-08 ㉕ 실증).
#: 비어 있으면 종전대로 「m · 연장」으로 선다.
billing_unit: str = ""
billing_quantity: float = 0.0
#: 구조물이 놓인 자리의 지반 갈래(`soil`·`ripping_rock`·`blasting_rock`)와 그 판정 근거.
#: 품셈 9-13 구조물터파기의 **토질 축**이 이 값으로 갈린다. 못 가르면 `None` 이다.
ground_type: str | None = None
ground_type_basis: str = ""
#: 파는 깊이(m) — 단면으로 터파기를 세운 종류만(옹벽 기초분). 비면 인계가 직고 + 기초 깊이로 봄.
trench_depth_m: float | None = None
#: 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값.
library_item: str = ""
#: 치수가 정본에 없어 **등록부 기본값으로 선** 까닭(2026-09-14 브레인 판정) — 차 있으면 줄·표·그림은
#: 서되 **금액·자재·토공·운반 합에는 안 듦**(`is_unconfirmed`). 설계자가 치수를 적으면 비고 금액이 섬.
unconfirmed: str = ""
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
@@ -0,0 +1,532 @@
"""구조물 원단위 — **돌쌓기·큰돌쌓기 전개식**(버림·터파기 줄 포함) · 2026-09-14 가름.
식을 발명하지 않음 실무 정본 식을 옮긴 자리(`UnitQuantity` 머리글). 계수·판정은 `_StoneSpec`.
터파기·되메우기·잔토는 **토공으로 가는 **(`DESTINATION`) 내역 줄로 세지 않음.
"""
from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION, Component, _num
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import (
BLINDING_THICKNESS_M,
STONE_BACK_LENGTH_TABLE,
STONE_BACKFILL_VERIFIED_HEIGHT_M,
STONE_BACKFILL_WIDTH_M,
STONE_KIND_OPTION,
STONE_MASONRY,
STONE_PACKING_RATIO,
STONE_PILE_BASIS,
_back_length,
back_length_default_note,
dry_masonry_height_note,
face_slope_ratio,
fill_concrete_mpa,
STONE_SUPPLY_DEFAULT_NOTE,
STONE_SUPPLY_KEYS,
is_collected_stone,
load_stone_kind_table,
stone_coefficients,
stone_weight_per_m2,
wall_thickness,
wants_blinding,
weep_hole_spec,
)
from common_util.common_util_excavation import (
BASIS_WALL,
WALL_BLINDING_DEPTH_M,
WALL_FOUNDATION_DEPTH_M,
WALL_TRENCH_CLEARANCE_M,
wall_trench_area_m2,
wall_trench_width_m,
)
def _blinding_component(
base_width_m: float,
length_m: float,
options: dict[str, Any],
width_basis: str = "",
):
"""버림 콘크리트 한 줄. 넣지 않기로 했으면 `None`.
폭은 **잡석다짐 **(KCS 34 50 05)이고, 값은 확정 **구조물 하단 길이**.
부르는 쪽이 폭과 까닭(`width_basis`) 함께 넘긴다.
"""
if not wants_blinding(options) or base_width_m <= 0 or length_m <= 0:
return None
basis = (
f"기초 폭 {base_width_m:.2f}m × 연장 × 두께 {BLINDING_THICKNESS_M:.2f}m"
" · 두께는 KDS 44 90 00(100㎜) · 폭은 잡석다짐 폭(KCS 34 50 05)"
)
if width_basis:
basis = f"{basis} = {width_basis}"
return Component(
"버림콘크리트",
"",
base_width_m * length_m * BLINDING_THICKNESS_M,
DESTINATION["버림콘크리트"],
basis,
)
#: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인).
#: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가
#: 「뒷길이 45㎝」 계수로 돌던 자리다.
BOULDER_DIAMETERS = ("40~60", "60~80", "80~100")
#: 큰돌쌓기 전개 상수. **재료 원단위는 품셈에 없다** — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」.
#: 그래서 여기서 내는 것은 **면적과 터파기 계열까지**이고 큰돌 자체는 미확보로 둔다.
BOULDER_MASONRY = {
# ⚠ 검산 참고값 — 곱하지 않는다. 까닭은 `STONE_MASONRY` 의 같은 칸 주석을 볼 것.
# 큰돌쌓기는 전면 기울기가 「1:0.3 **이상**」이라 특히 하드코딩하면 안 된다.
"sheet_check_factor_at_0_3": 1.04,
# ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다
# (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다).
# 여기서 다시 두면 그림과 물량이 갈린다.
}
def _boulder_diameter(options: dict[str, Any]) -> str | None:
"""저장 제원의 직경 갈래. 표에 없는 값이면 **지어내지 않고 `None`**."""
raw = options.get("stone_cm")
text = str(raw).strip() if raw is not None else ""
return text if text in BOULDER_DIAMETERS else None
def boulder_masonry(
height_m: float, length_m: float, options: dict[str, Any]
) -> tuple[list[Component], list[str]]:
"""큰돌쌓기(품셈 13-6) 전개 — **치수로 낼 수 있는 것까지만** 낸다.
재료 원단위가 품셈에 없다 13-6 [] 재료량은 설계수량을 적용한다.
돌쌓기(13-4)처럼 뒷길이별 돌중량·고임돌 표가 **없으므로** 큰돌·고임돌·막자갈을
내지 않는다. 대신 ** 내는지** 알림으로 남긴다.
고임돌·채움콘크리트 품은 **품에 포함** 있다(13-6-1 []·13-6-2 [])
따로 세우면 이중계상이다.
뒤채움(조약돌) **13-3 적용**한다(13-6 []) 표에서 찾지 않는다.
두께는 13-4-3 [] 준용하는데 **직고별 범위값**(상부 20~40 / 하부 30~140)이라
값으로 정한다. 미확보로 둔다.
"""
notes: list[str] = []
if height_m <= 0 or length_m <= 0:
return [], ["높이·연장이 없어 전개하지 않음"]
diameter = _boulder_diameter(options)
if diameter is None:
return [], [
"큰돌쌓기 돌 직경이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 "
f"{' · '.join(BOULDER_DIAMETERS)}㎝ 중 하나를 고르면 값이 섭니다"
]
# ⚠ 큰돌쌓기는 **표준경사표 대상이 아니다** — 교본이 「1:0.3 **이상**」으로만 두었다.
# 저장 제원에 값이 있으면 그것을 쓰고, 없으면 하한 0.3 으로 둔다.
slope_ratio = _num(options.get("face_slope_ratio"), 0.3)
face_area = height_m * length_m
# 기울기 몫은 **한 번만** — 13-4 와 같은 자리다(㉘).
masonry_area = face_area * math.hypot(1.0, slope_ratio)
components = [
Component(
"큰돌쌓기",
"",
masonry_area,
DESTINATION["돌쌓기"],
f"정면적 × √(1+{slope_ratio}²) · 직경 {diameter}㎝ (품셈 13-6)",
)
]
# 터파기·되메우기·잔토 — 치수에서 나온다. 두께는 **직경 갈래의 위 끝**을 벽 두께로 본다.
# ⚠ 품셈에 큰돌쌓기 터파기 폭 규정이 없어 **돌쌓기와 같은 방식**(벽 두께 + 여유 0.2m)으로
# 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다.
upper_cm = float(str(diameter).split("~")[-1])
thickness = upper_cm / 100.0
# 버림 콘크리트 — 돌쌓기와 같은 자리(사용자 확정 ⑭). 빼려면 저장 제원에서 「안 넣음」.
blinding = _blinding_component(
thickness,
length_m,
options,
f"하단 길이(직경 위끝) {thickness:.2f}m — 확정 ⑪ 준용",
)
if blinding is not None:
components.append(blinding)
components.extend(
_trench_components(
height_m,
length_m,
thickness,
options,
notes,
"⚠ 큰돌쌓기 터파기 폭 규정이 품셈에 없어 돌쌓기 방식을 준용",
)
)
notes.append(
"재료(큰돌) 원단위 미확보 — 품셈 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 "
"돌쌓기(13-4)의 뒷길이별 돌중량 표에 해당하는 것이 없음"
)
notes.append("고임돌·채움콘크리트는 **품에 포함**(13-6 [주]①) — 따로 세우지 않음")
notes.append(
"뒤채움(조약돌)은 13-3 적용(13-6 [주]⑧). 두께는 13-4-3 [주]⑨ 준용인데 "
"직고별 범위값(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정함 — 미확보"
)
# ⚠ **채워져 있으면 그 사유를 붙이지 않는다**(2026-09-09). `bond` 는 레지스트리에 이미
# 있는 칸인데 조건 없이 붙이고 있어, 값을 고른 뒤에도 「못 고름」이 뜨고 있었다.
# ⚠ 얻는 것은 **노무 품 갈래뿐**이고 재료 원단위는 13-6 [주]⑦ 때문에 여전히 안 선다 —
# 그래도 고치는 까닭은 **사유가 거짓이면 사유 칸 전체를 못 믿게 되기 때문**이다.
if not str(options.get("bond") or "").strip():
notes.append("⚠ 메/찰 구분이 저장 제원에 없어 13-6-1(메)·13-6-2(찰) 중 어느 쪽인지 못 고름")
return components, notes
def _trench_components(
height_m: float,
length_m: float,
thickness: float,
options: dict[str, Any],
notes: list[str],
extra_basis: str = "",
) -> list[Component]:
"""터파기·되메우기·잔토 — **공용 단면 함수 한 벌**(`common_util_excavation`).
여기서 식을 다시 짜지 않는다 화면(B06 횡단도) 그리는 단면과 같은 코드라야
그림과 물량이 갈린다. 평균두께는 이쪽이 값을 **넘겨 준다**(저쪽은 두께를 모름).
기초유 / 기초버림 정본 제목이고 **기초분 0.45 0.07 이냐** 가른다.
고른 프로젝트는 **비탈분만** 세우고 사유를 남긴다 한쪽으로 찍으면 임의값이
금액으로 굳는다(기초분이 통째로 0.45/m ).
"""
foundation = str(options.get("foundation") or "").strip()
if foundation in ("기초유", "기초버림"):
has_foundation = foundation == "기초유"
area = wall_trench_area_m2(height_m, thickness, has_foundation=has_foundation)
base_depth = WALL_FOUNDATION_DEPTH_M if has_foundation else WALL_BLINDING_DEPTH_M
base_basis = f"기초분({foundation}) + "
else:
width = wall_trench_width_m(thickness)
area = None if width is None else height_m * width
base_depth = 0.0
base_basis = ""
notes.append(
"⚠ 기초 유/무(기초유·기초버림)를 안 정해 **기초 터파기 몫이 안 섰음** — 비탈분만 세움"
)
if area is None:
notes.append("⚠ 높이·평균두께가 없어 터파기가 서지 않음")
return []
excavation = area * length_m
backfill = (base_depth + height_m) * WALL_TRENCH_CLEARANCE_M * length_m
basis = (
f"{base_basis}H × (평균두께 {thickness:.2f} + {WALL_TRENCH_CLEARANCE_M}) × 연장"
f" · {BASIS_WALL}"
)
if extra_basis:
basis = f"{basis} · {extra_basis}"
return [
Component("터파기", "", excavation, DESTINATION["터파기"], basis),
Component(
"되메우기",
"",
backfill,
DESTINATION["되메우기"],
f"(기초깊이 {base_depth:g} + H) × {WALL_TRENCH_CLEARANCE_M} × 연장",
),
Component(
"잔토처리",
"",
excavation - backfill,
DESTINATION["잔토처리"],
"터파기 되메우기",
),
]
def stone_masonry(
height_m: float,
length_m: float,
options: dict[str, Any],
wet: bool,
face: str | None = None,
face_reason: str = "",
) -> tuple[list[Component], list[str]]:
"""돌쌓기(찰/메) 1구간 전개 — 실무 `기슭막이(찰쌓기, H=1.5, 기초무)` 시트의 식.
실측 대조(m당, H=1.5, 뒷길이 45, 기울기 1:0.3):
정면적 1.5 · 비탈면적 1.57 · 평균두께 0.83 · 입적 1.245
터파기 1.55 · 되메우기 0.30 · 잔토 1.25
"""
notes: list[str] = []
if height_m <= 0 or length_m <= 0:
return [], ["높이·연장이 없어 전개하지 않음"]
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, wet=wet, height_m=height_m)
if default_note:
notes.append(default_note)
height_note = None if wet else dry_masonry_height_note(height_m)
if height_note:
notes.append(height_note)
# ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3).
# 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고
# 멈추면 이미 저장된 프로젝트가 통째로 빈다.
picked, kind_note = stone_coefficients(options, back_cm)
if kind_note:
notes.append(kind_note)
# ⚠ **고른 종류의 빈 칸은 빈 칸으로 덮는다.** `None` 이라고 안 덮으면 종전 값(깬돌 계열)이
# 남아 「야면석 75㎝」처럼 **원문에 「-」인 칸에 값이 서는** 일이 생긴다(만들다 잡음).
if picked.get("kind"):
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
table[key] = picked.get(key)
else:
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
if picked.get(key) is not None:
table[key] = picked[key]
# ⚠ **교본의 「뒤채움 몫」(깬돌·잡석 1/2 · 야면석 1/3)은 이제 막자갈 밑수가 아니다** —
# ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로
# 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다.
kind_label = str(picked.get("kind") or "")
# ⭐ 2026-09-13 브레인 판정 — **계수 열 고르기와 돌종류는 다른 축.** 「실무 관행」은 계수 열만
# 바꾸고 종류는 지워선 안 됨(야면석이 「돌」·계산식 무게로 조용히 서던 결함). 돌 규격·무게는
# **저장 제원의 종류**를 따름 — 아는 종류일 때만(모르는 글은 종전대로 규격 빈칸).
chosen_kind = str(options.get(STONE_KIND_OPTION) or "").strip()
stone_kind_name = (
chosen_kind if chosen_kind in (load_stone_kind_table().get("kinds") or []) else kind_label
)
# ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도
# 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이
# 이긴다. (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것.)
# **기본 0.3 의 근거** — 교본 7-3 돌흙막이 기준: 「돌 찰쌓기 3.0m 이하 **1:0.3** /
# 돌 메쌓기 2.0m 이하 **1:0.3** / 큰돌쌓기 **1:0.3 이상**(전도 방지)」
# (`지식DB 02_상세설계/구조물/돌쌓기.md §1`, 값은 `data_masonry` 의 `face_slope`).
# 전면 기울기 — 품셈 표준경사표로 자동 판정하고, 저장 제원에 값이 있으면 그것이 이긴다.
slope_ratio, slope_basis = face_slope_ratio(
options, wet=wet, height_m=height_m, face=face, face_reason=face_reason
)
constants = STONE_MASONRY
face_area = height_m * length_m # 정면적
# 돌쌓기 면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다. **기울기 몫은 한 번만.**
# 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04).
masonry_area = face_area * math.hypot(1.0, slope_ratio)
# 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.**
# ⚠ 표준도 제원에 상부·하부 두께 칸이 있으면 **그 값이 식을 이긴다**(확정 ② 후반).
back_m = back_cm / 100.0 # 석적(아래)이 뒷길이를 m 로 쓴다
top_thickness, bottom_thickness, thickness_basis = wall_thickness(
options, back_cm=back_cm, height_m=height_m
)
thickness = (top_thickness + bottom_thickness) / 2.0
volume = face_area * thickness # 입적
components = [
Component(
"돌쌓기",
"",
masonry_area,
DESTINATION["돌쌓기"],
f"정면적 × √(1+{slope_ratio:g}²) — 비탈면적 · {slope_basis}",
),
# 입적 — 정본 계산표 좌측 열의 이름 그대로다(확정 ⑦). 값을 새로 내는 것이 아니라
# 이미 쓰고 있던 밑수를 화면에 드러내는 줄이다.
Component(
"입적",
"",
volume,
DESTINATION["입적"],
f"정면적 × 평균두께 {thickness:.2f} · {thickness_basis}",
),
]
# ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25).
# 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과
# 구별이 안 되고, 받는 쪽이 「값이 0 인 자재」로 읽는다.
if table["wedge_stone_m3_per_m2"] is None:
notes.append(
f"고임돌 계수가 품셈 표에 없습니다 — 뒷길이 {back_cm}"
+ (f" · {kind_label}" if kind_label else "")
+ " 칸이 「-」입니다"
)
else:
components.append(
Component(
"고임돌",
"",
masonry_area * _num(table["wedge_stone_m3_per_m2"]),
DESTINATION["고임돌"],
f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)"
+ (f" · {kind_label}" if kind_label else ""),
)
)
# 석적 — ⭐ 확정 5차 작은 것 4. 「체적」은 위 **입적** 줄이 이미 그것이고, 이쪽만 새 줄이다.
stone_pile = face_area * back_m * STONE_PACKING_RATIO
components.append(
Component(
"석적",
"",
stone_pile,
DESTINATION["석적"],
f"정면적 × 뒷길이 {back_m:g}m × {STONE_PACKING_RATIO}(채움률) · {STONE_PILE_BASIS}",
)
)
# 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다(헬퍼 한 벌).
# ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고,
# 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다).
stone_name, stone_ton, weight_tail, weight_source = stone_weight_per_m2(
back_cm, stone_kind_name, table["stone_ton_per_m2"]
)
weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else ""
if not stone_kind_name:
notes.append(
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — "
"야면석이면 계산식이 안 맞아 관측표로 갈립니다"
)
if stone_ton is None:
# 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮).
notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함")
else:
components.append(
Component(
"",
"ton",
masonry_area * stone_ton,
DESTINATION[""],
weight_basis,
source=weight_source,
spec=stone_kind_name,
)
)
# 막자갈(뒷채움) — ⭐ 확정 5차 작은 것 3 「(상 + 하) ÷ 2 × H」. **뒷채움 폭**이지 벽 두께가
# 아니다. 옛 식(입적 − 몸통 − 고임돌)에서 갈아탄 자리다.
backfill_top = STONE_BACKFILL_WIDTH_M["top"]
backfill_bottom = STONE_BACKFILL_WIDTH_M["bottom"]
rubble = (backfill_top + backfill_bottom) / 2.0 * height_m * length_m
components.append(
Component(
"막자갈",
"",
rubble,
DESTINATION["막자갈"],
f"(뒷채움 상 {backfill_top:g} + 하 {backfill_bottom:g}) ÷ 2 × H {height_m:g}m"
f" × 연장 {length_m:g}m · 정본 여섯 탭 공통값",
)
)
notes.append(
f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · "
f"{backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다"
"(소광리는 같은 식에 0.30/0.60 을 씁니다)"
)
# ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가
# 없으면 아무도 못 본다(2026-09-09 그물 침).
if height_m > STONE_BACKFILL_VERIFIED_HEIGHT_M:
notes.append(
f"직고 {height_m:g}m 는 뒷채움 폭 정본 확인 구간"
f"(H {STONE_BACKFILL_VERIFIED_HEIGHT_M:g}m 이하) **밖**입니다 — 붙박이 폭이라 "
"직고가 커져도 두께가 안 늘어 품셈 13-4-4 [주]⑨ 뒤채움 조약돌 두께 범위보다 "
"얇아집니다(그 표는 직고와 함께 두꺼워짐). 값은 정본 식대로 세우되 "
"**높은 벽의 뒷채움 폭은 실무자 확인이 필요합니다**"
)
if wet:
mpa, mpa_basis = fill_concrete_mpa(options)
components.append(
Component(
"채움콘크리트",
"",
masonry_area * _num(table["fill_concrete_m3_per_m2"]),
DESTINATION["채움콘크리트"],
# ⚠ 돌 종류로 계수가 갈리는데(깬돌 0.2 · 야면석 0.15) 근거에 종류가 안
# 적히던 자리다 — 값은 바뀌는데 **왜 바뀌었는지가 안 보였다**(고임돌은
# 적고 있었다). 2026-09-09 맞춤.
f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)"
+ (f" · {kind_label}" if kind_label else "")
+ f" · {mpa_basis}",
spec=f"{mpa}",
)
)
components.append(
Component(
"모르터",
"",
masonry_area * constants["mortar_m3_per_m2"],
DESTINATION["모르터"],
f"돌쌓기 × {constants['mortar_m3_per_m2']} ㎥/㎡ (줄눈)",
)
)
# ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢).
# 물구멍 — 벽면적 2㎡당 1개소, 개소당 관 길이 = **평균두께**(관이 벽을 가로지름).
# ⚠ 이것은 **관(파이프) 자재**이지 공제 대상이 아니다. 품셈 1-2-1 이 「공제하지 않는다」고
# 말하는 물구멍은 **콘크리트 체적에서 뺄 구멍**이고, 여기 값은 그 구멍에 넣는 **관 길이**다.
# ⚠⚠ **㉥ 이중계상** — 품셈 13-6-2·13-7-2 [주]③ 은 제잡비 **윗단** 값에
# 「물빼기 파이프 설치에 관계되는 노무비, 재료비를 포함한다」고 한다. 그 쪽을 쓰면
# 이 줄과 겹친다. **우리 선택은 이 줄을 세우고 제잡비는 아랫단(미설치)** 이다
# (`structure_unit_observed` 의 `double_count_rules`).
# 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인).
# ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면
# 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다.
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
# ⭐ 2026-09-13 브레인 판정(PLAN 10장) — 개소당 관 길이 = **평균두께**. 정본이 그렇게 잡고
# 관은 벽을 가로지르므로 이치에도 맞음. 옛 상수 0.5m 는 그만큼 짧게 섰음.
# ⚠ 골막이(`_Revetment`)는 그 정본이 0.5 라 그대로 둠 — 여기는 기슭막이 계열만.
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
components.append(
Component(
"물구멍관",
"m",
masonry_area / hole_area * thickness,
DESTINATION["물구멍관"],
f"돌쌓기 ÷ {hole_area:g}㎡/개소 × 평균두께 {thickness:.2f} m/개소 · {hole_basis}",
spec=f"Ø{hole_dia}",
)
)
# 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.**
# ⚠ 밑수가 확정 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)
if collected > 0:
components.append(
Component(
"채집석",
"",
collected,
DESTINATION["채집석"],
f"석적 {stone_pile:.3f} + 고임돌 {wedge:.3f} + 막자갈 {rubble:.3f}"
" · 현장 채집분 · ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌",
)
)
# 버림 콘크리트 — 기초 바닥에 까는 얇은 층. 빼려면 저장 제원에서 「안 넣음」으로 둔다.
# 잡석다짐 폭 = **하단 길이**(확정 ⑪) — 벽에서는 하부 두께다. 버림도 같은 폭을 쓴다.
base_width = bottom_thickness
blinding = _blinding_component(
base_width,
length_m,
options,
f"하단 길이(하부 두께) {bottom_thickness:.2f}m — 확정 ⑪ 「기준 구조물에서 유도」",
)
if blinding is not None:
components.append(blinding)
# ⓘ 기초잡석은 `build_table` 이 버림 뒤에 세움(확정 3차 ② 두께 0.2 · PLAN 10장 「계상함」).
# 옛 「두께가 없어 안 섬」 사유는 걷음 — 줄과 사유가 함께 떠 서로 어긋났음(2026-09-13 화면 실측).
# 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다).
components.extend(
_trench_components(height_m, length_m, thickness, options, notes, thickness_basis)
)
return components, notes
@@ -12,9 +12,9 @@
**형태가 돌쌓기가 아닌 갈래는 식이 없다** 콘크리트·돌망태·통나무/목재틀·바자.
지어내지 않고 ** 없는지** 줄로 남긴다.
**뒷길이· 종류 기슭막이 등록부에 아직 없다**(2026-09-09 실측). 돌쌓기 식은 둘로
계수가 갈리는데, 없으면 `_back_length` **조용히 45 돈다**. 나오므 아무도
알아채는 자리라 ** 사실을 드러낸다** 값이 있기는 하니 보이는 그것.
뒷길이· 종류 기슭막이 등록부에 섰음(`test_revetment_stone_fields`). 고른 장은
돌쌓기 식이 내는 (뒷길이를 골라 기본 45· 종류를 정해) 그대 드러냄.
2026-09-14 까지 붙이던 칸이 없어 **거짓 + 같은 **이라 걷음.
"""
from __future__ import annotations
@@ -23,13 +23,13 @@ import math
from decimal import ROUND_HALF_UP, Decimal
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). 여기 없는 형태는 전개식이 없다.
STONE_FORMS: dict[str, bool] = {
"돌쌓기(찰)": True,
"돌쌓기(메)": False,
}
#: ⚠ 구조물도 기울기·그림이 같은 표를 씀(`StructureSheet.REVETMENT_STONE_FORMS` 한 벌).
STONE_FORMS: dict[str, bool] = REVETMENT_STONE_FORMS
#: 식이 없는 형태와 **왜 없는지**. 품셈 장이 다르거나 원단위가 원문에 없다.
WITHHELD_FORMS: dict[str, str] = {
@@ -85,12 +85,6 @@ BED_SILL_FORMS: dict[str, dict[str, Any]] = {
},
}
#: 돌쌓기 식이 계수를 가르는 데 쓰는 칸 — 기슭막이 등록부에 아직 없는 것을 알린다.
STONE_SPEC_KEYS: tuple[tuple[str, str], ...] = (
("back_len_cm", "뒷길이"),
("stone_kind", "돌 종류"),
)
def revetment(
height_m: float,
@@ -110,17 +104,7 @@ def revetment(
"— 형태를 골라야 물량이 섭니다"
]
components, notes = stone_masonry(
height_m, length_m, options, STONE_FORMS[form], face, face_reason
)
# ⚠ 계수를 가르는 칸이 없으면 **기본값으로 조용히 돈다** — 그 사실을 드러낸다.
missing = [label for key, label in STONE_SPEC_KEYS if not options.get(key)]
if missing and components:
notes.append(
f"⚠ 기슭막이 제원에 {' · '.join(missing)} 칸이 없어 **돌쌓기 기본값으로 섰음** "
"— 그 값이 고임돌·야면석·채움콘크리트 계수를 가름"
)
return components, notes
return stone_masonry(height_m, length_m, options, STONE_FORMS[form], face, face_reason)
#: 돌골막이 원단위 — 소광리 정본 「골막이(찰)(치수조서연결)」, **개소당**.
@@ -201,6 +185,43 @@ def _ceil2(value: float) -> float:
return math.ceil(value * 100) / 100
def erosion_check_section(height_m: float, options: dict[str, Any]) -> dict[str, Any]:
"""골막이 한 개소의 치수 한 벌 — **표(`erosion_check_dam`)와 구조물도 그림이 같이 씀.**
상장·하장·높이 · 뒷길이 · 반수면 비탈 · 상부/하부/평균 두께 · 사면장 · 바닥파기 · 방수로 .
자름(`_floor2`) 표와 같게 그림 글자가 근거 글자와 같은 수를 보이게.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _back_length
const = EROSION_CHECK_DAM
top_m = _num(options.get("top_length_m"), 0.0)
bottom_m = _num(options.get("bottom_length_m"), 0.0)
back_cm = _back_length(options)
back_m = back_cm / 100.0
given = _num(options.get("face_slope_ratio"), 0.0)
slope = given if given > 0 else const["default_slope_ratio"]
top_raw = back_m + const["thickness_top_per_m"] * height_m
bottom_raw = back_m + const["thickness_bottom_per_m"] * height_m
thickness = _floor2((top_raw + bottom_raw) / 2)
return {
"top_m": top_m,
"bottom_m": bottom_m,
"height_m": height_m,
"back_cm": back_cm,
"back_m": back_m,
"slope": slope,
"slope_given": given > 0,
"top_t": _floor2(top_raw),
"bottom_t": bottom_raw,
"thickness": thickness,
"slant": _floor2(math.hypot((top_m - bottom_m) / 2, height_m)),
"trench_width": thickness + const["trench_extra_m"],
"trench_depth": const["trench_depth_m"],
"spillway": str(options.get("spillway") or "").strip() != "없음",
"pipe_r": const["spillway_pipe_r_m"],
}
def erosion_check_dam(
height_m: float, options: dict[str, Any]
) -> tuple[list[Component], list[str]]:
@@ -210,6 +231,7 @@ def erosion_check_dam(
STONE_BACK_LENGTH_TABLE,
STONE_MASONRY,
_back_length,
back_length_default_note,
fill_concrete_mpa,
stone_weight_per_m2,
weep_hole_spec,
@@ -240,19 +262,16 @@ def erosion_check_dam(
coeff = STONE_BACK_LENGTH_TABLE.get(back_cm)
if coeff is None:
return [], [f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다"]
if not any(options.get(key) is not None for key in ("back_len_cm", "stone_back_length_cm")):
notes.append(
f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 "
"— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
)
back_m = back_cm / 100.0
given = _num(options.get("face_slope_ratio"), 0.0)
if given > 0:
slope, slope_note = given, f"사용자 지정 1:{given:g}"
else:
slope = const["default_slope_ratio"]
slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)"
default_note = back_length_default_note(options, back_cm)
if default_note:
notes.append(default_note)
geo = erosion_check_section(height_m, options)
back_m, slope = geo["back_m"], geo["slope"]
slope_note = (
f"사용자 지정 1:{slope:g}"
if geo["slope_given"]
else f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)"
)
# ① 정면적(사다리꼴) − ② 방수로 파형강관 단면
# ⚠ 「없음」이면 안 뺀다 — 정면적이 밑수라 **열한 줄이 통째로 움직인다.**
@@ -272,14 +291,7 @@ def erosion_check_dam(
)
masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0)))
top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m)
thickness = _floor2(
(
(back_m + const["thickness_top_per_m"] * height_m)
+ (back_m + const["thickness_bottom_per_m"] * height_m)
)
/ 2
)
top_t, thickness = geo["top_t"], geo["thickness"]
volume = _floor2(front_area * thickness)
facing = top_m * (top_t - back_m)
base_area = masonry + facing
@@ -340,9 +352,7 @@ def erosion_check_dam(
)
# 바닥파기 — 비탈 사면장 두 쪽 + 하장 한 쪽, 폭은 평균두께 + 0.3.
slant = _floor2(math.hypot((top_m - bottom_m) / 2, height_m))
width = thickness + const["trench_extra_m"]
depth = const["trench_depth_m"]
slant, width, depth = geo["slant"], geo["trench_width"], geo["trench_depth"]
trench = _floor2(slant * depth * width * 2 + bottom_m * depth * width)
spoil = _round2(trench - (slant * depth * width + bottom_m * depth * width))
rows.extend(
@@ -360,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"]
@@ -415,7 +426,9 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
("면목", "m", 2.0 * 1.0, "2 × 1"),
),
},
"콘크리트 L형수로 H=0.2": {
# ⚠ 키는 **등록부 선택지 글자 그대로**(「L형수로 H=0.2」) — 2026-09-14 까지 앞에
# 「콘크리트 」가 붙어 있어 골라도 **영영 「원단위가 없습니다」**로 섰음.
"L형수로 H=0.2": {
"rows": (
("터파기", "", 0.31645, "[{(1.11+1.51)÷2×0.2} + {(0.33×0.33)÷2}] × 1.0"),
("되메우기", "", 0.129975, "(0.2+0.30)÷2×0.25×1 + (0.2+0.19)÷2×0.33×1.0"),
@@ -426,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"),
),
@@ -446,6 +459,17 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
#: 수량은 여기서 제 것으로 낸다.** 그림이 같다고 기슭막이 원단위를 끌어오면 그 확정을 어긴다.
SOIL_GUARD_SOD: dict[str, Any] = {
"unit_label": "개소당",
#: 정본 평균 붙박이 치수(m) — 아래 줄 식의 수 그대로. 기슭 0.54 = 사면장 √(0.2²+0.5²).
#: 구조물도 그림이 이 치수로 그림(`test_b08_structure_figure_small` 이 줄 값과 대조).
"dims": {
"top_m": 1.5,
"bottom_m": 1.1,
"height_m": 0.5,
"sod_m": 0.2,
"slant_m": 0.54,
"trench_width_m": 0.4,
"trench_depth_m": 0.2,
},
"rows": (
(
"",
@@ -467,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:
@@ -479,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) — 개소 제원을 안 봅니다",
]
@@ -494,10 +520,11 @@ 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:
return [], ["연장이 없어 전개하지 않음"]
from B08_Quantity.B08_Quantity_Wording import option_missing
return [], [option_missing("length_m", "open_ditch")]
spec = str(options.get("ditch_spec") or "콘크리트 개거 150×200").strip()
table = OPEN_DITCH_FORMS.get(spec)
if table is None:
@@ -505,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":
@@ -538,7 +567,6 @@ def bed_sill(area_m2: float, options: dict[str, Any]) -> tuple[list[Component],
등록부 밑수도 `area_m2` 축이 그대로 맞는다.
기울기 몫이 **없다** 평면에 붙이는 것이라 (1+) 곱하지 않는다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
form = str(options.get("form") or "").strip()
table = BED_SILL_FORMS.get(form)
@@ -583,14 +611,26 @@ 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㎝ 이상 콘크리트(버림) 포설 후 "
"돌붙임」(KDS 44 90 00 의 100㎜ 와 같음)"
)
notes.append(BED_SILL_TRENCH_NOTE)
return components, notes
#: 바닥막이 터파기 사유 — 표 사유·구조물도 그림이 **같은 말**(2026-09-14 그림에서 드러남).
BED_SILL_TRENCH_NOTE = (
"⚠ 터파기는 정본대로 면적 × 돌 두께만 — 그 밑 버림·기초잡석 두께는 판 깊이에 안 들어감"
" · 옹벽은 버림·잡석까지 팜 — 바닥막이는 정본이 돌 두께만이라 다름"
)
@@ -0,0 +1,611 @@
"""구조물 원단위 — **돌쌓기 제원·계수표·판정** · 2026-09-14 `UnitQuantity` 에서 가름.
뒷길이 계수표 · 종류 계수 · 무게 · 물구멍 · 채움 강도 · 버림·기초잡석 상수 · 표준경사 판정 ·
두께. 성분을 세우지 않음 세우는 것은 `_Masonry`(전개식)·`UnitQuantity.build_table`.
"""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import _num
# ── 계수표 — 식에 박지 않고 여기서 고른다 ─────────────────────────────
# 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」.
# ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮).
#: ⚠ **일곱 규격**이다 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 를 다 준다.
#: 앞서 네 칸(35·45·55·60)만 들고 25·30 을 35 로, 75 를 60 으로 **접고** 있었다.
#: `stone_ton_per_m2`(돌중량)는 **실무 관측값**이라 그 넷에만 있다 — 없는 칸은 `None`.
STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = {
25: {"fill_concrete_m3_per_m2": 0.11, "wedge_stone_m3_per_m2": None, "stone_ton_per_m2": None},
30: {"fill_concrete_m3_per_m2": 0.14, "wedge_stone_m3_per_m2": 0.10, "stone_ton_per_m2": None},
35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575},
45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88},
55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10},
60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None},
75: {"fill_concrete_m3_per_m2": 0.34, "wedge_stone_m3_per_m2": 0.25, "stone_ton_per_m2": None},
}
DEFAULT_BACK_LENGTH_CM = 45
#: ⑤ 돌 중량이 어디서 온 값인가 (2026-09-08 사용자 확정 2차 ⑤ 「DB 확인 → 없으면 현재 유지,
#: 나중에 프로그램에서 확인할 수 있게 근거를 남길 것」).
#: ⚠ **품셈·교본에는 돌중량표가 없다** — 지식DB 전수 확인 결과 이 값이 나오는 곳은
#: 실무 원단위 라이브러리 한 곳뿐이다(기술문서에는 그 라이브러리를 인용한 줄만 있음).
#: 그래서 값을 바꾸지 않고 **어디서 온 값인지**를 줄마다 달아 화면에서 되짚게 한다.
STONE_WEIGHT_SOURCE = "uljin_library"
STONE_WEIGHT_BASIS = (
"⚠ 실무 관측값 — 울진 소광 원단위 라이브러리 「돌뒷길이별 원단위표」(35·45·55㎝만 있음)."
" 품셈·교본에는 돌중량표가 없음"
)
#: ⭐ 사용자 확정 5차 큰 것 7 — **돌 무게는 계산식**: `뒷길이 × 0.77 × 2.65`.
#: 정본 여섯 탭이 모두 그 식을 그대로 적어 둔다(「2.09 × 0.45 × 0.77 × 2.65 ton/m3」).
#: ⚠ **야면석은 그 식이 안 맞는다** — 공극이 커 ㎥당 무게가 달라 원문이 값을 직접 준다
#: (35→0.575 · 45→0.880 · 55→1.100 ton/㎡). 그래서 **종류로 갈래를 둔다.**
#: ⚠ **잠정이다** — 확정 문구가 「나중에 실무자 협의 후 명확히」이므로 화면 근거에 적는다.
STONE_PACKING_RATIO = 0.77 # 채움률 — 돌 사이 공극을 뺀 몫
STONE_DENSITY_TON_M3 = 2.65 # 석재 비중
#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우는 **계산식**으로 선다(`stone_masonry` 주석).
STONE_WEIGHT_OBSERVED_KINDS = frozenset({"야면석·호박돌", "야면석"})
STONE_WEIGHT_FORMULA_BASIS = (
"⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값"
)
#: 물구멍 칸 — 2026-09-09 에 등록부에 생겼다(랩탑 메인 `0b0763ed`). **빈 칸이 기본**이다.
#: ⚠ 기본 Ø50 은 **국가기준**이고 2㎡당 1개소는 실무 관측값이다(법은 「2~3㎡당 1개소 이상」).
WEEP_HOLE_DIAMETER_KEY = "weep_hole_diameter_mm"
WEEP_HOLE_AREA_KEY = "weep_hole_area_m2"
WEEP_HOLE_DIAMETER_DEFAULT_MM = 50
def weep_hole_spec(options: dict[str, Any]) -> tuple[float, int, str]:
"""(개소당 벽면적 ㎡, 관 지름 ㎜, 근거). 안 정하면 기본으로 서고 그 사실이 근거에 남는다."""
area = _num(options.get(WEEP_HOLE_AREA_KEY), 0.0)
diameter = _num(options.get(WEEP_HOLE_DIAMETER_KEY), 0.0)
area_given = area > 0
dia_given = diameter > 0
if not area_given:
area = STONE_MASONRY["weep_hole_area_m2"]
if not dia_given:
diameter = WEEP_HOLE_DIAMETER_DEFAULT_MM
basis = (
f"{area:g}㎡당 1개소 · Ø{int(diameter)}"
+ ("" if area_given else " · ⚠ 면적 안 정함 — 실무 관측 2㎡(법은 2~3㎡당 1개소 이상)")
+ ("" if dia_given else " · ⚠ 지름 안 정함 — 국가기준 Ø50")
)
return area, int(diameter), basis
def stone_weight_per_m2(
back_cm: int, kind: str, observed: float | None
) -> tuple[str, float | None, str, str]:
"""(줄 이름, ton/㎡, 근거, source) — ⭐ 확정 5차 큰 것 7.
**계산식이 기본**이고 야면석만 관측표다. 돌쌓기·골막이가 같은 규칙을 쓰도록 여기 벌만 둔다.
"""
if kind in STONE_WEIGHT_OBSERVED_KINDS:
basis = (
f"× {observed} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}"
if observed is not None
else ""
)
return kind, observed, basis, STONE_WEIGHT_SOURCE
ton = back_cm / 100.0 * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3
basis = (
f"× {ton:.3f} ton/㎡ = 뒷길이 {back_cm / 100.0:g} × {STONE_PACKING_RATIO}"
f" × {STONE_DENSITY_TON_M3}"
+ (f" · {kind}" if kind else " · ⚠ 돌 종류를 안 골랐음")
+ f" · {STONE_WEIGHT_FORMULA_BASIS}"
)
return kind or "", ton, basis, ""
#: 뒷채움(막자갈) 폭 — ⭐ 사용자 확정 5차 작은 것 3 「막자갈 = (상 + 하) ÷ 2 × H」.
#: ⚠⚠ **여기 상·하는 벽 두께가 아니라 뒷채움 폭이다.** 벽 두께(0.75/0.9)를 끌어다 쓰면
#: H=2.0 에서 실무 0.75 가 1.66(입적)으로 튄다 — 옛 식이 곧 그 자리였다.
#: 정본 `04.구조도(기슭막이).xls` **여섯 탭 전부** 상 0.30 · 하 0.45 로 같다
#: (H=1.0~3.0 · 메/찰 · 기초유/버림 전수 확인, 2026-09-09).
#: ⓘ 소광리는 같은 식에 **0.30 / 0.60** 을 쓴다(그래서 H=2.0 에서 0.900). 문서마다 폭이
#: 다르므로 **값이 적힌 구조물은 그 값**을 쓰고, 없으면 이 정본 값으로 서되 그 사실을 알린다.
STONE_BACKFILL_WIDTH_M = {"top": 0.30, "bottom": 0.45}
#: ⚠⚠ 정본 전수 확인이 **H=1.0~3.0** 까지다 — 그보다 높은 벽은 **근거가 없다.**
#: 그리고 그 자리에서 품셈 13-4-4 [주]⑨ <뒤채움 조약돌 두께> 범위와 **갈린다**:
#: H 3.0 두께 0.359 (품셈 0.33~0.58 안) · H 3.5 0.354 (품셈 0.40~0.70 **아래**)
#: 까닭은 이 식의 두께(= 막자갈 ÷ 비탈면적)가 `0.375 ÷ √(1+경사²)` 로 거의 붙박이인데,
#: 품셈 표는 직고와 함께 두꺼워지기 때문이다 — **방향이 반대다.**
#: ⚠ 교본 7-3 은 「찰 3.0m 이하 · 메 2.0m 이하」로 두지만 **코드가 높이를 막지 않고**
#: 표준경사표(13-4-4 [주]⑪)는 직고 7m 까지 칸을 준다 — 즉 **높은 벽이 실제로 설 수 있다.**
#: 값을 눅이거나 막지 않고 **그 사실을 사유로 드러낸다**(임의 확정 금지).
STONE_BACKFILL_VERIFIED_HEIGHT_M = 3.0
#: 석적 — ⭐ 확정 5차 작은 것 4. **정본에는 없고 소광리에만 있는 줄**이라 사유에 적는다.
#: 소광리 식 `H × ℓ3 × 0.77`(면적 × 뒷길이 × 채움률) 그대로.
STONE_PILE_BASIS = "⚠ 정본에 없는 줄 — 소광리 시트에만 있음(정면적 × 뒷길이 × 0.77)"
#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3.
#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다.
#: 그 값이 「깬돌」 계열이라, **자재로는 야면석을 내면서 계수는 깬돌**을 쓰는 어긋남이
#: 있었다(2026-09-08 지식DB 대조). 랩탑 창이 `stone_kind` 칸을 만들어 축이 생겼다.
STONE_KIND_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
STONE_KIND_PREFIX = "stone_kind_"
STONE_KIND_OPTION = "stone_kind"
@lru_cache(maxsize=1)
def load_stone_kind_table() -> dict[str, Any]:
"""돌 종류별 계수표. 파일이 없으면 **빈 표** — 그러면 종전 값으로 돈다."""
folder = STONE_KIND_DIR
if not folder.is_dir():
return {}
files = sorted(folder.glob(STONE_KIND_PREFIX + "*.json"))
if not files:
return {}
try:
return json.loads(files[-1].read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
#: ⑨ 계수를 **어느 열로 볼 것인가** (2026-09-08 사용자 확정 2차 ⑨).
#: 기본은 **품셈 열**(야면석 고임돌 0.11 · 채움 0.15)이고, 「실무 관행」으로 두면
#: 건설품셈 참고자료 한 벌(깬돌 계열 0.15 · 0.20)로 덮어쓴다.
#: ⚠ **빈 칸은 「안 정함」**이고 그때 기본(품셈)으로 선다 — 등록부 default 는 비어 있다.
#: 기본값을 등록부가 아니라 여기가 갖는 것은 `face_slope_ratio` 와 같은 결이다.
COEFF_BASIS_KEY = "stone_coeff_basis"
COEFF_BASIS_PRACTICE = {"실무 관행", "실무관행", "practice", "관행"}
def wants_practice_coefficients(options: dict[str, Any]) -> bool:
"""계수를 실무 관행 열로 볼 것인가. 빈 칸이면 **아니오**(= 품셈 열)."""
return str(options.get(COEFF_BASIS_KEY) or "").strip() in COEFF_BASIS_PRACTICE
def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]:
"""(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다.
값을 낸다고 멈추지 않는다 이미 저장된 프로젝트가 통째로 비어 버린다.
표에 -( 규격에 돌을 ) **지어내지 않고** 사유를 낸다.
"""
table = load_stone_kind_table()
key = str(back_cm)
kind = str(options.get(STONE_KIND_OPTION) or "").strip()
if not table:
return {}, ""
if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]:
return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다")
practice = wants_practice_coefficients(options)
if not kind or practice:
fallback = table.get("fallback") or {}
message = (
f"{kind}」이지만 계수를 **실무 관행 열**(건설품셈 참고자료)로 덮어썼습니다 —"
" 기본은 품셈 열입니다(확정 ⑨)"
if practice and kind
else str(fallback.get("message") or "")
)
return {
"wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key),
"fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key),
"backfill_ratio": fallback.get("backfill_ratio_of_back_length"),
"kind": "",
}, message
if kind not in (table.get("kinds") or []):
return {}, f"{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다"
wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key)
fill = ((table.get("fill_concrete_m3_per_m2") or {}).get(kind) or {}).get(key)
ratio = (table.get("backfill_ratio_of_back_length") or {}).get(kind)
note = ""
if wedge is None:
note = f"품셈 13-4-3 에 「{kind} · 뒷길이 {back_cm}㎝」 칸이 비어 있습니다 — 그 규격에 그 돌을 쓰지 않습니다"
return {
"wedge_stone_m3_per_m2": wedge,
"fill_concrete_m3_per_m2": fill,
"backfill_ratio": ratio,
"kind": kind,
}, note
# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것.
#: ⚠⚠ **기슭막이 계열(돌쌓기 찰·메) 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09).
#: 골막이는 **더하는 몫이 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26:
#: 골막이 평균두께 = {(3 + 0.1×H) + (3 + 0.4×H)} ÷ 2
#: 기슭막이 상부 = ℓ3 + 0.30 · 하부 = 상부 + 0.30(H 1)
#: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을
#: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어
#: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는**
#: 결함이 조용히 남는다.
#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.**
#: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) ·
#: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**.
STONE_MASONRY = {
# ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침).
# 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다
# (1:0.3 → √(1+0.3²) = 1.0440 ≈ 1.04). 시트가 반올림해 적은 것을 우리가
# **별도 계수로 오해해 `hypot` 위에 또 곱하고 있었다** — 면적이 4 % 부풀었고
# 그 면적이 고임돌·야면석·채움콘크리트·모르터·물구멍 **전부의 밑수**였다.
# ⚠ 하드코딩하면 안 되는 값이다 — 큰돌쌓기는 「1:0.3 **이상**」이라 기울기가
# 바뀔 수 있고, 그때 1.04 는 틀린 값이 되지만 `hypot` 은 따라간다.
"sheet_check_factor_at_0_3": 1.04, # 1:0.3 에서 시트값과 맞는지 대조하는 자리
# ⭐ 2026-09-09 확정 2차 ② — **실무 구조물도 식**으로 갈았음(랩탑 보조가 소광리 원본
# 네 탭에서 찾아 실측과 소수점까지 대조).
# 상부 두께 = 뒷길이(3) + 0.30
# 하부 두께 = 상부 + 0.30 × (H 1.0)
# ⚠ **옛 식에는 뒷길이가 없었다**(0.45 + 0.10·H / 0.45 + 0.40·H) — 뒷길이를 35 로
# 바꿔도 두께가 안 바뀌어 돌 부피·막자갈·터파기가 다 같은 값으로 섰다.
# ⚠ 정본 xls 의 「상부 0.75 · 하부 0.90」은 **H=1.5 규칙값을 다른 높이 탭에 복사**한
# 흔적이라 그대로 쓰면 H=1.5 밖에서 틀린다.
# ⚠ 돌조공(H=0.5 · 0.55/0.65)은 이 규칙 밖 — 다른 시설이라 여기서 다루지 않는다.
"thickness_top_add_m": 0.30, # 상부 = 뒷길이 + 0.30
"thickness_slope_per_m": 0.30, # 하부 = 상부 + 0.30 × (H 1.0)
"thickness_height_base_m": 1.0, # 그 식의 기준 높이
# ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다.
# 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다.
"weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적
# ⚠ **구조물마다 다른 값이다** — 실무 관측: 반중력식 옹벽 0.32 m/m · 돌기슭막이 0.39 m ·
# 돌골막이 0.5 m/개소(2026-09-08 랩탑 보조). 여기 0.5 하나로 돌고 있으니 그 사실을
# 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다.
# ⓘ 2026-09-13 — 돌쌓기(기슭막이)는 **평균두께**로 갈음(브레인 판정).
# 이 값은 골막이 정본용으로만 남음.
"weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이 — 골막이
"mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만)
# ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다
# (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다).
# 여기서 다시 두면 그림과 물량이 갈린다.
}
# 성분이 어디로 가는가 — 이중계상을 막는 표시.
# `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증)
# `material` = 자재총괄로 감(할증은 거기서 한 번만)
# `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해)
#: 버림 콘크리트 — **빠뜨리고 있던 줄**이다(2026-09-09 사용자 확정 ⑭).
#: 두께 근거: KDS 44 90 00 도로암거구조설계기준 「기초시공시 기초지반 다짐을 시행하고
#: 구조물 시공이 원활하도록 **100 mm 두께의 버림콘크리트**를 타설하도록 한다」.
#: 폭 근거: KCS 34 50 05 「버림 콘크리트의 두께는 설계도서에 따르며, **폭은 잡석다짐의
#: 폭과 동일**하게 한다」.
#: ⭐ 그 잡석다짐 폭이 **정해졌다** — 「기준 구조물에서 유도, 같은 값을 공유」
#: (2026-09-08 사용자 확정 2차 ⑪ 「기슭막이면 **하단 길이** × 종단방향 폭 × 높이」).
#: ⇒ 벽에서 **하단 길이 = 하부 두께**다. 종전의 「터파기 폭 잠정」을 그 값으로 바꾼다.
#: ⚠ **기초잡석(품셈 12-25) 물량은 아직 못 센다** — 폭은 이제 있으나 **두께가 어느
#: 원문에도 없다**(품셈은 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라
#: **다른 구조물 값을 옮겨 쓰지 않고** 사유로 드러낸다.
#: 돌을 **사 오나 캐나** — 기본은 「캔다」(2026-09-09 사용자 확정 ②).
#: 법이 그쪽을 권한다 — 별표2 「석축 등에 필요한 야면석 등은 **가급적 현장에서
#: 채취·사용**하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 **구입 단가가
#: 0건**이고 울진 일위대가는 「구입/채집」 두 벌을 갖고 있다.
#: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다.
STONE_SUPPLY_KEYS = ("stone_supply", "stone_source")
STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"}
#: 조달을 안 정한 구조물의 사유 — 셈은 확정 ② 「기본은 캔다」대로 채집이되 **안 정한 사실**을 보임.
STONE_SUPPLY_DEFAULT_NOTE = "돌 조달(채집·구입)을 안 정해 「채집」으로 셈 — 사용자 확정 ② 기본 · 구조물 상세에서 고르면 바뀜"
#: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도
#: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다).
#:
#: 채집석 공제는 사토에서 한 번만 뺀다.
#: B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며,
#: 빼는 자리는 유토곡선의 사토뿐이다 —
#: 실어 내는 몫(spoil_m3 natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다.
#:
#: ⚠ **부호를 넘기지 않는다.** 실무 시트가 `−274.66` 으로 적혀 있어 그대로 넘기면 두 번
#: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다.
COLLECTED_STONE_KEY = "collected_stone_deduction_m3"
#: ⑩ 채움 콘크리트 강도 (2026-09-08 사용자 확정 2차 ⑩ 「기본 210, 고를 수 있게」).
#: ⚠ **빈 칸은 「안 정함」**이고 그때 210 으로 선다 — 등록부 default 는 비어 있다.
#: 180 은 국가기준 하한(돌쌓기 전용 값)이라 고르면 그 값이 이긴다.
FILL_CONCRETE_MPA_KEY = "fill_concrete_mpa"
FILL_CONCRETE_MPA_DEFAULT = "210"
FILL_CONCRETE_MPA_CHOICES = ("180", "210")
def fill_concrete_mpa(options: dict[str, Any]) -> tuple[str, str]:
"""(강도, 근거). 안 정했으면 기본 210 이고 그 사실이 근거에 적힌다."""
value = str(options.get(FILL_CONCRETE_MPA_KEY) or "").strip()
if value in FILL_CONCRETE_MPA_CHOICES:
return value, f"강도 {value} — 저장 제원에서 고른 값"
if value:
return (
FILL_CONCRETE_MPA_DEFAULT,
f"강도 {FILL_CONCRETE_MPA_DEFAULT} — ⚠ 저장값 「{value}」은 180·210 중에"
" 없어 기본으로 섬",
)
return (
FILL_CONCRETE_MPA_DEFAULT,
f"강도 {FILL_CONCRETE_MPA_DEFAULT} — 안 정해 기본값(확정 ⑩)",
)
BLINDING_THICKNESS_M = 0.10
#: 기초잡석 두께(m) — 2026-09-09 사용자 확정 3차 ② 「0.2 m」.
#: ⚠ 품셈 12-25 는 **㎥당 품만** 주고 두께를 정하지 않는다(전 출처 소진). 폭은 **버림 폭과
#: 같고**(KCS 34 50 05 「버림 콘크리트의 폭은 잡석다짐의 폭과 동일」) 그 폭은 확정 ⑪ 로
#: 하단 길이다. ⇒ **기초잡석 = 버림 × (잡석두께 ÷ 버림두께)** 로 나온다 — 같은 폭·연장이라
#: 두께 비만 곱하면 된다. 관측 원단위로 오는 구조물(옹벽)도 같은 식으로 선다
#: (버림 0.15㎥/m ÷ 0.1 = 폭 1.5m ⇒ 잡석 0.30㎥/m — 확정 3차 ② 의 그 값).
RUBBLE_BASE_THICKNESS_M = 0.2
#: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭).
#: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다.
BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding")
#: 표준경사표 — 품셈 13-4-4 [주]⑪. 파일이 없으면 종전 기본값(0.3)으로 돈다.
SLOPE_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry"
SLOPE_PREFIX = "masonry_slope_"
#: 사용자가 덮어쓰는 칸 — 값이 있으면 **그 값이 이긴다**(확정 ⑨).
FACE_SLOPE_KEYS = ("face_slope_ratio",)
#: 성토/절토는 **측점 단면유형(`design.section_mode`) + 설치 측**으로 판정한다
#: (`common_util_structure_face_role` 한 벌). 못 가르는 자리(`both_cut` + 「자동」)만 남아
#: 그때 쓸 칸을 여기 한 곳에 둔다 — **눅여 쓰는 것이 아니라 종전값으로 서고 사유를 적는다.**
DEFAULT_SLOPE_FACE = "성토"
LEGACY_FACE_SLOPE_RATIO = 0.3
@lru_cache(maxsize=1)
def load_slope_table(path: str | None = None) -> dict[str, Any]:
"""표준경사표. 없으면 빈 표 — 그때는 종전 기본값으로 돈다(값을 지어내지 않는다)."""
target = Path(path) if path else None
if target is None:
files = sorted(SLOPE_DIR.glob(SLOPE_PREFIX + "*.json")) if SLOPE_DIR.is_dir() else []
target = files[-1] if files else None
if target is None or not target.is_file():
return {}
return json.loads(target.read_text(encoding="utf-8"))
def face_slope_ratio(
options: dict[str, Any],
*,
wet: bool,
height_m: float,
face: str | None = None,
face_reason: str = "",
table: dict[str, Any] | None = None,
) -> tuple[float, str]:
"""(기울기 1:n 의 n, 근거 문구).
**사용자가 정했으면 값이 이긴다**(확정 ). 정했으면 품셈 13-4-4 []
<표준경사>에서 **직고 · / · 성토/절토** 셋으로 고른다.
자동으로 고른 값을 **저장에 박지 않는다** 정한 없음 0.3 으로 정함
구별돼야 한다(오늘 패널 접힘에서 지킨 것과 같은 자리).
경계값은 ** **이다 원문이 `3` 이라 직고 3.0m `~3` 칸이다.
"""
for key in FACE_SLOPE_KEYS:
given = _num(options.get(key), 0.0)
if options.get(key) is not None and given > 0:
return given, f"사용자 지정 1:{given:g}"
found = table if table is not None else load_slope_table()
steps = found.get("steps_m") or []
bond = "찰쌓기" if wet else "메쌓기"
# ⚠ 성토/절토는 **판정 한 벌**(`common_util_structure_face_role`)이 준다. 못 가르면
# `None` 이 오는데, **성토로 눅이지 않는다** — 임의값이 금액으로 굳으면 안 된다.
# 그때는 종전값으로 서되 **왜 못 갈랐는지**를 근거에 적는다.
if not face:
why = face_reason or "성절토를 가를 근거 없음"
return (
LEGACY_FACE_SLOPE_RATIO,
f"{why} — 표준경사표를 못 골라 종전값 1:{LEGACY_FACE_SLOPE_RATIO} 로 섰음",
)
row = ((found.get("table") or {}).get(bond) or {}).get(face)
if not row:
return LEGACY_FACE_SLOPE_RATIO, f"표준경사표를 못 읽어 종전값 1:{LEGACY_FACE_SLOPE_RATIO}"
index = len(steps)
for i, limit in enumerate(steps):
if height_m <= float(limit):
index = i
break
ratio = float(row[min(index, len(row) - 1)])
label = f"직고 {height_m:g}m" + (f"{steps[index]:g}m" if index < len(steps) else " 7m 초과")
note = f"품셈 13-4-4 [주]⑪ 표준경사 · {bond} {face} · {label} → 1:{ratio:g}"
if face_reason:
note += f" · {face_reason}"
return ratio, note
def is_collected_stone(options: dict[str, Any]) -> bool:
"""이 구조물의 돌을 **캐서 쓰는가**. 정한 적이 없으면 「캔다」(확정 ②)."""
for key in STONE_SUPPLY_KEYS:
raw = options.get(key)
if raw is None:
continue
if str(raw).strip().lower() in STONE_SUPPLY_PURCHASED:
return False
return True
def wants_blinding(options: dict[str, Any]) -> bool:
"""버림 콘크리트를 넣을지. **정한 적이 없으면 넣는다**(사용자 확정 ⑭).
넣음·제외·`false` 적혀 있을 때만 뺀다 칸을 빼기 읽으면
저장해 없는 프로젝트에서 줄이 통째로 사라진다.
"""
for key in BLINDING_OPTION_KEYS:
raw = options.get(key)
if raw is None:
continue
text = str(raw).strip().lower()
if text in {"false", "0", "no", "제외", "안 넣음", "안넣음", "빼기"}:
return False
return True
#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인).
#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다**
#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다.
#: 옛 이름도 함께 본다(다른 곳에서 그 이름으로 넣어 줄 수 있다).
BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm")
#: 메쌓기 높이 — **두 기준이 공존**(2026-09-14 브레인 판정 ㉰ · 지식DB 대조 정합):
#: 임도기술교본 7-3:27 「돌흙막이 높이는 원칙적으로 찰쌓기는 3.0m 이하, 메쌓기는 2.0m 이하」 ↔
#: 품셈 13-4-4 [주]⑪ 원문 L7192 「돌쌓기는 높이 3m 이상이면 전부 또는 하부를 찰쌓기로 한다」.
#: 막지 않고 사유만(판정 ① 「놓기를 막지 말 것」과 한 결).
DRY_MASONRY_GUIDE_M = 2.0
DRY_MASONRY_LIMIT_M = 3.0
def dry_masonry_height_note(height_m: float) -> str | None:
"""메쌓기 높이가 기준을 넘으면 사유 한 줄 — 두 기준을 다 적음."""
if height_m >= DRY_MASONRY_LIMIT_M:
return (
f"⚠ 메쌓기 높이 {height_m:g}m — 품셈 13-4-4 [주]⑪(원문 L7192) 「돌쌓기는 높이 3m 이상이면"
" 전부 또는 하부를 찰쌓기로 한다」 · 임도기술교본 7-3:27 은 메쌓기 2.0m 이하 — 막지 않음(설계자 확인)"
)
if height_m > DRY_MASONRY_GUIDE_M:
return (
f"⚠ 메쌓기 높이 {height_m:g}m — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」를 넘음"
" · 품셈 13-4-4 [주]⑪(원문 L7192)은 3m 이상부터 찰쌓기 — 두 기준이 다름 · 막지 않음"
)
return None
#: 뒷길이 표준표 — 품셈 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 ) **계수가 없다고 드러낸다**
접으면 다른 규격 계수가 조용히 돈다.
"""
for key in BACK_LENGTH_KEYS:
raw = options.get(key)
if raw is None:
continue
try:
value = int(float(raw))
except (TypeError, ValueError):
continue
# ⚠ **접지 않는다.** 앞서 「가장 가까운 위 칸」으로 접고 있었는데, 그러면 40㎝ 가
# 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측).
# 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다.
return value
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,
*,
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}㎝ 로 섰습니다 "
"— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
)
#: 벽 두께를 **사용자가 덮어쓰는 칸** — 비우면 실무 구조물도 식으로 돈다(확정 2차 ② 후반
#: 「사용자가 값을 바꿀 수 있게」). 야면석 계수·채움 강도와 같은 자리다.
WALL_THICKNESS_KEYS: tuple[str, str] = ("thickness_top_m", "thickness_bottom_m")
def wall_thickness(
options: dict[str, Any], *, back_cm: float, height_m: float
) -> tuple[float, float, str]:
"""(상부 두께 m, 하부 두께 m, 근거 문구) — **사용자가 넣은 값이 식을 이긴다.**
칸은 정함이다 자동으로 나온 값을 저장에 박지 않는다(`face_slope_ratio`
같은 태도). 상부만 넣으면 하부는 ** 상부 위에서** 식을 이어 간다 모양
(아래로 벌어지는 기울기) 사용자 위에 그대로 얹는 것이 자연스럽다.
"""
def given(key: str) -> float | None:
raw = options.get(key)
if raw in (None, ""):
return None
try:
value = float(raw)
except (TypeError, ValueError):
return None
return value if value > 0 else None
constants = STONE_MASONRY
back_m = back_cm / 100.0
top_given = given(WALL_THICKNESS_KEYS[0])
top = top_given if top_given is not None else back_m + constants["thickness_top_add_m"]
rise = constants["thickness_slope_per_m"] * max(
height_m - constants["thickness_height_base_m"], 0.0
)
bottom_given = given(WALL_THICKNESS_KEYS[1])
bottom = bottom_given if bottom_given is not None else top + rise
top_basis = (
f"상부 {top:.2f}(사용자 입력)"
if top_given is not None
else f"상부 {top:.2f}(뒷길이 {back_cm:g}㎝ + {constants['thickness_top_add_m']:g})"
)
bottom_basis = (
f"하부 {bottom:.2f}(사용자 입력)"
if bottom_given is not None
else (
f"하부 {bottom:.2f}(상부 + {constants['thickness_slope_per_m']:g}"
f"×(H{constants['thickness_height_base_m']:g}))"
)
)
return top, bottom, f"실무 구조물도 식 — {top_basis} · {bottom_basis}"
+62 -1
View File
@@ -30,7 +30,12 @@ from B06_Section.B06_Section_Repository import (
get_longitudinal_section,
get_workflow_route_context,
)
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import SummaryInput
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import (
FACE_DRESSING_CUT_CLASSES,
FACE_DRESSING_FILL_CLASSES,
FACE_DRESSING_FILL_SUGGESTED,
SummaryInput,
)
from B08_Quantity.B08_Quantity_Engine_EarthworkSummary import build_table as build_summary_table
from B08_Quantity.B08_Quantity_Engine_EarthworkTable import StationArea, build_table
from B08_Quantity.B08_Quantity_Engine_Handoff import load_mapping
@@ -151,8 +156,23 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
},
# 노체다짐 — 기본 꺼짐. 켠 프로젝트에서만 줄이 선다(2026-09-13 판정).
subgrade_compaction_enabled=bool(settings.get("subgrade_compaction_enabled")),
# 면고르기 면적 덮어쓰기 — 비우면 파종 면적(2026-09-14 판정 Ⓐ).
face_dressing_area_m2={
"fill": settings.get("face_dressing_fill_area_m2"),
"cut": settings.get("face_dressing_cut_area_m2"),
},
)
)
# 면고르기 갈래 고르기 — 선택지는 서버 한 곳(원문 표 두 벌) · 제안값 없음(판정 Ⓒ).
table["face_dressing_choices"] = {
"cut": list(FACE_DRESSING_CUT_CLASSES),
"fill": list(FACE_DRESSING_FILL_CLASSES),
# 성토면 제안(회색 · [제안값 넣기]) — 비워 두면 여전히 「안 정함」(브레인 ②).
"fill_suggested": {
"value": FACE_DRESSING_FILL_SUGGESTED[0],
"basis": FACE_DRESSING_FILL_SUGGESTED[1],
},
}
# 준비공·사방공 — 못 서는 줄도 사유와 함께 남긴다(빈 표는 「빠뜨림」과 구별이 안 됨).
structures = await _route_structures(project_id)
table["preparation"] = build_preparation_table(
@@ -174,6 +194,11 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse:
road_surface_area(slopes),
# 대상 — 기본 노면 + 절토(별표2 문언) · 노면만은 설계자가 고름(2026-09-14 판정).
topsoil_target(settings) == "road_only",
# 임목폐기물 — 현장 조사값 · 수동 처리단가 · 분리발주(2026-09-14 판정).
settings.get("tree_waste") or {},
settings.get("tree_waste_unit_price_krw_per_ton"),
settings.get("waste_separate_order"),
settings.get("tree_waste_root_method"),
)
method, method_is_default = concrete_placing_method(settings)
# ⚠ 금액에 바로 걸리는 값이라 「기본값으로 돌고 있음」을 응답에 실어 화면이 띄우게 한다.
@@ -427,6 +452,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 [주]②).
@@ -449,6 +476,12 @@ class QuantitySettingsBody(BaseModel):
topsoil_target: str | None = None
# 임목축적 등급 — "소림"·"중림"·"밀림"(품셈 9-21 [주]①). `""` 는 「안 정함」이다.
stand_volume_class: str | None = None
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질(9-19-1 원문 표). `""` 는 「안 정함」.
face_dressing_cut_class: str | None = None
face_dressing_fill_class: str | None = None
# 면고르기 면적 덮어쓰기(㎡) — `None` 은 파종 면적을 그대로(2026-09-14 판정 Ⓐ).
face_dressing_fill_area_m2: float | None = None
face_dressing_cut_area_m2: float | None = None
# 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다.
frame_material: dict[str, Any] | None = None
# 임목파쇄 — 기본 꺼짐(확정 5차 5번). 켜면 줄이 서고, 부피를 넣으면 값이 선다.
@@ -456,6 +489,12 @@ class QuantitySettingsBody(BaseModel):
wood_chipping_volume_m3: float | None = None
# 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄.
subgrade_compaction_enabled: bool | None = None
# 임목폐기물 — 현장 조사값 넷(통째로 갈아 끼움) · 수동 처리단가 · 분리발주.
tree_waste: dict[str, float | None] | None = None
tree_waste_unit_price_krw_per_ton: float | None = None
waste_separate_order: bool | None = None
# 뿌리 산정법 — "root_ball"(뿌리분 체적 × 1,300) · `""` 는 기본(분배비 15/85)으로 되돌림.
tree_waste_root_method: str | None = None
# 토량환산계수(다짐) — `{갈래: {"compacted": C, "reason": 사유}}`.
# ⚠ **기본값을 복사해 넣지 않는다** — 안 고른 갈래는 키가 없어야 정본이 선다.
# 빈 dict 는 「전부 기본값으로 되돌림」이라 통째로 갈아 끼운다.
@@ -474,6 +513,9 @@ NULLABLE_SETTING_KEYS = (
"topsoil_haul_distance_m",
"wood_chipping_volume_m3",
"dozer_haul_limit_m",
"tree_waste_unit_price_krw_per_ton",
"face_dressing_fill_area_m2",
"face_dressing_cut_area_m2",
)
@@ -511,10 +553,26 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
"message": f"도쟈 한계거리는 종무대 {free_haul:g} m 보다 커야 합니다.",
},
)
for key, choices in (
("face_dressing_cut_class", FACE_DRESSING_CUT_CLASSES),
("face_dressing_fill_class", FACE_DRESSING_FILL_CLASSES),
):
if key in values and values[key] not in choices:
values[key] = "" # 선택지 밖·빈 값은 「안 정함」 — 가까운 갈래로 안 고침
if "concrete_placing_method" in values:
method = values["concrete_placing_method"]
# 「안 정함」으로 되돌릴 수 있어야 한다 — 빈 값이면 지운다(8-22 ② 와 같은 자리).
values["concrete_placing_method"] = method if method in CONCRETE_PLACING_METHODS else None
if "tree_waste" in values:
# 양수만 남긴다 — 빈 칸은 키가 없어야 「안 넣음」으로 읽힌다.
values["tree_waste"] = {
key: float(value)
for key, value in (values["tree_waste"] or {}).items()
if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
}
if "tree_waste_root_method" in values:
method = values["tree_waste_root_method"]
values["tree_waste_root_method"] = method if method == "root_ball" else None
if "topsoil_target" in values:
# 기본(노면 + 절토)은 저장하지 않는다 — 「안 정함」과 같게 둬 법 문언이 선다.
target = values["topsoil_target"]
@@ -552,8 +610,11 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
(
"rock_methods",
"material_supply",
"material_surcharge",
"concrete_placing_method",
"topsoil_target",
"tree_waste",
"tree_waste_root_method",
"ancillary_counts",
# 고른 계수를 **기본값으로 되돌릴 길**이 있어야 한다 — 병합이면 못 지운다.
"conversion_factors_override",
+64 -31
View File
@@ -57,8 +57,19 @@ router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
def _collect_structures(
project_root: str,
designs: list[dict[str, Any]] | None = None,
) -> tuple[list[dict[str, Any]], dict[str, str], list[str]]:
"""전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다."""
"""전개 대상 구조물·타입명·건너뛴 사유를 함께 낸다.
정본 둘을 읽음(A1, 2026-09-14) `structures.json` + `pipe_points.json`(계곡 통과 시설).
앞서 뒤엣것을 읽어 집수정·날개벽· 유입/유출 기슭막이·독립 기슭막이·물넘이포장이
원단위·인계·내역에 줄도 섰음.
지점 종류(`managed_by`) `structures.json` 저장분으로 남아 있어도
지점 정본이 주인이라 세지 않음(구조물 집계표와 같은 규칙).
"""
from B08_Quantity.B08_Quantity_Engine_Pipe import extra_walls_from_designs, facility_structures
from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file
_revision, items = load_structures(project_root)
types = structure_type_map()
targets: list[dict[str, Any]] = []
@@ -72,6 +83,11 @@ def _collect_structures(
skipped.append(f"{type_id}: 레지스트리에 없는 타입")
continue
names[type_id] = definition.name
if definition.managed_by:
skipped.append(
f"{definition.name}: 관 지점 정본이 주인 — 구조물 목록 옛 저장분은 안 셈"
)
continue
if definition.design_owner:
skipped.append(
f"{definition.name}: {definition.design_owner} 가 이미 셈 — 중복 계상 방지"
@@ -86,6 +102,14 @@ def _collect_structures(
# 여기서 빼지 않으면 **같은 시설이 두 줄로** 나간다.
continue
targets.append(payload)
points = read_pipe_points_file(pipe_points_path_in(Path(project_root)))
# 다단 추가 기슭막이(④) — 횡단 설계에 남은 「선 다단 벽 목록」도 줄로 세움.
extra_walls = extra_walls_from_designs(designs)
for row in facility_structures([point.as_dict() for point in points], extra_walls):
for type_id in (row["type_id"], row.get("attachment_parent_type")):
if type_id in types:
names[type_id] = types[type_id].name
targets.append(row)
return targets, names, sorted(set(skipped))
@@ -125,6 +149,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:
"""구조물 원단위와 자재총괄을 **한 응답**으로 낸다.
@@ -142,7 +193,7 @@ async def get_material_summary(project_id: UUID) -> JSONResponse:
)
try:
structures, names, skipped = _collect_structures(project_root)
structures, names, skipped = _collect_structures(project_root, await _designs(project_id))
except Exception:
logger.exception("B08 구조물 정본 읽기 실패: project_id=%s", project_id)
return JSONResponse(
@@ -162,12 +213,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 = [
@@ -360,7 +408,7 @@ async def project_haul_inputs(project_id: UUID) -> dict[str, Any]:
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
structures, names, _skipped = _collect_structures(project_root)
structures, names, _skipped = _collect_structures(project_root, await _designs(project_id))
settings = quantity_settings(project_root)
unit_table = build_unit_table(
structures,
@@ -403,7 +451,7 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
structures, names, skipped = _collect_structures(project_root)
structures, names, skipped = _collect_structures(project_root, await _designs(project_id))
settings = quantity_settings(project_root)
modes = await _section_modes(project_id)
unit_table = build_unit_table(
@@ -415,28 +463,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"),
@@ -464,6 +494,9 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
structure_trench_water=settings.get("structure_trench_water"),
# 임목축적 등급 — 지장목제거 뿌리뽑기(제근 9-21)의 품 갈래. 안 넣으면 B09 가 후보를 보임.
stand_volume_class=settings.get("stand_volume_class"),
# 면고르기 갈래 — 절토면 토질 · 성토면 시공·토질. 비면 그 줄이 입력 사유로 막힘.
face_dressing_cut_class=settings.get("face_dressing_cut_class") or None,
face_dressing_fill_class=settings.get("face_dressing_fill_class") or None,
# 구조물도 양식 일위대가로 셀 장 — 그 구조물은 호표 `AX-ST` 줄 하나로(PLAN 6장 ②).
priced_sheets=_priced_sheets(project_root, unit_table, modes, settings),
)
@@ -512,7 +545,7 @@ async def structure_bill_prices(project_id: UUID, build: Any) -> dict[str, dict[
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
project_root = resolve_stored_project_path(stored_path)
structures, names, _skipped = _collect_structures(project_root)
structures, names, _skipped = _collect_structures(project_root, await _designs(project_id))
settings = quantity_settings(project_root)
modes = await _section_modes(project_id)
unit_table = build_unit_table(
@@ -0,0 +1,112 @@
"""B08 라이브러리 — STmate 출력 엑셀에서 **고정형 항목 뽑아 넣기** (PLAN 4장 · 2026-09-14 브레인 판정).
걸음:
[읽기] 파일을 올리면 읽은 호표 ·구성 · 읽은 사유를 돌려줌 **아무것도 **.
사용자가 수를 보고 넣을지 정함(판정 읽은 수를 보이고 묻는 걸음).
[넣기] 같은 파일 + 고른 호표 차례 + 우리 구조물 종류 서버가 **파일을 다시 읽어** 개인 단에 .
브라우저가 보낸 줄을 받아 적지 않음(CLAUDE.md 5) · 개인 단만(판정 ) · 종류당 하나라 같은 종류 것은
덮어씀(판정 ) · 종류는 사용자가 고름 이름으로 자동으로 붙임(판정 ).
"""
import asyncio
import io
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, File, Form, UploadFile
from fastapi.responses import JSONResponse
from common_util.common_util_auth import verify_session
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
BASE = "/{project_id}/quantity/structure-sheets/library/stmate"
#: 출력 엑셀은 수백 KB — 넉넉히 두되 끝없이 받지 않음.
MAX_BYTES = 20 * 1024 * 1024
def _error(status: int, message: str, **extra: Any) -> JSONResponse:
return JSONResponse(
status_code=status, content={"status": "error", "message": message, **extra}
)
async def _read(file: UploadFile) -> dict[str, Any] | JSONResponse:
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import read_recipes
data = await file.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
return _error(413, "파일이 너무 큼(20MB 넘음)")
return await asyncio.to_thread(read_recipes, io.BytesIO(data))
@router.post(BASE + "/read")
async def read_stmate_recipes(
project_id: UUID,
file: UploadFile = File(...),
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""① 읽기만 — 호표 목록(차례·명칭·규격·단위·구성 줄 수)과 못 읽은 사유."""
read = await _read(file)
if isinstance(read, JSONResponse):
return read
hopyo = [
{
"no": h["no"],
"name": h["name"],
"spec": h["spec"],
"unit": h["unit"],
"rows": len(h["rows"]),
"percent_rows": sum(1 for row in h["rows"] if row["unit"] == "%"),
"contract_rows": h["contract_rows"],
}
for h in read["hopyo"]
]
return JSONResponse(
content={
"status": "success",
"file_name": file.filename or "",
"project": read["project"],
"hopyo": hopyo,
"counts": {"hopyo": len(hopyo), "rows": sum(h["rows"] for h in hopyo)},
"problems": read["problems"],
}
)
@router.post(BASE + "/save")
async def save_stmate_recipe(
project_id: UUID,
file: UploadFile = File(...),
hopyo_no: int = Form(...),
type_id: str = Form(..., min_length=1, max_length=100),
session: dict[str, Any] = Depends(verify_session),
) -> JSONResponse:
"""② 넣기 — 서버가 다시 읽은 호표 하나를 고정형 항목으로 **로그인한 사람 개인 단**에 씀."""
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B08_Quantity.B08_Quantity_Engine_StmateRecipe import recipe_item
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import save_item_personal, tier_dirs
folder = tier_dirs(session.get("company_id"), session.get("user_id")).get("personal")
if folder is None:
return _error(403, "개인 라이브러리는 회사에 속한 사용자만 씁니다.")
if type_id not in structure_type_map():
return _error(400, f"모르는 구조물 종류: {type_id}")
read = await _read(file)
if isinstance(read, JSONResponse):
return read
hopyo = next((h for h in read["hopyo"] if h["no"] == hopyo_no), None)
if hopyo is None:
return _error(404, f"{hopyo_no}호표를 읽지 못함", problems=read["problems"])
item = recipe_item(
hopyo, type_id=type_id, file_name=file.filename or "", project=read["project"]
)
code = await asyncio.to_thread(save_item_personal, folder, item)
return JSONResponse(
content={
"status": "success",
"code": code,
"name": item["name"],
"rows": len(item["rows"]),
"note": item["note"],
}
)
@@ -44,6 +44,7 @@ def project_structure_sheets(
project_root: str,
section_modes: dict[float, str] | None,
ground_types: dict[float, str] | None = None,
designs: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""구조물도 장 목록 — **원단위 탭(`material-summary`)과 같은 입력**으로 전개해 접는다.
@@ -52,6 +53,7 @@ def project_structure_sheets(
늦게 부른다(함수 import) B08 B05 부르고 B05 다시 B07 부를 있어
모듈 위에서 부르면 맞물린다.
"""
from B08_Quantity.B08_Quantity_Engine_StructureFigure import attach_figures
from B08_Quantity.B08_Quantity_Engine_StructureLibrary import project_templates
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates
@@ -59,7 +61,8 @@ def project_structure_sheets(
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
from common_util.common_util_project_settings import quantity_settings
structures, names, skipped = _collect_structures(project_root)
# 설계를 넘겨야 다단 추가 기슭막이 장이 원단위 탭과 같이 섬(④).
structures, names, skipped = _collect_structures(project_root, designs)
settings = quantity_settings(project_root)
templates = project_templates(project_root)
unit_table = build_unit_table(
@@ -74,6 +77,8 @@ def project_structure_sheets(
payload = build_standard_sheets(unit_table, section_modes)
# 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장).
apply_templates(payload, settings, templates)
# 상단 그림(읽기 전용 SVG 자료) — 못 그리는 장은 까닭(PLAN 12장).
attach_figures(payload, settings.get("rubble_base_thickness_m"))
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
payload["skipped_structures"] = skipped
return payload
@@ -84,11 +89,12 @@ async def _sheets_of(project_id: UUID, project_root: str) -> dict[str, Any]:
단면유형·지반 갈래는 원단위 창구(`B08_Quantity_Router_Material`) 그대로 .
"""
from B08_Quantity.B08_Quantity_Router_Material import _ground_types, _section_modes
from B08_Quantity.B08_Quantity_Router_Material import _designs, _ground_types, _section_modes
modes = await _section_modes(project_id)
ground = await _ground_types(project_id)
return await asyncio.to_thread(project_structure_sheets, project_root, modes, ground)
designs = await _designs(project_id)
return await asyncio.to_thread(project_structure_sheets, project_root, modes, ground, designs)
def _not_found() -> JSONResponse:
@@ -256,6 +262,13 @@ async def put_structure_sheet_spec(
try:
revision, stored = await asyncio.to_thread(load_structures, project_root)
updated, changed = apply_spec(stored, member_ids, spec)
outside = member_ids - {str(item.structure_id) for item in stored}
if outside:
# 관 지점 시설(A1)은 `pipe_points.json` 이 정본 — 조용히 안 먹히지 않게 알림.
notes.append(
f"관 지점 시설 {len(outside)}개소(배수관 기슭막이 등)는 이 화면에서 제원을 못 적음"
" — B05 배수 시설·구조물 집계표에서 고칠 것"
)
new_revision = await asyncio.to_thread(
save_structures, project_root, updated, base_revision=payload.base_revision
)
@@ -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`·없음이면 **안 정한 것**이라 표토제거 줄이 「근거 없음」으로 선다. */
@@ -98,6 +100,11 @@ export interface QuantitySettings {
ancillary_counts?: Record<string, number | null>;
/** 임목축적 등급 — `"소림"`·`"중림"`·`"밀림"`(품셈 9-21 [주]①). 본수가 아니라 축적이다. */
stand_volume_class?: string | null;
/** 면고르기 갈래(서버 선택지 안) · 면적 덮어쓰기(㎡, 없음 = 파종 면적). */
face_dressing_cut_class?: string | null;
face_dressing_fill_class?: string | null;
face_dressing_fill_area_m2?: number | null;
face_dressing_cut_area_m2?: number | null;
/** 표토제거 대상 — 없음이면 기본(노면 + 절토) · `"road_only"` 는 노면만. */
topsoil_target?: string | null;
/** 규준틀 개소당 재료 — `{자재명: 수량}`. 비우면 제안값(실무 관측)이 선다. */
@@ -108,6 +115,14 @@ export interface QuantitySettings {
wood_chipping_volume_m3?: number | null;
/** 노체다짐 — 기본 꺼짐(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄. */
subgrade_compaction_enabled?: boolean | null;
/** 임목폐기물 조사값 넷 — 비면 톤이 안 선다. */
tree_waste?: Record<string, number | null>;
/** 처리단가(원/톤) — 수동 · 미확정. */
tree_waste_unit_price_krw_per_ton?: number | null;
/** 폐기물 분리발주 — 기본 아님. */
waste_separate_order?: boolean | null;
/** 뿌리 산정법 — 없음이면 분배비 15/85 · `"root_ball"` 은 뿌리분 체적 × 1,300. */
tree_waste_root_method?: string | null;
}
/** 갈래 하나의 「무엇을 골랐나」. 서버 `earthwork_conversion_choices` 와 짝이다. */
@@ -145,6 +160,8 @@ export interface EarthworkTable {
settings?: QuantitySettings;
/** 갈래별 토량환산계수 선택 상태 — 산출 조건 패널이 그린다. */
conversion_factor_choices?: Record<string, ConversionFactorChoice>;
/** 면고르기 갈래 선택지 — 품셈 9-19-1 원문 표 두 벌(서버 한 곳). 화면은 그대로 보임. */
face_dressing_choices?: { cut: string[]; fill: string[] };
/** 품셈 암종별 범위(안내용). 정의처가 서버라 내려받아 보인다. */
conversion_factor_pumsem_ranges?: PumsemRange[];
/** 도쟈 한계거리 — 지금 값·기본값·근거(서버가 정본). 종무대 20 m 는 규정이라 값만 보인다. */
+49 -1
View File
@@ -28,6 +28,8 @@ export interface MaterialRow {
unit: string;
net_amount: number;
surcharge_pct: number | null;
/** 할증률표 값 = 고를 수 있는 천장(품셈 1-3-1 「표의 값 이내」). `null` 이면 칸이 안 섬. */
surcharge_cap?: number | null;
total_amount: number;
supply: string;
supply_label: string;
@@ -241,9 +243,50 @@ export interface SupplyChoice {
export interface MaterialGridOptions {
/** 저장 전 변경분 — 고른 값은 여기 쌓이고 [저장]에서만 정본으로 간다. */
choices: Record<string, SupplyChoice>;
/** `{ : % | null}` `material_surcharge`. .
* `null` = . 0 ~ ( ). */
surcharge?: Record<string, number | null>;
onChange: () => void;
}
/** 할증률 칸 — 표값(상한) 안에서만. 범위를 넘으면 칸이 빨갛게 되고 값을 안 받음. */
function surchargeCell(
row: MaterialRow,
key: string,
picks: Record<string, number | null>,
onChange: () => void,
): HTMLTableCellElement {
const cap = row.surcharge_cap as number;
const td = document.createElement("td");
const input = document.createElement("input");
input.type = "number";
input.min = "0";
input.max = String(cap);
input.step = "0.1";
input.className = "b08-grid__select";
input.style.width = "4.5em";
const picked = picks[key];
// 안 고른 칸은 **비워 두고 회색 제안값**(지금 붙는 표값)만 — 기본값을 값처럼 채우지 않음(폼 ② 규칙).
input.value = picked === null || picked === undefined ? "" : String(picked);
input.placeholder = String(row.surcharge_pct ?? cap);
input.title = `표값 ${cap}% 이내(품셈 1-3-1) · 비우면 표값`;
input.addEventListener("change", () => {
const text = input.value.trim();
const value = Number(text);
if (text !== "" && !(Number.isFinite(value) && value >= 0 && value <= cap)) {
input.setCustomValidity(`0 ~ ${cap}% 만 받음`);
input.reportValidity();
return;
}
input.setCustomValidity("");
picks[key] = text === "" ? null : value;
td.classList.add("is-changed");
onChange();
});
td.append(input);
return td;
}
/** 표 안의 고르는 칸. 바꾼 줄은 **표시가 남는다** — 무엇을 만졌는지 보여야 한다. */
function choiceCell(
value: string,
@@ -337,7 +380,12 @@ export function renderMaterialGrid(
tr.append(textCell(row.unit, "b08-grid__unit"));
tr.append(textCell(num(row.net_amount, 2)));
// 미확보는 빈칸이 아니라 「-」 — 빈칸이면 0 % 로 오해된다.
tr.append(textCell(row.surcharge_pct === null ? "" : num(row.surcharge_pct, 0)));
// 표값이 있는 줄은 「이내」로 고를 칸(초안 `surcharge` 가 이어졌을 때만).
tr.append(
options?.surcharge && typeof row.surcharge_cap === "number"
? surchargeCell(row, key, options.surcharge, options.onChange)
: textCell(row.surcharge_pct === null ? "" : num(row.surcharge_pct, 0)),
);
tr.append(textCell(num(row.total_amount, 2)));
if (options) {
// 관급/사급은 **자재마다 갈리는 발주 결정**이라 줄에서 고른다(2026-09-07 확정).
+52 -16
View File
@@ -40,6 +40,9 @@ import {
} from "./B08_Quantity_UI_MaterialGrid";
import { renderStructureSheets } from "./B08_Quantity_UI_StructureSheet";
import { renderStructureSummary } from "./B08_Quantity_UI_StructureSummary";
import { appendTreeWasteFields } from "./B08_Quantity_UI_Side_TreeWaste";
import { appendFaceDressingFields } from "./B08_Quantity_UI_Side_FaceDressing";
import { appendBenchCutFields } from "./B08_Quantity_UI_Side_BenchCut";
/** locale 헬퍼 */
function L(key: keyof typeof ui_locales): string {
@@ -93,6 +96,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,
@@ -105,10 +109,20 @@ async function saveQuantitySettings(projectId: string, draft: DraftSettings): Pr
// `""` 는 기본(노면 + 절토)으로 되돌림 — 서버가 None 으로 둔다.
topsoil_target: draft.topsoil_target,
stand_volume_class: draft.stand_volume_class,
// 면고르기 — 갈래 `""`·면적 `null` 도 그대로(「안 정함」·「파종 면적 그대로」로 되돌리는 길).
face_dressing_cut_class: draft.face_dressing_cut_class,
face_dressing_fill_class: draft.face_dressing_fill_class,
face_dressing_fill_area_m2: draft.face_dressing_fill_area_m2,
face_dressing_cut_area_m2: draft.face_dressing_cut_area_m2,
frame_material: draft.frame_material,
wood_chipping_enabled: draft.wood_chipping_enabled,
wood_chipping_volume_m3: draft.wood_chipping_volume_m3,
subgrade_compaction_enabled: draft.subgrade_compaction_enabled,
// 조사값은 **통째로** — 지운 칸까지 가야 되돌릴 길이 있다.
tree_waste: draft.tree_waste,
tree_waste_unit_price_krw_per_ton: draft.tree_waste_unit_price_krw_per_ton,
waste_separate_order: draft.waste_separate_order,
tree_waste_root_method: draft.tree_waste_root_method,
// 개소는 **통째로** 보낸다 — 지운 항목까지 그대로 가야 되돌릴 길이 있다.
ancillary_counts: draft.ancillary_counts,
// 토량환산계수 — 고른 갈래만 담긴다. 빈 dict 는 「전부 기본값으로 되돌림」이다.
@@ -293,6 +307,11 @@ interface DraftSettings {
ancillary_counts: Record<string, number | null>;
// 임목축적 등급 — "소림"·"중림"·"밀림". ⚠ 본수가 아니라 축적이다(품셈 9-21 [주]①).
stand_volume_class: string;
// 면고르기 — 갈래 둘(서버 선택지) · 면적 덮어쓰기 둘(`null` = 파종 면적). 칸은 `_Side_FaceDressing`.
face_dressing_cut_class: string;
face_dressing_fill_class: string;
face_dressing_fill_area_m2: number | null;
face_dressing_cut_area_m2: number | null;
// 규준틀 개소당 재료 — 비우면 **제안값(실무 관측)**이 선다. 값이 아니라 「고칠 수 있음」이 요점.
frame_material: Record<string, number | null>;
// 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 켜야 줄이 선다. 근주이식은 칸 자체가 없다.
@@ -300,8 +319,15 @@ interface DraftSettings {
wood_chipping_volume_m3: number | null;
// 노체다짐 — **기본 꺼짐**(9-16-2 [주]⑤ 조건부). 켜면 토공집계에 별도 줄(수량 = 성토량).
subgrade_compaction_enabled: boolean;
// 임목폐기물 — 조사값 넷 · 수동 처리단가(미확정) · 분리발주(기본 아님).
tree_waste: Record<string, number | null>;
tree_waste_unit_price_krw_per_ton: number | null;
waste_separate_order: boolean;
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)이 선다.
@@ -495,6 +521,14 @@ function buildQuantitySidePanel(
}
}
// ── 면고르기 — 밑수가 파종 면적이라 반영률 바로 밑. 선택지는 서버 목록 그대로(칸은 따로 뺀 파일).
appendFaceDressingFields(panel, draft, table?.face_dressing_choices, {
field,
optionalNumberField,
selectField,
hintRow,
});
// ── 표토 두께 — 표토 운반 부피(제거 ㎡ × T)에 씀 · 제거 줄은 ㎡ 라 안 곱함(2026-09-13 「실무대로」) ──
// ⚠ 2026-09-08 ㉘ 자기 감사: 서버·엔진은 이 값을 받고 있었는데 **화면에 넣을 칸이 없었다.**
// 「죽은 칸」(넣어도 안 쓰임)의 반대 짝이다 — 쓰이는데 넣을 데가 없던 자리.
@@ -532,22 +566,8 @@ function buildQuantitySidePanel(
// 지금 값과 근거를 보이고 바꿀 수 있게 할 것.** 정한 값이 화면에 안 보이면 다음 사람이
// 왜 그 값인지 모른다.
panel.append(field(L("B08_Quantity_Side_Structure"), ""));
panel.append(
optionalNumberField(
L("B08_Quantity_Side_BenchCut_Label"),
draft.bench_cut_depth_m,
"0.01",
(value) => {
draft.bench_cut_depth_m = value;
draft.dirty = true;
},
),
);
panel.append(hintRow(L("B08_Quantity_Side_BenchCut_Hint")));
// 층따기 관행값 — **제안값**이지 확정이 아니다(원본 그림 확인 전 · 10장 판정). 칸에 넣지 않고 보이기만.
const benchSuggest = hintRow(L("B08_Quantity_Side_BenchCut_Suggest"));
benchSuggest.classList.add("b08-quantity__hint--warn");
panel.append(benchSuggest);
// 층따기 칸 · KDS 근거 줄 · [제안값 넣기] — 누른 때만 채움(교본 6-4 「설계도서에 명시」).
appendBenchCutFields(panel, draft, { field, optionalNumberField, selectField, hintRow });
panel.append(
optionalNumberField(
L("B08_Quantity_Side_Rubble_Label"),
@@ -703,6 +723,9 @@ function buildQuantitySidePanel(
);
panel.append(hintRow(L("B08_Quantity_Side_SubgradeCompaction_Hint")));
// ── 임목폐기물 — 조사값 넷 · 수동 처리단가 · 분리발주(2026-09-14). 칸은 따로 뺀 파일에서.
appendTreeWasteFields(panel, draft, { field, optionalNumberField, selectField, hintRow });
// ── 콘크리트 타설 방식 — ⚠ **금액에 바로 걸리는 값**이라 잠정임을 조용히 두지 않는다 ──
panel.append(field(L("B08_Quantity_Side_Placing"), ""));
panel.append(
@@ -924,6 +947,7 @@ function buildQuantityBody(
material.material,
{
choices: draft.material_supply,
surcharge: draft.material_surcharge,
onChange: () => {
draft.dirty = true;
},
@@ -997,14 +1021,26 @@ export async function renderB08Quantity(root: HTMLElement): Promise<void> {
topsoil_haul_distance_m: (stored.topsoil_haul_distance_m as number | null) ?? null,
topsoil_target: (stored.topsoil_target as string) ?? "",
stand_volume_class: (stored.stand_volume_class as string) ?? "",
face_dressing_cut_class: (stored.face_dressing_cut_class as string) ?? "",
face_dressing_fill_class: (stored.face_dressing_fill_class as string) ?? "",
face_dressing_fill_area_m2: (stored.face_dressing_fill_area_m2 as number | null) ?? null,
face_dressing_cut_area_m2: (stored.face_dressing_cut_area_m2 as number | null) ?? null,
frame_material: { ...((stored.frame_material ?? {}) as Record<string, number | null>) },
wood_chipping_enabled: Boolean(stored.wood_chipping_enabled),
wood_chipping_volume_m3: (stored.wood_chipping_volume_m3 as number | null) ?? null,
subgrade_compaction_enabled: Boolean(stored.subgrade_compaction_enabled),
tree_waste: { ...((stored.tree_waste ?? {}) as Record<string, number | null>) },
tree_waste_unit_price_krw_per_ton:
(stored.tree_waste_unit_price_krw_per_ton as number | null) ?? null,
waste_separate_order: Boolean(stored.waste_separate_order),
tree_waste_root_method: (stored.tree_waste_root_method as string) ?? "",
ancillary_counts: {
...((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]) => [
@@ -0,0 +1,56 @@
/* =============================================================================
* B08_Quantity_UI_Side_BenchCut.ts
* · · [ ] (2026-09-14 ).
*
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* 6-4 .
* . ** ** KDS 44 30 00
* 2.3.4 (4) ( 500 mm) · , [] .
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton } from "@ui/ui_template_elements";
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** KDS 44 30 00 도로토공 2.3.4 (4) ② — 기초지반이 토사인 경우 최소 높이(임도 지반이 대개 토사). */
const KDS_SOIL_MIN_HEIGHT_M = 0.5;
export interface BenchCutDraft {
bench_cut_depth_m: number | null;
dirty: boolean;
}
export function appendBenchCutFields(
panel: HTMLElement,
draft: BenchCutDraft,
h: SideFieldHelpers,
): void {
const row = h.optionalNumberField(
L("B08_Quantity_Side_BenchCut_Label"),
draft.bench_cut_depth_m,
"0.01",
(value) => {
draft.bench_cut_depth_m = value;
draft.dirty = true;
},
);
panel.append(row, h.hintRow(L("B08_Quantity_Side_BenchCut_Hint")));
// 층따기 표준치수 — KDS 근거 표시(2026-09-14 브레인 정정 · 「제안값」 경고 아님).
panel.append(h.hintRow(L("B08_Quantity_Side_BenchCut_Suggest")));
panel.append(
createButton({
label: L("B08_Quantity_Side_BenchCut_Fill"),
variant: "ghost",
onClick: () => {
const input = row.querySelector("input");
if (input) input.value = String(KDS_SOIL_MIN_HEIGHT_M);
draft.bench_cut_depth_m = KDS_SOIL_MIN_HEIGHT_M;
draft.dirty = true;
},
}),
);
}
@@ -0,0 +1,95 @@
/* =============================================================================
* B08_Quantity_UI_Side_FaceDressing.ts
* · · +
* (2026-09-14 ~ · ee9368ec).
*
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* ** **(`face_dressing_choices`) ( ).
* ( ) .
* ( × ) .
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
import type { SideFieldHelpers } from "./B08_Quantity_UI_Side_TreeWaste";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface FaceDressingDraft {
/** `""` 는 「안 정함」 — 서버가 선택지 밖 값도 `""` 로 되돌림. */
face_dressing_cut_class: string;
face_dressing_fill_class: string;
/** `null` 은 「파종 면적 그대로」. */
face_dressing_fill_area_m2: number | null;
face_dressing_cut_area_m2: number | null;
dirty: boolean;
}
export interface FaceDressingChoices {
cut: string[];
fill: string[];
}
export function appendFaceDressingFields(
panel: HTMLElement,
draft: FaceDressingDraft,
choices: FaceDressingChoices | undefined,
h: SideFieldHelpers,
): void {
if (!choices) return;
const pick = (
label: keyof typeof ui_locales,
value: string,
list: string[],
set: (value: string) => void,
): HTMLElement =>
h.selectField(
L(label),
value,
[
{ value: "", label: L("B08_Quantity_FaceDressing_Unset") },
...list.map((name) => ({ value: name, label: name })),
],
(picked) => {
set(picked);
draft.dirty = true;
},
);
const area = (
label: keyof typeof ui_locales,
value: number | null,
set: (v: number | null) => void,
) => {
const row = h.optionalNumberField(L(label), value, "0.01", (typed) => {
set(typed);
draft.dirty = true;
});
const input = row.querySelector("input");
if (input) input.placeholder = L("B08_Quantity_FaceDressing_Area_Placeholder");
return row;
};
panel.append(h.field(L("B08_Quantity_Side_FaceDressing"), ""));
panel.append(
pick("B08_Quantity_FaceDressing_Cut", draft.face_dressing_cut_class, choices.cut, (v) => {
draft.face_dressing_cut_class = v;
}),
);
panel.append(
pick("B08_Quantity_FaceDressing_Fill", draft.face_dressing_fill_class, choices.fill, (v) => {
draft.face_dressing_fill_class = v;
}),
);
panel.append(
area("B08_Quantity_FaceDressing_FillArea", draft.face_dressing_fill_area_m2, (v) => {
draft.face_dressing_fill_area_m2 = v;
}),
);
panel.append(
area("B08_Quantity_FaceDressing_CutArea", draft.face_dressing_cut_area_m2, (v) => {
draft.face_dressing_cut_area_m2 = v;
}),
);
panel.append(h.hintRow(L("B08_Quantity_Side_FaceDressing_Hint")));
}
@@ -0,0 +1,131 @@
/* =============================================================================
* B08_Quantity_UI_Side_TreeWaste.ts
* · · (2026-09-14 ).
*
* (`B08_Quantity_UI_Page.ts`) 700 (CLAUDE.md 4).
* [] (5).
* ========================================================================== */
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
export interface TreeWasteDraft {
tree_waste: Record<string, number | null>;
tree_waste_unit_price_krw_per_ton: number | null;
waste_separate_order: boolean;
/** 뿌리 산정법 — `""` 분배비 15/85(기본) · "root_ball" 뿌리분 체적 × 1,300. */
tree_waste_root_method: string;
dirty: boolean;
}
/** 페이지의 칸 만들기 도우미 — 같은 모양을 쓰려고 받아 씀. */
export interface SideFieldHelpers {
field(label: string, value: string): HTMLElement;
optionalNumberField(
label: string,
value: number | null,
step: string,
onInput: (value: number | null) => void,
): HTMLElement;
selectField(
label: string,
value: string,
options: { value: string; label: string }[],
onChange: (value: string) => void,
): HTMLElement;
hintRow(text: string): HTMLElement;
}
/** 조사값 칸 — 서버 `TREE_WASTE_INPUTS` 와 같은 키. */
const INPUTS: [string, keyof typeof ui_locales, string][] = [
["stems_per_1000m2", "B08_Quantity_TreeWaste_Stems", "1"],
["dbh_cm", "B08_Quantity_TreeWaste_Dbh", "0.1"],
["height_m", "B08_Quantity_TreeWaste_Height", "0.1"],
["unit_weight_kg_m3", "B08_Quantity_TreeWaste_UnitWeight", "10"],
];
/** 수동 단가 칸 — 값이 있으면 빨간 테두리(미확정). */
function markManual(row: HTMLElement, value: number | null): void {
const input = row.querySelector("input");
if (input) input.style.outline = value ? "2px solid var(--color-danger, #d9534f)" : "";
}
export function appendTreeWasteFields(
panel: HTMLElement,
draft: TreeWasteDraft,
h: SideFieldHelpers,
): void {
panel.append(h.field(L("B08_Quantity_Side_TreeWaste"), ""));
for (const [key, label, step] of INPUTS) {
panel.append(
h.optionalNumberField(L(label), draft.tree_waste[key] ?? null, step, (value) => {
draft.tree_waste[key] = value;
draft.dirty = true;
}),
);
}
panel.append(h.hintRow(L("B08_Quantity_Side_TreeWaste_Hint")));
// 뿌리 — 지침 부록1 이 두 방법을 둠. 뿌리분 직경은 흉고직경과 갈라 따로 받음(지침 표기가 모호).
panel.append(
h.selectField(
L("B08_Quantity_TreeWaste_RootMethod"),
draft.tree_waste_root_method,
[
{ value: "", label: L("B08_Quantity_TreeWaste_RootRatio") },
{ value: "root_ball", label: L("B08_Quantity_TreeWaste_RootBall") },
],
(value) => {
draft.tree_waste_root_method = value;
draft.dirty = true;
},
),
);
panel.append(
h.optionalNumberField(
L("B08_Quantity_TreeWaste_RootBallDiameter"),
draft.tree_waste["root_ball_diameter_m"] ?? null,
"0.01",
(value) => {
draft.tree_waste["root_ball_diameter_m"] = value;
draft.dirty = true;
},
),
);
panel.append(h.hintRow(L("B08_Quantity_TreeWaste_Root_Hint")));
const price = h.optionalNumberField(
L("B08_Quantity_TreeWaste_Price"),
draft.tree_waste_unit_price_krw_per_ton,
"1000",
(value) => {
draft.tree_waste_unit_price_krw_per_ton = value;
draft.dirty = true;
markManual(price, value);
},
);
markManual(price, draft.tree_waste_unit_price_krw_per_ton);
panel.append(price);
const priceHint = h.hintRow(L("B08_Quantity_TreeWaste_Price_Hint"));
priceHint.classList.add("b08-quantity__hint--warn");
panel.append(priceHint);
panel.append(
h.selectField(
L("B08_Quantity_TreeWaste_Separate"),
draft.waste_separate_order ? "on" : "",
[
{ value: "", label: L("B08_Quantity_TreeWaste_Separate_Off") },
{ value: "on", label: L("B08_Quantity_TreeWaste_Separate_On") },
],
(value) => {
draft.waste_separate_order = value === "on";
draft.dirty = true;
},
),
);
panel.append(h.hintRow(L("B08_Quantity_TreeWaste_Separate_Hint")));
}
@@ -0,0 +1,80 @@
/* =============================================================================
* B08_Quantity_UI_StructureFigure.ts
* ** ** (m, +y) SVG (PLAN 12).
*
* ( `B08_Quantity_Engine_StructureFigure` ). CAD .
* **** .
* = 6 : 4( ) 60% .
* ========================================================================== */
import { el } from "./B08_Quantity_UI_StructureSheet_Formula";
export type FigureShape =
| { kind: "path"; points: [number, number][]; role: "wall" | "guide"; dash: boolean }
| {
kind: "text";
text: string;
at: [number, number];
align: "left" | "center" | "right";
size: number;
};
const SVG_NS = "http://www.w3.org/2000/svg";
const ANCHOR = { left: "start", center: "middle", right: "end" } as const;
export const FIGURE_CSS = `
.b08-sheet__figure { height: 60vh; min-height: 16rem; border: 1px solid var(--color-border);
background: var(--color-surface); }
.b08-sheet__figure svg { display: block; width: 100%; height: 100%; }
.b08-sheet__figure--none { height: auto; min-height: 0; padding: 6px 10px; }
.b08-fig__wall, .b08-fig__guide { fill: none; vector-effect: non-scaling-stroke; }
.b08-fig__wall { stroke: var(--color-text); stroke-width: 2; }
.b08-fig__guide { stroke: var(--color-text-secondary, #888); stroke-width: 1; }
.b08-fig__guide.is-dash { stroke-dasharray: 6 4; }
.b08-sheet__figure text { fill: var(--color-text); }
`;
/** 그림 칸 — 모양이 없으면 까닭 한 줄. */
export function figureSection(
figure: FigureShape[] | null | undefined,
reason: string | null | undefined,
): HTMLElement {
const box = el("div", "b08-sheet__figure");
if (!figure?.length) {
box.classList.add("b08-sheet__figure--none");
box.append(el("p", "b08-quantity__message", (reason ?? "그림 없음").replace(/\*\*/g, "")));
return box;
}
const svg = document.createElementNS(SVG_NS, "svg");
const group = document.createElementNS(SVG_NS, "g");
for (const shape of figure) {
if (shape.kind === "text") {
const text = document.createElementNS(SVG_NS, "text");
text.setAttribute("x", String(shape.at[0]));
text.setAttribute("y", String(-shape.at[1]));
text.setAttribute("font-size", String(shape.size));
text.setAttribute("text-anchor", ANCHOR[shape.align]);
text.setAttribute("dominant-baseline", "middle");
text.textContent = shape.text;
group.append(text);
continue;
}
const line = document.createElementNS(SVG_NS, "polyline");
line.setAttribute("points", shape.points.map(([x, y]) => `${x},${-y}`).join(" "));
line.setAttribute("class", `b08-fig__${shape.role}${shape.dash ? " is-dash" : ""}`);
group.append(line);
}
svg.append(group);
box.append(svg);
// 보이는 틀은 **그려진 뒤 실제 글자 폭**으로 — 글자 폭을 서버가 짐작하지 않게.
requestAnimationFrame(() => {
if (!svg.isConnected) return;
const b = group.getBBox();
const pad = Math.max(b.width, b.height) * 0.04;
svg.setAttribute(
"viewBox",
`${b.x - pad} ${b.y - pad} ${b.width + pad * 2} ${b.height + pad * 2}`,
);
});
return box;
}
@@ -5,7 +5,7 @@
* `B08_Quantity_UI_Page.ts` (PLAN 0 ).
* : `{ label: "구조물도", build: () => renderStructureSheets(projectId) }`
* (`/quantity/structure-sheets`) .
* · .
* = (`_StructureFigure`, SVG) + + .
* [ ] (`structures.json`) B07 .
* (PLAN 3 ) ** ** ( ).
* [ ] ** Node **( ). + .
@@ -33,6 +33,7 @@ import {
} from "./B08_Quantity_UI_StructureSheet_Spec";
import { buildLibraryPanel, libraryLabel } from "./B08_Quantity_UI_StructureSheet_Library";
import { unitPriceSection } from "./B08_Quantity_UI_StructureSheet_UnitPrice";
import { FIGURE_CSS, figureSection, type FigureShape } from "./B08_Quantity_UI_StructureFigure";
export interface StructureSheetRow {
no: number;
@@ -75,6 +76,9 @@ export interface StructureSheet extends StandardSheetSpec {
}[];
notes: string[];
unpriced_rows: string[];
/** 상단 그림 모양 목록 — 못 그리는 장은 `null` + `figure_reason`(PLAN 12장). */
figure?: FigureShape[] | null;
figure_reason?: string | null;
/** 양식으로 선 장이면 그 양식 — 없으면 지금 전개 줄(고정형 모양). */
/** `imported_from` 없음 = 프로그램 기본을 읽는 중(가져오기 전). */
library_item?: {
@@ -144,7 +148,7 @@ function injectStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
style.textContent = CSS + FIGURE_CSS;
document.head.append(style);
}
@@ -256,7 +260,8 @@ function sheetBody(
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
el("span", "b08-grid__caption", sheet.unit_label),
);
main.append(head);
// 한 장 = 상단 그림 + 하단 표(PLAN 3장 한 장의 짜임) — 못 그리는 장은 까닭.
main.append(head, figureSection(sheet.figure, sheet.figure_reason));
if (!sheet.rows.length) {
main.append(
el("p", "b08-quantity__message", "이 제원은 원단위 줄이 서지 않음 — 아래 사유 참고"),
@@ -8,13 +8,17 @@
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { buildStmatePanel } from "./B08_Quantity_UI_StructureSheet_Stmate";
const TIER_LABELS: Record<string, string> = { personal: "개인", company: "회사", program: "기본" };
/** 항목 종류 배지 — 양식형 = 제원을 바꾸면 수량이 다시 남 · 고정형 = 박힌 수량(명세 13장). */
const KIND_LABELS: Record<string, string> = { form: "양식형", fixed: "고정형" };
interface LibraryItem {
tier: string;
code: string;
name: string;
kind: string;
}
/** 장 머리의 양식 표시 — **어느 단에서 가져왔나** + **그 뒤 고쳤나**(지금 읽는 단이 아님). */
@@ -92,7 +96,8 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
const option = document.createElement("option");
option.value = `${item.tier}|${item.code}`;
const now = item.code === currentCode ? " (지금)" : "";
option.textContent = `${TIER_LABELS[item.tier] ?? item.tier} · ${item.name}${now}`;
const kind = KIND_LABELS[item.kind] ?? item.kind;
option.textContent = `${TIER_LABELS[item.tier] ?? item.tier} · [${kind}] ${item.name}${now}`;
return option;
}),
);
@@ -187,5 +192,15 @@ export function buildLibraryPanel(options: LibraryPanelOptions): HTMLElement {
mine.append(save, remove);
panel.append(title, scope, load, list, take, mine, status);
// 고정형 항목을 만드는 둘째 길 — STmate 출력 엑셀에서 호표 하나를 뽑아 개인 단에(PLAN 4장).
panel.append(
buildStmatePanel({
projectId,
typeId,
onSaved: () => {
if (!list.hidden) load.click();
},
}),
);
return panel;
}
@@ -0,0 +1,144 @@
/* =============================================================================
* B08_Quantity_UI_StructureSheet_Stmate.ts
* [STmate ] (PLAN 4 · 2026-09-14 ).
*
* : [] · · ( )
* [ ] ** **( ).
* ( ).
* (`b08-spec*`) .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
interface ReadHopyo {
no: number;
name: string;
spec: string;
unit: string;
rows: number;
percent_rows: number;
contract_rows: number;
}
interface ReadResult {
project: string;
hopyo: ReadHopyo[];
counts: { hopyo: number; rows: number };
problems: string[];
}
async function postForm<T>(url: string, form: FormData): Promise<T> {
const response = await fetch(url, { method: "POST", credentials: "include", body: form });
const payload = (await response.json().catch(() => ({}))) as T & { message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
export function buildStmatePanel(options: {
projectId: string;
typeId: string;
/** 넣은 뒤 — 목록을 다시 받게 함. */
onSaved: () => void;
}): HTMLElement {
const { projectId, typeId, onSaved } = options;
const base = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/library`;
const box = document.createElement("div");
box.className = "b08-sheet__actions";
const input = document.createElement("input");
input.type = "file";
input.accept = ".xlsx";
input.className = "b08-spec__input";
input.title = "STmate 가 내보낸 내역 엑셀(일위대가표 시트)";
const read = document.createElement("button");
read.type = "button";
read.className = "b08-quantity__tab";
read.textContent = "STmate 엑셀 읽기";
const list = document.createElement("select");
list.className = "b08-spec__input";
list.hidden = true;
const put = document.createElement("button");
put.type = "button";
put.className = "b08-spec__save";
put.textContent = "내 라이브러리에 넣기";
put.hidden = true;
const status = document.createElement("p");
status.className = "b08-spec__scope";
let result: ReadResult | null = null;
input.addEventListener("change", () => {
result = null;
list.hidden = put.hidden = true;
status.textContent = "";
});
read.addEventListener("click", () => {
const file = input.files?.[0];
if (!file) {
status.textContent = "엑셀 파일을 먼저 고르세요";
return;
}
void (async () => {
read.disabled = true;
status.textContent = "읽는 중…";
try {
const form = new FormData();
form.append("file", file);
result = await postForm<ReadResult>(`${base}/stmate/read`, form);
list.replaceChildren(
...result.hopyo.map((h) => {
const option = document.createElement("option");
option.value = String(h.no);
const extra = h.percent_rows ? ` · 가산 행 ${h.percent_rows}` : "";
option.textContent = `${h.no}호표 ${h.name} ${h.spec} / ${h.unit} · 구성 ${h.rows}${extra}`;
return option;
}),
);
list.hidden = put.hidden = result.hopyo.length === 0;
const missed = result.problems.length
? ` · 못 읽은 것 ${result.problems.length}건 — ${result.problems.slice(0, 3).join(" / ")}`
: "";
status.textContent = `${result.project}」 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}줄 읽음${missed}`;
} catch (error) {
status.textContent = error instanceof Error ? error.message : "읽지 못함";
} finally {
read.disabled = false;
}
})();
});
put.addEventListener("click", () => {
const file = input.files?.[0];
const picked = result?.hopyo.find((h) => String(h.no) === list.value);
if (!file || !result || !picked) return;
const question =
`읽은 것: 호표 ${result.counts.hopyo}개 · 구성 줄 ${result.counts.rows}\n` +
`넣을 것: 「${picked.name} ${picked.spec}」 구성 ${picked.rows}줄 → 이 장 종류로 내 라이브러리\n` +
"· 같은 종류 내 것이 있으면 덮어씀\n· 수량은 원문 시점값(현행 품셈 대조 전) · 단가는 안 가져옴";
if (!window.confirm(question)) return;
void (async () => {
put.disabled = true;
status.textContent = "넣는 중…";
try {
const form = new FormData();
form.append("file", file);
form.append("hopyo_no", String(picked.no));
form.append("type_id", typeId);
const saved = await postForm<{ code: string; name: string; note: string }>(
`${base}/stmate/save`,
form,
);
status.textContent = `내 라이브러리에 넣음 — 「${saved.name}」 [고정형] · ${saved.note}`;
onSaved();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "넣지 못함";
} finally {
put.disabled = false;
}
})();
});
const wrap = document.createElement("div");
box.append(read, put);
wrap.append(input, box, list, status);
return wrap;
}
@@ -136,6 +136,11 @@ export function unitPriceEditor(options: EditorOptions): HTMLElement {
find.addEventListener("click", () => {
picking = index;
render();
// 코드가 아직 없는 줄은 그 줄 이름으로 **후보만** 띄움 — 고르는 것은 사용자(명세 2장 · 이름 자동 확정 금지).
if (!row.ref_code && !row.work_item_code && row.name) {
query.value = row.name;
void search();
}
picker.querySelector("input")?.focus();
});
code.append(" ", find);
@@ -236,6 +236,8 @@ export interface PreparationRow {
reason: string;
reference_amount?: number;
work_item_code: string | null;
/** 수동 단가로 선 금액 수 — 「미확정 N건」·빨간 테두리(임목폐기물 처리). */
unconfirmed?: number;
}
export interface PreparationTable {
@@ -260,6 +262,14 @@ export function renderPreparationGrid(
const caption = document.createElement("p");
caption.className = "b08-grid__caption";
caption.textContent = `${table.row_count}줄 · 값을 낼 근거가 아직 없는 줄 ${table.pending_count}`;
const unconfirmed = table.rows.reduce((sum, row) => sum + (row.unconfirmed ?? 0), 0);
if (unconfirmed) {
const badge = document.createElement("span");
badge.style.cssText =
"margin-left:8px;padding:0 6px;border-radius:8px;color:#fff;background:var(--color-danger,#d9534f);font-size:12px";
badge.textContent = `미확정 ${unconfirmed}`;
caption.append(badge);
}
wrap.append(caption);
const scroller = document.createElement("div");
@@ -291,6 +301,8 @@ export function renderPreparationGrid(
if (row.reference_amount) {
note.append(document.createTextNode(` (참고 면적 ${num(row.reference_amount, 1)})`));
}
// 수동 단가로 선 금액이 든 줄 — 빨간 테두리(미확정).
if (row.unconfirmed) note.style.outline = "2px solid var(--color-danger, #d9534f)";
tr.append(note);
// 값을 못 세운 줄의 「수량」은 **막힘** — 근거가 오면 채워질 자리라 제외과 갈라 보인다.
markRow(tr, PREPARATION_KEYS, row.amount === null ? { 3: "blocked" } : undefined);
+3
View File
@@ -28,6 +28,7 @@ TYPE_LABELS = {
"pipe": "배수관",
"pipe_inlet_basin": "배수관 유입부 집수정",
"ford_pavement": "물넘이포장",
"open_ditch": "개거(겉도랑)",
"ford_bridge": "세월교",
"box_culvert": "BOX암거",
# 사방 시설 — 레지스트리 이름 그대로 옮긴 것(2026-09-08 확인). 지어낸 이름 아님.
@@ -44,6 +45,8 @@ OPTION_LABELS = {
"form": ("옹벽 형식", "구조물 상세 입력"),
"height_m": ("높이(m)", "구조물 배치"),
"length_m": ("연장(m)", "구조물 배치"),
# 물넘이포장(관 지점 시설) — B05 시설 칸 · 구조물 집계표에서 적음(2026-09-14 A3).
"thickness_cm": ("포장 두께(㎝)", "구조물 배치 물넘이포장 칸 · 구조물 집계표"),
# 2026-09-09 칸이 생겼음(`ebdf2988`) — 「아직 칸이 없음」이 거짓이 되어 고침.
# ⚠ 비워 두면 품셈 표준경사표로 자동 판정된다(확정 ⑨).
"face_slope_ratio": ("전면 기울기", "구조물 상세 입력 — 비우면 품셈 표준경사로 자동"),
@@ -64,8 +64,12 @@ SUPPLY_OWNER = "owner_supplied"
#: 그것을 「우리가 만들어야 하는 것」에 얹으면 **결국 이중계상으로 간다**(㉠~㉦ 규칙).
_NOT_OUR_ROW = "not_our_row"
#: B08 `BLOCKED_UNCONFIRMED` 와 같은 글 — 치수 없이 기본값으로 선 줄.
BLOCKED_UNCONFIRMED = "unconfirmed"
_BLOCKED_LABELS = {
_NOT_OUR_ROW: "여기서 세지 않는 줄",
BLOCKED_UNCONFIRMED: "미확정 — 금액에 안 들어감",
"input_missing": "입력이 필요합니다",
"unit_data_missing": "원단위가 없습니다(우리가 만들 것)",
"formula_missing": "전개식이 없습니다(우리가 만들 것)",
@@ -123,6 +127,12 @@ class HandoffWorkItem:
def display_name(self) -> str:
return f"{self.name} {self.spec}".strip()
@property
def unconfirmed(self) -> bool:
"""B08 이 치수 없이 기본값으로 세운 줄(2026-09-14 브레인 판정) — 내역 제자리에
** 금액 + 빨간 테두리** 서고 합계에 · 머리에 미확정 N건 금액에 들어감."""
return self.blocked_kind == BLOCKED_UNCONFIRMED
@dataclass(frozen=True)
class HandoffMaterial:
@@ -165,6 +175,19 @@ class BillRow:
in_bill: bool = True
#: 금액을 낸 단가 코드(`B-…#갈래`) — 단산 번호를 그 코드로 찾음. 안 선 줄은 빈 글.
price_code: str = ""
#: 성분 단가 — 실무 내역서 「노무비·재료비·경비 단가」 칸(화면이 곱하지 않게 서버가 실음).
unit_material_krw: Decimal | None = None
unit_labor_krw: Decimal | None = None
unit_expense_krw: Decimal | None = None
#: 수동 단가로 선 몫 — 화면 빨간 테두리(구조물도 호표 줄).
unconfirmed: int = 0
#: 「단산 N 참조」 — 비고 첫 조각과 같은 글. 번호는 낼 때마다 매김(저장 안 함).
price_basis_label: str = ""
#: 단가표 제목이 아닌 줄(묶음·구조물도 호표)의 **단위당 구성** `(단가 코드, 수량)` — 집계표가 자원까지 풂.
parts: list[tuple[str, Decimal]] = field(default_factory=list)
#: 할증 칸 이름·얹힌 율(사용자) — `B09_Estimation_BillRates`(2차 ④).
rate_key: str = ""
rate: dict[str, str] | None = None
#: 줄 사유 **조각** — `(닿는 열 키, 글)`. 화면 「비고」는 이것을 이어 붙인 것이고,
#: 근거 호버는 열 키로 걸러 **그 사유가 닿는 칸에만** 띄운다(PLAN 8-36 ㉮).
#: ⚠ 종전엔 `note` 한 칸에 덮어썼다 — 한 줄에 사유가 둘이면 **하나가 조용히 사라졌다**
@@ -210,6 +233,14 @@ class BillRow:
"material_krw": str(self.material_krw),
"labor_krw": str(self.labor_krw),
"expense_krw": str(self.expense_krw),
"unit_material_krw": money(self.unit_material_krw),
"unit_labor_krw": money(self.unit_labor_krw),
"unit_expense_krw": money(self.unit_expense_krw),
"price_code": self.price_code,
"unconfirmed": self.unconfirmed,
"price_basis_label": self.price_basis_label,
"rate_key": self.rate_key,
"rate": self.rate,
"is_group": self.is_group,
"in_bill": self.in_bill,
"note": self.note,
@@ -235,10 +266,14 @@ class BillResult:
notes: list[str] = field(default_factory=list)
#: ③ 단가산출서 한 벌 — 조판할 때 번호가 매겨진다.
price_basis: Any = None
#: 일위대가 호표 번호 한 벌 — 내역에 처음 쓰인 차례(저장 안 함).
unit_price_sheet: Any = None
#: 자재대 표 — 사급·관급·미정 셋으로 갈린다(PLAN 8-7 「금액은 B09」).
material_sheet: Any = None
#: 수동 단가로 선 자리 — 내역서 끝 「미확정 N건」(PLAN 확정 ⑦). 줄마다 `{name, count}`.
unconfirmed: list[dict[str, Any]] = field(default_factory=list)
#: 치수 없이 기본값으로 선 구조물 줄 — 제자리에 빈 금액으로 서고 **합계에 안 듦**(`{name, reason}`).
unpriced: list[dict[str, Any]] = field(default_factory=list)
@property
def direct_material_krw(self) -> Decimal:
@@ -252,6 +287,17 @@ class BillResult:
def direct_expense_krw(self) -> Decimal:
return sum((r.expense_krw for r in self.rows if not r.is_group), _ZERO)
def resource_quantities(self) -> dict[str, Decimal]:
"""집계표에 넘길 `{단가 코드: 수량}` — 내역 줄 차례 그대로(처음 쓰인 차례가 호표 번호)."""
found: dict[str, Decimal] = {}
for row in self.rows:
if row.is_group or row.amount_krw is None or row.quantity is None:
continue
for code, amount in row.parts or [(row.price_code, Decimal(1))]:
if code:
found[code] = found.get(code, _ZERO) + amount * row.quantity
return found
@property
def body_total_krw(self) -> Decimal:
"""내역서 **본체** 합계 — 줄마다 절사한 금액의 합.
@@ -370,6 +416,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402
_leaf_row,
_material_row,
_structure_price_row,
_sum_groups,
bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름)
)
@@ -380,12 +427,18 @@ def build_bill(
build: UnitPriceBuild | None = None,
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()
@@ -399,7 +452,8 @@ def build_bill(
composites: list[HandoffWorkItem] = []
templated: list[HandoffWorkItem] = []
for item in work_items:
if not item.in_bill:
if not item.in_bill and not (item.unconfirmed and item.work_item_code in index):
# (미확정 줄은 제자리 나무로 감 — `_leaf_row` 가 금액 없이 세우고 `unpriced` 에 셈)
# ⚠ 코드 유무보다 **먼저** 가른다. 보정량계는 공종코드가 없어서가 아니라
# **검산용 줄이라서** 금액이 없는 것이다 — `missing` 으로 새면 「단가를 구해야 할
# 줄」로 잘못 읽힌다.
@@ -510,18 +564,24 @@ def build_bill(
if item.origin == "structure"
else "공종을 못 골랐습니다 — B08 인계에 공종코드가 없습니다."
)
# ⚠ B08 이 막힘 까닭을 실어 보냈으면 **그 문구를 그대로** — 「코드가 없다」로 덮으면
# 입력하면 풀리는 자리(암 갈래 구성비·시공법)가 매핑 구멍으로 읽힘(2026-09-14 흙깎기 암).
if item.blocked_reason:
kind = item.blocked_kind or _NOT_OUR_ROW
reason = f"{_BLOCKED_LABELS.get(kind, '막힘')}{item.blocked_reason}"
result.missing.append(
{
"name": item.display_name,
"unit": item.unit,
"quantity": str(item.quantity),
"reason": reason,
**({"blocked_kind": item.blocked_kind} if item.blocked_kind else {}),
}
)
# ── 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])
@@ -573,7 +633,15 @@ def build_bill(
),
_ZERO,
)
haul_total = sum((item.quantity for item in work_items if item.haul_equipment), _ZERO)
# 「덤프 적재」 짝 줄은 운반이 아니라 싣기 — 같은 흙을 운반량으로 두 번 세지 않게 뺌.
haul_total = sum(
(
item.quantity
for item in work_items
if item.haul_equipment and item.haul_equipment != "dump_loading"
),
_ZERO,
)
if cut_total > 0 and haul_total > 0:
check_haul_volume_within_cut(
haul_volume_total_m3=haul_total,
@@ -586,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
@@ -600,7 +673,18 @@ def build_bill(
if label:
# 종전처럼 **맨 앞**에 놓는다 — 실무 참조번호(「단산 46」)가 먼저 읽혀야 한다.
row.notes.insert(0, ("unit_price_krw", label))
row.price_basis_label = label
result.price_basis = sheet
from B09_Estimation.B09_Estimation_UnitPriceSheet import build_unit_price_sheet
result.unit_price_sheet = build_unit_price_sheet(
result.rows, unit_prices.book, structure_prices
)
# 할증율·일괄보정(사용자 · 2차 ④) — 줄 금액이 선 뒤, 머리글 합 앞.
from B09_Estimation.B09_Estimation_BillRates import apply_bill_rates
apply_bill_rates(result.rows, rates, bill_line)
_sum_groups(result.rows)
if any(m.surcharge_pct is None for m in materials):
result.notes.append(
@@ -622,6 +706,8 @@ def bill_summary(result: BillResult) -> dict[str, Any]:
"missing": result.missing,
"unconfirmed": result.unconfirmed,
"unconfirmed_count": sum(int(entry["count"]) for entry in result.unconfirmed),
"unpriced": result.unpriced,
"unpriced_count": len(result.unpriced),
"body_total_krw": str(result.body_total_krw),
"direct_material_krw": str(result.direct_material_krw),
"direct_labor_krw": str(result.direct_labor_krw),
@@ -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)
@@ -22,9 +22,22 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import (
_MasterNode,
_decimal,
)
from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
DUMP_PARENT,
LOADING_EQUIPMENT,
dump_child_for,
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, find_variant_code
from B09_Estimation.B09_Estimation_UnitPrice import (
UnitPriceBuild,
_master_edition,
find_variant_code,
)
def bill_line(unit: Money3, quantity) -> Money3:
@@ -40,6 +53,42 @@ def bill_line(unit: Money3, quantity) -> Money3:
)
def settle_quantity(row: BillRow) -> Decimal:
"""수량을 품셈 1-2-2 자리로 **반올림해 확정**하고 그 값을 돌려줌 — 금액은 확정한 수량으로 셈.
산림청고시 2025-82 1-2-2 [] 설계서 수량의 단위와 소수자리 표시는 표에 따르며,
**반올림하여 적용**한다 수량 먼저 확정 금액(`bill_line` 버림). 둘을 섞지 않음(2026-09-14
브레인 판정). 종전엔 인계 전정밀(0.2818199996) 곱해 미만 절사에 1원씩 샜음.
"""
row.quantity, _ = round_quantity(row.quantity, row.name, row.unit, row.spec)
return row.quantity
def _sum_groups(rows: list[BillRow]) -> None:
"""머리글 줄 금액 = 그 아래 줄 금액의 합(성분마다) — 실무 내역서 계 줄. 화면은 더하지 않음.
`direct_*`·`body_total_krw` 머리글을 빼고 더하므로 .
"""
for group in rows:
if not group.is_group:
continue
prefix = f"{group.item_no}-"
children = [
r for r in rows if not r.is_group and r.item_no.startswith(prefix) and r.amount_krw
]
group.material_krw = sum((r.material_krw for r in children), Decimal(0))
group.labor_krw = sum((r.labor_krw for r in children), Decimal(0))
group.expense_krw = sum((r.expense_krw for r in children), Decimal(0))
group.amount_krw = sum((r.amount_krw for r in children), Decimal(0))
def _set_unit(row: BillRow, unit: Money3) -> None:
"""성분 단가 칸 — 금액을 낸 바로 그 3분할(내역 서식 「노무비·재료비·경비 단가」)."""
row.unit_material_krw = unit.material
row.unit_labor_krw = unit.labor
row.unit_expense_krw = unit.expense
def _composite_row(
item_no: str,
item: HandoffWorkItem,
@@ -73,6 +122,7 @@ def _composite_row(
# 묶음도 호표 한 장 — 조각 줄 0.1원 · 성분 소계 원 미만 절사(아래 `floored`, 명세 7장).
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1"))
money = scaled if money is None else money + scaled
row.parts.append((f"B-{code}", amount))
reasons: list[str] = []
for pending in item.composite_not_ready:
@@ -103,13 +153,15 @@ 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
row.material_krw = line.material
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
@@ -159,7 +211,11 @@ 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 [])
row.unconfirmed = int(entry["unconfirmed"] or 0)
row.unit_price_krw = entry["total"]
row.amount_krw = line.total
row.material_krw = line.material
@@ -236,6 +292,10 @@ def _leaf_row(
# 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격).
row.quantity = item.quantity
row.add_note("", item.in_bill_reason or "합계 검산용 줄 — 금액을 매기지 않습니다.")
if item.unconfirmed:
# 치수 없이 기본값으로 선 줄 — 빨간 테두리 + 머리 「미확정 N건 — 금액에 안 들어감」.
row.unconfirmed = 1
result.unpriced.append({"name": item.display_name, "reason": item.blocked_reason})
result.excluded.append(row)
return row
@@ -268,6 +328,41 @@ def _leaf_row(
return row
price_code = f"B-{node.code}"
loading = item.haul_equipment == LOADING_EQUIPMENT
if node.code == DUMP_PARENT and (item.haul_equipment == "dump_truck" or loading):
# 덤프 운반(10-12) — 잎(토사·암절취·발파암) × 운반거리마다 호표 한 장. 거리 없으면 0원 금지.
# 적재 짝 줄은 거리 무관 `#적재` 한 장.
child = dump_child_for(item.variant_value, _master_edition())
why = ""
wanted = ""
if child is None:
why = (
f"덤프 운반 갈래 「{item.variant_value or '미지정'}」를 10-12 잎"
"(토사·암절취·발파암)에 못 맞춤"
)
elif loading:
wanted = loading_title_code(child)
elif item.haul_distance_m is None:
why = "입력이 필요합니다 — 운반거리 미입력(유토곡선·사토장 거리가 서면 섬)"
else:
wanted = dump_title_code(child, item.haul_distance_m)
if wanted and wanted not in unit_prices.book.titles:
why = unit_prices.component_gaps.get(child) or "덤프 운반·적재 일위대가를 못 세웠습니다"
if why:
row.add_note("unit_price_krw", why)
result.missing.append(
{
"name": row.name,
"code": node.code,
"unit": row.unit,
"quantity": str(item.quantity),
"reason": why,
}
)
return row
price_code = wanted
if not loading:
row.spec = f"{row.spec} L={item.haul_distance_m}m".strip()
if price_code not in unit_prices.book.titles:
# B08 은 **의미**(어느 공종·어느 제원)만 보내고 갈래 키는 우리가 만든다.
# 못 맞추면 후보를 보이는 길로 내려간다 — 가까운 갈래를 임의로 고르지 않는다.
@@ -279,7 +374,8 @@ def _leaf_row(
default = unit_prices.default_variants.get(node.code)
if picked is not None:
price_code = picked
row.spec = f"{row.spec} {item.variant_value}".strip()
variant = str(item.variant_value) # 규격 글로 시작하는 갈래는 한 번만(면고르기)
row.spec = variant if variant.startswith(row.spec) else f"{row.spec} {variant}"
elif default is not None:
# 표에 없거나 안 준 암질(풍화암·암) — **원문이 정한 갈래**로만 선다(9-4-1 [주]① 평균).
price_code = f"{price_code}#{default[0]}"
@@ -332,7 +428,8 @@ def _leaf_row(
names = ", ".join(f"{c[2:]} {unit_prices.book.title(c).name}" for c in children)
row.add_note(
"unit_price_krw",
f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}",
f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}"
+ (f" / {known_gap_note(node.code)}" if item.variant_value else ""),
)
reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)"
else:
@@ -461,7 +558,8 @@ 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)
# 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상.
row.amount_krw = line.total
@@ -469,9 +567,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 저녁 비었다.** 걸려 있던 셋(구조물터파기·되메우기·잔토처리)이
@@ -498,8 +610,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,
@@ -534,9 +652,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)"
+133
View File
@@ -0,0 +1,133 @@
"""B09 원가계산 — 내역 **할증율·일괄보정** (PLAN 12장 2차 ④ · STmate `wBoqRate`·`wM_Rate`).
사용자가 고친 (`estimation.edits.bill_rates`) 내역 금액에 얹는다 프로젝트 단위.
"<단가 코드 또는 명칭>|<규격>" = 하나(선택 항목만 고른 줄마다 )
"*" = 내역서 전체
{"all": "10"} ·· 같은 비율(%)
{"material": "5", "labor": "0", "expense": "3"} 비목별(%)
+ "rounding": {"material": {"mode": "floor"|"round", "digits": 0~4}, } 비목별 절사/반올림 · 소수 자리
차례 칸이 있으면 , 없으면 "*"
성분 단가 × (1 + ) 고른 자리로 절사/반올림( 고르면 미만 절사 호표 성분 소계 규칙)
금액은 종전대로 성분마다 절사(명세 7 · 내역 자리 규칙은 바꿈).
할증은 **금액만** 움직임 수량·자원 집계표 수량은 그대로.
되돌리기 칸을 지우면 계산값으로. 여러 줄을 한꺼번에 바꾸므로 화면이 할증 전부 되돌리기 .
"""
from __future__ import annotations
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import Money3
ALL_ROWS = "*"
PARTS = ("material", "labor", "expense")
_LABELS = {"material": "", "labor": "", "expense": ""}
_MODES = {"floor": ROUND_FLOOR, "round": ROUND_HALF_UP}
_DEFAULT_ROUNDING = {"mode": "floor", "digits": 0}
def rate_key(row: Any) -> str:
"""줄 칸 이름 — 단가 코드(없으면 명칭) + 규격. 내역이 다시 서도 같은 줄이면 같은 이름."""
return f"{row.price_code or row.name}|{row.spec}"
def parse_rate(value: Any) -> tuple[dict[str, Decimal], dict[str, dict[str, Any]]] | None:
"""저장본 한 칸 → (성분별 율 %, 성분별 자리). 모양이 틀리면 `None`."""
if not isinstance(value, dict):
return None
try:
if "all" in value:
rates = dict.fromkeys(PARTS, Decimal(str(value["all"])))
else:
rates = {part: Decimal(str(value.get(part) or 0)) for part in PARTS}
except (InvalidOperation, ValueError):
return None
if any(not rate.is_finite() or rate <= -100 or rate > 1000 for rate in rates.values()):
return None
raw = value.get("rounding") or {}
if isinstance(raw, dict) and "mode" in raw:
raw = dict.fromkeys(PARTS, raw)
rounding: dict[str, dict[str, Any]] = {}
for part in PARTS:
chosen = raw.get(part) if isinstance(raw, dict) else None
chosen = chosen if isinstance(chosen, dict) else _DEFAULT_ROUNDING
mode, digits = str(chosen.get("mode") or "floor"), chosen.get("digits", 0)
if mode not in _MODES or not str(digits).isdigit() or not 0 <= int(digits) <= 4:
return None
rounding[part] = {"mode": mode, "digits": int(digits)}
return rates, rounding
def _round(value: Decimal, rule: dict[str, Any]) -> Decimal:
return value.quantize(Decimal(1).scaleb(-rule["digits"]), rounding=_MODES[rule["mode"]])
def apply_bill_rates(rows: list[Any], raw: dict[str, Any] | None, bill_line: Any) -> None:
"""금액이 선 내역 줄에 율을 얹음 — `bill_line` 은 줄 금액 규칙(순환 import 를 피해 받음)."""
for row in rows:
row.rate_key = rate_key(row)
if not raw:
return
for row in rows:
if row.is_group or row.amount_krw is None or row.unit_material_krw is None:
continue
source = row.rate_key if row.rate_key in raw else ALL_ROWS if ALL_ROWS in raw else None
parsed = parse_rate(raw.get(source)) if source else None
if not parsed:
continue
rates, rounding = parsed
before = {
"material": row.unit_material_krw,
"labor": row.unit_labor_krw,
"expense": row.unit_expense_krw,
}
unit = Money3(
**{
part: _round(before[part] * (1 + rates[part] / 100), rounding[part])
for part in PARTS
}
)
line = bill_line(unit, row.quantity)
row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = (
unit.material,
unit.labor,
unit.expense,
)
row.unit_price_krw = unit.total
row.amount_krw = line.total
row.material_krw, row.labor_krw, row.expense_krw = line.material, line.labor, line.expense
same = len(set(rates.values())) == 1
text = (
f"{rates['material']}%"
if same
else " · ".join(f"{_LABELS[part]} {rates[part]}%" for part in PARTS)
)
row.rate = {
"source": source,
**{part: str(rates[part]) for part in PARTS},
"was_unit_price_krw": str(sum(before.values(), Decimal(0))),
}
row.add_note(
"unit_price_krw",
f"할증 {text} (사용자{' · 내역서 전체' if source == ALL_ROWS else ''})",
)
def validate_rate(value: Any) -> dict[str, Any]:
"""저장 전 검사 — 받을 수 있는 모양(글)으로. 틀리면 `ValueError`."""
parsed = parse_rate(value)
if parsed is None:
raise ValueError(
"할증율은 -100 보다 크고 1000 이하인 수(%) · 자리는 절사/반올림 × 소수 0~4 여야 합니다"
)
rates, rounding = parsed
body: dict[str, Any] = (
{"all": str(rates["material"])}
if isinstance(value, dict) and "all" in value
else {part: str(rates[part]) for part in PARTS}
)
body["rounding"] = rounding
return body
+75
View File
@@ -0,0 +1,75 @@
"""B09 원가계산 — **치즐 소모량**(대형브레이커 깨기 · PLAN 1장 Ⓐ-3 · 2026-09-14 브레인 판정).
품셈 표가 브레이커 옆에 치즐소모량(/hr) 제8장 [] 브레이커 손료 치즐
소모율을 **추가**. 실무 원본(영월·봉화·울진) 단가산출근거 (2) 치즐 손료 · M=0.006 × 223,000 / Q
= 382.2 W/ 재료비(재료비 집계표 M00118 치즐(대형 브레이카) ).
수량 치즐(/hr) ÷ Q × 장비 배분율 브레이커 줄과 같은 D(단가산출) 나란히(Q 함께 실음)
단가 자원 목록 `AR-M` 치즐 **수동 단가가 들어와야** 제목이 (임의 단가 없음) ·
들어오면 종전대로 붙은
기계 (`X-0230` 대형브레이커) 부착 장비라 손료만 치즐은 안에 없어 이중계상 아님(시험).
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
#: 자원 목록 치즐(대형브레이커용 · 본) — `resource_catalog_ext` 에 올린 코드.
CHISEL_CODE = "AR-M-a2926452"
#: 치즐을 붙이는 기계 — 대형브레이커(분류 0230).
BREAKER_PREFIX = "0230-"
CHISEL_MISSING = "치즐소모량(본/hr) — 치즐 단가 없음(「자재 단가」 탭에서 넣으면 붙음)"
#: ⚠ 선례를 함께 적음(2026-09-14 브레인 판정) — 사용자가 「같은 방식으로 유추할지」를 스스로 고르게.
CHISEL_AVERAGE_NOT_GIVEN = (
"치즐(평균 갈래) — 원문 9-4-1 [주]① 은 Q 평균만 정하고 치즐 평균은 안 줌(지어내지 않음)"
" · 참고 선례: 9-12-3 발파암 표는 같은 자리에서 치즐 평균"
" 「(0.006+0.02+0.03)/3 = 0.018」 을 스스로 적음 — 같은 방식으로 볼지는 판정 대기"
)
def chisel_per_hour(node: dict[str, Any]) -> Decimal | None:
"""표의 「치즐소모(량)(본/hr)」 줄 값 — 그 이름 칸 뒤 첫 수. 없으면 `None`."""
from B09_Estimation.B09_Estimation_MachineProductivity import parse_measure
for table in node.get("tables", []):
for row in table.get("raw_row") or []:
cells = ["".join(str(cell).split()) for cell in row]
for index, cell in enumerate(cells):
if not cell.startswith("치즐소모"):
continue
value = next(
(parse_measure(t) for t in cells[index + 1 :] if parse_measure(t)), None
)
if value:
return value
return None
def attach_chisel(
book: Any,
title_code: str,
per_hour: Decimal | None,
capacity: Decimal,
share: Decimal = Decimal(1),
) -> bool:
"""치즐 줄을 그 제목의 D 에 붙임 — 치즐 제목(수동 단가)이 없거나 소모량이 없으면 `False`."""
if not per_hour or CHISEL_CODE not in book.titles:
return False
book.add_output_detail(
title_code,
CHISEL_CODE,
per_hour / capacity * share,
note=(
f"치즐 {per_hour} 본/hr ÷ Q {capacity}"
+ (f" × 배분 {share}" if share != 1 else "")
+ " — 품셈 제8장 [주]⑤ 치즐 소모율 추가"
),
output=capacity,
)
return True
def without_chisel_labels(labels: list[str]) -> list[str]:
"""치즐이 붙은 뒤 「못 붙은 줄」 에서 치즐 글을 걷음."""
return [label for label in labels if "치즐" not in label]
+329
View File
@@ -0,0 +1,329 @@
"""B09 계약 단계 — 당초설계 → 계약내역 (PLAN 12장 「설계 뒤 네 단계」 · 2026-09-14 브레인 배정).
근거: STmate 분석 `27_계약_실행_기성_단계.md` §2 (`wM_Mk_Cont`) ·
`35_형식과_단계의_공통과_차이.md` §3.
· 낙찰률 하나가 아니라 **노무·재료·경비별 단가 적용률**
· 옵션 여섯(아래 `OPTIONS` 화면 표기 그대로)
· **행별 적용 제외**(적용율을 제외할(ex 관급자재대..) 공정을 선택)
· 0% 이면 계약단가(W코드) 0 줄이 아니라 **공내역**
**설계를 건드린다** 설계 내역(`/estimation/bill`) 줄을 **복사해** 적용률을 얹는다.
설계 내역·원가계산서·골든셋은 그대로다.
**값이 맞다가 아니라 구조가 선다까지** 계약 표본 0(27 §9)이라 단위로 맞춰 .
절사는 설계 내역과 같은 규칙(`bill_line` 성분 단가 미만 · 성분마다 절사) 빌려 .
"""
from __future__ import annotations
import copy
from dataclasses import replace
from decimal import Decimal, InvalidOperation
from typing import Any
# ⚠ `_Rows` 를 먼저 부르면 순환 import — 설계 내역 본체가 다시 내보내는 자리에서 받음.
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line
from B09_Estimation.B09_Estimation_PriceBook import Money3, PriceBook, PriceBookError, PriceKind
_ZERO = Decimal(0)
_HUNDRED = Decimal(100)
#: 저장 자리 — `estimation` 구획 안 한 칸.
SETTINGS_KEY = "contract"
#: 단가 적용률 — STmate 【 노 】【 재 】【 경 】 차례.
RATE_FIELDS: tuple[tuple[str, str], ...] = (
("labor_pct", ""),
("material_pct", ""),
("expense_pct", ""),
)
#: 적용 옵션 — 화면 표기 그대로(27번 §2.2). 뜻이 확인 안 된 것은 칸만 받고 까닭을 적음.
OPTIONS: tuple[tuple[str, str], ...] = (
("apply_to_base_prices", "기초단가(재,노,경,일식)에 단가적용율 적용하기"),
("generate_unit_prices", "적용율 적용된 일위대가/산출근거 생성하기"),
("separate_same_code", "계약내역내 동일코드의 계약단가 개별생성"),
("labor_ratio", "노무비율 적용"),
("tax_free_material", "비과세자재대(원가계산)에 단가적용율 적용하기"),
("zero_makes_empty", '단가적용율이 0% 이면 계약단가(W코드)를 "0"化 (공내역 생성)'),
)
#: 계산에 아직 안 쓰는 옵션과 그 까닭 — 조용히 무시하지 않고 화면에 적음.
OPTION_NOT_USED = {
"labor_ratio": (
"「노무비 비율 별도」의 계산 뜻이 분석 자료에서 확인 안 됨(27번 §2.2) — 칸만 받음"
),
"tax_free_material": "비과세자재대 줄이 아직 원가계산서에 없음 — 칸만 받음",
}
_SHEETS_NEED_BASE = "「기초단가에 적용」을 함께 켜야 계약 일위대가·산출근거가 섬"
def _pct(value: Any) -> Decimal | None:
try:
number = Decimal(str(value))
except (InvalidOperation, ValueError):
return None
return number if number >= 0 else None
def clean_settings(values: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""저장값과 거른 까닭. 적용률은 0 이상 수 · 비면 100(적용 안 함과 같음)."""
errors: list[str] = []
cleaned: dict[str, Any] = {}
for key, label in RATE_FIELDS:
raw = values.get(key)
if raw in (None, ""):
cleaned[key] = "100"
continue
pct = _pct(raw)
if pct is None:
errors.append(f"{label}】 적용률이 0 이상 수가 아님 — {raw}")
continue
cleaned[key] = str(pct)
for key, _ in OPTIONS:
cleaned[key] = bool(values.get(key))
cleaned["excluded"] = sorted({str(item) for item in values.get("excluded") or [] if item})
return cleaned, errors
def _money(value: Any) -> Decimal:
return Decimal(str(value)) if value not in (None, "") else _ZERO
#: 기초단가 층 — 적용률이 걸리는 카탈로그 종류와 성분(자재 = 재 · 노임 = 노 · 중기 취득가 = 경).
_BASE_KIND_RATE = {
PriceKind.MATERIAL: "material_pct",
PriceKind.LABOR: "labor_pct",
PriceKind.MACHINE_BASE: "expense_pct",
}
def contract_book(book: PriceBook, factors: dict[str, Decimal]) -> PriceBook:
"""「기초단가(재,노,경,일식)에 단가적용율 적용」 — **복사본**의 채택 단가에 적용률을 곱함.
(중기사용료 X · 단가산출 D · 일위대가 B) **다시 조립** 선다 설계와 같은 절사
(`PriceBook.resolve`). 원본 단가표(캐시 공유) 건드림. 일식(W) 단가 0 이라 그대로.
"""
copied = copy.deepcopy(book)
for title in copied.titles.values():
key = _BASE_KIND_RATE.get(title.kind)
if key is None:
continue
slot = title.adopted_slot - 1
if 0 <= slot < len(title.slots) and title.slots[slot] is not None:
title.slots[slot] = title.slots[slot] * factors[key]
return copied
#: 계약 호표로 내는 층 — 일위대가(B)와 그 아래 단가산출근거(D).
_SHEET_KINDS = (PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS)
def _add_sheets(
contract_build: Any, base_code: str, code: str, sheets: dict[str, dict[str, Any]]
) -> None:
"""「적용율 적용된 일위대가/산출근거」 — 설계 본표와 **같은 꼴**(`detail_of`)로 W 코드 한 장씩.
일위대가가 부르는 산출근거(D) 따라 (W-D-, 줄끼리 공유).
"""
from B09_Estimation.B09_Estimation_UnitPrice_View import detail_of
book = contract_build.book
stack = [(base_code, code)]
while stack:
design_code, sheet_code = stack.pop()
if sheet_code in sheets:
continue
sheets[sheet_code] = {
**detail_of(contract_build, design_code),
"code": sheet_code,
"design_code": design_code,
}
for detail in book.details.get(design_code, []):
ref = book.titles.get(detail.ref_code)
if ref is not None and ref.kind in _SHEET_KINDS and detail.ref_code != design_code:
stack.append((detail.ref_code, f"W-{detail.ref_code}"))
def contract_bill(
bill_rows: list[dict[str, Any]],
settings: dict[str, Any],
build: Any = None,
) -> dict[str, Any]:
"""설계 내역 줄(`BillRow.as_dict`) → 계약내역 줄 · 합계 · 까닭 · 계약 호표.
`build` = 설계 일위대가 조립본(`UnitPriceBuild` 캐시 공유본이라 ** 고침**). 기초단가에
적용 켜진 때만 단가표 복사본(`contract_book`) 다시 조립.
0% = 0 (2026-09-14 브레인 판정) 적용 적용제외 체크가 맡음. 공내역 생성
0 줄에 W 코드 공내역 이름을 붙이는 것뿐(끄면 그냥 0 ). 표본 없음 확인 대기.
"""
rates = {key: _pct(settings.get(key, "100")) or _ZERO for key, _ in RATE_FIELDS}
zero_empty = bool(settings.get("zero_makes_empty"))
separate = bool(settings.get("separate_same_code"))
excluded = {str(item) for item in settings.get("excluded") or []}
def factor(key: str) -> Decimal:
return rates[key] / _HUNDRED
book = build.book if build is not None else None
scaled_book = (
contract_book(book, {key: factor(key) for key, _ in RATE_FIELDS})
if book is not None and settings.get("apply_to_base_prices")
else None
)
# 조립본의 곁가지(못 붙은 줄 등)는 그대로 읽고 단가표만 계약 복사본으로 — 얕은 복사.
contract_build = replace(build, book=scaled_book) if scaled_book is not None else None
sheets: dict[str, dict[str, Any]] = {}
rows: list[dict[str, Any]] = []
design = Money3()
contract = Money3()
for source in bill_rows:
row = dict(source)
if row.get("is_group") or not row.get("in_bill", True):
rows.append(row)
continue
unit_design = Money3(
material=_money(row.get("unit_material_krw")),
labor=_money(row.get("unit_labor_krw")),
expense=_money(row.get("unit_expense_krw")),
)
quantity = row.get("quantity")
if quantity in (None, "") or row.get("unit_material_krw") is None:
row.update(contract_note="설계 단가가 안 선 줄 — 계약단가도 못 섬", contract_code="")
rows.append(row)
continue
qty = Decimal(str(quantity))
design_line = bill_line(unit_design, qty)
design += design_line
base_code = str(row.get("price_code") or row.get("code") or "")
if str(row.get("item_no")) in excluded:
unit, code, note = unit_design, base_code, "적용 제외 — 설계 단가 그대로"
else:
unit, note = _contract_unit(row, unit_design, factor, book, scaled_book)
suffix = f"@{row.get('item_no')}" if separate else ""
code = f"W-{base_code}{suffix}"
if unit.total == 0 and zero_empty:
note = "공내역 — 적용률 0%"
if (
contract_build is not None
and settings.get("generate_unit_prices")
and note.startswith("기초단가 적용")
and scaled_book.titles[base_code].kind in _SHEET_KINDS
):
_add_sheets(contract_build, base_code, code, sheets)
line = bill_line(unit, qty)
contract += line
row.update(
contract_code=code,
contract_unit_material_krw=str(unit.material),
contract_unit_labor_krw=str(unit.labor),
contract_unit_expense_krw=str(unit.expense),
contract_unit_price_krw=str(unit.total),
contract_material_krw=str(line.material),
contract_labor_krw=str(line.labor),
contract_expense_krw=str(line.expense),
contract_amount_krw=str(line.total),
contract_excluded=str(row.get("item_no")) in excluded,
contract_note=note,
)
rows.append(row)
_group_sums(rows)
return {
"rows": rows,
"totals": {
"design": _totals(design),
"contract": _totals(contract),
"ratio_pct": {
part: str(
(getattr(contract, part) / getattr(design, part) * _HUNDRED).quantize(
Decimal("0.001")
)
)
if getattr(design, part)
else None
for part in ("material", "labor", "expense")
},
},
"options_not_used": {
**{k: v for k, v in OPTION_NOT_USED.items() if settings.get(k)},
**(
{"generate_unit_prices": _SHEETS_NEED_BASE}
if settings.get("generate_unit_prices") and not settings.get("apply_to_base_prices")
else {}
),
},
# 「적용율 적용된 일위대가/산출근거 생성」 — 기초단가 적용이 켜진 때만
# (성분 곱셈에는 다시 조립할 호표가 없음).
"unit_price_sheets": list(sheets.values()),
}
def _contract_unit(
row: dict[str, Any],
unit_design: Money3,
factor,
book: PriceBook | None,
scaled_book: PriceBook | None,
) -> tuple[Money3, str]:
"""계약 성분 단가와 비고. 기초단가 적용이면 단가표를 다시 조립, 아니면 성분에 곱함.
설계 단가가 단가표 조립값과 다르면(할증·수동 단가·구조물도 호표) 기초단가로
성분 곱셈으로 떨어지고 사실을 비고에 적음(조용히 틀린 값을 세우지 않음).
"""
scaled = Money3(
material=unit_design.material * factor("material_pct"),
labor=unit_design.labor * factor("labor_pct"),
expense=unit_design.expense * factor("expense_pct"),
).floored(Decimal(1))
if scaled_book is None or book is None:
return scaled, ""
unit, why = reassembled_unit(str(row.get("price_code") or ""), unit_design, book, scaled_book)
if unit is None:
return scaled, f"기초단가로 못 풂({why}) — 성분에 적용"
return unit, "기초단가 적용 — 단가표 다시 조립"
def reassembled_unit(
code: str, unit_design: Money3, book: PriceBook, new_book: PriceBook
) -> tuple[Money3 | None, str]:
"""고친 단가표 복사본으로 그 줄 단가를 다시 조립 — 못 풀면 (None, 까닭).
설계 단가가 설계 단가표 조립값과 같을 때만 다르면(할증·수동 단가·구조물도 호표)
다시 조립한 값이 단가가 아님(계약·실행예산 공용).
"""
if code not in book.titles:
return None, "단가표 밖 코드"
try:
if book.resolve(code).floored(Decimal(1)) != unit_design:
return None, "설계 단가가 단가표 조립값과 다름 — 할증·수동 단가"
return new_book.resolve(code).floored(Decimal(1)), ""
except PriceBookError as error:
return None, str(error)
def _totals(money: Money3) -> dict[str, str]:
return {
"material_krw": str(money.material),
"labor_krw": str(money.labor),
"expense_krw": str(money.expense),
"total_krw": str(money.total),
}
def _group_sums(rows: list[dict[str, Any]], stage: str = "contract") -> None:
"""묶음 줄 단계 금액(`{stage}_…_krw`) — 아래 줄의 합(설계 내역 `_group_sums` 와 같은 꼴)."""
for group in rows:
if not group.get("is_group"):
continue
prefix = f"{group.get('item_no')}."
children = [
r
for r in rows
if not r.get("is_group")
and str(r.get("item_no", "")).startswith(prefix)
and r.get(f"{stage}_amount_krw") is not None
]
for part in ("material", "labor", "expense", "amount"):
group[f"{stage}_{part}_krw"] = str(
sum((Decimal(r[f"{stage}_{part}_krw"]) for r in children), _ZERO)
)
+423
View File
@@ -0,0 +1,423 @@
"""B09 원가계산서 화면 자료 — STmate 「제잡비 계산」(wM_KanJub) **일반형식** 서식 차례 (PLAN 12장).
화면만 닮는다 **값은 우리 엔진**(`Engine_Cost.calculate_cost`) 낸다. 여기는
엔진 줄을 STmate 서식 차례·표기(././1)) 늘어놓고
기준 입력판 (번호·제목은 DFM `TWM_KANJUB` 그대로) 선택지를 요율 데이터에서 뽑고
프로젝트 저장값을 `CostInput` 으로 옮기는 일만 한다. 계산을 벌로 짜지 않는다.
형식은 일반 수공·실적일반·실적수공은 항목·밑수·요율이 달라(35 문서) 차례로 세움.
"""
from __future__ import annotations
from decimal import Decimal, InvalidOperation
from typing import Any
from B09_Estimation.B09_Estimation_Engine_Cost import CostInput, CostResult
from B09_Estimation.B09_Estimation_Engine_Cost_Options import (
CUT_BASES,
CUT_UNITS_KRW,
OVERHEAD_CLASSES,
VAT_MODES,
)
from B09_Estimation.B09_Estimation_Rates import RateDataset
_ZERO = Decimal(0)
#: 저장 자리 — `estimation` 구획 안 한 칸.
SETTINGS_KEY = "cost_sheet"
#: 기준 입력판 — DFM 번호·제목 그대로. 엔진이 받는 칸만 연다(나머지 번호는 형식이 서면 채움).
#: (칸 이름, DFM 제목, 요율 변수 경로, 한글 이름표)
WORK_TYPE_FIELDS: tuple[tuple[str, str], ...] = (
("work_type_indirect_labor", "2.공사의종류(주)"),
("work_type_safety", "7.산업안전보건관리비"),
("environment_work_type", "8.환 경 보 전 비"),
("equipment_guarantee_work_type", " 9.건설기계대여금 지급수수료"),
)
AMOUNT_FIELDS: tuple[tuple[str, str], ...] = (
("owner_supplied_material_krw", "6.관/사급내용 — 관급자재대(순자재대)"),
("procurement_fee_krw", "6.관/사급내용 — 조달수수료"),
("indirect_material_krw", "- 간 접 재 료 비"),
("profit_adjustment_krw", "이윤 보정액"),
("overhead_managed_material_krw", "관리품목 자재대(일반관리비 밑수)"),
("tax_exempt_material_krw", "면세품 금액(산림조합-면세품)"),
)
#: 10·11·12 — 선택지가 요율 데이터가 아니라 **엔진 규칙**(`Engine_Cost_Options`)에서 옴.
CHOICE_FIELDS: tuple[tuple[str, str], ...] = (
("employment_insurance_grade", "4.고 용 보 험 료"),
("retirement_mutual_aid_mode", "5.퇴직공제부금비"),
("overhead_class", "10.일반관리비"),
("cut_basis", "11.공급가절사기준"),
("cut_unit_krw", "11.절사 단위"),
("vat_mode", "12.부 가 가 치 세"),
("private_material_position", "13.사급비 위치"),
("bid_method", "14. 낙 찰 방 식"),
("subcontract_guarantee", "하도급대금 지급보증수수료"),
("performance_guarantee_mode", "15. 이행보증 방식"),
("contract_law_basis", "16. 적용기준"),
("waste_placement", "폐기물처리비 자리"),
)
#: 칸 밑 안내 — 기본값의 까닭과 실무가 어떻게 쓰는지(2026-09-14 브레인 판정 · 도자 60m 방식).
FIELD_HINTS: dict[str, str] = {
"employment_insurance_grade": "자동 = 추정금액 구간으로 등급을 고름(임도는 대개 7등급 이하)",
"retirement_mutual_aid_mode": (
"자동 = 요율 데이터 적용 하한(추정금액 1억 이상)일 때 적용 · STmate 의 토목·준설·건축·기타"
" 구분은 현행 제비율(2026-04-13)에서 율이 같아(2.3%) 칸으로 두지 않음"
),
"overhead_class": "임도가 (주)공사·전문공사 어느 쪽인지 정한 규정 없음 — 기본 (주)공사",
"cut_basis": (
"기본 「총공사비 1,000원 미만 버림」 — 근거: 산림청고시 제2025-82호 「금액의 단위표준」"
"(설계서의 총액 · 원 · 1,000 · 미만버림) · 실무 6건이 여섯 다 000 으로 끝남 ·"
" 깎은 몫은 이윤 줄에 「절사 자동보정(산림청고시 2025-82호)」으로 적혀 조용히 안 깎임 ·"
" 발주처가 달리 정하면 「절사 안 함」으로 끄고 이윤보정액을 직접 넣을 것"
),
"vat_mode": "실무 임도는 (0) 공급가액의 10% · 산림조합 형식은 계류보전(영덕) 1건",
"private_material_position": (
"사급자재대는 설계내역서 재료비에 이미 들어 옴(실무 거창·영월 · STmate 기본) —"
" 다른 자리(이윤 다음·도급공사비 직전·관급자재대 다음)는 사급 금액 칸이 설 때 엶"
),
"bid_method": (
"하도급대금 지급보증 요율 줄만 고름 — 종합심사 외 = 규모 구간 ·"
" 종합심사 대상 = 300억 이상 줄"
" · 턴키(대안) = 0.084% · 설계 단계 금액엔 이 한 줄뿐, 계약 단계 단가 적용률로 이어질 칸"
),
"subcontract_guarantee": (
"기본 꺼짐 — 실무 원가계산서 6건에 이 줄이 없음. 건설산업기본법 제34조 대상"
"(하도급대금 지급보증서 교부)이면 켤 것 · 실무에 없는 것이 「대상 아님」인지"
" 「안 적음」인지는 못 가름"
),
"performance_guarantee_mode": (
"일반계약(기본) = 추정가격 300억 이상일 때만 공사이행보증수수료를 셈 · 최저가·기술제안 ="
" 규모와 무관하게 셈 — 근거: 예정가격작성기준 제19조③20호(시행령 제52조제1항 단서) ·"
" 제비율 기준 「소규모 공사는 통상 비대상」 · 실무 임도 원가계산서 여섯 건에 금액 없음"
),
"contract_law_basis": (
"계산에 안 씀 — 지금 요율은 조달청 표 한 벌 ·"
" 국가/지방 구간 차이(예정가격작성기준 제20조 표)는"
" 원문이 이미지라 미확인 · 행자부 표 없음. 발주처 구분만 받아 둠"
),
"waste_placement": (
"법 문언(기본): 예정가격작성기준 제19조③18호 — 폐기물처리비는 경비라 일반관리비·이윤"
" 밑수에 듦 · 실무 관행: 울진소광 원가계산서(이윤 뒤·총원가 안)·임목폐기물처리 실정보고"
"(공급가액 뒤)는 승률 밖 — 금액이 크게 갈려 설계자가 고름"
),
}
_GRADE_OPTIONS = [
{"value": "auto", "label": "자동(추정금액 구간)"},
{"value": "none", "label": "없음"},
*({"value": str(g), "label": f"{g} 등급"} for g in range(1, 7)),
{"value": "7", "label": "7 등급이하"},
]
#: DFM `12.부 가 가 치 세` 선택지 표기 그대로.
VAT_MODE_LABELS = {
"supply": "(0) 공급가액의 10%",
"material": "(1) 재료비의 10%",
"none": "(2) 없음",
"forest_coop": "(3) 산림조합 형식",
"forest_coop_exempt": "(4) 산림조합-면세품",
}
def choice_options() -> dict[str, list[dict[str, str]]]:
"""10·11·12 선택지.
절사 값은 **`none`** 값이면 저장되지 않아(`clean_settings`)
기본으로 되돌아가 ** 수가 없다**.
기본은 총공사비에서 조정 · 1,000 미만(산림청고시 2025-82).
"""
return {
"employment_insurance_grade": _GRADE_OPTIONS,
"retirement_mutual_aid_mode": [
{"value": "auto", "label": "자동(추정금액 1억 이상)"},
{"value": "apply", "label": "적용"},
{"value": "none", "label": "미적용"},
],
"waste_placement": [
{"value": "expense", "label": "경비 — 승률 안(법 문언 · 기본)"},
{"value": "after_profit", "label": "이윤 뒤 · 총원가 안 — 승률 밖(실무 관행)"},
],
"private_material_position": [
{"value": "material", "label": "재료비에 포함"},
],
"bid_method": [
{"value": "not_comprehensive", "label": "종합심사 외"},
{"value": "comprehensive", "label": "종합심사 대상"},
{"value": "turnkey", "label": "턴키(대안)"},
],
"subcontract_guarantee": [
{"value": "off", "label": "계상 안 함(기본)"},
{"value": "on", "label": "계상"},
],
"performance_guarantee_mode": [
{"value": "general", "label": "일반계약"},
{"value": "lowest_price_tech", "label": "최저가,기술제안"},
],
"contract_law_basis": [
{"value": "", "label": "미정(계산에 안 씀)"},
{"value": "national", "label": "일반(국가계약법)"},
{"value": "local", "label": "일반(지방계약법)"},
{"value": "moi", "label": "행자부기준"},
],
"overhead_class": [{"value": k, "label": v} for k, v in OVERHEAD_CLASSES.items()],
"cut_basis": [
*(
{
"value": k,
"label": f"{v}에서 조정"
+ (" (기본 · 고시 2025-82호)" if k == "grand_total" else ""),
}
for k, v in CUT_BASES.items()
),
{"value": "none", "label": "절사 안 함(이윤보정액 직접 입력)"},
],
"cut_unit_krw": [{"value": str(u), "label": f"{u:,}원 미만"} for u in CUT_UNITS_KRW],
"vat_mode": [{"value": k, "label": VAT_MODE_LABELS[k]} for k in VAT_MODES],
}
#: 공종 이름표 — 조달청 제비율 적용기준(현행 2026-04-13) 표기.
WORK_TYPE_LABELS: dict[str, str] = {
"civil": "토목",
"landscape": "조경",
"industrial_facilities_civil": "산업설비(토목)",
"building": "건축공사",
"heavy_construction": "중건설공사",
"special_construction": "특수건설공사",
"civil_road": "토목 — 도로(교량·터널·활주로 등)",
"civil_plant": "토목 — 플랜트(발전소·쓰레기소각장 등)",
"civil_subway": "토목 — 지하철",
"civil_railway": "토목 — 철도",
"civil_water_and_sewer": "토목 — 상하수도",
"civil_port": "토목 — 항만",
"civil_port_with_silt_screen": "토목 — 항만(오탁방지막·준설토방지막 설치)",
"civil_dam": "토목 — 댐",
"civil_land_development": "토목 — 택지개발",
"civil_other": "토목 — 그 밖의 토목공사(하천 등)",
"building_housing_redevelopment": "건축 — 주택(재개발·재건축)",
"building_new_housing": "건축 — 주택(신축)",
"building_other": "건축 — 그 밖의 건축",
"civil_general": "일반건설 — 토목",
"industrial_facilities": "일반건설 — 산업설비",
}
#: 산업안전보건관리비 공종은 「토목공사」 표기(간접노무비 「토목」과 키가 같아 따로 둠).
SAFETY_LABELS = {"civil": "토목공사"}
def _unique(values: list[str]) -> list[str]:
return list(dict.fromkeys(values))
def field_options(dataset: RateDataset) -> dict[str, list[dict[str, str]]]:
"""기준 입력판 고르개 선택지 — **요율 데이터에 있는 공종만**(코드에 목록을 박지 않음)."""
variables = dataset.variables
sources = {
"work_type_indirect_labor": [
row["work_type"] for row in variables["rate_indirect_labor"]["brackets"]
],
"work_type_safety": [row["work_type"] for row in variables["rate_safety_pct"]["brackets"]],
"environment_work_type": [
row["work_type"] for row in variables["rate_environment"]["all_work_types"]
],
"equipment_guarantee_work_type": [
row["work_type"]
for row in variables["rate_equipment_payment_guarantee"]["general_construction"]
],
}
options: dict[str, list[dict[str, str]]] = {}
for key, values in sources.items():
labels = {**WORK_TYPE_LABELS, **(SAFETY_LABELS if key == "work_type_safety" else {})}
options[key] = [{"value": v, "label": labels.get(v, v)} for v in _unique(values)]
return {**options, **choice_options()}
def _decimal(value: Any) -> Decimal:
try:
number = Decimal(str(value))
except (InvalidOperation, ValueError):
return _ZERO
return number if number >= 0 else _ZERO
def clean_settings(values: dict[str, Any], dataset: RateDataset) -> dict[str, Any]:
"""저장할 값 — 데이터에 없는 공종·음수·빈칸은 버린다(버린 칸은 엔진 기본값이 섬)."""
options = field_options(dataset)
cleaned: dict[str, Any] = {}
for key, _ in (*WORK_TYPE_FIELDS, *CHOICE_FIELDS):
value = str(values.get(key) or "")
if value and value in {option["value"] for option in options[key]}:
cleaned[key] = int(value) if key == "cut_unit_krw" else value
for key, _ in AMOUNT_FIELDS:
if values.get(key) not in (None, ""):
cleaned[key] = str(_decimal(values.get(key)))
try:
days = int(values.get("duration_days"))
except (TypeError, ValueError):
days = 0
if days > 0:
cleaned["duration_days"] = days
return cleaned
def cost_input(
direct: dict[str, Decimal],
stored: dict[str, Any],
waste_krw: Decimal = _ZERO,
waste_separate_order: bool = False,
rate_overrides: tuple[dict[str, Any], ...] = (),
) -> CostInput:
"""내역서 직접비 + 저장값 → 엔진 입력. 저장 안 한 칸은 **엔진 기본값**."""
kwargs: dict[str, Any] = {
key: stored[key] for key, _ in (*WORK_TYPE_FIELDS, *CHOICE_FIELDS) if stored.get(key)
}
kwargs.update(
{key: _decimal(stored[key]) for key, _ in AMOUNT_FIELDS if stored.get(key) is not None}
)
if stored.get("duration_days"):
kwargs["duration_days"] = int(stored["duration_days"])
return CostInput(
direct_material_krw=direct["material"],
direct_labor_krw=direct["labor"],
direct_expense_krw=direct["expense"],
waste_disposal_krw=waste_krw,
waste_separate_order=waste_separate_order,
rate_overrides=tuple(rate_overrides),
**kwargs,
)
#: 경비 세목 — STmate 일반형식 서식 차례·표기. 키는 우리 엔진 줄.
EXPENSE_ORDER: tuple[tuple[str, str], ...] = (
("industrial_accident_insurance", "산재보험료"),
("employment_insurance", "고용보험료"),
("health_insurance", "건강보험료"),
("long_term_care_insurance", "장기요양보험료"),
("national_pension", "연금보험료"),
("retirement_mutual_aid", "퇴직공제부금비"),
("safety_management_cost", "산업안전보건관리비"),
("other_expense", "기타 경비"),
("performance_guarantee_fee", "공사이행 수수료"),
("subcontract_payment_guarantee", "하도대금 수수료"),
("environment_preservation", "환 경 보 전 비"),
("equipment_payment_guarantee", "건설기계 대여금"),
("asbestos_contribution", "석면분담금"),
("wage_claim_contribution", "임금채권부담금"),
("waste_disposal", "폐기물처리비"),
)
def _row(
mark: str, label: str, amount: Decimal, level: int, key: str, result: CostResult | None = None
) -> dict[str, Any]:
line = result.line(key) if result is not None and result.has(key) else None
return {
"key": key,
"mark": mark,
"label": label,
"level": level,
"formula": line.formula_text if line else "",
"rate_percent": None
if line is None or line.rate_percent is None
else str(line.rate_percent),
"amount_krw": str(amount),
"note": line.note if line else "",
"total": level == 0,
}
def sheet_rows(result: CostResult, data: CostInput) -> list[dict[str, Any]]:
"""일반형식 서식 줄 — 나.순공사원가 … 바.총공사비. 엔진에 없는 줄은 안 세움."""
amount = result.amount
rows = [
_row(
"나.",
"순공사원가(ㄱ+ㄴ+ㄷ)",
amount("net_construction_cost"),
0,
"net_construction_cost",
result,
),
_row("ㄱ.", "재 료 비", amount("material_cost"), 1, "material_cost", result),
_row("ㄴ.", "노 무 비", amount("labor_cost"), 1, "labor_cost", result),
_row("1)", "직접노무비", data.direct_labor_krw, 2, "direct_labor"),
_row("2)", "간접노무비", amount("indirect_labor_cost"), 2, "indirect_labor_cost", result),
_row("ㄷ.", "경 비", amount("expense"), 1, "expense", result),
_row("1)", "내역서경비", data.direct_expense_krw, 2, "direct_expense"),
]
number = 2
for key, label in EXPENSE_ORDER:
waste_elsewhere = data.waste_separate_order or data.waste_placement == "after_profit"
if not result.has(key) or (key == "waste_disposal" and waste_elsewhere):
continue
rows.append(_row(f"{number})", label, amount(key), 2, key, result))
if key == "safety_management_cost":
# STmate 도 두 비교 줄을 나란히 둠 — 작은 쪽 채택.
for sub in ("safety_management_cost_a", "safety_management_cost_b"):
if result.has(sub):
rows.append(_row("", result.line(sub).name, amount(sub), 3, sub, result))
number += 1
overhead = amount("general_overhead")
rows.append(_row("5)", "일반관리비", overhead, 1, "general_overhead", result))
rows.append(
{
**_row("다.", "소 계", amount("net_construction_cost") + overhead, 0, "subtotal"),
"formula": "나.순공사원가 + 5)일반관리비",
}
)
if result.has("profit_adjustment"):
rows.append(
_row(
"",
"이윤(보정 전)",
amount("profit_before_adjustment"),
2,
"profit_before_adjustment",
result,
)
)
rows.append(
_row("", "이윤 보정액", amount("profit_adjustment"), 2, "profit_adjustment", result)
)
profit_row = _row("6)", "이윤", amount("profit"), 1, "profit", result)
if result.has("profit_before_adjustment"):
before = result.line("profit_before_adjustment")
profit_row.update(formula=before.formula_text, rate_percent=str(before.rate_percent))
rows.append(profit_row)
if (
data.waste_placement == "after_profit"
and not data.waste_separate_order
and result.has("waste_disposal")
):
# 실무 관행 자리(울진소광) — 이윤 뒤 · 총원가 안.
rows.append(_row("", "폐기물처리비", amount("waste_disposal"), 1, "waste_disposal", result))
rows.append(_row("라.", "공급가액", amount("total_cost"), 0, "total_cost", result))
rows.append(_row("", "부가가치세", amount("vat"), 1, "vat", result))
rows.append(_row("마.", "도급공사비", amount("contract_amount"), 0, "contract_amount", result))
if result.has("owner_supplied_material_total"):
rows.append(
_row(
"",
"관급자재대",
amount("owner_supplied_material_total"),
1,
"owner_supplied_material_total",
result,
)
)
if data.waste_separate_order and result.has("waste_disposal"):
rows.append(
_row(
"", "폐기물처리비(분리발주)", amount("waste_disposal"), 1, "waste_disposal", result
)
)
rows.append(_row("바.", "총공사비", amount("grand_total"), 0, "grand_total", result))
return rows
def status_line(result: CostResult, data: CostInput) -> dict[str, str]:
"""아래 상태줄 — DFM `직재: / 직노: / 산경: / 이윤:` 차례."""
return {
"직재": str(data.direct_material_krw),
"직노": str(data.direct_labor_krw),
"산경": str(data.direct_expense_krw),
"이윤": str(result.amount("profit")),
}
@@ -0,0 +1,164 @@
"""원가계산서 4형식 중 나머지 셋 — **서식 차례만** (수공 S · 실적일반 TA · 실적수공 SA).
근거: STmate 분석 `12_원가계산_4형식과_RATE표_복원.md` §4~6 (DFM 복원 서식) ·
`35_형식과_단계의_공통과_차이.md` §1·§2 (공통 사슬 · 형식별 차이 · 12 정정 반영).
**금액을 낸다** 요율 데이터가 없다(2026-09-14 확인: 수공 세부 경비 율표 `RateB` ·
표준시장단가 제비율 `RATE_RA`·`RATE_RA1`·실적 안전표 `An_A`). 줄마다 무엇이 막혔는지만 적는다.
**0 원으로 채우지 않는다** 0 원은 없다 아니라 공짜 읽혀 총액이 조용히 작아진다.
35 §5 미확인(실적형 이윤 밑수 · 실적수공 안전관리비 존재) 확인 대기 세운다.
"""
from __future__ import annotations
from typing import Any
#: 줄 상태 — 화면이 그대로 보인다.
COMMON = "공통 요율 — 일반형식과 같은 표(형식 전체가 서면 금액이 섬)"
SEPARATE = "별도내역 — 요율이 아니라 따로 산출한 금액"
RATE_B = "요율표 미확보 — 수공 세부 경비 율표(STmate RateB)"
RATE_RA = "요율표 미확보 — 표준시장단가(실적공사비) 제비율(STmate RATE_RA·RATE_RA1)"
RATE_AN_A = "요율표 미확보 — 실적 산업안전보건관리비 표(STmate An_A·An_A1)"
TOTAL = "합계 — 위 줄이 다 서야 섬"
PENDING_PROFIT = "확인 대기 — 실적형 이윤 밑수가 복원식과 화면 문구가 충돌(35번 §5-1)"
PENDING_SAFETY = "확인 대기 — 실적수공에 안전관리비가 실제로 있는지 미확인(35번 §5-2)"
DIRECT = "설계내역서 합계에서 옴"
_SUGONG_EXPENSES = (
"수도광열비",
"복리후생비",
"소모품·사무용품비",
"여비·교통비·통신비",
"세금·공과금",
"폐기물처리비",
"도서인쇄비",
"지급수수료",
)
FORM_LABELS = {
"general": "일반형식",
"sugong": "수공형식",
"actual_general": "실적일반",
"actual_sugong": "실적수공",
}
def _r(mark: str, label: str, formula: str, status: str, level: int) -> dict[str, Any]:
return {
"key": "",
"mark": mark,
"label": label,
"level": level,
"formula": formula,
"rate_percent": None,
"amount_krw": None,
"note": status,
"total": level == 0,
"blocked": status not in (COMMON, DIRECT),
}
def _sugong() -> list[dict[str, Any]]:
"""수공 S — 12번 §4.1."""
rows = [
_r("1.", "순공사원가(가+나+다)", "", TOTAL, 0),
_r("가.", "순공사비", "", DIRECT, 1),
_r("나.", "간접노무비", "직접노무비 ×", COMMON, 1),
_r("다.", "경 비", "", TOTAL, 1),
_r("1)", "산재보험료", "(직접노무비+간접노무비) ×", COMMON, 2),
_r("2)", "고용보험료", "(직접노무비+간접노무비) ×", COMMON, 2),
_r("3)", "건강보험료", "직접노무비 ×", COMMON, 2),
_r("4)", "장기요양보험료", "건강보험료 ×", COMMON, 2),
_r("5)", "연금보험료", "직접노무비 ×", COMMON, 2),
_r("6)", "퇴직공제부금비", "직접노무비 ×", COMMON, 2),
_r("7)", "산업안전보건관리비", "(재료비+직접노무비+도급자관급자재) × 율", COMMON, 2),
_r("8)", "안전관리비", "", SEPARATE, 2),
_r("9)", "품질관리비", "", SEPARATE, 2),
]
rows += [
_r(f"{10 + i})", name, "(직노·노무·재료 중 고른 밑수) × 율", RATE_B, 2)
for i, name in enumerate(_SUGONG_EXPENSES)
]
rows += [
_r("18)", "공사이행 수수료", "[기초액 + (직공비−기준액) × 율] × 공기", COMMON, 2),
_r("19)", "하도대금 수수료", "(재료비+직노+산출경비) ×", COMMON, 2),
_r("20)", "환 경 보 전 비", "(재료비+직노+산출경비) ×", COMMON, 2),
_r("21)", "건설기계 대여금", "(재료비+직노+산출경비) ×", COMMON, 2),
_r("22)", "석면분담금", "(직노+간노) ×", COMMON, 2),
_r("23)", "임금채권부담금", "(직노+간노) ×", COMMON, 2),
_r(
"2.",
"일반관리비",
"(순공사비+간접노무비+경비) × 율 — 밑수에 수공 경비가 들어 미산출",
RATE_B,
1,
),
_r("3.", "이 윤", "(순공사원가+일반관리비−재료비) × 율 — 밑수 미산출", RATE_B, 1),
_r("4.", "공급가액", "", TOTAL, 0),
_r("5.", "도급공사비", "", TOTAL, 0),
_r("6.", "총공사비", "", TOTAL, 0),
]
return rows
def _actual(sugong: bool) -> list[dict[str, Any]]:
"""실적일반 TA(§5 · 35번 정정) / 실적수공 SA(§6)."""
labor_base = "(직접노무비+간접노무비)" if sugong else "노무비"
rows = [
_r("1.", "직접공사비", "", DIRECT, 0),
_r("2.", "간접공사비", "", TOTAL, 0),
_r("1)", "간접노무비", "직접노무비 ×", RATE_RA, 1),
_r("2)", "산재보험료", f"{labor_base} ×", COMMON, 1),
_r("3)", "고용보험료", f"{labor_base} ×", COMMON, 1),
_r("4)", "건강보험료", "직접노무비 ×", COMMON, 1),
_r("5)", "장기요양보험료", "건강보험료 ×", COMMON, 1),
_r("6)", "연금보험료", "직접노무비 ×", COMMON, 1),
_r("7)", "퇴직공제부금비", "직접노무비 ×", COMMON, 1),
_r("8)", "산업안전보건관리비", "(직접공사비+도급자관급자재) ×", RATE_AN_A, 1),
_r("9)", "안전관리비", "", PENDING_SAFETY if sugong else SEPARATE, 1),
_r("10)", "품질관리비", "", SEPARATE, 1),
]
if sugong:
rows += [
_r(f"{11 + i})", name, "(직노·노무·재료 중 고른 밑수) × 율", RATE_B, 1)
for i, name in enumerate(_SUGONG_EXPENSES)
]
guarantee_base = "(재료비+직노+산출경비)"
tail = 19
else:
rows.append(_r("11)", "기타경비", "직접공사비 ×", RATE_RA, 1))
guarantee_base = "직접공사비"
tail = 12
rows += [
_r(f"{tail})", "공사이행 수수료", "[기초액 + (직공비−기준액) × 율] × 공기", COMMON, 1),
_r(f"{tail + 1})", "하도대금 수수료", f"{guarantee_base} ×", COMMON, 1),
_r(f"{tail + 2})", "환 경 보 전 비", "(재료비+직노+산출경비) ×", COMMON, 1),
_r(f"{tail + 3})", "건설기계 대여금", f"{guarantee_base} ×", COMMON, 1),
_r(f"{tail + 4})", "석면분담금", f"{labor_base} ×", COMMON, 1),
_r(f"{tail + 5})", "임금채권부담금", f"{labor_base} ×", COMMON, 1),
_r(
"3.",
"일반관리비",
"(직접공사비+간접노무비) ×"
if sugong
else "(직접공사비+간접공사비+관리품목자재) × 율",
RATE_RA,
0,
),
_r("4.", "이 윤", "", PENDING_PROFIT, 0),
_r("5.", "공급가액", "", TOTAL, 0),
_r("6.", "도급공사비", "", TOTAL, 0),
_r("7.", "총공사비", "", TOTAL, 0),
]
return rows
def form_rows(form: str) -> list[dict[str, Any]]:
"""형식별 서식 줄(금액 없음). 일반형식은 엔진이 셈하므로 여기 없음."""
if form == "sugong":
return _sugong()
if form == "actual_general":
return _actual(sugong=False)
if form == "actual_sugong":
return _actual(sugong=True)
raise ValueError(f"서식이 없는 형식입니다: {form}")
+338
View File
@@ -0,0 +1,338 @@
"""B09 원가계산 — **사용자가 고친 값** 한 벌 (PLAN 12장 2차 · 2026-09-14 브레인 판정).
전부 **프로젝트 단위** 라이브러리 층을 만들지 않는다(B09 프로젝트의 내역·단가라 다른
프로젝트로 가져갈 것이 없음. 양식·라이브러리는 B08 구조물도에만 있음).
저장 자리 산출 조건 `estimation.edits` 파일 구획
얹는 자리 서버가 기본 조립(`cached_build`) **복사본에** 고친 값을 얹고 다시 계산
(캐시 키에 고친 값이 들어감 브라우저 값을 받아 적지 않음)
되돌리기 고친 값을 지우면 계산값으로 돌아감(B08 집계표 같은 )
기본 조립은 ** 바꾼다** 고친 값이 없으면 `cached_build` 그대로라 골든셋이 그대로 돈다.
얹지 못한 고친 (단가표에서 사라진 코드· 없는 슬롯· 풀리는 ) 조용히 버리지 않고 `edit_skipped` 남긴다.
구획
adopted_slots {자재 코드: 슬롯 1~6} 자재단가대비표 채택(`wM_Boxa`)
sheet_rows {"코드|i": {quantity}|{removed}} · 일위대가·단산 구성행(`wM_Edit_iLWi`)
{"코드|+n": {ref, quantity}} 더하기(뒤에 붙음)
price_basis_q {"D-코드|i": {q_formula}} 단가산출 Q (`wM_Edit_San`)
식은 **명세 13 언어**(B08 구조물도 풀이기 그대로) 소수 2자리 사사오입 확정
수량 = 수량 × Q ÷ Q(몫은 그대로)
"""
from __future__ import annotations
import copy
import json
from dataclasses import replace
from decimal import Decimal, InvalidOperation
from functools import lru_cache
from typing import Any
from B09_Estimation.B09_Estimation_PriceBook import (
PRICE_SLOT_COUNT,
PriceBookError,
PriceDetail,
PriceKind,
)
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build
EDITS_KEY = "edits"
#: 화면·근거 표기 — B08 구조물도 집계표와 같은 말.
USER_SOURCE = "user"
SECTIONS = ("adopted_slots", "sheet_rows", "price_basis_q", "bill_rates")
#: 단가표(조립)에 얹는 구획 — 캐시 키는 이것만. `bill_rates` 는 내역 줄에 얹음(`B09_Estimation_BillRates`).
BUILD_SECTIONS = ("adopted_slots", "sheet_rows", "price_basis_q")
#: 구성행을 고칠 수 있는 본표 — 일위대가(B) · 단가산출(D).
EDITABLE_KINDS = (PriceKind.UNIT_PRICE, PriceKind.PRICE_BASIS)
#: 줄 더하기로 부를 수 있는 단가표 층.
ADDABLE_KINDS = (
PriceKind.LABOR,
PriceKind.MATERIAL,
PriceKind.MACHINE_HOURLY,
PriceKind.UNIT_PRICE,
PriceKind.PRICE_BASIS,
PriceKind.LUMPSUM,
)
class EditError(ValueError):
"""고친 값을 받을 수 없음 — 저장하지 않고 까닭을 돌려줌."""
def normalize(raw: Any) -> dict[str, dict[str, Any]]:
"""저장본을 아는 구획만 남긴 모양으로 — 모르는 구획·빈 구획은 버림."""
edits: dict[str, dict[str, Any]] = {}
if not isinstance(raw, dict):
return edits
for section in SECTIONS:
values = raw.get(section)
if isinstance(values, dict) and values:
edits[section] = {str(key): value for key, value in values.items()}
return edits
def edits_key(raw: Any) -> str:
"""캐시 키 — 같은 고친 값이면 같은 글(차례 무관)."""
edits = {key: value for key, value in normalize(raw).items() if key in BUILD_SECTIONS}
return json.dumps(edits, sort_keys=True, ensure_ascii=False) if edits else ""
def split_key(key: str) -> tuple[str, str]:
"""`코드|i` · `코드|+n` → (코드, 뒤 조각)."""
code, _, tail = key.rpartition("|")
return code, tail
def _quantity(text: Any) -> Decimal:
try:
value = Decimal(str(text).replace(",", "").strip())
except (InvalidOperation, ValueError) as error:
raise EditError(f"수량이 수가 아닙니다: {text}") from error
if not value.is_finite() or value < 0:
raise EditError(f"수량은 0 이상이어야 합니다: {text}")
return value
def evaluate_q(formulas: dict[str, str]) -> dict[str, Decimal | str]:
"""Q 식 여럿을 한 번에 — 명세 13장 식 언어(B08 `evaluate_sheets`, Node 한 벌) · 소수 2자리 사사오입.
칸마다 (0 보다 ) 또는 까닭(). 풀이를 파이썬으로 다시 짜지 않음( 금지).
"""
if not formulas:
return {}
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
keys = list(formulas)
rows = [
{
"seq": index + 1,
"name": key,
"formula": formulas[key],
"destination": "reference",
"rounding": {"mode": "round", "digits": 2},
}
for index, key in enumerate(keys)
]
solved = evaluate_sheets([{"rows": rows}])
if solved is None:
return {key: "식 풀이기(Node)를 못 돌렸습니다" for key in keys}
result: dict[str, Decimal | str] = {}
for key, row in zip(keys, solved[0]):
if row.get("error") or row.get("amount") is None:
result[key] = str(row.get("error") or "식 값이 없습니다")
continue
value = Decimal(str(row["amount"]))
result[key] = value if value > 0 else "Q 는 0 보다 커야 합니다"
return result
def _apply_adopted_slots(build: UnitPriceBuild, values: dict[str, Any]) -> None:
for code, slot in values.items():
title = build.book.titles.get(code)
number = int(slot) if str(slot).isdigit() else 0
if title is None or title.kind is not PriceKind.MATERIAL:
build.edit_skipped.append(f"채택 슬롯 {code} — 단가표에 없는 자재")
continue
if not 1 <= number <= PRICE_SLOT_COUNT or title.slots[number - 1] is None:
build.edit_skipped.append(f"채택 슬롯 {code}{slot}번 슬롯에 값이 없음")
continue
title.adopted_slot = number
def _mark(build: UnitPriceBuild, code: str, index: int, **info: Any) -> None:
build.edit_rows.setdefault(code, {}).setdefault(index, {}).update(info)
def _apply_q(build: UnitPriceBuild, values: dict[str, Any]) -> None:
formulas = {
key: str(value.get("q_formula") or "")
for key, value in values.items()
if isinstance(value, dict)
}
solved = evaluate_q({key: text for key, text in formulas.items() if text})
for key, text in formulas.items():
code, tail = split_key(key)
details = build.book.details.get(code, [])
index = int(tail) if tail.isdigit() else -1
if not 0 <= index < len(details) or details[index].output is None:
build.edit_skipped.append(f"Q 식 {key} — Q 로 선 줄이 아님")
continue
value = solved.get(key)
if not isinstance(value, Decimal):
build.edit_skipped.append(f"Q 식 {key}{value or '식이 비었음'}")
continue
detail = details[index]
details[index] = replace(
detail,
quantity=detail.quantity * detail.output / value,
output=value,
note=f"{detail.note} · Q(사용자) = {text} = {value}",
)
_mark(build, code, index, q_formula=text, was_output=str(detail.output))
def _apply_rows(build: UnitPriceBuild, values: dict[str, Any]) -> None:
added: list[tuple[str, int, str, Any]] = []
for key, value in values.items():
code, tail = split_key(key)
details = build.book.details.get(code)
title = build.book.titles.get(code)
if details is None or title is None or title.kind not in EDITABLE_KINDS:
build.edit_skipped.append(f"구성행 {key} — 고칠 수 있는 본표가 아님")
continue
if tail.startswith("+") and tail[1:].isdigit():
added.append((code, int(tail[1:]), key, value))
continue
index = int(tail) if tail.isdigit() else -1
if not 0 <= index < len(details) or not isinstance(value, dict):
build.edit_skipped.append(f"구성행 {key} — 줄이 없음")
continue
detail = details[index]
if value.get("removed"):
details[index] = replace(detail, quantity=Decimal(0))
_mark(build, code, index, removed=True, was_quantity=str(detail.quantity), user=True)
continue
try:
quantity = _quantity(value.get("quantity"))
except EditError as error:
build.edit_skipped.append(f"구성행 {key}{error}")
continue
details[index] = replace(detail, quantity=quantity)
_mark(build, code, index, was_quantity=str(detail.quantity), user=True)
for code, _number, key, value in sorted(added):
ref = str((value or {}).get("ref") or "")
if ref not in build.book.titles or ref == code:
build.edit_skipped.append(f"줄 더하기 {key} — 단가표에 없는 코드 {ref}")
continue
try:
quantity = _quantity((value or {}).get("quantity"))
except EditError as error:
build.edit_skipped.append(f"줄 더하기 {key}{error}")
continue
details = build.book.details[code]
details.append(PriceDetail(code, ref, quantity, note="사용자가 더한 줄"))
_mark(build, code, len(details) - 1, key=key, added=True, user=True)
def apply_edits(build: UnitPriceBuild, edits: dict[str, dict[str, Any]]) -> UnitPriceBuild:
"""고친 값을 얹은 **새 벌** — 기본 벌(캐시)은 건드리지 않음. 차례: 채택 → Q 식 → 구성행."""
edited = copy.deepcopy(build)
edited.edit_skipped = []
edited.edit_rows = {}
_apply_adopted_slots(edited, edits.get("adopted_slots", {}))
_apply_q(edited, edits.get("price_basis_q", {}))
_apply_rows(edited, edits.get("sheet_rows", {}))
return edited
@lru_cache(maxsize=8)
def edited_build(args: tuple, key: str) -> UnitPriceBuild:
"""프로젝트 조립 한 벌 — `args` 는 `cached_build` 인자 그대로, `key` 는 `edits_key`."""
base = cached_build(*args)
if not key:
return base
return apply_edits(base, json.loads(key))
def row_edits(build: UnitPriceBuild, code: str) -> list[dict[str, Any]]:
"""본표 줄마다 고친 값 표시 — 줄 차례는 상세 줄 차례와 같음(한 줄에 한 칸)."""
marks = getattr(build, "edit_rows", {}).get(code, {})
details = build.book.details.get(code, [])
return [
{"key": f"{code}|{index}", "user": False, **marks.get(index, {})}
| ({"output": str(detail.output)} if detail.output is not None else {})
for index, detail in enumerate(details)
]
def validate_change(build: UnitPriceBuild, section: str, key: str, value: Any) -> Any:
"""고친 값 하나를 받기 전 검사 — 받을 수 있는 모양으로 돌려주거나 `EditError`."""
if section not in SECTIONS:
raise EditError(f"모르는 구획: {section}")
if section == "adopted_slots":
title = build.book.titles.get(key)
if title is None or title.kind is not PriceKind.MATERIAL:
raise EditError(f"단가표에 없는 자재입니다: {key}")
number = int(value) if str(value).isdigit() else 0
if not 1 <= number <= PRICE_SLOT_COUNT or title.slots[number - 1] is None:
raise EditError(f"{title.name}: {value}번 원천에 값이 없어 채택할 수 없습니다")
return number
if section == "bill_rates":
from B09_Estimation.B09_Estimation_BillRates import validate_rate
try:
return validate_rate(value)
except ValueError as error:
raise EditError(str(error)) from error
code, tail = split_key(key)
title = build.book.titles.get(code)
if title is None or title.kind not in EDITABLE_KINDS or not isinstance(value, dict):
raise EditError(f"고칠 수 있는 본표가 아닙니다: {code}")
if section == "price_basis_q":
text = str(value.get("q_formula") or "").strip()
details = build.book.details.get(code, [])
index = int(tail) if tail.isdigit() else -1
if not 0 <= index < len(details) or details[index].output is None:
raise EditError("Q 로 선 줄이 아니라 Q 식을 고칠 수 없습니다")
solved = evaluate_q({key: text}).get(key)
if not isinstance(solved, Decimal):
raise EditError(f"Q 식을 풀지 못했습니다: {solved}")
return {"q_formula": text}
if tail.startswith("+"):
ref = str(value.get("ref") or "")
ref_title = build.book.titles.get(ref)
if ref_title is None or ref_title.kind not in ADDABLE_KINDS or ref == code:
raise EditError(f"더할 수 없는 코드입니다: {ref}")
checked = {"ref": ref, "quantity": str(_quantity(value.get("quantity")))}
_check_cycle(build, code, ref)
return checked
details = build.book.details.get(code, [])
index = int(tail) if tail.isdigit() else -1
if not 0 <= index < len(details):
raise EditError(f"그 본표에 없는 줄입니다: {key}")
detail = details[index]
if (
detail.percent_of_labor is not None
or detail.percent_of_material is not None
or detail.percent_of_parent is not None
):
raise EditError(
"비율 줄(제잡비·공구손료·잡품)은 수량으로 고치지 않습니다 — 산출 조건에서 고칩니다"
)
if value.get("removed"):
return {"removed": True}
return {"quantity": str(_quantity(value.get("quantity")))}
def _check_cycle(build: UnitPriceBuild, code: str, ref: str) -> None:
"""더한 줄이 돌림 참조를 만들면 거절 — 그 본표를 시험 삼아 풀어 봄."""
trial = copy.deepcopy(build.book)
trial.details.setdefault(code, []).append(PriceDetail(code, ref, Decimal(1)))
try:
trial.resolve(code)
except PriceBookError as error:
raise EditError(f"그 줄을 더하면 단가를 풀 수 없습니다: {error}") from error
def merge_changes(
stored: Any, build: UnitPriceBuild, changes: list[dict[str, Any]]
) -> dict[str, dict[str, Any]]:
"""바꿀 것 여럿을 한 번에 — `value` 가 `None` 이면 그 칸을 지움(↺ 계산값으로).
더한 줄의 수량만 고칠 (`ref` 없이) 저장된 `ref` 이어 .
"""
edits = normalize(stored)
for change in changes:
section, key = str(change.get("section") or ""), str(change.get("key") or "")
if not key:
raise EditError("고칠 칸이 비었습니다")
if change.get("value") is None:
edits.get(section, {}).pop(key, None)
continue
value = change["value"]
previous = edits.get(section, {}).get(key)
if isinstance(value, dict) and isinstance(previous, dict) and "ref" in previous:
value = {**previous, **value}
edits.setdefault(section, {})[key] = validate_change(build, section, key, value)
return normalize(edits)
+181 -26
View File
@@ -18,7 +18,17 @@ from __future__ import annotations
from dataclasses import dataclass, field, replace
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from typing import Any
from B09_Estimation.B09_Estimation_Engine_Cost_Options import (
CUT_BASES,
VAT_MODES,
cut_gap,
overhead_base,
profit_cut,
vat_base,
)
from B09_Estimation.B09_Estimation_RateOverride import apply_overrides
from B09_Estimation.B09_Estimation_Rates import (
RateDataset,
flat_rate,
@@ -87,11 +97,64 @@ class CostInput:
#: 이윤 수동 조정액 — 설계자 명시 입력일 때만. 프로그램이 스스로 채우지 않는다.
profit_adjustment_krw: Decimal = _ZERO
#: 조정액 줄 산식 칸 — 비면 「설계자 명시 입력」. 절사 자동보정이 제 이름을 적는 자리.
profit_adjustment_label: str = ""
#: 폐기물처리비 — 요율이 아니라 **실비**. 총원가 밖, 관급자재대와 나란히.
#: TODO(미결 PLAN 8-14·9-6): 자리 확정 대기 (사용자 「실무자 확인 후 재공유」).
#: 실무 근거는 거창 원가계산서의 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 한 줄뿐.
#: 폐기물처리비 — 요율이 아니라 **실비**(수량 × 처리단가).
#: ⭐ 2026-09-14 판정 — **비목은 경비**(예정가격작성기준 제19조③18호)라 순공사원가에 들고
#: **일반관리비·이윤 밑수에 든다.** 빠지면 총액이 조용히 작아진다.
#: ⚠ 법정경비 밑수(직접공사비·노무비)에는 안 넣는다 — 그 밑수는 요율 데이터가 정한 합이다.
waste_disposal_krw: Decimal = _ZERO
#: 분리발주 — 켜면 공사와 따로 발주하는 용역이라 **총원가 밖**, 총공사비에만 더한다
#: (거창 원가계산서 `총공사비 = 도급액 + 관급자재대 + 폐기물처리비` 모양). 기본 꺼짐.
waste_separate_order: bool = False
#: 폐기물처리비 자리 — `expense`(기본 · 법 문언: 경비 · 일반관리비·이윤 밑수 안) ·
#: `after_profit`(실무 관행: 이윤 뒤 · 총원가 안 · 부가세 안 · 승률 밖 — 울진소광·실정보고).
#: 분리발주(`waste_separate_order`)가 켜지면 그쪽이 먼저(총원가 밖).
waste_placement: str = "expense"
#: ── 기준 입력 10·11·12 (규칙은 `Engine_Cost_Options`) — 기본값은 종전 계산과 같음 ──
#: 원가계산 형식 — 일반관리비 밑수가 형식마다 다름(지금은 `general` 만).
form: str = "general"
#: 10. 일반관리비 — 요율 표 이름((주)공사 / 전문공사).
overhead_class: str = "civil_landscape_industrial"
#: 일반 형식 일반관리비 밑수에 더하는 관리품목 자재대.
overhead_managed_material_krw: Decimal = _ZERO
#: 12. 부가세 방식(`VAT_MODES`) · 산림조합-면세품이면 면세품 금액.
vat_mode: str = "supply"
tax_exempt_material_krw: Decimal = _ZERO
#: 11. 절사 — `grand_total`(총공사비에서 조정) · `supply`(공급가액) · `none`·빈 값은 안 자름.
#: ⭐ 2026-09-14 **기본이 켜짐** — 근거 산림청고시 제2025-82호 「금액의 단위표준」
#: 「설계서의 총액 · 원 · 1,000 · 미만버림」.
#: 실무 6건이 여섯 다 `000` 으로 끝나는 것이 증거.
#: ⚠ 2026-09-08 「조용히 깎지 않음」 합의의 정신은 살린다 — 깎은 몫과
#: **까닭(고시 번호)** 을 이윤 조정액 줄과 결과 메모에 적고,
#: 설계자가 `none` 으로 **끌 수 있게** 둔다.
cut_basis: str = "grand_total"
cut_unit_krw: int = 1000
#: 4. 고용보험 등급 — `auto`(추정금액 구간) · `none`(없음) · `1`~`7`(등급 직접).
employment_insurance_grade: str = "auto"
#: 5. 퇴직공제부금비 — `auto`(추정금액 1억 이상) · `apply`(적용) · `none`(미적용).
#: ⚠ STmate 는 토목·준설·건축·기타로 가르나 현행 제비율(2026-04-13)은 공종 구분 없이 2.3% —
#: 율이 안 갈리는 구분은 칸으로 안 세움.
retirement_mutual_aid_mode: str = "auto"
#: 13. 사급비 위치 — `material`(재료비에 포함) 뿐. 사급은 이미 내역 재료비에 들어 오므로
#: 계산에 안 씀(실무 거창·영월 · STmate 기본). 다른 자리는 사급 금액 칸이 설 때 엶.
private_material_position: str = "material"
#: 14. 낙찰방식 — `not_comprehensive`(종합심사 외) · `comprehensive`(종합심사 대상) · `turnkey`.
#: 하도급대금 지급보증 요율 줄만 고름.
bid_method: str = "not_comprehensive"
#: 하도급대금 지급보증수수료 — `off`(기본) · `on`. 실무 여섯 건에 줄 없음 · 건설산업기본법
#: 제34조 대상이면 켬(「대상 아님」인지 「안 적음」인지는 실무로 못 가름).
subcontract_guarantee: str = "off"
#: 15. 이행보증 — `general`(일반계약: 추정가격 300억 이상만) ·
#: `lowest_price_tech`(최저가·기술제안: 규모 무관).
performance_guarantee_mode: str = "general"
#: 16. 적용기준 — `national`·`local`·`moi`. 계산에 안 씀: 현행 조달청 표 한 벌뿐 · 국가/지방
#: 구간 차이(예정가격작성기준 제20조 표)는 원문 이미지라 미확인 · 행자부 표 없음. 칸만 받음.
contract_law_basis: str = ""
#: 프로젝트 요율 덮어쓰기(`RateOverride` 한 줄씩, 사유 포함) — 발주처별 별도요율.
rate_overrides: tuple[dict[str, Any], ...] = ()
#: 환경보전비 공종 (`rate_environment.all_work_types` 의 값).
#: TODO(미결 PLAN 9-6): 임도가 「도로 0.9 %」인지 「기타 토목 0.8 %」인지 미확정.
@@ -168,8 +231,11 @@ class CostResult:
def _load_dataset(data: CostInput) -> RateDataset:
if data.rate_file_path:
return load_rate_dataset_from_path(data.rate_file_path)
return load_rate_dataset(data.rate_file_name)
dataset = load_rate_dataset_from_path(data.rate_file_path)
else:
dataset = load_rate_dataset(data.rate_file_name)
# 프로젝트 요율 덮어쓰기 — 복사본에만 얹음(마스터 파일은 그대로).
return apply_overrides(dataset, data.rate_overrides)
def _emitter(result: CostResult):
@@ -211,14 +277,16 @@ def _emitter(result: CostResult):
_SCALE_MAX_PASSES = 5
def _scale_signature(dataset: RateDataset, amount: Decimal) -> tuple:
def _scale_signature(
dataset: RateDataset, amount: Decimal, overhead_class: str = "civil_landscape_industrial"
) -> tuple:
"""이 금액이 어느 구간들에 떨어지는가 — 구간이 바뀌었는지 판정하는 지문.
규모(추정가격) 갈리는 요율만 모은다. 지문이 같으면 필요가 없다.
"""
parts: list[str] = []
for variable, bracket_field, key in (
("rate_overhead", "estimated_price_bracket", "civil_landscape_industrial"),
("rate_overhead", "estimated_price_bracket", overhead_class),
("rate_profit", "estimated_price_bracket", "brackets"),
("rate_goyong", "estimated_amount_bracket", "brackets"),
("rate_subcontract_payment_guarantee", "estimated_price_bracket", "brackets"),
@@ -276,7 +344,7 @@ def calculate_cost(data: CostInput) -> CostResult:
tried_amounts: list[Decimal] = []
for _ in range(_SCALE_MAX_PASSES):
signature = _scale_signature(dataset, scale)
signature = _scale_signature(dataset, scale, data.overhead_class)
if signature in seen_signatures:
# 구간이 진동한다 — 보수적으로 **높은 쪽**을 잡고 그 사실을 남긴다.
highest = max([*tried_amounts, scale])
@@ -288,7 +356,7 @@ def calculate_cost(data: CostInput) -> CostResult:
trial = _calculate_with_scale(data, dataset, scale, [])
estimated_price = trial.totals["total_cost"]
if _scale_signature(dataset, estimated_price) == signature:
if _scale_signature(dataset, estimated_price, data.overhead_class) == signature:
return trial
scale = estimated_price
@@ -297,14 +365,77 @@ def calculate_cost(data: CostInput) -> CostResult:
)
#: 절사 뒤 잔차 맞춤 반복 상한. 실무 6건은 **두 걸음 안에** 앉음
#: (다섯 건 한 걸음 · 울진 신설 두 걸음).
_CUT_MAX_PASSES = 3
#: 11. 절사 끔 — 고르개가 보내는 값. 빈 값도 같이 받는다(저장 안 된 옛 프로젝트).
CUT_OFF = ("", "none")
def _calculate_with_scale(
data: CostInput,
dataset: RateDataset,
scale: Decimal,
notes: list[str],
) -> CostResult:
"""규모 기준액을 못 박고 계산 — 11. 절사가 켜져 있으면 이윤을 보정해 다시 셈.
**걸음마다 ÷1.1 해야 한다**(2026-09-14 고침). 이윤을 1 깎으면 부가세가 따라 줄어
총공사비는 **1.1** 줄므로, 잔차를 그대로 빼면 경계를 **지나쳐** 진동한다
울진 신설 실측 `654 999 900 910 909 ` 앉았다. ÷1.1 하면 `654 999 0`.
"""
result = _calculate_once(data, dataset, scale, notes)
if data.cut_basis in CUT_OFF or data.cut_unit_krw <= 0:
return result
key = "grand_total" if data.cut_basis == "grand_total" else "total_cost"
automatic = _ZERO
gap = _ZERO
for _ in range(_CUT_MAX_PASSES):
gap = cut_gap(result.totals[key], data.cut_unit_krw)
if gap == 0:
break
# 실무 식 — 총공사비 절사 + 부가세가 공급가액 비례면 ÷1.1(잔차 걸음도 같음).
automatic += profit_cut(gap, data.cut_basis, data.vat_mode)
adjusted = replace(
data,
profit_adjustment_krw=data.profit_adjustment_krw + automatic,
cut_basis="none",
profit_adjustment_label=(
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만"
" 절사 자동보정(산림청고시 2025-82호)"
+ (" + 설계자 입력" if data.profit_adjustment_krw else "")
),
)
result = _calculate_once(adjusted, dataset, scale, notes)
result.notes.append(
f"{CUT_BASES.get(data.cut_basis, data.cut_basis)} {data.cut_unit_krw:,}원 미만 절사 —"
f" 이윤에서 {automatic:,}원 자동보정."
" 근거: 산림청고시 제2025-82호 「금액의 단위표준」(설계서의 총액 1,000원 미만버림) ·"
" 기준 입력판 11 에서 끌 수 있음"
)
if gap:
# 조용히 남기지 않는다 — 안 앉았으면 끝자리가 남았다는 것을 화면에 보인다.
result.notes.append(
f"⚠ 절사가 {_CUT_MAX_PASSES}걸음 안에 안 앉음 —"
f" 끝자리 {gap:,}원이 남음(설계자 확인 필요)"
)
return result
def _calculate_once(
data: CostInput,
dataset: RateDataset,
scale: Decimal,
notes: list[str],
) -> CostResult:
"""규모 기준액을 못 박고 한 번 계산한다."""
result = CostResult(rate_version=dataset.version_stamp, notes=list(notes))
if data.rate_overrides:
# 판 지문은 마스터 그대로 — 덮어쓴 칸 수·사유를 따로 남겨 「왜 다른지」가 보이게.
result.notes.append(
f"프로젝트 요율 덮어쓰기 {len(data.rate_overrides)}건 — "
+ " · ".join(str(o.get("reason") or "") for o in data.rate_overrides)
)
emit = _emitter(result)
if data.enabled_items == DEFAULT_ITEMS:
@@ -353,12 +484,17 @@ def _calculate_with_scale(
scale_reference=scale,
)
statutory = statutory_expenses(dataset, data, ctx, result, emit)
# 폐기물처리비 — 분리발주가 아니면 경비 한 줄. 법정경비 **뒤**에 둬 그 밑수에 안 섞인다.
after_profit = not data.waste_separate_order and data.waste_placement == "after_profit"
waste_in_expense = (
_ZERO if data.waste_separate_order or after_profit else _waste_line(data, emit)
)
expense_total = data.direct_expense_krw + statutory
expense_total = data.direct_expense_krw + statutory + waste_in_expense
emit(
key="expense",
name="경비",
base_label="직접경비(산출경비)+법정경비",
base_label="직접경비(산출경비)+법정경비" + ("+폐기물처리비" if waste_in_expense else ""),
base=expense_total,
amount=expense_total,
)
@@ -372,36 +508,48 @@ def _calculate_with_scale(
amount=net_construction_cost,
)
# 10. (주)공사/전문공사 = 요율 구간표만 갈림 · 밑수는 형식이 정함
# (일반 = 순공사원가 + 관리품목자재대).
overhead_row = select_bracket(
dataset.variable("rate_overhead")["civil_landscape_industrial"],
dataset.variable("rate_overhead")[data.overhead_class],
amount_field="estimated_price_bracket",
amount=ctx.scale_reference,
label="일반관리비",
)
managed = data.overhead_managed_material_krw
overhead = emit(
key="general_overhead",
name="일반관리비",
base_label="순공사원가",
base=net_construction_cost,
base_label="순공사원가+관리품목자재대" if managed else "순공사원가",
base=overhead_base(data.form, net_construction_cost, managed),
percent=rate_percent(overhead_row, label="일반관리비"),
)
profit = _profit_lines(dataset, data, emit, ctx, net_construction_cost, overhead)
total_cost = net_construction_cost + overhead + profit
# 실무 관행 자리 — 이윤 뒤 · 총원가 안(부가세 밑수에 듦) · 일반관리비·이윤 밑수 밖.
waste_after_profit = _waste_line(data, emit) if after_profit else _ZERO
total_cost = net_construction_cost + overhead + profit + waste_after_profit
emit(
key="total_cost",
name="총원가",
base_label="순공사원가+일반관리비+이윤",
base_label="순공사원가+일반관리비+이윤" + ("+폐기물처리비" if waste_after_profit else ""),
base=total_cost,
amount=total_cost,
)
# 12. 부가세 방식 — 밑수만 갈림(공급가액·재료비·없음·산림조합 둘). 율은 요율 데이터.
vat = emit(
key="vat",
name="부가가치세",
base_label="총원가",
base=total_cost,
percent=flat_rate(dataset, "rate_vat"),
base_label="총원가" if data.vat_mode == "supply" else VAT_MODES[data.vat_mode],
base=vat_base(
data.vat_mode,
total_cost,
material_cost,
data.direct_expense_krw,
data.tax_exempt_material_krw,
),
percent=_ZERO if data.vat_mode == "none" else flat_rate(dataset, "rate_vat"),
)
contract_amount = total_cost + vat
emit(
@@ -413,13 +561,14 @@ def _calculate_with_scale(
)
owner_total = _owner_supplied_line(data, emit)
waste = _waste_line(data, emit)
# 분리발주면 공사 원가가 아니다 — 총원가 밖에 서고 총공사비에만 더한다.
waste = _waste_line(data, emit) if data.waste_separate_order else _ZERO
grand_total = contract_amount + owner_total + waste
emit(
key="grand_total",
name="총공사비",
base_label="도급공사비+관급자재대" + ("+폐기물처리비" if waste else ""),
base_label="도급공사비+관급자재대" + ("+폐기물처리비(분리발주)" if waste else ""),
base=grand_total,
amount=grand_total,
)
@@ -436,7 +585,7 @@ def _calculate_with_scale(
"vat": vat,
"contract_amount": contract_amount,
"owner_supplied_material_total": owner_total,
"waste_disposal": waste,
"waste_disposal": waste_in_expense + waste_after_profit + waste,
"grand_total": grand_total,
}
return result
@@ -470,7 +619,7 @@ def _profit_lines(
emit(
key="profit_adjustment",
name="이윤 조정액",
base_label="설계자 명시 입력",
base_label=data.profit_adjustment_label or "설계자 명시 입력",
base=_ZERO,
amount=-data.profit_adjustment_krw,
note="도급공사비 끝수 맞춤 — 법정 항목 아님 (★법대로 8-10)",
@@ -508,16 +657,22 @@ def _owner_supplied_line(data: CostInput, emit) -> Decimal:
def _waste_line(data: CostInput, emit) -> Decimal:
"""폐기물처리비 — 요율이 아니라 실비. 설계자 입력이 있을 때만 줄이 선다."""
"""폐기물처리비 — 요율이 아니라 실비. 금액이 있을 때만 줄이 선다."""
if not data.waste_disposal_krw:
return _ZERO
return emit(
key="waste_disposal",
name="폐기물처리비",
base_label="설계자 입력(실비)",
base_label="수량 × 처리단가(실비)",
base=data.waste_disposal_krw,
amount=floor_won(data.waste_disposal_krw),
note="⚠ 자리 미확정 — 실무 관측 한 줄이 유일한 근거 (PLAN 8-14)",
note=(
"분리발주 — 총원가 밖 별도 표기"
if data.waste_separate_order
else "실무 관행 자리 — 이윤 뒤 · 총원가 안 · 일반관리비·이윤 밑수 밖(설계자 선택)"
if data.waste_placement == "after_profit"
else "경비(예정가격작성기준 제19조③18호) — 일반관리비·이윤 밑수에 듦"
),
)
@@ -0,0 +1,94 @@
"""원가계산서 기준 입력 10·11·12 — 일반관리비 주/전문 · 절사 · 부가세 방식 (PLAN 6장 새 절).
`Engine_Cost` 700줄에 가까워 **고르는 규칙만** 여기 둔다. 계산 사슬은 엔진 그대로.
근거(2026-09-14 조사 STmate `TWM_KANJUB` 힌트 · 실무 엑셀 수식)
10 일반관리비 식은 그대로, **요율 구간표만** 주공사/전문공사로 갈림
(KJ 전문공사: 1/10 규모). 밑수는 **형식마다** 다름(35 §1) 일반 T 순공사원가 +
관리품목자재대.
11 절사 절사된 금액은 이윤에서 자동보정(KJ 힌트). 실무 봉화·영월:
차액 = 총공사비 천원 미만 버림 · 이윤 ROUND(차액 ÷ 1.1) 잔차
(부가세가 공급가액 비례라 ÷1.1).
영덕(산림조합-면세품): 차액 그대로 (부가세가 이윤과 무관).
12 부가세 공급가액의 10% / 재료비의 10% / 없음 / 산림조합 형식 (재료비 + 산출경비 10%) /
산림조합-면세품 (재료비-면세품 + 산출경비의 10%)(KJ 힌트). 기성 단계는 선택지가 달라
(직접입력 · 공급가액 · 재료비 · 재료비+산출경비) 이름을 문자열로 두어 늘릴 있게 .
기본값은 **전부 종전과 같게** 공급가액 10% · 주공사 · 절사 없음(수동 이윤보정만).
자동 이윤보정은 설계자가 절사 기준을 **고른 때만** 돈다(2026-09-08 조용히 깎지 않음).
"""
from __future__ import annotations
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal
_ZERO = Decimal(0)
# ── 10 일반관리비 ─────────────────────────────────────────────────────────
#: 요율 데이터 `rate_overhead` 의 표 이름 — (주)공사 / 전문공사.
OVERHEAD_CLASSES = {
"civil_landscape_industrial": "(주)공사",
"specialty_electric_communication_fire_other": "전문공사",
}
DEFAULT_OVERHEAD_CLASS = "civil_landscape_industrial"
#: 형식별 일반관리비 밑수 — 지금은 일반(T)만. 나머지 셋은 형식이 설 때 한 줄씩.
OVERHEAD_BASE_LABELS = {"general": "순공사원가+관리품목자재대"}
def overhead_base(form: str, net_construction_cost: Decimal, managed_material: Decimal) -> Decimal:
"""형식별 일반관리비 밑수. 모르는 형식은 멈춤 — 다른 형식 밑수로 조용히 세지 않음."""
if form == "general":
return net_construction_cost + managed_material
raise ValueError(f"일반관리비 밑수가 아직 없는 형식입니다: {form}")
# ── 12 부가가치세 ─────────────────────────────────────────────────────────
VAT_MODES = {
"supply": "공급가액",
"material": "재료비",
"none": "없음",
"forest_coop": "재료비+산출경비",
"forest_coop_exempt": "재료비−면세품+산출경비",
}
DEFAULT_VAT_MODE = "supply"
def vat_base(
mode: str,
total_cost: Decimal,
material_cost: Decimal,
direct_expense: Decimal,
tax_exempt_material: Decimal,
) -> Decimal:
"""부가세 밑수. 「없음」은 0. 모르는 방식은 멈춤."""
if mode == "supply":
return total_cost
if mode == "material":
return material_cost
if mode == "none":
return _ZERO
if mode == "forest_coop":
return material_cost + direct_expense
if mode == "forest_coop_exempt":
return material_cost - tax_exempt_material + direct_expense
raise ValueError(f"모르는 부가세 방식입니다: {mode}")
# ── 11 절사 ───────────────────────────────────────────────────────────────
#: 절사 기준 — `grand_total` 총공사비에서 조정 · `supply` 공급가액 자체를 자름. 빈 값 = 안 자름.
CUT_BASES = {"grand_total": "총공사비", "supply": "공급가액"}
CUT_UNITS_KRW = (10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000)
def cut_gap(amount: Decimal, unit: int) -> Decimal:
"""`unit` 원 미만 버림으로 떨어지는 몫."""
unit_decimal = Decimal(unit)
kept = (amount / unit_decimal).quantize(Decimal(1), rounding=ROUND_FLOOR) * unit_decimal
return amount - kept
def profit_cut(gap: Decimal, basis: str, vat_mode: str) -> Decimal:
"""이윤에서 뺄 몫. 총공사비 절사 + 부가세가 공급가액 비례면 ÷1.1 반올림(실무 봉화 373→339)."""
if basis == "grand_total" and vat_mode == "supply":
return (gap / Decimal("1.1")).quantize(Decimal(1), rounding=ROUND_HALF_UP)
return gap
+286
View File
@@ -0,0 +1,286 @@
"""B09 실행예산 단계 — 설계 내역 → 실행예산 (PLAN 12장 「설계 뒤 네 단계」 · 2026-09-14 배정).
근거: STmate 분석 `27_계약_실행_기성_단계.md` §4 (`wM_Boq_ExecX` 시간/단위당 중기실행단가 계산
입력) · `35_형식과_단계의_공통과_차이.md` §3 · 원자료 `ui_form_catalog.txt` 65~67.
· 설계 중기사용료와 **별도로** 1단위당 중기사용료(/단위) · 1단위 = 시간
· 최초단가(노무비·재료비·경비) 실행단가 · 절사 1 ~ 100,000 미만
· 차액 보정 : 기본보정 / 노무비 / 재료비 / 경비
· 최초수량 실행수량
**설계·계약을 건드린다** 설계 단가표를 **복사해** 중기(X) 호표만 실행단가로 갈아 끼우고
일위대가를 다시 조립한다. 설계 내역·원가계산서·골든셋은 그대로다.
**값이 맞다가 아니라 구조가 선다까지** 실행예산 표본 0(27 §9 · 35 중기 화면 구조
확인 · 전체 실행예산 실제 미확인). 아래 둘은 **구조로 읽은 **이라 확인 대기:
실행단가 성분 = 시간당 실행단가 × 설계 성분 비율(최초단가 실행단가 줄에서 읽음)
절사는 시간당 성분마다 · 차액 = 절사한 시간당 합계 성분
"""
from __future__ import annotations
import copy
from decimal import ROUND_FLOOR, Decimal, InvalidOperation
from typing import Any
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line
from B09_Estimation.B09_Estimation_Contract import _group_sums, _money, _totals, reassembled_unit
from B09_Estimation.B09_Estimation_PriceBook import (
DEFAULT_ADOPTED_SLOT,
PRICE_SLOT_COUNT,
Money3,
PriceBook,
PriceBookError,
PriceDetail,
PriceKind,
PriceTitle,
)
_ZERO = Decimal(0)
#: 저장 자리 — `estimation` 구획 안 한 칸.
SETTINGS_KEY = "execution"
#: 절사 기준 — `wCm_Jeol` 표기 차례(원 미만 절사 단위).
CUT_UNITS: tuple[str, ...] = ("1", "10", "100", "1000", "10000", "100000")
#: 차액 보정 — `wCm_OPT` 표기 차례.
CORRECTIONS: tuple[tuple[str, str], ...] = (
("basic", "기본보정"),
("labor", "노무비"),
("material", "재료비"),
("expense", "경비"),
)
#: 고를 때 무엇이 다른지 — 화면에 그대로 적음(2026-09-14 브레인 지시).
#: ⚠ 「기본보정」은 표본이 오면 고칠 자리 — 지금 뜻은 지어낸 것이 아니라 「안 몰고 남김」.
CORRECTION_HINTS = {
"basic": "차액을 어느 비목에도 안 몰고 남김(뜻 확인 대기)",
"labor": "차액을 노무비에 더함",
"material": "차액을 재료비에 더함",
"expense": "차액을 경비에 더함",
}
#: 뜻이 확인 안 된 보정 — 조용히 한쪽에 몰지 않고 차액을 그대로 보임.
CORRECTION_NOT_KNOWN = {
"basic": "「기본보정」의 뜻이 분석 자료에서 확인 안 됨(27번 §4) — 차액을 안 몰고 그대로 보임",
}
#: 실행단가를 받치는 기초단가 줄 — 성분마다 한 줄(자재 = 재료 · 노임 = 노무 · 중기 취득가 = 경비).
_PART_KIND = (
("material", PriceKind.MATERIAL, "M"),
("labor", PriceKind.LABOR, "L"),
("expense", PriceKind.MACHINE_BASE, "S"),
)
_PART_LABEL = {"material": "재료비", "labor": "노무비", "expense": "경비"}
def _number(value: Any) -> Decimal | None:
try:
number = Decimal(str(value))
except (InvalidOperation, ValueError):
return None
return number if number.is_finite() else None
def clean_settings(values: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""저장값과 거른 까닭. 사용료가 빈 중기는 설계 사용료 그대로 · 수량이 빈 줄은 설계 수량."""
errors: list[str] = []
machines: dict[str, dict[str, str]] = {}
for code, entry in (values.get("machines") or {}).items():
entry = entry or {}
if entry.get("unit_price_krw") in (None, ""):
continue
price = _number(entry.get("unit_price_krw"))
hours = _number(entry.get("hours_per_unit"))
if price is None or price < 0:
errors.append(
f"{code} 1단위당 중기사용료가 0 이상 수가 아님 — {entry.get('unit_price_krw')}"
)
continue
if hours is None or hours <= 0:
errors.append(
f"{code} 「1단위 = 시간」이 0 보다 큰 수가 아님 — {entry.get('hours_per_unit')}"
)
continue
cut = str(entry.get("cut_unit_krw") or "1")
correction = str(entry.get("correction") or "basic")
machines[str(code)] = {
"unit_price_krw": str(price),
"hours_per_unit": str(hours),
"cut_unit_krw": cut if cut in CUT_UNITS else "1",
"correction": correction if correction in dict(CORRECTIONS) else "basic",
}
quantities: dict[str, str] = {}
for item_no, raw in (values.get("quantities") or {}).items():
if raw in (None, ""):
continue
qty = _number(raw)
if qty is None or qty < 0:
errors.append(f"{item_no} 실행수량이 0 이상 수가 아님 — {raw}")
continue
quantities[str(item_no)] = str(qty)
return {"machines": machines, "quantities": quantities}, errors
def execution_hourly(
design: Money3, unit_price: Decimal, hours: Decimal, cut: Decimal, correction: str
) -> tuple[Money3 | None, Decimal, str]:
"""시간당 실행단가(성분) · 차액 · 까닭. 못 가르면 (None, 0, 까닭).
시간당 실행단가 = 1단위당 중기사용료 ÷ 1단위 시간 설계 성분 비율로 가름 성분마다 절사
차액(절사한 합계 성분 ) 고른 비목에 더함. 기본보정 미확인이라 차액을 남김.
"""
if design.total <= 0:
return None, _ZERO, "설계 중기사용료 성분 합이 0 — 비율로 못 가름"
per_hour = unit_price / hours
parts = {
part: (per_hour * getattr(design, part) / design.total / cut).to_integral_value(
rounding=ROUND_FLOOR
)
* cut
for part, _, _ in _PART_KIND
}
target = (per_hour / cut).to_integral_value(rounding=ROUND_FLOOR) * cut
diff = target - sum(parts.values(), _ZERO)
note = ""
if diff and correction in parts:
parts[correction] += diff
note = f"차액 {diff}원 → {dict(CORRECTIONS)[correction]}"
elif diff:
note = f"차액 {diff}원 남음 — {CORRECTION_NOT_KNOWN['basic']}"
return Money3(**parts), diff, note
def execution_book(book: PriceBook, hourly: dict[str, Money3]) -> PriceBook:
"""중기(X) 호표를 실행단가로 갈아 끼운 **복사본** — 원본 단가표(캐시 공유)는 안 건드림.
X 상세 줄을 비우고 성분마다 기초단가 (수량 1) 달아 일위대가가 설계와 같은
규칙(`PriceBook.resolve`)으로 다시 조립되게 .
"""
copied = copy.deepcopy(book)
for code, money in hourly.items():
copied.details[code] = []
for part, kind, prefix in _PART_KIND:
slots: list[Decimal | None] = [None] * PRICE_SLOT_COUNT
slots[DEFAULT_ADOPTED_SLOT - 1] = getattr(money, part)
ref = f"{prefix}-실행-{code}"
copied.titles[ref] = PriceTitle(
code=ref, kind=kind, name=f"중기실행단가 {_PART_LABEL[part]}", slots=slots
)
copied.add_detail(PriceDetail(code, ref, Decimal(1), note="실행예산 중기실행단가"))
return copied
def machines_under(book: PriceBook, codes: list[str]) -> dict[str, set[str]]:
"""내역 단가코드 → 그 아래 중기(X) 코드들 — X 안으로는 안 내려감."""
found: dict[str, set[str]] = {}
def walk(code: str, seen: tuple[str, ...]) -> set[str]:
if code in found:
return found[code]
title = book.titles.get(code)
if title is None or code in seen:
return set()
if title.kind is PriceKind.MACHINE_HOURLY:
return {code}
result: set[str] = set()
for detail in book.details.get(code, []):
if detail.ref_code != code:
result |= walk(detail.ref_code, (*seen, code))
found[code] = result
return result
return {code: walk(code, ()) for code in codes}
def execution_bill(
bill_rows: list[dict[str, Any]],
settings: dict[str, Any],
build: Any = None,
) -> dict[str, Any]:
"""설계 내역 줄 → 실행예산 줄 · 중기 실행단가 표 · 합계.
`build` = 설계 일위대가 조립본(캐시 공유본이라 ** 고침**) 없으면 중기 없이 수량만.
"""
entries = settings.get("machines") or {}
quantities = settings.get("quantities") or {}
book = build.book if build is not None else None
codes = [str(row.get("price_code") or "") for row in bill_rows if not row.get("is_group")]
under = machines_under(book, codes) if book is not None else {}
machines: list[dict[str, Any]] = []
hourly: dict[str, Money3] = {}
for code in sorted(set().union(*under.values())):
title = book.titles[code]
item: dict[str, Any] = {"code": code, "name": title.name, "spec": title.spec}
try:
design = book.resolve(code)
except PriceBookError as error:
machines.append({**item, "note": f"설계 중기사용료가 안 섬({error})"})
continue
item.update(design=_totals(design), **entries.get(code, {}))
entry = entries.get(code)
if entry:
money, diff, note = execution_hourly(
design,
Decimal(entry["unit_price_krw"]),
Decimal(entry["hours_per_unit"]),
Decimal(entry["cut_unit_krw"]),
entry["correction"],
)
item.update(note=note, diff_krw=str(diff))
if money is not None:
hourly[code] = money
item["execution"] = _totals(money)
machines.append(item)
exec_book = execution_book(book, hourly) if hourly else None
rows: list[dict[str, Any]] = []
design_sum = Money3()
exec_sum = Money3()
for source in bill_rows:
row = dict(source)
if row.get("is_group") or not row.get("in_bill", True):
rows.append(row)
continue
quantity = row.get("quantity")
if quantity in (None, "") or row.get("unit_material_krw") is None:
row.update(execution_note="설계 단가가 안 선 줄 — 실행단가도 못 섬")
rows.append(row)
continue
unit_design = Money3(
material=_money(row.get("unit_material_krw")),
labor=_money(row.get("unit_labor_krw")),
expense=_money(row.get("unit_expense_krw")),
)
design_sum += bill_line(unit_design, Decimal(str(quantity)))
code = str(row.get("price_code") or "")
unit, note = unit_design, ""
changed = under.get(code, set()) & set(hourly)
if exec_book is not None and changed:
rebuilt, why = reassembled_unit(code, unit_design, book, exec_book)
if rebuilt is None:
note = f"중기 실행단가를 못 얹음({why}) — 설계 단가 그대로"
else:
unit, note = rebuilt, f"중기 실행단가 적용 — {', '.join(sorted(changed))}"
item_no = str(row.get("item_no"))
qty = Decimal(quantities.get(item_no, str(quantity)))
line = bill_line(unit, qty)
exec_sum += line
row.update(
execution_quantity=str(qty),
execution_quantity_changed=item_no in quantities,
execution_unit_material_krw=str(unit.material),
execution_unit_labor_krw=str(unit.labor),
execution_unit_expense_krw=str(unit.expense),
execution_unit_price_krw=str(unit.total),
execution_material_krw=str(line.material),
execution_labor_krw=str(line.labor),
execution_expense_krw=str(line.expense),
execution_amount_krw=str(line.total),
execution_note=note,
)
rows.append(row)
_group_sums(rows, "execution")
return {
"rows": rows,
"machines": machines,
"totals": {"design": _totals(design_sum), "execution": _totals(exec_sum)},
}
@@ -44,6 +44,16 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
"ⓘ 커플링밴드는 값이 표에 없지만 [주]① 이 「**1EA/6m**」를 줍니다 — 자재가 서면 "
"m 당 1/6 EA 로 셀 수 있습니다(다만 「필요시 별도 산정」이라 조건이 하나 더 붙습니다).",
),
# 2026-09-14 ⑤ 판정 Ⓓ·Ⓔ — 두 표를 갈래로 읽은 뒤 남는 [주] 둘. 값을 짓지 않고 말만 둠.
"FP-09-19-01": (
"원문 [주]",
"ⓘ 절토면 암 갈래(연암·보통암ㆍ경암): [주]③ 「소형브레이커 조작 인력품은 착암공으로 한다」"
" — 표에 착암공 수량이 없어 줄을 안 세움(보통인부는 표 그대로). "
"ⓘ 절토면 풍화암: [주]② 「소형브레이커를 사용할 시는 연암 고르기 품을 **준용할 수 있다**」"
" — 선택지라 따로 갈래를 안 만듦(브레이커를 쓰면 연암 갈래를 고를 것). "
"ⓘ 성토면 · 기계: 원문이 굴착기 0.6㎥ 형식을 안 적어 무한궤도·타이어 두 갈래로 세움"
"(제안 무한궤도 — 영월 실무 「06M3 B/H」 · 2026-09-14 브레인 ②).",
),
"FP-12-25": (
"운반거리 미정",
"⚠ 이 값에는 **운반 몫이 빠져 있습니다** — 품셈 12-25 는 「운반 | 덤프트럭(15ton)」 줄을 "
@@ -88,6 +98,13 @@ def known_gap_note(code: str | None) -> str:
for prefix, note in CONDITIONAL_INCLUDED.items():
if plain.startswith(prefix):
parts.append(note)
# 다른 품셈에서 빌린 밑수(사용자 확정) — 교차 참조와 실무 어긋남을 값 옆에(2026-09-14 · 종전엔
# 고르기형 표 안내가 「못 붙은 줄」로 섰고 이 문구는 아무 데서도 안 불렸음).
from B09_Estimation.B09_Estimation_WorkItemUnit import borrowed_unit_note
borrowed = borrowed_unit_note(plain)
if borrowed:
parts.append(f"{borrowed}")
return " / ".join(parts)
+110 -6
View File
@@ -140,6 +140,17 @@ def machine_list(build: UnitPriceBuild) -> list[dict[str, Any]]:
return rows
def machine_summary_amounts(unit_money: Any, quantity: Decimal) -> dict[str, Decimal]:
"""중기시간금액집계표 금액 — **성분마다 반올림한 뒤 합**(단가 열 없이 합계·노무·재료·경비).
근거 실무 6 골든셋 성분마다 131/144 · 합계를 번에 반올림하면 107/144.
"""
return {
key: round_at(getattr(unit_money, key) * quantity, OutputPlace.RESOURCE_SUMMARY)
for key in ("labor", "material", "expense")
}
def resource_summary(
quantities: dict[str, Decimal],
build: UnitPriceBuild | None = None,
@@ -181,19 +192,26 @@ def resource_summary(
slot[0] += detail.quantity * amount
for raw_code, quantity in quantities.items():
code = raw_code if raw_code.startswith("B-") else f"B-{raw_code}"
code = raw_code if raw_code in book.titles or raw_code.startswith("B-") else f"B-{raw_code}"
if code not in book.titles:
missing.append(raw_code)
continue
walk(code, Decimal(str(quantity)), (code,))
if book.titles[code].kind in unfold:
walk(code, Decimal(str(quantity)), (code,))
else:
# 구조물도 표 줄이 자원(노임·자재·중기)을 바로 부른 자리 — 그대로 한 줄.
slot = picked.setdefault(code, [_ZERO, book.titles[code]])
slot[0] += Decimal(str(quantity))
groups: dict[str, list[dict[str, Any]]] = {
"labor": [],
"material": [],
"expense": [],
"machine": [],
"lumpsum": [],
}
for ref, (amount, title) in sorted(picked.items()):
# ⚠ 차례 = **처음 쓰인 차례**(내역 줄 → 호표 안 줄) — 호표 번호가 그 차례(2026-09-14 브레인 판정).
for ref, (amount, title) in picked.items():
if title is None:
missing.append(ref)
continue
@@ -202,12 +220,12 @@ def resource_summary(
PriceKind.MATERIAL: "material",
PriceKind.MACHINE_BASE: "expense",
PriceKind.MACHINE_HOURLY: "machine",
PriceKind.LUMPSUM: "lumpsum",
}.get(title.kind)
if bucket is None:
# 일식(W)은 단가 0 이라 자원이 아님 — 그 밖의 종류가 오면 조용히 버리지 않고 드러냄.
if title.kind is not PriceKind.LUMPSUM:
missing.append(f"{ref} (집계 칸 없는 종류 {title.kind.value})")
missing.append(f"{ref} (집계 칸 없는 종류 {title.kind.value})")
continue
row: dict[str, Any] = {}
try:
unit_money = book.resolve(ref)
unit_price: Decimal | None = unit_money.total
@@ -215,11 +233,17 @@ def resource_summary(
money: Decimal | None = round_at(
unit_money.total * amount, OutputPlace.RESOURCE_SUMMARY
)
if bucket == "machine":
parts = machine_summary_amounts(unit_money, amount)
money = sum(parts.values(), _ZERO)
row = {f"{key}_krw": str(value) for key, value in parts.items()}
row.update({f"unit_{key}_krw": str(getattr(unit_money, key)) for key in parts})
note = ""
except Exception as error:
unit_price, money, note = None, None, str(error)
groups[bucket].append(
{
"number": len(groups[bucket]) + 1,
"code": ref,
"name": title.name,
"spec": title.spec,
@@ -227,6 +251,7 @@ def resource_summary(
"unit": title.unit,
"unit_price_krw": _money(unit_price),
"amount_krw": _money(money),
**row,
"note": note,
}
)
@@ -238,6 +263,85 @@ def resource_summary(
}
def add_material_rows(summary: dict[str, Any], sheet: Any) -> None:
"""재료비 집계표에 **자재대 줄**(사급·관급)을 이어 붙임 — 실무 집계표는 내역 자재 줄까지 한 표.
값은 자재대 표의 단가·수량 그대로, 금액만 집계표 자리(반올림).
단가 세운 ·공급 미정 줄은 실음(자재대 표가 이미 이름째 드러냄).
"""
if sheet is None:
return
rows = summary["groups"]["material"]
for supply, items in (("사급", sheet.contractor_rows), ("관급", sheet.owner_rows)):
for item in items:
if item.unit_price_krw is None:
continue
rows.append(
{
"number": len(rows) + 1,
"code": "",
"name": item.name,
"spec": item.spec,
"quantity": str(item.total_amount),
"unit": item.unit,
"unit_price_krw": str(item.unit_price_krw),
"amount_krw": str(
round_at(
item.unit_price_krw * item.total_amount, OutputPlace.RESOURCE_SUMMARY
)
),
"note": f"자재대({supply})",
}
)
def bill_lists(summary: dict[str, Any]) -> dict[str, Any]:
"""내역이 쓴 자원의 **목록표**(색인) — 집계표와 같은 코드·같은 차례, 수량·금액만 뺌.
값을 새로 세지 않음 집계표 줄의 단가 칸을 그대로 옮김. 경비목록표만 **기계 취득가(천원)**
실음(실무 서식: 중기 호표가 부르는 `S` ) 카탈로그 그대로.
"""
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
groups = summary["groups"]
keep = ("number", "code", "name", "spec", "unit", "unit_price_krw", "note")
def index(rows: list[dict[str, Any]], extra: tuple[str, ...] = ()) -> list[dict[str, Any]]:
return [{key: row.get(key) for key in (*keep, *extra)} for row in rows]
catalog = load_machine_catalog()
machines = dict.fromkeys(row["code"][2:].split("#")[0] for row in groups["machine"])
expense = [
{
"code": f"S-{code}",
"name": catalog.machines[code].name,
"spec": catalog.machines[code].specification,
"unit": "천원",
"unit_price_krw": _money(catalog.machines[code].price_thousand_krw),
"note": "",
}
for code in machines
if code in catalog.machines
] + index(groups["expense"])
for number, row in enumerate(expense, start=1):
row["number"] = number
unit_keys = ("unit_labor_krw", "unit_material_krw", "unit_expense_krw")
return {
"labor": index(groups["labor"]),
"material": index(groups["material"]),
"expense": expense,
"machine": index(groups["machine"], unit_keys),
"lumpsum": index(groups["lumpsum"]),
}
def bill_sheets(result: Any, build: UnitPriceBuild) -> dict[str, Any]:
"""내역 한 벌의 집계표 넷(`resources`)과 목록표(`lists`) — 내역 응답에 함께 실음."""
resources = resource_summary(result.resource_quantities(), build)
add_material_rows(resources, result.material_sheet)
return {"resources": resources, "lists": bill_lists(resources)}
def all_lists(build: UnitPriceBuild | None = None) -> dict[str, Any]:
"""목록표 넷을 한 번에 — 화면이 탭 하나에서 다 쓴다."""
prices = build or cached_build()
@@ -110,6 +110,8 @@ def material_price_comparison(build: UnitPriceBuild | None = None) -> dict[str,
note = ""
except Exception as error: # 채택 슬롯이 비었다 — 지어내지 않는다
adopted, note = None, str(error)
# 최소단가 — 값이 선 원천 가운데 가장 싼 칸(STmate `wM_Boxa` 「최소단가」 표시 · 채택 규칙 17번 §5.5).
filled = [(value, i + 1) for i, value in enumerate(title.slots[:5]) if value]
rows.append(
{
"code": code,
@@ -117,6 +119,7 @@ def material_price_comparison(build: UnitPriceBuild | None = None) -> dict[str,
"spec": title.spec,
"unit": title.unit,
"slots": slots,
"min_slot": min(filled)[1] if filled else None,
"adopted_slot": title.adopted_slot,
"adopted_price_krw": _money(adopted),
"note": note,
@@ -132,6 +132,32 @@ MASHED_MACHINE_FIXES: dict[str, tuple[str, str]] = {
#: (0230) 표의 「시간당 계」 — 규격이 달라도 같은 값이다(원문 여섯 줄 모두 6,601).
MASHED_LOSS_COEFFICIENT = Decimal("0.0006601")
#: ⚠ **같은 파싱 병의 나머지** (2026-09-14 · 축 C 1장 ① 브레인 차례).
#: 원천이 쪽 끝 기종의 이름 칸에 **다음 쪽 머리글 + 다음 분류 이름**을 붙여 넣어 ① 그 기종은
#: 이름에 표 머리글이 붙고 규격이 비고 ② 다음 분류 첫 기종들은 **이름이 빔**. 이름으로 찾아지지
#: 않고 화면에도 빈 이름으로 선다. 이름·규격은 **건설공사 표준품셈 제8장 표**(`pum_const_2026.json`
#: 기종 목록 줄 · 붙은 꼬리 「(분류번호) 이름」)에서 읽음 — 취득가·손료계수는 원천 그대로.
#: ⚠ 원천이 바로 실으면 이 표는 지운다(위 표와 같은 약속).
GLUED_MACHINE_FIXES: dict[str, tuple[str, str]] = {
"5220-0015": ("소형브레이커(전기식)", "1.5㎾"),
"6532-0220": ("진동파일 해머(유압식)", "162㎾"),
"7101-0450": ("고성능 착정기", "335.70㎾"),
"7120-0746": ("버킷식준설기", "7.46kW"),
"7995-0050": ("배관파이프", "ø50-2.6m"),
}
#: 이름이 빈 분류 — 분류번호(코드 앞 넷) → 원문 이름. 규격은 원천 값 그대로.
EMPTY_NAME_BY_GROUP: dict[str, str] = {
"0240": "유압식 진동콤팩터(굴착기 부착용)",
"3611": "콘크리트 피니셔(중앙분리대용)",
"5330": "드릴웨곤",
"6540": "워터젯트",
"6802": "파일천공전용장비",
"7202": "자동세륜기(롤 타입)",
"7930": "모터",
"8201": "3D GNSS 머신 가이던스(굴착기용)",
"9070": "이우선(비자항)",
}
class MachineCostError(LookupError):
"""기계경비를 세울 수 없는 경우. 0 으로 때우지 않고 멈춘다."""
@@ -205,6 +231,11 @@ def load_machine_catalog(file_name: str = "mach_base_2026.json") -> MachineCatal
# 뭉개진 줄 — 원문으로 이름·규격을 되살리고 손료계수를 채운다.
row = {**row, "machine_name": fixed[0], "specification": fixed[1]}
coefficient = {**coefficient, "loss_coefficient_per_hour": MASHED_LOSS_COEFFICIENT}
elif code in GLUED_MACHINE_FIXES:
name, spec = GLUED_MACHINE_FIXES[code]
row = {**row, "machine_name": name, "specification": spec}
elif not str(row.get("machine_name") or "").strip() and code[:4] in EMPTY_NAME_BY_GROUP:
row = {**row, "machine_name": EMPTY_NAME_BY_GROUP[code[:4]]}
catalog.machines[code] = MachineSpec(
machine_code=code,
name=row["machine_name"],
@@ -201,7 +201,9 @@ def parse_operating_tables(
if codes:
carry_prefix = codes[-1][:4]
names = _tokens(row[1])
specs = _tokens(row[2])
# 규격 칸의 괄호 토큰은 **앞 규격의 딸림값**(「100 (155)」 · 크레인 「15 (0.38)」) —
# 규격 하나로 세면 코드 수와 안 맞아 줄째 버려짐(공기압축기 5205 여섯 · 09-14).
specs = [token for token in _tokens(row[2]) if not token.startswith("(")]
fuels = _tokens(row[3])
miscs = _tokens(row[4])
operators = _tokens(row[5])
@@ -408,6 +408,7 @@ def attach_machine_share(
if (sources or {}).get(work_item_code)
else ""
),
output=hourly_output(factors),
)
cycle_factors[work_item_code] = factors
return share * Decimal(100)
@@ -429,7 +429,11 @@ def attach_dozer_share(
continue
# Q 로 선 장비 줄 — D(단가산출)에 달고 B 는 D 를 1 로 부름(층 차례 X → D → B).
book.add_output_detail(
title_code, hourly_code, dozer_machine_hours_per_unit(factors), factors.formula_text
title_code,
hourly_code,
dozer_machine_hours_per_unit(factors),
factors.formula_text,
output=dozer_hourly_output(factors),
)
return Decimal(100)
return _ZERO

Some files were not shown because too many files have changed in this diff Show More