Merge remote-tracking branch 'origin/dev' into main_desktop_1
This commit is contained in:
@@ -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 은 상세
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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} % 반영"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 × D³ × 1,300 × N (지침 「V = πD²(D/2 + RH2/3) = 0.3927D³」
|
||||
· 「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 × D³ × 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]
|
||||
@@ -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+n²) 를 곱하지 않는다.
|
||||
"""
|
||||
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}"
|
||||
@@ -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",
|
||||
|
||||
@@ -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 는 규정이라 값만 보인다. */
|
||||
|
||||
@@ -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 확정).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.28181999…96)로 곱해 원 미만 절사에 1원씩 샜음.
|
||||
"""
|
||||
row.quantity, _ = round_quantity(row.quantity, row.name, row.unit, row.spec)
|
||||
return row.quantity
|
||||
|
||||
|
||||
def _sum_groups(rows: list[BillRow]) -> None:
|
||||
"""머리글 줄 금액 = 그 아래 줄 금액의 합(성분마다) — 실무 내역서 계 줄. 화면은 더하지 않음.
|
||||
|
||||
⚠ `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)"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user